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