diff --git a/docs/ahriman.application.handlers.rst b/docs/ahriman.application.handlers.rst index 3c312ef0..c754110c 100644 --- a/docs/ahriman.application.handlers.rst +++ b/docs/ahriman.application.handlers.rst @@ -36,6 +36,14 @@ ahriman.application.handlers.clean module :no-undoc-members: :show-inheritance: +ahriman.application.handlers.copy module +---------------------------------------- + +.. automodule:: ahriman.application.handlers.copy + :members: + :no-undoc-members: + :show-inheritance: + ahriman.application.handlers.daemon module ------------------------------------------ diff --git a/docs/faq/general.rst b/docs/faq/general.rst index 3ce28833..d54583ea 100644 --- a/docs/faq/general.rst +++ b/docs/faq/general.rst @@ -148,13 +148,11 @@ Before using this command you will need to create local directory and put ``PKGB How to copy package from another repository ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -As simple as add package from archive. Considering case when you would like to copy package ``package`` with version ``ver-rel`` from repository ``source-repository`` to ``target-respository`` (same architecture), the command will be following: +It is possible to copy package and its metadata between local repositories, optionally removing the source archive, e.g.: .. code-block:: shell - sudo -u ahriman ahriman -r target-repository package-add /var/lib/ahriman/repository/source-repository/x86_64/package-ver-rel-x86_64.pkg.tar.zst - -In addition, you can remove source package as usual later. + sudo -u ahriman ahriman -r target-repository package-copy source-repository ahriman This feature in particular useful if for managing multiple repositories like ``[testing]`` and ``[extra]``. diff --git a/src/ahriman/application/ahriman.py b/src/ahriman/application/ahriman.py index 1392f021..53f3ce3f 100644 --- a/src/ahriman/application/ahriman.py +++ b/src/ahriman/application/ahriman.py @@ -110,6 +110,7 @@ Start web service (requires additional configuration): _set_package_add_parser(subparsers) _set_package_changes_parser(subparsers) _set_package_changes_remove_parser(subparsers) + _set_package_copy_parser(subparsers) _set_package_remove_parser(subparsers) _set_package_status_parser(subparsers) _set_package_status_remove_parser(subparsers) @@ -334,6 +335,27 @@ def _set_package_changes_remove_parser(root: SubParserAction) -> argparse.Argume return parser +def _set_package_copy_parser(root: SubParserAction) -> argparse.ArgumentParser: + """ + add parser for package copy subcommand + + Args: + root(SubParserAction): subparsers for the commands + + Returns: + argparse.ArgumentParser: created argument parser + """ + parser = root.add_parser("package-copy", aliases=["copy"], help="copy package from another repository", + description="copy package and its metadata from another repository", + formatter_class=_HelpFormatter) + parser.add_argument("source", help="source repository name") + parser.add_argument("package", help="package base", nargs="+") + parser.add_argument("-e", "--exit-code", help="return non-zero exit status if result is empty", action="store_true") + parser.add_argument("--remove", help="remove package from the source repository after", action="store_true") + parser.set_defaults(handler=handlers.Copy) + return parser + + def _set_package_remove_parser(root: SubParserAction) -> argparse.ArgumentParser: """ add parser for package removal subcommand diff --git a/src/ahriman/application/application/application_packages.py b/src/ahriman/application/application/application_packages.py index c2b2ef4d..18fcf701 100644 --- a/src/ahriman/application/application/application_packages.py +++ b/src/ahriman/application/application/application_packages.py @@ -141,19 +141,19 @@ class ApplicationPackages(ApplicationProperties): self.database.build_queue_insert(package) self.reporter.set_unknown(package) - def add(self, names: Iterable[str], source: PackageSource, username: str | None = None) -> None: + def add(self, packages: Iterable[str], source: PackageSource, username: str | None = None) -> None: """ add packages for the next build Args: - names(Iterable[str]): list of package bases to add + packages(Iterable[str]): list of package bases to add source(PackageSource): package source to add username(str | None, optional): optional override of username for build process (Default value = None) """ - for name in names: - resolved_source = source.resolve(name, self.repository.paths) + for package in packages: + resolved_source = source.resolve(package, self.repository.paths) fn = getattr(self, f"_add_{resolved_source.value}") - fn(name, username) + fn(package, username) def on_result(self, result: Result) -> None: """ @@ -167,16 +167,16 @@ class ApplicationPackages(ApplicationProperties): """ raise NotImplementedError - def remove(self, names: Iterable[str]) -> Result: + def remove(self, packages: Iterable[str]) -> Result: """ remove packages from repository Args: - names(Iterable[str]): list of packages (either base or name) to remove + packages(Iterable[str]): list of packages (either base or name) to remove Returns: Result: removal result """ - result = self.repository.process_remove(names) + result = self.repository.process_remove(packages) self.on_result(result) return result diff --git a/src/ahriman/application/application/application_repository.py b/src/ahriman/application/application/application_repository.py index 6ac306db..42d292b0 100644 --- a/src/ahriman/application/application/application_repository.py +++ b/src/ahriman/application/application/application_repository.py @@ -191,6 +191,12 @@ class ApplicationRepository(ApplicationProperties): """ updates = {} + # always add already built packages, because they will be always added + updates.update({ + package.base: package + for package in self.repository.load_archives(self.repository.packages_built()) + }) + if aur: updates.update({package.base: package for package in self.repository.updates_aur(filter_packages, vcs=vcs)}) if local: diff --git a/src/ahriman/application/handlers/__init__.py b/src/ahriman/application/handlers/__init__.py index e29723cc..23b88a0f 100644 --- a/src/ahriman/application/handlers/__init__.py +++ b/src/ahriman/application/handlers/__init__.py @@ -21,6 +21,7 @@ from ahriman.application.handlers.add import Add from ahriman.application.handlers.backup import Backup from ahriman.application.handlers.change import Change from ahriman.application.handlers.clean import Clean +from ahriman.application.handlers.copy import Copy from ahriman.application.handlers.daemon import Daemon from ahriman.application.handlers.dump import Dump from ahriman.application.handlers.handler import Handler diff --git a/src/ahriman/application/handlers/copy.py b/src/ahriman/application/handlers/copy.py new file mode 100644 index 00000000..7add09ad --- /dev/null +++ b/src/ahriman/application/handlers/copy.py @@ -0,0 +1,95 @@ +# +# Copyright (c) 2021-2024 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 ahriman.application.application import Application +from ahriman.application.handlers.handler import Handler +from ahriman.core.configuration import Configuration +from ahriman.models.build_status import BuildStatusEnum +from ahriman.models.package import Package +from ahriman.models.package_source import PackageSource +from ahriman.models.repository_id import RepositoryId + + +class Copy(Handler): + """ + copy packages handler + """ + + ALLOW_MULTI_ARCHITECTURE_RUN = False # conflicting 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=report) + application.on_start() + + configuration_path, _ = configuration.check_loaded() + source_repository_id = RepositoryId(repository_id.architecture, args.source) + source_configuration = Configuration.from_path(configuration_path, source_repository_id) + source_application = Application(source_repository_id, source_configuration, report=report) + + packages = source_application.repository.packages(args.package) + Copy.check_status(args.exit_code, packages) + + for package in packages: + Copy.copy_package(package, application, source_application) + + # run update + application.update([]) + + if args.remove: + source_application.remove(args.package) + + @staticmethod + def copy_package(package: Package, application: Application, source_application: Application) -> None: + """ + copy package ``package`` from source repository to target repository + + Args: + package(Package): package to copy + application(Application): application instance of the target repository + source_application(Application): application instance of the source repository + """ + # copy files + source_paths = [ + str(source_application.repository.paths.repository / source.filename) + for source in package.packages.values() + if source.filename is not None + ] + application.add(source_paths, PackageSource.Archive) + + # copy metadata + application.reporter.package_changes_update( + package.base, source_application.reporter.package_changes_get(package.base) + ) + application.reporter.package_dependencies_update( + package.base, source_application.reporter.package_dependencies_get(package.base) + ) + application.reporter.package_update(package, BuildStatusEnum.Pending) diff --git a/tests/ahriman/application/application/test_application_repository.py b/tests/ahriman/application/application/test_application_repository.py index 3f023f40..c9e2b315 100644 --- a/tests/ahriman/application/application/test_application_repository.py +++ b/tests/ahriman/application/application/test_application_repository.py @@ -1,5 +1,6 @@ import pytest +from pathlib import Path from pytest_mock import MockerFixture from unittest.mock import call as MockCall @@ -213,6 +214,9 @@ def test_updates_all(application_repository: ApplicationRepository, package_ahri """ must get updates for all """ + path = Path("local") + mocker.patch("ahriman.core.repository.package_info.PackageInfo.packages_built", return_value=[path]) + updates_built_mock = mocker.patch("ahriman.core.repository.package_info.PackageInfo.load_archives") updates_aur_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_aur", return_value=[package_ahriman]) updates_local_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_local") @@ -220,6 +224,7 @@ def test_updates_all(application_repository: ApplicationRepository, package_ahri updates_deps_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_dependencies") application_repository.updates([], aur=True, local=True, manual=True, vcs=True, check_files=True) + updates_built_mock.assert_called_once_with([path]) updates_aur_mock.assert_called_once_with([], vcs=True) updates_local_mock.assert_called_once_with(vcs=True) updates_manual_mock.assert_called_once_with() @@ -230,12 +235,16 @@ def test_updates_disabled(application_repository: ApplicationRepository, mocker: """ must get updates without anything """ + path = Path("local") + mocker.patch("ahriman.core.repository.package_info.PackageInfo.packages_built", return_value=[path]) + updates_built_mock = mocker.patch("ahriman.core.repository.package_info.PackageInfo.load_archives") updates_aur_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_aur") updates_local_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_local") updates_manual_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_manual") updates_deps_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_dependencies") application_repository.updates([], aur=False, local=False, manual=False, vcs=True, check_files=False) + updates_built_mock.assert_called_once_with([path]) updates_aur_mock.assert_not_called() updates_local_mock.assert_not_called() updates_manual_mock.assert_not_called() @@ -246,12 +255,16 @@ def test_updates_no_aur(application_repository: ApplicationRepository, mocker: M """ must get updates without aur """ + path = Path("local") + mocker.patch("ahriman.core.repository.package_info.PackageInfo.packages_built", return_value=[path]) + updates_built_mock = mocker.patch("ahriman.core.repository.package_info.PackageInfo.load_archives") updates_aur_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_aur") updates_local_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_local") updates_manual_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_manual") updates_deps_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_dependencies") application_repository.updates([], aur=False, local=True, manual=True, vcs=True, check_files=True) + updates_built_mock.assert_called_once_with([path]) updates_aur_mock.assert_not_called() updates_local_mock.assert_called_once_with(vcs=True) updates_manual_mock.assert_called_once_with() @@ -262,12 +275,16 @@ def test_updates_no_local(application_repository: ApplicationRepository, mocker: """ must get updates without local packages """ + path = Path("local") + mocker.patch("ahriman.core.repository.package_info.PackageInfo.packages_built", return_value=[path]) + updates_built_mock = mocker.patch("ahriman.core.repository.package_info.PackageInfo.load_archives") updates_aur_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_aur") updates_local_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_local") updates_manual_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_manual") updates_deps_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_dependencies") application_repository.updates([], aur=True, local=False, manual=True, vcs=True, check_files=True) + updates_built_mock.assert_called_once_with([path]) updates_aur_mock.assert_called_once_with([], vcs=True) updates_local_mock.assert_not_called() updates_manual_mock.assert_called_once_with() @@ -278,12 +295,16 @@ def test_updates_no_manual(application_repository: ApplicationRepository, mocker """ must get updates without manual """ + path = Path("local") + mocker.patch("ahriman.core.repository.package_info.PackageInfo.packages_built", return_value=[path]) + updates_built_mock = mocker.patch("ahriman.core.repository.package_info.PackageInfo.load_archives") updates_aur_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_aur") updates_local_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_local") updates_manual_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_manual") updates_deps_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_dependencies") application_repository.updates([], aur=True, local=True, manual=False, vcs=True, check_files=True) + updates_built_mock.assert_called_once_with([path]) updates_aur_mock.assert_called_once_with([], vcs=True) updates_local_mock.assert_called_once_with(vcs=True) updates_manual_mock.assert_not_called() @@ -294,12 +315,16 @@ def test_updates_no_vcs(application_repository: ApplicationRepository, mocker: M """ must get updates without VCS """ + path = Path("local") + mocker.patch("ahriman.core.repository.package_info.PackageInfo.packages_built", return_value=[path]) + updates_built_mock = mocker.patch("ahriman.core.repository.package_info.PackageInfo.load_archives") updates_aur_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_aur") updates_local_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_local") updates_manual_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_manual") updates_deps_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_dependencies") application_repository.updates([], aur=True, local=True, manual=True, vcs=False, check_files=True) + updates_built_mock.assert_called_once_with([path]) updates_aur_mock.assert_called_once_with([], vcs=False) updates_local_mock.assert_called_once_with(vcs=False) updates_manual_mock.assert_called_once_with() @@ -310,12 +335,16 @@ def test_updates_no_check_files(application_repository: ApplicationRepository, m """ must get updates without checking broken links """ + path = Path("local") + mocker.patch("ahriman.core.repository.package_info.PackageInfo.packages_built", return_value=[path]) + updates_built_mock = mocker.patch("ahriman.core.repository.package_info.PackageInfo.load_archives") updates_aur_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_aur") updates_local_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_local") updates_manual_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_manual") updates_deps_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_dependencies") application_repository.updates([], aur=True, local=True, manual=True, vcs=True, check_files=False) + updates_built_mock.assert_called_once_with([path]) updates_aur_mock.assert_called_once_with([], vcs=True) updates_local_mock.assert_called_once_with(vcs=True) updates_manual_mock.assert_called_once_with() @@ -326,12 +355,16 @@ def test_updates_with_filter(application_repository: ApplicationRepository, mock """ must get updates with filter """ + path = Path("local") + mocker.patch("ahriman.core.repository.package_info.PackageInfo.packages_built", return_value=[path]) + updates_built_mock = mocker.patch("ahriman.core.repository.package_info.PackageInfo.load_archives") updates_aur_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_aur") updates_local_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_local") updates_manual_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_manual") updates_deps_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_dependencies") application_repository.updates(["filter"], aur=True, local=True, manual=True, vcs=True, check_files=True) + updates_built_mock.assert_called_once_with([path]) updates_aur_mock.assert_called_once_with(["filter"], vcs=True) updates_local_mock.assert_called_once_with(vcs=True) updates_manual_mock.assert_called_once_with() diff --git a/tests/ahriman/application/handlers/test_handler_copy.py b/tests/ahriman/application/handlers/test_handler_copy.py new file mode 100644 index 00000000..c364d90c --- /dev/null +++ b/tests/ahriman/application/handlers/test_handler_copy.py @@ -0,0 +1,105 @@ +import argparse +import pytest + +from pytest_mock import MockerFixture + +from ahriman.application.application import Application +from ahriman.application.handlers import Copy +from ahriman.core.configuration import Configuration +from ahriman.core.repository import Repository +from ahriman.models.build_status import BuildStatusEnum +from ahriman.models.package import Package +from ahriman.models.package_source import PackageSource + + +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.source = "source" + args.package = ["ahriman"] + args.exit_code = False + args.remove = 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) + mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) + mocker.patch("ahriman.core.repository.Repository.packages", return_value=[package_ahriman]) + application_mock = mocker.patch("ahriman.application.handlers.Copy.copy_package") + update_mock = mocker.patch("ahriman.application.application.Application.update") + remove_mock = mocker.patch("ahriman.application.application.Application.remove") + on_start_mock = mocker.patch("ahriman.application.application.Application.on_start") + + _, repository_id = configuration.check_loaded() + Copy.run(args, repository_id, configuration, report=False) + application_mock.assert_called_once_with(package_ahriman, pytest.helpers.anyvar(int), pytest.helpers.anyvar(int)) + update_mock.assert_called_once_with([]) + remove_mock.assert_not_called() + on_start_mock.assert_called_once_with() + + +def test_run_remove(args: argparse.Namespace, configuration: Configuration, repository: Repository, + package_ahriman: Package, mocker: MockerFixture) -> None: + """ + must run command and remove packages afterwards + """ + args = _default_args(args) + args.remove = True + mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) + mocker.patch("ahriman.core.repository.Repository.packages", return_value=[package_ahriman]) + mocker.patch("ahriman.application.handlers.Copy.copy_package") + mocker.patch("ahriman.application.application.Application.update") + remove_mock = mocker.patch("ahriman.application.application.Application.remove") + + _, repository_id = configuration.check_loaded() + Copy.run(args, repository_id, configuration, report=False) + remove_mock.assert_called_once_with(args.package) + + +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.exit_code = True + mocker.patch("ahriman.core.repository.Repository.packages", return_value=[]) + mocker.patch("ahriman.application.application.Application.update") + check_mock = mocker.patch("ahriman.application.handlers.Handler.check_status") + + _, repository_id = configuration.check_loaded() + Copy.run(args, repository_id, configuration, report=False) + check_mock.assert_called_once_with(True, []) + + +def test_copy_package(package_ahriman: Package, application: Application, mocker: MockerFixture) -> None: + """ + must copy package between repositories and its metadata + """ + add_mock = mocker.patch("ahriman.application.application.Application.add") + changes_get_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get") + changes_update_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_update") + deps_get_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_dependencies_get") + deps_update_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_dependencies_update") + package_update_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_update") + path = application.repository.paths.repository / package_ahriman.packages[package_ahriman.base].filename + + Copy.copy_package(package_ahriman, application, application) + add_mock.assert_called_once_with([str(path)], PackageSource.Archive) + changes_get_mock.assert_called_once_with(package_ahriman.base) + changes_update_mock.assert_called_once_with(package_ahriman.base, changes_get_mock.return_value) + deps_get_mock.assert_called_once_with(package_ahriman.base) + deps_update_mock.assert_called_once_with(package_ahriman.base, deps_get_mock.return_value) + package_update_mock.assert_called_once_with(package_ahriman, BuildStatusEnum.Pending) diff --git a/tests/ahriman/application/test_ahriman.py b/tests/ahriman/application/test_ahriman.py index 741f8d72..b5b523b7 100644 --- a/tests/ahriman/application/test_ahriman.py +++ b/tests/ahriman/application/test_ahriman.py @@ -309,6 +309,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_remove_option_architecture(parser: argparse.ArgumentParser) -> None: """ package-remove command must correctly parse architecture list