Files
pkgmgr/src/pkgmgr/actions/install/installers/nix/profile/inspector.py
Kevin Veen-Birkenbach 06a6a77a48
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 / linter-shell (push) Has been cancelled
Mark stable commit / linter-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
*fix(nix): resolve nix profile conflicts without numeric indices and fix update pkgmgr system test*
* Switch conflict handling from index-based removal to token-based removal (*nix profile remove <name>*) for newer nix versions
* Add robust parsing of *nix profile list --json* with normalization and heuristics for output/name matching
* Detect at runtime whether numeric profile indices are supported and fall back automatically when they are not
* Ensure *pkgmgr* / *package-manager* flake outputs are correctly identified and cleaned up during reinstall
* Fix failing E2E test *test_update_pkgmgr_shallow_pkgmgr_with_system* by reliably removing conflicting profile entries before reinstall

https://chatgpt.com/share/693efae5-b8bc-800f-94e3-28c93b74ed7b
2025-12-14 18:58:29 +01:00

163 lines
5.1 KiB
Python

from __future__ import annotations
from typing import Any, List, TYPE_CHECKING
from .matcher import (
entry_matches_output,
entry_matches_store_path,
stable_unique_ints,
)
from .normalizer import normalize_elements
from .parser import parse_profile_list_json
from .result import extract_stdout_text
if TYPE_CHECKING:
# Keep these as TYPE_CHECKING-only to avoid runtime import cycles.
from pkgmgr.actions.install.context import RepoContext
from pkgmgr.core.command.runner import CommandRunner
class NixProfileInspector:
"""
Reads and inspects the user's Nix profile list (JSON).
Public API:
- list_json()
- find_installed_indices_for_output() (legacy; may not work on newer nix)
- find_indices_by_store_path() (legacy; may not work on newer nix)
- find_remove_tokens_for_output()
- find_remove_tokens_for_store_prefixes()
"""
def list_json(self, ctx: "RepoContext", runner: "CommandRunner") -> dict[str, Any]:
res = runner.run(ctx, "nix profile list --json", allow_failure=False)
raw = extract_stdout_text(res)
return parse_profile_list_json(raw)
# ---------------------------------------------------------------------
# Legacy index helpers (still useful on older nix; newer nix may reject indices)
# ---------------------------------------------------------------------
def find_installed_indices_for_output(
self,
ctx: "RepoContext",
runner: "CommandRunner",
output: str,
) -> List[int]:
data = self.list_json(ctx, runner)
entries = normalize_elements(data)
hits: List[int] = []
for e in entries:
if e.index is None:
continue
if entry_matches_output(e, output):
hits.append(e.index)
return stable_unique_ints(hits)
def find_indices_by_store_path(
self,
ctx: "RepoContext",
runner: "CommandRunner",
store_path: str,
) -> List[int]:
needle = (store_path or "").strip()
if not needle:
return []
data = self.list_json(ctx, runner)
entries = normalize_elements(data)
hits: List[int] = []
for e in entries:
if e.index is None:
continue
if entry_matches_store_path(e, needle):
hits.append(e.index)
return stable_unique_ints(hits)
# ---------------------------------------------------------------------
# New token-based helpers (works with newer nix where indices are rejected)
# ---------------------------------------------------------------------
def find_remove_tokens_for_output(
self,
ctx: "RepoContext",
runner: "CommandRunner",
output: str,
) -> List[str]:
"""
Returns profile remove tokens to remove entries matching a given output.
We always include the raw output token first because nix itself suggests:
nix profile remove pkgmgr
"""
out = (output or "").strip()
if not out:
return []
data = self.list_json(ctx, runner)
entries = normalize_elements(data)
tokens: List[str] = [out] # critical: matches nix's own suggestion for conflicts
for e in entries:
if entry_matches_output(e, out):
# Prefer removing by key/name (non-index) when possible.
# New nix rejects numeric indices; these tokens are safer.
k = (e.key or "").strip()
n = (e.name or "").strip()
if k and not k.isdigit():
tokens.append(k)
elif n and not n.isdigit():
tokens.append(n)
# stable unique preserving order
seen: set[str] = set()
uniq: List[str] = []
for t in tokens:
if t and t not in seen:
uniq.append(t)
seen.add(t)
return uniq
def find_remove_tokens_for_store_prefixes(
self,
ctx: "RepoContext",
runner: "CommandRunner",
prefixes: List[str],
) -> List[str]:
"""
Returns remove tokens for entries whose store path matches any prefix.
"""
prefixes = [(p or "").strip() for p in (prefixes or []) if p]
prefixes = [p for p in prefixes if p]
if not prefixes:
return []
data = self.list_json(ctx, runner)
entries = normalize_elements(data)
tokens: List[str] = []
for e in entries:
if not e.store_paths:
continue
if any(sp == p for sp in e.store_paths for p in prefixes):
k = (e.key or "").strip()
n = (e.name or "").strip()
if k and not k.isdigit():
tokens.append(k)
elif n and not n.isdigit():
tokens.append(n)
seen: set[str] = set()
uniq: List[str] = []
for t in tokens:
if t and t not in seen:
uniq.append(t)
seen.add(t)
return uniq