diff --git a/docs/ahriman.application.handlers.rst b/docs/ahriman.application.handlers.rst index f45d6887..2c8f7f90 100644 --- a/docs/ahriman.application.handlers.rst +++ b/docs/ahriman.application.handlers.rst @@ -84,6 +84,14 @@ ahriman.application.handlers.help module :no-undoc-members: :show-inheritance: +ahriman.application.handlers.hold module +---------------------------------------- + +.. automodule:: ahriman.application.handlers.hold + :members: + :no-undoc-members: + :show-inheritance: + ahriman.application.handlers.key\_import module ----------------------------------------------- diff --git a/docs/ahriman.core.database.migrations.rst b/docs/ahriman.core.database.migrations.rst index 29984079..3cd39952 100644 --- a/docs/ahriman.core.database.migrations.rst +++ b/docs/ahriman.core.database.migrations.rst @@ -148,6 +148,14 @@ ahriman.core.database.migrations.m017\_pkgbuild module :no-undoc-members: :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 --------------- diff --git a/docs/ahriman.web.schemas.rst b/docs/ahriman.web.schemas.rst index 425ae914..1d51aeca 100644 --- a/docs/ahriman.web.schemas.rst +++ b/docs/ahriman.web.schemas.rst @@ -116,6 +116,14 @@ ahriman.web.schemas.file\_schema module :no-undoc-members: :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 --------------------------------------- diff --git a/docs/ahriman.web.views.v1.packages.rst b/docs/ahriman.web.views.v1.packages.rst index 71c0655b..38522757 100644 --- a/docs/ahriman.web.views.v1.packages.rst +++ b/docs/ahriman.web.views.v1.packages.rst @@ -28,6 +28,14 @@ ahriman.web.views.v1.packages.dependencies module :no-undoc-members: :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 ----------------------------------------- diff --git a/frontend/src/api/client/ServiceClient.ts b/frontend/src/api/client/ServiceClient.ts index 21006840..4b6d9de6 100644 --- a/frontend/src/api/client/ServiceClient.ts +++ b/frontend/src/api/client/ServiceClient.ts @@ -78,6 +78,14 @@ export class ServiceClient { return this.client.request("/api/v1/service/pgp", { method: "POST", json: data }); } + async servicePackageHoldUpdate(packageBase: string, repository: RepositoryId, isHeld: boolean): Promise { + 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 { return this.client.request("/api/v1/service/rebuild", { method: "POST", diff --git a/frontend/src/components/dialogs/PackageInfoDialog.tsx b/frontend/src/components/dialogs/PackageInfoDialog.tsx index bca30be4..ea71b03b 100644 --- a/frontend/src/components/dialogs/PackageInfoDialog.tsx +++ b/frontend/src/components/dialogs/PackageInfoDialog.tsx @@ -130,6 +130,19 @@ export default function PackageInfoDialog({ } }; + const handleHoldToggle: () => Promise = 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 = async key => { if (!localPackageBase) { return; @@ -189,6 +202,8 @@ export default function PackageInfoDialog({ isAuthorized={isAuthorized} refreshDatabase={refreshDatabase} onRefreshDatabaseChange={setRefreshDatabase} + isHeld={status?.is_held ?? false} + onHoldToggle={() => void handleHoldToggle()} onUpdate={() => void handleUpdate()} onRemove={() => void handleRemove()} autoRefreshIntervals={autoRefreshIntervals} diff --git a/frontend/src/components/package/PackageInfoActions.tsx b/frontend/src/components/package/PackageInfoActions.tsx index eabbdac7..fd48280b 100644 --- a/frontend/src/components/package/PackageInfoActions.tsx +++ b/frontend/src/components/package/PackageInfoActions.tsx @@ -18,7 +18,9 @@ * along with this program. If not, see . */ import DeleteIcon from "@mui/icons-material/Delete"; +import PauseCircleIcon from "@mui/icons-material/PauseCircle"; import PlayArrowIcon from "@mui/icons-material/PlayArrow"; +import PlayCircleIcon from "@mui/icons-material/PlayCircle"; import { Button, Checkbox, DialogActions, FormControlLabel } from "@mui/material"; import AutoRefreshControl from "components/common/AutoRefreshControl"; import type { AutoRefreshInterval } from "models/AutoRefreshInterval"; @@ -26,6 +28,8 @@ import type React from "react"; interface PackageInfoActionsProps { isAuthorized: boolean; + isHeld: boolean; + onHoldToggle: () => void; refreshDatabase: boolean; onRefreshDatabaseChange: (checked: boolean) => void; onUpdate: () => void; @@ -39,6 +43,8 @@ export default function PackageInfoActions({ isAuthorized, refreshDatabase, onRefreshDatabaseChange, + isHeld, + onHoldToggle, onUpdate, onRemove, autoRefreshIntervals, @@ -52,6 +58,9 @@ export default function PackageInfoActions({ control={ onRefreshDatabaseChange(checked)} size="small" />} label="update pacman databases" /> + diff --git a/frontend/src/components/table/PackageTable.tsx b/frontend/src/components/table/PackageTable.tsx index 1ee9d658..3ba3d2c3 100644 --- a/frontend/src/components/table/PackageTable.tsx +++ b/frontend/src/components/table/PackageTable.tsx @@ -107,7 +107,8 @@ export default function PackageTable({ autoRefreshIntervals }: PackageTableProps width: 120, align: "center", headerAlign: "center", - renderCell: (params: GridRenderCellParams) => , + renderCell: (params: GridRenderCellParams) => + , }, ], [], diff --git a/frontend/src/components/table/StatusCell.tsx b/frontend/src/components/table/StatusCell.tsx index f74c12e8..d4ba7a86 100644 --- a/frontend/src/components/table/StatusCell.tsx +++ b/frontend/src/components/table/StatusCell.tsx @@ -17,6 +17,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +import PauseCircleIcon from "@mui/icons-material/PauseCircle"; import { Chip } from "@mui/material"; import type { BuildStatus } from "models/BuildStatus"; import type React from "react"; @@ -24,10 +25,12 @@ import { StatusColors } from "theme/StatusColors"; interface StatusCellProps { status: BuildStatus; + isHeld?: boolean; } -export default function StatusCell({ status }: StatusCellProps): React.JSX.Element { +export default function StatusCell({ status, isHeld }: StatusCellProps): React.JSX.Element { return : undefined} label={status} size="small" sx={{ diff --git a/frontend/src/models/PackageRow.ts b/frontend/src/models/PackageRow.ts index 0012cea4..fb9db427 100644 --- a/frontend/src/models/PackageRow.ts +++ b/frontend/src/models/PackageRow.ts @@ -32,6 +32,7 @@ export class PackageRow { timestamp: string; timestampValue: number; status: BuildStatus; + isHeld: boolean; constructor(descriptor: PackageStatus) { this.id = descriptor.package.base; @@ -45,6 +46,7 @@ export class PackageRow { this.timestamp = new Date(descriptor.status.timestamp * 1000).toISOStringShort(); this.timestampValue = descriptor.status.timestamp; this.status = descriptor.status.status; + this.isHeld = descriptor.status.is_held ?? false; } private static extractListProperties(pkg: PackageStatus["package"], property: "groups" | "licenses"): string[] { diff --git a/frontend/src/models/Status.ts b/frontend/src/models/Status.ts index 01715863..78f6715f 100644 --- a/frontend/src/models/Status.ts +++ b/frontend/src/models/Status.ts @@ -22,4 +22,5 @@ import type { BuildStatus } from "models/BuildStatus"; export interface Status { status: BuildStatus; timestamp: number; + is_held?: boolean; } diff --git a/src/ahriman/application/handlers/hold.py b/src/ahriman/application/handlers/hold.py new file mode 100644 index 00000000..7b053d22 --- /dev/null +++ b/src/ahriman/application/handlers/hold.py @@ -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 . +# +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, + ] diff --git a/src/ahriman/core/database/migrations/m018_package_hold.py b/src/ahriman/core/database/migrations/m018_package_hold.py new file mode 100644 index 00000000..92528748 --- /dev/null +++ b/src/ahriman/core/database/migrations/m018_package_hold.py @@ -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 . +# +__all__ = ["steps"] + + +steps = [ + """alter table package_statuses add column is_held integer not null default 0""", +] diff --git a/src/ahriman/core/database/operations/package_operations.py b/src/ahriman/core/database/operations/package_operations.py index 13a96436..a1371a12 100644 --- a/src/ahriman/core/database/operations/package_operations.py +++ b/src/ahriman/core/database/operations/package_operations.py @@ -211,13 +211,37 @@ class PackageOperations(Operations): dict[str, BuildStatus]: map of the package base to its status """ 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( """select * from package_statuses where repository = :repository""", {"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: """ remove package from database diff --git a/src/ahriman/core/repository/update_handler.py b/src/ahriman/core/repository/update_handler.py index b1ef75bb..df27cc59 100644 --- a/src/ahriman/core/repository/update_handler.py +++ b/src/ahriman/core/repository/update_handler.py @@ -58,12 +58,17 @@ class UpdateHandler(PackageInfo, Cleaner): continue 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] = [] for local in self.packages(filter_packages): with self.in_package_context(local.base, local.version): if not local.remote.is_remote: 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 try: diff --git a/src/ahriman/core/status/client.py b/src/ahriman/core/status/client.py index a664033c..4ba7925f 100644 --- a/src/ahriman/core/status/client.py +++ b/src/ahriman/core/status/client.py @@ -199,6 +199,19 @@ class Client: """ 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: """ post log record diff --git a/src/ahriman/core/status/local_client.py b/src/ahriman/core/status/local_client.py index a445768c..1a4e7d26 100644 --- a/src/ahriman/core/status/local_client.py +++ b/src/ahriman/core/status/local_client.py @@ -42,7 +42,7 @@ class LocalClient(Client): """ Args: repository_id(RepositoryId): repository unique identifier - database(SQLite): database instance: + database(SQLite): database instance """ self.database = database self.repository_id = repository_id @@ -143,6 +143,16 @@ class LocalClient(Client): return packages 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: """ post log record diff --git a/src/ahriman/core/status/watcher.py b/src/ahriman/core/status/watcher.py index 8c9b0955..9e05efa0 100644 --- a/src/ahriman/core/status/watcher.py +++ b/src/ahriman/core/status/watcher.py @@ -18,6 +18,7 @@ # along with this program. If not, see . # from collections.abc import Callable +from dataclasses import replace from threading import Lock from typing import Any, Self @@ -129,6 +130,19 @@ class Watcher(LazyLogging): 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_remove: Callable[[str, str], None] diff --git a/src/ahriman/core/status/web_client.py b/src/ahriman/core/status/web_client.py index e2cd9d48..83b9dfb9 100644 --- a/src/ahriman/core/status/web_client.py +++ b/src/ahriman/core/status/web_client.py @@ -314,6 +314,18 @@ class WebClient(Client, SyncAhrimanClient): 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: """ post log record diff --git a/src/ahriman/models/build_status.py b/src/ahriman/models/build_status.py index cc4e176e..44798866 100644 --- a/src/ahriman/models/build_status.py +++ b/src/ahriman/models/build_status.py @@ -51,10 +51,12 @@ class BuildStatus: Attributes: status(BuildStatusEnum): build status timestamp(int): build status update time + is_held(bool | None): whether package held or not """ status: BuildStatusEnum = BuildStatusEnum.Unknown timestamp: int = field(default_factory=lambda: int(utcnow().timestamp())) + is_held: bool | None = field(default=None, kw_only=True) def __post_init__(self) -> None: """ @@ -83,7 +85,7 @@ class BuildStatus: Returns: 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]: """ @@ -94,5 +96,6 @@ class BuildStatus: """ return { "status": self.status.value, - "timestamp": self.timestamp + "timestamp": self.timestamp, + "is_held": self.is_held, } diff --git a/src/ahriman/web/schemas/__init__.py b/src/ahriman/web/schemas/__init__.py index b04d2621..8347f30f 100644 --- a/src/ahriman/web/schemas/__init__.py +++ b/src/ahriman/web/schemas/__init__.py @@ -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_search_schema import EventSearchSchema 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_v2_schema import InfoV2Schema from ahriman.web.schemas.internal_status_schema import InternalStatusSchema diff --git a/src/ahriman/web/schemas/hold_schema.py b/src/ahriman/web/schemas/hold_schema.py new file mode 100644 index 00000000..db1a61b2 --- /dev/null +++ b/src/ahriman/web/schemas/hold_schema.py @@ -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 . +# +from ahriman.web.apispec import Schema, fields + + +class HoldSchema(Schema): + """ + request hold schema + """ + + is_held = fields.Boolean(required=True, metadata={ + "description": "Package hold status", + }) diff --git a/src/ahriman/web/schemas/status_schema.py b/src/ahriman/web/schemas/status_schema.py index e75be001..882203a6 100644 --- a/src/ahriman/web/schemas/status_schema.py +++ b/src/ahriman/web/schemas/status_schema.py @@ -33,3 +33,6 @@ class StatusSchema(Schema): "description": "Last update timestamp", "example": 1680537091, }) + is_held = fields.Boolean(metadata={ + "description": "Package hold status", + }) diff --git a/src/ahriman/web/views/v1/packages/hold.py b/src/ahriman/web/views/v1/packages/hold.py new file mode 100644 index 00000000..0a342ce4 --- /dev/null +++ b/src/ahriman/web/views/v1/packages/hold.py @@ -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 . +# +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 diff --git a/tests/ahriman/application/handlers/test_handler_hold.py b/tests/ahriman/application/handlers/test_handler_hold.py new file mode 100644 index 00000000..48072cdd --- /dev/null +++ b/tests/ahriman/application/handlers/test_handler_hold.py @@ -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) diff --git a/tests/ahriman/application/test_ahriman.py b/tests/ahriman/application/test_ahriman.py index 25587b8b..ef3502c1 100644 --- a/tests/ahriman/application/test_ahriman.py +++ b/tests/ahriman/application/test_ahriman.py @@ -464,6 +464,30 @@ def test_subparsers_package_status_update_package_status_remove(parser: argparse 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: """ patch-add command must imply action, architecture list, exit code, lock, report and repository diff --git a/tests/ahriman/core/database/migrations/test_m018_package_hold.py b/tests/ahriman/core/database/migrations/test_m018_package_hold.py new file mode 100644 index 00000000..c49acebc --- /dev/null +++ b/tests/ahriman/core/database/migrations/test_m018_package_hold.py @@ -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 diff --git a/tests/ahriman/core/database/operations/test_package_operations.py b/tests/ahriman/core/database/operations/test_package_operations.py index 60c0327e..6cc1306c 100644 --- a/tests/ahriman/core/database/operations/test_package_operations.py +++ b/tests/ahriman/core/database/operations/test_package_operations.py @@ -103,6 +103,33 @@ def test_packages_get_select_statuses(database: SQLite, connection: Connection) 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: """ must totally remove package from the database @@ -156,8 +183,9 @@ def test_package_update_get(database: SQLite, package_ahriman: Package) -> None: status = BuildStatus() database.package_update(package_ahriman) database.status_update(package_ahriman.base, status) + expected = BuildStatus(status.status, status.timestamp, is_held=False) 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: @@ -189,7 +217,8 @@ def test_status_update(database: SQLite, package_ahriman: Package) -> None: database.package_update(package_ahriman) 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: diff --git a/tests/ahriman/core/repository/test_update_handler.py b/tests/ahriman/core/repository/test_update_handler.py index 1a458c99..ae39c5b1 100644 --- a/tests/ahriman/core/repository/test_update_handler.py +++ b/tests/ahriman/core/repository/test_update_handler.py @@ -6,7 +6,7 @@ from typing import Any from ahriman.core.exceptions import UnknownPackageError 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.event import EventType 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 """ - 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]) package_load_mock = mocker.patch("ahriman.models.package.Package.from_aur") diff --git a/tests/ahriman/core/status/test_client.py b/tests/ahriman/core/status/test_client.py index 0ac2cf08..8af1a20e 100644 --- a/tests/ahriman/core/status/test_client.py +++ b/tests/ahriman/core/status/test_client.py @@ -167,6 +167,14 @@ def test_package_get(client: Client, package_ahriman: Package) -> None: 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: """ must process log record addition without exception diff --git a/tests/ahriman/core/status/test_local_client.py b/tests/ahriman/core/status/test_local_client.py index d698f1f6..c06d7b28 100644 --- a/tests/ahriman/core/status/test_local_client.py +++ b/tests/ahriman/core/status/test_local_client.py @@ -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) +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, mocker: MockerFixture) -> None: """ diff --git a/tests/ahriman/core/status/test_watcher.py b/tests/ahriman/core/status/test_watcher.py index 7a0f4434..6d45f913 100644 --- a/tests/ahriman/core/status/test_watcher.py +++ b/tests/ahriman/core/status/test_watcher.py @@ -75,6 +75,27 @@ def test_package_get_failed(watcher: Watcher, package_ahriman: Package) -> None: 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: """ must remove package base diff --git a/tests/ahriman/core/status/test_web_client.py b/tests/ahriman/core/status/test_web_client.py index a9f22256..4c1a8a1e 100644 --- a/tests/ahriman/core/status/test_web_client.py +++ b/tests/ahriman/core/status/test_web_client.py @@ -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] +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, mocker: MockerFixture) -> None: """ diff --git a/tests/ahriman/models/test_build_status.py b/tests/ahriman/models/test_build_status.py index 16b9c348..fe996fae 100644 --- a/tests/ahriman/models/test_build_status.py +++ b/tests/ahriman/models/test_build_status.py @@ -46,9 +46,9 @@ def test_build_status_pretty_print(build_status_failed: BuildStatus) -> None: 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()) - assert other == build_status_failed + status = BuildStatus(BuildStatusEnum.Success, 42, is_held=True) + assert "(held)" in status.pretty_print() diff --git a/tests/ahriman/web/schemas/test_hold_schema.py b/tests/ahriman/web/schemas/test_hold_schema.py new file mode 100644 index 00000000..1982fb6b --- /dev/null +++ b/tests/ahriman/web/schemas/test_hold_schema.py @@ -0,0 +1 @@ +# schema testing goes in view class tests diff --git a/tests/ahriman/web/views/v1/packages/test_view_v1_packages_hold.py b/tests/ahriman/web/views/v1/packages/test_view_v1_packages_hold.py new file mode 100644 index 00000000..96cc3dd0 --- /dev/null +++ b/tests/ahriman/web/views/v1/packages/test_view_v1_packages_hold.py @@ -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())