From c5898d82a35e8d08a2c0109f7b7b639b578f74f7 Mon Sep 17 00:00:00 2001 From: Kivi Kaitaniemi Date: Sat, 7 Feb 2026 20:24:29 +0200 Subject: [PATCH] Consider foreign provided packages when installing foreign packages #46 --- plugins/decman-pacman/pyproject.toml | 6 + .../src/decman/plugins/aur/__init__.py | 3 +- .../src/decman/plugins/aur/commands.py | 10 + .../src/decman/plugins/aur/fpm.py | 15 +- .../src/decman/plugins/pacman.py | 15 + plugins/decman-pacman/tests/test_fpm.py | 383 ++++++++++++++++++ uv.lock | 24 ++ 7 files changed, 453 insertions(+), 3 deletions(-) create mode 100644 plugins/decman-pacman/tests/test_fpm.py diff --git a/plugins/decman-pacman/pyproject.toml b/plugins/decman-pacman/pyproject.toml index 19dd272..ae5b6d4 100644 --- a/plugins/decman-pacman/pyproject.toml +++ b/plugins/decman-pacman/pyproject.toml @@ -8,6 +8,12 @@ dependencies = [ "requests", ] +[dependency-groups] +dev = [ + "pytest>=8.4.2", + "pytest-mock>=3.15.1", +] + [project.entry-points."decman.plugins"] pacman = "decman.plugins.pacman:Pacman" aur = "decman.plugins.aur:AUR" diff --git a/plugins/decman-pacman/src/decman/plugins/aur/__init__.py b/plugins/decman-pacman/src/decman/plugins/aur/__init__.py index 1f9d3bc..27d606e 100644 --- a/plugins/decman-pacman/src/decman/plugins/aur/__init__.py +++ b/plugins/decman-pacman/src/decman/plugins/aur/__init__.py @@ -208,7 +208,8 @@ class AUR(plugins.Plugin): output.print_summary("Upgrading foreign packages.") if not dry_run: - fpm.upgrade(upgrade_devel, force, self.ignored_packages) + # don't try to upgrade removed packages + fpm.upgrade(upgrade_devel, force, self.ignored_packages | actually_to_remove) to_install = ( (self.packages | custom_package_names) diff --git a/plugins/decman-pacman/src/decman/plugins/aur/commands.py b/plugins/decman-pacman/src/decman/plugins/aur/commands.py index 1878f8c..8c0d0d7 100644 --- a/plugins/decman-pacman/src/decman/plugins/aur/commands.py +++ b/plugins/decman-pacman/src/decman/plugins/aur/commands.py @@ -143,6 +143,16 @@ class AurPacmanInterface(pacman.PacmanInterface): """ return self._get_orphans(pacman.PacmanInterface._is_foreign) + def is_provided_by_installed(self, dependency: str) -> bool: + return pacman.strip_dependency(dependency) in self._local_provides_index + + def filter_installed_packages(self, deps: set[str]) -> set[str]: + out = set() + for d in deps: + if not self.is_provided_by_installed(d) and d not in self.get_all_packages(): + out.add(d) + return out + def is_installable(self, pkg: str) -> bool: """ Returns True if a package can be installed using pacman. diff --git a/plugins/decman-pacman/src/decman/plugins/aur/fpm.py b/plugins/decman-pacman/src/decman/plugins/aur/fpm.py index 2614967..723f3c3 100644 --- a/plugins/decman-pacman/src/decman/plugins/aur/fpm.py +++ b/plugins/decman-pacman/src/decman/plugins/aur/fpm.py @@ -135,6 +135,9 @@ class ResolvedDependencies: self.foreign_build_dep_pkgs: set[str] = set() self.build_order: list[str] = [] self.packages: dict[str, ForeignPackage] = {} + # maps dependency names to package names + self.providers: dict[str, list[str]] = {} + self.all_provided: set[str] = set() self._pkgbases_to_pkgs: dict[str, set[str]] = {} self._pkgs_to_pkgbases: dict[str, str] = {} @@ -277,8 +280,11 @@ class ForeignPackageManager: if not output.prompt_confirm("Proceed?", default=True): raise ForeignPackageManagerError("Installing aborted by the user.") + needed_pacman_deps = self._pacman.filter_installed_packages( + resolved_dependencies.pacman_deps - resolved_dependencies.all_provided + ) output.print_summary("Installing foreign package dependencies from pacman.") - self._pacman.install_dependencies(resolved_dependencies.pacman_deps) + self._pacman.install_dependencies(needed_pacman_deps) try: with PackageBuilder( @@ -319,7 +325,8 @@ class ForeignPackageManager: output.print_summary("Installing foreign packages.") self._pacman.install_files( package_files_to_install, - as_explicit=resolved_dependencies.foreign_pkgs, + as_explicit=resolved_dependencies.foreign_pkgs + - resolved_dependencies.foreign_dep_pkgs, ) else: output.print_summary("No packages to install.") @@ -379,6 +386,10 @@ class ForeignPackageManager: f"Failed to find '{pkgname}' from AUR or user provided packages." ) + for provided in info.provides: + result.providers.setdefault(provided, []).append(pkgname) + result.all_provided.add(provided) + result.pacman_deps.update(info.native_dependencies(self._pacman)) result.add_pkgbase_info(pkgname, info.pkgbase) diff --git a/plugins/decman-pacman/src/decman/plugins/pacman.py b/plugins/decman-pacman/src/decman/plugins/pacman.py index ace02ee..7e67ba0 100644 --- a/plugins/decman-pacman/src/decman/plugins/pacman.py +++ b/plugins/decman-pacman/src/decman/plugins/pacman.py @@ -208,6 +208,7 @@ class PacmanInterface: self._dbpath = dbpath self._handle = self._create_pyalpm_handle() self._name_index = self._create_name_index() + self._local_provides_index = self._create_local_provides_index() self._provides_index = self._create_provides_index() self._requiredby_index = self._create_requiredby_index() @@ -231,6 +232,14 @@ class PacmanInterface: def _create_name_index(self) -> dict[str, pyalpm.Package]: return {pkg.name: pkg for db in self._handle.get_syncdbs() for pkg in db.pkgcache} + def _create_local_provides_index(self) -> dict[str, set[str]]: + out: dict[str, set[str]] = {} + for pkg in self._handle.get_localdb().pkgcache: + for p in pkg.provides: + out.setdefault(strip_dependency(p), set()).add(pkg.name) + out.setdefault(p, set()).add(pkg.name) + return out + def _create_provides_index(self) -> dict[str, set[str]]: out: dict[str, set[str]] = {} for db in self._handle.get_syncdbs(): @@ -249,6 +258,12 @@ class PacmanInterface: def _is_foreign(self, package: str) -> bool: return not self._is_native(package) + def get_all_packages(self) -> set[str]: + """ + Returns a set of all installed packages. + """ + return {pkg for pkg in self._handle.get_localdb().pkgcache} + def get_native_explicit(self) -> set[str]: """ Returns a set of explicitly installed native packages. diff --git a/plugins/decman-pacman/tests/test_fpm.py b/plugins/decman-pacman/tests/test_fpm.py new file mode 100644 index 0000000..bab5309 --- /dev/null +++ b/plugins/decman-pacman/tests/test_fpm.py @@ -0,0 +1,383 @@ +import typing +from unittest.mock import MagicMock +from urllib.parse import parse_qs, unquote, urlparse + +import pytest +from decman.plugins.aur.commands import AurCommands +from decman.plugins.aur.fpm import ForeignPackageManager +from decman.plugins.aur.package import PackageInfo, PackageSearch + + +class FakeAurPacmanInterface: + def __init__(self) -> None: + self.installed_native: set[str] = set() + self.installed_foreign: dict[str, str] = {} + self.explicitly_installed: set[str] = set() + self.not_installable: set[str] = set() + self.installed_files: list[str] = [] # To track what install_files() actually does + self.provided_pkgs: set[str] = set() + + def get_native_explicit(self) -> set[str]: + return self.installed_native.intersection(self.explicitly_installed) + + def get_native_orphans(self) -> set[str]: + return set() + + def get_foreign_explicit(self) -> set[str]: + return set(self.installed_foreign.keys()).intersection(self.explicitly_installed) + + def get_dependants(self, package: str) -> set[str]: + return set() + + def set_as_dependencies(self, packages: set[str]): + self.explicitly_installed.difference_update(packages) + + def install(self, packages: set[str]): + self.installed_native.update(packages) + self.explicitly_installed.update(packages) + + def upgrade(self): + pass + + def is_provided_by_installed(self, dependency: str) -> bool: + return dependency in self.provided_pkgs + + def get_all_packages(self) -> set[str]: + return self.installed_native | self.installed_foreign.keys() + + def filter_installed_packages(self, deps: set[str]) -> set[str]: + out = set() + for d in deps: + if not self.is_provided_by_installed(d) and d not in self.get_all_packages(): + out.add(d) + return out + + def remove(self, packages: set[str]): + self.installed_native.difference_update(packages) + for p in packages: + self.installed_foreign.pop(p, None) + self.explicitly_installed.difference_update(packages) + + def get_foreign_orphans(self) -> set[str]: + return set() + + def is_installable(self, pkg: str) -> bool: + return pkg not in self.not_installable + + def get_versioned_foreign_packages(self) -> list[tuple[str, str]]: + return list(self.installed_foreign.items()) + + def install_dependencies(self, deps: set[str]): + self.installed_native.update(deps) + + def install_files(self, files: list[str], as_explicit: set[str]): + self.installed_files.extend(files) + + for file in files: + self.installed_foreign[file] = "file" + + for pkg in as_explicit: + self.explicitly_installed.add(pkg) + + +class FakeStore: + def __init__(self) -> None: + self._store: dict[str, typing.Any] = {} + + def __getitem__(self, key: str) -> typing.Any: + return self._store[key] + + def __setitem__(self, key: str, value: typing.Any) -> None: + self._store[key] = value + + def get(self, key: str, default: typing.Any = None) -> typing.Any: + return self._store.get(key, default) + + def ensure(self, key: str, default: typing.Any = None): + if key not in self._store: + self._store[key] = default + + def __enter__(self) -> "FakeStore": + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def save(self) -> None: + pass + + def __repr__(self) -> str: + return repr(self._store) + + +class MockAurServer: + def __init__(self) -> None: + self.db: dict[str, dict] = {} # Maps pkgname -> raw JSON result dict + + def seed(self, packages: list[PackageInfo]): + for pkg in packages: + # Reconstruct the raw JSON structure expected by PackageSearch + entry = { + "Name": pkg.pkgname, + "PackageBase": pkg.pkgbase or pkg.pkgname, + "Version": pkg.version, + "Description": "Mock Description", + "URL": "https://example.com", + "Depends": pkg.dependencies, + "MakeDepends": pkg.make_dependencies, + "CheckDepends": pkg.check_dependencies, + "Provides": pkg.provides, + # Add other fields if your class relies on them + } + self.db[pkg.pkgname] = entry + + def handle_request(self, url, *args, **kwargs): + parsed = urlparse(url) + path = parsed.path + query = parse_qs(parsed.query) + + results = [] + + # --- Handle: Multi-info query (.../info?arg[]=pkg1&arg[]=pkg2) --- + if "/rpc/v5/info" in path and "arg[]" in query: + requested_names = query["arg[]"] + for name in requested_names: + if name in self.db: + results.append(self.db[name]) + + # --- Handle: Single info query (.../rpc/v5/info/pkgname) --- + elif "/rpc/v5/info/" in path: + # Extract package name from end of path + pkg_name = path.split("/")[-1] + if pkg_name in self.db: + results.append(self.db[pkg_name]) + + # --- Handle: Search providers (.../rpc/v5/search/dep?by=provides) --- + elif "/rpc/v5/search/" in path and query.get("by") == ["provides"]: + search_term = path.split("/")[-1] + search_term = unquote(search_term) + + # Linear search through DB for 'Provides' + for entry in self.db.values(): + if search_term in entry.get("Provides", []): + results.append(entry) + # Also match if the package name itself matches the provider request + elif entry["Name"] == search_term: + results.append(entry) + + # Construct the response object + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "version": 5, + "type": "multiinfo", + "resultcount": len(results), + "results": results, + } + + return mock_response + + +@pytest.fixture +def mock_aur(mocker): + server = MockAurServer() + mocker.patch("requests.get", side_effect=server.handle_request) + return server + + +@pytest.fixture +def mock_pacman(mocker): + pacman = FakeAurPacmanInterface() + return pacman + + +@pytest.fixture +def mock_fpm(mocker, mock_aur, mock_pacman): + mock_builder_cls = mocker.patch("decman.plugins.aur.fpm.PackageBuilder") + mock_builder_instance = mock_builder_cls.return_value + mock_builder_instance.__enter__.return_value = mock_builder_instance + mock_builder_instance.__exit__.return_value = None + + # NOTE: find_latest_cached_package must return a tuple, otherwise + # the 'assert built_pkg is not None' line in install() will fail. + def mock_find_cached(store, package): + # return just the package, so that mock pacman can get the package name from the 'file' name + return ("1.0.0", package) + + mocker.patch("decman.plugins.aur.fpm.find_latest_cached_package", side_effect=mock_find_cached) + mocker.patch("decman.plugins.aur.fpm.add_package_to_cache", return_value=None) + + # handle prompts automatically + mocker.patch("decman.core.output.prompt_confirm", return_value=True) + + store = FakeStore() + search = PackageSearch() + commands = AurCommands() + mgr = ForeignPackageManager( + store=store, # type: ignore + pacman=mock_pacman, + search=search, + commands=commands, + pkg_cache_dir="/tmp/cache", + build_dir="/tmp/build", + makepkg_user="nobody", + ) + + return mgr + + +def test_remove_pacman_deps_provided_by_foreign_packages( + mock_fpm, mock_aur, mock_pacman: FakeAurPacmanInterface +): + mock_pacman.not_installable |= {"kwin-hifps", "qt6-base-hifps", "syncthingtray-qt6"} + pkgs = [ + PackageInfo( + pkgbase="kwin-hifps", + pkgname="kwin-hifps", + version="1", + git_url="...", + dependencies=("qt6-base-hifps",), + ), + PackageInfo( + pkgbase="qt6-base-hifps", + pkgname="qt6-base-hifps", + version="1", + git_url="...", + provides=("qt6-base",), + ), + PackageInfo( + pkgbase="syncthingtray-qt6", + pkgname="syncthingtray-qt6", + version="1", + git_url="...", + dependencies=("qt6-base",), + ), + ] + mock_aur.seed(pkgs) + + mock_fpm.install(["kwin-hifps", "syncthingtray-qt6"]) + + assert len(mock_pacman.installed_files) == 3 + assert mock_pacman.explicitly_installed == {"kwin-hifps", "syncthingtray-qt6"} + assert "qt6-base" not in mock_pacman.installed_native + + +def test_remove_pacman_deps_provided_by_already_installed_foreign_packages( + mock_fpm, mock_aur, mock_pacman: FakeAurPacmanInterface +): + mock_pacman.not_installable |= {"kwin-hifps", "qt6-base-hifps", "syncthingtray-qt6"} + pkgs = [ + PackageInfo( + pkgbase="kwin-hifps", + pkgname="kwin-hifps", + version="1", + git_url="...", + dependencies=("qt6-base-hifps",), + ), + PackageInfo( + pkgbase="qt6-base-hifps", + pkgname="qt6-base-hifps", + version="1", + git_url="...", + provides=("qt6-base",), + ), + PackageInfo( + pkgbase="syncthingtray-qt6", + pkgname="syncthingtray-qt6", + version="1", + git_url="...", + dependencies=("qt6-base",), + ), + ] + mock_pacman.installed_foreign = { + "kwin-hifps": "1", + "qt6-base-hifps": "1", + } + mock_pacman.explicitly_installed.add("kwin-hifps") + mock_pacman.provided_pkgs.add("qt6-base") + mock_aur.seed(pkgs) + + mock_fpm.install(["syncthingtray-qt6"]) + + assert len(mock_pacman.installed_files) == 1 + assert mock_pacman.explicitly_installed == {"kwin-hifps", "syncthingtray-qt6"} + assert "qt6-base" not in mock_pacman.installed_native + + +def test_install_simple_package( + mock_fpm, mock_pacman: FakeAurPacmanInterface, mock_aur: MockAurServer +): + mock_pacman.not_installable.add("foo") + pkg = PackageInfo( + pkgbase="foo", + pkgname="foo", + version="100.0.0", + git_url="...", + ) + mock_aur.seed([pkg]) + + mock_fpm.install(["foo"]) + + assert len(mock_pacman.installed_files) == 1 + assert "foo" in mock_pacman.installed_files[0] + assert "foo" in mock_pacman.explicitly_installed + assert "foo" in mock_pacman.installed_foreign + + +def test_upgrade_foreign_package(mock_fpm, mock_pacman, mock_aur): + mock_pacman.not_installable.add("my-app") + mock_pacman.installed_foreign = {"my-app": "1.0"} + mock_pacman.explicitly_installed = {"my-app"} + + pkg = PackageInfo( + pkgbase="my-app", + pkgname="my-app", + version="2.0", + git_url="...", + ) + mock_aur.seed([pkg]) + + mock_fpm.upgrade() + + assert len(mock_pacman.installed_files) == 1 + assert "my-app" in mock_pacman.installed_foreign + assert "my-app" in mock_pacman.installed_files[0] + + +def test_upgrade_skips_current_package(mock_fpm, mock_pacman, mock_aur): + mock_pacman.not_installable.add("stable-app") + mock_pacman.installed_foreign = {"stable-app": "5.0"} + mock_pacman.explicitly_installed = {"stable-app"} + + pkg = PackageInfo( + pkgbase="stable-app", + pkgname="stable-app", + version="5.0", + git_url="...", + ) + mock_aur.seed([pkg]) + + mock_fpm.upgrade() + + assert len(mock_pacman.installed_files) == 0 + + +def test_install_resolves_dependencies(mock_fpm, mock_pacman, mock_aur): + mock_pacman.not_installable |= {"lib-helper", "main-app"} + pkg_dep = PackageInfo(pkgbase="lib-helper", pkgname="lib-helper", version="1.5", git_url="...") + pkg_main = PackageInfo( + pkgbase="main-app", + pkgname="main-app", + version="2.0", + dependencies=("lib-helper",), + git_url="...", + ) + + mock_aur.seed([pkg_dep, pkg_main]) + + mock_fpm.install(["main-app"]) + + assert len(mock_pacman.installed_files) == 2 + assert "main-app" in mock_pacman.explicitly_installed + assert "main-app" in mock_pacman.installed_files + assert "lib-helper" in mock_pacman.installed_files diff --git a/uv.lock b/uv.lock index c089fe5..af67c27 100644 --- a/uv.lock +++ b/uv.lock @@ -126,6 +126,12 @@ dependencies = [ { name = "requests" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-mock" }, +] + [package.metadata] requires-dist = [ { name = "decman", editable = "." }, @@ -133,6 +139,12 @@ requires-dist = [ { name = "requests" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-mock", specifier = ">=3.15.1" }, +] + [[package]] name = "decman-systemd" version = "1.0.0" @@ -211,6 +223,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + [[package]] name = "requests" version = "2.32.5"