Production: - agent/bedrock_adapter.py, agent/vertex_adapter.py: pm.ensure_import ran at module import. In any process that imports these modules without a committed PM selection (CI's build_environment test venv, a fresh checkout) that sync rebuilt the dependency environment mid-process and replaced sys.path with a generation missing the caller's own packages (anthropic, aiohttp vanished). The extra is now ensured at first client build / credential request. - plugins/platforms/matrix/adapter.py: a complete install needs no ensure_and_bind round trip; only a partial one syncs. - tools/browser_tool.py: drop the facade's duplicate warm_agent_browser_npx_cache shim; the compat pointer already resolves to browser_tool_install. Test harness: - tests/home_io_guard.py: PATH-entry probes (shutil.which) and the running interpreter's own installation (stdlib reads, realpath ancestry, fixture symlinks into it) are not Hermes state; a patched Path.expanduser must not crash the guard. run_tests.sh no longer filters PATH — the guard owns it. - tests/tui_gateway/conftest.py: import hermes_bootstrap before any file opens a MagicMock hermes_constants window (6 files exited the process at boot). - tests/hermes_cli/conftest.py probe_root: scratch checkouts the import guard probes need hermes_bootstrap.py (the launcher imports it). - tests/pm/_fixtures.py stage_host_python: a copied relocatable python needs its stdlib beside it (No module named 'encodings' on CI). - tests/install/e2e-assets/smoke-env.mjs: dependency-free env shaping so the source-build-env probe runs under bare node (main deleted the Playwright entry it was imported through). - adapt main's new tests to branch seams (model_metadata_http, launch completion tail, CI toolchain exports uv after python, source_launch hermes_cli stub, systemd_notify single marker).
171 lines
6.3 KiB
Python
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)
|
|
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 |