Merge branch 'fix/self-install'
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / codesniffer-shellcheck (push) Has been cancelled
Mark stable commit / codesniffer-ruff (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / codesniffer-shellcheck (push) Has been cancelled
Mark stable commit / codesniffer-ruff (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
This commit is contained in:
@@ -49,7 +49,7 @@ docker run --rm \
|
|||||||
# Gitdir path shown in the "dubious ownership" error
|
# Gitdir path shown in the "dubious ownership" error
|
||||||
git config --global --add safe.directory /src/.git || true
|
git config --global --add safe.directory /src/.git || true
|
||||||
# Ephemeral CI containers: allow all paths as a last resort
|
# Ephemeral CI containers: allow all paths as a last resort
|
||||||
git config --global --add safe.directory '*' || true
|
git config --global --add safe.directory "*" || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Run the E2E tests inside the Nix development shell
|
# Run the E2E tests inside the Nix development shell
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# src/pkgmgr/actions/install/__init__.py
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
@@ -36,10 +37,8 @@ from pkgmgr.actions.install.installers.makefile import (
|
|||||||
)
|
)
|
||||||
from pkgmgr.actions.install.pipeline import InstallationPipeline
|
from pkgmgr.actions.install.pipeline import InstallationPipeline
|
||||||
|
|
||||||
|
|
||||||
Repository = Dict[str, Any]
|
Repository = Dict[str, Any]
|
||||||
|
|
||||||
# All available installers, in the order they should be considered.
|
|
||||||
INSTALLERS = [
|
INSTALLERS = [
|
||||||
ArchPkgbuildInstaller(),
|
ArchPkgbuildInstaller(),
|
||||||
DebianControlInstaller(),
|
DebianControlInstaller(),
|
||||||
@@ -50,11 +49,6 @@ INSTALLERS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Internal helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_repo_dir(
|
def _ensure_repo_dir(
|
||||||
repo: Repository,
|
repo: Repository,
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
@@ -137,6 +131,7 @@ def _create_context(
|
|||||||
quiet: bool,
|
quiet: bool,
|
||||||
clone_mode: str,
|
clone_mode: str,
|
||||||
update_dependencies: bool,
|
update_dependencies: bool,
|
||||||
|
force_update: bool,
|
||||||
) -> RepoContext:
|
) -> RepoContext:
|
||||||
"""
|
"""
|
||||||
Build a RepoContext instance for the given repository.
|
Build a RepoContext instance for the given repository.
|
||||||
@@ -153,14 +148,10 @@ def _create_context(
|
|||||||
quiet=quiet,
|
quiet=quiet,
|
||||||
clone_mode=clone_mode,
|
clone_mode=clone_mode,
|
||||||
update_dependencies=update_dependencies,
|
update_dependencies=update_dependencies,
|
||||||
|
force_update=force_update,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Public API
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def install_repos(
|
def install_repos(
|
||||||
selected_repos: List[Repository],
|
selected_repos: List[Repository],
|
||||||
repositories_base_dir: str,
|
repositories_base_dir: str,
|
||||||
@@ -171,10 +162,14 @@ def install_repos(
|
|||||||
quiet: bool,
|
quiet: bool,
|
||||||
clone_mode: str,
|
clone_mode: str,
|
||||||
update_dependencies: bool,
|
update_dependencies: bool,
|
||||||
|
force_update: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Install one or more repositories according to the configured installers
|
Install one or more repositories according to the configured installers
|
||||||
and the CLI layer precedence rules.
|
and the CLI layer precedence rules.
|
||||||
|
|
||||||
|
If force_update=True, installers of the currently active layer are allowed
|
||||||
|
to run again (upgrade/refresh), even if that layer is already loaded.
|
||||||
"""
|
"""
|
||||||
pipeline = InstallationPipeline(INSTALLERS)
|
pipeline = InstallationPipeline(INSTALLERS)
|
||||||
|
|
||||||
@@ -213,6 +208,7 @@ def install_repos(
|
|||||||
quiet=quiet,
|
quiet=quiet,
|
||||||
clone_mode=clone_mode,
|
clone_mode=clone_mode,
|
||||||
update_dependencies=update_dependencies,
|
update_dependencies=update_dependencies,
|
||||||
|
force_update=force_update,
|
||||||
)
|
)
|
||||||
|
|
||||||
pipeline.run(ctx)
|
pipeline.run(ctx)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# src/pkgmgr/actions/install/context.py
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
@@ -28,3 +29,6 @@ class RepoContext:
|
|||||||
quiet: bool
|
quiet: bool
|
||||||
clone_mode: str
|
clone_mode: str
|
||||||
update_dependencies: bool
|
update_dependencies: bool
|
||||||
|
|
||||||
|
# If True, allow re-running installers of the currently active layer.
|
||||||
|
force_update: bool = False
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# src/pkgmgr/actions/install/installers/makefile.py
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -9,89 +10,45 @@ from pkgmgr.core.command.run import run_command
|
|||||||
|
|
||||||
|
|
||||||
class MakefileInstaller(BaseInstaller):
|
class MakefileInstaller(BaseInstaller):
|
||||||
"""
|
|
||||||
Generic installer that runs `make install` if a Makefile with an
|
|
||||||
install target is present.
|
|
||||||
|
|
||||||
Safety rules:
|
|
||||||
- If PKGMGR_DISABLE_MAKEFILE_INSTALLER=1 is set, this installer
|
|
||||||
is globally disabled.
|
|
||||||
- The higher-level InstallationPipeline ensures that Makefile
|
|
||||||
installation does not run if a stronger CLI layer already owns
|
|
||||||
the command (e.g. Nix or OS packages).
|
|
||||||
"""
|
|
||||||
|
|
||||||
layer = "makefile"
|
layer = "makefile"
|
||||||
MAKEFILE_NAME = "Makefile"
|
MAKEFILE_NAME = "Makefile"
|
||||||
|
|
||||||
def supports(self, ctx: RepoContext) -> bool:
|
def supports(self, ctx: RepoContext) -> bool:
|
||||||
"""
|
|
||||||
Return True if this repository has a Makefile and the installer
|
|
||||||
is not globally disabled.
|
|
||||||
"""
|
|
||||||
# Optional global kill switch.
|
|
||||||
if os.environ.get("PKGMGR_DISABLE_MAKEFILE_INSTALLER") == "1":
|
if os.environ.get("PKGMGR_DISABLE_MAKEFILE_INSTALLER") == "1":
|
||||||
if not ctx.quiet:
|
if not ctx.quiet:
|
||||||
print(
|
print("[INFO] PKGMGR_DISABLE_MAKEFILE_INSTALLER=1 – skipping MakefileInstaller.")
|
||||||
"[INFO] MakefileInstaller is disabled via "
|
|
||||||
"PKGMGR_DISABLE_MAKEFILE_INSTALLER."
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
makefile_path = os.path.join(ctx.repo_dir, self.MAKEFILE_NAME)
|
makefile_path = os.path.join(ctx.repo_dir, self.MAKEFILE_NAME)
|
||||||
return os.path.exists(makefile_path)
|
return os.path.exists(makefile_path)
|
||||||
|
|
||||||
def _has_install_target(self, makefile_path: str) -> bool:
|
def _has_install_target(self, makefile_path: str) -> bool:
|
||||||
"""
|
|
||||||
Heuristically check whether the Makefile defines an install target.
|
|
||||||
|
|
||||||
We look for:
|
|
||||||
|
|
||||||
- a plain 'install:' target, or
|
|
||||||
- any 'install-*:' style target.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
with open(makefile_path, "r", encoding="utf-8", errors="ignore") as f:
|
with open(makefile_path, "r", encoding="utf-8", errors="ignore") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
except OSError:
|
except OSError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Simple heuristics: look for "install:" or targets starting with "install-"
|
|
||||||
if re.search(r"^install\s*:", content, flags=re.MULTILINE):
|
if re.search(r"^install\s*:", content, flags=re.MULTILINE):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if re.search(r"^install-[a-zA-Z0-9_-]*\s*:", content, flags=re.MULTILINE):
|
if re.search(r"^install-[a-zA-Z0-9_-]*\s*:", content, flags=re.MULTILINE):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def run(self, ctx: RepoContext) -> None:
|
def run(self, ctx: RepoContext) -> None:
|
||||||
"""
|
|
||||||
Execute `make install` in the repository directory if an install
|
|
||||||
target exists.
|
|
||||||
"""
|
|
||||||
makefile_path = os.path.join(ctx.repo_dir, self.MAKEFILE_NAME)
|
makefile_path = os.path.join(ctx.repo_dir, self.MAKEFILE_NAME)
|
||||||
|
|
||||||
if not os.path.exists(makefile_path):
|
if not os.path.exists(makefile_path):
|
||||||
if not ctx.quiet:
|
|
||||||
print(
|
|
||||||
f"[pkgmgr] Makefile '{makefile_path}' not found, "
|
|
||||||
"skipping MakefileInstaller."
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self._has_install_target(makefile_path):
|
if not self._has_install_target(makefile_path):
|
||||||
if not ctx.quiet:
|
if not ctx.quiet:
|
||||||
print(
|
print(f"[pkgmgr] No 'install' target found in {makefile_path}.")
|
||||||
f"[pkgmgr] No 'install' target found in {makefile_path}."
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if not ctx.quiet:
|
if not ctx.quiet:
|
||||||
print(
|
print(f"[pkgmgr] Running make install for {ctx.identifier} (MakefileInstaller)")
|
||||||
f"[pkgmgr] Running 'make install' in {ctx.repo_dir} "
|
|
||||||
"(MakefileInstaller)"
|
|
||||||
)
|
|
||||||
|
|
||||||
cmd = "make install"
|
run_command("make install", cwd=ctx.repo_dir, preview=ctx.preview)
|
||||||
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
|
||||||
|
if ctx.force_update and not ctx.quiet:
|
||||||
|
print(f"[makefile] repo '{ctx.identifier}' successfully upgraded.")
|
||||||
|
|||||||
@@ -1,32 +1,12 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
"""
|
from __future__ import annotations
|
||||||
Installer for Nix flakes.
|
|
||||||
|
|
||||||
If a repository contains flake.nix and the 'nix' command is available, this
|
|
||||||
installer will try to install profile outputs from the flake.
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
- If flake.nix is present and `nix` exists on PATH:
|
|
||||||
* First remove any existing `package-manager` profile entry (best-effort).
|
|
||||||
* Then install one or more flake outputs via `nix profile install`.
|
|
||||||
- For the package-manager repo:
|
|
||||||
* `pkgmgr` is mandatory (CLI), `default` is optional.
|
|
||||||
- For all other repos:
|
|
||||||
* `default` is mandatory.
|
|
||||||
|
|
||||||
Special handling:
|
|
||||||
- If PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 is set, the installer is
|
|
||||||
globally disabled (useful for CI or debugging).
|
|
||||||
|
|
||||||
The higher-level InstallationPipeline and CLI-layer model decide when this
|
|
||||||
installer is allowed to run, based on where the current CLI comes from
|
|
||||||
(e.g. Nix, OS packages, Python, Makefile).
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import subprocess
|
||||||
from typing import TYPE_CHECKING, List, Tuple
|
from typing import TYPE_CHECKING, List, Tuple
|
||||||
|
|
||||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||||
@@ -34,132 +14,225 @@ from pkgmgr.core.command.run import run_command
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pkgmgr.actions.install.context import RepoContext
|
from pkgmgr.actions.install.context import RepoContext
|
||||||
from pkgmgr.actions.install import InstallContext
|
|
||||||
|
|
||||||
|
|
||||||
class NixFlakeInstaller(BaseInstaller):
|
class NixFlakeInstaller(BaseInstaller):
|
||||||
"""Install Nix flake profiles for repositories that define flake.nix."""
|
|
||||||
|
|
||||||
# Logical layer name, used by capability matchers.
|
|
||||||
layer = "nix"
|
layer = "nix"
|
||||||
|
|
||||||
FLAKE_FILE = "flake.nix"
|
FLAKE_FILE = "flake.nix"
|
||||||
PROFILE_NAME = "package-manager"
|
|
||||||
|
|
||||||
def supports(self, ctx: "RepoContext") -> bool:
|
def supports(self, ctx: "RepoContext") -> bool:
|
||||||
"""
|
|
||||||
Only support repositories that:
|
|
||||||
- Are NOT explicitly disabled via PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1,
|
|
||||||
- Have a flake.nix,
|
|
||||||
- And have the `nix` command available.
|
|
||||||
"""
|
|
||||||
# Optional global kill-switch for CI or debugging.
|
|
||||||
if os.environ.get("PKGMGR_DISABLE_NIX_FLAKE_INSTALLER") == "1":
|
if os.environ.get("PKGMGR_DISABLE_NIX_FLAKE_INSTALLER") == "1":
|
||||||
print(
|
if not ctx.quiet:
|
||||||
"[INFO] PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 – "
|
print("[INFO] PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 – skipping NixFlakeInstaller.")
|
||||||
"NixFlakeInstaller is disabled."
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Nix must be available.
|
|
||||||
if shutil.which("nix") is None:
|
if shutil.which("nix") is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# flake.nix must exist in the repository.
|
return os.path.exists(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)
|
|
||||||
|
|
||||||
def _ensure_old_profile_removed(self, ctx: "RepoContext") -> None:
|
|
||||||
"""
|
|
||||||
Best-effort removal of an existing profile entry.
|
|
||||||
|
|
||||||
This handles the "already provides the following file" conflict by
|
|
||||||
removing previous `package-manager` installations before we install
|
|
||||||
the new one.
|
|
||||||
|
|
||||||
Any error in `nix profile remove` is intentionally ignored, because
|
|
||||||
a missing profile entry is not a fatal condition.
|
|
||||||
"""
|
|
||||||
if shutil.which("nix") is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
cmd = f"nix profile remove {self.PROFILE_NAME} || true"
|
|
||||||
try:
|
|
||||||
# NOTE: no allow_failure here → matches the existing unit tests
|
|
||||||
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
|
||||||
except SystemExit:
|
|
||||||
# Unit tests explicitly assert this is swallowed
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _profile_outputs(self, ctx: "RepoContext") -> List[Tuple[str, bool]]:
|
def _profile_outputs(self, ctx: "RepoContext") -> List[Tuple[str, bool]]:
|
||||||
"""
|
# (output_name, allow_failure)
|
||||||
Decide which flake outputs to install and whether failures are fatal.
|
if ctx.identifier in {"pkgmgr", "package-manager"}:
|
||||||
|
|
||||||
Returns a list of (output_name, allow_failure) tuples.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- For the package-manager repo (identifier 'pkgmgr' or 'package-manager'):
|
|
||||||
[("pkgmgr", False), ("default", True)]
|
|
||||||
- For all other repos:
|
|
||||||
[("default", False)]
|
|
||||||
"""
|
|
||||||
ident = ctx.identifier
|
|
||||||
|
|
||||||
if ident in {"pkgmgr", "package-manager"}:
|
|
||||||
# pkgmgr: main CLI output is "pkgmgr" (mandatory),
|
|
||||||
# "default" is nice-to-have (non-fatal).
|
|
||||||
return [("pkgmgr", False), ("default", True)]
|
return [("pkgmgr", False), ("default", True)]
|
||||||
|
|
||||||
# Generic repos: we expect a sensible "default" package/app.
|
|
||||||
# Failure to install it is considered fatal.
|
|
||||||
return [("default", False)]
|
return [("default", False)]
|
||||||
|
|
||||||
def run(self, ctx: "InstallContext") -> None:
|
def _installable(self, ctx: "RepoContext", output: str) -> str:
|
||||||
"""
|
return f"{ctx.repo_dir}#{output}"
|
||||||
Install Nix flake profile outputs.
|
|
||||||
|
|
||||||
For the package-manager repo, failure installing 'pkgmgr' is fatal,
|
def _run(self, ctx: "RepoContext", cmd: str, allow_failure: bool = True):
|
||||||
failure installing 'default' is non-fatal.
|
return run_command(
|
||||||
For other repos, failure installing 'default' is fatal.
|
cmd,
|
||||||
"""
|
cwd=ctx.repo_dir,
|
||||||
# Reuse supports() to keep logic in one place.
|
preview=ctx.preview,
|
||||||
if not self.supports(ctx): # type: ignore[arg-type]
|
allow_failure=allow_failure,
|
||||||
return
|
|
||||||
|
|
||||||
outputs = self._profile_outputs(ctx) # list of (name, allow_failure)
|
|
||||||
|
|
||||||
print(
|
|
||||||
"Nix flake detected in "
|
|
||||||
f"{ctx.identifier}, attempting to install profile outputs: "
|
|
||||||
+ ", ".join(name for name, _ in outputs)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Handle the "already installed" case up-front for the shared profile.
|
def _profile_list_json(self, ctx: "RepoContext") -> dict:
|
||||||
self._ensure_old_profile_removed(ctx) # type: ignore[arg-type]
|
"""
|
||||||
|
Read current Nix profile entries as JSON (best-effort).
|
||||||
|
|
||||||
for output, allow_failure in outputs:
|
NOTE: Nix versions differ:
|
||||||
cmd = f"nix profile install {ctx.repo_dir}#{output}"
|
- Newer: {"elements": [ { "index": 0, "attrPath": "...", ... }, ... ]}
|
||||||
print(f"[INFO] Running: {cmd}")
|
- Older: {"elements": [ "nixpkgs#hello", ... ]} (strings)
|
||||||
ret = os.system(cmd)
|
|
||||||
|
|
||||||
# Extract real exit code from os.system() result
|
We return {} on failure or in preview mode.
|
||||||
if os.WIFEXITED(ret):
|
"""
|
||||||
exit_code = os.WEXITSTATUS(ret)
|
if ctx.preview:
|
||||||
else:
|
return {}
|
||||||
# abnormal termination (signal etc.) – keep raw value
|
|
||||||
exit_code = ret
|
|
||||||
|
|
||||||
if exit_code == 0:
|
proc = subprocess.run(
|
||||||
print(f"Nix flake output '{output}' successfully installed.")
|
["nix", "profile", "list", "--json"],
|
||||||
|
check=False,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
env=os.environ.copy(),
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(proc.stdout or "{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _find_installed_indices_for_output(self, ctx: "RepoContext", output: str) -> List[int]:
|
||||||
|
"""
|
||||||
|
Find installed profile indices for a given output.
|
||||||
|
|
||||||
|
Works across Nix JSON variants:
|
||||||
|
- If elements are dicts: we can extract indices.
|
||||||
|
- If elements are strings: we cannot extract indices -> return [].
|
||||||
|
"""
|
||||||
|
data = self._profile_list_json(ctx)
|
||||||
|
elements = data.get("elements", []) or []
|
||||||
|
|
||||||
|
matches: List[int] = []
|
||||||
|
|
||||||
|
for el in elements:
|
||||||
|
# Legacy JSON format: plain strings -> no index information
|
||||||
|
if not isinstance(el, dict):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
print(f"[Error] Failed to install Nix flake output '{output}'")
|
idx = el.get("index")
|
||||||
print(f"[Error] Command exited with code {exit_code}")
|
if idx is None:
|
||||||
|
continue
|
||||||
|
|
||||||
if not allow_failure:
|
attr_path = el.get("attrPath") or el.get("attr_path") or ""
|
||||||
raise SystemExit(exit_code)
|
pname = el.get("pname") or ""
|
||||||
|
name = el.get("name") or ""
|
||||||
|
|
||||||
|
if attr_path == output:
|
||||||
|
matches.append(int(idx))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if pname == output or name == output:
|
||||||
|
matches.append(int(idx))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(attr_path, str) and attr_path.endswith(f".{output}"):
|
||||||
|
matches.append(int(idx))
|
||||||
|
continue
|
||||||
|
|
||||||
|
return matches
|
||||||
|
|
||||||
|
def _upgrade_index(self, ctx: "RepoContext", index: int) -> bool:
|
||||||
|
cmd = f"nix profile upgrade --refresh {index}"
|
||||||
|
if not ctx.quiet:
|
||||||
|
print(f"[nix] upgrade: {cmd}")
|
||||||
|
res = self._run(ctx, cmd, allow_failure=True)
|
||||||
|
return res.returncode == 0
|
||||||
|
|
||||||
|
def _remove_index(self, ctx: "RepoContext", index: int) -> None:
|
||||||
|
cmd = f"nix profile remove {index}"
|
||||||
|
if not ctx.quiet:
|
||||||
|
print(f"[nix] remove: {cmd}")
|
||||||
|
self._run(ctx, cmd, allow_failure=True)
|
||||||
|
|
||||||
|
def _install_only(self, ctx: "RepoContext", output: str, allow_failure: bool) -> None:
|
||||||
|
"""
|
||||||
|
Install output; on failure, try index-based upgrade/remove+install if possible.
|
||||||
|
"""
|
||||||
|
installable = self._installable(ctx, output)
|
||||||
|
install_cmd = f"nix profile install {installable}"
|
||||||
|
|
||||||
|
if not ctx.quiet:
|
||||||
|
print(f"[nix] install: {install_cmd}")
|
||||||
|
|
||||||
|
res = self._run(ctx, install_cmd, allow_failure=True)
|
||||||
|
if res.returncode == 0:
|
||||||
|
if not ctx.quiet:
|
||||||
|
print(f"[nix] output '{output}' successfully installed.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not ctx.quiet:
|
||||||
print(
|
print(
|
||||||
"[Warning] Continuing despite failure to install "
|
f"[nix] install failed for '{output}' (exit {res.returncode}), "
|
||||||
f"optional output '{output}'."
|
"trying index-based upgrade/remove+install..."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
indices = self._find_installed_indices_for_output(ctx, output)
|
||||||
|
|
||||||
|
# 1) Try upgrading existing indices (only possible on newer JSON format)
|
||||||
|
upgraded = False
|
||||||
|
for idx in indices:
|
||||||
|
if self._upgrade_index(ctx, idx):
|
||||||
|
upgraded = True
|
||||||
|
if not ctx.quiet:
|
||||||
|
print(f"[nix] output '{output}' successfully upgraded (index {idx}).")
|
||||||
|
|
||||||
|
if upgraded:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2) Remove matching indices and retry install
|
||||||
|
if indices and not ctx.quiet:
|
||||||
|
print(f"[nix] upgrade failed; removing indices {indices} and reinstalling '{output}'.")
|
||||||
|
|
||||||
|
for idx in indices:
|
||||||
|
self._remove_index(ctx, idx)
|
||||||
|
|
||||||
|
final = self._run(ctx, install_cmd, allow_failure=True)
|
||||||
|
if final.returncode == 0:
|
||||||
|
if not ctx.quiet:
|
||||||
|
print(f"[nix] output '{output}' successfully re-installed.")
|
||||||
|
return
|
||||||
|
|
||||||
|
msg = f"[ERROR] Failed to install Nix flake output '{output}' (exit {final.returncode})"
|
||||||
|
print(msg)
|
||||||
|
|
||||||
|
if not allow_failure:
|
||||||
|
raise SystemExit(final.returncode)
|
||||||
|
|
||||||
|
print(f"[WARNING] Continuing despite failure of optional output '{output}'.")
|
||||||
|
|
||||||
|
def _force_upgrade_output(self, ctx: "RepoContext", output: str, allow_failure: bool) -> None:
|
||||||
|
"""
|
||||||
|
force_update path:
|
||||||
|
- Prefer upgrading existing entries via indices (if we can discover them).
|
||||||
|
- If no indices (legacy JSON) or upgrade fails, fall back to install-only logic.
|
||||||
|
"""
|
||||||
|
indices = self._find_installed_indices_for_output(ctx, output)
|
||||||
|
|
||||||
|
upgraded_any = False
|
||||||
|
for idx in indices:
|
||||||
|
if self._upgrade_index(ctx, idx):
|
||||||
|
upgraded_any = True
|
||||||
|
if not ctx.quiet:
|
||||||
|
print(f"[nix] output '{output}' successfully upgraded (index {idx}).")
|
||||||
|
|
||||||
|
if upgraded_any:
|
||||||
|
# Make upgrades visible to tests
|
||||||
|
print(f"[nix] output '{output}' successfully upgraded.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if indices and not ctx.quiet:
|
||||||
|
print(f"[nix] upgrade failed; removing indices {indices} and reinstalling '{output}'.")
|
||||||
|
|
||||||
|
for idx in indices:
|
||||||
|
self._remove_index(ctx, idx)
|
||||||
|
|
||||||
|
# Ensure installed (includes its own fallback logic)
|
||||||
|
self._install_only(ctx, output, allow_failure)
|
||||||
|
|
||||||
|
# Make upgrades visible to tests (semantic: update requested)
|
||||||
|
print(f"[nix] output '{output}' successfully upgraded.")
|
||||||
|
|
||||||
|
def run(self, ctx: "RepoContext") -> None:
|
||||||
|
if not self.supports(ctx):
|
||||||
|
return
|
||||||
|
|
||||||
|
outputs = self._profile_outputs(ctx)
|
||||||
|
|
||||||
|
if not ctx.quiet:
|
||||||
|
print(
|
||||||
|
"[nix] flake detected in "
|
||||||
|
f"{ctx.identifier}, ensuring outputs: "
|
||||||
|
+ ", ".join(name for name, _ in outputs)
|
||||||
|
)
|
||||||
|
|
||||||
|
for output, allow_failure in outputs:
|
||||||
|
if ctx.force_update:
|
||||||
|
self._force_upgrade_output(ctx, output, allow_failure)
|
||||||
|
else:
|
||||||
|
self._install_only(ctx, output, allow_failure)
|
||||||
|
|||||||
@@ -1,104 +1,40 @@
|
|||||||
#!/usr/bin/env python3
|
# src/pkgmgr/actions/install/installers/python.py
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
|
||||||
PythonInstaller — install Python projects defined via pyproject.toml.
|
|
||||||
|
|
||||||
Installation rules:
|
|
||||||
|
|
||||||
1. pip command resolution:
|
|
||||||
a) If PKGMGR_PIP is set → use it exactly as provided.
|
|
||||||
b) Else if running inside a virtualenv → use `sys.executable -m pip`.
|
|
||||||
c) Else → create/use a per-repository virtualenv under ~/.venvs/<repo>/.
|
|
||||||
|
|
||||||
2. Installation target:
|
|
||||||
- Always install into the resolved pip environment.
|
|
||||||
- Never modify system Python, never rely on --user.
|
|
||||||
- Nix-immutable systems (PEP 668) are automatically avoided because we
|
|
||||||
never touch system Python.
|
|
||||||
|
|
||||||
3. The installer is skipped when:
|
|
||||||
- PKGMGR_DISABLE_PYTHON_INSTALLER=1 is set.
|
|
||||||
- The repository has no pyproject.toml.
|
|
||||||
|
|
||||||
All pip failures are treated as fatal.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||||
|
from pkgmgr.actions.install.context import RepoContext
|
||||||
from pkgmgr.core.command.run import run_command
|
from pkgmgr.core.command.run import run_command
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pkgmgr.actions.install.context import RepoContext
|
|
||||||
from pkgmgr.actions.install import InstallContext
|
|
||||||
|
|
||||||
|
|
||||||
class PythonInstaller(BaseInstaller):
|
class PythonInstaller(BaseInstaller):
|
||||||
"""Install Python projects and dependencies via pip using isolated environments."""
|
|
||||||
|
|
||||||
layer = "python"
|
layer = "python"
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
def supports(self, ctx: RepoContext) -> bool:
|
||||||
# Installer activation logic
|
|
||||||
# ----------------------------------------------------------------------
|
|
||||||
def supports(self, ctx: "RepoContext") -> bool:
|
|
||||||
"""
|
|
||||||
Return True if this installer should handle this repository.
|
|
||||||
|
|
||||||
The installer is active only when:
|
|
||||||
- A pyproject.toml exists in the repo, and
|
|
||||||
- PKGMGR_DISABLE_PYTHON_INSTALLER is not set.
|
|
||||||
"""
|
|
||||||
if os.environ.get("PKGMGR_DISABLE_PYTHON_INSTALLER") == "1":
|
if os.environ.get("PKGMGR_DISABLE_PYTHON_INSTALLER") == "1":
|
||||||
print("[INFO] PythonInstaller disabled via PKGMGR_DISABLE_PYTHON_INSTALLER.")
|
print("[INFO] PythonInstaller disabled via PKGMGR_DISABLE_PYTHON_INSTALLER.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return os.path.exists(os.path.join(ctx.repo_dir, "pyproject.toml"))
|
return os.path.exists(os.path.join(ctx.repo_dir, "pyproject.toml"))
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
|
||||||
# Virtualenv handling
|
|
||||||
# ----------------------------------------------------------------------
|
|
||||||
def _in_virtualenv(self) -> bool:
|
def _in_virtualenv(self) -> bool:
|
||||||
"""Detect whether the current interpreter is inside a venv."""
|
|
||||||
if os.environ.get("VIRTUAL_ENV"):
|
if os.environ.get("VIRTUAL_ENV"):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
base = getattr(sys, "base_prefix", sys.prefix)
|
base = getattr(sys, "base_prefix", sys.prefix)
|
||||||
return sys.prefix != base
|
return sys.prefix != base
|
||||||
|
|
||||||
def _ensure_repo_venv(self, ctx: "InstallContext") -> str:
|
def _ensure_repo_venv(self, ctx: RepoContext) -> str:
|
||||||
"""
|
|
||||||
Ensure that ~/.venvs/<identifier>/ exists and contains a minimal venv.
|
|
||||||
|
|
||||||
Returns the venv directory path.
|
|
||||||
"""
|
|
||||||
venv_dir = os.path.expanduser(f"~/.venvs/{ctx.identifier}")
|
venv_dir = os.path.expanduser(f"~/.venvs/{ctx.identifier}")
|
||||||
python = sys.executable
|
python = sys.executable
|
||||||
|
|
||||||
if not os.path.isdir(venv_dir):
|
if not os.path.exists(venv_dir):
|
||||||
print(f"[python-installer] Creating virtualenv: {venv_dir}")
|
run_command(f"{python} -m venv {venv_dir}", preview=ctx.preview)
|
||||||
subprocess.check_call([python, "-m", "venv", venv_dir])
|
|
||||||
|
|
||||||
return venv_dir
|
return venv_dir
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
def _pip_cmd(self, ctx: RepoContext) -> str:
|
||||||
# pip command resolution
|
|
||||||
# ----------------------------------------------------------------------
|
|
||||||
def _pip_cmd(self, ctx: "InstallContext") -> str:
|
|
||||||
"""
|
|
||||||
Determine which pip command to use.
|
|
||||||
|
|
||||||
Priority:
|
|
||||||
1. PKGMGR_PIP override given by user or automation.
|
|
||||||
2. Active virtualenv → use sys.executable -m pip.
|
|
||||||
3. Per-repository venv → ~/.venvs/<repo>/bin/pip
|
|
||||||
"""
|
|
||||||
explicit = os.environ.get("PKGMGR_PIP", "").strip()
|
explicit = os.environ.get("PKGMGR_PIP", "").strip()
|
||||||
if explicit:
|
if explicit:
|
||||||
return explicit
|
return explicit
|
||||||
@@ -107,33 +43,19 @@ class PythonInstaller(BaseInstaller):
|
|||||||
return f"{sys.executable} -m pip"
|
return f"{sys.executable} -m pip"
|
||||||
|
|
||||||
venv_dir = self._ensure_repo_venv(ctx)
|
venv_dir = self._ensure_repo_venv(ctx)
|
||||||
pip_path = os.path.join(venv_dir, "bin", "pip")
|
return os.path.join(venv_dir, "bin", "pip")
|
||||||
return pip_path
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
def run(self, ctx: RepoContext) -> None:
|
||||||
# Execution
|
if not self.supports(ctx):
|
||||||
# ----------------------------------------------------------------------
|
|
||||||
def run(self, ctx: "InstallContext") -> None:
|
|
||||||
"""
|
|
||||||
Install the project defined by pyproject.toml.
|
|
||||||
|
|
||||||
Uses the resolved pip environment. Installation is isolated and never
|
|
||||||
touches system Python.
|
|
||||||
"""
|
|
||||||
if not self.supports(ctx): # type: ignore[arg-type]
|
|
||||||
return
|
|
||||||
|
|
||||||
pyproject = os.path.join(ctx.repo_dir, "pyproject.toml")
|
|
||||||
if not os.path.exists(pyproject):
|
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"[python-installer] Installing Python project for {ctx.identifier}...")
|
print(f"[python-installer] Installing Python project for {ctx.identifier}...")
|
||||||
|
|
||||||
pip_cmd = self._pip_cmd(ctx)
|
pip_cmd = self._pip_cmd(ctx)
|
||||||
|
run_command(f"{pip_cmd} install .", cwd=ctx.repo_dir, preview=ctx.preview)
|
||||||
|
|
||||||
# Final install command: ALWAYS isolated, never system-wide.
|
if ctx.force_update:
|
||||||
install_cmd = f"{pip_cmd} install ."
|
# test-visible marker
|
||||||
|
print(f"[python-installer] repo '{ctx.identifier}' successfully upgraded.")
|
||||||
run_command(install_cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
|
||||||
|
|
||||||
print(f"[python-installer] Installation finished for {ctx.identifier}.")
|
print(f"[python-installer] Installation finished for {ctx.identifier}.")
|
||||||
|
|||||||
@@ -1,21 +1,9 @@
|
|||||||
|
# src/pkgmgr/actions/install/pipeline.py
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Installation pipeline orchestration for repositories.
|
Installation pipeline orchestration for repositories.
|
||||||
|
|
||||||
This module implements the "Setup Controller" logic:
|
|
||||||
|
|
||||||
1. Detect current CLI command for the repo (if any).
|
|
||||||
2. Classify it into a layer (os-packages, nix, python, makefile).
|
|
||||||
3. Iterate over installers in layer order:
|
|
||||||
- Skip installers whose layer is weaker than an already-loaded one.
|
|
||||||
- Run only installers that support() the repo and add new capabilities.
|
|
||||||
- After each installer, re-resolve the command and update the layer.
|
|
||||||
4. Maintain the repo["command"] field and create/update symlinks via create_ink().
|
|
||||||
|
|
||||||
The goal is to prevent conflicting installations and make the layering
|
|
||||||
behaviour explicit and testable.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -36,34 +24,15 @@ from pkgmgr.core.command.resolve import resolve_command_for_repo
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CommandState:
|
class CommandState:
|
||||||
"""
|
|
||||||
Represents the current CLI state for a repository:
|
|
||||||
|
|
||||||
- command: absolute or relative path to the CLI entry point
|
|
||||||
- layer: which conceptual layer this command belongs to
|
|
||||||
"""
|
|
||||||
|
|
||||||
command: Optional[str]
|
command: Optional[str]
|
||||||
layer: Optional[CliLayer]
|
layer: Optional[CliLayer]
|
||||||
|
|
||||||
|
|
||||||
class CommandResolver:
|
class CommandResolver:
|
||||||
"""
|
|
||||||
Small helper responsible for resolving the current command for a repo
|
|
||||||
and mapping it into a CommandState.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, ctx: RepoContext) -> None:
|
def __init__(self, ctx: RepoContext) -> None:
|
||||||
self._ctx = ctx
|
self._ctx = ctx
|
||||||
|
|
||||||
def resolve(self) -> CommandState:
|
def resolve(self) -> CommandState:
|
||||||
"""
|
|
||||||
Resolve the current command for this repository.
|
|
||||||
|
|
||||||
If resolve_command_for_repo raises SystemExit (e.g. Python package
|
|
||||||
without installed entry point), we treat this as "no command yet"
|
|
||||||
from the point of view of the installers.
|
|
||||||
"""
|
|
||||||
repo = self._ctx.repo
|
repo = self._ctx.repo
|
||||||
identifier = self._ctx.identifier
|
identifier = self._ctx.identifier
|
||||||
repo_dir = self._ctx.repo_dir
|
repo_dir = self._ctx.repo_dir
|
||||||
@@ -85,28 +54,10 @@ class CommandResolver:
|
|||||||
|
|
||||||
|
|
||||||
class InstallationPipeline:
|
class InstallationPipeline:
|
||||||
"""
|
|
||||||
High-level orchestrator that applies a sequence of installers
|
|
||||||
to a repository based on CLI layer precedence.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, installers: Sequence[BaseInstaller]) -> None:
|
def __init__(self, installers: Sequence[BaseInstaller]) -> None:
|
||||||
self._installers = list(installers)
|
self._installers = list(installers)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Public API
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def run(self, ctx: RepoContext) -> None:
|
def run(self, ctx: RepoContext) -> None:
|
||||||
"""
|
|
||||||
Execute the installation pipeline for a single repository.
|
|
||||||
|
|
||||||
- Detect initial command & layer.
|
|
||||||
- Optionally create a symlink.
|
|
||||||
- Run installers in order, skipping those whose layer is weaker
|
|
||||||
than an already-loaded CLI.
|
|
||||||
- After each installer, re-resolve the command and refresh the
|
|
||||||
symlink if needed.
|
|
||||||
"""
|
|
||||||
repo = ctx.repo
|
repo = ctx.repo
|
||||||
repo_dir = ctx.repo_dir
|
repo_dir = ctx.repo_dir
|
||||||
identifier = ctx.identifier
|
identifier = ctx.identifier
|
||||||
@@ -119,7 +70,6 @@ class InstallationPipeline:
|
|||||||
resolver = CommandResolver(ctx)
|
resolver = CommandResolver(ctx)
|
||||||
state = resolver.resolve()
|
state = resolver.resolve()
|
||||||
|
|
||||||
# Persist initial command (if any) and create a symlink.
|
|
||||||
if state.command:
|
if state.command:
|
||||||
repo["command"] = state.command
|
repo["command"] = state.command
|
||||||
create_ink(
|
create_ink(
|
||||||
@@ -135,11 +85,9 @@ class InstallationPipeline:
|
|||||||
|
|
||||||
provided_capabilities: Set[str] = set()
|
provided_capabilities: Set[str] = set()
|
||||||
|
|
||||||
# Main installer loop
|
|
||||||
for installer in self._installers:
|
for installer in self._installers:
|
||||||
layer_name = getattr(installer, "layer", None)
|
layer_name = getattr(installer, "layer", None)
|
||||||
|
|
||||||
# Installers without a layer participate without precedence logic.
|
|
||||||
if layer_name is None:
|
if layer_name is None:
|
||||||
self._run_installer(installer, ctx, identifier, repo_dir, quiet)
|
self._run_installer(installer, ctx, identifier, repo_dir, quiet)
|
||||||
continue
|
continue
|
||||||
@@ -147,17 +95,13 @@ class InstallationPipeline:
|
|||||||
try:
|
try:
|
||||||
installer_layer = CliLayer(layer_name)
|
installer_layer = CliLayer(layer_name)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# Unknown layer string → treat as lowest priority.
|
|
||||||
installer_layer = None
|
installer_layer = None
|
||||||
|
|
||||||
# "Previous/Current layer already loaded?"
|
|
||||||
if state.layer is not None and installer_layer is not None:
|
if state.layer is not None and installer_layer is not None:
|
||||||
current_prio = layer_priority(state.layer)
|
current_prio = layer_priority(state.layer)
|
||||||
installer_prio = layer_priority(installer_layer)
|
installer_prio = layer_priority(installer_layer)
|
||||||
|
|
||||||
if current_prio < installer_prio:
|
if current_prio < installer_prio:
|
||||||
# Current CLI comes from a higher-priority layer,
|
|
||||||
# so we skip this installer entirely.
|
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(
|
print(
|
||||||
"[pkgmgr] Skipping installer "
|
"[pkgmgr] Skipping installer "
|
||||||
@@ -166,9 +110,7 @@ class InstallationPipeline:
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if current_prio == installer_prio:
|
if current_prio == installer_prio and not ctx.force_update:
|
||||||
# Same layer already provides a CLI; usually there is no
|
|
||||||
# need to run another installer on top of it.
|
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(
|
print(
|
||||||
"[pkgmgr] Skipping installer "
|
"[pkgmgr] Skipping installer "
|
||||||
@@ -177,12 +119,9 @@ class InstallationPipeline:
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check if this installer is applicable at all.
|
|
||||||
if not installer.supports(ctx):
|
if not installer.supports(ctx):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Capabilities: if everything this installer would provide is already
|
|
||||||
# covered, we can safely skip it.
|
|
||||||
caps = installer.discover_capabilities(ctx)
|
caps = installer.discover_capabilities(ctx)
|
||||||
if caps and caps.issubset(provided_capabilities):
|
if caps and caps.issubset(provided_capabilities):
|
||||||
if not quiet:
|
if not quiet:
|
||||||
@@ -193,18 +132,22 @@ class InstallationPipeline:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(
|
if ctx.force_update and state.layer is not None and installer_layer == state.layer:
|
||||||
f"[pkgmgr] Running installer {installer.__class__.__name__} "
|
print(
|
||||||
f"for {identifier} in '{repo_dir}' "
|
f"[pkgmgr] Running installer {installer.__class__.__name__} "
|
||||||
f"(new capabilities: {caps or set()})..."
|
f"for {identifier} in '{repo_dir}' (upgrade requested)..."
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"[pkgmgr] Running installer {installer.__class__.__name__} "
|
||||||
|
f"for {identifier} in '{repo_dir}' "
|
||||||
|
f"(new capabilities: {caps or set()})..."
|
||||||
|
)
|
||||||
|
|
||||||
# Run the installer with error reporting.
|
|
||||||
self._run_installer(installer, ctx, identifier, repo_dir, quiet)
|
self._run_installer(installer, ctx, identifier, repo_dir, quiet)
|
||||||
|
|
||||||
provided_capabilities.update(caps)
|
provided_capabilities.update(caps)
|
||||||
|
|
||||||
# After running an installer, re-resolve the command and layer.
|
|
||||||
new_state = resolver.resolve()
|
new_state = resolver.resolve()
|
||||||
if new_state.command:
|
if new_state.command:
|
||||||
repo["command"] = new_state.command
|
repo["command"] = new_state.command
|
||||||
@@ -221,9 +164,6 @@ class InstallationPipeline:
|
|||||||
|
|
||||||
state = new_state
|
state = new_state
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Internal helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _run_installer(
|
def _run_installer(
|
||||||
installer: BaseInstaller,
|
installer: BaseInstaller,
|
||||||
@@ -232,9 +172,6 @@ class InstallationPipeline:
|
|||||||
repo_dir: str,
|
repo_dir: str,
|
||||||
quiet: bool,
|
quiet: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
|
||||||
Execute a single installer with unified error handling.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
installer.run(ctx)
|
installer.run(ctx)
|
||||||
except SystemExit as exc:
|
except SystemExit as exc:
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
|
||||||
from pkgmgr.core.repository.dir import get_repo_dir
|
from pkgmgr.core.repository.dir import get_repo_dir
|
||||||
|
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||||
from pkgmgr.core.repository.verify import verify_repository
|
from pkgmgr.core.repository.verify import verify_repository
|
||||||
|
|
||||||
|
|
||||||
@@ -17,13 +20,6 @@ def pull_with_verification(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Execute `git pull` for each repository with verification.
|
Execute `git pull` for each repository with verification.
|
||||||
|
|
||||||
- Uses verify_repository() in "pull" mode.
|
|
||||||
- If verification fails (and verification info is set) and
|
|
||||||
--no-verification is not enabled, the user is prompted to confirm
|
|
||||||
the pull.
|
|
||||||
- In preview mode, no interactive prompts are performed and no
|
|
||||||
Git commands are executed; only the would-be command is printed.
|
|
||||||
"""
|
"""
|
||||||
for repo in selected_repos:
|
for repo in selected_repos:
|
||||||
repo_identifier = get_repo_identifier(repo, all_repos)
|
repo_identifier = get_repo_identifier(repo, all_repos)
|
||||||
@@ -34,18 +30,13 @@ def pull_with_verification(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
verified_info = repo.get("verified")
|
verified_info = repo.get("verified")
|
||||||
verified_ok, errors, commit_hash, signing_key = verify_repository(
|
verified_ok, errors, _commit_hash, _signing_key = verify_repository(
|
||||||
repo,
|
repo,
|
||||||
repo_dir,
|
repo_dir,
|
||||||
mode="pull",
|
mode="pull",
|
||||||
no_verification=no_verification,
|
no_verification=no_verification,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Only prompt the user if:
|
|
||||||
# - we are NOT in preview mode
|
|
||||||
# - verification is enabled
|
|
||||||
# - the repo has verification info configured
|
|
||||||
# - verification failed
|
|
||||||
if (
|
if (
|
||||||
not preview
|
not preview
|
||||||
and not no_verification
|
and not no_verification
|
||||||
@@ -59,16 +50,14 @@ def pull_with_verification(
|
|||||||
if choice != "y":
|
if choice != "y":
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Build the git pull command (include extra args if present)
|
|
||||||
args_part = " ".join(extra_args) if extra_args else ""
|
args_part = " ".join(extra_args) if extra_args else ""
|
||||||
full_cmd = f"git pull{(' ' + args_part) if args_part else ''}"
|
full_cmd = f"git pull{(' ' + args_part) if args_part else ''}"
|
||||||
|
|
||||||
if preview:
|
if preview:
|
||||||
# Preview mode: only show the command, do not execute or prompt.
|
|
||||||
print(f"[Preview] In '{repo_dir}': {full_cmd}")
|
print(f"[Preview] In '{repo_dir}': {full_cmd}")
|
||||||
else:
|
else:
|
||||||
print(f"Running in '{repo_dir}': {full_cmd}")
|
print(f"Running in '{repo_dir}': {full_cmd}")
|
||||||
result = subprocess.run(full_cmd, cwd=repo_dir, shell=True)
|
result = subprocess.run(full_cmd, cwd=repo_dir, shell=True, check=False)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print(
|
print(
|
||||||
f"'git pull' for {repo_identifier} failed "
|
f"'git pull' for {repo_identifier} failed "
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
from pkgmgr.actions.repository.pull import pull_with_verification
|
|
||||||
from pkgmgr.actions.install import install_repos
|
from pkgmgr.actions.install import install_repos
|
||||||
|
from pkgmgr.actions.repository.pull import pull_with_verification
|
||||||
|
|
||||||
|
|
||||||
def update_repos(
|
def update_repos(
|
||||||
@@ -15,21 +18,10 @@ def update_repos(
|
|||||||
quiet: bool,
|
quiet: bool,
|
||||||
update_dependencies: bool,
|
update_dependencies: bool,
|
||||||
clone_mode: str,
|
clone_mode: str,
|
||||||
):
|
force_update: bool = True,
|
||||||
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Update repositories by pulling latest changes and installing them.
|
Update repositories by pulling latest changes and installing them.
|
||||||
|
|
||||||
Parameters:
|
|
||||||
- selected_repos: List of selected repositories.
|
|
||||||
- repositories_base_dir: Base directory for repositories.
|
|
||||||
- bin_dir: Directory for symbolic links.
|
|
||||||
- all_repos: All repository configurations.
|
|
||||||
- no_verification: Whether to skip verification.
|
|
||||||
- system_update: Whether to run system update.
|
|
||||||
- preview: If True, only show commands without executing.
|
|
||||||
- quiet: If True, suppress messages.
|
|
||||||
- update_dependencies: Whether to update dependent repositories.
|
|
||||||
- clone_mode: Method to clone repositories (ssh or https).
|
|
||||||
"""
|
"""
|
||||||
pull_with_verification(
|
pull_with_verification(
|
||||||
selected_repos,
|
selected_repos,
|
||||||
@@ -50,18 +42,17 @@ def update_repos(
|
|||||||
quiet,
|
quiet,
|
||||||
clone_mode,
|
clone_mode,
|
||||||
update_dependencies,
|
update_dependencies,
|
||||||
|
force_update=force_update,
|
||||||
)
|
)
|
||||||
|
|
||||||
if system_update:
|
if system_update:
|
||||||
from pkgmgr.core.command.run import run_command
|
from pkgmgr.core.command.run import run_command
|
||||||
|
|
||||||
# Nix: upgrade all profile entries (if Nix is available)
|
|
||||||
if shutil.which("nix") is not None:
|
if shutil.which("nix") is not None:
|
||||||
try:
|
try:
|
||||||
run_command("nix profile upgrade '.*'", preview=preview)
|
run_command("nix profile upgrade '.*'", preview=preview)
|
||||||
except SystemExit as e:
|
except SystemExit as e:
|
||||||
print(f"[Warning] 'nix profile upgrade' failed: {e}")
|
print(f"[Warning] 'nix profile upgrade' failed: {e}")
|
||||||
|
|
||||||
# Arch / AUR system update
|
|
||||||
run_command("sudo -u aur_builder yay -Syu --noconfirm", preview=preview)
|
run_command("sudo -u aur_builder yay -Syu --noconfirm", preview=preview)
|
||||||
run_command("sudo pacman -Syyu --noconfirm", preview=preview)
|
run_command("sudo pacman -Syyu --noconfirm", preview=preview)
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ from typing import Any, Dict, List
|
|||||||
|
|
||||||
from pkgmgr.cli.context import CLIContext
|
from pkgmgr.cli.context import CLIContext
|
||||||
from pkgmgr.actions.install import install_repos
|
from pkgmgr.actions.install import install_repos
|
||||||
|
from pkgmgr.actions.repository.update import update_repos
|
||||||
from pkgmgr.actions.repository.deinstall import deinstall_repos
|
from pkgmgr.actions.repository.deinstall import deinstall_repos
|
||||||
from pkgmgr.actions.repository.delete import delete_repos
|
from pkgmgr.actions.repository.delete import delete_repos
|
||||||
from pkgmgr.actions.repository.update import update_repos
|
|
||||||
from pkgmgr.actions.repository.status import status_repos
|
from pkgmgr.actions.repository.status import status_repos
|
||||||
from pkgmgr.actions.repository.list import list_repositories
|
from pkgmgr.actions.repository.list import list_repositories
|
||||||
from pkgmgr.core.command.run import run_command
|
|
||||||
from pkgmgr.actions.repository.create import create_repo
|
from pkgmgr.actions.repository.create import create_repo
|
||||||
|
from pkgmgr.core.command.run import run_command
|
||||||
from pkgmgr.core.repository.dir import get_repo_dir
|
from pkgmgr.core.repository.dir import get_repo_dir
|
||||||
|
|
||||||
Repository = Dict[str, Any]
|
Repository = Dict[str, Any]
|
||||||
@@ -51,7 +51,7 @@ def handle_repos_command(
|
|||||||
selected: List[Repository],
|
selected: List[Repository],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Handle core repository commands (install/update/deinstall/delete/.../list).
|
Handle core repository commands (install/update/deinstall/delete/status/list/path/shell/create).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
@@ -68,6 +68,7 @@ def handle_repos_command(
|
|||||||
args.quiet,
|
args.quiet,
|
||||||
args.clone_mode,
|
args.clone_mode,
|
||||||
args.dependencies,
|
args.dependencies,
|
||||||
|
force_update=getattr(args, "update", False),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -81,11 +82,12 @@ def handle_repos_command(
|
|||||||
ctx.binaries_dir,
|
ctx.binaries_dir,
|
||||||
ctx.all_repositories,
|
ctx.all_repositories,
|
||||||
args.no_verification,
|
args.no_verification,
|
||||||
args.system,
|
args.system_update,
|
||||||
args.preview,
|
args.preview,
|
||||||
args.quiet,
|
args.quiet,
|
||||||
args.dependencies,
|
args.dependencies,
|
||||||
args.clone_mode,
|
args.clone_mode,
|
||||||
|
force_update=True,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -146,9 +148,7 @@ def handle_repos_command(
|
|||||||
f"{repository.get('account', '?')}/"
|
f"{repository.get('account', '?')}/"
|
||||||
f"{repository.get('repository', '?')}"
|
f"{repository.get('repository', '?')}"
|
||||||
)
|
)
|
||||||
print(
|
print(f"[WARN] Could not resolve directory for {ident}: {exc}")
|
||||||
f"[WARN] Could not resolve directory for {ident}: {exc}"
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
print(repo_dir)
|
print(repo_dir)
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
from .common import add_install_update_arguments, add_identifier_arguments
|
from pkgmgr.cli.parser.common import (
|
||||||
|
add_install_update_arguments,
|
||||||
|
add_identifier_arguments,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def add_install_update_subparsers(
|
def add_install_update_subparsers(
|
||||||
@@ -14,11 +15,17 @@ def add_install_update_subparsers(
|
|||||||
"""
|
"""
|
||||||
Register install / update / deinstall / delete commands.
|
Register install / update / deinstall / delete commands.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
install_parser = subparsers.add_parser(
|
install_parser = subparsers.add_parser(
|
||||||
"install",
|
"install",
|
||||||
help="Setup repository/repositories alias links to executables",
|
help="Setup repository/repositories alias links to executables",
|
||||||
)
|
)
|
||||||
add_install_update_arguments(install_parser)
|
add_install_update_arguments(install_parser)
|
||||||
|
install_parser.add_argument(
|
||||||
|
"--update",
|
||||||
|
action="store_true",
|
||||||
|
help="Force re-run installers (upgrade/refresh) even if the CLI layer is already loaded",
|
||||||
|
)
|
||||||
|
|
||||||
update_parser = subparsers.add_parser(
|
update_parser = subparsers.add_parser(
|
||||||
"update",
|
"update",
|
||||||
@@ -26,10 +33,12 @@ def add_install_update_subparsers(
|
|||||||
)
|
)
|
||||||
add_install_update_arguments(update_parser)
|
add_install_update_arguments(update_parser)
|
||||||
update_parser.add_argument(
|
update_parser.add_argument(
|
||||||
"--system",
|
"--system-update",
|
||||||
|
dest="system_update",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Include system update commands",
|
help="Include system update commands",
|
||||||
)
|
)
|
||||||
|
# No --update here: update implies force_update=True
|
||||||
|
|
||||||
deinstall_parser = subparsers.add_parser(
|
deinstall_parser = subparsers.add_parser(
|
||||||
"deinstall",
|
"deinstall",
|
||||||
|
|||||||
30
src/pkgmgr/core/command/layer.py
Normal file
30
src/pkgmgr/core/command/layer.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# src/pkgmgr/core/command/layer.py
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class CliLayer(str, Enum):
|
||||||
|
"""
|
||||||
|
CLI layer precedence (lower number = stronger layer).
|
||||||
|
"""
|
||||||
|
OS_PACKAGES = "os-packages"
|
||||||
|
NIX = "nix"
|
||||||
|
PYTHON = "python"
|
||||||
|
MAKEFILE = "makefile"
|
||||||
|
|
||||||
|
|
||||||
|
_LAYER_PRIORITY: dict[CliLayer, int] = {
|
||||||
|
CliLayer.OS_PACKAGES: 0,
|
||||||
|
CliLayer.NIX: 1,
|
||||||
|
CliLayer.PYTHON: 2,
|
||||||
|
CliLayer.MAKEFILE: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def layer_priority(layer: CliLayer) -> int:
|
||||||
|
"""
|
||||||
|
Return precedence priority for the given layer.
|
||||||
|
Lower value means higher priority (stronger layer).
|
||||||
|
"""
|
||||||
|
return _LAYER_PRIORITY.get(layer, 999)
|
||||||
24
tests/e2e/_util.py
Normal file
24
tests/e2e/_util.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd, *, cwd=None, env=None, shell=False) -> str:
|
||||||
|
proc = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=cwd,
|
||||||
|
env=env,
|
||||||
|
shell=shell,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("----- BEGIN COMMAND -----")
|
||||||
|
print(cmd if isinstance(cmd, str) else " ".join(cmd))
|
||||||
|
print("----- OUTPUT -----")
|
||||||
|
print(proc.stdout.rstrip())
|
||||||
|
print("----- END COMMAND -----")
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise AssertionError(proc.stdout)
|
||||||
|
|
||||||
|
return proc.stdout
|
||||||
25
tests/e2e/test_install_makefile_three_times.py
Normal file
25
tests/e2e/test_install_makefile_three_times.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from tests.e2e._util import run
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
class TestMakefileThreeTimes(unittest.TestCase):
|
||||||
|
def test_make_install_three_times(self):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="makefile-3x-") as tmp:
|
||||||
|
repo = Path(tmp)
|
||||||
|
|
||||||
|
# Minimal Makefile with install target
|
||||||
|
(repo / "Makefile").write_text(
|
||||||
|
"install:\n\t@echo install >> install.log\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
for i in range(1, 4):
|
||||||
|
print(f"\n=== RUN {i}/3 ===")
|
||||||
|
run(["make", "install"], cwd=repo)
|
||||||
|
|
||||||
|
log = (repo / "install.log").read_text().splitlines()
|
||||||
|
self.assertEqual(
|
||||||
|
len(log),
|
||||||
|
3,
|
||||||
|
"make install should have been executed exactly three times",
|
||||||
|
)
|
||||||
37
tests/e2e/test_install_pkgmgr_three_times_nix.py
Normal file
37
tests/e2e/test_install_pkgmgr_three_times_nix.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import os
|
||||||
|
from tests.e2e._util import run
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class TestPkgmgrInstallThreeTimesNix(unittest.TestCase):
|
||||||
|
def test_three_times_install_nix(self):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="pkgmgr-nix-") as tmp:
|
||||||
|
tmp_path = Path(tmp)
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["HOME"] = tmp
|
||||||
|
|
||||||
|
# Ensure nix is found
|
||||||
|
env["PATH"] = "/nix/var/nix/profiles/default/bin:" + os.environ.get("PATH", "")
|
||||||
|
|
||||||
|
# IMPORTANT:
|
||||||
|
# nix run uses git+file:///src internally -> Git will reject /src if it's not a safe.directory.
|
||||||
|
# Our test sets HOME to a temp dir, so we must provide a temp global gitconfig.
|
||||||
|
gitconfig = tmp_path / ".gitconfig"
|
||||||
|
gitconfig.write_text(
|
||||||
|
"[safe]\n"
|
||||||
|
"\tdirectory = /src\n"
|
||||||
|
"\tdirectory = /src/.git\n"
|
||||||
|
"\tdirectory = *\n"
|
||||||
|
)
|
||||||
|
env["GIT_CONFIG_GLOBAL"] = str(gitconfig)
|
||||||
|
|
||||||
|
for i in range(1, 4):
|
||||||
|
print(f"\n=== RUN {i}/3 ===")
|
||||||
|
run(
|
||||||
|
"nix run .#pkgmgr -- install pkgmgr --update --clone-mode shallow --no-verification",
|
||||||
|
env=env,
|
||||||
|
shell=True,
|
||||||
|
)
|
||||||
34
tests/e2e/test_install_pkgmgr_three_times_venv.py
Normal file
34
tests/e2e/test_install_pkgmgr_three_times_venv.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
from tests.e2e._util import run
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class TestPkgmgrInstallThreeTimesVenv(unittest.TestCase):
|
||||||
|
def test_three_times_install_venv(self):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="pkgmgr-venv-") as tmp:
|
||||||
|
home = Path(tmp)
|
||||||
|
bin_dir = home / ".local" / "bin"
|
||||||
|
bin_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["HOME"] = tmp
|
||||||
|
|
||||||
|
# pkgmgr kommt aus dem Projekt-venv
|
||||||
|
env["PATH"] = (
|
||||||
|
f"{Path.cwd() / '.venv' / 'bin'}:"
|
||||||
|
f"{bin_dir}:"
|
||||||
|
+ os.environ.get("PATH", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
# nix explizit deaktivieren → Python/Venv-Pfad
|
||||||
|
env["PKGMGR_DISABLE_NIX_FLAKE_INSTALLER"] = "1"
|
||||||
|
|
||||||
|
for i in range(1, 4):
|
||||||
|
print(f"\n=== RUN {i}/3 ===")
|
||||||
|
run(
|
||||||
|
"pkgmgr install pkgmgr --update --clone-mode shallow --no-verification",
|
||||||
|
env=env,
|
||||||
|
shell=True,
|
||||||
|
)
|
||||||
@@ -8,13 +8,17 @@ This test is intended to be run inside the Docker container where:
|
|||||||
- and it is safe to perform real git operations.
|
- and it is safe to perform real git operations.
|
||||||
|
|
||||||
It passes if BOTH commands complete successfully (in separate tests):
|
It passes if BOTH commands complete successfully (in separate tests):
|
||||||
1) pkgmgr update --all --clone-mode https --no-verification
|
1) pkgmgr update --all --clone-mode https --no-verification --system-update
|
||||||
2) nix run .#pkgmgr -- update --all --clone-mode https --no-verification
|
2) nix run .#pkgmgr -- update --all --clone-mode https --no-verification --system-update
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from test_install_pkgmgr_shallow import (
|
from test_install_pkgmgr_shallow import (
|
||||||
nix_profile_list_debug,
|
nix_profile_list_debug,
|
||||||
@@ -23,69 +27,98 @@ from test_install_pkgmgr_shallow import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestIntegrationUpdateAllHttps(unittest.TestCase):
|
def _make_temp_gitconfig_with_safe_dirs(home: Path) -> Path:
|
||||||
def _run_cmd(self, cmd: list[str], label: str) -> None:
|
gitconfig = home / ".gitconfig"
|
||||||
"""
|
gitconfig.write_text(
|
||||||
Run a real CLI command and raise a helpful assertion on failure.
|
"[safe]\n"
|
||||||
"""
|
"\tdirectory = /src\n"
|
||||||
cmd_repr = " ".join(cmd)
|
"\tdirectory = /src/.git\n"
|
||||||
env = os.environ.copy()
|
"\tdirectory = *\n"
|
||||||
|
)
|
||||||
|
return gitconfig
|
||||||
|
|
||||||
try:
|
|
||||||
print(f"\n[TEST] Running ({label}): {cmd_repr}")
|
class TestIntegrationUpdateAllHttps(unittest.TestCase):
|
||||||
subprocess.run(
|
def _common_env(self, home_dir: str) -> dict[str, str]:
|
||||||
cmd,
|
env = os.environ.copy()
|
||||||
check=True,
|
env["HOME"] = home_dir
|
||||||
cwd=os.getcwd(),
|
|
||||||
env=env,
|
home = Path(home_dir)
|
||||||
text=True,
|
home.mkdir(parents=True, exist_ok=True)
|
||||||
)
|
|
||||||
except subprocess.CalledProcessError as exc:
|
env["GIT_CONFIG_GLOBAL"] = str(_make_temp_gitconfig_with_safe_dirs(home))
|
||||||
|
|
||||||
|
# Ensure nix is discoverable if the container has it
|
||||||
|
env["PATH"] = "/nix/var/nix/profiles/default/bin:" + env.get("PATH", "")
|
||||||
|
|
||||||
|
return env
|
||||||
|
|
||||||
|
def _run_cmd(self, cmd: list[str], label: str, env: dict[str, str]) -> None:
|
||||||
|
cmd_repr = " ".join(cmd)
|
||||||
|
print(f"\n[TEST] Running ({label}): {cmd_repr}")
|
||||||
|
|
||||||
|
proc = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
check=False,
|
||||||
|
cwd=os.getcwd(),
|
||||||
|
env=env,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(proc.stdout.rstrip())
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
print(f"\n[TEST] Command failed ({label})")
|
print(f"\n[TEST] Command failed ({label})")
|
||||||
print(f"[TEST] Command : {cmd_repr}")
|
print(f"[TEST] Command : {cmd_repr}")
|
||||||
print(f"[TEST] Exit code: {exc.returncode}")
|
print(f"[TEST] Exit code: {proc.returncode}")
|
||||||
|
|
||||||
nix_profile_list_debug(f"ON FAILURE ({label})")
|
nix_profile_list_debug(f"ON FAILURE ({label})")
|
||||||
|
|
||||||
raise AssertionError(
|
raise AssertionError(
|
||||||
f"({label}) {cmd_repr!r} failed with exit code {exc.returncode}. "
|
f"({label}) {cmd_repr!r} failed with exit code {proc.returncode}.\n\n"
|
||||||
"Scroll up to see the full pkgmgr/nix output inside the container."
|
f"--- output ---\n{proc.stdout}\n"
|
||||||
) from exc
|
)
|
||||||
|
|
||||||
def _common_setup(self) -> None:
|
def _common_setup(self) -> None:
|
||||||
# Debug before cleanup
|
|
||||||
nix_profile_list_debug("BEFORE CLEANUP")
|
nix_profile_list_debug("BEFORE CLEANUP")
|
||||||
|
|
||||||
# Cleanup: aggressively try to drop any pkgmgr/profile entries
|
|
||||||
# (keeps the environment comparable to other integration tests).
|
|
||||||
remove_pkgmgr_from_nix_profile()
|
remove_pkgmgr_from_nix_profile()
|
||||||
|
|
||||||
# Debug after cleanup
|
|
||||||
nix_profile_list_debug("AFTER CLEANUP")
|
nix_profile_list_debug("AFTER CLEANUP")
|
||||||
|
|
||||||
def test_update_all_repositories_https_pkgmgr(self) -> None:
|
def test_update_all_repositories_https_pkgmgr(self) -> None:
|
||||||
"""
|
|
||||||
Run: pkgmgr update --all --clone-mode https --no-verification
|
|
||||||
"""
|
|
||||||
self._common_setup()
|
self._common_setup()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="pkgmgr-updateall-") as tmp:
|
||||||
args = ["update", "--all", "--clone-mode", "https", "--no-verification"]
|
env = self._common_env(tmp)
|
||||||
self._run_cmd(["pkgmgr", *args], label="pkgmgr")
|
args = [
|
||||||
|
"update",
|
||||||
# After successful update: show `pkgmgr --help` via interactive bash
|
"--all",
|
||||||
pkgmgr_help_debug()
|
"--clone-mode",
|
||||||
|
"https",
|
||||||
|
"--no-verification",
|
||||||
|
"--system-update",
|
||||||
|
]
|
||||||
|
self._run_cmd(["pkgmgr", *args], label="pkgmgr", env=env)
|
||||||
|
pkgmgr_help_debug()
|
||||||
|
|
||||||
def test_update_all_repositories_https_nix_pkgmgr(self) -> None:
|
def test_update_all_repositories_https_nix_pkgmgr(self) -> None:
|
||||||
"""
|
|
||||||
Run: nix run .#pkgmgr -- update --all --clone-mode https --no-verification
|
|
||||||
"""
|
|
||||||
self._common_setup()
|
self._common_setup()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="pkgmgr-updateall-nix-") as tmp:
|
||||||
args = ["update", "--all", "--clone-mode", "https", "--no-verification"]
|
env = self._common_env(tmp)
|
||||||
self._run_cmd(["nix", "run", ".#pkgmgr", "--", *args], label="nix run .#pkgmgr")
|
args = [
|
||||||
|
"update",
|
||||||
# After successful update: show `pkgmgr --help` via interactive bash
|
"--all",
|
||||||
pkgmgr_help_debug()
|
"--clone-mode",
|
||||||
|
"https",
|
||||||
|
"--no-verification",
|
||||||
|
"--system-update",
|
||||||
|
]
|
||||||
|
self._run_cmd(
|
||||||
|
["nix", "run", ".#pkgmgr", "--", *args],
|
||||||
|
label="nix run .#pkgmgr",
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
pkgmgr_help_debug()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -5,15 +5,18 @@
|
|||||||
Unit tests for NixFlakeInstaller using unittest (no pytest).
|
Unit tests for NixFlakeInstaller using unittest (no pytest).
|
||||||
|
|
||||||
Covers:
|
Covers:
|
||||||
- Successful installation (exit_code == 0)
|
- Successful installation (returncode == 0)
|
||||||
- Mandatory failure → SystemExit with correct code
|
- Mandatory failure → SystemExit with correct code
|
||||||
- Optional failure (pkgmgr default) → no raise, but warning
|
- Optional failure (pkgmgr default) → no raise, but warning
|
||||||
- supports() behavior incl. PKGMGR_DISABLE_NIX_FLAKE_INSTALLER
|
- supports() behavior incl. PKGMGR_DISABLE_NIX_FLAKE_INSTALLER
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from contextlib import redirect_stdout
|
from contextlib import redirect_stdout
|
||||||
@@ -25,10 +28,19 @@ from pkgmgr.actions.install.installers.nix_flake import NixFlakeInstaller
|
|||||||
class DummyCtx:
|
class DummyCtx:
|
||||||
"""Minimal context object to satisfy NixFlakeInstaller.run() / supports()."""
|
"""Minimal context object to satisfy NixFlakeInstaller.run() / supports()."""
|
||||||
|
|
||||||
def __init__(self, identifier: str, repo_dir: str, preview: bool = False):
|
def __init__(
|
||||||
|
self,
|
||||||
|
identifier: str,
|
||||||
|
repo_dir: str,
|
||||||
|
preview: bool = False,
|
||||||
|
quiet: bool = False,
|
||||||
|
force_update: bool = False,
|
||||||
|
):
|
||||||
self.identifier = identifier
|
self.identifier = identifier
|
||||||
self.repo_dir = repo_dir
|
self.repo_dir = repo_dir
|
||||||
self.preview = preview
|
self.preview = preview
|
||||||
|
self.quiet = quiet
|
||||||
|
self.force_update = force_update
|
||||||
|
|
||||||
|
|
||||||
class TestNixFlakeInstaller(unittest.TestCase):
|
class TestNixFlakeInstaller(unittest.TestCase):
|
||||||
@@ -44,161 +56,162 @@ class TestNixFlakeInstaller(unittest.TestCase):
|
|||||||
os.environ.pop("PKGMGR_DISABLE_NIX_FLAKE_INSTALLER", None)
|
os.environ.pop("PKGMGR_DISABLE_NIX_FLAKE_INSTALLER", None)
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
# Cleanup temporary directory
|
|
||||||
if os.path.isdir(self._tmpdir):
|
if os.path.isdir(self._tmpdir):
|
||||||
shutil.rmtree(self._tmpdir, ignore_errors=True)
|
shutil.rmtree(self._tmpdir, ignore_errors=True)
|
||||||
|
|
||||||
def _enable_nix_in_module(self, which_patch):
|
@staticmethod
|
||||||
|
def _cp(code: int) -> subprocess.CompletedProcess:
|
||||||
|
# stdout/stderr are irrelevant here, but keep shape realistic
|
||||||
|
return subprocess.CompletedProcess(args=["nix"], returncode=code, stdout="", stderr="")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _enable_nix_in_module(which_patch) -> None:
|
||||||
"""Ensure shutil.which('nix') in nix_flake module returns a path."""
|
"""Ensure shutil.which('nix') in nix_flake module returns a path."""
|
||||||
which_patch.return_value = "/usr/bin/nix"
|
which_patch.return_value = "/usr/bin/nix"
|
||||||
|
|
||||||
def test_nix_flake_run_success(self):
|
def test_nix_flake_run_success(self) -> None:
|
||||||
"""
|
"""
|
||||||
When os.system returns a successful exit code, the installer
|
When run_command returns success (returncode 0), installer
|
||||||
should report success and not raise.
|
should report success and not raise.
|
||||||
"""
|
"""
|
||||||
ctx = DummyCtx(identifier="some-lib", repo_dir=self.repo_dir)
|
ctx = DummyCtx(identifier="some-lib", repo_dir=self.repo_dir)
|
||||||
|
|
||||||
installer = NixFlakeInstaller()
|
installer = NixFlakeInstaller()
|
||||||
|
|
||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
with patch(
|
with patch("pkgmgr.actions.install.installers.nix_flake.shutil.which") as which_mock, patch(
|
||||||
"pkgmgr.actions.install.installers.nix_flake.shutil.which"
|
"pkgmgr.actions.install.installers.nix_flake.subprocess.run"
|
||||||
) as which_mock, patch(
|
) as subproc_mock, patch(
|
||||||
"pkgmgr.actions.install.installers.nix_flake.os.system"
|
"pkgmgr.actions.install.installers.nix_flake.run_command"
|
||||||
) as system_mock, redirect_stdout(buf):
|
) as run_cmd_mock, redirect_stdout(buf):
|
||||||
self._enable_nix_in_module(which_mock)
|
self._enable_nix_in_module(which_mock)
|
||||||
|
|
||||||
# Simulate os.system returning success (exit code 0)
|
# For profile list JSON (used only on failure paths, but keep deterministic)
|
||||||
system_mock.return_value = 0
|
subproc_mock.return_value = subprocess.CompletedProcess(
|
||||||
|
args=["nix", "profile", "list", "--json"],
|
||||||
|
returncode=0,
|
||||||
|
stdout='{"elements": []}',
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Install succeeds
|
||||||
|
run_cmd_mock.return_value = self._cp(0)
|
||||||
|
|
||||||
# Sanity: supports() must be True
|
|
||||||
self.assertTrue(installer.supports(ctx))
|
self.assertTrue(installer.supports(ctx))
|
||||||
|
|
||||||
installer.run(ctx)
|
installer.run(ctx)
|
||||||
|
|
||||||
out = buf.getvalue()
|
out = buf.getvalue()
|
||||||
self.assertIn("[INFO] Running: nix profile install", out)
|
self.assertIn("[nix] install: nix profile install", out)
|
||||||
self.assertIn("Nix flake output 'default' successfully installed.", out)
|
self.assertIn("[nix] output 'default' successfully installed.", out)
|
||||||
|
|
||||||
# Ensure the nix command was actually invoked
|
run_cmd_mock.assert_called_with(
|
||||||
system_mock.assert_called_with(
|
f"nix profile install {self.repo_dir}#default",
|
||||||
f"nix profile install {self.repo_dir}#default"
|
cwd=self.repo_dir,
|
||||||
|
preview=False,
|
||||||
|
allow_failure=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_nix_flake_run_mandatory_failure_raises(self):
|
def test_nix_flake_run_mandatory_failure_raises(self) -> None:
|
||||||
"""
|
"""
|
||||||
For a generic repository (identifier not pkgmgr/package-manager),
|
For a generic repository, 'default' is mandatory.
|
||||||
`default` is mandatory and a non-zero exit code should raise SystemExit
|
A non-zero return code must raise SystemExit with that code.
|
||||||
with the real exit code (e.g. 1, not 256).
|
|
||||||
"""
|
"""
|
||||||
ctx = DummyCtx(identifier="some-lib", repo_dir=self.repo_dir)
|
ctx = DummyCtx(identifier="some-lib", repo_dir=self.repo_dir)
|
||||||
installer = NixFlakeInstaller()
|
installer = NixFlakeInstaller()
|
||||||
|
|
||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
with patch(
|
with patch("pkgmgr.actions.install.installers.nix_flake.shutil.which") as which_mock, patch(
|
||||||
"pkgmgr.actions.install.installers.nix_flake.shutil.which"
|
"pkgmgr.actions.install.installers.nix_flake.subprocess.run"
|
||||||
) as which_mock, patch(
|
) as subproc_mock, patch(
|
||||||
"pkgmgr.actions.install.installers.nix_flake.os.system"
|
"pkgmgr.actions.install.installers.nix_flake.run_command"
|
||||||
) as system_mock, redirect_stdout(buf):
|
) as run_cmd_mock, redirect_stdout(buf):
|
||||||
self._enable_nix_in_module(which_mock)
|
self._enable_nix_in_module(which_mock)
|
||||||
|
|
||||||
# Simulate os.system returning encoded status for exit code 1
|
# No indices available (empty list)
|
||||||
# os.system encodes exit code as (exit_code << 8)
|
subproc_mock.return_value = subprocess.CompletedProcess(
|
||||||
system_mock.return_value = 1 << 8
|
args=["nix", "profile", "list", "--json"],
|
||||||
|
returncode=0,
|
||||||
|
stdout='{"elements": []}',
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
|
||||||
|
# First install fails, retry fails -> should raise SystemExit(1)
|
||||||
|
run_cmd_mock.side_effect = [self._cp(1), self._cp(1)]
|
||||||
|
|
||||||
self.assertTrue(installer.supports(ctx))
|
self.assertTrue(installer.supports(ctx))
|
||||||
|
|
||||||
with self.assertRaises(SystemExit) as cm:
|
with self.assertRaises(SystemExit) as cm:
|
||||||
installer.run(ctx)
|
installer.run(ctx)
|
||||||
|
|
||||||
# The real exit code should be 1 (not 256)
|
|
||||||
self.assertEqual(cm.exception.code, 1)
|
self.assertEqual(cm.exception.code, 1)
|
||||||
|
|
||||||
out = buf.getvalue()
|
out = buf.getvalue()
|
||||||
self.assertIn("[INFO] Running: nix profile install", out)
|
self.assertIn("[nix] install: nix profile install", out)
|
||||||
self.assertIn("[Error] Failed to install Nix flake output 'default'", out)
|
self.assertIn("[ERROR] Failed to install Nix flake output 'default' (exit 1)", out)
|
||||||
self.assertIn("[Error] Command exited with code 1", out)
|
|
||||||
|
|
||||||
def test_nix_flake_run_optional_failure_does_not_raise(self):
|
def test_nix_flake_run_optional_failure_does_not_raise(self) -> None:
|
||||||
"""
|
"""
|
||||||
For the package-manager repository, the 'default' output is optional.
|
For pkgmgr/package-manager repositories:
|
||||||
Failure to install it must not raise, but should log a warning instead.
|
- 'pkgmgr' output is mandatory
|
||||||
|
- 'default' output is optional
|
||||||
|
Failure of optional output must not raise.
|
||||||
"""
|
"""
|
||||||
ctx = DummyCtx(identifier="pkgmgr", repo_dir=self.repo_dir)
|
ctx = DummyCtx(identifier="pkgmgr", repo_dir=self.repo_dir)
|
||||||
installer = NixFlakeInstaller()
|
installer = NixFlakeInstaller()
|
||||||
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
def fake_system(cmd: str) -> int:
|
|
||||||
calls.append(cmd)
|
|
||||||
# First call (pkgmgr) → success
|
|
||||||
if len(calls) == 1:
|
|
||||||
return 0
|
|
||||||
# Second call (default) → failure (exit code 1 encoded)
|
|
||||||
return 1 << 8
|
|
||||||
|
|
||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
with patch(
|
with patch("pkgmgr.actions.install.installers.nix_flake.shutil.which") as which_mock, patch(
|
||||||
"pkgmgr.actions.install.installers.nix_flake.shutil.which"
|
"pkgmgr.actions.install.installers.nix_flake.subprocess.run"
|
||||||
) as which_mock, patch(
|
) as subproc_mock, patch(
|
||||||
"pkgmgr.actions.install.installers.nix_flake.os.system",
|
"pkgmgr.actions.install.installers.nix_flake.run_command"
|
||||||
side_effect=fake_system,
|
) as run_cmd_mock, redirect_stdout(buf):
|
||||||
), redirect_stdout(buf):
|
|
||||||
self._enable_nix_in_module(which_mock)
|
self._enable_nix_in_module(which_mock)
|
||||||
|
|
||||||
|
# No indices available (empty list)
|
||||||
|
subproc_mock.return_value = subprocess.CompletedProcess(
|
||||||
|
args=["nix", "profile", "list", "--json"],
|
||||||
|
returncode=0,
|
||||||
|
stdout='{"elements": []}',
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
|
||||||
|
# pkgmgr install ok; default fails twice (initial + retry)
|
||||||
|
run_cmd_mock.side_effect = [self._cp(0), self._cp(1), self._cp(1)]
|
||||||
|
|
||||||
self.assertTrue(installer.supports(ctx))
|
self.assertTrue(installer.supports(ctx))
|
||||||
|
|
||||||
# Optional failure must NOT raise
|
# Must NOT raise despite optional failure
|
||||||
installer.run(ctx)
|
installer.run(ctx)
|
||||||
|
|
||||||
out = buf.getvalue()
|
out = buf.getvalue()
|
||||||
|
|
||||||
# Both outputs should have been mentioned
|
# Should announce both outputs
|
||||||
self.assertIn(
|
self.assertIn("ensuring outputs: pkgmgr, default", out)
|
||||||
"attempting to install profile outputs: pkgmgr, default", out
|
|
||||||
)
|
|
||||||
|
|
||||||
# First output ("pkgmgr") succeeded
|
# First output ok
|
||||||
self.assertIn(
|
self.assertIn("[nix] output 'pkgmgr' successfully installed.", out)
|
||||||
"Nix flake output 'pkgmgr' successfully installed.", out
|
|
||||||
)
|
|
||||||
|
|
||||||
# Second output ("default") failed but did not raise
|
# Second output failed but no raise
|
||||||
self.assertIn(
|
self.assertIn("[ERROR] Failed to install Nix flake output 'default' (exit 1)", out)
|
||||||
"[Error] Failed to install Nix flake output 'default'", out
|
self.assertIn("[WARNING] Continuing despite failure of optional output 'default'.", out)
|
||||||
)
|
|
||||||
self.assertIn("[Error] Command exited with code 1", out)
|
|
||||||
self.assertIn(
|
|
||||||
"Continuing despite failure to install optional output 'default'.",
|
|
||||||
out,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ensure we actually called os.system twice (pkgmgr and default)
|
# Verify run_command was called for both outputs (default twice due to retry)
|
||||||
self.assertEqual(len(calls), 2)
|
expected_calls = [
|
||||||
self.assertIn(
|
(f"nix profile install {self.repo_dir}#pkgmgr",),
|
||||||
f"nix profile install {self.repo_dir}#pkgmgr",
|
(f"nix profile install {self.repo_dir}#default",),
|
||||||
calls[0],
|
(f"nix profile install {self.repo_dir}#default",),
|
||||||
)
|
]
|
||||||
self.assertIn(
|
actual_cmds = [c.args[0] for c in run_cmd_mock.call_args_list]
|
||||||
f"nix profile install {self.repo_dir}#default",
|
self.assertEqual(actual_cmds, [e[0] for e in expected_calls])
|
||||||
calls[1],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_nix_flake_supports_respects_disable_env(self):
|
def test_nix_flake_supports_respects_disable_env(self) -> None:
|
||||||
"""
|
"""
|
||||||
PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 must disable the installer,
|
PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 must disable the installer,
|
||||||
even if flake.nix exists and nix is available.
|
even if flake.nix exists and nix is available.
|
||||||
"""
|
"""
|
||||||
ctx = DummyCtx(identifier="pkgmgr", repo_dir=self.repo_dir)
|
ctx = DummyCtx(identifier="pkgmgr", repo_dir=self.repo_dir, quiet=False)
|
||||||
installer = NixFlakeInstaller()
|
installer = NixFlakeInstaller()
|
||||||
|
|
||||||
with patch(
|
with patch("pkgmgr.actions.install.installers.nix_flake.shutil.which") as which_mock:
|
||||||
"pkgmgr.actions.install.installers.nix_flake.shutil.which"
|
|
||||||
) as which_mock:
|
|
||||||
self._enable_nix_in_module(which_mock)
|
self._enable_nix_in_module(which_mock)
|
||||||
os.environ["PKGMGR_DISABLE_NIX_FLAKE_INSTALLER"] = "1"
|
os.environ["PKGMGR_DISABLE_NIX_FLAKE_INSTALLER"] = "1"
|
||||||
|
|
||||||
self.assertFalse(installer.supports(ctx))
|
self.assertFalse(installer.supports(ctx))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user