sync_venv took four mutually exclusive plugin kwargs (plugin_dirs, extra_plugin_dirs, selection, staged_plugin) and venv_is_current two, with runtime ValueErrors guarding the combinations. Replace them with a single `plugins=` argument typed as one of pm.plugin_inputs.Members, Candidates, Selection or StagedUpdate, so a conflicting request cannot be expressed. The module also owns the worker wire encoding that client.py and worker.py each duplicated. pm.install.sync_venv is split into cohesive helpers (feature policy, install lock, publication snapshot, target selection, commit) with the same ordering, receipts and recovery; its CC drops from 54 to 16. All in-tree callers and tests move to the new argument.
608 lines
28 KiB
Python
608 lines
28 KiB
Python
"""Real isolated workers: no parent imports or callables cross the wire."""
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
from pathlib import Path
|
|
import subprocess
|
|
import shutil
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
from pm import paths
|
|
from pm.package import InstallError
|
|
from pm.plugin_inputs import Members, Selection
|
|
from pm.runtime import runtime_python
|
|
from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401
|
|
from tests.pm.test_runtime_wheelhouse import locked_wheelhouse # noqa: F401
|
|
from tests.pm._fixtures import (
|
|
_wheel,
|
|
build_worker as build_worker,
|
|
client as client,
|
|
isolated_python as isolated_python,
|
|
)
|
|
|
|
# Spawns children with a home it builds itself; the parent's must stay real.
|
|
pytestmark = pytest.mark.real_machine_home
|
|
|
|
|
|
def test_isolated_worker_preserves_install_error(client, monkeypatch):
|
|
engine = importlib.import_module("pm.install")
|
|
monkeypatch.setattr(engine, "ensure", lambda *a, **kw: pytest.fail("engine ran in caller"))
|
|
with pytest.raises(InstallError) as caught:
|
|
client.ensure("node", explicit=True)
|
|
assert caught.value.package == "node"
|
|
assert caught.value.cause == "not in the lockfile"
|
|
assert caught.value.remedy == "add it with `hermes pm lock --bump`"
|
|
assert not paths.facts_path().exists()
|
|
|
|
|
|
|
|
def test_refused_or_already_paused_install_does_not_acquire_runtime(client, monkeypatch):
|
|
import threading
|
|
from pm.downloader import DownloadPaused
|
|
|
|
monkeypatch.setattr(client, "runtime_command", lambda path, **kwargs: pytest.fail("refusal acquired PM runtime"))
|
|
monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1")
|
|
with pytest.raises(InstallError, match="lazy installs are disabled"):
|
|
client.ensure("node")
|
|
paused = threading.Event()
|
|
paused.set()
|
|
with pytest.raises(DownloadPaused):
|
|
client.ensure("node", explicit=True, pause_event=paused)
|
|
|
|
|
|
def _current_environment(tmp_path, monkeypatch, members):
|
|
from pm.environments import install_state_dir
|
|
from pm.lock import Facts
|
|
from pm.packages import Venv
|
|
|
|
repo = tmp_path / "project"
|
|
repo.mkdir()
|
|
(repo / "uv.lock").write_text("version = 1\n")
|
|
monkeypatch.setattr(paths, "repo_root", lambda: repo)
|
|
environment = install_state_dir(repo) / "environments" / "existing" / "venv"
|
|
environment.mkdir(parents=True)
|
|
(environment / "pyvenv.cfg").write_text("home = test\n")
|
|
Facts(paths.runtime_facts_path()).record_state(
|
|
"venv", Venv().expected_stamp([], plugin_dirs=members), [], environment=environment,
|
|
)
|
|
return repo
|
|
|
|
|
|
@pytest.mark.parametrize("member_shape", ["paths", "sources"])
|
|
@pytest.mark.parametrize("route", ["worker", "direct", "foreign-runtime"])
|
|
def test_currency_probe_preserves_union_and_candidate_inputs(client, tmp_path, monkeypatch, isolated_python,
|
|
member_shape, route):
|
|
import json
|
|
from pm.environments import runtime_facts_path, selected_venv
|
|
from pm.lock import Facts
|
|
from pm.packages import Venv
|
|
|
|
candidate = tmp_path / "candidate"
|
|
candidate.mkdir()
|
|
manifest = candidate / "plugin.yaml"
|
|
manifest.write_text('name: candidate\npython_dependencies: ["candidate-dep==1"]\n')
|
|
members = [candidate] if member_shape == "paths" else {tmp_path / "installed": candidate}
|
|
repo = _current_environment(tmp_path, monkeypatch, members)
|
|
environment = selected_venv(repo)
|
|
recorded = ["incumbent-extra", "provider-extra"]
|
|
facts_path = runtime_facts_path(repo)
|
|
Facts(facts_path).record_state(
|
|
"venv", Venv(repo).expected_stamp(recorded, plugin_dirs=members), recorded,
|
|
environment=environment,
|
|
)
|
|
monkeypatch.setattr(client, "is_runtime", lambda: route != "worker")
|
|
if route == "foreign-runtime":
|
|
monkeypatch.setattr(paths, "repo_root", lambda: tmp_path / "other-project")
|
|
root_args = {"project_root": repo} if route != "worker" else {}
|
|
acquisitions = []
|
|
|
|
def ready_runtime(*, bootstrap, cache):
|
|
assert bootstrap is False, "currency probe attempted to bootstrap PM"
|
|
acquisitions.append(bootstrap)
|
|
return isolated_python
|
|
|
|
monkeypatch.setattr("pm.runtime.runtime_python", ready_runtime)
|
|
if route != "direct":
|
|
engine = importlib.import_module("pm.install")
|
|
monkeypatch.setattr(engine, "venv_is_current", lambda **kw: pytest.fail("probe ran in caller"))
|
|
def snapshot():
|
|
return {path.relative_to(tmp_path): (path.read_bytes() if path.is_file() else None)
|
|
for path in tmp_path.rglob("*")}
|
|
|
|
from pm.lock import Lockfile
|
|
pins = Lockfile(paths.lockfile_path())
|
|
pins.set_pin("python", "test.1", {"any": {"url": "https://example.invalid/python", "sha256": "1" * 64}})
|
|
pins.save()
|
|
Facts(facts_path).record_state("venv", Venv(repo).expected_stamp(recorded, plugin_dirs=members), recorded,
|
|
environment=environment)
|
|
before = snapshot()
|
|
assert client.venv_is_current(extras=["provider-extra"], plugins=Members(members), **root_args)
|
|
assert client.venv_is_current(extras=[], plugins=Members(members), **root_args)
|
|
assert not client.venv_is_current(extras=["new-extra"], plugins=Members(members), **root_args)
|
|
assert not client.venv_is_current(extras=recorded, plugins=Members([]), **root_args)
|
|
assert snapshot() == before, "currency queries changed dependency state"
|
|
assert bool(acquisitions) is (route != "direct")
|
|
pins.set_pin("uv", "unrelated", {"any": {"url": "https://example.invalid/uv", "sha256": "3" * 64}})
|
|
pins.save()
|
|
unchanged = snapshot()
|
|
assert client.venv_is_current(extras=[], plugins=Members(members), **root_args)
|
|
assert snapshot() == unchanged
|
|
for version, digest in [("test.1", "2" * 64), ("test.2", "1" * 64)]:
|
|
pins.set_pin("python", version, {"any": {"url": "https://example.invalid/python", "sha256": digest}})
|
|
pins.save()
|
|
changed = snapshot()
|
|
assert not client.venv_is_current(plugins=Members(members), **root_args)
|
|
assert snapshot() == changed
|
|
pins.set_pin("python", "test.1", {"any": {"url": "https://example.invalid/python", "sha256": "1" * 64}})
|
|
pins.save()
|
|
marker = environment / "pyvenv.cfg"
|
|
contents = marker.read_bytes()
|
|
marker.unlink()
|
|
changed = snapshot()
|
|
assert not client.venv_is_current(plugins=Members(members), **root_args)
|
|
assert snapshot() == changed
|
|
marker.write_bytes(contents)
|
|
assert client.venv_is_current(plugins=Members(members), **root_args)
|
|
|
|
manifest.write_text('name: candidate\npython_dependencies: ["candidate-dep==2"]\n')
|
|
changed = snapshot()
|
|
assert not client.venv_is_current(extras=["provider-extra"], plugins=Members(members), **root_args)
|
|
assert snapshot() == changed
|
|
assert selected_venv(repo) == environment
|
|
# Corruption must not be mistaken for a missing or current environment.
|
|
data = json.loads(facts_path.read_text())
|
|
data["packages"]["venv"]["extras"] = "provider-extra"
|
|
facts_path.write_text(json.dumps(data))
|
|
malformed = snapshot()
|
|
with pytest.raises(ValueError, match="invalid recorded dependency state"):
|
|
client.venv_is_current(extras=[], plugins=Members(members), **root_args)
|
|
assert snapshot() == malformed
|
|
|
|
|
|
def _assert_worker_holds_lock(repo):
|
|
from pm.environments import install_state_dir
|
|
from pm.filesystem import lock_fd
|
|
|
|
with (install_state_dir(repo) / ".install.lock").open("a+b") as lock:
|
|
assert not lock_fd(lock.fileno(), wait=False), "callback escaped the worker's runtime lock"
|
|
|
|
|
|
@pytest.mark.parametrize("explicit", [True, False])
|
|
def test_sync_discovers_profile_members_after_worker_acquires_lock(client, tmp_path, monkeypatch, isolated_python, explicit):
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
import time
|
|
from hermes_cli.runtime_state import runtime_lock
|
|
from tests.pm._fixtures import worker_toolchain
|
|
|
|
sibling = tmp_path / "home/profiles/sibling/plugins/dependency"
|
|
sibling.mkdir(parents=True)
|
|
(sibling / "plugin.yaml").write_text("name: dependency\npython_dependencies: [fixture-dep==1]\n")
|
|
repo = _current_environment(tmp_path, monkeypatch, [sibling])
|
|
ready = tmp_path / "waiting-for-lock"
|
|
worker_toolchain(client, monkeypatch, isolated_python,
|
|
"from contextlib import contextmanager\nimport hermes_cli.runtime_state as state\n"
|
|
"original = state.runtime_lock\n@contextmanager\ndef lock(project, **kwargs):\n"
|
|
f" Path({str(ready)!r}).touch()\n"
|
|
" with original(project, **kwargs) as held:\n yield held\nstate.runtime_lock = lock\n")
|
|
with ThreadPoolExecutor() as executor:
|
|
with runtime_lock(repo):
|
|
future = executor.submit(client.sync_venv, explicit=explicit, plugins=Selection({
|
|
"home": str(tmp_path / "home"), "enabled": ["plain"], "disabled": [],
|
|
}))
|
|
deadline = time.monotonic() + 15
|
|
while not ready.exists() and time.monotonic() < deadline:
|
|
time.sleep(0.01)
|
|
assert ready.exists(), "worker never reached the install lock"
|
|
assert not future.done(), "worker ignored the install lock"
|
|
(sibling.parent.parent / "config.yaml").write_text("plugins:\n enabled: [dependency]\n")
|
|
future.result(timeout=30)
|
|
assert client.venv_is_current()
|
|
|
|
|
|
@pytest.mark.parametrize("current", [True, False])
|
|
@pytest.mark.parametrize("tools_present", [False, True], ids=["cold-tools", "ready-tools"])
|
|
def test_lazy_disabled_sync_does_not_bootstrap_tools(client, tmp_path, monkeypatch, isolated_python, current, tools_present):
|
|
import json
|
|
from pm import receipt
|
|
|
|
repo = _current_environment(tmp_path, monkeypatch, [])
|
|
if not current:
|
|
(repo / "uv.lock").write_text("version = 2\n")
|
|
monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1")
|
|
# Exercise runtime acquisition too: the other worker tests supply a ready
|
|
# interpreter, which hides an explicit tool install before worker refusal.
|
|
monkeypatch.setattr("pm.runtime.runtime_python", runtime_python)
|
|
from pm import _uv
|
|
original_toolchain = _uv._toolchain
|
|
|
|
def toolchain(**kwargs):
|
|
assert kwargs.get("realize") is False, "lazy-disabled sync bootstrapped tools"
|
|
if tools_present:
|
|
return tmp_path / "uv", isolated_python
|
|
return original_toolchain(**kwargs)
|
|
|
|
monkeypatch.setattr(_uv, "_toolchain", toolchain)
|
|
monkeypatch.setattr("pm.runtime_stage.stage_runtime",
|
|
lambda *a, **kw: pytest.fail("lazy-disabled sync prepared PM runtime"))
|
|
with receipt.worker_context("lazy-disabled-sync"):
|
|
with pytest.raises(InstallError, match="lazy installs are disabled") as caught:
|
|
client.sync_venv([], plugins=Members([]))
|
|
result = receipt.last_for_update("lazy-disabled-sync", consume=True)
|
|
assert caught.value.package == "pm-runtime"
|
|
assert result is not None
|
|
assert result["outcome"] == "failed"
|
|
receipts = list((tmp_path / "home" / "logs" / "update_receipts").glob("pm_*.json"))
|
|
assert len(receipts) == 1
|
|
assert json.loads(receipts[0].read_text()) == result
|
|
assert not paths.facts_path().exists()
|
|
|
|
|
|
def test_invalid_selection_waits_for_failed_receipt_and_lock_release(client, tmp_path, monkeypatch):
|
|
import json
|
|
from pm.environments import install_state_dir
|
|
from pm.filesystem import lock_fd
|
|
|
|
repo = _current_environment(tmp_path, monkeypatch, [])
|
|
home = tmp_path / "home"
|
|
(home / "config.yaml").write_text("plugins: []\n")
|
|
with pytest.raises(ValueError, match="plugins must be a mapping"):
|
|
client.sync_venv([], explicit=True, plugins=Selection({"home": str(home), "enabled": [], "disabled": []}))
|
|
receipts = list((home / "logs" / "update_receipts").glob("pm_*.json"))
|
|
assert len(receipts) == 1
|
|
assert json.loads(receipts[0].read_text())["outcome"] == "failed"
|
|
with (install_state_dir(repo) / ".install.lock").open("a+b") as lock:
|
|
assert lock_fd(lock.fileno(), wait=False)
|
|
|
|
|
|
def _node_archive(server, body=b"#!/bin/sh\nexit 0\n"):
|
|
import hashlib
|
|
import io
|
|
import zipfile
|
|
from pm.lock import Lockfile
|
|
from pm.registry import get_package
|
|
from pm.store import current_target
|
|
|
|
target = current_target()
|
|
package = get_package("node")
|
|
relative = package.binary(Path("."), target)
|
|
assert relative is not None
|
|
stream = io.BytesIO()
|
|
with zipfile.ZipFile(stream, "w") as archive:
|
|
info = zipfile.ZipInfo((Path("node-package") / relative).as_posix())
|
|
info.external_attr = 0o100755 << 16
|
|
archive.writestr(info, body)
|
|
payload = stream.getvalue()
|
|
RangeHandler.payloads["/node.zip"] = payload
|
|
lock = Lockfile(paths.lockfile_path())
|
|
lock.set_pin("node", "1", {target: {"url": url(server, "/node.zip"),
|
|
"sha256": hashlib.sha256(payload).hexdigest()}})
|
|
lock.save()
|
|
return target, relative, body
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
@pytest.mark.parametrize("operation", ["ensure", "stage_only"])
|
|
def test_worker_artifact_lifecycle_keeps_identity_and_relays_progress(client, dl_server, operation):
|
|
import hashlib
|
|
from pm.lock import Facts, Lockfile
|
|
from pm.store import tree_digest
|
|
|
|
target, relative, body = _node_archive(dl_server)
|
|
stages, downloads = [], []
|
|
|
|
def progress(*args):
|
|
stages.append(args)
|
|
return object() # Notifications never serialize caller-owned return values.
|
|
|
|
if operation == "stage_only":
|
|
entry = client.stage_only("node", target, progress=progress)
|
|
assert isinstance(entry, Path)
|
|
assert not paths.facts_path().exists()
|
|
else:
|
|
base = {"PATH": "/caller/bin", "CALLER": "kept", "PYTHONPATH": "/caller/dependencies"}
|
|
runner = client.ensure("node", explicit=True, base_env=base,
|
|
progress=lambda *args: stages.append(args),
|
|
download_progress=lambda *args: downloads.append(args))
|
|
fact = Facts(paths.facts_path()).get("node")
|
|
entry = paths.store_root() / fact["entry"]
|
|
assert runner.env["CALLER"] == "kept"
|
|
assert runner.env["PYTHONPATH"] == base["PYTHONPATH"]
|
|
assert runner.env["PATH"].endswith(base["PATH"])
|
|
assert downloads and downloads[-1][0] == downloads[-1][1]
|
|
assert all(isinstance(row, tuple) for rows in downloads[-1][2].values() for row in rows)
|
|
assert (entry / relative).read_bytes() == body
|
|
assert stages and stages[0][0] == "download"
|
|
|
|
def install():
|
|
if operation == "stage_only":
|
|
return client.stage_only("node", target)
|
|
client.ensure("node", explicit=True)
|
|
return entry
|
|
|
|
first_tree = tree_digest(entry)
|
|
RangeHandler.payloads.clear()
|
|
assert install() == entry
|
|
assert tree_digest(entry) == first_tree
|
|
_, _, replacement = _node_archive(dl_server, b"#!/bin/sh\nexit 0\n# repinned\n")
|
|
assert install() == entry
|
|
assert (entry / relative).read_bytes() == replacement
|
|
assert not list(paths.store_root().glob("fetch-*"))
|
|
good_tree = tree_digest(entry)
|
|
previous_facts = paths.facts_path().read_bytes() if paths.facts_path().exists() else None
|
|
lock = Lockfile(paths.lockfile_path())
|
|
RangeHandler.payloads["/node.zip"] = b"not an archive"
|
|
lock.set_pin("node", "1", {target: {"url": url(dl_server, "/node.zip"),
|
|
"sha256": hashlib.sha256(b"not an archive").hexdigest()}})
|
|
lock.save()
|
|
with pytest.raises(RuntimeError if operation == "stage_only" else InstallError, match="zip"):
|
|
install()
|
|
assert tree_digest(entry) == good_tree
|
|
if operation == "stage_only":
|
|
assert not paths.facts_path().exists(), "foreign staging cannot publish host identity"
|
|
else:
|
|
assert paths.facts_path().read_bytes() == previous_facts
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
@pytest.mark.parametrize("from_progress", [False, True])
|
|
def test_pause_event_reaches_running_worker_without_hanging(client, dl_server, monkeypatch, from_progress):
|
|
import threading
|
|
from pm.downloader import DownloadPaused
|
|
from pm.lock import Facts
|
|
|
|
_node_archive(dl_server, b"#!/bin/sh\nexit 0\n#" + b"x" * (8 << 20))
|
|
RangeHandler.slow_per_chunk = 0.03
|
|
pause, transferring = threading.Event(), threading.Event()
|
|
original = RangeHandler.do_GET
|
|
|
|
def get(handler):
|
|
if handler.headers.get("Range") not in (None, "bytes=0-0"):
|
|
transferring.set()
|
|
original(handler)
|
|
|
|
monkeypatch.setattr(RangeHandler, "do_GET", get)
|
|
|
|
def cancel():
|
|
assert transferring.wait(10), "worker never began transferring"
|
|
pause.set()
|
|
|
|
thread = None
|
|
progress = None
|
|
if from_progress:
|
|
def progress(stage, done, total, label):
|
|
if stage == "download" and done:
|
|
pause.set()
|
|
else:
|
|
thread = threading.Thread(target=cancel)
|
|
thread.start()
|
|
try:
|
|
with pytest.raises(DownloadPaused):
|
|
client.ensure("node", explicit=True, progress=progress, pause_event=pause)
|
|
finally:
|
|
if thread is not None:
|
|
thread.join(timeout=15)
|
|
assert not thread.is_alive()
|
|
assert Facts(paths.facts_path()).get("node") is None
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
def test_installed_tool_needs_no_pm_runtime(client, dl_server, monkeypatch):
|
|
_node_archive(dl_server)
|
|
client.ensure("node", explicit=True)
|
|
monkeypatch.setattr("pm.runtime.runtime_python", lambda: pytest.fail("hot path bootstrapped PM"))
|
|
assert client.ensure("node", base_env={"PATH": "caller"}).env["PATH"].endswith("caller")
|
|
|
|
|
|
def test_environment_probe_needs_no_pm_runtime(client, monkeypatch, tmp_path):
|
|
from pm import environment_python, python_tool
|
|
monkeypatch.setattr("pm.runtime.runtime_python", lambda: pytest.fail("probe bootstrapped PM"))
|
|
root = tmp_path / "not-created"
|
|
assert environment_python("test", root=root) is None
|
|
assert python_tool("test", "test", root=root) is None
|
|
assert not root.exists()
|
|
|
|
|
|
def test_worker_receipt_is_exact_even_if_latest_is_replaced(client, tmp_path, monkeypatch):
|
|
import json
|
|
from pm import receipt
|
|
|
|
_current_environment(tmp_path, monkeypatch, [])
|
|
original_accept = receipt.accept_worker_receipt
|
|
received = []
|
|
|
|
def accept(data, update_id):
|
|
# Simulate a different process publishing after this worker completes.
|
|
point = tmp_path / "home" / "logs" / "update_receipts" / "latest.json"
|
|
point.write_text(json.dumps({"update_id": "unrelated", "outcome": "failed"}))
|
|
received.append(data)
|
|
original_accept(data, update_id)
|
|
|
|
monkeypatch.setattr(receipt, "accept_worker_receipt", accept)
|
|
with receipt.worker_context("my-update"):
|
|
client.sync_venv([], explicit=True, plugins=Members([]))
|
|
result = receipt.last_for_update("my-update", consume=True)
|
|
assert received and result == received[0]
|
|
assert result["update_id"] == "my-update" and result["outcome"] == "ok"
|
|
|
|
|
|
def _patch_worker_apply(client, monkeypatch, isolated_python, body):
|
|
"""Fault injection at the build boundary, not an alternate transport/engine."""
|
|
import textwrap
|
|
|
|
worker = Path(client.__file__).with_name("worker.py")
|
|
script = (
|
|
"import os, runpy, sys\n"
|
|
f"sys.path.insert(0, {str(worker.parent.parent)!r})\n"
|
|
"from pm.packages import Venv\n"
|
|
"from pm.workspace import ResolutionConflict\n"
|
|
"def apply(self, *args, **kwargs):\n"
|
|
+ textwrap.indent(body, " ") + "\n"
|
|
"Venv.apply = apply\n"
|
|
f"runpy.run_path({str(worker)!r}, run_name='__main__')\n"
|
|
)
|
|
monkeypatch.setattr(client, "runtime_command", lambda path, **kwargs: [str(isolated_python), "-I", "-B", "-c", script])
|
|
|
|
|
|
def test_resolution_conflict_survives_worker_and_receipt(client, tmp_path, monkeypatch, isolated_python, capfd):
|
|
from pm import receipt
|
|
from pm.workspace import ResolutionConflict
|
|
|
|
repo = _current_environment(tmp_path, monkeypatch, [])
|
|
(repo / "uv.lock").write_text("version = 2\n")
|
|
_patch_worker_apply(client, monkeypatch, isolated_python,
|
|
"print('engine stdout', flush=True)\n"
|
|
"os.write(1, b'native stdout\\n')\n"
|
|
"raise ResolutionConflict('venv', 'impossible union', 'change member')")
|
|
with receipt.worker_context("conflict-update"):
|
|
with pytest.raises(ResolutionConflict) as caught:
|
|
client.sync_venv([], explicit=True, plugins=Members([]))
|
|
result = receipt.last_for_update("conflict-update", consume=True)
|
|
assert (caught.value.package, caught.value.cause, caught.value.remedy) == (
|
|
"venv", "impossible union", "change member")
|
|
assert result["outcome"] == "failed"
|
|
assert "engine stdout" in capfd.readouterr().err
|
|
|
|
|
|
def test_failed_facts_write_restores_exact_config_before_reporting(client, tmp_path, monkeypatch, isolated_python):
|
|
from tests.pm._fixtures import worker_toolchain
|
|
from pm.environments import install_state_dir
|
|
|
|
repo = _current_environment(tmp_path, monkeypatch, [])
|
|
home = tmp_path / "home"
|
|
config = home / "config.yaml"
|
|
config.write_text("# preserve me\nplugins: {enabled: [old]}\n")
|
|
previous = config.read_bytes()
|
|
facts = (install_state_dir(repo) / "facts.json").read_bytes()
|
|
(repo / "uv.lock").write_text("version = 2\n")
|
|
worker_toolchain(client, monkeypatch, isolated_python,
|
|
"from pm.packages import Venv\nfrom pm.lock import Facts\n"
|
|
"Venv.apply = lambda *args, **kwargs: {}\n"
|
|
"def fail(*args, **kwargs):\n"
|
|
f" assert b'new' in Path({str(config)!r}).read_bytes()\n"
|
|
" raise OSError('facts disk full')\nFacts.record_state = fail\n")
|
|
with pytest.raises(OSError, match="facts disk full"):
|
|
client.sync_venv(explicit=True, plugins=Selection({"home": str(home), "enabled": ["new"], "disabled": []}))
|
|
assert config.read_bytes() == previous
|
|
assert (install_state_dir(repo) / "facts.json").read_bytes() == facts
|
|
assert not (install_state_dir(repo) / "publication.json").exists()
|
|
|
|
|
|
def test_invalid_arguments_keep_the_engine_exception_type(client):
|
|
with pytest.raises(ValueError, match="repair restores"):
|
|
client.sync_venv([], repair=True)
|
|
with pytest.raises(ValueError, match="environment name"):
|
|
client.ensure_environment("../escape", ["example==1"], explicit=True)
|
|
with pytest.raises(KeyError):
|
|
client.ensure("no-such-package", explicit=True)
|
|
|
|
|
|
def test_worker_death_reports_transport_failure(client, monkeypatch, isolated_python):
|
|
monkeypatch.setattr(client, "runtime_command", lambda path, **kwargs: [str(isolated_python), "-I", "-c", "import os; os._exit(7)"])
|
|
with pytest.raises(InstallError, match="worker.*result"):
|
|
client.ensure("node", explicit=True)
|
|
|
|
|
|
def test_foreign_checkout_sync_uses_its_own_pm_generation(client, tmp_path, monkeypatch, isolated_python):
|
|
from pm import venv_is_current
|
|
from pm.environments import selected_venv, runtime_facts_path
|
|
|
|
from tests.pm._fixtures import worker_toolchain
|
|
worker_toolchain(client, monkeypatch, isolated_python)
|
|
foreign = tmp_path / "other checkout"
|
|
foreign.mkdir()
|
|
(foreign / "pyproject.toml").write_text(
|
|
'[project]\nname="foreign-proof"\nversion="1"\nrequires-python=">=3.11"\n'
|
|
'[tool.uv]\npackage=false\n', encoding="utf-8")
|
|
client.lock_project(foreign, offline=True, explicit=True)
|
|
assert not venv_is_current(project_root=foreign)
|
|
original_root = paths.repo_root()
|
|
client.sync_venv([], project_root=foreign, plugins=Members([]), explicit=True)
|
|
selected = selected_venv(foreign)
|
|
assert selected != foreign / "venv"
|
|
assert selected.is_relative_to(runtime_facts_path(foreign).parent)
|
|
assert runtime_facts_path(foreign).is_file()
|
|
assert venv_is_current(project_root=foreign)
|
|
assert paths.repo_root() == original_root
|
|
assert not runtime_facts_path(original_root).exists()
|
|
|
|
|
|
def test_cold_manager_build_does_not_bootstrap_a_worker(client, tmp_path, monkeypatch, locked_wheelhouse):
|
|
import pm
|
|
|
|
wheels, _ = locked_wheelhouse
|
|
|
|
uv = shutil.which("uv")
|
|
assert uv
|
|
monkeypatch.setattr(client, "runtime_command", lambda *a, **kw: pytest.fail("manager build requested itself"))
|
|
monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: (Path(uv), Path(sys.executable))
|
|
if kwargs == {"realize": False} else pytest.fail("bootstrap tried to install tools"))
|
|
project = Path(__file__).resolve().parents[2] / "pm"
|
|
python = pm.stage_manager_runtime(python=Path(sys.executable), destination=tmp_path / "manager",
|
|
project=project, wheelhouse=wheels, offline=True)
|
|
result = subprocess.run([str(python), "-I", "-c", "import packaging, tomli_w, truststore; from ruamel.yaml import YAML"],
|
|
capture_output=True, text=True, timeout=30)
|
|
assert result.returncode == 0, result.stderr
|
|
with pytest.raises(FileExistsError):
|
|
pm.stage_manager_runtime(python=Path(sys.executable), destination=python.parent.parent)
|
|
|
|
|
|
def test_worker_side_environment_reuses_and_keeps_selection_on_failed_tool(client, tmp_path, monkeypatch, isolated_python):
|
|
import zipfile
|
|
from pm import environment_python, python_tool
|
|
|
|
from tests.pm._fixtures import worker_toolchain
|
|
worker_toolchain(client, monkeypatch, isolated_python)
|
|
wheel = _wheel(tmp_path, "side_dep")
|
|
with zipfile.ZipFile(wheel, "a") as archive:
|
|
archive.writestr("side_dep/cli.py", "def main():\n print('real side dependency')\n")
|
|
archive.writestr("side_dep-1.0.dist-info/entry_points.txt", "[console_scripts]\nside-proof = side_dep.cli:main\n")
|
|
requirements = [f"side-dep @ {wheel.as_uri()}"]
|
|
root = tmp_path / "side environment"
|
|
assert environment_python("proof", root=root) is None
|
|
executable = client.ensure_python_tool("proof", requirements, "side-proof", root=root, explicit=True)
|
|
assert python_tool("proof", "side-proof", root=root) == executable
|
|
result = subprocess.run([str(executable)], capture_output=True, text=True, timeout=30)
|
|
assert result.returncode == 0, result.stderr
|
|
assert result.stdout.strip() == "real side dependency"
|
|
selected = environment_python("proof", root=root)
|
|
selection = (root / "active.json").read_bytes()
|
|
generations = set(root.glob("gen-*"))
|
|
assert client.ensure_environment("proof", requirements, root=root) == selected
|
|
assert set(root.glob("gen-*")) == generations
|
|
with pytest.raises(InstallError, match="do not provide"):
|
|
client.ensure_python_tool("proof", requirements, "missing-command", root=root, explicit=True)
|
|
assert (root / "active.json").read_bytes() == selection
|
|
assert set(root.glob("gen-*")) == generations
|
|
assert python_tool("proof", "side-proof", root=root) == executable
|
|
monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1")
|
|
with pytest.raises(InstallError, match="lazy installs are disabled"):
|
|
client.ensure_environment("proof", ["absent-dependency==0"], root=root)
|
|
assert (root / "active.json").read_bytes() == selection
|
|
|
|
|
|
@pytest.mark.parametrize("operation", ["activate", "stage_manager_runtime"])
|
|
@pytest.mark.parametrize("route", ["client", "wire"])
|
|
def test_unknown_worker_operation_is_not_dispatched(client, monkeypatch, tmp_path, isolated_python, operation, route):
|
|
import json
|
|
|
|
arguments = {"destination": str(tmp_path / "unused")}
|
|
if route == "client":
|
|
monkeypatch.setattr(client, "runtime_command", lambda *a, **kw: pytest.fail("unsupported operation acquired PM"))
|
|
with pytest.raises(KeyError, match=operation):
|
|
client._request(operation, arguments)
|
|
else:
|
|
request = {"id": "unsupported", "operation": operation, "arguments": arguments,
|
|
"callbacks": [], "packages": [],
|
|
"context": {"repo": str(paths.repo_root()), "lockfile": str(paths.lockfile_path())}}
|
|
worker = Path(client.__file__).with_name("worker.py")
|
|
result = subprocess.run(client.runtime_command(worker), input=json.dumps(request) + "\n",
|
|
capture_output=True, text=True, encoding="utf-8", timeout=30,
|
|
env=client.runtime_environment())
|
|
assert result.returncode == 0, result.stderr
|
|
response = json.loads(result.stdout)
|
|
assert response["error"]["type"] == "KeyError", response
|
|
assert operation in response["error"]["message"]
|
|
assert not paths.facts_path().exists()
|