Files
hermes-agent/pm/environment.py

283 lines
13 KiB
Python

"""PM's private Python engine: tool routing, isolation and command execution.
Operations own source, destination and publication. Bootstrap may inject its
staged toolchain directly without recursing through the worker it is building.
"""
from __future__ import annotations
import codecs
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
import io
import os
from pathlib import Path
import subprocess
import tempfile
import time
from typing import TextIO
from pm.package import InstallError
def prune_site_pth(venv_dir: Path) -> None:
"""Drop .pth files that must never execute inside a shipped payload.
``uv sync`` leaves two behind: ``_virtualenv.pth`` (repoints
``sys.prefix`` at the venv) and the project's ``__editable__`` pointer
(names the BUILD machine — the payload wires the repo snapshot itself,
see scripts/build/launcher_wrapper.py). Bundled launchers process every
other .pth with ``site.addsitedir()``; pywin32.pth is load-bearing on
Windows (win32\\lib on sys.path is what makes ``import pywintypes``
resolve, which portalocker/concurrent-log-handler need to write logs).
"""
if (venv_dir / "Scripts").is_dir():
sites = [venv_dir / "Lib" / "site-packages"]
else:
lib = venv_dir / "lib"
sites = sorted(lib.glob("python*/site-packages")) if lib.is_dir() else []
for site_dir in sites:
if not site_dir.is_dir():
continue
for pth in site_dir.glob("*.pth"):
if pth.name == "_virtualenv.pth" or pth.name.startswith("__editable__"):
try:
pth.unlink()
except OSError:
pass
def _run_streaming(command: list[str], *, cwd: Path, env: dict[str, str],
timeout: int, output: TextIO) -> subprocess.CompletedProcess:
"""Keep CI progress live, a bounded diagnostic tail, and a wall-clock timeout."""
deadline = time.monotonic() + timeout
proc = subprocess.Popen(command, cwd=str(cwd), env=env, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", bufsize=0)
pipe = proc.stdout
assert isinstance(pipe, io.TextIOWrapper) # Popen was given stdout=PIPE and text=True.
tail = ""
try:
# A descendant can keep stdout open after proc exits. Nonblocking reads
# bound that drain without leaving a thread stuck in readline()/close().
# PM's Python >=3.14 supports nonblocking pipes on Windows as well as POSIX.
os.set_blocking(pipe.fileno(), False)
decoder = io.IncrementalNewlineDecoder(
codecs.getincrementaldecoder(pipe.encoding)(errors="replace"), translate=True,
)
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise subprocess.TimeoutExpired(command, timeout, stderr=tail)
try:
data = os.read(pipe.fileno(), 65536)
except BlockingIOError:
time.sleep(min(.05, remaining))
continue
text = decoder.decode(data, final=not data)
if text:
tail = (tail + text)[-2000:]
output.write(text)
output.flush()
if not data:
break
# EOF can precede process exit; it does not grant another timeout budget.
try:
code = proc.wait(timeout=max(0, deadline - time.monotonic()))
except subprocess.TimeoutExpired:
raise subprocess.TimeoutExpired(command, timeout, stderr=tail) from None
except BaseException:
proc.kill()
proc.wait(timeout=5)
raise
finally:
pipe.close()
return subprocess.CompletedProcess(command, code, "", tail)
def _base_environment(env: Mapping[str, str] | None = None) -> dict[str, str]:
"""Ambient UV settings are never policy; explicit build index settings are."""
index_settings = {
"UV_DEFAULT_INDEX", "UV_EXTRA_INDEX_URL", "UV_NO_INDEX", "UV_FIND_LINKS",
"UV_INSECURE_HOST", "UV_KEYRING_PROVIDER", "UV_NATIVE_TLS",
}
return {key: value for key, value in (os.environ if env is None else env).items()
if not key.startswith("PYTHON") and key != "VIRTUAL_ENV"
and (not key.startswith("UV_") or
(env is not None and (key.startswith("UV_INDEX") or key in index_settings)))}
def managed_environment(destination: Path, *, python: Path | None = None,
cache: Path | None = None, env: Mapping[str, str] | None = None,
offline: bool = False, explicit: bool = False,
output: TextIO | None = None, realize: bool = True) -> PythonEnvironment:
from pm._uv import _toolchain
from pm.packages import uv_cache_dir
tools = _toolchain(explicit=explicit, realize=realize)
if tools is None:
raise InstallError("venv", "PM's pinned toolchain is unavailable")
uv, pinned_python = tools
return PythonEnvironment(
uv=uv, python=pinned_python if python is None else python.absolute(),
destination=destination.absolute(), cache=uv_cache_dir() if cache is None else cache.absolute(),
env=_base_environment(env), offline=offline, output=output,
)
@dataclass(frozen=True, kw_only=True)
class PythonEnvironment:
uv: Path
python: Path
destination: Path
cache: Path
env: Mapping[str, str]
offline: bool = False
output: TextIO | None = None
no_config: bool = False
@property
def executable(self) -> Path:
return self.destination / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
def _run(self, args: list[str], *, cwd: Path, timeout: int) -> subprocess.CompletedProcess:
# Explicit index credentials survive, but cannot redirect the project,
# interpreter or cache selected by the operation.
env = _base_environment(self.env)
env.update(UV_PYTHON=str(self.python), UV_PROJECT_ENVIRONMENT=str(self.destination),
UV_CACHE_DIR=str(self.cache), UV_PYTHON_DOWNLOADS="never")
with tempfile.TemporaryDirectory(prefix="pm-uv-config-") as config:
env.update(XDG_CONFIG_HOME=config, XDG_CONFIG_DIRS=config)
command = [str(self.uv), *args]
if self.no_config and "--no-config" not in command:
command.append("--no-config")
if self.offline:
command.append("--offline")
if self.output is not None:
return _run_streaming(command, cwd=cwd, env=env, timeout=timeout, output=self.output)
return subprocess.run(command, cwd=str(cwd), env=env, capture_output=True,
text=True, encoding="utf-8", errors="replace", timeout=timeout)
def create(self) -> None:
"""Create at the final destination; callers must not move a live venv."""
self.destination.parent.mkdir(parents=True, exist_ok=True)
result = self._run(
["venv", "--relocatable", "--no-project", "--no-config",
"--python", str(self.python), str(self.destination)],
cwd=self.destination.parent, timeout=120,
)
if result.returncode:
raise InstallError("venv", f"uv venv failed: {result.stderr[-600:]}")
def lock(self, source: Path, *, upgrade: bool = False, timeout: int = 1800) -> None:
from pm.workspace import classify_uv_failure
command = ["lock", "--python", str(self.python)]
if upgrade:
command.append("--upgrade")
result = self._run(command, cwd=source, timeout=timeout)
if result.returncode:
raise classify_uv_failure("lock", result.returncode, result.stderr or result.stdout)
def check_lock(self, source: Path) -> None:
from pm.workspace import classify_uv_failure
result = self._run(["lock", "--check", "--python", str(self.python)],
cwd=source, timeout=1800)
if result.returncode:
raise classify_uv_failure("lock", result.returncode, result.stderr or result.stdout)
def sync(self, source: Path, *, extras: Sequence[str] = (), groups: Sequence[str] = (),
timeout: int = 1800, frozen: bool = True, all_extras: bool = False,
no_install_project: bool = False, locked: bool = False,
no_default_groups: bool = False, only_groups: bool = False) -> None:
"""Install the root and every member; resolve only in a writable workspace.
``frozen=False`` is reserved for the caller-owned generated workspace,
never the original project's lock. Seed/replay policy belongs to PM.
"""
from pm.workspace import classify_uv_failure
if only_groups and (not groups or extras or all_extras):
raise ValueError("group-only builds require groups and cannot select extras")
if not frozen:
self.lock(source, timeout=timeout)
# Locking members alone is insufficient: plain sync only installs root deps.
command = ["sync", "--locked" if locked else "--frozen", "--all-packages",
"--python", str(self.python)]
if no_default_groups:
command.append("--no-default-groups")
if all_extras:
command.append("--all-extras")
if no_install_project:
# --all-packages has no single selected project in uv, so
# --no-install-project alone does not exclude the root. Name it
# explicitly without dropping member dependencies or installations.
import tomllib
project = tomllib.loads((source / "pyproject.toml").read_text(encoding="utf-8-sig"))
command += ["--no-install-project", "--no-install-package", project["project"]["name"]]
for extra in sorted(set(extras)):
command += ["--extra", extra]
for group in sorted(set(groups)):
command += ["--only-group" if only_groups else "--group", group]
result = self._run(command, cwd=source, timeout=timeout)
if result.returncode:
raise classify_uv_failure("sync", result.returncode, result.stderr or result.stdout)
def export_requirements(self, source: Path, out: Path, *, extras: Sequence[str] = (),
timeout: int = 1800) -> None:
from pm.workspace import classify_uv_failure
command = ["export", "--frozen", "--python", str(self.python), "--no-default-groups",
"--no-emit-project", "--no-hashes", "--no-annotate", "--no-header",
"--format", "requirements-txt", "--output-file", str(out)]
for extra in sorted(set(extras)):
command += ["--extra", extra]
result = self._run(command, cwd=source, timeout=timeout)
if result.returncode:
raise classify_uv_failure("export", result.returncode, result.stderr or result.stdout)
def install_requirements(self, requirements: Sequence[str], *, wheelhouse: Path | None = None) -> None:
if not requirements:
return
# A file avoids command-line length limits and shell/marker quoting.
with tempfile.TemporaryDirectory(prefix="pm-requirements-") as temporary:
requirements_file = Path(temporary) / "requirements.txt"
requirements_file.write_text("\n".join(requirements) + "\n", encoding="utf-8")
self._install_requirements_file(requirements_file, wheelhouse=wheelhouse)
def _install_requirements_file(self, requirements: Path, *, wheelhouse: Path | None = None,
timeout: int = 1800) -> None:
from pm.workspace import classify_uv_failure
command = ["pip", "install", "--no-config", "--python", str(self.executable),
"--requirements", str(requirements)]
if wheelhouse is not None:
command += ["--no-index", "--only-binary", ":all:",
"--find-links", str(wheelhouse.absolute())]
result = self._run(command, cwd=requirements.parent, timeout=timeout)
if result.returncode:
raise classify_uv_failure("pip", result.returncode, result.stderr or result.stdout)
def install_wheelhouse(self, source: Path, wheelhouse: Path, *, timeout: int = 1800) -> None:
"""Install rebuilt wheels whose hashes the bundle manifest owns, not uv.lock."""
requirements = source / "requirements.txt"
self.export_requirements(source, requirements, timeout=timeout)
self._install_requirements_file(requirements, wheelhouse=wheelhouse, timeout=timeout)
self.check()
def prune_cache(self, *, ci: bool = False) -> None:
command = ["cache", "prune", "--no-config"]
if ci:
command += ["--ci", "--force"]
result = self._run(command, cwd=Path.cwd(), timeout=1800)
if result.returncode:
raise InstallError("uv", f"cache pruning failed: {result.stderr[-600:]}")
def check(self) -> None:
result = self._run(
["pip", "check", "--no-config", "--python", str(self.executable)],
cwd=self.destination.parent, timeout=60,
)
if result.returncode:
raise InstallError("venv", f"dependency validation failed: {result.stderr[-600:]}")