diff --git a/docs/ahriman.application.handlers.rst b/docs/ahriman.application.handlers.rst
index 2c8f7f90..a45f0fb9 100644
--- a/docs/ahriman.application.handlers.rst
+++ b/docs/ahriman.application.handlers.rst
@@ -164,6 +164,14 @@ ahriman.application.handlers.restore module
:no-undoc-members:
:show-inheritance:
+ahriman.application.handlers.rollback module
+--------------------------------------------
+
+.. automodule:: ahriman.application.handlers.rollback
+ :members:
+ :no-undoc-members:
+ :show-inheritance:
+
ahriman.application.handlers.run module
---------------------------------------
diff --git a/docs/ahriman.web.schemas.rst b/docs/ahriman.web.schemas.rst
index 1d51aeca..7a697397 100644
--- a/docs/ahriman.web.schemas.rst
+++ b/docs/ahriman.web.schemas.rst
@@ -252,6 +252,14 @@ ahriman.web.schemas.package\_version\_schema module
:no-undoc-members:
:show-inheritance:
+ahriman.web.schemas.packager\_schema module
+-------------------------------------------
+
+.. automodule:: ahriman.web.schemas.packager_schema
+ :members:
+ :no-undoc-members:
+ :show-inheritance:
+
ahriman.web.schemas.pagination\_schema module
---------------------------------------------
@@ -332,6 +340,14 @@ ahriman.web.schemas.repository\_stats\_schema module
:no-undoc-members:
:show-inheritance:
+ahriman.web.schemas.rollback\_schema module
+-------------------------------------------
+
+.. automodule:: ahriman.web.schemas.rollback_schema
+ :members:
+ :no-undoc-members:
+ :show-inheritance:
+
ahriman.web.schemas.search\_schema module
-----------------------------------------
diff --git a/docs/ahriman.web.views.v1.service.rst b/docs/ahriman.web.views.v1.service.rst
index c9f10f3d..368efec3 100644
--- a/docs/ahriman.web.views.v1.service.rst
+++ b/docs/ahriman.web.views.v1.service.rst
@@ -68,6 +68,14 @@ ahriman.web.views.v1.service.request module
:no-undoc-members:
:show-inheritance:
+ahriman.web.views.v1.service.rollback module
+--------------------------------------------
+
+.. automodule:: ahriman.web.views.v1.service.rollback
+ :members:
+ :no-undoc-members:
+ :show-inheritance:
+
ahriman.web.views.v1.service.search module
------------------------------------------
diff --git a/src/ahriman/application/application/application_repository.py b/src/ahriman/application/application/application_repository.py
index 400ace68..ccf8fbfc 100644
--- a/src/ahriman/application/application/application_repository.py
+++ b/src/ahriman/application/application/application_repository.py
@@ -156,12 +156,15 @@ class ApplicationRepository(ApplicationProperties):
result = Result()
# process already built packages if any
- built_packages = self.repository.packages_built()
- if built_packages: # speedup a bit
+ if built_packages := self.repository.packages_built(): # speedup a bit
build_result = self.repository.process_update(built_packages, packagers)
self.on_result(build_result)
result.merge(build_result)
+ # filter packages which were prebuilt
+ succeeded = {package.base for package in build_result.success}
+ updates = [package for package in updates if package.base not in succeeded]
+
builder = Updater.load(self.repository_id, self.configuration, self.repository)
# ok so for now we split all packages into chunks and process each chunk accordingly
diff --git a/src/ahriman/application/handlers/add.py b/src/ahriman/application/handlers/add.py
index 6da7b9c5..172cec88 100644
--- a/src/ahriman/application/handlers/add.py
+++ b/src/ahriman/application/handlers/add.py
@@ -21,10 +21,10 @@ import argparse
from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler, SubParserAction
+from ahriman.application.handlers.update import Update
from ahriman.core.configuration import Configuration
from ahriman.core.utils import enum_values, extract_user
from ahriman.models.package_source import PackageSource
-from ahriman.models.packagers import Packagers
from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.repository_id import RepositoryId
@@ -48,26 +48,7 @@ class Add(Handler):
"""
application = Application(repository_id, configuration, report=report, refresh_pacman_database=args.refresh)
application.on_start()
-
- application.add(args.package, args.source, args.username)
- patches = [PkgbuildPatch.from_env(patch) for patch in args.variable] if args.variable is not None else []
- for package in args.package: # for each requested package insert patch
- for patch in patches:
- application.reporter.package_patches_update(package, patch)
-
- if not args.now:
- return
-
- packages = application.updates(args.package, aur=False, local=False, manual=True, vcs=False, check_files=False)
- if args.changes: # generate changes if requested
- application.changes(packages)
-
- packages = application.with_dependencies(packages, process_dependencies=args.dependencies)
- packagers = Packagers(args.username, {package.base: package.packager for package in packages})
-
- application.print_updates(packages, log_fn=application.logger.info)
- result = application.update(packages, packagers, bump_pkgrel=args.increment)
- Add.check_status(args.exit_code, not result.is_empty)
+ Add.perform_action(application, args)
@staticmethod
def _set_package_add_parser(root: SubParserAction) -> argparse.ArgumentParser:
@@ -103,14 +84,34 @@ class Add(Handler):
parser.add_argument("--increment", help="increment package release (pkgrel) version on duplicate",
action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("-n", "--now", help="run update function after", action="store_true")
- parser.add_argument("-y", "--refresh", help="download fresh package databases from the mirror before actions, "
- "-yy to force refresh even if up to date",
- action="count", default=False)
parser.add_argument("-s", "--source", help="explicitly specify the package source for this command",
type=PackageSource, choices=enum_values(PackageSource), default=PackageSource.Auto)
parser.add_argument("-u", "--username", help="build as user", default=extract_user())
parser.add_argument("-v", "--variable", help="apply specified makepkg variables to the next build",
action="append")
+ parser.add_argument("-y", "--refresh", help="download fresh package databases from the mirror before actions, "
+ "-yy to force refresh even if up to date",
+ action="count", default=False)
+ parser.set_defaults(aur=False, check_files=False, dry_run=False, local=False, manual=True, vcs=False)
return parser
+ @staticmethod
+ def perform_action(application: Application, args: argparse.Namespace) -> None:
+ """
+ perform add action
+
+ Args:
+ application(Application): application instance
+ args(argparse.Namespace): command line args
+ """
+ application.add(args.package, args.source, args.username)
+ patches = [PkgbuildPatch.from_env(patch) for patch in args.variable] if args.variable is not None else []
+ for package in args.package: # for each requested package insert patch
+ for patch in patches:
+ application.reporter.package_patches_update(package, patch)
+
+ if not args.now:
+ return
+ Update.perform_action(application, args)
+
arguments = [_set_package_add_parser]
diff --git a/src/ahriman/application/handlers/rollback.py b/src/ahriman/application/handlers/rollback.py
new file mode 100644
index 00000000..98f78d0d
--- /dev/null
+++ b/src/ahriman/application/handlers/rollback.py
@@ -0,0 +1,131 @@
+#
+# Copyright (c) 2021-2026 ahriman team.
+#
+# This file is part of ahriman
+# (see https://github.com/arcan1s/ahriman).
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+import argparse
+
+from dataclasses import replace
+from pathlib import Path
+
+from ahriman.application.application import Application
+from ahriman.application.handlers.add import Add
+from ahriman.application.handlers.handler import Handler, SubParserAction
+from ahriman.core.configuration import Configuration
+from ahriman.core.exceptions import UnknownPackageError
+from ahriman.core.utils import extract_user
+from ahriman.models.package import Package
+from ahriman.models.package_source import PackageSource
+from ahriman.models.repository_id import RepositoryId
+
+
+class Rollback(Handler):
+ """
+ package rollback handler
+ """
+
+ @classmethod
+ def run(cls, args: argparse.Namespace, repository_id: RepositoryId, configuration: Configuration, *,
+ report: bool) -> None:
+ """
+ callback for command line
+
+ Args:
+ args(argparse.Namespace): command line args
+ repository_id(RepositoryId): repository unique identifier
+ configuration(Configuration): configuration instance
+ report(bool): force enable or disable reporting
+ """
+ application = Application(repository_id, configuration, report=report)
+ application.on_start()
+
+ package = Rollback.package_load(application, args.package, args.version)
+ artifacts = Rollback.package_artifacts(application, package)
+
+ args.package = [str(artifact) for artifact in artifacts]
+ Add.perform_action(application, args)
+
+ if args.hold:
+ application.reporter.package_hold_update(package.base, enabled=True)
+
+ @staticmethod
+ def _set_package_rollback_parser(root: SubParserAction) -> argparse.ArgumentParser:
+ """
+ add parser for package rollback subcommand
+
+ Args:
+ root(SubParserAction): subparsers for the commands
+
+ Returns:
+ argparse.ArgumentParser: created argument parser
+ """
+ parser = root.add_parser("package-rollback", help="rollback package",
+ description="rollback package to specified version from archives")
+ parser.add_argument("package", help="package base")
+ parser.add_argument("version", help="package version")
+ parser.add_argument("--hold", help="hold package afterwards",
+ action=argparse.BooleanOptionalAction, default=True)
+ parser.add_argument("-u", "--username", help="build as user", default=extract_user())
+ parser.set_defaults(aur=False, changes=False, check_files=False, dependencies=False, dry_run=False,
+ exit_code=True, increment=False, now=True, local=False, manual=False, refresh=False,
+ source=PackageSource.Archive, variable=None, vcs=False)
+ return parser
+
+ @staticmethod
+ def package_artifacts(application: Application, package: Package) -> list[Path]:
+ """
+ look for requested package artifacts and return paths to them
+
+ Args:
+ application(Application): application instance
+ package(Package): package descriptor
+
+ Returns:
+ list[Path]: paths to found artifacts
+
+ Raises:
+ UnknownPackageError: if artifacts do not exist
+ """
+ # lookup for built artifacts
+ artifacts = application.repository.package_archives_lookup(package)
+ if not artifacts:
+ raise UnknownPackageError(package.base)
+ return artifacts
+
+ @staticmethod
+ def package_load(application: Application, package_base: str, version: str) -> Package:
+ """
+ load package from repository, while setting requested version
+
+ Args:
+ application(Application): application instance
+ package_base(str): package base
+ version(str): package version
+
+ Returns:
+ Package: loaded package
+
+ Raises:
+ UnknownPackageError: if package does not exist
+ """
+ try:
+ package, _ = next(iter(application.reporter.package_get(package_base)))
+ return replace(package, version=version)
+ except StopIteration:
+ raise UnknownPackageError(package_base) from None
+
+ arguments = [_set_package_rollback_parser]
diff --git a/src/ahriman/application/handlers/update.py b/src/ahriman/application/handlers/update.py
index 239ef12a..5fa10f25 100644
--- a/src/ahriman/application/handlers/update.py
+++ b/src/ahriman/application/handlers/update.py
@@ -48,22 +48,7 @@ class Update(Handler):
"""
application = Application(repository_id, configuration, report=report, refresh_pacman_database=args.refresh)
application.on_start()
-
- packages = application.updates(args.package, aur=args.aur, local=args.local, manual=args.manual, vcs=args.vcs,
- check_files=args.check_files)
- if args.changes: # generate changes if requested
- application.changes(packages)
-
- if args.dry_run: # exit from application if no build requested
- Update.check_status(args.exit_code, packages) # status code check
- return
-
- packages = application.with_dependencies(packages, process_dependencies=args.dependencies)
- packagers = Packagers(args.username, {package.base: package.packager for package in packages})
-
- application.print_updates(packages, log_fn=application.logger.info)
- result = application.update(packages, packagers, bump_pkgrel=args.increment)
- Update.check_status(args.exit_code, not result.is_empty)
+ Update.perform_action(application, args)
@staticmethod
def _set_repo_check_parser(root: SubParserAction) -> argparse.ArgumentParser:
@@ -153,6 +138,31 @@ class Update(Handler):
return print(line) if dry_run else application.logger.info(line) # pylint: disable=bad-builtin
return inner
+ @staticmethod
+ def perform_action(application: Application, args: argparse.Namespace) -> None:
+ """
+ perform update action
+
+ Args:
+ application(Application): application instance
+ args(argparse.Namespace): command line args
+ """
+ packages = application.updates(args.package, aur=args.aur, local=args.local, manual=args.manual, vcs=args.vcs,
+ check_files=args.check_files)
+ if args.changes: # generate changes if requested
+ application.changes(packages)
+
+ if args.dry_run: # exit from application if no build requested
+ Update.check_status(args.exit_code, packages) # status code check
+ return
+
+ packages = application.with_dependencies(packages, process_dependencies=args.dependencies)
+ packagers = Packagers(args.username, {package.base: package.packager for package in packages})
+
+ application.print_updates(packages, log_fn=application.logger.info)
+ result = application.update(packages, packagers, bump_pkgrel=args.increment)
+ Update.check_status(args.exit_code, not result.is_empty)
+
arguments = [
_set_repo_check_parser,
_set_repo_update_parser,
diff --git a/src/ahriman/core/repository/package_info.py b/src/ahriman/core/repository/package_info.py
index be5f4560..b192491b 100644
--- a/src/ahriman/core/repository/package_info.py
+++ b/src/ahriman/core/repository/package_info.py
@@ -25,7 +25,6 @@ from pathlib import Path
from tempfile import TemporaryDirectory
from ahriman.core.alpm.pacman import Pacman
-from ahriman.core.build_tools.package_version import PackageVersion
from ahriman.core.build_tools.sources import Sources
from ahriman.core.configuration import Configuration
from ahriman.core.log import LazyLogging
@@ -89,19 +88,21 @@ class PackageInfo(LazyLogging):
return sorted(result)
- def load_archives(self, packages: Iterable[Path]) -> list[Package]:
+ def load_archives(self, packages: Iterable[Path], *, latest_only: bool = True) -> list[Package]:
"""
load packages from list of archives
Args:
packages(Iterable[Path]): paths to package archives
+ latest_only(bool, optional): filter packages with the same base, keeping only fresh packages installed
+ (Default value = True)
Returns:
list[Package]: list of read packages
"""
sources = {package.base: package.remote for package, _, in self.reporter.package_get(None)}
- result: dict[str, Package] = {}
+ result: dict[str, dict[str, Package]] = {}
# we are iterating over bases, not single packages
for full_path in packages:
try:
@@ -109,17 +110,23 @@ class PackageInfo(LazyLogging):
if (source := sources.get(local.base)) is not None: # update source with remote
local.remote = source
- current = result.setdefault(local.base, local)
- if current.version != local.version:
- # force version to max of them
- self.logger.warning("version of %s differs, found %s and %s",
- current.base, current.version, local.version)
- if PackageVersion(current).is_outdated(local, self.configuration, calculate_version=False):
- current.version = local.version
+ loaded_versions = result.setdefault(local.base, {})
+ current = loaded_versions.setdefault(local.version, local)
current.packages.update(local.packages)
except Exception:
self.logger.exception("could not load package from %s", full_path)
- return list(result.values())
+
+ if latest_only:
+ comparator: Callable[[Package, Package], int] = lambda left, right: left.vercmp(right.version)
+ for package_base, versions in result.items():
+ newest = max(versions.values(), key=cmp_to_key(comparator))
+ result[package_base] = {newest.version: newest}
+
+ return [
+ package
+ for versions in result.values()
+ for package in versions.values()
+ ]
def package_archives(self, package_base: str) -> list[Package]:
"""
@@ -133,16 +140,17 @@ class PackageInfo(LazyLogging):
Returns:
list[Package]: list of packages belonging to this base, sorted by version by ascension
"""
- 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)
- if not local.supports_architecture(self.repository_id.architecture):
- continue
- packages.setdefault((local.base, local.version), local).packages.update(local.packages)
+ archive = self.paths.archive_for(package_base)
+ if not archive.is_dir():
+ return []
+
+ packages = self.load_archives(filter(package_like, archive.iterdir()), latest_only=False)
comparator: Callable[[Package, Package], int] = lambda left, right: left.vercmp(right.version)
- return sorted(packages.values(), key=cmp_to_key(comparator))
+ return sorted(
+ (package for package in packages if package.supports_architecture(self.repository_id.architecture)),
+ key=cmp_to_key(comparator),
+ )
def package_archives_lookup(self, package: Package) -> list[Path]:
"""
@@ -155,19 +163,11 @@ class PackageInfo(LazyLogging):
list[Path]: list of built packages and signatures if available, empty list otherwise
"""
archive = self.paths.archive_for(package.base)
- if not archive.is_dir():
- return []
- for path in filter(package_like, archive.iterdir()):
- # check if package version is the same
- built = Package.from_archive(path)
+ for built in self.package_archives(package.base):
if built.version != package.version:
continue
- # all packages must be either any or same architecture
- if not built.supports_architecture(self.repository_id.architecture):
- continue
-
return list_flatmap(built.packages.values(), lambda single: archive.glob(f"{single.filename}*"))
return []
diff --git a/src/ahriman/core/spawn.py b/src/ahriman/core/spawn.py
index 09a857d8..39c28a2e 100644
--- a/src/ahriman/core/spawn.py
+++ b/src/ahriman/core/spawn.py
@@ -232,6 +232,27 @@ class Spawn(Thread, LazyLogging):
"""
return self._spawn_process(repository_id, "package-remove", *packages)
+ def packages_rollback(self, repository_id: RepositoryId, package: str, version: str, username: str | None, *,
+ hold: bool) -> str:
+ """
+ rollback package
+
+ Args:
+ repository_id(RepositoryId): repository unique identifier
+ package(str): package base to rollback
+ version(str): package version to rollback
+ username(str | None): optional override of username for build process
+ hold(bool): hold package after rollback
+
+ Returns:
+ str: spawned process identifier
+ """
+ kwargs = {
+ "username": username,
+ self.boolean_action_argument("hold", hold): "",
+ }
+ return self._spawn_process(repository_id, "package-rollback", package, version, **kwargs)
+
def packages_update(self, repository_id: RepositoryId, username: str | None, *,
aur: bool, local: bool, manual: bool, increment: bool, refresh: bool) -> str:
"""
diff --git a/src/ahriman/web/schemas/__init__.py b/src/ahriman/web/schemas/__init__.py
index 8347f30f..e5e5f2c5 100644
--- a/src/ahriman/web/schemas/__init__.py
+++ b/src/ahriman/web/schemas/__init__.py
@@ -48,6 +48,7 @@ from ahriman.web.schemas.package_properties_schema import PackagePropertiesSchem
from ahriman.web.schemas.package_schema import PackageSchema
from ahriman.web.schemas.package_status_schema import PackageStatusSchema, PackageStatusSimplifiedSchema
from ahriman.web.schemas.package_version_schema import PackageVersionSchema
+from ahriman.web.schemas.packager_schema import PackagerSchema
from ahriman.web.schemas.pagination_schema import PaginationSchema
from ahriman.web.schemas.patch_name_schema import PatchNameSchema
from ahriman.web.schemas.patch_schema import PatchSchema
@@ -58,6 +59,7 @@ from ahriman.web.schemas.process_schema import ProcessSchema
from ahriman.web.schemas.remote_schema import RemoteSchema
from ahriman.web.schemas.repository_id_schema import RepositoryIdSchema
from ahriman.web.schemas.repository_stats_schema import RepositoryStatsSchema
+from ahriman.web.schemas.rollback_schema import RollbackSchema
from ahriman.web.schemas.search_schema import SearchSchema
from ahriman.web.schemas.status_schema import StatusSchema
from ahriman.web.schemas.update_flags_schema import UpdateFlagsSchema
diff --git a/src/ahriman/web/schemas/build_options_schema.py b/src/ahriman/web/schemas/build_options_schema.py
index f914d485..48d712a5 100644
--- a/src/ahriman/web/schemas/build_options_schema.py
+++ b/src/ahriman/web/schemas/build_options_schema.py
@@ -17,10 +17,11 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from ahriman.web.apispec import Schema, fields
+from ahriman.web.apispec import fields
+from ahriman.web.schemas.packager_schema import PackagerSchema
-class BuildOptionsSchema(Schema):
+class BuildOptionsSchema(PackagerSchema):
"""
request build options schema
"""
@@ -28,9 +29,6 @@ class BuildOptionsSchema(Schema):
increment = fields.Boolean(dump_default=True, metadata={
"description": "Increment pkgrel on conflicts",
})
- packager = fields.String(metadata={
- "description": "Packager identity if applicable",
- })
refresh = fields.Boolean(dump_default=True, metadata={
"description": "Refresh pacman database"
})
diff --git a/src/ahriman/web/schemas/packager_schema.py b/src/ahriman/web/schemas/packager_schema.py
new file mode 100644
index 00000000..16380338
--- /dev/null
+++ b/src/ahriman/web/schemas/packager_schema.py
@@ -0,0 +1,30 @@
+#
+# Copyright (c) 2021-2026 ahriman team.
+#
+# This file is part of ahriman
+# (see https://github.com/arcan1s/ahriman).
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+from ahriman.web.apispec import Schema, fields
+
+
+class PackagerSchema(Schema):
+ """
+ request packager schema
+ """
+
+ packager = fields.String(metadata={
+ "description": "Packager identity if applicable",
+ })
diff --git a/src/ahriman/web/schemas/rollback_schema.py b/src/ahriman/web/schemas/rollback_schema.py
new file mode 100644
index 00000000..8d9ef7ae
--- /dev/null
+++ b/src/ahriman/web/schemas/rollback_schema.py
@@ -0,0 +1,40 @@
+#
+# Copyright (c) 2021-2026 ahriman team.
+#
+# This file is part of ahriman
+# (see https://github.com/arcan1s/ahriman).
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+from ahriman import __version__
+from ahriman.web.apispec import fields
+from ahriman.web.schemas.packager_schema import PackagerSchema
+
+
+class RollbackSchema(PackagerSchema):
+ """
+ request schema for package rollback
+ """
+
+ hold = fields.Boolean(dump_default=True, metadata={
+ "description": "Hold package after rollback",
+ })
+ package = fields.String(required=True, metadata={
+ "description": "Package name",
+ "example": "ahriman",
+ })
+ version = fields.String(required=True, metadata={
+ "description": "Package version",
+ "example": __version__,
+ })
diff --git a/src/ahriman/web/views/base.py b/src/ahriman/web/views/base.py
index 5a3f271f..a38f22c9 100644
--- a/src/ahriman/web/views/base.py
+++ b/src/ahriman/web/views/base.py
@@ -227,7 +227,7 @@ class BaseView(View, CorsViewMixin):
extract repository from request
Returns:
- RepositoryIde: repository if possible to construct and first one otherwise
+ RepositoryId: repository if possible to construct and first one otherwise
"""
architecture = self.request.query.get("architecture")
name = self.request.query.get("repository")
diff --git a/src/ahriman/web/views/v1/service/add.py b/src/ahriman/web/views/v1/service/add.py
index a0356f77..ec94f605 100644
--- a/src/ahriman/web/views/v1/service/add.py
+++ b/src/ahriman/web/views/v1/service/add.py
@@ -44,7 +44,6 @@ class AddView(BaseView):
description="Add new package(s) from AUR",
permission=POST_PERMISSION,
error_400_enabled=True,
- error_404_description="Repository is unknown",
schema=ProcessIdSchema,
query_schema=RepositoryIdSchema,
body_schema=PackagePatchSchema,
diff --git a/src/ahriman/web/views/v1/service/rebuild.py b/src/ahriman/web/views/v1/service/rebuild.py
index 0e88749d..6499fb71 100644
--- a/src/ahriman/web/views/v1/service/rebuild.py
+++ b/src/ahriman/web/views/v1/service/rebuild.py
@@ -43,7 +43,6 @@ class RebuildView(BaseView):
description="Rebuild packages which depend on specified one",
permission=POST_PERMISSION,
error_400_enabled=True,
- error_404_description="Repository is unknown",
schema=ProcessIdSchema,
query_schema=RepositoryIdSchema,
body_schema=PackageNamesSchema,
diff --git a/src/ahriman/web/views/v1/service/remove.py b/src/ahriman/web/views/v1/service/remove.py
index 8a21589a..3d0f62f5 100644
--- a/src/ahriman/web/views/v1/service/remove.py
+++ b/src/ahriman/web/views/v1/service/remove.py
@@ -43,7 +43,6 @@ class RemoveView(BaseView):
description="Remove specified packages from the repository",
permission=POST_PERMISSION,
error_400_enabled=True,
- error_404_description="Repository is unknown",
schema=ProcessIdSchema,
query_schema=RepositoryIdSchema,
body_schema=PackageNamesSchema,
diff --git a/src/ahriman/web/views/v1/service/request.py b/src/ahriman/web/views/v1/service/request.py
index 55eb2e28..d56a8bc0 100644
--- a/src/ahriman/web/views/v1/service/request.py
+++ b/src/ahriman/web/views/v1/service/request.py
@@ -44,7 +44,6 @@ class RequestView(BaseView):
description="Request new package(s) to be added from AUR",
permission=POST_PERMISSION,
error_400_enabled=True,
- error_404_description="Repository is unknown",
schema=ProcessIdSchema,
query_schema=RepositoryIdSchema,
body_schema=PackagePatchSchema,
diff --git a/src/ahriman/web/views/v1/service/rollback.py b/src/ahriman/web/views/v1/service/rollback.py
new file mode 100644
index 00000000..348a0316
--- /dev/null
+++ b/src/ahriman/web/views/v1/service/rollback.py
@@ -0,0 +1,77 @@
+#
+# Copyright (c) 2021-2026 ahriman team.
+#
+# This file is part of ahriman
+# (see https://github.com/arcan1s/ahriman).
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+from aiohttp.web import HTTPBadRequest, Response
+from typing import ClassVar
+
+from ahriman.models.user_access import UserAccess
+from ahriman.web.apispec.decorators import apidocs
+from ahriman.web.schemas import ProcessIdSchema, RepositoryIdSchema, RollbackSchema
+from ahriman.web.views.base import BaseView
+
+
+class RollbackView(BaseView):
+ """
+ package rollback web view
+
+ Attributes:
+ POST_PERMISSION(UserAccess): (class attribute) post permissions of self
+ """
+
+ POST_PERMISSION: ClassVar[UserAccess] = UserAccess.Full
+ ROUTES = ["/api/v1/service/rollback"]
+
+ @apidocs(
+ tags=["Actions"],
+ summary="Rollback package",
+ description="Rollback package to specified version",
+ permission=POST_PERMISSION,
+ error_400_enabled=True,
+ schema=ProcessIdSchema,
+ query_schema=RepositoryIdSchema,
+ body_schema=RollbackSchema,
+ )
+ async def post(self) -> Response:
+ """
+ run package rollback
+
+ Returns:
+ Response: 200 with spawned process id
+
+ Raises:
+ HTTPBadRequest: if bad data is supplied
+ """
+ try:
+ data = await self.request.json()
+ package = self.get_non_empty(lambda key: data[key], "package")
+ version = self.get_non_empty(lambda key: data[key], "version")
+ except Exception as ex:
+ raise HTTPBadRequest(reason=str(ex))
+
+ repository_id = self.repository_id()
+ username = await self.username()
+ process_id = self.spawner.packages_rollback(
+ repository_id,
+ package,
+ version,
+ username,
+ hold=data.get("hold", True),
+ )
+
+ return self.json_response({"process_id": process_id})
diff --git a/src/ahriman/web/views/v1/service/update.py b/src/ahriman/web/views/v1/service/update.py
index bc95b457..955bd9bd 100644
--- a/src/ahriman/web/views/v1/service/update.py
+++ b/src/ahriman/web/views/v1/service/update.py
@@ -43,14 +43,13 @@ class UpdateView(BaseView):
description="Run repository update process",
permission=POST_PERMISSION,
error_400_enabled=True,
- error_404_description="Repository is unknown",
schema=ProcessIdSchema,
query_schema=RepositoryIdSchema,
body_schema=UpdateFlagsSchema,
)
async def post(self) -> Response:
"""
- run repository update. No parameters supported here
+ run repository update
Returns:
Response: 200 with spawned process id
diff --git a/src/ahriman/web/views/v1/service/upload.py b/src/ahriman/web/views/v1/service/upload.py
index 145df917..54d1a52f 100644
--- a/src/ahriman/web/views/v1/service/upload.py
+++ b/src/ahriman/web/views/v1/service/upload.py
@@ -118,7 +118,6 @@ class UploadView(BaseView):
permission=POST_PERMISSION,
response_code=HTTPCreated,
error_400_enabled=True,
- error_404_description="Repository is unknown",
query_schema=RepositoryIdSchema,
body_schema=FileSchema,
body_location="form",
diff --git a/src/ahriman/web/web.py b/src/ahriman/web/web.py
index 0eab64da..a40ea234 100644
--- a/src/ahriman/web/web.py
+++ b/src/ahriman/web/web.py
@@ -104,6 +104,7 @@ def _create_watcher(path: Path, repository_id: RepositoryId) -> Watcher:
package_info = PackageInfo()
package_info.configuration = configuration
package_info.paths = configuration.repository_paths
+ package_info.reporter = client
package_info.repository_id = repository_id
return Watcher(client, package_info)
diff --git a/tests/ahriman/application/application/test_application_repository.py b/tests/ahriman/application/application/test_application_repository.py
index d3ca9a2b..d9d698b6 100644
--- a/tests/ahriman/application/application/test_application_repository.py
+++ b/tests/ahriman/application/application/test_application_repository.py
@@ -190,13 +190,14 @@ def test_update(application_repository: ApplicationRepository, package_ahriman:
"""
paths = [package.filepath for package in package_ahriman.packages.values()]
tree = Tree([Leaf(package_ahriman)])
+ prebuilt_result = Result()
resolve_mock = mocker.patch("ahriman.application.application.workers.local_updater.LocalUpdater.partition",
return_value=tree.levels())
mocker.patch("ahriman.core.repository.repository.Repository.packages_built", return_value=paths)
build_mock = mocker.patch("ahriman.application.application.workers.local_updater.LocalUpdater.update",
return_value=result)
- update_mock = mocker.patch("ahriman.core.repository.Repository.process_update", return_value=result)
+ update_mock = mocker.patch("ahriman.core.repository.Repository.process_update", return_value=prebuilt_result)
on_result_mock = mocker.patch(
"ahriman.application.application.application_repository.ApplicationRepository.on_result")
@@ -204,7 +205,24 @@ def test_update(application_repository: ApplicationRepository, package_ahriman:
resolve_mock.assert_called_once_with([package_ahriman])
build_mock.assert_called_once_with([package_ahriman], Packagers("username"), bump_pkgrel=True)
update_mock.assert_called_once_with(paths, Packagers("username"))
- on_result_mock.assert_has_calls([MockCall(result), MockCall(result)])
+ on_result_mock.assert_has_calls([MockCall(prebuilt_result), MockCall(result)])
+
+
+def test_update_prebuilt_filter(application_repository: ApplicationRepository, package_ahriman: Package, result: Result,
+ mocker: MockerFixture) -> None:
+ """
+ must filter out packages which were successfully prebuilt
+ """
+ paths = [package.filepath for package in package_ahriman.packages.values()]
+
+ resolve_mock = mocker.patch("ahriman.application.application.workers.local_updater.LocalUpdater.partition",
+ return_value=[])
+ mocker.patch("ahriman.core.repository.repository.Repository.packages_built", return_value=paths)
+ mocker.patch("ahriman.core.repository.Repository.process_update", return_value=result)
+ mocker.patch("ahriman.application.application.application_repository.ApplicationRepository.on_result")
+
+ application_repository.update([package_ahriman], Packagers("username"), bump_pkgrel=True)
+ resolve_mock.assert_called_once_with([])
def test_update_empty(application_repository: ApplicationRepository, package_ahriman: Package, result: Result,
diff --git a/tests/ahriman/application/handlers/test_handler_add.py b/tests/ahriman/application/handlers/test_handler_add.py
index f9507ab0..17d82aeb 100644
--- a/tests/ahriman/application/handlers/test_handler_add.py
+++ b/tests/ahriman/application/handlers/test_handler_add.py
@@ -3,14 +3,12 @@ import pytest
from pytest_mock import MockerFixture
+from ahriman.application.application import Application
from ahriman.application.handlers.add import Add
from ahriman.core.configuration import Configuration
from ahriman.core.repository import Repository
-from ahriman.models.package import Package
from ahriman.models.package_source import PackageSource
-from ahriman.models.packagers import Packagers
from ahriman.models.pkgbuild_patch import PkgbuildPatch
-from ahriman.models.result import Result
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
@@ -24,13 +22,9 @@ def _default_args(args: argparse.Namespace) -> argparse.Namespace:
argparse.Namespace: generated arguments for these test cases
"""
args.package = ["ahriman"]
- args.changes = True
- args.exit_code = False
- args.increment = True
args.now = False
args.refresh = 0
args.source = PackageSource.Auto
- args.dependencies = True
args.username = "username"
args.variable = None
return args
@@ -43,103 +37,49 @@ def test_run(args: argparse.Namespace, configuration: Configuration, repository:
"""
args = _default_args(args)
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
- application_mock = mocker.patch("ahriman.application.application.Application.add")
- dependencies_mock = mocker.patch("ahriman.application.application.Application.with_dependencies")
on_start_mock = mocker.patch("ahriman.application.application.Application.on_start")
+ perform_mock = mocker.patch("ahriman.application.handlers.add.Add.perform_action")
_, repository_id = configuration.check_loaded()
Add.run(args, repository_id, configuration, report=False)
- application_mock.assert_called_once_with(args.package, args.source, args.username)
- dependencies_mock.assert_not_called()
on_start_mock.assert_called_once_with()
+ perform_mock.assert_called_once_with(pytest.helpers.anyvar(int), args)
-def test_run_with_patches(args: argparse.Namespace, configuration: Configuration, repository: Repository,
- mocker: MockerFixture) -> None:
+def test_perform_action(args: argparse.Namespace, application: Application, mocker: MockerFixture) -> None:
"""
- must run command and insert temporary patches
+ must perform add action
+ """
+ args = _default_args(args)
+ application_mock = mocker.patch("ahriman.application.application.Application.add")
+ update_mock = mocker.patch("ahriman.application.handlers.update.Update.perform_action")
+
+ Add.perform_action(application, args)
+ application_mock.assert_called_once_with(args.package, args.source, args.username)
+ update_mock.assert_not_called()
+
+
+def test_perform_action_with_patches(args: argparse.Namespace, application: Application, mocker: MockerFixture) -> None:
+ """
+ must perform add action and insert temporary patches
"""
args = _default_args(args)
args.variable = ["KEY=VALUE"]
- mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
mocker.patch("ahriman.application.application.Application.add")
- application_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_patches_update")
+ patches_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_patches_update")
- _, repository_id = configuration.check_loaded()
- Add.run(args, repository_id, configuration, report=False)
- application_mock.assert_called_once_with(args.package[0], PkgbuildPatch("KEY", "VALUE"))
+ Add.perform_action(application, args)
+ patches_mock.assert_called_once_with(args.package[0], PkgbuildPatch("KEY", "VALUE"))
-def test_run_with_updates(args: argparse.Namespace, configuration: Configuration, repository: Repository,
- package_ahriman: Package, mocker: MockerFixture) -> None:
+def test_perform_action_with_updates(args: argparse.Namespace, application: Application, mocker: MockerFixture) -> None:
"""
- must run command with updates after
+ must perform add action with updates after
"""
args = _default_args(args)
args.now = True
- result = Result()
- result.add_updated(package_ahriman)
mocker.patch("ahriman.application.application.Application.add")
- mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
- application_mock = mocker.patch("ahriman.application.application.Application.update", return_value=result)
- check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_status")
- changes_mock = mocker.patch("ahriman.application.application.Application.changes")
- updates_mock = mocker.patch("ahriman.application.application.Application.updates", return_value=[package_ahriman])
- dependencies_mock = mocker.patch("ahriman.application.application.Application.with_dependencies",
- return_value=[package_ahriman])
- print_mock = mocker.patch("ahriman.application.application.Application.print_updates")
+ update_mock = mocker.patch("ahriman.application.handlers.update.Update.perform_action")
- _, repository_id = configuration.check_loaded()
- Add.run(args, repository_id, configuration, report=False)
- updates_mock.assert_called_once_with(args.package,
- aur=False, local=False, manual=True, vcs=False, check_files=False)
- changes_mock.assert_called_once_with([package_ahriman])
- application_mock.assert_called_once_with([package_ahriman],
- Packagers(args.username, {package_ahriman.base: "packager"}),
- bump_pkgrel=args.increment)
- dependencies_mock.assert_called_once_with([package_ahriman], process_dependencies=args.dependencies)
- check_mock.assert_called_once_with(False, True)
- print_mock.assert_called_once_with([package_ahriman], log_fn=pytest.helpers.anyvar(int))
-
-
-def test_run_no_changes(args: argparse.Namespace, configuration: Configuration, repository: Repository,
- mocker: MockerFixture) -> None:
- """
- must skip changes calculation during package addition
- """
- args = _default_args(args)
- args.now = True
- args.changes = False
- mocker.patch("ahriman.application.application.Application.add")
- mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
- mocker.patch("ahriman.application.application.Application.update")
- mocker.patch("ahriman.application.handlers.handler.Handler.check_status")
- mocker.patch("ahriman.application.application.Application.updates")
- mocker.patch("ahriman.application.application.Application.with_dependencies")
- mocker.patch("ahriman.application.application.Application.print_updates")
- changes_mock = mocker.patch("ahriman.application.application.Application.changes")
-
- _, repository_id = configuration.check_loaded()
- Add.run(args, repository_id, configuration, report=False)
- changes_mock.assert_not_called()
-
-
-def test_run_empty_exception(args: argparse.Namespace, configuration: Configuration, repository: Repository,
- mocker: MockerFixture) -> None:
- """
- must raise ExitCode exception on empty result
- """
- args = _default_args(args)
- args.now = True
- args.exit_code = True
- mocker.patch("ahriman.application.application.Application.add")
- mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
- mocker.patch("ahriman.application.application.Application.update", return_value=Result())
- mocker.patch("ahriman.application.application.Application.with_dependencies")
- mocker.patch("ahriman.application.application.Application.updates")
- mocker.patch("ahriman.application.application.Application.print_updates")
- check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_status")
-
- _, repository_id = configuration.check_loaded()
- Add.run(args, repository_id, configuration, report=False)
- check_mock.assert_called_once_with(True, False)
+ Add.perform_action(application, args)
+ update_mock.assert_called_once_with(application, args)
diff --git a/tests/ahriman/application/handlers/test_handler_rollback.py b/tests/ahriman/application/handlers/test_handler_rollback.py
new file mode 100644
index 00000000..d32fef97
--- /dev/null
+++ b/tests/ahriman/application/handlers/test_handler_rollback.py
@@ -0,0 +1,113 @@
+import argparse
+import pytest
+
+from pytest_mock import MockerFixture
+
+from ahriman.application.application import Application
+from ahriman.application.handlers.rollback import Rollback
+from ahriman.core.configuration import Configuration
+from ahriman.core.exceptions import UnknownPackageError
+from ahriman.core.repository import Repository
+from ahriman.models.package import Package
+
+
+def _default_args(args: argparse.Namespace) -> argparse.Namespace:
+ """
+ default arguments for these test cases
+
+ Args:
+ args(argparse.Namespace): command line arguments fixture
+
+ Returns:
+ argparse.Namespace: generated arguments for these test cases
+ """
+ args.package = "ahriman"
+ args.version = "1.0.0-1"
+ args.hold = False
+ return args
+
+
+def test_run(args: argparse.Namespace, configuration: Configuration, repository: Repository, package_ahriman: Package,
+ mocker: MockerFixture) -> None:
+ """
+ must run command
+ """
+ args = _default_args(args)
+ artifacts = [package.filepath for package in package_ahriman.packages.values()]
+ mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
+ on_start_mock = mocker.patch("ahriman.application.application.Application.on_start")
+ load_mock = mocker.patch("ahriman.application.handlers.rollback.Rollback.package_load",
+ return_value=package_ahriman)
+ artifacts_mock = mocker.patch("ahriman.application.handlers.rollback.Rollback.package_artifacts",
+ return_value=artifacts)
+ perform_mock = mocker.patch("ahriman.application.handlers.add.Add.perform_action")
+ hold_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_hold_update")
+
+ _, repository_id = configuration.check_loaded()
+ Rollback.run(args, repository_id, configuration, report=False)
+ on_start_mock.assert_called_once_with()
+ load_mock.assert_called_once_with(pytest.helpers.anyvar(int), package_ahriman.base, args.version)
+ artifacts_mock.assert_called_once_with(pytest.helpers.anyvar(int), package_ahriman)
+ perform_mock.assert_called_once_with(pytest.helpers.anyvar(int), args)
+ hold_mock.assert_not_called()
+
+
+def test_run_hold(args: argparse.Namespace, configuration: Configuration, repository: Repository,
+ package_ahriman: Package, mocker: MockerFixture) -> None:
+ """
+ must hold package after rollback
+ """
+ args = _default_args(args)
+ args.hold = True
+ mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
+ mocker.patch("ahriman.application.application.Application.on_start")
+ mocker.patch("ahriman.application.handlers.rollback.Rollback.package_load", return_value=package_ahriman)
+ mocker.patch("ahriman.application.handlers.rollback.Rollback.package_artifacts", return_value=[])
+ mocker.patch("ahriman.application.handlers.add.Add.perform_action")
+ hold_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_hold_update")
+
+ _, repository_id = configuration.check_loaded()
+ Rollback.run(args, repository_id, configuration, report=False)
+ hold_mock.assert_called_once_with(package_ahriman.base, enabled=True)
+
+
+def test_package_artifacts(application: Application, package_ahriman: Package, mocker: MockerFixture) -> None:
+ """
+ must return package artifacts
+ """
+ artifacts = [package.filepath for package in package_ahriman.packages.values()]
+ lookup_mock = mocker.patch("ahriman.core.repository.Repository.package_archives_lookup", return_value=artifacts)
+
+ assert Rollback.package_artifacts(application, package_ahriman) == artifacts
+ lookup_mock.assert_called_once_with(package_ahriman)
+
+
+def test_package_artifacts_empty(application: Application, package_ahriman: Package,
+ mocker: MockerFixture) -> None:
+ """
+ must raise UnknownPackageError if no artifacts found
+ """
+ mocker.patch("ahriman.core.repository.Repository.package_archives_lookup", return_value=[])
+ with pytest.raises(UnknownPackageError):
+ Rollback.package_artifacts(application, package_ahriman)
+
+
+def test_package_load(application: Application, package_ahriman: Package, mocker: MockerFixture) -> None:
+ """
+ must load package from reporter
+ """
+ package_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_get",
+ return_value=[(package_ahriman, None)])
+
+ result = Rollback.package_load(application, package_ahriman.base, "2.0.0-1")
+ assert result.version == "2.0.0-1"
+ package_mock.assert_called_once_with(package_ahriman.base)
+
+
+def test_package_load_unknown(application: Application, package_ahriman: Package, mocker: MockerFixture) -> None:
+ """
+ must raise UnknownPackageError if package not found
+ """
+ mocker.patch("ahriman.core.status.local_client.LocalClient.package_get", return_value=[])
+ with pytest.raises(UnknownPackageError):
+ Rollback.package_load(application, package_ahriman.base, package_ahriman.version)
diff --git a/tests/ahriman/application/handlers/test_handler_update.py b/tests/ahriman/application/handlers/test_handler_update.py
index 428ddbde..f0290e8a 100644
--- a/tests/ahriman/application/handlers/test_handler_update.py
+++ b/tests/ahriman/application/handlers/test_handler_update.py
@@ -39,26 +39,39 @@ def _default_args(args: argparse.Namespace) -> argparse.Namespace:
return args
-def test_run(args: argparse.Namespace, package_ahriman: Package, configuration: Configuration, repository: Repository,
+def test_run(args: argparse.Namespace, configuration: Configuration, repository: Repository,
mocker: MockerFixture) -> None:
"""
must run command
"""
args = _default_args(args)
+ mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
+ on_start_mock = mocker.patch("ahriman.application.application.Application.on_start")
+ perform_mock = mocker.patch("ahriman.application.handlers.update.Update.perform_action")
+
+ _, repository_id = configuration.check_loaded()
+ Update.run(args, repository_id, configuration, report=False)
+ on_start_mock.assert_called_once_with()
+ perform_mock.assert_called_once_with(pytest.helpers.anyvar(int), args)
+
+
+def test_perform_action(args: argparse.Namespace, application: Application, package_ahriman: Package,
+ mocker: MockerFixture) -> None:
+ """
+ must perform update action
+ """
+ args = _default_args(args)
result = Result()
result.add_updated(package_ahriman)
- mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
application_mock = mocker.patch("ahriman.application.application.Application.update", return_value=result)
check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_status")
dependencies_mock = mocker.patch("ahriman.application.application.Application.with_dependencies",
return_value=[package_ahriman])
updates_mock = mocker.patch("ahriman.application.application.Application.updates", return_value=[package_ahriman])
changes_mock = mocker.patch("ahriman.application.application.Application.changes")
- on_start_mock = mocker.patch("ahriman.application.application.Application.on_start")
print_mock = mocker.patch("ahriman.application.application.Application.print_updates")
- _, repository_id = configuration.check_loaded()
- Update.run(args, repository_id, configuration, report=False)
+ Update.perform_action(application, args)
application_mock.assert_called_once_with([package_ahriman],
Packagers(args.username, {package_ahriman.base: "packager"}),
bump_pkgrel=args.increment)
@@ -67,35 +80,31 @@ def test_run(args: argparse.Namespace, package_ahriman: Package, configuration:
changes_mock.assert_called_once_with([package_ahriman])
dependencies_mock.assert_called_once_with([package_ahriman], process_dependencies=args.dependencies)
check_mock.assert_called_once_with(False, True)
- on_start_mock.assert_called_once_with()
print_mock.assert_called_once_with([package_ahriman], log_fn=pytest.helpers.anyvar(int))
-def test_run_empty_exception(args: argparse.Namespace, configuration: Configuration, repository: Repository,
- mocker: MockerFixture) -> None:
+def test_perform_action_empty_exception(args: argparse.Namespace, application: Application,
+ mocker: MockerFixture) -> None:
"""
must raise ExitCode exception on empty update list
"""
args = _default_args(args)
args.exit_code = True
args.dry_run = True
- mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
mocker.patch("ahriman.application.application.Application.updates", return_value=[])
check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_status")
- _, repository_id = configuration.check_loaded()
- Update.run(args, repository_id, configuration, report=False)
+ Update.perform_action(application, args)
check_mock.assert_called_once_with(True, [])
-def test_run_update_empty_exception(args: argparse.Namespace, package_ahriman: Package, configuration: Configuration,
- repository: Repository, mocker: MockerFixture) -> None:
+def test_perform_action_update_empty_exception(args: argparse.Namespace, application: Application,
+ package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must raise ExitCode exception on empty build result
"""
args = _default_args(args)
args.exit_code = True
- mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
mocker.patch("ahriman.application.application.Application.update", return_value=Result())
mocker.patch("ahriman.application.application.Application.updates", return_value=[package_ahriman])
mocker.patch("ahriman.application.application.Application.with_dependencies", return_value=[package_ahriman])
@@ -103,26 +112,23 @@ def test_run_update_empty_exception(args: argparse.Namespace, package_ahriman: P
mocker.patch("ahriman.application.application.Application.changes")
check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_status")
- _, repository_id = configuration.check_loaded()
- Update.run(args, repository_id, configuration, report=False)
+ Update.perform_action(application, args)
check_mock.assert_called_once_with(True, False)
-def test_run_dry_run(args: argparse.Namespace, package_ahriman: Package, configuration: Configuration,
- repository: Repository, mocker: MockerFixture) -> None:
+def test_perform_action_dry_run(args: argparse.Namespace, application: Application, package_ahriman: Package,
+ mocker: MockerFixture) -> None:
"""
must run simplified command
"""
args = _default_args(args)
args.dry_run = True
- mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
application_mock = mocker.patch("ahriman.application.application.Application.update")
check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_status")
updates_mock = mocker.patch("ahriman.application.application.Application.updates", return_value=[package_ahriman])
changes_mock = mocker.patch("ahriman.application.application.Application.changes")
- _, repository_id = configuration.check_loaded()
- Update.run(args, repository_id, configuration, report=False)
+ Update.perform_action(application, args)
updates_mock.assert_called_once_with(
args.package, aur=args.aur, local=args.local, manual=args.manual, vcs=args.vcs, check_files=args.check_files)
application_mock.assert_not_called()
@@ -130,22 +136,19 @@ def test_run_dry_run(args: argparse.Namespace, package_ahriman: Package, configu
check_mock.assert_called_once_with(False, [package_ahriman])
-def test_run_no_changes(args: argparse.Namespace, configuration: Configuration, repository: Repository,
- mocker: MockerFixture) -> None:
+def test_perform_action_no_changes(args: argparse.Namespace, application: Application, mocker: MockerFixture) -> None:
"""
must skip changes calculation
"""
args = _default_args(args)
args.dry_run = True
args.changes = False
- mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
mocker.patch("ahriman.application.application.Application.update")
mocker.patch("ahriman.application.handlers.handler.Handler.check_status")
mocker.patch("ahriman.application.application.Application.updates")
changes_mock = mocker.patch("ahriman.application.application.Application.changes")
- _, repository_id = configuration.check_loaded()
- Update.run(args, repository_id, configuration, report=False)
+ Update.perform_action(application, args)
changes_mock.assert_not_called()
diff --git a/tests/ahriman/application/test_ahriman.py b/tests/ahriman/application/test_ahriman.py
index ef3502c1..ddbe3220 100644
--- a/tests/ahriman/application/test_ahriman.py
+++ b/tests/ahriman/application/test_ahriman.py
@@ -271,6 +271,18 @@ def test_subparsers_package_add_option_variable_multiple(parser: argparse.Argume
assert args.variable == ["var1", "var2"]
+def test_subparsers_package_add_repo_update(parser: argparse.ArgumentParser) -> None:
+ """
+ package-add must have same keys as repo-update
+ """
+ args = parser.parse_args(["package-add", "ahriman"])
+ reference_args = parser.parse_args(["repo-update"])
+ del args.now
+ del args.source
+ del args.variable
+ assert dir(args) == dir(reference_args)
+
+
def test_subparsers_package_archives(parser: argparse.ArgumentParser) -> None:
"""
package-archives command must imply action, exit code, info, lock, quiet, report and unsafe
@@ -325,6 +337,26 @@ def test_subparsers_package_changes_remove_package_changes(parser: argparse.Argu
assert dir(args) == dir(reference_args)
+def test_subparsers_package_copy_option_architecture(parser: argparse.ArgumentParser) -> None:
+ """
+ package-copy command must correctly parse architecture list
+ """
+ args = parser.parse_args(["package-copy", "source", "ahriman"])
+ assert args.architecture is None
+ args = parser.parse_args(["-a", "x86_64", "package-copy", "source", "ahriman"])
+ assert args.architecture == "x86_64"
+
+
+def test_subparsers_package_copy_option_repository(parser: argparse.ArgumentParser) -> None:
+ """
+ package-copy command must correctly parse repository list
+ """
+ args = parser.parse_args(["package-copy", "source", "ahriman"])
+ assert args.repository is None
+ args = parser.parse_args(["-r", "repo", "package-copy", "source", "ahriman"])
+ assert args.repository == "repo"
+
+
def test_subparsers_package_pkgbuild(parser: argparse.ArgumentParser) -> None:
"""
package-pkgbuild command must imply action, exit code, lock, quiet, report and unsafe
@@ -363,26 +395,6 @@ def test_subparsers_package_pkgbuild_remove_package_pkgbuild(parser: argparse.Ar
assert dir(args) == dir(reference_args)
-def test_subparsers_package_copy_option_architecture(parser: argparse.ArgumentParser) -> None:
- """
- package-copy command must correctly parse architecture list
- """
- args = parser.parse_args(["package-copy", "source", "ahriman"])
- assert args.architecture is None
- args = parser.parse_args(["-a", "x86_64", "package-copy", "source", "ahriman"])
- assert args.architecture == "x86_64"
-
-
-def test_subparsers_package_copy_option_repository(parser: argparse.ArgumentParser) -> None:
- """
- package-copy command must correctly parse repository list
- """
- args = parser.parse_args(["package-copy", "source", "ahriman"])
- assert args.repository is None
- args = parser.parse_args(["-r", "repo", "package-copy", "source", "ahriman"])
- assert args.repository == "repo"
-
-
def test_subparsers_package_remove_option_architecture(parser: argparse.ArgumentParser) -> None:
"""
package-remove command must correctly parse architecture list
@@ -403,6 +415,68 @@ def test_subparsers_package_remove_option_repository(parser: argparse.ArgumentPa
assert args.repository == "repo"
+def test_subparsers_package_rollback(parser: argparse.ArgumentParser) -> None:
+ """
+ package-rollback command must imply aur, changes, check-files, dependencies, dry-run, exit-code, increment, now,
+ local, manual, refresh, source, variable and vcs
+ """
+ args = parser.parse_args(["package-rollback", "ahriman", "1.0.0-1"])
+ assert not args.aur
+ assert not args.changes
+ assert not args.check_files
+ assert not args.dependencies
+ assert not args.dry_run
+ assert args.exit_code
+ assert not args.increment
+ assert not args.local
+ assert not args.manual
+ assert args.now
+ assert not args.refresh
+ assert not args.vcs
+ assert args.variable is None
+
+
+def test_subparsers_package_rollback_option_architecture(parser: argparse.ArgumentParser) -> None:
+ """
+ package-rollback command must correctly parse architecture list
+ """
+ args = parser.parse_args(["package-rollback", "ahriman", "1.0.0-1"])
+ assert args.architecture is None
+ args = parser.parse_args(["-a", "x86_64", "package-rollback", "ahriman", "1.0.0-1"])
+ assert args.architecture == "x86_64"
+
+
+def test_subparsers_package_rollback_option_repository(parser: argparse.ArgumentParser) -> None:
+ """
+ package-rollback command must correctly parse repository list
+ """
+ args = parser.parse_args(["package-rollback", "ahriman", "1.0.0-1"])
+ assert args.repository is None
+ args = parser.parse_args(["-r", "repo", "package-rollback", "ahriman", "1.0.0-1"])
+ assert args.repository == "repo"
+
+
+def test_subparsers_package_rollback_option_hold(parser: argparse.ArgumentParser) -> None:
+ """
+ package-rollback command must correctly parse hold option
+ """
+ args = parser.parse_args(["package-rollback", "ahriman", "1.0.0-1"])
+ assert args.hold
+ args = parser.parse_args(["package-rollback", "ahriman", "1.0.0-1", "--no-hold"])
+ assert not args.hold
+
+
+def test_subparsers_package_rollback_package_add(parser: argparse.ArgumentParser) -> None:
+ """
+ package-rollback must have same keys as package-add
+ """
+ args = parser.parse_args(["package-rollback", "ahriman", "1.0.0-1"])
+ reference_args = parser.parse_args(["package-add", "ahriman"])
+ del args.hold
+ del args.version
+ assert dir(args) == dir(reference_args)
+
+
def test_subparsers_package_status(parser: argparse.ArgumentParser) -> None:
"""
package-status command must imply lock, quiet, report and unsafe
diff --git a/tests/ahriman/core/repository/test_package_info.py b/tests/ahriman/core/repository/test_package_info.py
index 7f4efb3b..7309310b 100644
--- a/tests/ahriman/core/repository/test_package_info.py
+++ b/tests/ahriman/core/repository/test_package_info.py
@@ -8,7 +8,6 @@ from unittest.mock import MagicMock
from ahriman.core.repository import Repository
from ahriman.models.changes import Changes
from ahriman.models.package import Package
-from ahriman.models.repository_id import RepositoryId
def test_full_depends(repository: Repository, package_ahriman: Package, package_python_schedule: Package,
@@ -93,18 +92,41 @@ def test_load_archives_different_version(repository: Repository, package_python_
assert packages[0].version == package_python_schedule.version
+def test_load_archives_all_versions(repository: Repository, package_ahriman: Package,
+ mocker: MockerFixture) -> None:
+ """
+ must load packages with different versions keeping all when latest_only is False
+ """
+ mocker.patch("ahriman.models.package.Package.from_archive",
+ side_effect=[package_ahriman, replace(package_ahriman, version="0.0.1-1")])
+ mocker.patch("ahriman.core.status.local_client.LocalClient.package_get", return_value=[])
+
+ packages = repository.load_archives([Path("a.pkg.tar.xz"), Path("b.pkg.tar.xz")], latest_only=False)
+ assert len(packages) == 2
+
+
def test_package_archives(repository: Repository, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must load package archives sorted by version
"""
- mocker.patch("ahriman.core.repository.package_info.package_like", return_value=True)
- mocker.patch("pathlib.Path.iterdir", return_value=[str(i) for i in range(5)])
- mocker.patch("ahriman.models.package.Package.from_archive",
- side_effect=lambda version: replace(package_ahriman, version=version))
+ mocker.patch("pathlib.Path.is_dir", return_value=True)
+ mocker.patch("pathlib.Path.iterdir")
+ load_mock = mocker.patch("ahriman.core.repository.package_info.PackageInfo.load_archives",
+ return_value=[replace(package_ahriman, version=str(i)) for i in range(5)])
result = repository.package_archives(package_ahriman.base)
assert len(result) == 5
assert [p.version for p in result] == [str(i) for i in range(5)]
+ load_mock.assert_called_once_with(pytest.helpers.anyvar(int), latest_only=False)
+
+
+def test_package_archives_no_directory(repository: Repository, package_ahriman: Package,
+ mocker: MockerFixture) -> None:
+ """
+ must return empty list if archive directory does not exist
+ """
+ mocker.patch("pathlib.Path.is_dir", return_value=False)
+ assert repository.package_archives(package_ahriman.base) == []
def test_package_archives_architecture_mismatch(repository: Repository, package_ahriman: Package,
@@ -114,8 +136,10 @@ def test_package_archives_architecture_mismatch(repository: Repository, package_
"""
package_ahriman.packages[package_ahriman.base].architecture = "i686"
- mocker.patch("pathlib.Path.iterdir", return_value=[package_ahriman.packages[package_ahriman.base].filepath])
- mocker.patch("ahriman.models.package.Package.from_archive", return_value=package_ahriman)
+ mocker.patch("pathlib.Path.is_dir", return_value=True)
+ mocker.patch("pathlib.Path.iterdir")
+ mocker.patch("ahriman.core.repository.package_info.PackageInfo.load_archives",
+ return_value=[package_ahriman])
result = repository.package_archives(package_ahriman.base)
assert len(result) == 0
@@ -126,13 +150,7 @@ def test_package_archives_lookup(repository: Repository, package_ahriman: Packag
"""
must existing packages which match the version
"""
- mocker.patch("pathlib.Path.is_dir", return_value=True)
- 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=[
+ archives_mock = mocker.patch("ahriman.core.repository.package_info.PackageInfo.package_archives", return_value=[
package_ahriman,
package_python_schedule,
replace(package_ahriman, version="1"),
@@ -140,6 +158,7 @@ def test_package_archives_lookup(repository: Repository, package_ahriman: Packag
glob_mock = mocker.patch("pathlib.Path.glob", return_value=[Path("1.pkg.tar.xz")])
assert repository.package_archives_lookup(package_ahriman) == [Path("1.pkg.tar.xz")]
+ archives_mock.assert_called_once_with(package_ahriman.base)
glob_mock.assert_called_once_with(f"{package_ahriman.packages[package_ahriman.base].filename}*")
@@ -148,12 +167,8 @@ def test_package_archives_lookup_version_mismatch(repository: Repository, packag
"""
must return nothing if no packages found with the same version
"""
- mocker.patch("pathlib.Path.is_dir", return_value=True)
- 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"))
-
+ mocker.patch("ahriman.core.repository.package_info.PackageInfo.package_archives",
+ return_value=[replace(package_ahriman, version="1")])
assert repository.package_archives_lookup(package_ahriman) == []
@@ -162,14 +177,7 @@ def test_package_archives_lookup_architecture_mismatch(repository: Repository, p
"""
must return nothing if architecture doesn't match
"""
- package_ahriman.packages[package_ahriman.base].architecture = "x86_64"
- mocker.patch("pathlib.Path.is_dir", return_value=True)
- repository.repository_id = RepositoryId("i686", repository.repository_id.name)
- mocker.patch("pathlib.Path.iterdir", return_value=[
- Path("1.pkg.tar.zst"),
- ])
- mocker.patch("ahriman.models.package.Package.from_archive", return_value=package_ahriman)
-
+ mocker.patch("ahriman.core.repository.package_info.PackageInfo.package_archives", return_value=[])
assert repository.package_archives_lookup(package_ahriman) == []
@@ -178,7 +186,7 @@ def test_package_archives_lookup_no_archive_directory(repository: Repository, pa
"""
must return nothing if no archive directory found
"""
- mocker.patch("pathlib.Path.is_dir", return_value=False)
+ mocker.patch("ahriman.core.repository.package_info.PackageInfo.package_archives", return_value=[])
assert repository.package_archives_lookup(package_ahriman) == []
diff --git a/tests/ahriman/core/test_spawn.py b/tests/ahriman/core/test_spawn.py
index 40377faf..531034dd 100644
--- a/tests/ahriman/core/test_spawn.py
+++ b/tests/ahriman/core/test_spawn.py
@@ -196,6 +196,26 @@ def test_packages_remove(spawner: Spawn, repository_id: RepositoryId, mocker: Mo
spawn_mock.assert_called_once_with(repository_id, "package-remove", "ahriman", "linux")
+def test_packages_rollback(spawner: Spawn, repository_id: RepositoryId, mocker: MockerFixture) -> None:
+ """
+ must call package rollback
+ """
+ spawn_mock = mocker.patch("ahriman.core.spawn.Spawn._spawn_process")
+ assert spawner.packages_rollback(repository_id, "ahriman", "1.0.0-1", "packager", hold=False)
+ spawn_mock.assert_called_once_with(repository_id, "package-rollback", "ahriman", "1.0.0-1",
+ **{"username": "packager", "no-hold": ""})
+
+
+def test_packages_rollback_with_hold(spawner: Spawn, repository_id: RepositoryId, mocker: MockerFixture) -> None:
+ """
+ must call package rollback with hold
+ """
+ spawn_mock = mocker.patch("ahriman.core.spawn.Spawn._spawn_process")
+ assert spawner.packages_rollback(repository_id, "ahriman", "1.0.0-1", "packager", hold=True)
+ spawn_mock.assert_called_once_with(repository_id, "package-rollback", "ahriman", "1.0.0-1",
+ **{"username": "packager", "hold": ""})
+
+
def test_packages_update(spawner: Spawn, repository_id: RepositoryId, mocker: MockerFixture) -> None:
"""
must call repo update
diff --git a/tests/ahriman/web/schemas/test_packager_schema.py b/tests/ahriman/web/schemas/test_packager_schema.py
new file mode 100644
index 00000000..1982fb6b
--- /dev/null
+++ b/tests/ahriman/web/schemas/test_packager_schema.py
@@ -0,0 +1 @@
+# schema testing goes in view class tests
diff --git a/tests/ahriman/web/schemas/test_rollback_schema.py b/tests/ahriman/web/schemas/test_rollback_schema.py
new file mode 100644
index 00000000..1982fb6b
--- /dev/null
+++ b/tests/ahriman/web/schemas/test_rollback_schema.py
@@ -0,0 +1 @@
+# schema testing goes in view class tests
diff --git a/tests/ahriman/web/views/v1/service/test_view_v1_service_rollback.py b/tests/ahriman/web/views/v1/service/test_view_v1_service_rollback.py
new file mode 100644
index 00000000..d6d9156d
--- /dev/null
+++ b/tests/ahriman/web/views/v1/service/test_view_v1_service_rollback.py
@@ -0,0 +1,70 @@
+import pytest
+
+from aiohttp.test_utils import TestClient
+from pytest_mock import MockerFixture
+from unittest.mock import AsyncMock
+
+from ahriman.models.repository_id import RepositoryId
+from ahriman.models.user_access import UserAccess
+from ahriman.web.views.v1.service.rollback import RollbackView
+
+
+async def test_get_permission() -> None:
+ """
+ must return correct permission for the request
+ """
+ for method in ("POST",):
+ request = pytest.helpers.request("", "", method)
+ assert await RollbackView.get_permission(request) == UserAccess.Full
+
+
+def test_routes() -> None:
+ """
+ must return correct routes
+ """
+ assert RollbackView.ROUTES == ["/api/v1/service/rollback"]
+
+
+async def test_post(client: TestClient, repository_id: RepositoryId, mocker: MockerFixture) -> None:
+ """
+ must call post request correctly
+ """
+ rollback_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_rollback", return_value="abc")
+ user_mock = AsyncMock()
+ user_mock.return_value = "username"
+ mocker.patch("ahriman.web.views.base.BaseView.username", side_effect=user_mock)
+ request_schema = pytest.helpers.schema_request(RollbackView.post)
+ response_schema = pytest.helpers.schema_response(RollbackView.post)
+
+ payload = {"package": "ahriman", "version": "version"}
+ assert not request_schema.validate(payload)
+ response = await client.post("/api/v1/service/rollback", json=payload)
+ assert response.ok
+ rollback_mock.assert_called_once_with(repository_id, "ahriman", "version", "username", hold=True)
+
+ json = await response.json()
+ assert json["process_id"] == "abc"
+ assert not response_schema.validate(json)
+
+
+async def test_post_empty(client: TestClient, mocker: MockerFixture) -> None:
+ """
+ must call raise 400 on empty request
+ """
+ rollback_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_rollback")
+ response_schema = pytest.helpers.schema_response(RollbackView.post, code=400)
+
+ response = await client.post("/api/v1/service/rollback", json={"package": "", "version": "version"})
+ assert response.status == 400
+ assert not response_schema.validate(await response.json())
+ rollback_mock.assert_not_called()
+
+ response = await client.post("/api/v1/service/rollback", json={"package": "ahriman", "version": ""})
+ assert response.status == 400
+ assert not response_schema.validate(await response.json())
+ rollback_mock.assert_not_called()
+
+ response = await client.post("/api/v1/service/rollback", json={})
+ assert response.status == 400
+ assert not response_schema.validate(await response.json())
+ rollback_mock.assert_not_called()