Add AUR plugin

This commit is contained in:
Kivi Kaitaniemi
2025-12-14 22:28:08 +02:00
parent 5861ca219b
commit 6fe1f4cb6f
16 changed files with 1203 additions and 291 deletions
+302
View File
@@ -0,0 +1,302 @@
from typing import Any
import pytest
from decman.plugins import aur as aur_plugin
class FakeStore(dict):
def ensure(self, key: str, default: Any) -> None:
if key not in self:
self[key] = default
class FakeModule:
def __init__(self, name: str, aur_pkgs: set[str], custom_pkgs: set[Any]) -> None:
self.name = name
self._changed = False
self._aur_pkgs = aur_pkgs
self._custom_pkgs = custom_pkgs
class FakeCustomPackage:
def __init__(self, pkgname: str) -> None:
self.pkgname = pkgname
def __hash__(self) -> int: # needed because instances go into sets
return hash(self.pkgname)
def __eq__(self, other: object) -> bool:
return isinstance(other, FakeCustomPackage) and self.pkgname == other.pkgname
def parse(self, commands: Any) -> str:
# Whatever ForeignPackageManager expects; we just need something to feed into add_custom_pkg
return f"parsed-{self.pkgname}"
def test_process_modules_collects_aur_and_custom_packages_and_marks_changed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
aur = aur_plugin.AUR()
store = FakeStore()
cp1 = FakeCustomPackage("custom1")
cp2 = FakeCustomPackage("custom2")
mod1 = FakeModule("mod1", {"aur1", "aur2"}, {cp1})
mod2 = FakeModule("mod2", {"aur3"}, {cp2})
def fake_run_method_with_attribute(mod: FakeModule, attr: str):
if attr == "__aur__packages__":
return mod._aur_pkgs
if attr == "__custom__packages__":
return mod._custom_pkgs
return None
monkeypatch.setattr(
aur_plugin.plugins, "run_method_with_attribute", fake_run_method_with_attribute
)
aur.process_modules(store, {mod1, mod2})
# union of all aur/custom packages collected
assert aur.packages == {"aur1", "aur2", "aur3"}
assert aur.custom_packages == {cp1, cp2}
# stored per-module
assert store["aur_packages_for_module"]["mod1"] == {"aur1", "aur2"}
assert store["aur_packages_for_module"]["mod2"] == {"aur3"}
assert store["custom_packages_for_module"]["mod1"] == {cp1}
assert store["custom_packages_for_module"]["mod2"] == {cp2}
# first run: modules marked changed
assert mod1._changed is True
assert mod2._changed is True
def test_apply_respects_ignored_packages_and_protects_their_dependencies(
monkeypatch: pytest.MonkeyPatch,
) -> None:
aur = aur_plugin.AUR()
store = FakeStore()
# Desired AUR/custom state
aur.packages = {"desired-aur"}
cp = FakeCustomPackage("custom-aur")
aur.custom_packages = {cp}
# Ignored foreign package (installed) and an ignored but *uninstalled* package
aur.ignored_packages = {"ignored-aur", "ignored-not-installed"}
# Fake PackageSearch
class FakePackageSearch:
def __init__(self, timeout: int) -> None:
self.timeout = timeout
self.added: list[Any] = []
def add_custom_pkg(self, parsed: Any) -> None:
self.added.append(parsed)
monkeypatch.setattr(aur_plugin, "PackageSearch", FakePackageSearch)
monkeypatch.setattr(aur_plugin.os, "makedirs", lambda *x, **kw: None)
# Fake pacman interface for foreign/native info
class FakePM:
def __init__(self, commands, print_highlights, keywords) -> None:
self.commands = commands
self.print_highlights = print_highlights
self.keywords = keywords
self.remove_called_with: set[str] | None = None
self.set_as_deps_called_with: set[str] | None = None
def get_native_explicit(self) -> set[str]:
# no natives needed for this scenario
return set()
def get_foreign_explicit(self) -> set[str]:
# All explicitly installed foreign packages:
# - ignored-aur (ignored, must stay and protect deps)
# - dep-of-ignored (candidate; has ignored dependant)
# - orphan-foreign (candidate; no dependants)
return {"ignored-aur", "dep-of-ignored", "orphan-foreign"}
def get_foreign_orphans(self) -> set[str]:
# orphan-foreign also considered orphan
return {"orphan-foreign"}
def get_dependants(self, pkg: str) -> set[str]:
if pkg == "dep-of-ignored":
# ignored-aur depends on dep-of-ignored -> must demote, not remove
return {"ignored-aur"}
if pkg == "orphan-foreign":
return set()
return set()
def remove(self, pkgs: set[str]) -> None:
self.remove_called_with = pkgs
def set_as_dependencies(self, pkgs: set[str]) -> None:
self.set_as_deps_called_with = pkgs
fake_pm = FakePM(None, None, None)
def fake_pm_ctor(commands, print_highlights, keywords) -> FakePM:
fake_pm.commands = commands
fake_pm.print_highlights = print_highlights
fake_pm.keywords = keywords
return fake_pm
monkeypatch.setattr(aur_plugin, "AurPacmanInterface", fake_pm_ctor)
# Fake ForeignPackageManager
class FakeFPM:
def __init__(
self,
store_arg,
pm_arg,
package_search_arg,
commands_arg,
cache_dir,
build_dir,
makepkg_user,
) -> None:
self.store = store_arg
self.pm = pm_arg
self.package_search = package_search_arg
self.commands = commands_arg
self.cache_dir = cache_dir
self.build_dir = build_dir
self.makepkg_user = makepkg_user
self.upgrade_args: tuple[bool, bool, set[str]] | None = None
self.install_called_with: list[str] | None = None
def upgrade(self, upgrade_devel: bool, force: bool, ignored: set[str]) -> None:
self.upgrade_args = (upgrade_devel, force, ignored)
def install(self, pkgs: list[str], force: bool = False) -> None:
# store as set to ignore ordering
self.install_called_with = pkgs
fake_fpm = FakeFPM(None, None, None, None, None, None, None)
def fake_fpm_ctor(
store_arg, pm_arg, package_search_arg, commands_arg, cache_dir, build_dir, makepkg_user
):
fake_fpm.store = store_arg
fake_fpm.pm = pm_arg
fake_fpm.package_search = package_search_arg
fake_fpm.commands = commands_arg
fake_fpm.cache_dir = cache_dir
fake_fpm.build_dir = build_dir
fake_fpm.makepkg_user = makepkg_user
return fake_fpm
monkeypatch.setattr(aur_plugin, "ForeignPackageManager", fake_fpm_ctor)
printed_lists: list[tuple[str, list[str]]] = []
printed_summaries: list[str] = []
def fake_print_list(title: str, items: list[str]) -> None:
printed_lists.append((title, items))
def fake_print_summary(msg: str) -> None:
printed_summaries.append(msg)
monkeypatch.setattr(aur_plugin.output, "print_list", fake_print_list)
monkeypatch.setattr(aur_plugin.output, "print_summary", fake_print_summary)
# Use params to test flag propagation into upgrade/install
ok = aur.apply(store, dry_run=False, params=["aur-upgrade-devel", "aur-force"])
assert ok is True
# Removal / demotion logic:
#
# custom_package_names = {"custom-aur"}
# currently_installed_foreign = {"ignored-aur", "dep-of-ignored", "orphan-foreign"}
# orphans = {"orphan-foreign"}
#
# to_remove candidates:
# (foreign | orphans) - desired - custom - ignored
# = {"ignored-aur", "dep-of-ignored", "orphan-foreign"} {"orphan-foreign"}
# - {"desired-aur"} - {"custom-aur"} - {"ignored-aur"}
# = {"dep-of-ignored", "orphan-foreign"}
#
# dependants_to_keep includes ignored installed foreign -> dep-of-ignored is demoted, orphan-foreign removed.
assert fake_pm.remove_called_with == {"orphan-foreign"}
assert fake_pm.set_as_deps_called_with == {"dep-of-ignored"}
# Ensure ignored packages were not removed
assert "ignored-aur" not in (fake_pm.remove_called_with or set())
# Upgrade called with flags and ignored set
assert fake_fpm.upgrade_args == (True, True, aur.ignored_packages)
# to_install = (packages | custom_names) - installed_foreign - ignored
# = {"desired-aur", "custom-aur"} - {"ignored-aur", "dep-of-ignored", "orphan-foreign"}
# - {"ignored-aur", "ignored-not-installed"}
# = {"desired-aur", "custom-aur"}
assert set(fake_fpm.install_called_with or []) == {"desired-aur", "custom-aur"}
# ignored packages must not be installed
assert "ignored-aur" not in (fake_fpm.install_called_with or [])
assert "ignored-not-installed" not in (fake_fpm.install_called_with or [])
# Also check the printed lists mirror this
titles = [t for t, _ in printed_lists]
assert "Removing foreign packages:" in titles
assert "Setting previously explicitly installed foreign packages as dependencies:" in titles
assert "Installing foreign packages:" in titles
remove_list = next(items for t, items in printed_lists if "Removing foreign packages:" in t)
demote_list = next(
items
for t, items in printed_lists
if "Setting previously explicitly installed foreign packages as dependencies:" in t
)
install_list = next(items for t, items in printed_lists if "Installing foreign packages:" in t)
assert remove_list == ["orphan-foreign"]
assert demote_list == ["dep-of-ignored"]
# Order of install_list is deterministic because sorted() is used
assert install_list == ["custom-aur", "desired-aur"]
assert any("Upgrading foreign packages." in s for s in printed_summaries)
def test_apply_returns_false_on_aur_rpc_error(monkeypatch: pytest.MonkeyPatch) -> None:
aur = aur_plugin.AUR()
store = FakeStore()
# Force PackageSearch to fail immediately
class FailingPackageSearch:
def __init__(self, timeout: int) -> None:
raise aur_plugin.AurRPCError("RPC down", "url")
monkeypatch.setattr(aur_plugin, "PackageSearch", FailingPackageSearch)
monkeypatch.setattr(aur_plugin.os, "makedirs", lambda *x, **kw: None)
errors_logged: list[str] = []
continuations: list[str] = []
traceback_called: list[bool] = []
def fake_print_error(msg: str) -> None:
errors_logged.append(msg)
def fake_print_continuation(msg: str) -> None:
continuations.append(msg)
def fake_print_traceback() -> None:
traceback_called.append(True)
monkeypatch.setattr(aur_plugin.output, "print_error", fake_print_error)
monkeypatch.setattr(aur_plugin.output, "print_continuation", fake_print_continuation)
monkeypatch.setattr(aur_plugin.output, "print_traceback", fake_print_traceback)
ok = aur.apply(store, dry_run=False)
assert ok is False
assert any("AUR RPC" in msg or "fetch data from AUR RPC" in msg for msg in errors_logged)
assert any("RPC down" in msg for msg in continuations)
assert traceback_called
@@ -2,9 +2,9 @@ import pathlib
import pytest
from decman.plugins.pacman import package as pkg_mod
from decman.plugins.pacman.error import AurRPCError, PKGBUILDParseError
from decman.plugins.pacman.package import (
from decman.plugins.aur import package as pkg_mod
from decman.plugins.aur.error import AurRPCError, PKGBUILDParseError
from decman.plugins.aur.package import (
CustomPackage,
PackageInfo,
PackageSearch,
@@ -1,7 +1,7 @@
import pytest
from decman.plugins.pacman.error import DependencyCycleError
from decman.plugins.pacman.resolver import DepGraph, ForeignPackage
from decman.plugins.aur.error import DependencyCycleError
from decman.plugins.aur.resolver import DepGraph, ForeignPackage
def test_add_dependency():
+291
View File
@@ -0,0 +1,291 @@
from typing import Any
import pytest
from decman.plugins import pacman as pacman_plugin
class FakeStore(dict):
def ensure(self, key: str, default: Any) -> None:
if key not in self:
self[key] = default
class FakeModule:
def __init__(self, name: str, packages: set[str]) -> None:
self.name = name
self._changed = False
self._packages = packages
def test_process_modules_collects_packages_and_marks_changed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
pacman = pacman_plugin.Pacman()
store = FakeStore()
mod1 = FakeModule("mod1", {"pkg1", "pkg2"})
mod2 = FakeModule("mod2", {"pkg3"})
def fake_run_method_with_attribute(mod: FakeModule, attr: str) -> set[str]:
assert attr == "__pacman__packages__"
return mod._packages
monkeypatch.setattr(
pacman_plugin.plugins,
"run_method_with_attribute",
fake_run_method_with_attribute,
)
pacman.process_modules(store, {mod1, mod2})
# packages collected
assert pacman.packages == {"pkg1", "pkg2", "pkg3"}
# stored mapping per module
assert store["packages_for_module"]["mod1"] == {"pkg1", "pkg2"}
assert store["packages_for_module"]["mod2"] == {"pkg3"}
# modules marked changed (first run)
assert mod1._changed is True
assert mod2._changed is True
def test_apply_dry_run_computes_sets_and_does_not_call_pacman(
monkeypatch: pytest.MonkeyPatch,
) -> None:
pacman = pacman_plugin.Pacman()
store = FakeStore()
# Desired state
pacman.packages = {"keep-explicit", "new-pkg"}
# Fake PacmanInterface returned by plugin module
class FakePM:
def __init__(self, commands, print_highlights, keywords) -> None: # noqa: D401
self.commands = commands
self.print_highlights = print_highlights
self.keywords = keywords
self.remove_called_with: set[str] | None = None
self.set_as_deps_called_with: set[str] | None = None
self.upgrade_called = False
self.install_called_with: set[str] | None = None
def get_native_explicit(self) -> set[str]:
# keep-explicit (in desired), old-explicit (to demote/remove)
return {"keep-explicit", "old-explicit"}
def get_foreign_explicit(self) -> set[str]:
# foreign-package protects its deps
return {"foreign-pkg"}
def get_native_orphans(self) -> set[str]:
# orphan-explicit is also candidate
return {"orphan-explicit"}
def get_dependants(self, pkg: str) -> set[str]:
# old-explicit has a foreign dependant -> demote to dep
# orphan-explicit has no dependants -> remove
if pkg == "old-explicit":
return {"foreign-pkg"}
if pkg == "orphan-explicit":
return set()
return set()
def remove(self, pkgs: set[str]) -> None:
self.remove_called_with = pkgs
def set_as_dependencies(self, pkgs: set[str]) -> None:
self.set_as_deps_called_with = pkgs
def upgrade(self) -> None:
self.upgrade_called = True
def install(self, pkgs: set[str]) -> None:
self.install_called_with = pkgs
fake_pm = FakePM(None, None, None)
def fake_pm_ctor(commands, print_highlights, keywords) -> FakePM:
# constructor used in Pacman.apply
fake_pm.commands = commands
fake_pm.print_highlights = print_highlights
fake_pm.keywords = keywords
return fake_pm
monkeypatch.setattr(pacman_plugin, "PacmanInterface", fake_pm_ctor)
printed_lists: list[tuple[str, list[str]]] = []
printed_summaries: list[str] = []
def fake_print_list(title: str, items: list[str]) -> None:
printed_lists.append((title, items))
def fake_print_summary(msg: str) -> None:
printed_summaries.append(msg)
monkeypatch.setattr(pacman_plugin.output, "print_list", fake_print_list)
monkeypatch.setattr(pacman_plugin.output, "print_summary", fake_print_summary)
ok = pacman.apply(store, dry_run=True)
assert ok is True
# to_remove = (native | orphans) - desired
# = {keep-explicit, old-explicit} {orphan-explicit} - {keep-explicit, new-pkg}
# = {old-explicit, orphan-explicit}
#
# old-explicit has foreign dependant -> demoted to dep
# orphan-explicit has no dependants -> removed
# printed lists (titles and contents)
titles = [t for t, _ in printed_lists]
assert "Removing pacman packages:" in titles
assert "Setting previously explicitly installed packages as dependencies:" in titles
assert "Installing pacman packages:" in titles
# find lists by title
remove_list = next(items for t, items in printed_lists if "Removing pacman packages:" in t)
demote_list = next(
items
for t, items in printed_lists
if "Setting previously explicitly installed packages as dependencies:" in t
)
install_list = next(items for t, items in printed_lists if "Installing pacman packages:" in t)
assert remove_list == ["orphan-explicit"]
assert demote_list == ["old-explicit"]
# to_install = desired - currently_installed_native
# = {keep-explicit, new-pkg} - {keep-explicit, old-explicit}
# = {new-pkg}
assert install_list == ["new-pkg"]
# Upgrade summary printed even in dry-run
assert any("Upgrading packages." in s for s in printed_summaries)
# No mutating calls in dry-run
assert fake_pm.remove_called_with is None
assert fake_pm.set_as_deps_called_with is None
assert fake_pm.upgrade_called is False
assert fake_pm.install_called_with is None
def test_apply_returns_false_on_command_failure(monkeypatch: pytest.MonkeyPatch) -> None:
pacman = pacman_plugin.Pacman()
store = FakeStore()
pacman.packages = set()
class FailingPM:
def __init__(self, *args, **kwargs) -> None: # noqa: D401
pass
def get_native_explicit(self) -> set[str]:
raise pacman_plugin.errors.CommandFailedError(["get_native_explicit"], "boom")
monkeypatch.setattr(pacman_plugin, "PacmanInterface", FailingPM)
errors_logged: list[str] = []
continuations: list[str] = []
traceback_called = []
def fake_print_error(msg: str) -> None:
errors_logged.append(msg)
def fake_print_continuation(msg: str) -> None:
continuations.append(msg)
def fake_print_traceback() -> None:
traceback_called.append(True)
monkeypatch.setattr(pacman_plugin.output, "print_error", fake_print_error)
monkeypatch.setattr(pacman_plugin.output, "print_continuation", fake_print_continuation)
monkeypatch.setattr(pacman_plugin.output, "print_traceback", fake_print_traceback)
ok = pacman.apply(store, dry_run=False)
assert ok is False
assert any("pacman command failed" in msg for msg in errors_logged)
assert any("boom" in msg for msg in continuations)
assert traceback_called # at least once
def test_ignored_packages_are_not_removed_or_installed(monkeypatch: pytest.MonkeyPatch) -> None:
pacman = pacman_plugin.Pacman()
store = FakeStore()
# Desired state: "already" and "new" should be managed normally.
# "ignored-installed" is currently installed but not desired -> would normally be removed.
# "ignored-uninstalled" is desired but not installed -> would normally be installed.
pacman.packages = {"already", "new", "ignored-uninstalled"}
pacman.ignored_packages = {"ignored-installed", "ignored-uninstalled"}
class FakePM:
def __init__(self, commands, print_highlights, keywords) -> None:
self.commands = commands
self.print_highlights = print_highlights
self.keywords = keywords
self.remove_called_with: set[str] | None = None
self.install_called_with: set[str] | None = None
self.set_as_deps_called_with: set[str] | None = None
self.upgrade_called = False
def get_native_explicit(self) -> set[str]:
# currently installed explicit packages
return {"ignored-installed", "already"}
def get_foreign_explicit(self) -> set[str]:
return set()
def get_native_orphans(self) -> set[str]:
return set()
def get_dependants(self, pkg: str) -> set[str]:
return set()
def remove(self, pkgs: set[str]) -> None:
self.remove_called_with = pkgs
def set_as_dependencies(self, pkgs: set[str]) -> None:
self.set_as_deps_called_with = pkgs
def upgrade(self) -> None:
self.upgrade_called = True
def install(self, pkgs: set[str]) -> None:
self.install_called_with = pkgs
fake_pm = FakePM(None, None, None)
def fake_pm_ctor(commands, print_highlights, keywords) -> FakePM:
fake_pm.commands = commands
fake_pm.print_highlights = print_highlights
fake_pm.keywords = keywords
return fake_pm
monkeypatch.setattr(pacman_plugin, "PacmanInterface", fake_pm_ctor)
printed_lists: list[tuple[str, list[str]]] = []
def fake_print_list(title: str, items: list[str]) -> None:
printed_lists.append((title, items))
# don't care about summaries here
monkeypatch.setattr(pacman_plugin.output, "print_list", fake_print_list)
monkeypatch.setattr(pacman_plugin.output, "print_summary", lambda *_args, **_kw: None)
ok = pacman.apply(store, dry_run=False)
assert ok is True
# Ignored packages must never be passed to remove() or install()
assert (
fake_pm.remove_called_with is None or "ignored-installed" not in fake_pm.remove_called_with
)
assert fake_pm.install_called_with is not None
assert "ignored-uninstalled" not in fake_pm.install_called_with
# Also ensure the printed install list doesn't contain ignored packages
install_items = next(
items for title, items in printed_lists if "Installing pacman packages:" in title
)
assert "ignored-uninstalled" not in install_items
# "new" is the only package that should be installed in this scenario
assert install_items == ["new"]