Unify pinned tool installation and cross-target staging

Use one acquisition, assembly, verification, replacement, rollback and cleanup path. Keep host facts and cross-target markers as concrete recording differences, including interrupted-entry recovery and verified Python copies.

Exercise both routes with real archive servers, pause/resume, multi-archive progress, post-publication failure and killed publishers. Keep native Windows directory-hold coverage gated to its host.
This commit is contained in:
ethernet
2026-09-12 18:57:16 -04:00
parent c9ffa52757
commit da076f3fa4
5 changed files with 229 additions and 169 deletions

View File

@@ -20,17 +20,8 @@ from pm.store import Store, current_target, merge_tree, tree_digest
LOG = logging.getLogger(__name__)
# ``progress(stage, done, total, label)`` — stage is "download" | "unpack",
# label is the archive counter ("1/2") when a package has several. Slow
# lines sit in one stage for minutes, so the byte counters are what prove
# liveness to a UI.
def _artifact_progress(progress, index: int, count: int):
if progress is None:
return None
label = f"{index + 1}/{count}" if count > 1 else ""
return lambda done, total: progress("download", done, total, label)
# ``progress(stage, done, total, label)`` reports download/unpack/verify;
# multi-archive labels follow lockfile order.
def _lockfile() -> Lockfile:
@@ -224,7 +215,7 @@ def _restore_previous_entry(store: Store, entry, previous) -> None:
def _install(
package: Package,
lockfile: Lockfile,
facts: Facts,
facts: Facts | None,
store: Store,
target: str,
progress=None,
@@ -232,7 +223,8 @@ def _install(
download_progress: ProgressFn | None = None,
*,
copy_from: tuple[Facts, Store] | None = None,
) -> None:
) -> Path:
"""Realize one pin. Host installs commit facts; cross-target stages carry a marker."""
version = lockfile.version(package.name)
if version is None:
raise InstallError(
@@ -243,28 +235,41 @@ def _install(
if reason is not None:
raise InstallError(package.name, f"unavailable on {target}: {reason}", "none")
artifacts = lockfile.artifacts(package.name, target)
entry_name = package.store_entry(version, target)
entry = store.entry(entry_name)
if getattr(package, "pin_only", False):
return entry
artifacts = lockfile.artifacts(package.name, target)
pin = json.dumps({"target": target, "sha256": [a["sha256"] for a in artifacts]})
with store.install_lock():
if pause_event is not None and pause_event.is_set():
raise DownloadPaused("install paused")
facts.reload()
entry = store.entry(entry_name)
previous_entry = store.entry(f".previous-{entry_name}")
if facts is not None:
facts.reload()
previous = facts.get(package.name) if facts is not None else None
previous_entry = store.entry(f".previous-{'stage-' if facts is None else ''}{entry_name}")
if previous_entry.exists():
# An interrupted replacement keeps its old bytes outside scratch.
# Facts commit last; only a verified committed replacement wins.
fact = facts.get(package.name)
if fact and fact.get("entry") == entry_name and _entry_verified(package, fact, store, target):
# Facts commit last. Stages have no host-side commit record, so
# an interrupted stage always restores its prior usable bytes.
if (previous and previous.get("entry") == entry_name
and _entry_verified(package, previous, store, target)):
_remove_entry(store, previous_entry.name)
else:
_restore_previous_entry(store, entry, previous_entry)
if facts.installed(
package.name, version, store.root, _identity(lockfile, package.name, target)
) and _entry_verified(package, facts.get(package.name), store, target):
if facts is not None:
current = previous is not None and facts.installed(
package.name, version, store.root, _identity(lockfile, package.name, target)
) and _entry_verified(package, previous, store, target)
else:
try:
recorded = (entry / ".pm-stage-pin.json").read_text(encoding="utf-8")
except OSError:
recorded = None
current = recorded == pin and not package.verify(entry, target)
if current:
_remove_downloads(store, artifacts)
return
return entry
if not artifacts:
raise InstallError(
package.name,
@@ -273,7 +278,6 @@ def _install(
)
with store.scratch() as scratch:
staged = scratch / "tree"
previous = facts.get(package.name)
try:
def tick(done, total, ranges):
if progress is not None:
@@ -318,18 +322,21 @@ def _install(
reason = package.verify(staged, target)
if reason:
raise InstallError(package.name, f"staged entry failed verification: {reason}")
if entry.exists():
if facts is None:
(staged / ".pm-stage-pin.json").write_text(pin, encoding="utf-8")
if entry.exists() or entry.is_symlink():
entry.rename(previous_entry)
try:
store.publish(staged, entry_name)
reason = package.verify(entry, target)
if reason:
raise InstallError(package.name, f"published entry failed verification: {reason}")
facts.record(
package.name, version, entry_name, package.env(entry, target), store.root,
target=target, artifacts=[a["sha256"] for a in artifacts],
digest=tree_digest(entry),
)
if facts is not None:
facts.record(
package.name, version, entry_name, package.env(entry, target), store.root,
target=target, artifacts=[a["sha256"] for a in artifacts],
digest=tree_digest(entry),
)
except BaseException:
if previous_entry.exists():
_restore_previous_entry(store, entry, previous_entry)
@@ -353,91 +360,17 @@ def _install(
else version
)
LOG.info("repair: %s re-realized %s -> %s", package.name, old, new)
return entry
def stage_only(name: str, target: str, progress=None) -> "Path":
"""Cross-target staging: publish the pinned (package, version, target)
entry into the store and return its path. No facts are written and no
Runner is composed -- the staged binaries belong to ANOTHER machine
(e.g. linux-arm64-bionic .debs staged on a glibc CI host); this host's
installed-state must not learn about them. Idempotent: an already
published + verifying entry is returned as-is.
"""
lockfile = _lockfile()
store = _store()
package = get_package(name)
version = lockfile.version(package.name)
if version is None:
raise InstallError(package.name, "not in the lockfile")
reason = package.missing_reason(target)
if reason is not None:
raise InstallError(package.name, f"unavailable on {target}: {reason}")
if getattr(package, "pin_only", False):
# A pure pin (e.g. the termux-docker digest): no bytes, no store
# entry, nothing to verify locally -- the pin IS the artifact.
return store.root / package.store_entry(version, target)
artifacts = lockfile.artifacts(package.name, target)
entry_name = package.store_entry(version, target)
# The stage pin marker (same identity shape as a fact's recorded
# artifacts: target + artifact digests) lets stage_only honor a
# same-version hash repin without any host-side facts: the entry
# belongs to ANOTHER machine, so the marker travels inside the entry.
pin = json.dumps({"target": target, "sha256": [a["sha256"] for a in artifacts]})
with store.install_lock():
entry = store.entry(entry_name)
previous_entry = store.entry(f".previous-stage-{entry_name}")
if previous_entry.exists():
# A killed publisher may have installed only part of the new tree.
_restore_previous_entry(store, entry, previous_entry)
if store.published(entry_name):
marker = entry / ".pm-stage-pin.json"
try:
recorded = marker.read_text(encoding="utf-8")
except OSError:
recorded = None
if not package.verify(entry, target) and recorded == pin:
_remove_downloads(store, artifacts)
return entry
if not artifacts:
raise InstallError(
package.name,
f"no artifact for {target} in the lockfile",
"run `hermes pm lock --bump` for this package",
)
with store.scratch() as scratch:
staged = scratch / "tree"
for index, artifact in enumerate(artifacts):
archive = store.fetch(
artifact["url"], artifact["sha256"], scratch,
progress=_artifact_progress(progress, index, len(artifacts)),
)
if index == 0:
package.unpack(archive, staged, target)
else:
extra = scratch / f"extra-{index}"
package.unpack(archive, extra, target)
merge_tree(extra, staged)
package.stage(store, staged, version, target)
reason = package.verify(staged, target)
if reason:
raise InstallError(package.name, f"staged entry failed verification: {reason}")
(staged / ".pm-stage-pin.json").write_text(pin, encoding="utf-8")
# Keep the old pin usable until the replacement has been verified.
if entry.exists() or entry.is_symlink():
entry.rename(previous_entry)
try:
store.publish(staged, entry_name)
reason = package.verify(entry, target)
if reason:
raise InstallError(package.name, f"published entry failed verification: {reason}")
except BaseException:
if previous_entry.exists():
_restore_previous_entry(store, entry, previous_entry)
raise
if previous_entry.exists():
_remove_entry(store, previous_entry.name)
_remove_downloads(store, artifacts)
return store.entry(entry_name)
def stage_only(
name: str, target: str, progress=None, *,
pause_event: threading.Event | None = None,
download_progress: ProgressFn | None = None,
) -> Path:
"""Realize a cross-target pin without host facts or an executable Runner."""
return _install(get_package(name), _lockfile(), None, _store(), target,
progress=progress, pause_event=pause_event, download_progress=download_progress)
def ensure(
@@ -454,7 +387,7 @@ def ensure(
policy names, so the policy does not apply to them.
``progress(stage, done, total, label)`` reports the slow parts of an
install to a UI; see _artifact_progress.
install to a UI, including ordered multi-archive labels.
"""
if isinstance(get_package(name), StatePackage):
sync_venv(explicit=explicit)

View File

@@ -4,6 +4,8 @@ from __future__ import annotations
import hashlib
import io
import json
from functools import partial
import threading
import zipfile
from pathlib import Path
@@ -13,7 +15,7 @@ import pytest
import pm
from pm import paths, registry
from pm.downloader import DownloadPaused
from pm.ensure import ensure
from pm.ensure import ensure, stage_only
from pm.lock import Facts, Lockfile
from pm.package import Package
from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401
@@ -37,7 +39,14 @@ def isolate_home(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
def test_install_pause_preserves_archives_and_resumes_the_same_pin(tmp_path, monkeypatch, dl_server):
@pytest.fixture(params=["install", "stage"])
def realize(request):
if request.param == "install":
return partial(ensure, explicit=True)
return partial(stage_only, target="linux-arm64-bionic")
def test_install_pause_preserves_archives_and_resumes_the_same_pin(tmp_path, monkeypatch, dl_server, realize):
root = tmp_path / "store"
lock_path = tmp_path / "lock.json"
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
@@ -52,7 +61,7 @@ def test_install_pause_preserves_archives_and_resumes_the_same_pin(tmp_path, mon
RangeHandler.payloads[path] = payload
pins.append({"url": url(dl_server, path), "sha256": hashlib.sha256(payload).hexdigest()})
lock = Lockfile(lock_path)
lock.set_pin(ComponentPackage.name, "1", {pm.current_target(): pins})
lock.set_pin(ComponentPackage.name, "1", {"any": pins})
lock.save()
pause = threading.Event()
@@ -61,7 +70,7 @@ def test_install_pause_preserves_archives_and_resumes_the_same_pin(tmp_path, mon
pause.set()
with pytest.raises(DownloadPaused):
ensure(ComponentPackage.name, explicit=True, progress=progress, pause_event=pause)
realize(ComponentPackage.name, progress=progress, pause_event=pause)
assert Facts(paths.facts_path()).get(ComponentPackage.name) is None
assert list(paths.partials_root().glob("*.ranges"))
first_requests = [request for request in RangeHandler.ranges_seen if request[0] == "/component-0.zip"]
@@ -69,17 +78,22 @@ def test_install_pause_preserves_archives_and_resumes_the_same_pin(tmp_path, mon
assert (root / f"fetch-{pins[0]['sha256']}").is_dir()
pause.clear()
ensure(ComponentPackage.name, explicit=True, pause_event=pause)
result = realize(ComponentPackage.name, pause_event=pause)
fact = Facts(paths.facts_path()).get(ComponentPackage.name)
assert fact["artifacts"] == [pin["sha256"] for pin in pins]
if fact is None:
entry = result
assert json.loads((entry / ".pm-stage-pin.json").read_text())["sha256"] == [pin["sha256"] for pin in pins]
else:
entry = root / fact["entry"]
assert fact["artifacts"] == [pin["sha256"] for pin in pins]
for name, body in contents.items():
assert (root / fact["entry"] / name).read_bytes() == body
assert (entry / name).read_bytes() == body
assert [request for request in RangeHandler.ranges_seen if request[0] == "/component-0.zip"] == first_requests
assert not list(paths.partials_root().glob("*.part"))
assert not list(root.glob("fetch-*"))
def test_install_progress_covers_all_archives_including_cache(tmp_path, monkeypatch, dl_server):
def test_install_progress_covers_all_archives_including_cache(tmp_path, monkeypatch, dl_server, realize):
root = tmp_path / "store"
lock_path = tmp_path / "lock.json"
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
@@ -93,14 +107,14 @@ def test_install_progress_covers_all_archives_including_cache(tmp_path, monkeypa
RangeHandler.payloads[path] = payload
pins.append({"url": url(dl_server, path), "sha256": hashlib.sha256(payload).hexdigest()})
lock = Lockfile(lock_path)
lock.set_pin(ComponentPackage.name, "1", {pm.current_target(): pins})
lock.set_pin(ComponentPackage.name, "1", {"any": pins})
lock.save()
store = pm.Store(root)
with store.scratch() as scratch:
store.fetch(pins[0]["url"], pins[0]["sha256"], scratch)
ticks = []
stages = []
ensure(ComponentPackage.name, explicit=True,
realize(ComponentPackage.name,
progress=lambda *args: stages.append(args),
download_progress=lambda done, total, ranges: ticks.append((done, total, ranges)))
expected = sum(map(len, payloads))

View File

@@ -116,6 +116,42 @@ def _pin(env, name: str, version: str, digest: str) -> None:
# ── item 1: digest-bound facts ────────────────────────────────────────
@pytest.mark.parametrize("route", ["install", "stage"])
def test_realization_reports_ordered_multi_archive_progress(pm_env, monkeypatch, route):
from pm.ensure import ensure, stage_only
env = pm_env
monkeypatch.setattr(registry._packages["faketool"], "flatten", False)
artifacts = []
for name, files in [("tool.tar.gz", {"bin/faketool": "#!tool"}),
("data.tar.gz", {"share/data": "auxiliary"})]:
_, digest = make_tar(env["docroot"], name, files)
artifacts.append({"url": f"{env['base_url']}/{name}", "sha256": digest})
lock = Lockfile(env["lockfile_path"])
lock.set_pin("faketool", "1.0", {"any": artifacts})
lock.save()
events = []
progress = lambda stage, done, total, label: events.append((stage, done, total, label))
if route == "install":
ensure("faketool", explicit=True, base_env={}, progress=progress)
fact = Facts(paths.facts_path()).get("faketool")
entry = paths.store_root() / fact["entry"]
assert fact["artifacts"] == [artifact["sha256"] for artifact in artifacts]
assert fact["digest"] == tree_digest(entry)
assert not (entry / ".pm-stage-pin.json").exists()
else:
entry = stage_only("faketool", "linux-arm64-bionic", progress=progress)
assert json.loads((entry / ".pm-stage-pin.json").read_text()) == {
"target": "linux-arm64-bionic", "sha256": [artifact["sha256"] for artifact in artifacts],
}
assert not paths.facts_path().exists()
assert (entry / "bin/faketool").read_text() == "#!tool"
assert (entry / "share/data").read_text() == "auxiliary"
assert [label for stage, _, _, label in events if stage == "unpack"] == ["1/2", "2/2"]
assert any(stage == "verify" for stage, _, _, _ in events)
assert not list(paths.store_root().glob("fetch-*"))
def test_same_version_different_sha_is_not_installed_and_repaired(pm_env):
"""The witness: same version, different artifact sha. Version/path
matching cannot see this; identity matching must — check() reports
@@ -181,17 +217,27 @@ def test_install_repairs_corrupt_entry_from_verified_archive(pm_env, matching_fa
assert Facts(facts_path).get("faketool")["digest"] == tree_digest(binary.parent.parent)
@pytest.mark.parametrize("replacement", ["invalid", "publish-failure", "interrupted", "facts-failure"])
def test_failed_replacement_preserves_entry_and_facts(pm_env, monkeypatch, replacement):
from pm.ensure import ensure
@pytest.mark.parametrize(("route", "replacement"), [
(route, failure) for route in ("install", "stage")
for failure in ("invalid", "publish-failure", "post-publish-invalid", "interrupted", "facts-failure")
if route == "install" or failure != "facts-failure"
])
def test_failed_replacement_preserves_entry_and_facts(pm_env, monkeypatch, route, replacement):
from functools import partial
from pm.ensure import ensure, stage_only
from pm.package import InstallError
env = pm_env
ensure("faketool", base_env={})
target = current_target() if route == "install" else "linux-arm64-bionic"
realize = partial(ensure, "faketool", explicit=True, base_env={}) if route == "install" else partial(
stage_only, "faketool", target)
realize()
facts_path = paths.facts_path()
old_facts = facts_path.read_bytes()
old = Facts(facts_path).get("faketool")
binary = paths.store_root() / old["entry"] / "bin" / "faketool"
old_facts = facts_path.read_bytes() if facts_path.exists() else None
entry = paths.store_root() / FakeTool().store_entry("1.0", target)
marker = entry / ".pm-stage-pin.json"
old_marker = marker.read_bytes() if marker.exists() else None
binary = entry / "bin/faketool"
files = {"bin/unrelated": "bad layout"} if replacement == "invalid" else {"bin/faketool": "#!new"}
_, digest = make_tar(env["docroot"], "replacement.tar.gz", files)
lockfile = Lockfile(env["lockfile_path"])
@@ -199,15 +245,19 @@ def test_failed_replacement_preserves_entry_and_facts(pm_env, monkeypatch, repla
"url": f"{env['base_url']}/replacement.tar.gz", "sha256": digest,
}})
lockfile.save()
if replacement in ("publish-failure", "interrupted"):
if replacement in ("publish-failure", "post-publish-invalid", "interrupted"):
original = Store.publish
def fail_package_publish(self, staged, name):
if name == old["entry"]:
if replacement == "interrupted":
raise KeyboardInterrupt()
raise OSError("replacement publication failed")
return original(self, staged, name)
if name != entry.name:
return original(self, staged, name)
if replacement == "interrupted":
raise KeyboardInterrupt()
if replacement == "post-publish-invalid":
published = original(self, staged, name)
(published / "bin/faketool").unlink()
return published
raise OSError("replacement publication failed")
monkeypatch.setattr(Store, "publish", fail_package_publish)
elif replacement == "facts-failure":
@@ -217,24 +267,33 @@ def test_failed_replacement_preserves_entry_and_facts(pm_env, monkeypatch, repla
expected_error = KeyboardInterrupt if replacement == "interrupted" else InstallError
with pytest.raises(expected_error):
ensure("faketool", explicit=True, base_env={})
realize()
assert binary.read_bytes() == b"#!x"
assert facts_path.read_bytes() == old_facts
assert (facts_path.read_bytes() if facts_path.exists() else None) == old_facts
assert (marker.read_bytes() if marker.exists() else None) == old_marker
def test_killed_replacement_recovers_on_next_install(pm_env):
"""A process exit between moving old bytes and publishing new bytes is recoverable."""
@pytest.mark.parametrize("route", ["install", "stage"])
@pytest.mark.parametrize("interruption", ["before-publish", "after-publish"])
def test_killed_replacement_recovers_on_next_install(pm_env, route, interruption):
"""A killed publisher retains old bytes outside scratch until recovery."""
import os
import subprocess
import sys
import textwrap
from functools import partial
from pm.ensure import ensure
from pm.ensure import ensure, stage_only
env = pm_env
ensure("faketool", base_env={})
target = current_target() if route == "install" else "linux-arm64-bionic"
realize = partial(ensure, "faketool", explicit=True, base_env={}) if route == "install" else partial(
stage_only, "faketool", target)
realize()
old_fact = Facts(paths.facts_path()).get("faketool")
entry = paths.store_root() / FakeTool().store_entry("1.0", target)
old_digest = tree_digest(entry)
_, digest = make_tar(env["docroot"], "replacement.tar.gz", {"bin/faketool": "#!new"})
lockfile = Lockfile(env["lockfile_path"])
lockfile.set_pin("faketool", "1.0", {"any": {
@@ -248,28 +307,39 @@ def test_killed_replacement_recovers_on_next_install(pm_env):
import pm.registry as registry
from pm.store import Store
from tests.pm.test_pm_authority import FakeTool
from pm.ensure import ensure
from pm.ensure import ensure, stage_only
paths.lockfile_path = lambda: Path(sys.argv[1])
registry._packages[FakeTool.name] = FakeTool()
publish = Store.publish
def crash(self, staged, name):
if name.startswith('faketool-'):
os._exit(17)
return publish(self, staged, name)
if not name.startswith('faketool-'):
return publish(self, staged, name)
if sys.argv[3] == 'after-publish':
publish(self, staged, name)
os._exit(17)
Store.publish = crash
ensure('faketool', explicit=True, base_env={})
if sys.argv[2] == 'install':
ensure('faketool', explicit=True, base_env={})
else:
stage_only('faketool', 'linux-arm64-bionic')
""")
child = subprocess.run(
[sys.executable, "-c", code, str(env["lockfile_path"])],
[sys.executable, "-c", code, str(env["lockfile_path"]), route, interruption],
env=dict(os.environ), capture_output=True, text=True, timeout=30,
)
assert child.returncode == 17, child.stderr
assert Facts(paths.facts_path()).get("faketool") == old_fact
ensure("faketool", explicit=True, base_env={})
fact = Facts(paths.facts_path()).get("faketool")
entry = paths.store_root() / fact["entry"]
assert (entry / "bin" / "faketool").read_bytes() == b"#!new"
assert fact["digest"] == tree_digest(entry)
previous = entry.with_name(f".previous-{'stage-' if route == 'stage' else ''}{entry.name}")
assert tree_digest(previous) == old_digest
realize()
assert (entry / "bin/faketool").read_bytes() == b"#!new"
assert not previous.exists()
if route == "install":
fact = Facts(paths.facts_path()).get("faketool")
assert fact["digest"] == tree_digest(entry)
else:
assert not paths.facts_path().exists()
assert json.loads((entry / ".pm-stage-pin.json").read_text())["sha256"] == [digest]
def test_failed_restore_preserves_both_interrupted_versions(pm_env, monkeypatch):

View File

@@ -172,6 +172,38 @@ def test_stage_only_repin_same_version_rebuilds(tmp_path, sandbox, monkeypatch):
assert (stable / "bin" / "tool").read_bytes() == payload_b
@pytest.mark.platforms("windows")
def test_stage_repin_refuses_a_native_directory_hold_then_recovers(sandbox, monkeypatch):
import ctypes
from ctypes import wintypes
original, replacement = b"original", b"replacement"
_arm_lock(monkeypatch, [{"url": "https://example.test/tool", "sha256": _sha(original)}])
_seed_fetch_cache(sandbox, original)
entry = ensure_mod.stage_only("stage-test", TARGET)
marker = (entry / ".pm-stage-pin.json").read_bytes()
_arm_lock(monkeypatch, [{"url": "https://example.test/tool", "sha256": _sha(replacement)}])
_seed_fetch_cache(sandbox, replacement)
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
kernel.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD,
wintypes.LPVOID, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
kernel.CreateFileW.restype = wintypes.HANDLE
kernel.CloseHandle.argtypes = [wintypes.HANDLE]
kernel.CloseHandle.restype = wintypes.BOOL
# FILE_FLAG_BACKUP_SEMANTICS opens a directory; omit FILE_SHARE_DELETE.
handle = kernel.CreateFileW(str(entry), 0, 3, None, 3, 0x02000000, None)
assert handle != wintypes.HANDLE(-1).value, ctypes.get_last_error()
try:
with pytest.raises(InstallError):
ensure_mod.stage_only("stage-test", TARGET)
assert (entry / "bin/tool").read_bytes() == original
assert (entry / ".pm-stage-pin.json").read_bytes() == marker
finally:
kernel.CloseHandle(handle)
ensure_mod.stage_only("stage-test", TARGET)
assert (entry / "bin/tool").read_bytes() == replacement
@pytest.mark.parametrize("error_name", ["HashError", "DownloadPaused"])
def test_permanent_or_paused_download_is_not_retried(error_name):
from pm import downloader
@@ -200,7 +232,7 @@ def test_repin_failure_preserves_previous_staged_entry(sandbox, monkeypatch, fai
raise InstallError("stage-test", "injected staging failure")
if failure == "fetch":
monkeypatch.setattr(sandbox, "fetch", fail)
monkeypatch.setattr(sandbox, "fetch_many", fail)
else:
monkeypatch.setattr(sandbox, "publish", fail)
with pytest.raises(InstallError, match="injected staging failure"):

View File

@@ -199,8 +199,7 @@ def test_bundled_uv_uses_a_verified_writable_python_without_changing_runtime(ins
assert tree_digest(entry) == shipped_digest
@pytest.mark.platforms("windows")
@pytest.mark.parametrize("damage", ["source", "copy", "publication"])
@pytest.mark.parametrize("damage", [None, "source", "copy", "publication"])
def test_copy_failure_preserves_previous_python(installed_uv, monkeypatch, damage):
import pm.registry as registry
from pm.store import Store, tree_digest
@@ -216,12 +215,15 @@ def test_copy_failure_preserves_previous_python(installed_uv, monkeypatch, damag
entry_name = python.store_entry("test", target)
source = shipped / entry_name
source.mkdir()
(source / "python.exe").write_bytes(b"new interpreter")
binary_rel = python.binary(source, target).relative_to(source)
(source / binary_rel).parent.mkdir(parents=True, exist_ok=True)
(source / binary_rel).write_bytes(b"new interpreter")
facts.record("python", "test", entry_name, {}, shipped,
target=target, artifacts=[digest], digest=tree_digest(source))
previous = writable / entry_name
previous.mkdir()
(previous / "python.exe").write_bytes(b"previous interpreter")
(previous / binary_rel).parent.mkdir(parents=True, exist_ok=True)
(previous / binary_rel).write_bytes(b"previous interpreter")
previous_facts = Facts(writable / "facts.json")
previous_facts.record("python", "previous", entry_name, {}, writable,
target=target, artifacts=[digest], digest=tree_digest(previous))
@@ -229,19 +231,28 @@ def test_copy_failure_preserves_previous_python(installed_uv, monkeypatch, damag
copytree = shutil.copytree
if damage == "source":
(source / "python.exe").write_bytes(b"damaged source")
(source / binary_rel).write_bytes(b"damaged source")
elif damage == "copy":
def damaged_copy(src, dest, **kwargs):
copytree(src, dest, **kwargs)
(dest / "python.exe").write_bytes(b"damaged copy")
def damaged_copy(src, dest, *args, **kwargs):
result = copytree(src, dest, *args, **kwargs)
if Path(src) == source:
(dest / binary_rel).write_bytes(b"damaged copy")
return result
monkeypatch.setattr(shutil, "copytree", damaged_copy)
else:
elif damage == "publication":
def failed_publication(*args, **kwargs):
raise OSError("publication refused")
monkeypatch.setattr(Store, "publish", failed_publication)
with pytest.raises(InstallError, match="verification|copied bytes|publication refused"):
if damage is None:
monkeypatch.setattr(Store, "fetch_many", lambda *args, **kwargs: pytest.fail("copy downloaded bytes"))
ensure._install(python, ensure._lockfile(), previous_facts, Store(writable), target,
copy_from=(facts, Store(shipped)))
assert (previous / "python.exe").read_bytes() == b"previous interpreter"
assert previous_facts.path.read_bytes() == before
assert tree_digest(previous) == tree_digest(source)
assert previous_facts.get("python")["digest"] == facts.get("python")["digest"]
else:
with pytest.raises(InstallError, match="verification|copied bytes|publication refused"):
ensure._install(python, ensure._lockfile(), previous_facts, Store(writable), target,
copy_from=(facts, Store(shipped)))
assert (previous / binary_rel).read_bytes() == b"previous interpreter"
assert previous_facts.path.read_bytes() == before