feat(pm): ship the uv cache + mutable-venv bootstrap seed for sealed installs
The blow-the-venv-on-update contract needs rebuilds to be cheap, and sealed installs need a writable venv at all (settled 2026-09-02 plan, task 7): - uv_cache_dir(): hermes-owned machine cache at <default hermes root>/cache/uv — content-addressed, shared across profiles. uv_env() ALWAYS pins UV_CACHE_DIR there (ambient UV_* stripped), so the cache that ships is the cache that gets used. First call on a sealed install seeds it from the payload's shipped uv-cache/ (read-only payload can't serve uv's working cache); the .seeded marker makes it once-only and non-clobbering. - pm bundle stages the warmed cache into the payload after the venv sync (uv-cache/ beside manifest.json) — warm 'uv sync --offline' rebuilds probed at 0.4s vs 1.2s cold. - Venv.venv_dir(): sealed installs resolve the MUTABLE venv to the machine hermes root (<root>/venv), not the read-only payload; dev/source installs keep the repo-local venv unchanged. - Venv.seed_mutable_venv(): the bootstrap seed — lazy-off installs copy the payload's shipped venv out as the starting point; lazy-on installs skip the copy (first sync builds fresh from the shipped cache). adopt() triggers it (KeyError-guarded, failure reported never fatal — a cold sync still converges). tests/pm/test_uv_cache.py: 6 tests (env pinning + ambient strip, payload seed + marker once-only, cold machine, sealed venv_dir, seed copy idempotence, lazy-on skip). tests/pm: 175 passed, 0 failed.
This commit is contained in:
18
pm/cli.py
18
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}")
|
||||
|
||||
15
pm/ensure.py
15
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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
128
tests/pm/test_uv_cache.py
Normal file
128
tests/pm/test_uv_cache.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user