Make pkgmgr installers fail hard and integrate Nix-based test pipeline (see https://chatgpt.com/share/69332bc4-a128-800f-a69c-fdc24c4cc7fe)
This commit is contained in:
30
Makefile
30
Makefile
@@ -1,8 +1,8 @@
|
|||||||
.PHONY: install setup uninstall aur_builder_setup test
|
.PHONY: install setup uninstall aur_builder_setup test
|
||||||
|
|
||||||
# Local Nix cache directories in the repo
|
# Local Nix cache directories in the repo
|
||||||
NIX_STORE_DIR := .nix/store
|
NIX_STORE_VOLUME := pkgmgr_nix_store
|
||||||
NIX_CACHE_DIR := .nix/cache
|
NIX_CACHE_VOLUME := pkgmgr_nix_cache
|
||||||
|
|
||||||
setup: install
|
setup: install
|
||||||
@echo "Running pkgmgr setup via main.py..."
|
@echo "Running pkgmgr setup via main.py..."
|
||||||
@@ -14,20 +14,28 @@ setup: install
|
|||||||
python3 main.py install; \
|
python3 main.py install; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
test:
|
|
||||||
@echo "Ensuring local Nix cache directories exist..."
|
build-no-cache:
|
||||||
@mkdir -p "$(NIX_STORE_DIR)" "$(NIX_CACHE_DIR)"
|
@echo "Building test image 'package-manager-test' with no cache..."
|
||||||
|
docker build --no-cache -t package-manager-test .
|
||||||
|
|
||||||
|
build:
|
||||||
@echo "Building test image 'package-manager-test'..."
|
@echo "Building test image 'package-manager-test'..."
|
||||||
docker build -t package-manager-test .
|
docker build -t package-manager-test .
|
||||||
@echo "Running tests inside Nix devShell with local cache..."
|
|
||||||
|
|
||||||
|
test: build
|
||||||
|
@echo "Ensuring Docker Nix volumes exist (auto-created if missing)..."
|
||||||
|
@echo "Running tests inside Nix devShell with cached store..."
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v "$$(pwd)/$(NIX_STORE_DIR):/nix" \
|
-v "$$(pwd):/src" \
|
||||||
-v "$$(pwd)/$(NIX_CACHE_DIR):/root/.cache/nix" \
|
-v "$(NIX_STORE_VOLUME):/nix" \
|
||||||
|
-v "$(NIX_CACHE_VOLUME):/root/.cache/nix" \
|
||||||
--workdir /src \
|
--workdir /src \
|
||||||
--entrypoint nix \
|
--entrypoint bash \
|
||||||
package-manager-test \
|
package-manager-test \
|
||||||
develop .#default --no-write-lock-file -c \
|
-c 'git config --global --add safe.directory /src && nix develop .#default --no-write-lock-file -c python3 -m unittest discover -s tests -p "test_*.py"'
|
||||||
python -m unittest discover -s tests -p "test_*.py"
|
|
||||||
|
|
||||||
install:
|
install:
|
||||||
@echo "Making 'main.py' executable..."
|
@echo "Making 'main.py' executable..."
|
||||||
|
|||||||
52
flake.nix
52
flake.nix
@@ -47,33 +47,37 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
# Packages: nix build .#pkgmgr / .#default
|
# Packages: nix build .#pkgmgr / .#default
|
||||||
packages = forAllSystems (system:
|
packages = forAllSystems (system:
|
||||||
let
|
let
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
python = pkgs.python311;
|
python = pkgs.python311;
|
||||||
pypkgs = pkgs.python311Packages;
|
pypkgs = pkgs.python311Packages;
|
||||||
|
|
||||||
# Be robust: ansible-core if available, otherwise ansible.
|
# Be robust: ansible-core if available, otherwise ansible.
|
||||||
ansiblePkg =
|
ansiblePkg =
|
||||||
if pkgs ? ansible-core then pkgs.ansible-core
|
if pkgs ? ansible-core then pkgs.ansible-core
|
||||||
else pkgs.ansible;
|
else pkgs.ansible;
|
||||||
in
|
in
|
||||||
rec {
|
rec {
|
||||||
pkgmgr = pypkgs.buildPythonApplication {
|
pkgmgr = pypkgs.buildPythonApplication {
|
||||||
pname = "package-manager";
|
pname = "package-manager";
|
||||||
version = "0.1.0";
|
version = "0.1.0";
|
||||||
src = ./.;
|
src = ./.;
|
||||||
|
|
||||||
propagatedBuildInputs = [
|
pyproject = true;
|
||||||
pypkgs.pyyaml
|
build-system = [ pypkgs.setuptools ];
|
||||||
ansiblePkg
|
|
||||||
];
|
propagatedBuildInputs = [
|
||||||
};
|
pypkgs.pyyaml
|
||||||
|
ansiblePkg
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
# default package just points to pkgmgr
|
||||||
|
default = pkgmgr;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
# default package just points to pkgmgr
|
|
||||||
default = pkgmgr;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
# Apps: nix run .#pkgmgr / .#default
|
# Apps: nix run .#pkgmgr / .#default
|
||||||
apps = forAllSystems (system:
|
apps = forAllSystems (system:
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ This module orchestrates the installation of repositories by:
|
|||||||
3. Creating executable links using create_ink().
|
3. Creating executable links using create_ink().
|
||||||
4. Running a sequence of modular installer components that handle
|
4. Running a sequence of modular installer components that handle
|
||||||
specific technologies or manifests (pkgmgr.yml, PKGBUILD, Nix,
|
specific technologies or manifests (pkgmgr.yml, PKGBUILD, Nix,
|
||||||
Ansible requirements, Python, Makefile).
|
Ansible requirements, Python, Makefile, AUR).
|
||||||
|
|
||||||
The goal is to keep this file thin and delegate most logic to small,
|
The goal is to keep this file thin and delegate most logic to small,
|
||||||
focused installer classes.
|
focused installer classes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from typing import List, Dict, Any, Tuple
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
from pkgmgr.get_repo_identifier import get_repo_identifier
|
from pkgmgr.get_repo_identifier import get_repo_identifier
|
||||||
from pkgmgr.get_repo_dir import get_repo_dir
|
from pkgmgr.get_repo_dir import get_repo_dir
|
||||||
@@ -38,7 +38,7 @@ from pkgmgr.installers.makefile import MakefileInstaller
|
|||||||
from pkgmgr.installers.aur import AurInstaller
|
from pkgmgr.installers.aur import AurInstaller
|
||||||
|
|
||||||
|
|
||||||
# Ordered list of installers to apply to each repository
|
# Ordered list of installers to apply to each repository.
|
||||||
INSTALLERS = [
|
INSTALLERS = [
|
||||||
PkgmgrManifestInstaller(),
|
PkgmgrManifestInstaller(),
|
||||||
PkgbuildInstaller(),
|
PkgbuildInstaller(),
|
||||||
@@ -159,7 +159,10 @@ def install_repos(
|
|||||||
"""
|
"""
|
||||||
Install repositories by creating symbolic links and processing standard
|
Install repositories by creating symbolic links and processing standard
|
||||||
manifest files (pkgmgr.yml, PKGBUILD, flake.nix, Ansible requirements,
|
manifest files (pkgmgr.yml, PKGBUILD, flake.nix, Ansible requirements,
|
||||||
Python manifests, Makefile) via dedicated installer components.
|
Python manifests, Makefile, AUR) via dedicated installer components.
|
||||||
|
|
||||||
|
Any installer failure (SystemExit) is treated as fatal and will abort
|
||||||
|
the current installation.
|
||||||
"""
|
"""
|
||||||
for repo in selected_repos:
|
for repo in selected_repos:
|
||||||
identifier = get_repo_identifier(repo, all_repos)
|
identifier = get_repo_identifier(repo, all_repos)
|
||||||
|
|||||||
@@ -5,5 +5,15 @@
|
|||||||
Installer package for pkgmgr.
|
Installer package for pkgmgr.
|
||||||
|
|
||||||
Each installer implements a small, focused step in the repository
|
Each installer implements a small, focused step in the repository
|
||||||
installation pipeline (e.g. PKGBUILD dependencies, Nix flakes, Python, etc.).
|
installation pipeline (e.g. PKGBUILD dependencies, Nix flakes, Python,
|
||||||
|
Ansible requirements, pkgmgr.yml, Makefile, AUR).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ This installer installs collections and roles via ansible-galaxy when found.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
@@ -50,24 +51,30 @@ class AnsibleRequirementsInstaller(BaseInstaller):
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
def _load_requirements(self, req_path: str, identifier: str) -> Dict[str, Any]:
|
def _load_requirements(self, req_path: str, identifier: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Load requirements.yml.
|
||||||
|
|
||||||
|
Any parsing error is treated as fatal (SystemExit).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
with open(req_path, "r", encoding="utf-8") as f:
|
with open(req_path, "r", encoding="utf-8") as f:
|
||||||
return yaml.safe_load(f) or {}
|
return yaml.safe_load(f) or {}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"Error loading {self.REQUIREMENTS_FILE} in {identifier}: {exc}")
|
print(f"Error loading {self.REQUIREMENTS_FILE} in {identifier}: {exc}")
|
||||||
return {}
|
raise SystemExit(
|
||||||
|
f"{self.REQUIREMENTS_FILE} parsing failed for {identifier}: {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
def _validate_requirements(self, requirements: Dict[str, Any], identifier: str) -> None:
|
def _validate_requirements(self, requirements: Dict[str, Any], identifier: str) -> None:
|
||||||
"""
|
"""
|
||||||
Validate the requirements.yml structure.
|
Validate the requirements.yml structure.
|
||||||
|
|
||||||
Raises SystemExit on any validation error.
|
Raises SystemExit on any validation error.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
errors: List[str] = []
|
errors: List[str] = []
|
||||||
|
|
||||||
if not isinstance(requirements, dict):
|
if not isinstance(requirements, dict):
|
||||||
errors.append("Top-level structure must be a mapping.")
|
errors.append("Top-level structure must be a mapping.")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
allowed_keys = {"collections", "roles"}
|
allowed_keys = {"collections", "roles"}
|
||||||
unknown_keys = set(requirements.keys()) - allowed_keys
|
unknown_keys = set(requirements.keys()) - allowed_keys
|
||||||
@@ -88,19 +95,19 @@ class AnsibleRequirementsInstaller(BaseInstaller):
|
|||||||
|
|
||||||
for idx, entry in enumerate(value):
|
for idx, entry in enumerate(value):
|
||||||
if isinstance(entry, str):
|
if isinstance(entry, str):
|
||||||
# short form "community.docker" etc.
|
# Short form "community.docker", etc.
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if isinstance(entry, dict):
|
if isinstance(entry, dict):
|
||||||
# Collections: brauchen zwingend 'name'
|
|
||||||
if section == "collections":
|
if section == "collections":
|
||||||
|
# Collections require 'name'
|
||||||
if not entry.get("name"):
|
if not entry.get("name"):
|
||||||
errors.append(
|
errors.append(
|
||||||
f"Entry #{idx} in '{section}' is a mapping "
|
f"Entry #{idx} in '{section}' is a mapping "
|
||||||
f"but has no 'name' key."
|
f"but has no 'name' key."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Roles: 'name' ODER 'src' sind ok (beides gängig)
|
# Roles: 'name' OR 'src' are acceptable.
|
||||||
if not (entry.get("name") or entry.get("src")):
|
if not (entry.get("name") or entry.get("src")):
|
||||||
errors.append(
|
errors.append(
|
||||||
f"Entry #{idx} in '{section}' is a mapping but "
|
f"Entry #{idx} in '{section}' is a mapping but "
|
||||||
@@ -127,7 +134,7 @@ class AnsibleRequirementsInstaller(BaseInstaller):
|
|||||||
if not requirements:
|
if not requirements:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Validate structure before doing anything dangerous
|
# Validate structure before doing anything dangerous.
|
||||||
self._validate_requirements(requirements, ctx.identifier)
|
self._validate_requirements(requirements, ctx.identifier)
|
||||||
|
|
||||||
if "collections" not in requirements and "roles" not in requirements:
|
if "collections" not in requirements and "roles" not in requirements:
|
||||||
@@ -166,4 +173,3 @@ class AnsibleRequirementsInstaller(BaseInstaller):
|
|||||||
print(f"Ansible roles found in {ctx.identifier}, installing...")
|
print(f"Ansible roles found in {ctx.identifier}, installing...")
|
||||||
cmd = f"{galaxy_cmd} role install -r {tmp_filename}"
|
cmd = f"{galaxy_cmd} role install -r {tmp_filename}"
|
||||||
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
# pkgmgr/installers/aur.py
|
#!/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 os
|
||||||
import shutil
|
import shutil
|
||||||
import yaml
|
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
from pkgmgr.installers.base import BaseInstaller
|
from pkgmgr.installers.base import BaseInstaller
|
||||||
from pkgmgr.context import RepoContext
|
from pkgmgr.context import RepoContext
|
||||||
from pkgmgr.run_command import run_command
|
from pkgmgr.run_command import run_command
|
||||||
@@ -16,11 +30,6 @@ AUR_CONFIG_FILENAME = "aur.yml"
|
|||||||
class AurInstaller(BaseInstaller):
|
class AurInstaller(BaseInstaller):
|
||||||
"""
|
"""
|
||||||
Installer for Arch AUR dependencies declared in an `aur.yml` file.
|
Installer for Arch AUR dependencies declared in an `aur.yml` file.
|
||||||
|
|
||||||
This installer is:
|
|
||||||
- Arch-only (requires `pacman`)
|
|
||||||
- optional helper-driven (yay/paru/..)
|
|
||||||
- safe to ignore on non-Arch systems
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _is_arch_like(self) -> bool:
|
def _is_arch_like(self) -> bool:
|
||||||
@@ -30,6 +39,12 @@ class AurInstaller(BaseInstaller):
|
|||||||
return os.path.join(ctx.repo_dir, AUR_CONFIG_FILENAME)
|
return os.path.join(ctx.repo_dir, AUR_CONFIG_FILENAME)
|
||||||
|
|
||||||
def _load_config(self, ctx: RepoContext) -> dict:
|
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)
|
path = self._config_path(ctx)
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
return {}
|
return {}
|
||||||
@@ -38,12 +53,12 @@ class AurInstaller(BaseInstaller):
|
|||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
data = yaml.safe_load(f) or {}
|
data = yaml.safe_load(f) or {}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"[Warning] Failed to load AUR config from '{path}': {exc}")
|
print(f"[Error] Failed to load AUR config from '{path}': {exc}")
|
||||||
return {}
|
raise SystemExit(f"AUR config '{path}' could not be parsed: {exc}")
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
print(f"[Warning] AUR config '{path}' is not a mapping. Ignoring.")
|
print(f"[Error] AUR config '{path}' is not a mapping.")
|
||||||
return {}
|
raise SystemExit(f"AUR config '{path}' must be a mapping at top level.")
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@@ -85,6 +100,8 @@ class AurInstaller(BaseInstaller):
|
|||||||
- We are on an Arch-like system (pacman available),
|
- We are on an Arch-like system (pacman available),
|
||||||
- An aur.yml exists,
|
- An aur.yml exists,
|
||||||
- That aur.yml declares at least one package.
|
- That aur.yml declares at least one package.
|
||||||
|
|
||||||
|
An invalid aur.yml will raise SystemExit during config loading.
|
||||||
"""
|
"""
|
||||||
if not self._is_arch_like():
|
if not self._is_arch_like():
|
||||||
return False
|
return False
|
||||||
@@ -99,6 +116,9 @@ class AurInstaller(BaseInstaller):
|
|||||||
def run(self, ctx: RepoContext) -> None:
|
def run(self, ctx: RepoContext) -> None:
|
||||||
"""
|
"""
|
||||||
Install AUR packages using the configured helper (default: yay).
|
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():
|
if not self._is_arch_like():
|
||||||
print("AUR installer skipped: not an Arch-like system.")
|
print("AUR installer skipped: not an Arch-like system.")
|
||||||
@@ -127,5 +147,4 @@ class AurInstaller(BaseInstaller):
|
|||||||
print(f"Installing AUR packages via '{helper}': {pkg_list_str}")
|
print(f"Installing AUR packages via '{helper}': {pkg_list_str}")
|
||||||
|
|
||||||
cmd = f"{helper} -S --noconfirm {pkg_list_str}"
|
cmd = f"{helper} -S --noconfirm {pkg_list_str}"
|
||||||
# We respect preview mode to allow dry runs.
|
|
||||||
run_command(cmd, preview=ctx.preview)
|
run_command(cmd, preview=ctx.preview)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Base interface for all installer components in the pkgmgr installation pipeline.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
from pkgmgr.context import RepoContext
|
from pkgmgr.context import RepoContext
|
||||||
|
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ class BaseInstaller(ABC):
|
|||||||
A single step in the installation pipeline for a repository.
|
A single step in the installation pipeline for a repository.
|
||||||
|
|
||||||
Implementations should be small and focused on one technology or manifest
|
Implementations should be small and focused on one technology or manifest
|
||||||
type (e.g. PKGBUILD, Nix, Python, Ansible).
|
type (e.g. PKGBUILD, Nix, Python, Ansible, pkgmgr.yml).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -22,6 +23,9 @@ class BaseInstaller(ABC):
|
|||||||
"""
|
"""
|
||||||
Return True if this installer should run for the given repository
|
Return True if this installer should run for the given repository
|
||||||
context. This is typically based on file existence or platform checks.
|
context. This is typically based on file existence or platform checks.
|
||||||
|
|
||||||
|
Implementations must never swallow critical errors silently; if a
|
||||||
|
configuration is broken, they should raise SystemExit.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@@ -29,6 +33,9 @@ class BaseInstaller(ABC):
|
|||||||
def run(self, ctx: RepoContext) -> None:
|
def run(self, ctx: RepoContext) -> None:
|
||||||
"""
|
"""
|
||||||
Execute the installer logic for the given repository context.
|
Execute the installer logic for the given repository context.
|
||||||
Implementations may raise SystemExit via run_command() on errors.
|
|
||||||
|
Implementations are allowed to raise SystemExit (for example via
|
||||||
|
run_command()) on errors. Such failures are considered fatal for
|
||||||
|
the installation pipeline.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|||||||
@@ -25,8 +25,11 @@ class MakefileInstaller(BaseInstaller):
|
|||||||
return os.path.exists(makefile_path)
|
return os.path.exists(makefile_path)
|
||||||
|
|
||||||
def run(self, ctx: RepoContext) -> None:
|
def run(self, ctx: RepoContext) -> None:
|
||||||
|
"""
|
||||||
|
Execute `make install` in the repository directory.
|
||||||
|
|
||||||
|
Any failure in `make install` is treated as a fatal error and will
|
||||||
|
propagate as SystemExit from run_command().
|
||||||
|
"""
|
||||||
cmd = "make install"
|
cmd = "make install"
|
||||||
try:
|
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||||
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
|
||||||
except SystemExit as exc:
|
|
||||||
print(f"[Warning] Failed to run '{cmd}' for {ctx.identifier}: {exc}")
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
Installer for Nix flakes.
|
Installer for Nix flakes.
|
||||||
|
|
||||||
If a repository contains flake.nix and the 'nix' command is available, this
|
If a repository contains flake.nix and the 'nix' command is available, this
|
||||||
installer will try to install a profile output from the flake.
|
installer will try to install profile outputs from the flake.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -22,12 +22,22 @@ class NixFlakeInstaller(BaseInstaller):
|
|||||||
FLAKE_FILE = "flake.nix"
|
FLAKE_FILE = "flake.nix"
|
||||||
|
|
||||||
def supports(self, ctx: RepoContext) -> bool:
|
def supports(self, ctx: RepoContext) -> bool:
|
||||||
|
"""
|
||||||
|
Only support repositories that:
|
||||||
|
- Have a flake.nix
|
||||||
|
- And have the `nix` command available.
|
||||||
|
"""
|
||||||
if shutil.which("nix") is None:
|
if shutil.which("nix") is None:
|
||||||
return False
|
return False
|
||||||
flake_path = os.path.join(ctx.repo_dir, self.FLAKE_FILE)
|
flake_path = os.path.join(ctx.repo_dir, self.FLAKE_FILE)
|
||||||
return os.path.exists(flake_path)
|
return os.path.exists(flake_path)
|
||||||
|
|
||||||
def run(self, ctx: RepoContext) -> None:
|
def run(self, ctx: RepoContext) -> None:
|
||||||
|
"""
|
||||||
|
Install Nix flake profile outputs (pkgmgr, default).
|
||||||
|
|
||||||
|
Any failure in `nix profile install` is treated as fatal (SystemExit).
|
||||||
|
"""
|
||||||
flake_path = os.path.join(ctx.repo_dir, self.FLAKE_FILE)
|
flake_path = os.path.join(ctx.repo_dir, self.FLAKE_FILE)
|
||||||
if not os.path.exists(flake_path):
|
if not os.path.exists(flake_path):
|
||||||
return
|
return
|
||||||
@@ -43,5 +53,6 @@ class NixFlakeInstaller(BaseInstaller):
|
|||||||
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||||
print(f"Nix flake output '{output}' successfully installed.")
|
print(f"Nix flake output '{output}' successfully installed.")
|
||||||
except SystemExit as e:
|
except SystemExit as e:
|
||||||
print(f"[Warning] Failed to install Nix flake output '{output}': {e}")
|
print(f"[Error] Failed to install Nix flake output '{output}': {e}")
|
||||||
|
# Hard fail: a broken flake is considered a fatal error.
|
||||||
|
raise
|
||||||
|
|||||||
@@ -32,10 +32,8 @@ class PkgbuildInstaller(BaseInstaller):
|
|||||||
def _extract_pkgbuild_array(self, ctx: RepoContext, var_name: str) -> List[str]:
|
def _extract_pkgbuild_array(self, ctx: RepoContext, var_name: str) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Extract a Bash array (depends/makedepends) from PKGBUILD using bash itself.
|
Extract a Bash array (depends/makedepends) from PKGBUILD using bash itself.
|
||||||
Returns a list of package names or an empty list on error.
|
|
||||||
|
|
||||||
Uses a minimal shell environment (no profile/rc) to avoid noise from MOTD
|
Any failure in sourcing or extracting the variable is treated as fatal.
|
||||||
or interactive shell banners polluting the output.
|
|
||||||
"""
|
"""
|
||||||
pkgbuild_path = os.path.join(ctx.repo_dir, self.PKGBUILD_NAME)
|
pkgbuild_path = os.path.join(ctx.repo_dir, self.PKGBUILD_NAME)
|
||||||
if not os.path.exists(pkgbuild_path):
|
if not os.path.exists(pkgbuild_path):
|
||||||
@@ -48,8 +46,14 @@ class PkgbuildInstaller(BaseInstaller):
|
|||||||
cwd=ctx.repo_dir,
|
cwd=ctx.repo_dir,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
return []
|
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] = []
|
packages: List[str] = []
|
||||||
for line in output.splitlines():
|
for line in output.splitlines():
|
||||||
|
|||||||
@@ -28,12 +28,19 @@ class PkgmgrManifestInstaller(BaseInstaller):
|
|||||||
return os.path.exists(manifest_path)
|
return os.path.exists(manifest_path)
|
||||||
|
|
||||||
def _load_manifest(self, manifest_path: str) -> Dict[str, Any]:
|
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:
|
try:
|
||||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||||
return yaml.safe_load(f) or {}
|
return yaml.safe_load(f) or {}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"Error loading {self.MANIFEST_NAME} in '{manifest_path}': {exc}")
|
print(f"Error loading {self.MANIFEST_NAME} in '{manifest_path}': {exc}")
|
||||||
return {}
|
raise SystemExit(
|
||||||
|
f"{self.MANIFEST_NAME} parsing failed for '{manifest_path}': {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
def _collect_dependency_ids(self, dependencies: List[Dict[str, Any]]) -> List[str]:
|
def _collect_dependency_ids(self, dependencies: List[Dict[str, Any]]) -> List[str]:
|
||||||
ids: List[str] = []
|
ids: List[str] = []
|
||||||
@@ -70,14 +77,12 @@ class PkgmgrManifestInstaller(BaseInstaller):
|
|||||||
|
|
||||||
dep_repo_ids = self._collect_dependency_ids(dependencies)
|
dep_repo_ids = self._collect_dependency_ids(dependencies)
|
||||||
|
|
||||||
|
# Optionally pull dependencies if requested.
|
||||||
if ctx.update_dependencies and dep_repo_ids:
|
if ctx.update_dependencies and dep_repo_ids:
|
||||||
cmd_pull = "pkgmgr pull " + " ".join(dep_repo_ids)
|
cmd_pull = "pkgmgr pull " + " ".join(dep_repo_ids)
|
||||||
try:
|
run_command(cmd_pull, preview=ctx.preview)
|
||||||
run_command(cmd_pull, preview=ctx.preview)
|
|
||||||
except SystemExit as exc:
|
|
||||||
print(f"Warning: 'pkgmgr pull' for dependencies failed (exit code {exc}).")
|
|
||||||
|
|
||||||
# Install dependencies one by one
|
# Install dependencies one by one.
|
||||||
for dep in dependencies:
|
for dep in dependencies:
|
||||||
if not isinstance(dep, dict):
|
if not isinstance(dep, dict):
|
||||||
continue
|
continue
|
||||||
@@ -108,7 +113,5 @@ class PkgmgrManifestInstaller(BaseInstaller):
|
|||||||
if ctx.clone_mode:
|
if ctx.clone_mode:
|
||||||
cmd += f" --clone-mode {ctx.clone_mode}"
|
cmd += f" --clone-mode {ctx.clone_mode}"
|
||||||
|
|
||||||
try:
|
# Dependency installation failures are fatal.
|
||||||
run_command(cmd, preview=ctx.preview)
|
run_command(cmd, preview=ctx.preview)
|
||||||
except SystemExit as exc:
|
|
||||||
print(f"[Warning] Failed to install dependency '{repo_id}': {exc}")
|
|
||||||
|
|||||||
@@ -1,31 +1,35 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
"""
|
||||||
|
Installer for Python projects based on pyproject.toml and/or requirements.txt.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
- Determine a pip command in this order:
|
||||||
|
1. $PKGMGR_PIP (explicit override, e.g. ~/.venvs/pkgmgr/bin/pip)
|
||||||
|
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).
|
||||||
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from .base import BaseInstaller
|
from pkgmgr.installers.base import BaseInstaller
|
||||||
from pkgmgr.run_command import run_command
|
from pkgmgr.run_command import run_command
|
||||||
|
|
||||||
|
|
||||||
class PythonInstaller(BaseInstaller):
|
class PythonInstaller(BaseInstaller):
|
||||||
"""
|
"""Install Python projects and dependencies via pip."""
|
||||||
Install Python projects based on pyproject.toml and/or requirements.txt.
|
|
||||||
|
|
||||||
Strategy:
|
|
||||||
- Determine a pip command in this order:
|
|
||||||
1. $PKGMGR_PIP (explicit override, e.g. ~/.venvs/pkgmgr/bin/pip)
|
|
||||||
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
|
|
||||||
"""
|
|
||||||
|
|
||||||
name = "python"
|
name = "python"
|
||||||
|
|
||||||
def supports(self, ctx) -> bool:
|
def supports(self, ctx) -> bool:
|
||||||
"""
|
"""
|
||||||
Return True if this installer should handle the given repository.
|
Return True if this installer should handle the given repository.
|
||||||
|
|
||||||
ctx must provide:
|
|
||||||
- repo_dir: filesystem path to the repository
|
|
||||||
"""
|
"""
|
||||||
repo_dir = ctx.repo_dir
|
repo_dir = ctx.repo_dir
|
||||||
return (
|
return (
|
||||||
@@ -37,24 +41,20 @@ class PythonInstaller(BaseInstaller):
|
|||||||
"""
|
"""
|
||||||
Resolve the pip command to use.
|
Resolve the pip command to use.
|
||||||
"""
|
"""
|
||||||
# 1) Explicit override via environment variable
|
|
||||||
explicit = os.environ.get("PKGMGR_PIP", "").strip()
|
explicit = os.environ.get("PKGMGR_PIP", "").strip()
|
||||||
if explicit:
|
if explicit:
|
||||||
return explicit
|
return explicit
|
||||||
|
|
||||||
# 2) Current Python interpreter (works well in Nix/dev shells)
|
|
||||||
if sys.executable:
|
if sys.executable:
|
||||||
return f"{sys.executable} -m pip"
|
return f"{sys.executable} -m pip"
|
||||||
|
|
||||||
# 3) Fallback to plain pip
|
|
||||||
return "pip"
|
return "pip"
|
||||||
|
|
||||||
def run(self, ctx) -> None:
|
def run(self, ctx) -> None:
|
||||||
"""
|
"""
|
||||||
ctx must provide:
|
Install Python project (pyproject.toml) and/or requirements.txt.
|
||||||
- repo_dir: path to repository
|
|
||||||
- identifier: human readable name
|
Any pip failure is propagated as SystemExit.
|
||||||
- preview: bool
|
|
||||||
"""
|
"""
|
||||||
pip_cmd = self._pip_cmd()
|
pip_cmd = self._pip_cmd()
|
||||||
|
|
||||||
@@ -65,12 +65,7 @@ class PythonInstaller(BaseInstaller):
|
|||||||
f"installing Python project..."
|
f"installing Python project..."
|
||||||
)
|
)
|
||||||
cmd = f"{pip_cmd} install ."
|
cmd = f"{pip_cmd} install ."
|
||||||
try:
|
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||||
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
|
||||||
except SystemExit as exc:
|
|
||||||
print(
|
|
||||||
f"[Warning] Failed to install Python project in {ctx.identifier}: {exc}"
|
|
||||||
)
|
|
||||||
|
|
||||||
req_txt = os.path.join(ctx.repo_dir, "requirements.txt")
|
req_txt = os.path.join(ctx.repo_dir, "requirements.txt")
|
||||||
if os.path.exists(req_txt):
|
if os.path.exists(req_txt):
|
||||||
@@ -79,11 +74,4 @@ class PythonInstaller(BaseInstaller):
|
|||||||
f"installing Python dependencies..."
|
f"installing Python dependencies..."
|
||||||
)
|
)
|
||||||
cmd = f"{pip_cmd} install -r requirements.txt"
|
cmd = f"{pip_cmd} install -r requirements.txt"
|
||||||
try:
|
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||||
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
|
||||||
except SystemExit as exc:
|
|
||||||
print(
|
|
||||||
f"[Warning] Failed to install Python dependencies in {ctx.identifier}: {exc}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import unittest
|
|||||||
|
|
||||||
|
|
||||||
class TestIntegrationInstallAllShallow(unittest.TestCase):
|
class TestIntegrationInstallAllShallow(unittest.TestCase):
|
||||||
def test_install_all_repositories_shallow(self):
|
def test_install_pkgmgr_self_install(self):
|
||||||
"""
|
"""
|
||||||
Run: pkgmgr install --all --clone-mode shallow --no-verification
|
Run: pkgmgr install --all --clone-mode shallow --no-verification
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user