uv treats `foo_bar` and `foo-bar` as the same extra (PEP 685), so an exact comparison would drop a recorded extra whose spelling differs from its declaration. Compare normalized names; the recorded spelling is still what reaches uv. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
501 lines
24 KiB
Python
501 lines
24 KiB
Python
"""Source-update launch completion must publish a real PM generation.
|
|
|
|
Only tool acquisition is substituted: the isolated worker uses the test host's
|
|
uv/Python. Currency checks, resolution, installation, validation, facts and
|
|
selection all run through production PM. The shared worker fixture stages PM's
|
|
small locked dependency graph; the application project needs no downloads.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
import pm
|
|
from hermes_cli import venv_sync
|
|
from pm.environments import install_state_dir, runtime_facts_path, selected_venv, site_packages
|
|
from pm import paths
|
|
from pm.lock import Facts
|
|
from pm.package import InstallError
|
|
from tests.pm._fixtures import isolated_python # noqa: F401
|
|
|
|
# Spawns children with a home it builds itself; the parent's must stay real.
|
|
pytestmark = pytest.mark.real_machine_home
|
|
|
|
|
|
@pytest.fixture
|
|
def source_launch(tmp_path, monkeypatch, isolated_python):
|
|
client = importlib.import_module("pm.client")
|
|
engine = importlib.import_module("pm.install")
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
|
|
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "store"))
|
|
monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False)
|
|
monkeypatch.setattr(paths, "lockfile_path", lambda: tmp_path / "tool-lock.json")
|
|
|
|
uv = shutil.which("uv")
|
|
assert uv, "source launch integration requires real uv"
|
|
worker = Path(client.__file__).with_name("worker.py")
|
|
worker_code = (
|
|
"import runpy, sys\n"
|
|
"from pathlib import Path\n"
|
|
f"sys.path.insert(0, {str(worker.parent.parent)!r})\n"
|
|
"import pm._uv\n"
|
|
f"pm._uv._toolchain = lambda **kwargs: (Path({uv!r}), Path({sys.executable!r}))\n"
|
|
f"runpy.run_path({str(worker)!r}, run_name='__main__')\n"
|
|
)
|
|
command = [str(isolated_python), "-I", "-B", "-c", worker_code]
|
|
monkeypatch.setattr(client, "runtime_command", lambda *args, **kwargs: command)
|
|
monkeypatch.setattr(engine, "sync_venv", lambda *args, **kwargs: pytest.fail("sync escaped worker isolation"))
|
|
|
|
root = tmp_path / "source with spaces"
|
|
root.mkdir()
|
|
(root / ".git").mkdir()
|
|
(root / "install-stamp.json").write_text(
|
|
json.dumps({"updateMechanism": "self"}), encoding="utf-8",
|
|
)
|
|
# The startup heal hands the shared completion tail (launchers, products,
|
|
# maintenance) to the checkout's own hermes_cli/source_completion.py. This
|
|
# source slice has no products; record the hand-off instead of running it.
|
|
(root / "hermes_cli").mkdir()
|
|
(root / "hermes_cli" / "source_completion.py").write_text(
|
|
"import json, sys\n"
|
|
f"open({str(tmp_path / 'completion-calls')!r}, 'a').write(json.dumps(sys.argv[1:]) + '\\n')\n",
|
|
encoding="utf-8",
|
|
)
|
|
(root / "pyproject.toml").write_text(
|
|
'[project]\nname = "launch-proof"\nversion = "1"\nrequires-python = ">=3.11"\n'
|
|
'[project.optional-dependencies]\nall = []\nlaunch-extra = []\n'
|
|
'[tool.uv]\npackage = false\nno-index = true\noffline = true\n', encoding="utf-8",
|
|
)
|
|
pm.lock_project(root, offline=True, explicit=True)
|
|
|
|
# Acquisition uses the real host interpreter; publish its installed fact
|
|
# through PM too. Unrecorded store bytes are deliberately not launchable.
|
|
from pm.store import current_target, tree_digest
|
|
|
|
store = paths.store_root()
|
|
entry = store / "python-test"
|
|
store_python = entry / "bin" / "python3"
|
|
store_python.parent.mkdir(parents=True)
|
|
# A Nix wrapper resets sys.executable; use the underlying binary instead.
|
|
store_python.symlink_to(sys._base_executable)
|
|
Facts(paths.facts_path()).record(
|
|
"python", "test", entry.name, {"PATH": [str(store_python.parent)]}, store,
|
|
target=current_target(), digest=tree_digest(entry),
|
|
)
|
|
return root, store_python, command
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
@pytest.mark.parametrize("update", ["launch", "sync", "pm-update"])
|
|
def test_source_python_pin_update_survives_real_gc(source_launch, tmp_path, monkeypatch, update):
|
|
from hermes_cli import _launchers
|
|
from pm.cli import cmd_gc
|
|
from pm.lock import Lockfile
|
|
from pm.store import current_target, tree_digest
|
|
from tests.hermes_cli.test_source_launcher_publication import BOOT_FILES
|
|
|
|
root, old_python, _ = source_launch
|
|
repository = Path(__file__).resolve().parents[2]
|
|
for relative in BOOT_FILES:
|
|
destination = root / relative
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(repository / relative, destination)
|
|
(root / "source_probe.py").write_text(
|
|
"import json, sys, selected_probe\n"
|
|
"print(json.dumps({'executable': sys.executable, 'value': selected_probe.VALUE}))\n",
|
|
encoding="utf-8",
|
|
)
|
|
pin = Lockfile(paths.lockfile_path())
|
|
pin.set_pin("python", "A", {})
|
|
pin.save()
|
|
pm.sync_venv(explicit=True, project_root=root)
|
|
# Initial installation is allowed to publish. No explicit writer is called
|
|
# after replacement: the source-update owner must refresh this same command.
|
|
_launchers.ensure_install_launchers(root, root / ".hermes" / "bin")
|
|
command = _launchers.installation_command(root, module="source_probe")
|
|
(site_packages(selected_venv(root)) / "selected_probe.py").write_text("VALUE = 'A'\n")
|
|
child_env = {**os.environ, "HERMES_DISABLE_LAZY_INSTALLS": "1"}
|
|
before = subprocess.run(command, env=child_env, capture_output=True, text=True, timeout=30)
|
|
assert before.returncode == 0, before.stderr
|
|
assert json.loads(before.stdout)["executable"] == str(old_python)
|
|
|
|
# Only acquisition is substituted. This is a new real interpreter entry,
|
|
# a changed tool pin and a real Facts publication, not a launcher rewrite.
|
|
store = paths.store_root()
|
|
new_python = store / "python-B" / "bin" / "python3"
|
|
new_python.parent.mkdir(parents=True)
|
|
new_python.symlink_to(sys._base_executable)
|
|
Facts(paths.facts_path()).record(
|
|
"python", "B", "python-B", {"PATH": [str(new_python.parent)]}, store,
|
|
target=current_target(), digest=tree_digest(new_python.parent.parent),
|
|
)
|
|
if update != "pm-update":
|
|
pin.set_pin("python", "B", {})
|
|
pin.save()
|
|
assert not pm.venv_is_current(project_root=root)
|
|
previous = selected_venv(root)
|
|
if update == "launch":
|
|
assert venv_sync.prepare_launch(root, []) == new_python
|
|
# The heal finished the whole tail, with update wording, through the checkout's own completion.
|
|
calls = [json.loads(line) for line in (tmp_path / "completion-calls").read_text().splitlines()]
|
|
assert calls == [["--source", str(root), "--finish-update"]]
|
|
elif update == "sync":
|
|
# This is the PM sync -> launcher publication sequence now split across
|
|
# update_completion._prepare and _complete_selected. The former checkout
|
|
# case adds no distinct PM/GC coverage; the fresh-interpreter handoff is
|
|
# exercised separately in test_update_completion_process.py.
|
|
assert venv_sync.sync(root) == {"state": "synced", "ok": True}
|
|
else:
|
|
from types import SimpleNamespace
|
|
from pm import cli
|
|
from pm.update import Resolved
|
|
|
|
monkeypatch.setattr(paths, "repo_root", lambda: root)
|
|
monkeypatch.setattr(cli, "repo_root", lambda: root)
|
|
# The latest release and artifact acquisition are fixture inputs; the
|
|
# CLI must still pin, ensure, synchronize and publish through its owners.
|
|
monkeypatch.setattr(cli, "resolve_package", lambda *a, **k: Resolved("python", "A", "semver", "B"))
|
|
monkeypatch.setattr(cli, "_pin_artifacts", lambda *a: {})
|
|
monkeypatch.setattr(importlib.import_module("pm.install"), "sync_venv", pm.sync_venv)
|
|
assert cli.cmd_update(SimpleNamespace(names=["python"], target=None, check=False, uv=False, npm=False, termux=False)) == 0
|
|
assert Lockfile(paths.lockfile_path()).version("python") == "B"
|
|
assert pm.venv_is_current(project_root=root)
|
|
assert selected_venv(root) != previous
|
|
(site_packages(selected_venv(root)) / "selected_probe.py").write_text("VALUE = 'B'\n")
|
|
monkeypatch.setattr(paths, "repo_root", lambda: root)
|
|
assert cmd_gc(None) == 0
|
|
assert not old_python.exists(), "real PM GC did not remove the superseded Python"
|
|
assert new_python.is_file()
|
|
after = subprocess.run(command, env=child_env, capture_output=True, text=True, timeout=30)
|
|
assert after.returncode == 0, after.stderr
|
|
assert json.loads(after.stdout) == {"executable": str(new_python), "value": "B"}
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
def test_launcher_publication_failure_retries_without_rebuilding_dependencies(source_launch, monkeypatch):
|
|
from hermes_cli import _launchers
|
|
|
|
root, store_python, _ = source_launch
|
|
with monkeypatch.context() as failed_publication:
|
|
failed_publication.setattr(_launchers, "ensure_install_launchers", lambda *a: [])
|
|
result = venv_sync.sync(root)
|
|
assert not result["ok"] and "launcher publication failed" in result["detail"]
|
|
assert pm.venv_is_current(project_root=root)
|
|
committed = runtime_facts_path(root).read_bytes()
|
|
assert venv_sync.prepare_launch(root, []) == store_python
|
|
assert runtime_facts_path(root).read_bytes() == committed
|
|
assert (root / ".hermes" / "bin" / "hermes").is_file()
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
@pytest.mark.parametrize("checkout", [False, True])
|
|
def test_source_publication_leaves_external_install_launchers_alone(source_launch, checkout):
|
|
root, _, _ = source_launch
|
|
(root / "install-stamp.json").write_text(
|
|
json.dumps({"updateMechanism": "external", "distribution": "nix"}), encoding="utf-8",
|
|
)
|
|
if not checkout:
|
|
(root / ".git").rmdir()
|
|
launcher = root / ".hermes" / "bin" / "hermes"
|
|
launcher.parent.mkdir(parents=True)
|
|
launcher.write_text("externally owned launcher\n", encoding="utf-8")
|
|
assert venv_sync.sync(root)["ok"]
|
|
assert launcher.read_text(encoding="utf-8") == "externally owned launcher\n"
|
|
|
|
|
|
def _fact(root):
|
|
fact = Facts(runtime_facts_path(root), strict=True).get("venv")
|
|
assert fact is not None
|
|
selected = selected_venv(root)
|
|
assert Path(fact["environment"]) == selected
|
|
assert selected.is_relative_to(install_state_dir(root) / "environments")
|
|
assert (selected / "pyvenv.cfg").is_file()
|
|
assert Path(fact["resolved_lock"]).is_file()
|
|
probe = subprocess.run(
|
|
[str(selected / "bin" / "python"), "-I", "-c", "import json,sys; print(json.dumps(sys.prefix))"],
|
|
capture_output=True, text=True, timeout=30,
|
|
)
|
|
assert probe.returncode == 0, probe.stderr
|
|
assert Path(json.loads(probe.stdout)) == selected
|
|
return fact
|
|
|
|
|
|
def _receipts(tmp_path):
|
|
return set((tmp_path / "home" / "logs" / "update_receipts").glob("pm_*.json"))
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
def test_launch_without_marker_publishes_then_skips_and_rebuilds_on_lock_change(source_launch, tmp_path):
|
|
root, store_python, _ = source_launch
|
|
assert not runtime_facts_path(root).exists()
|
|
assert not (root / ".update-incomplete").exists()
|
|
assert not pm.venv_is_current(project_root=root)
|
|
|
|
assert venv_sync.prepare_launch(root, []) == store_python
|
|
first = _fact(root)
|
|
assert first["extras"] == ["all"]
|
|
assert pm.venv_is_current(project_root=root)
|
|
facts_bytes = runtime_facts_path(root).read_bytes()
|
|
generations = set((install_state_dir(root) / "environments").iterdir())
|
|
receipts = _receipts(tmp_path)
|
|
assert receipts
|
|
|
|
# A caller still in its old interpreter must re-exec, but must not sync again.
|
|
assert venv_sync.prepare_launch(root, []) == store_python
|
|
assert runtime_facts_path(root).read_bytes() == facts_bytes
|
|
assert set((install_state_dir(root) / "environments").iterdir()) == generations
|
|
assert _receipts(tmp_path) == receipts, "current launch performed another sync"
|
|
|
|
lock = root / "uv.lock"
|
|
lock.write_bytes(lock.read_bytes() + b"\n# source update changes the committed lock\n")
|
|
assert not pm.venv_is_current(project_root=root)
|
|
assert venv_sync.prepare_launch(root, []) == store_python
|
|
rebuilt = _fact(root)
|
|
assert rebuilt["stamp"] != first["stamp"]
|
|
assert rebuilt["environment"] != first["environment"]
|
|
assert rebuilt["extras"] == first["extras"]
|
|
assert Path(first["environment"]).is_dir()
|
|
assert pm.venv_is_current(project_root=root)
|
|
assert not (root / ".update-incomplete").exists()
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
def test_process_spawned_by_the_update_commits_dependencies_but_not_the_tail(source_launch, tmp_path):
|
|
"""A process an update spawns before its dependencies are current (its restarted gateway)
|
|
must not boot on a tree built for another interpreter; it syncs, but leaves the tail alone."""
|
|
import time
|
|
from hermes_cli.update_lock import update_marker_path
|
|
from pm.environments import committed_venv
|
|
|
|
root, store_python, _ = source_launch
|
|
marker = update_marker_path()
|
|
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
marker.write_text(f"{os.getppid()}\n{int(time.time())}\n", encoding="utf-8") # the updater is our ancestor
|
|
assert committed_venv(root) is None
|
|
|
|
assert venv_sync.prepare_launch(root, []) == store_python
|
|
assert committed_venv(root) == Path(_fact(root)["environment"])
|
|
assert not (tmp_path / "completion-calls").exists(), "the tail is the updater's, not its child's"
|
|
assert not venv_sync.completion_pending_path(root).exists()
|
|
|
|
facts_bytes, receipts = runtime_facts_path(root).read_bytes(), _receipts(tmp_path)
|
|
venv_sync.prepare_launch(root, [])
|
|
assert runtime_facts_path(root).read_bytes() == facts_bytes
|
|
assert _receipts(tmp_path) == receipts, "a committed child synced again under the updater's claim"
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
def test_failed_real_sync_preserves_previous_selection_and_retries(source_launch, tmp_path):
|
|
root, store_python, _ = source_launch
|
|
# Established PM installs must retain their selection, not gain legacy [all].
|
|
pm.sync_venv(["launch-extra"], explicit=True, project_root=root)
|
|
previous = _fact(root)
|
|
facts_bytes = runtime_facts_path(root).read_bytes()
|
|
generations = set((install_state_dir(root) / "environments").iterdir())
|
|
lock = root / "uv.lock"
|
|
valid_lock = lock.read_bytes()
|
|
lock.write_text("version = 1\nnot valid TOML\n", encoding="utf-8")
|
|
markers = [root / name for name in (".update-incomplete", ".lazy-refresh-incomplete")]
|
|
for marker in markers:
|
|
marker.write_text("legacy pending install", encoding="utf-8")
|
|
|
|
for _ in range(2):
|
|
receipts = _receipts(tmp_path)
|
|
with pytest.raises(InstallError, match="uv sync exited"):
|
|
venv_sync.prepare_launch(root, [])
|
|
failed, = _receipts(tmp_path) - receipts
|
|
assert json.loads(failed.read_text(encoding="utf-8"))["outcome"] == "failed"
|
|
assert runtime_facts_path(root).read_bytes() == facts_bytes
|
|
assert _fact(root) == previous
|
|
assert set((install_state_dir(root) / "environments").iterdir()) == generations
|
|
assert not pm.venv_is_current(project_root=root)
|
|
assert all(marker.read_text(encoding="utf-8") == "legacy pending install" for marker in markers)
|
|
|
|
lock.write_bytes(valid_lock + b"\n# corrected source update\n")
|
|
assert venv_sync.prepare_launch(root, []) == store_python
|
|
rebuilt = _fact(root)
|
|
assert rebuilt["environment"] != previous["environment"]
|
|
assert rebuilt["extras"] == ["launch-extra"]
|
|
assert pm.venv_is_current(project_root=root)
|
|
assert not any(marker.exists() for marker in markers)
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
def test_source_update_that_removes_a_recorded_extra_still_syncs(source_launch):
|
|
"""A dropped extra (hindsight, 73c598e319) must not brick every later sync."""
|
|
root, store_python, _ = source_launch
|
|
pm.sync_venv(["all", "launch-extra"], explicit=True, project_root=root)
|
|
assert _fact(root)["extras"] == ["all", "launch-extra"]
|
|
|
|
pyproject = root / "pyproject.toml"
|
|
pyproject.write_text(pyproject.read_text(encoding="utf-8").replace("launch-extra = []\n", ""),
|
|
encoding="utf-8")
|
|
pm.lock_project(root, offline=True, explicit=True)
|
|
assert not pm.venv_is_current(project_root=root)
|
|
|
|
assert venv_sync.prepare_launch(root, []) == store_python
|
|
assert _fact(root)["extras"] == ["all"]
|
|
assert pm.venv_is_current(project_root=root)
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
def test_recorded_extra_spelled_differently_from_its_declaration_survives(source_launch):
|
|
"""uv matches extras by PEP 685 name; `launch_extra` is the declared `launch-extra`."""
|
|
root, store_python, _ = source_launch
|
|
pm.sync_venv(["all", "launch_extra"], explicit=True, project_root=root)
|
|
assert _fact(root)["extras"] == ["all", "launch_extra"]
|
|
|
|
lock = root / "uv.lock"
|
|
lock.write_bytes(lock.read_bytes() + b"\n# source update changes the committed lock\n")
|
|
assert venv_sync.prepare_launch(root, []) == store_python
|
|
assert _fact(root)["extras"] == ["all", "launch_extra"]
|
|
assert pm.venv_is_current(project_root=root)
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
@pytest.mark.parametrize("mode", ["script", "module", "command"])
|
|
def test_real_bootstrap_reexecs_before_app_imports(source_launch, tmp_path, isolated_python, mode):
|
|
root, store_python, worker_command = source_launch
|
|
repository = Path(__file__).resolve().parents[2]
|
|
# Copy the real bootstrap so it owns this disposable source install. The
|
|
# other modules remain real checkout imports; only acquisition is injected.
|
|
shutil.copy2(repository / "hermes_bootstrap.py", root / "hermes_bootstrap.py")
|
|
(root / "launch_test_tools.py").write_text(
|
|
"import sys\n"
|
|
"from pathlib import Path\n"
|
|
f"sys.path.insert(1, {str(repository)!r})\n"
|
|
"import pm.client\n"
|
|
"from pm import paths\n"
|
|
f"paths.lockfile_path = lambda: Path({str(tmp_path / 'tool-lock.json')!r})\n"
|
|
f"pm.client.runtime_command = lambda *args, **kwargs: {worker_command!r}\n",
|
|
encoding="utf-8",
|
|
)
|
|
entry = root / "launch_probe.py"
|
|
entry.write_text(
|
|
"import launch_test_tools\n"
|
|
"import hermes_bootstrap\n"
|
|
"import json, sys\n"
|
|
"from pathlib import Path\n"
|
|
"from pm.environments import selected_venv, site_packages\n"
|
|
"from hermes_cli.venv_sync import prepare_launch\n"
|
|
"root = Path(__file__).parent\n"
|
|
"selected = selected_venv(root)\n"
|
|
"print(json.dumps({'executable': sys.executable, 'args': sys.argv[1:],\n"
|
|
" 'site': str(site_packages(selected)), 'path': sys.path,\n"
|
|
" 'module': getattr(__spec__, 'name', None),\n"
|
|
" 'launch_current': prepare_launch(root, sys.argv[1:]) is None}))\n",
|
|
encoding="utf-8",
|
|
)
|
|
args = ["--profile", "name with spaces", "-c", "session"]
|
|
invocation = {
|
|
"script": [str(entry)],
|
|
"module": ["-m", "launch_probe"],
|
|
"command": ["-c", "import launch_probe"],
|
|
}[mode]
|
|
assert not runtime_facts_path(root).exists()
|
|
activated_old = tmp_path / "activated-old-dependencies"
|
|
if mode == "script":
|
|
pm.sync_venv(["all"], explicit=True, project_root=root)
|
|
old_site = site_packages(selected_venv(root))
|
|
# Executable .pth files are real activation hooks. If bootstrap selects
|
|
# the old generation before completing the update, this leaves evidence.
|
|
(old_site / "old_dependency.pth").write_text(
|
|
f"import pathlib; pathlib.Path({str(activated_old)!r}).touch()\n", encoding="utf-8",
|
|
)
|
|
activation_probe = subprocess.run(
|
|
[str(isolated_python), "-I", "-c",
|
|
f"import sys; sys.path.insert(0, {str(repository)!r}); "
|
|
"from pathlib import Path; from pm.environments import activate_dependencies; "
|
|
f"activate_dependencies(Path({str(root)!r}))"],
|
|
capture_output=True, text=True, timeout=30,
|
|
)
|
|
assert activation_probe.returncode == 0, activation_probe.stderr
|
|
assert activated_old.is_file(), "positive control did not execute the old activation hook"
|
|
activated_old.unlink()
|
|
lock = root / "uv.lock"
|
|
lock.write_bytes(lock.read_bytes() + b"\n# update before activation\n")
|
|
if mode == "module":
|
|
# These leftovers must not trigger an immediate second (repair) sync
|
|
# after launch completion, which would validate the full app graph.
|
|
for name in (".update-incomplete", ".lazy-refresh-incomplete"):
|
|
(root / name).write_text("legacy pending install", encoding="utf-8")
|
|
before_receipts = _receipts(tmp_path)
|
|
result = subprocess.run(
|
|
[str(isolated_python), *invocation, *args], cwd=root,
|
|
env=dict(os.environ), capture_output=True, text=True, timeout=60,
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
output = json.loads(result.stdout)
|
|
assert output["executable"] == str(store_python)
|
|
assert output["args"] == args
|
|
assert output["launch_current"] is True
|
|
assert output["module"] == (None if mode == "script" else "launch_probe")
|
|
selected = selected_venv(root)
|
|
assert Path(output["site"]).is_relative_to(selected)
|
|
assert output["site"] in output["path"]
|
|
assert not activated_old.exists(), "old dependencies activated before source-update completion"
|
|
assert _fact(root)["extras"] == ["all"]
|
|
receipt, = _receipts(tmp_path) - before_receipts
|
|
assert json.loads(receipt.read_text(encoding="utf-8"))["outcome"] == "ok"
|
|
assert not (root / ".update-incomplete").exists()
|
|
assert not (root / ".lazy-refresh-incomplete").exists()
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
@pytest.mark.parametrize("argv", [[], ["--version"]])
|
|
def test_failed_launch_completion_degrades_to_a_warning(source_launch, tmp_path, isolated_python, argv):
|
|
"""An update whose dependency sync cannot finish (offline, bad lock) must leave a usable
|
|
CLI on the previous generation with a warning — and a metadata query must not even try."""
|
|
root, store_python, worker_command = source_launch
|
|
repository = Path(__file__).resolve().parents[2]
|
|
shutil.copy2(repository / "hermes_bootstrap.py", root / "hermes_bootstrap.py")
|
|
(root / "launch_test_tools.py").write_text(
|
|
"import sys\n"
|
|
"from pathlib import Path\n"
|
|
f"sys.path.insert(1, {str(repository)!r})\n"
|
|
"import pm.client\n"
|
|
"from pm import paths\n"
|
|
f"paths.lockfile_path = lambda: Path({str(tmp_path / 'tool-lock.json')!r})\n"
|
|
f"pm.client.runtime_command = lambda *args, **kwargs: {worker_command!r}\n",
|
|
encoding="utf-8",
|
|
)
|
|
entry = root / "launch_probe.py"
|
|
entry.write_text(
|
|
"import launch_test_tools\n"
|
|
"import hermes_bootstrap\n"
|
|
"import json, sys\n"
|
|
"print(json.dumps({'executable': sys.executable, 'args': sys.argv[1:]}))\n",
|
|
encoding="utf-8",
|
|
)
|
|
pm.sync_venv(["all"], explicit=True, project_root=root)
|
|
previous = _fact(root)
|
|
lock = root / "uv.lock"
|
|
lock.write_text("version = 1\nnot valid TOML\n", encoding="utf-8")
|
|
assert not pm.venv_is_current(project_root=root)
|
|
before_receipts = _receipts(tmp_path)
|
|
|
|
result = subprocess.run(
|
|
[str(isolated_python), str(entry), *argv], cwd=root,
|
|
env=dict(os.environ), capture_output=True, text=True, timeout=60,
|
|
)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {"executable": str(isolated_python), "args": argv}
|
|
assert _fact(root) == previous
|
|
if argv:
|
|
assert "source-update completion failed" not in result.stderr
|
|
assert _receipts(tmp_path) == before_receipts, "a metadata query attempted a dependency sync"
|
|
else:
|
|
assert "source-update completion failed" in result.stderr
|
|
assert "hermes update" in result.stderr
|
|
|