From 5dd6d8b2819046a7ed8536e021a6a2877cbb9f89 Mon Sep 17 00:00:00 2001 From: Kivi Kaitaniemi Date: Sun, 14 Dec 2025 19:10:39 +0200 Subject: [PATCH] Implement PKGBUILD parsing for CustomPackages --- pyproject.toml | 1 + src/decman/config.py | 2 + src/decman/plugins/pacman/__init__.py | 34 ++- src/decman/plugins/pacman/commands.py | 7 + src/decman/plugins/pacman/package.py | 235 ++++++++++++++++- ... => test_decman_plugins_pacman_package.py} | 249 +++++++++++++++++- 6 files changed, 518 insertions(+), 10 deletions(-) rename tests/{test_decman_plugins_pacman_packages.py => test_decman_plugins_pacman_package.py} (67%) diff --git a/pyproject.toml b/pyproject.toml index 134ddd0..95821b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ decman = "decman.app:main" [project.entry-points."decman.plugins"] systemd = "decman.plugins.systemd:Systemd" pacman = "decman.plugins.pacman:Pacman" +aur = "decman.plugins.pacman:AUR" [dependency-groups] dev = [ diff --git a/src/decman/config.py b/src/decman/config.py index ca5afa4..bfdda4d 100644 --- a/src/decman/config.py +++ b/src/decman/config.py @@ -25,3 +25,5 @@ quiet_output: bool = False color_output: bool = True module_on_disable_scripts_dir: str = "/var/lib/decman/scripts/" + +arch: str = "x86_64" diff --git a/src/decman/plugins/pacman/__init__.py b/src/decman/plugins/pacman/__init__.py index 0d30eb8..c862814 100644 --- a/src/decman/plugins/pacman/__init__.py +++ b/src/decman/plugins/pacman/__init__.py @@ -48,17 +48,47 @@ def custom_packages(fn): return fn +class AUR(plugins.Plugin): + """ + Plugin that manages additional pacman packages installed outside the pacman repos. + + AUR packages are added directly to ``packages`` or declared by modules via ``@aur_packages``. + + Custom packages are added directly to ``custom_packages`` or declared by modules via + ``@custom_packages``. + """ + + NAME = "aur" + + def __init__(self) -> None: + self.packages: set[str] = set() + self.custom_packages: set[CustomPackage] = set() + self.commands = PacmanCommands() + + def available(self) -> bool: + return shutil.which("pacman") is not None + + def process_modules(self, store: _store.Store, modules: set[module.Module]): + # This is used to track changes in modules. + store.ensure("aur_packages_for_module", {}) + store.ensure("custom_packages_for_module", {}) + + def apply( + self, store: _store.Store, dry_run: bool = False, params: list[str] | None = None + ) -> bool: + return True + + class Pacman(plugins.Plugin): """ Plugin that manages pacman packages added directly to ``packages`` or declared by modules via - @packages. + ``@packages``. """ NAME = "pacman" def __init__(self) -> None: self.packages: set[str] = set() - self.aur_packages: set[str] = set() self.commands = PacmanCommands() def available(self) -> bool: diff --git a/src/decman/plugins/pacman/commands.py b/src/decman/plugins/pacman/commands.py index d09d2cf..b202489 100644 --- a/src/decman/plugins/pacman/commands.py +++ b/src/decman/plugins/pacman/commands.py @@ -173,6 +173,13 @@ class PacmanCommands: return makechrootpkg_cmd + def print_srcinfo(self) -> list[str]: + """ + Running this command prints SRCINFO generated from the package in the current + working directory. + """ + return ["makepkg", "--printsrcinfo"] + class PacmanInterface: """ diff --git a/src/decman/plugins/pacman/package.py b/src/decman/plugins/pacman/package.py index 2546f26..40a2fe6 100644 --- a/src/decman/plugins/pacman/package.py +++ b/src/decman/plugins/pacman/package.py @@ -1,11 +1,17 @@ import dataclasses +import os +import pathlib import re +import tempfile import requests # type: ignore +import decman.config as config +import decman.core.command as command +import decman.core.error as errors import decman.core.output as output -from decman.plugins.pacman.commands import PacmanInterface -from decman.plugins.pacman.error import AurRPCError +from decman.plugins.pacman.commands import PacmanCommands, PacmanInterface +from decman.plugins.pacman.error import AurRPCError, PKGBUILDParseError def strip_dependency(dep: str) -> str: @@ -138,9 +144,14 @@ class CustomPackage: """ Custom package installed from some other location than the official repos or the AUR. + ``pkgname`` is required because the PKGBUILD might be for split packages. + Exactly one of ``git_url`` or ``pkgbuild_directory`` must be provided. Parameters: + ``pkgname``: + Name of the package. + ``git_url``: URL to a git repository containing the PKGBUILD. @@ -148,23 +159,237 @@ class CustomPackage: Path to the directory containing the PKGBUILD. """ - def __init__(self, git_url: str | None, pkgbuild_directory: str | None) -> None: + def __init__(self, pkgname: str, git_url: str | None, pkgbuild_directory: str | None) -> None: if git_url is None and pkgbuild_directory is None: raise ValueError("Both git_url and pkgbuild_directory cannot be None.") if git_url is not None and pkgbuild_directory is not None: raise ValueError("Both git_url and pkgbuild_directory cannot be set.") + self.pkgname = pkgname self.git_url = git_url self.pkgbuild_directory = pkgbuild_directory - def parse(self) -> PackageInfo: + def parse(self, commands: PacmanCommands) -> PackageInfo: """ Parses this package's PKGBUILD to ``PackageInfo``. If this fails, raises a ``PKGBUILDParseError``. """ - raise NotImplementedError + if self.pkgbuild_directory is not None: + srcinfo = self._srcinfo_from_pkgbuild_directory(commands) + else: + srcinfo = self._srcinfo_from_git(commands) + + return self._parse_srcinfo(srcinfo) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, CustomPackage): + return False + return ( + self.git_url == other.git_url + and self.pkgbuild_directory == other.pkgbuild_directory + and self.pkgname == other.pkgname + ) + + def __hash__(self) -> int: + return hash((self.pkgname, self.git_url, self.pkgbuild_directory)) + + def __str__(self) -> str: + if self.git_url is not None: + return f"CustomPackage(pkgname={self.pkgname}, git_url={self.git_url})" + return ( + f"CustomPackage(pkgname={self.pkgname}, pkgbuild_directory={self.pkgbuild_directory})" + ) + + def _srcinfo_from_pkgbuild_directory(self, commands: PacmanCommands) -> str: + assert self.pkgbuild_directory is not None, ( + "This will not get called if pkgbuild_directory is unset." + ) + + path = pathlib.Path(self.pkgbuild_directory) + if not path.is_dir(): + raise PKGBUILDParseError( + self.git_url, + self.pkgbuild_directory, + f"pkgbuild_directory '{path}' does not exist or is not a directory.", + ) + + if not (path / "PKGBUILD").exists(): + raise PKGBUILDParseError( + self.git_url, self.pkgbuild_directory, f"No PKGBUILD found in '{path}'." + ) + + return self._run_makepkg_printsrcinfo(path, commands) + + def _srcinfo_from_git(self, commands: PacmanCommands) -> str: + assert self.git_url is not None, "This will not get called if git_url is unset." + with tempfile.TemporaryDirectory(prefix="decman-pkgbuild-") as tmpdir: + tmp_path = pathlib.Path(tmpdir) + try: + cmd = commands.git_clone(self.git_url, tmpdir) + command.check_run_result(cmd, command.run(cmd)) + except errors.CommandFailedError as error: + raise PKGBUILDParseError( + self.git_url, self.pkgbuild_directory, "Failed to clone PKGBUILD repository." + ) from error + + if not (tmp_path / "PKGBUILD").exists(): + raise PKGBUILDParseError( + self.git_url, + self.pkgbuild_directory, + f"Cloned repository '{self.git_url}' does not contain a PKGBUILD.", + ) + + return self._run_makepkg_printsrcinfo(tmp_path, commands) + + def _run_makepkg_printsrcinfo(self, path: pathlib.Path, commands: PacmanCommands) -> str: + orig_wd = os.getcwd() + try: + os.chdir(path) + cmd = commands.print_srcinfo() + _, srcinfo = command.check_run_result(cmd, command.run(cmd)) + except errors.CommandFailedError as error: + raise PKGBUILDParseError( + self.git_url, self.pkgbuild_directory, "Failed to generate SRCINFO using makepkg." + ) from error + finally: + os.chdir(orig_wd) + + return srcinfo + + def _parse_srcinfo(self, srcinfo: str) -> PackageInfo: + pkgbase: str | None = None + pkgver: str | None = None + pkgrel: str | None = None + epoch: str | None = None + provides: list[str] = [] + + # I'm not sure if split packages can have dependencies listed in the base. + # Easy to handle regardless + base_depends: list[str] = [] + base_makedepends: list[str] = [] + base_checkdepends: list[str] = [] + + pkg_depends: list[str] = [] + pkg_makedepends: list[str] = [] + pkg_checkdepends: list[str] = [] + + current_pkg: str | None = None + found_pkgnames = set() + + for raw in srcinfo.splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + + key, value = (part.strip() for part in line.split("=", 1)) + + is_base = current_pkg is None + is_target_pkg = current_pkg == self.pkgname + + match key: + case "pkgbase": + pkgbase = value + current_pkg = None + + case "pkgname": + current_pkg = value + found_pkgnames.add(value) + + case "pkgver": + if pkgver is None or current_pkg == self.pkgname: + pkgver = value + + case "pkgrel": + if pkgrel is None or current_pkg == self.pkgname: + pkgrel = value + + case "epoch": + if epoch is None or current_pkg == self.pkgname: + epoch = value + + case "provides": + if is_target_pkg: + provides.append(value) + + case "depends": + if is_base: + base_depends.append(value) + elif is_target_pkg: + pkg_depends.append(value) + + case "makedepends": + if is_base: + base_makedepends.append(value) + elif is_target_pkg: + pkg_makedepends.append(value) + + case "checkdepends": + if is_base: + base_checkdepends.append(value) + elif is_target_pkg: + pkg_checkdepends.append(value) + + case _ if key.startswith("depends") and key.removeprefix("depends_") == config.arch: + if is_base: + base_depends.append(value) + elif is_target_pkg: + pkg_depends.append(value) + + case _ if ( + key.startswith("makedepends") + and key.removeprefix("makedepends_") == config.arch + ): + if is_base: + base_makedepends.append(value) + elif is_target_pkg: + pkg_makedepends.append(value) + + case _ if ( + key.startswith("checkdepends") + and key.removeprefix("checkdepends_") == config.arch + ): + if is_base: + base_checkdepends.append(value) + elif is_target_pkg: + pkg_checkdepends.append(value) + + if pkgbase is None or pkgver is None: + raise PKGBUILDParseError( + self.git_url, + self.pkgbuild_directory, + "Missing required fields (pkgbase/pkgver) in SRCINFO.", + ) + + if self.pkgname not in found_pkgnames: + raise PKGBUILDParseError( + self.git_url, + self.pkgbuild_directory, + f"Package {self.pkgname} not found in SRCINFO.\ + Packages present: {' '.join(found_pkgnames)}.", + ) + + version_core = pkgver + if pkgrel is not None: + version_core = f"{version_core}-{pkgrel}" + + if epoch is not None: + version = f"{epoch}:{version_core}" + else: + version = version_core + + return PackageInfo( + pkgname=self.pkgname, + pkgbase=pkgbase, + version=version, + git_url=self.git_url, + pkgbuild_directory=self.pkgbuild_directory, + provides=tuple(provides), + dependencies=tuple(base_depends + pkg_depends), + make_dependencies=tuple(base_makedepends + pkg_makedepends), + check_dependencies=tuple(base_checkdepends + pkg_checkdepends), + ) class PackageSearch: diff --git a/tests/test_decman_plugins_pacman_packages.py b/tests/test_decman_plugins_pacman_package.py similarity index 67% rename from tests/test_decman_plugins_pacman_packages.py rename to tests/test_decman_plugins_pacman_package.py index 16d6f78..7dc3bed 100644 --- a/tests/test_decman_plugins_pacman_packages.py +++ b/tests/test_decman_plugins_pacman_package.py @@ -1,8 +1,9 @@ +import pathlib import pytest from decman.plugins.pacman import package as pkg_mod -from decman.plugins.pacman.error import AurRPCError +from decman.plugins.pacman.error import AurRPCError, PKGBUILDParseError from decman.plugins.pacman.package import ( CustomPackage, PackageInfo, @@ -102,10 +103,252 @@ def test_packageinfo_foreign_and_native_dependencies_are_split_and_stripped(): def test_custompackage_requires_exactly_one_source(): with pytest.raises(ValueError, match="cannot be None"): - CustomPackage(git_url=None, pkgbuild_directory=None) + CustomPackage("pkg", git_url=None, pkgbuild_directory=None) with pytest.raises(ValueError, match="cannot be set"): - CustomPackage(git_url="git://example", pkgbuild_directory="/tmp") + CustomPackage("pkg", git_url="git://example", pkgbuild_directory="/tmp") + + +class DummyCommands: + """Minimal stub; only here so type checks pass where needed.""" + + pass + + +@pytest.mark.parametrize( + "srcinfo, expected_version", + [ + ( + """ + pkgbase = foo + pkgver = 1.2.3 + pkgrel = 4 + pkgname = foo + """, + "1.2.3-4", + ), + ( + """ + pkgbase = foo + pkgver = 1.2.3 + pkgrel = 4 + epoch = 2 + pkgname = foo + """, + "2:1.2.3-4", + ), + ( + """ + pkgbase = foo + pkgver = 1.2.3 + pkgname = foo + """, + "1.2.3", + ), + ], +) +def test_parse_srcinfo_version_handling(srcinfo: str, expected_version: str) -> None: + pkg = CustomPackage(pkgname="foo", git_url=None, pkgbuild_directory="/dummy") + + info = pkg._parse_srcinfo(srcinfo) + + assert info.pkgname == "foo" + assert info.pkgbase == "foo" + assert info.version == expected_version + + +def test_parse_srcinfo_single_package_dependencies() -> None: + srcinfo = """ + pkgbase = foo + pkgver = 1.2.3 + pkgrel = 1 + depends = bar>=1.0 + makedepends = baz + checkdepends = qux + + pkgname = foo + """ + + pkg = CustomPackage(pkgname="foo", git_url=None, pkgbuild_directory="/dummy") + + info = pkg._parse_srcinfo(srcinfo) + + assert info.dependencies == ("bar>=1.0",) + assert info.make_dependencies == ("baz",) + assert info.check_dependencies == ("qux",) + + +def test_parse_srcinfo_split_package_uses_only_target_pkg_dependencies(monkeypatch) -> None: + # Ensure arch-specific keys match + monkeypatch.setattr(pkg_mod.config, "arch", "x86_64", raising=False) + + srcinfo = """ + pkgbase = clion + pkgver = 2025.3 + pkgrel = 1 + makedepends = rsync + depends = base-dep + depends_x86_64 = base-arch-dep + + pkgname = clion + depends = libdbusmenu-glib + depends_x86_64 = clion-arch-dep + checkdepends = clion-check + + pkgname = clion-jre + depends = jre-dep + makedepends = jre-make + + pkgname = clion-cmake + depends = cmake-dep + """ + + pkg = CustomPackage(pkgname="clion", git_url=None, pkgbuild_directory="/dummy") + + info = pkg._parse_srcinfo(srcinfo) + + # version + assert info.pkgbase == "clion" + assert info.version == "2025.3-1" + + # base deps + target pkg deps (including arch-specific) + assert info.dependencies == ( + "base-dep", + "base-arch-dep", + "libdbusmenu-glib", + "clion-arch-dep", + ) + + # only base and target pkg makedepends + assert info.make_dependencies == ("rsync",) + + # base + target pkg checkdepends + assert info.check_dependencies == ("clion-check",) + + +def test_parse_srcinfo_arch_specific_ignored_for_other_arch(monkeypatch) -> None: + # Different arch → *_x86_64 keys should be ignored + monkeypatch.setattr(pkg_mod.config, "arch", "aarch64", raising=False) + + srcinfo = """ + pkgbase = foo + pkgver = 1.0 + pkgrel = 1 + depends_x86_64 = base-arch-dep + + pkgname = foo + depends = common-dep + depends_x86_64 = pkg-arch-dep + """ + + pkg = CustomPackage(pkgname="foo", git_url=None, pkgbuild_directory="/dummy") + + info = pkg._parse_srcinfo(srcinfo) + + # Only common deps, no *_x86_64 because arch != x86_64 + assert info.dependencies == ("common-dep",) + + +def test_parse_srcinfo_missing_required_fields_raises() -> None: + # Missing pkgbase + srcinfo_no_pkgbase = """ + pkgver = 1.0 + pkgrel = 1 + pkgname = foo + """ + pkg = CustomPackage(pkgname="foo", git_url=None, pkgbuild_directory="/dummy") + + with pytest.raises(PKGBUILDParseError) as excinfo: + pkg._parse_srcinfo(srcinfo_no_pkgbase) + assert "pkgbase/pkgver" in str(excinfo.value) + + # Missing pkgver + srcinfo_no_pkgver = """ + pkgbase = foo + pkgname = foo + """ + + with pytest.raises(PKGBUILDParseError) as excinfo2: + pkg._parse_srcinfo(srcinfo_no_pkgver) + assert "pkgbase/pkgver" in str(excinfo2.value) + + +def test_parse_srcinfo_missing_target_pkg_raises() -> None: + srcinfo = """ + pkgbase = foo + pkgver = 1.0 + pkgrel = 1 + pkgname = other + """ + + pkg = CustomPackage(pkgname="foo", git_url=None, pkgbuild_directory="/dummy") + + with pytest.raises(PKGBUILDParseError) as excinfo: + pkg._parse_srcinfo(srcinfo) + + msg = str(excinfo.value) + assert "Package foo not found in SRCINFO" in msg + assert "other" in msg # listed in present packages + + +def test_srcinfo_from_pkgbuild_directory_missing_dir_raises(tmp_path: pathlib.Path) -> None: + missing = tmp_path / "does-not-exist" + + pkg = CustomPackage(pkgname="foo", git_url=None, pkgbuild_directory=str(missing)) + + with pytest.raises(PKGBUILDParseError) as excinfo: + pkg._srcinfo_from_pkgbuild_directory(DummyCommands()) + + msg = str(excinfo.value) + assert "does not exist or is not a directory" in msg + + +def test_srcinfo_from_pkgbuild_directory_missing_pkgbuild_raises(tmp_path: pathlib.Path) -> None: + path = tmp_path / "pkgdir" + path.mkdir() + + pkg = CustomPackage(pkgname="foo", git_url=None, pkgbuild_directory=str(path)) + + with pytest.raises(PKGBUILDParseError) as excinfo: + pkg._srcinfo_from_pkgbuild_directory(DummyCommands()) + + msg = str(excinfo.value) + assert "No PKGBUILD found" in msg + + +def test_custom_package_equality_and_hash() -> None: + a1 = CustomPackage( + pkgname="foo", git_url="https://example.com/repo.git", pkgbuild_directory=None + ) + a2 = CustomPackage( + pkgname="foo", git_url="https://example.com/repo.git", pkgbuild_directory=None + ) + b = CustomPackage(pkgname="foo", git_url=None, pkgbuild_directory="/some/path") + + assert a1 == a2 + assert hash(a1) == hash(a2) + + assert a1 != b + assert hash(a1) != hash(b) + + +def test_custom_package_str_git_and_directory() -> None: + git_pkg = CustomPackage( + pkgname="foo", + git_url="https://example.com/repo.git", + pkgbuild_directory=None, + ) + dir_pkg = CustomPackage( + pkgname="foo", + git_url=None, + pkgbuild_directory="/some/path", + ) + + assert "pkgname=foo" in str(git_pkg) + assert "git_url=https://example.com/repo.git" in str(git_pkg) + + assert "pkgname=foo" in str(dir_pkg) + assert "pkgbuild_directory=/some/path" in str(dir_pkg) # --- PackageSearch: caching ------------------------------------------------