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:
297
pkgmgr/capabilities.py
Normal file
297
pkgmgr/capabilities.py
Normal 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(),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user