Compare commits

..

17 Commits

Author SHA1 Message Date
e6df656ce3 add archive trigger 2025-08-14 13:33:04 +03:00
c89f6ad98c feat: use atexit instead of del for triggers 2025-08-14 13:33:04 +03:00
c734f0815a add archive trigger 2025-08-05 16:43:07 +03:00
dc3cee9449 lookup through archive packages before build 2025-08-05 12:43:16 +03:00
0b2acfac9b use generic packages tree for all repos 2025-08-01 16:53:28 +03:00
c5d849d6a6 implement atomic_move method, move files only with lock 2025-08-01 16:53:28 +03:00
63ccb5fc11 write tests to support new changes 2025-08-01 16:53:28 +03:00
04e554d096 store built packages in archive tree instead of repository 2025-08-01 16:53:28 +03:00
10798b9ba3 fix: correctly process trigger repo specific settings in validator (see #154) 2025-08-01 16:53:15 +03:00
358e3dc4d2 feat: expose repository name and architecure in configuration if available
In some cases there are reference to current repository settings. In
order to handle it correctly two ro options have been added

Related to #154
2025-07-31 14:14:22 +03:00
c13cd029bc feat: fully readable configuration from environment 2025-07-23 14:49:38 +03:00
ae32cc8fbb type: use custom comparable for comparable functions 2025-07-15 21:20:49 +03:00
dff5b775a9 refactor: move logs rotation to separated trigger which is enabled by default
Previous solution, well, worked kinda fine-ish, though we have much
better mechanisms to do so
2025-07-15 11:26:00 +03:00
db3f20546e fix: do not update datalist if search substring hasn't changed 2025-07-14 21:30:27 +03:00
53368468a4 fix: block autoupdate on any modal opened 2025-07-14 21:12:33 +03:00
228c2cce51 style: use parebtgeses-less exceptions in side effects (tests only) 2025-07-14 20:33:54 +03:00
f5aec4e5c1 fix: fix search result sorting based if there is exact match or
starts with (closes #152)
2025-07-14 01:12:27 +03:00
93 changed files with 1917 additions and 436 deletions

View File

@ -165,6 +165,11 @@ Again, the most checks can be performed by `tox` command, though some additional
# Blank line again and package imports # Blank line again and package imports
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
# Multiline import example
from ahriman.core.database.operations import (
AuthOperations,
BuildOperations,
)
``` ```
* One file should define only one class, exception is class satellites in case if file length remains less than 400 lines. * One file should define only one class, exception is class satellites in case if file length remains less than 400 lines.

View File

@ -132,6 +132,14 @@ ahriman.core.database.migrations.m015\_logs\_process\_id module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.core.database.migrations.m016\_archive module
-----------------------------------------------------
.. automodule:: ahriman.core.database.migrations.m016_archive
:members:
:no-undoc-members:
:show-inheritance:
Module contents Module contents
--------------- ---------------

View File

@ -0,0 +1,29 @@
ahriman.core.housekeeping package
=================================
Submodules
----------
ahriman.core.housekeeping.archive\_rotation\_trigger module
-----------------------------------------------------------
.. automodule:: ahriman.core.housekeeping.archive_rotation_trigger
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.housekeeping.logs\_rotation\_trigger module
--------------------------------------------------------
.. automodule:: ahriman.core.housekeeping.logs_rotation_trigger
:members:
:no-undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: ahriman.core.housekeeping
:members:
:no-undoc-members:
:show-inheritance:

View File

@ -15,6 +15,7 @@ Subpackages
ahriman.core.distributed ahriman.core.distributed
ahriman.core.formatters ahriman.core.formatters
ahriman.core.gitremote ahriman.core.gitremote
ahriman.core.housekeeping
ahriman.core.http ahriman.core.http
ahriman.core.log ahriman.core.log
ahriman.core.report ahriman.core.report

View File

@ -40,6 +40,7 @@ This package contains everything required for the most of application actions an
* ``ahriman.core.distributed`` package with triggers and helpers for distributed build system. * ``ahriman.core.distributed`` package with triggers and helpers for distributed build system.
* ``ahriman.core.formatters`` package provides ``Printer`` sub-classes for printing data (e.g. package properties) to stdout which are used by some handlers. * ``ahriman.core.formatters`` package provides ``Printer`` sub-classes for printing data (e.g. package properties) to stdout which are used by some handlers.
* ``ahriman.core.gitremote`` is a package with remote PKGBUILD triggers. Should not be called directly. * ``ahriman.core.gitremote`` is a package with remote PKGBUILD triggers. Should not be called directly.
* ``ahriman.core.housekeeping`` package provides few triggers for removing old data.
* ``ahriman.core.http`` package provides HTTP clients which can be used later by other classes. * ``ahriman.core.http`` package provides HTTP clients which can be used later by other classes.
* ``ahriman.core.log`` is a log utils package. It includes logger loader class, custom HTTP based logger and some wrappers. * ``ahriman.core.log`` is a log utils package. It includes logger loader class, custom HTTP based logger and some wrappers.
* ``ahriman.core.report`` is a package with reporting triggers. Should not be called directly. * ``ahriman.core.report`` is a package with reporting triggers. Should not be called directly.

View File

@ -65,6 +65,8 @@ will try to read value from ``SECRET`` environment variable. In case if the requ
will eventually lead ``key`` option in section ``section1`` to be set to the value of ``HOME`` environment variable (if available). will eventually lead ``key`` option in section ``section1`` to be set to the value of ``HOME`` environment variable (if available).
Moreover, configuration can be read from environment variables directly by following the same naming convention, e.g. in the example above, one can have environment variable named ``section1:key`` (e.g. ``section1:key=$HOME``) and it will be substituted to the configuration with the highest priority.
There is also additional subcommand which will allow to validate configuration and print found errors. In order to do so, run ``service-config-validate`` subcommand, e.g.: There is also additional subcommand which will allow to validate configuration and print found errors. In order to do so, run ``service-config-validate`` subcommand, e.g.:
.. code-block:: shell .. code-block:: shell
@ -81,7 +83,6 @@ Base configuration settings.
* ``apply_migrations`` - perform database migrations on the application start, boolean, optional, default ``yes``. Useful if you are using git version. Note, however, that this option must be changed only if you know what to do and going to handle migrations manually. * ``apply_migrations`` - perform database migrations on the application start, boolean, optional, default ``yes``. Useful if you are using git version. Note, however, that this option must be changed only if you know what to do and going to handle migrations manually.
* ``database`` - path to the application SQLite database, string, required. * ``database`` - path to the application SQLite database, string, required.
* ``include`` - path to directory with configuration files overrides, string, optional. Files will be read in alphabetical order. * ``include`` - path to directory with configuration files overrides, string, optional. Files will be read in alphabetical order.
* ``keep_last_logs`` - amount of build logs to be kept for each package, integer, optional ,default ``0``. Logs will be cleared at the end of each process.
* ``logging`` - path to logging configuration, string, required. Check ``logging.ini`` for reference. * ``logging`` - path to logging configuration, string, required. Check ``logging.ini`` for reference.
``alpm:*`` groups ``alpm:*`` groups
@ -96,6 +97,13 @@ libalpm and AUR related configuration. Group name can refer to architecture, e.g
* ``sync_files_database`` - download files database from mirror, boolean, required. * ``sync_files_database`` - download files database from mirror, boolean, required.
* ``use_ahriman_cache`` - use local pacman package cache instead of system one, boolean, required. With this option enabled you might want to refresh database periodically (available as additional flag for some subcommands). If set to ``no``, databases must be synchronized manually. * ``use_ahriman_cache`` - use local pacman package cache instead of system one, boolean, required. With this option enabled you might want to refresh database periodically (available as additional flag for some subcommands). If set to ``no``, databases must be synchronized manually.
``archive`` group
-----------------
Describes settings for packages archives management extensions.
* ``keep_built_packages`` - keep this amount of built packages with different versions, integer, required. ``0`` (or negative number) will effectively disable archives removal.
``auth`` group ``auth`` group
-------------- --------------
@ -138,6 +146,8 @@ Build related configuration. Group name can refer to architecture, e.g. ``build:
Base repository settings. Base repository settings.
* ``architecture`` - repository architecture, string. This field is read-only and generated automatically from run options if possible.
* ``name`` - repository name, string. This field is read-only and generated automatically from run options if possible.
* ``root`` - root path for application, string, required. * ``root`` - root path for application, string, required.
``sign:*`` groups ``sign:*`` groups
@ -180,7 +190,7 @@ Web server settings. This feature requires ``aiohttp`` libraries to be installed
* ``wait_timeout`` - wait timeout in seconds, maximum amount of time to be waited before lock will be free, integer, optional. * ``wait_timeout`` - wait timeout in seconds, maximum amount of time to be waited before lock will be free, integer, optional.
``keyring`` group ``keyring`` group
-------------------- -----------------
Keyring package generator plugin. Keyring package generator plugin.
@ -198,6 +208,13 @@ Keyring generator plugin
* ``revoked`` - list of revoked packagers keys, space separated list of strings, optional. * ``revoked`` - list of revoked packagers keys, space separated list of strings, optional.
* ``trusted`` - list of master keys, space separated list of strings, optional, if not set, the ``key`` option from ``sign`` group will be used. * ``trusted`` - list of master keys, space separated list of strings, optional, if not set, the ``key`` option from ``sign`` group will be used.
``housekeeping`` group
----------------------
This section describes settings for the ``ahriman.core.housekeeping.LogsRotationTrigger`` plugin.
* ``keep_last_logs`` - amount of build logs to be kept for each package, integer, optional ,default ``0``. Logs will be cleared at the end of each process.
``mirrorlist`` group ``mirrorlist`` group
-------------------- --------------------

View File

@ -40,6 +40,7 @@ package_ahriman-core() {
'rsync: sync by using rsync') 'rsync: sync by using rsync')
install="$pkgbase.install" install="$pkgbase.install"
backup=('etc/ahriman.ini' backup=('etc/ahriman.ini'
'etc/ahriman.ini.d/00-housekeeping.ini'
'etc/ahriman.ini.d/logging.ini') 'etc/ahriman.ini.d/logging.ini')
cd "$pkgbase-$pkgver" cd "$pkgbase-$pkgver"
@ -49,6 +50,7 @@ package_ahriman-core() {
# keep usr/share configs as reference and copy them to /etc # keep usr/share configs as reference and copy them to /etc
install -Dm644 "$pkgdir/usr/share/$pkgbase/settings/ahriman.ini" "$pkgdir/etc/ahriman.ini" install -Dm644 "$pkgdir/usr/share/$pkgbase/settings/ahriman.ini" "$pkgdir/etc/ahriman.ini"
install -Dm644 "$pkgdir/usr/share/$pkgbase/settings/ahriman.ini.d/00-housekeeping.ini" "$pkgdir/etc/ahriman.ini.d/00-housekeeping.ini"
install -Dm644 "$pkgdir/usr/share/$pkgbase/settings/ahriman.ini.d/logging.ini" "$pkgdir/etc/ahriman.ini.d/logging.ini" install -Dm644 "$pkgdir/usr/share/$pkgbase/settings/ahriman.ini.d/logging.ini" "$pkgdir/etc/ahriman.ini.d/logging.ini"
install -Dm644 "$srcdir/$pkgbase.sysusers" "$pkgdir/usr/lib/sysusers.d/$pkgbase.conf" install -Dm644 "$srcdir/$pkgbase.sysusers" "$pkgdir/usr/lib/sysusers.d/$pkgbase.conf"

View File

@ -7,8 +7,6 @@ logging = ahriman.ini.d/logging.ini
;apply_migrations = yes ;apply_migrations = yes
; Path to the application SQLite database. ; Path to the application SQLite database.
database = ${repository:root}/ahriman.db database = ${repository:root}/ahriman.db
; Keep last build logs for each package
keep_last_logs = 5
[alpm] [alpm]
; Path to pacman system database cache. ; Path to pacman system database cache.
@ -45,9 +43,13 @@ triggers[] = ahriman.core.gitremote.RemotePullTrigger
triggers[] = ahriman.core.report.ReportTrigger triggers[] = ahriman.core.report.ReportTrigger
triggers[] = ahriman.core.upload.UploadTrigger triggers[] = ahriman.core.upload.UploadTrigger
triggers[] = ahriman.core.gitremote.RemotePushTrigger triggers[] = ahriman.core.gitremote.RemotePushTrigger
triggers[] = ahriman.core.housekeeping.LogsRotationTrigger
triggers[] = ahriman.core.housekeeping.ArchiveRotationTrigger
; List of well-known triggers. Used only for configuration purposes. ; List of well-known triggers. Used only for configuration purposes.
triggers_known[] = ahriman.core.gitremote.RemotePullTrigger triggers_known[] = ahriman.core.gitremote.RemotePullTrigger
triggers_known[] = ahriman.core.gitremote.RemotePushTrigger triggers_known[] = ahriman.core.gitremote.RemotePushTrigger
triggers_known[] = ahriman.core.housekeeping.ArchiveRotationTrigger
triggers_known[] = ahriman.core.housekeeping.LogsRotationTrigger
triggers_known[] = ahriman.core.report.ReportTrigger triggers_known[] = ahriman.core.report.ReportTrigger
triggers_known[] = ahriman.core.upload.UploadTrigger triggers_known[] = ahriman.core.upload.UploadTrigger
; Maximal age in seconds of the VCS packages before their version will be updated with its remote source. ; Maximal age in seconds of the VCS packages before their version will be updated with its remote source.

View File

@ -0,0 +1,7 @@
[archive]
; Keep amount of last built packages in archive. 0 means keep all packages
keep_built_packages = 1
[logs-rotation]
; Keep last build logs for each package
keep_last_logs = 5

View File

@ -1,5 +1,6 @@
[build] [build]
; List of well-known triggers. Used only for configuration purposes. ; List of well-known triggers. Used only for configuration purposes.
triggers_known[] = ahriman.core.archive.ArchiveTrigger
triggers_known[] = ahriman.core.distributed.WorkerLoaderTrigger triggers_known[] = ahriman.core.distributed.WorkerLoaderTrigger
triggers_known[] = ahriman.core.distributed.WorkerTrigger triggers_known[] = ahriman.core.distributed.WorkerTrigger
triggers_known[] = ahriman.core.support.KeyringTrigger triggers_known[] = ahriman.core.support.KeyringTrigger

View File

@ -148,8 +148,19 @@
packageAddInput.addEventListener("keyup", _ => { packageAddInput.addEventListener("keyup", _ => {
clearTimeout(packageAddInput.requestTimeout); clearTimeout(packageAddInput.requestTimeout);
packageAddInput.requestTimeout = setTimeout(_ => {
// do not update datalist if search string didn't change yet
const value = packageAddInput.value; const value = packageAddInput.value;
const previousValue = packageAddInput.dataset.previousValue;
if (value === previousValue) {
return;
}
// store current search string in attributes
packageAddInput.dataset.previousValue = value;
// perform data list update
packageAddInput.requestTimeout = setTimeout(_ => {
if (value.length >= 3) { if (value.length >= 3) {
makeRequest( makeRequest(

View File

@ -164,7 +164,7 @@
function toggleTableAutoReload(interval) { function toggleTableAutoReload(interval) {
clearInterval(tableAutoReloadTask); clearInterval(tableAutoReloadTask);
tableAutoReloadTask = toggleAutoReload(tableAutoReloadButton, interval, tableAutoReloadInput, _ => { tableAutoReloadTask = toggleAutoReload(tableAutoReloadButton, interval, tableAutoReloadInput, _ => {
if (!dashboardModal.classList.contains("show") && if (!hasActiveModal() &&
!hasActiveDropdown()) { !hasActiveDropdown()) {
packagesLoad(); packagesLoad();
statusLoad(); statusLoad();

View File

@ -68,6 +68,11 @@
.some(el => el.classList.contains("show")); .some(el => el.classList.contains("show"));
} }
function hasActiveModal() {
return Array.from(document.querySelectorAll(".modal"))
.some(el => el.classList.contains("show"));
}
function headerClass(status) { function headerClass(status) {
if (status === "pending") return ["bg-warning"]; if (status === "pending") return ["bg-warning"];
if (status === "building") return ["bg-warning"]; if (status === "building") return ["bg-warning"];

View File

@ -28,6 +28,7 @@ from ahriman.core.alpm.remote import AUR, Official
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import OptionError from ahriman.core.exceptions import OptionError
from ahriman.core.formatters import AurPrinter from ahriman.core.formatters import AurPrinter
from ahriman.core.types import Comparable
from ahriman.models.aur_package import AURPackage from ahriman.models.aur_package import AURPackage
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
@ -115,7 +116,7 @@ class Search(Handler):
raise OptionError(sort_by) raise OptionError(sort_by)
# always sort by package name at the last # always sort by package name at the last
# well technically it is not a string, but we can deal with it # well technically it is not a string, but we can deal with it
comparator: Callable[[AURPackage], tuple[str, str]] =\ comparator: Callable[[AURPackage], Comparable] = \
lambda package: (getattr(package, sort_by), package.name) lambda package: (getattr(package, sort_by), package.name)
return sorted(packages, key=comparator) return sorted(packages, key=comparator)

View File

@ -25,6 +25,7 @@ from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler, SubParserAction from ahriman.application.handlers.handler import Handler, SubParserAction
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.formatters import PackagePrinter, StatusPrinter from ahriman.core.formatters import PackagePrinter, StatusPrinter
from ahriman.core.types import Comparable
from ahriman.core.utils import enum_values from ahriman.core.utils import enum_values
from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.package import Package from ahriman.models.package import Package
@ -64,8 +65,8 @@ class Status(Handler):
Status.check_status(args.exit_code, packages) Status.check_status(args.exit_code, packages)
comparator: Callable[[tuple[Package, BuildStatus]], str] = lambda item: item[0].base comparator: Callable[[tuple[Package, BuildStatus]], Comparable] = lambda item: item[0].base
filter_fn: Callable[[tuple[Package, BuildStatus]], bool] =\ filter_fn: Callable[[tuple[Package, BuildStatus]], bool] = \
lambda item: args.status is None or item[1].status == args.status lambda item: args.status is None or item[1].status == args.status
for package, package_status in sorted(filter(filter_fn, packages), key=comparator): for package, package_status in sorted(filter(filter_fn, packages), key=comparator):
PackagePrinter(package, package_status)(verbose=args.info) PackagePrinter(package, package_status)(verbose=args.info)

View File

@ -21,6 +21,7 @@ import argparse
from ahriman.application.handlers.handler import Handler, SubParserAction from ahriman.application.handlers.handler import Handler, SubParserAction
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.utils import walk
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
from ahriman.models.repository_paths import RepositoryPaths from ahriman.models.repository_paths import RepositoryPaths
@ -49,6 +50,7 @@ class TreeMigrate(Handler):
target_tree.tree_create() target_tree.tree_create()
# perform migration # perform migration
TreeMigrate.tree_move(current_tree, target_tree) TreeMigrate.tree_move(current_tree, target_tree)
TreeMigrate.fix_symlinks(target_tree)
@staticmethod @staticmethod
def _set_service_tree_migrate_parser(root: SubParserAction) -> argparse.ArgumentParser: def _set_service_tree_migrate_parser(root: SubParserAction) -> argparse.ArgumentParser:
@ -66,6 +68,22 @@ class TreeMigrate(Handler):
parser.set_defaults(lock=None, quiet=True, report=False) parser.set_defaults(lock=None, quiet=True, report=False)
return parser return parser
@staticmethod
def fix_symlinks(paths: RepositoryPaths) -> None:
"""
fix packages archives symlinks
Args:
paths(RepositoryPaths): new repository paths
"""
archives = {path.name: path for path in walk(paths.archive)}
for symlink in walk(paths.repository):
if symlink.exists(): # no need to check for symlinks as we have just walked through the tree
continue
if (source_archive := archives.get(symlink.name)) is not None:
symlink.unlink()
symlink.symlink_to(source_archive.relative_to(symlink.parent, walk_up=True))
@staticmethod @staticmethod
def tree_move(from_tree: RepositoryPaths, to_tree: RepositoryPaths) -> None: def tree_move(from_tree: RepositoryPaths, to_tree: RepositoryPaths) -> None:
""" """

View File

@ -52,7 +52,7 @@ class Validate(Handler):
""" """
from ahriman.core.configuration.validator import Validator from ahriman.core.configuration.validator import Validator
schema = Validate.schema(repository_id, configuration) schema = Validate.schema(configuration)
validator = Validator(configuration=configuration, schema=schema) validator = Validator(configuration=configuration, schema=schema)
if validator.validate(configuration.dump()): if validator.validate(configuration.dump()):
@ -83,12 +83,11 @@ class Validate(Handler):
return parser return parser
@staticmethod @staticmethod
def schema(repository_id: RepositoryId, configuration: Configuration) -> ConfigurationSchema: def schema(configuration: Configuration) -> ConfigurationSchema:
""" """
get schema with triggers get schema with triggers
Args: Args:
repository_id(RepositoryId): repository unique identifier
configuration(Configuration): configuration instance configuration(Configuration): configuration instance
Returns: Returns:
@ -107,12 +106,12 @@ class Validate(Handler):
continue continue
# default settings if any # default settings if any
for schema_name, schema in trigger_class.configuration_schema(repository_id, None).items(): for schema_name, schema in trigger_class.configuration_schema(None).items():
erased = Validate.schema_erase_required(copy.deepcopy(schema)) erased = Validate.schema_erase_required(copy.deepcopy(schema))
root[schema_name] = Validate.schema_merge(root.get(schema_name, {}), erased) root[schema_name] = Validate.schema_merge(root.get(schema_name, {}), erased)
# settings according to enabled triggers # settings according to enabled triggers
for schema_name, schema in trigger_class.configuration_schema(repository_id, configuration).items(): for schema_name, schema in trigger_class.configuration_schema(configuration).items():
root[schema_name] = Validate.schema_merge(root.get(schema_name, {}), copy.deepcopy(schema)) root[schema_name] = Validate.schema_merge(root.get(schema_name, {}), copy.deepcopy(schema))
return root return root

View File

@ -31,20 +31,21 @@ class Repo(LazyLogging):
Attributes: Attributes:
name(str): repository name name(str): repository name
paths(RepositoryPaths): repository paths instance root(Path): repository root
sign_args(list[str]): additional args which have to be used to sign repository archive sign_args(list[str]): additional args which have to be used to sign repository archive
uid(int): uid of the repository owner user uid(int): uid of the repository owner user
""" """
def __init__(self, name: str, paths: RepositoryPaths, sign_args: list[str]) -> None: def __init__(self, name: str, paths: RepositoryPaths, sign_args: list[str], root: Path | None = None) -> None:
""" """
Args: Args:
name(str): repository name name(str): repository name
paths(RepositoryPaths): repository paths instance paths(RepositoryPaths): repository paths instance
sign_args(list[str]): additional args which have to be used to sign repository archive sign_args(list[str]): additional args which have to be used to sign repository archive
root(Path | None, optional): repository root. If none set, the default will be used (Default value = None)
""" """
self.name = name self.name = name
self.paths = paths self.root = root or paths.repository
self.uid, _ = paths.root_owner self.uid, _ = paths.root_owner
self.sign_args = sign_args self.sign_args = sign_args
@ -56,45 +57,56 @@ class Repo(LazyLogging):
Returns: Returns:
Path: path to repository database Path: path to repository database
""" """
return self.paths.repository / f"{self.name}.db.tar.gz" return self.root / f"{self.name}.db.tar.gz"
def add(self, path: Path) -> None: def add(self, path: Path, *, remove: bool = True) -> None:
""" """
add new package to repository add new package to repository
Args: Args:
path(Path): path to archive to add path(Path): path to archive to add
remove(bool, optional): whether to remove old packages or not (Default value = True)
""" """
command = ["repo-add", *self.sign_args]
if remove:
command.extend(["--remove"])
command.extend([str(self.repo_path), str(path)])
# add to repository
check_output( check_output(
"repo-add", *self.sign_args, "-R", str(self.repo_path), str(path), *command,
exception=BuildError.from_process(path.name), exception=BuildError.from_process(path.name),
cwd=self.paths.repository, cwd=self.root,
logger=self.logger, logger=self.logger,
user=self.uid) user=self.uid,
)
def init(self) -> None: def init(self) -> None:
""" """
create empty repository database. It just calls add with empty arguments create empty repository database. It just calls add with empty arguments
""" """
check_output("repo-add", *self.sign_args, str(self.repo_path), check_output("repo-add", *self.sign_args, str(self.repo_path),
cwd=self.paths.repository, logger=self.logger, user=self.uid) cwd=self.root, logger=self.logger, user=self.uid)
def remove(self, package: str, filename: Path) -> None: def remove(self, package_name: str | None, filename: Path) -> None:
""" """
remove package from repository remove package from repository
Args: Args:
package(str): package name to remove package_name(str | None): package name to remove. If none set, it will be guessed from filename
filename(Path): package filename to remove filename(Path): package filename to remove
""" """
package_name = package_name or filename.name.rsplit("-", maxsplit=3)[0]
# remove package and signature (if any) from filesystem # remove package and signature (if any) from filesystem
for full_path in self.paths.repository.glob(f"{filename}*"): for full_path in self.root.glob(f"**/{filename.name}*"):
full_path.unlink() full_path.unlink()
# remove package from registry # remove package from registry
check_output( check_output(
"repo-remove", *self.sign_args, str(self.repo_path), package, "repo-remove", *self.sign_args, str(self.repo_path), package_name,
exception=BuildError.from_process(package), exception=BuildError.from_process(package_name),
cwd=self.paths.repository, cwd=self.root,
logger=self.logger, logger=self.logger,
user=self.uid) user=self.uid,
)

View File

@ -0,0 +1,20 @@
#
# Copyright (c) 2021-2025 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from ahriman.core.archive.archive_trigger import ArchiveTrigger

View File

@ -0,0 +1,127 @@
#
# Copyright (c) 2021-2025 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 datetime
from pathlib import Path
from ahriman.core.alpm.repo import Repo
from ahriman.core.log import LazyLogging
from ahriman.core.utils import utcnow
from ahriman.models.package import Package
from ahriman.models.repository_paths import RepositoryPaths
class ArchiveTree(LazyLogging):
"""
wrapper around archive tree
Attributes:
paths(RepositoryPaths): repository paths instance
repository_id(RepositoryId): repository unique identifier
sign_args(list[str]): additional args which have to be used to sign repository archive
"""
def __init__(self, repository_path: RepositoryPaths, sign_args: list[str]) -> None:
"""
Args:
repository_path(RepositoryPaths): repository paths instance
sign_args(list[str]): additional args which have to be used to sign repository archive
"""
self.paths = repository_path
self.repository_id = repository_path.repository_id
self.sign_args = sign_args
def repository_for(self, date: datetime.date | None = None) -> Path:
"""
get full path to repository at the specified date
Args:
date(datetime.date | None, optional): date to generate path. If none supplied then today will be used
Returns:
Path: path to the repository root
"""
date = date or utcnow().today()
return (
self.paths.archive
/ "repos"
/ date.strftime("%Y")
/ date.strftime("%m")
/ date.strftime("%d")
/ self.repository_id.name
/ self.repository_id.architecture
)
def symlinks_create(self, packages: list[Package]) -> None:
"""
create symlinks for the specified packages in today's repository
Args:
packages(list[Package]): list of packages to be updated
"""
root = self.repository_for()
repo = Repo(self.repository_id.name, self.paths, self.sign_args, root)
for package in packages:
archive = self.paths.archive_for(package.base)
for package_name, single in package.packages.items():
if single.filename is None:
self.logger.warning("received empty package filename for %s", package_name)
continue
has_file = False
for file in archive.glob(f"{single.filename}*"):
if not (symlink := root / file.name).exists():
has_file |= True
symlink.symlink_to(file.relative_to(symlink.parent, walk_up=True))
if has_file:
repo.add(root / single.filename)
def symlinks_fix(self) -> None:
"""
remove broken symlinks across all repositories
"""
for root, _, files in self.paths.archive.walk():
*_, name, architecture = root.parts
if self.repository_id.name != name or self.repository_id.architecture != architecture:
continue # we only process same name repositories
for file in files:
path = root / file
if not path.is_symlink():
continue # find symlinks only
if path.exists():
continue # filter out not broken symlinks
repo = Repo(self.repository_id.name, self.paths, self.sign_args, root)
repo.remove(None, path)
def tree_create(self) -> None:
"""
create repository tree for current repository
"""
path = self.repository_for()
if path.exists():
return
with self.paths.preserve_owner(self.paths.archive):
path.mkdir(0o755, parents=True)

View File

@ -0,0 +1,71 @@
#
# Copyright (c) 2021-2025 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from ahriman.core import context
from ahriman.core.archive.archive_tree import ArchiveTree
from ahriman.core.configuration import Configuration
from ahriman.core.sign.gpg import GPG
from ahriman.core.triggers import Trigger
from ahriman.models.package import Package
from ahriman.models.repository_id import RepositoryId
from ahriman.models.result import Result
class ArchiveTrigger(Trigger):
"""
archive repository extension
Attributes:
paths(RepositoryPaths): repository paths instance
"""
def __init__(self, repository_id: RepositoryId, configuration: Configuration) -> None:
"""
Args:
repository_id(RepositoryId): repository unique identifier
configuration(Configuration): configuration instance
"""
Trigger.__init__(self, repository_id, configuration)
self.paths = configuration.repository_paths
ctx = context.get()
self.tree = ArchiveTree(self.paths, ctx.get(GPG).repository_sign_args)
def on_result(self, result: Result, packages: list[Package]) -> None:
"""
run trigger
Args:
result(Result): build result
packages(list[Package]): list of all available packages
"""
self.tree.symlinks_create(packages)
def on_start(self) -> None:
"""
trigger action which will be called at the start of the application
"""
self.tree.tree_create()
def on_stop(self) -> None:
"""
trigger action which will be called before the stop of the application
"""
self.tree.symlinks_fix()

View File

@ -19,6 +19,7 @@
# #
# pylint: disable=too-many-public-methods # pylint: disable=too-many-public-methods
import configparser import configparser
import os
import shlex import shlex
import sys import sys
@ -42,7 +43,6 @@ class Configuration(configparser.RawConfigParser):
SYSTEM_CONFIGURATION_PATH(Path): (class attribute) default system configuration path distributed by package SYSTEM_CONFIGURATION_PATH(Path): (class attribute) default system configuration path distributed by package
includes(list[Path]): list of includes which were read includes(list[Path]): list of includes which were read
path(Path | None): path to root configuration file path(Path | None): path to root configuration file
repository_id(RepositoryId | None): repository unique identifier
Examples: Examples:
Configuration class provides additional method in order to handle application configuration. Since this class is Configuration class provides additional method in order to handle application configuration. Since this class is
@ -93,7 +93,7 @@ class Configuration(configparser.RawConfigParser):
}, },
) )
self.repository_id: RepositoryId | None = None self._repository_id: RepositoryId | None = None
self.path: Path | None = None self.path: Path | None = None
self.includes: list[Path] = [] self.includes: list[Path] = []
@ -128,6 +128,32 @@ class Configuration(configparser.RawConfigParser):
""" """
return self.getpath("settings", "logging") return self.getpath("settings", "logging")
@property
def repository_id(self) -> RepositoryId | None:
"""
repository identifier
Returns:
RepositoryId: repository unique identifier
"""
return self._repository_id
@repository_id.setter
def repository_id(self, repository_id: RepositoryId | None) -> None:
"""
setter for repository identifier
Args:
repository_id(RepositoryId | None): repository unique identifier
"""
self._repository_id = repository_id
if repository_id is None or repository_id.is_empty:
self.remove_option("repository", "name")
self.remove_option("repository", "architecture")
else:
self.set_option("repository", "name", repository_id.name)
self.set_option("repository", "architecture", repository_id.architecture)
@property @property
def repository_name(self) -> str: def repository_name(self) -> str:
""" """
@ -164,6 +190,7 @@ class Configuration(configparser.RawConfigParser):
""" """
configuration = cls() configuration = cls()
configuration.load(path) configuration.load(path)
configuration.load_environment()
configuration.merge_sections(repository_id) configuration.merge_sections(repository_id)
return configuration return configuration
@ -288,6 +315,16 @@ class Configuration(configparser.RawConfigParser):
self.read(self.path) self.read(self.path)
self.load_includes() # load includes self.load_includes() # load includes
def load_environment(self) -> None:
"""
load environment variables into configuration
"""
for name, value in os.environ.items():
if ":" not in name:
continue
section, key = name.rsplit(":", maxsplit=1)
self.set_option(section, key, value)
def load_includes(self, path: Path | None = None) -> None: def load_includes(self, path: Path | None = None) -> None:
""" """
load configuration includes from specified path load configuration includes from specified path
@ -356,11 +393,16 @@ class Configuration(configparser.RawConfigParser):
""" """
reload configuration if possible or raise exception otherwise reload configuration if possible or raise exception otherwise
""" """
# get current properties and validate input
path, repository_id = self.check_loaded() path, repository_id = self.check_loaded()
for section in self.sections(): # clear current content
# clear current content
for section in self.sections():
self.remove_section(section) self.remove_section(section)
self.load(path)
self.merge_sections(repository_id) # create another instance and copy values from there
instance = self.from_path(path, repository_id)
self.copy_from(instance)
def set_option(self, section: str, option: str, value: str) -> None: def set_option(self, section: str, option: str, value: str) -> None:
""" """

View File

@ -45,11 +45,6 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
"path_exists": True, "path_exists": True,
"path_type": "dir", "path_type": "dir",
}, },
"keep_last_logs": {
"type": "integer",
"coerce": "integer",
"min": 0,
},
"logging": { "logging": {
"type": "path", "type": "path",
"coerce": "absolute_path", "coerce": "absolute_path",
@ -254,6 +249,10 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
"repository": { "repository": {
"type": "dict", "type": "dict",
"schema": { "schema": {
"architecture": {
"type": "string",
"empty": False,
},
"name": { "name": {
"type": "string", "type": "string",
"empty": False, "empty": False,

View File

@ -0,0 +1,84 @@
#
# Copyright (c) 2021-2025 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 dataclasses import replace
from sqlite3 import Connection
from ahriman.application.handlers.handler import Handler
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.configuration import Configuration
from ahriman.models.package import Package
from ahriman.models.pacman_synchronization import PacmanSynchronization
from ahriman.models.repository_paths import RepositoryPaths
__all__ = ["migrate_data"]
def migrate_data(connection: Connection, configuration: Configuration) -> None:
"""
perform data migration
Args:
connection(Connection): database connection
configuration(Configuration): configuration instance
"""
del connection
config_path, _ = configuration.check_loaded()
args = argparse.Namespace(configuration=config_path, architecture=None, repository=None, repository_id=None)
for repository_id in Handler.repositories_extract(args):
paths = replace(configuration.repository_paths, repository_id=repository_id)
pacman = Pacman(repository_id, configuration, refresh_database=PacmanSynchronization.Disabled)
# create archive directory if required
if not paths.archive.is_dir():
with paths.preserve_owner(paths.archive):
paths.archive.mkdir(mode=0o755, parents=True)
move_packages(paths, pacman)
def move_packages(repository_paths: RepositoryPaths, pacman: Pacman) -> None:
"""
move packages from repository to archive and create symbolic links
Args:
repository_paths(RepositoryPaths): repository paths instance
pacman(Pacman): alpm wrapper instance
"""
for source in repository_paths.repository.iterdir():
if not source.is_file(follow_symlinks=False):
continue # skip symbolic links if any
filename = source.name
if filename.startswith(".") or ".pkg." not in filename:
# we don't use package_like method here, because it also filters out signatures
continue
package = Package.from_archive(source, pacman)
# move package to the archive directory
target = repository_paths.archive_for(package.base) / filename
source.rename(target)
# create symlink to the archive
source.symlink_to(target.relative_to(source.parent, walk_up=True))

View File

@ -25,8 +25,16 @@ from typing import Self
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.database.migrations import Migrations from ahriman.core.database.migrations import Migrations
from ahriman.core.database.operations import AuthOperations, BuildOperations, ChangesOperations, \ from ahriman.core.database.operations import (
DependenciesOperations, EventOperations, LogsOperations, PackageOperations, PatchOperations AuthOperations,
BuildOperations,
ChangesOperations,
DependenciesOperations,
EventOperations,
LogsOperations,
PackageOperations,
PatchOperations,
)
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId

View File

@ -0,0 +1,21 @@
#
# Copyright (c) 2021-2025 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from ahriman.core.housekeeping.archive_rotation_trigger import ArchiveRotationTrigger
from ahriman.core.housekeeping.logs_rotation_trigger import LogsRotationTrigger

View File

@ -0,0 +1,115 @@
#
# Copyright (c) 2021-2025 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 collections.abc import Callable
from functools import cmp_to_key
from ahriman.core import context
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.configuration import Configuration
from ahriman.core.triggers import Trigger
from ahriman.core.utils import package_like
from ahriman.models.package import Package
from ahriman.models.repository_id import RepositoryId
from ahriman.models.result import Result
class ArchiveRotationTrigger(Trigger):
"""
remove packages from archive
Attributes:
keep_built_packages(int): number of last packages to keep
paths(RepositoryPaths): repository paths instance
"""
CONFIGURATION_SCHEMA = {
"archive": {
"type": "dict",
"schema": {
"keep_built_packages": {
"type": "integer",
"required": True,
"coerce": "integer",
"min": 0,
},
},
},
}
def __init__(self, repository_id: RepositoryId, configuration: Configuration) -> None:
"""
Args:
repository_id(RepositoryId): repository unique identifier
configuration(Configuration): configuration instance
"""
Trigger.__init__(self, repository_id, configuration)
section = next(iter(self.configuration_sections(configuration)))
self.keep_built_packages = max(configuration.getint(section, "keep_built_packages"), 0)
self.paths = configuration.repository_paths
@classmethod
def configuration_sections(cls, configuration: Configuration) -> list[str]:
"""
extract configuration sections from configuration
Args:
configuration(Configuration): configuration instance
Returns:
list[str]: read configuration sections belong to this trigger
"""
return list(cls.CONFIGURATION_SCHEMA.keys())
def archives_remove(self, package: Package, pacman: Pacman) -> None:
"""
remove older versions of the specified package
Args:
package(Package): package which has been updated to check for older versions
pacman(Pacman): alpm wrapper instance
"""
packages: dict[tuple[str, str], Package] = {}
# we can't use here load_archives, because it ignores versions
for full_path in filter(package_like, self.paths.archive_for(package.base).iterdir()):
local = Package.from_archive(full_path, pacman)
packages.setdefault((local.base, local.version), local).packages.update(local.packages)
comparator: Callable[[Package, Package], int] = lambda left, right: left.vercmp(right.version)
to_remove = sorted(packages.values(), key=cmp_to_key(comparator))
for single in to_remove[:-self.keep_built_packages]:
self.logger.info("removing version %s of package %s", single.version, single.base)
for archive in single.packages.values():
for path in self.paths.archive_for(single.base).glob(f"{archive.filename}*"):
path.unlink()
def on_result(self, result: Result, packages: list[Package]) -> None:
"""
run trigger
Args:
result(Result): build result
packages(list[Package]): list of all available packages
"""
ctx = context.get()
pacman = ctx.get(Pacman)
for package in result.success:
self.archives_remove(package, pacman)

View File

@ -0,0 +1,87 @@
#
# Copyright (c) 2021-2025 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from ahriman.core import context
from ahriman.core.configuration import Configuration
from ahriman.core.status import Client
from ahriman.core.triggers import Trigger
from ahriman.models.package import Package
from ahriman.models.repository_id import RepositoryId
from ahriman.models.result import Result
class LogsRotationTrigger(Trigger):
"""
rotate logs after build processes
Attributes:
keep_last_records(int): number of last records to keep
"""
CONFIGURATION_SCHEMA = {
"logs-rotation": {
"type": "dict",
"schema": {
"keep_last_logs": {
"type": "integer",
"required": True,
"coerce": "integer",
"min": 0,
},
},
},
}
def __init__(self, repository_id: RepositoryId, configuration: Configuration) -> None:
"""
Args:
repository_id(RepositoryId): repository unique identifier
configuration(Configuration): configuration instance
"""
Trigger.__init__(self, repository_id, configuration)
section = next(iter(self.configuration_sections(configuration)))
self.keep_last_records = configuration.getint( # read old-style first and then fallback to new style
"settings", "keep_last_logs",
fallback=configuration.getint(section, "keep_last_logs"))
@classmethod
def configuration_sections(cls, configuration: Configuration) -> list[str]:
"""
extract configuration sections from configuration
Args:
configuration(Configuration): configuration instance
Returns:
list[str]: read configuration sections belong to this trigger
"""
return list(cls.CONFIGURATION_SCHEMA.keys())
def on_result(self, result: Result, packages: list[Package]) -> None:
"""
run trigger
Args:
result(Result): build result
packages(list[Package]): list of all available packages
"""
ctx = context.get()
reporter = ctx.get(Client)
reporter.logs_rotate(self.keep_last_records)

View File

@ -17,7 +17,6 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
import atexit
import logging import logging
import uuid import uuid
@ -37,7 +36,6 @@ class HttpLogHandler(logging.Handler):
method method
Attributes: Attributes:
keep_last_records(int): number of last records to keep
reporter(Client): build status reporter instance reporter(Client): build status reporter instance
suppress_errors(bool): suppress logging errors (e.g. if no web server available) suppress_errors(bool): suppress logging errors (e.g. if no web server available)
""" """
@ -56,7 +54,6 @@ class HttpLogHandler(logging.Handler):
self.reporter = Client.load(repository_id, configuration, report=report) self.reporter = Client.load(repository_id, configuration, report=report)
self.suppress_errors = suppress_errors self.suppress_errors = suppress_errors
self.keep_last_records = configuration.getint("settings", "keep_last_logs", fallback=0)
@classmethod @classmethod
def load(cls, repository_id: RepositoryId, configuration: Configuration, *, report: bool) -> Self: def load(cls, repository_id: RepositoryId, configuration: Configuration, *, report: bool) -> Self:
@ -83,7 +80,6 @@ class HttpLogHandler(logging.Handler):
root.addHandler(handler) root.addHandler(handler)
LogRecordId.DEFAULT_PROCESS_ID = str(uuid.uuid4()) # assign default process identifier for log records LogRecordId.DEFAULT_PROCESS_ID = str(uuid.uuid4()) # assign default process identifier for log records
atexit.register(handler.rotate)
return handler return handler
@ -104,9 +100,3 @@ class HttpLogHandler(logging.Handler):
if self.suppress_errors: if self.suppress_errors:
return return
self.handleError(record) self.handleError(record)
def rotate(self) -> None:
"""
rotate log records, removing older ones
"""
self.reporter.logs_rotate(self.keep_last_records)

View File

@ -26,6 +26,7 @@ from typing import Any
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.sign.gpg import GPG from ahriman.core.sign.gpg import GPG
from ahriman.core.types import Comparable
from ahriman.core.utils import pretty_datetime, pretty_size, utcnow from ahriman.core.utils import pretty_datetime, pretty_size, utcnow
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
from ahriman.models.result import Result from ahriman.models.result import Result
@ -111,7 +112,7 @@ class JinjaTemplate:
Returns: Returns:
list[dict[str, str]]: sorted content according to comparator defined list[dict[str, str]]: sorted content according to comparator defined
""" """
comparator: Callable[[dict[str, str]], str] = lambda item: item["filename"] comparator: Callable[[dict[str, str]], Comparable] = lambda item: item["filename"]
return sorted(content, key=comparator) return sorted(content, key=comparator)
def make_html(self, result: Result, template_name: Path | str) -> str: def make_html(self, result: Result, template_name: Path | str) -> str:

View File

@ -28,6 +28,7 @@ from ahriman.core.configuration import Configuration
from ahriman.core.report.jinja_template import JinjaTemplate from ahriman.core.report.jinja_template import JinjaTemplate
from ahriman.core.report.report import Report from ahriman.core.report.report import Report
from ahriman.core.status import Client from ahriman.core.status import Client
from ahriman.core.types import Comparable
from ahriman.models.event import EventType from ahriman.models.event import EventType
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
@ -86,7 +87,7 @@ class RSS(Report, JinjaTemplate):
Returns: Returns:
list[dict[str, str]]: sorted content according to comparator defined list[dict[str, str]]: sorted content according to comparator defined
""" """
comparator: Callable[[dict[str, str]], datetime.datetime] = \ comparator: Callable[[dict[str, str]], Comparable] = \
lambda item: parsedate_to_datetime(item["build_date"]) lambda item: parsedate_to_datetime(item["build_date"])
return sorted(content, key=comparator, reverse=True) return sorted(content, key=comparator, reverse=True)

View File

@ -19,7 +19,7 @@
# #
import shutil import shutil
from collections.abc import Iterable from collections.abc import Generator, Iterable
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
@ -27,7 +27,7 @@ from ahriman.core.build_tools.package_archive import PackageArchive
from ahriman.core.build_tools.task import Task from ahriman.core.build_tools.task import Task
from ahriman.core.repository.cleaner import Cleaner from ahriman.core.repository.cleaner import Cleaner
from ahriman.core.repository.package_info import PackageInfo from ahriman.core.repository.package_info import PackageInfo
from ahriman.core.utils import safe_filename from ahriman.core.utils import atomic_move, filelock, package_like, safe_filename
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
from ahriman.models.event import EventType from ahriman.models.event import EventType
from ahriman.models.package import Package from ahriman.models.package import Package
@ -41,6 +41,141 @@ class Executor(PackageInfo, Cleaner):
trait for common repository update processes trait for common repository update processes
""" """
def _archive_lookup(self, package: Package) -> Generator[Path, None, None]:
"""
check if there is a rebuilt package already
Args:
package(Package): package to check
Yields:
Path: list of built packages and signatures if available, empty list otherwise
"""
archive = self.paths.archive_for(package.base)
# find all packages which have same version
same_version = [
built
for path in filter(package_like, archive.iterdir())
if (built := Package.from_archive(path, self.pacman)).version == package.version
]
# no packages of the same version found
if not same_version:
return
packages = [single for built in same_version for single in built.packages.values()]
# all packages must be either any or same architecture
if not all(single.architecture in ("any", self.architecture) for single in packages):
return
for single in packages:
yield from archive.glob(f"{single.filename}*")
def _archive_rename(self, description: PackageDescription, package_base: str) -> None:
"""
rename package archive removing special symbols
Args:
description(PackageDescription): package description
package_base(str): package base name
"""
if description.filename is None:
self.logger.warning("received empty package filename for base %s", package_base)
return # suppress type checking, it never can be none actually
if (safe := safe_filename(description.filename)) != description.filename:
atomic_move(self.paths.packages / description.filename, self.paths.packages / safe)
description.filename = safe
def _package_build(self, package: Package, path: Path, packager: str | None,
local_version: str | None) -> str | None:
"""
build single package
Args:
package(Package): package to build
path(Path): path to directory with package files
packager(str | None): packager identifier used for this package
local_version(str | None): local version of the package
Returns:
str | None: current commit sha if available
"""
self.reporter.set_building(package.base)
task = Task(package, self.configuration, self.architecture, self.paths)
patches = self.reporter.package_patches_get(package.base, None)
commit_sha = task.init(path, patches, local_version)
loaded_package = Package.from_build(path, self.architecture, None)
if prebuilt := list(self._archive_lookup(loaded_package)):
self.logger.info("using prebuilt packages for %s-%s", loaded_package.base, loaded_package.version)
built = []
for artefact in prebuilt:
with filelock(artefact):
shutil.copy(artefact, path)
built.append(path / artefact.name)
else:
built = task.build(path, PACKAGER=packager)
package.with_packages(built, self.pacman)
for src in built:
dst = self.paths.packages / src.name
atomic_move(src, dst)
return commit_sha
def _package_remove(self, package_name: str, path: Path) -> None:
"""
remove single package from repository
Args:
package_name(str): package name
path(Path): path to package archive
"""
try:
self.repo.remove(package_name, path)
except Exception:
self.logger.exception("could not remove %s", package_name)
def _package_remove_base(self, package_base: str) -> None:
"""
remove package base from repository
Args:
package_base(str): package base name:
"""
try:
with self.in_event(package_base, EventType.PackageRemoved):
self.reporter.package_remove(package_base)
except Exception:
self.logger.exception("could not remove base %s", package_base)
def _package_update(self, filename: str | None, package_base: str, packager_key: str | None) -> None:
"""
update built package in repository database
Args:
filename(str | None): archive filename
package_base(str): package base name
packager_key(str | None): packager key identifier
"""
if filename is None:
self.logger.warning("received empty package filename for base %s", package_base)
return # suppress type checking, it never can be none actually
# in theory, it might be NOT packages directory, but we suppose it is
full_path = self.paths.packages / filename
files = self.sign.process_sign_package(full_path, packager_key)
for src in files:
dst = self.paths.archive_for(package_base) / src.name
atomic_move(src, dst) # move package to archive directory
if not (symlink := self.paths.repository / dst.name).exists():
symlink.symlink_to(dst.relative_to(symlink.parent, walk_up=True)) # create link to archive
self.repo.add(self.paths.repository / filename)
def process_build(self, updates: Iterable[Package], packagers: Packagers | None = None, *, def process_build(self, updates: Iterable[Package], packagers: Packagers | None = None, *,
bump_pkgrel: bool = False) -> Result: bump_pkgrel: bool = False) -> Result:
""" """
@ -55,21 +190,6 @@ class Executor(PackageInfo, Cleaner):
Returns: Returns:
Result: build result Result: build result
""" """
def build_single(package: Package, local_path: Path, packager_id: str | None) -> str | None:
self.reporter.set_building(package.base)
task = Task(package, self.configuration, self.architecture, self.paths)
local_version = local_versions.get(package.base) if bump_pkgrel else None
patches = self.reporter.package_patches_get(package.base, None)
commit_sha = task.init(local_path, patches, local_version)
built = task.build(local_path, PACKAGER=packager_id)
package.with_packages(built, self.pacman)
for src in built:
dst = self.paths.packages / src.name
shutil.move(src, dst)
return commit_sha
packagers = packagers or Packagers() packagers = packagers or Packagers()
local_versions = {package.base: package.version for package in self.packages()} local_versions = {package.base: package.version for package in self.packages()}
@ -80,16 +200,21 @@ class Executor(PackageInfo, Cleaner):
try: try:
with self.in_event(single.base, EventType.PackageUpdated, failure=EventType.PackageUpdateFailed): with self.in_event(single.base, EventType.PackageUpdated, failure=EventType.PackageUpdateFailed):
packager = self.packager(packagers, single.base) packager = self.packager(packagers, single.base)
last_commit_sha = build_single(single, Path(dir_name), packager.packager_id) local_version = local_versions.get(single.base) if bump_pkgrel else None
commit_sha = self._package_build(single, Path(dir_name), packager.packager_id, local_version)
# update commit hash for changes keeping current diff if there is any # update commit hash for changes keeping current diff if there is any
changes = self.reporter.package_changes_get(single.base) changes = self.reporter.package_changes_get(single.base)
self.reporter.package_changes_update(single.base, Changes(last_commit_sha, changes.changes)) self.reporter.package_changes_update(single.base, Changes(commit_sha, changes.changes))
# update dependencies list # update dependencies list
package_archive = PackageArchive(self.paths.build_root, single, self.pacman, self.scan_paths) package_archive = PackageArchive(self.paths.build_root, single, self.pacman, self.scan_paths)
dependencies = package_archive.depends_on() dependencies = package_archive.depends_on()
self.reporter.package_dependencies_update(single.base, dependencies) self.reporter.package_dependencies_update(single.base, dependencies)
# update result set # update result set
result.add_updated(single) result.add_updated(single)
except Exception: except Exception:
self.reporter.set_failed(single.base) self.reporter.set_failed(single.base)
result.add_failed(single) result.add_failed(single)
@ -107,19 +232,6 @@ class Executor(PackageInfo, Cleaner):
Returns: Returns:
Result: remove result Result: remove result
""" """
def remove_base(package_base: str) -> None:
try:
with self.in_event(package_base, EventType.PackageRemoved):
self.reporter.package_remove(package_base)
except Exception:
self.logger.exception("could not remove base %s", package_base)
def remove_package(package: str, archive_path: Path) -> None:
try:
self.repo.remove(package, archive_path) # remove the package itself
except Exception:
self.logger.exception("could not remove %s", package)
packages_to_remove: dict[str, Path] = {} packages_to_remove: dict[str, Path] = {}
bases_to_remove: list[str] = [] bases_to_remove: list[str] = []
@ -136,6 +248,7 @@ class Executor(PackageInfo, Cleaner):
}) })
bases_to_remove.append(local.base) bases_to_remove.append(local.base)
result.add_removed(local) result.add_removed(local)
elif requested.intersection(local.packages.keys()): elif requested.intersection(local.packages.keys()):
packages_to_remove.update({ packages_to_remove.update({
package: properties.filepath package: properties.filepath
@ -152,11 +265,11 @@ class Executor(PackageInfo, Cleaner):
# remove packages from repository files # remove packages from repository files
for package, filename in packages_to_remove.items(): for package, filename in packages_to_remove.items():
remove_package(package, filename) self._package_remove(package, filename)
# remove bases from registered # remove bases from registered
for package in bases_to_remove: for package in bases_to_remove:
remove_base(package) self._package_remove_base(package)
return result return result
@ -172,27 +285,6 @@ class Executor(PackageInfo, Cleaner):
Returns: Returns:
Result: path to repository database Result: path to repository database
""" """
def rename(archive: PackageDescription, package_base: str) -> None:
if archive.filename is None:
self.logger.warning("received empty package name for base %s", package_base)
return # suppress type checking, it never can be none actually
if (safe := safe_filename(archive.filename)) != archive.filename:
shutil.move(self.paths.packages / archive.filename, self.paths.packages / safe)
archive.filename = safe
def update_single(name: str | None, package_base: str, packager_key: str | None) -> None:
if name is None:
self.logger.warning("received empty package name for base %s", package_base)
return # suppress type checking, it never can be none actually
# in theory, it might be NOT packages directory, but we suppose it is
full_path = self.paths.packages / name
files = self.sign.process_sign_package(full_path, packager_key)
for src in files:
dst = self.paths.repository / safe_filename(src.name)
shutil.move(src, dst)
package_path = self.paths.repository / safe_filename(name)
self.repo.add(package_path)
current_packages = {package.base: package for package in self.packages()} current_packages = {package.base: package for package in self.packages()}
local_versions = {package_base: package.version for package_base, package in current_packages.items()} local_versions = {package_base: package.version for package_base, package in current_packages.items()}
@ -207,8 +299,8 @@ class Executor(PackageInfo, Cleaner):
packager = self.packager(packagers, local.base) packager = self.packager(packagers, local.base)
for description in local.packages.values(): for description in local.packages.values():
rename(description, local.base) self._archive_rename(description, local.base)
update_single(description.filename, local.base, packager.key) self._package_update(description.filename, local.base, packager.key)
self.reporter.set_success(local) self.reporter.set_success(local)
result.add_updated(local) result.add_updated(local)
@ -216,12 +308,13 @@ class Executor(PackageInfo, Cleaner):
if local.base in current_packages: if local.base in current_packages:
current_package_archives = set(current_packages[local.base].packages.keys()) current_package_archives = set(current_packages[local.base].packages.keys())
removed_packages.extend(current_package_archives.difference(local.packages)) removed_packages.extend(current_package_archives.difference(local.packages))
except Exception: except Exception:
self.reporter.set_failed(local.base) self.reporter.set_failed(local.base)
result.add_failed(local) result.add_failed(local)
self.logger.exception("could not process %s", local.base) self.logger.exception("could not process %s", local.base)
self.clear_packages()
self.clear_packages()
self.process_remove(removed_packages) self.process_remove(removed_packages)
return result return result

View File

@ -80,8 +80,7 @@ class Trigger(LazyLogging):
return self.repository_id.architecture return self.repository_id.architecture
@classmethod @classmethod
def configuration_schema(cls, repository_id: RepositoryId, def configuration_schema(cls, configuration: Configuration | None) -> ConfigurationSchema:
configuration: Configuration | None) -> ConfigurationSchema:
""" """
configuration schema based on supplied service configuration configuration schema based on supplied service configuration
@ -89,7 +88,6 @@ class Trigger(LazyLogging):
Schema must be in cerberus format, for details and examples you can check built-in triggers. Schema must be in cerberus format, for details and examples you can check built-in triggers.
Args: Args:
repository_id(str): repository unique identifier
configuration(Configuration | None): configuration instance. If set to None, the default schema configuration(Configuration | None): configuration instance. If set to None, the default schema
should be returned should be returned
@ -101,10 +99,12 @@ class Trigger(LazyLogging):
result: ConfigurationSchema = {} result: ConfigurationSchema = {}
for target in cls.configuration_sections(configuration): for target in cls.configuration_sections(configuration):
if not configuration.has_section(target): for section in configuration.sections():
if not (section == target or section.startswith(f"{target}:")):
# either repository specific or exact name
continue continue
section, schema_name = configuration.gettype( schema_name = configuration.get(section, "type", fallback=section)
target, repository_id, fallback=cls.CONFIGURATION_SCHEMA_FALLBACK)
if schema_name not in cls.CONFIGURATION_SCHEMA: if schema_name not in cls.CONFIGURATION_SCHEMA:
continue continue
result[section] = cls.CONFIGURATION_SCHEMA[schema_name] result[section] = cls.CONFIGURATION_SCHEMA[schema_name]

View File

@ -17,6 +17,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
import atexit
import contextlib import contextlib
import os import os
@ -60,17 +61,8 @@ class TriggerLoader(LazyLogging):
def __init__(self) -> None: def __init__(self) -> None:
"""""" """"""
self._on_stop_requested = False
self.triggers: list[Trigger] = [] self.triggers: list[Trigger] = []
def __del__(self) -> None:
"""
custom destructor object which calls on_stop in case if it was requested
"""
if not self._on_stop_requested:
return
self.on_stop()
@classmethod @classmethod
def load(cls, repository_id: RepositoryId, configuration: Configuration) -> Self: def load(cls, repository_id: RepositoryId, configuration: Configuration) -> Self:
""" """
@ -250,10 +242,11 @@ class TriggerLoader(LazyLogging):
run triggers on load run triggers on load
""" """
self.logger.debug("executing triggers on start") self.logger.debug("executing triggers on start")
self._on_stop_requested = True
for trigger in self.triggers: for trigger in self.triggers:
with self.__execute_trigger(trigger): with self.__execute_trigger(trigger):
trigger.on_start() trigger.on_start()
# register on_stop call
atexit.register(self.on_stop)
def on_stop(self) -> None: def on_stop(self) -> None:
""" """

View File

@ -17,7 +17,15 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from typing import Protocol from typing import Any, Protocol
class Comparable(Protocol):
"""
class which supports :func:`__lt__` operation`
"""
def __lt__(self, other: Any) -> bool: ...
class HasBool(Protocol): class HasBool(Protocol):

View File

@ -18,13 +18,16 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
# pylint: disable=too-many-lines # pylint: disable=too-many-lines
import contextlib
import datetime import datetime
import fcntl
import io import io
import itertools import itertools
import logging import logging
import os import os
import re import re
import selectors import selectors
import shutil
import subprocess import subprocess
from collections.abc import Callable, Generator, Iterable, Mapping from collections.abc import Callable, Generator, Iterable, Mapping
@ -39,11 +42,13 @@ from ahriman.models.repository_paths import RepositoryPaths
__all__ = [ __all__ = [
"atomic_move",
"check_output", "check_output",
"check_user", "check_user",
"dataclass_view", "dataclass_view",
"enum_values", "enum_values",
"extract_user", "extract_user",
"filelock",
"filter_json", "filter_json",
"full_version", "full_version",
"minmax", "minmax",
@ -65,6 +70,25 @@ __all__ = [
T = TypeVar("T") T = TypeVar("T")
def atomic_move(src: Path, dst: Path) -> None:
"""
move file from ``source`` location to ``destination``. This method uses lock and :func:`shutil.move` to ensure that
file will be copied (if not rename) atomically. This method blocks execution until lock is available
Args:
src(Path): path to the source file
dst(Path): path to the destination
Examples:
This method is a drop-in replacement for :func:`shutil.move` (except it doesn't allow to override copy method)
which first locking destination file. To use it simply call method with arguments::
>>> atomic_move(src, dst)
"""
with filelock(dst):
shutil.move(src, dst)
# pylint: disable=too-many-locals # pylint: disable=too-many-locals
def check_output(*args: str, exception: Exception | Callable[[int, list[str], str, str], Exception] | None = None, def check_output(*args: str, exception: Exception | Callable[[int, list[str], str, str], Exception] | None = None,
cwd: Path | None = None, input_data: str | None = None, cwd: Path | None = None, input_data: str | None = None,
@ -233,6 +257,27 @@ def extract_user() -> str | None:
return os.getenv("SUDO_USER") or os.getenv("DOAS_USER") or os.getenv("USER") return os.getenv("SUDO_USER") or os.getenv("DOAS_USER") or os.getenv("USER")
@contextlib.contextmanager
def filelock(path: Path) -> Generator[None, None, None]:
"""
lock on file passed as argument
Args:
path(Path): path object on which lock must be performed
"""
lock_path = path.with_name(f".{path.name}")
try:
with lock_path.open("ab") as lock_file:
fd = lock_file.fileno()
try:
fcntl.flock(fd, fcntl.LOCK_EX) # lock file and wait lock is until available
yield
finally:
fcntl.flock(fd, fcntl.LOCK_UN) # unlock file first
finally:
lock_path.unlink(missing_ok=True) # remove lock file at the end
def filter_json(source: dict[str, Any], known_fields: Iterable[str]) -> dict[str, Any]: def filter_json(source: dict[str, Any], known_fields: Iterable[str]) -> dict[str, Any]:
""" """
filter json object by fields used for json-to-object conversion filter json object by fields used for json-to-object conversion

View File

@ -520,8 +520,7 @@ class Package(LazyLogging):
else: else:
remote_version = remote.version remote_version = remote.version
result: int = vercmp(self.version, remote_version) return self.vercmp(remote_version) < 0
return result < 0
def next_pkgrel(self, local_version: str | None) -> str | None: def next_pkgrel(self, local_version: str | None) -> str | None:
""" """
@ -540,7 +539,7 @@ class Package(LazyLogging):
if local_version is None: if local_version is None:
return None # local version not found, keep upstream pkgrel return None # local version not found, keep upstream pkgrel
if vercmp(self.version, local_version) > 0: if self.vercmp(local_version) > 0:
return None # upstream version is newer than local one, keep upstream pkgrel return None # upstream version is newer than local one, keep upstream pkgrel
*_, local_pkgrel = parse_version(local_version) *_, local_pkgrel = parse_version(local_version)
@ -561,6 +560,19 @@ class Package(LazyLogging):
details = "" if self.is_single_package else f""" ({" ".join(sorted(self.packages.keys()))})""" details = "" if self.is_single_package else f""" ({" ".join(sorted(self.packages.keys()))})"""
return f"{self.base}{details}" return f"{self.base}{details}"
def vercmp(self, version: str) -> int:
"""
typed wrapper around :func:`pyalpm.vercmp()`
Args:
version(str): version to compare
Returns:
int: negative if current version is less than provided, positive if greater than and zero if equals
"""
result: int = vercmp(self.version, version)
return result
def view(self) -> dict[str, Any]: def view(self) -> dict[str, Any]:
""" """
generate json package view generate json package view

View File

@ -85,6 +85,16 @@ class RepositoryPaths(LazyLogging):
return Path(self.repository_id.architecture) # legacy tree suffix return Path(self.repository_id.architecture) # legacy tree suffix
return Path(self.repository_id.name) / self.repository_id.architecture return Path(self.repository_id.name) / self.repository_id.architecture
@property
def archive(self) -> Path:
"""
archive directory root
Returns:
Path: archive directory root
"""
return self.root / "archive"
@property @property
def build_root(self) -> Path: def build_root(self) -> Path:
""" """
@ -227,7 +237,7 @@ class RepositoryPaths(LazyLogging):
set owner of path recursively (from root) to root owner set owner of path recursively (from root) to root owner
Notes: Notes:
More likely you don't want to call this method explicitly, consider using :func:`preserve_owner` More likely you don't want to call this method explicitly, consider using :func:`preserve_owner()`
as context manager instead as context manager instead
Args: Args:
@ -249,6 +259,23 @@ class RepositoryPaths(LazyLogging):
set_owner(path) set_owner(path)
path = path.parent path = path.parent
def archive_for(self, package_base: str) -> Path:
"""
get path to archive specified search criteria
Args:
package_base(str): package base name
Returns:
Path: path to archive directory for package base
"""
directory = self.archive / "packages" / package_base[0] / package_base
if not directory.is_dir(): # create if not exists
with self.preserve_owner(self.archive):
directory.mkdir(mode=0o755, parents=True)
return directory
def cache_for(self, package_base: str) -> Path: def cache_for(self, package_base: str) -> Path:
""" """
get path to cached PKGBUILD and package sources for the package base get path to cached PKGBUILD and package sources for the package base
@ -282,6 +309,10 @@ class RepositoryPaths(LazyLogging):
path = path or self.root path = path or self.root
def walk(root: Path) -> Generator[Path, None, None]: def walk(root: Path) -> Generator[Path, None, None]:
yield root
if not root.exists():
return
# basically walk, but skipping some content # basically walk, but skipping some content
for child in root.iterdir(): for child in root.iterdir():
yield child yield child
@ -320,6 +351,7 @@ class RepositoryPaths(LazyLogging):
with self.preserve_owner(): with self.preserve_owner():
for directory in ( for directory in (
self.archive,
self.cache, self.cache,
self.chroot, self.chroot,
self.packages, self.packages,

View File

@ -21,8 +21,18 @@ import aiohttp_jinja2
import logging import logging
from aiohttp.typedefs import Middleware from aiohttp.typedefs import Middleware
from aiohttp.web import HTTPClientError, HTTPException, HTTPMethodNotAllowed, HTTPNoContent, HTTPServerError, \ from aiohttp.web import (
HTTPUnauthorized, Request, StreamResponse, json_response, middleware HTTPClientError,
HTTPException,
HTTPMethodNotAllowed,
HTTPNoContent,
HTTPServerError,
HTTPUnauthorized,
Request,
StreamResponse,
json_response,
middleware,
)
from ahriman.web.middlewares import HandlerType from ahriman.web.middlewares import HandlerType

View File

@ -21,6 +21,7 @@ from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
from collections.abc import Callable from collections.abc import Callable
from typing import ClassVar from typing import ClassVar
from ahriman.core.types import Comparable
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.models.worker import Worker from ahriman.models.worker import Worker
from ahriman.web.apispec.decorators import apidocs from ahriman.web.apispec.decorators import apidocs
@ -74,7 +75,7 @@ class WorkersView(BaseView):
""" """
workers = self.workers.workers workers = self.workers.workers
comparator: Callable[[Worker], str] = lambda item: item.identifier comparator: Callable[[Worker], Comparable] = lambda item: item.identifier
response = [worker.view() for worker in sorted(workers, key=comparator)] response = [worker.view() for worker in sorted(workers, key=comparator)]
return json_response(response) return json_response(response)

View File

@ -25,8 +25,12 @@ from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.apispec.decorators import apidocs from ahriman.web.apispec.decorators import apidocs
from ahriman.web.schemas import PackageNameSchema, PackageStatusSchema, PackageStatusSimplifiedSchema, \ from ahriman.web.schemas import (
RepositoryIdSchema PackageNameSchema,
PackageStatusSchema,
PackageStatusSimplifiedSchema,
RepositoryIdSchema,
)
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView
from ahriman.web.views.status_view_guard import StatusViewGuard from ahriman.web.views.status_view_guard import StatusViewGuard

View File

@ -23,6 +23,7 @@ from aiohttp.web import HTTPNoContent, Response, json_response
from collections.abc import Callable from collections.abc import Callable
from typing import ClassVar from typing import ClassVar
from ahriman.core.types import Comparable
from ahriman.models.build_status import BuildStatus from ahriman.models.build_status import BuildStatus
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
@ -68,7 +69,7 @@ class PackagesView(StatusViewGuard, BaseView):
repository_id = self.repository_id() repository_id = self.repository_id()
packages = self.service(repository_id).packages packages = self.service(repository_id).packages
comparator: Callable[[tuple[Package, BuildStatus]], str] = lambda items: items[0].base comparator: Callable[[tuple[Package, BuildStatus]], Comparable] = lambda items: items[0].base
response = [ response = [
{ {
"package": package.view(), "package": package.view(),

View File

@ -22,6 +22,7 @@ from collections.abc import Callable
from typing import ClassVar from typing import ClassVar
from ahriman.core.alpm.remote import AUR from ahriman.core.alpm.remote import AUR
from ahriman.core.types import Comparable
from ahriman.models.aur_package import AURPackage from ahriman.models.aur_package import AURPackage
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.apispec.decorators import apidocs from ahriman.web.apispec.decorators import apidocs
@ -70,7 +71,12 @@ class SearchView(BaseView):
if not packages: if not packages:
raise HTTPNotFound(reason=f"No packages found for terms: {search}") raise HTTPNotFound(reason=f"No packages found for terms: {search}")
comparator: Callable[[AURPackage], str] = lambda item: item.package_base comparator: Callable[[AURPackage], Comparable] = \
lambda item: (
item.package_base not in search, # inverted because False < True
not any(item.package_base.startswith(term) for term in search), # same as above
item.package_base,
)
response = [ response = [
{ {
"package": package.package_base, "package": package.package_base,

View File

@ -26,6 +26,7 @@ from tempfile import NamedTemporaryFile
from typing import ClassVar from typing import ClassVar
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.utils import atomic_move
from ahriman.models.repository_paths import RepositoryPaths from ahriman.models.repository_paths import RepositoryPaths
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.apispec.decorators import apidocs from ahriman.web.apispec.decorators import apidocs
@ -152,10 +153,8 @@ class UploadView(BaseView):
files.append(await self.save_file(part, target, max_body_size=max_body_size)) files.append(await self.save_file(part, target, max_body_size=max_body_size))
# and now we can rename files, which is relatively fast operation
# it is probably good way to call lock here, however
for filename, current_location in files: for filename, current_location in files:
target_location = current_location.parent / filename target_location = current_location.parent / filename
current_location.rename(target_location) atomic_move(current_location, target_location)
raise HTTPCreated raise HTTPCreated

View File

@ -37,6 +37,7 @@ SUBPACKAGES = {
"ahriman-triggers": [ "ahriman-triggers": [
prefix / "share" / "ahriman" / "settings" / "ahriman.ini.d" / "00-triggers.ini", prefix / "share" / "ahriman" / "settings" / "ahriman.ini.d" / "00-triggers.ini",
site_packages / "ahriman" / "application" / "handlers" / "triggers_support.py", site_packages / "ahriman" / "application" / "handlers" / "triggers_support.py",
site_packages / "ahriman" / "core" / "archive",
site_packages / "ahriman" / "core" / "distributed", site_packages / "ahriman" / "core" / "distributed",
site_packages / "ahriman" / "core" / "support", site_packages / "ahriman" / "core" / "support",
], ],

View File

@ -141,7 +141,7 @@ def test_add_remote_missing(application_packages: ApplicationPackages, mocker: M
""" """
must raise UnknownPackageError if remote package wasn't found must raise UnknownPackageError if remote package wasn't found
""" """
mocker.patch("requests.get", side_effect=Exception()) mocker.patch("requests.get", side_effect=Exception)
with pytest.raises(UnknownPackageError): with pytest.raises(UnknownPackageError):
application_packages._add_remote("url") application_packages._add_remote("url")

View File

@ -135,7 +135,7 @@ def test_unknown_no_aur(application_repository: ApplicationRepository, package_a
must return empty list in case if there is locally stored PKGBUILD must return empty list in case if there is locally stored PKGBUILD
""" """
mocker.patch("ahriman.core.repository.repository.Repository.packages", return_value=[package_ahriman]) mocker.patch("ahriman.core.repository.repository.Repository.packages", return_value=[package_ahriman])
mocker.patch("ahriman.models.package.Package.from_aur", side_effect=Exception()) mocker.patch("ahriman.models.package.Package.from_aur", side_effect=Exception)
mocker.patch("ahriman.models.package.Package.from_build", return_value=package_ahriman) mocker.patch("ahriman.models.package.Package.from_build", return_value=package_ahriman)
mocker.patch("pathlib.Path.is_dir", return_value=True) mocker.patch("pathlib.Path.is_dir", return_value=True)
mocker.patch("ahriman.core.build_tools.sources.Sources.has_remotes", return_value=False) mocker.patch("ahriman.core.build_tools.sources.Sources.has_remotes", return_value=False)
@ -149,7 +149,7 @@ def test_unknown_no_aur_no_local(application_repository: ApplicationRepository,
must return list of packages missing in aur and in local storage must return list of packages missing in aur and in local storage
""" """
mocker.patch("ahriman.core.repository.repository.Repository.packages", return_value=[package_ahriman]) mocker.patch("ahriman.core.repository.repository.Repository.packages", return_value=[package_ahriman])
mocker.patch("ahriman.models.package.Package.from_aur", side_effect=Exception()) mocker.patch("ahriman.models.package.Package.from_aur", side_effect=Exception)
mocker.patch("pathlib.Path.is_dir", return_value=False) mocker.patch("pathlib.Path.is_dir", return_value=False)
packages = application_repository.unknown() packages = application_repository.unknown()

View File

@ -46,7 +46,7 @@ def test_call_exception(args: argparse.Namespace, configuration: Configuration,
""" """
args.configuration = Path("") args.configuration = Path("")
args.quiet = False args.quiet = False
mocker.patch("ahriman.core.configuration.Configuration.from_path", side_effect=Exception()) mocker.patch("ahriman.core.configuration.Configuration.from_path", side_effect=Exception)
logging_mock = mocker.patch("logging.Logger.exception") logging_mock = mocker.patch("logging.Logger.exception")
_, repository_id = configuration.check_loaded() _, repository_id = configuration.check_loaded()
@ -60,7 +60,7 @@ def test_call_exit_code(args: argparse.Namespace, configuration: Configuration,
""" """
args.configuration = Path("") args.configuration = Path("")
args.quiet = False args.quiet = False
mocker.patch("ahriman.core.configuration.Configuration.from_path", side_effect=ExitCode()) mocker.patch("ahriman.core.configuration.Configuration.from_path", side_effect=ExitCode)
logging_mock = mocker.patch("logging.Logger.exception") logging_mock = mocker.patch("logging.Logger.exception")
_, repository_id = configuration.check_loaded() _, repository_id = configuration.check_loaded()

View File

@ -6,6 +6,7 @@ from unittest.mock import call as MockCall
from ahriman.application.handlers.tree_migrate import TreeMigrate from ahriman.application.handlers.tree_migrate import TreeMigrate
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.models.package import Package
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
from ahriman.models.repository_paths import RepositoryPaths from ahriman.models.repository_paths import RepositoryPaths
@ -16,6 +17,7 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc
""" """
tree_create_mock = mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") tree_create_mock = mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create")
application_mock = mocker.patch("ahriman.application.handlers.tree_migrate.TreeMigrate.tree_move") application_mock = mocker.patch("ahriman.application.handlers.tree_migrate.TreeMigrate.tree_move")
symlinks_mock = mocker.patch("ahriman.application.handlers.tree_migrate.TreeMigrate.fix_symlinks")
_, repository_id = configuration.check_loaded() _, repository_id = configuration.check_loaded()
old_paths = configuration.repository_paths old_paths = configuration.repository_paths
new_paths = RepositoryPaths(old_paths.root, old_paths.repository_id, _force_current_tree=True) new_paths = RepositoryPaths(old_paths.root, old_paths.repository_id, _force_current_tree=True)
@ -23,6 +25,37 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc
TreeMigrate.run(args, repository_id, configuration, report=False) TreeMigrate.run(args, repository_id, configuration, report=False)
tree_create_mock.assert_called_once_with() tree_create_mock.assert_called_once_with()
application_mock.assert_called_once_with(old_paths, new_paths) application_mock.assert_called_once_with(old_paths, new_paths)
symlinks_mock.assert_called_once_with(new_paths)
def test_fix_symlinks(repository_paths: RepositoryPaths, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must replace symlinks during migration
"""
mocker.patch("ahriman.models.repository_paths.RepositoryPaths.preserve_owner")
mocker.patch("ahriman.application.handlers.tree_migrate.walk", side_effect=[
[
repository_paths.archive_for(package_ahriman.base) / "file",
repository_paths.archive_for(package_ahriman.base) / "symlink",
],
[
repository_paths.repository / "file",
repository_paths.repository / "symlink",
],
])
mocker.patch("pathlib.Path.exists", autospec=True, side_effect=lambda p: p.name == "file")
unlink_mock = mocker.patch("pathlib.Path.unlink")
symlink_mock = mocker.patch("pathlib.Path.symlink_to")
TreeMigrate.fix_symlinks(repository_paths)
unlink_mock.assert_called_once_with()
symlink_mock.assert_called_once_with(
Path("..") /
".." /
".." /
repository_paths.archive_for(package_ahriman.base).relative_to(repository_paths.root) /
"symlink"
)
def test_move_tree(mocker: MockerFixture) -> None: def test_move_tree(mocker: MockerFixture) -> None:

View File

@ -2,6 +2,7 @@ import argparse
import json import json
import pytest import pytest
from pathlib import Path
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from ahriman.application.handlers.validate import Validate from ahriman.application.handlers.validate import Validate
@ -53,12 +54,50 @@ def test_run_skip(args: argparse.Namespace, configuration: Configuration, mocker
print_mock.assert_not_called() print_mock.assert_not_called()
def test_run_default(args: argparse.Namespace, configuration: Configuration) -> None:
"""
must run on default configuration without errors
"""
args.exit_code = True
_, repository_id = configuration.check_loaded()
default = Configuration.from_path(Configuration.SYSTEM_CONFIGURATION_PATH, repository_id)
# copy autogenerated values
for section, key in (("build", "build_command"), ("repository", "root")):
value = configuration.get(section, key)
default.set_option(section, key, value)
Validate.run(args, repository_id, default, report=False)
def test_run_repo_specific_triggers(args: argparse.Namespace, configuration: Configuration,
resource_path_root: Path) -> None:
"""
must correctly insert repo specific triggers
"""
args.exit_code = True
_, repository_id = configuration.check_loaded()
# remove unused sections
for section in ("archive", "customs3", "github:x86_64", "logs-rotation", "mirrorlist"):
configuration.remove_section(section)
configuration.set_option("report", "target", "test")
for section in ("test", "test:i686", "test:another-repo:x86_64"):
configuration.set_option(section, "type", "html")
configuration.set_option(section, "link_path", "http://link_path")
configuration.set_option(section, "path", "path")
configuration.set_option(section, "template", "template")
configuration.set_option(section, "templates", str(resource_path_root))
Validate.run(args, repository_id, configuration, report=False)
def test_schema(configuration: Configuration) -> None: def test_schema(configuration: Configuration) -> None:
""" """
must generate full schema correctly must generate full schema correctly
""" """
_, repository_id = configuration.check_loaded() schema = Validate.schema(configuration)
schema = Validate.schema(repository_id, configuration)
# defaults # defaults
assert schema.pop("console") assert schema.pop("console")
@ -91,9 +130,7 @@ def test_schema_invalid_trigger(configuration: Configuration) -> None:
""" """
configuration.set_option("build", "triggers", "some.invalid.trigger.path.Trigger") configuration.set_option("build", "triggers", "some.invalid.trigger.path.Trigger")
configuration.remove_option("build", "triggers_known") configuration.remove_option("build", "triggers_known")
_, repository_id = configuration.check_loaded() assert Validate.schema(configuration) == CONFIGURATION_SCHEMA
assert Validate.schema(repository_id, configuration) == CONFIGURATION_SCHEMA
def test_schema_erase_required() -> None: def test_schema_erase_required() -> None:

View File

@ -230,7 +230,7 @@ def test_clear_close_exception(lock: Lock) -> None:
must suppress IO exception on file closure must suppress IO exception on file closure
""" """
close_mock = lock._pid_file = MagicMock() close_mock = lock._pid_file = MagicMock()
close_mock.close.side_effect = IOError() close_mock.close.side_effect = IOError
lock.clear() lock.clear()

View File

@ -1,8 +1,10 @@
import datetime import datetime
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 sqlite3 import Cursor
from typing import Any, TypeVar from typing import Any, TypeVar
from unittest.mock import MagicMock, PropertyMock from unittest.mock import MagicMock, PropertyMock
@ -11,12 +13,14 @@ from ahriman.core.alpm.remote import AUR
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.database.migrations import Migrations
from ahriman.core.repository import Repository from ahriman.core.repository import Repository
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
from ahriman.models.aur_package import AURPackage from ahriman.models.aur_package import AURPackage
from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.migration import Migration
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.package_description import PackageDescription from ahriman.models.package_description import PackageDescription
from ahriman.models.package_source import PackageSource from ahriman.models.package_source import PackageSource
@ -48,7 +52,9 @@ def anyvar(cls: type[T], strict: bool = False) -> T:
T: any wrapper T: any wrapper
""" """
class AnyVar(cls): class AnyVar(cls):
"""any value wrapper""" """
any value wrapper
"""
def __eq__(self, other: Any) -> bool: def __eq__(self, other: Any) -> bool:
""" """
@ -271,16 +277,23 @@ def configuration(repository_id: RepositoryId, tmp_path: Path, resource_path_roo
@pytest.fixture @pytest.fixture
def database(configuration: Configuration) -> SQLite: def database(configuration: Configuration, mocker: MockerFixture) -> SQLite:
""" """
database fixture database fixture
Args: Args:
configuration(Configuration): configuration fixture configuration(Configuration): configuration fixture
mocker(MockerFixture): mocker object
Returns: Returns:
SQLite: database test instance SQLite: database test instance
""" """
original_method = Migrations.perform_migration
def perform_migration(self: Migrations, cursor: Cursor, migration: Migration) -> None:
original_method(self, cursor, replace(migration, migrate_data=lambda *args: None))
mocker.patch.object(Migrations, "perform_migration", autospec=True, side_effect=perform_migration)
return SQLite.load(configuration) return SQLite.load(configuration)

View File

@ -108,7 +108,7 @@ def test_aur_request_failed(aur: AUR, mocker: MockerFixture) -> None:
""" """
must reraise generic exception must reraise generic exception
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
with pytest.raises(Exception): with pytest.raises(Exception):
aur.aur_request("info", "ahriman") aur.aur_request("info", "ahriman")
@ -116,7 +116,7 @@ def test_aur_request_failed(aur: AUR, mocker: MockerFixture) -> None:
def test_aur_request_failed_http_error(aur: AUR, mocker: MockerFixture) -> None: def test_aur_request_failed_http_error(aur: AUR, mocker: MockerFixture) -> None:
""" must reraise http exception """ must reraise http exception
""" """
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
with pytest.raises(requests.HTTPError): with pytest.raises(requests.HTTPError):
aur.aur_request("info", "ahriman") aur.aur_request("info", "ahriman")

View File

@ -80,7 +80,7 @@ def test_arch_request_failed(official: Official, mocker: MockerFixture) -> None:
""" """
must reraise generic exception must reraise generic exception
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
with pytest.raises(Exception): with pytest.raises(Exception):
official.arch_request("akonadi", by="q") official.arch_request("akonadi", by="q")
@ -89,7 +89,7 @@ def test_arch_request_failed_http_error(official: Official, mocker: MockerFixtur
""" """
must reraise http exception must reraise http exception
""" """
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
with pytest.raises(requests.HTTPError): with pytest.raises(requests.HTTPError):
official.arch_request("akonadi", by="q") official.arch_request("akonadi", by="q")

View File

@ -242,7 +242,7 @@ def test_files_no_entry(pacman: Pacman, pyalpm_package_ahriman: pyalpm.Package,
pacman.handle = handle_mock pacman.handle = handle_mock
tar_mock = MagicMock() tar_mock = MagicMock()
tar_mock.extractfile.side_effect = KeyError() tar_mock.extractfile.side_effect = KeyError
open_mock = MagicMock() open_mock = MagicMock()
open_mock.__enter__.return_value = tar_mock open_mock.__enter__.return_value = tar_mock

View File

@ -131,7 +131,7 @@ def test_sync_exception(pacman_database: PacmanDatabase, mocker: MockerFixture)
""" """
must suppress all exceptions on failure must suppress all exceptions on failure
""" """
mocker.patch("ahriman.core.alpm.pacman_database.PacmanDatabase.sync_packages", side_effect=Exception()) mocker.patch("ahriman.core.alpm.pacman_database.PacmanDatabase.sync_packages", side_effect=Exception)
pacman_database.sync(force=True) pacman_database.sync(force=True)

View File

@ -4,6 +4,15 @@ from pathlib import Path
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from ahriman.core.alpm.repo import Repo from ahriman.core.alpm.repo import Repo
from ahriman.models.repository_paths import RepositoryPaths
def test_root(repository_paths: RepositoryPaths) -> None:
"""
must correctly define repository root
"""
assert Repo(repository_paths.repository_id.name, repository_paths, []).root == repository_paths.repository
assert Repo(repository_paths.repository_id.name, repository_paths, [], Path("path")).root == Path("path")
def test_repo_path(repo: Repo) -> None: def test_repo_path(repo: Repo) -> None:
@ -22,6 +31,18 @@ def test_repo_add(repo: Repo, mocker: MockerFixture) -> None:
repo.add(Path("path")) repo.add(Path("path"))
check_output_mock.assert_called_once() # it will be checked later check_output_mock.assert_called_once() # it will be checked later
assert check_output_mock.call_args[0][0] == "repo-add" assert check_output_mock.call_args[0][0] == "repo-add"
assert "--remove" in check_output_mock.call_args[0]
def test_repo_add_no_remove(repo: Repo, mocker: MockerFixture) -> None:
"""
must call repo-add without remove flag
"""
check_output_mock = mocker.patch("ahriman.core.alpm.repo.check_output")
repo.add(Path("path"), remove=False)
check_output_mock.assert_called_once() # it will be checked later
assert "--remove" not in check_output_mock.call_args[0]
def test_repo_init(repo: Repo, mocker: MockerFixture) -> None: def test_repo_init(repo: Repo, mocker: MockerFixture) -> None:
@ -52,7 +73,7 @@ def test_repo_remove_fail_no_file(repo: Repo, mocker: MockerFixture) -> None:
must fail on missing file must fail on missing file
""" """
mocker.patch("pathlib.Path.glob", return_value=[Path("package.pkg.tar.xz")]) mocker.patch("pathlib.Path.glob", return_value=[Path("package.pkg.tar.xz")])
mocker.patch("pathlib.Path.unlink", side_effect=FileNotFoundError()) mocker.patch("pathlib.Path.unlink", side_effect=FileNotFoundError)
with pytest.raises(FileNotFoundError): with pytest.raises(FileNotFoundError):
repo.remove("package", Path("package.pkg.tar.xz")) repo.remove("package", Path("package.pkg.tar.xz"))

View File

@ -90,7 +90,7 @@ async def test_get_oauth_username_exception_1(oauth: OAuth, mocker: MockerFixtur
""" """
must return None in case of OAuth request error (get_access_token) must return None in case of OAuth request error (get_access_token)
""" """
mocker.patch("aioauth_client.GoogleClient.get_access_token", side_effect=Exception()) mocker.patch("aioauth_client.GoogleClient.get_access_token", side_effect=Exception)
user_info_mock = mocker.patch("aioauth_client.GoogleClient.user_info") user_info_mock = mocker.patch("aioauth_client.GoogleClient.user_info")
email = await oauth.get_oauth_username("code") email = await oauth.get_oauth_username("code")
@ -103,7 +103,7 @@ async def test_get_oauth_username_exception_2(oauth: OAuth, mocker: MockerFixtur
must return None in case of OAuth request error (user_info) must return None in case of OAuth request error (user_info)
""" """
mocker.patch("aioauth_client.GoogleClient.get_access_token", return_value=("token", "")) mocker.patch("aioauth_client.GoogleClient.get_access_token", return_value=("token", ""))
mocker.patch("aioauth_client.GoogleClient.user_info", side_effect=Exception()) mocker.patch("aioauth_client.GoogleClient.user_info", side_effect=Exception)
email = await oauth.get_oauth_username("code") email = await oauth.get_oauth_username("code")
assert email is None assert email is None

View File

@ -1,8 +1,8 @@
import configparser import configparser
from io import StringIO
import pytest import pytest
import os
from io import StringIO
from pathlib import Path from pathlib import Path
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from unittest.mock import call as MockCall from unittest.mock import call as MockCall
@ -20,6 +20,40 @@ def test_architecture(configuration: Configuration) -> None:
assert configuration.architecture == "x86_64" assert configuration.architecture == "x86_64"
def test_repository_id(configuration: Configuration, repository_id: RepositoryId) -> None:
"""
must return repository identifier
"""
assert configuration.repository_id == repository_id
assert configuration.get("repository", "name") == repository_id.name
assert configuration.get("repository", "architecture") == repository_id.architecture
def test_repository_id_erase(configuration: Configuration) -> None:
"""
must remove repository identifier properties if empty identifier supplied
"""
configuration.repository_id = None
assert configuration.get("repository", "name", fallback=None) is None
assert configuration.get("repository", "architecture", fallback=None) is None
configuration.repository_id = RepositoryId("", "")
assert configuration.get("repository", "name", fallback=None) is None
assert configuration.get("repository", "architecture", fallback=None) is None
def test_repository_id_update(configuration: Configuration, repository_id: RepositoryId) -> None:
"""
must update repository identifier and related configuration options
"""
repository_id = RepositoryId("i686", repository_id.name)
configuration.repository_id = repository_id
assert configuration.repository_id == repository_id
assert configuration.get("repository", "name") == repository_id.name
assert configuration.get("repository", "architecture") == repository_id.architecture
def test_repository_name(configuration: Configuration) -> None: def test_repository_name(configuration: Configuration) -> None:
""" """
must return valid repository name must return valid repository name
@ -42,12 +76,16 @@ def test_from_path(repository_id: RepositoryId, mocker: MockerFixture) -> None:
mocker.patch("ahriman.core.configuration.Configuration.get", return_value="ahriman.ini.d") mocker.patch("ahriman.core.configuration.Configuration.get", return_value="ahriman.ini.d")
read_mock = mocker.patch("ahriman.core.configuration.Configuration.read") read_mock = mocker.patch("ahriman.core.configuration.Configuration.read")
load_includes_mock = mocker.patch("ahriman.core.configuration.Configuration.load_includes") load_includes_mock = mocker.patch("ahriman.core.configuration.Configuration.load_includes")
merge_mock = mocker.patch("ahriman.core.configuration.Configuration.merge_sections")
environment_mock = mocker.patch("ahriman.core.configuration.Configuration.load_environment")
path = Path("path") path = Path("path")
configuration = Configuration.from_path(path, repository_id) configuration = Configuration.from_path(path, repository_id)
assert configuration.path == path assert configuration.path == path
read_mock.assert_called_once_with(path) read_mock.assert_called_once_with(path)
load_includes_mock.assert_called_once_with() load_includes_mock.assert_called_once_with()
merge_mock.assert_called_once_with(repository_id)
environment_mock.assert_called_once_with()
def test_from_path_file_missing(repository_id: RepositoryId, mocker: MockerFixture) -> None: def test_from_path_file_missing(repository_id: RepositoryId, mocker: MockerFixture) -> None:
@ -324,6 +362,18 @@ def test_gettype_from_section_no_section(configuration: Configuration) -> None:
configuration.gettype("rsync:x86_64", configuration.repository_id) configuration.gettype("rsync:x86_64", configuration.repository_id)
def test_load_environment(configuration: Configuration) -> None:
"""
must load environment variables
"""
os.environ["section:key"] = "value1"
os.environ["section:identifier:key"] = "value2"
configuration.load_environment()
assert configuration.get("section", "key") == "value1"
assert configuration.get("section:identifier", "key") == "value2"
def test_load_includes(mocker: MockerFixture) -> None: def test_load_includes(mocker: MockerFixture) -> None:
""" """
must load includes must load includes
@ -444,10 +494,12 @@ def test_reload(configuration: Configuration, mocker: MockerFixture) -> None:
""" """
load_mock = mocker.patch("ahriman.core.configuration.Configuration.load") load_mock = mocker.patch("ahriman.core.configuration.Configuration.load")
merge_mock = mocker.patch("ahriman.core.configuration.Configuration.merge_sections") merge_mock = mocker.patch("ahriman.core.configuration.Configuration.merge_sections")
environment_mock = mocker.patch("ahriman.core.configuration.Configuration.load_environment")
configuration.reload() configuration.reload()
load_mock.assert_called_once_with(configuration.path) load_mock.assert_called_once_with(configuration.path)
merge_mock.assert_called_once_with(configuration.repository_id) merge_mock.assert_called_once_with(configuration.repository_id)
environment_mock.assert_called_once_with()
def test_reload_clear(configuration: Configuration, mocker: MockerFixture) -> None: def test_reload_clear(configuration: Configuration, mocker: MockerFixture) -> None:

View File

@ -0,0 +1,82 @@
import pytest
from dataclasses import replace
from pathlib import Path
from pytest_mock import MockerFixture
from sqlite3 import Connection
from typing import Any
from unittest.mock import call as MockCall
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.configuration import Configuration
from ahriman.core.database.migrations.m016_archive import migrate_data, move_packages
from ahriman.models.package import Package
from ahriman.models.repository_paths import RepositoryPaths
def test_migrate_data(connection: Connection, configuration: Configuration, mocker: MockerFixture) -> None:
"""
must perform data migration
"""
_, repository_id = configuration.check_loaded()
repositories = [
repository_id,
replace(repository_id, architecture="i686"),
]
mocker.patch("ahriman.application.handlers.handler.Handler.repositories_extract", return_value=repositories)
migration_mock = mocker.patch("ahriman.core.database.migrations.m016_archive.move_packages")
migrate_data(connection, configuration)
migration_mock.assert_has_calls([
MockCall(replace(configuration.repository_paths, repository_id=repository), pytest.helpers.anyvar(int))
for repository in repositories
])
def test_move_packages(repository_paths: RepositoryPaths, pacman: Pacman, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must move packages to the archive directory
"""
def is_file(self: Path, *args: Any, **kwargs: Any) -> bool:
return "file" in self.name
mocker.patch("pathlib.Path.iterdir", return_value=[
repository_paths.repository / ".hidden-file.pkg.tar.xz",
repository_paths.repository / "directory",
repository_paths.repository / "file.pkg.tar.xz",
repository_paths.repository / "file.pkg.tar.xz.sig",
repository_paths.repository / "symlink.pkg.tar.xz",
])
mocker.patch("pathlib.Path.is_dir", return_value=True)
mocker.patch("pathlib.Path.is_file", autospec=True, side_effect=is_file)
archive_mock = mocker.patch("ahriman.models.package.Package.from_archive", return_value=package_ahriman)
rename_mock = mocker.patch("pathlib.Path.rename")
symlink_mock = mocker.patch("pathlib.Path.symlink_to")
move_packages(repository_paths, pacman)
archive_mock.assert_has_calls([
MockCall(repository_paths.repository / "file.pkg.tar.xz", pacman),
MockCall(repository_paths.repository / "file.pkg.tar.xz.sig", pacman),
])
rename_mock.assert_has_calls([
MockCall(repository_paths.archive_for(package_ahriman.base) / "file.pkg.tar.xz"),
MockCall(repository_paths.archive_for(package_ahriman.base) / "file.pkg.tar.xz.sig"),
])
symlink_mock.assert_has_calls([
MockCall(
Path("..") /
".." /
".." /
repository_paths.archive_for(package_ahriman.base).relative_to(repository_paths.root) /
"file.pkg.tar.xz"
),
MockCall(
Path("..") /
".." /
".." /
repository_paths.archive_for(package_ahriman.base).relative_to(repository_paths.root) /
"file.pkg.tar.xz.sig"
),
])

View File

@ -42,7 +42,7 @@ def test_apply_migration_exception(migrations: Migrations, mocker: MockerFixture
must roll back and close cursor on exception during migration must roll back and close cursor on exception during migration
""" """
cursor = MagicMock() cursor = MagicMock()
mocker.patch("logging.Logger.info", side_effect=Exception()) mocker.patch("logging.Logger.info", side_effect=Exception)
migrations.connection.cursor.return_value = cursor migrations.connection.cursor.return_value = cursor
with pytest.raises(Exception): with pytest.raises(Exception):
@ -59,7 +59,7 @@ def test_apply_migration_sql_exception(migrations: Migrations) -> None:
must close cursor on general migration error must close cursor on general migration error
""" """
cursor = MagicMock() cursor = MagicMock()
cursor.execute.side_effect = Exception() cursor.execute.side_effect = Exception
migrations.connection.cursor.return_value = cursor migrations.connection.cursor.return_value = cursor
with pytest.raises(Exception): with pytest.raises(Exception):

View File

@ -37,7 +37,7 @@ def test_register_failed(distributed_system: DistributedSystem, mocker: MockerFi
""" """
must suppress any exception happened during worker registration must suppress any exception happened during worker registration
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
distributed_system.register() distributed_system.register()
@ -45,7 +45,7 @@ def test_register_failed_http_error(distributed_system: DistributedSystem, mocke
""" """
must suppress HTTP exception happened during worker registration must suppress HTTP exception happened during worker registration
""" """
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
distributed_system.register() distributed_system.register()
@ -70,7 +70,7 @@ def test_workers_failed(distributed_system: DistributedSystem, mocker: MockerFix
""" """
must suppress any exception happened during worker extraction must suppress any exception happened during worker extraction
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
distributed_system.workers() distributed_system.workers()
@ -78,5 +78,5 @@ def test_workers_failed_http_error(distributed_system: DistributedSystem, mocker
""" """
must suppress HTTP exception happened during worker extraction must suppress HTTP exception happened during worker extraction
""" """
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
distributed_system.workers() distributed_system.workers()

View File

@ -85,7 +85,7 @@ def test_run_failed(configuration: Configuration, mocker: MockerFixture) -> None
""" """
must reraise exception on error occurred must reraise exception on error occurred
""" """
mocker.patch("ahriman.core.gitremote.remote_pull.RemotePull.repo_clone", side_effect=Exception()) mocker.patch("ahriman.core.gitremote.remote_pull.RemotePull.repo_clone", side_effect=Exception)
_, repository_id = configuration.check_loaded() _, repository_id = configuration.check_loaded()
runner = RemotePull(repository_id, configuration, "gitremote") runner = RemotePull(repository_id, configuration, "gitremote")

View File

@ -82,7 +82,7 @@ def test_run_failed(local_client: Client, configuration: Configuration, result:
""" """
must reraise exception on error occurred must reraise exception on error occurred
""" """
mocker.patch("ahriman.core.build_tools.sources.Sources.fetch", side_effect=Exception()) mocker.patch("ahriman.core.build_tools.sources.Sources.fetch", side_effect=Exception)
runner = RemotePush(local_client, configuration, "gitremote") runner = RemotePush(local_client, configuration, "gitremote")
with pytest.raises(GitRemoteError): with pytest.raises(GitRemoteError):

View File

@ -0,0 +1,34 @@
import pytest
from ahriman.core.configuration import Configuration
from ahriman.core.housekeeping import ArchiveRotationTrigger, LogsRotationTrigger
@pytest.fixture
def archive_rotation_trigger(configuration: Configuration) -> ArchiveRotationTrigger:
"""
archive rotation trigger fixture
Args:
configuration(Configuration): configuration fixture
Returns:
ArchiveRotationTrigger: archive rotation trigger test instance
"""
_, repository_id = configuration.check_loaded()
return ArchiveRotationTrigger(repository_id, configuration)
@pytest.fixture
def logs_rotation_trigger(configuration: Configuration) -> LogsRotationTrigger:
"""
logs rotation trigger fixture
Args:
configuration(Configuration): configuration fixture
Returns:
LogsRotationTrigger: logs rotation trigger test instance
"""
_, repository_id = configuration.check_loaded()
return LogsRotationTrigger(repository_id, configuration)

View File

@ -0,0 +1,83 @@
import pytest
from dataclasses import replace
from pathlib import Path
from pytest_mock import MockerFixture
from typing import Any
from unittest.mock import call as MockCall
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.configuration import Configuration
from ahriman.core.housekeeping import ArchiveRotationTrigger
from ahriman.models.package import Package
from ahriman.models.result import Result
def test_configuration_sections(configuration: Configuration) -> None:
"""
must correctly parse target list
"""
assert ArchiveRotationTrigger.configuration_sections(configuration) == ["archive"]
def test_archives_remove(archive_rotation_trigger: ArchiveRotationTrigger, package_ahriman: Package,
pacman: Pacman, mocker: MockerFixture) -> None:
"""
must remove older packages
"""
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("pathlib.Path.is_dir", return_value=True)
mocker.patch("ahriman.core.housekeeping.archive_rotation_trigger.package_like", return_value=True)
mocker.patch("pathlib.Path.glob", return_value=[Path(str(i)) for i in range(5)])
mocker.patch("pathlib.Path.iterdir", return_value=[Path(str(i)) for i in range(5)])
mocker.patch("ahriman.models.package.Package.from_archive", side_effect=package)
unlink_mock = mocker.patch("pathlib.Path.unlink", autospec=True)
archive_rotation_trigger.archives_remove(package_ahriman, pacman)
unlink_mock.assert_has_calls([
MockCall(Path("0")),
MockCall(Path("1")),
])
def test_archives_remove_keep(archive_rotation_trigger: ArchiveRotationTrigger, package_ahriman: Package,
pacman: Pacman, mocker: MockerFixture) -> None:
"""
must keep all packages if set to
"""
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("pathlib.Path.is_dir", return_value=True)
mocker.patch("ahriman.core.housekeeping.archive_rotation_trigger.package_like", return_value=True)
mocker.patch("pathlib.Path.glob", return_value=[Path(str(i)) for i in range(5)])
mocker.patch("pathlib.Path.iterdir", return_value=[Path(str(i)) for i in range(5)])
mocker.patch("ahriman.models.package.Package.from_archive", side_effect=package)
unlink_mock = mocker.patch("pathlib.Path.unlink", autospec=True)
archive_rotation_trigger.keep_built_packages = 0
archive_rotation_trigger.archives_remove(package_ahriman, pacman)
unlink_mock.assert_not_called()
def test_on_result(archive_rotation_trigger: ArchiveRotationTrigger, package_ahriman: Package,
package_python_schedule: Package, mocker: MockerFixture) -> None:
"""
must rotate archives
"""
mocker.patch("ahriman.core._Context.get")
remove_mock = mocker.patch("ahriman.core.housekeeping.ArchiveRotationTrigger.archives_remove")
archive_rotation_trigger.on_result(Result(added=[package_ahriman], failed=[package_python_schedule]), [])
remove_mock.assert_called_once_with(package_ahriman, pytest.helpers.anyvar(int))

View File

@ -0,0 +1,26 @@
from pytest_mock import MockerFixture
from unittest.mock import MagicMock
from ahriman.core.configuration import Configuration
from ahriman.core.housekeeping import LogsRotationTrigger
from ahriman.core.status import Client
from ahriman.models.result import Result
def test_configuration_sections(configuration: Configuration) -> None:
"""
must correctly parse target list
"""
assert LogsRotationTrigger.configuration_sections(configuration) == ["logs-rotation"]
def test_on_result(logs_rotation_trigger: LogsRotationTrigger, mocker: MockerFixture) -> None:
"""
must rotate logs
"""
client_mock = MagicMock()
context_mock = mocker.patch("ahriman.core._Context.get", return_value=client_mock)
logs_rotation_trigger.on_result(Result(), [])
context_mock.assert_called_once_with(Client)
client_mock.logs_rotate.assert_called_once_with(logs_rotation_trigger.keep_last_records)

View File

@ -51,7 +51,7 @@ def test_login_failed(ahriman_client: SyncAhrimanClient, user: User, mocker: Moc
must suppress any exception happened during login must suppress any exception happened during login
""" """
ahriman_client.user = user ahriman_client.user = user
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
ahriman_client._login(requests.Session()) ahriman_client._login(requests.Session())
@ -60,7 +60,7 @@ def test_login_failed_http_error(ahriman_client: SyncAhrimanClient, user: User,
must suppress HTTP exception happened during login must suppress HTTP exception happened during login
""" """
ahriman_client.user = user ahriman_client.user = user
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
ahriman_client._login(requests.Session()) ahriman_client._login(requests.Session())

View File

@ -124,7 +124,7 @@ def test_make_request_failed(mocker: MockerFixture) -> None:
""" """
must process request errors must process request errors
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
logging_mock = mocker.patch("logging.Logger.exception") logging_mock = mocker.patch("logging.Logger.exception")
with pytest.raises(Exception): with pytest.raises(Exception):
@ -136,7 +136,7 @@ def test_make_request_suppress_errors(mocker: MockerFixture) -> None:
""" """
must suppress request errors correctly must suppress request errors correctly
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
logging_mock = mocker.patch("logging.Logger.exception") logging_mock = mocker.patch("logging.Logger.exception")
with pytest.raises(Exception): with pytest.raises(Exception):

View File

@ -20,14 +20,12 @@ def test_load(configuration: Configuration, mocker: MockerFixture) -> None:
add_mock = mocker.patch("logging.Logger.addHandler") add_mock = mocker.patch("logging.Logger.addHandler")
load_mock = mocker.patch("ahriman.core.status.Client.load") load_mock = mocker.patch("ahriman.core.status.Client.load")
atexit_mock = mocker.patch("atexit.register")
_, repository_id = configuration.check_loaded() _, repository_id = configuration.check_loaded()
handler = HttpLogHandler.load(repository_id, configuration, report=False) handler = HttpLogHandler.load(repository_id, configuration, report=False)
assert handler assert handler
add_mock.assert_called_once_with(handler) add_mock.assert_called_once_with(handler)
load_mock.assert_called_once_with(repository_id, configuration, report=False) load_mock.assert_called_once_with(repository_id, configuration, report=False)
atexit_mock.assert_called_once_with(handler.rotate)
def test_load_exist(configuration: Configuration) -> None: def test_load_exist(configuration: Configuration) -> None:
@ -61,7 +59,7 @@ def test_emit_failed(configuration: Configuration, log_record: logging.LogRecord
must call handle error on exception must call handle error on exception
""" """
log_record.package_id = LogRecordId(package_ahriman.base, package_ahriman.version) log_record.package_id = LogRecordId(package_ahriman.base, package_ahriman.version)
mocker.patch("ahriman.core.status.Client.package_logs_add", side_effect=Exception()) mocker.patch("ahriman.core.status.Client.package_logs_add", side_effect=Exception)
handle_error_mock = mocker.patch("logging.Handler.handleError") handle_error_mock = mocker.patch("logging.Handler.handleError")
_, repository_id = configuration.check_loaded() _, repository_id = configuration.check_loaded()
handler = HttpLogHandler(repository_id, configuration, report=False, suppress_errors=False) handler = HttpLogHandler(repository_id, configuration, report=False, suppress_errors=False)
@ -76,7 +74,7 @@ def test_emit_suppress_failed(configuration: Configuration, log_record: logging.
must not call handle error on exception if suppress flag is set must not call handle error on exception if suppress flag is set
""" """
log_record.package_id = LogRecordId(package_ahriman.base, package_ahriman.version) log_record.package_id = LogRecordId(package_ahriman.base, package_ahriman.version)
mocker.patch("ahriman.core.status.Client.package_logs_add", side_effect=Exception()) mocker.patch("ahriman.core.status.Client.package_logs_add", side_effect=Exception)
handle_error_mock = mocker.patch("logging.Handler.handleError") handle_error_mock = mocker.patch("logging.Handler.handleError")
_, repository_id = configuration.check_loaded() _, repository_id = configuration.check_loaded()
handler = HttpLogHandler(repository_id, configuration, report=False, suppress_errors=True) handler = HttpLogHandler(repository_id, configuration, report=False, suppress_errors=True)
@ -96,16 +94,3 @@ def test_emit_skip(configuration: Configuration, log_record: logging.LogRecord,
handler.emit(log_record) handler.emit(log_record)
log_mock.assert_not_called() log_mock.assert_not_called()
def test_rotate(configuration: Configuration, mocker: MockerFixture) -> None:
"""
must rotate logs
"""
rotate_mock = mocker.patch("ahriman.core.status.Client.logs_rotate")
_, repository_id = configuration.check_loaded()
handler = HttpLogHandler(repository_id, configuration, report=False, suppress_errors=False)
handler.rotate()
rotate_mock.assert_called_once_with(handler.keep_last_records)

View File

@ -61,7 +61,7 @@ def test_load_fallback(configuration: Configuration, mocker: MockerFixture) -> N
""" """
must fall back to stderr without errors must fall back to stderr without errors
""" """
mocker.patch("ahriman.core.log.log_loader.fileConfig", side_effect=PermissionError()) mocker.patch("ahriman.core.log.log_loader.fileConfig", side_effect=PermissionError)
_, repository_id = configuration.check_loaded() _, repository_id = configuration.check_loaded()
LogLoader.load(repository_id, configuration, LogHandler.Journald, quiet=False, report=False) LogLoader.load(repository_id, configuration, LogHandler.Journald, quiet=False, report=False)

View File

@ -13,7 +13,7 @@ def test_report_failure(configuration: Configuration, mocker: MockerFixture) ->
""" """
must raise ReportFailed on errors must raise ReportFailed on errors
""" """
mocker.patch("ahriman.core.report.html.HTML.generate", side_effect=Exception()) mocker.patch("ahriman.core.report.html.HTML.generate", side_effect=Exception)
_, repository_id = configuration.check_loaded() _, repository_id = configuration.check_loaded()
with pytest.raises(ReportError): with pytest.raises(ReportError):

View File

@ -41,7 +41,7 @@ def test_send_failed(telegram: Telegram, mocker: MockerFixture) -> None:
""" """
must reraise generic exception must reraise generic exception
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
with pytest.raises(Exception): with pytest.raises(Exception):
telegram._send("a text") telegram._send("a text")
@ -50,7 +50,7 @@ def test_send_failed_http_error(telegram: Telegram, mocker: MockerFixture) -> No
""" """
must reraise http exception must reraise http exception
""" """
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
with pytest.raises(requests.HTTPError): with pytest.raises(requests.HTTPError):
telegram._send("a text") telegram._send("a text")

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 typing import Any from typing import Any
@ -13,34 +14,214 @@ from ahriman.models.packagers import Packagers
from ahriman.models.user import User from ahriman.models.user import User
def test_archive_lookup(executor: Executor, package_ahriman: Package, package_python_schedule: Package,
mocker: MockerFixture) -> None:
"""
must existing packages which match the version
"""
mocker.patch("ahriman.models.repository_paths.RepositoryPaths.preserve_owner")
mocker.patch("pathlib.Path.iterdir", return_value=[
Path("1.pkg.tar.zst"),
Path("2.pkg.tar.zst"),
Path("3.pkg.tar.zst"),
])
mocker.patch("ahriman.models.package.Package.from_archive", side_effect=[
package_ahriman,
package_python_schedule,
replace(package_ahriman, version="1"),
])
glob_mock = mocker.patch("pathlib.Path.glob", return_value=[Path("1.pkg.tar.xz")])
assert list(executor._archive_lookup(package_ahriman)) == [Path("1.pkg.tar.xz")]
glob_mock.assert_called_once_with(f"{package_ahriman.packages[package_ahriman.base].filename}*")
def test_archive_lookup_version_mismatch(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must return nothing if no packages found with the same version
"""
mocker.patch("ahriman.models.repository_paths.RepositoryPaths.preserve_owner")
mocker.patch("pathlib.Path.iterdir", return_value=[
Path("1.pkg.tar.zst"),
])
mocker.patch("ahriman.models.package.Package.from_archive", return_value=replace(package_ahriman, version="1"))
assert list(executor._archive_lookup(package_ahriman)) == []
def test_archive_lookup_architecture_mismatch(executor: Executor, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must return nothing if architecture doesn't match
"""
package_ahriman.packages[package_ahriman.base].architecture = "x86_64"
mocker.patch("ahriman.core.repository.executor.Executor.architecture", return_value="i686")
mocker.patch("ahriman.models.repository_paths.RepositoryPaths.preserve_owner")
mocker.patch("pathlib.Path.iterdir", return_value=[
Path("1.pkg.tar.zst"),
])
mocker.patch("ahriman.models.package.Package.from_archive", return_value=package_ahriman)
assert list(executor._archive_lookup(package_ahriman)) == []
def test_archive_rename(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must correctly remove package archive
"""
path = "gconf-3.2.6+11+g07808097-10-x86_64.pkg.tar.zst"
safe_path = "gconf-3.2.6-11-g07808097-10-x86_64.pkg.tar.zst"
package_ahriman.packages[package_ahriman.base].filename = path
rename_mock = mocker.patch("ahriman.core.repository.executor.atomic_move")
executor._archive_rename(package_ahriman.packages[package_ahriman.base], package_ahriman.base)
rename_mock.assert_called_once_with(executor.paths.packages / path, executor.paths.packages / safe_path)
assert package_ahriman.packages[package_ahriman.base].filename == safe_path
def test_archive_rename_empty_filename(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must skip renaming if filename is not set
"""
package_ahriman.packages[package_ahriman.base].filename = None
rename_mock = mocker.patch("ahriman.core.repository.executor.atomic_move")
executor._archive_rename(package_ahriman.packages[package_ahriman.base], package_ahriman.base)
rename_mock.assert_not_called()
def test_package_build(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must build single package
"""
mocker.patch("ahriman.core.build_tools.task.Task.build", return_value=[Path(package_ahriman.base)])
status_client_mock = mocker.patch("ahriman.core.status.Client.set_building")
init_mock = mocker.patch("ahriman.core.build_tools.task.Task.init", return_value="sha")
package_mock = mocker.patch("ahriman.models.package.Package.from_build", return_value=package_ahriman)
lookup_mock = mocker.patch("ahriman.core.repository.executor.Executor._archive_lookup", return_value=[])
with_packages_mock = mocker.patch("ahriman.models.package.Package.with_packages")
rename_mock = mocker.patch("ahriman.core.repository.executor.atomic_move")
assert executor._package_build(package_ahriman, Path("local"), "packager", None) == "sha"
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)
package_mock.assert_called_once_with(Path("local"), executor.architecture, None)
lookup_mock.assert_called_once_with(package_ahriman)
with_packages_mock.assert_called_once_with([Path(package_ahriman.base)], executor.pacman)
rename_mock.assert_called_once_with(Path(package_ahriman.base), executor.paths.packages / package_ahriman.base)
def test_package_build_copy(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must copy package from archive if there are already built ones
"""
path = package_ahriman.packages[package_ahriman.base].filepath
mocker.patch("ahriman.core.build_tools.task.Task.build", return_value=[Path(package_ahriman.base)])
mocker.patch("ahriman.core.build_tools.task.Task.init")
mocker.patch("ahriman.models.package.Package.from_build", return_value=package_ahriman)
mocker.patch("ahriman.core.repository.executor.Executor._archive_lookup", return_value=[path])
mocker.patch("ahriman.core.repository.executor.atomic_move")
mocker.patch("ahriman.models.package.Package.with_packages")
copy_mock = mocker.patch("shutil.copy")
rename_mock = mocker.patch("ahriman.core.repository.executor.atomic_move")
executor._package_build(package_ahriman, Path("local"), "packager", None)
copy_mock.assert_called_once_with(path, Path("local"))
rename_mock.assert_called_once_with(Path("local") / path, executor.paths.packages / path)
def test_package_remove(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must run remove for packages
"""
repo_remove_mock = mocker.patch("ahriman.core.alpm.repo.Repo.remove")
executor._package_remove(package_ahriman.base, package_ahriman.packages[package_ahriman.base].filepath)
repo_remove_mock.assert_called_once_with(
package_ahriman.base, package_ahriman.packages[package_ahriman.base].filepath)
def test_package_remove_failed(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must suppress errors during archive removal
"""
mocker.patch("ahriman.core.alpm.repo.Repo.remove", side_effect=Exception)
executor._package_remove(package_ahriman.base, package_ahriman.packages[package_ahriman.base].filepath)
def test_package_remove_base(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must run remove base from status client
"""
status_client_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_remove")
executor._package_remove_base(package_ahriman.base)
status_client_mock.assert_called_once_with(package_ahriman.base)
def test_package_remove_base_failed(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must suppress errors during base removal
"""
mocker.patch("ahriman.core.status.local_client.LocalClient.package_remove", side_effect=Exception)
executor._package_remove_base(package_ahriman.base)
def test_package_update(executor: Executor, package_ahriman: Package, user: User, mocker: MockerFixture) -> None:
"""
must update built package in repository
"""
rename_mock = mocker.patch("ahriman.core.repository.executor.atomic_move")
symlink_mock = mocker.patch("pathlib.Path.symlink_to")
repo_add_mock = mocker.patch("ahriman.core.alpm.repo.Repo.add")
sign_package_mock = mocker.patch("ahriman.core.sign.gpg.GPG.process_sign_package", side_effect=lambda fn, _: [fn])
filepath = next(package.filepath for package in package_ahriman.packages.values())
executor._package_update(filepath, package_ahriman.base, user.key)
# must move files (once)
rename_mock.assert_called_once_with(
executor.paths.packages / filepath, executor.paths.archive_for(package_ahriman.base) / filepath)
# must sign package
sign_package_mock.assert_called_once_with(executor.paths.packages / filepath, user.key)
# symlink to the archive
symlink_mock.assert_called_once_with(
Path("..") /
".." /
".." /
executor.paths.archive_for(
package_ahriman.base).relative_to(
executor.paths.root) /
filepath)
# must add package
repo_add_mock.assert_called_once_with(executor.paths.repository / filepath)
def test_package_update_empty_filename(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must skip update for package which does not have path
"""
rename_mock = mocker.patch("ahriman.core.repository.executor.atomic_move")
executor._package_update(None, package_ahriman.base, None)
rename_mock.assert_not_called()
def test_process_build(executor: Executor, package_ahriman: Package, passwd: Any, mocker: MockerFixture) -> None: def test_process_build(executor: Executor, package_ahriman: Package, passwd: Any, mocker: MockerFixture) -> None:
""" """
must run build process must run build process
""" """
mocker.patch("ahriman.models.repository_paths.getpwuid", return_value=passwd) mocker.patch("ahriman.models.repository_paths.getpwuid", return_value=passwd)
mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_ahriman]) mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_ahriman])
mocker.patch("ahriman.core.build_tools.task.Task.build", return_value=[Path(package_ahriman.base)])
init_mock = mocker.patch("ahriman.core.build_tools.task.Task.init", return_value="sha")
move_mock = mocker.patch("shutil.move")
status_client_mock = mocker.patch("ahriman.core.status.Client.set_building")
changes_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get", changes_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get",
return_value=Changes("commit", "change")) return_value=Changes("commit", "change"))
commit_sha_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_update") commit_sha_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_update")
depends_on_mock = mocker.patch("ahriman.core.build_tools.package_archive.PackageArchive.depends_on", depends_on_mock = mocker.patch("ahriman.core.build_tools.package_archive.PackageArchive.depends_on",
return_value=Dependencies()) return_value=Dependencies())
dependencies_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_dependencies_update") dependencies_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_dependencies_update")
with_packages_mock = mocker.patch("ahriman.models.package.Package.with_packages") build_mock = mocker.patch("ahriman.core.repository.executor.Executor._package_build", return_value="sha")
executor.process_build([package_ahriman], Packagers("packager"), bump_pkgrel=False) executor.process_build([package_ahriman], Packagers("packager"), bump_pkgrel=False)
init_mock.assert_called_once_with(pytest.helpers.anyvar(int), pytest.helpers.anyvar(int), None)
with_packages_mock.assert_called_once_with([Path(package_ahriman.base)], executor.pacman)
changes_mock.assert_called_once_with(package_ahriman.base) changes_mock.assert_called_once_with(package_ahriman.base)
build_mock.assert_called_once_with(package_ahriman, pytest.helpers.anyvar(Path, strict=True), None, None)
depends_on_mock.assert_called_once_with() depends_on_mock.assert_called_once_with()
dependencies_mock.assert_called_once_with(package_ahriman.base, Dependencies()) dependencies_mock.assert_called_once_with(package_ahriman.base, Dependencies())
# must move files (once)
move_mock.assert_called_once_with(Path(package_ahriman.base), executor.paths.packages / package_ahriman.base)
# must update status
status_client_mock.assert_called_once_with(package_ahriman.base)
commit_sha_mock.assert_called_once_with(package_ahriman.base, Changes("sha", "change")) commit_sha_mock.assert_called_once_with(package_ahriman.base, Changes("sha", "change"))
@ -50,7 +231,7 @@ def test_process_build_bump_pkgrel(executor: Executor, package_ahriman: Package,
""" """
mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_ahriman]) mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_ahriman])
mocker.patch("ahriman.core.build_tools.task.Task.build", return_value=[Path(package_ahriman.base)]) mocker.patch("ahriman.core.build_tools.task.Task.build", return_value=[Path(package_ahriman.base)])
mocker.patch("shutil.move") mocker.patch("ahriman.core.repository.executor.atomic_move")
init_mock = mocker.patch("ahriman.core.build_tools.task.Task.init") init_mock = mocker.patch("ahriman.core.build_tools.task.Task.init")
executor.process_build([package_ahriman], Packagers("packager"), bump_pkgrel=True) executor.process_build([package_ahriman], Packagers("packager"), bump_pkgrel=True)
@ -67,7 +248,7 @@ def test_process_build_failure(executor: Executor, package_ahriman: Package, moc
mocker.patch("ahriman.core.repository.executor.Executor.packages_built") mocker.patch("ahriman.core.repository.executor.Executor.packages_built")
mocker.patch("ahriman.core.build_tools.task.Task.build", return_value=[Path(package_ahriman.base)]) mocker.patch("ahriman.core.build_tools.task.Task.build", return_value=[Path(package_ahriman.base)])
mocker.patch("ahriman.core.build_tools.task.Task.init") mocker.patch("ahriman.core.build_tools.task.Task.init")
mocker.patch("shutil.move", side_effect=Exception()) mocker.patch("ahriman.core.repository.executor.atomic_move", side_effect=Exception)
status_client_mock = mocker.patch("ahriman.core.status.Client.set_failed") status_client_mock = mocker.patch("ahriman.core.status.Client.set_failed")
executor.process_build([package_ahriman]) executor.process_build([package_ahriman])
@ -79,15 +260,15 @@ def test_process_remove_base(executor: Executor, package_ahriman: Package, mocke
must run remove process for whole base must run remove process for whole base
""" """
mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_ahriman]) mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_ahriman])
repo_remove_mock = mocker.patch("ahriman.core.alpm.repo.Repo.remove") remove_mock = mocker.patch("ahriman.core.repository.executor.Executor._package_remove")
status_client_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_remove") base_remove_mock = mocker.patch("ahriman.core.repository.executor.Executor._package_remove_base")
executor.process_remove([package_ahriman.base]) executor.process_remove([package_ahriman.base])
# must remove via alpm wrapper # must remove via alpm wrapper
repo_remove_mock.assert_called_once_with( remove_mock.assert_called_once_with(
package_ahriman.base, package_ahriman.packages[package_ahriman.base].filepath) package_ahriman.base, package_ahriman.packages[package_ahriman.base].filepath)
# must update status and remove package files # must update status and remove package files
status_client_mock.assert_called_once_with(package_ahriman.base) base_remove_mock.assert_called_once_with(package_ahriman.base)
def test_process_remove_with_debug(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None: def test_process_remove_with_debug(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None:
@ -99,12 +280,12 @@ def test_process_remove_with_debug(executor: Executor, package_ahriman: Package,
f"{package_ahriman.base}-debug": package_ahriman.packages[package_ahriman.base], f"{package_ahriman.base}-debug": package_ahriman.packages[package_ahriman.base],
} }
mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_ahriman]) mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_ahriman])
mocker.patch("ahriman.core.status.local_client.LocalClient.package_remove") mocker.patch("ahriman.core.repository.executor.Executor._package_remove_base")
repo_remove_mock = mocker.patch("ahriman.core.alpm.repo.Repo.remove") remove_mock = mocker.patch("ahriman.core.repository.executor.Executor._package_remove")
executor.process_remove([package_ahriman.base]) executor.process_remove([package_ahriman.base])
# must remove via alpm wrapper # must remove via alpm wrapper
repo_remove_mock.assert_has_calls([ remove_mock.assert_has_calls([
MockCall(package_ahriman.base, package_ahriman.packages[package_ahriman.base].filepath), MockCall(package_ahriman.base, package_ahriman.packages[package_ahriman.base].filepath),
MockCall(f"{package_ahriman.base}-debug", package_ahriman.packages[package_ahriman.base].filepath), MockCall(f"{package_ahriman.base}-debug", package_ahriman.packages[package_ahriman.base].filepath),
]) ])
@ -116,12 +297,12 @@ def test_process_remove_base_multiple(executor: Executor, package_python_schedul
must run remove process for whole base with multiple packages must run remove process for whole base with multiple packages
""" """
mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_python_schedule]) mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_python_schedule])
repo_remove_mock = mocker.patch("ahriman.core.alpm.repo.Repo.remove") remove_mock = mocker.patch("ahriman.core.repository.executor.Executor._package_remove")
status_client_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_remove") status_client_mock = mocker.patch("ahriman.core.repository.executor.Executor._package_remove_base")
executor.process_remove([package_python_schedule.base]) executor.process_remove([package_python_schedule.base])
# must remove via alpm wrapper # must remove via alpm wrapper
repo_remove_mock.assert_has_calls([ remove_mock.assert_has_calls([
MockCall(package, props.filepath) MockCall(package, props.filepath)
for package, props in package_python_schedule.packages.items() for package, props in package_python_schedule.packages.items()
], any_order=True) ], any_order=True)
@ -135,45 +316,27 @@ def test_process_remove_base_single(executor: Executor, package_python_schedule:
must run remove process for single package in base must run remove process for single package in base
""" """
mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_python_schedule]) mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_python_schedule])
repo_remove_mock = mocker.patch("ahriman.core.alpm.repo.Repo.remove") remove_mock = mocker.patch("ahriman.core.repository.executor.Executor._package_remove")
status_client_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_remove") status_client_mock = mocker.patch("ahriman.core.repository.executor.Executor._package_remove_base")
executor.process_remove(["python2-schedule"]) executor.process_remove(["python2-schedule"])
# must remove via alpm wrapper # must remove via alpm wrapper
repo_remove_mock.assert_called_once_with( remove_mock.assert_called_once_with(
"python2-schedule", package_python_schedule.packages["python2-schedule"].filepath) "python2-schedule", package_python_schedule.packages["python2-schedule"].filepath)
# must not update status # must not update status
status_client_mock.assert_not_called() status_client_mock.assert_not_called()
def test_process_remove_failed(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must suppress tree clear errors during package base removal
"""
mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_ahriman])
mocker.patch("ahriman.core.status.local_client.LocalClient.package_remove", side_effect=Exception())
executor.process_remove([package_ahriman.base])
def test_process_remove_tree_clear_failed(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must suppress remove errors
"""
mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_ahriman])
mocker.patch("ahriman.core.alpm.repo.Repo.remove", side_effect=Exception())
executor.process_remove([package_ahriman.base])
def test_process_remove_nothing(executor: Executor, package_ahriman: Package, package_python_schedule: Package, def test_process_remove_nothing(executor: Executor, package_ahriman: Package, package_python_schedule: Package,
mocker: MockerFixture) -> None: mocker: MockerFixture) -> None:
""" """
must not remove anything if it was not requested must not remove anything if it was not requested
""" """
mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_ahriman]) mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_ahriman])
repo_remove_mock = mocker.patch("ahriman.core.alpm.repo.Repo.remove") remove_mock = mocker.patch("ahriman.core.repository.executor.Executor._package_remove")
executor.process_remove([package_python_schedule.base]) executor.process_remove([package_python_schedule.base])
repo_remove_mock.assert_not_called() remove_mock.assert_not_called()
def test_process_remove_unknown(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None: def test_process_remove_unknown(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None:
@ -181,11 +344,11 @@ def test_process_remove_unknown(executor: Executor, package_ahriman: Package, mo
must remove unknown package base must remove unknown package base
""" """
mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[]) mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[])
repo_remove_mock = mocker.patch("ahriman.core.alpm.repo.Repo.remove") remove_mock = mocker.patch("ahriman.core.repository.executor.Executor._package_remove")
status_client_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_remove") status_client_mock = mocker.patch("ahriman.core.repository.executor.Executor._package_remove_base")
executor.process_remove([package_ahriman.base]) executor.process_remove([package_ahriman.base])
repo_remove_mock.assert_not_called() remove_mock.assert_not_called()
status_client_mock.assert_called_once_with(package_ahriman.base) status_client_mock.assert_called_once_with(package_ahriman.base)
@ -195,9 +358,8 @@ def test_process_update(executor: Executor, package_ahriman: Package, user: User
""" """
mocker.patch("ahriman.core.repository.executor.Executor.load_archives", return_value=[package_ahriman]) mocker.patch("ahriman.core.repository.executor.Executor.load_archives", return_value=[package_ahriman])
mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_ahriman]) mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_ahriman])
move_mock = mocker.patch("shutil.move") rename_mock = mocker.patch("ahriman.core.repository.executor.Executor._archive_rename")
repo_add_mock = mocker.patch("ahriman.core.alpm.repo.Repo.add") update_mock = mocker.patch("ahriman.core.repository.executor.Executor._package_update")
sign_package_mock = mocker.patch("ahriman.core.sign.gpg.GPG.process_sign_package", side_effect=lambda fn, _: [fn])
status_client_mock = mocker.patch("ahriman.core.status.Client.set_success") status_client_mock = mocker.patch("ahriman.core.status.Client.set_success")
remove_mock = mocker.patch("ahriman.core.repository.executor.Executor.process_remove") remove_mock = mocker.patch("ahriman.core.repository.executor.Executor.process_remove")
packager_mock = mocker.patch("ahriman.core.repository.executor.Executor.packager", return_value=user) packager_mock = mocker.patch("ahriman.core.repository.executor.Executor.packager", return_value=user)
@ -206,12 +368,8 @@ def test_process_update(executor: Executor, package_ahriman: Package, user: User
# must return complete # must return complete
assert executor.process_update([filepath], Packagers("packager")) assert executor.process_update([filepath], Packagers("packager"))
packager_mock.assert_called_once_with(Packagers("packager"), "ahriman") packager_mock.assert_called_once_with(Packagers("packager"), "ahriman")
# must move files (once) rename_mock.assert_called_once_with(package_ahriman.packages[package_ahriman.base], package_ahriman.base)
move_mock.assert_called_once_with(executor.paths.packages / filepath, executor.paths.repository / filepath) update_mock.assert_called_once_with(filepath.name, package_ahriman.base, user.key)
# must sign package
sign_package_mock.assert_called_once_with(executor.paths.packages / filepath, user.key)
# must add package
repo_add_mock.assert_called_once_with(executor.paths.repository / filepath)
# must update status # must update status
status_client_mock.assert_called_once_with(package_ahriman) status_client_mock.assert_called_once_with(package_ahriman)
# must clear directory # must clear directory
@ -226,58 +384,26 @@ def test_process_update_group(executor: Executor, package_python_schedule: Packa
""" """
must group single packages under one base must group single packages under one base
""" """
mocker.patch("shutil.move")
mocker.patch("ahriman.core.repository.executor.Executor.load_archives", return_value=[package_python_schedule]) mocker.patch("ahriman.core.repository.executor.Executor.load_archives", return_value=[package_python_schedule])
mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_python_schedule]) mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_python_schedule])
repo_add_mock = mocker.patch("ahriman.core.alpm.repo.Repo.add") update_mock = mocker.patch("ahriman.core.repository.executor.Executor._package_update")
status_client_mock = mocker.patch("ahriman.core.status.Client.set_success") status_client_mock = mocker.patch("ahriman.core.status.Client.set_success")
remove_mock = mocker.patch("ahriman.core.repository.executor.Executor.process_remove") remove_mock = mocker.patch("ahriman.core.repository.executor.Executor.process_remove")
executor.process_update([package.filepath for package in package_python_schedule.packages.values()]) executor.process_update([package.filepath for package in package_python_schedule.packages.values()])
repo_add_mock.assert_has_calls([ update_mock.assert_has_calls([
MockCall(executor.paths.repository / package.filepath) MockCall(package.filename, package_python_schedule.base, None)
for package in package_python_schedule.packages.values() for package in package_python_schedule.packages.values()
], any_order=True) ], any_order=True)
status_client_mock.assert_called_once_with(package_python_schedule) status_client_mock.assert_called_once_with(package_python_schedule)
remove_mock.assert_called_once_with([]) remove_mock.assert_called_once_with([])
def test_process_update_unsafe(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must encode file name
"""
path = "gconf-3.2.6+11+g07808097-10-x86_64.pkg.tar.zst"
safe_path = "gconf-3.2.6-11-g07808097-10-x86_64.pkg.tar.zst"
package_ahriman.packages[package_ahriman.base].filename = path
mocker.patch("ahriman.core.repository.executor.Executor.load_archives", return_value=[package_ahriman])
mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_ahriman])
move_mock = mocker.patch("shutil.move")
repo_add_mock = mocker.patch("ahriman.core.alpm.repo.Repo.add")
executor.process_update([Path(path)])
move_mock.assert_has_calls([
MockCall(executor.paths.packages / path, executor.paths.packages / safe_path),
MockCall(executor.paths.packages / safe_path, executor.paths.repository / safe_path)
])
repo_add_mock.assert_called_once_with(executor.paths.repository / safe_path)
def test_process_empty_filename(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must skip update for package which does not have path
"""
package_ahriman.packages[package_ahriman.base].filename = None
mocker.patch("ahriman.core.repository.executor.Executor.load_archives", return_value=[package_ahriman])
mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_ahriman])
executor.process_update([package.filepath for package in package_ahriman.packages.values()])
def test_process_update_failed(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None: def test_process_update_failed(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None:
""" """
must process update for failed package must process update for failed package
""" """
mocker.patch("shutil.move", side_effect=Exception()) mocker.patch("ahriman.core.repository.executor.Executor._package_update", side_effect=Exception)
mocker.patch("ahriman.core.repository.executor.Executor.load_archives", return_value=[package_ahriman]) mocker.patch("ahriman.core.repository.executor.Executor.load_archives", return_value=[package_ahriman])
mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_ahriman]) mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_ahriman])
status_client_mock = mocker.patch("ahriman.core.status.Client.set_failed") status_client_mock = mocker.patch("ahriman.core.status.Client.set_failed")
@ -294,8 +420,7 @@ def test_process_update_removed_package(executor: Executor, package_python_sched
without_python2 = Package.from_json(package_python_schedule.view()) without_python2 = Package.from_json(package_python_schedule.view())
del without_python2.packages["python2-schedule"] del without_python2.packages["python2-schedule"]
mocker.patch("shutil.move") mocker.patch("ahriman.core.repository.executor.Executor._package_update")
mocker.patch("ahriman.core.alpm.repo.Repo.add")
mocker.patch("ahriman.core.repository.executor.Executor.load_archives", return_value=[without_python2]) mocker.patch("ahriman.core.repository.executor.Executor.load_archives", return_value=[without_python2])
mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_python_schedule]) mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_python_schedule])
remove_mock = mocker.patch("ahriman.core.repository.executor.Executor.process_remove") remove_mock = mocker.patch("ahriman.core.repository.executor.Executor.process_remove")

View File

@ -40,7 +40,7 @@ def test_load_archives_failed(package_info: PackageInfo, mocker: MockerFixture)
""" """
must skip packages which cannot be loaded must skip packages which cannot be loaded
""" """
mocker.patch("ahriman.models.package.Package.from_archive", side_effect=Exception()) mocker.patch("ahriman.models.package.Package.from_archive", side_effect=Exception)
assert not package_info.load_archives([Path("a.pkg.tar.xz")]) assert not package_info.load_archives([Path("a.pkg.tar.xz")])

View File

@ -59,7 +59,7 @@ def test_updates_aur_failed(update_handler: UpdateHandler, package_ahriman: Pack
must update status via client for failed load must update status via client for failed load
""" """
mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.packages", return_value=[package_ahriman]) mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.packages", return_value=[package_ahriman])
mocker.patch("ahriman.models.package.Package.from_aur", side_effect=Exception()) mocker.patch("ahriman.models.package.Package.from_aur", side_effect=Exception)
status_client_mock = mocker.patch("ahriman.core.status.Client.set_failed") status_client_mock = mocker.patch("ahriman.core.status.Client.set_failed")
update_handler.updates_aur([], vcs=True) update_handler.updates_aur([], vcs=True)
@ -281,7 +281,7 @@ def test_updates_local_with_failures(update_handler: UpdateHandler, package_ahri
""" """
mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.packages") mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.packages")
mocker.patch("pathlib.Path.iterdir", return_value=[Path(package_ahriman.base)]) mocker.patch("pathlib.Path.iterdir", return_value=[Path(package_ahriman.base)])
mocker.patch("ahriman.core.build_tools.sources.Sources.fetch", side_effect=Exception()) mocker.patch("ahriman.core.build_tools.sources.Sources.fetch", side_effect=Exception)
assert not update_handler.updates_local(vcs=True) assert not update_handler.updates_local(vcs=True)
@ -336,6 +336,6 @@ def test_updates_manual_with_failures(update_handler: UpdateHandler, package_ahr
""" """
must process manual through the packages with failure must process manual through the packages with failure
""" """
mocker.patch("ahriman.core.database.SQLite.build_queue_get", side_effect=Exception()) mocker.patch("ahriman.core.database.SQLite.build_queue_get", side_effect=Exception)
mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.packages", return_value=[package_ahriman]) mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.packages", return_value=[package_ahriman])
assert update_handler.updates_manual() == [] assert update_handler.updates_manual() == []

View File

@ -83,7 +83,7 @@ def test_key_download_failure(gpg: GPG, mocker: MockerFixture) -> None:
""" """
must download the key from public server and log error if any (and raise it again) must download the key from public server and log error if any (and raise it again)
""" """
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
with pytest.raises(requests.HTTPError): with pytest.raises(requests.HTTPError):
gpg.key_download("keyserver.ubuntu.com", "0xE989490C") gpg.key_download("keyserver.ubuntu.com", "0xE989490C")

View File

@ -120,7 +120,7 @@ def test_configuration_reload_failed(web_client: WebClient, mocker: MockerFixtur
""" """
must suppress any exception happened during configuration reload must suppress any exception happened during configuration reload
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
web_client.configuration_reload() web_client.configuration_reload()
@ -128,7 +128,7 @@ def test_configuration_reload_failed_http_error(web_client: WebClient, mocker: M
""" """
must suppress HTTP exception happened during configuration reload must suppress HTTP exception happened during configuration reload
""" """
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
web_client.configuration_reload() web_client.configuration_reload()
@ -137,7 +137,7 @@ def test_configuration_reload_failed_suppress(web_client: WebClient, mocker: Moc
must suppress any exception happened during configuration reload and don't log must suppress any exception happened during configuration reload and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.configuration_reload() web_client.configuration_reload()
@ -149,7 +149,7 @@ def test_configuration_reload_failed_http_error_suppress(web_client: WebClient,
must suppress HTTP exception happened during configuration reload and don't log must suppress HTTP exception happened during configuration reload and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.configuration_reload() web_client.configuration_reload()
@ -172,7 +172,7 @@ def test_event_add_failed(web_client: WebClient, mocker: MockerFixture) -> None:
""" """
must suppress any exception happened during events creation must suppress any exception happened during events creation
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
web_client.event_add(Event("", "")) web_client.event_add(Event("", ""))
@ -180,7 +180,7 @@ def test_event_add_failed_http_error(web_client: WebClient, mocker: MockerFixtur
""" """
must suppress HTTP exception happened during events creation must suppress HTTP exception happened during events creation
""" """
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
web_client.event_add(Event("", "")) web_client.event_add(Event("", ""))
@ -189,7 +189,7 @@ def test_event_add_failed_suppress(web_client: WebClient, mocker: MockerFixture)
must suppress any exception happened during events creation and don't log must suppress any exception happened during events creation and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.event_add(Event("", "")) web_client.event_add(Event("", ""))
@ -201,7 +201,7 @@ def test_event_add_failed_http_error_suppress(web_client: WebClient, mocker: Moc
must suppress HTTP exception happened during events creation and don't log must suppress HTTP exception happened during events creation and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.event_add(Event("", "")) web_client.event_add(Event("", ""))
@ -271,7 +271,7 @@ def test_event_get_failed(web_client: WebClient, mocker: MockerFixture) -> None:
""" """
must suppress any exception happened during events fetch must suppress any exception happened during events fetch
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
web_client.event_get(None, None) web_client.event_get(None, None)
@ -279,7 +279,7 @@ def test_event_get_failed_http_error(web_client: WebClient, mocker: MockerFixtur
""" """
must suppress HTTP exception happened during events fetch must suppress HTTP exception happened during events fetch
""" """
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
web_client.event_get(None, None) web_client.event_get(None, None)
@ -288,7 +288,7 @@ def test_event_get_failed_suppress(web_client: WebClient, mocker: MockerFixture)
must suppress any exception happened during events fetch and don't log must suppress any exception happened during events fetch and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.event_get(None, None) web_client.event_get(None, None)
@ -300,7 +300,7 @@ def test_event_get_failed_http_error_suppress(web_client: WebClient, mocker: Moc
must suppress HTTP exception happened during events fetch and don't log must suppress HTTP exception happened during events fetch and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.event_get(None, None) web_client.event_get(None, None)
@ -322,7 +322,7 @@ def test_logs_rotate_failed(web_client: WebClient, mocker: MockerFixture) -> Non
""" """
must suppress any exception happened during logs rotation must suppress any exception happened during logs rotation
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
web_client.logs_rotate(42) web_client.logs_rotate(42)
@ -330,7 +330,7 @@ def test_logs_rotate_failed_http_error(web_client: WebClient, mocker: MockerFixt
""" """
must suppress HTTP exception happened during logs rotation must suppress HTTP exception happened during logs rotation
""" """
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
web_client.logs_rotate(42) web_client.logs_rotate(42)
@ -339,7 +339,7 @@ def test_logs_rotate_failed_suppress(web_client: WebClient, mocker: MockerFixtur
must suppress any exception happened during logs rotation and don't log must suppress any exception happened during logs rotation and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.logs_rotate(42) web_client.logs_rotate(42)
@ -351,7 +351,7 @@ def test_logs_rotate_failed_http_error_suppress(web_client: WebClient, mocker: M
must suppress HTTP exception happened during logs rotation and don't log must suppress HTTP exception happened during logs rotation and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.logs_rotate(42) web_client.logs_rotate(42)
@ -379,7 +379,7 @@ def test_package_changes_get_failed(web_client: WebClient, package_ahriman: Pack
""" """
must suppress any exception happened during changes fetch must suppress any exception happened during changes fetch
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
web_client.package_changes_get(package_ahriman.base) web_client.package_changes_get(package_ahriman.base)
@ -388,7 +388,7 @@ def test_package_changes_get_failed_http_error(web_client: WebClient, package_ah
""" """
must suppress HTTP exception happened during changes fetch must suppress HTTP exception happened during changes fetch
""" """
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
web_client.package_changes_get(package_ahriman.base) web_client.package_changes_get(package_ahriman.base)
@ -398,7 +398,7 @@ def test_package_changes_get_failed_suppress(web_client: WebClient, package_ahri
must suppress any exception happened during changes fetch and don't log must suppress any exception happened during changes fetch and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.package_changes_get(package_ahriman.base) web_client.package_changes_get(package_ahriman.base)
@ -411,7 +411,7 @@ def test_package_changes_get_failed_http_error_suppress(web_client: WebClient, p
must suppress HTTP exception happened during changes fetch and don't log must suppress HTTP exception happened during changes fetch and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.package_changes_get(package_ahriman.base) web_client.package_changes_get(package_ahriman.base)
@ -434,7 +434,7 @@ def test_package_changes_update_failed(web_client: WebClient, package_ahriman: P
""" """
must suppress any exception happened during changes update must suppress any exception happened during changes update
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
web_client.package_changes_update(package_ahriman.base, Changes()) web_client.package_changes_update(package_ahriman.base, Changes())
@ -443,7 +443,7 @@ def test_package_changes_update_failed_http_error(web_client: WebClient, package
""" """
must suppress HTTP exception happened during changes update must suppress HTTP exception happened during changes update
""" """
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
web_client.package_changes_update(package_ahriman.base, Changes()) web_client.package_changes_update(package_ahriman.base, Changes())
@ -453,7 +453,7 @@ def test_package_changes_update_failed_suppress(web_client: WebClient, package_a
must suppress any exception happened during changes update and don't log must suppress any exception happened during changes update and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.package_changes_update(package_ahriman.base, Changes()) web_client.package_changes_update(package_ahriman.base, Changes())
@ -466,7 +466,7 @@ def test_package_changes_update_failed_http_error_suppress(web_client: WebClient
must suppress HTTP exception happened during changes update and don't log must suppress HTTP exception happened during changes update and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.package_changes_update(package_ahriman.base, Changes()) web_client.package_changes_update(package_ahriman.base, Changes())
@ -495,7 +495,7 @@ def test_package_dependencies_get_failed(web_client: WebClient, package_ahriman:
""" """
must suppress any exception happened during dependencies fetch must suppress any exception happened during dependencies fetch
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
web_client.package_dependencies_get(package_ahriman.base) web_client.package_dependencies_get(package_ahriman.base)
@ -504,7 +504,7 @@ def test_package_dependencies_get_failed_http_error(web_client: WebClient, packa
""" """
must suppress HTTP exception happened during dependencies fetch must suppress HTTP exception happened during dependencies fetch
""" """
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
web_client.package_dependencies_get(package_ahriman.base) web_client.package_dependencies_get(package_ahriman.base)
@ -514,7 +514,7 @@ def test_package_dependencies_get_failed_suppress(web_client: WebClient, package
must suppress any exception happened during dependencies fetch and don't log must suppress any exception happened during dependencies fetch and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.package_dependencies_get(package_ahriman.base) web_client.package_dependencies_get(package_ahriman.base)
@ -527,7 +527,7 @@ def test_package_dependencies_get_failed_http_error_suppress(web_client: WebClie
must suppress HTTP exception happened during dependencies fetch and don't log must suppress HTTP exception happened during dependencies fetch and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.package_dependencies_get(package_ahriman.base) web_client.package_dependencies_get(package_ahriman.base)
@ -551,7 +551,7 @@ def test_package_dependencies_update_failed(web_client: WebClient, package_ahrim
""" """
must suppress any exception happened during dependencies update must suppress any exception happened during dependencies update
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
web_client.package_dependencies_update(package_ahriman.base, Dependencies()) web_client.package_dependencies_update(package_ahriman.base, Dependencies())
@ -560,7 +560,7 @@ def test_package_dependencies_update_failed_http_error(web_client: WebClient, pa
""" """
must suppress HTTP exception happened during dependencies update must suppress HTTP exception happened during dependencies update
""" """
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
web_client.package_dependencies_update(package_ahriman.base, Dependencies()) web_client.package_dependencies_update(package_ahriman.base, Dependencies())
@ -570,7 +570,7 @@ def test_package_dependencies_update_failed_suppress(web_client: WebClient, pack
must suppress any exception happened during dependencies update and don't log must suppress any exception happened during dependencies update and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.package_dependencies_update(package_ahriman.base, Dependencies()) web_client.package_dependencies_update(package_ahriman.base, Dependencies())
@ -583,7 +583,7 @@ def test_package_dependencies_update_failed_http_error_suppress(web_client: WebC
must suppress HTTP exception happened during dependencies update and don't log must suppress HTTP exception happened during dependencies update and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.package_dependencies_update(package_ahriman.base, Dependencies()) web_client.package_dependencies_update(package_ahriman.base, Dependencies())
@ -612,7 +612,7 @@ def test_package_get_failed(web_client: WebClient, mocker: MockerFixture) -> Non
""" """
must suppress any exception happened during status getting must suppress any exception happened during status getting
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
assert web_client.package_get(None) == [] assert web_client.package_get(None) == []
@ -620,7 +620,7 @@ def test_package_get_failed_http_error(web_client: WebClient, mocker: MockerFixt
""" """
must suppress HTTP exception happened during status getting must suppress HTTP exception happened during status getting
""" """
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
assert web_client.package_get(None) == [] assert web_client.package_get(None) == []
@ -668,7 +668,7 @@ def test_package_logs_add_failed(web_client: WebClient, log_record: logging.LogR
""" """
must pass exception during log post must pass exception during log post
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
log_record.package_base = package_ahriman.base log_record.package_base = package_ahriman.base
record = LogRecord(LogRecordId(package_ahriman.base, package_ahriman.version), record = LogRecord(LogRecordId(package_ahriman.base, package_ahriman.version),
log_record.created, log_record.getMessage()) log_record.created, log_record.getMessage())
@ -682,7 +682,7 @@ def test_package_logs_add_failed_http_error(web_client: WebClient, log_record: l
""" """
must pass HTTP exception during log post must pass HTTP exception during log post
""" """
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
log_record.package_base = package_ahriman.base log_record.package_base = package_ahriman.base
record = LogRecord(LogRecordId(package_ahriman.base, package_ahriman.version), record = LogRecord(LogRecordId(package_ahriman.base, package_ahriman.version),
log_record.created, log_record.getMessage()) log_record.created, log_record.getMessage())
@ -734,7 +734,7 @@ def test_package_logs_get_failed(web_client: WebClient, package_ahriman: Package
""" """
must suppress any exception happened during logs fetch must suppress any exception happened during logs fetch
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
web_client.package_logs_get(package_ahriman.base) web_client.package_logs_get(package_ahriman.base)
@ -743,7 +743,7 @@ def test_package_logs_get_failed_http_error(web_client: WebClient, package_ahrim
""" """
must suppress HTTP exception happened during logs fetch must suppress HTTP exception happened during logs fetch
""" """
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
web_client.package_logs_get(package_ahriman.base) web_client.package_logs_get(package_ahriman.base)
@ -753,7 +753,7 @@ def test_package_logs_get_failed_suppress(web_client: WebClient, package_ahriman
must suppress any exception happened during logs fetch and don't log must suppress any exception happened during logs fetch and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.package_logs_get(package_ahriman.base) web_client.package_logs_get(package_ahriman.base)
@ -766,7 +766,7 @@ def test_package_logs_get_failed_http_error_suppress(web_client: WebClient, pack
must suppress HTTP exception happened during logs fetch and don't log must suppress HTTP exception happened during logs fetch and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.package_logs_get(package_ahriman.base) web_client.package_logs_get(package_ahriman.base)
@ -788,7 +788,7 @@ def test_package_logs_remove_failed(web_client: WebClient, package_ahriman: Pack
""" """
must suppress any exception happened during logs removal must suppress any exception happened during logs removal
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
web_client.package_logs_remove(package_ahriman.base, "42") web_client.package_logs_remove(package_ahriman.base, "42")
@ -797,7 +797,7 @@ def test_package_logs_remove_failed_http_error(web_client: WebClient, package_ah
""" """
must suppress HTTP exception happened during logs removal must suppress HTTP exception happened during logs removal
""" """
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
web_client.package_logs_remove(package_ahriman.base, "42") web_client.package_logs_remove(package_ahriman.base, "42")
@ -807,7 +807,7 @@ def test_package_logs_remove_failed_suppress(web_client: WebClient, package_ahri
must suppress any exception happened during logs removal and don't log must suppress any exception happened during logs removal and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.package_logs_remove(package_ahriman.base, "42") web_client.package_logs_remove(package_ahriman.base, "42")
@ -820,7 +820,7 @@ def test_package_logs_remove_failed_http_error_suppress(web_client: WebClient, p
must suppress HTTP exception happened during logs removal and don't log must suppress HTTP exception happened during logs removal and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.package_logs_remove(package_ahriman.base, "42") web_client.package_logs_remove(package_ahriman.base, "42")
@ -847,7 +847,7 @@ def test_package_patches_get_failed(web_client: WebClient, package_ahriman: Pack
""" """
must suppress any exception happened during patches fetch must suppress any exception happened during patches fetch
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
web_client.package_patches_get(package_ahriman.base, None) web_client.package_patches_get(package_ahriman.base, None)
@ -856,7 +856,7 @@ def test_package_patches_get_failed_http_error(web_client: WebClient, package_ah
""" """
must suppress HTTP exception happened during patches fetch must suppress HTTP exception happened during patches fetch
""" """
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
web_client.package_patches_get(package_ahriman.base, None) web_client.package_patches_get(package_ahriman.base, None)
@ -866,7 +866,7 @@ def test_package_patches_get_failed_suppress(web_client: WebClient, package_ahri
must suppress any exception happened during patches fetch and don't log must suppress any exception happened during patches fetch and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.package_patches_get(package_ahriman.base, None) web_client.package_patches_get(package_ahriman.base, None)
@ -879,7 +879,7 @@ def test_package_patches_get_failed_http_error_suppress(web_client: WebClient, p
must suppress HTTP exception happened during patches fetch and don't log must suppress HTTP exception happened during patches fetch and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.package_patches_get(package_ahriman.base, None) web_client.package_patches_get(package_ahriman.base, None)
@ -901,7 +901,7 @@ def test_package_patches_update_failed(web_client: WebClient, package_ahriman: P
""" """
must suppress any exception happened during patches update must suppress any exception happened during patches update
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
web_client.package_patches_update(package_ahriman.base, PkgbuildPatch("key", "value")) web_client.package_patches_update(package_ahriman.base, PkgbuildPatch("key", "value"))
@ -910,7 +910,7 @@ def test_package_patches_update_failed_http_error(web_client: WebClient, package
""" """
must suppress HTTP exception happened during patches update must suppress HTTP exception happened during patches update
""" """
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
web_client.package_patches_update(package_ahriman.base, PkgbuildPatch("key", "value")) web_client.package_patches_update(package_ahriman.base, PkgbuildPatch("key", "value"))
@ -920,7 +920,7 @@ def test_package_patches_update_failed_suppress(web_client: WebClient, package_a
must suppress any exception happened during patches update and don't log must suppress any exception happened during patches update and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.package_patches_update(package_ahriman.base, PkgbuildPatch("key", "value")) web_client.package_patches_update(package_ahriman.base, PkgbuildPatch("key", "value"))
@ -933,7 +933,7 @@ def test_package_patches_update_failed_http_error_suppress(web_client: WebClient
must suppress HTTP exception happened during patches update and don't log must suppress HTTP exception happened during patches update and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.package_patches_update(package_ahriman.base, PkgbuildPatch("key", "value")) web_client.package_patches_update(package_ahriman.base, PkgbuildPatch("key", "value"))
@ -954,7 +954,7 @@ def test_package_patches_remove_failed(web_client: WebClient, package_ahriman: P
""" """
must suppress any exception happened during patches removal must suppress any exception happened during patches removal
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
web_client.package_patches_remove(package_ahriman.base, None) web_client.package_patches_remove(package_ahriman.base, None)
@ -963,7 +963,7 @@ def test_package_patches_remove_failed_http_error(web_client: WebClient, package
""" """
must suppress HTTP exception happened during patches removal must suppress HTTP exception happened during patches removal
""" """
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
web_client.package_patches_remove(package_ahriman.base, None) web_client.package_patches_remove(package_ahriman.base, None)
@ -973,7 +973,7 @@ def test_package_patches_remove_failed_suppress(web_client: WebClient, package_a
must suppress any exception happened during patches removal and don't log must suppress any exception happened during patches removal and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.package_patches_remove(package_ahriman.base, None) web_client.package_patches_remove(package_ahriman.base, None)
@ -986,7 +986,7 @@ def test_package_patches_remove_failed_http_error_suppress(web_client: WebClient
must suppress HTTP exception happened during patches removal and don't log must suppress HTTP exception happened during patches removal and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.package_patches_remove(package_ahriman.base, None) web_client.package_patches_remove(package_ahriman.base, None)
@ -1008,7 +1008,7 @@ def test_package_remove_failed(web_client: WebClient, package_ahriman: Package,
""" """
must suppress any exception happened during removal must suppress any exception happened during removal
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
web_client.package_remove(package_ahriman.base) web_client.package_remove(package_ahriman.base)
@ -1017,7 +1017,7 @@ def test_package_remove_failed_http_error(web_client: WebClient, package_ahriman
""" """
must suppress HTTP exception happened during removal must suppress HTTP exception happened during removal
""" """
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
web_client.package_remove(package_ahriman.base) web_client.package_remove(package_ahriman.base)
@ -1039,7 +1039,7 @@ def test_package_status_update_failed(web_client: WebClient, package_ahriman: Pa
""" """
must suppress any exception happened during update must suppress any exception happened during update
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
web_client.package_status_update(package_ahriman.base, BuildStatusEnum.Unknown) web_client.package_status_update(package_ahriman.base, BuildStatusEnum.Unknown)
@ -1048,7 +1048,7 @@ def test_package_status_update_failed_http_error(web_client: WebClient, package_
""" """
must suppress HTTP exception happened during update must suppress HTTP exception happened during update
""" """
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
web_client.package_status_update(package_ahriman.base, BuildStatusEnum.Unknown) web_client.package_status_update(package_ahriman.base, BuildStatusEnum.Unknown)
@ -1068,7 +1068,7 @@ def test_package_update_failed(web_client: WebClient, package_ahriman: Package,
""" """
must suppress any exception happened during addition must suppress any exception happened during addition
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
web_client.package_update(package_ahriman, BuildStatusEnum.Unknown) web_client.package_update(package_ahriman, BuildStatusEnum.Unknown)
@ -1077,7 +1077,7 @@ def test_package_update_failed_http_error(web_client: WebClient, package_ahriman
""" """
must suppress HTTP exception happened during addition must suppress HTTP exception happened during addition
""" """
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
web_client.package_update(package_ahriman, BuildStatusEnum.Unknown) web_client.package_update(package_ahriman, BuildStatusEnum.Unknown)
@ -1086,7 +1086,7 @@ def test_package_update_failed_suppress(web_client: WebClient, package_ahriman:
must suppress any exception happened during addition and don't log must suppress any exception happened during addition and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.package_update(package_ahriman, BuildStatusEnum.Unknown) web_client.package_update(package_ahriman, BuildStatusEnum.Unknown)
@ -1099,7 +1099,7 @@ def test_package_update_failed_http_error_suppress(web_client: WebClient, packag
must suppress HTTP exception happened during addition and don't log must suppress HTTP exception happened during addition and don't log
""" """
web_client.suppress_errors = True web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
logging_mock = mocker.patch("logging.exception") logging_mock = mocker.patch("logging.exception")
web_client.package_update(package_ahriman, BuildStatusEnum.Unknown) web_client.package_update(package_ahriman, BuildStatusEnum.Unknown)
@ -1127,7 +1127,7 @@ def test_status_get_failed(web_client: WebClient, mocker: MockerFixture) -> None
""" """
must suppress any exception happened during web service status getting must suppress any exception happened during web service status getting
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
assert web_client.status_get().architecture is None assert web_client.status_get().architecture is None
@ -1135,7 +1135,7 @@ def test_status_get_failed_http_error(web_client: WebClient, mocker: MockerFixtu
""" """
must suppress HTTP exception happened during web service status getting must suppress HTTP exception happened during web service status getting
""" """
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
assert web_client.status_get().architecture is None assert web_client.status_get().architecture is None
@ -1159,7 +1159,7 @@ def test_status_update_self_failed(web_client: WebClient, mocker: MockerFixture)
""" """
must suppress any exception happened during service update must suppress any exception happened during service update
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception)
web_client.status_update(BuildStatusEnum.Unknown) web_client.status_update(BuildStatusEnum.Unknown)
@ -1167,5 +1167,5 @@ def test_status_update_failed_http_error(web_client: WebClient, mocker: MockerFi
""" """
must suppress HTTP exception happened during service update must suppress HTTP exception happened during service update
""" """
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
web_client.status_update(BuildStatusEnum.Unknown) web_client.status_update(BuildStatusEnum.Unknown)

View File

@ -1,4 +1,5 @@
import datetime import datetime
import fcntl
import logging import logging
import os import os
import pytest import pytest
@ -9,15 +10,48 @@ from typing import Any
from unittest.mock import call as MockCall from unittest.mock import call as MockCall
from ahriman.core.exceptions import BuildError, CalledProcessError, OptionError, UnsafeRunError from ahriman.core.exceptions import BuildError, CalledProcessError, OptionError, UnsafeRunError
from ahriman.core.utils import check_output, check_user, dataclass_view, enum_values, extract_user, filter_json, \ from ahriman.core.utils import (
full_version, minmax, package_like, parse_version, partition, pretty_datetime, pretty_interval, pretty_size, \ atomic_move,
safe_filename, srcinfo_property, srcinfo_property_list, trim_package, utcnow, walk check_output,
check_user,
dataclass_view,
enum_values,
extract_user,
filelock,
filter_json,
full_version,
minmax,
package_like,
parse_version,
partition,
pretty_datetime,
pretty_interval,
pretty_size,
safe_filename,
srcinfo_property,
srcinfo_property_list,
trim_package,
utcnow,
walk,
)
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.package_source import PackageSource from ahriman.models.package_source import PackageSource
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
from ahriman.models.repository_paths import RepositoryPaths from ahriman.models.repository_paths import RepositoryPaths
def test_atomic_move(mocker: MockerFixture) -> None:
"""
must move file with locking
"""
filelock_mock = mocker.patch("ahriman.core.utils.filelock")
move_mock = mocker.patch("shutil.move")
atomic_move(Path("source"), Path("destination"))
filelock_mock.assert_called_once_with(Path("destination"))
move_mock.assert_called_once_with(Path("source"), Path("destination"))
def test_check_output(mocker: MockerFixture) -> None: def test_check_output(mocker: MockerFixture) -> None:
""" """
must run command and log result must run command and log result
@ -237,6 +271,53 @@ def test_extract_user() -> None:
assert extract_user() == "doas" assert extract_user() == "doas"
def test_filelock(mocker: MockerFixture) -> None:
"""
must perform file locking
"""
lock_mock = mocker.patch("fcntl.flock")
open_mock = mocker.patch("pathlib.Path.open", autospec=True)
unlink_mock = mocker.patch("pathlib.Path.unlink")
with filelock(Path("local")):
pass
open_mock.assert_called_once_with(Path(".local"), "ab")
lock_mock.assert_has_calls([
MockCall(pytest.helpers.anyvar(int), fcntl.LOCK_EX),
MockCall(pytest.helpers.anyvar(int), fcntl.LOCK_UN),
])
unlink_mock.assert_called_once_with(missing_ok=True)
def test_filelock_remove_lock(mocker: MockerFixture) -> None:
"""
must remove lock file in case of exception
"""
mocker.patch("pathlib.Path.open", side_effect=Exception)
unlink_mock = mocker.patch("pathlib.Path.unlink")
with pytest.raises(Exception):
with filelock(Path("local")):
pass
unlink_mock.assert_called_once_with(missing_ok=True)
def test_filelock_unlock(mocker: MockerFixture) -> None:
"""
must unlock file in case of exception
"""
mocker.patch("pathlib.Path.open")
lock_mock = mocker.patch("fcntl.flock")
with pytest.raises(Exception):
with filelock(Path("local")):
raise Exception
lock_mock.assert_has_calls([
MockCall(pytest.helpers.anyvar(int), fcntl.LOCK_EX),
MockCall(pytest.helpers.anyvar(int), fcntl.LOCK_UN),
])
def test_filter_json(package_ahriman: Package) -> None: def test_filter_json(package_ahriman: Package) -> None:
""" """
must filter fields by known list must filter fields by known list

View File

@ -19,10 +19,9 @@ def test_configuration_schema(configuration: Configuration) -> None:
""" """
section = "console" section = "console"
configuration.set_option("report", "target", section) configuration.set_option("report", "target", section)
_, repository_id = configuration.check_loaded()
expected = {section: ReportTrigger.CONFIGURATION_SCHEMA[section]} expected = {section: ReportTrigger.CONFIGURATION_SCHEMA[section]}
assert ReportTrigger.configuration_schema(repository_id, configuration) == expected assert ReportTrigger.configuration_schema(configuration) == expected
def test_configuration_schema_no_section(configuration: Configuration) -> None: def test_configuration_schema_no_section(configuration: Configuration) -> None:
@ -31,9 +30,7 @@ def test_configuration_schema_no_section(configuration: Configuration) -> None:
""" """
section = "abracadabra" section = "abracadabra"
configuration.set_option("report", "target", section) configuration.set_option("report", "target", section)
_, repository_id = configuration.check_loaded() assert ReportTrigger.configuration_schema(configuration) == {}
assert ReportTrigger.configuration_schema(repository_id, configuration) == {}
def test_configuration_schema_no_schema(configuration: Configuration) -> None: def test_configuration_schema_no_schema(configuration: Configuration) -> None:
@ -43,17 +40,15 @@ def test_configuration_schema_no_schema(configuration: Configuration) -> None:
section = "abracadabra" section = "abracadabra"
configuration.set_option("report", "target", section) configuration.set_option("report", "target", section)
configuration.set_option(section, "key", "value") configuration.set_option(section, "key", "value")
_, repository_id = configuration.check_loaded()
assert ReportTrigger.configuration_schema(repository_id, configuration) == {} assert ReportTrigger.configuration_schema(configuration) == {}
def test_configuration_schema_empty(configuration: Configuration) -> None: def test_configuration_schema_empty(configuration: Configuration) -> None:
""" """
must return default schema if no configuration set must return default schema if no configuration set
""" """
_, repository_id = configuration.check_loaded() assert ReportTrigger.configuration_schema(None) == ReportTrigger.CONFIGURATION_SCHEMA
assert ReportTrigger.configuration_schema(repository_id, None) == ReportTrigger.CONFIGURATION_SCHEMA
def test_configuration_schema_variables() -> None: def test_configuration_schema_variables() -> None:

View File

@ -49,7 +49,7 @@ def test_load_trigger_package_error_on_creation(trigger_loader: TriggerLoader, c
""" """
must raise InvalidException on trigger initialization if any exception is thrown must raise InvalidException on trigger initialization if any exception is thrown
""" """
mocker.patch("ahriman.core.triggers.trigger.Trigger.__init__", side_effect=Exception()) mocker.patch("ahriman.core.triggers.trigger.Trigger.__init__", side_effect=Exception)
_, repository_id = configuration.check_loaded() _, repository_id = configuration.check_loaded()
with pytest.raises(ExtensionError): with pytest.raises(ExtensionError):
@ -67,7 +67,7 @@ def test_load_trigger_class_package_invalid_import(trigger_loader: TriggerLoader
""" """
must raise InvalidExtension on invalid import must raise InvalidExtension on invalid import
""" """
mocker.patch("importlib.import_module", side_effect=ModuleNotFoundError()) mocker.patch("importlib.import_module", side_effect=ModuleNotFoundError)
with pytest.raises(ExtensionError): with pytest.raises(ExtensionError):
trigger_loader.load_trigger_class("random.module") trigger_loader.load_trigger_class("random.module")
@ -137,7 +137,7 @@ def test_on_result_exception(trigger_loader: TriggerLoader, package_ahriman: Pac
""" """
must suppress exception during trigger run must suppress exception during trigger run
""" """
upload_mock = mocker.patch("ahriman.core.upload.UploadTrigger.on_result", side_effect=Exception()) upload_mock = mocker.patch("ahriman.core.upload.UploadTrigger.on_result", side_effect=Exception)
report_mock = mocker.patch("ahriman.core.report.ReportTrigger.on_result") report_mock = mocker.patch("ahriman.core.report.ReportTrigger.on_result")
log_mock = mocker.patch("logging.Logger.exception") log_mock = mocker.patch("logging.Logger.exception")
@ -153,38 +153,12 @@ def test_on_start(trigger_loader: TriggerLoader, mocker: MockerFixture) -> None:
""" """
upload_mock = mocker.patch("ahriman.core.upload.UploadTrigger.on_start") upload_mock = mocker.patch("ahriman.core.upload.UploadTrigger.on_start")
report_mock = mocker.patch("ahriman.core.report.ReportTrigger.on_start") report_mock = mocker.patch("ahriman.core.report.ReportTrigger.on_start")
atexit_mock = mocker.patch("atexit.register")
trigger_loader.on_start() trigger_loader.on_start()
assert trigger_loader._on_stop_requested
report_mock.assert_called_once_with() report_mock.assert_called_once_with()
upload_mock.assert_called_once_with() upload_mock.assert_called_once_with()
atexit_mock.assert_called_once_with(trigger_loader.on_stop)
def test_on_stop_with_on_start(configuration: Configuration, mocker: MockerFixture) -> None:
"""
must call on_stop on exit if on_start was called
"""
mocker.patch("ahriman.core.upload.UploadTrigger.on_start")
mocker.patch("ahriman.core.report.ReportTrigger.on_start")
on_stop_mock = mocker.patch("ahriman.core.triggers.trigger_loader.TriggerLoader.on_stop")
_, repository_id = configuration.check_loaded()
trigger_loader = TriggerLoader.load(repository_id, configuration)
trigger_loader.on_start()
del trigger_loader
on_stop_mock.assert_called_once_with()
def test_on_stop_without_on_start(configuration: Configuration, mocker: MockerFixture) -> None:
"""
must call not on_stop on exit if on_start wasn't called
"""
on_stop_mock = mocker.patch("ahriman.core.triggers.trigger_loader.TriggerLoader.on_stop")
_, repository_id = configuration.check_loaded()
trigger_loader = TriggerLoader.load(repository_id, configuration)
del trigger_loader
on_stop_mock.assert_not_called()
def test_on_stop(trigger_loader: TriggerLoader, mocker: MockerFixture) -> None: def test_on_stop(trigger_loader: TriggerLoader, mocker: MockerFixture) -> None:

View File

@ -172,7 +172,7 @@ def test_release_get_exception(github: GitHub, mocker: MockerFixture) -> None:
""" """
must re-raise non HTTPError exception must re-raise non HTTPError exception
""" """
mocker.patch("ahriman.core.upload.github.GitHub.make_request", side_effect=Exception()) mocker.patch("ahriman.core.upload.github.GitHub.make_request", side_effect=Exception)
with pytest.raises(Exception): with pytest.raises(Exception):
github.release_get() github.release_get()

View File

@ -13,7 +13,7 @@ def test_upload_failure(configuration: Configuration, mocker: MockerFixture) ->
""" """
must raise SyncFailed on errors must raise SyncFailed on errors
""" """
mocker.patch("ahriman.core.upload.rsync.Rsync.sync", side_effect=Exception()) mocker.patch("ahriman.core.upload.rsync.Rsync.sync", side_effect=Exception)
_, repository_id = configuration.check_loaded() _, repository_id = configuration.check_loaded()
with pytest.raises(SynchronizationError): with pytest.raises(SynchronizationError):

View File

@ -393,7 +393,7 @@ def test_actual_version_failed(package_tpacpi_bat_git: Package, configuration: C
""" """
must return same version in case if exception occurred must return same version in case if exception occurred
""" """
mocker.patch("ahriman.core.build_tools.task.Task.init", side_effect=Exception()) mocker.patch("ahriman.core.build_tools.task.Task.init", side_effect=Exception)
mocker.patch("pathlib.Path.glob", return_value=[Path("local")]) mocker.patch("pathlib.Path.glob", return_value=[Path("local")])
unlink_mock = mocker.patch("pathlib.Path.unlink") unlink_mock = mocker.patch("pathlib.Path.unlink")
@ -516,6 +516,15 @@ 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_vercmp(package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must call vercmp
"""
vercmp_mock = mocker.patch("ahriman.models.package.vercmp")
package_ahriman.vercmp("version")
vercmp_mock.assert_called_once_with(package_ahriman.version, "version")
def test_with_packages(package_ahriman: Package, package_python_schedule: Package, pacman: Pacman, def test_with_packages(package_ahriman: Package, package_python_schedule: Package, pacman: Pacman,
mocker: MockerFixture) -> None: mocker: MockerFixture) -> None:
""" """

View File

@ -62,7 +62,7 @@ def test_resolve_aur_no_access(repository_paths: RepositoryPaths, mocker: Mocker
""" """
must resolve auto type into the AUR package in case if we cannot read in suggested path must resolve auto type into the AUR package in case if we cannot read in suggested path
""" """
mocker.patch("pathlib.Path.is_dir", side_effect=PermissionError()) mocker.patch("pathlib.Path.is_dir", side_effect=PermissionError)
assert PackageSource.Auto.resolve("package", repository_paths) == PackageSource.AUR assert PackageSource.Auto.resolve("package", repository_paths) == PackageSource.AUR

View File

@ -248,6 +248,28 @@ def test_chown_invalid_path(repository_paths: RepositoryPaths) -> None:
repository_paths._chown(repository_paths.root.parent) repository_paths._chown(repository_paths.root.parent)
def test_archive_for(repository_paths: RepositoryPaths, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must correctly define archive path
"""
mocker.patch("pathlib.Path.is_dir", return_value=True)
path = repository_paths.archive_for(package_ahriman.base)
assert path == repository_paths.archive / "packages" / "a" / package_ahriman.base
def test_archive_for_create_tree(repository_paths: RepositoryPaths, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must create archive directory if it doesn't exist
"""
owner_mock = mocker.patch("ahriman.models.repository_paths.RepositoryPaths.preserve_owner")
mkdir_mock = mocker.patch("pathlib.Path.mkdir")
repository_paths.archive_for(package_ahriman.base)
owner_mock.assert_called_once_with(repository_paths.archive)
mkdir_mock.assert_called_once_with(mode=0o755, parents=True)
def test_cache_for(repository_paths: RepositoryPaths, package_ahriman: Package) -> None: def test_cache_for(repository_paths: RepositoryPaths, package_ahriman: Package) -> None:
""" """
must return correct path for cache directory must return correct path for cache directory
@ -287,13 +309,24 @@ def test_preserve_owner_specific(tmp_path: Path, repository_id: RepositoryId, mo
chown_mock.assert_has_calls([MockCall(repository_paths.root / "content" / "created2")]) chown_mock.assert_has_calls([MockCall(repository_paths.root / "content" / "created2")])
def test_preserve_owner_no_directory(tmp_path: Path, repository_id: RepositoryId, mocker: MockerFixture) -> None:
"""
must skip directory scan if it does not exist
"""
repository_paths = RepositoryPaths(tmp_path, repository_id)
chown_mock = mocker.patch("ahriman.models.repository_paths.RepositoryPaths._chown")
with repository_paths.preserve_owner(Path("empty")):
(repository_paths.root / "created1").touch()
chown_mock.assert_not_called()
def test_tree_clear(repository_paths: RepositoryPaths, package_ahriman: Package, mocker: MockerFixture) -> None: def test_tree_clear(repository_paths: RepositoryPaths, package_ahriman: Package, mocker: MockerFixture) -> None:
""" """
must remove any package related files must remove any package related files
""" """
paths = { paths = {
getattr(repository_paths, prop)(package_ahriman.base) repository_paths.cache_for(package_ahriman.base),
for prop in dir(repository_paths) if prop.endswith("_for")
} }
rmtree_mock = mocker.patch("shutil.rmtree") rmtree_mock = mocker.patch("shutil.rmtree")
@ -313,6 +346,7 @@ def test_tree_create(repository_paths: RepositoryPaths, mocker: MockerFixture) -
for prop in dir(repository_paths) for prop in dir(repository_paths)
if not prop.startswith("_") if not prop.startswith("_")
and prop not in ( and prop not in (
"archive_for",
"build_root", "build_root",
"logger_name", "logger_name",
"logger", "logger",

View File

@ -72,7 +72,7 @@ async def test_exception_handler_success(mocker: MockerFixture) -> None:
must pass 2xx and 3xx codes must pass 2xx and 3xx codes
""" """
request = pytest.helpers.request("", "", "") request = pytest.helpers.request("", "", "")
request_handler = AsyncMock(side_effect=HTTPNoContent()) request_handler = AsyncMock(side_effect=HTTPNoContent)
logging_mock = mocker.patch("logging.Logger.exception") logging_mock = mocker.patch("logging.Logger.exception")
handler = exception_handler(logging.getLogger()) handler = exception_handler(logging.getLogger())
@ -86,7 +86,7 @@ async def test_exception_handler_unauthorized(mocker: MockerFixture) -> None:
must handle unauthorized exception as json response must handle unauthorized exception as json response
""" """
request = pytest.helpers.request("", "", "") request = pytest.helpers.request("", "", "")
request_handler = AsyncMock(side_effect=HTTPUnauthorized()) request_handler = AsyncMock(side_effect=HTTPUnauthorized)
mocker.patch("ahriman.web.middlewares.exception_handler._is_templated_unauthorized", return_value=False) mocker.patch("ahriman.web.middlewares.exception_handler._is_templated_unauthorized", return_value=False)
render_mock = mocker.patch("aiohttp_jinja2.render_template") render_mock = mocker.patch("aiohttp_jinja2.render_template")
@ -101,7 +101,7 @@ async def test_exception_handler_unauthorized_templated(mocker: MockerFixture) -
must handle unauthorized exception as json response in html context must handle unauthorized exception as json response in html context
""" """
request = pytest.helpers.request("", "", "") request = pytest.helpers.request("", "", "")
request_handler = AsyncMock(side_effect=HTTPUnauthorized()) request_handler = AsyncMock(side_effect=HTTPUnauthorized)
mocker.patch("ahriman.web.middlewares.exception_handler._is_templated_unauthorized", return_value=True) mocker.patch("ahriman.web.middlewares.exception_handler._is_templated_unauthorized", return_value=True)
render_mock = mocker.patch("aiohttp_jinja2.render_template") render_mock = mocker.patch("aiohttp_jinja2.render_template")
@ -154,7 +154,7 @@ async def test_exception_handler_client_error(mocker: MockerFixture) -> None:
must handle client exception must handle client exception
""" """
request = pytest.helpers.request("", "", "") request = pytest.helpers.request("", "", "")
request_handler = AsyncMock(side_effect=HTTPBadRequest()) request_handler = AsyncMock(side_effect=HTTPBadRequest)
logging_mock = mocker.patch("logging.Logger.exception") logging_mock = mocker.patch("logging.Logger.exception")
handler = exception_handler(logging.getLogger()) handler = exception_handler(logging.getLogger())
@ -168,7 +168,7 @@ async def test_exception_handler_server_error(mocker: MockerFixture) -> None:
must handle server exception must handle server exception
""" """
request = pytest.helpers.request("", "", "") request = pytest.helpers.request("", "", "")
request_handler = AsyncMock(side_effect=HTTPInternalServerError()) request_handler = AsyncMock(side_effect=HTTPInternalServerError)
logging_mock = mocker.patch("logging.Logger.exception") logging_mock = mocker.patch("logging.Logger.exception")
handler = exception_handler(logging.getLogger()) handler = exception_handler(logging.getLogger())

View File

@ -86,7 +86,7 @@ async def test_on_startup_exception(application: Application, watcher: Watcher,
must throw exception on load error must throw exception on load error
""" """
mocker.patch("aiohttp.web.Application.__getitem__", return_value={"": watcher}) mocker.patch("aiohttp.web.Application.__getitem__", return_value={"": watcher})
mocker.patch("ahriman.core.status.watcher.Watcher.load", side_effect=Exception()) mocker.patch("ahriman.core.status.watcher.Watcher.load", side_effect=Exception)
with pytest.raises(InitializeError): with pytest.raises(InitializeError):
await _on_startup(application) await _on_startup(application)

View File

@ -60,7 +60,7 @@ async def test_get_process_exception(client: TestClient, mocker: MockerFixture)
""" """
must raise 404 on invalid PGP server response must raise 404 on invalid PGP server response
""" """
import_mock = mocker.patch("ahriman.core.sign.gpg.GPG.key_download", side_effect=Exception()) import_mock = mocker.patch("ahriman.core.sign.gpg.GPG.key_download", side_effect=Exception)
response_schema = pytest.helpers.schema_response(PGPView.get, code=400) response_schema = pytest.helpers.schema_response(PGPView.get, code=400)
response = await client.get("/api/v1/service/pgp", params={"key": "0xdeadbeaf", "server": "keyserver.ubuntu.com"}) response = await client.get("/api/v1/service/pgp", params={"key": "0xdeadbeaf", "server": "keyserver.ubuntu.com"})

View File

@ -109,7 +109,7 @@ async def test_post(client: TestClient, repository_paths: RepositoryPaths, mocke
local = Path("local") local = Path("local")
save_mock = pytest.helpers.patch_view(client.app, "save_file", save_mock = pytest.helpers.patch_view(client.app, "save_file",
AsyncMock(return_value=("filename", local / ".filename"))) AsyncMock(return_value=("filename", local / ".filename")))
rename_mock = mocker.patch("pathlib.Path.rename") rename_mock = mocker.patch("ahriman.web.views.v1.service.upload.atomic_move")
# no content validation here because it has invalid schema # no content validation here because it has invalid schema
data = FormData() data = FormData()
@ -118,7 +118,7 @@ async def test_post(client: TestClient, repository_paths: RepositoryPaths, mocke
response = await client.post("/api/v1/service/upload", data=data) response = await client.post("/api/v1/service/upload", data=data)
assert response.ok assert response.ok
save_mock.assert_called_once_with(pytest.helpers.anyvar(int), repository_paths.packages, max_body_size=None) save_mock.assert_called_once_with(pytest.helpers.anyvar(int), repository_paths.packages, max_body_size=None)
rename_mock.assert_called_once_with(local / "filename") rename_mock.assert_called_once_with(local / ".filename", local / "filename")
async def test_post_with_sig(client: TestClient, repository_paths: RepositoryPaths, mocker: MockerFixture) -> None: async def test_post_with_sig(client: TestClient, repository_paths: RepositoryPaths, mocker: MockerFixture) -> None:
@ -131,7 +131,7 @@ async def test_post_with_sig(client: TestClient, repository_paths: RepositoryPat
("filename", local / ".filename"), ("filename", local / ".filename"),
("filename.sig", local / ".filename.sig"), ("filename.sig", local / ".filename.sig"),
])) ]))
rename_mock = mocker.patch("pathlib.Path.rename") rename_mock = mocker.patch("ahriman.web.views.v1.service.upload.atomic_move")
# no content validation here because it has invalid schema # no content validation here because it has invalid schema
data = FormData() data = FormData()
@ -145,8 +145,8 @@ async def test_post_with_sig(client: TestClient, repository_paths: RepositoryPat
MockCall(pytest.helpers.anyvar(int), repository_paths.packages, max_body_size=None), MockCall(pytest.helpers.anyvar(int), repository_paths.packages, max_body_size=None),
]) ])
rename_mock.assert_has_calls([ rename_mock.assert_has_calls([
MockCall(local / "filename"), MockCall(local / ".filename", local / "filename"),
MockCall(local / "filename.sig"), MockCall(local / ".filename.sig", local / "filename.sig"),
]) ])

View File

@ -82,7 +82,7 @@ async def test_post_exception_inside(client: TestClient, mocker: MockerFixture)
exception handler must handle 500 errors exception handler must handle 500 errors
""" """
payload = {"status": BuildStatusEnum.Success.value} payload = {"status": BuildStatusEnum.Success.value}
mocker.patch("ahriman.core.status.watcher.Watcher.status_update", side_effect=Exception()) mocker.patch("ahriman.core.status.watcher.Watcher.status_update", side_effect=Exception)
response_schema = pytest.helpers.schema_response(StatusView.post, code=500) response_schema = pytest.helpers.schema_response(StatusView.post, code=500)
response = await client.post("/api/v1/status", json=payload) response = await client.post("/api/v1/status", json=payload)

View File

@ -40,7 +40,7 @@ async def test_post_unauthorized(client_with_auth: TestClient, mocker: MockerFix
""" """
must raise exception if unauthorized must raise exception if unauthorized
""" """
mocker.patch("ahriman.web.views.v1.user.logout.check_authorized", side_effect=HTTPUnauthorized()) mocker.patch("ahriman.web.views.v1.user.logout.check_authorized", side_effect=HTTPUnauthorized)
forget_mock = mocker.patch("ahriman.web.views.v1.user.logout.forget") forget_mock = mocker.patch("ahriman.web.views.v1.user.logout.forget")
response_schema = pytest.helpers.schema_response(LogoutView.post, code=401) response_schema = pytest.helpers.schema_response(LogoutView.post, code=401)

View File

@ -10,6 +10,9 @@ root = /
sync_files_database = no sync_files_database = no
use_ahriman_cache = no use_ahriman_cache = no
[archive]
keep_built_packages = 3
[auth] [auth]
client_id = client_id client_id = client_id
client_secret = client_secret client_secret = client_secret
@ -37,6 +40,9 @@ target =
[keyring] [keyring]
target = keyring target = keyring
[logs-rotation]
keep_last_logs = 5
[mirrorlist] [mirrorlist]
target = mirrorlist target = mirrorlist
servers = http://localhost servers = http://localhost