diff --git a/.github/coverage.toml b/.github/coverage.toml index 7ab942f2bc..5322ca9ba7 100644 --- a/.github/coverage.toml +++ b/.github/coverage.toml @@ -1,5 +1,5 @@ # Coverage configuration for the one coverage-measured leg of unit-tests.yml -# (`test`, Python 3.14), passed with `--rcfile`. Coverage reads a single config +# (`test`, Python 3.15), passed with `--rcfile`. Coverage reads a single config # file, so this repeats pyproject.toml's [tool.coverage.*] settings and adds the # three below on top. tests/project/test_coverage_rcfile.py asserts every # pyproject setting is repeated here unchanged. diff --git a/.github/python-versions.toml b/.github/python-versions.toml new file mode 100644 index 0000000000..018983db13 --- /dev/null +++ b/.github/python-versions.toml @@ -0,0 +1,59 @@ +# The Python version tiers (#1660). Every workflow matrix and every +# single-interpreter job's version is read from here through +# util/python_versions.py, and tests/project/test_python_versions_1660.py pins +# the classifiers and docs to it. A tier change is a one-line edit below plus +# the matching edit to ruleset 23497679's Compat contexts. +# +# Tiers: supported (blocking), ktlo (end-of-life, non-blocking) and pre-release +# (non-blocking; none listed today). `runner` is the image a version needs when +# ubuntu-latest cannot provide it. `latest` is the newest supported CPython, +# which the single-interpreter jobs run on. See +# docs/source/contributing/workflows.rst. + +latest = "3.15" + +[versions.cpython] +"3.7" = { tier = "ktlo", runner = "ubuntu-22.04" } +"3.8" = { tier = "ktlo", runner = "ubuntu-24.04" } +"3.9" = { tier = "ktlo", runner = "ubuntu-24.04" } +"3.10" = { tier = "ktlo" } +"3.11" = { tier = "supported" } +"3.12" = { tier = "supported" } +"3.13" = { tier = "supported" } +"3.14" = { tier = "supported" } +"3.15" = { tier = "supported" } + +[versions.pypy] +"pypy3.10" = { tier = "ktlo" } +"pypy3.11" = { tier = "supported" } + +# The pins kept off the tiers, each with its reason in the comment above it. +# tests/project/test_python_versions_1660.py fails on a pin not listed here, on +# an entry that matches no pin, and on an entry without a comment. + +# The parity matrix's one supported leg saves the apt cache. Both legs install +# one set under one key, and a second saver fails with `Unable to reserve +# cache`. +[[exemptions]] +workflow = "unit-tests.yml" +job = "pypcap-parity" +where = "env.APT_CACHE_SAVE" +value = "3.11" + +# conda-forge's conda has no py315 build, so installing 3.15 into base fails +# with `RemoveError: Conda cannot remove itself` (#1633, conda 26.7.2). 3.15 +# leaves base on its own Python; `conda build --python` still builds for 3.15. +# Drop this once conda publishes a py315 build. +[[exemptions]] +workflow = "create-release.yml" +job = "conda" +where = "Conda environment creation and activation: with.python-version" +value = "3.15" + +# As for create-release.yml's conda job: conda-forge's conda has no py315 build +# yet. +[[exemptions]] +workflow = "cron-conda.yml" +job = "conda-dist" +where = "Conda environment creation and activation: with.python-version" +value = "3.15" diff --git a/.github/workflows/ci-timing.yml b/.github/workflows/ci-timing.yml index 053a235ae2..2e702434d9 100644 --- a/.github/workflows/ci-timing.yml +++ b/.github/workflows/ci-timing.yml @@ -47,10 +47,16 @@ jobs: steps: - uses: actions/checkout@v7 + # #1660: `latest` from .github/python-versions.toml, read with the + # runner's own python3. + - name: Read the Python versions + id: python-versions + run: python3 util/python_versions.py --github-output + # Nothing is installed: the script is standard library only. - uses: actions/setup-python@v7 with: - python-version: "3.14" + python-version: ${{ steps.python-versions.outputs.latest }} # The newest earlier run that uploaded a report, so a week whose run failed # on a HARD threshold is still next week's baseline. A first run, or one diff --git a/.github/workflows/coverage-comment.yml b/.github/workflows/coverage-comment.yml index 843b29f6e6..8640310d92 100644 --- a/.github/workflows/coverage-comment.yml +++ b/.github/workflows/coverage-comment.yml @@ -1,6 +1,6 @@ name: Coverage Comment -# Posts the coverage the `test` job's Python 3.14 leg measured in +# Posts the coverage the `test` job's Python 3.15 leg measured in # unit-tests.yml as a pull-request comment. It runs no tests. The comment cannot # be posted from unit-tests.yml itself: that file is also a reusable workflow, # and a called workflow can only narrow the token its caller passes, never widen @@ -82,7 +82,7 @@ jobs: sys.exit(f'invalid test outcome in artifact: {tests!r}') total = number(data['total']) status = '' if tests == 'success' else f' -- **tests {tests}**, so this is partial' - print(f"**Coverage: {total:.2f}%**{status} (unit tier, Python 3.14, " + print(f"**Coverage: {total:.2f}%**{status} (unit tier, Python 3.15, " f"`{os.environ['HEAD_SHA'][:9]}`, Unit Tests run {os.environ['RUN_CONCLUSION']})") print() print('| Package | Statements | Missed | Branches | Partial | Cover |') diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 9045825987..a8ec0a1313 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -219,9 +219,16 @@ jobs: ref: ${{ github.sha }} fetch-depth: 0 + # #1660: .github/python-versions.toml, read with the runner's own python3: + # `latest` for this job, the ``pypi`` and ``conda`` matrices, and the tags + # the two completeness checks below count, all from this run's commit. + - name: Read the Python versions + id: python-versions + run: python3 util/python_versions.py --github-output + - uses: actions/setup-python@v7 with: - python-version: '3.14' + python-version: ${{ steps.python-versions.outputs.latest }} - name: Install and Setup run: | @@ -256,9 +263,10 @@ jobs: tag: "conda-${{ steps.get_version.outputs.PCAPKIT_VERSION }}+0" # PyPI evidence: compare the file count for this version against the 7 - # expected -- the 6 supported legs (``pypi``'s ``strategy.matrix.python-version``) - # each producing a wheel, plus the sdist that the "No Source Distribution" - # step keeps only on the 3.14 leg. A release-level 200 only proves *some* + # expected -- the 6 supported legs (``pypi``'s ``python-version`` axis, + # whose wheel tags ``PCAPKIT_PYPI_WHEELS`` lists) each producing a wheel, + # plus the sdist that the "No Source Distribution" step keeps only on the + # 3.15 leg, ``latest``. A release-level 200 only proves *some* # file landed, not all of them, so presence alone -- ``PCAPKIT_TAG_EXISTS``'s # mistake, repeated -- is not enough. Only the supported tier's wheels are # counted (#1633): a KTLO leg's wheel neither completes the release nor, @@ -284,10 +292,13 @@ jobs: id: check_pypi env: PCAPKIT_VERSION: ${{ steps.get_version.outputs.PCAPKIT_VERSION }} + PCAPKIT_PYPI_WHEELS: ${{ steps.python-versions.outputs.pypi-wheels }} run: | set -euo pipefail - expected=7 + # One wheel per supported leg, plus the sdist. + read -r -a wheels <<< "$PCAPKIT_PYPI_WHEELS" + expected=$(( ${#wheels[@]} + 1 )) # `--retry-connrefused` and the `|| status=000` fallback keep a # transient pypi.org outage from failing this step outright: under # `set -e` a bare curl failure here would fail `version_check`, @@ -313,10 +324,10 @@ jobs: # `python3 -` reads from stdin, so nothing is indented that # Python did not itself put there. count="$(printf '%s\n' \ - "import json" \ + "import json, os" \ "with open('/tmp/pypi_release.json') as f:" \ " data = json.load(f)" \ - "supported = {'cp311', 'cp312', 'cp313', 'cp314', 'cp315', 'pp311'}" \ + "supported = set(os.environ['PCAPKIT_PYPI_WHEELS'].split())" \ "names = [u.get('filename', '') for u in data.get('urls', [])]" \ "wheels = {n.split('-')[2] for n in names if n.endswith('.whl') and n.count('-') == 4}" \ "print(len(wheels & supported) + any(n.endswith('.tar.gz') for n in names))" \ @@ -343,10 +354,13 @@ jobs: id: check_conda env: PCAPKIT_VERSION: ${{ steps.get_version.outputs.PCAPKIT_VERSION }} + PCAPKIT_CONDA_BUILDS: ${{ steps.python-versions.outputs.conda-builds }} run: | set -euo pipefail - expected=10 + # Each of ``conda``'s two platforms, for each supported version. + read -r -a builds <<< "$PCAPKIT_CONDA_BUILDS" + expected=$(( 2 * ${#builds[@]} )) # Same coupling risk, and the same fix, as the PyPI check above. status="$(curl -s --retry 3 --retry-connrefused \ -o /tmp/anaconda_release.json -w '%{http_code}' \ @@ -358,11 +372,11 @@ jobs: # one line per `printf` argument, no line indented in the actual # source `python3 -` reads. count="$(printf '%s\n' \ - "import json" \ + "import json, os" \ "with open('/tmp/anaconda_release.json') as f:" \ " data = json.load(f)" \ "platforms = {'linux-64', 'osx-64'}" \ - "py_tags = {'py311', 'py312', 'py313', 'py314', 'py315'}" \ + "py_tags = set(os.environ['PCAPKIT_CONDA_BUILDS'].split())" \ "seen = {" \ " (d.get('attrs', {}).get('subdir'), tag)" \ " for d in data.get('distributions', [])" \ @@ -390,6 +404,10 @@ jobs: PCAPKIT_PYPI_COMPLETE: ${{ steps.check_pypi.outputs.PCAPKIT_PYPI_COMPLETE }} PCAPKIT_CONDA_COMPLETE: ${{ steps.check_conda.outputs.PCAPKIT_CONDA_COMPLETE }} PCAPKIT_CONDA_LABEL: ${{ steps.get_version.outputs.PCAPKIT_CONDA_LABEL }} + # #1660: from .github/python-versions.toml, for ``pypi`` and ``conda``. + PYTHON_LATEST: ${{ steps.python-versions.outputs.latest }} + PYTHON_PYPI: ${{ steps.python-versions.outputs.pypi }} + PYTHON_CONDA: ${{ steps.python-versions.outputs.conda }} # No ``environment:`` here, and none needed: this is a test gate with no outward # effect, and a job that calls a reusable workflow may not declare one anyway. @@ -797,29 +815,22 @@ jobs: ## one leg failing must not cancel its siblings: each leg still has its own ## wheel to attach to the GitHub Release, and a cancelled leg never does fail-fast: false - matrix: - # #1633: the supported tier, whose wheels version_check counts. - python-version: - - "3.11" - - "3.12" - - "3.13" - - "3.14" - - "3.15" - - "pypy3.11" - # The KTLO tier: Python 3.10 language level, end-of-life, still built - # and published but never blocking. Each step that can fail on one - # carries `continue-on-error: ${{ matrix.ktlo == true }}`, the - # step-level form, so ``needs.pypi.result`` cannot see it -- see the - # same choice in unit-tests.yml's ``test`` job. #1672: each step after - # setup-python waits on the ``outcome`` of the one it needs, so a KTLO - # leg stops at its first failure, where a supported leg stops, rather - # than building with the image's ``python`` or uploading nothing; and - # ``KTLO summary`` reports each failed step as a warning. - include: - - python-version: "3.10" - ktlo: true - - python-version: "pypy3.10" - ktlo: true + # #1660: CPython and PyPy from .github/python-versions.toml, through + # ``version_check``. #1633: the supported tier is the ``python-version`` + # axis, whose wheels version_check counts. The KTLO tier -- end-of-life, + # still built and published but never blocking -- is added by + # ``include``, without the versions that need an older runner than this + # job's ``ubuntu-latest``: Python 3.10 language level today. Each step + # that can fail on one carries `continue-on-error: ${{ matrix.ktlo == + # true }}`, the step-level form, so ``needs.pypi.result`` cannot see it + # -- see the same choice in unit-tests.yml's ``test`` job. #1672: each + # step after setup-python waits on the ``outcome`` of the one it needs, + # so a KTLO leg stops at its first failure, where a supported leg stops, + # rather than building with the image's ``python`` or uploading nothing; + # and ``KTLO summary`` reports each failed step as a warning. A KTLO leg + # also needs its own ``ktlo_*`` output above, which + # tests/project/test_python_matrix_tiers_1633.py checks. + matrix: ${{ fromJSON(needs.version_check.outputs.PYTHON_PYPI) }} steps: # #1634: this run's commit, not ``ref: v`` -- that tag was # whatever ``main`` had become by approval time -- and then the check @@ -878,7 +889,7 @@ jobs: - name: No Source Distribution for Python ${{ matrix.python-version }} id: no_sdist - if: ${{ matrix.python-version != '3.14' && steps.build.outcome == 'success' }} + if: ${{ matrix.python-version != needs.version_check.outputs.PYTHON_LATEST && steps.build.outcome == 'success' }} continue-on-error: ${{ matrix.ktlo == true }} run: | rm dist/*.tar.gz @@ -1037,16 +1048,12 @@ jobs: - macos-latest - ubuntu-latest # - windows-latest # skip for now - # #1633: the supported tier only. There is no KTLO 3.10 leg because it + # #1633: the supported tier only, from .github/python-versions.toml + # through ``version_check`` (#1660). There is no KTLO 3.10 leg because it # can only fail: conda/requirements.txt pins soupsieve==3.0.3 # (Requires-Python >=3.11.5), and util/conda-dist.py pins the newest # release of every dependency, so that floor only rises. - python-version: - - "3.11" - - "3.12" - - "3.13" - - "3.14" - - "3.15" + python-version: ${{ fromJSON(needs.version_check.outputs.PYTHON_CONDA) }} steps: # #1634: this run's commit, not ``ref: conda-+0``, then the check # that both tags these packages are filed under name it. diff --git a/.github/workflows/cron-conda.yml b/.github/workflows/cron-conda.yml index b358ea1043..ec1f23f92f 100644 --- a/.github/workflows/cron-conda.yml +++ b/.github/workflows/cron-conda.yml @@ -146,9 +146,15 @@ jobs: persist-credentials: false fetch-depth: 0 + # #1660: `latest` for this job and `conda-dist`'s versions, from + # .github/python-versions.toml, read with the runner's own python3. + - name: Read the Python versions + id: python-versions + run: python3 util/python_versions.py --github-output + - uses: actions/setup-python@v7 with: - python-version: '3.14' + python-version: ${{ steps.python-versions.outputs.latest }} # `.[all,dev]`, not `.[all]` alone: `util/conda-dist.py` below only # *refreshes* the version pin of every package already named in @@ -302,6 +308,7 @@ jobs: PCAPKIT_VERSION: ${{ steps.get_version.outputs.PCAPKIT_VERSION }} PCAPKIT_CONDA_LABEL: ${{ steps.get_version.outputs.PCAPKIT_CONDA_LABEL }} PCAPKIT_BUILD: ${{ steps.get_version.outputs.PCAPKIT_BUILD }} + PYTHON_CONDA: ${{ steps.python-versions.outputs.conda }} conda-tag: # See create-release.yml: "(update)" keeps this apart from the Conda Tag job @@ -424,14 +431,10 @@ jobs: - macos-latest - ubuntu-latest # - windows-latest # skip for now - # #1633: the supported tier; create-release.yml's ``conda`` job says why + # #1633: the supported tier, from .github/python-versions.toml through + # `conda-update` (#1660); create-release.yml's ``conda`` job says why # there is no KTLO 3.10 leg. - python-version: - - "3.11" - - "3.12" - - "3.13" - - "3.14" - - "3.15" + python-version: ${{ fromJSON(needs.conda-update.outputs.PYTHON_CONDA) }} steps: - uses: actions/checkout@v7 with: diff --git a/.github/workflows/cron-vendor.yml b/.github/workflows/cron-vendor.yml index 1e61d4d1fc..f3b687cd5f 100644 --- a/.github/workflows/cron-vendor.yml +++ b/.github/workflows/cron-vendor.yml @@ -146,9 +146,15 @@ jobs: # live credential for anything running in between to find. persist-credentials: false + # #1660: `latest` from .github/python-versions.toml, read with the + # runner's own python3. + - name: Read the Python versions + id: python-versions + run: python3 util/python_versions.py --github-output + - uses: actions/setup-python@v7 with: - python-version: '3.14' + python-version: ${{ steps.python-versions.outputs.latest }} - name: Install and Setup run: | diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index d449e5cc38..b4795a8119 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -135,9 +135,15 @@ jobs: # question (#1643). persist-credentials: false + # #1660: `latest` from .github/python-versions.toml, read with the + # runner's own python3. + - name: Read the Python versions + id: python-versions + run: python3 util/python_versions.py --github-output + - uses: actions/setup-python@v7 with: - python-version: '3.14' + python-version: ${{ steps.python-versions.outputs.latest }} # Builds the Sphinx documentation into `docs/build/html`, which the deploy # step below publishes. `PCAPKIT_SPHINX` is what makes the diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 02a09c11be..02fc7360c3 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -165,13 +165,22 @@ jobs: # source, and running them several times over would produce several # copies of the same findings for several times the runner cost. # - # 3.14 specifically, matching cron-vendor.yml, because it is the version - # the recorded counts above were measured on -- so a count that moves - # means the code moved, not the interpreter. 3.15 is newer, and supported - # since #1633, but moving to it would re-baseline every count above. + # `latest` from .github/python-versions.toml, like every single-interpreter + # job (#1661). Moving off 3.14 changes no count. Measured at 0bc157660 + # on setup-python's 3.14.8 and 3.15.0 builds: pylint 4.1.3 (astroid + # 4.3.4) reports the same 515 C, 71 E and 4970 W, message for message; + # only R0401 cyclic-import differs, as it does between two runs on one + # interpreter (see the header). bandit, mypy and vermin print identical + # reports. To test a 3.15 without C `_decimal`, use setup-python's build, + # which has none: faking it with `sys.modules['_decimal'] = None` makes + # astroid crash on pcapkit/toolkit/dpkt.py on 3.14.8 too (#1677). + - name: Read the Python versions + id: python-versions + run: python3 util/python_versions.py --github-output + - uses: actions/setup-python@v7 with: - python-version: '3.14' + python-version: ${{ steps.python-versions.outputs.latest }} # `.[all,dev]` rather than a bare install: pylint and mypy resolve imports, # so findings depend on what is importable. #910 narrowed `all` to core diff --git a/.github/workflows/python-compatibility.yml b/.github/workflows/python-compatibility.yml index bc2ee1080e..d421a7d0e7 100644 --- a/.github/workflows/python-compatibility.yml +++ b/.github/workflows/python-compatibility.yml @@ -23,6 +23,27 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + # #1660: the matrix below, from .github/python-versions.toml -- see the job of + # the same name in unit-tests.yml. + python-versions: + name: Read the Python versions + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + compatibility: ${{ steps.read.outputs.compatibility }} + steps: + - uses: actions/checkout@v7 + with: + sparse-checkout: | + .github/python-versions.toml + util/python_versions.py + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Read .github/python-versions.toml + id: read + run: python3 util/python_versions.py --github-output + compatibility: # Named "Python ${{ matrix.python-version }}" until now, which collided # exactly with the Unit Tests matrix job of the same name: `gh pr checks` @@ -37,33 +58,20 @@ jobs: # `failure`, so every commit showed red (#1672). 3.7 fails "Verify package" # by design. No 3.6: setup-python builds it only for retired Ubuntu images. name: Compat Python ${{ matrix.python-version }}${{ matrix.ktlo && ' (KTLO)' || '' }} + needs: [python-versions] runs-on: ${{ matrix.os || 'ubuntu-latest' }} env: ANNOTATION_LEVEL: ${{ matrix.ktlo && 'warning' || 'error' }} strategy: fail-fast: false - matrix: - python-version: - - "3.11" - - "3.12" - - "3.13" - - "3.14" - - "3.15" - # setup-python publishes 3.8 and 3.9 builds up to Ubuntu 24.04 only, and - # `ubuntu-latest` moves to 26.04 from 2026-10-19, hence the pin. 3.7's - # newest is for the deprecated 22.04; see the `test` job in unit-tests.yml. - include: - - python-version: "3.7" - ktlo: true - os: ubuntu-22.04 - - python-version: "3.8" - ktlo: true - os: ubuntu-24.04 - - python-version: "3.9" - ktlo: true - os: ubuntu-24.04 - - python-version: "3.10" - ktlo: true + # Both tiers, as for the `test` job in unit-tests.yml (#1660): a KTLO leg + # carries `ktlo: true`, and `os` where `ubuntu-latest` cannot provide it. + # setup-python publishes 3.8 and 3.9 builds up to Ubuntu 24.04 only, and + # `ubuntu-latest` moves to 26.04 from 2026-10-19, hence their pin. 3.7's + # newest is for the deprecated 22.04; see the `test` job in unit-tests.yml. + # Each supported leg is one of ruleset 23497679's required contexts, so a + # tier change edits the ruleset too. + matrix: ${{ fromJSON(needs.python-versions.outputs.compatibility) }} steps: - uses: actions/checkout@v7 id: checkout diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 9aa6c478bb..6eed5bbd3c 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -40,6 +40,48 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + # #1660: the Python versions, from .github/python-versions.toml. The matrix + # jobs below read theirs from here with `fromJSON`, and `gate` reads `latest` + # for its name. util/python_versions.py needs only the standard library, so + # the runner's own `python3` runs it, against a checkout of those two files: + # its tomllib needs 3.11, and each image that runs it has 3.12 or newer (the + # script's docstring names each one's). + # Runs on every path, `gate-only` included, and asks for no write permission, + # since this file is a reusable workflow whose callers grant `contents: read`. + # + # The other single-interpreter jobs (`unittest-ordering`, `changelog`, + # `project-tests`) run the same script in a step of their own, which leaves + # their `needs:` and `if:` as they were. setup-python takes an empty + # `python-version` as "the Python on PATH", with only a warning, so + # `unittest-ordering`'s `!cancelled()` would otherwise need a check that this + # job succeeded. + python-versions: + name: Read the Python versions + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + latest: ${{ steps.read.outputs.latest }} + test: ${{ steps.read.outputs.test }} + integration: ${{ steps.read.outputs.integration }} + pypcap-parity: ${{ steps.read.outputs.pypcap-parity }} + engine-supported: ${{ steps.read.outputs.engine-supported }} + engine-ktlo: ${{ steps.read.outputs.engine-ktlo }} + + steps: + - uses: actions/checkout@v7 + with: + sparse-checkout: | + .github/python-versions.toml + util/python_versions.py + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Read .github/python-versions.toml + id: read + run: python3 util/python_versions.py --github-output + test: # `github.event_name` is NOT 'workflow_call' inside a called workflow -- # the github context is inherited from the caller, so it reads @@ -49,7 +91,7 @@ jobs: # reusable workflow to know how it was invoked. name: Python ${{ matrix.python-version }}${{ matrix.ktlo && ' (KTLO)' || '' }} if: ${{ inputs.gate-only != true && needs.changes.outputs.code != 'false' }} - needs: [changes] + needs: [changes, python-versions] runs-on: ${{ matrix.os || 'ubuntu-latest' }} timeout-minutes: 45 # #1672: the level of every annotation a failing step writes -- a warning on @@ -58,53 +100,39 @@ jobs: ANNOTATION_LEVEL: ${{ matrix.ktlo && 'warning' || 'error' }} strategy: fail-fast: false - matrix: - # #1633: the supported tier, 3.11-3.15, blocks through `required-checks`. - python-version: - - "3.11" - - "3.12" - - "3.13" - - "3.14" - - "3.15" - # Exactly one leg runs under coverage, so measuring it adds no leg to - # the queue. 3.14 because it had the most headroom under the 45-minute - # cap on main (15.6, 15.8 and 16.8 minutes - # in runs 37393532176, 37392964804 and 37391296686, against 12.8-22.0 - # for the other four), and because coverage's sys.monitoring core, - # which measures branches only from 3.14 on, is its cheapest tracer. - include: - - python-version: "3.14" - coverage: true - # The KTLO tier (#1633): end-of-life versions that still build and - # test but never block. `required-checks` reads this job's one - # `needs.test.result`, so a KTLO leg cannot fail the job: each step - # that can fail on one carries `continue-on-error: ${{ matrix.ktlo - # == true }}` instead -- the step-level form, whose effect on the job - # is documented, as for engine-tests' KTLO cell. The leg concludes - # success, and the job's last step, `KTLO summary`, reports each - # failed step as a warning (#1672). 3.8 and 3.9 need - # ubuntu-24.04: setup-python has no Ubuntu 26.04 build of either, and - # `ubuntu-latest` moves to 26.04 from 2026-10-19. - # - # 3.7 needs ubuntu-22.04, the newest image setup-python builds it for - # (3.7.17). It cannot install today -- the `test` extra's `pytest>=8` - # needs 3.8, and the sources use syntax 3.7 cannot parse -- so the leg - # stops at its install step, with one annotation naming pip's reason - # (see that step). ubuntu-22.04 is deprecated: brownouts from - # 2027-03-23, retirement on 2027-04-17 (actions/runner-images#14254). - # A brownout fails the leg before any step runs, which no step-level - # `continue-on-error` can contain, so this leg must go before then. - - python-version: "3.7" - ktlo: true - os: ubuntu-22.04 - - python-version: "3.8" - ktlo: true - os: ubuntu-24.04 - - python-version: "3.9" - ktlo: true - os: ubuntu-24.04 - - python-version: "3.10" - ktlo: true + # #1660: .github/python-versions.toml's CPython tiers, through the + # `python-versions` job. The supported tier is the `python-version` axis + # and blocks through `required-checks` (#1633); `include` marks one leg + # `coverage: true` and adds each KTLO leg with `ktlo: true`. + # + # Exactly one leg runs under coverage, so measuring it adds no leg to + # the queue. It is `latest`, the newest supported version, like every + # single-interpreter job (#1661). Uncovered, 3.15's tests step took a + # median 8.1 minutes (max 10.4) in runs 38428091094-38433484101, and + # coverage cost 1.8x on 3.15 locally (2.0x on 3.14): a step of about + # 15-19 minutes under the job's 45. + # + # The KTLO tier (#1633): end-of-life versions that still build and + # test but never block. `required-checks` reads this job's one + # `needs.test.result`, so a KTLO leg cannot fail the job: each step + # that can fail on one carries `continue-on-error: ${{ matrix.ktlo + # == true }}` instead -- the step-level form, whose effect on the job + # is documented, as for engine-tests' KTLO cell. The leg concludes + # success, and the job's last step, `KTLO summary`, reports each + # failed step as a warning (#1672). A version `ubuntu-latest` cannot + # provide carries its `runner` from the file as `os`: 3.8 and 3.9 need + # ubuntu-24.04, as setup-python has no Ubuntu 26.04 build of either + # and `ubuntu-latest` moves to 26.04 from 2026-10-19. + # + # 3.7 needs ubuntu-22.04, the newest image setup-python builds it for + # (3.7.17). It cannot install today -- the `test` extra's `pytest>=8` + # needs 3.8, and the sources use syntax 3.7 cannot parse -- so the leg + # stops at its install step, with one annotation naming pip's reason + # (see that step). ubuntu-22.04 is deprecated: brownouts from + # 2027-03-23, retirement on 2027-04-17 (actions/runner-images#14254). + # A brownout fails the leg before any step runs, which no step-level + # `continue-on-error` can contain, so this leg must go before then. + matrix: ${{ fromJSON(needs.python-versions.outputs.test) }} steps: - uses: actions/checkout@v7 @@ -226,6 +254,7 @@ jobs: shell: bash env: SHA: ${{ github.sha }} + PYTHON: ${{ matrix.python-version }} TESTS_OUTCOME: ${{ steps.tests.outcome }} run: | set -euo pipefail @@ -290,7 +319,7 @@ jobs: json.dump({'total': total, 'tests': tests, 'packages': rows}, open('coverage-comment/coverage-summary.json', 'w')) status = '' if tests == 'success' else f' -- **tests {tests}**, so this is partial' - print(f"**Coverage: {total:.2f}%**{status} (unit tier, Python 3.14, `{os.environ['SHA'][:9]}`)") + print(f"**Coverage: {total:.2f}%**{status} (unit tier, Python {os.environ['PYTHON']}, `{os.environ['SHA'][:9]}`)") print() print('| Package | Statements | Missed | Branches | Partial | Cover |') print('|---|--:|--:|--:|--:|--:|') @@ -378,7 +407,7 @@ jobs: integration: name: Integration Python ${{ matrix.python-version }}${{ matrix.ktlo && ' (KTLO)' || '' }} if: ${{ inputs.gate-only != true && needs.changes.outputs.code != 'false' }} - needs: [changes] + needs: [changes, python-versions] runs-on: ${{ matrix.os || 'ubuntu-latest' }} timeout-minutes: 45 # See the `test` job above. @@ -386,26 +415,9 @@ jobs: ANNOTATION_LEVEL: ${{ matrix.ktlo && 'warning' || 'error' }} strategy: fail-fast: false - matrix: - # The two tiers of the `test` job above, for the same reasons. - python-version: - - "3.11" - - "3.12" - - "3.13" - - "3.14" - - "3.15" - include: - - python-version: "3.7" - ktlo: true - os: ubuntu-22.04 - - python-version: "3.8" - ktlo: true - os: ubuntu-24.04 - - python-version: "3.9" - ktlo: true - os: ubuntu-24.04 - - python-version: "3.10" - ktlo: true + # The two tiers of the `test` job above, for the same reasons, without + # its coverage leg. + matrix: ${{ fromJSON(needs.python-versions.outputs.integration) }} steps: - uses: actions/checkout@v7 @@ -609,15 +621,17 @@ jobs: # every annotation titled by engine and Python version and a per-cell row in # the job summary. # - # #1633 splits the interpreters into two tiers. The supported cells, 3.11-3.15, - # share one step, and any one of them failing fails the job. 3.10 is KTLO: - # end-of-life, still tested on every run, never blocking. It is its own step - # with step-level `continue-on-error: true` -- the treatment #845 gave the - # then-prerelease 3.15 cell -- so a 3.10 failure is reported as warnings and a - # job-summary row (#1672) while the job, and `required-checks`, which reads its - # result, stay green. 3.7-3.9 are not cells here: setup-python has no - # Ubuntu 26.04 build of any of them, and this job installs every interpreter - # on `ubuntu-latest`; PCAP_CT's extra is also `>= '3.10'`. + # #1633 splits the interpreters into two tiers, read from + # .github/python-versions.toml by `python-versions` (#1660). The supported + # cells share one step, and any one of them failing fails the job. The KTLO + # cells (3.10 today) are end-of-life, still tested on every run, never + # blocking. They are a step of their own with step-level `continue-on-error: + # true` -- the treatment #845 gave the then-prerelease 3.15 cell -- so a KTLO + # failure is reported as warnings and a job-summary row (#1672) while the + # job, and `required-checks`, which reads its result, stay green. A version + # with a `runner` in that file is no cell here, since this job installs every + # interpreter on `ubuntu-latest`: so not 3.7-3.9, which setup-python builds + # for no Ubuntu 26.04. PCAP_CT's extra is also `>= '3.10'`. # # Scoped deliberately out (#845): per-engine integration coverage (needs # regenerated captures, so it stays in aggregate in `integration` and @@ -627,7 +641,7 @@ jobs: engine-tests: name: Engines (${{ matrix.engine }}) if: ${{ inputs.gate-only != true && needs.changes.outputs.code != 'false' }} - needs: [changes] + needs: [changes, python-versions] runs-on: ubuntu-latest timeout-minutes: 30 env: @@ -649,23 +663,21 @@ jobs: steps: - uses: actions/checkout@v7 - # Each interpreter is then reachable as `python3.X`. The KTLO one is - # installed by a step of its own so that losing it cannot fail the job; - # it comes first because the last version installed is the default - # `python`. The supported five share the second step. + # Each interpreter is then reachable as `python3.X`. The KTLO ones are + # installed by a step of their own so that losing one cannot fail the + # job; it comes first because the last version installed is the default + # `python`, and is skipped when the tier has no cell here. The supported + # ones share the second step. Each output is one version per line, the + # list form setup-python reads. - uses: actions/setup-python@v7 + if: ${{ needs.python-versions.outputs.engine-ktlo != '' }} continue-on-error: true with: - python-version: "3.10" + python-version: ${{ needs.python-versions.outputs.engine-ktlo }} - uses: actions/setup-python@v7 with: - python-version: | - 3.11 - 3.12 - 3.13 - 3.14 - 3.15 + python-version: ${{ needs.python-versions.outputs.engine-supported }} # Only the engines that need a system package get one: # @@ -971,13 +983,14 @@ jobs: # Every cell runs even after one fails, so one red run reports all of # them; the step fails at the end if any did. ANNOTATION_LEVEL is the # level of the cell script's annotations (#1672). - - name: Run the 3.11-3.15 cells + - name: Run the supported cells shell: bash env: + PYTHONS: ${{ needs.python-versions.outputs.engine-supported }} ANNOTATION_LEVEL: error run: | failed=() - for py in 3.11 3.12 3.13 3.14 3.15; do + for py in $PYTHONS; do echo "::group::Python $py ($ENGINE)" if ! bash "$RUNNER_TEMP/engine-cell.sh" "$py"; then failed+=("$py") @@ -991,24 +1004,31 @@ jobs: fi # KTLO, non-blocking (#1633) -- see this job's comment. Runs on every event, - # as the 3.10 cell did while it was supported. #1672: this step is the - # KTLO cell's report, as `KTLO summary` is a KTLO leg's in the matrix jobs. - # A failure is a job-summary row and warnings, the cell's own included, - # and an interpreter the setup step above could not install fails here. - - name: Run the 3.10 cell (KTLO, non-blocking) + # as the 3.10 cell did while it was supported; with no KTLO cell it loops + # over nothing. #1672: this step is the KTLO cells' report, as `KTLO + # summary` is a KTLO leg's in the matrix jobs. A failure is a job-summary + # row and warnings, the cell's own included, and an interpreter the setup + # step above could not install fails here. + - name: Run the KTLO cells (non-blocking) shell: bash continue-on-error: true env: + PYTHONS: ${{ needs.python-versions.outputs.engine-ktlo }} ANNOTATION_LEVEL: warning run: | - echo "::group::Python 3.10 ($ENGINE)" - if ! bash "$RUNNER_TEMP/engine-cell.sh" 3.10; then - echo "| 3.10 | $ENGINE | | FAILED (KTLO, non-blocking) |" >> "$GITHUB_STEP_SUMMARY" + failed=() + for py in $PYTHONS; do + echo "::group::Python $py ($ENGINE)" + if ! bash "$RUNNER_TEMP/engine-cell.sh" "$py"; then + failed+=("$py") + echo "| $py | $ENGINE | | FAILED (KTLO, non-blocking) |" >> "$GITHUB_STEP_SUMMARY" + fi echo "::endgroup::" - echo "::warning title=$ENGINE failed on Python 3.10 (KTLO%2C non-blocking)::3.10 is end-of-life and KTLO per #1633; this does not fail the job." + done + if [ "${#failed[@]}" -ne 0 ]; then + echo "::warning title=$ENGINE failed on Python ${failed[*]} (KTLO%2C non-blocking)::${failed[*]} is end-of-life and KTLO per #1633; this does not fail the job." exit 1 fi - echo "::endgroup::" # Whether upstream PyPCAP is worth building in CI at all -- #751's own # instruction was "try to build and if the CI is not a good suit, then we @@ -1038,13 +1058,15 @@ jobs: # The matrix is deliberately just 3.10 and 3.11, not every version: PyPCAP's # marker and PyPCAPFile's marker are both "python_version < '3.12'", so # 3.12 and later would install neither extra here and pay this job's full - # fixture-tier run for zero new coverage. 3.11 is the one supported leg; 3.10 - # is KTLO (#1633), non-blocking through step-level `continue-on-error`, for - # the reason given on the `test` job's matrix. + # fixture-tier run for zero new coverage. util/python_versions.py takes the + # versions below 3.12 that run on `ubuntu-latest` from both tiers (#1660). + # 3.11 is the one supported leg; 3.10 is KTLO (#1633), non-blocking through + # step-level `continue-on-error`, for the reason given on the `test` job's + # matrix. pypcap-parity: name: PyPCAP/PyPCAPFile parity Python ${{ matrix.python-version }}${{ matrix.ktlo && ' (KTLO)' || '' }} if: ${{ inputs.gate-only != true && needs.changes.outputs.code != 'false' }} - needs: [changes] + needs: [changes, python-versions] runs-on: ubuntu-latest timeout-minutes: 45 env: @@ -1059,13 +1081,7 @@ jobs: ANNOTATION_LEVEL: ${{ matrix.ktlo && 'warning' || 'error' }} strategy: fail-fast: false - matrix: - python-version: - - "3.10" - - "3.11" - include: - - python-version: "3.10" - ktlo: true + matrix: ${{ fromJSON(needs.python-versions.outputs.pypcap-parity) }} steps: - uses: actions/checkout@v7 @@ -1456,9 +1472,15 @@ jobs: steps: - uses: actions/checkout@v7 + # `latest` from .github/python-versions.toml, read here rather than + # through `python-versions`: see that job's comment. + - name: Read the Python versions + id: python-versions + run: python3 util/python_versions.py --github-output + - uses: actions/setup-python@v7 with: - python-version: "3.14" + python-version: ${{ steps.python-versions.outputs.latest }} cache: pip # DPKT/crypto/NGAP join `test` for the same reason the `test` job above @@ -1510,9 +1532,14 @@ jobs: steps: - uses: actions/checkout@v7 + # See `unittest-ordering` above. + - name: Read the Python versions + id: python-versions + run: python3 util/python_versions.py --github-output + - uses: actions/setup-python@v7 with: - python-version: "3.14" + python-version: ${{ steps.python-versions.outputs.latest }} # No install step: the generator imports argparse, difflib, pathlib, re, sys # and typing and nothing else, so it runs against a bare interpreter. @@ -1620,9 +1647,14 @@ jobs: steps: - uses: actions/checkout@v7 + # See `unittest-ordering` above. + - name: Read the Python versions + id: python-versions + run: python3 util/python_versions.py --github-output + - uses: actions/setup-python@v7 with: - python-version: "3.14" + python-version: ${{ steps.python-versions.outputs.latest }} cache: pip - name: Install package and test dependencies @@ -1642,9 +1674,12 @@ jobs: # all, on one interpreter. It is deliberately not the matrix -- the matrix # has already run against this very commit, from this workflow's own push or # pull_request trigger, before anything reaches a release path. + # + # On `latest`, from `python-versions`, which its name needs too (#1661). gate: - name: Gate (full suite, Python 3.14) + name: Gate (full suite, Python ${{ needs.python-versions.outputs.latest }}) if: ${{ inputs.gate-only == true }} + needs: [python-versions] runs-on: ubuntu-latest timeout-minutes: 45 @@ -1653,7 +1688,7 @@ jobs: - uses: actions/setup-python@v7 with: - python-version: "3.14" + python-version: ${{ needs.python-versions.outputs.latest }} cache: pip # DPKT joins Scapy here for the same reason as the `integration` job @@ -1666,7 +1701,7 @@ jobs: # above, which each cover only the subset their own selection reaches. # PyPCAPFile is deliberately NOT added here -- see the `test` job's # comment above for the bug that used to make installing it a - # regression, now fixed by #743/#746. This job runs on 3.14 only, where + # regression, now fixed by #743/#746. This job runs on `latest` only, where # PyPCAPFile's marker resolves to nothing regardless, so adding it here # would be a no-op that misleadingly suggests the extra is exercised by # `gate`; #751's `engine-tests` and `pypcap-parity` jobs cover it for diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e40002e98d..5dfef6eac2 100755 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -151,7 +151,8 @@ make vermin # minimum-Python-version check Four of them — `pylint`, `mypy`, `bandit` and `vermin` — also run in CI as the `Lint` job in `.github/workflows/lint.yml`: on every pull request against `main`, on a weekly Saturday schedule -and on demand through `workflow_dispatch`, on Python 3.14 alone rather than across the test matrix. +and on demand through `workflow_dispatch`, on the newest supported Python alone (`latest` in +`.github/python-versions.toml`) rather than across the test matrix. The job drives them through the `Makefile` rather than restating their flags (`vermin` by its `vermin-ci` variant), with `RUN=` emptying the `pipenv run` prefix. Both paths read the same flag variables, so a check cannot come out clean locally and red in CI because the two drifted. diff --git a/docs/source/contributing/conventions/test-coverage.rst b/docs/source/contributing/conventions/test-coverage.rst index 64bc010e45..49085b5596 100644 --- a/docs/source/contributing/conventions/test-coverage.rst +++ b/docs/source/contributing/conventions/test-coverage.rst @@ -100,7 +100,7 @@ the fix back, and say in the pull request that you did: git apply /tmp/fix.diff python -m pytest tests//test__unit.py -q # must pass -CI measures one leg, the ``test`` job's Python 3.14 cell in +CI measures one leg, the ``test`` job's Python 3.15 cell in :file:`.github/workflows/unit-tests.yml`, with branch coverage over :mod:`pcapkit` configured by :file:`.github/coverage.toml`; :file:`.github/workflows/coverage-comment.yml` posts the totals on the pull request. Locally, measure with ``coverage run`` rather than diff --git a/docs/source/contributing/releasing.rst b/docs/source/contributing/releasing.rst index d815b5db3d..9edba5de4c 100644 --- a/docs/source/contributing/releasing.rst +++ b/docs/source/contributing/releasing.rst @@ -48,23 +48,23 @@ Editing ``__version__`` by hand without also moving :file:`CITATION.cff`'s ``tests/project/test_bump_version.py``'s ``RepositoryCitationTests.test_the_citation_file_names_the_packaged_version`` (``:494-514``) asserts the two agree, and ``create-release.yml``'s -``unit-tests`` job (``:401-415``) calls ``unit-tests.yml`` with +``unit-tests`` job (``:419-433``) calls ``unit-tests.yml`` with ``gate-only: true``, which runs the **full** suite rather than the tiered -subset an ordinary push runs (its ``gate`` job, ``unit-tests.yml:1645-1714``). +subset an ordinary push runs (its ``gate`` job, ``unit-tests.yml:1679-1749``). That gate runs whenever a release will -- a moved ``__version__`` is exactly what makes ``version_check``'s evidence read "not yet published" -- and every -publishing job requires it to have succeeded (``:434,619,784,1026``), so a +publishing job requires it to have succeeded (``:452,637,802,1037``), so a citation left behind fails the gate well before any approval is requested. Either run the script, or move both fields by hand in the same commit. This is the **only** edit a person makes to get a release started -- the tag, the Release, and every upload are the workflow's job from here. Two other checks run unconditionally on the same path, though neither is a step in *this* -process: the ``changelog`` job (``unit-tests.yml:1505-1526``) fails outright if +process: the ``changelog`` job (``unit-tests.yml:1527-1546``) fails outright if ``CHANGELOG.md`` has drifted from its source entry under :file:`docs/source/changelog/`, and the release-body step warns, without failing, if that entry's heading still reads "unreleased" -(``create-release.yml:488-489``). Both enforce what the changelog convention +(``create-release.yml:506-507``). Both enforce what the changelog convention already asks for. Two Ways to Start a Release @@ -79,10 +79,10 @@ neither is a workaround for the other: release, not which version: ``version_check``'s checkout (``:216-220``) pins ``ref: ${{ github.sha }}``, which on this trigger is the commit the pushed tag points at, and reads ``pcapkit.__version__`` out - of *that* tree (``:237``). Every downstream tag and release name is built + of *that* tree (``:244``). Every downstream tag and release name is built from that output -- ``v${{ needs.version_check.outputs.PCAPKIT_VERSION }}`` - is ``github``'s ``tag_name`` at ``:530`` and the release ``pypi`` and - ``conda`` upload to at ``:933`` and ``:1283``, always from + is ``github``'s ``tag_name`` at ``:548`` and the release ``pypi`` and + ``conda`` upload to at ``:944`` and ``:1290``, always from ``PCAPKIT_VERSION``, never from ``github.ref_name`` -- so the version actually released is whatever ``__version__`` says on the tagged commit, not the name of the tag that was pushed. The two agree only if the tag @@ -126,19 +126,19 @@ days later, and tagged ``main``'s tip ``2c0008c3c`` as both ``v1.5.0b9`` and the default branch, and ``tag`` pulled ``main`` before tagging. * On ``workflow_run``, where ``github`` creates the ``v*`` tag, it passes - ``target_commitish`` ``github.sha`` (``:543``); on a ``v*`` push the tag + ``target_commitish`` ``github.sha`` (``:561``); on a ``v*`` push the tag already exists and it passes none. Either way it then checks that the tag names that commit. -* ``tag`` cuts ``conda-+0`` at that commit (``:656-683``) before it +* ``tag`` cuts ``conda-+0`` at that commit (``:674-701``) before it reads ``main`` at all; the :file:`conda/build` reset is a separate commit on - top of ``main`` (``:705-758``), described under `Precautions`_. + top of ``main`` (``:723-776``), described under `Precautions`_. * ``pypi`` and ``conda`` check out that commit and, before building, check that every tag they publish under names it, as ``tag`` does for the ``v*`` tag. A run created for a *later* commit of the same version therefore fails with an ``::error`` instead of publishing a second tree under that version; a partial release is finished by re-running the run that tagged it. * ``pypi`` and ``conda`` attach their files with ``gh release upload`` - (``:926-963``, ``:1278-1313``), not ``action-gh-release``, which calls Update + (``:937-974``, ``:1285-1320``), not ``action-gh-release``, which calls Update a release before every upload (3.0.2's ``src/github.ts:631-683``). A file the release already has is left alone (`#1640 `__): the action @@ -232,11 +232,11 @@ The owner's choice on `#1634 the release with a GitHub App installation token, which Create a release accepts for a target that changes a workflow file when it has the "Workflows" repository permission (write). "Mint the release App's token" -(``create-release.yml:503-511``) runs +(``create-release.yml:521-529``) runs ``actions/create-github-app-token@v3`` with ``permission-contents: write`` and ``permission-workflows: write`` and no ``owner`` or ``repositories``, so the token is "scoped to only the current repository" and revoked in the action's -``post`` step (v3.3.0's README). Only Create Release spends it (``:547``). +``post`` step (v3.3.0's README). Only Create Release spends it (``:565``). While ``vars.RELEASE_APP_CLIENT_ID`` is unset, which reads as "an empty string", the step skips and Create Release spends ``GITHUB_TOKEN``, so the workflow keeps working, refusals included, until the App exists. A PAT would @@ -261,13 +261,13 @@ the release test gate, ask for a second approval and race the first run's uploads from another concurrency group, so ``version_check`` skips a ``push`` whose ``github.actor_id`` is ``vars.RELEASE_APP_BOT_ID`` (``:210-212``), and ``release_status`` reports it with ``::notice title=Nothing to release`` -(``:1385-1387``). A tag pushed by a person runs as before. Whether a tag made +(``:1392-1394``). A tag pushed by a person runs as before. Whether a tag made through the Releases API fires ``push``, and with the bot as actor, is unmeasured; if not, the guard never matches and the cost is one approval to reject. A refused Create Release (404, or 403 "Resource not accessible by -integration") is explained by "Explain a refused release tag" (``:580-592``) +integration") is explained by "Explain a refused release tag" (``:598-610``) according to the token spent: with ``GITHUB_TOKEN`` it lists the setup above; with the App's, it points at the App's permissions and installation. For a final release, first delete any draft ``v`` the refused run left: @@ -302,7 +302,7 @@ checks *is* the one it is about to create: * ``tag`` checks whether ``conda-+0``, the tag *it* creates, already exists. * ``pypi`` compares PyPI's file count for the version against the 7 expected - -- the 6 supported legs' wheels plus the sdist kept only on the 3.14 leg; the + -- the 6 supported legs' wheels plus the sdist kept only on the 3.15 leg; the KTLO legs' wheels (#1633) are not counted. This job is already idempotent (``skip-existing: true`` on both upload steps), so the check exists only to save the cost of the build matrix, not for correctness. @@ -404,8 +404,8 @@ Only ``github-release`` carries a required reviewer; ``conda-tag``, removed on `#887 `__. What still makes one approval mean *the whole release* is the ``needs:`` graph: ``tag`` and ``pypi`` both depend on ``github`` -(``.github/workflows/create-release.yml:619,784``), and ``conda`` depends on -``tag`` and ``github`` (``:1026``) -- so nothing downstream of ``github`` can +(``.github/workflows/create-release.yml:637,802``), and ``conda`` depends on +``tag`` and ``github`` (``:1037``) -- so nothing downstream of ``github`` can start before it is approved, and rejecting it leaves nothing tagged and nothing published. Before this change ``tag`` depended on ``version_check`` alone, so removing its reviewer without moving this dependency would have let @@ -452,7 +452,7 @@ back, and neither is needed, since a plain re-run self-heals; see **``environment: pypi`` stays even though its reviewer is gone.** ``pypi`` publishes through PyPI's OIDC trusted publishing (``environment: pypi`` at -``:779``, ``id-token: write`` at ``:783``), and PyPI's trusted-publisher +``:797``, ``id-token: write`` at ``:801``), and PyPI's trusted-publisher configuration can be scoped to a GitHub Actions environment name. If this project's publisher on PyPI is scoped that way, removing the ``pypi`` name -- not just its reviewer -- would break the upload with a claim mismatch; @@ -463,7 +463,7 @@ the name regardless, since there is no upside to removing it. **``conda-tag`` writes to ``main``, so a release is not read-only on the branch.** After cutting ``conda-+0`` at the run's commit, the ``tag`` job pushes a commit setting :file:`conda/build` to ``0`` on top of -``main`` (``.github/workflows/create-release.yml:705-758``) -- a plain +``main`` (``.github/workflows/create-release.yml:723-776``) -- a plain fast-forward push, never a rewrite -- so the next Conda Update bump of that version is ``+1``. There is none when the run's commit is not on ``main``, nor when ``main``'s tip already says ``0`` (the usual case, since diff --git a/docs/source/contributing/workflows.rst b/docs/source/contributing/workflows.rst index 1d03fda881..cdbfdb0780 100644 --- a/docs/source/contributing/workflows.rst +++ b/docs/source/contributing/workflows.rst @@ -259,7 +259,7 @@ the caller's own workflow run, as one of the caller's own jobs, rather than starting an independent run after the caller finishes. All four calls in this repository target the same reusable workflow, with the same input: -* ``.github/workflows/create-release.yml:413`` -- job ``unit-tests`` calls +* ``.github/workflows/create-release.yml:431`` -- job ``unit-tests`` calls ``./.github/workflows/unit-tests.yml`` with ``gate-only: true``. * ``.github/workflows/cron-conda.yml:50`` -- job ``unit-tests`` calls ``./.github/workflows/unit-tests.yml`` with ``gate-only: true``. @@ -268,9 +268,10 @@ repository target the same reusable workflow, with the same input: * ``.github/workflows/deploy-pages.yml:55`` -- job ``unit-tests`` calls ``./.github/workflows/unit-tests.yml`` with ``gate-only: true``. -``gate-only: true`` selects **two** of ``unit-tests.yml``'s **ten** jobs, -not one: ``gate`` (the full suite, one Python version) and ``changelog`` -(checks ``CHANGELOG.md`` against its source entry). It skips the other +``gate-only: true`` selects **three** of ``unit-tests.yml``'s **eleven** jobs, +not one: ``gate`` (the full suite, on ``latest``), ``changelog`` +(checks ``CHANGELOG.md`` against its source entry) and ``python-versions``, +which reads ``gate``'s version (see `Python Versions`_ below). It skips the other **eight** -- the five matrix jobs, ``test`` (nine Python legs: five supported, four KTLO), ``integration`` (nine), ``engine-tests`` (one job for each of five engines, each looping over six Python versions), @@ -284,7 +285,7 @@ and ``project-tests``, and ``required-checks`` (see `Required Status Checks`_ below), gated out by their own ``if:`` rather than by having already run. ``changelog`` carries no ``if:`` at all and runs on every path regardless -- deliberately, per its own -comment (``unit-tests.yml:1493-1499``): ``create-release.yml`` feeds +comment (``unit-tests.yml:1515-1521``): ``create-release.yml`` feeds ``CHANGELOG.md`` to the GitHub Release body, so the release path is exactly where a drifted file must not go unchecked. Confirmed on run `36210743295 `__ (a @@ -315,13 +316,13 @@ anything other than ``success`` -- including ``skipped``, which is exactly what Vendor Update's *own* jobs report when *their* ``workflow_run``-path guards decline (see ``cron-vendor.yml``'s ``vendor-update`` job). ``unit-tests`` then depends on that job with -``needs: [ version_check ]`` (``create-release.yml:403``) and an ``if:`` that +``needs: [ version_check ]`` (``create-release.yml:421``) and an ``if:`` that calls no status-check function, so a skipped dependency skips it too -- and every job past it is gated the same way: ``github`` needs ``[ unit-tests, -version_check ]`` (``:434``), ``tag`` needs ``[ github, unit-tests, -version_check ]`` (``:619``), ``pypi`` needs ``[ github, unit-tests, -version_check ]`` (``:784``), and ``conda`` needs ``[ tag, github, -unit-tests, version_check ]`` (``:1026``). +version_check ]`` (``:452``), ``tag`` needs ``[ github, unit-tests, +version_check ]`` (``:637``), ``pypi`` needs ``[ github, unit-tests, +version_check ]`` (``:802``), and ``conda`` needs ``[ tag, github, +unit-tests, version_check ]`` (``:1037``). .. mermaid:: @@ -347,7 +348,7 @@ unit-tests, version_check ]`` (``:1026``). This is a transitive reduction, as in :doc:`releasing`: ``version_check`` and ``unit-tests`` are also direct ``needs:`` of ``tag`` and ``pypi`` -(``create-release.yml:619,784``) and of ``conda`` (``:1026``), but ``github`` +(``create-release.yml:637,802``) and of ``conda`` (``:1037``), but ``github`` already implies those edges, since it depends on both itself. Drawing them would add lines without changing which job can start before which, or which job a skip in ``version_check`` reaches. @@ -386,7 +387,7 @@ Coverage (`#1063 `__) ------------------------------------------------------------------------- Coverage is measured inside an existing leg rather than by a workflow of its -own: the ``Python 3.14`` leg of ``test`` (matrix key ``coverage: true``) runs +own: the ``Python 3.15`` leg of ``test`` (matrix key ``coverage: true``) runs its unchanged pytest selection under ``coverage run --rcfile=.github/coverage.toml``. That rcfile repeats :file:`pyproject.toml`'s ``[tool.coverage.*]`` settings @@ -552,41 +553,42 @@ grepping every workflow file for its name: - Where * - ``Required checks passed`` - job ``required-checks`` - - ``unit-tests.yml:1806`` + - ``unit-tests.yml:1841`` * - ``Compat Python 3.11`` - job ``compatibility``, matrix leg ``3.11`` - - ``python-compatibility.yml:39,47`` + - ``python-compatibility.yml:60``, ``.github/python-versions.toml:20`` * - ``Compat Python 3.12`` - job ``compatibility``, matrix leg ``3.12`` - - ``python-compatibility.yml:39,48`` + - ``python-compatibility.yml:60``, ``.github/python-versions.toml:21`` * - ``Compat Python 3.13`` - job ``compatibility``, matrix leg ``3.13`` - - ``python-compatibility.yml:39,49`` + - ``python-compatibility.yml:60``, ``.github/python-versions.toml:22`` * - ``Compat Python 3.14`` - job ``compatibility``, matrix leg ``3.14`` - - ``python-compatibility.yml:39,50`` + - ``python-compatibility.yml:60``, ``.github/python-versions.toml:23`` * - ``Compat Python 3.15`` - job ``compatibility``, matrix leg ``3.15`` - - ``python-compatibility.yml:39,51`` + - ``python-compatibility.yml:60``, ``.github/python-versions.toml:24`` That is the list after `#1633 `__, which added ``Compat Python 3.15`` and removed ``Compat Python 3.10``. Moving a version between the tiers below means editing the ruleset in the same change. ``Required checks passed`` is defined by exactly one job -(``unit-tests.yml:1806``). ``Compat Python`` is defined by one, the -``compatibility`` job in ``python-compatibility.yml`` (``:39``), expanding to -the five required legs at ``:47-51`` and to four KTLO legs at ``:56-66`` whose -names end in ``(KTLO)``, so no required context matches one. A comment at -``unit-tests.yml:1717`` mentions the string without defining it. +(``unit-tests.yml:1841``). ``Compat Python`` is defined by one, the +``compatibility`` job in ``python-compatibility.yml`` (``:60``), whose matrix +(``:74``) comes from :file:`.github/python-versions.toml`: the five required legs +are its supported tier (``:20-24``), and its four KTLO legs (``:16-19``) have +names ending in ``(KTLO)``, so no required context matches one. A comment at +``unit-tests.yml:1752`` mentions the string without defining it. ``Required checks passed`` is an aggregate, not a single check run. The ruleset requires it and the five ``Compat Python 3.11``-``3.15`` legs, nothing else, as -``unit-tests.yml``'s own comment (``unit-tests.yml:1716-1726``) records. It +``unit-tests.yml``'s own comment (``unit-tests.yml:1751-1761``) records. It ``needs:`` the gating jobs of ``unit-tests.yml`` -- ``test``, ``integration``, ``engine-tests`` and ``pypcap-parity``, plus ``changes`` and ``project-tests`` for the docs-only path -- and checks each result explicitly -(``unit-tests.yml:1805-1859``). Rulesets match check names literally, with no +(``unit-tests.yml:1840-1894``). Rulesets match check names literally, with no wildcard, so requiring the matrix cells one by one would mean editing the ruleset whenever a matrix changes; the aggregate keeps the required list fixed. It accepts the four legs as ``skipped`` only on a docs-only pull request, where @@ -595,19 +597,20 @@ instead. The ``Compat`` legs stay required separately because they come from ``python-compatibility.yml``, and a job cannot ``needs:`` a job in another workflow file. This job runs on Unit Tests' own ``push``/``pull_request`` triggers; the ``gate-only: true`` reusable calls documented above skip it via -``if: ${{ always() && inputs.gate-only != true }}`` (``unit-tests.yml:1808``), +``if: ${{ always() && inputs.gate-only != true }}`` (``unit-tests.yml:1843``), so it is never produced -- and never expected -- on those paths. -Python versions come in two tiers (#1633). The **supported** tier, 3.11-3.15, -blocks: a failed leg fails ``Required checks passed`` or its own required -``Compat`` context. The **KTLO** tier -- end-of-life 3.7-3.10, still built and +Python versions come in two tiers (#1633), declared in +:file:`.github/python-versions.toml` (see `Python Versions`_). The **supported** +tier, 3.11-3.15, blocks: a failed leg fails ``Required checks passed`` or its own +required ``Compat`` context. The **KTLO** tier -- end-of-life 3.7-3.10, still built and tested -- never blocks, is never a required context, and never shows a commit or a pull request red (#1672). Each KTLO leg shares its job with supported legs, in ``unit-tests.yml``, ``python-compatibility.yml`` and ``create-release.yml``, and every such job works the same way: * Every step that can fail on a KTLO leg has an ``id`` and - ``continue-on-error: ${{ matrix.ktlo == true }}`` (``unit-tests.yml:78-87``), + ``continue-on-error: ${{ matrix.ktlo == true }}`` (``unit-tests.yml:115-125``), unless it is masked on every leg, as the timings uploads are. Its ``outcome`` is then ``failure`` but its ``conclusion`` ``success``, so the job, its check run and ``required-checks`` read ``success``. A step that needs an earlier one's work @@ -616,8 +619,8 @@ every such job works the same way: * The last step, ``KTLO summary``, runs on KTLO legs only and reports each failed ``outcome`` as a warning and a job-summary line. A step's own annotations take ``ANNOTATION_LEVEL``: ``warning`` on a KTLO leg, ``error`` on a supported one. - ``engine-tests``' 3.10 cell, a step of its own with ``continue-on-error: true``, - reports the same way. + ``engine-tests``' KTLO cells, a step of their own with ``continue-on-error: true``, + report the same way. * Never job-level ``continue-on-error``: it passes the *run*, but the leg's check run still concludes ``failure``, which showed every commit red while ``Compat Python 3.7 (KTLO)`` carried it. @@ -640,16 +643,43 @@ in no matrix: setup-python builds it only for retired Ubuntu images. Nor are are they in ``pypcap-parity``, which also runs on ``ubuntu-latest``; none of them was measured on that job. +Python Versions +--------------- + +Since `#1660 `__, +:file:`.github/python-versions.toml` declares every Python version CI runs: one +line per version, with its tier -- ``supported``, ``ktlo``, or ``pre-release``, +which none is today -- and, where ``ubuntu-latest`` cannot provide it, the +``runner`` it needs. ``latest`` names the newest supported CPython, which every +job on one interpreter runs. :file:`util/python_versions.py` reads the file with +the runner's own ``python3`` -- its ``tomllib`` needs 3.11, and every image that +runs it has 3.12 or newer -- and writes each matrix as JSON: a ``python-versions`` +job in ``unit-tests.yml`` and ``python-compatibility.yml``, ``version_check`` in +``create-release.yml`` and ``conda-update`` in ``cron-conda.yml`` hand theirs on +through ``fromJSON``, and each single-interpreter job reads ``latest``. + +**A tier change is a one-line edit to that file, plus the matching edit to ruleset +23497679's ``Compat`` contexts.** The file's ``exemptions`` list each pin kept +off ``latest``, with its reason in the comment above it, such as the conda +jobs' ``!= '3.15'``. ``tests/project/test_python_versions_1660.py`` fails on a +pin the file does not list, on an exemption with no comment, and on a +classifier, doc or comment that no longer matches the file -- the coverage +leg's version, the release checks' counts. Its tests, and the other tests that +read the file, skip on the KTLO interpreters, which have no ``tomllib``. Two +edits it names rather than makes: a new ``pypi`` KTLO leg needs its own +``ktlo_*`` output for ``release_status``, and a pre-release leg needs +non-blocking mechanics no workflow has yet, so the script refuses one. + Deployment ``environment:`` ---------------------------- Four jobs, all in ``create-release.yml``, declare a deployment ``environment:`` and can therefore pause for an approval: -* ``github`` -- ``environment: github-release`` (``create-release.yml:431``) -* ``tag`` -- ``environment: conda-tag`` (``create-release.yml:617``) -* ``pypi`` -- ``environment: pypi`` (``create-release.yml:779``) -* ``conda`` -- ``environment: anaconda`` (``create-release.yml:1023``) +* ``github`` -- ``environment: github-release`` (``create-release.yml:449``) +* ``tag`` -- ``environment: conda-tag`` (``create-release.yml:635``) +* ``pypi`` -- ``environment: pypi`` (``create-release.yml:797``) +* ``conda`` -- ``environment: anaconda`` (``create-release.yml:1034``) No other workflow in scope here declares one -- including ``deploy-pages.yml``, whose ``deploy-pages`` job pushes straight to the diff --git a/pyproject.toml b/pyproject.toml index a33750627f..dfd6387873 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,8 @@ license = { text="BSD 3-Clause License" } # reason to withdraw the declaration. Until it lands, sub-3.8 support is intent # rather than something that works. # -# Independently, CI has two tiers (#1633). **3.11 through 3.15** are supported and +# Independently, CI has two tiers (#1633), declared in .github/python-versions.toml +# (#1660), which a tier change edits. **3.11 through 3.15** are supported and # blocking. **3.7 through 3.10** are KTLO: end-of-life, still built and tested, never # blocking, and a failure there is a warning on a green leg (#1672). Each 3.7 leg # fails a step by design: the import fails as above, and the ``test`` extra's diff --git a/tests/project/test_ci_timing.py b/tests/project/test_ci_timing.py index ee9ee339ae..afca5e0e56 100644 --- a/tests/project/test_ci_timing.py +++ b/tests/project/test_ci_timing.py @@ -45,6 +45,15 @@ import zipfile from unittest import mock +from tests.project.test_python_versions_1660 import NO_TOMLLIB +from tests.project.test_python_versions_1660 import legs as matrix_legs +from tests.project.test_python_versions_1660 import matrix, tomllib + +# util/ci_timing.py reads .github/python-versions.toml with tomllib on import +# (#1660), and runs on ``latest`` only (ci-timing.yml); on 3.8-3.10 nothing here can. +if tomllib is None: + raise unittest.SkipTest(NO_TOMLLIB) + ROOT = pathlib.Path(__file__).resolve().parents[2] FIXTURES = pathlib.Path(__file__).resolve().parent / 'ci_timing_fixtures' WORKFLOWS = ROOT / '.github' / 'workflows' @@ -60,6 +69,10 @@ #: Runs per fake page, small so the paging is exercised. PAGE = 2 PR_RUN, CANCELLED_RUN, PUSH_RUN, TIMEOUT_RUN = 38001768735, 38002005234, 37998639834, 37323913186 +#: The coverage leg, and the legs paired with it, when the fixtures were recorded. +#: #1661 moved coverage to 3.15, which no fixture run has. +FIXTURE_COVERAGE_JOB = 'Python 3.14' +FIXTURE_PAIRED_JOBS = ('Python 3.11', 'Python 3.12', 'Python 3.13') JUNIT_ARTIFACT_ID = 11648726901 #: The day slices a 7-day window ending at :data:`NOW` is listed in. SLICES = [f'2026-10-{day:02d}T00:00:00Z..2026-10-{day:02d}T23:59:59Z' for day in range(9, 2, -1)] @@ -320,7 +333,9 @@ class TestEndToEnd(unittest.TestCase): def setUpClass(cls) -> 'None': cls.api = FakeAPI() # One or two runs per event in the fixtures, so the sample floor comes down. - with mock.patch.object(ci_timing, 'MIN_SAMPLES', 1): + with mock.patch.object(ci_timing, 'MIN_SAMPLES', 1), \ + mock.patch.object(ci_timing, 'COVERAGE_JOB', FIXTURE_COVERAGE_JOB), \ + mock.patch.object(ci_timing, 'PAIRED_JOBS', FIXTURE_PAIRED_JOBS): cls.status, cls.stdout, cls.report, cls.summary = run_main(api=cls.api) def ids(self) -> 'list[str]': @@ -422,9 +437,9 @@ def test_the_coverage_ratio_pairs_legs_within_a_run(self) -> 'None': steps = {job['name']: next(span(s['started_at'], s['completed_at']) for s in job['steps'] if s['name'] == ci_timing.TESTS_STEP) for job in jobs_of(run) if re.fullmatch(r'Python 3\.1\d', job['name'])} - paired = [steps[name] for name in ci_timing.PAIRED_JOBS if name in steps] + paired = [steps[name] for name in FIXTURE_PAIRED_JOBS if name in steps] if len(paired) >= 2: - ratios.append(steps[ci_timing.COVERAGE_JOB] / statistics.median(paired)) + ratios.append(steps[FIXTURE_COVERAGE_JOB] / statistics.median(paired)) self.assertEqual(len(ratios), 2) self.assertAlmostEqual(self.report['derived']['coverage_ratio']['median'], statistics.median(ratios)) @@ -711,12 +726,13 @@ def test_both_pytest_runs_write_the_file_the_upload_reads(self) -> 'None': self.assertIn('--durations=', section) def test_the_legs_the_review_reads_exist(self) -> 'None': - test = re.search(r'(?ms)^ test:\n(.*?)(?=^ [\w-]+:\n)', self.text).group(1) + """``test``'s legs, from .github/python-versions.toml since #1660.""" + found = matrix_legs(matrix('unit-tests.yml', 'test')) for leg in ci_timing.JUNIT_LEGS: with self.subTest(leg=leg): - self.assertIn(f'- "{leg}"', test) - coverage = re.search(r'include:\n\s+- python-version: "([\d.]+)"\n\s+coverage: true', test) - self.assertEqual(f'Python {coverage.group(1)}', ci_timing.COVERAGE_JOB) + self.assertIn(leg, [entry['python-version'] for entry in found if not entry.get('ktlo')]) + coverage = [entry['python-version'] for entry in found if entry.get('coverage')] + self.assertEqual([f'Python {version}' for version in coverage], [ci_timing.COVERAGE_JOB]) def test_the_reusable_workflow_asks_for_no_write_permission(self) -> 'None': """Its four callers grant ``contents: read``, so a write scope would break them.""" diff --git a/tests/project/test_ktlo_legs_stay_green_1672.py b/tests/project/test_ktlo_legs_stay_green_1672.py index 45e6096c0f..f45ac39205 100644 --- a/tests/project/test_ktlo_legs_stay_green_1672.py +++ b/tests/project/test_ktlo_legs_stay_green_1672.py @@ -38,6 +38,7 @@ from tests.project.test_python_matrix_tiers_1633 import (ANNOTATION_LEVEL, KTLO_GUARD, KTLO_LEGS, LEG_37, TITLE_37, is_ktlo, job, legs, titles, workflow, workflow_commands) +from tests.project.test_python_versions_1660 import live_outputs, resolve from tests.project.test_release_gates import NULL, STATUS, evaluate_condition, evaluate_expression if TYPE_CHECKING: @@ -67,14 +68,18 @@ ('unit-tests.yml', 'integration'): ('Upload test timings',), ('create-release.yml', 'pypi'): ('Publish package to TestPyPI',), } -#: ``engine-tests``'s KTLO steps: the 3.10 interpreter, and the 3.10 cell. -ENGINE_KTLO = ('Run the 3.10 cell (KTLO, non-blocking)',) +#: ``engine-tests``'s KTLO steps: the KTLO cells, and the setup step of their +#: interpreters, which reads :data:`ENGINE_KTLO_OUTPUT` (#1660). +ENGINE_KTLO = ('Run the KTLO cells (non-blocking)',) +ENGINE_KTLO_OUTPUT = '${{ needs.python-versions.outputs.engine-ktlo }}' #: The workflow commands a step a KTLO leg runs may write. KTLO_COMMANDS = frozenset({'${ANNOTATION_LEVEL}', 'warning', 'notice', 'group', 'endgroup'}) #: A workflow command a script writes, by its name. COMMAND = re.compile(r'''(?:echo|printf|print\(f?)\s*["']::([^\s:"']+)''') #: A status-check function: an ``if:`` holding none gets ``success() &&`` prepended. STATUS_FUNCTION = re.compile(r'\b(?:success|failure|cancelled|always)\(\)') +#: A ``needs`` output a step's ``if:`` reads, such as the tier file's (#1660). +NEEDS = re.compile(r'needs\.([\w-]+)\.outputs\.([\w-]+)') #: The outputs a step with an ``id`` is taken to set: ones that let every later step run. OUTPUTS = { 'apt-plan': {'key': 'apt-debs-ImageOS-X64-set-0123456789abcdef', 'restore-prefix': 'apt-debs-ImageOS-X64-set-'}, @@ -107,8 +112,8 @@ def summary_step(spec: 'dict[str, Any]') -> 'dict[str, Any]': def is_engine_ktlo(step: 'dict[str, Any]') -> 'bool': - """Whether ``step`` is one of ``engine-tests``'s, which run only the KTLO interpreter.""" - return label(step) in ENGINE_KTLO or str(step.get('with', {}).get('python-version')) == '3.10' + """Whether ``step`` is one of ``engine-tests``'s, which run only the KTLO interpreters.""" + return label(step) in ENGINE_KTLO or step.get('with', {}).get('python-version') == ENGINE_KTLO_OUTPUT def stringify(value: 'object') -> 'str': @@ -134,12 +139,29 @@ def leg_env(name: 'str', spec: 'dict[str, Any]', leg: 'dict[str, Any]') -> 'dict for key, value in merged.items()} +def needs_context(name: 'str', job_id: 'str', spec: 'dict[str, Any]') -> 'dict[str, Any]': + """The ``needs`` outputs the job's steps test, each as the tier file sets it (#1660). + + An output that does not come from :file:`util/python_versions.py` is unset. + + """ + found = {} # type: dict[str, Any] + for step in spec['steps']: + for target, output in NEEDS.findall(str(step.get('if', ''))): + try: + value = resolve(name, job_id, f'${{{{ needs.{target}.outputs.{output} }}}}') + except AssertionError: + value = None + found.setdefault(target, {'outputs': {}})['outputs'][output] = NULL if value is None else value + return found + + def continue_on_error(step: 'dict[str, Any]', context: 'dict[str, Any]') -> 'bool': value = step.get('continue-on-error', False) return value if isinstance(value, bool) else evaluate_condition(str(value), context) -def run_leg(name: 'str', spec: 'dict[str, Any]', leg: 'dict[str, Any]', +def run_leg(name: 'str', job_id: 'str', spec: 'dict[str, Any]', leg: 'dict[str, Any]', failing: 'int' = -1) -> 'tuple[str, list[Step]]': """The job's conclusion on ``leg``, and each step's, with step ``failing`` failing. @@ -149,11 +171,12 @@ def run_leg(name: 'str', spec: 'dict[str, Any]', leg: 'dict[str, Any]', """ env = leg_env(name, spec, leg) + needs = needs_context(name, job_id, spec) steps = {} # type: dict[str, dict[str, Any]] failed = False record = [] # type: list[Step] for index, step in enumerate(spec['steps']): - context = {'matrix': leg, 'env': env, 'steps': steps, 'inputs': {}, + context = {'matrix': leg, 'env': env, 'steps': steps, 'inputs': {}, 'needs': needs, STATUS: {'success': not failed, 'failure': failed, 'cancelled': False}} condition = '' if step.get('if') is None else str(step['if']) runs = STATUS_FUNCTION.search(condition) is not None or not failed @@ -172,11 +195,12 @@ def run_leg(name: 'str', spec: 'dict[str, Any]', leg: 'dict[str, Any]', return ('failure' if failed else 'success'), record -def scenarios(name: 'str', spec: 'dict[str, Any]', leg: 'dict[str, Any]') -> 'list[tuple[int, str, list[Step]]]': +def scenarios(name: 'str', job_id: 'str', spec: 'dict[str, Any]', + leg: 'dict[str, Any]') -> 'list[tuple[int, str, list[Step]]]': """``(failing step, job conclusion, steps)`` for each step that runs on ``leg``.""" found = [] for index in range(len(spec['steps'])): - conclusion, record = run_leg(name, spec, leg, failing=index) + conclusion, record = run_leg(name, job_id, spec, leg, failing=index) if record[index].outcome != 'skipped': found.append((index, conclusion, record)) return found @@ -207,16 +231,17 @@ def test_no_step_can_fail_a_ktlo_leg(self) -> None: for name, job_id in KTLO_JOBS: spec = job(name, job_id) blocking = BLOCKS_EVERY_LEG.get((name, job_id), ()) - for leg in filter(is_ktlo, legs(spec)): - runs = scenarios(name, spec, leg) + for leg in filter(is_ktlo, legs(name, job_id)): + runs = scenarios(name, job_id, spec, leg) self.assertGreater(len(runs), 3, f'{job_id} on {leg} runs almost nothing') for index, conclusion, _ in runs: step = label(spec['steps'][index]) with self.subTest(workflow=name, job=job_id, python=leg['python-version'], step=step): self.assertEqual(conclusion, 'failure' if step in blocking else 'success') spec = job('unit-tests.yml', 'engine-tests') - for leg in legs(spec): - ktlo = [(index, conclusion) for index, conclusion, _ in scenarios('unit-tests.yml', spec, leg) + for leg in legs('unit-tests.yml', 'engine-tests'): + ktlo = [(index, conclusion) + for index, conclusion, _ in scenarios('unit-tests.yml', 'engine-tests', spec, leg) if is_engine_ktlo(spec['steps'][index])] self.assertEqual(len(ktlo), 2, leg) for index, conclusion in ktlo: @@ -243,8 +268,8 @@ def test_every_failure_it_masks_reaches_the_summary(self) -> None: self.assertEqual(summary.get('env'), SUMMARY_ENV) scripts.add(summary['run']) blocking = BLOCKS_EVERY_LEG.get((name, job_id), ()) - for leg in filter(is_ktlo, legs(spec)): - for index, _, record in scenarios(name, spec, leg): + for leg in filter(is_ktlo, legs(name, job_id)): + for index, _, record in scenarios(name, job_id, spec, leg): step = spec['steps'][index] if step is summary or label(step) in blocking: continue @@ -270,7 +295,7 @@ def test_annotation_level_is_a_warning_on_a_ktlo_leg_only(self) -> None: spec = job(name, job_id) with self.subTest(workflow=name, job=job_id): self.assertEqual((spec.get('env') or {}).get('ANNOTATION_LEVEL'), ANNOTATION_LEVEL) - for leg in legs(spec): + for leg in legs(name, job_id): with self.subTest(workflow=name, job=job_id, python=leg['python-version']): self.assertEqual(leg_env(name, spec, leg)['ANNOTATION_LEVEL'], 'warning' if is_ktlo(leg) else 'error') @@ -280,8 +305,8 @@ def test_no_step_a_ktlo_leg_runs_writes_an_error(self) -> None: spec = job(name, job_id) blocking = BLOCKS_EVERY_LEG.get((name, job_id), ()) reached = set() # type: set[int] - for leg in filter(is_ktlo, legs(spec)): - for _, _, record in scenarios(name, spec, leg): + for leg in filter(is_ktlo, legs(name, job_id)): + for _, _, record in scenarios(name, job_id, spec, leg): reached.update(index for index, step in enumerate(record) if step.outcome != 'skipped') for index in sorted(reached): step = spec['steps'][index] @@ -371,33 +396,35 @@ def engine_cell(self, py: 'str', level: 'Optional[str]') -> 'subprocess.Complete cwd=self.tmp, capture_output=True, text=True, check=False, timeout=60) def test_the_engine_ktlo_cell_warns(self) -> None: - """The 3.10 cell's step, and the cell script it runs, write no error; 3.12's does.""" + """A KTLO cell's step, and the cell script it runs, write no error; a supported cell's does.""" steps = {label(step): step for step in job('unit-tests.yml', 'engine-tests')['steps']} - ktlo = steps['Run the 3.10 cell (KTLO, non-blocking)'] - self.assertEqual(ktlo.get('env'), {'ANNOTATION_LEVEL': 'warning'}) + ktlo = steps['Run the KTLO cells (non-blocking)'] + self.assertEqual(ktlo.get('env'), {'PYTHONS': ENGINE_KTLO_OUTPUT, 'ANNOTATION_LEVEL': 'warning'}) + ktlo_py = live_outputs()['engine-ktlo'].split()[0] + supported_py = live_outputs()['engine-supported'].split()[0] self.assertLessEqual(commands(ktlo['run']), {'group', 'endgroup', 'warning'}) cell = steps['Write the per-cell script']['run'].split("<<'CELL'\n", 1)[1].split('\nCELL\n', 1)[0] self.assertLessEqual(commands(cell), {'${ANNOTATION_LEVEL}', 'notice'}) expected = ("::{} title=Scapy failed to install on Python {}::This cell expects 'supported', " 'which requires a successful install. pip exited 1.') - for py, level in (('3.10', 'warning'), ('3.12', 'error')): + for py, level in ((ktlo_py, 'warning'), (supported_py, 'error')): with self.subTest(python=py): result = self.engine_cell(py, level) self.assertEqual(result.returncode, 1, result.stderr) self.assertEqual(workflow_commands(result.stdout), [expected.format(level, py)]) - unset = self.engine_cell('3.10', None) + unset = self.engine_cell(ktlo_py, None) self.assertNotEqual(unset.returncode, 0) self.assertIn('ANNOTATION_LEVEL: unbound variable', unset.stderr) (self.tmp / 'engine-cell.sh').write_text('exit 1\n', encoding='utf-8') page = self.tmp / 'cells.md' - result = bash(ktlo['run'], {'RUNNER_TEMP': str(self.tmp), 'ENGINE': 'Scapy', + result = bash(ktlo['run'], {'RUNNER_TEMP': str(self.tmp), 'ENGINE': 'Scapy', 'PYTHONS': ktlo_py, 'GITHUB_STEP_SUMMARY': str(page)}, self.tmp) self.assertEqual(result.returncode, 1, result.stderr) self.assertEqual(workflow_commands(result.stdout)[-1], - '::warning title=Scapy failed on Python 3.10 (KTLO%2C non-blocking)::3.10 is ' + f'::warning title=Scapy failed on Python {ktlo_py} (KTLO%2C non-blocking)::{ktlo_py} is ' 'end-of-life and KTLO per #1633; this does not fail the job.') - self.assertEqual(page.read_text(encoding='utf-8'), '| 3.10 | Scapy | | FAILED (KTLO, non-blocking) |\n') + self.assertEqual(page.read_text(encoding='utf-8'), f'| {ktlo_py} | Scapy | | FAILED (KTLO, non-blocking) |\n') class TestASupportedLegStillFails(unittest.TestCase): @@ -417,13 +444,13 @@ def test_every_failure_still_fails_a_supported_leg(self) -> None: masked = [label(step) for step in spec['steps'] if step.get('continue-on-error') is True] with self.subTest(workflow=name, job=job_id): self.assertEqual(masked, list(MASKED_ON_EVERY_LEG.get((name, job_id), ()))) - for leg in (leg for leg in legs(spec) if not is_ktlo(leg)): - clean, record = run_leg(name, spec, leg) + for leg in (leg for leg in legs(name, job_id) if not is_ktlo(leg)): + clean, record = run_leg(name, job_id, spec, leg) with self.subTest(workflow=name, job=job_id, python=leg['python-version']): self.assertEqual(clean, 'success') self.assertEqual(record[summary].outcome, 'skipped') context = {'matrix': leg, 'env': leg_env(name, spec, leg)} - for index, conclusion, record in scenarios(name, spec, leg): + for index, conclusion, record in scenarios(name, job_id, spec, leg): step = spec['steps'][index] with self.subTest(workflow=name, job=job_id, python=leg['python-version'], step=label(step)): self.assertEqual(conclusion, 'success' if step.get('continue-on-error') is True else 'failure') @@ -433,10 +460,11 @@ def test_every_failure_still_fails_a_supported_leg(self) -> None: def test_a_supported_engine_cell_still_fails_its_job_as_an_error(self) -> None: spec = job('unit-tests.yml', 'engine-tests') - loop = next(step for step in spec['steps'] if step.get('name') == 'Run the 3.11-3.15 cells') - self.assertEqual(loop.get('env'), {'ANNOTATION_LEVEL': 'error'}) - for leg in legs(spec): - for index, conclusion, _ in scenarios('unit-tests.yml', spec, leg): + loop = next(step for step in spec['steps'] if step.get('name') == 'Run the supported cells') + self.assertEqual(loop.get('env'), {'PYTHONS': '${{ needs.python-versions.outputs.engine-supported }}', + 'ANNOTATION_LEVEL': 'error'}) + for leg in legs('unit-tests.yml', 'engine-tests'): + for index, conclusion, _ in scenarios('unit-tests.yml', 'engine-tests', spec, leg): step = spec['steps'][index] if is_engine_ktlo(step): continue diff --git a/tests/project/test_python_matrix_tiers_1633.py b/tests/project/test_python_matrix_tiers_1633.py index 6c7316762f..0aa762e737 100644 --- a/tests/project/test_python_matrix_tiers_1633.py +++ b/tests/project/test_python_matrix_tiers_1633.py @@ -31,11 +31,17 @@ annotation is parsed as the runner parses it. 3.6 stays out: setup-python builds it only for retired images. +Since #1660 the tiers are declared once, in :file:`.github/python-versions.toml`, +and every matrix is read from it, so the expectations below are read from it +too: what is pinned here is that each tier keeps its mechanics wherever the file +puts a version. :mod:`tests.project.test_python_versions_1660` pins that the file +reproduces the matrices ``main`` spelt out before. + """ from __future__ import annotations -import ast +import importlib.util import json import os import pathlib @@ -46,10 +52,9 @@ import unittest from typing import TYPE_CHECKING -try: - import yaml -except ImportError: # pragma: no cover - yaml = None # type: ignore[assignment] +from tests.project.test_python_versions_1660 import legs as expand +from tests.project.test_python_versions_1660 import matrix, resolve, setups, workflow +from tests.project.test_python_versions_1660 import script as versions_script if TYPE_CHECKING: from typing import Any, Optional @@ -57,21 +62,41 @@ ROOT = pathlib.Path(__file__).resolve().parents[2] WORKFLOWS = ROOT / '.github' / 'workflows' -SUPPORTED = ('3.11', '3.12', '3.13', '3.14', '3.15') +#: The tiers, from .github/python-versions.toml (#1660); empty if it is missing. +try: + TIERS = versions_script().load() +except AssertionError: # pragma: no cover + TIERS = {'latest': None, 'versions': {'cpython': {}, 'pypy': {}}} + + +def tier(impl: 'str', name: 'str') -> 'tuple[str, ...]': + return tuple(v for v, entry in TIERS['versions'][impl].items() if entry['tier'] == name) + + +SUPPORTED = tier('cpython', 'supported') +KTLO = tier('cpython', 'ktlo') +#: The image each version needs where ``ubuntu-latest`` cannot provide it: +#: setup-python has no Ubuntu 26.04 build of 3.7-3.9, and ubuntu-22.04, 3.7's, is +#: deprecated and retires 2027-04-17. +PINNED_OS = {v: entry['runner'] for v, entry in TIERS['versions']['cpython'].items() if 'runner' in entry} +#: The KTLO versions a job pinned to ``ubuntu-latest`` runs. +KTLO_ON_LATEST = tuple(v for v in KTLO if v not in PINNED_OS) +#: PyPCAP/PyPCAPFile's ``python_version < '3.12'`` marker bounds ``pypcap-parity``. +PARITY = tuple(v for v in SUPPORTED + KTLO_ON_LATEST if tuple(map(int, v.split('.'))) < (3, 12)) #: The step-level guard every failure-prone step of a KTLO leg's job carries. KTLO_GUARD = '${{ matrix.ktlo == true }}' #: The job-level level of every annotation a failing step writes (#1672). ANNOTATION_LEVEL = "${{ matrix.ktlo && 'warning' || 'error' }}" -#: Ruleset 23497679's required contexts once the owner applies #1633's edit. +#: Ruleset 23497679's required contexts, which a tier change edits with the file. REQUIRED = frozenset({'Required checks passed'} | {f'Compat Python {v}' for v in SUPPORTED}) #: Every Python matrix, as (workflow, job, the blocking versions it must run). MATRICES = ( ('unit-tests.yml', 'test', SUPPORTED), ('unit-tests.yml', 'integration', SUPPORTED), - ('unit-tests.yml', 'pypcap-parity', ('3.11',)), # PyPCAP/PyPCAPFile's `< '3.12'` marker + ('unit-tests.yml', 'pypcap-parity', tuple(v for v in PARITY if v in SUPPORTED)), ('python-compatibility.yml', 'compatibility', SUPPORTED), - ('create-release.yml', 'pypi', SUPPORTED + ('pypy3.11',)), + ('create-release.yml', 'pypi', SUPPORTED + tier('pypy', 'supported')), ('create-release.yml', 'conda', SUPPORTED), ('cron-conda.yml', 'conda-dist', SUPPORTED), ) @@ -80,15 +105,12 @@ ('unit-tests.yml', 'pypcap-parity'), ('create-release.yml', 'pypi')) #: Where each KTLO leg must be, as (workflow, job, KTLO versions). KTLO_LEGS = ( - ('unit-tests.yml', 'test', ('3.7', '3.8', '3.9', '3.10')), - ('unit-tests.yml', 'integration', ('3.7', '3.8', '3.9', '3.10')), - ('unit-tests.yml', 'pypcap-parity', ('3.10',)), - ('python-compatibility.yml', 'compatibility', ('3.7', '3.8', '3.9', '3.10')), - ('create-release.yml', 'pypi', ('3.10', 'pypy3.10')), + ('unit-tests.yml', 'test', KTLO), + ('unit-tests.yml', 'integration', KTLO), + ('unit-tests.yml', 'pypcap-parity', tuple(v for v in PARITY if v in KTLO)), + ('python-compatibility.yml', 'compatibility', KTLO), + ('create-release.yml', 'pypi', KTLO_ON_LATEST + tier('pypy', 'ktlo')), ) -#: The newest Ubuntu each pinned KTLO version has a setup-python build for; the -#: rest run on ``ubuntu-latest``. ubuntu-22.04 is deprecated and retires 2027-04-17. -PINNED_OS = {'3.7': 'ubuntu-22.04', '3.8': 'ubuntu-24.04', '3.9': 'ubuntu-24.04'} #: The jobs 3.7 joins (#1633), each as (workflow, job, install-or-verify step). LEGACY_37 = ( ('unit-tests.yml', 'test', 'Install package and test dependencies'), @@ -148,44 +170,20 @@ 'six': 6, 'seven': 7, 'eight': 8, 'nine': 9, 'ten': 10} -def workflow(name: 'str') -> 'dict[str, Any]': - """``name`` parsed, or the test skipped when PyYAML (the ``test`` extra) is absent.""" - if yaml is None: # pragma: no cover - raise unittest.SkipTest('PyYAML, from the test extra, is not installed') - return yaml.safe_load((WORKFLOWS / name).read_text(encoding='utf-8')) - - def job(name: 'str', job_id: 'str') -> 'dict[str, Any]': """One job of a workflow.""" return workflow(name)['jobs'][job_id] -def legs(spec: 'dict[str, Any]') -> 'list[dict[str, Any]]': - """The matrix combinations GitHub expands ``spec`` to. +def legs(name: 'str', job_id: 'str') -> 'list[dict[str, Any]]': + """The legs GitHub expands job ``job_id``'s matrix to, read from the tier file (#1660).""" + return expand(matrix(name, job_id)) - The base axes' product, then each ``include`` entry: merged into every - combination whose original values it matches, or added as one of its own -- - GitHub's documented ``include`` rule, which is what lets ``ktlo: true`` mark - an existing leg as well as add a new one. - """ - matrix = dict(spec['strategy']['matrix']) - include = matrix.pop('include', []) - combos = [{}] # type: list[dict[str, Any]] - for key, values in matrix.items(): - combos = [dict(combo, **{key: str(value)}) for combo in combos for value in values] - for entry in include: - entry = {key: str(value) if key in matrix else value for key, value in entry.items()} - matched = [combo for combo in combos - if all(combo.get(key) == value for key, value in entry.items() if key in matrix)] - added = False - for combo in matched: - if all(key in matrix or key not in combo for key in entry): - combo.update(entry) - added = True - if not added: - combos.append(dict(entry)) - return combos +def engine_inputs() -> 'list[tuple[dict[str, Any], str]]': + """engine-tests' setup-python steps, each with the versions its input reads.""" + return [(step, resolve('unit-tests.yml', 'engine-tests', step['with']['python-version'])) + for step in setups(job('unit-tests.yml', 'engine-tests'))] def is_ktlo(leg: 'dict[str, Any]') -> 'bool': @@ -199,27 +197,27 @@ def bash(script: 'str', env: 'dict[str, str]') -> 'subprocess.CompletedProcess[s class TestTheSupportedTierBlocks(unittest.TestCase): - """3.11-3.15 everywhere, and nothing softens a supported leg.""" + """The supported tier everywhere, and nothing softens a supported leg.""" def test_every_matrix_runs_the_supported_tier_as_blocking(self) -> None: + self.assertTrue(SUPPORTED, 'the tier file lists no supported CPython') for name, job_id, blocking in MATRICES: with self.subTest(workflow=name, job=job_id): spec = job(name, job_id) - versions = sorted({leg['python-version'] for leg in legs(spec) if not is_ktlo(leg)}) + versions = sorted({leg['python-version'] for leg in legs(name, job_id) if not is_ktlo(leg)}) self.assertEqual(versions, sorted(blocking)) # #1672: not even KTLO-only; job-level, a failed leg's check run is red. self.assertNotIn('continue-on-error', spec) def test_engine_tests_runs_every_supported_cell_in_its_blocking_step(self) -> None: steps = job('unit-tests.yml', 'engine-tests')['steps'] - setups = [step for step in steps if str(step.get('uses', '')).startswith('actions/setup-python@')] - blocking = [step for step in setups if 'continue-on-error' not in step] - self.assertEqual(len(blocking), 1) - self.assertEqual(blocking[0]['with']['python-version'].split(), list(SUPPORTED)) - loop = next(step for step in steps if step.get('name') == 'Run the 3.11-3.15 cells') + blocking = [versions for step, versions in engine_inputs() if 'continue-on-error' not in step] + self.assertEqual([versions.split() for versions in blocking], [list(SUPPORTED)]) + loop = next(step for step in steps if step.get('name') == 'Run the supported cells') self.assertNotIn('continue-on-error', loop) self.assertNotIn('if', loop) - self.assertIn(f"for py in {' '.join(SUPPORTED)};", loop['run']) + self.assertEqual(resolve('unit-tests.yml', 'engine-tests', loop['env']['PYTHONS']).split(), list(SUPPORTED)) + self.assertIn('for py in $PYTHONS;', loop['run']) def test_the_supported_tier_is_what_the_classifiers_list(self) -> None: text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8') @@ -232,26 +230,39 @@ def test_requires_python_is_unchanged(self) -> None: self.assertEqual(re.findall(r'(?m)^requires-python = (.+)$', text), ['">=3.6, <4"']) def test_ci_timing_pairs_the_coverage_leg_with_supported_legs_only(self) -> None: - text = (ROOT / 'util' / 'ci_timing.py').read_text(encoding='utf-8') - coverage = ast.literal_eval(re.search(r'(?m)^COVERAGE_JOB = (.+)$', text).group(1)) # type: ignore[union-attr] - paired = ast.literal_eval(re.search(r'(?m)^PAIRED_JOBS = (\(.+\))$', text).group(1)) # type: ignore[union-attr] - self.assertEqual(sorted((coverage,) + paired), [f'Python {v}' for v in SUPPORTED]) + spec = importlib.util.spec_from_file_location('ci_timing_1633', ROOT / 'util' / 'ci_timing.py') + assert spec is not None and spec.loader is not None + ci_timing = importlib.util.module_from_spec(spec) + spec.loader.exec_module(ci_timing) # type: ignore[union-attr] + self.assertEqual(ci_timing.COVERAGE_JOB, f"Python {TIERS['latest']}") + self.assertEqual(sorted((ci_timing.COVERAGE_JOB,) + ci_timing.PAIRED_JOBS), + sorted(f'Python {v}' for v in SUPPORTED)) + + def test_pypcap_parity_stops_where_the_engines_markers_do(self) -> None: + """The script's ``PARITY_BELOW`` is pyproject.toml's PyPCAP and PyPCAPFile marker.""" + text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8') + for extra in ('PyPCAP', 'PyPCAPFile'): + with self.subTest(extra=extra): + marker = re.search(rf"(?m)^{extra} = \[ \"\w+; python_version < '(3\.\d+)'\" \]$", text) + self.assertIsNotNone(marker) + self.assertEqual(tuple(map(int, marker.group(1).split('.'))), versions_script().PARITY_BELOW) # type: ignore[union-attr] class TestTheKTLOTierNeverBlocks(unittest.TestCase): """Each KTLO leg exists, and none of them can fail what aggregates it.""" def test_each_ktlo_leg_is_there(self) -> None: + self.assertTrue(KTLO, 'the tier file lists no KTLO CPython') for name, job_id, versions in KTLO_LEGS: with self.subTest(workflow=name, job=job_id): - found = sorted(leg['python-version'] for leg in legs(job(name, job_id)) if is_ktlo(leg)) + found = sorted(leg['python-version'] for leg in legs(name, job_id) if is_ktlo(leg)) self.assertEqual(found, sorted(versions)) def test_37_to_39_run_where_setup_python_still_builds_them(self) -> None: """No Ubuntu 26.04 build of any, and ``ubuntu-latest`` moves to 26.04.""" for name, job_id, _ in KTLO_LEGS: spec = job(name, job_id) - for leg in legs(spec): + for leg in legs(name, job_id): if leg['python-version'] in PINNED_OS: with self.subTest(workflow=name, job=job_id, python=leg['python-version']): self.assertEqual(leg.get('os'), PINNED_OS[leg['python-version']]) @@ -274,26 +285,26 @@ def test_no_ktlo_leg_can_fail_an_aggregated_job(self) -> None: self.assertIn(step.get('continue-on-error'), (True, KTLO_GUARD)) def test_engine_tests_isolates_the_ktlo_interpreter_and_cell(self) -> None: + """The KTLO versions ``ubuntu-latest`` can run, in steps of their own that cannot fail the job.""" steps = job('unit-tests.yml', 'engine-tests')['steps'] - setup = [step for step in steps if str(step.get('uses', '')).startswith('actions/setup-python@') - and step.get('continue-on-error') is True] - self.assertEqual([step['with']['python-version'] for step in setup], ['3.10']) - cell = next(step for step in steps if step.get('name') == 'Run the 3.10 cell (KTLO, non-blocking)') + setup = [versions for step, versions in engine_inputs() if step.get('continue-on-error') is True] + self.assertEqual([versions.split() for versions in setup], [list(KTLO_ON_LATEST)]) + cell = next(step for step in steps if step.get('name') == 'Run the KTLO cells (non-blocking)') self.assertIs(cell.get('continue-on-error'), True) - self.assertIn('engine-cell.sh" 3.10', cell['run']) + self.assertEqual(resolve('unit-tests.yml', 'engine-tests', cell['env']['PYTHONS']).split(), + list(KTLO_ON_LATEST)) + self.assertIn('engine-cell.sh" "$py"', cell['run']) def test_no_ktlo_leg_is_a_required_context(self) -> None: spec = job('python-compatibility.yml', 'compatibility') template = spec['name'] names = {} - for leg in legs(spec): + for leg in legs('python-compatibility.yml', 'compatibility'): names[template.replace('${{ matrix.python-version }}', leg['python-version']) .replace("${{ matrix.ktlo && ' (KTLO)' || '' }}", ' (KTLO)' if is_ktlo(leg) else '')] = leg self.assertNotIn('${{', ''.join(names), template) self.assertEqual({n for n, leg in names.items() if not is_ktlo(leg)}, REQUIRED - {'Required checks passed'}) self.assertFalse({n for n, leg in names.items() if is_ktlo(leg)} & REQUIRED) - comment = (WORKFLOWS / 'unit-tests.yml').read_text(encoding='utf-8') - self.assertIn('five for `Compat Python 3.11`-`3.15`', comment) class TestNoKTLOLegCanFailTheRelease(unittest.TestCase): @@ -307,9 +318,16 @@ def setUp(self) -> None: self.tmp = pathlib.Path(tmp.name) def pypi_complete(self, files: 'list[str]') -> 'str': - """``PCAPKIT_PYPI_COMPLETE`` for a release holding ``files``, ``curl`` stubbed.""" + """``PCAPKIT_PYPI_COMPLETE`` for a release holding ``files``, ``curl`` stubbed. + + The supported wheel tags reach the step as ``PCAPKIT_PYPI_WHEELS``, read + from the tier file as the workflow reads it (#1660). + + """ steps = job('create-release.yml', 'version_check')['steps'] - script = next(step for step in steps if step.get('name') == 'Check PyPI publish completeness')['run'] + step = next(step for step in steps if step.get('name') == 'Check PyPI publish completeness') + script = step['run'] + wheels = resolve('create-release.yml', 'version_check', step['env']['PCAPKIT_PYPI_WHEELS']) reply = self.tmp / 'reply.json' reply.write_text(json.dumps({'urls': [{'filename': name} for name in files]}), encoding='utf-8') bin_dir = self.tmp / 'bin' @@ -322,14 +340,17 @@ def pypi_complete(self, files: 'list[str]') -> 'str': output.write_text('', encoding='utf-8') result = bash(script.replace('/tmp/pypi_release.json', str(self.tmp / 'release.json')), {'PATH': f'{bin_dir}{os.pathsep}{os.environ["PATH"]}', 'REPLY': str(reply), - 'GITHUB_OUTPUT': str(output), 'PCAPKIT_VERSION': '1.5.0'}) + 'GITHUB_OUTPUT': str(output), 'PCAPKIT_VERSION': '1.5.0', + 'PCAPKIT_PYPI_WHEELS': wheels}) self.assertEqual(result.returncode, 0, result.stderr) return dict(line.split('=', 1) for line in output.read_text(encoding='utf-8').split())['PCAPKIT_PYPI_COMPLETE'] def test_pypi_completeness_counts_the_supported_tier_only(self) -> None: - supported = [f'pypcapkit-1.5.0-{tag}-none-any.whl' - for tag in ('cp311', 'cp312', 'cp313', 'cp314', 'cp315', 'pp311')] - ktlo = ['pypcapkit-1.5.0-cp310-none-any.whl', 'pypcapkit-1.5.0-pp310-none-any.whl'] + tags = {leg['python-version']: versions_script().wheel_tag(leg['python-version']) + for leg in legs('create-release.yml', 'pypi')} + supported = [f'pypcapkit-1.5.0-{tags[v]}-none-any.whl' for v in SUPPORTED + tier('pypy', 'supported')] + ktlo = [f'pypcapkit-1.5.0-{tags[v]}-none-any.whl' for v in KTLO_ON_LATEST + tier('pypy', 'ktlo')] + self.assertTrue(supported and ktlo) sdist = ['pypcapkit-1.5.0.tar.gz'] self.assertEqual(self.pypi_complete(supported + sdist), 'true') self.assertEqual(self.pypi_complete(supported + ktlo + sdist), 'true') @@ -340,7 +361,7 @@ def test_each_ktlo_leg_records_its_outcome_for_release_status(self) -> None: spec = job('create-release.yml', 'pypi') record = next(step for step in spec['steps'] if step.get('id') == 'ktlo') status = job('create-release.yml', 'release_status')['steps'][0]['env'] - for leg in (leg for leg in legs(spec) if is_ktlo(leg)): + for leg in (leg for leg in legs('create-release.yml', 'pypi') if is_ktlo(leg)): key = 'ktlo_' + leg['python-version'].replace('.', '') with self.subTest(leg=leg['python-version']): self.assertEqual(spec['outputs'][key], f'${{{{ steps.ktlo.outputs.{key} }}}}') @@ -378,7 +399,7 @@ def test_no_conda_leg_is_ktlo_and_315_leaves_base_python_alone(self) -> None: for name, job_id in (('create-release.yml', 'conda'), ('cron-conda.yml', 'conda-dist')): spec = job(name, job_id) with self.subTest(workflow=name, job=job_id): - self.assertFalse([leg for leg in legs(spec) if is_ktlo(leg)]) + self.assertFalse([leg for leg in legs(name, job_id) if is_ktlo(leg)]) setup = next(step for step in spec['steps'] if str(step.get('uses', '')).startswith('conda-incubator/setup-miniconda@')) self.assertEqual(setup['with']['python-version'], @@ -531,13 +552,12 @@ def test_37_is_a_ktlo_leg_of_exactly_the_three_test_matrices(self) -> None: for name, job_id, _ in LEGACY_37: spec = job(name, job_id) with self.subTest(workflow=name, job=job_id): - found = [leg for leg in legs(spec) if leg['python-version'] == '3.7'] + found = [leg for leg in legs(name, job_id) if leg['python-version'] == '3.7'] self.assertEqual(found, [{'python-version': '3.7', 'ktlo': True, 'os': 'ubuntu-22.04'}]) self.assertTrue(spec['name'].endswith("${{ matrix.ktlo && ' (KTLO)' || '' }}")) joined = {(name, job_id) for name, job_id, _ in MATRICES - if '3.7' in [leg['python-version'] for leg in legs(job(name, job_id))]} - engines = [str(step['with']['python-version']) for step in job('unit-tests.yml', 'engine-tests')['steps'] - if str(step.get('uses', '')).startswith('actions/setup-python@')] + if '3.7' in [leg['python-version'] for leg in legs(name, job_id)]} + engines = [versions for _, versions in engine_inputs()] if '3.7' in ' '.join(engines).split(): joined.add(('unit-tests.yml', 'engine-tests')) self.assertEqual(joined, {(name, job_id) for name, job_id, _ in LEGACY_37}) @@ -562,7 +582,7 @@ def test_a_leg_that_cannot_install_stops_there(self) -> None: for step in steps[index + 1:]: condition = str(step.get('if', '')) if 'matrix.coverage' in condition: - continue # the 3.14 leg only, which a failed install fails anyway + continue # the `latest` leg only, which a failed install fails anyway if step.get('name') == 'KTLO summary': continue with self.subTest(workflow=name, job=job_id, step=step.get('name') or step.get('uses')): @@ -706,13 +726,11 @@ class TestTheProseFollowsTheTiers(unittest.TestCase): def test_the_workflows_page_counts_each_matrix_jobs_legs(self) -> None: page = prose(ROOT / 'docs' / 'source' / 'contributing' / 'workflows.rst') - engines = {version for step in job('unit-tests.yml', 'engine-tests')['steps'] - if str(step.get('uses', '')).startswith('actions/setup-python@') - for version in str(step['with']['python-version']).split()} + engines = {version for _, versions in engine_inputs() for version in versions.split()} claims = { - r'``test`` \((\w+) Python legs': len(legs(job('unit-tests.yml', 'test'))), - r'``integration`` \((\w+)\)': len(legs(job('unit-tests.yml', 'integration'))), - r'``pypcap-parity`` \((\w+)\)': len(legs(job('unit-tests.yml', 'pypcap-parity'))), + r'``test`` \((\w+) Python legs': len(legs('unit-tests.yml', 'test')), + r'``integration`` \((\w+)\)': len(legs('unit-tests.yml', 'integration')), + r'``pypcap-parity`` \((\w+)\)': len(legs('unit-tests.yml', 'pypcap-parity')), r'each looping over (\w+) Python versions': len(engines), } for pattern, count in claims.items(): diff --git a/tests/project/test_python_versions_1660.py b/tests/project/test_python_versions_1660.py new file mode 100644 index 0000000000..66d9cc1b5c --- /dev/null +++ b/tests/project/test_python_versions_1660.py @@ -0,0 +1,934 @@ +# -*- coding: utf-8 -*- +"""#1660: the Python version tiers are declared once, in :file:`.github/python-versions.toml`. + +Before #1660 every matrix spelt its versions out, so a tier change -- 3.11 +reaching end-of-life, a new pre-release -- meant editing some 27 files by hand. +Now :file:`util/python_versions.py` reads the file, and each workflow takes its +matrices, and each single-interpreter job its ``latest``, from that script's +step outputs. Four things are pinned here: + +* **The file and the script.** :func:`python_versions.load` rejects a malformed + file, ``latest`` is the newest supported CPython (the owner's ruling on + #1661), each exemption's reason is the comment above it, and the script runs + on the ``python3`` of every runner that runs it. +* **Nothing moved.** The tiers as this change introduced them expand to exactly + the legs, in the same order, that ``main`` spelt out at :data:`MAIN`, apart + from the one deliberate move: the coverage leg and the ``gate`` from 3.14 to + ``latest``, 3.15. Job-name templates are compared too, since rulesets match + check names literally. Where git still has :data:`MAIN`, the recorded + matrices are read back against it. +* **Every version comes from the file.** No workflow pins a Python version -- + in a setup action's input, a matrix, a comparison, a job name, an ``env`` + value, or a wheel or conda tag and version loop in a script -- unless the + file's ``exemptions`` list that pin with its value and its reason, and every + exemption still matches a pin. Every single-interpreter job reads ``latest``, + and Create Release's Anaconda check is run to show it counts what the file + lists. (Expected outcomes per engine and version in ``engine-tests``' cell + script are facts about the engines, not pins, and are not read.) +* **The prose follows the file.** The classifiers, README, SECURITY.md, the + docs' version lists, the bug-report template, and the comments and docs + naming the coverage leg's version or the release checks' counts. + +The script reads the file with :mod:`tomllib`, in the standard library from 3.11, +so every test that reads it skips on the KTLO interpreters, 3.8-3.10 -- the +:mod:`tests.project.test_python_matrix_tiers_1633` and +:mod:`tests.project.test_ktlo_legs_stay_green_1672` modules and +:mod:`tests.project.test_ci_timing` whole, since they read it on import. What +they check is the repository's files, which read the same on any interpreter. + +""" + +from __future__ import annotations + +import ast +import contextlib +import importlib.util +import io +import json +import os +import pathlib +import re +import shutil +import subprocess # nosec B404 +import sys +import tempfile +import textwrap +import unittest +from typing import TYPE_CHECKING +from unittest import mock + +try: + import tomllib +except ImportError: # Python 3.10 and older + tomllib = None # type: ignore[assignment] + +try: + import yaml +except ImportError: # pragma: no cover + yaml = None # type: ignore[assignment] + +if TYPE_CHECKING: + from typing import Any, Callable, Iterator, Optional + +ROOT = pathlib.Path(__file__).resolve().parents[2] +WORKFLOWS = ROOT / '.github' / 'workflows' +SCRIPT = ROOT / 'util' / 'python_versions.py' +TIER_FILE = ROOT / '.github' / 'python-versions.toml' +#: How every workflow runs the script. +SCRIPT_STEP = 'python3 util/python_versions.py --github-output' +#: Why a test that reads the tier file skips where :mod:`tomllib` is missing. +NO_TOMLLIB = 'tomllib, which reads .github/python-versions.toml, needs Python 3.11+' + + +def load_script() -> 'Any': + """:file:`util/python_versions.py`, which is a script and not a package.""" + spec = importlib.util.spec_from_file_location('python_versions', SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) # type: ignore[union-attr] + return module + + +#: Loaded once, so a missing script fails each test rather than the import. It +#: imports tomllib, so it is not loaded at all without one. +try: + python_versions = None if tomllib is None else load_script() +except FileNotFoundError: # pragma: no cover + python_versions = None + + +def script() -> 'Any': + """The script; a test that needs it skips without :mod:`tomllib`, as on 3.8-3.10.""" + if tomllib is None: + raise unittest.SkipTest(NO_TOMLLIB) + if python_versions is None: + raise AssertionError(f'{SCRIPT.relative_to(ROOT)} is missing') + return python_versions + + +def live_outputs() -> 'dict[str, str]': + """Every output the script writes for the file as it stands.""" + return script().outputs(script().load()) + + +def workflow(name: 'str') -> 'dict[str, Any]': + """``name`` parsed, or the test skipped when PyYAML (the ``test`` extra) is absent.""" + if yaml is None: # pragma: no cover + raise unittest.SkipTest('PyYAML, from the test extra, is not installed') + return yaml.safe_load((WORKFLOWS / name).read_text(encoding='utf-8')) + + +def has_main() -> 'bool': + """Whether this clone holds :data:`MAIN`; a shallow CI checkout does not.""" + found = subprocess.run(['git', 'cat-file', '-e', f'{MAIN}^{{commit}}'], cwd=ROOT, capture_output=True, check=False) + return not found.returncode + + +def workflow_at_main(name: 'str') -> 'dict[str, Any]': + """``name`` as it stood at :data:`MAIN`.""" + text = subprocess.run(['git', 'show', f'{MAIN}:.github/workflows/{name}'], cwd=ROOT, + capture_output=True, text=True, check=True).stdout + return yaml.safe_load(text) + + +def workflows(load: 'Callable[[str], dict[str, Any]]' = workflow) -> 'Iterator[tuple[str, dict[str, Any]]]': + for path in sorted(WORKFLOWS.glob('*.yml')): + yield path.name, load(path.name) + + +#: ``${{ needs.J.outputs.O }}`` or ``${{ steps.S.outputs.O }}``, optionally in ``fromJSON()``. +READ = re.compile(r'\$\{\{\s*(fromJSON\(\s*)?((?:needs|steps)\.[\w-]+\.outputs\.[\w-]+)\s*\)?\s*\}\}') +REF = re.compile(r'(needs|steps)\.([\w-]+)\.outputs\.([\w-]+)') + + +def script_output(name: 'str', job_id: 'str', ref: 'str') -> 'str': + """The script output that reference ``ref``, read in job ``job_id``, ends at. + + A ``needs`` reference is followed through the job it names, which must be in + ``job_id``'s ``needs``, to that job's own ``outputs``; a ``steps`` one must + name a step of the job that runs :data:`SCRIPT_STEP`. + + """ + jobs = workflow(name)['jobs'] + kind, target, output = REF.fullmatch(ref).groups() # type: ignore[union-attr] + if kind == 'needs': + needs = jobs[job_id].get('needs', []) + assert target in ([needs] if isinstance(needs, str) else needs), f'{name} {job_id} does not need {target}' + value = jobs[target]['outputs'][output] + inner = READ.fullmatch(value) + assert inner is not None and not inner.group(1), f'{name} {target}.outputs.{output} is {value!r}' + return script_output(name, target, inner.group(2)) + step = next(step for step in jobs[job_id]['steps'] if step.get('id') == target) + assert step['run'].strip() == SCRIPT_STEP, f'{name} {job_id} step {target} runs {step["run"]!r}' + return output + + +def resolve(name: 'str', job_id: 'str', text: 'Any', values: 'Optional[dict[str, str]]' = None) -> 'Any': + """What ``text`` reads from the script's outputs, or :data:`None` if it reads none.""" + match = READ.fullmatch(str(text).strip()) + if match is None: + return None + output = script_output(name, job_id, match.group(2)) + values = live_outputs() if values is None else values + assert output in values, f'{name} {job_id} reads {output!r}, which the script does not write' + return json.loads(values[output]) if match.group(1) else values[output] + + +def matrix(name: 'str', job_id: 'str', values: 'Optional[dict[str, str]]' = None) -> 'dict[str, Any]': + """Job ``job_id``'s ``strategy.matrix``, each expression read from the script.""" + raw = workflow(name)['jobs'][job_id]['strategy']['matrix'] + if isinstance(raw, str): + found = resolve(name, job_id, raw, values) + assert isinstance(found, dict), f'{name} {job_id} matrix is {raw!r}' + return found + return {key: (resolve(name, job_id, value, values) if isinstance(value, str) else value) + for key, value in raw.items()} + + +def legs(spec: 'dict[str, Any]') -> 'list[dict[str, Any]]': + """The matrix combinations GitHub expands ``spec`` to, in its order. + + The base axes' product, then each ``include`` entry: merged into every + combination whose original values it matches, or added as one of its own -- + GitHub's documented ``include`` rule, which is what lets ``ktlo: true`` mark + an existing leg as well as add a new one. + + """ + spec = dict(spec) + include = spec.pop('include', []) + combos = [{}] # type: list[dict[str, Any]] + for key, values in spec.items(): + combos = [dict(combo, **{key: str(value)}) for combo in combos for value in values] + for entry in include: + entry = {key: str(value) if key in spec else value for key, value in entry.items()} + matched = [combo for combo in combos + if all(combo.get(key) == value for key, value in entry.items() if key in spec)] + added = False + for combo in matched: + if all(key in spec or key not in combo for key in entry): + combo.update(entry) + added = True + if not added: + combos.append(dict(entry)) + return combos + + +def setups(spec: 'dict[str, Any]') -> 'list[dict[str, Any]]': + """A job's setup-python and setup-miniconda steps.""" + return [step for step in spec.get('steps', []) + if str(step.get('uses', '')).startswith(('actions/setup-python@', 'conda-incubator/setup-miniconda@'))] + + +#: The last commit before the file: its matrices are what the file must reproduce. +MAIN = '1024109fc1a8d16d67d24d5ed5af2c57bb81076b' +#: Every Python matrix as MAIN spelt it out, as (workflow, job): ``strategy.matrix``. +SUPPORTED_AT_MAIN = ['3.11', '3.12', '3.13', '3.14', '3.15'] +KTLO_AT_MAIN = [{'python-version': '3.7', 'ktlo': True, 'os': 'ubuntu-22.04'}, + {'python-version': '3.8', 'ktlo': True, 'os': 'ubuntu-24.04'}, + {'python-version': '3.9', 'ktlo': True, 'os': 'ubuntu-24.04'}, + {'python-version': '3.10', 'ktlo': True}] +CONDA_AT_MAIN = {'os': ['macos-latest', 'ubuntu-latest'], 'python-version': SUPPORTED_AT_MAIN} +MAIN_MATRICES = { + ('unit-tests.yml', 'test'): { + 'python-version': SUPPORTED_AT_MAIN, + 'include': [{'python-version': '3.14', 'coverage': True}] + KTLO_AT_MAIN}, + ('unit-tests.yml', 'integration'): {'python-version': SUPPORTED_AT_MAIN, 'include': KTLO_AT_MAIN}, + ('unit-tests.yml', 'pypcap-parity'): { + 'python-version': ['3.10', '3.11'], 'include': [{'python-version': '3.10', 'ktlo': True}]}, + ('python-compatibility.yml', 'compatibility'): {'python-version': SUPPORTED_AT_MAIN, 'include': KTLO_AT_MAIN}, + ('create-release.yml', 'pypi'): { + 'python-version': SUPPORTED_AT_MAIN + ['pypy3.11'], + 'include': [{'python-version': '3.10', 'ktlo': True}, {'python-version': 'pypy3.10', 'ktlo': True}]}, + ('create-release.yml', 'conda'): CONDA_AT_MAIN, + ('cron-conda.yml', 'conda-dist'): CONDA_AT_MAIN, +} +#: engine-tests' two setup inputs at MAIN, (KTLO step, supported step), and its loops. +ENGINES_AT_MAIN = ('3.10', '\n'.join(SUPPORTED_AT_MAIN)) +#: Each matrix job's name template at MAIN, which must not change. +TEMPLATES_AT_MAIN = { + ('unit-tests.yml', 'test'): "Python ${{ matrix.python-version }}${{ matrix.ktlo && ' (KTLO)' || '' }}", + ('unit-tests.yml', 'integration'): + "Integration Python ${{ matrix.python-version }}${{ matrix.ktlo && ' (KTLO)' || '' }}", + ('unit-tests.yml', 'pypcap-parity'): + "PyPCAP/PyPCAPFile parity Python ${{ matrix.python-version }}${{ matrix.ktlo && ' (KTLO)' || '' }}", + ('python-compatibility.yml', 'compatibility'): + "Compat Python ${{ matrix.python-version }}${{ matrix.ktlo && ' (KTLO)' || '' }}", + ('create-release.yml', 'pypi'): + "PyPI distribution for Python ${{ matrix.python-version }}${{ matrix.ktlo && ' (KTLO)' || '' }}", + ('create-release.yml', 'conda'): + 'Conda deployment (release) on ${{ matrix.os }} with Python ${{ matrix.python-version }}', + ('cron-conda.yml', 'conda-dist'): + 'Conda deployment (update) on ${{ matrix.os }} with Python ${{ matrix.python-version }}', +} +#: Ruleset 23497679's required contexts at MAIN. +REQUIRED_AT_MAIN = {'Required checks passed'} | {f'Compat Python {v}' for v in SUPPORTED_AT_MAIN} +#: The tiers MAIN's matrices encode, as this change first wrote them into the file. +TIERS_AT_MAIN = { + 'latest': '3.15', + 'versions': { + 'cpython': {'3.7': {'tier': 'ktlo', 'runner': 'ubuntu-22.04'}, + '3.8': {'tier': 'ktlo', 'runner': 'ubuntu-24.04'}, + '3.9': {'tier': 'ktlo', 'runner': 'ubuntu-24.04'}, + '3.10': {'tier': 'ktlo'}, + **{version: {'tier': 'supported'} for version in SUPPORTED_AT_MAIN}}, + 'pypy': {'pypy3.10': {'tier': 'ktlo'}, 'pypy3.11': {'tier': 'supported'}}, + }, +} + + +def render(template: 'str', leg: 'dict[str, Any]') -> 'str': + """A matrix job's name for one leg, for the two expression shapes these names use.""" + name = template.replace("${{ matrix.ktlo && ' (KTLO)' || '' }}", ' (KTLO)' if leg.get('ktlo') is True else '') + name = re.sub(r'\$\{\{ matrix\.([\w-]+) \}\}', lambda match: str(leg[match.group(1)]), name) + assert '${{' not in name, name + return name + + +def toml(value: 'Any') -> 'str': + """``value`` as a TOML value, every table inline; JSON's string escapes are TOML's.""" + if isinstance(value, bool): + return 'true' if value else 'false' + if isinstance(value, str): + return json.dumps(value) + if isinstance(value, dict): + return '{%s}' % ', '.join(f'{json.dumps(key)} = {toml(item)}' for key, item in value.items()) + if isinstance(value, list): + return '[%s]' % ', '.join(map(toml, value)) + raise TypeError(f'no TOML for {value!r}') + + +def write_tiers(directory: 'pathlib.Path', data: 'dict[str, Any]') -> 'pathlib.Path': + """``data`` written as a tier file, one top-level key per line.""" + path = directory / 'python-versions.toml' + path.write_text(''.join(f'{json.dumps(key)} = {toml(value)}\n' for key, value in data.items()), encoding='utf-8') + return path + + +#: :mod:`tomllib` is in the standard library from this version. +TOMLLIB_FLOOR = (3, 11) +#: The ``python3`` each runner label a job runs the script on puts on ``PATH``, every +#: image it may mean: actions/runner-images at 2c421200c (2026-10-12), "Language and +#: Runtime" in images/ubuntu/Ubuntu2404-Readme.md:28 (``ubuntu-latest`` today), +#: Ubuntu2604-Readme.md:27 (from 2026-10-19) and images/macos/macos-26-arm64-Readme.md:28 +#: (``macos-latest``). Ubuntu2204-Readme.md:30's is 3.10.12, which has no tomllib. +RUNNER_PYTHON3 = {'ubuntu-latest': ((3, 12), (3, 14)), 'macos-latest': ((3, 14),)} + + +class TestTheFileAndTheScript(unittest.TestCase): + """The file is well formed, and the script reads it as a runner's ``python3`` would.""" + + def setUp(self) -> None: + tmp = tempfile.TemporaryDirectory(prefix='pcapkit-1660-') # pylint: disable=consider-using-with + self.addCleanup(tmp.cleanup) + self.tmp = pathlib.Path(tmp.name) + + def test_the_file_loads_and_latest_is_the_newest_supported_version(self) -> None: + """The owner's ruling (#1661): single-interpreter jobs run the newest supported version.""" + data = script().load() + supported = script().tier(data, 'cpython', 'supported') + self.assertEqual(data['latest'], max(supported, key=script().key)) + self.assertTrue(supported and script().tier(data, 'pypy', 'supported')) + + def test_the_file_is_toml_and_the_json_is_gone(self) -> None: + """The owner asked for a format with comments (#1677); TOML is the repo's config format.""" + self.assertEqual(script().FILE, TIER_FILE) + self.assertEqual(tomllib.loads(TIER_FILE.read_text(encoding='utf-8')), script().load()) + self.assertFalse((ROOT / '.github' / 'python-versions.json').exists()) + self.assertEqual(tomllib.loads(write_tiers(self.tmp, TIERS_AT_MAIN).read_text(encoding='utf-8')), + TIERS_AT_MAIN) + + def test_each_exemption_says_why_in_a_comment(self) -> None: + """tomllib drops comments, so the file's text is read: a comment heads each exemption.""" + lines = TIER_FILE.read_text(encoding='utf-8').splitlines() + headers = [index for index, line in enumerate(lines) if line.strip() == '[[exemptions]]'] + self.assertEqual(len(headers), len(script().load()['exemptions'])) + for index in headers: + with self.subTest(line=index + 1): + self.assertTrue(index and lines[index - 1].startswith('# '), 'say why above the exemption') + + def test_a_malformed_file_is_refused_with_its_reason(self) -> None: + cpython = TIERS_AT_MAIN['versions']['cpython'] + cases = { + 'unknown tier': dict(cpython, **{'3.15': {'tier': 'blocking'}}), + 'pre-release': dict(cpython, **{'3.16': {'tier': 'pre-release'}}), + 'misspelt version': dict(cpython, **{'3.16.0': {'tier': 'supported'}}), + 'runner typo': dict(cpython, **{'3.10': {'tier': 'ktlo', 'runner': 'ubuntu-latest'}}), + 'unknown key': dict(cpython, **{'3.10': {'tier': 'ktlo', 'os': 'ubuntu-24.04'}}), + 'out of order': dict(list(cpython.items())[::-1]), + } + for label, versions in cases.items(): + with self.subTest(case=label): + data = dict(TIERS_AT_MAIN, versions=dict(TIERS_AT_MAIN['versions'], cpython=versions)) + with self.assertRaises(ValueError): + script().load(write_tiers(self.tmp, data)) + exemption = {'workflow': 'lint.yml', 'job': 'lint', 'where': 'x', 'value': '3.14'} + self.assertEqual(script().load(write_tiers(self.tmp, dict(TIERS_AT_MAIN, exemptions=[exemption])))[ + 'exemptions'], [exemption]) + for label, data in (('latest is not supported', dict(TIERS_AT_MAIN, latest='3.10')), + ('an exemption has no value', dict(TIERS_AT_MAIN, exemptions=[ + dict(exemption, value='')])), + ('a reason is a comment, not a field', dict(TIERS_AT_MAIN, exemptions=[ + dict(exemption, reason='why')])), + ('an unknown top-level key', dict(TIERS_AT_MAIN, matrices={})), + ('no PyPy', dict(TIERS_AT_MAIN, versions={'cpython': cpython})), + ('PyPy lists nothing', dict(TIERS_AT_MAIN, versions={'cpython': cpython, 'pypy': {}}))): + with self.subTest(case=label), self.assertRaises(ValueError): + script().load(write_tiers(self.tmp, data)) + invalid = self.tmp / 'invalid.toml' + invalid.write_text('latest = 3.15\nlatest = "3.15"\n', encoding='utf-8') + with self.subTest(case='not TOML'), self.assertRaises(ValueError): + script().load(invalid) + + def test_a_supported_version_on_an_older_runner_is_refused(self) -> None: + """A job pinned to ``ubuntu-latest`` would otherwise stop testing it without a word.""" + pinned = {'3.11': {'tier': 'supported', 'runner': 'ubuntu-24.04'}} + cpython = dict(TIERS_AT_MAIN['versions']['cpython'], **pinned) + data = script().load(write_tiers(self.tmp, dict(TIERS_AT_MAIN, versions=dict( + TIERS_AT_MAIN['versions'], cpython=cpython)))) + with self.assertRaisesRegex(ValueError, 'cannot run supported 3.11'): + script().outputs(data) + + @staticmethod + def parse_github_output(text: 'str') -> 'dict[str, str]': + """``$GITHUB_OUTPUT`` as the runner reads it: ``name=value``, or ``name< 'tuple[int, str]': + """The script's ``main`` run in this process, with its status and stdout.""" + environ = {key: value for key, value in os.environ.items() if key != 'GITHUB_OUTPUT'} + if github_output is not None: + environ['GITHUB_OUTPUT'] = github_output + stdout = io.StringIO() + with mock.patch.dict(os.environ, environ, clear=True), contextlib.redirect_stdout(stdout): + status = script().main(list(argv)) + return status, stdout.getvalue() + + def test_the_command_line_writes_each_output_as_the_runner_reads_it(self) -> None: + """One ``name=value`` line each, a multi-line value under a fresh delimiter.""" + output = self.tmp / 'github-output' + status, printed = self.main('--github-output', github_output=str(output)) + self.assertEqual(status, 0, printed) + self.assertEqual(self.parse_github_output(output.read_text(encoding='utf-8')), live_outputs()) + self.assertEqual(self.main('latest', 'conda'), (0, f"{live_outputs()['latest']}\n{live_outputs()['conda']}\n")) + for argv, env, title in ((('nope',), None, 'Cannot read the Python version tiers'), + (('--github-output',), None, 'GITHUB_OUTPUT is not set'), + (('--file', str(write_tiers(self.tmp, dict(TIERS_AT_MAIN, latest='3.10')))), + None, 'Cannot read the Python version tiers')): + with self.subTest(argv=argv): + status, printed = self.main(*argv, github_output=env) + self.assertEqual(status, 1) + self.assertRegex(printed, rf'^::error title={title}::') + # And as a workflow runs it, by path, with nothing but the runner's environment. + output.unlink() + result = subprocess.run([sys.executable, str(SCRIPT), '--github-output'], + cwd=ROOT, env={'GITHUB_OUTPUT': str(output), 'PATH': ''}, + capture_output=True, text=True, check=False, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.parse_github_output(output.read_text(encoding='utf-8')), live_outputs()) + + def test_the_script_runs_on_every_runners_own_python3(self) -> None: + """Standard library only, tomllib's 3.11 the floor, and each image that runs it newer. + + Each job running :data:`SCRIPT_STEP` does so before any setup-python step, + so on the image's ``python3``: :data:`RUNNER_PYTHON3`. + + """ + ast.parse(SCRIPT.read_text(encoding='utf-8'), feature_version=TOMLLIB_FLOOR) + tree = ast.parse(SCRIPT.read_text(encoding='utf-8')) + imported = {alias.name.split('.')[0] for node in ast.walk(tree) if isinstance(node, ast.Import) + for alias in node.names} + imported |= {node.module.split('.')[0] for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) and node.module and not node.level} + self.assertLessEqual(imported, {'__future__', 'argparse', 'json', 'os', 'pathlib', 're', 'sys', + 'tomllib', 'typing', 'uuid'}) + self.assertIn('tomllib', imported) + found = 0 + for name, spec in workflows(): + for job_id, job in spec.get('jobs', {}).items(): + steps = job.get('steps', []) + runs = [i for i, step in enumerate(steps) if str(step.get('run', '')).strip() == SCRIPT_STEP] + for index in runs: + found += 1 + with self.subTest(workflow=name, job=job_id): + self.assertEqual(setups({'steps': steps[:index]}), []) + self.assertIn(job.get('runs-on'), RUNNER_PYTHON3) + self.assertGreaterEqual(min(RUNNER_PYTHON3[job['runs-on']]), TOMLLIB_FLOOR) + self.assertGreaterEqual(found, 10) + + def test_without_tomllib_the_tests_that_read_the_file_skip(self) -> None: + """On 3.8-3.10 these modules skip, or their tests do, rather than fail to import. + + Run in a fresh interpreter whose ``import tomllib`` fails, as it does there, + where the two scripts that read the file cannot load at all. That + interpreter may already hold :mod:`tomllib`: on the coverage leg, + ``patch = subprocess`` starts coverage in it, which reads its TOML + config first. So the module is dropped and its entry set to ``None``, + which makes any import of it raise :exc:`ModuleNotFoundError`. + + """ + probe = textwrap.dedent('''\ + import importlib, importlib.util, json, sys, unittest + for name in [name for name in sys.modules if name == 'tomllib' or name.startswith('tomllib.')]: + del sys.modules[name] + sys.modules['tomllib'] = None + found = {} + for name in sys.argv[1:]: + try: + importlib.import_module(name) + found[name] = 'imported' + except unittest.SkipTest as error: + found[name] = f'skipped: {error}' + try: + importlib.import_module(sys.argv[1]).script() + except unittest.SkipTest as error: + found['script()'] = f'skipped: {error}' + for path in ('util/python_versions.py', 'util/ci_timing.py'): + spec = importlib.util.spec_from_file_location(path, path) + try: + spec.loader.exec_module(importlib.util.module_from_spec(spec)) + found[path] = 'loaded' + except ImportError as error: + found[path] = f'{type(error).__name__}: {error}' + print(json.dumps(found)) + ''') + modules = ['tests.project.test_python_versions_1660', 'tests.project.test_python_matrix_tiers_1633', + 'tests.project.test_ktlo_legs_stay_green_1672', 'tests.project.test_ci_timing', + 'tests.project.test_unit_tests_queue', 'tests.project.test_workflow_apt_cache'] + result = subprocess.run([sys.executable, '-c', probe, *modules], cwd=ROOT, capture_output=True, + text=True, check=False, timeout=120) + self.assertEqual(result.returncode, 0, result.stderr) + skipped = f'skipped: {NO_TOMLLIB}' + blocked = 'ModuleNotFoundError: import of tomllib halted; None in sys.modules' + self.assertEqual(json.loads(result.stdout.splitlines()[-1]), { + 'tests.project.test_python_versions_1660': 'imported', + 'tests.project.test_python_matrix_tiers_1633': skipped, + 'tests.project.test_ktlo_legs_stay_green_1672': skipped, + 'tests.project.test_ci_timing': skipped, + 'tests.project.test_unit_tests_queue': 'imported', + 'tests.project.test_workflow_apt_cache': 'imported', + 'script()': skipped, + 'util/python_versions.py': blocked, + 'util/ci_timing.py': blocked, + }) + + +class TestNothingMovedButLatest(unittest.TestCase): + """The file reproduces every matrix ``main`` spelt out, but for the coverage leg.""" + + @classmethod + def setUpClass(cls) -> None: + with tempfile.TemporaryDirectory(prefix='pcapkit-1660-') as tmp: + cls.values = script().outputs(script().load(write_tiers(pathlib.Path(tmp), TIERS_AT_MAIN))) + + def test_every_matrix_expands_to_mains_legs(self) -> None: + """Same legs, same order; the coverage leg moves from 3.14 to ``latest`` (#1661). + + Where the clone holds :data:`MAIN`, :data:`MAIN_MATRICES` is read back + against it first, so the comparison is with what ``main`` really ran. + + """ + for (name, job_id), spec in MAIN_MATRICES.items(): + if has_main(): + with self.subTest(recorded=name, job=job_id): + self.assertEqual(workflow_at_main(name)['jobs'][job_id]['strategy']['matrix'], spec) + expected = legs(spec) + if (name, job_id) == ('unit-tests.yml', 'test'): + for leg in expected: + leg.pop('coverage', None) + if leg['python-version'] == TIERS_AT_MAIN['latest']: + leg['coverage'] = True + with self.subTest(workflow=name, job=job_id): + self.assertEqual(legs(matrix(name, job_id, self.values)), expected) + + def test_engine_tests_installs_and_runs_mains_cells(self) -> None: + if has_main(): + engines = tuple(step['with']['python-version'].strip() + for step in setups(workflow_at_main('unit-tests.yml')['jobs']['engine-tests'])) + self.assertEqual(engines, ENGINES_AT_MAIN) + spec = workflow('unit-tests.yml')['jobs']['engine-tests'] + inputs = tuple(resolve('unit-tests.yml', 'engine-tests', step['with']['python-version'], self.values) + for step in setups(spec)) + self.assertEqual(inputs, ENGINES_AT_MAIN) + loops = {step['name']: resolve('unit-tests.yml', 'engine-tests', step['env']['PYTHONS'], self.values) + for step in spec['steps'] if 'PYTHONS' in step.get('env', {})} + self.assertEqual(loops, {'Run the supported cells': ENGINES_AT_MAIN[1], + 'Run the KTLO cells (non-blocking)': ENGINES_AT_MAIN[0]}) + + def test_no_job_name_changes_but_the_gates(self) -> None: + """Rulesets match check names literally, so each template and leg name stays.""" + for (name, job_id), template in TEMPLATES_AT_MAIN.items(): + with self.subTest(workflow=name, job=job_id): + self.assertEqual(workflow(name)['jobs'][job_id]['name'], template) + compat = TEMPLATES_AT_MAIN[('python-compatibility.yml', 'compatibility')] + compat_legs = legs(matrix('python-compatibility.yml', 'compatibility', self.values)) + blocking = {render(compat, leg) for leg in compat_legs + if leg.get('ktlo') is not True} + self.assertEqual(blocking | {'Required checks passed'}, REQUIRED_AT_MAIN) + self.assertEqual(workflow('unit-tests.yml')['jobs']['required-checks']['name'], 'Required checks passed') + gate = workflow('unit-tests.yml')['jobs']['gate']['name'] + expression = re.search(r'\$\{\{[^}]*\}\}', gate).group(0) # type: ignore[union-attr] + self.assertEqual(gate.replace(expression, resolve('unit-tests.yml', 'gate', expression, self.values)), + 'Gate (full suite, Python 3.15)') + if has_main(): + self.assertEqual(workflow_at_main('unit-tests.yml')['jobs']['gate']['name'], + 'Gate (full suite, Python 3.14)') + for (name, job_id), template in TEMPLATES_AT_MAIN.items(): + with self.subTest(recorded=name, job=job_id): + self.assertEqual(workflow_at_main(name)['jobs'][job_id]['name'], template) + + +#: Setup actions whose ``python-version`` input picks the interpreter. +SETUP_ACTIONS = ('actions/setup-python@', 'conda-incubator/setup-miniconda@') +#: A literal version, or a list of them. +VERSIONS = re.compile(r'\s*(?:pypy)?3\.\d+(?:\s+(?:pypy)?3\.\d+)*\s*') +#: ``matrix.python-version`` compared with a literal version. +COMPARED = re.compile(r"matrix\.python-version\s*[!=]=\s*'([^']+)'|'([^']+)'\s*[!=]=\s*matrix\.python-version") +#: A job name naming its Python outside an expression. +NAMED = re.compile(r'\bPython ((?:pypy)?3\.\d+)\b') +#: A wheel or conda build tag, and a shell loop over literal versions. +TAG = re.compile(r'\b(?:cp|pp|py)3\d{1,2}\b') +LOOP = re.compile(r'\bfor \w+ in ((?:(?:pypy)?3\.\d+\s*)+)') + + +def strings(node: 'Any', where: 'str') -> 'Iterator[tuple[str, str]]': + """Every string under ``node``, with a readable path to it.""" + if isinstance(node, str): + yield where, node + elif isinstance(node, dict): + for key, value in node.items(): + yield from strings(value, f'{where}.{key}' if where else str(key)) + elif isinstance(node, list): + for value in node: + yield from strings(value, where) + + +def step_label(step: 'dict[str, Any]') -> 'str': + """A step's name, else ``Set up Python`` for a setup action, else what it ``uses``.""" + if 'name' in step: + return str(step['name']) + return 'Set up Python' if str(step.get('uses', '')).startswith(SETUP_ACTIONS) else str(step.get('uses')) + + +def pins(load: 'Callable[[str], dict[str, Any]]' = workflow) -> 'dict[tuple[str, str, str], list[str]]': + """Every literal Python version a workflow selects with, as (workflow, job, where): values.""" + found = {} # type: dict[tuple[str, str, str], list[str]] + + def add(name: 'str', job_id: 'str', where: 'str', value: 'str') -> None: + found.setdefault((name, job_id, where), []).append(' '.join(value.split())) + + for name, spec in workflows(load): + for job_id, job in spec.get('jobs', {}).items(): + title = str(job.get('name', '')) + for version in NAMED.findall(re.sub(r'\$\{\{.*?\}\}', '', title)): + add(name, job_id, 'name', version) + raw = job.get('strategy', {}).get('matrix', {}) + if isinstance(raw, dict): + for where, text in strings({key: value for key, value in raw.items() + if key in ('python-version', 'include')}, 'strategy.matrix'): + if VERSIONS.fullmatch(text): + add(name, job_id, where, text) + for where, text in strings({key: value for key, value in job.items() if key != 'steps'}, ''): + for a, b in COMPARED.findall(text): + add(name, job_id, where, a or b) + if where.startswith('env.') and VERSIONS.fullmatch(text): + add(name, job_id, where, text) + for step in job.get('steps', ()): + label = step_label(step) + version = step.get('with', {}).get('python-version') + if str(step.get('uses', '')).startswith(SETUP_ACTIONS) and isinstance(version, str) \ + and '${{' not in version: + add(name, job_id, f'{label}: with.python-version', version) + for where, text in strings({key: value for key, value in step.items() if key != 'run'}, ''): + for a, b in COMPARED.findall(text): + add(name, job_id, f'{label}: {where}', a or b) + if where.startswith('env.') and VERSIONS.fullmatch(text): + add(name, job_id, f'{label}: {where}', text) + code = '\n'.join(line for line in str(step.get('run', '')).splitlines() + if not line.strip().startswith('#')) + for found_tag in TAG.findall(code): + add(name, job_id, f'{label}: run', found_tag) + for loop in LOOP.findall(code): + add(name, job_id, f'{label}: run', loop) + for a, b in COMPARED.findall(code): + add(name, job_id, f'{label}: run', a or b) + return found + + +#: The jobs that run on one interpreter, which the ruling (#1661) puts on ``latest``. +SINGLE = (('unit-tests.yml', 'gate'), ('unit-tests.yml', 'unittest-ordering'), ('unit-tests.yml', 'changelog'), + ('unit-tests.yml', 'project-tests'), ('create-release.yml', 'version_check'), + ('cron-conda.yml', 'conda-update'), ('cron-vendor.yml', 'vendor-update'), + ('deploy-pages.yml', 'deploy-pages'), ('ci-timing.yml', 'report'), ('lint.yml', 'lint')) + + +class TestEveryVersionComesFromTheFile(unittest.TestCase): + """No workflow selects a Python version the file does not give it, bar the exemptions.""" + + #: A pin of each kind the scan reads, as it stood at :data:`MAIN`. + PINS_AT_MAIN = ( + ('unit-tests.yml', 'gate', 'name'), + ('unit-tests.yml', 'gate', 'Set up Python: with.python-version'), + ('unit-tests.yml', 'test', 'strategy.matrix.python-version'), + ('unit-tests.yml', 'test', 'strategy.matrix.include.python-version'), + ('unit-tests.yml', 'engine-tests', 'Run the 3.11-3.15 cells: run'), + ('create-release.yml', 'version_check', 'Check PyPI publish completeness: run'), + ('create-release.yml', 'pypi', 'No Source Distribution for Python ${{ matrix.python-version }}: if'), + ('unit-tests.yml', 'pypcap-parity', 'env.APT_CACHE_SAVE'), + ('lint.yml', 'lint', 'Set up Python: with.python-version'), + ) + + def test_no_pin_but_the_files_exemptions(self) -> None: + """Each pin is exempt with its value; each exemption still matches a pin. + + Where the clone holds :data:`MAIN`, the scan is shown not to be blind + first: it finds :data:`PINS_AT_MAIN` in ``main``'s workflows. + + """ + if has_main(): + at_main = pins(workflow_at_main) + for key in self.PINS_AT_MAIN: + with self.subTest(at_main=key): + self.assertIn(key, at_main) + exemptions = {(e['workflow'], e['job'], e['where']): e['value'] for e in script().load()['exemptions']} + found = pins() + for key, values in found.items(): + with self.subTest(pin=key): + self.assertIn(key, exemptions, f'{key} pins {values}: read it from the file, or exempt it there') + self.assertEqual(values, [exemptions[key]] * len(values)) + for key in exemptions: + with self.subTest(exemption=key): + self.assertIn(key, found, 'this exemption matches no pin: drop it from the file') + + def test_every_setup_reads_the_matrix_or_the_file(self) -> None: + """A setup action's version is ``matrix.python-version``, a script output, or an exempt literal.""" + exemptions = {(e['workflow'], e['job'], e['where']) for e in script().load()['exemptions']} + for name, spec in workflows(): + for job_id, job in spec.get('jobs', {}).items(): + for step in setups(job): + text = str(step.get('with', {}).get('python-version', '')) + where = (name, job_id, f'{step_label(step)}: with.python-version') + with self.subTest(step=where): + if where in exemptions or 'matrix.python-version' in text: + continue + output = script_output(name, job_id, READ.fullmatch(text).group(2)) # type: ignore[union-attr] + expected = {'engine-tests': ('engine-supported', 'engine-ktlo')}.get(job_id, ('latest',)) + self.assertIn(output, expected) + + def test_every_single_interpreter_job_runs_latest(self) -> None: + latest = script().load()['latest'] + for name, job_id in SINGLE: + job = workflow(name)['jobs'][job_id] + with self.subTest(workflow=name, job=job_id): + steps = [step for step in setups(job) if str(step['uses']).startswith('actions/setup-python@')] + self.assertEqual([resolve(name, job_id, step['with']['python-version']) for step in steps], [latest]) + + def test_the_coverage_leg_and_the_sdist_leg_are_latest(self) -> None: + latest = script().load()['latest'] + covered = [leg['python-version'] for leg in legs(matrix('unit-tests.yml', 'test')) if leg.get('coverage')] + self.assertEqual(covered, [latest]) + step = next(step for step in workflow('create-release.yml')['jobs']['pypi']['steps'] + if step.get('id') == 'no_sdist') + condition = re.fullmatch(r"\$\{\{ matrix\.python-version != (\S+) && steps\.build\.outcome == 'success' \}\}", + step['if']) + self.assertIsNotNone(condition, step['if']) + self.assertEqual(resolve('create-release.yml', 'pypi', '${{ %s }}' % condition.group(1)), latest) # type: ignore[union-attr] + + def test_the_versions_jobs_read_two_files_and_write_nothing(self) -> None: + """``unit-tests.yml`` is a reusable workflow whose callers grant only ``contents: read``.""" + for name in ('unit-tests.yml', 'python-compatibility.yml'): + spec = workflow(name) + job = spec['jobs']['python-versions'] + with self.subTest(workflow=name): + self.assertEqual(job.get('permissions', spec['permissions']), {'contents': 'read'}) + checkout = job['steps'][0] + self.assertTrue(checkout['uses'].startswith('actions/checkout@')) + self.assertEqual(checkout['with']['sparse-checkout'].split(), + ['.github/python-versions.toml', 'util/python_versions.py']) + self.assertIs(checkout['with']['sparse-checkout-cone-mode'], False) + self.assertEqual(job['steps'][1]['run'].strip(), SCRIPT_STEP) + self.assertNotIn('if', job) + + +class TestTheAnacondaCheckCountsTheFile(unittest.TestCase): + """``version_check``'s Anaconda evidence, run as the workflow runs it, ``curl`` stubbed. + + Its PyPI twin is executed by ``test_python_matrix_tiers_1633``. + + """ + + def setUp(self) -> None: + if shutil.which('bash') is None or shutil.which('python3') is None: # pragma: no cover + self.skipTest('bash and python3 are needed to run the workflow shell') + + def complete(self, distributions: 'list[tuple[str, str]]') -> 'str': + """``PCAPKIT_CONDA_COMPLETE`` for a release holding these (platform, build) pairs.""" + step = next(step for step in workflow('create-release.yml')['jobs']['version_check']['steps'] + if step.get('name') == 'Check Anaconda publish completeness') + builds = resolve('create-release.yml', 'version_check', step['env']['PCAPKIT_CONDA_BUILDS']) + with tempfile.TemporaryDirectory(prefix='pcapkit-1660-') as tmp: + scratch = pathlib.Path(tmp) + reply = scratch / 'reply.json' + reply.write_text(json.dumps({'distributions': [{'attrs': {'subdir': platform, 'build': f'{build}_0'}} + for platform, build in distributions]}), encoding='utf-8') + curl = scratch / 'curl' + curl.write_text('#!/bin/bash\nwhile [ $# -gt 0 ]; do [ "$1" = -o ] && cp "$REPLY" "$2"; shift; done\n' + 'printf 200\n', encoding='utf-8') + curl.chmod(0o755) + output = scratch / 'output' + output.write_text('', encoding='utf-8') + result = subprocess.run( + ['bash', '-e', '-c', step['run'].replace('/tmp/anaconda_release.json', str(scratch / 'release.json'))], + env=dict(os.environ, PATH=f'{scratch}{os.pathsep}{os.environ["PATH"]}', REPLY=str(reply), + GITHUB_OUTPUT=str(output), PCAPKIT_VERSION='1.5.0', PCAPKIT_CONDA_BUILDS=builds), + capture_output=True, text=True, check=False, timeout=60) + self.assertEqual(result.returncode, 0, result.stderr) + written = dict(line.split('=', 1) for line in output.read_text(encoding='utf-8').split()) + return written['PCAPKIT_CONDA_COMPLETE'] + + def test_each_platform_needs_each_supported_build(self) -> None: + builds = live_outputs()['conda-builds'].split() + every = [(platform, build) for platform in ('linux-64', 'osx-64') for build in builds] + ktlo = [('linux-64', 'py' + version.replace('.', '')) + for version in script().tier(script().load(), 'cpython', 'ktlo')] + self.assertEqual(self.complete(every), 'true') + self.assertEqual(self.complete(every[1:] + ktlo), 'false') + self.assertEqual(self.complete([]), 'false') + + +def prose(path: 'pathlib.Path') -> 'str': + """``path``'s text with each line break, and a leading ``#``/``#:`` after it, as one space.""" + return re.sub(r'[ \t]*\n[ \t]*(?:#:?[ \t]*)?', ' ', path.read_text(encoding='utf-8')) + + +class TestTheProseFollowsTheFile(unittest.TestCase): + """What the classifiers, docs and comments say about the versions, read against the file.""" + + @classmethod + def setUpClass(cls) -> None: + data = script().load() + cls.latest = data['latest'] + cls.supported = script().tier(data, 'cpython', 'supported') + cls.ktlo = script().tier(data, 'cpython', 'ktlo') + values = script().outputs(data) + cls.wheels = len(values['pypi-wheels'].split()) + cls.conda = len(json.loads(values['conda'])) + + def test_the_classifiers_name_the_supported_tier_and_both_implementations(self) -> None: + text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8') + self.assertEqual(re.findall(r'"Programming Language :: Python :: (3\.\d+)"', text), self.supported) + self.assertEqual(re.findall(r'"Programming Language :: Python :: Implementation :: (\w+)"', text), + ['CPython', 'PyPy']) + + #: (file, claim with the first and last version of a tier captured, which tier). + RANGES = ( + ('README.md', r'CI supports (3\.\d+) to (3\.\d+)', 'supported'), + ('README.md', r'tests the end-of-life (3\.\d+) to (3\.\d+)', 'ktlo'), + ('docs/source/index.rst', r'CI supports (3\.\d+) to (3\.\d+)', 'supported'), + ('docs/source/index.rst', r'tests the end-of-life (3\.\d+) to (3\.\d+)', 'ktlo'), + ('docs/source/index.rst', r'(3\.\d+) to (3\.\d+) are end-of-life and best-effort', 'ktlo'), + ('docs/source/contributing/pep.rst', r'in CI against Python (3\.\d+) through (3\.\d+)', 'supported'), + ('docs/source/contributing/pep.rst', r'against the end-of-life (3\.\d+) through (3\.\d+)', 'ktlo'), + ('docs/source/contributing/workflows.rst', r'The \*\*supported\*\* tier, (3\.\d+)-(3\.\d+),', 'supported'), + ('docs/source/contributing/workflows.rst', r'end-of-life (3\.\d+)-(3\.\d+), still built', 'ktlo'), + ('SECURITY.md', r'blocking tier: CPython (3\.\d+) through (3\.\d+)', 'supported'), + ('SECURITY.md', r'but (3\.\d+) to (3\.\d+) are best-effort', 'ktlo'), + ('pyproject.toml', r'\*\*(3\.\d+) through (3\.\d+)\*\* are supported', 'supported'), + ('pyproject.toml', r'\*\*(3\.\d+) through (3\.\d+)\*\* are KTLO', 'ktlo'), + ('pyproject.toml', r'the KTLO tier \((3\.\d+)-(3\.\d+)\)', 'ktlo'), + ('.github/workflows/lint.yml', r'Python Compatibility sweep (3\.\d+)-(3\.\d+),', 'supported'), + ('.github/workflows/lint.yml', r'the non-blocking KTLO legs (3\.\d+)-(3\.\d+)', 'ktlo'), + ) + + def test_each_tier_range_names_the_files_tier(self) -> None: + for path, pattern, name in self.RANGES: + versions = self.supported if name == 'supported' else self.ktlo + with self.subTest(path=path, claim=pattern): + self.assertEqual(re.findall(pattern, prose(ROOT / path)), [(versions[0], versions[-1])]) + + def test_the_required_contexts_table_is_the_supported_tier(self) -> None: + """The docs' copy of ruleset 23497679, which a tier change edits with it.""" + page = prose(ROOT / 'docs' / 'source' / 'contributing' / 'workflows.rst') + self.assertEqual(re.findall(r'\* - ``Compat Python (3\.\d+)``', page), self.supported) + comment = prose(WORKFLOWS / 'unit-tests.yml') + self.assertEqual(re.findall(r'and (\w+) for `Compat Python (3\.\d+)`-`(3\.\d+)`', comment), + [(['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven'][len(self.supported)], + self.supported[0], self.supported[-1])]) + + def test_the_workflows_page_cites_each_version_on_its_line(self) -> None: + """The required contexts' rows, and the two tiers' spans under them, cite the file by line.""" + lines = TIER_FILE.read_text(encoding='utf-8').splitlines() + page = prose(ROOT / 'docs' / 'source' / 'contributing' / 'workflows.rst') + rows = re.findall(r'``Compat Python (3\.\d+)`` .*? ``\.github/python-versions\.toml:(\d+)``', page) + self.assertEqual([version for version, _ in rows], self.supported) + for version, line in rows: + with self.subTest(version=version): + self.assertTrue(lines[int(line) - 1].startswith(f'"{version}" = '), lines[int(line) - 1]) + for label, versions in (('supported tier', self.supported), ('KTLO legs', self.ktlo)): + spans = re.findall(rf'its (?:\w+ )?{label} \(``:(\d+)-(\d+)``\)', page) + with self.subTest(tier=label): + self.assertEqual(len(spans), 1, spans) + first, last = map(int, spans[0]) + self.assertEqual([line.split(' = ', 1)[0] for line in lines[first - 1:last]], + [f'"{version}"' for version in versions]) + + def test_the_bug_report_template_offers_supported_versions(self) -> None: + text = (ROOT / '.github' / 'ISSUE_TEMPLATE' / 'bug_report.md').read_text(encoding='utf-8') + versions = re.search(r'Python Version: \[e\.g\. ([^\]]+)\]', text).group(1).split(', ') # type: ignore[union-attr] + self.assertEqual(versions[0], self.latest) + self.assertLessEqual(set(versions), set(self.supported)) + implementations = re.search(r'Python Implementation: \[e\.g\. ([^\]]+)\]', text).group(1) # type: ignore[union-attr] + self.assertEqual(implementations.split(', '), ['CPython', 'PyPy']) + + #: (file, a claim naming the coverage leg's or the sdist leg's version) -- ``latest``. + LATEST = ( + ('.github/workflows/coverage-comment.yml', r'\(unit tier, Python (\d+\.\d+), '), + ('.github/workflows/coverage-comment.yml', r"the `test` job's Python (\d+\.\d+) leg"), + ('.github/coverage.toml', r'\(`test`, Python (\d+\.\d+)\)'), + ('docs/source/contributing/workflows.rst', + r'the ``Python (\d+\.\d+)`` leg of ``test`` \(matrix key ``coverage: true``\)'), + ('docs/source/contributing/conventions/test-coverage.rst', r"the ``test`` job's Python (\d+\.\d+) cell"), + ('.github/workflows/create-release.yml', r'keeps only on the (\d+\.\d+) leg'), + ('docs/source/contributing/releasing.rst', r'the sdist kept only on the (\d+\.\d+) leg'), + ) + + def test_each_claim_about_the_latest_leg_names_latest(self) -> None: + for path, pattern in self.LATEST: + with self.subTest(path=path, claim=pattern): + self.assertEqual(re.findall(pattern, prose(ROOT / path)), [self.latest]) + + def test_the_release_checks_counts_follow_the_matrices(self) -> None: + """version_check's own counts come from the file; the prose stating them must too.""" + release = prose(WORKFLOWS / 'create-release.yml') + page = prose(ROOT / 'docs' / 'source' / 'contributing' / 'releasing.rst') + claims = ( + (release, r'against the (\d+) expected', [str(self.wheels + 1)] * 2), + (release, r'expected -- the (\d+) supported legs', [str(self.wheels)]), + (release, r'the (\d+)-leg ``conda`` matrix \(2 OS x (\d+) Python versions\)', + [(str(2 * self.conda), str(self.conda))]), + (release, r'only (\d+) of (\d+) legs', [(str(2 * self.conda - 1), str(2 * self.conda))]), + (page, r'against the (\d+) expected -- the (\d+) supported legs', + [(str(self.wheels + 1), str(self.wheels))]), + (page, r'against the (\d+) expected \(2 OS x (\d+) Python versions\)', + [(str(2 * self.conda), str(self.conda))]), + ) + for text, pattern, expected in claims: + with self.subTest(claim=pattern): + self.assertEqual(re.findall(pattern, text), expected) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/project/test_release_tags_the_run_sha_1634.py b/tests/project/test_release_tags_the_run_sha_1634.py index b457a7cf24..0136bc0a73 100644 --- a/tests/project/test_release_tags_the_run_sha_1634.py +++ b/tests/project/test_release_tags_the_run_sha_1634.py @@ -1167,7 +1167,7 @@ def test_an_asset_left_by_a_failed_upload_is_uploaded_again(self) -> None: self.assertEqual(self.on_release(), [f'{self.CONDA}\tuploaded']) def test_only_the_files_the_release_lacks_go_up(self) -> None: - """``pypi``'s own ``dist/*``: the 3.14 leg's sdist, after its wheel landed.""" + """``pypi``'s own ``dist/*``: the 3.15 leg's sdist, after its wheel landed.""" self.build(self.WHEEL, self.SDIST) self.published(self.WHEEL) result = self.upload('pypi') diff --git a/tests/project/test_unit_tests_queue.py b/tests/project/test_unit_tests_queue.py index b1dd187d13..d11d4d9430 100644 --- a/tests/project/test_unit_tests_queue.py +++ b/tests/project/test_unit_tests_queue.py @@ -11,7 +11,7 @@ code, and a Markdown file under a code directory code too. * ``unittest-ordering`` stays off pull requests and out of the required set, and queues behind ``test`` without needing it to pass (#1538); - ``engine-tests``'s KTLO 3.10 cell stays non-blocking through a step-level + ``engine-tests``'s KTLO cells stay non-blocking through a step-level ``continue-on-error`` rather than a job-level one (#1633), and the job has no ``DPKT`` cell (#1538). * The classifier, the engine cell loops and the apt retry are executed too: the @@ -39,6 +39,8 @@ import unittest from typing import TYPE_CHECKING +from tests.project.test_python_versions_1660 import live_outputs + if TYPE_CHECKING: from typing import Callable, Optional @@ -190,7 +192,7 @@ def test_the_four_legs_skip_only_on_a_docs_only_diff(self) -> None: with self.subTest(job=name): head = header(job(name)) self.assertIn("needs.changes.outputs.code != 'false'", head) - self.assertRegex(head, r'(?m)^ needs: \[changes\]$') + self.assertRegex(head, r'(?m)^ needs: \[changes, python-versions\]$') def test_project_tests_runs_only_on_a_docs_only_diff(self) -> None: """``project-tests`` never duplicates ``test`` on a code change.""" @@ -214,17 +216,18 @@ def test_unittest_ordering_runs_verbose_under_a_step_cap(self) -> None: self.assertRegex(run_step, r'(?m)^ timeout-minutes: \d+$') def test_engine_tests_is_one_job_per_engine_with_a_non_blocking_ktlo_step(self) -> None: - """E: matrix on engine only; KTLO 3.10 blocks nothing, 3.11-3.15 do (#1633).""" + """E: matrix on engine only; the KTLO cells block nothing, the supported ones do (#1633).""" section = job('engine-tests') head = header(section) - self.assertNotIn('python-version', head) + self.assertNotIn('python-version:', head) # no axis; `needs` names `python-versions` self.assertNotIn('continue-on-error', head) self.assertRegex(section, r'(?m)^ engine:$') - ktlo = section.split('- name: Run the 3.10 cell', 1)[1] + ktlo = section.split('- name: Run the KTLO cells (non-blocking)', 1)[1] self.assertRegex(ktlo, r'(?m)^ continue-on-error: true$') - gating = section.split('- name: Run the 3.11-3.15 cells', 1)[1].split(' - name:', 1)[0] + gating = section.split('- name: Run the supported cells', 1)[1].split(' - name:', 1)[0] self.assertNotIn('continue-on-error', gating) - self.assertIn('for py in 3.11 3.12 3.13 3.14 3.15;', gating) + self.assertIn('PYTHONS: ${{ needs.python-versions.outputs.engine-supported }}', gating) + self.assertIn('for py in $PYTHONS;', gating) def test_unittest_ordering_queues_behind_test_and_survives_its_failure(self) -> None: """#1538 P4(a): queued after ``test``, and ``!cancelled()`` so a red ``test`` still runs it. @@ -240,7 +243,7 @@ def test_unittest_ordering_queues_behind_test_and_survives_its_failure(self) -> def test_the_ktlo_cell_runs_on_every_event(self) -> None: """#1633: 3.10 still runs wherever it did; #1538's main-only cut was for prerelease 3.15.""" - ktlo = step(job('engine-tests'), 'Run the 3.10 cell (KTLO, non-blocking)') + ktlo = step(job('engine-tests'), 'Run the KTLO cells (non-blocking)') self.assertNotRegex(ktlo, r'(?m)^ if:') def test_engine_tests_has_no_dpkt_cell(self) -> None: @@ -391,33 +394,54 @@ def test_a_failed_diff_is_code(self) -> None: class TestEngineCellLoops(_Scratch): """The two cell-loop steps of ``engine-tests``, with the cell script stubbed.""" + #: Each loop step, and the output of .github/python-versions.toml it loops over (#1660). + LOOPS = {'Run the supported cells': 'engine-supported', 'Run the KTLO cells (non-blocking)': 'engine-ktlo'} + def run_loop(self, name: 'str', fail: 'str') -> 'tuple[int, list[str]]': """Run loop step ``name`` with the cell for ``fail`` failing; status and cells run.""" (self.tmp / 'engine-cell.sh').write_text( 'echo "$1" >> "$RUNNER_TEMP/calls"\n[ "$1" != "$FAIL_PY" ]\n', encoding='utf-8') status = bash(step_script(job('engine-tests'), name), {'RUNNER_TEMP': str(self.tmp), 'ENGINE': 'Scapy', 'FAIL_PY': fail, - 'GITHUB_STEP_SUMMARY': str(self.tmp / 'summary')}) + 'GITHUB_STEP_SUMMARY': str(self.tmp / 'summary'), 'PYTHONS': self.versions(name)}) calls = self.tmp / 'calls' return status, calls.read_text(encoding='utf-8').split() if calls.exists() else [] + def versions(self, name: 'str') -> 'str': + """What step ``name``'s ``PYTHONS`` holds: one version per line, as the job output does.""" + expression = f'PYTHONS: ${{{{ needs.python-versions.outputs.{self.LOOPS[name]} }}}}' + self.assertIn(expression, step(job('engine-tests'), name)) + return live_outputs()[self.LOOPS[name]] + def test_every_gating_cell_runs_and_passing_passes(self) -> None: - """All five gating interpreters run; none failing exits 0.""" - status, calls = self.run_loop('Run the 3.11-3.15 cells', fail='none') + """Every supported interpreter runs; none failing exits 0.""" + status, calls = self.run_loop('Run the supported cells', fail='none') self.assertEqual(status, 0) - self.assertEqual(calls, ['3.11', '3.12', '3.13', '3.14', '3.15']) + self.assertEqual(calls, self.versions('Run the supported cells').split()) + self.assertGreater(len(calls), 1) def test_one_failing_cell_fails_the_step_after_the_rest_ran(self) -> None: """A failure is not swallowed, and does not stop the later cells.""" - status, calls = self.run_loop('Run the 3.11-3.15 cells', fail='3.12') + expected = self.versions('Run the supported cells').split() + status, calls = self.run_loop('Run the supported cells', fail=expected[1]) self.assertNotEqual(status, 0) - self.assertEqual(calls, ['3.11', '3.12', '3.13', '3.14', '3.15']) + self.assertEqual(calls, expected) + self.assertIn(f'| {expected[1]} | Scapy | | FAILED |', (self.tmp / 'summary').read_text(encoding='utf-8')) def test_a_failing_ktlo_cell_fails_its_own_step(self) -> None: """The step fails; its ``continue-on-error`` keeps the job green, its annotation is a warning (#1672).""" - status, calls = self.run_loop('Run the 3.10 cell (KTLO, non-blocking)', fail='3.10') + expected = self.versions('Run the KTLO cells (non-blocking)').split() + status, calls = self.run_loop('Run the KTLO cells (non-blocking)', fail=expected[0]) self.assertNotEqual(status, 0) - self.assertEqual(calls, ['3.10']) + self.assertEqual(calls, expected) + + def test_no_ktlo_cell_is_a_passing_no_op(self) -> None: + """A KTLO tier with no cell on ``ubuntu-latest`` loops over nothing, rather than failing.""" + (self.tmp / 'engine-cell.sh').write_text('exit 1\n', encoding='utf-8') + status = bash(step_script(job('engine-tests'), 'Run the KTLO cells (non-blocking)'), + {'RUNNER_TEMP': str(self.tmp), 'ENGINE': 'Scapy', 'PYTHONS': '', + 'GITHUB_STEP_SUMMARY': str(self.tmp / 'summary')}) + self.assertEqual(status, 0) #: The two apt steps, as (job, step name, extra env). @@ -565,7 +589,7 @@ def test_the_scan_is_not_blind(self) -> None: ("# Ruleset 23497679's required_status_checks", '# them.'), (' required-checks:', 'echo "All required jobs reported an accepted result."'), ('if: ${{ always() && inputs.gate-only != true }}', None), - ('# The KTLO tier (#1633)', '# `ubuntu-latest` moves to 26.04'), + ('# The KTLO tier (#1633)', '`ubuntu-latest` moves to 26.04 from 2026-10-19.'), ], } @@ -628,9 +652,10 @@ def test_the_scan_is_not_blind(self) -> None: '631-647': "softprops/action-gh-release 3.0.2's src/github.ts", }, 'docs/source/contributing/workflows.rst': { - '39': '.github/workflows/python-compatibility.yml', - '47-51': '.github/workflows/python-compatibility.yml', - '56-66': '.github/workflows/python-compatibility.yml', + '60': '.github/workflows/python-compatibility.yml', + '74': '.github/workflows/python-compatibility.yml', + '20-24': '.github/python-versions.toml', + '16-19': '.github/python-versions.toml', }, } diff --git a/tests/project/test_workflow_apt_cache.py b/tests/project/test_workflow_apt_cache.py index 7f8d6a0778..8f8bd5889b 100644 --- a/tests/project/test_workflow_apt_cache.py +++ b/tests/project/test_workflow_apt_cache.py @@ -37,6 +37,7 @@ import unittest from typing import TYPE_CHECKING +from tests.project.test_python_versions_1660 import legs, matrix from tests.project.test_unit_tests_queue import job, step_script if TYPE_CHECKING: @@ -317,7 +318,8 @@ def test_only_one_parity_cell_saves(self) -> None: value = job_env('pypcap-parity', 'APT_CACHE_SAVE') match = re.fullmatch(r"\$\{\{ matrix\.python-version == '([\d.]+)' \}\}", value) self.assertIsNotNone(match, value) - versions = re.findall(r'(?m)^ - "([\d.]+)"$', job('pypcap-parity')) + # The parity matrix's legs, from .github/python-versions.toml (#1660). + versions = [leg['python-version'] for leg in legs(matrix('unit-tests.yml', 'pypcap-parity'))] self.assertIn(match.group(1), versions) # type: ignore[union-attr] self.assertGreater(len(versions), 1) diff --git a/util/ci_timing.py b/util/ci_timing.py index 7d8a52172e..16c0c4588a 100644 --- a/util/ci_timing.py +++ b/util/ci_timing.py @@ -65,6 +65,7 @@ import statistics import sys import time +import tomllib import traceback import urllib.error import urllib.parse @@ -93,11 +94,24 @@ #: The aggregate job the ruleset requires; its completion is "mergeable". REQUIRED_JOB = 'Required checks passed' -#: The leg running under coverage, and the legs its tests step is paired against: -#: the rest of the supported tier (#1633), not the KTLO legs, which are named -#: ``Python 3.x (KTLO)`` and may stop early without failing. -COVERAGE_JOB = 'Python 3.14' -PAIRED_JOBS = ('Python 3.11', 'Python 3.12', 'Python 3.13', 'Python 3.15') +#: The Python version tiers (#1660), which the two names below are read from. This +#: script runs on ``latest`` (ci-timing.yml), so :mod:`tomllib`, 3.11+, is there. +TIERS = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.github', 'python-versions.toml') + + +def _coverage_legs() -> 'tuple[str, tuple[str, ...]]': + """The ``latest`` leg's job name, and the other supported legs', from :data:`TIERS`.""" + with open(TIERS, 'rb') as file: + data = tomllib.load(file) + paired = tuple(f'Python {version}' for version, entry in data['versions']['cpython'].items() + if entry['tier'] == 'supported' and version != data['latest']) + return f"Python {data['latest']}", paired + + +#: The leg running under coverage, ``latest``, and the legs its tests step is paired +#: against: the rest of the supported tier (#1633), not the KTLO legs, which are +#: named ``Python 3.x (KTLO)`` and may stop early without failing. +COVERAGE_JOB, PAIRED_JOBS = _coverage_legs() TESTS_STEP = 'Run unit tests' #: The leg ms/test is measured on: a supported version not under coverage, kept on #: 3.13 -- the newest such leg when W4's baseline was measured -- so that W4 compares @@ -115,7 +129,8 @@ r'Report coverage', r'Run full test suite', r'Run tests/.* under plain unittest', - r'Run the 3\.10 cell.*', + # engine-tests' KTLO step, named by its one version until #1660. + r'Run the (?:3\.10 cell|KTLO cells).*', ) # Thresholds. Each is anchored to a value measured on 2026-10-05..09 for #1538, diff --git a/util/python_versions.py b/util/python_versions.py new file mode 100644 index 0000000000..c316cf1620 --- /dev/null +++ b/util/python_versions.py @@ -0,0 +1,264 @@ +# -*- coding: utf-8 -*- +"""The Python version tiers in :file:`.github/python-versions.toml`, as the workflows read them (#1660). + +Every workflow matrix that runs Python legs, and the version of every job that +runs on one interpreter, comes from that file through this script. A workflow +runs it with the runner's own ``python3``, before any setup-python step, as +``python3 util/python_versions.py --github-output``, which appends one step +output per name below to ``$GITHUB_OUTPUT``. So it is standard library only: +:mod:`tomllib` needs 3.11, and each image it runs on has a newer ``python3`` -- +3.12.3 on ``ubuntu-latest`` (24.04), 3.14.4 on 26.04, which that label moves to +from 2026-10-19, and 3.14.7 on ``macos-latest`` (macOS 26 arm64), per +actions/runner-images' READMEs at 2c421200c. + +``latest`` + The newest supported CPython, for the single-interpreter jobs. +``test``, ``integration``, ``compatibility`` + Both tiers. A KTLO leg carries ``ktlo: true``, and ``os`` where + ``ubuntu-latest`` cannot provide it; ``test`` marks the ``latest`` leg + ``coverage: true``. +``pypcap-parity`` + The versions below 3.12 that run on ``ubuntu-latest``. +``pypi`` + CPython, then PyPy, both tiers, on ``ubuntu-latest``. +``conda`` + The supported CPython versions, as one matrix axis. +``engine-supported``, ``engine-ktlo`` + What ``engine-tests`` installs on ``ubuntu-latest``, one version per line, + as ``actions/setup-python`` reads a list. +``pypi-wheels``, ``conda-builds`` + The supported tier's wheel tags and conda build tags, which Create Release's + completeness checks count. + +Each matrix keeps the shape its job had when the versions were spelled out in +the workflow -- a ``python-version`` axis, then ``include`` -- so GitHub expands +it to the same legs in the same order. +``tests/project/test_python_versions_1660.py`` compares the two. + +Run without arguments it prints every output; with names, just their values. + +""" + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import re +import sys +import tomllib +import uuid +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Callable, Optional + +ROOT = pathlib.Path(__file__).resolve().parents[1] +FILE = ROOT / '.github' / 'python-versions.toml' + +#: Each implementation, and how its versions are spelled. +SPELLING = {'cpython': re.compile(r'3\.(\d+)'), 'pypy': re.compile(r'pypy3\.(\d+)')} +TIERS = ('supported', 'ktlo', 'pre-release') +#: The images a version may name as its ``runner``. +RUNNER = re.compile(r'ubuntu-\d{2}\.04') +#: Wheel tag prefix per implementation, as util/wheel_rename.py writes it. +WHEEL_PREFIX = {'cpython': 'cp', 'pypy': 'pp'} +#: PyPCAP's and PyPCAPFile's ``python_version < '3.12'`` markers in pyproject.toml: +#: from 3.12 the parity job would install neither and cover nothing new. +PARITY_BELOW = (3, 12) + + +def key(version: 'str') -> 'tuple[int, int]': + """``'3.10'`` or ``'pypy3.10'`` as ``(3, 10)``, for ordering.""" + minor = re.search(r'3\.(\d+)$', version) + if minor is None: + raise ValueError(f'not a Python version: {version!r}') + return 3, int(minor.group(1)) + + +def load(path: 'pathlib.Path' = FILE) -> 'dict[str, Any]': + """The tier file, validated. + + Raises :exc:`ValueError` naming the first problem: invalid TOML, an unknown + key or tier, a misspelt version or runner, a ``latest`` that is not a + supported CPython, an exemption missing a field, or a pre-release entry -- no + workflow has the non-blocking pre-release legs one needs + (``allow-prereleases``, a name of its own) yet, so listing one fails here + instead of running it as something else. An exemption's reason is the + comment above it, which :mod:`tomllib` does not keep. + + """ + data = tomllib.loads(pathlib.Path(path).read_text(encoding='utf-8')) + if set(data) - {'latest', 'versions', 'exemptions'}: + raise ValueError('the top level holds only "latest", "versions" and "exemptions"') + versions = data.get('versions') + if not isinstance(versions, dict) or set(versions) != set(SPELLING): + raise ValueError(f'"versions" maps exactly {sorted(SPELLING)} to their versions') + for impl, entries in versions.items(): + if not isinstance(entries, dict) or not entries: + raise ValueError(f'{impl} lists no versions') + for version, entry in entries.items(): + where = f'{impl} {version}' + if not SPELLING[impl].fullmatch(version): + raise ValueError(f'{where}: not spelt as {SPELLING[impl].pattern}') + if not isinstance(entry, dict) or 'tier' not in entry or set(entry) - {'tier', 'runner'}: + raise ValueError(f'{where}: an entry is {{"tier": ...}}, with an optional "runner"') + if entry['tier'] not in TIERS: + raise ValueError(f'{where}: tier {entry["tier"]!r} is not one of {TIERS}') + if entry['tier'] == 'pre-release': + raise ValueError(f'{where}: no workflow runs pre-release legs yet; wire their ' + 'non-blocking mechanics before listing one (#1660)') + if 'runner' in entry and not RUNNER.fullmatch(str(entry['runner'])): + raise ValueError(f'{where}: runner {entry["runner"]!r} is not an ubuntu-YY.04 image') + if list(entries) != sorted(entries, key=key): + raise ValueError(f'{impl}: list the versions oldest first') + if data.get('latest') not in tier(data, 'cpython', 'supported'): + raise ValueError(f'latest {data.get("latest")!r} is not a supported CPython version') + for index, exemption in enumerate(data.get('exemptions', [])): + fields = ('workflow', 'job', 'where', 'value') + if (not isinstance(exemption, dict) or set(exemption) != set(fields) + or not all(isinstance(exemption[name], str) and exemption[name] for name in fields)): + raise ValueError(f'exemption {index}: give each of {fields}, as a non-empty string') + return data + + +def tier(data: 'dict[str, Any]', impl: 'str', name: 'str') -> 'list[str]': + """The versions of ``impl`` in tier ``name``, oldest first.""" + return [version for version, entry in data['versions'][impl].items() if entry['tier'] == name] + + +def runner(data: 'dict[str, Any]', impl: 'str', version: 'str') -> 'Optional[str]': + """The image ``version`` needs, or :data:`None` for ``ubuntu-latest``.""" + return data['versions'][impl][version].get('runner') + + +def on_latest(data: 'dict[str, Any]', impl: 'str', name: 'str', job: 'str') -> 'list[str]': + """Tier ``name`` of ``impl`` as a job pinned to ``ubuntu-latest`` can run it. + + A KTLO version needing an older image is left out. A supported one cannot be, + so that raises: ``job`` would silently stop testing it. + + """ + found = [] + for version in tier(data, impl, name): + if runner(data, impl, version) is None: + found.append(version) + elif name == 'supported': + raise ValueError(f'{job} runs on ubuntu-latest only, which cannot run supported {version}') + return found + + +def tiered(data: 'dict[str, Any]', impl: 'str' = 'cpython') -> 'dict[str, Any]': + """The supported tier as the axis, each KTLO version added by ``include``.""" + include = [] + for version in tier(data, impl, 'ktlo'): + leg = {'python-version': version, 'ktlo': True} # type: dict[str, Any] + if runner(data, impl, version) is not None: + leg['os'] = runner(data, impl, version) + include.append(leg) + return {'python-version': tier(data, impl, 'supported'), 'include': include} + + +def test_matrix(data: 'dict[str, Any]') -> 'dict[str, Any]': + """unit-tests.yml's ``test``: both tiers, the one coverage leg on ``latest``.""" + matrix = tiered(data) + matrix['include'].insert(0, {'python-version': data['latest'], 'coverage': True}) + return matrix + + +def parity_matrix(data: 'dict[str, Any]') -> 'dict[str, Any]': + """unit-tests.yml's ``pypcap-parity``: every version below :data:`PARITY_BELOW` it can run. + + The axis lists both tiers and ``include`` marks the KTLO ones, as the job did. + + """ + supported = [v for v in on_latest(data, 'cpython', 'supported', 'pypcap-parity') if key(v) < PARITY_BELOW] + ktlo = [v for v in on_latest(data, 'cpython', 'ktlo', 'pypcap-parity') if key(v) < PARITY_BELOW] + return {'python-version': sorted(ktlo + supported, key=key), + 'include': [{'python-version': version, 'ktlo': True} for version in ktlo]} + + +def pypi_matrix(data: 'dict[str, Any]') -> 'dict[str, Any]': + """create-release.yml's ``pypi``: one wheel per CPython and PyPy version, both tiers.""" + impls = tuple(SPELLING) + return {'python-version': [v for impl in impls for v in on_latest(data, impl, 'supported', 'pypi')], + 'include': [{'python-version': v, 'ktlo': True} + for impl in impls for v in on_latest(data, impl, 'ktlo', 'pypi')]} + + +def wheel_tag(version: 'str') -> 'str': + """``'3.11'`` as ``'cp311'``, ``'pypy3.11'`` as ``'pp311'``.""" + impl = 'pypy' if version.startswith('pypy') else 'cpython' + return WHEEL_PREFIX[impl] + ''.join(map(str, key(version))) + + +#: Each output's builder. +OUTPUTS = { + 'latest': lambda data: data['latest'], + 'test': test_matrix, + 'integration': tiered, + 'compatibility': tiered, + 'pypcap-parity': parity_matrix, + 'pypi': pypi_matrix, + 'conda': lambda data: tier(data, 'cpython', 'supported'), + 'engine-supported': lambda data: '\n'.join(on_latest(data, 'cpython', 'supported', 'engine-tests')), + 'engine-ktlo': lambda data: '\n'.join(on_latest(data, 'cpython', 'ktlo', 'engine-tests')), + 'pypi-wheels': lambda data: ' '.join(map(wheel_tag, pypi_matrix(data)['python-version'])), + 'conda-builds': lambda data: ' '.join('py' + ''.join(map(str, key(v))) for v in tier(data, 'cpython', 'supported')), +} # type: dict[str, Callable[[dict[str, Any]], Any]] + + +def outputs(data: 'dict[str, Any]') -> 'dict[str, str]': + """Every output, as the string a workflow reads: JSON for a matrix, plain text otherwise.""" + found = {} + for name, build in OUTPUTS.items(): + value = build(data) + found[name] = value if isinstance(value, str) else json.dumps(value, separators=(',', ':')) + return found + + +def write_github_output(values: 'dict[str, str]', path: 'str') -> 'None': + """Append ``values`` to a ``$GITHUB_OUTPUT`` file, a multi-line one under a fresh delimiter.""" + with open(path, 'a', encoding='utf-8') as file: + for name, value in values.items(): + if '\n' in value: + delimiter = f'EOF_{uuid.uuid4().hex}' + file.write(f'{name}<<{delimiter}\n{value}\n{delimiter}\n') + else: + file.write(f'{name}={value}\n') + + +def main(argv: 'Optional[list[str]]' = None) -> 'int': + parser = argparse.ArgumentParser(description=__doc__.split('\n', 1)[0]) + parser.add_argument('names', nargs='*', metavar='NAME', help='print only these outputs, one per line') + parser.add_argument('--file', type=pathlib.Path, default=FILE, help='the tier file (default: %(default)s)') + parser.add_argument('--github-output', action='store_true', help='append every output to $GITHUB_OUTPUT') + args = parser.parse_args(argv) + + try: + values = outputs(load(args.file)) + unknown = sorted(set(args.names) - set(values)) + if unknown: + raise ValueError(f'no output named {", ".join(unknown)}; there are {", ".join(values)}') + except (OSError, ValueError) as error: # tomllib.TOMLDecodeError is a ValueError + message = str(error).replace('%', '%25').replace('\r', '%0D').replace('\n', '%0A') + print(f'::error title=Cannot read the Python version tiers::{args.file}: {message}') + return 1 + + if args.github_output: + path = os.environ.get('GITHUB_OUTPUT') + if not path: + print('::error title=GITHUB_OUTPUT is not set::--github-output only works inside a workflow step') + return 1 + write_github_output(values, path) + for name in args.names: + print(values[name]) + if not args.names: + for name, value in values.items(): + print(f'{name}: {value}'.replace('\n', ' ')) + return 0 + + +if __name__ == '__main__': + sys.exit(main())