"""The lockfile (versions + hashes, machine-written) and the installed-state file (what is actually on this machine). lock.json: {"schema": 1, "packages": {name: {"version": ..., "artifacts": {target: {"url": ..., "sha256": ...}}}}} facts.json: {"schema": 1, "packages": {name: {"entry": ..., "version": ..., "env": ..., "stamp": ...}}} A target's value is one {"url", "sha256"} object, or a LIST of them when the package is split across several archives that must land in one directory (llama.cpp's engine zip and its cudart zip: Windows resolves a DLL from the loading executable's own directory). Both shapes read back as a list. lock.json artifacts carry the RESOLVED url beside the hash: the lockfile is the complete machine interface (nix reads it as pure data), and the python url templates are consulted only at `pm lock --bump` time. The "any" target key covers target-independent artifacts (npm's tarball). facts.json env values hold {{store}} templates so a CI-built file adopts onto any machine by substitution. """ from __future__ import annotations import json import os import re from copy import deepcopy from pathlib import Path SCHEMA = 1 STORE_TOKEN = "{{store}}" def _read(path: Path, *, strict: bool = False) -> dict: try: text = path.read_text(encoding="utf-8-sig") except FileNotFoundError: return {"schema": SCHEMA, "packages": {}} except OSError: if strict: raise return {"schema": SCHEMA, "packages": {}} try: data = json.loads(text) if isinstance(data, dict) and data.get("schema") == SCHEMA and isinstance(data.get("packages"), dict): return data except ValueError: pass if strict: raise ValueError(f"cannot read recorded package state: {path}") if text.strip(): # An unparsable-but-nonempty state file is evidence, not garbage: # the next _write would silently discard every installed-state # record. Keep the bytes for post-mortem. try: from pm.paths import store_root if path.parent == store_root() and (store_root().parent / "manifest.json").is_file(): import logging logging.getLogger(__name__).warning("invalid shipped state file: %s", path) else: path.with_suffix(".corrupt").write_text(text, encoding="utf-8") except OSError: pass return {"schema": SCHEMA, "packages": {}} def _write(path: Path, data: dict) -> None: from pm.filesystem import durable_write_bytes durable_write_bytes(path, (json.dumps(data, indent=2, sort_keys=True) + "\n").encode("utf-8")) class StaleLockRow(RuntimeError): """A concurrent editor changed a row that this writer plans to publish.""" class Lockfile: """Snapshot of lock.json with conflict-checked publication of changed pins.""" def __init__(self, path: Path): self.path = Path(path).resolve() self._packages = _read(self.path)["packages"] self._base = deepcopy(self._packages) self._touched: set[str] = set() def version(self, name: str) -> str | None: return (self._packages.get(name) or {}).get("version") def artifacts(self, name: str, target: str) -> list[dict]: """Every archive this target needs, in extraction order. One artifact and a list of them are the same thing here — the single form is just the common case written short.""" artifacts = (self._packages.get(name) or {}).get("artifacts") or {} found = artifacts.get(target) if found is None: found = artifacts.get("any") if found is None: return [] return list(found) if isinstance(found, list) else [found] def pinned_artifacts(self, name: str) -> dict: """Return the complete pin table, including targets without discovery.""" return deepcopy((self._packages.get(name) or {}).get("artifacts") or {}) def names(self) -> list[str]: return sorted(self._packages) def set_pin(self, name: str, version: str, artifacts: dict[str, dict]) -> None: self._packages[name] = {"version": version, "artifacts": deepcopy(artifacts)} self._touched.add(name) def save(self) -> None: """Merge touched rows under the file's lock, or leave all rows unchanged.""" from pm.filesystem import lock_fd self.path.parent.mkdir(parents=True, exist_ok=True) fd = os.open(self.path.with_name(f".{self.path.name}.lock"), os.O_CREAT | os.O_RDWR, 0o600) try: lock_fd(fd, wait=True) current = _read(self.path, strict=True)["packages"] updated = dict(current) for name in self._touched: intended = self._packages[name] if current.get(name) != self._base.get(name) and current.get(name) != intended: raise StaleLockRow(f"{name} changed in {self.path}; read the lockfile again and retry") updated[name] = intended if updated != current or not self.path.exists(): _write(self.path, {"schema": SCHEMA, "packages": updated}) self._packages = updated self._base = deepcopy(updated) self._touched.clear() finally: os.close(fd) def termux_docker_digest() -> str: """The pinned termux-docker image digest (pm package `termux-docker`). Single reader for every consumer -- the build/deb scripts, the builder image script, and the release workflow -- so a lock-schema change lands once. """ from pm.paths import lockfile_path return Lockfile(lockfile_path()).version("termux-docker") class Facts: """The installed-state file. Written only by pm.""" def __init__(self, path: Path, *, strict: bool = False): self.path = path self._packages = _read(path, strict=strict)["packages"] def reload(self) -> None: self._packages = _read(self.path, strict=True)["packages"] def get(self, name: str) -> dict | None: return self._packages.get(name) def refresh_digests(self, store_root: Path) -> int: """Publish all tool digests after packaging finishes changing their bytes.""" from pm.store import tree_digest packages = _read(self.path, strict=True)["packages"] root = store_root.resolve() count = 0 for name, fact in packages.items(): if not isinstance(fact, dict): raise ValueError(f"invalid payload fact: {name}") if "entry" not in fact and "stamp" in fact: continue entry_name = fact.get("entry") artifacts = fact.get("artifacts") if (not isinstance(entry_name, str) or entry_name in ("", ".", "..") or any(c in entry_name for c in "/\\:") or not isinstance(fact.get("version"), str) or not fact["version"] or not isinstance(fact.get("target"), str) or not fact["target"] or not isinstance(artifacts, list) or not artifacts or any(not isinstance(sha, str) or not re.fullmatch(r"[a-f0-9]{64}", sha) for sha in artifacts)): raise ValueError(f"incomplete payload tool: {name}") entry = root / entry_name if not entry.is_dir() or not entry.resolve().is_relative_to(root): raise ValueError(f"incomplete payload tool: {name}") fact["digest"] = tree_digest(entry) count += 1 if not count: raise ValueError(f"payload facts carry no tool entries: {self.path}") _write(self.path, {"schema": SCHEMA, "packages": packages}) self._packages = packages return count def installed( self, name: str, expected_version: str | None, store_root: Path, expected_identity=None, ) -> bool: """``expected_identity`` is (target, tuple(artifact sha256s)) — the identity the lock currently pins. When given, the fact must record that exact identity: a same-version re-pin, a different target, or a fact written before identity existed all count as NOT installed and force a reinstall. ``None`` keeps the version+path check.""" fact = self._packages.get(name) if not fact or "entry" not in fact: # State facts (``record_state``: venv stamp + extras) share this # file with tool facts but own no store entry. return False if expected_version is not None and fact.get("version") != expected_version: return False if expected_identity is not None: target, shas = expected_identity if "target" not in fact or "artifacts" not in fact: # Legacy fact: pre-dates digest-bound identity. Not # vouchable — force one reinstall. return False if (fact["target"], tuple(fact["artifacts"])) != (target, tuple(shas)): return False return (store_root / fact["entry"]).exists() def env_for(self, name: str, store_root: Path) -> dict: fact = self._packages.get(name) or {} return _resolve(fact.get("env", {}), store_root) def _merge_and_write(self, name: str, fact: dict) -> None: """Read-modify-write against disk so concurrent installs of different packages never clobber each other.""" on_disk = _read(self.path, strict=True)["packages"] for key, value in self._packages.items(): on_disk.setdefault(key, value) self._packages = on_disk self._packages[name] = fact _write(self.path, {"schema": SCHEMA, "packages": self._packages}) def record( self, name: str, version: str, entry: str, env: dict, store_root: Path, target: str | None = None, artifacts: list[str] | None = None, digest: str | None = None, ) -> None: """``target``/``artifacts`` record the identity this install came from (work item 1); ``digest`` is the realized tree digest of the published entry (work item 2). All three are additive — schema stays 1 and older facts without them still read back.""" fact: dict = { "entry": entry, "version": version, "env": _templatize(env, store_root), } if target is not None: fact["target"] = target if artifacts is not None: fact["artifacts"] = list(artifacts) if digest is not None: fact["digest"] = digest self._merge_and_write(name, fact) def record_state( self, name: str, stamp: str, extras: list[str], *, environment: Path | None = None, resolved_lock: Path | None = None, ) -> None: """Commit a verified state package and its selected environment atomically.""" fact = {"stamp": stamp, "extras": extras} if environment is not None: fact["environment"] = str(environment.resolve()) if resolved_lock is not None: fact["resolved_lock"] = str(resolved_lock.resolve()) self._merge_and_write(name, fact) def retain(self, names: set[str]) -> None: """Drop unselected package facts from an exclusively owned staged store.""" packages = _read(self.path, strict=True)["packages"] retained = {name: fact for name, fact in packages.items() if name in names} if retained != packages: _write(self.path, {"schema": SCHEMA, "packages": retained}) self._packages = retained def entries_in_use(self) -> set[str]: return {f["entry"] for f in self._packages.values() if "entry" in f} def _templatize(env: dict, store_root: Path) -> dict: root = str(store_root) out = {} for key, value in env.items(): if isinstance(value, list): out[key] = [str(v).replace(root, STORE_TOKEN) for v in value] else: out[key] = str(value).replace(root, STORE_TOKEN) return out def _resolve(env: dict, store_root: Path) -> dict: root = str(store_root) out = {} for key, value in env.items(): if isinstance(value, list): out[key] = [str(v).replace(STORE_TOKEN, root) for v in value] else: out[key] = str(value).replace(STORE_TOKEN, root) return out