refactor(nix): split NixFlakeInstaller into atomic modules and add GitHub 403 retry handling
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
- Move Nix flake installer into installers/nix/ with atomic components (installer, runner, profile, retry, types) - Preserve legacy behavior and semantics of NixFlakeInstaller - Add GitHub API 403 rate-limit retry with Fibonacci backoff + jitter - Update all imports to new nix module path - Rename legacy unit tests and adapt patches to new structure - Add unit test for simulated GitHub 403 retry without realtime sleeping https://chatgpt.com/share/693e925d-a79c-800f-b0b6-92b8ba260b11
This commit is contained in:
@@ -28,7 +28,7 @@ from pkgmgr.actions.install.installers.os_packages import (
|
|||||||
DebianControlInstaller,
|
DebianControlInstaller,
|
||||||
RpmSpecInstaller,
|
RpmSpecInstaller,
|
||||||
)
|
)
|
||||||
from pkgmgr.actions.install.installers.nix_flake import (
|
from pkgmgr.actions.install.installers.nix import (
|
||||||
NixFlakeInstaller,
|
NixFlakeInstaller,
|
||||||
)
|
)
|
||||||
from pkgmgr.actions.install.installers.python import PythonInstaller
|
from pkgmgr.actions.install.installers.python import PythonInstaller
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ pkgmgr.actions.install.installers.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from pkgmgr.actions.install.installers.base import BaseInstaller # noqa: F401
|
from pkgmgr.actions.install.installers.base import BaseInstaller # noqa: F401
|
||||||
from pkgmgr.actions.install.installers.nix_flake import NixFlakeInstaller # noqa: F401
|
from pkgmgr.actions.install.installers.nix import NixFlakeInstaller # noqa: F401
|
||||||
from pkgmgr.actions.install.installers.python import PythonInstaller # noqa: F401
|
from pkgmgr.actions.install.installers.python import PythonInstaller # noqa: F401
|
||||||
from pkgmgr.actions.install.installers.makefile import MakefileInstaller # noqa: F401
|
from pkgmgr.actions.install.installers.makefile import MakefileInstaller # noqa: F401
|
||||||
|
|
||||||
|
|||||||
4
src/pkgmgr/actions/install/installers/nix/__init__.py
Normal file
4
src/pkgmgr/actions/install/installers/nix/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from .installer import NixFlakeInstaller
|
||||||
|
from .retry import RetryPolicy
|
||||||
|
|
||||||
|
__all__ = ["NixFlakeInstaller", "RetryPolicy"]
|
||||||
166
src/pkgmgr/actions/install/installers/nix/installer.py
Normal file
166
src/pkgmgr/actions/install/installers/nix/installer.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# src/pkgmgr/actions/install/installers/nix/installer.py
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||||
|
|
||||||
|
from .profile import NixProfileInspector
|
||||||
|
from .retry import GitHubRateLimitRetry, RetryPolicy
|
||||||
|
from .runner import CommandRunner
|
||||||
|
|
||||||
|
|
||||||
|
class NixFlakeInstaller(BaseInstaller):
|
||||||
|
layer = "nix"
|
||||||
|
FLAKE_FILE = "flake.nix"
|
||||||
|
|
||||||
|
def __init__(self, policy: RetryPolicy | None = None) -> None:
|
||||||
|
self._runner = CommandRunner()
|
||||||
|
self._retry = GitHubRateLimitRetry(policy=policy)
|
||||||
|
self._profile = NixProfileInspector()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Compatibility: supports()
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def supports(self, ctx: "RepoContext") -> bool:
|
||||||
|
if os.environ.get("PKGMGR_DISABLE_NIX_FLAKE_INSTALLER") == "1":
|
||||||
|
if not ctx.quiet:
|
||||||
|
print("[INFO] PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 – skipping NixFlakeInstaller.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if shutil.which("nix") is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return os.path.exists(os.path.join(ctx.repo_dir, self.FLAKE_FILE))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Compatibility: output selection
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _profile_outputs(self, ctx: "RepoContext") -> List[Tuple[str, bool]]:
|
||||||
|
# (output_name, allow_failure)
|
||||||
|
if ctx.identifier in {"pkgmgr", "package-manager"}:
|
||||||
|
return [("pkgmgr", False), ("default", True)]
|
||||||
|
return [("default", False)]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Compatibility: run()
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Core logic (unchanged semantics)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _installable(self, ctx: "RepoContext", output: str) -> str:
|
||||||
|
return f"{ctx.repo_dir}#{output}"
|
||||||
|
|
||||||
|
def _install_only(self, ctx: "RepoContext", output: str, allow_failure: bool) -> None:
|
||||||
|
install_cmd = f"nix profile install {self._installable(ctx, output)}"
|
||||||
|
|
||||||
|
if not ctx.quiet:
|
||||||
|
print(f"[nix] install: {install_cmd}")
|
||||||
|
|
||||||
|
res = self._retry.run_with_retry(ctx, self._runner, install_cmd)
|
||||||
|
|
||||||
|
if res.returncode == 0:
|
||||||
|
if not ctx.quiet:
|
||||||
|
print(f"[nix] output '{output}' successfully installed.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not ctx.quiet:
|
||||||
|
print(
|
||||||
|
f"[nix] install failed for '{output}' (exit {res.returncode}), "
|
||||||
|
"trying index-based upgrade/remove+install..."
|
||||||
|
)
|
||||||
|
|
||||||
|
indices = self._profile.find_installed_indices_for_output(ctx, self._runner, output)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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._runner.run(ctx, install_cmd, allow_failure=True)
|
||||||
|
if final.returncode == 0:
|
||||||
|
if not ctx.quiet:
|
||||||
|
print(f"[nix] output '{output}' successfully re-installed.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"[ERROR] Failed to install Nix flake output '{output}' (exit {final.returncode})")
|
||||||
|
|
||||||
|
if not allow_failure:
|
||||||
|
raise SystemExit(final.returncode)
|
||||||
|
|
||||||
|
print(f"[WARNING] Continuing despite failure of optional output '{output}'.")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# force_update path (unchanged semantics)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _force_upgrade_output(self, ctx: "RepoContext", output: str, allow_failure: bool) -> None:
|
||||||
|
indices = self._profile.find_installed_indices_for_output(ctx, self._runner, 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:
|
||||||
|
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)
|
||||||
|
|
||||||
|
self._install_only(ctx, output, allow_failure)
|
||||||
|
|
||||||
|
print(f"[nix] output '{output}' successfully upgraded.")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Helpers
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _upgrade_index(self, ctx: "RepoContext", idx: int) -> bool:
|
||||||
|
res = self._runner.run(ctx, f"nix profile upgrade --refresh {idx}", allow_failure=True)
|
||||||
|
return res.returncode == 0
|
||||||
|
|
||||||
|
def _remove_index(self, ctx: "RepoContext", idx: int) -> None:
|
||||||
|
self._runner.run(ctx, f"nix profile remove {idx}", allow_failure=True)
|
||||||
67
src/pkgmgr/actions/install/installers/nix/profile.py
Normal file
67
src/pkgmgr/actions/install/installers/nix/profile.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# src/pkgmgr/actions/install/installers/nix/profile.py
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, List
|
||||||
|
|
||||||
|
class NixProfileInspector:
|
||||||
|
"""
|
||||||
|
Reads and interprets `nix profile list --json` and provides helpers for
|
||||||
|
finding indices matching a given output name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def find_installed_indices_for_output(self, ctx: "RepoContext", runner: "CommandRunner", output: str) -> List[int]:
|
||||||
|
res = runner.run(ctx, "nix profile list --json", allow_failure=True)
|
||||||
|
if res.returncode != 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(res.stdout or "{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
indices: List[int] = []
|
||||||
|
|
||||||
|
elements = data.get("elements")
|
||||||
|
if isinstance(elements, dict):
|
||||||
|
for idx_str, elem in elements.items():
|
||||||
|
try:
|
||||||
|
idx = int(idx_str)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if self._element_matches_output(elem, output):
|
||||||
|
indices.append(idx)
|
||||||
|
return sorted(indices)
|
||||||
|
|
||||||
|
if isinstance(elements, list):
|
||||||
|
for elem in elements:
|
||||||
|
idx = elem.get("index") if isinstance(elem, dict) else None
|
||||||
|
if isinstance(idx, int) and self._element_matches_output(elem, output):
|
||||||
|
indices.append(idx)
|
||||||
|
return sorted(indices)
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def element_matches_output(elem: Any, output: str) -> bool:
|
||||||
|
return NixProfileInspector._element_matches_output(elem, output)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _element_matches_output(elem: Any, output: str) -> bool:
|
||||||
|
out = (output or "").strip()
|
||||||
|
if not out or not isinstance(elem, dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
candidates: List[str] = []
|
||||||
|
for k in ("attrPath", "originalUrl", "url", "storePath", "name"):
|
||||||
|
v = elem.get(k)
|
||||||
|
if isinstance(v, str) and v:
|
||||||
|
candidates.append(v)
|
||||||
|
|
||||||
|
for c in candidates:
|
||||||
|
if c == out:
|
||||||
|
return True
|
||||||
|
if f"#{out}" in c:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
85
src/pkgmgr/actions/install/installers/nix/retry.py
Normal file
85
src/pkgmgr/actions/install/installers/nix/retry.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# src/pkgmgr/actions/install/installers/nix/retry.py
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from .types import RunResult
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RetryPolicy:
|
||||||
|
max_attempts: int = 7
|
||||||
|
base_delay_seconds: int = 30
|
||||||
|
jitter_seconds_min: int = 0
|
||||||
|
jitter_seconds_max: int = 60
|
||||||
|
|
||||||
|
|
||||||
|
class GitHubRateLimitRetry:
|
||||||
|
"""
|
||||||
|
Retries nix install commands only when the error looks like a GitHub API rate limit (HTTP 403).
|
||||||
|
Backoff: Fibonacci(base, base, ...) + random jitter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, policy: RetryPolicy | None = None) -> None:
|
||||||
|
self._policy = policy or RetryPolicy()
|
||||||
|
|
||||||
|
def run_with_retry(
|
||||||
|
self,
|
||||||
|
ctx: "RepoContext",
|
||||||
|
runner: "CommandRunner",
|
||||||
|
install_cmd: str,
|
||||||
|
) -> RunResult:
|
||||||
|
quiet = bool(getattr(ctx, "quiet", False))
|
||||||
|
delays = list(self._fibonacci_backoff(self._policy.base_delay_seconds, self._policy.max_attempts))
|
||||||
|
|
||||||
|
last: RunResult | None = None
|
||||||
|
|
||||||
|
for attempt, base_delay in enumerate(delays, start=1):
|
||||||
|
if not quiet:
|
||||||
|
print(f"[nix] attempt {attempt}/{self._policy.max_attempts}: {install_cmd}")
|
||||||
|
|
||||||
|
res = runner.run(ctx, install_cmd, allow_failure=True)
|
||||||
|
last = res
|
||||||
|
|
||||||
|
if res.returncode == 0:
|
||||||
|
return res
|
||||||
|
|
||||||
|
combined = f"{res.stdout}\n{res.stderr}"
|
||||||
|
if not self._is_github_rate_limit_error(combined):
|
||||||
|
return res
|
||||||
|
|
||||||
|
if attempt >= self._policy.max_attempts:
|
||||||
|
break
|
||||||
|
|
||||||
|
jitter = random.randint(self._policy.jitter_seconds_min, self._policy.jitter_seconds_max)
|
||||||
|
wait_time = base_delay + jitter
|
||||||
|
|
||||||
|
if not quiet:
|
||||||
|
print(
|
||||||
|
"[nix] GitHub rate limit detected (403). "
|
||||||
|
f"Retrying in {wait_time}s (base={base_delay}s, jitter={jitter}s)..."
|
||||||
|
)
|
||||||
|
|
||||||
|
time.sleep(wait_time)
|
||||||
|
|
||||||
|
return last if last is not None else RunResult(returncode=1, stdout="", stderr="nix install retry failed")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_github_rate_limit_error(text: str) -> bool:
|
||||||
|
t = (text or "").lower()
|
||||||
|
return (
|
||||||
|
"http error 403" in t
|
||||||
|
or "rate limit exceeded" in t
|
||||||
|
or "github api rate limit" in t
|
||||||
|
or "api rate limit exceeded" in t
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fibonacci_backoff(base: int, attempts: int) -> Iterable[int]:
|
||||||
|
a, b = base, base
|
||||||
|
for _ in range(max(1, attempts)):
|
||||||
|
yield a
|
||||||
|
a, b = b, a + b
|
||||||
60
src/pkgmgr/actions/install/installers/nix/runner.py
Normal file
60
src/pkgmgr/actions/install/installers/nix/runner.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from .types import RunResult
|
||||||
|
|
||||||
|
|
||||||
|
class CommandRunner:
|
||||||
|
"""
|
||||||
|
Executes commands (shell=True) inside a repository directory (if provided).
|
||||||
|
Supports preview mode and compact failure output logging.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def run(self, ctx: "RepoContext", cmd: str, allow_failure: bool) -> RunResult:
|
||||||
|
repo_dir = getattr(ctx, "repo_dir", None) or getattr(ctx, "repo_path", None)
|
||||||
|
preview = bool(getattr(ctx, "preview", False))
|
||||||
|
quiet = bool(getattr(ctx, "quiet", False))
|
||||||
|
|
||||||
|
if preview:
|
||||||
|
if not quiet:
|
||||||
|
print(f"[preview] {cmd}")
|
||||||
|
return RunResult(returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
|
try:
|
||||||
|
p = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
shell=True,
|
||||||
|
cwd=repo_dir,
|
||||||
|
check=False,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if not allow_failure:
|
||||||
|
raise
|
||||||
|
return RunResult(returncode=1, stdout="", stderr=str(e))
|
||||||
|
|
||||||
|
res = RunResult(returncode=p.returncode, stdout=p.stdout or "", stderr=p.stderr or "")
|
||||||
|
|
||||||
|
if res.returncode != 0 and not quiet:
|
||||||
|
self._print_compact_failure(res)
|
||||||
|
|
||||||
|
if res.returncode != 0 and not allow_failure:
|
||||||
|
raise SystemExit(res.returncode)
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _print_compact_failure(res: RunResult) -> None:
|
||||||
|
out = (res.stdout or "").strip()
|
||||||
|
err = (res.stderr or "").strip()
|
||||||
|
|
||||||
|
if out:
|
||||||
|
print("[nix] stdout (last lines):")
|
||||||
|
print("\n".join(out.splitlines()[-20:]))
|
||||||
|
|
||||||
|
if err:
|
||||||
|
print("[nix] stderr (last lines):")
|
||||||
|
print("\n".join(err.splitlines()[-40:]))
|
||||||
10
src/pkgmgr/actions/install/installers/nix/types.py
Normal file
10
src/pkgmgr/actions/install/installers/nix/types.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RunResult:
|
||||||
|
returncode: int
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
from typing import TYPE_CHECKING, List, Tuple
|
|
||||||
|
|
||||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
|
||||||
from pkgmgr.core.command.run import run_command
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pkgmgr.actions.install.context import RepoContext
|
|
||||||
|
|
||||||
|
|
||||||
class NixFlakeInstaller(BaseInstaller):
|
|
||||||
layer = "nix"
|
|
||||||
FLAKE_FILE = "flake.nix"
|
|
||||||
|
|
||||||
def supports(self, ctx: "RepoContext") -> bool:
|
|
||||||
if os.environ.get("PKGMGR_DISABLE_NIX_FLAKE_INSTALLER") == "1":
|
|
||||||
if not ctx.quiet:
|
|
||||||
print("[INFO] PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 – skipping NixFlakeInstaller.")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if shutil.which("nix") is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return os.path.exists(os.path.join(ctx.repo_dir, self.FLAKE_FILE))
|
|
||||||
|
|
||||||
def _profile_outputs(self, ctx: "RepoContext") -> List[Tuple[str, bool]]:
|
|
||||||
# (output_name, allow_failure)
|
|
||||||
if ctx.identifier in {"pkgmgr", "package-manager"}:
|
|
||||||
return [("pkgmgr", False), ("default", True)]
|
|
||||||
return [("default", False)]
|
|
||||||
|
|
||||||
def _installable(self, ctx: "RepoContext", output: str) -> str:
|
|
||||||
return f"{ctx.repo_dir}#{output}"
|
|
||||||
|
|
||||||
def _run(self, ctx: "RepoContext", cmd: str, allow_failure: bool = True):
|
|
||||||
return run_command(
|
|
||||||
cmd,
|
|
||||||
cwd=ctx.repo_dir,
|
|
||||||
preview=ctx.preview,
|
|
||||||
allow_failure=allow_failure,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _profile_list_json(self, ctx: "RepoContext") -> dict:
|
|
||||||
"""
|
|
||||||
Read current Nix profile entries as JSON (best-effort).
|
|
||||||
|
|
||||||
NOTE: Nix versions differ:
|
|
||||||
- Newer: {"elements": [ { "index": 0, "attrPath": "...", ... }, ... ]}
|
|
||||||
- Older: {"elements": [ "nixpkgs#hello", ... ]} (strings)
|
|
||||||
|
|
||||||
We return {} on failure or in preview mode.
|
|
||||||
"""
|
|
||||||
if ctx.preview:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
proc = subprocess.run(
|
|
||||||
["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
|
|
||||||
|
|
||||||
idx = el.get("index")
|
|
||||||
if idx is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
attr_path = el.get("attrPath") or el.get("attr_path") or ""
|
|
||||||
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(
|
|
||||||
f"[nix] install failed for '{output}' (exit {res.returncode}), "
|
|
||||||
"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)
|
|
||||||
@@ -23,7 +23,7 @@ from unittest.mock import patch
|
|||||||
import pkgmgr.actions.install as install_mod
|
import pkgmgr.actions.install as install_mod
|
||||||
from pkgmgr.actions.install import install_repos
|
from pkgmgr.actions.install import install_repos
|
||||||
from pkgmgr.actions.install.installers.makefile import MakefileInstaller
|
from pkgmgr.actions.install.installers.makefile import MakefileInstaller
|
||||||
from pkgmgr.actions.install.installers.nix_flake import NixFlakeInstaller
|
from pkgmgr.actions.install.installers.nix import NixFlakeInstaller
|
||||||
from pkgmgr.actions.install.installers.os_packages.arch_pkgbuild import (
|
from pkgmgr.actions.install.installers.os_packages.arch_pkgbuild import (
|
||||||
ArchPkgbuildInstaller,
|
ArchPkgbuildInstaller,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import unittest
|
|||||||
from contextlib import redirect_stdout
|
from contextlib import redirect_stdout
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from pkgmgr.actions.install.installers.nix_flake import NixFlakeInstaller
|
from pkgmgr.actions.install.installers.nix import NixFlakeInstaller
|
||||||
|
|
||||||
|
|
||||||
class DummyCtx:
|
class DummyCtx:
|
||||||
@@ -66,7 +66,7 @@ class TestNixFlakeInstaller(unittest.TestCase):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _enable_nix_in_module(which_patch) -> None:
|
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 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) -> None:
|
def test_nix_flake_run_success(self) -> None:
|
||||||
@@ -78,14 +78,14 @@ class TestNixFlakeInstaller(unittest.TestCase):
|
|||||||
installer = NixFlakeInstaller()
|
installer = NixFlakeInstaller()
|
||||||
|
|
||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
with patch("pkgmgr.actions.install.installers.nix_flake.shutil.which") as which_mock, patch(
|
with patch("pkgmgr.actions.install.installers.nix.installer.shutil.which") as which_mock, patch(
|
||||||
"pkgmgr.actions.install.installers.nix_flake.subprocess.run"
|
"pkgmgr.actions.install.installers.nix.installer.os.path.exists", return_value=True
|
||||||
) as subproc_mock, patch(
|
), patch(
|
||||||
"pkgmgr.actions.install.installers.nix_flake.run_command"
|
"pkgmgr.actions.install.installers.nix.runner.subprocess.run"
|
||||||
) as run_cmd_mock, redirect_stdout(buf):
|
) as subproc_mock, redirect_stdout(buf):
|
||||||
|
|
||||||
self._enable_nix_in_module(which_mock)
|
self._enable_nix_in_module(which_mock)
|
||||||
|
|
||||||
# For profile list JSON (used only on failure paths, but keep deterministic)
|
|
||||||
subproc_mock.return_value = subprocess.CompletedProcess(
|
subproc_mock.return_value = subprocess.CompletedProcess(
|
||||||
args=["nix", "profile", "list", "--json"],
|
args=["nix", "profile", "list", "--json"],
|
||||||
returncode=0,
|
returncode=0,
|
||||||
@@ -119,14 +119,14 @@ class TestNixFlakeInstaller(unittest.TestCase):
|
|||||||
installer = NixFlakeInstaller()
|
installer = NixFlakeInstaller()
|
||||||
|
|
||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
with patch("pkgmgr.actions.install.installers.nix_flake.shutil.which") as which_mock, patch(
|
with patch("pkgmgr.actions.install.installers.nix.installer.shutil.which") as which_mock, patch(
|
||||||
"pkgmgr.actions.install.installers.nix_flake.subprocess.run"
|
"pkgmgr.actions.install.installers.nix.installer.os.path.exists", return_value=True
|
||||||
) as subproc_mock, patch(
|
), patch(
|
||||||
"pkgmgr.actions.install.installers.nix_flake.run_command"
|
"pkgmgr.actions.install.installers.nix.runner.subprocess.run"
|
||||||
) as run_cmd_mock, redirect_stdout(buf):
|
) as subproc_mock, 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(
|
subproc_mock.return_value = subprocess.CompletedProcess(
|
||||||
args=["nix", "profile", "list", "--json"],
|
args=["nix", "profile", "list", "--json"],
|
||||||
returncode=0,
|
returncode=0,
|
||||||
@@ -157,14 +157,14 @@ class TestNixFlakeInstaller(unittest.TestCase):
|
|||||||
installer = NixFlakeInstaller()
|
installer = NixFlakeInstaller()
|
||||||
|
|
||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
with patch("pkgmgr.actions.install.installers.nix_flake.shutil.which") as which_mock, patch(
|
with patch("pkgmgr.actions.install.installers.nix.installer.shutil.which") as which_mock, patch(
|
||||||
"pkgmgr.actions.install.installers.nix_flake.subprocess.run"
|
"pkgmgr.actions.install.installers.nix.installer.os.path.exists", return_value=True
|
||||||
) as subproc_mock, patch(
|
), patch(
|
||||||
"pkgmgr.actions.install.installers.nix_flake.run_command"
|
"pkgmgr.actions.install.installers.nix.runner.subprocess.run"
|
||||||
) as run_cmd_mock, redirect_stdout(buf):
|
) as subproc_mock, 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(
|
subproc_mock.return_value = subprocess.CompletedProcess(
|
||||||
args=["nix", "profile", "list", "--json"],
|
args=["nix", "profile", "list", "--json"],
|
||||||
returncode=0,
|
returncode=0,
|
||||||
@@ -209,11 +209,12 @@ class TestNixFlakeInstaller(unittest.TestCase):
|
|||||||
ctx = DummyCtx(identifier="pkgmgr", repo_dir=self.repo_dir, quiet=False)
|
ctx = DummyCtx(identifier="pkgmgr", repo_dir=self.repo_dir, quiet=False)
|
||||||
installer = NixFlakeInstaller()
|
installer = NixFlakeInstaller()
|
||||||
|
|
||||||
with patch("pkgmgr.actions.install.installers.nix_flake.shutil.which") as which_mock:
|
with patch("pkgmgr.actions.install.installers.nix.installer.shutil.which") as which_mock, patch(
|
||||||
|
"pkgmgr.actions.install.installers.nix.installer.os.path.exists", return_value=True
|
||||||
|
):
|
||||||
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))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from pkgmgr.actions.install.installers.nix.retry import GitHubRateLimitRetry, RetryPolicy
|
||||||
|
from pkgmgr.actions.install.installers.nix.types import RunResult
|
||||||
|
|
||||||
|
|
||||||
|
class DummyCtx:
|
||||||
|
def __init__(self, quiet: bool = True) -> None:
|
||||||
|
self.quiet = quiet
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRunner:
|
||||||
|
"""
|
||||||
|
Simulates a runner that returns:
|
||||||
|
- HTTP 403 for the first N calls
|
||||||
|
- success afterwards
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, fail_count: int) -> None:
|
||||||
|
self.fail_count = fail_count
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def run(self, ctx: DummyCtx, cmd: str, allow_failure: bool) -> RunResult:
|
||||||
|
self.calls += 1
|
||||||
|
|
||||||
|
if self.calls <= self.fail_count:
|
||||||
|
return RunResult(
|
||||||
|
returncode=1,
|
||||||
|
stdout="",
|
||||||
|
stderr="error: HTTP error 403: rate limit exceeded (simulated)",
|
||||||
|
)
|
||||||
|
|
||||||
|
return RunResult(returncode=0, stdout="ok", stderr="")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitHub403Retry(unittest.TestCase):
|
||||||
|
def test_retries_on_403_without_realtime_waiting(self) -> None:
|
||||||
|
"""
|
||||||
|
Ensure:
|
||||||
|
- It retries only on GitHub 403-like errors
|
||||||
|
- It does not actually sleep in realtime (time.sleep patched)
|
||||||
|
- It stops once a success occurs
|
||||||
|
- Wait times follow Fibonacci(base=30) + jitter
|
||||||
|
"""
|
||||||
|
policy = RetryPolicy(
|
||||||
|
max_attempts=3, # attempts: 1,2,3
|
||||||
|
base_delay_seconds=30, # fibonacci delays: 30, 30, 60
|
||||||
|
jitter_seconds_min=0,
|
||||||
|
jitter_seconds_max=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
retry = GitHubRateLimitRetry(policy=policy)
|
||||||
|
ctx = DummyCtx(quiet=True)
|
||||||
|
runner = FakeRunner(fail_count=2) # fail twice (403), then succeed
|
||||||
|
|
||||||
|
# Make jitter deterministic and prevent real sleeping.
|
||||||
|
with patch("pkgmgr.actions.install.installers.nix.retry.random.randint", return_value=5) as jitter_mock, patch(
|
||||||
|
"pkgmgr.actions.install.installers.nix.retry.time.sleep"
|
||||||
|
) as sleep_mock:
|
||||||
|
res = retry.run_with_retry(ctx, runner, "nix profile install /tmp#default")
|
||||||
|
|
||||||
|
# Result should be success on 3rd attempt.
|
||||||
|
self.assertEqual(res.returncode, 0)
|
||||||
|
self.assertEqual(runner.calls, 3)
|
||||||
|
|
||||||
|
# jitter should be used for each retry sleep (attempt 1->2, attempt 2->3) => 2 sleeps
|
||||||
|
self.assertEqual(jitter_mock.call_count, 2)
|
||||||
|
self.assertEqual(sleep_mock.call_count, 2)
|
||||||
|
|
||||||
|
# Fibonacci delays for attempts=3: [30, 30, 60]
|
||||||
|
# sleep occurs after failed attempt 1 and 2, so base delays are 30 and 30
|
||||||
|
# wait_time = base_delay + jitter(5) => 35, 35
|
||||||
|
sleep_args = [c.args[0] for c in sleep_mock.call_args_list]
|
||||||
|
self.assertEqual(sleep_args, [35, 35])
|
||||||
|
|
||||||
|
def test_does_not_retry_on_non_403_errors(self) -> None:
|
||||||
|
"""
|
||||||
|
Ensure it does not retry when the error is not recognized as GitHub 403/rate limit.
|
||||||
|
"""
|
||||||
|
policy = RetryPolicy(max_attempts=7, base_delay_seconds=30)
|
||||||
|
retry = GitHubRateLimitRetry(policy=policy)
|
||||||
|
ctx = DummyCtx(quiet=True)
|
||||||
|
|
||||||
|
class Non403Runner:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def run(self, ctx: DummyCtx, cmd: str, allow_failure: bool) -> RunResult:
|
||||||
|
self.calls += 1
|
||||||
|
return RunResult(returncode=1, stdout="", stderr="some other error (simulated)")
|
||||||
|
|
||||||
|
runner = Non403Runner()
|
||||||
|
|
||||||
|
with patch("pkgmgr.actions.install.installers.nix.retry.time.sleep") as sleep_mock:
|
||||||
|
res = retry.run_with_retry(ctx, runner, "nix profile install /tmp#default")
|
||||||
|
|
||||||
|
self.assertEqual(res.returncode, 1)
|
||||||
|
self.assertEqual(runner.calls, 1) # no retries
|
||||||
|
self.assertEqual(sleep_mock.call_count, 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user