169 lines
5.7 KiB
Python
Executable File
169 lines
5.7 KiB
Python
Executable File
#!/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()
|