Compare commits

...

6 Commits

Author SHA1 Message Date
a04b6c3b9c refactor: move package archive lockup to package info trait 2026-03-15 20:23:20 +02:00
2e9837b70d build: update frontend dependencies 2026-03-15 19:18:58 +02:00
ac4a8fb2cd fix: hide ignore_list because it is ambiguous now 2026-03-15 19:06:53 +02:00
1db8eb0ac4 fix: preserve hold status on status updates 2026-03-15 19:03:05 +02:00
dc394f7df9 feat: dynamic package hold (#160)
* add dynamic hold implementation to backend

* update frontend to support new status

* force reporter loader

* handle missing packages explicitly

* handle missing packages explicitly
2026-03-15 18:47:02 +02:00
058f784b05 fix: center icons on buttons 2026-03-15 17:07:39 +02:00
45 changed files with 787 additions and 132 deletions

View File

@@ -84,6 +84,14 @@ ahriman.application.handlers.help module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.application.handlers.hold module
----------------------------------------
.. automodule:: ahriman.application.handlers.hold
:members:
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.key\_import module ahriman.application.handlers.key\_import module
----------------------------------------------- -----------------------------------------------

View File

@@ -148,6 +148,14 @@ ahriman.core.database.migrations.m017\_pkgbuild module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.core.database.migrations.m018\_package\_hold module
-----------------------------------------------------------
.. automodule:: ahriman.core.database.migrations.m018_package_hold
:members:
:no-undoc-members:
:show-inheritance:
Module contents Module contents
--------------- ---------------

View File

@@ -116,6 +116,14 @@ ahriman.web.schemas.file\_schema module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.web.schemas.hold\_schema module
---------------------------------------
.. automodule:: ahriman.web.schemas.hold_schema
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.schemas.info\_schema module ahriman.web.schemas.info\_schema module
--------------------------------------- ---------------------------------------

View File

@@ -28,6 +28,14 @@ ahriman.web.views.v1.packages.dependencies module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.web.views.v1.packages.hold module
-----------------------------------------
.. automodule:: ahriman.web.views.v1.packages.hold
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.views.v1.packages.logs module ahriman.web.views.v1.packages.logs module
----------------------------------------- -----------------------------------------

View File

@@ -29,14 +29,13 @@
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^5.1.4", "@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.3", "eslint": "^9.39.3",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.56.1", "typescript-eslint": "^8.56.1",
"vite": "^7.3.1", "vite": "^8.0.0"
"vite-tsconfig-paths": "^6.1.1"
} }
} }

View File

@@ -78,6 +78,14 @@ export class ServiceClient {
return this.client.request("/api/v1/service/pgp", { method: "POST", json: data }); return this.client.request("/api/v1/service/pgp", { method: "POST", json: data });
} }
async servicePackageHoldUpdate(packageBase: string, repository: RepositoryId, isHeld: boolean): Promise<void> {
return this.client.request(`/api/v1/packages/${encodeURIComponent(packageBase)}/hold`, {
method: "POST",
query: repository.toQuery(),
json: { is_held: isHeld },
});
}
async serviceRebuild(repository: RepositoryId, packages: string[]): Promise<void> { async serviceRebuild(repository: RepositoryId, packages: string[]): Promise<void> {
return this.client.request("/api/v1/service/rebuild", { return this.client.request("/api/v1/service/rebuild", {
method: "POST", method: "POST",

View File

@@ -130,6 +130,19 @@ export default function PackageInfoDialog({
} }
}; };
const handleHoldToggle: () => Promise<void> = async () => {
if (!localPackageBase || !currentRepository) {
return;
}
try {
const newHeldStatus = !(status?.is_held ?? false);
await client.service.servicePackageHoldUpdate(localPackageBase, currentRepository, newHeldStatus);
void queryClient.invalidateQueries({ queryKey: QueryKeys.package(localPackageBase, currentRepository) });
} catch (exception) {
showError("Action failed", `Could not update hold status: ${ApiError.errorDetail(exception)}`);
}
};
const handleDeletePatch: (key: string) => Promise<void> = async key => { const handleDeletePatch: (key: string) => Promise<void> = async key => {
if (!localPackageBase) { if (!localPackageBase) {
return; return;
@@ -189,6 +202,8 @@ export default function PackageInfoDialog({
isAuthorized={isAuthorized} isAuthorized={isAuthorized}
refreshDatabase={refreshDatabase} refreshDatabase={refreshDatabase}
onRefreshDatabaseChange={setRefreshDatabase} onRefreshDatabaseChange={setRefreshDatabase}
isHeld={status?.is_held ?? false}
onHoldToggle={() => void handleHoldToggle()}
onUpdate={() => void handleUpdate()} onUpdate={() => void handleUpdate()}
onRemove={() => void handleRemove()} onRemove={() => void handleRemove()}
autoRefreshIntervals={autoRefreshIntervals} autoRefreshIntervals={autoRefreshIntervals}

View File

@@ -18,7 +18,9 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import PauseCircleIcon from "@mui/icons-material/PauseCircle";
import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import PlayCircleIcon from "@mui/icons-material/PlayCircle";
import { Button, Checkbox, DialogActions, FormControlLabel } from "@mui/material"; import { Button, Checkbox, DialogActions, FormControlLabel } from "@mui/material";
import AutoRefreshControl from "components/common/AutoRefreshControl"; import AutoRefreshControl from "components/common/AutoRefreshControl";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval"; import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
@@ -26,6 +28,8 @@ import type React from "react";
interface PackageInfoActionsProps { interface PackageInfoActionsProps {
isAuthorized: boolean; isAuthorized: boolean;
isHeld: boolean;
onHoldToggle: () => void;
refreshDatabase: boolean; refreshDatabase: boolean;
onRefreshDatabaseChange: (checked: boolean) => void; onRefreshDatabaseChange: (checked: boolean) => void;
onUpdate: () => void; onUpdate: () => void;
@@ -39,6 +43,8 @@ export default function PackageInfoActions({
isAuthorized, isAuthorized,
refreshDatabase, refreshDatabase,
onRefreshDatabaseChange, onRefreshDatabaseChange,
isHeld,
onHoldToggle,
onUpdate, onUpdate,
onRemove, onRemove,
autoRefreshIntervals, autoRefreshIntervals,
@@ -52,6 +58,9 @@ export default function PackageInfoActions({
control={<Checkbox checked={refreshDatabase} onChange={(_, checked) => onRefreshDatabaseChange(checked)} size="small" />} control={<Checkbox checked={refreshDatabase} onChange={(_, checked) => onRefreshDatabaseChange(checked)} size="small" />}
label="update pacman databases" label="update pacman databases"
/> />
<Button onClick={onHoldToggle} variant="outlined" color="warning" startIcon={isHeld ? <PlayCircleIcon /> : <PauseCircleIcon />} size="small">
{isHeld ? "unhold" : "hold"}
</Button>
<Button onClick={onUpdate} variant="contained" color="success" startIcon={<PlayArrowIcon />} size="small"> <Button onClick={onUpdate} variant="contained" color="success" startIcon={<PlayArrowIcon />} size="small">
update update
</Button> </Button>

View File

@@ -107,7 +107,8 @@ export default function PackageTable({ autoRefreshIntervals }: PackageTableProps
width: 120, width: 120,
align: "center", align: "center",
headerAlign: "center", headerAlign: "center",
renderCell: (params: GridRenderCellParams<PackageRow>) => <StatusCell status={params.row.status} />, renderCell: (params: GridRenderCellParams<PackageRow>) =>
<StatusCell status={params.row.status} isHeld={params.row.isHeld} />,
}, },
], ],
[], [],

View File

@@ -17,6 +17,7 @@
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import PauseCircleIcon from "@mui/icons-material/PauseCircle";
import { Chip } from "@mui/material"; import { Chip } from "@mui/material";
import type { BuildStatus } from "models/BuildStatus"; import type { BuildStatus } from "models/BuildStatus";
import type React from "react"; import type React from "react";
@@ -24,10 +25,12 @@ import { StatusColors } from "theme/StatusColors";
interface StatusCellProps { interface StatusCellProps {
status: BuildStatus; status: BuildStatus;
isHeld?: boolean;
} }
export default function StatusCell({ status }: StatusCellProps): React.JSX.Element { export default function StatusCell({ status, isHeld }: StatusCellProps): React.JSX.Element {
return <Chip return <Chip
icon={isHeld ? <PauseCircleIcon /> : undefined}
label={status} label={status}
size="small" size="small"
sx={{ sx={{

View File

@@ -32,6 +32,7 @@ export class PackageRow {
timestamp: string; timestamp: string;
timestampValue: number; timestampValue: number;
status: BuildStatus; status: BuildStatus;
isHeld: boolean;
constructor(descriptor: PackageStatus) { constructor(descriptor: PackageStatus) {
this.id = descriptor.package.base; this.id = descriptor.package.base;
@@ -45,6 +46,7 @@ export class PackageRow {
this.timestamp = new Date(descriptor.status.timestamp * 1000).toISOStringShort(); this.timestamp = new Date(descriptor.status.timestamp * 1000).toISOStringShort();
this.timestampValue = descriptor.status.timestamp; this.timestampValue = descriptor.status.timestamp;
this.status = descriptor.status.status; this.status = descriptor.status.status;
this.isHeld = descriptor.status.is_held ?? false;
} }
private static extractListProperties(pkg: PackageStatus["package"], property: "groups" | "licenses"): string[] { private static extractListProperties(pkg: PackageStatus["package"], property: "groups" | "licenses"): string[] {

View File

@@ -22,4 +22,5 @@ import type { BuildStatus } from "models/BuildStatus";
export interface Status { export interface Status {
status: BuildStatus; status: BuildStatus;
timestamp: number; timestamp: number;
is_held?: boolean;
} }

View File

@@ -21,16 +21,24 @@ import { createTheme, type Theme } from "@mui/material/styles";
export function createAppTheme(mode: "light" | "dark"): Theme { export function createAppTheme(mode: "light" | "dark"): Theme {
return createTheme({ return createTheme({
components: {
MuiButton: {
styleOverrides: {
startIcon: {
alignItems: "center",
display: "flex",
},
},
},
MuiDialog: {
defaultProps: {
fullWidth: true,
maxWidth: "lg",
},
},
},
palette: { palette: {
mode, mode,
}, },
components: {
MuiDialog: {
defaultProps: {
maxWidth: "lg",
fullWidth: true,
},
},
},
}); });
} }

View File

@@ -1,7 +1,6 @@
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import path from "path"; import path from "path";
import { defineConfig, type Plugin } from "vite"; import { defineConfig, type Plugin } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
function rename(oldName: string, newName: string): Plugin { function rename(oldName: string, newName: string): Plugin {
return { return {
@@ -16,8 +15,11 @@ function rename(oldName: string, newName: string): Plugin {
} }
export default defineConfig({ export default defineConfig({
plugins: [react(), tsconfigPaths(), rename("index.html", "build-status.jinja2")], plugins: [react(), rename("index.html", "build-status.jinja2")],
base: "/", base: "/",
resolve: {
tsconfigPaths: true,
},
build: { build: {
chunkSizeWarningLimit: 10000, chunkSizeWarningLimit: 10000,
emptyOutDir: false, emptyOutDir: false,

View File

@@ -0,0 +1,93 @@
#
# 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 argparse
from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler, SubParserAction
from ahriman.core.configuration import Configuration
from ahriman.models.action import Action
from ahriman.models.repository_id import RepositoryId
class Hold(Handler):
"""
package hold handler
"""
@classmethod
def run(cls, args: argparse.Namespace, repository_id: RepositoryId, configuration: Configuration, *,
report: bool) -> None:
"""
callback for command line
Args:
args(argparse.Namespace): command line args
repository_id(RepositoryId): repository unique identifier
configuration(Configuration): configuration instance
report(bool): force enable or disable reporting
"""
client = Application(repository_id, configuration, report=True).reporter
match args.action:
case Action.Remove:
for package in args.package:
client.package_hold_update(package, enabled=False)
case Action.Update:
for package in args.package:
client.package_hold_update(package, enabled=True)
@staticmethod
def _set_package_hold_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for hold package subcommand
Args:
root(SubParserAction): subparsers for the commands
Returns:
argparse.ArgumentParser: created argument parser
"""
parser = root.add_parser("package-hold", help="hold package",
description="hold package from automatic updates")
parser.add_argument("package", help="package base", nargs="+")
parser.set_defaults(action=Action.Update, lock=None, quiet=True, report=False, unsafe=True)
return parser
@staticmethod
def _set_package_unhold_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for unhold package subcommand
Args:
root(SubParserAction): subparsers for the commands
Returns:
argparse.ArgumentParser: created argument parser
"""
parser = root.add_parser("package-unhold", help="unhold package",
description="remove package hold, allowing automatic updates")
parser.add_argument("package", help="package base", nargs="+")
parser.set_defaults(action=Action.Remove, lock=None, quiet=True, report=False, unsafe=True)
return parser
arguments = [
_set_package_hold_parser,
_set_package_unhold_parser,
]

View File

@@ -0,0 +1,25 @@
#
# 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/>.
#
__all__ = ["steps"]
steps = [
"""alter table package_statuses add column is_held integer not null default 0""",
]

View File

@@ -211,13 +211,37 @@ class PackageOperations(Operations):
dict[str, BuildStatus]: map of the package base to its status dict[str, BuildStatus]: map of the package base to its status
""" """
return { return {
row["package_base"]: BuildStatus.from_json({"status": row["status"], "timestamp": row["last_updated"]}) row["package_base"]: BuildStatus(row["status"], row["last_updated"], is_held=bool(row["is_held"]))
for row in connection.execute( for row in connection.execute(
"""select * from package_statuses where repository = :repository""", """select * from package_statuses where repository = :repository""",
{"repository": repository_id.id} {"repository": repository_id.id}
) )
} }
def package_hold_update(self, package_base: str, repository_id: RepositoryId | None = None, *,
enabled: bool) -> None:
"""
update package hold status
Args:
package_base(str): package base name
repository_id(RepositoryId, optional): repository unique identifier override (Default value = None)
enabled(bool): new hold status
"""
repository_id = repository_id or self._repository_id
def run(connection: Connection) -> None:
connection.execute(
"""update package_statuses set is_held = :is_held
where package_base = :package_base and repository = :repository""",
{
"is_held": int(enabled),
"package_base": package_base,
"repository": repository_id.id,
})
return self.with_connection(run, commit=True)
def package_remove(self, package_base: str, repository_id: RepositoryId | None = None) -> None: def package_remove(self, package_base: str, repository_id: RepositoryId | None = None) -> None:
""" """
remove package from database remove package from database

View File

@@ -27,7 +27,7 @@ from ahriman.core.build_tools.package_archive import PackageArchive
from ahriman.core.build_tools.task import Task from ahriman.core.build_tools.task import Task
from ahriman.core.repository.cleaner import Cleaner from ahriman.core.repository.cleaner import Cleaner
from ahriman.core.repository.package_info import PackageInfo from ahriman.core.repository.package_info import PackageInfo
from ahriman.core.utils import atomic_move, filelock, list_flatmap, package_like, safe_filename, symlink_relative from ahriman.core.utils import atomic_move, filelock, safe_filename, symlink_relative
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
from ahriman.models.event import EventType from ahriman.models.event import EventType
from ahriman.models.package import Package from ahriman.models.package import Package
@@ -41,34 +41,6 @@ class Executor(PackageInfo, Cleaner):
trait for common repository update processes trait for common repository update processes
""" """
def _archive_lookup(self, package: Package) -> list[Path]:
"""
check if there is a rebuilt package already
Args:
package(Package): package to check
Returns:
list[Path]: list of built packages and signatures if available, empty list otherwise
"""
archive = self.paths.archive_for(package.base)
if not archive.is_dir():
return []
for path in filter(package_like, archive.iterdir()):
# check if package version is the same
built = Package.from_archive(path)
if built.version != package.version:
continue
# all packages must be either any or same architecture
if not built.supports_architecture(self.repository_id.architecture):
continue
return list_flatmap(built.packages.values(), lambda single: archive.glob(f"{single.filename}*"))
return []
def _archive_rename(self, description: PackageDescription, package_base: str) -> None: def _archive_rename(self, description: PackageDescription, package_base: str) -> None:
""" """
rename package archive removing special symbols rename package archive removing special symbols
@@ -106,7 +78,7 @@ class Executor(PackageInfo, Cleaner):
commit_sha = task.init(path, patches, local_version) commit_sha = task.init(path, patches, local_version)
loaded_package = Package.from_build(path, self.repository_id.architecture, None) loaded_package = Package.from_build(path, self.repository_id.architecture, None)
if prebuilt := list(self._archive_lookup(loaded_package)): if prebuilt := self.package_archives_lookup(loaded_package):
self.logger.info("using prebuilt packages for %s-%s", loaded_package.base, loaded_package.version) self.logger.info("using prebuilt packages for %s-%s", loaded_package.base, loaded_package.version)
built = [] built = []
for artifact in prebuilt: for artifact in prebuilt:

View File

@@ -30,10 +30,11 @@ from ahriman.core.build_tools.sources import Sources
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.log import LazyLogging from ahriman.core.log import LazyLogging
from ahriman.core.status import Client from ahriman.core.status import Client
from ahriman.core.utils import package_like from ahriman.core.utils import list_flatmap, package_like
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
from ahriman.models.repository_paths import RepositoryPaths
class PackageInfo(LazyLogging): class PackageInfo(LazyLogging):
@@ -43,12 +44,14 @@ class PackageInfo(LazyLogging):
Attributes: Attributes:
configuration(Configuration): configuration instance configuration(Configuration): configuration instance
pacman(Pacman): alpm wrapper instance pacman(Pacman): alpm wrapper instance
paths(RepositoryPaths): repository paths instance
reporter(Client): build status reporter instance reporter(Client): build status reporter instance
repository_id(RepositoryId): repository unique identifier repository_id(RepositoryId): repository unique identifier
""" """
configuration: Configuration configuration: Configuration
pacman: Pacman pacman: Pacman
paths: RepositoryPaths
reporter: Client reporter: Client
repository_id: RepositoryId repository_id: RepositoryId
@@ -130,11 +133,9 @@ class PackageInfo(LazyLogging):
Returns: Returns:
list[Package]: list of packages belonging to this base, sorted by version by ascension list[Package]: list of packages belonging to this base, sorted by version by ascension
""" """
paths = self.configuration.repository_paths
packages: dict[tuple[str, str], Package] = {} packages: dict[tuple[str, str], Package] = {}
# we can't use here load_archives, because it ignores versions # we can't use here load_archives, because it ignores versions
for full_path in filter(package_like, paths.archive_for(package_base).iterdir()): for full_path in filter(package_like, self.paths.archive_for(package_base).iterdir()):
local = Package.from_archive(full_path) local = Package.from_archive(full_path)
if not local.supports_architecture(self.repository_id.architecture): if not local.supports_architecture(self.repository_id.architecture):
continue continue
@@ -143,6 +144,34 @@ class PackageInfo(LazyLogging):
comparator: Callable[[Package, Package], int] = lambda left, right: left.vercmp(right.version) comparator: Callable[[Package, Package], int] = lambda left, right: left.vercmp(right.version)
return sorted(packages.values(), key=cmp_to_key(comparator)) return sorted(packages.values(), key=cmp_to_key(comparator))
def package_archives_lookup(self, package: Package) -> list[Path]:
"""
check if there is a rebuilt package already
Args:
package(Package): package to check
Returns:
list[Path]: list of built packages and signatures if available, empty list otherwise
"""
archive = self.paths.archive_for(package.base)
if not archive.is_dir():
return []
for path in filter(package_like, archive.iterdir()):
# check if package version is the same
built = Package.from_archive(path)
if built.version != package.version:
continue
# all packages must be either any or same architecture
if not built.supports_architecture(self.repository_id.architecture):
continue
return list_flatmap(built.packages.values(), lambda single: archive.glob(f"{single.filename}*"))
return []
def package_changes(self, package: Package, last_commit_sha: str) -> Changes | None: def package_changes(self, package: Package, last_commit_sha: str) -> Changes | None:
""" """
extract package change for the package since last commit if available extract package change for the package since last commit if available
@@ -157,7 +186,7 @@ class PackageInfo(LazyLogging):
with TemporaryDirectory(ignore_cleanup_errors=True) as dir_name: with TemporaryDirectory(ignore_cleanup_errors=True) as dir_name:
dir_path = Path(dir_name) dir_path = Path(dir_name)
patches = self.reporter.package_patches_get(package.base, None) patches = self.reporter.package_patches_get(package.base, None)
current_commit_sha = Sources.load(dir_path, package, patches, self.configuration.repository_paths) current_commit_sha = Sources.load(dir_path, package, patches, self.paths)
if current_commit_sha != last_commit_sha: if current_commit_sha != last_commit_sha:
return Sources.changes(dir_path, last_commit_sha) return Sources.changes(dir_path, last_commit_sha)
@@ -173,7 +202,7 @@ class PackageInfo(LazyLogging):
Returns: Returns:
list[Package]: list of packages properties list[Package]: list of packages properties
""" """
packages = self.load_archives(filter(package_like, self.configuration.repository_paths.repository.iterdir())) packages = self.load_archives(filter(package_like, self.paths.repository.iterdir()))
if filter_packages: if filter_packages:
packages = [package for package in packages if package.base in filter_packages] packages = [package for package in packages if package.base in filter_packages]
@@ -186,7 +215,7 @@ class PackageInfo(LazyLogging):
Returns: Returns:
list[Path]: list of filenames from the directory list[Path]: list of filenames from the directory
""" """
return list(filter(package_like, self.configuration.repository_paths.packages.iterdir())) return list(filter(package_like, self.paths.packages.iterdir()))
def packages_depend_on(self, packages: list[Package], depends_on: Iterable[str] | None) -> list[Package]: def packages_depend_on(self, packages: list[Package], depends_on: Iterable[str] | None) -> list[Package]:
""" """

View File

@@ -42,7 +42,6 @@ class RepositoryProperties(EventLogger, LazyLogging):
Attributes: Attributes:
configuration(Configuration): configuration instance configuration(Configuration): configuration instance
database(SQLite): database instance database(SQLite): database instance
ignore_list(list[str]): package bases which will be ignored during auto updates
pacman(Pacman): alpm wrapper instance pacman(Pacman): alpm wrapper instance
paths(RepositoryPaths): repository paths instance paths(RepositoryPaths): repository paths instance
repo(Repo): repo commands wrapper instance repo(Repo): repo commands wrapper instance
@@ -69,7 +68,7 @@ class RepositoryProperties(EventLogger, LazyLogging):
self.paths: RepositoryPaths = configuration.repository_paths # additional workaround for pycharm typing self.paths: RepositoryPaths = configuration.repository_paths # additional workaround for pycharm typing
self.ignore_list = configuration.getlist("build", "ignore_packages", fallback=[]) self._ignore_list = configuration.getlist("build", "ignore_packages", fallback=[])
self.pacman = Pacman(repository_id, configuration, refresh_database=refresh_pacman_database) self.pacman = Pacman(repository_id, configuration, refresh_database=refresh_pacman_database)
self.sign = GPG(configuration) self.sign = GPG(configuration)
self.repo = Repo(self.repository_id.name, self.paths, self.sign.repository_sign_args) self.repo = Repo(self.repository_id.name, self.paths, self.sign.repository_sign_args)

View File

@@ -58,12 +58,17 @@ class UpdateHandler(PackageInfo, Cleaner):
continue continue
raise UnknownPackageError(package.base) raise UnknownPackageError(package.base)
ignore_list = self._ignore_list + [
package.base for package, status in self.reporter.package_get(None) if status.is_held
]
result: list[Package] = [] result: list[Package] = []
for local in self.packages(filter_packages): for local in self.packages(filter_packages):
with self.in_package_context(local.base, local.version): with self.in_package_context(local.base, local.version):
if not local.remote.is_remote: if not local.remote.is_remote:
continue # avoid checking local packages continue # avoid checking local packages
if local.base in self.ignore_list: if local.base in ignore_list:
self.logger.info("package %s is held, skip update check", local.base)
continue continue
try: try:

View File

@@ -199,6 +199,19 @@ class Client:
""" """
raise NotImplementedError raise NotImplementedError
def package_hold_update(self, package_base: str, *, enabled: bool) -> None:
"""
update package hold status
Args:
package_base(str): package base name
enabled(bool): new hold status
Raises:
NotImplementedError: not implemented method
"""
raise NotImplementedError
def package_logs_add(self, log_record: LogRecord) -> None: def package_logs_add(self, log_record: LogRecord) -> None:
""" """
post log record post log record

View File

@@ -42,7 +42,7 @@ class LocalClient(Client):
""" """
Args: Args:
repository_id(RepositoryId): repository unique identifier repository_id(RepositoryId): repository unique identifier
database(SQLite): database instance: database(SQLite): database instance
""" """
self.database = database self.database = database
self.repository_id = repository_id self.repository_id = repository_id
@@ -143,6 +143,16 @@ class LocalClient(Client):
return packages return packages
return [(package, status) for package, status in packages if package.base == package_base] return [(package, status) for package, status in packages if package.base == package_base]
def package_hold_update(self, package_base: str, *, enabled: bool) -> None:
"""
update package hold status
Args:
package_base(str): package base name
enabled(bool): new hold status
"""
self.database.package_hold_update(package_base, self.repository_id, enabled=enabled)
def package_logs_add(self, log_record: LogRecord) -> None: def package_logs_add(self, log_record: LogRecord) -> None:
""" """
post log record post log record

View File

@@ -18,6 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from collections.abc import Callable from collections.abc import Callable
from dataclasses import replace
from threading import Lock from threading import Lock
from typing import Any, Self from typing import Any, Self
@@ -129,6 +130,19 @@ class Watcher(LazyLogging):
package_logs_remove: Callable[[str, str | None], None] package_logs_remove: Callable[[str, str | None], None]
def package_hold_update(self, package_base: str, *, enabled: bool) -> None:
"""
update package hold status
Args:
package_base(str): package base name
enabled(bool): new hold status
"""
package, status = self.package_get(package_base)
with self._lock:
self._known[package_base] = (package, replace(status, is_held=enabled))
self.client.package_hold_update(package_base, enabled=enabled)
package_patches_get: Callable[[str, str | None], list[PkgbuildPatch]] package_patches_get: Callable[[str, str | None], list[PkgbuildPatch]]
package_patches_remove: Callable[[str, str], None] package_patches_remove: Callable[[str, str], None]
@@ -154,9 +168,9 @@ class Watcher(LazyLogging):
package_base(str): package base to update package_base(str): package base to update
status(BuildStatusEnum): new build status status(BuildStatusEnum): new build status
""" """
package, _ = self.package_get(package_base) package, current_status = self.package_get(package_base)
with self._lock: with self._lock:
self._known[package_base] = (package, BuildStatus(status)) self._known[package_base] = (package, BuildStatus(status, is_held=current_status.is_held))
self.client.package_status_update(package_base, status) self.client.package_status_update(package_base, status)
def package_update(self, package: Package, status: BuildStatusEnum) -> None: def package_update(self, package: Package, status: BuildStatusEnum) -> None:
@@ -168,7 +182,8 @@ class Watcher(LazyLogging):
status(BuildStatusEnum): new build status status(BuildStatusEnum): new build status
""" """
with self._lock: with self._lock:
self._known[package.base] = (package, BuildStatus(status)) _, current_status = self._known.get(package.base, (package, BuildStatus()))
self._known[package.base] = (package, BuildStatus(status, is_held=current_status.is_held))
self.client.package_update(package, status) self.client.package_update(package, status)
def status_update(self, status: BuildStatusEnum) -> None: def status_update(self, status: BuildStatusEnum) -> None:

View File

@@ -314,6 +314,18 @@ class WebClient(Client, SyncAhrimanClient):
return [] return []
def package_hold_update(self, package_base: str, *, enabled: bool) -> None:
"""
update package hold status
Args:
package_base(str): package base name
enabled(bool): new hold status
"""
with contextlib.suppress(Exception):
self.make_request("POST", f"{self.address}/api/v1/packages/{url_encode(package_base)}/hold",
params=self.repository_id.query(), json={"is_held": enabled})
def package_logs_add(self, log_record: LogRecord) -> None: def package_logs_add(self, log_record: LogRecord) -> None:
""" """
post log record post log record

View File

@@ -51,10 +51,12 @@ class BuildStatus:
Attributes: Attributes:
status(BuildStatusEnum): build status status(BuildStatusEnum): build status
timestamp(int): build status update time timestamp(int): build status update time
is_held(bool | None): whether package held or not
""" """
status: BuildStatusEnum = BuildStatusEnum.Unknown status: BuildStatusEnum = BuildStatusEnum.Unknown
timestamp: int = field(default_factory=lambda: int(utcnow().timestamp())) timestamp: int = field(default_factory=lambda: int(utcnow().timestamp()))
is_held: bool | None = field(default=None, kw_only=True)
def __post_init__(self) -> None: def __post_init__(self) -> None:
""" """
@@ -83,7 +85,7 @@ class BuildStatus:
Returns: Returns:
str: print-friendly string str: print-friendly string
""" """
return f"{self.status.value} ({pretty_datetime(self.timestamp)})" return f"{self.status.value} ({pretty_datetime(self.timestamp)}){" (held)" if self.is_held else ""}"
def view(self) -> dict[str, Any]: def view(self) -> dict[str, Any]:
""" """
@@ -94,5 +96,6 @@ class BuildStatus:
""" """
return { return {
"status": self.status.value, "status": self.status.value,
"timestamp": self.timestamp "timestamp": self.timestamp,
"is_held": self.is_held,
} }

View File

@@ -31,6 +31,7 @@ from ahriman.web.schemas.error_schema import ErrorSchema
from ahriman.web.schemas.event_schema import EventSchema from ahriman.web.schemas.event_schema import EventSchema
from ahriman.web.schemas.event_search_schema import EventSearchSchema from ahriman.web.schemas.event_search_schema import EventSearchSchema
from ahriman.web.schemas.file_schema import FileSchema from ahriman.web.schemas.file_schema import FileSchema
from ahriman.web.schemas.hold_schema import HoldSchema
from ahriman.web.schemas.info_schema import InfoSchema from ahriman.web.schemas.info_schema import InfoSchema
from ahriman.web.schemas.info_v2_schema import InfoV2Schema from ahriman.web.schemas.info_v2_schema import InfoV2Schema
from ahriman.web.schemas.internal_status_schema import InternalStatusSchema from ahriman.web.schemas.internal_status_schema import InternalStatusSchema

View File

@@ -0,0 +1,30 @@
#
# 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/>.
#
from ahriman.web.apispec import Schema, fields
class HoldSchema(Schema):
"""
request hold schema
"""
is_held = fields.Boolean(required=True, metadata={
"description": "Package hold status",
})

View File

@@ -33,3 +33,6 @@ class StatusSchema(Schema):
"description": "Last update timestamp", "description": "Last update timestamp",
"example": 1680537091, "example": 1680537091,
}) })
is_held = fields.Boolean(metadata={
"description": "Package hold status",
})

View File

@@ -0,0 +1,75 @@
#
# 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/>.
#
from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound
from typing import ClassVar
from ahriman.core.exceptions import UnknownPackageError
from ahriman.models.user_access import UserAccess
from ahriman.web.apispec.decorators import apidocs
from ahriman.web.schemas import HoldSchema, PackageNameSchema, RepositoryIdSchema
from ahriman.web.views.base import BaseView
from ahriman.web.views.status_view_guard import StatusViewGuard
class HoldView(StatusViewGuard, BaseView):
"""
package hold web view
Attributes:
POST_PERMISSION(UserAccess): (class attribute) post permissions of self
"""
POST_PERMISSION: ClassVar[UserAccess] = UserAccess.Full
ROUTES = ["/api/v1/packages/{package}/hold"]
@apidocs(
tags=["Packages"],
summary="Update package hold status",
description="Set package hold status",
permission=POST_PERMISSION,
error_400_enabled=True,
error_404_description="Package base and/or repository are unknown",
match_schema=PackageNameSchema,
query_schema=RepositoryIdSchema,
body_schema=HoldSchema,
)
async def post(self) -> None:
"""
update package hold status
Raises:
HTTPBadRequest: if bad data is supplied
HTTPNoContent: in case of success response
HTTPNotFound: if no package was found
"""
package_base = self.request.match_info["package"]
try:
data = await self.request.json()
is_held = data["is_held"]
except Exception as ex:
raise HTTPBadRequest(reason=str(ex))
try:
self.service().package_hold_update(package_base, enabled=is_held)
except UnknownPackageError:
raise HTTPNotFound(reason=f"Package {package_base} is unknown")
raise HTTPNoContent

View File

@@ -103,6 +103,7 @@ def _create_watcher(path: Path, repository_id: RepositoryId) -> Watcher:
# load package info wrapper # load package info wrapper
package_info = PackageInfo() package_info = PackageInfo()
package_info.configuration = configuration package_info.configuration = configuration
package_info.paths = configuration.repository_paths
package_info.repository_id = repository_id package_info.repository_id = repository_id
return Watcher(client, package_info) return Watcher(client, package_info)

View File

@@ -0,0 +1,53 @@
import argparse
import pytest
from pytest_mock import MockerFixture
from ahriman.application.handlers.hold import Hold
from ahriman.core.configuration import Configuration
from ahriman.core.repository import Repository
from ahriman.models.action import Action
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
Args:
args(argparse.Namespace): command line arguments fixture
Returns:
argparse.Namespace: generated arguments for these test cases
"""
args.package = ["ahriman"]
return args
def test_run(args: argparse.Namespace, configuration: Configuration, repository: Repository,
mocker: MockerFixture) -> None:
"""
must run command
"""
args = _default_args(args)
args.action = Action.Update
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
hold_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_hold_update")
_, repository_id = configuration.check_loaded()
Hold.run(args, repository_id, configuration, report=False)
hold_mock.assert_called_once_with("ahriman", enabled=True)
def test_run_remove(args: argparse.Namespace, configuration: Configuration, repository: Repository,
mocker: MockerFixture) -> None:
"""
must remove held status
"""
args = _default_args(args)
args.action = Action.Remove
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
hold_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_hold_update")
_, repository_id = configuration.check_loaded()
Hold.run(args, repository_id, configuration, report=False)
hold_mock.assert_called_once_with("ahriman", enabled=False)

View File

@@ -464,6 +464,30 @@ def test_subparsers_package_status_update_package_status_remove(parser: argparse
assert dir(args) == dir(reference_args) assert dir(args) == dir(reference_args)
def test_subparsers_package_hold(parser: argparse.ArgumentParser) -> None:
"""
package-hold command must imply action, lock, quiet, report and unsafe
"""
args = parser.parse_args(["package-hold", "ahriman"])
assert args.action == Action.Update
assert args.lock is None
assert args.quiet
assert not args.report
assert args.unsafe
def test_subparsers_package_unhold(parser: argparse.ArgumentParser) -> None:
"""
package-unhold command must imply action, lock, quiet, report and unsafe
"""
args = parser.parse_args(["package-unhold", "ahriman"])
assert args.action == Action.Remove
assert args.lock is None
assert args.quiet
assert not args.report
assert args.unsafe
def test_subparsers_patch_add(parser: argparse.ArgumentParser) -> None: def test_subparsers_patch_add(parser: argparse.ArgumentParser) -> None:
""" """
patch-add command must imply action, architecture list, exit code, lock, report and repository patch-add command must imply action, architecture list, exit code, lock, report and repository

View File

@@ -0,0 +1,8 @@
from ahriman.core.database.migrations.m018_package_hold import steps
def test_migration_package_hold() -> None:
"""
migration must not be empty
"""
assert steps

View File

@@ -103,6 +103,33 @@ def test_packages_get_select_statuses(database: SQLite, connection: Connection)
connection.execute(pytest.helpers.anyvar(str, strict=True)) connection.execute(pytest.helpers.anyvar(str, strict=True))
def test_package_hold_update(database: SQLite, package_ahriman: Package) -> None:
"""
must update package hold status
"""
database.package_update(package_ahriman)
database.status_update(package_ahriman.base, BuildStatus())
database.package_hold_update(package_ahriman.base, enabled=True)
assert next(status.is_held for _, status in database.packages_get())
database.package_hold_update(package_ahriman.base, enabled=False)
assert not next(status.is_held for _, status in database.packages_get())
def test_package_hold_update_preserves_on_package_update(database: SQLite, package_ahriman: Package) -> None:
"""
must preserve hold status on regular package update
"""
database.package_update(package_ahriman)
database.status_update(package_ahriman.base, BuildStatus())
database.package_hold_update(package_ahriman.base, enabled=True)
package_ahriman.version = "1.0.0"
database.package_update(package_ahriman)
assert next(status.is_held for _, status in database.packages_get())
def test_package_remove(database: SQLite, package_ahriman: Package, mocker: MockerFixture) -> None: def test_package_remove(database: SQLite, package_ahriman: Package, mocker: MockerFixture) -> None:
""" """
must totally remove package from the database must totally remove package from the database
@@ -156,8 +183,9 @@ def test_package_update_get(database: SQLite, package_ahriman: Package) -> None:
status = BuildStatus() status = BuildStatus()
database.package_update(package_ahriman) database.package_update(package_ahriman)
database.status_update(package_ahriman.base, status) database.status_update(package_ahriman.base, status)
expected = BuildStatus(status.status, status.timestamp, is_held=False)
assert next((db_package, db_status) assert next((db_package, db_status)
for db_package, db_status in database.packages_get()) == (package_ahriman, status) for db_package, db_status in database.packages_get()) == (package_ahriman, expected)
def test_package_update_remove_get(database: SQLite, package_ahriman: Package) -> None: def test_package_update_remove_get(database: SQLite, package_ahriman: Package) -> None:
@@ -189,7 +217,8 @@ def test_status_update(database: SQLite, package_ahriman: Package) -> None:
database.package_update(package_ahriman) database.package_update(package_ahriman)
database.status_update(package_ahriman.base, status) database.status_update(package_ahriman.base, status)
assert database.packages_get() == [(package_ahriman, status)] expected = BuildStatus(status.status, status.timestamp, is_held=False)
assert database.packages_get() == [(package_ahriman, expected)]
def test_status_update_skip_same_status(database: SQLite, package_ahriman: Package) -> None: def test_status_update_skip_same_status(database: SQLite, package_ahriman: Package) -> None:

View File

@@ -1,6 +1,5 @@
import pytest import pytest
from dataclasses import replace
from pathlib import Path from pathlib import Path
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from typing import Any from typing import Any
@@ -11,72 +10,9 @@ from ahriman.models.changes import Changes
from ahriman.models.dependencies import Dependencies from ahriman.models.dependencies import Dependencies
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.packagers import Packagers from ahriman.models.packagers import Packagers
from ahriman.models.repository_id import RepositoryId
from ahriman.models.user import User from ahriman.models.user import User
def test_archive_lookup(executor: Executor, package_ahriman: Package, package_python_schedule: Package,
mocker: MockerFixture) -> None:
"""
must existing packages which match the version
"""
mocker.patch("pathlib.Path.is_dir", return_value=True)
mocker.patch("pathlib.Path.iterdir", return_value=[
Path("1.pkg.tar.zst"),
Path("2.pkg.tar.zst"),
Path("3.pkg.tar.zst"),
])
mocker.patch("ahriman.models.package.Package.from_archive", side_effect=[
package_ahriman,
package_python_schedule,
replace(package_ahriman, version="1"),
])
glob_mock = mocker.patch("pathlib.Path.glob", return_value=[Path("1.pkg.tar.xz")])
assert list(executor._archive_lookup(package_ahriman)) == [Path("1.pkg.tar.xz")]
glob_mock.assert_called_once_with(f"{package_ahriman.packages[package_ahriman.base].filename}*")
def test_archive_lookup_version_mismatch(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must return nothing if no packages found with the same version
"""
mocker.patch("pathlib.Path.is_dir", return_value=True)
mocker.patch("pathlib.Path.iterdir", return_value=[
Path("1.pkg.tar.zst"),
])
mocker.patch("ahriman.models.package.Package.from_archive", return_value=replace(package_ahriman, version="1"))
assert list(executor._archive_lookup(package_ahriman)) == []
def test_archive_lookup_architecture_mismatch(executor: Executor, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must return nothing if architecture doesn't match
"""
package_ahriman.packages[package_ahriman.base].architecture = "x86_64"
mocker.patch("pathlib.Path.is_dir", return_value=True)
executor.repository_id = RepositoryId("i686", executor.repository_id.name)
mocker.patch("pathlib.Path.iterdir", return_value=[
Path("1.pkg.tar.zst"),
])
mocker.patch("ahriman.models.package.Package.from_archive", return_value=package_ahriman)
assert list(executor._archive_lookup(package_ahriman)) == []
def test_archive_lookup_no_archive_directory(
executor: Executor,
package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must return nothing if no archive directory found
"""
mocker.patch("pathlib.Path.is_dir", return_value=False)
assert list(executor._archive_lookup(package_ahriman)) == []
def test_archive_rename(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None: def test_archive_rename(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None:
""" """
must correctly remove package archive must correctly remove package archive
@@ -110,7 +46,7 @@ def test_package_build(executor: Executor, package_ahriman: Package, mocker: Moc
status_client_mock = mocker.patch("ahriman.core.status.Client.set_building") status_client_mock = mocker.patch("ahriman.core.status.Client.set_building")
init_mock = mocker.patch("ahriman.core.build_tools.task.Task.init", return_value="sha") init_mock = mocker.patch("ahriman.core.build_tools.task.Task.init", return_value="sha")
package_mock = mocker.patch("ahriman.models.package.Package.from_build", return_value=package_ahriman) package_mock = mocker.patch("ahriman.models.package.Package.from_build", return_value=package_ahriman)
lookup_mock = mocker.patch("ahriman.core.repository.executor.Executor._archive_lookup", return_value=[]) lookup_mock = mocker.patch("ahriman.core.repository.executor.Executor.package_archives_lookup", return_value=[])
with_packages_mock = mocker.patch("ahriman.models.package.Package.with_packages") with_packages_mock = mocker.patch("ahriman.models.package.Package.with_packages")
rename_mock = mocker.patch("ahriman.core.repository.executor.atomic_move") rename_mock = mocker.patch("ahriman.core.repository.executor.atomic_move")
@@ -131,7 +67,7 @@ def test_package_build_copy(executor: Executor, package_ahriman: Package, mocker
mocker.patch("ahriman.core.build_tools.task.Task.build", return_value=[Path(package_ahriman.base)]) mocker.patch("ahriman.core.build_tools.task.Task.build", return_value=[Path(package_ahriman.base)])
mocker.patch("ahriman.core.build_tools.task.Task.init") mocker.patch("ahriman.core.build_tools.task.Task.init")
mocker.patch("ahriman.models.package.Package.from_build", return_value=package_ahriman) mocker.patch("ahriman.models.package.Package.from_build", return_value=package_ahriman)
mocker.patch("ahriman.core.repository.executor.Executor._archive_lookup", return_value=[path]) mocker.patch("ahriman.core.repository.executor.Executor.package_archives_lookup", return_value=[path])
mocker.patch("ahriman.core.repository.executor.atomic_move") mocker.patch("ahriman.core.repository.executor.atomic_move")
mocker.patch("ahriman.models.package.Package.with_packages") mocker.patch("ahriman.models.package.Package.with_packages")
copy_mock = mocker.patch("shutil.copy") copy_mock = mocker.patch("shutil.copy")

View File

@@ -8,6 +8,7 @@ from unittest.mock import MagicMock
from ahriman.core.repository import Repository from ahriman.core.repository import Repository
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.repository_id import RepositoryId
def test_full_depends(repository: Repository, package_ahriman: Package, package_python_schedule: Package, def test_full_depends(repository: Repository, package_ahriman: Package, package_python_schedule: Package,
@@ -120,6 +121,67 @@ def test_package_archives_architecture_mismatch(repository: Repository, package_
assert len(result) == 0 assert len(result) == 0
def test_package_archives_lookup(repository: Repository, package_ahriman: Package, package_python_schedule: Package,
mocker: MockerFixture) -> None:
"""
must existing packages which match the version
"""
mocker.patch("pathlib.Path.is_dir", return_value=True)
mocker.patch("pathlib.Path.iterdir", return_value=[
Path("1.pkg.tar.zst"),
Path("2.pkg.tar.zst"),
Path("3.pkg.tar.zst"),
])
mocker.patch("ahriman.models.package.Package.from_archive", side_effect=[
package_ahriman,
package_python_schedule,
replace(package_ahriman, version="1"),
])
glob_mock = mocker.patch("pathlib.Path.glob", return_value=[Path("1.pkg.tar.xz")])
assert repository.package_archives_lookup(package_ahriman) == [Path("1.pkg.tar.xz")]
glob_mock.assert_called_once_with(f"{package_ahriman.packages[package_ahriman.base].filename}*")
def test_package_archives_lookup_version_mismatch(repository: Repository, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must return nothing if no packages found with the same version
"""
mocker.patch("pathlib.Path.is_dir", return_value=True)
mocker.patch("pathlib.Path.iterdir", return_value=[
Path("1.pkg.tar.zst"),
])
mocker.patch("ahriman.models.package.Package.from_archive", return_value=replace(package_ahriman, version="1"))
assert repository.package_archives_lookup(package_ahriman) == []
def test_package_archives_lookup_architecture_mismatch(repository: Repository, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must return nothing if architecture doesn't match
"""
package_ahriman.packages[package_ahriman.base].architecture = "x86_64"
mocker.patch("pathlib.Path.is_dir", return_value=True)
repository.repository_id = RepositoryId("i686", repository.repository_id.name)
mocker.patch("pathlib.Path.iterdir", return_value=[
Path("1.pkg.tar.zst"),
])
mocker.patch("ahriman.models.package.Package.from_archive", return_value=package_ahriman)
assert repository.package_archives_lookup(package_ahriman) == []
def test_package_archives_lookup_no_archive_directory(repository: Repository, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must return nothing if no archive directory found
"""
mocker.patch("pathlib.Path.is_dir", return_value=False)
assert repository.package_archives_lookup(package_ahriman) == []
def test_package_changes(repository: Repository, package_ahriman: Package, mocker: MockerFixture) -> None: def test_package_changes(repository: Repository, package_ahriman: Package, mocker: MockerFixture) -> None:
""" """
must load package changes must load package changes

View File

@@ -6,7 +6,7 @@ from typing import Any
from ahriman.core.exceptions import UnknownPackageError from ahriman.core.exceptions import UnknownPackageError
from ahriman.core.repository.update_handler import UpdateHandler from ahriman.core.repository.update_handler import UpdateHandler
from ahriman.models.build_status import BuildStatusEnum from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.dependencies import Dependencies from ahriman.models.dependencies import Dependencies
from ahriman.models.event import EventType from ahriman.models.event import EventType
from ahriman.models.package import Package from ahriman.models.package import Package
@@ -114,7 +114,8 @@ def test_updates_aur_ignore(update_handler: UpdateHandler, package_ahriman: Pack
""" """
must skip ignore packages must skip ignore packages
""" """
update_handler.ignore_list = [package_ahriman.base] mocker.patch("ahriman.core.status.local_client.LocalClient.package_get",
return_value=[(package_ahriman, BuildStatus(is_held=True))])
mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.packages", return_value=[package_ahriman]) mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.packages", return_value=[package_ahriman])
package_load_mock = mocker.patch("ahriman.models.package.Package.from_aur") package_load_mock = mocker.patch("ahriman.models.package.Package.from_aur")

View File

@@ -167,6 +167,14 @@ def test_package_get(client: Client, package_ahriman: Package) -> None:
assert client.package_get(package_ahriman.base) assert client.package_get(package_ahriman.base)
def test_package_hold_update(client: Client, package_ahriman: Package) -> None:
"""
must raise not implemented on hold update
"""
with pytest.raises(NotImplementedError):
client.package_hold_update(package_ahriman.base, enabled=True)
def test_package_logs_add(client: Client, package_ahriman: Package, log_record: logging.LogRecord) -> None: def test_package_logs_add(client: Client, package_ahriman: Package, log_record: logging.LogRecord) -> None:
""" """
must process log record addition without exception must process log record addition without exception

View File

@@ -106,6 +106,15 @@ def test_package_get_package(local_client: LocalClient, package_ahriman: Package
package_mock.assert_called_once_with(local_client.repository_id) package_mock.assert_called_once_with(local_client.repository_id)
def test_package_hold_update(local_client: LocalClient, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must update package hold status
"""
hold_mock = mocker.patch("ahriman.core.database.SQLite.package_hold_update")
local_client.package_hold_update(package_ahriman.base, enabled=True)
hold_mock.assert_called_once_with(package_ahriman.base, local_client.repository_id, enabled=True)
def test_package_logs_add(local_client: LocalClient, package_ahriman: Package, log_record: logging.LogRecord, def test_package_logs_add(local_client: LocalClient, package_ahriman: Package, log_record: logging.LogRecord,
mocker: MockerFixture) -> None: mocker: MockerFixture) -> None:
""" """

View File

@@ -75,6 +75,27 @@ def test_package_get_failed(watcher: Watcher, package_ahriman: Package) -> None:
watcher.package_get(package_ahriman.base) watcher.package_get(package_ahriman.base)
def test_package_hold_update(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must update package hold status
"""
cache_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_hold_update")
watcher._known = {package_ahriman.base: (package_ahriman, BuildStatus())}
watcher.package_hold_update(package_ahriman.base, enabled=True)
cache_mock.assert_called_once_with(package_ahriman.base, enabled=True)
_, status = watcher._known[package_ahriman.base]
assert status.is_held is True
def test_package_hold_update_unknown(watcher: Watcher, package_ahriman: Package) -> None:
"""
must fail on unknown package hold update
"""
with pytest.raises(UnknownPackageError):
watcher.package_hold_update(package_ahriman.base, enabled=True)
def test_package_remove(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None: def test_package_remove(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
""" """
must remove package base must remove package base
@@ -110,6 +131,19 @@ def test_package_status_update(watcher: Watcher, package_ahriman: Package, mocke
assert status.status == BuildStatusEnum.Success assert status.status == BuildStatusEnum.Success
def test_package_status_update_preserves_hold(watcher: Watcher, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must preserve hold status on package status update
"""
mocker.patch("ahriman.core.status.local_client.LocalClient.package_status_update")
watcher._known = {package_ahriman.base: (package_ahriman, BuildStatus(is_held=True))}
watcher.package_status_update(package_ahriman.base, BuildStatusEnum.Success)
_, status = watcher._known[package_ahriman.base]
assert status.is_held is True
def test_package_status_update_unknown(watcher: Watcher, package_ahriman: Package) -> None: def test_package_status_update_unknown(watcher: Watcher, package_ahriman: Package) -> None:
""" """
must fail on unknown package status update only must fail on unknown package status update only
@@ -129,6 +163,18 @@ def test_package_update(watcher: Watcher, package_ahriman: Package, mocker: Mock
cache_mock.assert_called_once_with(package_ahriman, pytest.helpers.anyvar(int)) cache_mock.assert_called_once_with(package_ahriman, pytest.helpers.anyvar(int))
def test_package_update_preserves_hold(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must preserve hold status on package update
"""
mocker.patch("ahriman.core.status.local_client.LocalClient.package_update")
watcher._known = {package_ahriman.base: (package_ahriman, BuildStatus(is_held=True))}
watcher.package_update(package_ahriman, BuildStatusEnum.Success)
_, status = watcher._known[package_ahriman.base]
assert status.is_held is True
def test_status_update(watcher: Watcher) -> None: def test_status_update(watcher: Watcher) -> None:
""" """
must update service status must update service status

View File

@@ -643,6 +643,34 @@ def test_package_get_single(web_client: WebClient, package_ahriman: Package, moc
assert (package_ahriman, BuildStatusEnum.Unknown) in [(package, status.status) for package, status in result] assert (package_ahriman, BuildStatusEnum.Unknown) in [(package, status.status) for package, status in result]
def test_package_hold_update(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must update hold status
"""
requests_mock = mocker.patch("ahriman.core.status.web_client.WebClient.make_request")
web_client.package_hold_update(package_ahriman.base, enabled=True)
requests_mock.assert_called_once_with("POST", pytest.helpers.anyvar(str, True),
params=web_client.repository_id.query(), json={"is_held": True})
def test_package_hold_update_failed(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must suppress any exception happened during hold update
"""
mocker.patch("requests.Session.request", side_effect=Exception)
web_client.package_hold_update(package_ahriman.base, enabled=True)
def test_package_hold_update_failed_http_error(web_client: WebClient, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must suppress HTTP exception happened during hold update
"""
mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
web_client.package_hold_update(package_ahriman.base, enabled=True)
def test_package_logs_add(web_client: WebClient, log_record: logging.LogRecord, package_ahriman: Package, def test_package_logs_add(web_client: WebClient, log_record: logging.LogRecord, package_ahriman: Package,
mocker: MockerFixture) -> None: mocker: MockerFixture) -> None:
""" """

View File

@@ -46,9 +46,9 @@ def test_build_status_pretty_print(build_status_failed: BuildStatus) -> None:
assert isinstance(build_status_failed.pretty_print(), str) assert isinstance(build_status_failed.pretty_print(), str)
def test_build_status_eq(build_status_failed: BuildStatus) -> None: def test_build_status_pretty_print_held() -> None:
""" """
must be equal must include held marker in pretty print
""" """
other = BuildStatus.from_json(build_status_failed.view()) status = BuildStatus(BuildStatusEnum.Success, 42, is_held=True)
assert other == build_status_failed assert "(held)" in status.pretty_print()

View File

@@ -0,0 +1 @@
# schema testing goes in view class tests

View File

@@ -0,0 +1,60 @@
import pytest
from aiohttp.test_utils import TestClient
from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.package import Package
from ahriman.models.user_access import UserAccess
from ahriman.web.views.v1.packages.hold import HoldView
async def test_get_permission() -> None:
"""
must return correct permission for the request
"""
for method in ("POST",):
request = pytest.helpers.request("", "", method)
assert await HoldView.get_permission(request) == UserAccess.Full
def test_routes() -> None:
"""
must return correct routes
"""
assert HoldView.ROUTES == ["/api/v1/packages/{package}/hold"]
async def test_post(client: TestClient, package_ahriman: Package) -> None:
"""
must update package hold status
"""
await client.post(f"/api/v1/packages/{package_ahriman.base}",
json={"status": BuildStatusEnum.Success.value, "package": package_ahriman.view()})
request_schema = pytest.helpers.schema_request(HoldView.post)
payload = {"is_held": True}
assert not request_schema.validate(payload)
response = await client.post(f"/api/v1/packages/{package_ahriman.base}/hold", json=payload)
assert response.status == 204
async def test_post_not_found(client: TestClient, package_ahriman: Package) -> None:
"""
must return Not Found for unknown package
"""
response_schema = pytest.helpers.schema_response(HoldView.post, code=404)
response = await client.post(f"/api/v1/packages/{package_ahriman.base}/hold", json={"is_held": False})
assert response.status == 404
assert not response_schema.validate(await response.json())
async def test_post_exception(client: TestClient, package_ahriman: Package) -> None:
"""
must raise exception on invalid payload
"""
response_schema = pytest.helpers.schema_response(HoldView.post, code=400)
response = await client.post(f"/api/v1/packages/{package_ahriman.base}/hold", json=[])
assert response.status == 400
assert not response_schema.validate(await response.json())