Files
hermes-agent/tests/pm/test_build_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

466 lines
23 KiB
Python

"""Offline behavior contracts for PM's caller-owned build operations."""
from __future__ import annotations
import json
import os
from pathlib import Path
import sys
import pytest
from pm.package import InstallError
from tests.pm._fixtures import (
_run,
_wheel,
build_worker as build_worker,
client as client,
isolated_python as isolated_python,
served as served,
)
def test_stage_tools_copies_verified_closure_without_acquiring_or_live_state(tmp_path, client, monkeypatch, served):
import importlib.util
import pm
from pm.lock import Facts, Lockfile
from pm.registry import _packages
from pm.store import current_target, tree_digest
target = current_target()
source = tmp_path / "canonical"
source.mkdir()
lock = Lockfile(tmp_path / "lock.json")
facts = Facts(source / "facts.json")
definition = tmp_path / "copy_fixture.py"
definition.write_text(
"from pm.package import Package\n"
"class CopyLeaf(Package):\n"
" name = 'copy-leaf'\n"
" def env(self, entry, target): return {'COPY_ROOT': str(entry)}\n"
"class CopyTool(CopyLeaf):\n"
" name = 'copy-tool'\n"
" deps = ('copy-leaf',)\n", encoding="utf-8")
spec = importlib.util.spec_from_file_location("copy_fixture", definition)
module = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, "copy_fixture", module)
spec.loader.exec_module(module)
for package in (module.CopyLeaf(), module.CopyTool()):
monkeypatch.setitem(_packages, package.name, package)
lock.set_pin(package.name, "1.0", {target: {"url": "https://invalid.test/tool.tgz", "sha256": "a" * 64}})
entry = source / package.store_entry("1.0", target)
entry.mkdir()
(entry / "data").write_text(package.name, encoding="utf-8")
facts.record(package.name, "1.0", entry.name, package.env(entry, target), source,
target=target, artifacts=["a" * 64], digest=tree_digest(entry))
lock.save()
from tests.pm._fixtures import make_tar
import shutil
directory, base = served
for name in ("copy-leaf", "copy-tool"):
archive, sha = make_tar(directory, name + ".tgz", {"data": name})
lock.set_pin(name, "1.0", {target: {"url": base + "/" + archive, "sha256": sha}})
lock.save()
shutil.rmtree(source)
assert pm.prepare_tools(["copy-tool"], out=source, target=target, cache=tmp_path / "python-cache") == source
facts = Facts(source / "facts.json")
before = (source / "facts.json").read_bytes()
home_before = sorted(str(path) for path in (tmp_path / "home").rglob("*"))
destination = tmp_path / "payload-tools"
result = pm.stage_tools(["copy-tool"], source_store=source, out=destination, target=target)
assert result == destination
# An old output may have been populated with hardlinks. A fresh staging
# request must establish independent bytes, not reuse those current facts.
tool_entry = facts.get("copy-tool")["entry"]
linked_copy = destination / tool_entry / "data"
linked_copy.unlink()
os.link(source / tool_entry / "data", linked_copy)
pm.stage_tools(["copy-tool"], source_store=source, out=destination, target=target)
staged = Facts(destination / "facts.json")
for name in ("copy-leaf", "copy-tool"):
entry = facts.get(name)["entry"]
copied, original = destination / entry / "data", source / entry / "data"
assert copied.read_bytes() == original.read_bytes()
assert not copied.samefile(original)
copied.write_text("signed output", encoding="utf-8")
assert original.read_text() == name
assert staged.get(name)["digest"] == facts.get(name)["digest"]
assert staged.env_for(name, destination)["COPY_ROOT"] == str(destination / entry)
assert (source / "facts.json").read_bytes() == before
assert sorted(str(path) for path in (tmp_path / "home").rglob("*")) == home_before
assert not (tmp_path / "home/config.yaml").exists()
assert not (tmp_path / "store/facts.json").exists()
# Every call must admit the source, even if destination facts are warm.
original = source / facts.get("copy-tool")["entry"] / "data"
original.write_text("corrupt", encoding="utf-8")
with pytest.raises(InstallError, match="source failed verification"):
pm.stage_tools(["copy-tool"], source_store=source, out=destination, target=target)
original.write_text("copy-tool", encoding="utf-8")
lock.set_pin("copy-tool", "1.0", {target: {"url": "https://invalid.test/repin", "sha256": "b" * 64}})
lock.save()
with pytest.raises(InstallError, match="source failed verification"):
pm.stage_tools(["copy-tool"], source_store=source, out=destination, target=target)
lock.set_pin("copy-tool", "1.0", {target: {"url": "https://invalid.test/tool.tgz", "sha256": facts.get("copy-tool")["artifacts"][0]}})
lock.save()
# A locally recorded link must not turn an independent copy into a shared
# mutable file in the cache or another caller's tree.
external = tmp_path / "external"
external.write_text("shared", encoding="utf-8")
original.unlink()
original.symlink_to(external)
package = module.CopyTool()
entry = original.parent
facts.record(package.name, "1.0", entry.name, package.env(entry, target), source,
target=target, artifacts=facts.get("copy-tool")["artifacts"], digest=tree_digest(entry))
with pytest.raises(InstallError, match="source.*verification|escapes"):
pm.stage_tools(["copy-tool"], source_store=source, out=tmp_path / "other-output", target=target)
@pytest.mark.parametrize("damage", [None, "entry", "env", "bytes", "pin", "binary"])
def test_verified_tools_admits_only_locked_entries_without_execution(tmp_path, monkeypatch, damage):
import subprocess
from pm import build_operations
from pm.lock import Facts, Lockfile
from pm.registry import get_package
from pm.store import current_target, tree_digest
target, store = current_target(), tmp_path / "tools"
lock = Lockfile(tmp_path / "lock.json")
package = get_package("python")
entry = store / package.store_entry("1.0", target)
binary = package.binary(entry, target)
assert binary is not None
binary.parent.mkdir(parents=True)
binary.write_bytes(b"already verified at publication")
lock.set_pin("python", "1.0", {target: {"url": "https://invalid.test/python", "sha256": "a" * 64}})
facts = Facts(store / "facts.json")
env = package.env(entry, target)
if damage == "entry":
renamed = entry.with_name("noncanonical")
entry.rename(renamed)
entry = renamed
env = package.env(entry, target)
if damage == "env":
env["PATH"] = [str(tmp_path / "unverified")]
if damage == "binary":
binary.unlink()
facts.record("python", "1.0", entry.name, env, store, target=target,
artifacts=["a" * 64], digest=tree_digest(entry))
if damage == "bytes":
binary.write_bytes(b"changed")
if damage == "pin":
lock.set_pin("python", "1.0", {target: {"url": "https://invalid.test/python", "sha256": "b" * 64}})
before = {p.relative_to(store): p.read_bytes() for p in store.rglob("*") if p.is_file()}
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: pytest.fail("read-only admission executed a process"))
if damage:
with pytest.raises(InstallError, match="source failed verification"):
build_operations.verified_tools(["python"], source_store=store, target=target, lock=lock)
else:
selected = build_operations.verified_tools(["python"], source_store=store, target=target, lock=lock)
assert selected.entries["python"].path == entry
assert selected.entries["python"].binary == binary
assert selected.environment({"PATH": "inherited"})["PATH"] == str(binary.parent) + os.pathsep + "inherited"
assert {p.relative_to(store): p.read_bytes() for p in store.rglob("*") if p.is_file()} == before
@pytest.fixture
def build_tools(tmp_path, monkeypatch, build_worker):
env = {key: value for key, value in os.environ.items()
if not key.startswith(("UV_", "PYTHON")) and key != "VIRTUAL_ENV"}
home = tmp_path / "home"
home.mkdir()
env.update(HOME=str(home), USERPROFILE=str(home), HERMES_HOME=str(home / ".hermes"))
monkeypatch.setenv("HERMES_HOME", env["HERMES_HOME"])
monkeypatch.setattr(Path, "home", lambda: home)
return env
@pytest.fixture
def locked_source(tmp_path, build_tools):
from pm import lock_project
wheels = tmp_path / "wheelhouse"
wheels.mkdir()
for name in ("base_dep", "chosen_dep", "dev_dep"):
_wheel(wheels, name)
source = tmp_path / "source with spaces"
source.mkdir()
(source / "pyproject.toml").write_text(
'[project]\nname="export-root"\nversion="1"\nrequires-python=">=3.11"\n'
'dependencies=["base-dep==1.0"]\n'
'[project.optional-dependencies]\nchosen=["chosen-dep==1.0; sys_platform == \'win32\'"]\n'
'[dependency-groups]\ndev=["dev-dep==1.0"]\n'
'[tool.uv]\npackage=false\nno-index=true\n'
f'find-links=[{json.dumps(wheels.as_posix())}]\n', encoding="utf-8",
)
lock_project(source, python=Path(sys.executable), cache=tmp_path / "cache",
env=build_tools, offline=True, explicit=True)
return source
@pytest.mark.parametrize("lock_state", ["current", "stale", "missing"])
def test_check_lock_never_changes_source(locked_source, tmp_path, build_tools, lock_state):
from pm import check_project_lock
source = locked_source
if lock_state == "stale":
manifest = source / "pyproject.toml"
manifest.write_text(manifest.read_text().replace('version="1"', 'version="2"'), encoding="utf-8")
elif lock_state == "missing":
(source / "uv.lock").unlink()
before = {p.relative_to(source): p.read_bytes() for p in source.rglob("*") if p.is_file()}
if lock_state == "current":
check_project_lock(source, python=Path(sys.executable), cache=tmp_path / "cache",
env=build_tools, offline=True, explicit=True)
else:
with pytest.raises(InstallError):
check_project_lock(source, python=Path(sys.executable), cache=tmp_path / "cache",
env=build_tools, offline=True, explicit=True)
assert {p.relative_to(source): p.read_bytes() for p in source.rglob("*") if p.is_file()} == before
assert not (source / ".venv").exists()
def test_frozen_export_keeps_markers_and_excludes_build_metadata(locked_source, tmp_path, build_tools):
from packaging.requirements import Requirement
from pm import export_requirements
source = locked_source
# Frozen export must use yesterday's resolution, not refresh today's edits.
manifest = source / "pyproject.toml"
manifest.write_text(manifest.read_text().replace("base-dep==1.0", "base-dep==2.0"), encoding="utf-8")
before = {p.name: p.read_bytes() for p in source.iterdir() if p.is_file()}
out = tmp_path / "export directory" / "requirements.txt"
export_requirements(source, out, extras=["chosen", "chosen"], python=Path(sys.executable),
cache=tmp_path / "cache", env=build_tools, explicit=True)
text = out.read_text(encoding="utf-8")
requirements = {r.name: r for r in map(Requirement, text.splitlines())}
assert set(requirements) == {"base-dep", "chosen-dep"}
assert str(requirements["base-dep"].specifier) == "==1.0"
marker = requirements["chosen-dep"].marker
assert marker is not None
assert marker.evaluate({"sys_platform": "win32"})
assert not marker.evaluate({"sys_platform": "linux"})
assert "#" not in text and "--hash" not in text
assert {p.name: p.read_bytes() for p in source.iterdir() if p.is_file()} == before
assert not (source / ".venv").exists()
def test_frozen_export_preserves_git_commit_pin(locked_source, tmp_path, build_tools):
from packaging.requirements import Requirement
from pm import export_requirements, lock_project
dependency = tmp_path / "git-dependency"
dependency.mkdir()
(dependency / "pyproject.toml").write_text(
'[project]\nname="git-dep"\nversion="1.0"\nrequires-python=">=3.11"\n'
'dependencies=[]\n', encoding="utf-8",
)
_run(["git", "init", "--quiet"], cwd=dependency, env=build_tools)
_run(["git", "add", "pyproject.toml"], cwd=dependency, env=build_tools)
_run(["git", "-c", "user.name=PM test", "-c", "user.email=pm@example.invalid",
"-c", "commit.gpgsign=false", "-c", f"core.hooksPath={tmp_path / 'empty-hooks'}",
"commit", "--quiet", "-m", "fixture"], cwd=dependency, env=build_tools)
revision = _run(["git", "rev-parse", "HEAD"], cwd=dependency, env=build_tools)
url = f"git+{dependency.as_uri()}@{revision}"
manifest = locked_source / "pyproject.toml"
manifest.write_text(manifest.read_text().replace('"base-dep==1.0"', json.dumps(f"git-dep @ {url}")),
encoding="utf-8")
lock_project(locked_source, python=Path(sys.executable), cache=tmp_path / "cache", env=build_tools, explicit=True)
before = (locked_source / "uv.lock").read_bytes()
out = tmp_path / "git-requirements.txt"
export_requirements(locked_source, out, cache=tmp_path / "cache", env=build_tools, explicit=True)
requirement = Requirement(out.read_text(encoding="utf-8").strip())
assert requirement.name == "git-dep"
assert requirement.url == url
assert (locked_source / "uv.lock").read_bytes() == before
@pytest.mark.parametrize("sealed", [False, True])
def test_requirements_build_installs_offline_markers_and_seals_only_build_pth(tmp_path, build_tools, sealed):
from pm import build_requirements_environment
wheels = tmp_path / "wheels with spaces"
wheels.mkdir()
_wheel(wheels, "leaf_dep")
_wheel(wheels, "app_dep", requirements=["leaf-dep==1.0"])
out = tmp_path / "destination with spaces"
# Ambient project and interpreter settings cannot escape the explicit build.
(tmp_path / "uv.toml").write_text('required-version="<0.1"\n', encoding="utf-8")
poison = tmp_path / "poison"
env = dict(build_tools, UV_PROJECT_ENVIRONMENT=str(poison), VIRTUAL_ENV=str(poison),
UV_PYTHON=str(poison), UV_CACHE_DIR=str(poison), UV_SYSTEM_PYTHON="true")
before = dict(os.environ)
executable = build_requirements_environment(
["app-dep==1.0; python_version >= '3'", "missing-dep==1.0; python_version < '2'"],
out=out, python=Path(sys.executable), wheelhouse=wheels, cache=tmp_path / "cache",
env=env, offline=True, sealed=sealed, explicit=True)
result = json.loads(_run(
[str(executable), "-I", "-c", "import sys, json, app_dep, leaf_dep, importlib.util; "
"print(json.dumps([app_dep.__version__, leaf_dep.__version__, sys.base_prefix, "
"importlib.util.find_spec('missing_dep') is None]))"], cwd=tmp_path, env=build_tools,
))
assert result == ["1.0", "1.0", sys.base_prefix, True]
assert executable.parent.parent == out
assert bool(list(out.rglob("_virtualenv.pth"))) is not sealed
assert not poison.exists()
assert not Path(build_tools["HERMES_HOME"]).exists()
assert dict(os.environ) == before
@pytest.mark.parametrize("failure", ["create", "install", "check"])
def test_failed_requirement_build_removes_only_its_candidate(tmp_path, build_tools, failure):
import zipfile
from pm import build_requirements_environment
wheels = tmp_path / "wheels"
wheels.mkdir()
wheel = _wheel(wheels, "app_dep")
previous = tmp_path / "previous"
executable = build_requirements_environment(["app-dep==1.0"], out=previous, wheelhouse=wheels,
env=build_tools, cache=tmp_path / "cache", offline=True, explicit=True)
previous_cfg = (previous / "pyvenv.cfg").read_bytes()
python = Path(sys.executable)
if failure == "create":
python = tmp_path / "missing-python"
elif failure == "install":
wheel.unlink()
else:
# The archive carries an extra distribution invisible to resolution.
# pip check must reject its unsatisfied dependency after installation.
with zipfile.ZipFile(wheel, "a") as archive:
archive.writestr("app_dep-1.0.data/purelib/ghost-1.0.dist-info/METADATA",
"Metadata-Version: 2.1\nName: ghost\nVersion: 1.0\n"
"Requires-Dist: missing-dep==1.0\n")
out = tmp_path / "candidate"
with pytest.raises(InstallError, match="dependency validation" if failure == "check" else None):
build_requirements_environment(["app-dep==1.0"], out=out, python=python, wheelhouse=wheels,
env=build_tools, cache=tmp_path / "cold-cache", offline=True, explicit=True)
assert not out.exists()
with pytest.raises(FileExistsError, match="already exists"):
build_requirements_environment(["app-dep==1.0"], out=previous, wheelhouse=wheels,
env=build_tools, cache=tmp_path / "cache", offline=True, explicit=True)
assert (previous / "pyvenv.cfg").read_bytes() == previous_cfg
assert _run([str(executable), "-I", "-c", "import app_dep; print(app_dep.__version__)"],
cwd=tmp_path, env=build_tools) == "1.0"
def test_wheelhouse_cannot_fall_back_to_index_or_build_source(tmp_path, build_tools):
import base64
import io
import tarfile
from pm import build_requirements_environment
index = tmp_path / "index" / "leaf-dep"
index.mkdir(parents=True)
wheel = _wheel(index, "leaf_dep")
(index / "index.html").write_text(f'<a href="{wheel.name}">{wheel.name}</a>', encoding="utf-8")
wheels = tmp_path / "wheelhouse"
wheels.mkdir()
# A real, self-contained sdist: relaxing binary-only would make it install.
backend = (
"from pathlib import Path\nimport base64\n"
"def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):\n"
f" Path(wheel_directory, {wheel.name!r}).write_bytes(base64.b64decode("
f"{base64.b64encode(wheel.read_bytes())!r}))\n"
f" return {wheel.name!r}\n"
)
entries = {
"pyproject.toml": '[project]\nname="leaf-dep"\nversion="1.0"\n'
'[build-system]\nrequires=[]\nbuild-backend="backend"\nbackend-path=["."]\n',
"backend.py": backend,
}
with tarfile.open(wheels / "leaf_dep-1.0.tar.gz", "w:gz") as archive:
for name, text in entries.items():
data = text.encode("utf-8")
info = tarfile.TarInfo(f"leaf_dep-1.0/{name}")
info.size = len(data)
archive.addfile(info, io.BytesIO(data))
env = dict(build_tools, UV_DEFAULT_INDEX=index.parent.as_uri())
out = tmp_path / "candidate"
with pytest.raises(InstallError):
build_requirements_environment(["leaf-dep==1.0"], out=out, wheelhouse=wheels,
env=env, cache=tmp_path / "cache", offline=True, explicit=True)
assert not out.exists()
# Both alternate sources really work when there is no wheelhouse restriction.
for name, settings in (("index", env), ("source", dict(build_tools, UV_NO_INDEX="1", UV_FIND_LINKS=str(wheels)))):
executable = build_requirements_environment(
["leaf-dep==1.0"], out=tmp_path / f"from-{name}", env=settings,
cache=tmp_path / f"{name}-cache", offline=True, explicit=True)
assert _run([str(executable), "-I", "-c", "import leaf_dep; print(leaf_dep.__version__)"],
cwd=tmp_path, env=build_tools) == "1.0"
@pytest.mark.parametrize("operation", ["check", "export", "build"])
def test_ready_tools_do_not_bypass_disabled_lazy_operations(locked_source, tmp_path, build_tools, monkeypatch, operation):
import importlib
from pm import build_requirements_environment, check_project_lock, export_requirements
monkeypatch.setattr(importlib.import_module("pm.install"), "lazy_installs_allowed", lambda: False)
out = tmp_path / "blocked-output"
before = {p.name: p.read_bytes() for p in locked_source.iterdir() if p.is_file()}
actions = {
"check": lambda: check_project_lock(locked_source, env=build_tools, cache=tmp_path / "cache"),
"export": lambda: export_requirements(locked_source, out, env=build_tools, cache=tmp_path / "cache"),
"build": lambda: build_requirements_environment(["base-dep==1.0"], out=out, env=build_tools,
cache=tmp_path / "cache", offline=True),
}
with pytest.raises(InstallError, match="lazy installs are disabled"):
actions[operation]()
assert not out.exists()
assert {p.name: p.read_bytes() for p in locked_source.iterdir() if p.is_file()} == before
# Passive lock validation is safe with already-ready tools and no network.
check_project_lock(locked_source, env=build_tools, cache=tmp_path / "cache", offline=True)
def test_prune_cache_does_not_acquire_a_missing_toolchain(tmp_path, monkeypatch):
import importlib
import pm.paths
from pm import prune_cache
monkeypatch.setattr("pm.client.is_runtime", lambda: True)
store = tmp_path / "empty-store"
monkeypatch.setattr(pm.paths, "store_root", lambda: store)
monkeypatch.setattr(pm.paths, "writable_store_root", lambda: store)
monkeypatch.setattr(pm.paths, "facts_path", lambda: store / "facts.json")
monkeypatch.setattr(importlib.import_module("pm.install"), "ensure",
lambda *args, **kwargs: pytest.fail("cache pruning must not acquire tools"))
with pytest.raises(InstallError, match="pinned toolchain is unavailable"):
prune_cache(tmp_path / "cache")
assert not store.exists()
@pytest.mark.parametrize("ci", [False, True])
def test_prune_cache_preserves_downloaded_wheels_unless_ci(tmp_path, build_tools, ci):
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
import threading
from pm import build_requirements_environment, prune_cache
wheels = tmp_path / "wheels"
wheels.mkdir()
wheel = _wheel(wheels, "cached_dep")
server = ThreadingHTTPServer(("127.0.0.1", 0), partial(SimpleHTTPRequestHandler, directory=str(wheels)))
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
cache = tmp_path / "cache"
requirement = f"cached-dep @ http://127.0.0.1:{server.server_port}/{wheel.name}"
try:
executable = build_requirements_environment(
[requirement], out=tmp_path / "first", cache=cache, env=build_tools, explicit=True)
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
assert _run([str(executable), "-I", "-c", "import cached_dep; print(cached_dep.__version__)"],
cwd=tmp_path, env=build_tools) == "1.0"
prune_cache(cache, ci=ci)
out = tmp_path / "second"
if ci:
with pytest.raises(InstallError):
build_requirements_environment([requirement], out=out, cache=cache, env=build_tools, offline=True, explicit=True)
assert not out.exists()
else:
second = build_requirements_environment([requirement], out=out, cache=cache, env=build_tools, offline=True, explicit=True)
assert _run([str(second), "-I", "-c", "import cached_dep; print(cached_dep.__version__)"],
cwd=tmp_path, env=build_tools) == "1.0"