From f6c94d59cc116efa06134d3152c5ecddb2494da0 Mon Sep 17 00:00:00 2001 From: Evgenii Alekseev Date: Wed, 11 Nov 2026 16:45:57 +0200 Subject: [PATCH] implement detailed build plan --- .../application/application/application.py | 18 ++- .../application/application_repository.py | 73 +++++++--- .../ahriman/application/handlers/update.py | 7 +- .../src/ahriman/core/formatters/__init__.py | 1 + .../core/formatters/build_plan_printer.py | 84 +++++++++++ .../ahriman/core/formatters/update_printer.py | 6 +- .../ahriman/core/repository/update_handler.py | 22 ++- ahriman-core/src/ahriman/models/build_plan.py | 133 ++++++++++++++++++ .../application/test_application.py | 26 ++++ .../test_application_repository.py | 25 ++++ .../handlers/test_handler_update.py | 20 ++- .../formatters/test_build_plan_printer.py | 38 +++++ .../core/formatters/test_update_printer.py | 9 ++ .../core/repository/test_update_handler.py | 17 ++- .../tests/ahriman/models/test_build_plan.py | 34 +++++ docs/ahriman.core.formatters.rst | 8 ++ docs/ahriman.models.rst | 8 ++ docs/faq/general.rst | 2 + 18 files changed, 491 insertions(+), 40 deletions(-) create mode 100644 ahriman-core/src/ahriman/core/formatters/build_plan_printer.py create mode 100644 ahriman-core/src/ahriman/models/build_plan.py create mode 100644 ahriman-core/tests/ahriman/core/formatters/test_build_plan_printer.py create mode 100644 ahriman-core/tests/ahriman/models/test_build_plan.py diff --git a/ahriman-core/src/ahriman/application/application/application.py b/ahriman-core/src/ahriman/application/application/application.py index cdc5ad98..c9cdf7e6 100644 --- a/ahriman-core/src/ahriman/application/application/application.py +++ b/ahriman-core/src/ahriman/application/application/application.py @@ -21,8 +21,9 @@ from collections.abc import Callable, Iterable from ahriman.application.application.application_packages import ApplicationPackages from ahriman.application.application.application_repository import ApplicationRepository -from ahriman.core.formatters import UpdatePrinter +from ahriman.core.formatters import BuildPlanPrinter, UpdatePrinter from ahriman.core.tree import Tree +from ahriman.models.build_plan import BuildPlan from ahriman.models.package import Package from ahriman.models.result import Result @@ -95,6 +96,21 @@ class Application(ApplicationPackages, ApplicationRepository): """ self.repository.triggers.on_stop() + def print_plan(self, plan: BuildPlan, *, log_fn: Callable[[str], None], bump_pkgrel: bool = False) -> None: + """ + print the packages and reasons in a build plan, grouped by dependency step + + Args: + plan(BuildPlan): preview to display + log_fn(Callable[[str], None]): output function + bump_pkgrel(bool, optional): whether duplicate releases are expected to increment + """ + local_versions = {} if not plan.items else { + package.base: package.version for package in self.repository.packages() + } + BuildPlanPrinter(plan, local_versions, bump_pkgrel=bump_pkgrel)( + verbose=True, log_fn=log_fn, separator=" -> ") + def print_updates(self, packages: list[Package], *, log_fn: Callable[[str], None]) -> None: """ print list of packages to be built. This method will build dependency tree and print updates accordingly diff --git a/ahriman-core/src/ahriman/application/application/application_repository.py b/ahriman-core/src/ahriman/application/application/application_repository.py index 2c051834..710b06e0 100644 --- a/ahriman-core/src/ahriman/application/application/application_repository.py +++ b/ahriman-core/src/ahriman/application/application/application_repository.py @@ -23,6 +23,7 @@ from ahriman.application.application.application_properties import ApplicationPr from ahriman.application.application.workers import Updater from ahriman.core.build_tools.sources import Sources from ahriman.core.exceptions import UnknownPackageError +from ahriman.models.build_plan import BuildPlan from ahriman.models.package import Package from ahriman.models.packagers import Packagers from ahriman.models.result import Result @@ -33,6 +34,53 @@ class ApplicationRepository(ApplicationProperties): repository control class """ + def _collect_updates(self, filter_packages: Iterable[str], *, aur: bool, local: bool, manual: bool, vcs: bool, + check_files: bool) -> BuildPlan: + """ + collect update candidates for execution or preview + + Args: + filter_packages(Iterable[str]): restrict update checks to package bases + aur(bool): check AUR and official repository packages + local(bool): check locally sourced packages + manual(bool): include manually queued packages + vcs(bool): check VCS package versions + check_files(bool): check for broken implicit dependencies + Returns: + BuildPlan: selected update candidates and their reasons + """ + plan = BuildPlan() + updates = {} + + # always add already built packages, because they will be always added + built = self.repository.load_archives(self.repository.packages_built()) + updates.update({package.base: package for package in built}) + for package in built: + plan.add(package, "already built") + + if aur: + updates.update({package.base: package for package in self.repository.updates_aur( + filter_packages, vcs=vcs, plan=plan)}) + if local: + updates.update({package.base: package for package in self.repository.updates_local(vcs=vcs, plan=plan)}) + if manual: + updates.update({package.base: package for package in self.repository.updates_manual(plan=plan)}) + if check_files: + updates.update({package.base: package for package in self.repository.updates_dependencies( + filter_packages, plan=plan)}) + + for package in updates.values(): + plan.add(package) + selected = plan.packages + for package in selected: + requesters = [ + candidate.base for candidate in selected if candidate.base != package.base and set( + package.packages_full).intersection( + candidate.depends_build)] + if requesters: + plan.add_dependency(package, requesters) + return plan + def changes(self, packages: Iterable[Package]) -> None: """ generate and update package changes @@ -182,7 +230,7 @@ class ApplicationRepository(ApplicationProperties): return result def updates(self, filter_packages: Iterable[str], *, - aur: bool, local: bool, manual: bool, vcs: bool, check_files: bool) -> list[Package]: + aur: bool, local: bool, manual: bool, vcs: bool, check_files: bool) -> BuildPlan: """ get list of packages to run update process @@ -193,25 +241,8 @@ class ApplicationRepository(ApplicationProperties): manual(bool): include or exclude manual updates vcs(bool): enable or disable checking of VCS packages check_files(bool): check for broken dependencies - Returns: - list[Package]: list of out-of-dated packages + BuildPlan: selected packages and their reasons """ - 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: - updates.update({package.base: package for package in self.repository.updates_local(vcs=vcs)}) - if manual: - updates.update({package.base: package for package in self.repository.updates_manual()}) - if check_files: - updates.update({package.base: package for package in self.repository.updates_dependencies(filter_packages)}) - - return [package for _, package in sorted(updates.items())] + return self._collect_updates(filter_packages, aur=aur, local=local, manual=manual, vcs=vcs, + check_files=check_files) diff --git a/ahriman-core/src/ahriman/application/handlers/update.py b/ahriman-core/src/ahriman/application/handlers/update.py index 5fa10f25..23d28671 100644 --- a/ahriman-core/src/ahriman/application/handlers/update.py +++ b/ahriman-core/src/ahriman/application/handlers/update.py @@ -147,13 +147,16 @@ class Update(Handler): 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) + plan = application.updates( + args.package, aur=args.aur, local=args.local, manual=args.manual, vcs=args.vcs, + check_files=args.check_files) + packages = plan.packages 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 + application.print_plan(plan, log_fn=print, bump_pkgrel=args.increment) return packages = application.with_dependencies(packages, process_dependencies=args.dependencies) diff --git a/ahriman-core/src/ahriman/core/formatters/__init__.py b/ahriman-core/src/ahriman/core/formatters/__init__.py index 55bf6995..839f0ded 100644 --- a/ahriman-core/src/ahriman/core/formatters/__init__.py +++ b/ahriman-core/src/ahriman/core/formatters/__init__.py @@ -18,6 +18,7 @@ # along with this program. If not, see . # from ahriman.core.formatters.aur_printer import AurPrinter +from ahriman.core.formatters.build_plan_printer import BuildPlanPrinter from ahriman.core.formatters.build_printer import BuildPrinter from ahriman.core.formatters.changes_printer import ChangesPrinter from ahriman.core.formatters.configuration_paths_printer import ConfigurationPathsPrinter diff --git a/ahriman-core/src/ahriman/core/formatters/build_plan_printer.py b/ahriman-core/src/ahriman/core/formatters/build_plan_printer.py new file mode 100644 index 00000000..43947585 --- /dev/null +++ b/ahriman-core/src/ahriman/core/formatters/build_plan_printer.py @@ -0,0 +1,84 @@ +# +# 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 collections.abc import Callable + +from ahriman.core.formatters.printer import Printer +from ahriman.core.formatters.update_printer import UpdatePrinter +from ahriman.core.tree import Tree +from ahriman.models.build_plan import BuildPlan +from ahriman.models.package import Package + + +class BuildPlanPrinter(Printer): + """ + print a build plan with dependency steps and package selection reasons + + Attributes: + plan(BuildPlan): plan to display + local_versions(dict[str, str]): published package versions indexed by base + bump_pkgrel(bool): whether duplicate package releases are expected to increment + """ + + def __init__(self, plan: BuildPlan, local_versions: dict[str, str], *, bump_pkgrel: bool = False) -> None: + """ + Args: + plan(BuildPlan): plan to display + local_versions(dict[str, str]): published package versions indexed by base + bump_pkgrel(bool, optional): show an expected release increment (Default value = False) + """ + self.plan = plan + self.local_versions = local_versions + self.bump_pkgrel = bump_pkgrel + + def _print_package(self, package: Package, *, verbose: bool, log_fn: Callable[[str], None], + separator: str) -> None: + """ + print a package and its selection reasons + + Args: + package(Package): package to display + verbose(bool): include package selection reasons + log_fn(Callable[[str], None]): output function + separator(str): version separator + """ + UpdatePrinter(package, self.local_versions.get(package.base), bump_pkgrel=self.bump_pkgrel)( + verbose=True, log_fn=log_fn, separator=separator) + reasons = self.plan.item(package.base).reasons + if verbose and reasons: + log_fn(f"\tReasons: {'; '.join(reasons)}") + + def print(self, *, verbose: bool, log_fn: Callable[[str], None] = Printer._print, + separator: str = " -> ") -> None: + """ + print the plan as numbered dependency steps + + Args: + verbose(bool): include package selection reasons + log_fn(Callable[[str], None], optional): output function + separator(str, optional): version separator (Default value = " -> ") + """ + if not self.plan.items: + log_fn("No packages selected") + return + + for index, level in enumerate(Tree.resolve(self.plan.packages), start=1): + log_fn(f"Step {index}:") + for package in level: + self._print_package(package, verbose=verbose, log_fn=log_fn, separator=separator) diff --git a/ahriman-core/src/ahriman/core/formatters/update_printer.py b/ahriman-core/src/ahriman/core/formatters/update_printer.py index fdaf57ba..256df405 100644 --- a/ahriman-core/src/ahriman/core/formatters/update_printer.py +++ b/ahriman-core/src/ahriman/core/formatters/update_printer.py @@ -32,15 +32,17 @@ class UpdatePrinter(StringPrinter): local_version(str | None): local version of the package if any """ - def __init__(self, remote: Package, local_version: str | None) -> None: + def __init__(self, remote: Package, local_version: str | None, *, bump_pkgrel: bool = True) -> None: """ Args: remote(Package): remote (new) package object local_version(str | None): local version of the package if any + bump_pkgrel(bool, optional): show a release increment for duplicates (Default value = True) """ StringPrinter.__init__(self, remote.base) self.package = remote self.local_version = local_version + self.bump_pkgrel = bump_pkgrel def properties(self) -> list[Property]: """ @@ -49,7 +51,7 @@ class UpdatePrinter(StringPrinter): Returns: list[Property]: list of content properties """ - if (pkgrel := self.package.next_pkgrel(self.local_version)) is not None: + if self.bump_pkgrel and (pkgrel := self.package.next_pkgrel(self.local_version)) is not None: epoch, pkgver, _ = parse_version(self.package.version) effective_new_version = full_version(epoch, pkgver, pkgrel) else: diff --git a/ahriman-core/src/ahriman/core/repository/update_handler.py b/ahriman-core/src/ahriman/core/repository/update_handler.py index dbe42575..9fe71115 100644 --- a/ahriman-core/src/ahriman/core/repository/update_handler.py +++ b/ahriman-core/src/ahriman/core/repository/update_handler.py @@ -24,6 +24,7 @@ from ahriman.core.build_tools.sources import Sources from ahriman.core.exceptions import UnknownPackageError from ahriman.core.repository.cleaner import Cleaner from ahriman.core.repository.package_info import PackageInfo +from ahriman.models.build_plan import BuildPlan from ahriman.models.build_status import BuildStatusEnum from ahriman.models.event import EventType from ahriman.models.package import Package @@ -47,13 +48,14 @@ class UpdateHandler(PackageInfo, Cleaner): package.base for package, status in self.reporter.package_get(None) if status.is_held } - def updates_aur(self, filter_packages: Iterable[str], *, vcs: bool) -> list[Package]: + def updates_aur(self, filter_packages: Iterable[str], *, vcs: bool, plan: BuildPlan | None = None) -> list[Package]: """ check AUR for updates Args: filter_packages(Iterable[str]): do not check every package just specified in the list vcs(bool): enable or disable checking of VCS packages + plan(BuildPlan | None, optional): record update reasons for a preview Returns: list[Package]: list of packages which are out-of-dated @@ -87,6 +89,10 @@ class UpdateHandler(PackageInfo, Cleaner): self.reporter.set_pending(local.base) self.event(local.base, EventType.PackageOutdated, "Remote version is newer than local") result.append(remote) + if plan is not None: + reason = "official repository update" if local.remote.source == PackageSource.Repository \ + else "AUR update" + plan.add(remote, reason) else: self.reporter.package_status_update(local.base, BuildStatusEnum.Success) except Exception: @@ -95,13 +101,14 @@ class UpdateHandler(PackageInfo, Cleaner): return result - def updates_dependencies(self, filter_packages: Iterable[str]) -> list[Package]: + def updates_dependencies(self, filter_packages: Iterable[str], *, plan: BuildPlan | None = None) -> list[Package]: """ check packages which are required to be rebuilt based on dynamic dependencies (e.g. linking, modules paths, etc.) Args: filter_packages(Iterable[str]): do not check every package just specified in the list + plan(BuildPlan | None, optional): record dependency reasons for a preview Returns: list[Package]: list of packages for which there is breaking linking @@ -138,17 +145,20 @@ class UpdateHandler(PackageInfo, Cleaner): self.reporter.set_pending(local.base) self.event(local.base, EventType.PackageOutdated, "Implicit dependencies are broken") result.append(local) + if plan is not None: + plan.add(local, "broken implicit dependency") break return result - def updates_local(self, *, vcs: bool) -> list[Package]: + def updates_local(self, *, vcs: bool, plan: BuildPlan | None = None) -> list[Package]: """ check local packages for updates Args: vcs(bool): enable or disable checking of VCS packages + plan(BuildPlan | None, optional): record update reasons for a preview Returns: list[Package]: list of local packages which are out-of-dated @@ -188,12 +198,14 @@ class UpdateHandler(PackageInfo, Cleaner): self.reporter.set_pending(local.base) self.event(local.base, EventType.PackageOutdated, "Locally pulled sources are outdated") result.append(remote) + if plan is not None: + plan.add(remote, "local source update") except Exception: self.logger.exception("could not process package at %s", cache_dir) return result - def updates_manual(self) -> list[Package]: + def updates_manual(self, *, plan: BuildPlan | None = None) -> list[Package]: """ check for packages for which manual update has been requested @@ -206,6 +218,8 @@ class UpdateHandler(PackageInfo, Cleaner): try: for local in self.database.build_queue_get(): result.append(local) + if plan is not None: + plan.add(local, "queued request") if local.base not in known_bases: self.reporter.set_unknown(local) else: diff --git a/ahriman-core/src/ahriman/models/build_plan.py b/ahriman-core/src/ahriman/models/build_plan.py new file mode 100644 index 00000000..1e51a4c9 --- /dev/null +++ b/ahriman-core/src/ahriman/models/build_plan.py @@ -0,0 +1,133 @@ +# +# 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 collections.abc import Iterator +from dataclasses import dataclass, field + +from ahriman.models.package import Package + + +@dataclass +class BuildPlanItem: + """ + one package and the reasons it appears in a build plan + + Attributes: + package(Package): package selected for the plan + reasons(list[str]): reasons why the package was selected + """ + + package: Package + reasons: list[str] = field(default_factory=list) + + def add_reason(self, reason: str) -> None: + """ + add a reason unless it is already present + + Args: + reason(str): reason for selecting the package + """ + if reason not in self.reasons: + self.reasons.append(reason) + + +class BuildPlan: + """ + in-memory preview of the packages selected for a build operation + + Attributes: + _items(dict[str, BuildPlanItem]): plan items indexed by package base + """ + + def __init__(self) -> None: + """ + initialize an empty build plan + """ + self._items: dict[str, BuildPlanItem] = {} + + @property + def items(self) -> list[BuildPlanItem]: + """ + get plan items in stable package-base order + + Returns: + list[BuildPlanItem]: selected plan items + """ + return [self._items[base] for base in sorted(self._items)] + + @property + def packages(self) -> list[Package]: + """ + get packages in stable package-base order + + Returns: + list[Package]: selected packages + """ + return [item.package for item in self.items] + + def add(self, package: Package, reason: str | None = None) -> BuildPlanItem: + """ + add or update a package in the plan + + Args: + package(Package): package selected for the plan + reason(str | None, optional): reason for selecting it + + Returns: + BuildPlanItem: plan item for the package + """ + item = self._items.setdefault(package.base, BuildPlanItem(package)) + item.package = package + if reason is not None: + item.add_reason(reason) + return item + + def add_dependency(self, package: Package, requesters: list[str]) -> None: + """ + add a dependency and identify the selected packages that require it + + Args: + package(Package): dependency package + requesters(list[str]): package bases requiring this dependency + """ + item = self.add(package) + for requester in sorted(requesters): + item.add_reason(f"required by {requester}") + if not requesters: + item.add_reason("required dependency") + + def item(self, package_base: str) -> BuildPlanItem: + """ + get a plan item by package base + + Args: + package_base(str): package base to look up + + Returns: + BuildPlanItem: matching item + """ + return self._items[package_base] + + def __iter__(self) -> Iterator[Package]: + """Iterate over selected packages in stable package-base order.""" + return iter(self.packages) + + def __len__(self) -> int: + """Return the number of selected packages.""" + return len(self._items) diff --git a/ahriman-core/tests/ahriman/application/application/test_application.py b/ahriman-core/tests/ahriman/application/application/test_application.py index 5df929c4..e1a35248 100644 --- a/ahriman-core/tests/ahriman/application/application/test_application.py +++ b/ahriman-core/tests/ahriman/application/application/test_application.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, call as MockCall from ahriman.application.application import Application from ahriman.core.tree import Leaf, Tree +from ahriman.models.build_plan import BuildPlan from ahriman.models.package import Package from ahriman.models.result import Result @@ -63,6 +64,31 @@ def test_print_updates(application: Application, package_ahriman: Package, mocke print_mock.assert_called_once_with(verbose=True, log_fn=print, separator=" -> ") +def test_print_plan(application: Application, package_ahriman: Package, mocker: MockerFixture) -> None: + """A plan preview prints a dependency step, version, and selection reason.""" + plan = BuildPlan() + plan.add(package_ahriman, "AUR update") + mocker.patch("ahriman.core.repository.repository.Repository.packages", return_value=[package_ahriman]) + mocker.patch("ahriman.core.tree.Tree.resolve", return_value=[[package_ahriman]]) + output: list[str] = [] + + application.print_plan(plan, log_fn=output.append) + + assert output == [ + "Step 1:", package_ahriman.base, f"\t{package_ahriman.version} -> {package_ahriman.version}", + "\tReasons: AUR update", + ] + + +def test_print_empty_plan(application: Application) -> None: + """An empty preview explains why it has no package lines.""" + output: list[str] = [] + + application.print_plan(BuildPlan(), log_fn=output.append) + + assert output == ["No packages selected"] + + def test_with_dependencies(application: Application, package_ahriman: Package, package_python_schedule: Package, mocker: MockerFixture) -> None: """ diff --git a/ahriman-core/tests/ahriman/application/application/test_application_repository.py b/ahriman-core/tests/ahriman/application/application/test_application_repository.py index e15bc34b..55737aae 100644 --- a/ahriman-core/tests/ahriman/application/application/test_application_repository.py +++ b/ahriman-core/tests/ahriman/application/application/test_application_repository.py @@ -7,6 +7,7 @@ from unittest.mock import call as MockCall from ahriman.application.application.application_repository import ApplicationRepository from ahriman.core.exceptions import UnknownPackageError from ahriman.core.tree import Leaf, Tree +from ahriman.models.build_plan import BuildPlan from ahriman.models.changes import Changes from ahriman.models.package import Package from ahriman.models.packagers import Packagers @@ -270,6 +271,30 @@ def test_updates_all(application_repository: ApplicationRepository, package_ahri updates_deps_mock.assert_called_once_with([]) +def test_updates_returns_plan(application_repository: ApplicationRepository, package_ahriman: Package, + package_python_schedule: Package, + mocker: MockerFixture) -> None: + """Update discovery records reasons in its build plan.""" + mocker.patch("ahriman.core.repository.package_info.PackageInfo.packages_built", return_value=[Path("built")]) + mocker.patch( + "ahriman.core.repository.package_info.PackageInfo.load_archives", + return_value=[package_python_schedule]) + package_ahriman.packages[package_ahriman.base].make_depends = [package_python_schedule.base] + + def collect(_filter: list[str], *, vcs: bool, plan: BuildPlan) -> list[Package]: + assert vcs + plan.add(package_ahriman, "AUR update") + return [package_ahriman] + + mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.updates_aur", side_effect=collect) + + plan = application_repository.updates([], aur=True, local=False, manual=False, vcs=True, check_files=False) + + assert list(plan) == [package_ahriman, package_python_schedule] + assert plan.item(package_ahriman.base).reasons == ["AUR update"] + assert plan.item(package_python_schedule.base).reasons == ["already built", f"required by {package_ahriman.base}"] + + def test_updates_disabled(application_repository: ApplicationRepository, mocker: MockerFixture) -> None: """ must get updates without anything diff --git a/ahriman-core/tests/ahriman/application/handlers/test_handler_update.py b/ahriman-core/tests/ahriman/application/handlers/test_handler_update.py index f0290e8a..0dd31aef 100644 --- a/ahriman-core/tests/ahriman/application/handlers/test_handler_update.py +++ b/ahriman-core/tests/ahriman/application/handlers/test_handler_update.py @@ -9,6 +9,7 @@ from ahriman.application.handlers.update import Update from ahriman.core.configuration import Configuration from ahriman.core.repository import Repository from ahriman.models.package import Package +from ahriman.models.build_plan import BuildPlan from ahriman.models.packagers import Packagers from ahriman.models.result import Result @@ -91,7 +92,8 @@ def test_perform_action_empty_exception(args: argparse.Namespace, application: A args = _default_args(args) args.exit_code = True args.dry_run = True - mocker.patch("ahriman.application.application.Application.updates", return_value=[]) + mocker.patch("ahriman.application.application.Application.updates", return_value=BuildPlan()) + mocker.patch("ahriman.application.application.Application.print_plan") check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_status") Update.perform_action(application, args) @@ -106,7 +108,9 @@ def test_perform_action_update_empty_exception(args: argparse.Namespace, applica args = _default_args(args) args.exit_code = True mocker.patch("ahriman.application.application.Application.update", return_value=Result()) - mocker.patch("ahriman.application.application.Application.updates", return_value=[package_ahriman]) + plan = BuildPlan() + plan.add(package_ahriman) + mocker.patch("ahriman.application.application.Application.updates", return_value=plan) mocker.patch("ahriman.application.application.Application.with_dependencies", return_value=[package_ahriman]) mocker.patch("ahriman.application.application.Application.print_updates") mocker.patch("ahriman.application.application.Application.changes") @@ -125,14 +129,17 @@ def test_perform_action_dry_run(args: argparse.Namespace, application: Applicati args.dry_run = True 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]) + plan = BuildPlan() + plan.add(package_ahriman, "AUR update") + updates_mock = mocker.patch("ahriman.application.application.Application.updates", return_value=plan) changes_mock = mocker.patch("ahriman.application.application.Application.changes") + print_mock = mocker.patch("ahriman.application.application.Application.print_plan") 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) + updates_mock.assert_called_once() application_mock.assert_not_called() changes_mock.assert_called_once_with([package_ahriman]) + print_mock.assert_called_once_with(plan, log_fn=print, bump_pkgrel=args.increment) check_mock.assert_called_once_with(False, [package_ahriman]) @@ -145,7 +152,8 @@ def test_perform_action_no_changes(args: argparse.Namespace, application: Applic args.changes = False 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.updates", return_value=BuildPlan()) + mocker.patch("ahriman.application.application.Application.print_plan") changes_mock = mocker.patch("ahriman.application.application.Application.changes") Update.perform_action(application, args) diff --git a/ahriman-core/tests/ahriman/core/formatters/test_build_plan_printer.py b/ahriman-core/tests/ahriman/core/formatters/test_build_plan_printer.py new file mode 100644 index 00000000..6dc6d388 --- /dev/null +++ b/ahriman-core/tests/ahriman/core/formatters/test_build_plan_printer.py @@ -0,0 +1,38 @@ +from ahriman.core.formatters import BuildPlanPrinter +from ahriman.models.build_plan import BuildPlan +from ahriman.models.package import Package + + +def test_print_plan(package_ahriman: Package) -> None: + """The plan printer owns step, version, and reason output.""" + plan = BuildPlan() + plan.add(package_ahriman, "AUR update") + output: list[str] = [] + + BuildPlanPrinter(plan, {package_ahriman.base: package_ahriman.version})( + verbose=True, log_fn=output.append, separator=" -> ") + + assert output == [ + "Step 1:", package_ahriman.base, + f"\t{package_ahriman.version} -> {package_ahriman.version}", "\tReasons: AUR update", + ] + + +def test_print_empty_plan() -> None: + """The plan printer explains an empty preview.""" + output: list[str] = [] + + BuildPlanPrinter(BuildPlan(), {})(verbose=True, log_fn=output.append) + + assert output == ["No packages selected"] + + +def test_print_plan_without_reasons(package_ahriman: Package) -> None: + """The concise printer mode omits reasons while retaining package versions.""" + plan = BuildPlan() + plan.add(package_ahriman) + output: list[str] = [] + + BuildPlanPrinter(plan, {})(verbose=False, log_fn=output.append, separator=" -> ") + + assert output == ["Step 1:", package_ahriman.base, f"\tN/A -> {package_ahriman.version}"] diff --git a/ahriman-core/tests/ahriman/core/formatters/test_update_printer.py b/ahriman-core/tests/ahriman/core/formatters/test_update_printer.py index 37dd0e9a..173b1920 100644 --- a/ahriman-core/tests/ahriman/core/formatters/test_update_printer.py +++ b/ahriman-core/tests/ahriman/core/formatters/test_update_printer.py @@ -34,6 +34,15 @@ def test_properties_keep_pkgrel(update_printer: UpdatePrinter) -> None: ] +def test_properties_no_increment(update_printer: UpdatePrinter) -> None: + """The preview honors a disabled release increment.""" + update_printer.local_version = update_printer.package.version + update_printer.bump_pkgrel = False + assert update_printer.properties() == [ + Property(update_printer.local_version, update_printer.package.version, is_required=True), + ] + + def test_title(update_printer: UpdatePrinter) -> None: """ must return non-empty title diff --git a/ahriman-core/tests/ahriman/core/repository/test_update_handler.py b/ahriman-core/tests/ahriman/core/repository/test_update_handler.py index 27e2b15c..f1db6ad4 100644 --- a/ahriman-core/tests/ahriman/core/repository/test_update_handler.py +++ b/ahriman-core/tests/ahriman/core/repository/test_update_handler.py @@ -6,6 +6,7 @@ from typing import Any from ahriman.core.exceptions import UnknownPackageError from ahriman.core.repository.update_handler import UpdateHandler +from ahriman.models.build_plan import BuildPlan from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.dependencies import Dependencies from ahriman.models.event import EventType @@ -117,8 +118,10 @@ def test_updates_aur_filter(update_handler: UpdateHandler, package_ahriman: Pack return_value=[package_ahriman]) mocker.patch("ahriman.core.build_tools.package_version.PackageVersion.is_outdated", return_value=True) package_load_mock = mocker.patch("ahriman.models.package.Package.from_aur", return_value=package_ahriman) + plan = BuildPlan() - assert update_handler.updates_aur([package_ahriman.base], vcs=True) == [package_ahriman] + assert update_handler.updates_aur([package_ahriman.base], vcs=True, plan=plan) == [package_ahriman] + assert plan.item(package_ahriman.base).reasons == ["AUR update"] packages_mock.assert_called_once_with([package_ahriman.base]) package_load_mock.assert_called_once_with(package_ahriman.base, None) @@ -192,6 +195,7 @@ def test_updates_dependencies(update_handler: UpdateHandler, package_ahriman: Pa return_value=[package_ahriman, package_python_schedule]) status_client_mock = mocker.patch("ahriman.core.status.Client.set_pending") event_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.event") + plan = BuildPlan() dependencies = { package_ahriman.base: Dependencies({"usr/lib/python3.11/site-packages": ["python"]}), package_python_schedule.base: Dependencies({"usr/lib/python3.12/site-packages": ["python"]}), @@ -201,7 +205,8 @@ def test_updates_dependencies(update_handler: UpdateHandler, package_ahriman: Pa mocker.patch("ahriman.core.alpm.pacman.Pacman.files", return_value={"python": {"usr/lib/python3.12/site-packages"}}) - assert update_handler.updates_dependencies(["filter"]) == [package_ahriman] + assert update_handler.updates_dependencies(["filter"], plan=plan) == [package_ahriman] + assert plan.item(package_ahriman.base).reasons == ["broken implicit dependency"] packages_mock.assert_called_once_with(["filter"]) status_client_mock.assert_called_once_with(package_ahriman.base) event_mock.assert_called_once_with(package_ahriman.base, EventType.PackageOutdated, @@ -263,8 +268,10 @@ def test_updates_local(update_handler: UpdateHandler, package_ahriman: Package, event_mock = mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.event") package_is_outdated_mock = mocker.patch("ahriman.core.build_tools.package_version.PackageVersion.is_outdated", return_value=True) + plan = BuildPlan() - assert update_handler.updates_local(vcs=True) == [package_ahriman] + assert update_handler.updates_local(vcs=True, plan=plan) == [package_ahriman] + assert plan.item(package_ahriman.base).reasons == ["local source update"] fetch_mock.assert_called_once_with(Path(package_ahriman.base), pytest.helpers.anyvar(int)) package_load_mock.assert_called_once_with(Path(package_ahriman.base), "x86_64", None) status_client_mock.assert_called_once_with(package_ahriman.base) @@ -368,8 +375,10 @@ def test_updates_manual_preserves_queue(update_handler: UpdateHandler, package_a """ update_handler.database.build_queue_insert(package_ahriman) mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.packages", return_value=[package_ahriman]) + plan = BuildPlan() - assert update_handler.updates_manual() == [package_ahriman] + assert update_handler.updates_manual(plan=plan) == [package_ahriman] + assert plan.item(package_ahriman.base).reasons == ["queued request"] assert update_handler.database.build_queue_get() == [package_ahriman] diff --git a/ahriman-core/tests/ahriman/models/test_build_plan.py b/ahriman-core/tests/ahriman/models/test_build_plan.py new file mode 100644 index 00000000..cd54fbe2 --- /dev/null +++ b/ahriman-core/tests/ahriman/models/test_build_plan.py @@ -0,0 +1,34 @@ +from ahriman.models.build_plan import BuildPlan +from ahriman.models.package import Package + + +def test_add_and_merge_plan_items(package_ahriman: Package, package_python_schedule: Package) -> None: + """A plan keeps one current package per base and accumulates distinct reasons.""" + plan = BuildPlan() + + item = plan.add(package_ahriman, "AUR update") + plan.add(package_ahriman, "AUR update") + plan.add(package_ahriman, "queued request") + plan.add(package_python_schedule) + + assert item.reasons == ["AUR update", "queued request"] + assert plan.packages == sorted([package_ahriman, package_python_schedule], key=lambda package: package.base) + assert plan.item(package_ahriman.base) is item + + +def test_add_dependency(package_python_schedule: Package) -> None: + """A plan records which selected package requires a dependency.""" + plan = BuildPlan() + + plan.add_dependency(package_python_schedule, ["ahriman", "python-ahriman"]) + + assert plan.item(package_python_schedule.base).reasons == ["required by ahriman", "required by python-ahriman"] + + +def test_add_dependency_without_requesters(package_python_schedule: Package) -> None: + """A dependency without a known direct requester still receives a reason.""" + plan = BuildPlan() + + plan.add_dependency(package_python_schedule, []) + + assert plan.item(package_python_schedule.base).reasons == ["required dependency"] diff --git a/docs/ahriman.core.formatters.rst b/docs/ahriman.core.formatters.rst index 36f23094..c5355614 100644 --- a/docs/ahriman.core.formatters.rst +++ b/docs/ahriman.core.formatters.rst @@ -4,6 +4,14 @@ ahriman.core.formatters package Submodules ---------- +ahriman.core.formatters.build\_plan\_printer module +---------------------------------------------------- + +.. automodule:: ahriman.core.formatters.build_plan_printer + :members: + :no-undoc-members: + :show-inheritance: + ahriman.core.formatters.aur\_printer module ------------------------------------------- diff --git a/docs/ahriman.models.rst b/docs/ahriman.models.rst index 99f34a95..65f985df 100644 --- a/docs/ahriman.models.rst +++ b/docs/ahriman.models.rst @@ -28,6 +28,14 @@ ahriman.models.auth\_settings module :no-undoc-members: :show-inheritance: +ahriman.models.build\_plan module +--------------------------------- + +.. automodule:: ahriman.models.build_plan + :members: + :no-undoc-members: + :show-inheritance: + ahriman.models.build\_status module ----------------------------------- diff --git a/docs/faq/general.rst b/docs/faq/general.rst index c087855e..f97440b1 100644 --- a/docs/faq/general.rst +++ b/docs/faq/general.rst @@ -274,6 +274,8 @@ In this scenario, the update process must be separated into several stages. Firs sudo -u ahriman ahriman repo-check +The command prints selected packages in dependency steps, with the old and proposed versions and the reason each package was selected. ``repo-update --dry-run`` uses the same preview. Proposed ``pkgrel`` values follow the ``--increment`` option. The preview describes current metadata; it does not freeze sources or guarantee later build inputs. + During the check process, the service will generate changes from the last known commit and will send it to remote service. In order to verify source files changes, the web interface or special subcommand can be used: .. code-block:: shell