Refactor pkgmgr self-install handling, add pip-enabled Python env to flake, and fix Nix/pip integration

- Rename requirements.txt to _requirements.txt to avoid unintended self-install via PythonInstaller.
- Update flake.nix to provide Python with pip in both devShell and runtime closure.
- Generate a wrapper for pkgmgr that uses the pip-enabled interpreter.
- Adjust NixFlakeInstaller to correctly handle old profile removal and install outputs.
- Update run_command to support string commands and allow_failure.
- Improve integration test by adding nix profile cleanup and debugging output.

Reference: https://chatgpt.com/share/69345df2-a960-800f-8395-92a7c3a6629f
This commit is contained in:
Kevin Veen-Birkenbach
2025-12-06 17:47:46 +01:00
parent 96a0409dbb
commit f57ab0c2d1
5 changed files with 180 additions and 102 deletions

View File

@@ -1,7 +1,7 @@
{ {
description = "Nix flake for Kevin's package-manager tool"; description = "Nix flake for Kevin's package-manager tool";
nixConfig = { nixConfig = {
extra-experimental-features = [ "nix-command" "flakes" ]; extra-experimental-features = [ "nix-command" "flakes" ];
}; };
@@ -23,9 +23,16 @@
# Dev shells: nix develop .#default (on both architectures) # Dev shells: nix develop .#default (on both architectures)
devShells = forAllSystems (system: devShells = forAllSystems (system:
let let
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
# Base Python interpreter
python = pkgs.python311; python = pkgs.python311;
pypkgs = pkgs.python311Packages;
# Python env with pip + pyyaml available, so `python -m pip` works
pythonEnv = python.withPackages (ps: with ps; [
pip
pyyaml
]);
# Be robust: ansible-core if available, otherwise ansible. # Be robust: ansible-core if available, otherwise ansible.
ansiblePkg = ansiblePkg =
@@ -34,8 +41,7 @@
in { in {
default = pkgs.mkShell { default = pkgs.mkShell {
buildInputs = [ buildInputs = [
python pythonEnv
pypkgs.pyyaml
pkgs.git pkgs.git
ansiblePkg ansiblePkg
]; ];
@@ -46,48 +52,59 @@
} }
); );
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;
# Optional: ansible mit in den Closure nehmen # Runtime Python for pkgmgr (with pip + pyyaml)
ansiblePkg = pythonEnv = python.withPackages (ps: with ps; [
if pkgs ? ansible-core then pkgs.ansible-core pip
else pkgs.ansible; pyyaml
in ]);
rec {
pkgmgr = pkgs.stdenv.mkDerivation {
pname = "package-manager";
version = "0.1.1";
src = ./.; # Optional: include Ansible in the runtime closure
ansiblePkg =
if pkgs ? ansible-core then pkgs.ansible-core
else pkgs.ansible;
in
rec {
pkgmgr = pkgs.stdenv.mkDerivation {
pname = "package-manager";
version = "0.1.1";
# Nix soll *kein* configure / build ausführen (also auch kein make) src = ./.;
dontConfigure = true;
dontBuild = true;
# Wenn du Python/Ansible im Runtime-Closure haben willst: # Nix should not run configure / build (no make)
buildInputs = [ dontConfigure = true;
python dontBuild = true;
pypkgs.pyyaml
ansiblePkg
];
installPhase = '' # Runtime deps: Python (with pip) + Ansible
mkdir -p "$out/bin" buildInputs = [
cp main.py "$out/bin/pkgmgr" pythonEnv
chmod +x "$out/bin/pkgmgr" ansiblePkg
''; ];
};
# default package just points to pkgmgr installPhase = ''
default = pkgmgr; mkdir -p "$out/bin"
}
);
# Wrapper that always uses the pythonEnv interpreter, so
# sys.executable -m pip has a working pip.
cat > "$out/bin/pkgmgr" << EOF
#!${pythonEnv}/bin/python3
import runpy
if __name__ == "__main__":
runpy.run_module("main", run_name="__main__")
EOF
chmod +x "$out/bin/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:

View File

@@ -10,17 +10,22 @@ installer will try to install profile outputs from the flake.
Behavior: Behavior:
- If flake.nix is present and `nix` exists on PATH: - If flake.nix is present and `nix` exists on PATH:
* First remove any existing `package-manager` profile entry (best-effort). * First remove any existing `package-manager` profile entry (best-effort).
* Then install the flake outputs (pkgmgr, default) via `nix profile install`. * Then install the flake outputs (`pkgmgr`, `default`) via `nix profile install`.
- Any failure in `nix profile install` is treated as fatal (SystemExit). - Failure installing `pkgmgr` is treated as fatal.
- Failure installing `default` is logged as an error/warning but does not abort.
""" """
import os import os
import shutil import shutil
from typing import TYPE_CHECKING
from pkgmgr.context import RepoContext
from pkgmgr.installers.base import BaseInstaller from pkgmgr.installers.base import BaseInstaller
from pkgmgr.run_command import run_command from pkgmgr.run_command import run_command
if TYPE_CHECKING:
from pkgmgr.context import RepoContext
from pkgmgr.install_repos import InstallContext
class NixFlakeInstaller(BaseInstaller): class NixFlakeInstaller(BaseInstaller):
"""Install Nix flake profiles for repositories that define flake.nix.""" """Install Nix flake profiles for repositories that define flake.nix."""
@@ -28,7 +33,7 @@ class NixFlakeInstaller(BaseInstaller):
FLAKE_FILE = "flake.nix" FLAKE_FILE = "flake.nix"
PROFILE_NAME = "package-manager" PROFILE_NAME = "package-manager"
def supports(self, ctx: RepoContext) -> bool: def supports(self, ctx: "RepoContext") -> bool:
""" """
Only support repositories that: Only support repositories that:
- Have a flake.nix - Have a flake.nix
@@ -39,7 +44,7 @@ class NixFlakeInstaller(BaseInstaller):
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 _ensure_old_profile_removed(self, ctx: RepoContext) -> None: def _ensure_old_profile_removed(self, ctx: "RepoContext") -> None:
""" """
Best-effort removal of an existing profile entry. Best-effort removal of an existing profile entry.
@@ -53,45 +58,46 @@ class NixFlakeInstaller(BaseInstaller):
if shutil.which("nix") is None: if shutil.which("nix") is None:
return return
# We do NOT use run_command here, because we explicitly want to ignore
# the failure of `nix profile remove` (e.g. entry not present).
# Using `|| true` makes this idempotent.
cmd = f"nix profile remove {self.PROFILE_NAME} || true" cmd = f"nix profile remove {self.PROFILE_NAME} || true"
# This will still respect preview mode inside run_command.
try: try:
# NOTE: no allow_failure here → matches the existing unit tests
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview) run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
except SystemExit: except SystemExit:
# Ignore any error here: if the profile entry does not exist, # Unit tests explicitly assert this is swallowed
# that's fine and not a fatal condition.
pass pass
def run(self, ctx: RepoContext) -> None: def run(self, ctx: "InstallContext") -> None:
""" """
Install Nix flake profile outputs (pkgmgr, default). Install Nix flake profile outputs (pkgmgr, default).
Any failure in `nix profile install` is treated as fatal (SystemExit). Any failure installing `pkgmgr` is treated as fatal (SystemExit).
The "already installed / file conflict" situation is avoided by A failure installing `default` is logged but does not abort.
removing the existing profile entry beforehand.
""" """
flake_path = os.path.join(ctx.repo_dir, self.FLAKE_FILE) # Reuse supports() to keep logic in one place
if not os.path.exists(flake_path): if not self.supports(ctx): # type: ignore[arg-type]
return
if shutil.which("nix") is None:
print("Warning: flake.nix found but 'nix' command not available. Skipping flake setup.")
return return
print("Nix flake detected, attempting to install profile outputs...") print("Nix flake detected, attempting to install profile outputs...")
# Handle the "already installed" case up-front: # Handle the "already installed" case up-front:
self._ensure_old_profile_removed(ctx) self._ensure_old_profile_removed(ctx) # type: ignore[arg-type]
for output in ("pkgmgr", "default"): for output in ("pkgmgr", "default"):
cmd = f"nix profile install {ctx.repo_dir}#{output}" cmd = f"nix profile install {ctx.repo_dir}#{output}"
try: try:
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview) # For 'default' we don't want the process to exit on error
allow_failure = output == "default"
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview, allow_failure=allow_failure)
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"[Error] 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. if output == "pkgmgr":
raise # Broken main CLI install → fatal
raise
# For 'default' we log and continue
print(
"[Warning] Continuing despite failure to install 'default' "
"because 'pkgmgr' is already installed."
)
break

View File

@@ -1,18 +1,45 @@
import sys # pkgmgr/run_command.py
import subprocess import subprocess
import os import sys
from typing import List, Optional, Union
def run_command(command, cwd=None, preview=False):
"""Run a shell command in a given directory, or print it in preview mode. CommandType = Union[str, List[str]]
If the command fails, exit the program with the command's exit code.
def run_command(
cmd: CommandType,
cwd: Optional[str] = None,
preview: bool = False,
allow_failure: bool = False,
) -> subprocess.CompletedProcess:
""" """
current_dir = cwd or os.getcwd() Run a command and optionally exit on error.
if preview:
print(f"[Preview] In '{current_dir}': {command}") - If `cmd` is a string, it is executed with `shell=True`.
- If `cmd` is a list of strings, it is executed without a shell.
"""
if isinstance(cmd, str):
display = cmd
else: else:
print(f"Running in '{current_dir}': {command}") display = " ".join(cmd)
result = subprocess.run(command, cwd=cwd, shell=True, check=False)
if result.returncode != 0: where = cwd or "."
print(f"Command failed with exit code {result.returncode}. Exiting.")
sys.exit(result.returncode) if preview:
print(f"[Preview] In '{where}': {display}")
# Fake a successful result; most callers ignore the return value anyway
return subprocess.CompletedProcess(cmd, 0) # type: ignore[arg-type]
print(f"Running in '{where}': {display}")
if isinstance(cmd, str):
result = subprocess.run(cmd, cwd=cwd, shell=True)
else:
result = subprocess.run(cmd, cwd=cwd)
if result.returncode != 0 and not allow_failure:
print(f"Command failed with exit code {result.returncode}. Exiting.")
sys.exit(result.returncode)
return result

View File

@@ -1,43 +1,71 @@
"""
Integration test: install all configured repositories using
--clone-mode shallow (HTTPS shallow clone) and --no-verification.
This test is intended to be run inside the Docker container where:
- network access is available,
- the config/config.yaml is present,
- and it is safe to perform real git operations.
It passes if the command completes without raising an exception.
"""
import runpy import runpy
import sys import sys
import unittest import unittest
import subprocess
class TestIntegrationInstallAllShallow(unittest.TestCase): def nix_profile_list_debug(label: str) -> None:
def test_install_pkgmgr_self_install(self): """
""" Print `nix profile list` for debugging inside the test container.
Run: pkgmgr install --all --clone-mode shallow --no-verification Never fails the test.
"""
print(f"\n--- NIX PROFILE LIST ({label}) ---")
proc = subprocess.run(
["nix", "profile", "list"],
capture_output=True,
text=True,
check=False,
)
stdout = proc.stdout.strip()
stderr = proc.stderr.strip()
if stdout:
print(stdout)
if stderr:
print("stderr:", stderr)
print("--- END ---\n")
def remove_pkgmgr_from_nix_profile() -> None:
"""
Best-effort cleanup before running the integration test.
We *do not* try to parse profile indices here, because modern `nix profile list`
prints a descriptive format without an index column inside the container.
Instead, we directly try to remove possible names:
- 'pkgmgr' (the actual name shown in `nix profile list`)
- 'package-manager' (the name mentioned in Nix's own error hints)
"""
for spec in ("pkgmgr", "package-manager"):
subprocess.run(
["nix", "profile", "remove", spec],
check=False, # never fail on cleanup
)
class TestIntegrationInstalPKGMGRShallow(unittest.TestCase):
def test_install_pkgmgr_self_install(self) -> None:
# Debug before cleanup
nix_profile_list_debug("BEFORE CLEANUP")
# Cleanup: aggressively try to drop any pkgmgr/profile entries
remove_pkgmgr_from_nix_profile()
# Debug after cleanup
nix_profile_list_debug("AFTER CLEANUP")
This will perform real installations/clones inside the container.
The test succeeds if no exception is raised.
"""
original_argv = sys.argv original_argv = sys.argv
try: try:
sys.argv = [ sys.argv = [
"pkgmgr", "python",
"install", "install",
"pkgmgr", "pkgmgr",
"--clone-mode", "--clone-mode",
"shallow", "shallow",
"--no-verification", "--no-verification",
] ]
# Execute main.py as if it was called from CLI.
# This will run the full install pipeline inside the container.
runpy.run_module("main", run_name="__main__") runpy.run_module("main", run_name="__main__")
finally: finally:
sys.argv = original_argv sys.argv = original_argv