Compare commits

..
8 Commits
Author SHA1 Message Date
arcanis 2080f1e06f Release 2.23.2 2026-10-25 01:45:45 +03:00
arcanis 2544d44051 fix: refresh databases on repo-update command 2026-10-24 16:48:23 +03:00
arcanis b07ad8cc0c build: clear pacman cache to resolve hashsums mismatch 2026-10-24 12:37:10 +03:00
arcanis c38cb5f680 feat: restore iterable method in parser 2026-10-24 12:20:29 +03:00
arcanis f776b33310 feat: preserve links to packages
This changes url handling in status page. Firstly, instead of using
hash for repository, now they are handled by query parameter. Secondly,
it adds a query parameter for package base. And lastly, it changes
behaviour for clicking on the package link, now it opens status page,
which allows to e.g. open a tab in background
2026-10-22 11:33:06 +03:00
arcanis eafa1b417a build: replace aiohttp-sse-git with aiohttp-sse
The upstream has released new version, which includes all required fixes
for the newest aiohttp
2026-10-20 16:23:56 +03:00
arcanis d0aff05be5 Release 2.23.1 2026-10-20 00:31:19 +03:00
arcanis 8a6b71a425 docs: commit docs requirements.txt 2026-10-20 00:30:57 +03:00
15 changed files with 128 additions and 44 deletions

No files matched your search

+3 -2
View File
@@ -16,7 +16,7 @@ pacman -S --noconfirm --asdeps base-devel npm python-build python-hatchling pyth
# optional dependencies
if [[ -z $MINIMAL_INSTALL ]]; then
# web server
pacman -S --noconfirm --asdeps python-aioauth-client python-aiohttp python-aiohttp-apispec-git python-aiohttp-cors python-aiohttp-jinja2 python-aiohttp-security python-aiohttp-session python-aiohttp-sse-git python-cryptography python-jinja
pacman -S --noconfirm --asdeps python-aioauth-client python-aiohttp python-aiohttp-apispec-git python-aiohttp-cors python-aiohttp-jinja2 python-aiohttp-security python-aiohttp-session python-aiohttp-sse python-cryptography python-jinja
# additional features
pacman -S --noconfirm --asdeps gnupg ipython python-boto3 python-cerberus python-matplotlib python-systemd rsync
fi
@@ -41,6 +41,7 @@ systemd-machine-id-setup
# remove unused dependencies
pacman -Qdtq | pacman -Rscn --noconfirm -
find "/var/cache/pacman/pkg" -type f -delete
# initial setup command as root
[[ -z $MINIMAL_INSTALL ]] && WEB_ARGS=("--web-port" "8080")
@@ -60,6 +61,6 @@ sudo -u ahriman -- ahriman --log-handler console package-add --now ahriman
# check if package was actually installed
test -n "$(find "/var/lib/ahriman/repository/github/x86_64" -name "ahriman*pkg*")"
# run package check
sudo -u ahriman -- ahriman repo-update
sudo -u ahriman -- ahriman --log-handler console repo-update --refresh
# stop web service lol
[[ -z $WEB_PID ]] || kill $WEB_PID
@@ -1,4 +1,4 @@
.TH AHRIMAN "1" "2026\-10\-20" "ahriman 2.23.0" "ArcH linux ReposItory MANager"
.TH AHRIMAN "1" "2026\-10\-25" "ahriman 2.23.2" "ArcH linux ReposItory MANager"
.SH NAME
ahriman \- ArcH linux ReposItory MANager
.SH SYNOPSIS
+1 -1
View File
@@ -17,4 +17,4 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
__version__ = "2.23.0"
__version__ = "2.23.2"
@@ -21,7 +21,7 @@ import re
import shlex
import tree_sitter_bash
from collections.abc import Iterator
from collections.abc import Iterable, Iterator
from enum import StrEnum
from tree_sitter import Language, Node, Parser
from typing import IO, cast
@@ -78,7 +78,7 @@ class PkgbuildNodeField(StrEnum):
Value = "value"
class PkgbuildParser:
class PkgbuildParser(Iterable[PkgbuildPatch]):
"""
PKGBUILD parser implementation in python
@@ -97,24 +97,29 @@ class PkgbuildParser:
original source, with their formatting preserved. Assignments inside shell statements and control-flow nodes are
extracted without checking whether those statements would run.
Each iteration parses the stored source independently, starting from the beginning.
Examples:
Parse package assignments::
>>> source = b"pkgname=example\\npkgver=1\\n"
>>> for patch in PkgbuildParser(BytesIO(source)).parse():
>>> for patch in PkgbuildParser(BytesIO(source)):
... print(patch.key, patch.value)
Function bodies are returned as function patches::
>>> source = b"pkgver() { printf 1; }"
>>> for patch in list(PkgbuildParser(BytesIO(source)).parse())
>>> for patch in PkgbuildParser(BytesIO(source)):
... print(patch.key, patch.value)
"""
_BRACE_EXPANSION = re.compile(r"(.*)\{([^{}]+)\}(.*)")
def __init__(self, stream: IO[bytes]) -> None:
""""""
"""
Args:
stream(IO[bytes]): input PKGBUILD stream
"""
self._content = stream.read()
@staticmethod
@@ -256,3 +261,12 @@ class PkgbuildParser:
)
yield from self._read(tree.root_node)
def __iter__(self) -> Iterator[PkgbuildPatch]:
"""
iterate over the parsed entries
Returns:
Iterator[PkgbuildPatch]: parsed entries
"""
return self.parse()
+1 -2
View File
@@ -80,8 +80,7 @@ class Pkgbuild(Mapping[str, Any]):
Returns:
Self: constructed instance of self
"""
parser = PkgbuildParser(stream)
fields = {patch.key: patch for patch in parser.parse()}
fields = {patch.key: patch for patch in PkgbuildParser(stream)}
# pkgbase is optional field, the pkgname must be used instead if not set
# however, pkgname is not presented is "package()" functions which we are parsing here too,
@@ -71,7 +71,7 @@ def test_parse_array_exception() -> None:
"""
parser = PkgbuildParser(BytesIO(b"var=(first second"))
with pytest.raises(PkgbuildParserError):
assert list(parser.parse())
list(parser.parse())
def test_parse_function() -> None:
@@ -119,11 +119,11 @@ def test_parse_function_exception() -> None:
"""
parser = PkgbuildParser(BytesIO(b"var() echo hello world } "))
with pytest.raises(PkgbuildParserError):
assert list(parser.parse())
list(parser.parse())
parser = PkgbuildParser(BytesIO(b"var() { echo hello world"))
with pytest.raises(PkgbuildParserError):
assert list(parser.parse())
list(parser.parse())
def test_parse_function_inner_shell() -> None:
@@ -50,7 +50,7 @@ def test_from_io(pkgbuild_ahriman: Pkgbuild, mocker: MockerFixture) -> None:
must correctly load from io
"""
load_mock = mocker.patch("ahriman.core.alpm.pkgbuild_parser.PkgbuildParser.parse",
return_value=pkgbuild_ahriman.fields.values())
return_value=iter(pkgbuild_ahriman.fields.values()))
assert Pkgbuild.from_io(BytesIO(b"mock")) == pkgbuild_ahriman
load_mock.assert_called_once_with()
@@ -60,9 +60,9 @@ def test_from_io_pkgbase(pkgbuild_ahriman: Pkgbuild, mocker: MockerFixture) -> N
must assign missing pkgbase if pkgname is presented
"""
mocker.patch("ahriman.core.alpm.pkgbuild_parser.PkgbuildParser.parse", side_effect=[
[value for key, value in pkgbuild_ahriman.fields.items() if key not in ("pkgbase",)],
[value for key, value in pkgbuild_ahriman.fields.items() if key not in ("pkgbase", "pkgname",)],
[value for key, value in pkgbuild_ahriman.fields.items()] + [PkgbuildPatch("pkgbase", "pkgbase")],
iter([value for key, value in pkgbuild_ahriman.fields.items() if key not in ("pkgbase",)]),
iter([value for key, value in pkgbuild_ahriman.fields.items() if key not in ("pkgbase", "pkgname",)]),
iter([value for key, value in pkgbuild_ahriman.fields.items()] + [PkgbuildPatch("pkgbase", "pkgbase")]),
])
assert Pkgbuild.from_io(BytesIO(b"mock"))["pkgbase"] == pkgbuild_ahriman["pkgname"]
@@ -75,7 +75,7 @@ def test_from_io_empty(pkgbuild_ahriman: Pkgbuild, mocker: MockerFixture) -> Non
must skip empty patches
"""
mocker.patch("ahriman.core.alpm.pkgbuild_parser.PkgbuildParser.parse",
return_value=list(pkgbuild_ahriman.fields.values()) + [PkgbuildPatch("", "")])
return_value=iter(list(pkgbuild_ahriman.fields.values()) + [PkgbuildPatch("", "")]))
assert Pkgbuild.from_io(BytesIO(b"mock")) == pkgbuild_ahriman
+2 -2
View File
@@ -2,7 +2,7 @@
pkgbase='ahriman'
pkgname=('ahriman' 'ahriman-core' 'ahriman-triggers' 'ahriman-web')
pkgver=2.23.0
pkgver=2.23.2
pkgrel=1
pkgdesc="ArcH linux ReposItory MANager"
arch=('any')
@@ -80,7 +80,7 @@ package_ahriman-triggers() {
package_ahriman-web() {
pkgname='ahriman-web'
pkgdesc="ArcH linux ReposItory MANager, web server"
depends=("$pkgbase-core=$pkgver" 'python-aiohttp-cors' 'python-aiohttp-jinja2' 'python-aiohttp-sse-git')
depends=("$pkgbase-core=$pkgver" 'python-aiohttp-cors' 'python-aiohttp-jinja2' 'python-aiohttp-sse')
optdepends=('python-aioauth-client: OAuth2 authorization support'
'python-aiohttp-apispec>=3.0.0: autogenerated API documentation'
'python-aiohttp-openmetrics: HTTP metrics support'
+1 -1
View File
@@ -59,7 +59,7 @@ RUN runuser -u build -- install-aur-package \
python-aiohttp-jinja2 \
python-aiohttp-session \
python-aiohttp-security \
python-aiohttp-sse-git \
python-aiohttp-sse \
python-requests-unixsocket2 \
python-tree-sitter \
python-tree-sitter-bash
+4
View File
@@ -125,6 +125,10 @@ sphinxcontrib-serializinghtml==2.0.0
# via sphinx
stdlib-list==0.11.1
# via pydeps
tree-sitter==0.26.0
# via ahriman-core
tree-sitter-bash==0.25.1
# via ahriman-core
urllib3==2.4.0
# via
# botocore
+1 -1
View File
@@ -42,5 +42,5 @@
"preview": "vite preview"
},
"type": "module",
"version": "2.23.0"
"version": "2.23.2"
}
+19 -6
View File
@@ -37,7 +37,9 @@ import PackageRebuildDialog from "components/dialogs/PackageRebuildDialog";
import PackageTableToolbar from "components/table/PackageTableToolbar";
import StatusCell from "components/table/StatusCell";
import { useDebounce } from "hooks/useDebounce";
import { navigate, packageHref } from "hooks/useLocationQuery";
import { usePackageTable } from "hooks/usePackageTable";
import { useRepository } from "hooks/useRepository";
import type { PackageRow } from "models/PackageRow";
import React, { useMemo } from "react";
@@ -59,6 +61,7 @@ function createListColumn(
export default function PackageTable(): React.JSX.Element {
const table = usePackageTable();
const { currentRepository } = useRepository();
const { handleRemove, handleUpdate, isAuthorized } = table;
const apiRef = useGridApiRef();
const debouncedSearch = useDebounce(table.searchText, 300);
@@ -79,11 +82,20 @@ export default function PackageTable(): React.JSX.Element {
headerName: "package base",
minWidth: 150,
renderCell: params =>
params.row.webUrl ?
<Link href={params.row.webUrl} rel="noopener noreferrer" target="_blank" underline="hover">
{params.value as string}
</Link>
: params.value as string,
<Link
href={packageHref(currentRepository?.key ?? "", params.row.base)}
onClick={event => {
const modified = event.ctrlKey || event.metaKey || event.shiftKey || event.altKey;
if (event.button === 0 && !modified) {
event.preventDefault();
navigate(packageHref(currentRepository?.key ?? "", params.row.base));
}
}}
tabIndex={params.tabIndex}
underline="hover"
>
{params.value as string}
</Link>,
},
{ align: "right", field: "version", headerAlign: "right", headerName: "version", width: 180 },
createListColumn("packages", "packages", { flex: 1, minWidth: 120 }),
@@ -124,7 +136,7 @@ export default function PackageTable(): React.JSX.Element {
],
} satisfies GridColDef<PackageRow>] : [],
],
[handleRemove, handleUpdate, isAuthorized],
[currentRepository, handleRemove, handleUpdate, isAuthorized],
);
return <Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
@@ -195,6 +207,7 @@ export default function PackageTable(): React.JSX.Element {
<PackageRebuildDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "rebuild"} />
<KeyImportDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "keyImport"} />
<PackageInfoDialog
key={`${currentRepository?.key}/${table.selectedPackage}`}
onClose={() => table.setSelectedPackage(null)}
open={table.selectedPackage !== null}
packageBase={table.selectedPackage}
+6 -14
View File
@@ -18,31 +18,23 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { RepositoryContext } from "contexts/RepositoryContext";
import { navigate, packageHref, useLocationQuery } from "hooks/useLocationQuery";
import type { RepositoryId } from "models/RepositoryId";
import React, { type ReactNode, useCallback, useMemo, useState, useSyncExternalStore } from "react";
function subscribeToHash(callback: () => void): () => void {
window.addEventListener("hashchange", callback);
return () => window.removeEventListener("hashchange", callback);
}
function getHashSnapshot(): string {
return window.location.hash.replace("#", "");
}
import React, { type ReactNode, useCallback, useMemo, useState } from "react";
export function RepositoryProvider({ children }: { children: ReactNode }): React.JSX.Element {
const [repositories, setRepositories] = useState<RepositoryId[]>([]);
const hash = useSyncExternalStore(subscribeToHash, getHashSnapshot);
const repositoryKey = new URLSearchParams(useLocationQuery()).get("repository_id");
const currentRepository = useMemo(() => {
if (repositories.length === 0) {
return null;
}
return repositories.find(repository => repository.key === hash) ?? repositories[0] ?? null;
}, [repositories, hash]);
return repositories.find(repository => repository.key === repositoryKey) ?? repositories[0] ?? null;
}, [repositories, repositoryKey]);
const setCurrentRepository = useCallback((repository: RepositoryId) => {
window.location.hash = repository.key;
navigate(packageHref(repository.key, null));
}, []);
const value = useMemo(() => ({
+52
View File
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useSyncExternalStore } from "react";
function subscribe(callback: () => void): () => void {
window.addEventListener("popstate", callback);
return () => window.removeEventListener("popstate", callback);
}
function getSnapshot(): string {
return window.location.search;
}
export function useLocationQuery(): string {
return useSyncExternalStore(subscribe, getSnapshot);
}
export function packageHref(repositoryKey: string, packageBase: string | null): string {
const query = new URLSearchParams(window.location.search);
query.set("repository_id", repositoryKey);
if (packageBase) {
query.set("package", packageBase);
} else {
query.delete("package");
}
return `${window.location.pathname}?${query}`;
}
export function navigate(href: string): void {
if (href === `${window.location.pathname}${window.location.search}`) {
return;
}
window.history.pushState(null, "", href);
window.dispatchEvent(new PopStateEvent("popstate"));
}
+10 -1
View File
@@ -19,6 +19,8 @@
*/
import type { GridFilterModel } from "@mui/x-data-grid";
import { useLocalStorage } from "hooks/useLocalStorage";
import { navigate, packageHref, useLocationQuery } from "hooks/useLocationQuery";
import { useRepository } from "hooks/useRepository";
import { useState } from "react";
export type DialogType = "dashboard" | "add" | "rebuild" | "keyImport";
@@ -43,7 +45,14 @@ export interface UseTableStateResult {
export function useTableState(): UseTableStateResult {
const [selectionModel, setSelectionModel] = useState<string[]>([]);
const [dialogOpen, setDialogOpen] = useState<DialogType | null>(null);
const [selectedPackage, setSelectedPackage] = useState<string | null>(null);
const { currentRepository } = useRepository();
const packageBase = new URLSearchParams(useLocationQuery()).get("package");
const selectedPackage = currentRepository && packageBase ? packageBase : null;
const setSelectedPackage = (base: string | null): void => {
if (currentRepository) {
navigate(packageHref(currentRepository.key, base));
}
};
const [searchText, setSearchText] = useState("");
const [paginationModel, setPaginationModel] = useLocalStorage("ahriman-packages-pagination", {