266 lines
12 KiB
Python
266 lines
12 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.ensure 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 managed_environment, prune_site_pth
|
|
|
|
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,
|
|
)
|
|
out.mkdir(parents=True)
|
|
try:
|
|
environment.create()
|
|
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)
|
|
environment.check()
|
|
if sealed:
|
|
prune_site_pth(out)
|
|
except BaseException:
|
|
shutil.rmtree(out, ignore_errors=True)
|
|
raise
|
|
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:
|
|
return environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
|
|
|
|
|
|
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.ensure 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
|