mirror of
https://github.com/arcan1s/ahriman.git
synced 2025-08-26 19:39:57 +00:00
Compare commits
19 Commits
c6306631e6
...
feature/tr
Author | SHA1 | Date | |
---|---|---|---|
e6df656ce3 | |||
c89f6ad98c | |||
c734f0815a | |||
dc3cee9449 | |||
0b2acfac9b | |||
c5d849d6a6 | |||
63ccb5fc11 | |||
04e554d096 | |||
10798b9ba3 | |||
358e3dc4d2 | |||
c13cd029bc | |||
ae32cc8fbb | |||
dff5b775a9 | |||
db3f20546e | |||
53368468a4 | |||
228c2cce51 | |||
f5aec4e5c1 | |||
9217c8c759 | |||
6392520e06 |
@ -165,6 +165,11 @@ Again, the most checks can be performed by `tox` command, though some additional
|
||||
|
||||
# Blank line again and package imports
|
||||
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.
|
||||
|
@ -100,6 +100,14 @@ ahriman.application.handlers.rebuild module
|
||||
:no-undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
ahriman.application.handlers.reload module
|
||||
------------------------------------------
|
||||
|
||||
.. automodule:: ahriman.application.handlers.reload
|
||||
:members:
|
||||
:no-undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
ahriman.application.handlers.remove module
|
||||
------------------------------------------
|
||||
|
||||
|
@ -132,6 +132,14 @@ ahriman.core.database.migrations.m015\_logs\_process\_id module
|
||||
:no-undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
ahriman.core.database.migrations.m016\_archive module
|
||||
-----------------------------------------------------
|
||||
|
||||
.. automodule:: ahriman.core.database.migrations.m016_archive
|
||||
:members:
|
||||
:no-undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
|
29
docs/ahriman.core.housekeeping.rst
Normal file
29
docs/ahriman.core.housekeeping.rst
Normal 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:
|
@ -15,6 +15,7 @@ Subpackages
|
||||
ahriman.core.distributed
|
||||
ahriman.core.formatters
|
||||
ahriman.core.gitremote
|
||||
ahriman.core.housekeeping
|
||||
ahriman.core.http
|
||||
ahriman.core.log
|
||||
ahriman.core.report
|
||||
|
@ -44,6 +44,14 @@ ahriman.web.schemas.changes\_schema module
|
||||
:no-undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
ahriman.web.schemas.configuration\_schema module
|
||||
------------------------------------------------
|
||||
|
||||
.. automodule:: ahriman.web.schemas.configuration_schema
|
||||
:members:
|
||||
:no-undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
ahriman.web.schemas.counters\_schema module
|
||||
-------------------------------------------
|
||||
|
||||
|
@ -12,6 +12,14 @@ ahriman.web.views.v1.service.add module
|
||||
:no-undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
ahriman.web.views.v1.service.config module
|
||||
------------------------------------------
|
||||
|
||||
.. automodule:: ahriman.web.views.v1.service.config
|
||||
:members:
|
||||
:no-undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
ahriman.web.views.v1.service.logs module
|
||||
----------------------------------------
|
||||
|
||||
|
@ -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.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.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.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.
|
||||
|
@ -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).
|
||||
|
||||
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.:
|
||||
|
||||
.. 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.
|
||||
* ``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.
|
||||
* ``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.
|
||||
|
||||
``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.
|
||||
* ``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
|
||||
--------------
|
||||
|
||||
@ -138,6 +146,8 @@ Build related configuration. Group name can refer to architecture, e.g. ``build:
|
||||
|
||||
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.
|
||||
|
||||
``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.
|
||||
|
||||
``keyring`` group
|
||||
--------------------
|
||||
-----------------
|
||||
|
||||
Keyring package generator plugin.
|
||||
|
||||
@ -198,6 +208,13 @@ Keyring generator plugin
|
||||
* ``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.
|
||||
|
||||
``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
|
||||
--------------------
|
||||
|
||||
|
@ -40,6 +40,7 @@ package_ahriman-core() {
|
||||
'rsync: sync by using rsync')
|
||||
install="$pkgbase.install"
|
||||
backup=('etc/ahriman.ini'
|
||||
'etc/ahriman.ini.d/00-housekeeping.ini'
|
||||
'etc/ahriman.ini.d/logging.ini')
|
||||
|
||||
cd "$pkgbase-$pkgver"
|
||||
@ -49,6 +50,7 @@ package_ahriman-core() {
|
||||
|
||||
# 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.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 "$srcdir/$pkgbase.sysusers" "$pkgdir/usr/lib/sysusers.d/$pkgbase.conf"
|
||||
|
@ -5,6 +5,7 @@ After=network.target
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/ahriman web
|
||||
ExecReload=/usr/bin/ahriman web-reload
|
||||
User=ahriman
|
||||
Group=ahriman
|
||||
|
||||
|
@ -7,8 +7,6 @@ logging = ahriman.ini.d/logging.ini
|
||||
;apply_migrations = yes
|
||||
; Path to the application SQLite database.
|
||||
database = ${repository:root}/ahriman.db
|
||||
; Keep last build logs for each package
|
||||
keep_last_logs = 5
|
||||
|
||||
[alpm]
|
||||
; Path to pacman system database cache.
|
||||
@ -45,9 +43,13 @@ triggers[] = ahriman.core.gitremote.RemotePullTrigger
|
||||
triggers[] = ahriman.core.report.ReportTrigger
|
||||
triggers[] = ahriman.core.upload.UploadTrigger
|
||||
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.
|
||||
triggers_known[] = ahriman.core.gitremote.RemotePullTrigger
|
||||
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.upload.UploadTrigger
|
||||
; Maximal age in seconds of the VCS packages before their version will be updated with its remote source.
|
||||
|
@ -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
|
@ -1,5 +1,6 @@
|
||||
[build]
|
||||
; 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.WorkerTrigger
|
||||
triggers_known[] = ahriman.core.support.KeyringTrigger
|
||||
|
@ -148,8 +148,19 @@
|
||||
|
||||
packageAddInput.addEventListener("keyup", _ => {
|
||||
clearTimeout(packageAddInput.requestTimeout);
|
||||
|
||||
// do not update datalist if search string didn't change yet
|
||||
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(_ => {
|
||||
const value = packageAddInput.value;
|
||||
|
||||
if (value.length >= 3) {
|
||||
makeRequest(
|
||||
|
@ -164,7 +164,7 @@
|
||||
function toggleTableAutoReload(interval) {
|
||||
clearInterval(tableAutoReloadTask);
|
||||
tableAutoReloadTask = toggleAutoReload(tableAutoReloadButton, interval, tableAutoReloadInput, _ => {
|
||||
if (!dashboardModal.classList.contains("show") &&
|
||||
if (!hasActiveModal() &&
|
||||
!hasActiveDropdown()) {
|
||||
packagesLoad();
|
||||
statusLoad();
|
||||
|
@ -68,6 +68,11 @@
|
||||
.some(el => el.classList.contains("show"));
|
||||
}
|
||||
|
||||
function hasActiveModal() {
|
||||
return Array.from(document.querySelectorAll(".modal"))
|
||||
.some(el => el.classList.contains("show"));
|
||||
}
|
||||
|
||||
function headerClass(status) {
|
||||
if (status === "pending") return ["bg-warning"];
|
||||
if (status === "building") return ["bg-warning"];
|
||||
|
70
src/ahriman/application/handlers/reload.py
Normal file
70
src/ahriman/application/handlers/reload.py
Normal file
@ -0,0 +1,70 @@
|
||||
#
|
||||
# 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 ahriman.application.application import Application
|
||||
from ahriman.application.handlers.handler import Handler, SubParserAction
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.models.repository_id import RepositoryId
|
||||
|
||||
|
||||
class Reload(Handler):
|
||||
"""
|
||||
web server reload handler
|
||||
"""
|
||||
|
||||
ALLOW_MULTI_ARCHITECTURE_RUN = False # system-wide action
|
||||
|
||||
@classmethod
|
||||
def run(cls, args: argparse.Namespace, repository_id: RepositoryId, configuration: Configuration, *,
|
||||
report: bool) -> None:
|
||||
"""
|
||||
callback for command line
|
||||
|
||||
Args:
|
||||
args(argparse.Namespace): command line args
|
||||
repository_id(RepositoryId): repository unique identifier
|
||||
configuration(Configuration): configuration instance
|
||||
report(bool): force enable or disable reporting
|
||||
"""
|
||||
application = Application(repository_id, configuration, report=True)
|
||||
client = application.repository.reporter
|
||||
client.configuration_reload()
|
||||
|
||||
@staticmethod
|
||||
def _set_web_reload_parser(root: SubParserAction) -> argparse.ArgumentParser:
|
||||
"""
|
||||
add parser for web reload subcommand
|
||||
|
||||
Args:
|
||||
root(SubParserAction): subparsers for the commands
|
||||
|
||||
Returns:
|
||||
argparse.ArgumentParser: created argument parser
|
||||
"""
|
||||
parser = root.add_parser("web-reload", help="reload configuration",
|
||||
description="reload web server configuration",
|
||||
epilog="This method forces the web server to reload its configuration. "
|
||||
"Note, however, that this method does not apply all configuration changes "
|
||||
"(like ports, authentication, etc)")
|
||||
parser.set_defaults(architecture="", lock=None, quiet=True, report=False, repository="", unsafe=True)
|
||||
return parser
|
||||
|
||||
arguments = [_set_web_reload_parser]
|
@ -28,6 +28,7 @@ from ahriman.core.alpm.remote import AUR, Official
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.exceptions import OptionError
|
||||
from ahriman.core.formatters import AurPrinter
|
||||
from ahriman.core.types import Comparable
|
||||
from ahriman.models.aur_package import AURPackage
|
||||
from ahriman.models.repository_id import RepositoryId
|
||||
|
||||
@ -115,7 +116,7 @@ class Search(Handler):
|
||||
raise OptionError(sort_by)
|
||||
# always sort by package name at the last
|
||||
# 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)
|
||||
return sorted(packages, key=comparator)
|
||||
|
||||
|
@ -25,6 +25,7 @@ from ahriman.application.application import Application
|
||||
from ahriman.application.handlers.handler import Handler, SubParserAction
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.formatters import PackagePrinter, StatusPrinter
|
||||
from ahriman.core.types import Comparable
|
||||
from ahriman.core.utils import enum_values
|
||||
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
|
||||
from ahriman.models.package import Package
|
||||
@ -64,8 +65,8 @@ class Status(Handler):
|
||||
|
||||
Status.check_status(args.exit_code, packages)
|
||||
|
||||
comparator: Callable[[tuple[Package, BuildStatus]], str] = lambda item: item[0].base
|
||||
filter_fn: Callable[[tuple[Package, BuildStatus]], bool] =\
|
||||
comparator: Callable[[tuple[Package, BuildStatus]], Comparable] = lambda item: item[0].base
|
||||
filter_fn: Callable[[tuple[Package, BuildStatus]], bool] = \
|
||||
lambda item: args.status is None or item[1].status == args.status
|
||||
for package, package_status in sorted(filter(filter_fn, packages), key=comparator):
|
||||
PackagePrinter(package, package_status)(verbose=args.info)
|
||||
|
@ -21,6 +21,7 @@ import argparse
|
||||
|
||||
from ahriman.application.handlers.handler import Handler, SubParserAction
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.utils import walk
|
||||
from ahriman.models.repository_id import RepositoryId
|
||||
from ahriman.models.repository_paths import RepositoryPaths
|
||||
|
||||
@ -49,6 +50,7 @@ class TreeMigrate(Handler):
|
||||
target_tree.tree_create()
|
||||
# perform migration
|
||||
TreeMigrate.tree_move(current_tree, target_tree)
|
||||
TreeMigrate.fix_symlinks(target_tree)
|
||||
|
||||
@staticmethod
|
||||
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)
|
||||
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
|
||||
def tree_move(from_tree: RepositoryPaths, to_tree: RepositoryPaths) -> None:
|
||||
"""
|
||||
|
@ -52,7 +52,7 @@ class Validate(Handler):
|
||||
"""
|
||||
from ahriman.core.configuration.validator import Validator
|
||||
|
||||
schema = Validate.schema(repository_id, configuration)
|
||||
schema = Validate.schema(configuration)
|
||||
validator = Validator(configuration=configuration, schema=schema)
|
||||
|
||||
if validator.validate(configuration.dump()):
|
||||
@ -83,12 +83,11 @@ class Validate(Handler):
|
||||
return parser
|
||||
|
||||
@staticmethod
|
||||
def schema(repository_id: RepositoryId, configuration: Configuration) -> ConfigurationSchema:
|
||||
def schema(configuration: Configuration) -> ConfigurationSchema:
|
||||
"""
|
||||
get schema with triggers
|
||||
|
||||
Args:
|
||||
repository_id(RepositoryId): repository unique identifier
|
||||
configuration(Configuration): configuration instance
|
||||
|
||||
Returns:
|
||||
@ -107,12 +106,12 @@ class Validate(Handler):
|
||||
continue
|
||||
|
||||
# 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))
|
||||
root[schema_name] = Validate.schema_merge(root.get(schema_name, {}), erased)
|
||||
|
||||
# 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))
|
||||
|
||||
return root
|
||||
|
@ -31,20 +31,21 @@ class Repo(LazyLogging):
|
||||
|
||||
Attributes:
|
||||
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
|
||||
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:
|
||||
name(str): repository name
|
||||
paths(RepositoryPaths): repository paths instance
|
||||
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.paths = paths
|
||||
self.root = root or paths.repository
|
||||
self.uid, _ = paths.root_owner
|
||||
self.sign_args = sign_args
|
||||
|
||||
@ -56,45 +57,56 @@ class Repo(LazyLogging):
|
||||
Returns:
|
||||
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
|
||||
|
||||
Args:
|
||||
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(
|
||||
"repo-add", *self.sign_args, "-R", str(self.repo_path), str(path),
|
||||
*command,
|
||||
exception=BuildError.from_process(path.name),
|
||||
cwd=self.paths.repository,
|
||||
cwd=self.root,
|
||||
logger=self.logger,
|
||||
user=self.uid)
|
||||
user=self.uid,
|
||||
)
|
||||
|
||||
def init(self) -> None:
|
||||
"""
|
||||
create empty repository database. It just calls add with empty arguments
|
||||
"""
|
||||
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
|
||||
|
||||
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
|
||||
"""
|
||||
package_name = package_name or filename.name.rsplit("-", maxsplit=3)[0]
|
||||
|
||||
# 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()
|
||||
|
||||
# remove package from registry
|
||||
check_output(
|
||||
"repo-remove", *self.sign_args, str(self.repo_path), package,
|
||||
exception=BuildError.from_process(package),
|
||||
cwd=self.paths.repository,
|
||||
"repo-remove", *self.sign_args, str(self.repo_path), package_name,
|
||||
exception=BuildError.from_process(package_name),
|
||||
cwd=self.root,
|
||||
logger=self.logger,
|
||||
user=self.uid)
|
||||
user=self.uid,
|
||||
)
|
||||
|
20
src/ahriman/core/archive/__init__.py
Normal file
20
src/ahriman/core/archive/__init__.py
Normal 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
|
127
src/ahriman/core/archive/archive_tree.py
Normal file
127
src/ahriman/core/archive/archive_tree.py
Normal 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)
|
71
src/ahriman/core/archive/archive_trigger.py
Normal file
71
src/ahriman/core/archive/archive_trigger.py
Normal 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()
|
@ -19,6 +19,7 @@
|
||||
#
|
||||
# pylint: disable=too-many-public-methods
|
||||
import configparser
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
@ -42,7 +43,6 @@ class Configuration(configparser.RawConfigParser):
|
||||
SYSTEM_CONFIGURATION_PATH(Path): (class attribute) default system configuration path distributed by package
|
||||
includes(list[Path]): list of includes which were read
|
||||
path(Path | None): path to root configuration file
|
||||
repository_id(RepositoryId | None): repository unique identifier
|
||||
|
||||
Examples:
|
||||
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.includes: list[Path] = []
|
||||
|
||||
@ -128,6 +128,32 @@ class Configuration(configparser.RawConfigParser):
|
||||
"""
|
||||
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
|
||||
def repository_name(self) -> str:
|
||||
"""
|
||||
@ -164,6 +190,7 @@ class Configuration(configparser.RawConfigParser):
|
||||
"""
|
||||
configuration = cls()
|
||||
configuration.load(path)
|
||||
configuration.load_environment()
|
||||
configuration.merge_sections(repository_id)
|
||||
return configuration
|
||||
|
||||
@ -288,6 +315,16 @@ class Configuration(configparser.RawConfigParser):
|
||||
self.read(self.path)
|
||||
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:
|
||||
"""
|
||||
load configuration includes from specified path
|
||||
@ -356,11 +393,16 @@ class Configuration(configparser.RawConfigParser):
|
||||
"""
|
||||
reload configuration if possible or raise exception otherwise
|
||||
"""
|
||||
# get current properties and validate input
|
||||
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.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:
|
||||
"""
|
||||
|
@ -45,11 +45,6 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
|
||||
"path_exists": True,
|
||||
"path_type": "dir",
|
||||
},
|
||||
"keep_last_logs": {
|
||||
"type": "integer",
|
||||
"coerce": "integer",
|
||||
"min": 0,
|
||||
},
|
||||
"logging": {
|
||||
"type": "path",
|
||||
"coerce": "absolute_path",
|
||||
@ -254,6 +249,10 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
|
||||
"repository": {
|
||||
"type": "dict",
|
||||
"schema": {
|
||||
"architecture": {
|
||||
"type": "string",
|
||||
"empty": False,
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"empty": False,
|
||||
|
84
src/ahriman/core/database/migrations/m016_archive.py
Normal file
84
src/ahriman/core/database/migrations/m016_archive.py
Normal 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))
|
@ -25,8 +25,16 @@ from typing import Self
|
||||
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.database.migrations import Migrations
|
||||
from ahriman.core.database.operations import AuthOperations, BuildOperations, ChangesOperations, \
|
||||
DependenciesOperations, EventOperations, LogsOperations, PackageOperations, PatchOperations
|
||||
from ahriman.core.database.operations import (
|
||||
AuthOperations,
|
||||
BuildOperations,
|
||||
ChangesOperations,
|
||||
DependenciesOperations,
|
||||
EventOperations,
|
||||
LogsOperations,
|
||||
PackageOperations,
|
||||
PatchOperations,
|
||||
)
|
||||
from ahriman.models.repository_id import RepositoryId
|
||||
|
||||
|
||||
|
21
src/ahriman/core/housekeeping/__init__.py
Normal file
21
src/ahriman/core/housekeeping/__init__.py
Normal 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
|
115
src/ahriman/core/housekeeping/archive_rotation_trigger.py
Normal file
115
src/ahriman/core/housekeeping/archive_rotation_trigger.py
Normal 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)
|
87
src/ahriman/core/housekeeping/logs_rotation_trigger.py
Normal file
87
src/ahriman/core/housekeeping/logs_rotation_trigger.py
Normal 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)
|
@ -17,7 +17,6 @@
|
||||
# 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 atexit
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
@ -37,7 +36,6 @@ class HttpLogHandler(logging.Handler):
|
||||
method
|
||||
|
||||
Attributes:
|
||||
keep_last_records(int): number of last records to keep
|
||||
reporter(Client): build status reporter instance
|
||||
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.suppress_errors = suppress_errors
|
||||
self.keep_last_records = configuration.getint("settings", "keep_last_logs", fallback=0)
|
||||
|
||||
@classmethod
|
||||
def load(cls, repository_id: RepositoryId, configuration: Configuration, *, report: bool) -> Self:
|
||||
@ -83,7 +80,6 @@ class HttpLogHandler(logging.Handler):
|
||||
root.addHandler(handler)
|
||||
|
||||
LogRecordId.DEFAULT_PROCESS_ID = str(uuid.uuid4()) # assign default process identifier for log records
|
||||
atexit.register(handler.rotate)
|
||||
|
||||
return handler
|
||||
|
||||
@ -104,9 +100,3 @@ class HttpLogHandler(logging.Handler):
|
||||
if self.suppress_errors:
|
||||
return
|
||||
self.handleError(record)
|
||||
|
||||
def rotate(self) -> None:
|
||||
"""
|
||||
rotate log records, removing older ones
|
||||
"""
|
||||
self.reporter.logs_rotate(self.keep_last_records)
|
||||
|
@ -26,6 +26,7 @@ from typing import Any
|
||||
|
||||
from ahriman.core.configuration import Configuration
|
||||
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.models.repository_id import RepositoryId
|
||||
from ahriman.models.result import Result
|
||||
@ -111,7 +112,7 @@ class JinjaTemplate:
|
||||
Returns:
|
||||
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)
|
||||
|
||||
def make_html(self, result: Result, template_name: Path | str) -> str:
|
||||
|
@ -28,6 +28,7 @@ from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.report.jinja_template import JinjaTemplate
|
||||
from ahriman.core.report.report import Report
|
||||
from ahriman.core.status import Client
|
||||
from ahriman.core.types import Comparable
|
||||
from ahriman.models.event import EventType
|
||||
from ahriman.models.package import Package
|
||||
from ahriman.models.repository_id import RepositoryId
|
||||
@ -86,7 +87,7 @@ class RSS(Report, JinjaTemplate):
|
||||
Returns:
|
||||
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"])
|
||||
return sorted(content, key=comparator, reverse=True)
|
||||
|
||||
|
@ -19,7 +19,7 @@
|
||||
#
|
||||
import shutil
|
||||
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Generator, Iterable
|
||||
from pathlib import Path
|
||||
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.repository.cleaner import Cleaner
|
||||
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.event import EventType
|
||||
from ahriman.models.package import Package
|
||||
@ -41,6 +41,141 @@ class Executor(PackageInfo, Cleaner):
|
||||
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, *,
|
||||
bump_pkgrel: bool = False) -> Result:
|
||||
"""
|
||||
@ -55,21 +190,6 @@ class Executor(PackageInfo, Cleaner):
|
||||
Returns:
|
||||
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()
|
||||
local_versions = {package.base: package.version for package in self.packages()}
|
||||
|
||||
@ -80,16 +200,21 @@ class Executor(PackageInfo, Cleaner):
|
||||
try:
|
||||
with self.in_event(single.base, EventType.PackageUpdated, failure=EventType.PackageUpdateFailed):
|
||||
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
|
||||
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
|
||||
package_archive = PackageArchive(self.paths.build_root, single, self.pacman, self.scan_paths)
|
||||
dependencies = package_archive.depends_on()
|
||||
self.reporter.package_dependencies_update(single.base, dependencies)
|
||||
|
||||
# update result set
|
||||
result.add_updated(single)
|
||||
|
||||
except Exception:
|
||||
self.reporter.set_failed(single.base)
|
||||
result.add_failed(single)
|
||||
@ -107,19 +232,6 @@ class Executor(PackageInfo, Cleaner):
|
||||
Returns:
|
||||
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] = {}
|
||||
bases_to_remove: list[str] = []
|
||||
|
||||
@ -136,6 +248,7 @@ class Executor(PackageInfo, Cleaner):
|
||||
})
|
||||
bases_to_remove.append(local.base)
|
||||
result.add_removed(local)
|
||||
|
||||
elif requested.intersection(local.packages.keys()):
|
||||
packages_to_remove.update({
|
||||
package: properties.filepath
|
||||
@ -152,11 +265,11 @@ class Executor(PackageInfo, Cleaner):
|
||||
|
||||
# remove packages from repository files
|
||||
for package, filename in packages_to_remove.items():
|
||||
remove_package(package, filename)
|
||||
self._package_remove(package, filename)
|
||||
|
||||
# remove bases from registered
|
||||
for package in bases_to_remove:
|
||||
remove_base(package)
|
||||
self._package_remove_base(package)
|
||||
|
||||
return result
|
||||
|
||||
@ -172,27 +285,6 @@ class Executor(PackageInfo, Cleaner):
|
||||
Returns:
|
||||
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()}
|
||||
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)
|
||||
|
||||
for description in local.packages.values():
|
||||
rename(description, local.base)
|
||||
update_single(description.filename, local.base, packager.key)
|
||||
self._archive_rename(description, local.base)
|
||||
self._package_update(description.filename, local.base, packager.key)
|
||||
self.reporter.set_success(local)
|
||||
result.add_updated(local)
|
||||
|
||||
@ -216,12 +308,13 @@ class Executor(PackageInfo, Cleaner):
|
||||
if local.base in current_packages:
|
||||
current_package_archives = set(current_packages[local.base].packages.keys())
|
||||
removed_packages.extend(current_package_archives.difference(local.packages))
|
||||
|
||||
except Exception:
|
||||
self.reporter.set_failed(local.base)
|
||||
result.add_failed(local)
|
||||
self.logger.exception("could not process %s", local.base)
|
||||
self.clear_packages()
|
||||
|
||||
self.clear_packages()
|
||||
self.process_remove(removed_packages)
|
||||
|
||||
return result
|
||||
|
@ -81,6 +81,11 @@ class Client:
|
||||
|
||||
return make_local_client()
|
||||
|
||||
def configuration_reload(self) -> None:
|
||||
"""
|
||||
reload configuration
|
||||
"""
|
||||
|
||||
def event_add(self, event: Event) -> None:
|
||||
"""
|
||||
create new event
|
||||
|
@ -17,6 +17,7 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# pylint: disable=too-many-public-methods
|
||||
import contextlib
|
||||
|
||||
from urllib.parse import quote_plus as url_encode
|
||||
@ -165,6 +166,13 @@ class WebClient(Client, SyncAhrimanClient):
|
||||
"""
|
||||
return f"{self.address}/api/v1/status"
|
||||
|
||||
def configuration_reload(self) -> None:
|
||||
"""
|
||||
reload configuration
|
||||
"""
|
||||
with contextlib.suppress(Exception):
|
||||
self.make_request("POST", f"{self.address}/api/v1/service/config")
|
||||
|
||||
def event_add(self, event: Event) -> None:
|
||||
"""
|
||||
create new event
|
||||
|
@ -80,8 +80,7 @@ class Trigger(LazyLogging):
|
||||
return self.repository_id.architecture
|
||||
|
||||
@classmethod
|
||||
def configuration_schema(cls, repository_id: RepositoryId,
|
||||
configuration: Configuration | None) -> ConfigurationSchema:
|
||||
def configuration_schema(cls, configuration: Configuration | None) -> ConfigurationSchema:
|
||||
"""
|
||||
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.
|
||||
|
||||
Args:
|
||||
repository_id(str): repository unique identifier
|
||||
configuration(Configuration | None): configuration instance. If set to None, the default schema
|
||||
should be returned
|
||||
|
||||
@ -101,13 +99,15 @@ class Trigger(LazyLogging):
|
||||
|
||||
result: ConfigurationSchema = {}
|
||||
for target in cls.configuration_sections(configuration):
|
||||
if not configuration.has_section(target):
|
||||
continue
|
||||
section, schema_name = configuration.gettype(
|
||||
target, repository_id, fallback=cls.CONFIGURATION_SCHEMA_FALLBACK)
|
||||
if schema_name not in cls.CONFIGURATION_SCHEMA:
|
||||
continue
|
||||
result[section] = cls.CONFIGURATION_SCHEMA[schema_name]
|
||||
for section in configuration.sections():
|
||||
if not (section == target or section.startswith(f"{target}:")):
|
||||
# either repository specific or exact name
|
||||
continue
|
||||
schema_name = configuration.get(section, "type", fallback=section)
|
||||
|
||||
if schema_name not in cls.CONFIGURATION_SCHEMA:
|
||||
continue
|
||||
result[section] = cls.CONFIGURATION_SCHEMA[schema_name]
|
||||
|
||||
return result
|
||||
|
||||
|
@ -17,6 +17,7 @@
|
||||
# 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 atexit
|
||||
import contextlib
|
||||
import os
|
||||
|
||||
@ -60,17 +61,8 @@ class TriggerLoader(LazyLogging):
|
||||
|
||||
def __init__(self) -> None:
|
||||
""""""
|
||||
self._on_stop_requested = False
|
||||
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
|
||||
def load(cls, repository_id: RepositoryId, configuration: Configuration) -> Self:
|
||||
"""
|
||||
@ -250,10 +242,11 @@ class TriggerLoader(LazyLogging):
|
||||
run triggers on load
|
||||
"""
|
||||
self.logger.debug("executing triggers on start")
|
||||
self._on_stop_requested = True
|
||||
for trigger in self.triggers:
|
||||
with self.__execute_trigger(trigger):
|
||||
trigger.on_start()
|
||||
# register on_stop call
|
||||
atexit.register(self.on_stop)
|
||||
|
||||
def on_stop(self) -> None:
|
||||
"""
|
||||
|
@ -17,7 +17,15 @@
|
||||
# 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 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):
|
||||
|
@ -18,13 +18,16 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# pylint: disable=too-many-lines
|
||||
import contextlib
|
||||
import datetime
|
||||
import fcntl
|
||||
import io
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import selectors
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from collections.abc import Callable, Generator, Iterable, Mapping
|
||||
@ -39,11 +42,13 @@ from ahriman.models.repository_paths import RepositoryPaths
|
||||
|
||||
|
||||
__all__ = [
|
||||
"atomic_move",
|
||||
"check_output",
|
||||
"check_user",
|
||||
"dataclass_view",
|
||||
"enum_values",
|
||||
"extract_user",
|
||||
"filelock",
|
||||
"filter_json",
|
||||
"full_version",
|
||||
"minmax",
|
||||
@ -65,6 +70,25 @@ __all__ = [
|
||||
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
|
||||
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,
|
||||
@ -233,6 +257,27 @@ def extract_user() -> str | None:
|
||||
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]:
|
||||
"""
|
||||
filter json object by fields used for json-to-object conversion
|
||||
|
@ -520,8 +520,7 @@ class Package(LazyLogging):
|
||||
else:
|
||||
remote_version = remote.version
|
||||
|
||||
result: int = vercmp(self.version, remote_version)
|
||||
return result < 0
|
||||
return self.vercmp(remote_version) < 0
|
||||
|
||||
def next_pkgrel(self, local_version: str | None) -> str | None:
|
||||
"""
|
||||
@ -540,7 +539,7 @@ class Package(LazyLogging):
|
||||
if local_version is None:
|
||||
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
|
||||
|
||||
*_, 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()))})"""
|
||||
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]:
|
||||
"""
|
||||
generate json package view
|
||||
|
@ -85,6 +85,16 @@ class RepositoryPaths(LazyLogging):
|
||||
return Path(self.repository_id.architecture) # legacy tree suffix
|
||||
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
|
||||
def build_root(self) -> Path:
|
||||
"""
|
||||
@ -227,7 +237,7 @@ class RepositoryPaths(LazyLogging):
|
||||
set owner of path recursively (from root) to root owner
|
||||
|
||||
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
|
||||
|
||||
Args:
|
||||
@ -249,6 +259,23 @@ class RepositoryPaths(LazyLogging):
|
||||
set_owner(path)
|
||||
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:
|
||||
"""
|
||||
get path to cached PKGBUILD and package sources for the package base
|
||||
@ -282,6 +309,10 @@ class RepositoryPaths(LazyLogging):
|
||||
path = path or self.root
|
||||
|
||||
def walk(root: Path) -> Generator[Path, None, None]:
|
||||
yield root
|
||||
if not root.exists():
|
||||
return
|
||||
|
||||
# basically walk, but skipping some content
|
||||
for child in root.iterdir():
|
||||
yield child
|
||||
@ -320,6 +351,7 @@ class RepositoryPaths(LazyLogging):
|
||||
|
||||
with self.preserve_owner():
|
||||
for directory in (
|
||||
self.archive,
|
||||
self.cache,
|
||||
self.chroot,
|
||||
self.packages,
|
||||
|
@ -21,8 +21,18 @@ import aiohttp_jinja2
|
||||
import logging
|
||||
|
||||
from aiohttp.typedefs import Middleware
|
||||
from aiohttp.web import HTTPClientError, HTTPException, HTTPMethodNotAllowed, HTTPNoContent, HTTPServerError, \
|
||||
HTTPUnauthorized, Request, StreamResponse, json_response, middleware
|
||||
from aiohttp.web import (
|
||||
HTTPClientError,
|
||||
HTTPException,
|
||||
HTTPMethodNotAllowed,
|
||||
HTTPNoContent,
|
||||
HTTPServerError,
|
||||
HTTPUnauthorized,
|
||||
Request,
|
||||
StreamResponse,
|
||||
json_response,
|
||||
middleware,
|
||||
)
|
||||
|
||||
from ahriman.web.middlewares import HandlerType
|
||||
|
||||
|
@ -22,6 +22,7 @@ from ahriman.web.schemas.aur_package_schema import AURPackageSchema
|
||||
from ahriman.web.schemas.auth_schema import AuthSchema
|
||||
from ahriman.web.schemas.build_options_schema import BuildOptionsSchema
|
||||
from ahriman.web.schemas.changes_schema import ChangesSchema
|
||||
from ahriman.web.schemas.configuration_schema import ConfigurationSchema
|
||||
from ahriman.web.schemas.counters_schema import CountersSchema
|
||||
from ahriman.web.schemas.dependencies_schema import DependenciesSchema
|
||||
from ahriman.web.schemas.error_schema import ErrorSchema
|
||||
|
@ -25,11 +25,11 @@ class AURPackageSchema(Schema):
|
||||
response AUR package schema
|
||||
"""
|
||||
|
||||
package = fields.String(required=True, metadata={
|
||||
"description": "Package base",
|
||||
"example": "ahriman",
|
||||
})
|
||||
description = fields.String(required=True, metadata={
|
||||
"description": "Package description",
|
||||
"example": "ArcH linux ReposItory MANager",
|
||||
})
|
||||
package = fields.String(required=True, metadata={
|
||||
"description": "Package base",
|
||||
"example": "ahriman",
|
||||
})
|
||||
|
@ -25,10 +25,10 @@ class ChangesSchema(Schema):
|
||||
response package changes schema
|
||||
"""
|
||||
|
||||
changes = fields.String(metadata={
|
||||
"description": "Package changes in patch format",
|
||||
})
|
||||
last_commit_sha = fields.String(metadata={
|
||||
"description": "Last recorded commit hash",
|
||||
"example": "f1875edca1eb8fc0e55c41d1cae5fa05b6b7c6",
|
||||
})
|
||||
changes = fields.String(metadata={
|
||||
"description": "Package changes in patch format",
|
||||
})
|
||||
|
39
src/ahriman/web/schemas/configuration_schema.py
Normal file
39
src/ahriman/web/schemas/configuration_schema.py
Normal file
@ -0,0 +1,39 @@
|
||||
#
|
||||
# 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.web.apispec import Schema, fields
|
||||
|
||||
|
||||
class ConfigurationSchema(Schema):
|
||||
"""
|
||||
response configuration schema
|
||||
"""
|
||||
|
||||
key = fields.String(required=True, metadata={
|
||||
"description": "Configuration key",
|
||||
"example": "host",
|
||||
})
|
||||
section = fields.String(required=True, metadata={
|
||||
"description": "Configuration section",
|
||||
"example": "web",
|
||||
})
|
||||
value = fields.String(required=True, metadata={
|
||||
"description": "Configuration value",
|
||||
"example": "127.0.0.1",
|
||||
})
|
@ -25,18 +25,6 @@ class CountersSchema(Schema):
|
||||
response package counters schema
|
||||
"""
|
||||
|
||||
total = fields.Integer(required=True, metadata={
|
||||
"description": "Total amount of packages",
|
||||
"example": 6,
|
||||
})
|
||||
_unknown = fields.Integer(data_key="unknown", required=True, metadata={
|
||||
"description": "Amount of packages in unknown state",
|
||||
"example": 0,
|
||||
})
|
||||
pending = fields.Integer(required=True, metadata={
|
||||
"description": "Amount of packages in pending state",
|
||||
"example": 2,
|
||||
})
|
||||
building = fields.Integer(required=True, metadata={
|
||||
"description": "Amount of packages in building state",
|
||||
"example": 1,
|
||||
@ -45,7 +33,19 @@ class CountersSchema(Schema):
|
||||
"description": "Amount of packages in failed state",
|
||||
"example": 1,
|
||||
})
|
||||
pending = fields.Integer(required=True, metadata={
|
||||
"description": "Amount of packages in pending state",
|
||||
"example": 2,
|
||||
})
|
||||
success = fields.Integer(required=True, metadata={
|
||||
"description": "Amount of packages in success state",
|
||||
"example": 3,
|
||||
})
|
||||
total = fields.Integer(required=True, metadata={
|
||||
"description": "Total amount of packages",
|
||||
"example": 6,
|
||||
})
|
||||
unknown_ = fields.Integer(data_key="unknown", required=True, metadata={
|
||||
"description": "Amount of packages in unknown state",
|
||||
"example": 0,
|
||||
})
|
||||
|
@ -30,17 +30,17 @@ class EventSchema(Schema):
|
||||
"description": "Event creation timestamp",
|
||||
"example": 1680537091,
|
||||
})
|
||||
data = fields.Dict(keys=fields.String(), metadata={
|
||||
"description": "Event metadata if available",
|
||||
})
|
||||
event = fields.String(required=True, metadata={
|
||||
"description": "Event type",
|
||||
"example": EventType.PackageUpdated,
|
||||
})
|
||||
message = fields.String(metadata={
|
||||
"description": "Event message if available",
|
||||
})
|
||||
object_id = fields.String(required=True, metadata={
|
||||
"description": "Event object identifier",
|
||||
"example": "ahriman",
|
||||
})
|
||||
message = fields.String(metadata={
|
||||
"description": "Event message if available",
|
||||
})
|
||||
data = fields.Dict(keys=fields.String(), metadata={
|
||||
"description": "Event metadata if available",
|
||||
})
|
||||
|
@ -31,14 +31,14 @@ class EventSearchSchema(PaginationSchema):
|
||||
"description": "Event type",
|
||||
"example": EventType.PackageUpdated,
|
||||
})
|
||||
object_id = fields.String(metadata={
|
||||
"description": "Event object identifier",
|
||||
"example": "ahriman",
|
||||
})
|
||||
from_date = fields.Integer(metadata={
|
||||
"description": "Minimal creation timestamp, inclusive",
|
||||
"example": 1680537091,
|
||||
})
|
||||
object_id = fields.String(metadata={
|
||||
"description": "Event object identifier",
|
||||
"example": "ahriman",
|
||||
})
|
||||
to_date = fields.Integer(metadata={
|
||||
"description": "Maximal creation timestamp, exclusive",
|
||||
"example": 1680537091,
|
||||
|
@ -33,10 +33,10 @@ class LogSchema(Schema):
|
||||
message = fields.String(required=True, metadata={
|
||||
"description": "Log message",
|
||||
})
|
||||
process_id = fields.String(metadata={
|
||||
"description": "Process unique identifier",
|
||||
})
|
||||
version = fields.String(required=True, metadata={
|
||||
"description": "Package version to tag",
|
||||
"example": __version__,
|
||||
})
|
||||
process_id = fields.String(metadata={
|
||||
"description": "Process unique identifier",
|
||||
})
|
||||
|
@ -25,11 +25,11 @@ class LoginSchema(Schema):
|
||||
request login schema
|
||||
"""
|
||||
|
||||
username = fields.String(required=True, metadata={
|
||||
"description": "Login username",
|
||||
"example": "user",
|
||||
})
|
||||
password = fields.String(required=True, metadata={
|
||||
"description": "Login password",
|
||||
"example": "pa55w0rd",
|
||||
})
|
||||
username = fields.String(required=True, metadata={
|
||||
"description": "Login username",
|
||||
"example": "user",
|
||||
})
|
||||
|
@ -30,10 +30,10 @@ class LogsSearchSchema(PaginationSchema):
|
||||
head = fields.Boolean(metadata={
|
||||
"description": "Return versions only without fetching logs themselves",
|
||||
})
|
||||
process_id = fields.String(metadata={
|
||||
"description": "Process unique identifier to search",
|
||||
})
|
||||
version = fields.String(metadata={
|
||||
"description": "Package version to search",
|
||||
"example": __version__,
|
||||
})
|
||||
process_id = fields.String(metadata={
|
||||
"description": "Process unique identifier to search",
|
||||
})
|
||||
|
@ -37,22 +37,14 @@ class PackagePropertiesSchema(Schema):
|
||||
"description": "Package build timestamp",
|
||||
"example": 1680537091,
|
||||
})
|
||||
depends = fields.List(fields.String(), metadata={
|
||||
"description": "Package dependencies list",
|
||||
"example": ["devtools"],
|
||||
})
|
||||
make_depends = fields.List(fields.String(), metadata={
|
||||
"description": "Package make dependencies list",
|
||||
"example": ["python-build"],
|
||||
})
|
||||
opt_depends = fields.List(fields.String(), metadata={
|
||||
"description": "Package optional dependencies list",
|
||||
"example": ["python-aiohttp"],
|
||||
})
|
||||
check_depends = fields.List(fields.String(), metadata={
|
||||
"description": "Package test dependencies list",
|
||||
"example": ["python-pytest"],
|
||||
})
|
||||
depends = fields.List(fields.String(), metadata={
|
||||
"description": "Package dependencies list",
|
||||
"example": ["devtools"],
|
||||
})
|
||||
description = fields.String(metadata={
|
||||
"description": "Package description",
|
||||
"example": "ArcH linux ReposItory MANager",
|
||||
@ -73,6 +65,14 @@ class PackagePropertiesSchema(Schema):
|
||||
"description": "Package licenses",
|
||||
"example": ["GPL3"],
|
||||
})
|
||||
make_depends = fields.List(fields.String(), metadata={
|
||||
"description": "Package make dependencies list",
|
||||
"example": ["python-build"],
|
||||
})
|
||||
opt_depends = fields.List(fields.String(), metadata={
|
||||
"description": "Package optional dependencies list",
|
||||
"example": ["python-aiohttp"],
|
||||
})
|
||||
provides = fields.List(fields.String(), metadata={
|
||||
"description": "Package provides list",
|
||||
"example": ["ahriman-git"],
|
||||
|
@ -32,18 +32,18 @@ class PackageSchema(Schema):
|
||||
"description": "Package base",
|
||||
"example": "ahriman",
|
||||
})
|
||||
version = fields.String(required=True, metadata={
|
||||
"description": "Package version",
|
||||
"example": __version__,
|
||||
})
|
||||
remote = fields.Nested(RemoteSchema(), required=True, metadata={
|
||||
"description": "Package remote properties",
|
||||
packager = fields.String(metadata={
|
||||
"description": "packager for the last success package build",
|
||||
"example": "ahriman bot <ahriman@example.com>",
|
||||
})
|
||||
packages = fields.Dict(
|
||||
keys=fields.String(), values=fields.Nested(PackagePropertiesSchema()), required=True, metadata={
|
||||
"description": "Packages which belong to this base",
|
||||
})
|
||||
packager = fields.String(metadata={
|
||||
"description": "packager for the last success package build",
|
||||
"example": "ahriman bot <ahriman@example.com>",
|
||||
remote = fields.Nested(RemoteSchema(), required=True, metadata={
|
||||
"description": "Package remote properties",
|
||||
})
|
||||
version = fields.String(required=True, metadata={
|
||||
"description": "Package version",
|
||||
"example": __version__,
|
||||
})
|
||||
|
@ -25,19 +25,19 @@ class RepositoryStatsSchema(Schema):
|
||||
response repository stats schema
|
||||
"""
|
||||
|
||||
bases = fields.Int(metadata={
|
||||
"description": "Amount of unique packages bases",
|
||||
"example": 2,
|
||||
})
|
||||
packages = fields.Int(metadata={
|
||||
"description": "Amount of unique packages",
|
||||
"example": 4,
|
||||
})
|
||||
archive_size = fields.Int(metadata={
|
||||
"description": "Total archive size of the packages in bytes",
|
||||
"example": 42000,
|
||||
})
|
||||
bases = fields.Int(metadata={
|
||||
"description": "Amount of unique packages bases",
|
||||
"example": 2,
|
||||
})
|
||||
installed_size = fields.Int(metadata={
|
||||
"description": "Total installed size of the packages in bytes",
|
||||
"example": 42000000,
|
||||
})
|
||||
packages = fields.Int(metadata={
|
||||
"description": "Amount of unique packages",
|
||||
"example": 4,
|
||||
})
|
||||
|
@ -25,7 +25,7 @@ class SearchSchema(Schema):
|
||||
request package search schema
|
||||
"""
|
||||
|
||||
_for = fields.List(fields.String(), data_key="for", required=True, metadata={
|
||||
for_ = fields.List(fields.String(), data_key="for", required=True, metadata={
|
||||
"description": "Keyword for search",
|
||||
"example": ["ahriman"],
|
||||
})
|
||||
|
@ -21,6 +21,7 @@ from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
|
||||
from collections.abc import Callable
|
||||
from typing import ClassVar
|
||||
|
||||
from ahriman.core.types import Comparable
|
||||
from ahriman.models.user_access import UserAccess
|
||||
from ahriman.models.worker import Worker
|
||||
from ahriman.web.apispec.decorators import apidocs
|
||||
@ -74,7 +75,7 @@ class WorkersView(BaseView):
|
||||
"""
|
||||
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)]
|
||||
|
||||
return json_response(response)
|
||||
|
@ -25,8 +25,12 @@ from ahriman.models.build_status import BuildStatusEnum
|
||||
from ahriman.models.package import Package
|
||||
from ahriman.models.user_access import UserAccess
|
||||
from ahriman.web.apispec.decorators import apidocs
|
||||
from ahriman.web.schemas import PackageNameSchema, PackageStatusSchema, PackageStatusSimplifiedSchema, \
|
||||
RepositoryIdSchema
|
||||
from ahriman.web.schemas import (
|
||||
PackageNameSchema,
|
||||
PackageStatusSchema,
|
||||
PackageStatusSimplifiedSchema,
|
||||
RepositoryIdSchema,
|
||||
)
|
||||
from ahriman.web.views.base import BaseView
|
||||
from ahriman.web.views.status_view_guard import StatusViewGuard
|
||||
|
||||
|
@ -23,6 +23,7 @@ from aiohttp.web import HTTPNoContent, Response, json_response
|
||||
from collections.abc import Callable
|
||||
from typing import ClassVar
|
||||
|
||||
from ahriman.core.types import Comparable
|
||||
from ahriman.models.build_status import BuildStatus
|
||||
from ahriman.models.package import Package
|
||||
from ahriman.models.user_access import UserAccess
|
||||
@ -68,7 +69,7 @@ class PackagesView(StatusViewGuard, BaseView):
|
||||
repository_id = self.repository_id()
|
||||
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 = [
|
||||
{
|
||||
"package": package.view(),
|
||||
|
84
src/ahriman/web/views/v1/service/config.py
Normal file
84
src/ahriman/web/views/v1/service/config.py
Normal 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/>.
|
||||
#
|
||||
from aiohttp.web import HTTPNoContent, Response, json_response
|
||||
from typing import ClassVar
|
||||
|
||||
from ahriman.core.formatters import ConfigurationPrinter
|
||||
from ahriman.models.user_access import UserAccess
|
||||
from ahriman.web.apispec.decorators import apidocs
|
||||
from ahriman.web.schemas import ConfigurationSchema
|
||||
from ahriman.web.views.base import BaseView
|
||||
|
||||
|
||||
class ConfigView(BaseView):
|
||||
"""
|
||||
configuration control view
|
||||
|
||||
Attributes:
|
||||
GET_PERMISSION(UserAccess): (class attribute) get permissions of self
|
||||
POST_PERMISSION(UserAccess): (class attribute) post permissions of self
|
||||
"""
|
||||
|
||||
GET_PERMISSION = POST_PERMISSION = UserAccess.Full # type: ClassVar[UserAccess]
|
||||
ROUTES = ["/api/v1/service/config"]
|
||||
|
||||
@apidocs(
|
||||
tags=["Actions"],
|
||||
summary="Get configuration",
|
||||
description="Get current web service configuration as nested dictionary",
|
||||
permission=GET_PERMISSION,
|
||||
schema=ConfigurationSchema(many=True),
|
||||
)
|
||||
async def get(self) -> Response:
|
||||
"""
|
||||
get current web service configuration
|
||||
|
||||
Returns:
|
||||
Response: current web service configuration as nested dictionary
|
||||
"""
|
||||
dump = self.configuration.dump()
|
||||
|
||||
response = [
|
||||
{
|
||||
"section": section,
|
||||
"key": key,
|
||||
"value": value,
|
||||
} for section, values in dump.items()
|
||||
for key, value in values.items()
|
||||
if key not in ConfigurationPrinter.HIDE_KEYS
|
||||
]
|
||||
return json_response(response)
|
||||
|
||||
@apidocs(
|
||||
tags=["Actions"],
|
||||
summary="Reload configuration",
|
||||
description="Reload configuration from current files",
|
||||
permission=POST_PERMISSION,
|
||||
)
|
||||
async def post(self) -> None:
|
||||
"""
|
||||
reload web service configuration
|
||||
|
||||
Raises:
|
||||
HTTPNoContent: on success response
|
||||
"""
|
||||
self.configuration.reload()
|
||||
|
||||
raise HTTPNoContent
|
@ -22,6 +22,7 @@ from collections.abc import Callable
|
||||
from typing import ClassVar
|
||||
|
||||
from ahriman.core.alpm.remote import AUR
|
||||
from ahriman.core.types import Comparable
|
||||
from ahriman.models.aur_package import AURPackage
|
||||
from ahriman.models.user_access import UserAccess
|
||||
from ahriman.web.apispec.decorators import apidocs
|
||||
@ -70,7 +71,12 @@ class SearchView(BaseView):
|
||||
if not packages:
|
||||
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 = [
|
||||
{
|
||||
"package": package.package_base,
|
||||
|
@ -26,6 +26,7 @@ from tempfile import NamedTemporaryFile
|
||||
from typing import ClassVar
|
||||
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.utils import atomic_move
|
||||
from ahriman.models.repository_paths import RepositoryPaths
|
||||
from ahriman.models.user_access import UserAccess
|
||||
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))
|
||||
|
||||
# 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:
|
||||
target_location = current_location.parent / filename
|
||||
current_location.rename(target_location)
|
||||
atomic_move(current_location, target_location)
|
||||
|
||||
raise HTTPCreated
|
||||
|
@ -37,6 +37,7 @@ SUBPACKAGES = {
|
||||
"ahriman-triggers": [
|
||||
prefix / "share" / "ahriman" / "settings" / "ahriman.ini.d" / "00-triggers.ini",
|
||||
site_packages / "ahriman" / "application" / "handlers" / "triggers_support.py",
|
||||
site_packages / "ahriman" / "core" / "archive",
|
||||
site_packages / "ahriman" / "core" / "distributed",
|
||||
site_packages / "ahriman" / "core" / "support",
|
||||
],
|
||||
|
@ -141,7 +141,7 @@ def test_add_remote_missing(application_packages: ApplicationPackages, mocker: M
|
||||
"""
|
||||
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):
|
||||
application_packages._add_remote("url")
|
||||
|
||||
|
@ -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
|
||||
"""
|
||||
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("pathlib.Path.is_dir", return_value=True)
|
||||
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
|
||||
"""
|
||||
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)
|
||||
|
||||
packages = application_repository.unknown()
|
||||
|
@ -46,7 +46,7 @@ def test_call_exception(args: argparse.Namespace, configuration: Configuration,
|
||||
"""
|
||||
args.configuration = Path("")
|
||||
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")
|
||||
|
||||
_, repository_id = configuration.check_loaded()
|
||||
@ -60,7 +60,7 @@ def test_call_exit_code(args: argparse.Namespace, configuration: Configuration,
|
||||
"""
|
||||
args.configuration = Path("")
|
||||
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")
|
||||
|
||||
_, repository_id = configuration.check_loaded()
|
||||
|
27
tests/ahriman/application/handlers/test_handler_reload.py
Normal file
27
tests/ahriman/application/handlers/test_handler_reload.py
Normal file
@ -0,0 +1,27 @@
|
||||
import argparse
|
||||
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from ahriman.application.handlers.reload import Reload
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.repository import Repository
|
||||
|
||||
|
||||
def test_run(args: argparse.Namespace, configuration: Configuration, repository: Repository,
|
||||
mocker: MockerFixture) -> None:
|
||||
"""
|
||||
must run command
|
||||
"""
|
||||
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
|
||||
run_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.configuration_reload")
|
||||
|
||||
_, repository_id = configuration.check_loaded()
|
||||
Reload.run(args, repository_id, configuration, report=False)
|
||||
run_mock.assert_called_once_with()
|
||||
|
||||
|
||||
def test_disallow_multi_architecture_run() -> None:
|
||||
"""
|
||||
must not allow multi architecture run
|
||||
"""
|
||||
assert not Reload.ALLOW_MULTI_ARCHITECTURE_RUN
|
@ -6,6 +6,7 @@ from unittest.mock import call as MockCall
|
||||
|
||||
from ahriman.application.handlers.tree_migrate import TreeMigrate
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.models.package import Package
|
||||
from ahriman.models.repository_id import RepositoryId
|
||||
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")
|
||||
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()
|
||||
old_paths = configuration.repository_paths
|
||||
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)
|
||||
tree_create_mock.assert_called_once_with()
|
||||
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:
|
||||
|
@ -2,6 +2,7 @@ import argparse
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from pathlib import Path
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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:
|
||||
"""
|
||||
must generate full schema correctly
|
||||
"""
|
||||
_, repository_id = configuration.check_loaded()
|
||||
schema = Validate.schema(repository_id, configuration)
|
||||
schema = Validate.schema(configuration)
|
||||
|
||||
# defaults
|
||||
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.remove_option("build", "triggers_known")
|
||||
_, repository_id = configuration.check_loaded()
|
||||
|
||||
assert Validate.schema(repository_id, configuration) == CONFIGURATION_SCHEMA
|
||||
assert Validate.schema(configuration) == CONFIGURATION_SCHEMA
|
||||
|
||||
|
||||
def test_schema_erase_required() -> None:
|
||||
|
@ -1563,6 +1563,35 @@ def test_subparsers_web_option_repository(parser: argparse.ArgumentParser) -> No
|
||||
assert args.repository == ""
|
||||
|
||||
|
||||
def test_subparsers_web_reload(parser: argparse.ArgumentParser) -> None:
|
||||
"""
|
||||
web-reload command must imply architecture, lock, quiet, report, repository and unsafe
|
||||
"""
|
||||
args = parser.parse_args(["web-reload"])
|
||||
assert args.architecture == ""
|
||||
assert args.lock is None
|
||||
assert args.quiet
|
||||
assert not args.report
|
||||
assert args.repository == ""
|
||||
assert args.unsafe
|
||||
|
||||
|
||||
def test_subparsers_web_reload_option_architecture(parser: argparse.ArgumentParser) -> None:
|
||||
"""
|
||||
web-reload command must correctly parse architecture list
|
||||
"""
|
||||
args = parser.parse_args(["-a", "x86_64", "web-reload"])
|
||||
assert args.architecture == ""
|
||||
|
||||
|
||||
def test_subparsers_web_reload_option_repository(parser: argparse.ArgumentParser) -> None:
|
||||
"""
|
||||
web-reload command must correctly parse repository list
|
||||
"""
|
||||
args = parser.parse_args(["-r", "repo", "web-reload"])
|
||||
assert args.repository == ""
|
||||
|
||||
|
||||
def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None:
|
||||
"""
|
||||
application must be run
|
||||
|
@ -230,7 +230,7 @@ def test_clear_close_exception(lock: Lock) -> None:
|
||||
must suppress IO exception on file closure
|
||||
"""
|
||||
close_mock = lock._pid_file = MagicMock()
|
||||
close_mock.close.side_effect = IOError()
|
||||
close_mock.close.side_effect = IOError
|
||||
lock.clear()
|
||||
|
||||
|
||||
|
@ -1,8 +1,10 @@
|
||||
import datetime
|
||||
import pytest
|
||||
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlite3 import Cursor
|
||||
from typing import Any, TypeVar
|
||||
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.configuration import Configuration
|
||||
from ahriman.core.database import SQLite
|
||||
from ahriman.core.database.migrations import Migrations
|
||||
from ahriman.core.repository import Repository
|
||||
from ahriman.core.spawn import Spawn
|
||||
from ahriman.core.status import Client
|
||||
from ahriman.core.status.watcher import Watcher
|
||||
from ahriman.models.aur_package import AURPackage
|
||||
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
|
||||
from ahriman.models.migration import Migration
|
||||
from ahriman.models.package import Package
|
||||
from ahriman.models.package_description import PackageDescription
|
||||
from ahriman.models.package_source import PackageSource
|
||||
@ -48,7 +52,9 @@ def anyvar(cls: type[T], strict: bool = False) -> T:
|
||||
T: any wrapper
|
||||
"""
|
||||
class AnyVar(cls):
|
||||
"""any value wrapper"""
|
||||
"""
|
||||
any value wrapper
|
||||
"""
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
"""
|
||||
@ -271,16 +277,23 @@ def configuration(repository_id: RepositoryId, tmp_path: Path, resource_path_roo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def database(configuration: Configuration) -> SQLite:
|
||||
def database(configuration: Configuration, mocker: MockerFixture) -> SQLite:
|
||||
"""
|
||||
database fixture
|
||||
|
||||
Args:
|
||||
configuration(Configuration): configuration fixture
|
||||
mocker(MockerFixture): mocker object
|
||||
|
||||
Returns:
|
||||
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)
|
||||
|
||||
|
||||
|
@ -108,7 +108,7 @@ def test_aur_request_failed(aur: AUR, mocker: MockerFixture) -> None:
|
||||
"""
|
||||
must reraise generic exception
|
||||
"""
|
||||
mocker.patch("requests.Session.request", side_effect=Exception())
|
||||
mocker.patch("requests.Session.request", side_effect=Exception)
|
||||
with pytest.raises(Exception):
|
||||
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:
|
||||
""" 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):
|
||||
aur.aur_request("info", "ahriman")
|
||||
|
||||
|
@ -80,7 +80,7 @@ def test_arch_request_failed(official: Official, mocker: MockerFixture) -> None:
|
||||
"""
|
||||
must reraise generic exception
|
||||
"""
|
||||
mocker.patch("requests.Session.request", side_effect=Exception())
|
||||
mocker.patch("requests.Session.request", side_effect=Exception)
|
||||
with pytest.raises(Exception):
|
||||
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
|
||||
"""
|
||||
mocker.patch("requests.Session.request", side_effect=requests.HTTPError())
|
||||
mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
|
||||
with pytest.raises(requests.HTTPError):
|
||||
official.arch_request("akonadi", by="q")
|
||||
|
||||
|
@ -242,7 +242,7 @@ def test_files_no_entry(pacman: Pacman, pyalpm_package_ahriman: pyalpm.Package,
|
||||
pacman.handle = handle_mock
|
||||
|
||||
tar_mock = MagicMock()
|
||||
tar_mock.extractfile.side_effect = KeyError()
|
||||
tar_mock.extractfile.side_effect = KeyError
|
||||
|
||||
open_mock = MagicMock()
|
||||
open_mock.__enter__.return_value = tar_mock
|
||||
|
@ -131,7 +131,7 @@ def test_sync_exception(pacman_database: PacmanDatabase, mocker: MockerFixture)
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
|
@ -4,6 +4,15 @@ from pathlib import Path
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
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:
|
||||
@ -22,6 +31,18 @@ def test_repo_add(repo: Repo, mocker: MockerFixture) -> None:
|
||||
repo.add(Path("path"))
|
||||
check_output_mock.assert_called_once() # it will be checked later
|
||||
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:
|
||||
@ -52,7 +73,7 @@ def test_repo_remove_fail_no_file(repo: Repo, mocker: MockerFixture) -> None:
|
||||
must fail on missing file
|
||||
"""
|
||||
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):
|
||||
repo.remove("package", Path("package.pkg.tar.xz"))
|
||||
|
@ -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)
|
||||
"""
|
||||
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")
|
||||
|
||||
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)
|
||||
"""
|
||||
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")
|
||||
assert email is None
|
||||
|
@ -1,8 +1,8 @@
|
||||
import configparser
|
||||
from io import StringIO
|
||||
|
||||
import pytest
|
||||
import os
|
||||
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from pytest_mock import MockerFixture
|
||||
from unittest.mock import call as MockCall
|
||||
@ -20,6 +20,40 @@ def test_architecture(configuration: Configuration) -> None:
|
||||
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:
|
||||
"""
|
||||
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")
|
||||
read_mock = mocker.patch("ahriman.core.configuration.Configuration.read")
|
||||
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")
|
||||
|
||||
configuration = Configuration.from_path(path, repository_id)
|
||||
assert configuration.path == path
|
||||
read_mock.assert_called_once_with(path)
|
||||
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:
|
||||
@ -324,6 +362,18 @@ def test_gettype_from_section_no_section(configuration: Configuration) -> None:
|
||||
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:
|
||||
"""
|
||||
must load includes
|
||||
@ -444,10 +494,12 @@ def test_reload(configuration: Configuration, mocker: MockerFixture) -> None:
|
||||
"""
|
||||
load_mock = mocker.patch("ahriman.core.configuration.Configuration.load")
|
||||
merge_mock = mocker.patch("ahriman.core.configuration.Configuration.merge_sections")
|
||||
environment_mock = mocker.patch("ahriman.core.configuration.Configuration.load_environment")
|
||||
|
||||
configuration.reload()
|
||||
load_mock.assert_called_once_with(configuration.path)
|
||||
merge_mock.assert_called_once_with(configuration.repository_id)
|
||||
environment_mock.assert_called_once_with()
|
||||
|
||||
|
||||
def test_reload_clear(configuration: Configuration, mocker: MockerFixture) -> None:
|
||||
|
82
tests/ahriman/core/database/migrations/test_m016_archive.py
Normal file
82
tests/ahriman/core/database/migrations/test_m016_archive.py
Normal 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"
|
||||
),
|
||||
])
|
@ -42,7 +42,7 @@ def test_apply_migration_exception(migrations: Migrations, mocker: MockerFixture
|
||||
must roll back and close cursor on exception during migration
|
||||
"""
|
||||
cursor = MagicMock()
|
||||
mocker.patch("logging.Logger.info", side_effect=Exception())
|
||||
mocker.patch("logging.Logger.info", side_effect=Exception)
|
||||
migrations.connection.cursor.return_value = cursor
|
||||
|
||||
with pytest.raises(Exception):
|
||||
@ -59,7 +59,7 @@ def test_apply_migration_sql_exception(migrations: Migrations) -> None:
|
||||
must close cursor on general migration error
|
||||
"""
|
||||
cursor = MagicMock()
|
||||
cursor.execute.side_effect = Exception()
|
||||
cursor.execute.side_effect = Exception
|
||||
migrations.connection.cursor.return_value = cursor
|
||||
|
||||
with pytest.raises(Exception):
|
||||
|
@ -37,7 +37,7 @@ def test_register_failed(distributed_system: DistributedSystem, mocker: MockerFi
|
||||
"""
|
||||
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()
|
||||
|
||||
|
||||
@ -45,7 +45,7 @@ def test_register_failed_http_error(distributed_system: DistributedSystem, mocke
|
||||
"""
|
||||
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()
|
||||
|
||||
|
||||
@ -70,7 +70,7 @@ def test_workers_failed(distributed_system: DistributedSystem, mocker: MockerFix
|
||||
"""
|
||||
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()
|
||||
|
||||
|
||||
@ -78,5 +78,5 @@ def test_workers_failed_http_error(distributed_system: DistributedSystem, mocker
|
||||
"""
|
||||
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()
|
||||
|
@ -85,7 +85,7 @@ def test_run_failed(configuration: Configuration, mocker: MockerFixture) -> None
|
||||
"""
|
||||
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()
|
||||
runner = RemotePull(repository_id, configuration, "gitremote")
|
||||
|
||||
|
@ -82,7 +82,7 @@ def test_run_failed(local_client: Client, configuration: Configuration, result:
|
||||
"""
|
||||
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")
|
||||
|
||||
with pytest.raises(GitRemoteError):
|
||||
|
34
tests/ahriman/core/housekeeping/conftest.py
Normal file
34
tests/ahriman/core/housekeeping/conftest.py
Normal 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)
|
@ -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))
|
@ -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)
|
@ -51,7 +51,7 @@ def test_login_failed(ahriman_client: SyncAhrimanClient, user: User, mocker: Moc
|
||||
must suppress any exception happened during login
|
||||
"""
|
||||
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())
|
||||
|
||||
|
||||
@ -60,7 +60,7 @@ def test_login_failed_http_error(ahriman_client: SyncAhrimanClient, user: User,
|
||||
must suppress HTTP exception happened during login
|
||||
"""
|
||||
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())
|
||||
|
||||
|
||||
|
@ -124,7 +124,7 @@ def test_make_request_failed(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
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")
|
||||
|
||||
with pytest.raises(Exception):
|
||||
@ -136,7 +136,7 @@ def test_make_request_suppress_errors(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
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")
|
||||
|
||||
with pytest.raises(Exception):
|
||||
|
@ -20,14 +20,12 @@ def test_load(configuration: Configuration, mocker: MockerFixture) -> None:
|
||||
|
||||
add_mock = mocker.patch("logging.Logger.addHandler")
|
||||
load_mock = mocker.patch("ahriman.core.status.Client.load")
|
||||
atexit_mock = mocker.patch("atexit.register")
|
||||
|
||||
_, repository_id = configuration.check_loaded()
|
||||
handler = HttpLogHandler.load(repository_id, configuration, report=False)
|
||||
assert handler
|
||||
add_mock.assert_called_once_with(handler)
|
||||
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:
|
||||
@ -61,7 +59,7 @@ def test_emit_failed(configuration: Configuration, log_record: logging.LogRecord
|
||||
must call handle error on exception
|
||||
"""
|
||||
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")
|
||||
_, repository_id = configuration.check_loaded()
|
||||
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
|
||||
"""
|
||||
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")
|
||||
_, repository_id = configuration.check_loaded()
|
||||
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)
|
||||
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)
|
||||
|
@ -61,7 +61,7 @@ def test_load_fallback(configuration: Configuration, mocker: MockerFixture) -> N
|
||||
"""
|
||||
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()
|
||||
LogLoader.load(repository_id, configuration, LogHandler.Journald, quiet=False, report=False)
|
||||
|
||||
|
@ -13,7 +13,7 @@ def test_report_failure(configuration: Configuration, mocker: MockerFixture) ->
|
||||
"""
|
||||
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()
|
||||
|
||||
with pytest.raises(ReportError):
|
||||
|
@ -41,7 +41,7 @@ def test_send_failed(telegram: Telegram, mocker: MockerFixture) -> None:
|
||||
"""
|
||||
must reraise generic exception
|
||||
"""
|
||||
mocker.patch("requests.Session.request", side_effect=Exception())
|
||||
mocker.patch("requests.Session.request", side_effect=Exception)
|
||||
with pytest.raises(Exception):
|
||||
telegram._send("a text")
|
||||
|
||||
@ -50,7 +50,7 @@ def test_send_failed_http_error(telegram: Telegram, mocker: MockerFixture) -> No
|
||||
"""
|
||||
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):
|
||||
telegram._send("a text")
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from pytest_mock import MockerFixture
|
||||
from typing import Any
|
||||
@ -13,34 +14,214 @@ from ahriman.models.packagers import Packagers
|
||||
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:
|
||||
"""
|
||||
must run build process
|
||||
"""
|
||||
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.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",
|
||||
return_value=Changes("commit", "change"))
|
||||
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",
|
||||
return_value=Dependencies())
|
||||
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)
|
||||
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)
|
||||
build_mock.assert_called_once_with(package_ahriman, pytest.helpers.anyvar(Path, strict=True), None, None)
|
||||
depends_on_mock.assert_called_once_with()
|
||||
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"))
|
||||
|
||||
|
||||
@ -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.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")
|
||||
|
||||
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.build_tools.task.Task.build", return_value=[Path(package_ahriman.base)])
|
||||
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")
|
||||
|
||||
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
|
||||
"""
|
||||
mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_ahriman])
|
||||
repo_remove_mock = mocker.patch("ahriman.core.alpm.repo.Repo.remove")
|
||||
status_client_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_remove")
|
||||
remove_mock = mocker.patch("ahriman.core.repository.executor.Executor._package_remove")
|
||||
base_remove_mock = mocker.patch("ahriman.core.repository.executor.Executor._package_remove_base")
|
||||
|
||||
executor.process_remove([package_ahriman.base])
|
||||
# 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)
|
||||
# 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:
|
||||
@ -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],
|
||||
}
|
||||
mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_ahriman])
|
||||
mocker.patch("ahriman.core.status.local_client.LocalClient.package_remove")
|
||||
repo_remove_mock = mocker.patch("ahriman.core.alpm.repo.Repo.remove")
|
||||
mocker.patch("ahriman.core.repository.executor.Executor._package_remove_base")
|
||||
remove_mock = mocker.patch("ahriman.core.repository.executor.Executor._package_remove")
|
||||
|
||||
executor.process_remove([package_ahriman.base])
|
||||
# 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(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
|
||||
"""
|
||||
mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_python_schedule])
|
||||
repo_remove_mock = mocker.patch("ahriman.core.alpm.repo.Repo.remove")
|
||||
status_client_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_remove")
|
||||
remove_mock = mocker.patch("ahriman.core.repository.executor.Executor._package_remove")
|
||||
status_client_mock = mocker.patch("ahriman.core.repository.executor.Executor._package_remove_base")
|
||||
|
||||
executor.process_remove([package_python_schedule.base])
|
||||
# must remove via alpm wrapper
|
||||
repo_remove_mock.assert_has_calls([
|
||||
remove_mock.assert_has_calls([
|
||||
MockCall(package, props.filepath)
|
||||
for package, props in package_python_schedule.packages.items()
|
||||
], 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
|
||||
"""
|
||||
mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_python_schedule])
|
||||
repo_remove_mock = mocker.patch("ahriman.core.alpm.repo.Repo.remove")
|
||||
status_client_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_remove")
|
||||
remove_mock = mocker.patch("ahriman.core.repository.executor.Executor._package_remove")
|
||||
status_client_mock = mocker.patch("ahriman.core.repository.executor.Executor._package_remove_base")
|
||||
|
||||
executor.process_remove(["python2-schedule"])
|
||||
# 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)
|
||||
# must not update status
|
||||
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,
|
||||
mocker: MockerFixture) -> None:
|
||||
"""
|
||||
must not remove anything if it was not requested
|
||||
"""
|
||||
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])
|
||||
repo_remove_mock.assert_not_called()
|
||||
remove_mock.assert_not_called()
|
||||
|
||||
|
||||
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
|
||||
"""
|
||||
mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[])
|
||||
repo_remove_mock = mocker.patch("ahriman.core.alpm.repo.Repo.remove")
|
||||
status_client_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_remove")
|
||||
remove_mock = mocker.patch("ahriman.core.repository.executor.Executor._package_remove")
|
||||
status_client_mock = mocker.patch("ahriman.core.repository.executor.Executor._package_remove_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)
|
||||
|
||||
|
||||
@ -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.packages", return_value=[package_ahriman])
|
||||
move_mock = mocker.patch("shutil.move")
|
||||
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])
|
||||
rename_mock = mocker.patch("ahriman.core.repository.executor.Executor._archive_rename")
|
||||
update_mock = mocker.patch("ahriman.core.repository.executor.Executor._package_update")
|
||||
status_client_mock = mocker.patch("ahriman.core.status.Client.set_success")
|
||||
remove_mock = mocker.patch("ahriman.core.repository.executor.Executor.process_remove")
|
||||
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
|
||||
assert executor.process_update([filepath], Packagers("packager"))
|
||||
packager_mock.assert_called_once_with(Packagers("packager"), "ahriman")
|
||||
# must move files (once)
|
||||
move_mock.assert_called_once_with(executor.paths.packages / filepath, executor.paths.repository / filepath)
|
||||
# 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)
|
||||
rename_mock.assert_called_once_with(package_ahriman.packages[package_ahriman.base], package_ahriman.base)
|
||||
update_mock.assert_called_once_with(filepath.name, package_ahriman.base, user.key)
|
||||
# must update status
|
||||
status_client_mock.assert_called_once_with(package_ahriman)
|
||||
# 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
|
||||
"""
|
||||
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.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")
|
||||
remove_mock = mocker.patch("ahriman.core.repository.executor.Executor.process_remove")
|
||||
|
||||
executor.process_update([package.filepath for package in package_python_schedule.packages.values()])
|
||||
repo_add_mock.assert_has_calls([
|
||||
MockCall(executor.paths.repository / package.filepath)
|
||||
update_mock.assert_has_calls([
|
||||
MockCall(package.filename, package_python_schedule.base, None)
|
||||
for package in package_python_schedule.packages.values()
|
||||
], any_order=True)
|
||||
status_client_mock.assert_called_once_with(package_python_schedule)
|
||||
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:
|
||||
"""
|
||||
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.packages", return_value=[package_ahriman])
|
||||
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())
|
||||
del without_python2.packages["python2-schedule"]
|
||||
|
||||
mocker.patch("shutil.move")
|
||||
mocker.patch("ahriman.core.alpm.repo.Repo.add")
|
||||
mocker.patch("ahriman.core.repository.executor.Executor._package_update")
|
||||
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])
|
||||
remove_mock = mocker.patch("ahriman.core.repository.executor.Executor.process_remove")
|
||||
|
@ -40,7 +40,7 @@ def test_load_archives_failed(package_info: PackageInfo, mocker: MockerFixture)
|
||||
"""
|
||||
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")])
|
||||
|
||||
|
||||
|
@ -59,7 +59,7 @@ def test_updates_aur_failed(update_handler: UpdateHandler, package_ahriman: Pack
|
||||
must update status via client for failed load
|
||||
"""
|
||||
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")
|
||||
|
||||
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("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)
|
||||
|
||||
@ -336,6 +336,6 @@ def test_updates_manual_with_failures(update_handler: UpdateHandler, package_ahr
|
||||
"""
|
||||
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])
|
||||
assert update_handler.updates_manual() == []
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user