diff --git a/Dockerfile b/Dockerfile index ea30d59..bd0f299 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,8 +8,6 @@ RUN pacman -Syu --noconfirm \ nix \ && pacman -Scc --noconfirm -ENV NIX_CONFIG="experimental-features = nix-command flakes" - # 2) Unprivileged user for building Arch packages RUN useradd -m builder WORKDIR /build diff --git a/PKGBUILD b/PKGBUILD index e9a4fe2..cb91300 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -3,31 +3,72 @@ pkgname=package-manager pkgver=0.1.1 pkgrel=1 -pkgdesc="Wrapper that runs Kevin's package-manager via Nix flake." +pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)." arch=('any') url="https://github.com/kevinveenbirkenbach/package-manager" license=('MIT') +# Nix is the only runtime dependency; Python is provided by the Nix closure. depends=('nix') +makedepends=('rsync') install=${pkgname}.install -source=('scripts/pkgmgr-wrapper.sh' - 'scripts/init-nix.sh') -sha256sums=('SKIP' - 'SKIP') +# Local source checkout — avoids the tarball requirement. +# This assumes you build the package from inside the main project repository. +source=( + "scripts/pkgmgr-wrapper.sh" + "scripts/init-nix.sh" +) + +sha256sums=('SKIP' 'SKIP') + +# Local source directory name under $srcdir +_srcdir_name="source" + +prepare() { + mkdir -p "$srcdir/$_srcdir_name" + + # Copy the full local tree into $srcdir/source, + # but avoid makepkg's own directories and the VCS metadata. + rsync -a \ + --exclude="src" \ + --exclude="pkg" \ + --exclude=".git" \ + "$startdir/" "$srcdir/$_srcdir_name/" +} build() { + cd "$srcdir/$_srcdir_name" : } package() { - install -d "$pkgdir/usr/bin" - install -d "$pkgdir/usr/lib/package-manager" + cd "$srcdir/$_srcdir_name" - # Wrapper - install -m0755 "scripts/pkgmgr-wrapper.sh" "$pkgdir/usr/bin/pkgmgr" + # Install the wrapper into /usr/bin + install -Dm0755 "scripts/pkgmgr-wrapper.sh" \ + "$pkgdir/usr/bin/pkgmgr" - # Shared Nix init script - install -m0755 "scripts/init-nix.sh" "$pkgdir/usr/lib/package-manager/init-nix.sh" + # Install Nix init helper + install -Dm0755 "scripts/init-nix.sh" \ + "$pkgdir/usr/lib/package-manager/init-nix.sh" + + # Install the full repository into /usr/lib/package-manager + mkdir -p "$pkgdir/usr/lib/package-manager" + + # Copy entire project tree from our local source checkout + cp -a . "$pkgdir/usr/lib/package-manager/" + + # Remove packaging-only and development artefacts from the installed tree + rm -rf \ + "$pkgdir/usr/lib/package-manager/.git" \ + "$pkgdir/usr/lib/package-manager/.github" \ + "$pkgdir/usr/lib/package-manager/tests" \ + "$pkgdir/usr/lib/package-manager/PKGBUILD" \ + "$pkgdir/usr/lib/package-manager/Dockerfile" \ + "$pkgdir/usr/lib/package-manager/debian" \ + "$pkgdir/usr/lib/package-manager/.gitignore" \ + "$pkgdir/usr/lib/package-manager/__pycache__" \ + "$pkgdir/usr/lib/package-manager/.gitkeep" } diff --git a/debian/control b/debian/control index 43b8757..04e9796 100644 --- a/debian/control +++ b/debian/control @@ -12,7 +12,7 @@ Architecture: any Depends: nix, ${misc:Depends} Description: Wrapper that runs Kevin's package-manager via Nix flake This package provides the `pkgmgr` command, which runs Kevin's package - manager via a Nix flake: - nix run "github:kevinveenbirkenbach/package-manager#pkgmgr" -- ... - Nix is used as the only runtime dependency and must be initialized on + manager via a local Nix flake + nix run /usr/lib/package-manager#pkgmgr -- ... + Nix is the only runtime dependency and must be initialized on the system to work correctly. diff --git a/debian/rules b/debian/rules index eeb72d1..cb7e642 100755 --- a/debian/rules +++ b/debian/rules @@ -4,7 +4,33 @@ dh $@ override_dh_auto_install: + # Create target directories + install -d debian/package-manager/usr/bin + install -d debian/package-manager/usr/lib/package-manager + # Install wrapper - install -D -m0755 scripts/pkgmgr-wrapper.sh debian/package-manager/usr/bin/pkgmgr + install -m0755 scripts/pkgmgr-wrapper.sh \ + debian/package-manager/usr/bin/pkgmgr + # Install shared Nix init script - install -D -m0755 scripts/init-nix.sh debian/package-manager/usr/lib/package-manager/init-nix.sh + install -m0755 scripts/init-nix.sh \ + debian/package-manager/usr/lib/package-manager/init-nix.sh + + # Copy full project source into /usr/lib/package-manager, + # but do not include the debian/ directory itself. + find . -mindepth 1 -maxdepth 1 \ + ! -name debian \ + ! -name .git \ + -exec cp -a {} debian/package-manager/usr/lib/package-manager/ \; + + # Remove packaging-only and development artefacts from the installed tree + rm -rf \ + debian/package-manager/usr/lib/package-manager/debian \ + debian/package-manager/usr/lib/package-manager/PKGBUILD \ + debian/package-manager/usr/lib/package-manager/Dockerfile \ + debian/package-manager/usr/lib/package-manager/.git \ + debian/package-manager/usr/lib/package-manager/.github \ + debian/package-manager/usr/lib/package-manager/tests \ + debian/package-manager/usr/lib/package-manager/.gitignore \ + debian/package-manager/usr/lib/package-manager/__pycache__ \ + debian/package-manager/usr/lib/package-manager/.gitkeep || true diff --git a/flake.nix b/flake.nix index dec290b..6734a13 100644 --- a/flake.nix +++ b/flake.nix @@ -13,14 +13,14 @@ let systems = [ "x86_64-linux" "aarch64-linux" ]; - # Small helper: build an attrset for all systems + # Helper to build an attribute set for all target systems forAllSystems = f: builtins.listToAttrs (map (system: { name = system; value = f system; }) systems); in { - # Dev shells: nix develop .#default (on both architectures) + # Development shells: `nix develop .#default` devShells = forAllSystems (system: let pkgs = nixpkgs.legacyPackages.${system}; @@ -28,13 +28,13 @@ # Base Python interpreter python = pkgs.python311; - # Python env with pip + pyyaml available, so `python -m pip` works + # Python environment with pip + PyYAML so `python -m pip` works pythonEnv = python.withPackages (ps: with ps; [ pip pyyaml ]); - # Be robust: ansible-core if available, otherwise ansible. + # Be robust: use ansible-core if available, otherwise ansible ansiblePkg = if pkgs ? ansible-core then pkgs.ansible-core else pkgs.ansible; @@ -52,12 +52,13 @@ } ); + # Packages: `nix build .#pkgmgr` or `nix build .#default` packages = forAllSystems (system: let pkgs = nixpkgs.legacyPackages.${system}; python = pkgs.python311; - # Runtime Python for pkgmgr (with pip + pyyaml) + # Runtime Python for pkgmgr (with pip + PyYAML) pythonEnv = python.withPackages (ps: with ps; [ pip pyyaml @@ -73,40 +74,49 @@ pname = "package-manager"; version = "0.1.1"; + # Use the current repository as the source src = ./.; - # Nix should not run configure / build (no make) + # No traditional configure/build steps dontConfigure = true; dontBuild = true; - # Runtime deps: Python (with pip) + Ansible + # Runtime dependencies: Python (with pip + PyYAML) + Ansible buildInputs = [ pythonEnv ansiblePkg ]; installPhase = '' - mkdir -p "$out/bin" + mkdir -p "$out/bin" "$out/lib/package-manager" - # Wrapper that always uses the pythonEnv interpreter, so - # sys.executable -m pip has a working pip. - cat > "$out/bin/pkgmgr" << EOF + # Copy the full project tree into the runtime closure + cp -a . "$out/lib/package-manager/" + + # Wrapper that runs main.py from the copied tree, + # using the pythonEnv interpreter. + cat > "$out/bin/pkgmgr" << 'EOF' #!${pythonEnv}/bin/python3 +import os import runpy + if __name__ == "__main__": - runpy.run_module("main", run_name="__main__") + base_dir = os.path.join(os.path.dirname(__file__), "..", "lib", "package-manager") + main_path = os.path.join(base_dir, "main.py") + os.chdir(base_dir) + runpy.run_path(main_path, run_name="__main__") EOF chmod +x "$out/bin/pkgmgr" ''; }; - # default package just points to pkgmgr + # Default package points to pkgmgr default = pkgmgr; } ); - # Apps: nix run .#pkgmgr / .#default + # Apps: `nix run .#pkgmgr` or `nix run .#default` apps = forAllSystems (system: let pkgmgrPkg = self.packages.${system}.pkgmgr; diff --git a/package-manager.spec b/package-manager.spec index 7288dbe..dfb8fa5 100644 --- a/package-manager.spec +++ b/package-manager.spec @@ -12,9 +12,9 @@ Requires: nix %description This package provides the `pkgmgr` command, which runs Kevin's package -manager via a Nix flake: +manager via a local Nix flake: - nix run "github:kevinveenbirkenbach/package-manager#pkgmgr" -- ... + nix run /usr/lib/package-manager#pkgmgr -- ... Nix is the only runtime dependency and must be initialized on the system to work correctly. @@ -31,12 +31,27 @@ rm -rf %{buildroot} install -d %{buildroot}%{_bindir} install -d %{buildroot}%{_libdir}/package-manager +# Copy full project source into /usr/lib/package-manager +cp -a . %{buildroot}%{_libdir}/package-manager/ + # Wrapper install -m0755 scripts/pkgmgr-wrapper.sh %{buildroot}%{_bindir}/pkgmgr -# Shared Nix init script +# Shared Nix init script (ensure it is executable in the installed tree) install -m0755 scripts/init-nix.sh %{buildroot}%{_libdir}/package-manager/init-nix.sh +# Remove packaging-only and development artefacts from the installed tree +rm -rf \ + %{buildroot}%{_libdir}/package-manager/PKGBUILD \ + %{buildroot}%{_libdir}/package-manager/Dockerfile \ + %{buildroot}%{_libdir}/package-manager/debian \ + %{buildroot}%{_libdir}/package-manager/.git \ + %{buildroot}%{_libdir}/package-manager/.github \ + %{buildroot}%{_libdir}/package-manager/tests \ + %{buildroot}%{_libdir}/package-manager/.gitignore \ + %{buildroot}%{_libdir}/package-manager/__pycache__ \ + %{buildroot}%{_libdir}/package-manager/.gitkeep || true + %post if [ -x %{_libdir}/package-manager/init-nix.sh ]; then %{_libdir}/package-manager/init-nix.sh || true @@ -51,7 +66,7 @@ echo ">>> package-manager removed. Nix itself was not removed." %doc README.md %license LICENSE %{_bindir}/pkgmgr -%{_libdir}/package-manager/init-nix.sh +%{_libdir}/package-manager/ %changelog * Sat Dec 06 2025 Kevin Veen-Birkenbach - 0.1.1-1 diff --git a/pkgmgr/capabilities.py b/pkgmgr/capabilities.py new file mode 100644 index 0000000..6463453 --- /dev/null +++ b/pkgmgr/capabilities.py @@ -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(), +] diff --git a/pkgmgr/install_repos.py b/pkgmgr/install_repos.py index 10c45a9..76aaca9 100644 --- a/pkgmgr/install_repos.py +++ b/pkgmgr/install_repos.py @@ -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) diff --git a/pkgmgr/installers/__init__.py b/pkgmgr/installers/__init__.py index e0126bc..e9a6fd3 100644 --- a/pkgmgr/installers/__init__.py +++ b/pkgmgr/installers/__init__.py @@ -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 diff --git a/pkgmgr/installers/base.py b/pkgmgr/installers/base.py index 933af93..f50d53a 100644 --- a/pkgmgr/installers/base.py +++ b/pkgmgr/installers/base.py @@ -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: """ diff --git a/pkgmgr/installers/makefile.py b/pkgmgr/installers/makefile.py index d0fbb80..89e3e5f 100644 --- a/pkgmgr/installers/makefile.py +++ b/pkgmgr/installers/makefile.py @@ -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: diff --git a/pkgmgr/installers/nix_flake.py b/pkgmgr/installers/nix_flake.py index 5ed0453..646af28 100644 --- a/pkgmgr/installers/nix_flake.py +++ b/pkgmgr/installers/nix_flake.py @@ -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" diff --git a/pkgmgr/installers/os_packages/arch_pkgbuild.py b/pkgmgr/installers/os_packages/arch_pkgbuild.py index f4040fe..058924d 100644 --- a/pkgmgr/installers/os_packages/arch_pkgbuild.py +++ b/pkgmgr/installers/os_packages/arch_pkgbuild.py @@ -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) diff --git a/pkgmgr/installers/os_packages/debian_control.py b/pkgmgr/installers/os_packages/debian_control.py index 49249b4..6e48165 100644 --- a/pkgmgr/installers/os_packages/debian_control.py +++ b/pkgmgr/installers/os_packages/debian_control.py @@ -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) diff --git a/pkgmgr/installers/os_packages/rpm_spec.py b/pkgmgr/installers/os_packages/rpm_spec.py index ef47575..ebd3707 100644 --- a/pkgmgr/installers/os_packages/rpm_spec.py +++ b/pkgmgr/installers/os_packages/rpm_spec.py @@ -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 + 2. Else if yum-builddep is available: + sudo yum-builddep -y + 3. Else if yum is available: + sudo yum-builddep -y # 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 (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) diff --git a/pkgmgr/installers/pkgmgr_manifest.py b/pkgmgr/installers/pkgmgr_manifest.py deleted file mode 100644 index 9c491c7..0000000 --- a/pkgmgr/installers/pkgmgr_manifest.py +++ /dev/null @@ -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) diff --git a/pkgmgr/installers/python.py b/pkgmgr/installers/python.py index 03ba22c..cfe58a8 100644 --- a/pkgmgr/installers/python.py +++ b/pkgmgr/installers/python.py @@ -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) diff --git a/post_install() b/post_install() deleted file mode 100644 index 7f45894..0000000 --- a/post_install() +++ /dev/null @@ -1,19 +0,0 @@ -post_install() { - echo ">>> Initializing Nix for this system..." - - # Create /nix if missing - if [ ! -d /nix ]; then - sudo mkdir -m 0755 /nix - sudo chown root:root /nix - fi - - # Enable Nix daemon - if systemctl list-unit-files | grep -q nix-daemon.service; then - sudo systemctl enable --now nix-daemon.service - fi - - # Add user to nix-users - if id -u kevinveenbirkenbach >/dev/null 2>&1; then - sudo usermod -aG nix-users kevinveenbirkenbach - fi -} diff --git a/scripts/init-nix.sh b/scripts/init-nix.sh index b092054..4ad7086 100644 --- a/scripts/init-nix.sh +++ b/scripts/init-nix.sh @@ -3,14 +3,14 @@ set -euo pipefail echo ">>> Initializing Nix environment for package-manager..." -# 1. /nix Store +# 1. /nix store if [ ! -d /nix ]; then echo ">>> Creating /nix store directory" mkdir -m 0755 /nix chown root:root /nix fi -# 2. nix-daemon aktivieren, falls vorhanden +# 2. Enable nix-daemon if available if command -v systemctl >/dev/null 2>&1 && systemctl list-unit-files | grep -q nix-daemon.service; then echo ">>> Enabling nix-daemon.service" systemctl enable --now nix-daemon.service 2>/dev/null || true @@ -18,16 +18,13 @@ else echo ">>> Warning: nix-daemon.service not found or systemctl not available." fi -# 3. Gruppe nix-users sicherstellen +# 3. Ensure nix-users group if ! getent group nix-users >/dev/null 2>&1; then echo ">>> Creating nix-users group" - # Debian/RPM/Arch haben alle groupadd groupadd -r nix-users 2>/dev/null || true fi -# 4. Benutzer zu nix-users hinzufügen (Best-Effort) - -# a) Wenn loginctl vorhanden ist → alle eingeloggten User +# 4. Add users to nix-users (best-effort) if command -v loginctl >/dev/null 2>&1; then for user in $(loginctl list-users | awk 'NR>1 {print $2}'); do if id "$user" >/dev/null 2>&1; then @@ -35,7 +32,6 @@ if command -v loginctl >/dev/null 2>&1; then usermod -aG nix-users "$user" 2>/dev/null || true fi done -# b) Fallback: logname (typisch bei Debian) elif command -v logname >/dev/null 2>&1; then USERNAME="$(logname 2>/dev/null || true)" if [ -n "$USERNAME" ] && id "$USERNAME" >/dev/null 2>&1; then @@ -44,5 +40,5 @@ elif command -v logname >/dev/null 2>&1; then fi fi -echo ">>> Nix initialization for package-manager complete." +echo ">>> Nix initialization complete." echo ">>> You may need to log out and log back in to activate group membership." diff --git a/scripts/pkgmgr-wrapper.sh b/scripts/pkgmgr-wrapper.sh index bed6ef5..52179d7 100644 --- a/scripts/pkgmgr-wrapper.sh +++ b/scripts/pkgmgr-wrapper.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash set -euo pipefail -# Enable flakes if not already configured. if [[ -z "${NIX_CONFIG:-}" ]]; then export NIX_CONFIG="experimental-features = nix-command flakes" fi -# Run Kevin’s package manager via Nix flake -exec nix run "github:kevinveenbirkenbach/package-manager#pkgmgr" -- "$@" +FLAKE_DIR="/usr/lib/package-manager" + +exec nix run "${FLAKE_DIR}#pkgmgr" -- "$@" diff --git a/tests/integration/test_integration_install_pkgmgr_shallow.py b/tests/integration/test_integration_install_pkgmgr_shallow.py index 28e0e18..a7d9525 100644 --- a/tests/integration/test_integration_install_pkgmgr_shallow.py +++ b/tests/integration/test_integration_install_pkgmgr_shallow.py @@ -1,5 +1,6 @@ import runpy import sys +import os import unittest import subprocess @@ -34,7 +35,7 @@ def remove_pkgmgr_from_nix_profile() -> None: prints a descriptive format without an index column inside the container. Instead, we directly try to remove possible names: - - 'pkgmgr' (the actual name shown in `nix profile list`) + - 'pkgmgr' (the actual name shown in `nix profile list`) - 'package-manager' (the name mentioned in Nix's own error hints) """ for spec in ("pkgmgr", "package-manager"): @@ -44,6 +45,44 @@ def remove_pkgmgr_from_nix_profile() -> None: ) +def pkgmgr_help_debug() -> None: + """ + Run `pkgmgr --help` after installation *inside an interactive bash shell*, + print its output and return code, but never fail the test. + + Reason: + - The installer adds venv/alias setup into shell rc files (~/.bashrc, ~/.zshrc) + - Those changes are only applied in a new interactive shell session. + """ + print("\n--- PKGMGR HELP (after installation, via bash -i) ---") + + # Simulate a fresh interactive bash, so ~/.bashrc gets sourced + proc = subprocess.run( + ["bash", "-i", "-c", "pkgmgr --help"], + capture_output=True, + text=True, + check=False, + env=os.environ.copy(), + ) + + stdout = proc.stdout.strip() + stderr = proc.stderr.strip() + + if stdout: + print(stdout) + if stderr: + print("stderr:", stderr) + + print(f"returncode: {proc.returncode}") + print("--- END ---\n") + + # Wichtig: Hier KEIN AssertionError mehr – das ist reine Debug-Ausgabe. + # Falls du später hart testen willst, kannst du optional: + # if proc.returncode != 0: + # self.fail("...") + # aber aktuell nur Sichtprüfung. + + class TestIntegrationInstalPKGMGRShallow(unittest.TestCase): def test_install_pkgmgr_self_install(self) -> None: # Debug before cleanup @@ -65,7 +104,11 @@ class TestIntegrationInstalPKGMGRShallow(unittest.TestCase): "shallow", "--no-verification", ] + # Führt die Installation via main.py aus runpy.run_module("main", run_name="__main__") + + # Nach erfolgreicher Installation: pkgmgr --help anzeigen (Debug) + pkgmgr_help_debug() finally: sys.argv = original_argv diff --git a/tests/unit/pkgmgr/installers/os_packages/test_arch_pkgbuild.py b/tests/unit/pkgmgr/installers/os_packages/test_arch_pkgbuild.py index e0a586a..5359f86 100644 --- a/tests/unit/pkgmgr/installers/os_packages/test_arch_pkgbuild.py +++ b/tests/unit/pkgmgr/installers/os_packages/test_arch_pkgbuild.py @@ -26,39 +26,69 @@ class TestArchPkgbuildInstaller(unittest.TestCase): ) self.installer = ArchPkgbuildInstaller() + @patch("pkgmgr.installers.os_packages.arch_pkgbuild.os.geteuid", return_value=1000) @patch("os.path.exists", return_value=True) - @patch("shutil.which", return_value="/usr/bin/pacman") - def test_supports_true_when_pacman_and_pkgbuild_exist(self, mock_which, mock_exists): + @patch("shutil.which") + def test_supports_true_when_tools_and_pkgbuild_exist( + self, mock_which, mock_exists, mock_geteuid + ): + def which_side_effect(name): + if name in ("pacman", "makepkg"): + return f"/usr/bin/{name}" + return None + + mock_which.side_effect = which_side_effect + self.assertTrue(self.installer.supports(self.ctx)) - mock_which.assert_called_with("pacman") + + calls = [c.args[0] for c in mock_which.call_args_list] + self.assertIn("pacman", calls) + self.assertIn("makepkg", calls) mock_exists.assert_called_with(os.path.join(self.ctx.repo_dir, "PKGBUILD")) + @patch("pkgmgr.installers.os_packages.arch_pkgbuild.os.geteuid", return_value=0) + @patch("os.path.exists", return_value=True) + @patch("shutil.which") + def test_supports_false_when_running_as_root( + self, mock_which, mock_exists, mock_geteuid + ): + mock_which.return_value = "/usr/bin/pacman" + self.assertFalse(self.installer.supports(self.ctx)) + + @patch("pkgmgr.installers.os_packages.arch_pkgbuild.os.geteuid", return_value=1000) @patch("os.path.exists", return_value=False) - @patch("shutil.which", return_value="/usr/bin/pacman") - def test_supports_false_when_pkgbuild_missing(self, mock_which, mock_exists): + @patch("shutil.which") + def test_supports_false_when_pkgbuild_missing( + self, mock_which, mock_exists, mock_geteuid + ): + mock_which.return_value = "/usr/bin/pacman" self.assertFalse(self.installer.supports(self.ctx)) @patch("pkgmgr.installers.os_packages.arch_pkgbuild.run_command") - @patch("subprocess.check_output", return_value="python\ngit\n") + @patch("pkgmgr.installers.os_packages.arch_pkgbuild.os.geteuid", return_value=1000) @patch("os.path.exists", return_value=True) - @patch("shutil.which", return_value="/usr/bin/pacman") - def test_run_installs_all_packages_and_uses_clean_bash( - self, mock_which, mock_exists, mock_check_output, mock_run_command + @patch("shutil.which") + def test_run_builds_and_installs_with_makepkg( + self, mock_which, mock_exists, mock_geteuid, mock_run_command ): + def which_side_effect(name): + if name in ("pacman", "makepkg"): + return f"/usr/bin/{name}" + return None + + mock_which.side_effect = which_side_effect + self.installer.run(self.ctx) - # subprocess.check_output call - args, kwargs = mock_check_output.call_args - cmd_list = args[0] - self.assertEqual(cmd_list[0], "bash") - self.assertIn("--noprofile", cmd_list) - self.assertIn("--norc", cmd_list) - - # pacman install command cmd = mock_run_command.call_args[0][0] - self.assertTrue(cmd.startswith("sudo pacman -S --noconfirm ")) - self.assertIn("python", cmd) - self.assertIn("git", cmd) + self.assertEqual( + cmd, + "makepkg --syncdeps --cleanbuild --install --noconfirm", + ) + self.assertEqual( + mock_run_command.call_args[1].get("cwd"), + self.ctx.repo_dir, + ) if __name__ == "__main__": diff --git a/tests/unit/pkgmgr/installers/os_packages/test_debian_control.py b/tests/unit/pkgmgr/installers/os_packages/test_debian_control.py index af079fe..f2cea5b 100644 --- a/tests/unit/pkgmgr/installers/os_packages/test_debian_control.py +++ b/tests/unit/pkgmgr/installers/os_packages/test_debian_control.py @@ -1,7 +1,8 @@ # tests/unit/pkgmgr/installers/os_packages/test_debian_control.py +import os import unittest -from unittest.mock import patch, mock_open +from unittest.mock import patch from pkgmgr.context import RepoContext from pkgmgr.installers.os_packages.debian_control import DebianControlInstaller @@ -26,40 +27,53 @@ class TestDebianControlInstaller(unittest.TestCase): self.installer = DebianControlInstaller() @patch("os.path.exists", return_value=True) - @patch("shutil.which", return_value="/usr/bin/apt-get") + @patch("shutil.which", return_value="/usr/bin/dpkg-buildpackage") def test_supports_true(self, mock_which, mock_exists): self.assertTrue(self.installer.supports(self.ctx)) @patch("os.path.exists", return_value=True) @patch("shutil.which", return_value=None) - def test_supports_false_without_apt(self, mock_which, mock_exists): + def test_supports_false_without_dpkg_buildpackage(self, mock_which, mock_exists): self.assertFalse(self.installer.supports(self.ctx)) @patch("pkgmgr.installers.os_packages.debian_control.run_command") - @patch("builtins.open", new_callable=mock_open, read_data=""" -Build-Depends: python3, git (>= 2.0) -Depends: curl | wget -""") + @patch("glob.glob", return_value=["/tmp/package-manager_0.1.1_all.deb"]) @patch("os.path.exists", return_value=True) - @patch("shutil.which", return_value="/usr/bin/apt-get") - def test_run_installs_parsed_packages( + @patch("shutil.which") + def test_run_builds_and_installs_debs( self, mock_which, mock_exists, - mock_file, - mock_run_command + mock_glob, + mock_run_command, ): + # dpkg-buildpackage + apt-get vorhanden + def which_side_effect(name): + if name == "dpkg-buildpackage": + return "/usr/bin/dpkg-buildpackage" + if name == "apt-get": + return "/usr/bin/apt-get" + return None + + mock_which.side_effect = which_side_effect + self.installer.run(self.ctx) - # First call: apt-get update - self.assertIn("apt-get update", mock_run_command.call_args_list[0][0][0]) + cmds = [c[0][0] for c in mock_run_command.call_args_list] - # Second call: install packages - install_cmd = mock_run_command.call_args_list[1][0][0] - self.assertIn("apt-get install -y", install_cmd) - self.assertIn("python3", install_cmd) - self.assertIn("git", install_cmd) - self.assertIn("curl", install_cmd) + # 1) apt-get update + self.assertTrue(any("apt-get update" in cmd for cmd in cmds)) + + # 2) apt-get build-dep ./ + self.assertTrue(any("apt-get build-dep -y ./ " in cmd or + "apt-get build-dep -y ./" + in cmd for cmd in cmds)) + + # 3) dpkg-buildpackage -b -us -uc + self.assertTrue(any("dpkg-buildpackage -b -us -uc" in cmd for cmd in cmds)) + + # 4) dpkg -i ../*.deb + self.assertTrue(any(cmd.startswith("sudo dpkg -i ") for cmd in cmds)) if __name__ == "__main__": diff --git a/tests/unit/pkgmgr/installers/os_packages/test_rpm_spec.py b/tests/unit/pkgmgr/installers/os_packages/test_rpm_spec.py index 467ce9b..09f5dda 100644 --- a/tests/unit/pkgmgr/installers/os_packages/test_rpm_spec.py +++ b/tests/unit/pkgmgr/installers/os_packages/test_rpm_spec.py @@ -1,7 +1,7 @@ # tests/unit/pkgmgr/installers/os_packages/test_rpm_spec.py import unittest -from unittest.mock import patch, mock_open +from unittest.mock import patch from pkgmgr.context import RepoContext from pkgmgr.installers.os_packages.rpm_spec import RpmSpecInstaller @@ -26,34 +26,67 @@ class TestRpmSpecInstaller(unittest.TestCase): self.installer = RpmSpecInstaller() @patch("glob.glob", return_value=["/tmp/repo/test.spec"]) - @patch("shutil.which", return_value="/usr/bin/dnf") + @patch("shutil.which") def test_supports_true(self, mock_which, mock_glob): + def which_side_effect(name): + if name == "rpmbuild": + return "/usr/bin/rpmbuild" + if name == "dnf": + return "/usr/bin/dnf" + return None + + mock_which.side_effect = which_side_effect + self.assertTrue(self.installer.supports(self.ctx)) @patch("glob.glob", return_value=[]) - @patch("shutil.which", return_value="/usr/bin/dnf") + @patch("shutil.which") def test_supports_false_missing_spec(self, mock_which, mock_glob): + mock_which.return_value = "/usr/bin/rpmbuild" self.assertFalse(self.installer.supports(self.ctx)) @patch("pkgmgr.installers.os_packages.rpm_spec.run_command") - @patch("builtins.open", new_callable=mock_open, read_data=""" -BuildRequires: python3-devel, git >= 2.0 -Requires: curl -""") - @patch("glob.glob", return_value=["/tmp/repo/test.spec"]) - @patch("shutil.which", return_value="/usr/bin/dnf") - @patch("os.path.exists", return_value=True) - def test_run_installs_parsed_dependencies( - self, mock_exists, mock_which, mock_glob, mock_file, mock_run_command + @patch("glob.glob") + @patch("shutil.which") + def test_run_builds_and_installs_rpms( + self, + mock_which, + mock_glob, + mock_run_command, ): + # glob.glob wird zweimal benutzt: einmal für *.spec, einmal für gebaute RPMs + def glob_side_effect(pattern, recursive=False): + if pattern.endswith("*.spec"): + return ["/tmp/repo/package-manager.spec"] + if "rpmbuild/RPMS" in pattern: + return ["/home/user/rpmbuild/RPMS/x86_64/package-manager-0.1.1.rpm"] + return [] + + mock_glob.side_effect = glob_side_effect + + def which_side_effect(name): + if name == "rpmbuild": + return "/usr/bin/rpmbuild" + if name == "dnf": + return "/usr/bin/dnf" + if name == "rpm": + return "/usr/bin/rpm" + return None + + mock_which.side_effect = which_side_effect + self.installer.run(self.ctx) - install_cmd = mock_run_command.call_args_list[0][0][0] + cmds = [c[0][0] for c in mock_run_command.call_args_list] - self.assertIn("dnf install -y", install_cmd) - self.assertIn("python3-devel", install_cmd) - self.assertIn("git", install_cmd) - self.assertIn("curl", install_cmd) + # 1) builddep + self.assertTrue(any("builddep -y" in cmd for cmd in cmds)) + + # 2) rpmbuild -ba + self.assertTrue(any(cmd.startswith("rpmbuild -ba ") for cmd in cmds)) + + # 3) rpm -i … + self.assertTrue(any(cmd.startswith("sudo rpm -i ") for cmd in cmds)) if __name__ == "__main__": diff --git a/tests/unit/pkgmgr/installers/test_pkgmgr_manifest.py b/tests/unit/pkgmgr/installers/test_pkgmgr_manifest.py deleted file mode 100644 index 6980f50..0000000 --- a/tests/unit/pkgmgr/installers/test_pkgmgr_manifest.py +++ /dev/null @@ -1,87 +0,0 @@ -# tests/unit/pkgmgr/installers/test_pkgmgr_manifest.py - -import os -import unittest -from unittest.mock import patch, mock_open - -from pkgmgr.context import RepoContext -from pkgmgr.installers.pkgmgr_manifest import PkgmgrManifestInstaller - - -class TestPkgmgrManifestInstaller(unittest.TestCase): - def setUp(self): - self.repo = {"name": "test-repo"} - self.ctx = RepoContext( - repo=self.repo, - identifier="test-id", - repo_dir="/tmp/repo", - repositories_base_dir="/tmp", - bin_dir="/bin", - all_repos=[self.repo], - no_verification=False, - preview=False, - quiet=False, - clone_mode="ssh", - update_dependencies=True, - ) - self.installer = PkgmgrManifestInstaller() - - @patch("os.path.exists", return_value=True) - def test_supports_true_when_manifest_exists(self, mock_exists): - self.assertTrue(self.installer.supports(self.ctx)) - manifest_path = os.path.join(self.ctx.repo_dir, "pkgmgr.yml") - mock_exists.assert_called_with(manifest_path) - - @patch("os.path.exists", return_value=False) - def test_supports_false_when_manifest_missing(self, mock_exists): - self.assertFalse(self.installer.supports(self.ctx)) - - @patch("pkgmgr.installers.pkgmgr_manifest.run_command") - @patch("builtins.open", new_callable=mock_open, read_data=""" -version: 1 -author: "Kevin" -url: "https://example.com" -description: "Test repo" -dependencies: - - repository: github:user/repo1 - version: main - reason: "Core dependency" - - repository: github:user/repo2 -""") - @patch("os.path.exists", return_value=True) - def test_run_installs_dependencies_and_pulls_when_update_enabled( - self, mock_exists, mock_file, mock_run_command - ): - self.installer.run(self.ctx) - - # First call: pkgmgr pull github:user/repo1 github:user/repo2 - # Then calls to pkgmgr install ... - cmds = [call_args[0][0] for call_args in mock_run_command.call_args_list] - - self.assertIn( - "pkgmgr pull github:user/repo1 github:user/repo2", - cmds, - ) - self.assertIn( - "pkgmgr install github:user/repo1 --version main --dependencies --clone-mode ssh", - cmds, - ) - # For repo2: no version but dependencies + clone_mode - self.assertIn( - "pkgmgr install github:user/repo2 --dependencies --clone-mode ssh", - cmds, - ) - - @patch("pkgmgr.installers.pkgmgr_manifest.run_command") - @patch("builtins.open", new_callable=mock_open, read_data="{}") - @patch("os.path.exists", return_value=True) - def test_run_no_dependencies_no_command_called( - self, mock_exists, mock_file, mock_run_command - ): - self.ctx.update_dependencies = True - self.installer.run(self.ctx) - mock_run_command.assert_not_called() - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/pkgmgr/installers/test_python_installer.py b/tests/unit/pkgmgr/installers/test_python_installer.py index a0f05d9..7c6186c 100644 --- a/tests/unit/pkgmgr/installers/test_python_installer.py +++ b/tests/unit/pkgmgr/installers/test_python_installer.py @@ -30,19 +30,12 @@ class TestPythonInstaller(unittest.TestCase): def test_supports_true_when_pyproject_exists(self, mock_exists): self.assertTrue(self.installer.supports(self.ctx)) - @patch("os.path.exists", side_effect=lambda path: path.endswith("requirements.txt")) - def test_supports_true_when_requirements_exists(self, mock_exists): - self.assertTrue(self.installer.supports(self.ctx)) - @patch("os.path.exists", return_value=False) - def test_supports_false_when_no_python_files(self, mock_exists): + def test_supports_false_when_no_pyproject(self, mock_exists): self.assertFalse(self.installer.supports(self.ctx)) @patch("pkgmgr.installers.python.run_command") - @patch( - "os.path.exists", - side_effect=lambda path: path.endswith("pyproject.toml") - ) + @patch("os.path.exists", side_effect=lambda path: path.endswith("pyproject.toml")) def test_run_installs_project_from_pyproject(self, mock_exists, mock_run_command): self.installer.run(self.ctx) cmd = mock_run_command.call_args[0][0] @@ -52,20 +45,6 @@ class TestPythonInstaller(unittest.TestCase): self.ctx.repo_dir, ) - @patch("pkgmgr.installers.python.run_command") - @patch( - "os.path.exists", - side_effect=lambda path: path.endswith("requirements.txt") - ) - def test_run_installs_dependencies_from_requirements(self, mock_exists, mock_run_command): - self.installer.run(self.ctx) - cmd = mock_run_command.call_args[0][0] - self.assertIn("pip install -r requirements.txt", cmd) - self.assertEqual( - mock_run_command.call_args[1].get("cwd"), - self.ctx.repo_dir, - ) - if __name__ == "__main__": unittest.main() diff --git a/tests/unit/pkgmgr/test_capabilities.py b/tests/unit/pkgmgr/test_capabilities.py new file mode 100644 index 0000000..bdceb80 --- /dev/null +++ b/tests/unit/pkgmgr/test_capabilities.py @@ -0,0 +1,76 @@ +# tests/unit/pkgmgr/test_capabilities.py + +import os +import unittest +from unittest.mock import patch, mock_open + +from pkgmgr.capabilities import ( + PythonRuntimeCapability, + MakeInstallCapability, + NixFlakeCapability, +) + + +class DummyCtx: + """Minimal RepoContext stub with just repo_dir.""" + def __init__(self, repo_dir: str): + self.repo_dir = repo_dir + + +class TestCapabilities(unittest.TestCase): + def setUp(self): + self.ctx = DummyCtx("/tmp/repo") + + @patch("pkgmgr.capabilities.os.path.exists") + def test_python_runtime_python_layer_pyproject(self, mock_exists): + cap = PythonRuntimeCapability() + + def exists_side_effect(path): + return path.endswith("pyproject.toml") + + mock_exists.side_effect = exists_side_effect + + self.assertTrue(cap.applies_to_layer("python")) + self.assertTrue(cap.is_provided(self.ctx, "python")) + + @patch("pkgmgr.capabilities._read_text_if_exists") + @patch("pkgmgr.capabilities.os.path.exists") + def test_python_runtime_nix_layer_flake(self, mock_exists, mock_read): + cap = PythonRuntimeCapability() + + def exists_side_effect(path): + return path.endswith("flake.nix") + + mock_exists.side_effect = exists_side_effect + mock_read.return_value = "buildPythonApplication something" + + self.assertTrue(cap.applies_to_layer("nix")) + self.assertTrue(cap.is_provided(self.ctx, "nix")) + + @patch("pkgmgr.capabilities.os.path.exists", return_value=True) + @patch( + "builtins.open", + new_callable=mock_open, + read_data="install:\n\t echo 'installing'\n", + ) + def test_make_install_makefile_layer(self, mock_file, mock_exists): + cap = MakeInstallCapability() + + self.assertTrue(cap.applies_to_layer("makefile")) + self.assertTrue(cap.is_provided(self.ctx, "makefile")) + + @patch("pkgmgr.capabilities.os.path.exists") + def test_nix_flake_capability_on_nix_layer(self, mock_exists): + cap = NixFlakeCapability() + + def exists_side_effect(path): + return path.endswith("flake.nix") + + mock_exists.side_effect = exists_side_effect + + self.assertTrue(cap.applies_to_layer("nix")) + self.assertTrue(cap.is_provided(self.ctx, "nix")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/pkgmgr/test_install_repos.py b/tests/unit/pkgmgr/test_install_repos.py index 245544a..0bd9ed3 100644 --- a/tests/unit/pkgmgr/test_install_repos.py +++ b/tests/unit/pkgmgr/test_install_repos.py @@ -1,13 +1,18 @@ -from pkgmgr.run_command import run_command +# tests/unit/pkgmgr/test_install_repos.py + import unittest from unittest.mock import patch, MagicMock from pkgmgr.context import RepoContext import pkgmgr.install_repos as install_module +from pkgmgr.installers.base import BaseInstaller -class DummyInstaller: +class DummyInstaller(BaseInstaller): """Simple installer for testing orchestration.""" + + layer = None # keine speziellen Capabilities + def __init__(self): self.calls = []