Files
hermes-agent/pm/build_operations.py
ethernet b4ad4b2c26 fix(pm): hermes pm lock checks a stale uv.lock quietly
The no-argument relock first runs PM's lock check, which reported a stale
lock the way build_env --check-lock does: a red "✗ Resolving Python
dependencies failed" plus uv's raw output, printed right before the relock.
A stale lock is the expected case for this command, so the check now takes
`quiet=True`, which captures uv's output instead of reporting it. The command
prints "→ Checking uv.lock…" then either "already current" or "out of date;
relocking" with the relock's own progress. build_env --check-lock keeps its
failure report unchanged; CI's remediation relies on it.
2026-09-24 15:56:25 -04:00

246 lines
11 KiB
Python

"""Build/CI operations with caller-owned inputs, independent of live selection."""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
import sys
from types import MappingProxyType
from pm.install import InstalledPackage
from pm.lock import Lockfile
from pm.package import InstallError
def check_project_lock(
source: Path, *, python: Path | None = None, cache: Path | None = None,
env: Mapping[str, str] | None = None, offline: bool = False, explicit: bool = False,
quiet: bool = False,
) -> None:
"""Reject a missing or stale lock without rewriting source or creating a venv.
``quiet`` captures uv's output instead of reporting it: for a caller that
expects a stale lock and says so itself, the red failure tail is noise.
The error still carries uv's message.
"""
from pm.environment import managed_environment
from pm.operations import _require_install_allowed
if not offline:
_require_install_allowed(explicit)
source = Path(source).absolute()
if not (source / "pyproject.toml").is_file():
raise InstallError("venv", f"project manifest is missing: {source}")
environment = managed_environment(
source / ".venv", python=Path(python) if python is not None else None,
cache=Path(cache) if cache is not None else None, env=env,
offline=offline, explicit=explicit, output=None if quiet else sys.stderr,
)
environment.check_lock(source)
def export_requirements(
source: Path, out: Path, *, extras: Sequence[str] = (), python: Path | None = None,
cache: Path | None = None, env: Mapping[str, str] | None = None, explicit: bool = False,
) -> None:
"""Export locked runtime requirements, preserving markers and direct URL pins."""
from pm.environment import managed_environment
from pm.operations import _require_install_allowed
_require_install_allowed(explicit)
source, out = Path(source).absolute(), Path(out).absolute()
if not (source / "pyproject.toml").is_file():
raise InstallError("venv", f"project manifest is missing: {source}")
if not (source / "uv.lock").is_file():
raise InstallError("venv", f"frozen export requires a lock: {source / 'uv.lock'}")
environment = managed_environment(
source / ".venv", python=Path(python) if python is not None else None,
cache=Path(cache) if cache is not None else None, env=env,
explicit=explicit, output=sys.stderr,
)
out.parent.mkdir(parents=True, exist_ok=True)
environment.export_requirements(source, out, extras=extras)
def build_requirements_environment(
requirements: Sequence[str], *, out: Path, python: Path | None = None,
cache: Path | None = None, env: Mapping[str, str] | None = None,
wheelhouse: Path | None = None, offline: bool = False, sealed: bool = False,
explicit: bool = False,
) -> Path:
"""Create and check a fresh environment. Never mutate an existing destination.
Wheelhouse builds disable indexes and source builds. Failure removes only
this invocation's exclusively claimed output, not prior builds.
"""
from pm.environment import _fresh_build, managed_environment
from pm.operations import _require_install_allowed, _requirements
_require_install_allowed(explicit)
requirements = _requirements(requirements) if requirements or isinstance(requirements, str) else []
out = Path(out).absolute()
wheelhouse = Path(wheelhouse).absolute() if wheelhouse is not None else None
if wheelhouse is not None and not wheelhouse.is_dir():
raise InstallError("venv", f"wheelhouse directory is missing: {wheelhouse}")
if out.exists() or out.is_symlink():
raise FileExistsError(f"environment destination already exists: {out}")
environment = managed_environment(
out, python=Path(python) if python is not None else None,
cache=Path(cache) if cache is not None else None, env=env,
offline=offline, explicit=explicit, output=sys.stderr,
)
with _fresh_build(environment, sealed=sealed):
environment.install_requirements(requirements, wheelhouse=wheelhouse)
return environment.executable
def prepare_tools(names: Sequence[str], *, out: Path, target: str,
cache: Path | None = None) -> Path:
"""Realize an explicit tool closure in a build-owned store, not live state."""
from pm.install import _install, _lockfile
from pm.lock import Facts
from pm.package import StatePackage
from pm.registry import walk
from pm.store import Store
if isinstance(names, str):
raise TypeError("names must be a sequence, not a string")
packages = walk(list(names))
if any(isinstance(package, StatePackage) for package in packages):
raise ValueError("prepare_tools accepts tools, not application state")
out = Path(out).resolve()
store, lock = Store(out), _lockfile()
with store.install_lock():
facts = Facts(out / "facts.json", strict=True)
for package in packages:
_install(package, lock, facts, store, target, _lock_held=True)
return out
def _copy_links(package, entry: Path) -> None:
import os
from pm.filesystem import is_junction
for directory, dirs, files in os.walk(entry):
for name in dirs + files:
path = Path(directory) / name
if path.is_symlink() or is_junction(path):
if (Path(os.readlink(path)).is_absolute() or not path.exists()
or not path.resolve().is_relative_to(entry)):
raise InstallError(package.name, f"tool copy link escapes entry: {path}")
@dataclass(frozen=True)
class VerifiedTools:
entries: Mapping[str, InstalledPackage]
_envs: tuple[dict, ...]
def environment(self, base: Mapping[str, str]) -> dict[str, str]:
from pm.package import compose_env
return compose_env(list(self._envs), base=dict(base))
def verified_tools(names: Sequence[str], *, source_store: Path, target: str,
lock: Lockfile | None = None) -> VerifiedTools:
"""Read a pinned tool closure without writes, acquisition or binary execution.
Publication already ran package verification. Admission binds its recorded
digest to the canonical entry, lock and declared environment; it does not
rerun arbitrary version probes just to read a previously published tool.
"""
from pm.install import _identity, _lockfile
from pm.lock import Facts
from pm.package import StatePackage
from pm.registry import walk
from pm.store import tree_digest
if isinstance(names, str):
raise TypeError("names must be a sequence, not a string")
packages = walk(list(names))
if any(isinstance(package, StatePackage) for package in packages):
raise ValueError("verified_tools accepts tool packages, not application state")
source_store = Path(source_store).resolve()
if not (source_store / "facts.json").is_file():
raise InstallError("tools", f"source facts missing: {source_store}")
facts = Facts(source_store / "facts.json", strict=True)
lock = lock if lock is not None else _lockfile()
entries, envs = {}, []
for package in packages:
if getattr(package, "pin_only", False):
continue
version = lock.version(package.name)
identity = _identity(lock, package.name, target)
if not version or identity is None:
raise InstallError(package.name, "tool source failed verification: missing pin")
fact = facts.get(package.name)
expected = package.store_entry(version, target)
entry = source_store / expected
if (not fact or fact.get("entry") != expected
or not entry.is_dir() or not entry.resolve().is_relative_to(source_store)
or not facts.installed(package.name, version, source_store, identity)
or tree_digest(entry) != fact.get("digest")):
raise InstallError(package.name, "tool source failed verification", "run preparation again")
_copy_links(package, entry)
binary = package.binary(entry, target)
env = package.env(entry, target)
if ((binary is not None and (not binary.is_file() or not binary.resolve().is_relative_to(entry)))
or facts.env_for(package.name, source_store) != env):
raise InstallError(package.name, "tool source failed verification: binary or environment")
entries[package.name] = InstalledPackage(entry, version, binary)
envs.append(env)
return VerifiedTools(MappingProxyType(entries), tuple(envs))
def stage_tools(names: Sequence[str], *, source_store: Path, out: Path, target: str) -> Path:
"""Copy a current, verified tool closure without acquisition or live selection.
Stores must be disjoint. Both publication locks cover admission and copying;
the ordinary installer owns verification, independent copies and fresh facts.
"""
from contextlib import ExitStack
from pm.install import _entry_verified, _install, _lockfile
from pm.lock import Facts
from pm.package import StatePackage
from pm.registry import walk
from pm.store import Store
if isinstance(names, str):
raise TypeError("names must be a sequence, not a string")
source_store, out = Path(source_store).resolve(), Path(out).resolve()
if source_store.is_relative_to(out) or out.is_relative_to(source_store):
raise ValueError("tool stores must be disjoint")
if not (source_store / "facts.json").is_file():
raise InstallError("tools", f"source facts missing: {source_store}")
packages = walk(list(names))
if any(isinstance(package, StatePackage) for package in packages):
raise ValueError("stage_tools accepts tool packages, not application state")
source, destination = Store(source_store), Store(out)
lock = _lockfile()
with ExitStack() as stack:
for store in sorted((source, destination), key=lambda store: str(store.root)):
stack.enter_context(store.install_lock())
selection = verified_tools(names, source_store=source_store, target=target, lock=lock)
facts = Facts(source_store / "facts.json", strict=True)
for package in packages:
if package.name not in selection.entries:
continue
fact = facts.get(package.name)
assert fact is not None # Admission requires the selected fact under the same store lock.
if not _entry_verified(package, fact, source, target):
raise InstallError(package.name, "tool copy source failed verification", "run preparation again")
staged = Facts(out / "facts.json", strict=True)
for package in packages:
_install(package, lock, staged, destination, target,
copy_from=(facts, source), _lock_held=True, _fresh_copy=True)
return out
def prune_cache(cache: Path, *, ci: bool = False) -> None:
"""Prune unused cache entries; CI mode also discards downloaded wheels."""
from pm.environment import managed_environment
cache = Path(cache).absolute()
environment = managed_environment(cache, cache=cache, realize=False, output=sys.stderr)
environment.prune_cache(ci=ci)