Files
hermes-agent/pm/build_operations.py
alt-glitch a1461d11ab fix(pm): spell store roots PM opens without Store like Store does
Some PM code reaches a store root from paths.store_root() or
writable_store_root() directly instead of through Store, so its file
calls kept the ordinary spelling and stay cut at MAX_PATH on Windows
with LongPathsEnabled=0. Worse, its comparisons against a Store root
(now \\?\-spelled) never matched.

Each such root now gets long_root() once, where the variable is born:
- Facts.refresh_digests digests whole payload tool trees;
- verified_tools walks and digests the source store's tool trees;
- the uv cache seed copies the payload's deep uv-cache tree out;
- _toolchain's writable store, so `location.root != writable` compares
  like with like and the copy store is built from the same spelling;
- _installed_location passes store.root to Facts.installed;
- ensure() picks the shipped facts by comparing two Store roots;
- the corrupt-facts guard compares both sides spelled long.

Returned and exported paths stay ordinary (previous commit).
2026-10-07 06:40:39 +05:30

251 lines
12 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.filesystem import long_root, native
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: {native(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}")
# Admission walks and digests whole tool trees: spell the root like Store does.
source_store = long_root(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")
# The selection leaves PM (bundle scripts compare and execute it): ordinary spelling.
entries[package.name] = InstalledPackage(Path(native(entry)), version,
Path(native(binary)) if binary is not None else None)
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)