Files
hermes-agent/pm/operations.py
ethernet 6a5a6a05d2 refactor(pm): rename the pm.ensure submodule to pm.install; lazy_deps back to the shim
`import pm.ensure` bound the submodule onto the package, shadowing the facade's
`pm.ensure()` function for every later caller in the process (photon's sidecar
start hit `'module' object is not callable`). The module is pm.install now; the
function keeps its name. The facade resolves through `__import__` rather than
`importlib.import_module` so a test that patches import_module globally does not
break attribute access on pm.

tools/lazy_deps.py returns to the 16-line stop_for_relaunch shim the branch wrote
(an origin/main merge had replaced it with main's 775-line implementation); the
project-metadata tests follow. update_cmd re-exports the four old_updater_deps
names the shim tests resolve through hermes_cli.update_cmd.
2026-09-18 23:27:05 -04:00

260 lines
11 KiB
Python

"""Python dependency operations executed by the isolated package manager.
Callers describe a dependency graph and its lifetime, never a uv command.
Only the private environment engine knows how to obtain or invoke uv.
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import sys
import uuid
from pm.package import InstallError
def _require_install_allowed(explicit: bool) -> None:
from pm.install import _refuse_lazy, lazy_installs_allowed
if not explicit and not lazy_installs_allowed():
raise _refuse_lazy("venv", "Python dependency operation requires an explicit request")
def build_environment(
*, source: Path, out: Path, python: Path | None = None,
cache: Path | None = None, env: Mapping[str, str] | None = None,
extras: Sequence[str] = (), groups: Sequence[str] = (), only_groups: bool = False,
all_extras: bool = False, no_install_project: bool = False,
frozen: bool = True, sealed: bool = False, offline: bool = False,
explicit: bool = False, timeout: int = 1800,
) -> Path:
"""Build and validate a fresh destination; never overwrite an existing tree.
A build has no profile/plugin discovery or application selection side effects.
Failure removes only the destination exclusively created by this invocation.
Sealed builds prune only the .pth files that refer to build-time state.
"""
from pm.environment import _fresh_build, managed_environment
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 frozen and not (source / "uv.lock").is_file():
raise InstallError("venv", f"frozen build requires a lock: {source / 'uv.lock'}")
if out.exists() or out.is_symlink():
raise FileExistsError(f"environment destination already exists: {out}")
_require_install_allowed(explicit)
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.sync(source, extras=extras, groups=groups, only_groups=only_groups, all_extras=all_extras,
no_install_project=no_install_project, frozen=frozen, timeout=timeout)
return environment.executable
def lock_project(
source: Path, *, upgrade: bool = False, python: Path | None = None,
env: Mapping[str, str] | None = None, cache: Path | None = None,
offline: bool = False, explicit: bool = False,
) -> None:
"""Refresh the project's resolution without creating or selecting a venv."""
from pm.environment import managed_environment
source = Path(source).absolute()
if not (source / "pyproject.toml").is_file():
raise InstallError("venv", f"project manifest is missing: {source}")
_require_install_allowed(explicit)
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=sys.stderr,
)
environment.lock(source, upgrade=upgrade)
def stage_manager_runtime(
*, python: Path, destination: Path, project: Path | None = None,
offline: bool = False, wheelhouse: Path | None = None, cache: Path | None = None,
) -> Path:
"""Stage PM's independent dependency graph using its private toolchain."""
from pm._uv import _toolchain
from pm.runtime_stage import stage_runtime
destination, python = Path(destination).absolute(), Path(python).absolute()
if destination.exists() or destination.is_symlink():
raise FileExistsError(f"environment destination already exists: {destination}")
# Tools are staged before this bootstrap: acquiring them here would need
# the TLS dependencies in the very runtime we are constructing.
tools = _toolchain(realize=False)
if tools is None:
raise InstallError("pm-runtime", "stage pinned tools before building the manager runtime")
destination.mkdir(parents=True)
try:
executable = stage_runtime(tools[0], python, destination,
project=Path(project) if project is not None else None,
offline=offline, wheelhouse=Path(wheelhouse) if wheelhouse is not None else None,
cache=Path(cache) if cache is not None else None)
from pm.lock import _write
_write(destination / "pm-runtime.json", {})
return executable
except BaseException:
shutil.rmtree(destination, ignore_errors=True)
raise
def _environment_root(name: str, root: Path | None) -> Path:
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]*", name):
raise ValueError("environment name must be a simple package name")
if root is not None:
return Path(root).absolute()
from hermes_constants import get_hermes_home
return get_hermes_home() / "environments" / name
def _python(environment: Path) -> Path:
from pm.environments import venv_python
return venv_python(environment)
def _selection(root: Path) -> dict:
try:
record = json.loads((root / "active.json").read_text(encoding="utf-8"))
except FileNotFoundError:
return {}
except (OSError, ValueError) as exc:
raise InstallError("venv", f"cannot read environment selection: {root}") from exc
if (not isinstance(record, dict) or not isinstance(record.get("generation"), str)
or not re.fullmatch(r"gen-[0-9a-f]{32}", record["generation"])):
raise InstallError("venv", f"invalid environment selection: {root}")
return record
def environment_python(name: str, *, root: Path | None = None) -> Path | None:
"""Read the selected interpreter without acquiring tools or writing state."""
root = _environment_root(name, root)
selected = _selection(root)
if not selected:
return None
environment = root / selected["generation"] / "venv"
if not environment.resolve().is_relative_to(root.resolve()):
raise InstallError(name, "selected environment escapes its root")
python = _python(environment)
return python if python.is_file() and (environment / "pyvenv.cfg").is_file() else None
def _tool(python: Path, executable: str) -> Path | None:
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]*", executable):
raise ValueError("tool executable must be a filename, not a path")
directory = python.parent
suffixes = (".exe", ".cmd", "") if os.name == "nt" else ("",)
return next((directory / (executable + suffix) for suffix in suffixes
if (directory / (executable + suffix)).is_file()), None)
def python_tool(name: str, executable: str, *, root: Path | None = None) -> Path | None:
"""Return a selected tool entrypoint, never an installer executable."""
python = environment_python(name, root=root)
return _tool(python, executable) if python is not None else None
def _requirements(requirements: Sequence[str]) -> list[str]:
from packaging.requirements import InvalidRequirement, Requirement
if isinstance(requirements, str) or not requirements:
raise ValueError("an isolated environment needs a nonempty sequence of requirements")
try:
return sorted({str(Requirement(requirement)) for requirement in requirements})
except (InvalidRequirement, TypeError) as exc:
raise ValueError(f"invalid environment requirement: {exc}") from exc
def ensure_environment(
name: str, requirements: Sequence[str], *, root: Path | None = None,
explicit: bool = False, timeout: int = 1800,
executable: str | None = None,
) -> Path:
"""Make an isolated dependency set current, then atomically select it.
Build at the final path: Windows launchers and scripts embed that path.
The prior generation survives both successful replacement and failed builds.
An optional tool entrypoint is validated before publication, not afterwards.
"""
from hermes_cli.runtime_state import _lock
from pm.install import _refuse_lazy, lazy_installs_allowed
from pm.lock import Lockfile, _write
from pm import paths
from pm.store import current_target
root = _environment_root(name, root)
requirements = _requirements(requirements)
if executable is not None:
_tool(Path("unused/python"), executable) # Validate before any write.
lock = Lockfile(paths.lockfile_path())
target = current_target()
inputs = {"requirements": requirements, "python": lock.version("python"), "target": target,
"artifacts": [item["sha256"] for item in lock.artifacts("python", target)]}
identity = hashlib.sha256(json.dumps(inputs, sort_keys=True).encode()).hexdigest()
def current() -> Path | None:
record = _selection(root)
python = environment_python(name, root=root)
if (record.get("inputs") == identity and python is not None
and (executable is None or _tool(python, executable) is not None)):
return python
return None
existing = current()
if existing is not None:
return existing
if not explicit and not lazy_installs_allowed():
raise _refuse_lazy(name, "isolated Python environment is missing or outdated")
root.mkdir(parents=True, exist_ok=True)
with (root / ".install.lock").open("a+b") as mutex:
_lock(mutex.fileno(), wait=True)
existing = current()
if existing is not None:
return existing
generation = root / f"gen-{uuid.uuid4().hex}"
generation.mkdir()
try:
(generation / "pyproject.toml").write_text(
'[project]\nname = "hermes-side-environment"\nversion = "0"\n'
'requires-python = ">=3.11"\ndependencies = '
+ json.dumps(requirements) + '\n[tool.uv]\npackage = false\n', encoding="utf-8",
)
previous = _selection(root)
if previous:
seed = root / previous["generation"] / "uv.lock"
if seed.is_file():
shutil.copyfile(seed, generation / "uv.lock")
python = build_environment(source=generation, out=generation / "venv", frozen=False,
explicit=explicit, timeout=timeout)
if executable is not None and _tool(python, executable) is None:
raise InstallError(name, f"installed requirements do not provide {executable!r}")
_write(root / "active.json", {"generation": generation.name, "inputs": identity,
"requirements": requirements})
except BaseException:
shutil.rmtree(generation, ignore_errors=True)
raise
return python
def ensure_python_tool(
name: str, requirements: Sequence[str], executable: str, *,
root: Path | None = None, explicit: bool = False, timeout: int = 1800,
) -> Path:
python = ensure_environment(name, requirements, root=root, explicit=explicit,
timeout=timeout, executable=executable)
tool = _tool(python, executable)
assert tool is not None # Validated under the publication lock above.
return tool