Files
hermes-agent/tests/pm/_fixtures.py
2026-10-10 15:20:13 +02:00

171 lines
6.3 KiB
Python

"""Real artifacts and isolated PM workers shared by lifecycle tests."""
from __future__ import annotations
from functools import partial
from contextlib import contextmanager
import hashlib
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
import importlib
import io
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tarfile
import threading
import zipfile
import pytest
def _wheel(directory: Path, name: str, version: str = "1.0", requirements=()) -> Path:
metadata = f"{name}-{version}.dist-info"
entries = {
f"{name}/__init__.py": f"__version__ = {version!r}\n",
f"{metadata}/METADATA": f"Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n"
+ "".join(f"Requires-Dist: {requirement}\n" for requirement in requirements),
f"{metadata}/WHEEL": "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
}
entries[f"{metadata}/RECORD"] = "".join(f"{path},,\n" for path in entries)
wheel = directory / f"{name}-{version}-py3-none-any.whl"
with zipfile.ZipFile(wheel, "w") as archive:
for path, body in entries.items():
archive.writestr(path, body)
return wheel
def stage_host_python(python: Path) -> Path:
"""Place a runnable copy of the test host's interpreter at ``python`` (``<root>/bin/python…``).
CI's test interpreter is a relocatable python-build-standalone whose prefix is wherever the
binary sits, so a bare copy cannot find its stdlib (``No module named 'encodings'``). Copy the
host's stdlib beside it (a real copy, not a link: payload guards refuse links that escape the
tree); site-packages and the test suite stay out. Production's package stage ships the whole
distribution.
"""
python.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(Path(sys._base_executable).resolve(), python)
host_lib = Path(sys.base_prefix) / "lib"
stdlib = next((d for d in host_lib.glob("python3.*") if (d / "os.py").is_file()), None)
if stdlib is not None and not (python.parent.parent / "lib" / stdlib.name).exists():
shutil.copytree(stdlib, python.parent.parent / "lib" / stdlib.name,
ignore=shutil.ignore_patterns("site-packages", "test", "__pycache__", "idlelib", "tkinter"),
symlinks=True)
return python
def _run(command, *, cwd: Path, env: dict) -> str:
result = subprocess.run(command, cwd=cwd, env=env, capture_output=True, text=True, timeout=60, check=False)
assert result.returncode == 0, result.stderr
return result.stdout.strip()
def _ar_member(name: str, data: bytes) -> bytes:
hdr = (
name.ljust(16).encode()
+ b"0".ljust(12)
+ b"0".ljust(6)
+ b"0".ljust(6)
+ b"100644".ljust(8)
+ str(len(data)).encode().ljust(10)
+ b"`\n"
)
pad = b"\n" if len(data) % 2 else b""
return hdr + data + pad
def make_tar(docroot: Path, name: str, files: dict[str, str]) -> tuple[str, str]:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
for rel, content in files.items():
data = content.encode()
info = tarfile.TarInfo(rel)
info.size = len(data)
info.mode = 0o755
tf.addfile(info, io.BytesIO(data))
payload = buf.getvalue()
(docroot / name).write_bytes(payload)
return name, hashlib.sha256(payload).hexdigest()
@contextmanager
def threaded_server(handler):
with ThreadingHTTPServer(("127.0.0.1", 0), handler) as server:
server.daemon_threads = True
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server
finally:
server.shutdown()
thread.join(timeout=5)
@pytest.fixture
def served(tmp_path):
docroot = tmp_path / "www"
docroot.mkdir()
handler = partial(SimpleHTTPRequestHandler, directory=str(docroot))
with threaded_server(handler) as server:
yield docroot, f"http://127.0.0.1:{server.server_port}"
@pytest.fixture(scope="module")
def isolated_python(tmp_path_factory):
from pm.runtime_stage import stage_runtime
root = tmp_path_factory.mktemp("pm-python")
uv = shutil.which("uv")
assert uv, "the worker contract requires real uv"
python = stage_runtime(Path(uv), Path(sys.executable), root)
_run([str(python), "-I", "-c", "import importlib.util; assert importlib.util.find_spec('yaml') is None"],
cwd=root, env=dict(os.environ))
return python
@pytest.fixture
def client(tmp_path, monkeypatch, isolated_python):
from pm import paths
client = importlib.import_module("pm.client")
monkeypatch.setattr("pm.runtime.runtime_python", lambda **kwargs: isolated_python)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "store"))
monkeypatch.setattr(paths, "lockfile_path", lambda: tmp_path / "lock.json")
return client
def worker_toolchain(client, monkeypatch, isolated_python, injection=""):
uv = shutil.which("uv")
assert uv
worker = Path(client.__file__).with_name("worker.py")
script = (
"import runpy, sys, os; from pathlib import Path; "
f"sys.path.insert(0, {str(worker.parent.parent)!r}); "
"import pm._uv; "
f"pm._uv._toolchain = lambda **kwargs: (Path({uv!r}), Path({sys.executable!r}));\n"
+ injection + f"\nrunpy.run_path({str(worker)!r}, run_name='__main__')"
)
monkeypatch.setattr(client, "runtime_command", lambda path, **kwargs: [str(isolated_python), "-I", "-B", "-c", script])
@pytest.fixture
def build_worker(client, isolated_python, monkeypatch):
"""Inject prepared real tools only inside the independent worker process."""
worker_toolchain(client, monkeypatch, isolated_python)
monkeypatch.setattr(client, "is_runtime", lambda: False)
def caller_engine(*args, **kwargs):
pytest.fail("dependency engine ran in the caller, not the PM worker")
for module, names in {
"pm.operations": ("build_environment", "lock_project"),
"pm.build_operations": ("build_requirements_environment", "export_requirements", "check_project_lock"),
}.items():
for name in names:
monkeypatch.setattr(importlib.import_module(module), name, caller_engine)
return client