Implement PKGBUILD parsing for CustomPackages

This commit is contained in:
Kivi Kaitaniemi
2025-12-14 19:10:39 +02:00
parent fb578c0511
commit 5dd6d8b281
6 changed files with 518 additions and 10 deletions
+1
View File
@@ -17,6 +17,7 @@ decman = "decman.app:main"
[project.entry-points."decman.plugins"] [project.entry-points."decman.plugins"]
systemd = "decman.plugins.systemd:Systemd" systemd = "decman.plugins.systemd:Systemd"
pacman = "decman.plugins.pacman:Pacman" pacman = "decman.plugins.pacman:Pacman"
aur = "decman.plugins.pacman:AUR"
[dependency-groups] [dependency-groups]
dev = [ dev = [
+2
View File
@@ -25,3 +25,5 @@ quiet_output: bool = False
color_output: bool = True color_output: bool = True
module_on_disable_scripts_dir: str = "/var/lib/decman/scripts/" module_on_disable_scripts_dir: str = "/var/lib/decman/scripts/"
arch: str = "x86_64"
+32 -2
View File
@@ -48,17 +48,47 @@ def custom_packages(fn):
return 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): class Pacman(plugins.Plugin):
""" """
Plugin that manages pacman packages added directly to ``packages`` or declared by modules via Plugin that manages pacman packages added directly to ``packages`` or declared by modules via
@packages. ``@packages``.
""" """
NAME = "pacman" NAME = "pacman"
def __init__(self) -> None: def __init__(self) -> None:
self.packages: set[str] = set() self.packages: set[str] = set()
self.aur_packages: set[str] = set()
self.commands = PacmanCommands() self.commands = PacmanCommands()
def available(self) -> bool: def available(self) -> bool:
+7
View File
@@ -173,6 +173,13 @@ class PacmanCommands:
return makechrootpkg_cmd 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: class PacmanInterface:
""" """
+230 -5
View File
@@ -1,11 +1,17 @@
import dataclasses import dataclasses
import os
import pathlib
import re import re
import tempfile
import requests # type: ignore 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 import decman.core.output as output
from decman.plugins.pacman.commands import PacmanInterface from decman.plugins.pacman.commands import PacmanCommands, PacmanInterface
from decman.plugins.pacman.error import AurRPCError from decman.plugins.pacman.error import AurRPCError, PKGBUILDParseError
def strip_dependency(dep: str) -> str: 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. 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. Exactly one of ``git_url`` or ``pkgbuild_directory`` must be provided.
Parameters: Parameters:
``pkgname``:
Name of the package.
``git_url``: ``git_url``:
URL to a git repository containing the PKGBUILD. URL to a git repository containing the PKGBUILD.
@@ -148,23 +159,237 @@ class CustomPackage:
Path to the directory containing the PKGBUILD. 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: if git_url is None and pkgbuild_directory is None:
raise ValueError("Both git_url and pkgbuild_directory cannot be None.") raise ValueError("Both git_url and pkgbuild_directory cannot be None.")
if git_url is not None and pkgbuild_directory is not None: if git_url is not None and pkgbuild_directory is not None:
raise ValueError("Both git_url and pkgbuild_directory cannot be set.") raise ValueError("Both git_url and pkgbuild_directory cannot be set.")
self.pkgname = pkgname
self.git_url = git_url self.git_url = git_url
self.pkgbuild_directory = pkgbuild_directory self.pkgbuild_directory = pkgbuild_directory
def parse(self) -> PackageInfo: def parse(self, commands: PacmanCommands) -> PackageInfo:
""" """
Parses this package's PKGBUILD to ``PackageInfo``. Parses this package's PKGBUILD to ``PackageInfo``.
If this fails, raises a ``PKGBUILDParseError``. 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: class PackageSearch:
@@ -1,8 +1,9 @@
import pathlib
import pytest import pytest
from decman.plugins.pacman import package as pkg_mod 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 ( from decman.plugins.pacman.package import (
CustomPackage, CustomPackage,
PackageInfo, PackageInfo,
@@ -102,10 +103,252 @@ def test_packageinfo_foreign_and_native_dependencies_are_split_and_stripped():
def test_custompackage_requires_exactly_one_source(): def test_custompackage_requires_exactly_one_source():
with pytest.raises(ValueError, match="cannot be None"): 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"): 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 ------------------------------------------------ # --- PackageSearch: caching ------------------------------------------------