diff --git a/.github/scripts/next_python_readiness.py b/.github/scripts/next_python_readiness.py new file mode 100644 index 00000000..09d5cfa5 --- /dev/null +++ b/.github/scripts/next_python_readiness.py @@ -0,0 +1,227 @@ +"""Report which direct dependencies have wheels for a not-yet-supported CPython. + +Used by `.github/workflows/protspace-future-python.yml` when the fresh resolve on +the next Python fails. `uv` reports only the *first* distribution it cannot +install, which says nothing about how far off the rest of the stack is. This +walks every direct dependency and asks PyPI the same question, so the run's job +summary answers "how close are we?" instead of naming one package. + +Informational only: it always exits 0. Whether a failed run is the ecosystem's +fault or protspace's is decided by the workflow, from uv's error text. + +Stdlib only — it runs before (and instead of) a working project environment. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import tomllib +import urllib.error +import urllib.request +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from pathlib import Path + +PYPI_JSON = "https://pypi.org/pypi/{name}/json" +TIMEOUT_S = 20 + +# Wheel filenames record the distribution name with `-` normalised to `_`, so the +# three tag fields are always the last three `-`-separated parts. +REQUIREMENT_NAME = re.compile(r"^\s*([A-Za-z0-9._-]+)") +CPYTHON_TAG = re.compile(r"^cp(\d)(\d+)t?$") + + +@dataclass +class Readiness: + name: str + version: str = "" + has_wheel: bool = False + has_sdist: bool = False + error: str = "" + + @property + def status(self) -> str: + if self.error: + return f"unknown — {self.error}" + if self.has_wheel: + return "wheel" + if self.has_sdist: + return "sdist only (source build)" + return "nothing installable" + + +def direct_dependencies(pyproject: Path, groups: list[str]) -> list[str]: + """Every distribution `uv sync --group ` installs on purpose. + + Optional extras are excluded: the canary does not pass `--extra`, so they are + not part of what it installs, and reporting them would overstate the blockers. + """ + data = tomllib.loads(pyproject.read_text()) + requirements = list(data.get("project", {}).get("dependencies", [])) + dependency_groups = data.get("dependency-groups", {}) + for group in groups: + requirements += dependency_groups.get(group, []) + + names: dict[str, None] = {} # dict, not set: preserves declaration order + for requirement in requirements: + if not isinstance(requirement, str): + continue # `{include-group = ...}` and other non-requirement entries + match = REQUIREMENT_NAME.match(requirement) + if match: + names.setdefault(canonicalize(match.group(1)), None) + return list(names) + + +def canonicalize(name: str) -> str: + """PEP 503 normalisation — what the PyPI JSON API expects.""" + return re.sub(r"[-_.]+", "-", name).lower() + + +def _cpython_at_most(tag: str, target: tuple[int, int]) -> bool: + match = CPYTHON_TAG.match(tag) + return bool(match) and (int(match.group(1)), int(match.group(2))) <= target + + +def _abi_supports( + py_tags: list[str], abi_tags: list[str], target: tuple[int, int] +) -> bool: + if f"cp{target[0]}{target[1]}" in abi_tags: + return True + if "abi3" in abi_tags: + # A stable-ABI wheel built for cp312 also loads on 3.15. + return any(_cpython_at_most(tag, target) for tag in py_tags) + if "none" in abi_tags: + return "py3" in py_tags or any(_cpython_at_most(tag, target) for tag in py_tags) + return False + + +def _platform_supports(plat_tags: list[str], platform: str) -> bool: + return any(tag == "any" or platform in tag for tag in plat_tags) + + +def wheel_is_compatible(filename: str, target: tuple[int, int], platform: str) -> bool: + parts = filename.removesuffix(".whl").split("-") + if len(parts) < 5: # name-version[-build]-python-abi-platform + return False + py_tags, abi_tags, plat_tags = (part.split(".") for part in parts[-3:]) + return _abi_supports(py_tags, abi_tags, target) and _platform_supports( + plat_tags, platform + ) + + +def check(name: str, target: tuple[int, int], platform: str) -> Readiness: + try: + with urllib.request.urlopen( + PYPI_JSON.format(name=name), timeout=TIMEOUT_S + ) as response: + payload = json.load(response) + except urllib.error.HTTPError as exc: + if exc.code == 404: + # Workspace members (protlabel) resolve locally and have no PyPI page + # under the version being resolved; that is not a blocker. + return Readiness(name, error="not on PyPI (workspace member?)") + return Readiness(name, error=f"HTTP {exc.code}") + except Exception as exc: # noqa: BLE001 — a probe must never fail the run + return Readiness(name, error=type(exc).__name__) + + version = payload["info"]["version"] + files = [ + file for file in payload["releases"].get(version, []) if not file.get("yanked") + ] + return Readiness( + name=name, + version=version, + has_wheel=any( + file["packagetype"] == "bdist_wheel" + and wheel_is_compatible(file["filename"], target, platform) + for file in files + ), + has_sdist=any(file["packagetype"] == "sdist" for file in files), + ) + + +def render(results: list[Readiness], python_version: str, platform: str) -> str: + ready = [r for r in results if r.has_wheel] + blocked = [r for r in results if not r.has_wheel and not r.error] + unknown = [r for r in results if r.error] + + lines = [ + f"## Python {python_version} wheel readiness", + "", + f"**{len(ready)} of {len(results)} direct dependencies** publish a wheel for " + f"CPython {python_version} on `{platform}`.", + "", + ] + if blocked: + lines += [ + "### Still missing a wheel", + "", + "| Package | Latest on PyPI | Status |", + "| --- | --- | --- |", + *(f"| `{r.name}` | {r.version} | {r.status} |" for r in blocked), + "", + "A `sdist only` entry may still install if it builds from source; " + "`nothing installable` cannot.", + "", + ] + else: + lines += [ + f"Every direct dependency is ready. Python {python_version} can be " + "promoted into `protspace-ci.yml`'s matrix once the tests pass.", + "", + ] + if unknown: + lines += [ + "### Not checked", + "", + *(f"- `{r.name}` — {r.error}" for r in unknown), + "", + ] + if ready: + lines += [f"
Ready ({len(ready)})", ""] + lines += [f"- `{r.name}` {r.version}" for r in ready] + lines += ["", "
", ""] + lines += [ + "> Direct dependencies only. A package listed as ready can still be blocked by a " + "compiled *transitive* dependency — `umap-learn` is pure Python but needs " + "`numba`/`llvmlite`. uv's error in the step above names the one that actually bit.", + "", + ] + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--pyproject", type=Path, required=True) + parser.add_argument("--python-version", required=True, help="e.g. 3.15") + parser.add_argument("--group", action="append", default=[], dest="groups") + parser.add_argument( + "--platform", + default="x86_64", + help="substring matched against wheel platform tags", + ) + args = parser.parse_args() + + major, _, minor = args.python_version.partition(".") + target = (int(major), int(minor)) + + names = direct_dependencies(args.pyproject, args.groups or ["dev"]) + with ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(lambda name: check(name, target, args.platform), names)) + results.sort(key=lambda r: (r.has_wheel, bool(r.error), r.name)) + + report = render(results, args.python_version, args.platform) + print(report) + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + with open(summary, "a", encoding="utf-8") as handle: + handle.write(report + "\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/protspace-future-python.yml b/.github/workflows/protspace-future-python.yml index 18cb3f96..0d996c81 100644 --- a/.github/workflows/protspace-future-python.yml +++ b/.github/workflows/protspace-future-python.yml @@ -2,20 +2,28 @@ name: protspace future-Python canary # Early-warning for the NEXT Python release (not yet in protspace-ci.yml's # matrix). It fresh-resolves the whole dev environment on that interpreter and -# runs the fast test suite. GitHub emails the workflow's last editor when a -# scheduled run fails, so a break surfaces here — before a user hits it. +# runs the fast test suite. # # WHY THIS EXISTS: protspace-ci.yml tests only released Pythons against the # pinned lock. Nothing there would tell you a coming release broke you until you # manually added it. This job watches ahead so adding the next version to the # matrix is a known-good one-line change, not a gamble. # -# EXPECTED TO BE RED UNTIL THE ECOSYSTEM CATCHES UP: a new CPython ships months -# before numba/llvmlite/pyarrow publish wheels for it, so early failures here are -# usually "the stack isn't ready yet", not a protspace bug. When it fails, read -# the log: a missing-wheel/source-build error for a dependency ≠ a protspace -# regression. Once this goes green, promote the version into protspace-ci.yml and -# bump the version below to the next one. +# RED MEANS ACT, GREEN MEANS WAIT. A new CPython ships months before torch, +# numba, pyarrow et al. publish wheels for it, so for most of this workflow's +# life the install cannot succeed at all. That is the ecosystem's state, not a +# protspace regression, and reporting it as a failure every month trains you to +# ignore the one mail that matters. So the run classifies uv's error instead: +# a recognised packaging gap writes a wheel-readiness table to the job summary +# and ends GREEN. Two things turn it red, and GitHub emails the workflow's last +# editor for both: +# +# 1. uv failed in a way that is NOT a known packaging gap, or the stack +# installed and the tests failed — a real problem, read the log. +# 2. Everything passed — the ecosystem has caught up. Promote the version into +# protspace-ci.yml's matrix and bump the one below to the next release. +# +# Every run's job summary tracks how close case 2 is. on: schedule: @@ -50,12 +58,73 @@ jobs: python-version: ${{ matrix.python-version }} - name: Resolve + install on the next Python (fresh, ignoring the pinned lock) + id: sync + # Failure here is the expected state for most of this workflow's life, so + # it must not end the job — the next two steps decide what it meant. + continue-on-error: true # --upgrade, NOT --locked: the committed lock has no wheels for the next # Python. A fresh resolve pulls the newest versions that support it; a # dependency with no compatible release is exactly the signal we want. - run: uv sync --group dev --upgrade + run: | + set -o pipefail + uv sync --group dev --upgrade 2>&1 | tee "$RUNNER_TEMP/sync.log" + + - name: Report wheel readiness + if: steps.sync.outcome == 'failure' + # uv names only the first distribution it cannot install, which says + # nothing about how far off the rest of the stack is. This walks every + # direct dependency so the summary answers "how close are we?". + # Needs Python 3.11+ for tomllib; every current runner image has 3.12+. + working-directory: ${{ github.workspace }} + run: | + python3 .github/scripts/next_python_readiness.py \ + --pyproject apps/protspace/pyproject.toml \ + --python-version '${{ matrix.python-version }}' \ + --group dev + + - name: Classify the failure — packaging gap (green) or real problem (red) + if: steps.sync.outcome == 'failure' + env: + PYTHON_VERSION: ${{ matrix.python-version }} + run: | + log="$RUNNER_TEMP/sync.log" + + # uv's vocabulary for "no artifact this interpreter can install or build". + no_artifact="doesn't have a source distribution or wheel for the current platform|only has wheels with the following Python ABI tags|Failed to build " + + if grep -qE "$no_artifact" "$log"; then + reason="a dependency publishes nothing installable for CPython ${PYTHON_VERSION} yet" + elif grep -q 'No solution found when resolving dependencies' "$log" \ + && grep -q 'requires Python' "$log"; then + # A dependency caps requires-python below the target, so resolution + # never even gets as far as picking artifacts. + reason="a dependency declares it does not support Python ${PYTHON_VERSION} yet" + else + { + echo "## Unrecognised failure on Python ${PYTHON_VERSION}" + echo + echo "\`uv sync\` failed, but not with a known packaging gap. This may be a real" + echo "protspace problem — read the log of the resolve step above." + } >> "$GITHUB_STEP_SUMMARY" + echo "::error::uv sync failed in a way this canary does not recognise as an ecosystem gap — read the log." + exit 1 + fi + + { + echo "## Ecosystem not ready for Python ${PYTHON_VERSION}" + echo + echo "Ending green: ${reason}. Nothing to do — this is not a protspace regression." + echo + echo "uv stopped at:" + echo + echo '```' + grep -E '^(error|hint):' "$log" | head -20 + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + echo "::notice::Ecosystem not ready for Python ${PYTHON_VERSION} — ${reason}. Ending green." - name: Assert the leg actually runs its matrix version + if: steps.sync.outcome == 'success' run: | uv run python -c " import sys @@ -66,4 +135,25 @@ jobs: " - name: Run tests + if: steps.sync.outcome == 'success' run: uv run pytest -m "not slow" -q + + - name: Signal that the next Python is ready to promote + if: steps.sync.outcome == 'success' + # Reached only when the install AND the tests passed — the whole point of + # the canary. Fails deliberately: a green run sends no mail, and this is + # the one moment there is something to do. + env: + PYTHON_VERSION: ${{ matrix.python-version }} + run: | + { + echo "## Python ${PYTHON_VERSION} is ready" + echo + echo "The dev environment resolved and installed on CPython ${PYTHON_VERSION} and the" + echo "fast suite passed. Promote it:" + echo + echo "1. Add \`'${PYTHON_VERSION}'\` to \`protspace-ci.yml\`'s test matrix." + echo "2. Bump this workflow's matrix to the next Python release." + } >> "$GITHUB_STEP_SUMMARY" + echo "::error::Python ${PYTHON_VERSION} is ready — promote it into protspace-ci.yml and bump this canary to the next release." + exit 1