diff --git a/scripts/ci/install_base_python_locks.py b/scripts/ci/install_base_python_locks.py index 518fcd689..db5b40df5 100644 --- a/scripts/ci/install_base_python_locks.py +++ b/scripts/ci/install_base_python_locks.py @@ -38,6 +38,15 @@ re.IGNORECASE, ), re.compile(r"requires a different Python", re.IGNORECASE), + # A base lock can pin a version that has since been yanked or that offers no + # wheel for the pinned coverage-image interpreter. pip proves the index was + # reachable only when it lists at least one concrete version. Empty lists, + # ``none``, and unreachable-index diagnostics remain fatal. + re.compile( + r"Could not find a version that satisfies the requirement[^\n]*" + r"\(from versions:\s*(?!none\b)(?=[A-Za-z0-9])[^)\n]+\)", + re.IGNORECASE, + ), ) Runner = Callable[..., subprocess.CompletedProcess[str]] @@ -150,12 +159,16 @@ def _is_deferable_preflight_failure(output: str) -> bool: """Return whether a failed candidate may be grouped or safely skipped. A hash-bearing supplement can fail pip's independent-closure check because a - transitive pin/hash lives in a sibling lock, and a base lock can explicitly - reject the pinned coverage-image interpreter. Those states are safe to - recover through a same-directory group or defer to the later networkless - coverage run. Hash mismatches, resolver crashes, empty diagnostics, and - registry/network failures remain fatal so a broken trusted build cannot be - mistaken for an optional lock. + transitive pin/hash lives in a sibling lock, a base lock can explicitly + reject the pinned coverage-image interpreter, and a base lock can pin a + version the reachable index no longer offers for that interpreter (yanked or + no matching wheel). Those states are safe to recover through a same-directory + group or defer to the later networkless coverage run. Hash mismatches, + resolver crashes, empty diagnostics, and registry/network failures — including + empty or ``none`` version lists — remain fatal so a broken trusted build cannot + be mistaken for an optional lock. Deferred paths retain a warning and bounded + pip diagnostics so the incompatibility stays visible without blocking + unrelated coverage evidence. """ return bool(output.strip()) and any( pattern.search(output) for pattern in DEFERABLE_PREFLIGHT_FAILURES @@ -171,8 +184,9 @@ def _report_fatal_preflight_failure( """Publish one bounded, source-aware fatal preflight failure.""" print( "::error::Trusted base Python lock preflight failed for " - f"{entry_label}; only incomplete hash closures or explicit Python " - "interpreter incompatibility may be deferred.", + f"{entry_label}; only incomplete hash closures, explicit Python " + "interpreter incompatibility, or a reachable-index version that is no " + "longer available for the coverage interpreter may be deferred.", file=stderr, ) failure_output = _bounded_failure_output(output) @@ -280,9 +294,8 @@ def install_materialized_locks( skipped += 1 print( "::warning::Skipping trusted base Python requirement candidate " - f"{entry.source}: hash-bearing content is not an independently " - "installable dependency closure and no same-directory lock group " - "completed it.", + f"{entry.source}: it could not be installed independently for the " + "coverage interpreter and no same-directory lock group completed it.", file=stderr, ) failure_output = _bounded_failure_output( diff --git a/tests/test_install_base_python_lock_missing_pin.py b/tests/test_install_base_python_lock_missing_pin.py new file mode 100644 index 000000000..63186bf7c --- /dev/null +++ b/tests/test_install_base_python_lock_missing_pin.py @@ -0,0 +1,97 @@ +"""Regression tests for unavailable pins in trusted base Python locks.""" + +from __future__ import annotations + +import io +import json +import subprocess +from pathlib import Path + +from scripts.ci import install_base_python_locks as installer + + +def _write_candidate(root: Path) -> None: + """Write one trusted materialized lock candidate and its manifest.""" + + (root / "manifest.json").write_text( + json.dumps( + [ + { + "file": "requirements-000.txt", + "source": "requirements-hashes.txt", + } + ] + ), + encoding="utf-8", + ) + (root / "requirements-000.txt").write_text( + "pypdf==6.13.3 --hash=sha256:" + ("a" * 64) + "\n", + encoding="utf-8", + ) + + +def _run_preflight_failure(root: Path, output: str) -> tuple[int, str, str]: + """Run the installer with one deterministic pip preflight failure.""" + + _write_candidate(root) + + def fake_runner(command: list[str], **kwargs): + return subprocess.CompletedProcess(command, 1, stdout=output) + + stdout = io.StringIO() + stderr = io.StringIO() + result = installer.install_materialized_locks( + root, + runner=fake_runner, + stdout=stdout, + stderr=stderr, + ) + return result, stdout.getvalue(), stderr.getvalue() + + +def test_reachable_index_missing_pin_is_visible_and_nonfatal(tmp_path: Path) -> None: + """A reachable index proving newer versions exist may defer a stale pin.""" + + output = ( + "ERROR: Could not find a version that satisfies the requirement " + "pypdf==6.13.3 (from versions: 6.14.1, 6.14.2)\n" + "ERROR: No matching distribution found for pypdf==6.13.3" + ) + + result, stdout, stderr = _run_preflight_failure(tmp_path, output) + + assert result == 0 + assert "candidates=1 installed=0 skipped=1" in stdout + assert "Could not find a version that satisfies the requirement" in stderr + + +def test_empty_index_missing_pin_remains_fatal(tmp_path: Path) -> None: + """An explicitly empty package index must never be treated as optional.""" + + output = ( + "ERROR: Could not find a version that satisfies the requirement " + "pypdf==6.13.3 (from versions: none)\n" + "ERROR: No matching distribution found for pypdf==6.13.3" + ) + + result, stdout, stderr = _run_preflight_failure(tmp_path, output) + + assert result == 1 + assert "preflight failed" in stderr + assert "installed=" not in stdout + + +def test_blank_version_list_missing_pin_remains_fatal(tmp_path: Path) -> None: + """A blank version list is not affirmative proof that the index is reachable.""" + + output = ( + "ERROR: Could not find a version that satisfies the requirement " + "pypdf==6.13.3 (from versions: )\n" + "ERROR: No matching distribution found for pypdf==6.13.3" + ) + + result, stdout, stderr = _run_preflight_failure(tmp_path, output) + + assert result == 1 + assert "preflight failed" in stderr + assert "installed=" not in stdout