diff --git a/pm/cli.py b/pm/cli.py index 7dcd2b9296..a5770468de 100644 --- a/pm/cli.py +++ b/pm/cli.py @@ -623,6 +623,24 @@ def cmd_bundle(args) -> int: write_features(features, out) print(f"✓ enabled-features.json ({len(features)} extras recorded)") + # Ship the uv cache: the staged venv sync just warmed the hermes-owned + # cache with every wheel this payload needs. Copying it in makes a + # mutable-venv rebuild from the bundle near-free (`uv sync --offline` + # from a warm cache probed at 0.4s vs 1.2s cold) — the blow-away-on- + # update contract depends on it. + from pm.packages import uv_cache_dir as bundle_uv_cache_dir + + payload_cache = out / "uv-cache" + if payload_cache.exists(): + shutil.rmtree(payload_cache, ignore_errors=True) + src_cache = bundle_uv_cache_dir() + if src_cache.is_dir(): + print(f" uv-cache: copying {src_cache} → payload...", flush=True) + shutil.copytree(src_cache, payload_cache) + print("✓ uv-cache (staged — warm rebuilds for the mutable venv)") + else: + print(" uv-cache: none warm (first bundle on this machine?)") + bad = _arch_guard(store_dir) for line in bad: print(f"✗ arch: {line}") diff --git a/pm/ensure.py b/pm/ensure.py index 46106b4b9f..18bb85d780 100644 --- a/pm/ensure.py +++ b/pm/ensure.py @@ -407,6 +407,21 @@ def adopt() -> bool: marker.write_text("", encoding="utf-8") except OSError: pass + + # Bootstrap the mutable venv (sealed installs): lazy-off copies the + # shipped venv out as the seed; lazy-on builds fresh from the shipped + # uv cache on first sync. Failure is reported, never fatal — a cold + # sync still converges. + try: + venv_package = get_package("venv") + except KeyError: + venv_package = None + seed = getattr(venv_package, "seed_mutable_venv", None) if venv_package else None + if seed is not None: + reason = seed() + if reason: + LOG.info("pm adopt: mutable venv seed skipped: %s", reason) + return True diff --git a/pm/packages.py b/pm/packages.py index 190a504a27..2dfdd3e051 100644 --- a/pm/packages.py +++ b/pm/packages.py @@ -235,6 +235,51 @@ def _uv_lock_digest(path: Path) -> bytes: _uv_lock_digest_cache: dict[Path, tuple] = {} +def uv_cache_dir() -> Path: + """The hermes-owned uv cache: machine-scoped and shared (keyed by + content — two profiles reuse one cache), anchored to the DEFAULT + hermes root like partials_root(). A bundle ships a seeded copy at + the payload root (uv-cache/); the first call on a sealed install + copies it out to the writable machine cache (the read-only payload + can't serve uv's working cache), and a warm `uv sync --offline` + from it is near-free (probed: 0.4s vs 1.2s cold) — the blow-away- + on-update contract depends on it. uv's default cache location is + per-user/platform-opinionated and never used by pm.""" + from hermes_constants import get_default_hermes_root + + machine_cache = get_default_hermes_root() / "cache" / "uv" + marker = machine_cache / ".seeded" + if not marker.is_file(): + # Seed from a shipped bundle cache when present (payload root = + # store_root().parent on a sealed install). + try: + from pm.paths import store_root + + payload_cache = store_root().parent / "uv-cache" + if payload_cache.is_dir(): + machine_cache.mkdir(parents=True, exist_ok=True) + import shutil as _shutil + + for entry in payload_cache.iterdir(): + if entry.name == ".seeded": + continue + dest = machine_cache / entry.name + if not dest.exists(): + ( + _shutil.copytree(entry, dest) + if entry.is_dir() + else _shutil.copy2(entry, dest) + ) + except OSError: + pass # seeding is best-effort; a cold sync still works + try: + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text("1", encoding="utf-8") + except OSError: + pass + return machine_cache + + def uv_env(base_env: Optional[dict] = None) -> dict[str, str]: """Sanitized env for pm's internal uv invocations: user-level UV overrides and active-venv leakage must not steer which interpreter or @@ -243,7 +288,9 @@ def uv_env(base_env: Optional[dict] = None) -> dict[str, str]: must not reach the subprocess either. User config discovery is redirected to an empty dir; HOME is left alone so caches, credentials, and git keep working (the #82446 isolation contract, which lived in - the installers' run_locked_uv_sync() before the sync moved into pm).""" + the installers' run_locked_uv_sync() before the sync moved into pm). + UV_CACHE_DIR always points at the hermes-owned cache (shipped with + bundles, shared machine-wide) — never the user's ambient cache.""" env = dict(os.environ if base_env is None else base_env) for key in list(env): if key.startswith("UV_") or key in ( @@ -258,6 +305,7 @@ def uv_env(base_env: Optional[dict] = None) -> dict[str, str]: env["XDG_CONFIG_HOME"] = str(isolated) env["XDG_CONFIG_DIRS"] = str(isolated) env["UV_NO_CONFIG"] = "1" + env["UV_CACHE_DIR"] = str(uv_cache_dir()) return env @@ -290,11 +338,48 @@ class Venv(StatePackage): return repo_root() def venv_dir(self) -> Path: + # Sealed installs are read-only: the MUTABLE venv lives in the + # writable machine hermes root (get_default_hermes_root()/venv — + # per-install, beside the uv cache), seeded on first adopt by + # copying the payload's shipped venv out. Dev/source installs + # keep the repo-local venv (project_venv_dir) unchanged. + from pm.ensure import sealed + + if sealed(): + from hermes_constants import get_default_hermes_root + + return get_default_hermes_root() / "venv" from hermes_constants import project_venv_dir found = project_venv_dir(self.project_root()) return found if found else self.project_root() / "venv" + def seed_mutable_venv(self) -> Optional[str]: + """Bootstrap the mutable venv from the shipped payload venv + (lazy-off installs). Returns None on success, else why not.""" + import shutil + + from pm.ensure import lazy_installs_allowed, sealed + from pm.paths import store_root + + if not sealed(): + return None # dev installs use the repo venv directly + dest = self.venv_dir() + if dest.exists(): + return None # already bootstrapped + if lazy_installs_allowed(): + # Lazy installs ON: build fresh from the shipped uv cache — + # no seed copy needed (the sync is near-free from the cache). + return None + payload_venv = store_root().parent / "venv" + if not payload_venv.is_dir(): + return "payload ships no venv to seed from" + try: + shutil.copytree(payload_venv, dest) + except OSError as exc: + return f"seed copy failed: {exc}" + return None + def expected_stamp(self, extras: list[str]) -> str: import hashlib import sys diff --git a/tests/pm/test_uv_cache.py b/tests/pm/test_uv_cache.py new file mode 100644 index 0000000000..14a4ddeebf --- /dev/null +++ b/tests/pm/test_uv_cache.py @@ -0,0 +1,128 @@ +"""pm uv cache: hermes-owned UV_CACHE_DIR + sealed-venv bootstrap seed. + +The cache ships with bundles and is copied out to the writable machine +cache on first use; every pm-internal uv invocation pins UV_CACHE_DIR. +The mutable venv on sealed installs lives in the machine hermes root, +seeded from the payload's shipped venv when lazy installs are off. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +import pm.packages as pkgs + + +def test_uv_env_pins_cache_dir(monkeypatch, tmp_path): + monkeypatch.setattr(pkgs, "uv_cache_dir", lambda: tmp_path / "c") + env = pkgs.uv_env({"UV_CACHE_DIR": "/ambient/user/cache", "PATH": "x"}) + assert env["UV_CACHE_DIR"] == str(tmp_path / "c") + # ambient UV_ vars are stripped, not inherited + assert "UV_PROJECT_ENVIRONMENT" not in env + + +def test_uv_cache_dir_seeds_from_payload(monkeypatch, tmp_path): + home = tmp_path / "home" + home.mkdir() + payload = tmp_path / "payload" + (payload / "uv-cache" / "wheels-v5").mkdir(parents=True) + (payload / "uv-cache" / "wheels-v5" / "some.pkg").write_text("x", encoding="utf-8") + + import hermes_constants + + monkeypatch.setattr(hermes_constants, "get_default_hermes_root", lambda: home) + import pm.paths as paths_mod + + monkeypatch.setattr(paths_mod, "store_root", lambda: payload / "tools") + + machine = pkgs.uv_cache_dir() + assert machine == home / "cache" / "uv" + # seed copied out + assert (machine / "wheels-v5" / "some.pkg").read_text(encoding="utf-8") == "x" + # seeded marker written → second call doesn't re-copy + assert (machine / ".seeded").is_file() + (payload / "uv-cache" / "wheels-v5" / "some.pkg").write_text("changed", encoding="utf-8") + pkgs.uv_cache_dir() + assert (machine / "wheels-v5" / "some.pkg").read_text(encoding="utf-8") == "x" + + +def test_uv_cache_dir_cold_machine_no_payload(monkeypatch, tmp_path): + home = tmp_path / "home" + home.mkdir() + import hermes_constants + + monkeypatch.setattr(hermes_constants, "get_default_hermes_root", lambda: home) + import pm.paths as paths_mod + + monkeypatch.setattr(paths_mod, "store_root", lambda: tmp_path / "nowhere" / "tools") + + machine = pkgs.uv_cache_dir() + assert machine == home / "cache" / "uv" + assert (machine / ".seeded").is_file() + + +def test_venv_dir_sealed_goes_to_machine_root(monkeypatch, tmp_path): + home = tmp_path / "home" + home.mkdir() + import sys + + import hermes_constants + + monkeypatch.setattr(hermes_constants, "get_default_hermes_root", lambda: home) + ensure_mod = sys.modules["pm.ensure"] + monkeypatch.setattr(ensure_mod, "sealed", lambda: True) + venv = pkgs.Venv() + assert venv.venv_dir() == home / "venv" + + +def test_seed_mutable_venv_copies_payload_venv(monkeypatch, tmp_path): + home = tmp_path / "home" + home.mkdir() + payload = tmp_path / "payload" + (payload / "venv" / "Scripts").mkdir(parents=True) + (payload / "venv" / "Scripts" / "python.exe").write_text("bin", encoding="utf-8") + + import hermes_constants + + monkeypatch.setattr(hermes_constants, "get_default_hermes_root", lambda: home) + import sys + + ensure_mod = sys.modules["pm.ensure"] + import pm.paths as paths_mod + + monkeypatch.setattr(ensure_mod, "sealed", lambda: True) + monkeypatch.setattr(ensure_mod, "lazy_installs_allowed", lambda: False) + monkeypatch.setattr(paths_mod, "store_root", lambda: payload / "tools") + + venv = pkgs.Venv() + reason = venv.seed_mutable_venv() + assert reason is None + seeded = home / "venv" + assert (seeded / "Scripts" / "python.exe").read_text(encoding="utf-8") == "bin" + # idempotent: second call is a no-op + assert venv.seed_mutable_venv() is None + + +def test_seed_mutable_venv_lazy_on_skips_copy(monkeypatch, tmp_path): + home = tmp_path / "home" + home.mkdir() + payload = tmp_path / "payload" + (payload / "venv").mkdir(parents=True) + + import hermes_constants + + monkeypatch.setattr(hermes_constants, "get_default_hermes_root", lambda: home) + import sys + + ensure_mod = sys.modules["pm.ensure"] + import pm.paths as paths_mod + + monkeypatch.setattr(ensure_mod, "sealed", lambda: True) + monkeypatch.setattr(ensure_mod, "lazy_installs_allowed", lambda: True) + monkeypatch.setattr(paths_mod, "store_root", lambda: payload / "tools") + + venv = pkgs.Venv() + assert venv.seed_mutable_venv() is None + assert not (home / "venv").exists() # builds fresh later, no seed copy