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.
246 lines
11 KiB
Python
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)
|