Add cross-distribution OS package installers (Arch PKGBUILD, Debian control, RPM spec) and restructure tests.
Remove deprecated AUR and Ansible requirements installers. Introduce Nix init + wrapper scripts and full packaging (Arch/DEB/RPM). Associated conversation: https://chatgpt.com/share/693476a8-b9f0-800f-8e0c-ea5151295ce2
This commit is contained in:
27
PKGBUILD
27
PKGBUILD
@@ -8,13 +8,14 @@ arch=('any')
|
||||
url="https://github.com/kevinveenbirkenbach/package-manager"
|
||||
license=('MIT')
|
||||
|
||||
# Nix is the only runtime dependency.
|
||||
depends=('nix')
|
||||
|
||||
makedepends=()
|
||||
install=${pkgname}.install
|
||||
|
||||
source=()
|
||||
sha256sums=()
|
||||
source=('scripts/pkgmgr-wrapper.sh'
|
||||
'scripts/init-nix.sh')
|
||||
sha256sums=('SKIP'
|
||||
'SKIP')
|
||||
|
||||
build() {
|
||||
:
|
||||
@@ -22,19 +23,11 @@ build() {
|
||||
|
||||
package() {
|
||||
install -d "$pkgdir/usr/bin"
|
||||
install -d "$pkgdir/usr/lib/package-manager"
|
||||
|
||||
cat > "$pkgdir/usr/bin/pkgmgr" << 'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# Wrapper
|
||||
install -m0755 "scripts/pkgmgr-wrapper.sh" "$pkgdir/usr/bin/pkgmgr"
|
||||
|
||||
# Enable flakes if not already configured.
|
||||
if [[ -z "${NIX_CONFIG:-}" ]]; then
|
||||
export NIX_CONFIG="experimental-features = nix-command flakes"
|
||||
fi
|
||||
|
||||
# Run package-manager via Nix flake
|
||||
exec nix run "github:kevinveenbirkenbach/package-manager#pkgmgr" -- "$@"
|
||||
EOF
|
||||
|
||||
chmod 755 "$pkgdir/usr/bin/pkgmgr"
|
||||
# Shared Nix init script
|
||||
install -m0755 "scripts/init-nix.sh" "$pkgdir/usr/lib/package-manager/init-nix.sh"
|
||||
}
|
||||
|
||||
5
debian/changelog
vendored
Normal file
5
debian/changelog
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
package-manager (0.1.1-1) unstable; urgency=medium
|
||||
|
||||
* Initial release.
|
||||
|
||||
-- Kevin Veen-Birkenbach <info@veen.world> Sat, 06 Dec 2025 16:30:00 +0100
|
||||
18
debian/control
vendored
Normal file
18
debian/control
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
Source: package-manager
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Maintainer: Kevin Veen-Birkenbach <info@veen.world>
|
||||
Build-Depends: debhelper-compat (= 13)
|
||||
Standards-Version: 4.6.2
|
||||
Rules-Requires-Root: no
|
||||
Homepage: https://github.com/kevinveenbirkenbach/package-manager
|
||||
|
||||
Package: package-manager
|
||||
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
|
||||
the system to work correctly.
|
||||
14
debian/postinst
vendored
Executable file
14
debian/postinst
vendored
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
configure)
|
||||
if [ -x /usr/lib/package-manager/init-nix.sh ]; then
|
||||
/usr/lib/package-manager/init-nix.sh || true
|
||||
else
|
||||
echo ">>> Warning: /usr/lib/package-manager/init-nix.sh not found or not executable."
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
10
debian/rules
vendored
Executable file
10
debian/rules
vendored
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/make -f
|
||||
|
||||
%:
|
||||
dh $@
|
||||
|
||||
override_dh_auto_install:
|
||||
# Install wrapper
|
||||
install -D -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
|
||||
11
package-manager.install
Normal file
11
package-manager.install
Normal file
@@ -0,0 +1,11 @@
|
||||
post_install() {
|
||||
/usr/lib/package-manager/init-nix.sh || true
|
||||
}
|
||||
|
||||
post_upgrade() {
|
||||
/usr/lib/package-manager/init-nix.sh || true
|
||||
}
|
||||
|
||||
post_remove() {
|
||||
echo ">>> package-manager removed. Nix itself was not removed."
|
||||
}
|
||||
58
package-manager.spec
Normal file
58
package-manager.spec
Normal file
@@ -0,0 +1,58 @@
|
||||
Name: package-manager
|
||||
Version: 0.1.1
|
||||
Release: 1%{?dist}
|
||||
Summary: Wrapper that runs Kevin's package-manager via Nix flake
|
||||
|
||||
License: MIT
|
||||
URL: https://github.com/kevinveenbirkenbach/package-manager
|
||||
Source0: %{name}-%{version}.tar.gz
|
||||
|
||||
BuildArch: noarch
|
||||
Requires: nix
|
||||
|
||||
%description
|
||||
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 the only runtime dependency and must be initialized on the
|
||||
system to work correctly.
|
||||
|
||||
%prep
|
||||
%setup -q
|
||||
|
||||
%build
|
||||
# No build step required
|
||||
:
|
||||
|
||||
%install
|
||||
rm -rf %{buildroot}
|
||||
install -d %{buildroot}%{_bindir}
|
||||
install -d %{buildroot}%{_libdir}/package-manager
|
||||
|
||||
# Wrapper
|
||||
install -m0755 scripts/pkgmgr-wrapper.sh %{buildroot}%{_bindir}/pkgmgr
|
||||
|
||||
# Shared Nix init script
|
||||
install -m0755 scripts/init-nix.sh %{buildroot}%{_libdir}/package-manager/init-nix.sh
|
||||
|
||||
%post
|
||||
if [ -x %{_libdir}/package-manager/init-nix.sh ]; then
|
||||
%{_libdir}/package-manager/init-nix.sh || true
|
||||
else
|
||||
echo ">>> Warning: %{_libdir}/package-manager/init-nix.sh not found or not executable."
|
||||
fi
|
||||
|
||||
%postun
|
||||
echo ">>> package-manager removed. Nix itself was not removed."
|
||||
|
||||
%files
|
||||
%doc README.md
|
||||
%license LICENSE
|
||||
%{_bindir}/pkgmgr
|
||||
%{_libdir}/package-manager/init-nix.sh
|
||||
|
||||
%changelog
|
||||
* Sat Dec 06 2025 Kevin Veen-Birkenbach <info@veen.world> - 0.1.1-1
|
||||
- Initial RPM packaging for package-manager
|
||||
@@ -30,23 +30,30 @@ from pkgmgr.context import RepoContext
|
||||
|
||||
# Installer implementations
|
||||
from pkgmgr.installers.pkgmgr_manifest import PkgmgrManifestInstaller
|
||||
from pkgmgr.installers.pkgbuild import PkgbuildInstaller
|
||||
from pkgmgr.installers.os_packages import (
|
||||
ArchPkgbuildInstaller,
|
||||
DebianControlInstaller,
|
||||
RpmSpecInstaller,
|
||||
)
|
||||
from pkgmgr.installers.nix_flake import NixFlakeInstaller
|
||||
from pkgmgr.installers.ansible_requirements import AnsibleRequirementsInstaller
|
||||
from pkgmgr.installers.python import PythonInstaller
|
||||
from pkgmgr.installers.makefile import MakefileInstaller
|
||||
from pkgmgr.installers.aur import AurInstaller
|
||||
|
||||
|
||||
# Ordered list of installers to apply to each repository.
|
||||
# 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
|
||||
INSTALLERS = [
|
||||
PkgmgrManifestInstaller(),
|
||||
PkgbuildInstaller(),
|
||||
NixFlakeInstaller(),
|
||||
AnsibleRequirementsInstaller(),
|
||||
PythonInstaller(),
|
||||
MakefileInstaller(),
|
||||
AurInstaller(),
|
||||
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)
|
||||
MakefileInstaller(), # generic 'make install'
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -4,16 +4,17 @@
|
||||
"""
|
||||
Installer package for pkgmgr.
|
||||
|
||||
Each installer implements a small, focused step in the repository
|
||||
installation pipeline (e.g. PKGBUILD dependencies, Nix flakes, Python,
|
||||
Ansible requirements, pkgmgr.yml, Makefile, AUR).
|
||||
This exposes all installer classes so users can import them directly from
|
||||
pkgmgr.installers.
|
||||
"""
|
||||
|
||||
from pkgmgr.installers.base import BaseInstaller # noqa: F401
|
||||
from pkgmgr.installers.pkgmgr_manifest import PkgmgrManifestInstaller # noqa: F401
|
||||
from pkgmgr.installers.pkgbuild import PkgbuildInstaller # noqa: F401
|
||||
from pkgmgr.installers.nix_flake import NixFlakeInstaller # noqa: F401
|
||||
from pkgmgr.installers.ansible_requirements import AnsibleRequirementsInstaller # noqa: F401
|
||||
from pkgmgr.installers.python import PythonInstaller # noqa: F401
|
||||
from pkgmgr.installers.makefile import MakefileInstaller # noqa: F401
|
||||
from pkgmgr.installers.aur import AurInstaller # noqa: F401
|
||||
|
||||
# OS-specific installers
|
||||
from pkgmgr.installers.os_packages.arch_pkgbuild import ArchPkgbuildInstaller # noqa: F401
|
||||
from pkgmgr.installers.os_packages.debian_control import DebianControlInstaller # noqa: F401
|
||||
from pkgmgr.installers.os_packages.rpm_spec import RpmSpecInstaller # noqa: F401
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Installer for Ansible dependencies defined in requirements.yml.
|
||||
|
||||
This installer installs collections and roles via ansible-galaxy when found.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
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 AnsibleRequirementsInstaller(BaseInstaller):
|
||||
"""Install Ansible collections and roles from requirements.yml."""
|
||||
|
||||
REQUIREMENTS_FILE = "requirements.yml"
|
||||
|
||||
def supports(self, ctx: RepoContext) -> bool:
|
||||
req_file = os.path.join(ctx.repo_dir, self.REQUIREMENTS_FILE)
|
||||
return os.path.exists(req_file)
|
||||
|
||||
def _get_ansible_galaxy_cmd(self) -> str:
|
||||
"""
|
||||
Resolve how to call ansible-galaxy:
|
||||
|
||||
1. If ansible-galaxy is on PATH, use it directly.
|
||||
2. Else, if nix is available, run it via Nix:
|
||||
nix --extra-experimental-features 'nix-command flakes' \
|
||||
run nixpkgs#ansible-core -- ansible-galaxy
|
||||
3. If neither is available, return an empty string.
|
||||
"""
|
||||
if shutil.which("ansible-galaxy"):
|
||||
return "ansible-galaxy"
|
||||
|
||||
if shutil.which("nix"):
|
||||
# Use Nix as the preferred provider
|
||||
return (
|
||||
"nix --extra-experimental-features 'nix-command flakes' "
|
||||
"run nixpkgs#ansible-core -- ansible-galaxy"
|
||||
)
|
||||
|
||||
return ""
|
||||
|
||||
def _load_requirements(self, req_path: str, identifier: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Load requirements.yml.
|
||||
|
||||
Any parsing error is treated as fatal (SystemExit).
|
||||
"""
|
||||
try:
|
||||
with open(req_path, "r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
except Exception as exc:
|
||||
print(f"Error loading {self.REQUIREMENTS_FILE} in {identifier}: {exc}")
|
||||
raise SystemExit(
|
||||
f"{self.REQUIREMENTS_FILE} parsing failed for {identifier}: {exc}"
|
||||
)
|
||||
|
||||
def _validate_requirements(self, requirements: Dict[str, Any], identifier: str) -> None:
|
||||
"""
|
||||
Validate the requirements.yml structure.
|
||||
|
||||
Raises SystemExit on any validation error.
|
||||
"""
|
||||
errors: List[str] = []
|
||||
|
||||
if not isinstance(requirements, dict):
|
||||
errors.append("Top-level structure must be a mapping.")
|
||||
else:
|
||||
allowed_keys = {"collections", "roles"}
|
||||
unknown_keys = set(requirements.keys()) - allowed_keys
|
||||
if unknown_keys:
|
||||
print(
|
||||
f"Warning: requirements.yml in {identifier} contains unknown keys: "
|
||||
f"{', '.join(sorted(unknown_keys))}"
|
||||
)
|
||||
|
||||
for section in ("collections", "roles"):
|
||||
if section not in requirements:
|
||||
continue
|
||||
|
||||
value = requirements[section]
|
||||
if not isinstance(value, list):
|
||||
errors.append(f"'{section}' must be a list.")
|
||||
continue
|
||||
|
||||
for idx, entry in enumerate(value):
|
||||
if isinstance(entry, str):
|
||||
# Short form "community.docker", etc.
|
||||
continue
|
||||
|
||||
if isinstance(entry, dict):
|
||||
if section == "collections":
|
||||
# Collections require 'name'
|
||||
if not entry.get("name"):
|
||||
errors.append(
|
||||
f"Entry #{idx} in '{section}' is a mapping "
|
||||
f"but has no 'name' key."
|
||||
)
|
||||
else:
|
||||
# Roles: 'name' OR 'src' are acceptable.
|
||||
if not (entry.get("name") or entry.get("src")):
|
||||
errors.append(
|
||||
f"Entry #{idx} in '{section}' is a mapping but "
|
||||
f"has neither 'name' nor 'src' key."
|
||||
)
|
||||
continue
|
||||
|
||||
errors.append(
|
||||
f"Entry #{idx} in '{section}' has invalid type "
|
||||
f"{type(entry).__name__}; expected string or mapping."
|
||||
)
|
||||
|
||||
if errors:
|
||||
print(f"Invalid requirements.yml in {identifier}:")
|
||||
for err in errors:
|
||||
print(f" - {err}")
|
||||
raise SystemExit(
|
||||
f"requirements.yml validation failed for {identifier}."
|
||||
)
|
||||
|
||||
def run(self, ctx: RepoContext) -> None:
|
||||
req_file = os.path.join(ctx.repo_dir, self.REQUIREMENTS_FILE)
|
||||
requirements = self._load_requirements(req_file, ctx.identifier)
|
||||
if not requirements:
|
||||
return
|
||||
|
||||
# Validate structure before doing anything dangerous.
|
||||
self._validate_requirements(requirements, ctx.identifier)
|
||||
|
||||
if "collections" not in requirements and "roles" not in requirements:
|
||||
return
|
||||
|
||||
print(f"Ansible dependencies found in {ctx.identifier}, installing...")
|
||||
|
||||
ansible_requirements: Dict[str, Any] = {}
|
||||
if "collections" in requirements:
|
||||
ansible_requirements["collections"] = requirements["collections"]
|
||||
if "roles" in requirements:
|
||||
ansible_requirements["roles"] = requirements["roles"]
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
suffix=".yml",
|
||||
delete=False,
|
||||
) as tmp:
|
||||
yaml.dump(ansible_requirements, tmp, default_flow_style=False)
|
||||
tmp_filename = tmp.name
|
||||
|
||||
galaxy_cmd = self._get_ansible_galaxy_cmd()
|
||||
if not galaxy_cmd:
|
||||
print(
|
||||
"Warning: ansible-galaxy is not available and 'nix' is missing. "
|
||||
"Skipping Ansible requirements installation."
|
||||
)
|
||||
return
|
||||
|
||||
if "collections" in ansible_requirements:
|
||||
print(f"Ansible collections found in {ctx.identifier}, installing...")
|
||||
cmd = f"{galaxy_cmd} collection install -r {tmp_filename}"
|
||||
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||
|
||||
if "roles" in ansible_requirements:
|
||||
print(f"Ansible roles found in {ctx.identifier}, installing...")
|
||||
cmd = f"{galaxy_cmd} role install -r {tmp_filename}"
|
||||
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||
@@ -1,150 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Installer for Arch AUR dependencies declared in an `aur.yml` file.
|
||||
|
||||
This installer is:
|
||||
- Arch-only (requires `pacman`)
|
||||
- helper-driven (yay/paru/..)
|
||||
- safe to ignore on non-Arch systems
|
||||
|
||||
Config parsing errors are treated as fatal to avoid silently ignoring
|
||||
broken configuration.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from typing import List
|
||||
|
||||
import yaml
|
||||
|
||||
from pkgmgr.installers.base import BaseInstaller
|
||||
from pkgmgr.context import RepoContext
|
||||
from pkgmgr.run_command import run_command
|
||||
|
||||
|
||||
AUR_CONFIG_FILENAME = "aur.yml"
|
||||
|
||||
|
||||
class AurInstaller(BaseInstaller):
|
||||
"""
|
||||
Installer for Arch AUR dependencies declared in an `aur.yml` file.
|
||||
"""
|
||||
|
||||
def _is_arch_like(self) -> bool:
|
||||
return shutil.which("pacman") is not None
|
||||
|
||||
def _config_path(self, ctx: RepoContext) -> str:
|
||||
return os.path.join(ctx.repo_dir, AUR_CONFIG_FILENAME)
|
||||
|
||||
def _load_config(self, ctx: RepoContext) -> dict:
|
||||
"""
|
||||
Load and validate aur.yml.
|
||||
|
||||
Any parsing error or invalid top-level structure is treated as fatal
|
||||
(SystemExit).
|
||||
"""
|
||||
path = self._config_path(ctx)
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
except Exception as exc:
|
||||
print(f"[Error] Failed to load AUR config from '{path}': {exc}")
|
||||
raise SystemExit(f"AUR config '{path}' could not be parsed: {exc}")
|
||||
|
||||
if not isinstance(data, dict):
|
||||
print(f"[Error] AUR config '{path}' is not a mapping.")
|
||||
raise SystemExit(f"AUR config '{path}' must be a mapping at top level.")
|
||||
|
||||
return data
|
||||
|
||||
def _get_helper(self, cfg: dict) -> str:
|
||||
# Priority: config.helper > $AUR_HELPER > "yay"
|
||||
helper = cfg.get("helper")
|
||||
if isinstance(helper, str) and helper.strip():
|
||||
return helper.strip()
|
||||
|
||||
env_helper = os.environ.get("AUR_HELPER")
|
||||
if env_helper:
|
||||
return env_helper.strip()
|
||||
|
||||
return "yay"
|
||||
|
||||
def _get_packages(self, cfg: dict) -> List[str]:
|
||||
raw = cfg.get("packages", [])
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
|
||||
names: List[str] = []
|
||||
for entry in raw:
|
||||
if isinstance(entry, str):
|
||||
name = entry.strip()
|
||||
if name:
|
||||
names.append(name)
|
||||
elif isinstance(entry, dict):
|
||||
name = str(entry.get("name", "")).strip()
|
||||
if name:
|
||||
names.append(name)
|
||||
|
||||
return names
|
||||
|
||||
# --- BaseInstaller API -------------------------------------------------
|
||||
|
||||
def supports(self, ctx: RepoContext) -> bool:
|
||||
"""
|
||||
This installer is supported if:
|
||||
- We are on an Arch-like system (pacman available),
|
||||
- An aur.yml exists,
|
||||
- That aur.yml declares at least one package.
|
||||
|
||||
An invalid aur.yml will raise SystemExit during config loading.
|
||||
"""
|
||||
if not self._is_arch_like():
|
||||
return False
|
||||
|
||||
cfg = self._load_config(ctx)
|
||||
if not cfg:
|
||||
return False
|
||||
|
||||
packages = self._get_packages(cfg)
|
||||
return len(packages) > 0
|
||||
|
||||
def run(self, ctx: RepoContext) -> None:
|
||||
"""
|
||||
Install AUR packages using the configured helper (default: yay).
|
||||
|
||||
Missing helper is treated as non-fatal (warning), everything else
|
||||
that fails in run_command() is fatal.
|
||||
"""
|
||||
if not self._is_arch_like():
|
||||
print("AUR installer skipped: not an Arch-like system.")
|
||||
return
|
||||
|
||||
cfg = self._load_config(ctx)
|
||||
if not cfg:
|
||||
print("AUR installer: no valid aur.yml found; skipping.")
|
||||
return
|
||||
|
||||
packages = self._get_packages(cfg)
|
||||
if not packages:
|
||||
print("AUR installer: no AUR packages defined; skipping.")
|
||||
return
|
||||
|
||||
helper = self._get_helper(cfg)
|
||||
if shutil.which(helper) is None:
|
||||
print(
|
||||
f"[Warning] AUR helper '{helper}' is not available on PATH. "
|
||||
f"Please install it (e.g. via your aur_builder setup). "
|
||||
f"Skipping AUR installation."
|
||||
)
|
||||
return
|
||||
|
||||
pkg_list_str = " ".join(packages)
|
||||
print(f"Installing AUR packages via '{helper}': {pkg_list_str}")
|
||||
|
||||
cmd = f"{helper} -S --noconfirm {pkg_list_str}"
|
||||
run_command(cmd, preview=ctx.preview)
|
||||
9
pkgmgr/installers/os_packages/__init__.py
Normal file
9
pkgmgr/installers/os_packages/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from .arch_pkgbuild import ArchPkgbuildInstaller
|
||||
from .debian_control import DebianControlInstaller
|
||||
from .rpm_spec import RpmSpecInstaller
|
||||
|
||||
__all__ = [
|
||||
"ArchPkgbuildInstaller",
|
||||
"DebianControlInstaller",
|
||||
"RpmSpecInstaller",
|
||||
]
|
||||
@@ -18,12 +18,17 @@ from pkgmgr.installers.base import BaseInstaller
|
||||
from pkgmgr.run_command import run_command
|
||||
|
||||
|
||||
class PkgbuildInstaller(BaseInstaller):
|
||||
class ArchPkgbuildInstaller(BaseInstaller):
|
||||
"""Install Arch dependencies (depends/makedepends) from PKGBUILD."""
|
||||
|
||||
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.
|
||||
"""
|
||||
if shutil.which("pacman") is None:
|
||||
return False
|
||||
pkgbuild_path = os.path.join(ctx.repo_dir, self.PKGBUILD_NAME)
|
||||
@@ -39,7 +44,10 @@ class PkgbuildInstaller(BaseInstaller):
|
||||
if not os.path.exists(pkgbuild_path):
|
||||
return []
|
||||
|
||||
script = f'source {self.PKGBUILD_NAME} >/dev/null 2>&1; printf "%s\\n" "${{{var_name}[@]}}"'
|
||||
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],
|
||||
@@ -64,6 +72,9 @@ class PkgbuildInstaller(BaseInstaller):
|
||||
return packages
|
||||
|
||||
def run(self, ctx: RepoContext) -> None:
|
||||
"""
|
||||
Install all packages from depends + makedepends via pacman.
|
||||
"""
|
||||
depends = self._extract_pkgbuild_array(ctx, "depends")
|
||||
makedepends = self._extract_pkgbuild_array(ctx, "makedepends")
|
||||
all_pkgs = depends + makedepends
|
||||
@@ -72,4 +83,4 @@ class PkgbuildInstaller(BaseInstaller):
|
||||
return
|
||||
|
||||
cmd = "sudo pacman -S --noconfirm " + " ".join(all_pkgs)
|
||||
run_command(cmd, preview=ctx.preview)
|
||||
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||
141
pkgmgr/installers/os_packages/debian_control.py
Normal file
141
pkgmgr/installers/os_packages/debian_control.py
Normal file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Installer for Debian/Ubuntu system dependencies defined in 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.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from typing import List
|
||||
|
||||
from pkgmgr.context import RepoContext
|
||||
from pkgmgr.installers.base import BaseInstaller
|
||||
from pkgmgr.run_command import run_command
|
||||
|
||||
|
||||
class DebianControlInstaller(BaseInstaller):
|
||||
"""Install Debian/Ubuntu system packages from debian/control."""
|
||||
|
||||
CONTROL_DIR = "debian"
|
||||
CONTROL_FILE = "control"
|
||||
|
||||
def _is_debian_like(self) -> bool:
|
||||
return shutil.which("apt-get") is not None
|
||||
|
||||
def _control_path(self, ctx: RepoContext) -> str:
|
||||
return os.path.join(ctx.repo_dir, self.CONTROL_DIR, self.CONTROL_FILE)
|
||||
|
||||
def supports(self, ctx: RepoContext) -> bool:
|
||||
"""
|
||||
This installer is supported if:
|
||||
- we are on a Debian-like system (apt-get available), and
|
||||
- debian/control exists.
|
||||
"""
|
||||
if not self._is_debian_like():
|
||||
return False
|
||||
|
||||
return os.path.exists(self._control_path(ctx))
|
||||
|
||||
def _parse_control_dependencies(self, control_path: str) -> List[str]:
|
||||
"""
|
||||
Parse Build-Depends, Build-Depends-Indep and Depends fields
|
||||
from debian/control.
|
||||
|
||||
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}.
|
||||
"""
|
||||
if not os.path.exists(control_path):
|
||||
return []
|
||||
|
||||
with open(control_path, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
deps: List[str] = []
|
||||
current_key = None
|
||||
current_val_lines: List[str] = []
|
||||
|
||||
target_keys = {
|
||||
"Build-Depends",
|
||||
"Build-Depends-Indep",
|
||||
"Depends",
|
||||
}
|
||||
|
||||
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 = []
|
||||
|
||||
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
|
||||
|
||||
def run(self, ctx: RepoContext) -> None:
|
||||
"""
|
||||
Install Debian/Ubuntu system packages via apt-get.
|
||||
"""
|
||||
control_path = self._control_path(ctx)
|
||||
packages = self._parse_control_dependencies(control_path)
|
||||
if not packages:
|
||||
return
|
||||
|
||||
# Update and install in two separate commands for clarity.
|
||||
run_command("sudo apt-get update", cwd=ctx.repo_dir, preview=ctx.preview)
|
||||
|
||||
cmd = "sudo apt-get install -y " + " ".join(packages)
|
||||
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||
152
pkgmgr/installers/os_packages/rpm_spec.py
Normal file
152
pkgmgr/installers/os_packages/rpm_spec.py
Normal file
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Installer for RPM-based system dependencies 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.).
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
from typing import List, Optional
|
||||
|
||||
from pkgmgr.context import RepoContext
|
||||
from pkgmgr.installers.base import BaseInstaller
|
||||
from pkgmgr.run_command import run_command
|
||||
|
||||
|
||||
class RpmSpecInstaller(BaseInstaller):
|
||||
"""Install RPM-based system packages from *.spec files."""
|
||||
|
||||
def _is_rpm_like(self) -> bool:
|
||||
return shutil.which("dnf") is not None or shutil.which("yum") is not None
|
||||
|
||||
def _spec_path(self, ctx: RepoContext) -> Optional[str]:
|
||||
pattern = os.path.join(ctx.repo_dir, "*.spec")
|
||||
matches = glob.glob(pattern)
|
||||
if not matches:
|
||||
return None
|
||||
# Take the first match deterministically (sorted)
|
||||
return sorted(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
|
||||
- a *.spec file exists in the repository root.
|
||||
"""
|
||||
if not self._is_rpm_like():
|
||||
return False
|
||||
|
||||
return self._spec_path(ctx) is not None
|
||||
|
||||
def _parse_spec_dependencies(self, spec_path: str) -> List[str]:
|
||||
"""
|
||||
Parse BuildRequires and Requires from a .spec file.
|
||||
|
||||
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.
|
||||
"""
|
||||
if not os.path.exists(spec_path):
|
||||
return []
|
||||
|
||||
with open(spec_path, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
deps: List[str] = []
|
||||
current_key = None
|
||||
current_val_lines: List[str] = []
|
||||
|
||||
target_keys = {
|
||||
"BuildRequires",
|
||||
"Requires",
|
||||
}
|
||||
|
||||
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 = []
|
||||
|
||||
for line in lines:
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith("#"):
|
||||
# Comment
|
||||
continue
|
||||
|
||||
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
|
||||
|
||||
def run(self, ctx: RepoContext) -> None:
|
||||
"""
|
||||
Install RPM-based system packages via dnf or yum.
|
||||
"""
|
||||
spec_path = self._spec_path(ctx)
|
||||
if not spec_path:
|
||||
return
|
||||
|
||||
packages = self._parse_spec_dependencies(spec_path)
|
||||
if not packages:
|
||||
return
|
||||
|
||||
pkg_mgr = shutil.which("dnf") or shutil.which("yum")
|
||||
if not pkg_mgr:
|
||||
print(
|
||||
"[Warning] No suitable RPM package manager (dnf/yum) found on PATH. "
|
||||
"Skipping RPM dependency installation."
|
||||
)
|
||||
return
|
||||
|
||||
cmd = f"sudo {pkg_mgr} install -y " + " ".join(packages)
|
||||
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||
48
scripts/init-nix.sh
Normal file
48
scripts/init-nix.sh
Normal file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo ">>> Initializing Nix environment for package-manager..."
|
||||
|
||||
# 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
|
||||
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
|
||||
else
|
||||
echo ">>> Warning: nix-daemon.service not found or systemctl not available."
|
||||
fi
|
||||
|
||||
# 3. Gruppe nix-users sicherstellen
|
||||
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
|
||||
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
|
||||
echo ">>> Adding user '$user' to nix-users"
|
||||
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
|
||||
echo ">>> Adding user '$USERNAME' to nix-users"
|
||||
usermod -aG nix-users "$USERNAME" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ">>> Nix initialization for package-manager complete."
|
||||
echo ">>> You may need to log out and log back in to activate group membership."
|
||||
10
scripts/pkgmgr-wrapper.sh
Normal file
10
scripts/pkgmgr-wrapper.sh
Normal file
@@ -0,0 +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" -- "$@"
|
||||
@@ -1,14 +1,14 @@
|
||||
# tests/unit/pkgmgr/installers/test_pkgbuild.py
|
||||
# tests/unit/pkgmgr/installers/os_packages/test_arch_pkgbuild.py
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.context import RepoContext
|
||||
from pkgmgr.installers.pkgbuild import PkgbuildInstaller
|
||||
from pkgmgr.installers.os_packages.arch_pkgbuild import ArchPkgbuildInstaller
|
||||
|
||||
|
||||
class TestPkgbuildInstaller(unittest.TestCase):
|
||||
class TestArchPkgbuildInstaller(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.repo = {"name": "test-repo"}
|
||||
self.ctx = RepoContext(
|
||||
@@ -24,7 +24,7 @@ class TestPkgbuildInstaller(unittest.TestCase):
|
||||
clone_mode="ssh",
|
||||
update_dependencies=False,
|
||||
)
|
||||
self.installer = PkgbuildInstaller()
|
||||
self.installer = ArchPkgbuildInstaller()
|
||||
|
||||
@patch("os.path.exists", return_value=True)
|
||||
@patch("shutil.which", return_value="/usr/bin/pacman")
|
||||
@@ -38,7 +38,7 @@ class TestPkgbuildInstaller(unittest.TestCase):
|
||||
def test_supports_false_when_pkgbuild_missing(self, mock_which, mock_exists):
|
||||
self.assertFalse(self.installer.supports(self.ctx))
|
||||
|
||||
@patch("pkgmgr.installers.pkgbuild.run_command")
|
||||
@patch("pkgmgr.installers.os_packages.arch_pkgbuild.run_command")
|
||||
@patch("subprocess.check_output", return_value="python\ngit\n")
|
||||
@patch("os.path.exists", return_value=True)
|
||||
@patch("shutil.which", return_value="/usr/bin/pacman")
|
||||
@@ -47,14 +47,14 @@ class TestPkgbuildInstaller(unittest.TestCase):
|
||||
):
|
||||
self.installer.run(self.ctx)
|
||||
|
||||
# Check subprocess.check_output arguments (clean shell)
|
||||
# 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)
|
||||
|
||||
# Check that pacman is called with the extracted packages
|
||||
# pacman install command
|
||||
cmd = mock_run_command.call_args[0][0]
|
||||
self.assertTrue(cmd.startswith("sudo pacman -S --noconfirm "))
|
||||
self.assertIn("python", cmd)
|
||||
@@ -0,0 +1,66 @@
|
||||
# tests/unit/pkgmgr/installers/os_packages/test_debian_control.py
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch, mock_open
|
||||
|
||||
from pkgmgr.context import RepoContext
|
||||
from pkgmgr.installers.os_packages.debian_control import DebianControlInstaller
|
||||
|
||||
|
||||
class TestDebianControlInstaller(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.repo = {"name": "repo"}
|
||||
self.ctx = RepoContext(
|
||||
repo=self.repo,
|
||||
identifier="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=False,
|
||||
)
|
||||
self.installer = DebianControlInstaller()
|
||||
|
||||
@patch("os.path.exists", return_value=True)
|
||||
@patch("shutil.which", return_value="/usr/bin/apt-get")
|
||||
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):
|
||||
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("os.path.exists", return_value=True)
|
||||
@patch("shutil.which", return_value="/usr/bin/apt-get")
|
||||
def test_run_installs_parsed_packages(
|
||||
self,
|
||||
mock_which,
|
||||
mock_exists,
|
||||
mock_file,
|
||||
mock_run_command
|
||||
):
|
||||
self.installer.run(self.ctx)
|
||||
|
||||
# First call: apt-get update
|
||||
self.assertIn("apt-get update", mock_run_command.call_args_list[0][0][0])
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
60
tests/unit/pkgmgr/installers/os_packages/test_rpm_spec.py
Normal file
60
tests/unit/pkgmgr/installers/os_packages/test_rpm_spec.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# tests/unit/pkgmgr/installers/os_packages/test_rpm_spec.py
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch, mock_open
|
||||
|
||||
from pkgmgr.context import RepoContext
|
||||
from pkgmgr.installers.os_packages.rpm_spec import RpmSpecInstaller
|
||||
|
||||
|
||||
class TestRpmSpecInstaller(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.repo = {"name": "repo"}
|
||||
self.ctx = RepoContext(
|
||||
repo=self.repo,
|
||||
identifier="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=False,
|
||||
)
|
||||
self.installer = RpmSpecInstaller()
|
||||
|
||||
@patch("glob.glob", return_value=["/tmp/repo/test.spec"])
|
||||
@patch("shutil.which", return_value="/usr/bin/dnf")
|
||||
def test_supports_true(self, mock_which, mock_glob):
|
||||
self.assertTrue(self.installer.supports(self.ctx))
|
||||
|
||||
@patch("glob.glob", return_value=[])
|
||||
@patch("shutil.which", return_value="/usr/bin/dnf")
|
||||
def test_supports_false_missing_spec(self, mock_which, mock_glob):
|
||||
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
|
||||
):
|
||||
self.installer.run(self.ctx)
|
||||
|
||||
install_cmd = mock_run_command.call_args_list[0][0][0]
|
||||
|
||||
self.assertIn("dnf install -y", install_cmd)
|
||||
self.assertIn("python3-devel", install_cmd)
|
||||
self.assertIn("git", install_cmd)
|
||||
self.assertIn("curl", install_cmd)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,168 +0,0 @@
|
||||
# tests/unit/pkgmgr/installers/test_ansible_requirements.py
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch, mock_open
|
||||
|
||||
from pkgmgr.context import RepoContext
|
||||
from pkgmgr.installers.ansible_requirements import AnsibleRequirementsInstaller
|
||||
|
||||
|
||||
class TestAnsibleRequirementsInstaller(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=False,
|
||||
)
|
||||
self.installer = AnsibleRequirementsInstaller()
|
||||
|
||||
@patch("os.path.exists", return_value=True)
|
||||
def test_supports_true_when_requirements_exist(self, mock_exists):
|
||||
self.assertTrue(self.installer.supports(self.ctx))
|
||||
mock_exists.assert_called_with(os.path.join(self.ctx.repo_dir, "requirements.yml"))
|
||||
|
||||
@patch("os.path.exists", return_value=False)
|
||||
def test_supports_false_when_requirements_missing(self, mock_exists):
|
||||
self.assertFalse(self.installer.supports(self.ctx))
|
||||
|
||||
@patch("pkgmgr.installers.ansible_requirements.run_command")
|
||||
@patch("tempfile.NamedTemporaryFile")
|
||||
@patch(
|
||||
"builtins.open",
|
||||
new_callable=mock_open,
|
||||
read_data="""
|
||||
collections:
|
||||
- name: community.docker
|
||||
roles:
|
||||
- src: geerlingguy.docker
|
||||
""",
|
||||
)
|
||||
@patch("os.path.exists", return_value=True)
|
||||
def test_run_installs_collections_and_roles(
|
||||
self, mock_exists, mock_file, mock_tmp, mock_run_command
|
||||
):
|
||||
# Fake temp file name
|
||||
mock_tmp().__enter__().name = "/tmp/req.yml"
|
||||
|
||||
self.installer.run(self.ctx)
|
||||
|
||||
cmds = [call[0][0] for call in mock_run_command.call_args_list]
|
||||
self.assertIn(
|
||||
"ansible-galaxy collection install -r /tmp/req.yml",
|
||||
cmds,
|
||||
)
|
||||
self.assertIn(
|
||||
"ansible-galaxy role install -r /tmp/req.yml",
|
||||
cmds,
|
||||
)
|
||||
|
||||
# --- Neue Tests für den Validator -------------------------------------
|
||||
|
||||
@patch("pkgmgr.installers.ansible_requirements.run_command")
|
||||
@patch(
|
||||
"builtins.open",
|
||||
new_callable=mock_open,
|
||||
read_data="""
|
||||
- not:
|
||||
- a: mapping
|
||||
""",
|
||||
)
|
||||
@patch("os.path.exists", return_value=True)
|
||||
def test_run_raises_when_top_level_is_not_mapping(
|
||||
self, mock_exists, mock_file, mock_run_command
|
||||
):
|
||||
# YAML ist eine Liste -> Validator soll fehlschlagen
|
||||
with self.assertRaises(SystemExit):
|
||||
self.installer.run(self.ctx)
|
||||
|
||||
mock_run_command.assert_not_called()
|
||||
|
||||
@patch("pkgmgr.installers.ansible_requirements.run_command")
|
||||
@patch(
|
||||
"builtins.open",
|
||||
new_callable=mock_open,
|
||||
read_data="""
|
||||
collections: community.docker
|
||||
roles:
|
||||
- src: geerlingguy.docker
|
||||
""",
|
||||
)
|
||||
@patch("os.path.exists", return_value=True)
|
||||
def test_run_raises_when_collections_is_not_list(
|
||||
self, mock_exists, mock_file, mock_run_command
|
||||
):
|
||||
# collections ist ein String statt Liste -> invalid
|
||||
with self.assertRaises(SystemExit):
|
||||
self.installer.run(self.ctx)
|
||||
|
||||
mock_run_command.assert_not_called()
|
||||
|
||||
@patch("pkgmgr.installers.ansible_requirements.run_command")
|
||||
@patch(
|
||||
"builtins.open",
|
||||
new_callable=mock_open,
|
||||
read_data="""
|
||||
collections:
|
||||
- name: community.docker
|
||||
roles:
|
||||
- version: "latest"
|
||||
""",
|
||||
)
|
||||
@patch("os.path.exists", return_value=True)
|
||||
def test_run_raises_when_role_mapping_has_no_name(
|
||||
self, mock_exists, mock_file, mock_run_command
|
||||
):
|
||||
# roles-Eintrag ist Mapping ohne 'name' -> invalid
|
||||
with self.assertRaises(SystemExit):
|
||||
self.installer.run(self.ctx)
|
||||
|
||||
mock_run_command.assert_not_called()
|
||||
|
||||
@patch("pkgmgr.installers.ansible_requirements.run_command")
|
||||
@patch("tempfile.NamedTemporaryFile")
|
||||
@patch(
|
||||
"builtins.open",
|
||||
new_callable=mock_open,
|
||||
read_data="""
|
||||
collections:
|
||||
- name: community.docker
|
||||
extra_key: should_be_ignored_but_warned
|
||||
""",
|
||||
)
|
||||
@patch("os.path.exists", return_value=True)
|
||||
def test_run_accepts_unknown_top_level_keys(
|
||||
self, mock_exists, mock_file, mock_tmp, mock_run_command
|
||||
):
|
||||
"""
|
||||
Unknown top-level keys (z.B. 'extra_key') sollen nur eine Warnung
|
||||
auslösen, aber keine Validation-Exception.
|
||||
"""
|
||||
mock_tmp().__enter__().name = "/tmp/req.yml"
|
||||
|
||||
# Erwartung: kein SystemExit, run_command wird für collections aufgerufen
|
||||
self.installer.run(self.ctx)
|
||||
|
||||
cmds = [call[0][0] for call in mock_run_command.call_args_list]
|
||||
self.assertIn(
|
||||
"ansible-galaxy collection install -r /tmp/req.yml",
|
||||
cmds,
|
||||
)
|
||||
# Keine roles definiert -> kein role-install
|
||||
self.assertNotIn(
|
||||
"ansible-galaxy role install -r /tmp/req.yml",
|
||||
cmds,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,97 +0,0 @@
|
||||
# tests/unit/pkgmgr/installers/test_aur.py
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch, mock_open
|
||||
|
||||
from pkgmgr.context import RepoContext
|
||||
from pkgmgr.installers.aur import AurInstaller, AUR_CONFIG_FILENAME
|
||||
|
||||
|
||||
class TestAurInstaller(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=False,
|
||||
)
|
||||
self.installer = AurInstaller()
|
||||
|
||||
@patch("shutil.which", return_value="/usr/bin/pacman")
|
||||
@patch("os.path.exists", return_value=True)
|
||||
@patch(
|
||||
"builtins.open",
|
||||
new_callable=mock_open,
|
||||
read_data="""
|
||||
helper: yay
|
||||
packages:
|
||||
- aurutils
|
||||
- name: some-aur-only-tool
|
||||
reason: "Test tool"
|
||||
""",
|
||||
)
|
||||
def test_supports_true_when_arch_and_aur_config_present(
|
||||
self, mock_file, mock_exists, mock_which
|
||||
):
|
||||
self.assertTrue(self.installer.supports(self.ctx))
|
||||
mock_which.assert_called_with("pacman")
|
||||
mock_exists.assert_called_with(os.path.join(self.ctx.repo_dir, AUR_CONFIG_FILENAME))
|
||||
|
||||
@patch("shutil.which", return_value=None)
|
||||
def test_supports_false_when_not_arch(self, mock_which):
|
||||
self.assertFalse(self.installer.supports(self.ctx))
|
||||
|
||||
@patch("shutil.which", return_value="/usr/bin/pacman")
|
||||
@patch("os.path.exists", return_value=False)
|
||||
def test_supports_false_when_no_config(self, mock_exists, mock_which):
|
||||
self.assertFalse(self.installer.supports(self.ctx))
|
||||
|
||||
@patch("shutil.which", side_effect=lambda name: "/usr/bin/pacman" if name == "pacman" else "/usr/bin/yay")
|
||||
@patch("pkgmgr.installers.aur.run_command")
|
||||
@patch(
|
||||
"builtins.open",
|
||||
new_callable=mock_open,
|
||||
read_data="""
|
||||
helper: yay
|
||||
packages:
|
||||
- aurutils
|
||||
- some-aur-only-tool
|
||||
""",
|
||||
)
|
||||
@patch("os.path.exists", return_value=True)
|
||||
def test_run_installs_packages_with_helper(
|
||||
self, mock_exists, mock_file, mock_run_command, mock_which
|
||||
):
|
||||
self.installer.run(self.ctx)
|
||||
|
||||
cmd = mock_run_command.call_args[0][0]
|
||||
self.assertTrue(cmd.startswith("yay -S --noconfirm "))
|
||||
self.assertIn("aurutils", cmd)
|
||||
self.assertIn("some-aur-only-tool", cmd)
|
||||
|
||||
@patch("shutil.which", return_value="/usr/bin/pacman")
|
||||
@patch(
|
||||
"builtins.open",
|
||||
new_callable=mock_open,
|
||||
read_data="packages: []",
|
||||
)
|
||||
@patch("os.path.exists", return_value=True)
|
||||
def test_run_skips_when_no_packages(
|
||||
self, mock_exists, mock_file, mock_which
|
||||
):
|
||||
with patch("pkgmgr.installers.aur.run_command") as mock_run_command:
|
||||
self.installer.run(self.ctx)
|
||||
mock_run_command.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user