Refactor pkgmgr installers, introduce capability-based execution, and replace manifest layer

References:
- Current ChatGPT conversation: https://chatgpt.com/share/6935d6d7-0ae4-800f-988a-44a50c17ba48
- Extended discussion: https://chatgpt.com/share/6935d734-fd84-800f-9755-290902b8cee8

Summary:
This commit performs a major cleanup and modernization of the installation pipeline:

1. Introduced a new capability-detection subsystem:
   - Capabilities (python-runtime, make-install, nix-flake) are detected per installer/layer.
   - Installers run only when they add new capabilities.
   - Prevents duplicated work such as Python installers running when Nix already provides the runtime.

2. Removed deprecated pkgmgr.yml manifest installer:
   - Dependency resolution is now delegated entirely to real package managers (Nix, pip, make, distro build tools).
   - Simplifies layering and avoids unnecessary recursion.

3. Reworked OS-specific installers:
   - Arch PKGBUILD now uses 'makepkg --syncdeps --cleanbuild --install --noconfirm'.
   - Debian installer now builds proper .deb packages via dpkg-buildpackage + installs them.
   - RPM installer now builds packages using rpmbuild and installs them via rpm.

4. Switched from remote GitHub flakes to local-flake execution:
   - Wrapper now executes: nix run /usr/lib/package-manager#pkgmgr
   - Avoids lock-file write attempts and improves reliability in CI.

5. Added bash -i based integration test:
   - Correctly sources ~/.bashrc and evaluates alias + venv activation.
   - ‘pkgmgr --help’ is now printed for debugging without failing tests.

6. Updated unit tests across all installers:
   - Removed references to manifest installer.
   - Adjusted expectations for new behaviors (makepkg, dpkg-buildpackage, rpmbuild).
   - Added capability subsystem tests.

7. Improved flake.nix packaging logic:
   - The entire project source tree is copied into the runtime closure.
   - pkgmgr wrapper now executes runpy inside the packaged directory.

Together, these changes create a predictable, layered, capability-driven installer pipeline with consistent behavior across Arch, Debian, RPM, Nix, and Python layers.
This commit is contained in:
Kevin Veen-Birkenbach
2025-12-07 20:36:39 +01:00
parent 5134fd5273
commit 16a9d55d4f
28 changed files with 984 additions and 632 deletions

297
pkgmgr/capabilities.py Normal file
View File

@@ -0,0 +1,297 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Capability detection for pkgmgr.
Each capability is represented by a class that:
- defines a logical name (e.g. "python-runtime", "make-install", "nix-flake")
- knows for which installer layer(s) it applies (e.g. "nix", "python",
"makefile", "os-packages")
- searches the repository config/build files for specific strings
to determine whether that capability is provided by that layer.
This allows pkgmgr to dynamically decide if a higher layer already covers
work a lower layer would otherwise do (e.g. Nix calling pyproject/make,
or distro packages wrapping Nix or Makefile logic).
"""
from __future__ import annotations
import glob
import os
from abc import ABC, abstractmethod
from typing import Iterable, TYPE_CHECKING
if TYPE_CHECKING:
from pkgmgr.context import RepoContext
# ---------------------------------------------------------------------------
# Helper functions
# ---------------------------------------------------------------------------
def _read_text_if_exists(path: str) -> str | None:
"""Read a file as UTF-8 text, returning None if it does not exist or fails."""
if not os.path.exists(path):
return None
try:
with open(path, "r", encoding="utf-8") as f:
return f.read()
except OSError:
return None
def _scan_files_for_patterns(files: Iterable[str], patterns: Iterable[str]) -> bool:
"""
Return True if any of the given files exists and contains at least one of
the given patterns (case-insensitive).
"""
lower_patterns = [p.lower() for p in patterns]
for path in files:
content = _read_text_if_exists(path)
if not content:
continue
lower_content = content.lower()
if any(p in lower_content for p in lower_patterns):
return True
return False
def _first_spec_file(repo_dir: str) -> str | None:
"""Return the first *.spec file in repo_dir, if any."""
matches = glob.glob(os.path.join(repo_dir, "*.spec"))
if not matches:
return None
return sorted(matches)[0]
# ---------------------------------------------------------------------------
# Base matcher
# ---------------------------------------------------------------------------
class CapabilityMatcher(ABC):
"""Base class for all capability detectors."""
#: Logical capability name (e.g. "python-runtime", "make-install").
name: str
@abstractmethod
def applies_to_layer(self, layer: str) -> bool:
"""Return True if this capability can be provided by the given layer."""
raise NotImplementedError
@abstractmethod
def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
"""
Return True if this capability is actually provided by the given layer
for this repository.
This is where we search for specific strings in build/config files
(flake.nix, pyproject.toml, Makefile, PKGBUILD, debian/rules, *.spec, ...).
"""
raise NotImplementedError
# ---------------------------------------------------------------------------
# Capability: python-runtime
#
# Provided when:
# - Layer "python":
# pyproject.toml exists → Python runtime via pip for this project
# - Layer "nix":
# flake.nix contains hints that it builds a Python app
# (buildPythonApplication, python3Packages., poetry2nix, pip install, ...)
# - Layer "os-packages":
# distro build scripts (PKGBUILD, debian/rules, *.spec) clearly call
# pip/python to install THIS Python project (heuristic).
# ---------------------------------------------------------------------------
class PythonRuntimeCapability(CapabilityMatcher):
name = "python-runtime"
def applies_to_layer(self, layer: str) -> bool:
# OS packages may wrap Python builds, but must explicitly prove it
return layer in {"python", "nix", "os-packages"}
def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
repo_dir = ctx.repo_dir
if layer == "python":
# For pkgmgr, a pyproject.toml is enough to say:
# "This layer provides the Python runtime for this project."
pyproject = os.path.join(repo_dir, "pyproject.toml")
return os.path.exists(pyproject)
if layer == "nix":
flake = os.path.join(repo_dir, "flake.nix")
content = _read_text_if_exists(flake)
if not content:
return False
content = content.lower()
patterns = [
"buildpythonapplication",
"python3packages.",
"poetry2nix",
"pip install",
"python -m pip",
]
return any(p in content for p in patterns)
if layer == "os-packages":
# Heuristic:
# - repo looks like a Python project (pyproject.toml or setup.py)
# - and OS build scripts call pip / python -m pip / setup.py install
pyproject = os.path.join(repo_dir, "pyproject.toml")
setup_py = os.path.join(repo_dir, "setup.py")
if not (os.path.exists(pyproject) or os.path.exists(setup_py)):
return False
pkgbuild = os.path.join(repo_dir, "PKGBUILD")
debian_rules = os.path.join(repo_dir, "debian", "rules")
spec = _first_spec_file(repo_dir)
scripts = [pkgbuild, debian_rules]
if spec:
scripts.append(spec)
patterns = [
"pip install .",
"python -m pip install",
"python3 -m pip install",
"setup.py install",
]
return _scan_files_for_patterns(scripts, patterns)
return False
# ---------------------------------------------------------------------------
# Capability: make-install
#
# Provided when:
# - Layer "makefile":
# Makefile has an "install:" target
# - Layer "python":
# pyproject.toml mentions "make install"
# - Layer "nix":
# flake.nix mentions "make install"
# - Layer "os-packages":
# distro build scripts call "make install" (they already consume the
# Makefile installation step).
# ---------------------------------------------------------------------------
class MakeInstallCapability(CapabilityMatcher):
name = "make-install"
def applies_to_layer(self, layer: str) -> bool:
return layer in {"makefile", "python", "nix", "os-packages"}
def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
repo_dir = ctx.repo_dir
if layer == "makefile":
makefile = os.path.join(repo_dir, "Makefile")
if not os.path.exists(makefile):
return False
try:
with open(makefile, "r", encoding="utf-8") as f:
for line in f:
if line.strip().startswith("install:"):
return True
except OSError:
return False
return False
if layer == "python":
pyproject = os.path.join(repo_dir, "pyproject.toml")
content = _read_text_if_exists(pyproject)
if not content:
return False
return "make install" in content.lower()
if layer == "nix":
flake = os.path.join(repo_dir, "flake.nix")
content = _read_text_if_exists(flake)
if not content:
return False
return "make install" in content.lower()
if layer == "os-packages":
pkgbuild = os.path.join(repo_dir, "PKGBUILD")
debian_rules = os.path.join(repo_dir, "debian", "rules")
spec = _first_spec_file(repo_dir)
scripts = [pkgbuild, debian_rules]
if spec:
scripts.append(spec)
# If any OS build script calls "make install", we assume it is
# already consuming the Makefile installation and thus provides
# the make-install capability.
return _scan_files_for_patterns(scripts, ["make install"])
return False
# ---------------------------------------------------------------------------
# Capability: nix-flake
#
# Provided when:
# - Layer "nix":
# flake.nix exists → Nix flake installer can install this project
# - Layer "os-packages":
# distro build scripts clearly call Nix (nix build/run/develop/profile),
# i.e. they already use Nix as part of building/installing.
# ---------------------------------------------------------------------------
class NixFlakeCapability(CapabilityMatcher):
name = "nix-flake"
def applies_to_layer(self, layer: str) -> bool:
# Only Nix itself and OS packages that explicitly wrap Nix
return layer in {"nix", "os-packages"}
def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
repo_dir = ctx.repo_dir
if layer == "nix":
flake = os.path.join(repo_dir, "flake.nix")
return os.path.exists(flake)
if layer == "os-packages":
pkgbuild = os.path.join(repo_dir, "PKGBUILD")
debian_rules = os.path.join(repo_dir, "debian", "rules")
spec = _first_spec_file(repo_dir)
scripts = [pkgbuild, debian_rules]
if spec:
scripts.append(spec)
patterns = [
"nix build",
"nix run",
"nix-shell",
"nix develop",
"nix profile",
]
return _scan_files_for_patterns(scripts, patterns)
return False
# ---------------------------------------------------------------------------
# Registry of all capability matchers currently supported.
# ---------------------------------------------------------------------------
CAPABILITY_MATCHERS: list[CapabilityMatcher] = [
PythonRuntimeCapability(),
MakeInstallCapability(),
NixFlakeCapability(),
]

View File

@@ -10,8 +10,8 @@ This module orchestrates the installation of repositories by:
2. Verifying the repository according to the configured policies.
3. Creating executable links using create_ink().
4. Running a sequence of modular installer components that handle
specific technologies or manifests (pkgmgr.yml, PKGBUILD, Nix,
Ansible requirements, Python, Makefile, AUR).
specific technologies or manifests (PKGBUILD, Nix flakes, Python
via pyproject.toml, Makefile, OS-specific package metadata).
The goal is to keep this file thin and delegate most logic to small,
focused installer classes.
@@ -29,7 +29,6 @@ from pkgmgr.clone_repos import clone_repos
from pkgmgr.context import RepoContext
# Installer implementations
from pkgmgr.installers.pkgmgr_manifest import PkgmgrManifestInstaller
from pkgmgr.installers.os_packages import (
ArchPkgbuildInstaller,
DebianControlInstaller,
@@ -41,18 +40,16 @@ from pkgmgr.installers.makefile import MakefileInstaller
# Layering:
# 1) pkgmgr.yml (high-level repo dependencies)
# 2) OS packages: PKGBUILD / debian/control / RPM spec
# 3) Nix flakes (flake.nix)
# 4) Python (pyproject / requirements)
# 5) Makefile fallback
# 1) OS packages: PKGBUILD / debian/control / RPM spec → os-deps.*
# 2) Nix flakes (flake.nix) → e.g. python-runtime, make-install
# 3) Python (pyproject.toml) → e.g. python-runtime, make-install
# 4) Makefile fallback → e.g. make-install
INSTALLERS = [
PkgmgrManifestInstaller(), # meta/pkgmgr.yml deps
ArchPkgbuildInstaller(), # Arch
DebianControlInstaller(), # Debian/Ubuntu
RpmSpecInstaller(), # Fedora/RHEL/CentOS
NixFlakeInstaller(), # 2) flake.nix (Nix layer)
PythonInstaller(), # 3) pyproject / requirements (fallback if no flake+nix)
NixFlakeInstaller(), # flake.nix (Nix layer)
PythonInstaller(), # pyproject.toml
MakefileInstaller(), # generic 'make install'
]
@@ -165,8 +162,8 @@ def install_repos(
) -> None:
"""
Install repositories by creating symbolic links and processing standard
manifest files (pkgmgr.yml, PKGBUILD, flake.nix, Ansible requirements,
Python manifests, Makefile, AUR) via dedicated installer components.
manifest files (PKGBUILD, flake.nix, Python manifests, Makefile, etc.)
via dedicated installer components.
Any installer failure (SystemExit) is treated as fatal and will abort
the current installation.
@@ -217,7 +214,29 @@ def install_repos(
preview=preview,
)
# Run all installers that support this repository.
# Track which logical capabilities have already been provided by
# earlier installers for this repository. This allows us to skip
# installers that would only duplicate work (e.g. Python runtime
# already provided by Nix flake → skip pyproject/Makefile).
provided_capabilities: set[str] = set()
# Run all installers that support this repository, but only if they
# provide at least one capability that is not yet satisfied.
for installer in INSTALLERS:
if installer.supports(ctx):
installer.run(ctx)
if not installer.supports(ctx):
continue
caps = installer.discover_capabilities(ctx)
# If the installer declares capabilities and *all* of them are
# already provided, we can safely skip it.
if caps and caps.issubset(provided_capabilities):
if not quiet:
print(
f"Skipping installer {installer.__class__.__name__} "
f"for {identifier} capabilities {caps} already provided."
)
continue
installer.run(ctx)
provided_capabilities.update(caps)

View File

@@ -9,7 +9,6 @@ pkgmgr.installers.
"""
from pkgmgr.installers.base import BaseInstaller # noqa: F401
from pkgmgr.installers.pkgmgr_manifest import PkgmgrManifestInstaller # noqa: F401
from pkgmgr.installers.nix_flake import NixFlakeInstaller # noqa: F401
from pkgmgr.installers.python import PythonInstaller # noqa: F401
from pkgmgr.installers.makefile import MakefileInstaller # noqa: F401

View File

@@ -6,8 +6,10 @@ Base interface for all installer components in the pkgmgr installation pipeline.
"""
from abc import ABC, abstractmethod
from typing import Set
from pkgmgr.context import RepoContext
from pkgmgr.capabilities import CAPABILITY_MATCHERS
class BaseInstaller(ABC):
@@ -15,9 +17,35 @@ class BaseInstaller(ABC):
A single step in the installation pipeline for a repository.
Implementations should be small and focused on one technology or manifest
type (e.g. PKGBUILD, Nix, Python, Ansible, pkgmgr.yml).
type (e.g. PKGBUILD, Nix, Python, Makefile, etc.).
"""
#: Logical layer name for this installer.
# Examples: "nix", "python", "makefile".
# This is used by capability matchers to decide which patterns to
# search for in the repository.
layer: str | None = None
def discover_capabilities(self, ctx: RepoContext) -> Set[str]:
"""
Determine which logical capabilities this installer will provide
for this specific repository instance.
This method delegates to the global capability matchers, which
inspect build/configuration files (flake.nix, pyproject.toml,
Makefile, etc.) and decide, via string matching, whether a given
capability is actually provided by this layer.
"""
caps: Set[str] = set()
if not self.layer:
return caps
for matcher in CAPABILITY_MATCHERS:
if matcher.applies_to_layer(self.layer) and matcher.is_provided(ctx, self.layer):
caps.add(matcher.name)
return caps
@abstractmethod
def supports(self, ctx: RepoContext) -> bool:
"""

View File

@@ -18,6 +18,9 @@ from pkgmgr.run_command import run_command
class MakefileInstaller(BaseInstaller):
"""Run `make install` if a Makefile exists in the repository."""
# Logical layer name, used by capability matchers.
layer = "makefile"
MAKEFILE_NAME = "Makefile"
def supports(self, ctx: RepoContext) -> bool:

View File

@@ -30,6 +30,9 @@ if TYPE_CHECKING:
class NixFlakeInstaller(BaseInstaller):
"""Install Nix flake profiles for repositories that define flake.nix."""
# Logical layer name, used by capability matchers.
layer = "nix"
FLAKE_FILE = "flake.nix"
PROFILE_NAME = "package-manager"

View File

@@ -1,17 +1,7 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Installer for Arch Linux dependencies defined in PKGBUILD files.
This installer extracts depends/makedepends from PKGBUILD and installs them
via pacman on Arch-based systems.
"""
# pkgmgr/installers/os_packages/arch_pkgbuild.py
import os
import shutil
import subprocess
from typing import List
from pkgmgr.context import RepoContext
from pkgmgr.installers.base import BaseInstaller
@@ -19,68 +9,51 @@ from pkgmgr.run_command import run_command
class ArchPkgbuildInstaller(BaseInstaller):
"""Install Arch dependencies (depends/makedepends) from PKGBUILD."""
"""
Build and install an Arch package from PKGBUILD via makepkg.
This installer is responsible for the full build + install of the
application on Arch-based systems. System dependencies are resolved
by makepkg itself (--syncdeps).
Note: makepkg must not be run as root, so this installer refuses
to run when the current user is UID 0.
"""
# Logical layer name, used by capability matchers.
layer = "os-packages"
PKGBUILD_NAME = "PKGBUILD"
def supports(self, ctx: RepoContext) -> bool:
"""
This installer is supported if:
- pacman is available, and
- a PKGBUILD file exists in the repository root.
- pacman and makepkg are available,
- a PKGBUILD file exists in the repository root,
- the current user is NOT root (makepkg forbids root).
"""
if shutil.which("pacman") is None:
# Do not run makepkg as root it is explicitly forbidden.
try:
if hasattr(os, "geteuid") and os.geteuid() == 0:
return False
except Exception:
# On non-POSIX platforms just ignore this check.
pass
if shutil.which("pacman") is None or shutil.which("makepkg") is None:
return False
pkgbuild_path = os.path.join(ctx.repo_dir, self.PKGBUILD_NAME)
return os.path.exists(pkgbuild_path)
def _extract_pkgbuild_array(self, ctx: RepoContext, var_name: str) -> List[str]:
"""
Extract a Bash array (depends/makedepends) from PKGBUILD using bash itself.
Any failure in sourcing or extracting the variable is treated as fatal.
"""
pkgbuild_path = os.path.join(ctx.repo_dir, self.PKGBUILD_NAME)
if not os.path.exists(pkgbuild_path):
return []
script = (
f'source {self.PKGBUILD_NAME} >/dev/null 2>&1; '
f'printf "%s\\n" "${{{var_name}[@]}}"'
)
try:
output = subprocess.check_output(
["bash", "--noprofile", "--norc", "-c", script],
cwd=ctx.repo_dir,
text=True,
)
except Exception as exc:
print(
f"[Error] Failed to extract '{var_name}' from PKGBUILD in "
f"{ctx.identifier}: {exc}"
)
raise SystemExit(
f"PKGBUILD parsing failed for '{var_name}' in {ctx.identifier}: {exc}"
)
packages: List[str] = []
for line in output.splitlines():
line = line.strip()
if not line:
continue
packages.append(line)
return packages
def run(self, ctx: RepoContext) -> None:
"""
Install all packages from depends + makedepends via pacman.
Build and install the package using makepkg.
This uses:
makepkg --syncdeps --cleanbuild --install --noconfirm
Any failure is treated as fatal (SystemExit).
"""
depends = self._extract_pkgbuild_array(ctx, "depends")
makedepends = self._extract_pkgbuild_array(ctx, "makedepends")
all_pkgs = depends + makedepends
if not all_pkgs:
return
cmd = "sudo pacman -S --noconfirm " + " ".join(all_pkgs)
cmd = "makepkg --syncdeps --cleanbuild --install --noconfirm"
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)

View File

@@ -2,15 +2,22 @@
# -*- coding: utf-8 -*-
"""
Installer for Debian/Ubuntu system dependencies defined in debian/control.
Installer for Debian/Ubuntu packages defined via debian/control.
This installer parses the debian/control file and installs packages from
Build-Depends / Build-Depends-Indep / Depends via apt-get on Debian-based
systems.
This installer:
1. Installs build dependencies via `apt-get build-dep ./`
2. Uses dpkg-buildpackage to build .deb packages from debian/*
3. Installs the resulting .deb files via `dpkg -i`
It is intended for Debian-based systems where dpkg-buildpackage and
apt/dpkg tooling are available.
"""
import glob
import os
import shutil
from typing import List
from pkgmgr.context import RepoContext
@@ -19,13 +26,22 @@ from pkgmgr.run_command import run_command
class DebianControlInstaller(BaseInstaller):
"""Install Debian/Ubuntu system packages from debian/control."""
"""
Build and install a Debian/Ubuntu package from debian/control.
This installer is responsible for the full build + install of the
application on Debian-like systems.
"""
# Logical layer name, used by capability matchers.
layer = "os-packages"
CONTROL_DIR = "debian"
CONTROL_FILE = "control"
def _is_debian_like(self) -> bool:
return shutil.which("apt-get") is not None
"""Return True if this looks like a Debian-based system."""
return shutil.which("dpkg-buildpackage") is not None
def _control_path(self, ctx: RepoContext) -> str:
return os.path.join(ctx.repo_dir, self.CONTROL_DIR, self.CONTROL_FILE)
@@ -33,7 +49,7 @@ class DebianControlInstaller(BaseInstaller):
def supports(self, ctx: RepoContext) -> bool:
"""
This installer is supported if:
- we are on a Debian-like system (apt-get available), and
- we are on a Debian-like system (dpkg-buildpackage available), and
- debian/control exists.
"""
if not self._is_debian_like():
@@ -41,101 +57,73 @@ class DebianControlInstaller(BaseInstaller):
return os.path.exists(self._control_path(ctx))
def _parse_control_dependencies(self, control_path: str) -> List[str]:
def _find_built_debs(self, repo_dir: str) -> List[str]:
"""
Parse Build-Depends, Build-Depends-Indep and Depends fields
from debian/control.
Find .deb files built by dpkg-buildpackage.
This is a best-effort parser that:
- joins continuation lines starting with space,
- splits fields by comma,
- strips version constraints and alternatives (x | y → x),
- filters out variable placeholders like ${misc:Depends}.
By default, dpkg-buildpackage creates .deb files in the parent
directory of the source tree.
"""
if not os.path.exists(control_path):
return []
parent = os.path.dirname(repo_dir)
pattern = os.path.join(parent, "*.deb")
return sorted(glob.glob(pattern))
with open(control_path, "r", encoding="utf-8") as f:
lines = f.readlines()
def _install_build_dependencies(self, ctx: RepoContext) -> None:
"""
Install build dependencies using `apt-get build-dep ./`.
deps: List[str] = []
current_key = None
current_val_lines: List[str] = []
This is a best-effort implementation that assumes:
- deb-src entries are configured in /etc/apt/sources.list*,
- apt-get is available on PATH.
target_keys = {
"Build-Depends",
"Build-Depends-Indep",
"Depends",
}
Any failure is treated as fatal (SystemExit), just like other
installer steps.
"""
if shutil.which("apt-get") is None:
print(
"[Warning] apt-get not found on PATH. "
"Skipping automatic build-dep installation for Debian."
)
return
def flush_current():
nonlocal current_key, current_val_lines, deps
if not current_key or not current_val_lines:
return
value = " ".join(l.strip() for l in current_val_lines)
# Split by comma into individual dependency expressions
for part in value.split(","):
part = part.strip()
if not part:
continue
# Take the first alternative: "foo | bar" → "foo"
if "|" in part:
part = part.split("|", 1)[0].strip()
# Strip version constraints: "pkg (>= 1.0)" → "pkg"
if " " in part:
part = part.split(" ", 1)[0].strip()
# Skip variable placeholders
if part.startswith("${") and part.endswith("}"):
continue
if part:
deps.append(part)
current_key = None
current_val_lines = []
# Update package lists first for reliable build-dep resolution.
run_command("sudo apt-get update", cwd=ctx.repo_dir, preview=ctx.preview)
for line in lines:
if line.startswith(" ") or line.startswith("\t"):
# Continuation of previous field
if current_key in target_keys:
current_val_lines.append(line)
continue
# New field
flush_current()
if ":" not in line:
continue
key, val = line.split(":", 1)
key = key.strip()
val = val.strip()
if key in target_keys:
current_key = key
current_val_lines = [val]
# Flush last field
flush_current()
# De-duplicate while preserving order
seen = set()
unique_deps: List[str] = []
for pkg in deps:
if pkg not in seen:
seen.add(pkg)
unique_deps.append(pkg)
return unique_deps
# Install build dependencies based on debian/control in the current tree.
# `apt-get build-dep ./` uses the source in the current directory.
builddep_cmd = "sudo apt-get build-dep -y ./"
run_command(builddep_cmd, cwd=ctx.repo_dir, preview=ctx.preview)
def run(self, ctx: RepoContext) -> None:
"""
Install Debian/Ubuntu system packages via apt-get.
Build and install Debian/Ubuntu packages from debian/*.
Steps:
1. apt-get build-dep ./ (automatic build dependency installation)
2. dpkg-buildpackage -b -us -uc
3. sudo dpkg -i ../*.deb
"""
control_path = self._control_path(ctx)
packages = self._parse_control_dependencies(control_path)
if not packages:
if not os.path.exists(control_path):
return
# Update and install in two separate commands for clarity.
run_command("sudo apt-get update", cwd=ctx.repo_dir, preview=ctx.preview)
# 1) Install build dependencies
self._install_build_dependencies(ctx)
cmd = "sudo apt-get install -y " + " ".join(packages)
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
# 2) Build the package
build_cmd = "dpkg-buildpackage -b -us -uc"
run_command(build_cmd, cwd=ctx.repo_dir, preview=ctx.preview)
# 3) Locate built .deb files
debs = self._find_built_debs(ctx.repo_dir)
if not debs:
print(
"[Warning] No .deb files found after dpkg-buildpackage. "
"Skipping Debian package installation."
)
return
# 4) Install .deb files
install_cmd = "sudo dpkg -i " + " ".join(os.path.basename(d) for d in debs)
parent = os.path.dirname(ctx.repo_dir)
run_command(install_cmd, cwd=parent, preview=ctx.preview)

View File

@@ -2,16 +2,21 @@
# -*- coding: utf-8 -*-
"""
Installer for RPM-based system dependencies defined in *.spec files.
Installer for RPM-based packages defined in *.spec files.
This installer parses the first *.spec file it finds in the repository
and installs packages from BuildRequires / Requires via dnf or yum on
RPM-based systems (Fedora / RHEL / CentOS / Rocky / Alma, etc.).
This installer:
1. Installs build dependencies via dnf/yum builddep (where available)
2. Uses rpmbuild to build RPMs from the provided .spec file
3. Installs the resulting RPMs via `rpm -i`
It targets RPM-based systems (Fedora / RHEL / CentOS / Rocky / Alma, etc.).
"""
import glob
import os
import shutil
from typing import List, Optional
from pkgmgr.context import RepoContext
@@ -20,23 +25,44 @@ from pkgmgr.run_command import run_command
class RpmSpecInstaller(BaseInstaller):
"""Install RPM-based system packages from *.spec files."""
"""
Build and install RPM-based packages from *.spec files.
This installer is responsible for the full build + install of the
application on RPM-like systems.
"""
# Logical layer name, used by capability matchers.
layer = "os-packages"
def _is_rpm_like(self) -> bool:
return shutil.which("dnf") is not None or shutil.which("yum") is not None
"""
Basic RPM-like detection:
- rpmbuild must be available
- at least one of dnf / yum / yum-builddep must be present
"""
if shutil.which("rpmbuild") is None:
return False
has_dnf = shutil.which("dnf") is not None
has_yum = shutil.which("yum") is not None
has_yum_builddep = shutil.which("yum-builddep") is not None
return has_dnf or has_yum or has_yum_builddep
def _spec_path(self, ctx: RepoContext) -> Optional[str]:
"""Return the first *.spec file in the repository root, if any."""
pattern = os.path.join(ctx.repo_dir, "*.spec")
matches = glob.glob(pattern)
matches = sorted(glob.glob(pattern))
if not matches:
return None
# Take the first match deterministically (sorted)
return sorted(matches)[0]
return matches[0]
def supports(self, ctx: RepoContext) -> bool:
"""
This installer is supported if:
- we are on an RPM-based system (dnf or yum available), and
- we are on an RPM-based system (rpmbuild + dnf/yum/yum-builddep available), and
- a *.spec file exists in the repository root.
"""
if not self._is_rpm_like():
@@ -44,109 +70,91 @@ class RpmSpecInstaller(BaseInstaller):
return self._spec_path(ctx) is not None
def _parse_spec_dependencies(self, spec_path: str) -> List[str]:
def _find_built_rpms(self) -> List[str]:
"""
Parse BuildRequires and Requires from a .spec file.
Find RPMs built by rpmbuild.
Best-effort parser that:
- joins continuation lines starting with space or tab,
- splits fields by comma,
- takes the first token of each entry as the package name,
- ignores macros and empty entries.
By default, rpmbuild outputs RPMs into:
~/rpmbuild/RPMS/*/*.rpm
"""
if not os.path.exists(spec_path):
return []
home = os.path.expanduser("~")
pattern = os.path.join(home, "rpmbuild", "RPMS", "**", "*.rpm")
return sorted(glob.glob(pattern, recursive=True))
with open(spec_path, "r", encoding="utf-8") as f:
lines = f.readlines()
def _install_build_dependencies(self, ctx: RepoContext, spec_path: str) -> None:
"""
Install build dependencies for the given .spec file.
deps: List[str] = []
current_key = None
current_val_lines: List[str] = []
Strategy (best-effort):
target_keys = {
"BuildRequires",
"Requires",
}
1. If dnf is available:
sudo dnf builddep -y <spec>
2. Else if yum-builddep is available:
sudo yum-builddep -y <spec>
3. Else if yum is available:
sudo yum-builddep -y <spec> # Some systems provide it via yum plugin
4. Otherwise: print a warning and skip automatic builddep install.
def flush_current():
nonlocal current_key, current_val_lines, deps
if not current_key or not current_val_lines:
return
value = " ".join(l.strip() for l in current_val_lines)
# Split by comma into individual dependency expressions
for part in value.split(","):
part = part.strip()
if not part:
continue
# Take first token as package name: "pkg >= 1.0" → "pkg"
token = part.split()[0].strip()
if not token:
continue
# Ignore macros like %{?something}
if token.startswith("%"):
continue
deps.append(token)
current_key = None
current_val_lines = []
Any failure in builddep installation is treated as fatal (SystemExit),
consistent with other installer steps.
"""
spec_basename = os.path.basename(spec_path)
for line in lines:
stripped = line.lstrip()
if stripped.startswith("#"):
# Comment
continue
if shutil.which("dnf") is not None:
cmd = f"sudo dnf builddep -y {spec_basename}"
elif shutil.which("yum-builddep") is not None:
cmd = f"sudo yum-builddep -y {spec_basename}"
elif shutil.which("yum") is not None:
# Some distributions ship yum-builddep as a plugin.
cmd = f"sudo yum-builddep -y {spec_basename}"
else:
print(
"[Warning] No suitable RPM builddep tool (dnf/yum-builddep/yum) found. "
"Skipping automatic build dependency installation for RPM."
)
return
if line.startswith(" ") or line.startswith("\t"):
# Continuation of previous field
if current_key in target_keys:
current_val_lines.append(line)
continue
# New field
flush_current()
if ":" not in line:
continue
key, val = line.split(":", 1)
key = key.strip()
val = val.strip()
if key in target_keys:
current_key = key
current_val_lines = [val]
# Flush last field
flush_current()
# De-duplicate while preserving order
seen = set()
unique_deps: List[str] = []
for pkg in deps:
if pkg not in seen:
seen.add(pkg)
unique_deps.append(pkg)
return unique_deps
# Run builddep in the repository directory so relative spec paths work.
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
def run(self, ctx: RepoContext) -> None:
"""
Install RPM-based system packages via dnf or yum.
Build and install RPM-based packages.
Steps:
1. dnf/yum builddep <spec> (automatic build dependency installation)
2. rpmbuild -ba path/to/spec
3. sudo rpm -i ~/rpmbuild/RPMS/*/*.rpm
"""
spec_path = self._spec_path(ctx)
if not spec_path:
return
packages = self._parse_spec_dependencies(spec_path)
if not packages:
return
# 1) Install build dependencies
self._install_build_dependencies(ctx, spec_path)
pkg_mgr = shutil.which("dnf") or shutil.which("yum")
if not pkg_mgr:
# 2) Build RPMs
# Use the full spec path, but run in the repo directory.
spec_basename = os.path.basename(spec_path)
build_cmd = f"rpmbuild -ba {spec_basename}"
run_command(build_cmd, cwd=ctx.repo_dir, preview=ctx.preview)
# 3) Find built RPMs
rpms = self._find_built_rpms()
if not rpms:
print(
"[Warning] No suitable RPM package manager (dnf/yum) found on PATH. "
"Skipping RPM dependency installation."
"[Warning] No RPM files found after rpmbuild. "
"Skipping RPM package installation."
)
return
cmd = f"sudo {pkg_mgr} install -y " + " ".join(packages)
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
# 4) Install RPMs
if shutil.which("rpm") is None:
print(
"[Warning] rpm binary not found on PATH. "
"Cannot install built RPMs."
)
return
install_cmd = "sudo rpm -i " + " ".join(rpms)
run_command(install_cmd, cwd=ctx.repo_dir, preview=ctx.preview)

View File

@@ -1,117 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Installer for pkgmgr.yml manifest dependencies.
This installer reads pkgmgr.yml (if present) and installs referenced pkgmgr
repository dependencies via pkgmgr itself.
"""
import os
from typing import Any, Dict, List
import yaml
from pkgmgr.context import RepoContext
from pkgmgr.installers.base import BaseInstaller
from pkgmgr.run_command import run_command
class PkgmgrManifestInstaller(BaseInstaller):
"""Install pkgmgr-defined repository dependencies from pkgmgr.yml."""
MANIFEST_NAME = "pkgmgr.yml"
def supports(self, ctx: RepoContext) -> bool:
manifest_path = os.path.join(ctx.repo_dir, self.MANIFEST_NAME)
return os.path.exists(manifest_path)
def _load_manifest(self, manifest_path: str) -> Dict[str, Any]:
"""
Load the pkgmgr.yml manifest.
Any parsing error is treated as a fatal error (SystemExit).
"""
try:
with open(manifest_path, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
except Exception as exc:
print(f"Error loading {self.MANIFEST_NAME} in '{manifest_path}': {exc}")
raise SystemExit(
f"{self.MANIFEST_NAME} parsing failed for '{manifest_path}': {exc}"
)
def _collect_dependency_ids(self, dependencies: List[Dict[str, Any]]) -> List[str]:
ids: List[str] = []
for dep in dependencies:
if not isinstance(dep, dict):
continue
repo_id = dep.get("repository")
if repo_id:
ids.append(str(repo_id))
return ids
def run(self, ctx: RepoContext) -> None:
manifest_path = os.path.join(ctx.repo_dir, self.MANIFEST_NAME)
manifest = self._load_manifest(manifest_path)
if not manifest:
return
dependencies = manifest.get("dependencies", []) or []
if not isinstance(dependencies, list) or not dependencies:
return
author = manifest.get("author")
url = manifest.get("url")
description = manifest.get("description")
if not ctx.preview:
print("pkgmgr manifest detected:")
if author:
print(f" author: {author}")
if url:
print(f" url: {url}")
if description:
print(f" description: {description}")
dep_repo_ids = self._collect_dependency_ids(dependencies)
# Optionally pull dependencies if requested.
if ctx.update_dependencies and dep_repo_ids:
cmd_pull = "pkgmgr pull " + " ".join(dep_repo_ids)
run_command(cmd_pull, preview=ctx.preview)
# Install dependencies one by one.
for dep in dependencies:
if not isinstance(dep, dict):
continue
repo_id = dep.get("repository")
if not repo_id:
continue
version = dep.get("version")
reason = dep.get("reason")
if reason and not ctx.preview:
print(f"Installing dependency {repo_id}: {reason}")
else:
print(f"Installing dependency {repo_id}...")
cmd = f"pkgmgr install {repo_id}"
if version:
cmd += f" --version {version}"
if ctx.no_verification:
cmd += " --no-verification"
if ctx.update_dependencies:
cmd += " --dependencies"
if ctx.clone_mode:
cmd += f" --clone-mode {ctx.clone_mode}"
# Dependency installation failures are fatal.
run_command(cmd, preview=ctx.preview)

View File

@@ -2,7 +2,7 @@
# -*- coding: utf-8 -*-
"""
Installer for Python projects based on pyproject.toml and/or requirements.txt.
Installer for Python projects based on pyproject.toml.
Strategy:
- Determine a pip command in this order:
@@ -10,7 +10,6 @@ Strategy:
2. sys.executable -m pip (current interpreter)
3. "pip" from PATH as last resort
- If pyproject.toml exists: pip install .
- If requirements.txt exists: pip install -r requirements.txt
All installation failures are treated as fatal errors (SystemExit).
"""
@@ -25,17 +24,18 @@ from pkgmgr.run_command import run_command
class PythonInstaller(BaseInstaller):
"""Install Python projects and dependencies via pip."""
name = "python"
# Logical layer name, used by capability matchers.
layer = "python"
def supports(self, ctx) -> bool:
"""
Return True if this installer should handle the given repository.
Only pyproject.toml is supported as the single source of truth
for Python dependencies and packaging metadata.
"""
repo_dir = ctx.repo_dir
return (
os.path.exists(os.path.join(repo_dir, "pyproject.toml"))
or os.path.exists(os.path.join(repo_dir, "requirements.txt"))
)
return os.path.exists(os.path.join(repo_dir, "pyproject.toml"))
def _pip_cmd(self) -> str:
"""
@@ -52,7 +52,7 @@ class PythonInstaller(BaseInstaller):
def run(self, ctx) -> None:
"""
Install Python project (pyproject.toml) and/or requirements.txt.
Install Python project defined via pyproject.toml.
Any pip failure is propagated as SystemExit.
"""
@@ -66,12 +66,3 @@ class PythonInstaller(BaseInstaller):
)
cmd = f"{pip_cmd} install ."
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
req_txt = os.path.join(ctx.repo_dir, "requirements.txt")
if os.path.exists(req_txt):
print(
f"requirements.txt found in {ctx.identifier}, "
f"installing Python dependencies..."
)
cmd = f"{pip_cmd} install -r requirements.txt"
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)