#!/usr/bin/env python3 """Read-only invariant checks for the active Decman configuration.""" from __future__ import annotations import json import os import runpy import shutil import subprocess import sys from collections import Counter from collections.abc import Callable from pathlib import Path from typing import cast ROOT = Path(__file__).resolve().parents[1] PACKAGES = ROOT / "packages" def lines(path: Path) -> set[str]: return { line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip() } def actual(command: str) -> set[str]: return set( subprocess.run( ["pacman", command], check=True, text=True, stdout=subprocess.PIPE ).stdout.split() ) def npm_actual() -> set[str]: if shutil.which("npm") is None: return set() raw = subprocess.run( ["npm", "list", "--global", "--depth=0", "--json"], check=True, text=True, stdout=subprocess.PIPE, ).stdout try: result = json.loads(raw) except json.JSONDecodeError as error: raise SystemExit("npm returned invalid JSON") from error dependencies = result.get("dependencies", {}) if not isinstance(dependencies, dict): raise SystemExit("npm dependencies must be an object") return set(dependencies) def optional_lines(path: Path) -> set[str]: return lines(path) if path.exists() else set() def main() -> None: repo_files = list(PACKAGES.glob("**/*-repo.txt")) aur_files = list(PACKAGES.glob("**/*-aur.txt")) npm_files = list(PACKAGES.glob("**/*-npm.txt")) repo = [name for path in repo_files for name in lines(path)] aur = [name for path in aur_files for name in lines(path)] npm = [name for path in npm_files for name in lines(path)] duplicate = {name for name, count in Counter(repo + aur).items() if count > 1} duplicate_npm = {name for name, count in Counter(npm).items() if count > 1} if duplicate: raise SystemExit(f"duplicate package ownership: {sorted(duplicate)}") if duplicate_npm: raise SystemExit(f"duplicate npm package ownership: {sorted(duplicate_npm)}") installed_repo = actual("-Qqen") installed_aur = actual("-Qqem") installed_npm = npm_actual() os.chdir(ROOT) sys.path.insert(0, str(ROOT)) import decman runpy.run_path(str(ROOT / "source.py")) active_concerns = { concern for module in decman.modules if isinstance((concern := getattr(module, "concern", None)), str) } expected_repo = set().union( *(lines(PACKAGES / f"{concern}-repo.txt") for concern in active_concerns) ) expected_aur = set().union( *(lines(PACKAGES / f"{concern}-aur.txt") for concern in active_concerns) ) expected_npm = set().union( *(optional_lines(PACKAGES / f"{concern}-npm.txt") for concern in active_concerns) ) loaded_repo: set[str] = set() loaded_aur: set[str] = set() loaded_npm: set[str] = set() for module in decman.modules: native = getattr(module, "native_packages", None) foreign = getattr(module, "aur_packages", None) global_npm = getattr(module, "global_npm_packages", None) if callable(native): loaded_repo |= cast(Callable[[], set[str]], native)() if callable(foreign): loaded_aur |= cast(Callable[[], set[str]], foreign)() if callable(global_npm): loaded_npm |= cast(Callable[[], set[str]], global_npm)() if ( loaded_repo != expected_repo or loaded_aur != expected_aur or loaded_npm != expected_npm ): raise SystemExit("active profile does not compose its package concerns") system_manifests = { name for module in decman.modules for name in getattr(module, "_system_manifests", ()) } user_manifests = { name for module in decman.modules for name in getattr(module, "_user_manifests", ()) } expected_system = set().union( *(lines(ROOT / "manifests" / name) for name in system_manifests) ) expected_user = set().union( *(lines(ROOT / "manifests" / name) for name in user_manifests) ) loaded_system: set[str] = set() loaded_user: set[str] = set() for module in decman.modules: system = getattr(module, "system_units", None) user = getattr(module, "user_units", None) if callable(system): loaded_system |= cast(Callable[[], set[str]], system)() if callable(user): loaded_user |= cast(Callable[[], dict[str, set[str]]], user)().get( "alex", set() ) if loaded_system != expected_system or loaded_user != expected_user: raise SystemExit("active profile does not compose its unit manifests") host_scopes = active_concerns & {"laptop/hardware", "desktop/local"} if len(host_scopes) != 1: raise SystemExit(f"active profile has ambiguous host concerns: {host_scopes}") profile = next(iter(host_scopes)).split("/", maxsplit=1)[0] pending_remove = sorted( (installed_repo - expected_repo) | (installed_aur - expected_aur) ) pending_install = sorted( (expected_repo - installed_repo) | (expected_aur - installed_aur) ) pending_npm_install = sorted(expected_npm - installed_npm) print( f"OK: repo={len(expected_repo)} aur={len(expected_aur)} " f"npm={len(expected_npm)} system={len(loaded_system)} " f"user={len(loaded_user)} concerns={len(active_concerns)} profile={profile}" ) print(f"PENDING REMOVE: {' '.join(pending_remove) or 'none'}") print(f"PENDING INSTALL: {' '.join(pending_install) or 'none'}") print(f"PENDING NPM INSTALL: {' '.join(pending_npm_install) or 'none'}") if __name__ == "__main__": main()