Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 227 additions & 0 deletions .github/scripts/next_python_readiness.py
Original file line number Diff line number Diff line change
@@ -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 <groups>` 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"<details><summary>Ready ({len(ready)})</summary>", ""]
lines += [f"- `{r.name}` {r.version}" for r in ready]
lines += ["", "</details>", ""]
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())
108 changes: 99 additions & 9 deletions .github/workflows/protspace-future-python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Loading