From da076f3fa41cb2feab37908100cb4e6be62ba8b8 Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 18:57:16 -0400 Subject: [PATCH] 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. --- pm/ensure.py | 165 +++++++--------------- tests/pm/test_install_download_control.py | 34 +++-- tests/pm/test_pm_authority.py | 130 +++++++++++++---- tests/pm/test_stage_only.py | 34 ++++- tests/pm/test_uv_python.py | 35 +++-- 5 files changed, 229 insertions(+), 169 deletions(-) diff --git a/pm/ensure.py b/pm/ensure.py index b1e2474773..4a1c17516c 100644 --- a/pm/ensure.py +++ b/pm/ensure.py @@ -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) diff --git a/tests/pm/test_install_download_control.py b/tests/pm/test_install_download_control.py index beb7d969ee..d9622cf31d 100644 --- a/tests/pm/test_install_download_control.py +++ b/tests/pm/test_install_download_control.py @@ -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)) diff --git a/tests/pm/test_pm_authority.py b/tests/pm/test_pm_authority.py index b5256a4f32..36d824397e 100644 --- a/tests/pm/test_pm_authority.py +++ b/tests/pm/test_pm_authority.py @@ -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): diff --git a/tests/pm/test_stage_only.py b/tests/pm/test_stage_only.py index 377b779c70..e8145bfc1d 100644 --- a/tests/pm/test_stage_only.py +++ b/tests/pm/test_stage_only.py @@ -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"): diff --git a/tests/pm/test_uv_python.py b/tests/pm/test_uv_python.py index ce616a7a5d..e621c66765 100644 --- a/tests/pm/test_uv_python.py +++ b/tests/pm/test_uv_python.py @@ -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