feat: support archive listing

This commit is contained in:
2026-03-12 02:26:59 +02:00
parent 81aeb56ba3
commit a09ad7617d
28 changed files with 474 additions and 108 deletions

View File

@@ -12,6 +12,14 @@ ahriman.application.handlers.add module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.application.handlers.archives module
--------------------------------------------
.. automodule:: ahriman.application.handlers.archives
:members:
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.backup module ahriman.application.handlers.backup module
------------------------------------------ ------------------------------------------

View File

@@ -4,6 +4,14 @@ ahriman.web.views.v1.packages package
Submodules Submodules
---------- ----------
ahriman.web.views.v1.packages.archives module
---------------------------------------------
.. automodule:: ahriman.web.views.v1.packages.archives
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.views.v1.packages.changes module ahriman.web.views.v1.packages.changes module
-------------------------------------------- --------------------------------------------

View File

@@ -154,13 +154,13 @@ class Application(ApplicationPackages, ApplicationRepository):
for package_name, packager in missing.items(): for package_name, packager in missing.items():
if (source_dir := self.repository.paths.cache_for(package_name)).is_dir(): if (source_dir := self.repository.paths.cache_for(package_name)).is_dir():
# there is local cache, load package from it # there is local cache, load package from it
leaf = Package.from_build(source_dir, self.repository.architecture, packager) leaf = Package.from_build(source_dir, self.repository.repository_id.architecture, packager)
else: else:
leaf = Package.from_aur(package_name, packager, include_provides=True) leaf = Package.from_aur(package_name, packager, include_provides=True)
portion[leaf.base] = leaf portion[leaf.base] = leaf
# register package in the database # register package in the database
self.repository.reporter.set_unknown(leaf) self.reporter.set_unknown(leaf)
return portion return portion

View File

@@ -46,7 +46,7 @@ class ApplicationRepository(ApplicationProperties):
continue # skip check in case if we can't calculate diff continue # skip check in case if we can't calculate diff
if (changes := self.repository.package_changes(package, last_commit_sha)) is not None: if (changes := self.repository.package_changes(package, last_commit_sha)) is not None:
self.repository.reporter.package_changes_update(package.base, changes) self.reporter.package_changes_update(package.base, changes)
def clean(self, *, cache: bool, chroot: bool, manual: bool, packages: bool, pacman: bool) -> None: def clean(self, *, cache: bool, chroot: bool, manual: bool, packages: bool, pacman: bool) -> None:
""" """

View File

@@ -0,0 +1,81 @@
#
# 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.core.formatters import PackagePrinter
from ahriman.models.action import Action
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.repository_id import RepositoryId
class Archives(Handler):
"""
package archives handler
"""
ALLOW_MULTI_ARCHITECTURE_RUN = False # conflicting io
@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
"""
application = Application(repository_id, configuration, report=True)
match args.action:
case Action.List:
archives = application.repository.package_archives(args.package)
for package in archives:
PackagePrinter(package, BuildStatus(BuildStatusEnum.Success))(verbose=args.info)
Archives.check_status(args.exit_code, bool(archives))
@staticmethod
def _set_package_archives_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for package archives subcommand
Args:
root(SubParserAction): subparsers for the commands
Returns:
argparse.ArgumentParser: created argument parser
"""
parser = root.add_parser("package-archives", help="list package archive versions",
description="list available archive versions for the package")
parser.add_argument("package", help="package base")
parser.add_argument("-e", "--exit-code", help="return non-zero exit status if result is empty",
action="store_true")
parser.add_argument("--info", help="show additional package information",
action=argparse.BooleanOptionalAction, default=False)
parser.set_defaults(action=Action.List, lock=None, quiet=True, report=False, unsafe=True)
return parser
arguments = [_set_package_archives_parser]

View File

@@ -47,8 +47,7 @@ class Change(Handler):
configuration(Configuration): configuration instance configuration(Configuration): configuration instance
report(bool): force enable or disable reporting report(bool): force enable or disable reporting
""" """
application = Application(repository_id, configuration, report=True) client = Application(repository_id, configuration, report=True).reporter
client = application.repository.reporter
match args.action: match args.action:
case Action.List: case Action.List:

View File

@@ -48,8 +48,7 @@ class Pkgbuild(Handler):
configuration(Configuration): configuration instance configuration(Configuration): configuration instance
report(bool): force enable or disable reporting report(bool): force enable or disable reporting
""" """
application = Application(repository_id, configuration, report=True) client = Application(repository_id, configuration, report=True).reporter
client = application.repository.reporter
match args.action: match args.action:
case Action.List: case Action.List:

View File

@@ -44,8 +44,7 @@ class Reload(Handler):
configuration(Configuration): configuration instance configuration(Configuration): configuration instance
report(bool): force enable or disable reporting report(bool): force enable or disable reporting
""" """
application = Application(repository_id, configuration, report=True) client = Application(repository_id, configuration, report=True).reporter
client = application.repository.reporter
client.configuration_reload() client.configuration_reload()
@staticmethod @staticmethod

View File

@@ -52,7 +52,7 @@ class Status(Handler):
report(bool): force enable or disable reporting report(bool): force enable or disable reporting
""" """
# we are using reporter here # we are using reporter here
client = Application(repository_id, configuration, report=True).repository.reporter client = Application(repository_id, configuration, report=True).reporter
if args.ahriman: if args.ahriman:
service_status = client.status_get() service_status = client.status_get()
StatusPrinter(service_status.status)(verbose=args.info) StatusPrinter(service_status.status)(verbose=args.info)

View File

@@ -47,8 +47,7 @@ class StatusUpdate(Handler):
configuration(Configuration): configuration instance configuration(Configuration): configuration instance
report(bool): force enable or disable reporting report(bool): force enable or disable reporting
""" """
application = Application(repository_id, configuration, report=True) client = Application(repository_id, configuration, report=True).reporter
client = application.repository.reporter
match args.action: match args.action:
case Action.Update if args.package: case Action.Update if args.package:

View File

@@ -61,12 +61,11 @@ class Executor(PackageInfo, Cleaner):
if built.version != package.version: if built.version != package.version:
continue continue
packages = built.packages.values()
# all packages must be either any or same architecture # all packages must be either any or same architecture
if not all(single.architecture in ("any", self.architecture) for single in packages): if not built.supports_architecture(self.repository_id.architecture):
continue continue
return list_flatmap(packages, lambda single: archive.glob(f"{single.filename}*")) return list_flatmap(built.packages.values(), lambda single: archive.glob(f"{single.filename}*"))
return [] return []
@@ -102,11 +101,11 @@ class Executor(PackageInfo, Cleaner):
""" """
self.reporter.set_building(package.base) self.reporter.set_building(package.base)
task = Task(package, self.configuration, self.architecture, self.paths) task = Task(package, self.configuration, self.repository_id.architecture, self.paths)
patches = self.reporter.package_patches_get(package.base, None) patches = self.reporter.package_patches_get(package.base, None)
commit_sha = task.init(path, patches, local_version) commit_sha = task.init(path, patches, local_version)
loaded_package = Package.from_build(path, self.architecture, None) loaded_package = Package.from_build(path, self.repository_id.architecture, None)
if prebuilt := list(self._archive_lookup(loaded_package)): if prebuilt := list(self._archive_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 = []
@@ -218,7 +217,7 @@ class Executor(PackageInfo, Cleaner):
except Exception: except Exception:
self.reporter.set_failed(single.base) self.reporter.set_failed(single.base)
result.add_failed(single) result.add_failed(single)
self.logger.exception("%s (%s) build exception", single.base, self.architecture) self.logger.exception("%s (%s) build exception", single.base, self.repository_id.architecture)
return result return result

View File

@@ -33,6 +33,7 @@ from ahriman.core.status import Client
from ahriman.core.utils import package_like from ahriman.core.utils import 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
class PackageInfo(LazyLogging): class PackageInfo(LazyLogging):
@@ -43,11 +44,13 @@ class PackageInfo(LazyLogging):
configuration(Configuration): configuration instance configuration(Configuration): configuration instance
pacman(Pacman): alpm wrapper instance pacman(Pacman): alpm wrapper instance
reporter(Client): build status reporter instance reporter(Client): build status reporter instance
repository_id(RepositoryId): repository unique identifier
""" """
configuration: Configuration configuration: Configuration
pacman: Pacman pacman: Pacman
reporter: Client reporter: Client
repository_id: RepositoryId
def full_depends(self, package: Package, packages: Iterable[Package]) -> list[str]: def full_depends(self, package: Package, packages: Iterable[Package]) -> list[str]:
""" """
@@ -133,6 +136,8 @@ class PackageInfo(LazyLogging):
# 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, 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):
continue
packages.setdefault((local.base, local.version), local).packages.update(local.packages) packages.setdefault((local.base, local.version), local).packages.update(local.packages)
comparator: Callable[[Package, Package], int] = lambda left, right: left.vercmp(right.version) comparator: Callable[[Package, Package], int] = lambda left, right: left.vercmp(right.version)

View File

@@ -72,32 +72,12 @@ class RepositoryProperties(EventLogger, LazyLogging):
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.name, self.paths, self.sign.repository_sign_args) self.repo = Repo(self.repository_id.name, self.paths, self.sign.repository_sign_args)
self.reporter = Client.load(repository_id, configuration, database, report=report) self.reporter = Client.load(repository_id, configuration, database, report=report)
self.triggers = TriggerLoader.load(repository_id, configuration) self.triggers = TriggerLoader.load(repository_id, configuration)
self.scan_paths = ScanPaths(configuration.getlist("build", "scan_paths", fallback=[])) self.scan_paths = ScanPaths(configuration.getlist("build", "scan_paths", fallback=[]))
@property
def architecture(self) -> str:
"""
repository architecture for backward compatibility
Returns:
str: repository architecture
"""
return self.repository_id.architecture
@property
def name(self) -> str:
"""
repository name for backward compatibility
Returns:
str: repository name
"""
return self.repository_id.name
def packager(self, packagers: Packagers, package_base: str) -> User: def packager(self, packagers: Packagers, package_base: str) -> User:
""" """
extract packager from configuration having username extract packager from configuration having username

View File

@@ -150,7 +150,7 @@ class UpdateHandler(PackageInfo, Cleaner):
) )
Sources.fetch(cache_dir, source) Sources.fetch(cache_dir, source)
remote = Package.from_build(cache_dir, self.architecture, None) remote = Package.from_build(cache_dir, self.repository_id.architecture, None)
local = packages.get(remote.base) local = packages.get(remote.base)
if local is None: if local is None:

View File

@@ -23,6 +23,7 @@ from typing import Any, Self
from ahriman.core.exceptions import UnknownPackageError from ahriman.core.exceptions import UnknownPackageError
from ahriman.core.log import LazyLogging from ahriman.core.log import LazyLogging
from ahriman.core.repository.package_info import PackageInfo
from ahriman.core.status import Client from ahriman.core.status import Client
from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
@@ -39,15 +40,18 @@ class Watcher(LazyLogging):
Attributes: Attributes:
client(Client): reporter instance client(Client): reporter instance
package_info(PackageInfo): package info instance
status(BuildStatus): daemon status status(BuildStatus): daemon status
""" """
def __init__(self, client: Client) -> None: def __init__(self, client: Client, package_info: PackageInfo) -> None:
""" """
Args: Args:
client(Client): reporter instance client(Client): reporter instance
package_info(PackageInfo): package info instance
""" """
self.client = client self.client = client
self.package_info = package_info
self._lock = Lock() self._lock = Lock()
self._known: dict[str, tuple[Package, BuildStatus]] = {} self._known: dict[str, tuple[Package, BuildStatus]] = {}
@@ -80,6 +84,18 @@ class Watcher(LazyLogging):
logs_rotate: Callable[[int], None] logs_rotate: Callable[[int], None]
def package_archives(self, package_base: str) -> list[Package]:
"""
get known package archives
Args:
package_base(str): package base
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_get: Callable[[str], Changes]
package_changes_update: Callable[[str, Changes], None] package_changes_update: Callable[[str, Changes], None]

View File

@@ -137,16 +137,6 @@ class Package(LazyLogging):
""" """
return list_flatmap(self.packages.values(), lambda package: package.groups) return list_flatmap(self.packages.values(), lambda package: package.groups)
@property
def is_single_package(self) -> bool:
"""
is it possible to transform package base to single package or not
Returns:
bool: true in case if this base has only one package with the same name
"""
return self.base in self.packages and len(self.packages) == 1
@property @property
def is_vcs(self) -> bool: def is_vcs(self) -> bool:
""" """
@@ -375,9 +365,22 @@ class Package(LazyLogging):
Returns: Returns:
str: print-friendly string str: print-friendly string
""" """
details = "" if self.is_single_package else f" ({" ".join(sorted(self.packages.keys()))})" is_single_package = self.base in self.packages and len(self.packages) == 1
details = "" if is_single_package else f" ({" ".join(sorted(self.packages.keys()))})"
return f"{self.base}{details}" return f"{self.base}{details}"
def supports_architecture(self, architecture: str) -> bool:
"""
helper to check if the package belongs to the specified architecture
Args:
architecture(str): probe repository architecture
Returns:
bool: ``True`` if all packages are same architecture or any
"""
return all(single.architecture in ("any", architecture) for single in self.packages.values())
def vercmp(self, version: str) -> int: def vercmp(self, version: str) -> int:
""" """
typed wrapper around :func:`pyalpm.vercmp()` typed wrapper around :func:`pyalpm.vercmp()`

View File

@@ -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 <http://www.gnu.org/licenses/>.
#
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 archives
Returns:
Response: 200 with package archives on success
Raises:
HTTPNotFound: if no package was found
"""
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])

View File

@@ -23,12 +23,14 @@ import logging
import socket import socket
from aiohttp.web import Application, normalize_path_middleware, run_app from aiohttp.web import Application, normalize_path_middleware, run_app
from pathlib import Path
from ahriman.core.auth import Auth from ahriman.core.auth import Auth
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.database import SQLite from ahriman.core.database import SQLite
from ahriman.core.distributed import WorkersCache from ahriman.core.distributed import WorkersCache
from ahriman.core.exceptions import InitializeError from ahriman.core.exceptions import InitializeError
from ahriman.core.repository.package_info import PackageInfo
from ahriman.core.spawn import Spawn from ahriman.core.spawn import Spawn
from ahriman.core.status import Client from ahriman.core.status import Client
from ahriman.core.status.watcher import Watcher from ahriman.core.status.watcher import Watcher
@@ -78,6 +80,34 @@ def _create_socket(configuration: Configuration, application: Application) -> so
return sock 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
package_info.repository_id = repository_id
return Watcher(client, package_info)
async def _on_shutdown(application: Application) -> None: async def _on_shutdown(application: Application) -> None:
""" """
web application shutdown handler web application shutdown handler
@@ -168,18 +198,11 @@ def setup_server(configuration: Configuration, spawner: Spawn, repositories: lis
# package cache # package cache
if not repositories: if not repositories:
raise InitializeError("No repositories configured, exiting") raise InitializeError("No repositories configured, exiting")
watchers: dict[RepositoryId, Watcher] = {}
configuration_path, _ = configuration.check_loaded() configuration_path, _ = configuration.check_loaded()
for repository_id in repositories: application[WatcherKey] = {
application.logger.info("load repository %s", repository_id) repository_id: _create_watcher(configuration_path, repository_id)
# load settings explicitly for architecture if any for repository_id in repositories
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
# workers cache # workers cache
application[WorkersKey] = WorkersCache(configuration) application[WorkersKey] = WorkersCache(configuration)
# process spawner # process spawner

View File

@@ -0,0 +1,84 @@
import argparse
import pytest
from pytest_mock import MockerFixture
from ahriman.application.handlers.archives import Archives
from ahriman.core.configuration import Configuration
from ahriman.core.database import SQLite
from ahriman.core.repository import Repository
from ahriman.models.action import Action
from ahriman.models.package import Package
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.action = Action.List
args.exit_code = False
args.info = False
args.package = "package"
return args
def test_run(args: argparse.Namespace, configuration: Configuration, repository: Repository,
package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must run command
"""
args = _default_args(args)
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
application_mock = mocker.patch("ahriman.core.repository.package_info.PackageInfo.package_archives",
return_value=[package_ahriman])
check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_status")
print_mock = mocker.patch("ahriman.core.formatters.Printer.print")
_, repository_id = configuration.check_loaded()
Archives.run(args, repository_id, configuration, report=False)
application_mock.assert_called_once_with(args.package)
check_mock.assert_called_once_with(False, True)
print_mock.assert_called_once_with(verbose=False, log_fn=pytest.helpers.anyvar(int), separator=": ")
def test_run_empty_exception(args: argparse.Namespace, configuration: Configuration, repository: Repository,
mocker: MockerFixture) -> None:
"""
must raise ExitCode exception on empty archives result
"""
args = _default_args(args)
args.exit_code = True
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
mocker.patch("ahriman.core.repository.package_info.PackageInfo.package_archives", return_value=[])
check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_status")
_, repository_id = configuration.check_loaded()
Archives.run(args, repository_id, configuration, report=False)
check_mock.assert_called_once_with(True, False)
def test_imply_with_report(args: argparse.Namespace, configuration: Configuration, database: SQLite,
mocker: MockerFixture) -> None:
"""
must create application object with native reporting
"""
args = _default_args(args)
mocker.patch("ahriman.core.database.SQLite.load", return_value=database)
load_mock = mocker.patch("ahriman.core.repository.Repository.load")
_, repository_id = configuration.check_loaded()
Archives.run(args, repository_id, configuration, report=False)
load_mock.assert_called_once_with(repository_id, configuration, database, report=True, refresh_pacman_database=0)
def test_disallow_multi_architecture_run() -> None:
"""
must not allow multi architecture run
"""
assert not Archives.ALLOW_MULTI_ARCHITECTURE_RUN

View File

@@ -271,6 +271,22 @@ def test_subparsers_package_add_option_variable_multiple(parser: argparse.Argume
assert args.variable == ["var1", "var2"] assert args.variable == ["var1", "var2"]
def test_subparsers_package_archives(parser: argparse.ArgumentParser) -> None:
"""
package-archives command must imply action, exit code, info, lock, quiet, report and unsafe
"""
args = parser.parse_args(["-a", "x86_64", "-r", "repo", "package-archives", "ahriman"])
assert args.action == Action.List
assert args.architecture == "x86_64"
assert not args.exit_code
assert not args.info
assert args.lock is None
assert args.quiet
assert not args.report
assert args.repository == "repo"
assert args.unsafe
def test_subparsers_package_changes(parser: argparse.ArgumentParser) -> None: def test_subparsers_package_changes(parser: argparse.ArgumentParser) -> None:
""" """
package-changes command must imply action, exit code, lock, quiet, report and unsafe package-changes command must imply action, exit code, lock, quiet, report and unsafe

View File

@@ -16,6 +16,7 @@ from ahriman.core.database import SQLite
from ahriman.core.database.migrations import Migrations from ahriman.core.database.migrations import Migrations
from ahriman.core.log.log_loader import LogLoader from ahriman.core.log.log_loader import LogLoader
from ahriman.core.repository import Repository from ahriman.core.repository import Repository
from ahriman.core.repository.package_info import PackageInfo
from ahriman.core.spawn import Spawn from ahriman.core.spawn import Spawn
from ahriman.core.status import Client from ahriman.core.status import Client
from ahriman.core.status.watcher import Watcher from ahriman.core.status.watcher import Watcher
@@ -688,4 +689,5 @@ def watcher(local_client: Client) -> Watcher:
Returns: Returns:
Watcher: package status watcher test instance Watcher: package status watcher test instance
""" """
return Watcher(local_client) package_info = PackageInfo()
return Watcher(local_client, package_info)

View File

@@ -11,6 +11,7 @@ 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
@@ -56,7 +57,7 @@ def test_archive_lookup_architecture_mismatch(executor: Executor, package_ahrima
""" """
package_ahriman.packages[package_ahriman.base].architecture = "x86_64" package_ahriman.packages[package_ahriman.base].architecture = "x86_64"
mocker.patch("pathlib.Path.is_dir", return_value=True) mocker.patch("pathlib.Path.is_dir", return_value=True)
mocker.patch("ahriman.core.repository.executor.Executor.architecture", return_value="i686") executor.repository_id = RepositoryId("i686", executor.repository_id.name)
mocker.patch("pathlib.Path.iterdir", return_value=[ mocker.patch("pathlib.Path.iterdir", return_value=[
Path("1.pkg.tar.zst"), Path("1.pkg.tar.zst"),
]) ])
@@ -116,7 +117,7 @@ def test_package_build(executor: Executor, package_ahriman: Package, mocker: Moc
assert executor._package_build(package_ahriman, Path("local"), "packager", None) == "sha" assert executor._package_build(package_ahriman, Path("local"), "packager", None) == "sha"
status_client_mock.assert_called_once_with(package_ahriman.base) status_client_mock.assert_called_once_with(package_ahriman.base)
init_mock.assert_called_once_with(pytest.helpers.anyvar(int), pytest.helpers.anyvar(int), None) init_mock.assert_called_once_with(pytest.helpers.anyvar(int), pytest.helpers.anyvar(int), None)
package_mock.assert_called_once_with(Path("local"), executor.architecture, None) package_mock.assert_called_once_with(Path("local"), executor.repository_id.architecture, None)
lookup_mock.assert_called_once_with(package_ahriman) lookup_mock.assert_called_once_with(package_ahriman)
with_packages_mock.assert_called_once_with([Path(package_ahriman.base)]) with_packages_mock.assert_called_once_with([Path(package_ahriman.base)])
rename_mock.assert_called_once_with(Path(package_ahriman.base), executor.paths.packages / package_ahriman.base) rename_mock.assert_called_once_with(Path(package_ahriman.base), executor.paths.packages / package_ahriman.base)

View File

@@ -1,5 +1,6 @@
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 unittest.mock import MagicMock from unittest.mock import MagicMock
@@ -95,26 +96,30 @@ def test_package_archives(repository: Repository, package_ahriman: Package, mock
""" """
must load package archives sorted by version must load package archives sorted by version
""" """
from dataclasses import replace
from typing import Any
def package(version: Any, *args: Any, **kwargs: Any) -> Package:
generated = replace(package_ahriman, version=str(version))
generated.packages = {
key: replace(value, filename=str(version))
for key, value in generated.packages.items()
}
return generated
mocker.patch("ahriman.core.repository.package_info.package_like", return_value=True) mocker.patch("ahriman.core.repository.package_info.package_like", return_value=True)
mocker.patch("pathlib.Path.iterdir", return_value=[Path(str(i)) for i in range(5)]) mocker.patch("pathlib.Path.iterdir", return_value=[str(i) for i in range(5)])
mocker.patch("ahriman.models.package.Package.from_archive", side_effect=package) mocker.patch("ahriman.models.package.Package.from_archive",
side_effect=lambda version: replace(package_ahriman, version=version))
result = repository.package_archives(package_ahriman.base) result = repository.package_archives(package_ahriman.base)
assert len(result) == 5 assert len(result) == 5
assert [p.version for p in result] == [str(i) for i in range(5)] assert [p.version for p in result] == [str(i) for i in range(5)]
def test_package_archives_architecture_mismatch(repository: Repository, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must skip packages with mismatched architecture
"""
package_ahriman.packages[package_ahriman.base].architecture = "i686"
mocker.patch("pathlib.Path.iterdir", return_value=[package_ahriman.packages[package_ahriman.base].filepath])
mocker.patch("ahriman.models.package.Package.from_archive", return_value=package_ahriman)
result = repository.package_archives(package_ahriman.base)
assert len(result) == 0
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,20 +6,6 @@ from ahriman.models.user import User
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
def test_architecture(repository: RepositoryProperties) -> None:
"""
must provide repository architecture for backward compatibility
"""
assert repository.architecture == repository.repository_id.architecture
def test_name(repository: RepositoryProperties) -> None:
"""
must provide repository name for backward compatibility
"""
assert repository.name == repository.repository_id.name
def test_packager(repository: RepositoryProperties, mocker: MockerFixture) -> None: def test_packager(repository: RepositoryProperties, mocker: MockerFixture) -> None:
""" """
must extract packager must extract packager

View File

@@ -45,6 +45,18 @@ def test_load_known(watcher: Watcher, package_ahriman: Package, mocker: MockerFi
assert status.status == BuildStatusEnum.Success assert status.status == BuildStatusEnum.Success
def test_package_archives(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must return package archives from package info
"""
archives_mock = 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]
archives_mock.assert_called_once_with(package_ahriman.base)
def test_package_get(watcher: Watcher, package_ahriman: Package) -> None: def test_package_get(watcher: Watcher, package_ahriman: Package) -> None:
""" """
must return package status must return package status

View File

@@ -101,20 +101,6 @@ def test_groups(package_ahriman: Package) -> None:
assert sorted(package_ahriman.groups) == package_ahriman.groups assert sorted(package_ahriman.groups) == package_ahriman.groups
def test_is_single_package_false(package_python_schedule: Package) -> None:
"""
python-schedule must not be single package
"""
assert not package_python_schedule.is_single_package
def test_is_single_package_true(package_ahriman: Package) -> None:
"""
ahriman must be single package
"""
assert package_ahriman.is_single_package
def test_is_vcs_false(package_ahriman: Package) -> None: def test_is_vcs_false(package_ahriman: Package) -> None:
""" """
ahriman must not be VCS package ahriman must not be VCS package
@@ -353,6 +339,30 @@ def test_build_status_pretty_print(package_ahriman: Package) -> None:
assert isinstance(package_ahriman.pretty_print(), str) assert isinstance(package_ahriman.pretty_print(), str)
def test_supports_architecture(package_ahriman: Package) -> None:
"""
must check if package supports architecture
"""
package_ahriman.packages[package_ahriman.base].architecture = "x86_64"
assert package_ahriman.supports_architecture("x86_64")
def test_supports_architecture_any(package_ahriman: Package) -> None:
"""
must support any architecture
"""
package_ahriman.packages[package_ahriman.base].architecture = "any"
assert package_ahriman.supports_architecture("x86_64")
def test_supports_architecture_mismatch(package_ahriman: Package) -> None:
"""
must not support mismatched architecture
"""
package_ahriman.packages[package_ahriman.base].architecture = "i686"
assert not package_ahriman.supports_architecture("x86_64")
def test_vercmp(package_ahriman: Package, mocker: MockerFixture) -> None: def test_vercmp(package_ahriman: Package, mocker: MockerFixture) -> None:
""" """
must call vercmp must call vercmp

View File

@@ -10,7 +10,7 @@ from ahriman.core.exceptions import InitializeError
from ahriman.core.spawn import Spawn from ahriman.core.spawn import Spawn
from ahriman.core.status.watcher import Watcher from ahriman.core.status.watcher import Watcher
from ahriman.web.keys import ConfigurationKey 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: 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: def test_setup_no_repositories(configuration: Configuration, spawner: Spawn) -> None:
""" """
must raise InitializeError if no repositories set must raise InitializeError if no repositories set

View File

@@ -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())