diff --git a/src/ahriman/core/status/watcher.py b/src/ahriman/core/status/watcher.py index e341b2a0..7d2f6502 100644 --- a/src/ahriman/core/status/watcher.py +++ b/src/ahriman/core/status/watcher.py @@ -23,6 +23,7 @@ from typing import Any, Self from ahriman.core.exceptions import UnknownPackageError from ahriman.core.log import LazyLogging +from ahriman.core.repository.package_info import PackageInfo from ahriman.core.status import Client from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.changes import Changes @@ -39,15 +40,18 @@ class Watcher(LazyLogging): Attributes: client(Client): reporter instance + package_info(PackageInfo): package info instance status(BuildStatus): daemon status """ - def __init__(self, client: Client) -> None: + def __init__(self, client: Client, package_info: PackageInfo) -> None: """ Args: client(Client): reporter instance + package_info(PackageInfo): package info instance """ self.client = client + self.package_info = package_info self._lock = Lock() self._known: dict[str, tuple[Package, BuildStatus]] = {} @@ -80,6 +84,15 @@ class Watcher(LazyLogging): logs_rotate: Callable[[int], None] + def package_archives(self, package_base: str) -> list[Package]: + """ + get known package archives + + Returns: + list[Package]: list of built package for this package base + """ + return self.package_info.package_archives(package_base) + package_changes_get: Callable[[str], Changes] package_changes_update: Callable[[str, Changes], None] diff --git a/src/ahriman/web/views/v1/packages/archives.py b/src/ahriman/web/views/v1/packages/archives.py new file mode 100644 index 00000000..684d6332 --- /dev/null +++ b/src/ahriman/web/views/v1/packages/archives.py @@ -0,0 +1,65 @@ +# +# 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 Response +from typing import ClassVar + +from ahriman.models.user_access import UserAccess +from ahriman.web.apispec.decorators import apidocs +from ahriman.web.schemas import PackageNameSchema, PackageSchema, RepositoryIdSchema +from ahriman.web.views.base import BaseView +from ahriman.web.views.status_view_guard import StatusViewGuard + + +class Archives(StatusViewGuard, BaseView): + """ + package archives web view + + Attributes: + GET_PERMISSION(UserAccess): (class attribute) get permissions of self + """ + + GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Reporter + ROUTES = ["/api/v1/packages/{package}/archives"] + + @apidocs( + tags=["Packages"], + summary="Get package archives", + description="Retrieve built package archives for the base", + permission=GET_PERMISSION, + error_404_description="Package base and/or repository are unknown", + schema=PackageSchema(many=True), + match_schema=PackageNameSchema, + query_schema=RepositoryIdSchema, + ) + async def get(self) -> Response: + """ + get package changes + + Returns: + Response: 200 with package change on success + + Raises: + HTTPNotFound: if package base is unknown + """ + package_base = self.request.match_info["package"] + + archives = self.service(package_base=package_base).package_archives(package_base) + + return self.json_response([archive.view() for archive in archives]) diff --git a/src/ahriman/web/web.py b/src/ahriman/web/web.py index 348ab66f..f2ff732f 100644 --- a/src/ahriman/web/web.py +++ b/src/ahriman/web/web.py @@ -23,12 +23,14 @@ import logging import socket from aiohttp.web import Application, normalize_path_middleware, run_app +from pathlib import Path from ahriman.core.auth import Auth from ahriman.core.configuration import Configuration from ahriman.core.database import SQLite from ahriman.core.distributed import WorkersCache from ahriman.core.exceptions import InitializeError +from ahriman.core.repository.package_info import PackageInfo from ahriman.core.spawn import Spawn from ahriman.core.status import Client from ahriman.core.status.watcher import Watcher @@ -78,6 +80,33 @@ def _create_socket(configuration: Configuration, application: Application) -> so return sock +def _create_watcher(path: Path, repository_id: RepositoryId) -> Watcher: + """ + build watcher for selected repository + + Args: + path(Path): path to configuration file + repository_id(RepositoryId): repository unique identifier + + Returns: + Watcher: watcher instance + """ + logging.getLogger(__name__).info("load repository %s", repository_id) + # load settings explicitly for architecture if any + configuration = Configuration.from_path(path, repository_id) + + # load database instance, because it holds identifier + database = SQLite.load(configuration) + # explicitly load local client + client = Client.load(repository_id, configuration, database, report=False) + + # load package info wrapper + package_info = PackageInfo() + package_info.configuration = configuration + + return Watcher(client, package_info) + + async def _on_shutdown(application: Application) -> None: """ web application shutdown handler @@ -168,18 +197,11 @@ def setup_server(configuration: Configuration, spawner: Spawn, repositories: lis # package cache if not repositories: raise InitializeError("No repositories configured, exiting") - watchers: dict[RepositoryId, Watcher] = {} configuration_path, _ = configuration.check_loaded() - for repository_id in repositories: - application.logger.info("load repository %s", repository_id) - # load settings explicitly for architecture if any - repository_configuration = Configuration.from_path(configuration_path, repository_id) - # load database instance, because it holds identifier - database = SQLite.load(repository_configuration) - # explicitly load local client - client = Client.load(repository_id, repository_configuration, database, report=False) - watchers[repository_id] = Watcher(client) - application[WatcherKey] = watchers + application[WatcherKey] = { + repository_id: _create_watcher(configuration_path, repository_id) + for repository_id in repositories + } # workers cache application[WorkersKey] = WorkersCache(configuration) # process spawner diff --git a/tests/ahriman/conftest.py b/tests/ahriman/conftest.py index 6c2c0975..56fadf19 100644 --- a/tests/ahriman/conftest.py +++ b/tests/ahriman/conftest.py @@ -16,6 +16,7 @@ from ahriman.core.database import SQLite from ahriman.core.database.migrations import Migrations from ahriman.core.log.log_loader import LogLoader from ahriman.core.repository import Repository +from ahriman.core.repository.package_info import PackageInfo from ahriman.core.spawn import Spawn from ahriman.core.status import Client from ahriman.core.status.watcher import Watcher @@ -688,4 +689,5 @@ def watcher(local_client: Client) -> Watcher: Returns: Watcher: package status watcher test instance """ - return Watcher(local_client) + package_info = PackageInfo() + return Watcher(local_client, package_info) diff --git a/tests/ahriman/core/status/test_watcher.py b/tests/ahriman/core/status/test_watcher.py index ff99d3da..96dbcf4c 100644 --- a/tests/ahriman/core/status/test_watcher.py +++ b/tests/ahriman/core/status/test_watcher.py @@ -8,6 +8,16 @@ from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.package import Package +def test_package_archives(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None: + """ + must return package archives from package info + """ + mocker.patch("ahriman.core.repository.package_info.PackageInfo.package_archives", return_value=[package_ahriman]) + + result = watcher.package_archives(package_ahriman.base) + assert result == [package_ahriman] + + def test_packages(watcher: Watcher, package_ahriman: Package) -> None: """ must return list of available packages diff --git a/tests/ahriman/web/test_web.py b/tests/ahriman/web/test_web.py index bbdc76a2..3bc04267 100644 --- a/tests/ahriman/web/test_web.py +++ b/tests/ahriman/web/test_web.py @@ -10,7 +10,7 @@ from ahriman.core.exceptions import InitializeError from ahriman.core.spawn import Spawn from ahriman.core.status.watcher import Watcher from ahriman.web.keys import ConfigurationKey -from ahriman.web.web import _create_socket, _on_shutdown, _on_startup, run_server, setup_server +from ahriman.web.web import _create_socket, _create_watcher, _on_shutdown, _on_startup, run_server, setup_server async def test_create_socket(application: Application, mocker: MockerFixture) -> None: @@ -139,6 +139,20 @@ def test_run_with_socket(application: Application, mocker: MockerFixture) -> Non ) +def test_create_watcher(configuration: Configuration, mocker: MockerFixture) -> None: + """ + must create watcher for repository + """ + database_mock = mocker.patch("ahriman.core.database.SQLite.load") + client_mock = mocker.patch("ahriman.core.status.Client.load") + configuration_path, repository_id = configuration.check_loaded() + + result = _create_watcher(configuration_path, repository_id) + assert isinstance(result, Watcher) + database_mock.assert_called_once() + client_mock.assert_called_once() + + def test_setup_no_repositories(configuration: Configuration, spawner: Spawn) -> None: """ must raise InitializeError if no repositories set diff --git a/tests/ahriman/web/views/v1/packages/test_view_v1_packages_archives.py b/tests/ahriman/web/views/v1/packages/test_view_v1_packages_archives.py new file mode 100644 index 00000000..fa7f6b62 --- /dev/null +++ b/tests/ahriman/web/views/v1/packages/test_view_v1_packages_archives.py @@ -0,0 +1,52 @@ +import pytest + +from aiohttp.test_utils import TestClient +from pytest_mock import MockerFixture + +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.archives import Archives + + +async def test_get_permission() -> None: + """ + must return correct permission for the request + """ + for method in ("GET",): + request = pytest.helpers.request("", "", method) + assert await Archives.get_permission(request) == UserAccess.Reporter + + +def test_routes() -> None: + """ + must return correct routes + """ + assert Archives.ROUTES == ["/api/v1/packages/{package}/archives"] + + +async def test_get(client: TestClient, package_ahriman: Package, mocker: MockerFixture) -> None: + """ + must get archives for package + """ + await client.post(f"/api/v1/packages/{package_ahriman.base}", + json={"status": BuildStatusEnum.Success.value, "package": package_ahriman.view()}) + mocker.patch("ahriman.core.status.watcher.Watcher.package_archives", return_value=[package_ahriman]) + response_schema = pytest.helpers.schema_response(Archives.get) + + response = await client.get(f"/api/v1/packages/{package_ahriman.base}/archives") + assert response.status == 200 + + archives = await response.json() + assert not response_schema.validate(archives) + + +async def test_get_not_found(client: TestClient, package_ahriman: Package) -> None: + """ + must return not found for missing package + """ + response_schema = pytest.helpers.schema_response(Archives.get, code=404) + + response = await client.get(f"/api/v1/packages/{package_ahriman.base}/archives") + assert response.status == 404 + assert not response_schema.validate(await response.json())