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:
95
flake.nix
95
flake.nix
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user