diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 1f65361631..51786a97dd 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -23,7 +23,7 @@ simply wrong, the most useful form is the observed value next to the expected on **System information** A clear and concise description of your system information. - OS Version: [e.g. macOS 15.3, Ubuntu 24.04, Windows 11] - - Python Version: [e.g. 3.14, 3.12, 3.10] + - Python Version: [e.g. 3.15, 3.13, 3.11] - Python Implementation: [e.g. CPython, PyPy] - `pcapkit` Version: [`python -c "import pcapkit; print(pcapkit.__version__)"`] - If you are working from a checkout rather than an installed release, please give diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 905f7f1c2b..cd67efaed4 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -100,7 +100,7 @@ concurrency: # artefact -- the tag it checks is the one it is about to create -- but ``tag``, # ``pypi`` and ``conda`` now each read their own evidence instead: ``tag`` checks # for the ``conda-+0`` tag it creates, ``pypi`` checks PyPI's file count -# for the version against the 8 expected, and ``conda`` both checks Anaconda's +# for the version against the 7 expected, and ``conda`` both checks Anaconda's # aggregate count as a job-level cost saver and, per leg, whether that leg's own # platform/Python distribution is already there -- the correctness fix, since # retrying an upload Anaconda already has fails outright rather than skipping @@ -221,12 +221,14 @@ jobs: with: tag: "conda-${{ steps.get_version.outputs.PCAPKIT_VERSION }}+0" - # PyPI evidence: compare the file count for this version against the 8 - # expected -- 7 matrix legs (``pypi``'s ``strategy.matrix.python-version``) + # 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* # file landed, not all of them, so presence alone -- ``PCAPKIT_TAG_EXISTS``'s - # mistake, repeated -- is not enough. + # mistake, repeated -- is not enough. Only the supported tier's wheels are + # counted (#1633): a KTLO leg's wheel neither completes the release nor, + # when that leg fails, leaves it incomplete for every later run to retry. # # The per-version endpoint's own ``releases`` mapping was empty on every # request checked while writing this, including against a version that @@ -251,7 +253,7 @@ jobs: run: | set -euo pipefail - expected=8 + expected=7 # `--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`, @@ -280,7 +282,10 @@ jobs: "import json" \ "with open('/tmp/pypi_release.json') as f:" \ " data = json.load(f)" \ - "print(len(data.get('urls', [])))" \ + "supported = {'cp311', 'cp312', 'cp313', 'cp314', 'cp315', 'pp311'}" \ + "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))" \ | python3 -)" fi @@ -323,7 +328,7 @@ jobs: "with open('/tmp/anaconda_release.json') as f:" \ " data = json.load(f)" \ "platforms = {'linux-64', 'osx-64'}" \ - "py_tags = {'py310', 'py311', 'py312', 'py313', 'py314'}" \ + "py_tags = {'py311', 'py312', 'py313', 'py314', 'py315'}" \ "seen = {" \ " (d.get('attrs', {}).get('subdir'), tag)" \ " for d in data.get('distributions', [])" \ @@ -547,7 +552,7 @@ jobs: git push origin "conda-${{ needs.version_check.outputs.PCAPKIT_VERSION }}+0" pypi: - name: PyPI distribution for Python ${{ matrix.python-version }} + name: PyPI distribution for Python ${{ matrix.python-version }}${{ matrix.ktlo && ' (KTLO)' || '' }} runs-on: ubuntu-latest # Named ``pypi`` rather than the upstream snippet's ``release``: see the note # above ``jobs:`` for why each target gets its own. #641 was that this line @@ -576,19 +581,35 @@ jobs: needs.unit-tests.result == 'success' && (needs.github.result == 'success' || needs.github.result == 'skipped') && (startsWith(github.ref_name, 'v') || needs.version_check.outputs.PCAPKIT_PYPI_COMPLETE == 'false') }} + # #1633: each KTLO leg's own outcome, under a name only that leg sets -- + # matrix job outputs are combined across legs -- so ``release_status`` can + # report it although it never reaches ``needs.pypi.result``. + outputs: + ktlo_310: ${{ steps.ktlo.outputs.ktlo_310 }} + ktlo_pypy310: ${{ steps.ktlo.outputs.ktlo_pypy310 }} strategy: ## 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.10" - "3.11" - "3.12" - "3.13" - "3.14" - - "pypy3.10" + - "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. + include: + - python-version: "3.10" + ktlo: true + - python-version: "pypy3.10" + ktlo: true steps: - name: Checkout code uses: actions/checkout@v7 @@ -597,10 +618,14 @@ jobs: fetch-depth: 0 - uses: actions/setup-python@v7 + id: setup + continue-on-error: ${{ matrix.ktlo == true }} with: python-version: ${{ matrix.python-version }} - name: Install and Setup + id: install + continue-on-error: ${{ matrix.ktlo == true }} run: | set -x @@ -608,6 +633,8 @@ jobs: python -m pip install -U build - name: Build Package + id: build + continue-on-error: ${{ matrix.ktlo == true }} env: PCAPKIT_VERSION: ${{ needs.version_check.outputs.PCAPKIT_VERSION }} run: | @@ -617,11 +644,15 @@ jobs: python util/wheel_rename.py dist/*.whl - name: No Source Distribution for Python ${{ matrix.python-version }} + id: no_sdist if: ${{ matrix.python-version != '3.14' }} + continue-on-error: ${{ matrix.ktlo == true }} run: | rm dist/*.tar.gz - name: Publish to PyPI + id: publish + continue-on-error: ${{ matrix.ktlo == true }} uses: pypa/gh-action-pypi-publish@release/v1 with: # password: ${{ secrets.PYPI_API_TOKEN }} @@ -642,6 +673,8 @@ jobs: attestations: false - name: Upload package to GitHub Release + id: upload + continue-on-error: ${{ matrix.ktlo == true }} uses: softprops/action-gh-release@v3.0.2 with: # allowUpdates: true @@ -650,6 +683,25 @@ jobs: tag_name: "v${{ needs.version_check.outputs.PCAPKIT_VERSION }}" token: "${{ secrets.GITHUB_TOKEN }}" + # #1633: what this KTLO leg's steps did before ``continue-on-error``, + # for ``release_status`` -- ``steps.*.outcome`` covers the steps with an + # ``id`` above, i.e. every one that can fail here except the TestPyPI + # upload, which no leg treats as a failure. + - name: Record this KTLO leg's outcome + id: ktlo + if: ${{ !cancelled() && matrix.ktlo == true }} + continue-on-error: ${{ matrix.ktlo == true }} + env: + LEG: ${{ matrix.python-version }} + OUTCOMES: ${{ join(steps.*.outcome, ' ') }} + run: | + case " $OUTCOMES " in + *" failure "*) result=failure ;; + *) result=success ;; + esac + echo "ktlo_$(printf '%s' "$LEG" | tr -d '.')=$result" >> "$GITHUB_OUTPUT" + echo "Python $LEG (KTLO, non-blocking): $result (step outcomes: $OUTCOMES)" + conda: name: Conda deployment (release) on ${{ matrix.os }} with Python ${{ matrix.python-version }} runs-on: ${{ matrix.os }} @@ -685,12 +737,16 @@ jobs: - macos-latest - ubuntu-latest # - windows-latest # skip for now + # #1633: the supported tier only. 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.10" - "3.11" - "3.12" - "3.13" - "3.14" + - "3.15" steps: - uses: actions/checkout@v7 with: @@ -800,13 +856,20 @@ jobs: mkdir -p conda/wheels python -m pip download -r conda/requirements.txt -d conda/wheels + # With `activate-environment: base`, `python-version` installs that Python + # into base, next to conda itself. conda-forge's conda has no py315 build + # yet, so on 3.15 that fails with "RemoveError: Conda cannot remove + # itself" (#1633, measured with conda 26.7.2). So 3.15 leaves base on its + # own Python: `conda build --python` below builds against 3.15 regardless, + # measured to build and import pypcapkit-*-py315_0. Drop the exception + # once conda publishes a py315 build. - name: Conda environment creation and activation uses: conda-incubator/setup-miniconda@v4.0.1 env: PCAPKIT_VERSION: ${{ needs.version_check.outputs.PCAPKIT_VERSION }} PCAPKIT_BUILD: "0" with: - python-version: ${{ matrix.python-version }} + python-version: ${{ matrix.python-version != '3.15' && matrix.python-version || '' }} activate-environment: base auto-update-conda: false auto-activate: true @@ -934,9 +997,22 @@ jobs: PCAPKIT_CONDA_TAG_EXISTS: ${{ needs.version_check.outputs.PCAPKIT_CONDA_TAG_EXISTS }} PCAPKIT_PYPI_COMPLETE: ${{ needs.version_check.outputs.PCAPKIT_PYPI_COMPLETE }} PCAPKIT_CONDA_COMPLETE: ${{ needs.version_check.outputs.PCAPKIT_CONDA_COMPLETE }} + PCAPKIT_PYPI_KTLO_310: ${{ needs.pypi.outputs.ktlo_310 }} + PCAPKIT_PYPI_KTLO_PYPY310: ${{ needs.pypi.outputs.ktlo_pypy310 }} run: | set -euo pipefail + # #1633: pypi's KTLO legs (3.10, pypy3.10) never reach + # needs.pypi.result -- their steps carry continue-on-error -- so + # nothing below can warn or fail because of one, and their wheels are + # not in PCAPKIT_PYPI_COMPLETE's count. Each leg records its own + # outcome as a job output instead, reported here as a notice. + ktlo_310="${PCAPKIT_PYPI_KTLO_310:-}" + ktlo_pypy310="${PCAPKIT_PYPI_KTLO_PYPY310:-}" + if [ -n "${ktlo_310}${ktlo_pypy310}" ]; then + echo "::notice title=KTLO legs (non-blocking)::pypi 3.10=${ktlo_310:-not run} pypy3.10=${ktlo_pypy310:-not run}. End-of-life Python, built and published but never blocking (#1633); neither result changes this run's verdict." + fi + # Case 1: version_check itself never ran. On a `workflow_run` # trigger this means the triggering Vendor Update run did not # conclude `success` -- most commonly because it found nothing to @@ -1025,7 +1101,7 @@ jobs: # Case 2: every target reconciled by skipping, none of them ran. # Quiet -- this is the steady state most runs are in. if [ "$all_reconciled" = "true" ] && [ "$all_skipped" = "true" ]; then - echo "::notice title=Nothing to release::pypcapkit ${PCAPKIT_VERSION} is already fully released -- the v* tag, the conda-*+0 tag, all 8 PyPI files and all 10 Anaconda files are present. This run correctly released nothing." + echo "::notice title=Nothing to release::pypcapkit ${PCAPKIT_VERSION} is already fully released -- the v* tag, the conda-*+0 tag, all 7 supported PyPI files and all 10 Anaconda files are present. This run correctly released nothing." exit 0 fi diff --git a/.github/workflows/cron-conda.yml b/.github/workflows/cron-conda.yml index 6e66db5ffe..0791b797dd 100644 --- a/.github/workflows/cron-conda.yml +++ b/.github/workflows/cron-conda.yml @@ -230,12 +230,14 @@ jobs: - macos-latest - ubuntu-latest # - windows-latest # skip for now + # #1633: the supported tier; create-release.yml's ``conda`` job says why + # there is no KTLO 3.10 leg. python-version: - - "3.10" - "3.11" - "3.12" - "3.13" - "3.14" + - "3.15" steps: - uses: actions/checkout@v7 with: @@ -276,13 +278,14 @@ jobs: mkdir -p conda/wheels python -m pip download -r conda/requirements.txt -d conda/wheels + # See create-release.yml's step of the same name for the 3.15 exception. - name: Conda environment creation and activation uses: conda-incubator/setup-miniconda@v4.0.1 env: PCAPKIT_VERSION: ${{ needs.conda-update.outputs.PCAPKIT_VERSION }} PCAPKIT_BUILD: ${{ needs.conda-update.outputs.PCAPKIT_BUILD }} with: - python-version: ${{ matrix.python-version }} + python-version: ${{ matrix.python-version != '3.15' && matrix.python-version || '' }} activate-environment: base auto-update-conda: false auto-activate: true diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9bdf2528c6..e94261fb04 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -159,16 +159,16 @@ jobs: steps: - uses: actions/checkout@v7 - # One interpreter, not a matrix. Unit Tests sweeps 3.10-3.14 and Python - # Compatibility sweeps the same plus a schedule-only 3.15 leg, because + # One interpreter, not a matrix. Unit Tests and Python Compatibility sweep + # 3.11-3.15, plus the non-blocking KTLO legs 3.8-3.10 (#1633), because # they check that the package *runs* everywhere; these tools read the # 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 newest - # non-experimental version in the test matrix and the version the recorded - # counts above were measured on -- so a count that moves means the code - # moved, not the interpreter. + # 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. - uses: actions/setup-python@v7 with: python-version: '3.14' diff --git a/.github/workflows/python-compatibility.yml b/.github/workflows/python-compatibility.yml index 7ed0a80f6a..6821759548 100644 --- a/.github/workflows/python-compatibility.yml +++ b/.github/workflows/python-compatibility.yml @@ -28,21 +28,37 @@ jobs: # exactly with the Unit Tests matrix job of the same name: `gh pr checks` # showed two rows reading `Python 3.10`, one a 13-second import smoke test # and one a three-minute test run, with nothing to tell them apart. - name: Compat Python ${{ matrix.python-version }} - runs-on: ubuntu-latest + # + # #1633: two tiers. The supported tier, 3.11-3.15, keeps the plain name, + # which is what ruleset 23497679 requires one context per version of. The + # KTLO tier -- end-of-life versions that still run, 3.8-3.10 -- is suffixed + # ` (KTLO)`, so no required context can ever match one, and carries + # `continue-on-error`, so a failed leg shows red without failing the run. + # 3.6 and 3.7 are left out: neither can install and import pcapkit (see the + # note on `requires-python` in pyproject.toml). + name: Compat Python ${{ matrix.python-version }}${{ matrix.ktlo && ' (KTLO)' || '' }} + runs-on: ${{ matrix.os || 'ubuntu-latest' }} + continue-on-error: ${{ matrix.ktlo == true }} strategy: fail-fast: false matrix: python-version: - # 3.15 deliberately excluded: ruleset 23497679's required checks - # only cover 3.10-3.14, so it cannot block a merge here either. Its - # replacement is the `compatibility-nightly` job below, which runs - # only on this workflow's existing weekly schedule. - - "3.10" - "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. + include: + - 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 steps: - uses: actions/checkout@v7 @@ -59,31 +75,3 @@ jobs: run: | python -m compileall -q pcapkit python -c 'import pcapkit; print(pcapkit.__version__)' - - # 3.15 is not in ruleset 23497679's required-checks list, so running it on - # every push/PR was queue contention with no gating signal (it was already - # `continue-on-error`). This job keeps early warning of a 3.15-only - # regression -- import and compileall only, ~14s -- without paying for it on - # every push: it fires solely on the schedule trigger above (line 9). - compatibility-nightly: - name: Compat Python 3.15 (scheduled) - if: ${{ github.event_name == 'schedule' }} - runs-on: ubuntu-latest - continue-on-error: true - steps: - - uses: actions/checkout@v7 - - - uses: actions/setup-python@v7 - with: - python-version: "3.15" - allow-prereleases: true - - - name: Install package - run: | - python -m pip install -U pip setuptools wheel - python -m pip install -e . - - - name: Verify package - run: | - python -m compileall -q pcapkit - python -c 'import pcapkit; print(pcapkit.__version__)' diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 2cb06fac1b..11229235db 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -47,24 +47,21 @@ jobs: # this workflow used to) therefore ran the whole matrix in every caller # and skipped the gate everywhere. An input is the only reliable way for a # reusable workflow to know how it was invoked. - name: Python ${{ matrix.python-version }} + name: Python ${{ matrix.python-version }}${{ matrix.ktlo && ' (KTLO)' || '' }} if: ${{ inputs.gate-only != true && needs.changes.outputs.code != 'false' }} needs: [changes] - runs-on: ubuntu-latest + runs-on: ${{ matrix.os || 'ubuntu-latest' }} timeout-minutes: 45 strategy: fail-fast: false matrix: + # #1633: the supported tier, 3.11-3.15, blocks through `required-checks`. python-version: - # 3.15 deliberately excluded: ruleset 23497679's required checks - # only cover 3.10-3.14, so a 3.15 leg here cannot block a merge and - # was pure queue contention -- see python-compatibility.yml for its - # (non-blocking, schedule-only) replacement. - - "3.10" - "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 @@ -74,11 +71,29 @@ jobs: 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. A failure still shows + # as a red step and an error annotation. 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. + - 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 steps: - uses: actions/checkout@v7 - uses: actions/setup-python@v7 + continue-on-error: ${{ matrix.ktlo == true }} with: python-version: ${{ matrix.python-version }} cache: pip @@ -113,6 +128,7 @@ jobs: # this job rather than added to it -- see either job's own comment for # why. - name: Install package and test dependencies + continue-on-error: ${{ matrix.ktlo == true }} run: | python -m pip install -U pip setuptools wheel python -m pip install -e '.[test,DPKT,crypto,NGAP]' @@ -127,12 +143,14 @@ jobs: # the log says what that was -- print it so the number `-n auto` picked # is a measured fact instead of an assumption about runner size. - name: Report available parallelism + continue-on-error: ${{ matrix.ktlo == true }} run: | nproc python -c "import os; print('cpu_count', os.cpu_count())" - name: Run unit tests id: tests + continue-on-error: ${{ matrix.ktlo == true }} # `--dist load` is pinned explicitly rather than left to default: a # future change to xdist's own default, or someone "optimising" to # `loadfile`/`loadscope`, cannot silently change how this suite is @@ -285,26 +303,36 @@ jobs: retention-days: 14 integration: - name: Integration Python ${{ matrix.python-version }} + name: Integration Python ${{ matrix.python-version }}${{ matrix.ktlo && ' (KTLO)' || '' }} if: ${{ inputs.gate-only != true && needs.changes.outputs.code != 'false' }} needs: [changes] - runs-on: ubuntu-latest + runs-on: ${{ matrix.os || 'ubuntu-latest' }} timeout-minutes: 45 strategy: fail-fast: false matrix: + # The two tiers of the `test` job above, for the same reasons. python-version: - # See the `test` job above for why 3.15 is excluded here. - - "3.10" - "3.11" - "3.12" - "3.13" - "3.14" + - "3.15" + include: + - 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 steps: - uses: actions/checkout@v7 - uses: actions/setup-python@v7 + continue-on-error: ${{ matrix.ktlo == true }} with: python-version: ${{ matrix.python-version }} cache: pip @@ -324,7 +352,7 @@ jobs: # are deliberately NOT added here: test_esp_unit.py and test_ngap_unit.py # are plain unit-tier and never enter this job's selection, so installing # cryptography/pycrate here would unlock nothing -- they are already - # covered, across all five Python versions, by the `test` job above. + # covered, on every Python version, by the `test` job above. # PyPCAPFile is NOT added here either, even though this job's selection # also reaches test_new_engine_parity_runtime.py's 6 HAS_PYPCAPFILE # methods -- see the `test` job's comment above for the bug that used to @@ -332,6 +360,7 @@ jobs: # `pypcap-parity` job below covers those 6 methods instead, on the same # fixture-tier selection as this job but in a venv of its own. - name: Install package, test and generator dependencies + continue-on-error: ${{ matrix.ktlo == true }} run: | python -m pip install -U pip setuptools wheel python -m pip install -e '.[test,Scapy,DPKT,cli]' @@ -339,6 +368,7 @@ jobs: # `shell: bash` rather than the default, because it runs with -o pipefail: # without it the `tee` below would swallow the generator's exit status. - name: Regenerate sample captures + continue-on-error: ${{ matrix.ktlo == true }} shell: bash run: | # Keep a failure here legible as what it is -- the fixtures could not @@ -363,6 +393,7 @@ jobs: # See the `test` job above for why this step exists. - name: Report available parallelism + continue-on-error: ${{ matrix.ktlo == true }} run: | nproc python -c "import os; print('cpu_count', os.cpu_count())" @@ -385,6 +416,7 @@ jobs: # say so. `--dist load` is pinned explicitly; see the `test` job above # for why. - name: Run full test suite + continue-on-error: ${{ matrix.ktlo == true }} shell: bash run: | if ! selection=$(python -c "from tests._tiers import fixture_tier_paths; print(' '.join(fixture_tier_paths()))"); then @@ -458,12 +490,15 @@ jobs: # every annotation titled by engine and Python version and a per-cell row in # the job summary. # - # 3.15 is experimental-prerelease per #845's ruling: present so a regression - # is visible, but never blocking; since #1538 it runs on main pushes only. - # It is its own step with step-level `continue-on-error: true`, so a 3.15 - # failure shows as a failed step and an error annotation while the job -- - # and `required-checks`, which reads its result -- stays green. The 3.10-3.14 - # cells share one step, and any one of them failing fails the job. + # #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 shows as a failed step and an + # error annotation while the job, and `required-checks`, which reads its + # result, stay green. 3.8 and 3.9 are not cells here: setup-python has no + # Ubuntu 26.04 build of either, and this job installs every interpreter on + # `ubuntu-latest`; 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 @@ -495,21 +530,23 @@ jobs: steps: - uses: actions/checkout@v7 - # All six interpreters in one step; each is then reachable as - # `python3.X`. setup-python does not ship 3.15 as a release build yet, so - # `allow-prereleases` is needed for it to resolve at all (as in - # python-compatibility.yml's compatibility-nightly job); for 3.10-3.14 a - # GA release exists and wins. + # 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. + - uses: actions/setup-python@v7 + continue-on-error: true + with: + python-version: "3.10" + - uses: actions/setup-python@v7 with: python-version: | - 3.10 3.11 3.12 3.13 3.14 3.15 - allow-prereleases: true # Only the engines that need a system package get one: # @@ -814,11 +851,11 @@ 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. - - name: Run the 3.10-3.14 cells + - name: Run the 3.11-3.15 cells shell: bash run: | failed=() - for py in 3.10 3.11 3.12 3.13 3.14; do + for py in 3.11 3.12 3.13 3.14 3.15; do echo "::group::Python $py ($ENGINE)" if ! bash "$RUNNER_TEMP/engine-cell.sh" "$py"; then failed+=("$py") @@ -831,17 +868,17 @@ jobs: exit 1 fi - # Non-blocking by #845, main pushes only by #1538 -- see this job's comment. - - name: Run the 3.15 cell (experimental, non-blocking) - if: ${{ github.event_name != 'pull_request' }} + # KTLO, non-blocking (#1633) -- see this job's comment. Runs on every event, + # as the 3.10 cell did while it was supported. + - name: Run the 3.10 cell (KTLO, non-blocking) shell: bash continue-on-error: true run: | - echo "::group::Python 3.15 ($ENGINE)" - if ! bash "$RUNNER_TEMP/engine-cell.sh" 3.15; then - echo "| 3.15 | $ENGINE | | FAILED (non-blocking) |" >> "$GITHUB_STEP_SUMMARY" + 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" echo "::endgroup::" - echo "::error title=$ENGINE failed on Python 3.15 (non-blocking)::3.15 is experimental per #845; this does not fail the job." + echo "::error title=$ENGINE failed on Python 3.10 (KTLO, non-blocking)::3.10 is end-of-life and KTLO per #1633; this does not fail the job." exit 1 fi echo "::endgroup::" @@ -871,12 +908,14 @@ jobs: # HAS_PYPCAPFILE's 15 gated methods, which come along for free once # PyPCAPFile is on this job's install line too. # - # The matrix is deliberately just 3.10 and 3.11, not the full five: PyPCAP's + # 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-3.14 would install neither extra here and pay this job's full - # fixture-tier run for zero new coverage. + # 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. pypcap-parity: - name: PyPCAP/PyPCAPFile parity Python ${{ matrix.python-version }} + name: PyPCAP/PyPCAPFile parity Python ${{ matrix.python-version }}${{ matrix.ktlo && ' (KTLO)' || '' }} if: ${{ inputs.gate-only != true && needs.changes.outputs.code != 'false' }} needs: [changes] runs-on: ubuntu-latest @@ -886,19 +925,24 @@ jobs: APT_PACKAGES: build-essential libpcap-dev tshark APT_SET: pypcap-parity # Both cells install the same set under the same key; only one saves it, - # or the other's save fails with `Unable to reserve cache`. - APT_CACHE_SAVE: ${{ matrix.python-version == '3.10' }} + # or the other's save fails with `Unable to reserve cache`. The supported + # leg, so the cache does not depend on a leg that may not get that far. + APT_CACHE_SAVE: ${{ matrix.python-version == '3.11' }} strategy: fail-fast: false matrix: python-version: - "3.10" - "3.11" + include: + - python-version: "3.10" + ktlo: true steps: - uses: actions/checkout@v7 - uses: actions/setup-python@v7 + continue-on-error: ${{ matrix.ktlo == true }} with: python-version: ${{ matrix.python-version }} cache: pip @@ -932,6 +976,7 @@ jobs: - name: Plan the system packages id: apt-plan if: env.APT_PACKAGES != '' + continue-on-error: ${{ matrix.ktlo == true }} timeout-minutes: 4 run: | set -eu @@ -955,6 +1000,7 @@ jobs: - name: Restore the cached .deb files id: apt-cache if: steps.apt-plan.outputs.key != '' + continue-on-error: ${{ matrix.ktlo == true }} uses: actions/cache/restore@v6 with: path: ${{ runner.temp }}/apt-debs/*.deb @@ -963,12 +1009,14 @@ jobs: - name: Seed apt's archive with the cached .deb files if: steps.apt-cache.outputs.cache-matched-key != '' + continue-on-error: ${{ matrix.ktlo == true }} run: | set -eu sudo cp "$RUNNER_TEMP"/apt-debs/*.deb /var/cache/apt/archives/ echo "Seeded $(find "$RUNNER_TEMP/apt-debs" -name '*.deb' | wc -l) .deb files" - name: Install libpcap headers, a C toolchain, and tshark + continue-on-error: ${{ matrix.ktlo == true }} timeout-minutes: 10 run: | set -eu @@ -1020,6 +1068,7 @@ jobs: key: ${{ steps.apt-plan.outputs.key }} - name: Install package, test and generator dependencies + continue-on-error: ${{ matrix.ktlo == true }} run: | python -m pip install -U pip setuptools wheel python -m pip install -e '.[test,Scapy,DPKT,cli,PyShark,PyPCAP,PyPCAPFile]' @@ -1027,6 +1076,7 @@ jobs: # See the `integration` job above for why this step is shaped the way it # is. - name: Regenerate sample captures + continue-on-error: ${{ matrix.ktlo == true }} shell: bash run: | if ! python examples/generators/make_samples.py 2>&1 | tee "$RUNNER_TEMP/make-samples.log"; then @@ -1043,6 +1093,7 @@ jobs: # See the `test` job above for why this step exists. - name: Report available parallelism + continue-on-error: ${{ matrix.ktlo == true }} run: | nproc python -c "import os; print('cpu_count', os.cpu_count())" @@ -1050,6 +1101,7 @@ jobs: # See the `integration` job above for why this is asked of # tests/_tiers.py directly rather than spelled out as literal flags. - name: Run full test suite + continue-on-error: ${{ matrix.ktlo == true }} shell: bash run: | if ! selection=$(python -c "from tests._tiers import fixture_tier_paths; print(' '.join(fixture_tier_paths()))"); then @@ -1454,7 +1506,7 @@ jobs: # 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 - # real, on the 3.10-3.14 (and 3.10-3.11) legs where it actually installs. + # real, on the 3.10-3.15 (and 3.10-3.11) legs, 3.10 being KTLO in both. - name: Install package, test and generator dependencies run: | python -m pip install -U pip setuptools wheel @@ -1498,7 +1550,7 @@ jobs: run: python -m pytest -q -n auto --dist load # Ruleset 23497679's required_status_checks requires six contexts: this - # job's `Required checks passed`, and five for `Compat Python 3.10`-`3.14` + # job's `Required checks passed`, and five for `Compat Python 3.11`-`3.15` # from the `compatibility` job in `.github/workflows/python-compatibility.yml`. # This job aggregates the gating jobs of *this* file, the ones its `needs:` # lists, because rulesets match check names literally and support no @@ -1561,13 +1613,15 @@ jobs: # here: the cell script exits 0 for a cell that declined exactly as # expected, so they contribute to that job's `success` like any other cell. # - # Nor is its 3.15 cell. It is a step with step-level `continue-on-error: - # true` inside each engine's job, whose effect on the job's result *is* - # documented (a step's `conclusion` is its result after `continue-on-error` - # is applied), so the job, and this gate, read `success` while the failed - # step stays visible. Not a job-level `continue-on-error`: GitHub does not - # document whether `needs..result` sees such a job's failure as - # `failure` or `success`, so a 3.15-only regression could block every merge. + # Nor is the KTLO tier (#1633): engine-tests' 3.10 cell, and the 3.8-3.10 + # legs of `test` and `integration` and the 3.10 leg of `pypcap-parity`. + # Each is non-blocking through step-level `continue-on-error`, whose effect + # on the job's result *is* documented (a step's `conclusion` is its result + # after `continue-on-error` is applied), so the job, and this gate, read + # `success` while the failed step stays visible. Not a job-level + # `continue-on-error`: GitHub does not document whether `needs..result` + # sees such a job's failure as `failure` or `success`, so a KTLO-only + # regression could block every merge. # # The docs-only path. On a pull request whose diff touches nothing but # `docs/**` and `*.md` files outside the code directories (the `changes` diff --git a/README.md b/README.md index 7b7bc0c1af..893c593300 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ Unlike popular PCAP extractors such as [Scapy](https://scapy.net), Where that depth is not needed, the same interface also drives six third-party extraction engines. -The whole project supports **Python 3.6** or later. +The whole project declares **Python 3.6** or later. CI supports 3.11 to 3.15, and +still tests the end-of-life 3.8 to 3.10 without letting a failure block anything. ## Installation diff --git a/SECURITY.md b/SECURITY.md index 9d64d3227d..d3544955ce 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,11 +12,11 @@ lines are not patched -- upgrading is the fix. | 1.3.x and older | :x: | no longer patched | | 0.x | :x: | no longer patched | -Supported interpreters are those the test matrix covers: CPython 3.10 -through 3.14. `pyproject.toml` declares `requires-python = ">=3.6, <4"` through the -`bpc-*` source conversion, but 3.8 and 3.9 are best-effort and below 3.8 is intent -rather than something exercised -- see the note above `requires-python` in -`pyproject.toml`. A report that only reproduces outside the tested range is still +Supported interpreters are the test matrix's blocking tier: CPython 3.11 +through 3.15. `pyproject.toml` declares `requires-python = ">=3.6, <4"` through the +`bpc-*` source conversion, but 3.8 to 3.10 are best-effort (tested, never blocking) +and below 3.8 is intent rather than something exercised -- see the note above +`requires-python` in `pyproject.toml`. A report that only reproduces outside the tested range is still welcome; it may be fixed by raising the floor rather than by patching. ## Reporting a Vulnerability diff --git a/docs/source/contributing/pep.rst b/docs/source/contributing/pep.rst index 5406e2c4ba..bbdd5330e7 100644 --- a/docs/source/contributing/pep.rst +++ b/docs/source/contributing/pep.rst @@ -587,9 +587,9 @@ Test Cases ---------- **Largely done.** There is a systematic test suite under ``tests/``, and it runs -in CI against Python 3.10 through 3.14, per -``.github/workflows/unit-tests.yml``. 3.15 is deliberately excluded there, -because a leg outside the branch ruleset's required checks cannot block a merge. +in CI against Python 3.11 through 3.15, per +``.github/workflows/unit-tests.yml``, and against the end-of-life 3.8 through 3.10 +on legs whose failures block nothing. The module count is measurable rather than worth writing down: .. code-block:: shell diff --git a/docs/source/contributing/releasing.rst b/docs/source/contributing/releasing.rst index 0c184dc39d..aa0f846a0e 100644 --- a/docs/source/contributing/releasing.rst +++ b/docs/source/contributing/releasing.rst @@ -44,23 +44,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 (``:362-376``) calls ``unit-tests.yml`` with +``unit-tests`` job (``:367-381``) 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:1429-1498``). +subset an ordinary push runs (its ``gate`` job, ``unit-tests.yml:1481-1550``). 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 (``:392,493,573,674``), so a +publishing job requires it to have succeeded (``:397,498,578,726``), 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:1289-1310``) fails outright if +process: the ``changelog`` job (``unit-tests.yml:1341-1362``) 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:446-447``). Both enforce what the changelog convention +(``create-release.yml:451-452``). Both enforce what the changelog convention already asks for. Two Ways to Start a Release @@ -131,8 +131,9 @@ 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 8 expected - -- 7 matrix legs plus the sdist kept only on the 3.14 leg. This job is +* ``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 + 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. * ``conda`` compares Anaconda's aggregate count against the 10 expected (2 OS @@ -233,8 +234,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:493,573``), and ``conda`` depends on -``tag`` and ``github`` (``:674``) -- so nothing downstream of ``github`` can +(``.github/workflows/create-release.yml:498,578``), and ``conda`` depends on +``tag`` and ``github`` (``:726``) -- 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 @@ -291,7 +292,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.** The ``tag`` job resets :file:`conda/build` to ``0``, commits that, and pushes straight to ``main`` -(``.github/workflows/create-release.yml:512-532``) before cutting the +(``.github/workflows/create-release.yml:517-537``) before cutting the ``conda-+0`` tag. Approving ``github-release`` therefore also approves a commit landing on the default branch, not only the artefacts that sound like they are the point. diff --git a/docs/source/contributing/workflows.rst b/docs/source/contributing/workflows.rst index 8b05d16ba4..ea37a1af45 100644 --- a/docs/source/contributing/workflows.rst +++ b/docs/source/contributing/workflows.rst @@ -232,7 +232,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:374`` -- job ``unit-tests`` calls +* ``.github/workflows/create-release.yml:379`` -- job ``unit-tests`` calls ``./.github/workflows/unit-tests.yml`` with ``gate-only: true``. * ``.github/workflows/cron-conda.yml:43`` -- job ``unit-tests`` calls ``./.github/workflows/unit-tests.yml`` with ``gate-only: true``. @@ -244,9 +244,10 @@ repository target the same reusable workflow, with the same input: ``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 -**eight** -- the five matrix jobs, ``test`` (five Python legs), -``integration`` (five), ``engine-tests`` (one job for each of five engines, -each looping over six Python versions), ``pypcap-parity`` (two) and ``unittest-ordering`` +**eight** -- the five matrix jobs, ``test`` (eight Python legs: five +supported, three KTLO), ``integration`` (eight), ``engine-tests`` (one job +for each of five engines, each looping over six Python versions), +``pypcap-parity`` (two) and ``unittest-ordering`` (eleven matrix cells), each of which has already run once for this commit from Unit Tests' own ``push`` trigger (and, all but ``unittest-ordering``, which runs on ``main`` pushes only, from its ``pull_request`` trigger), so @@ -256,7 +257,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:1277-1283``): ``create-release.yml`` feeds +comment (``unit-tests.yml:1329-1335``): ``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 @@ -283,13 +284,13 @@ Update run's own conclusion was 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:364``) and an ``if:`` that +``needs: [ version_check ]`` (``create-release.yml:369``) 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 ]`` (``:392``), ``tag`` needs ``[ github, unit-tests, -version_check ]`` (``:493``), ``pypi`` needs ``[ github, unit-tests, -version_check ]`` (``:573``), and ``conda`` needs ``[ tag, github, -unit-tests, version_check ]`` (``:674``). +version_check ]`` (``:397``), ``tag`` needs ``[ github, unit-tests, +version_check ]`` (``:498``), ``pypi`` needs ``[ github, unit-tests, +version_check ]`` (``:578``), and ``conda`` needs ``[ tag, github, +unit-tests, version_check ]`` (``:726``). .. mermaid:: @@ -315,7 +316,7 @@ unit-tests, version_check ]`` (``:674``). 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:493,573``) and of ``conda`` (``:674``), but ``github`` +(``create-release.yml:498,578``) and of ``conda`` (``:726``), 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. @@ -483,7 +484,7 @@ while the pool stays saturated, and the first runs with JUnit will show whether Work Kept Off the Pull-Request Queue (`#1538 `__) ----------------------------------------------------------------------------------------------------- -Three cuts in :file:`unit-tests.yml` keep work that cannot gate a merge from competing +Two cuts in :file:`unit-tests.yml` keep work that cannot gate a merge from competing with pull-request jobs for the 20-runner pool. None touches a required check or adds a permission to the reusable workflow. @@ -496,15 +497,13 @@ permission to the reusable workflow. fails the push run that GitHub Pages, Conda Update and Vendor Update (and so Create Release) wait on. The cost is wall time: the push run, and those workflows, finish about one slowest cell later, about 6 min before any queue wait (estimate). -* ``engine-tests``' ``Run the 3.15 cell (experimental, non-blocking)`` step runs on - ``main`` pushes only. This narrows `#845 `__'s - ruling -- present so a regression is visible, never blocking -- from every run to - every ``main`` push: a 3.15 regression shows within one merge. The step keeps - ``continue-on-error: true``. * ``engine-tests`` has no ``DPKT`` cell. Its only path was :file:`tests/toolkit/test_dpkt_unit.py`, which ``test`` runs with ``DPKT`` installed - on 3.10-3.14. Dropped with it: that file in a venv holding only ``test`` and ``DPKT``, - and on 3.15. + on every leg. Dropped with it: that file in a venv holding only ``test`` and ``DPKT``. + +A third cut, running the then-prerelease 3.15 engine cell on ``main`` pushes only, +ended with `#1633 `__: 3.15 is +supported now, so its cells block on every run. Required Status Checks ---------------------- @@ -522,38 +521,41 @@ grepping every workflow file for its name: - Where * - ``Required checks passed`` - job ``required-checks`` - - ``unit-tests.yml:1586`` - * - ``Compat Python 3.10`` - - job ``compatibility``, matrix leg ``3.10`` - - ``python-compatibility.yml:31,41`` + - ``unit-tests.yml:1640`` * - ``Compat Python 3.11`` - job ``compatibility``, matrix leg ``3.11`` - - ``python-compatibility.yml:31,42`` + - ``python-compatibility.yml:39,46`` * - ``Compat Python 3.12`` - job ``compatibility``, matrix leg ``3.12`` - - ``python-compatibility.yml:31,43`` + - ``python-compatibility.yml:39,47`` * - ``Compat Python 3.13`` - job ``compatibility``, matrix leg ``3.13`` - - ``python-compatibility.yml:31,44`` + - ``python-compatibility.yml:39,48`` * - ``Compat Python 3.14`` - job ``compatibility``, matrix leg ``3.14`` - - ``python-compatibility.yml:31,45`` + - ``python-compatibility.yml:39,49`` + * - ``Compat Python 3.15`` + - job ``compatibility``, matrix leg ``3.15`` + - ``python-compatibility.yml:39,50`` + +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:1586``). ``Compat Python`` is defined by two in -``python-compatibility.yml``: the required ``compatibility`` job (``:31``, -``Compat Python ${{ matrix.python-version }}``, expanding to the five required -legs at ``:41-45``) and the non-required ``compatibility-nightly`` job (``:69``, -``Compat Python 3.15 (scheduled)``, see below). A comment at -``unit-tests.yml:1501`` mentions the string without defining it. +(``unit-tests.yml:1640``). ``Compat Python`` is defined by one, the +``compatibility`` job in ``python-compatibility.yml`` (``:39``), expanding to +the five required legs at ``:46-50`` and to three KTLO legs at ``:53-61`` whose +names end in ``(KTLO)``, so no required context matches one. A comment at +``unit-tests.yml:1553`` 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.10``-``3.14`` legs, nothing else, as -``unit-tests.yml``'s own comment (``unit-tests.yml:1500-1510``) records. It +requires it and the five ``Compat Python 3.11``-``3.15`` legs, nothing else, as +``unit-tests.yml``'s own comment (``unit-tests.yml:1552-1562``) 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:1585-1639``). Rulesets match check names literally, with no +(``unit-tests.yml:1639-1693``). 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 @@ -562,17 +564,30 @@ 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:1588``), +``if: ${{ always() && inputs.gate-only != true }}`` (``unit-tests.yml:1642``), so it is never produced -- and never expected -- on those paths. -``Compat Python 3.10``-``3.14`` are five ordinary matrix legs, not an -aggregate: ``python-compatibility.yml``'s own comment (``:37-40``) and -``unit-tests.yml``'s matching one for its own, differently-matrixed -``test``/``integration`` jobs (``unit-tests.yml:59-62``, ``unit-tests.yml:297``) both say why 3.15 is -excluded from every *required* matrix in this repository -- the ruleset's -required-checks list stops at 3.14, so a 3.15 leg cannot gate a merge and is -kept advisory (``continue-on-error: true`` in ``python-compatibility.yml``'s -``compatibility-nightly`` job, ``:72``) rather than required. +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.8-3.10, still built and +tested -- never blocks and is never a required context. Each KTLO leg sits in the +same job as the supported ones, so the two mechanisms differ by whether something +aggregates the job: + +* ``python-compatibility.yml`` has no aggregate, so a KTLO leg carries job-level + ``continue-on-error`` (``:41``): it shows red, and the run stays green. +* In ``unit-tests.yml`` ``required-checks`` reads one ``needs..result`` per job, + and GitHub does not document what that reads when a job-level + ``continue-on-error`` leg fails. So every step that can fail on a KTLO leg carries + ``continue-on-error: ${{ matrix.ktlo == true }}`` instead, whose effect is + documented (``unit-tests.yml:74-82``); ``engine-tests`` runs its 3.10 cell as a + step of its own with ``continue-on-error: true``. + +3.6 and 3.7 are in no matrix: neither can install and import the package. Nor are +3.8 and 3.9 in ``engine-tests``, which installs every interpreter on +``ubuntu-latest``, where setup-python has no Ubuntu 26.04 build of either. Nor are +they in ``pypcap-parity``, which also runs on ``ubuntu-latest``; neither version +was measured on that job. Deployment ``environment:`` ---------------------------- @@ -580,10 +595,10 @@ 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:389``) -* ``tag`` -- ``environment: conda-tag`` (``create-release.yml:491``) -* ``pypi`` -- ``environment: pypi`` (``create-release.yml:568``) -* ``conda`` -- ``environment: anaconda`` (``create-release.yml:671``) +* ``github`` -- ``environment: github-release`` (``create-release.yml:394``) +* ``tag`` -- ``environment: conda-tag`` (``create-release.yml:496``) +* ``pypi`` -- ``environment: pypi`` (``create-release.yml:573``) +* ``conda`` -- ``environment: anaconda`` (``create-release.yml:723``) No other workflow in scope here declares one -- including ``deploy-pages.yml``, whose ``deploy-pages`` job pushes straight to the diff --git a/docs/source/index.rst b/docs/source/index.rst index 6d438f2353..5d4a4f85ad 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -10,7 +10,8 @@ construction and analysis. .. important:: - The whole project supports **Python 3.6** or later; CI covers 3.10 to 3.14, and 3.15 as an allowed-to-fail leg. + The whole project declares **Python 3.6** or later. CI supports 3.11 to 3.15, and + still tests the end-of-life 3.8 to 3.10 without letting a failure block anything. .. .. contents:: .. :depth: 2 @@ -257,8 +258,9 @@ Installation .. note:: - 3.8 and 3.9 are end-of-life and best-effort. Individual *engines* also stop - earlier than the library does; see `Engine prerequisites`_. + 3.8, 3.9 and 3.10 are end-of-life and best-effort: CI builds and tests them, + but a failure there blocks nothing. Individual *engines* also stop earlier than + the library does; see `Engine prerequisites`_. Run the following to install the current version from PyPI: diff --git a/examples/benchmark/README.rst b/examples/benchmark/README.rst index c5c2d4a7b0..b94e343ed4 100644 --- a/examples/benchmark/README.rst +++ b/examples/benchmark/README.rst @@ -460,8 +460,9 @@ self-tests below. Python 3.15 ----------- -Opt-in, and allowed to fail -- matching the repository's CI, which already treats 3.15 -as an allowed-to-fail leg. There is no released ``python:3.15-slim-bookworm``; the +Opt-in, and allowed to fail -- the benchmark's own choice, not CI's: the repository's CI +has run 3.15 as a blocking, supported leg since #1633. There is no released +``python:3.15-slim-bookworm``; the pins file names a ``3.15-rc`` digest, which freezes one particular release candidate and will name an older one as time passes. Measure it with: diff --git a/examples/benchmark/python-images.txt b/examples/benchmark/python-images.txt index 990c53336c..aa87302db4 100644 --- a/examples/benchmark/python-images.txt +++ b/examples/benchmark/python-images.txt @@ -59,9 +59,10 @@ # Opt-in, and allowed to fail. There is no released `python:3.15-slim-bookworm`; # `3.15-rc-slim-bookworm` is a release candidate and the tag moves with every rc, so # the digest below freezes one particular candidate and will name an older rc as -# time passes -- bump it deliberately, or drop the row when 3.15 ships. The -# repository's CI already treats 3.15 as an allowed-to-fail leg, and this matches -# that: `run.sh --pythons ...,3.15` measures it, a plain `run.sh` does not, and a +# time passes -- bump it deliberately, or drop the row when 3.15 ships. Opt-in is +# this file's own tier, not CI's: the repository's CI has run 3.15 as a blocking, +# supported leg since #1633. `run.sh --pythons ...,3.15` measures it, a plain +# `run.sh` does not, and a # version that cannot be built is reported as a missing column with the reason # rather than dropped. # diff --git a/pyproject.toml b/pyproject.toml index 13a0222e20..70d87db651 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,8 +44,11 @@ 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 covers **3.10 through 3.14** (3.15 runs allowed-to-fail), so 3.8 and 3.9 are best-effort -# (both are end-of-life), and the ``PCAP_CT`` extra needs 3.10 regardless. +# Independently, CI has two tiers (#1633). **3.11 through 3.15** are supported and +# blocking. **3.8 through 3.10** are KTLO: end-of-life, still built and tested, never +# blocking. 3.6 and 3.7 are not run at all: on 3.6 the build fails (no +# ``setuptools>=61``), on 3.7 the import fails as above, and the ``test`` extra's +# ``pytest>=8`` needs 3.8 on both. The ``PCAP_CT`` extra needs 3.10 regardless. requires-python = ">=3.6, <4" description = "PyPCAPKit: comprehensive network packet analysis library" keywords = [ "network", "pcap", "packet" ] @@ -62,15 +65,15 @@ classifiers = [ "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", + # The supported tier only (#1633). A version classifier is a claim of + # support, which the KTLO tier (3.8-3.10) is by definition not: it may stay + # red without blocking anything. ``requires-python`` above is what keeps + # those versions installable. "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", @@ -199,8 +202,8 @@ PyPCAP = [ "pypcap; python_version < '3.12'" ] # ``requires-python`` is ``>=3.6, <4`` (see the note above it): without the # marker, ``pip install pypcapkit[PCAP_CT]`` on a 3.6-3.9 interpreter would try # to resolve two distributions that exclude it and fail the whole install, where -# with it the extra simply resolves to nothing there. CI covers 3.10 upwards, so -# the marker never excludes a tested interpreter. +# with it the extra simply resolves to nothing there. CI installs it only in +# engine-tests' PCAP_CT cell, on 3.10 upwards, so the marker excludes none of those. PCAP_CT = [ "pcap-ct>=1.3.0b3; python_version >= '3.10'", "libpcap>=1.11.0b29; python_version >= '3.10'", diff --git a/tests/_dependency_gates.py b/tests/_dependency_gates.py index 7e7392ebec..ecef1ad940 100644 --- a/tests/_dependency_gates.py +++ b/tests/_dependency_gates.py @@ -45,9 +45,9 @@ One thing this deliberately does not model, recorded here rather than left to be discovered: **environment markers.** ``PyPCAPFile = [ "pypcapfile; -python_version < '3.12'" ]`` resolves to nothing on three of the five matrix -legs, so an install line carrying that extra would satisfy this module while -two legs ran the tests and three went on skipping them. :func:`requirement_key` +python_version < '3.12'" ]`` resolves to nothing on four of the five supported +legs (3.12-3.15), so an install line carrying that extra would satisfy this module +while only 3.11 and the KTLO legs ran the tests. :func:`requirement_key` strips the marker and :func:`provided_by` ignores it; the marker text is kept on :class:`Requirement` so a diagnostic can say so. @@ -327,7 +327,7 @@ class Exclusion(NamedTuple): 'engine can start.\n\n' "#751's dedicated engine-tests job now takes this: it installs PCAP_CT and a " 'system libpcap (apt-get libpcap0.8) on its own PCAP_CT matrix cell, across ' - 'the same 3.10-3.14 (plus non-blocking 3.15, main pushes only) matrix -- ' + 'the same 3.11-3.15 (plus the non-blocking KTLO 3.10 cell, #1633) matrix -- ' 'rebuilt per #849 as one ' 'venv per (Python version, engine) pair rather than the one shared venv #751 ' 'first landed, so PCAP_CT is kept apart from PyPCAP by construction now, not ' @@ -787,8 +787,8 @@ def _toml_array(text: 'str', name: 'str') -> 'Optional[tuple[Requirement, ...]]' """The ``name = [ ... ]`` array of double-quoted strings in ``text``. A targeted scan rather than :mod:`tomllib`, which is 3.11+ while the - ``test`` job's matrix floor is 3.10 -- and a guard that skips on one leg of - five is the shape of invisibility this whole module exists to remove. + ``test`` job's matrix floor is 3.8 (a KTLO leg, #1633) -- and a guard that skips on + some legs is the shape of invisibility this whole module exists to remove. Comments are stripped first, quote-aware, because the arrays here are heavily annotated; the brackets inside ``"requests[socks]"`` are what makes a naive "up to the first ``]``" slice wrong. diff --git a/tests/_tiers.py b/tests/_tiers.py index 5f77119873..f851ffe7b6 100644 --- a/tests/_tiers.py +++ b/tests/_tiers.py @@ -189,8 +189,8 @@ 'Exception', 'BaseException', }) -#: ``try`` statement node types. :class:`ast.TryStar` is 3.11+, and Python 3.10 -#: is the floor, so it is looked up rather than named. +#: ``try`` statement node types. :class:`ast.TryStar` is 3.11+, and CI still runs +#: 3.8-3.10 (#1633), so it is looked up rather than named. _TRY_NODES = (ast.Try,) + ((ast.TryStar,) if hasattr(ast, 'TryStar') else ()) # type: ignore[attr-defined] diff --git a/tests/const/test_const_enum_builtin_parity.py b/tests/const/test_const_enum_builtin_parity.py index 927f25e6f6..336d08bd97 100644 --- a/tests/const/test_const_enum_builtin_parity.py +++ b/tests/const/test_const_enum_builtin_parity.py @@ -263,7 +263,7 @@ class _StdStrEnum(str, enum.Enum): """A built-in string enumeration. Spelled ``(str, enum.Enum)`` rather than :class:`enum.StrEnum`, which is - 3.11+, because the CI matrix starts at 3.10. + 3.11+, because CI still tests 3.8-3.10 (KTLO, #1633). """ A = 'a' diff --git a/tests/foundation/test_extraction_no_eof.py b/tests/foundation/test_extraction_no_eof.py index 4c52b391bb..b0f3043809 100644 --- a/tests/foundation/test_extraction_no_eof.py +++ b/tests/foundation/test_extraction_no_eof.py @@ -177,7 +177,7 @@ def run_bounded(self, body: 'str') -> 'str': except subprocess.TimeoutExpired as exc: # NOTE: ``TimeoutExpired.output`` is :class:`bytes` even under # ``text=True`` on CPython 3.14 -- measured, not assumed -- but that is - # an implementation detail, and this suite runs on 3.10 through 3.15. + # an implementation detail, and this suite runs on 3.8 through 3.15. # Accept either rather than turning the informative "did not # terminate" failure into an ``AttributeError`` on some other version. partial = exc.output or b'' diff --git a/tests/project/test_annotation_names.py b/tests/project/test_annotation_names.py index fb371f7e86..f56be076c3 100644 --- a/tests/project/test_annotation_names.py +++ b/tests/project/test_annotation_names.py @@ -91,8 +91,9 @@ def _as_string(node: 'Optional[ast.expr]') -> 'Iterator[tuple[str, int]]': #: :class:`ast.TypeAlias` where the running interpreter has it, else ``()``. #: #: ``ast.TypeAlias`` and ``ast.TypeVar`` are :pep:`695` nodes, new in Python 3.12, -#: and the supported range starts at 3.10 -- so naming either one directly raises -#: :exc:`AttributeError` there, and does so in *every* test in this module rather +#: and CI's range starts at 3.8 (KTLO, #1633), the supported tier at 3.11 -- so +#: naming either one directly raises :exc:`AttributeError` on 3.8-3.11, and does so +#: in *every* test in this module rather #: than one, because :func:`bound_names` walks every node of every file. Resolving #: them through :func:`getattr` to an empty tuple instead leaves both branches #: fully live on 3.12+ and merely unreachable below it, since :func:`isinstance` diff --git a/tests/project/test_ci_timing.py b/tests/project/test_ci_timing.py index 65580a1850..fa5298da46 100644 --- a/tests/project/test_ci_timing.py +++ b/tests/project/test_ci_timing.py @@ -237,9 +237,16 @@ def test_every_other_workflow_reads(self) -> 'None': codeql = ci_timing.parse_workflow((WORKFLOWS / 'codeql-analysis.yml').read_text(encoding='utf-8')) self.assertTrue(codeql[0]['steps']) compat = ci_timing.parse_workflow((WORKFLOWS / 'python-compatibility.yml').read_text(encoding='utf-8')) - self.assertEqual(ci_timing.match_job(compat, 'Compat Python 3.15 (scheduled)')['id'], - 'compatibility-nightly') + self.assertEqual(ci_timing.match_job(compat, 'Compat Python 3.10 (KTLO)')['id'], + 'compatibility') self.assertEqual(ci_timing.match_job(compat, 'Compat Python 3.12')['id'], 'compatibility') + # #1633 retired the literal ``Compat Python 3.15 (scheduled)`` job this used + # to read, so the literal-over-template rule is checked on a stand-in. + both = ci_timing.parse_workflow( + 'jobs:\n matrixed:\n name: Compat Python ${{ matrix.python-version }}\n' + ' pinned:\n name: Compat Python 3.15 (scheduled)\n') + self.assertEqual(ci_timing.match_job(both, 'Compat Python 3.15 (scheduled)')['id'], 'pinned') + self.assertEqual(ci_timing.match_job(both, 'Compat Python 3.12')['id'], 'matrixed') class TestClient(unittest.TestCase): diff --git a/tests/project/test_conventions_doc_claims.py b/tests/project/test_conventions_doc_claims.py index 043363ab61..36cd5ae7e0 100644 --- a/tests/project/test_conventions_doc_claims.py +++ b/tests/project/test_conventions_doc_claims.py @@ -1137,7 +1137,7 @@ def test_a_non_string_key_raises_a_value_miss_on_every_helper(self) -> 'None': def _optional_dependencies() -> 'dict[str, list[str]]': """``pyproject.toml``'s ``[project.optional-dependencies]``, without a TOML library. - :mod:`tomllib` is 3.11+ and this repository supports 3.10 -- a first version used it + :mod:`tomllib` is 3.11+ and CI still tests 3.8-3.10 (#1633) -- a first version used it and failed the ``Python 3.10`` leg with ``ModuleNotFoundError`` while passing locally on 3.14. ``tomli``, the usual backport, is not a dependency here either (``grep -n tomli pyproject.toml`` finds nothing), so there is nothing to fall back to @@ -1251,17 +1251,17 @@ def setUp(self) -> 'None': def test_the_extras_reader_agrees_with_tomllib(self) -> 'None': """:func:`_optional_dependencies` matches a real TOML parser, key for key. - The reader exists because :mod:`tomllib` is 3.11+ and this repository supports - 3.10, so the assertions above cannot use it. That makes the reader itself an + The reader exists because :mod:`tomllib` is 3.11+ and CI still tests 3.8-3.10 + (#1633's KTLO legs), so the assertions above cannot use it. That makes the reader itself an unverified dependency of every extras claim on the page -- and its second version mis-parsed **3 of the 14** extras silently, inventing a requirement called ``9 skipped`` out of comment prose. So wherever a real parser *is* available -- which is every interpreter from 3.11 up, including the one this suite usually runs on -- the two are compared - directly. On 3.10 there is nothing to compare against and the test skips, which - is honest: the reader is then unverified on the one version it was written for, - and the CI matrix covers the other four. + directly. Below 3.11 there is nothing to compare against and the test skips, which + is honest: the reader is then unverified on the versions it was written for, + and the supported tier, 3.11-3.15, covers it. """ try: diff --git a/tests/project/test_coverage_rcfile.py b/tests/project/test_coverage_rcfile.py index fd125aae2d..9e417f73c1 100644 --- a/tests/project/test_coverage_rcfile.py +++ b/tests/project/test_coverage_rcfile.py @@ -17,7 +17,7 @@ try: import tomllib -except ImportError: # Python 3.10 +except ImportError: # Python 3.10 and older tomllib = None # type: ignore[assignment] ROOT = pathlib.Path(__file__).resolve().parents[2] diff --git a/tests/project/test_pyshark_encap_map.py b/tests/project/test_pyshark_encap_map.py index 5b47d723c7..e8c295ff38 100644 --- a/tests/project/test_pyshark_encap_map.py +++ b/tests/project/test_pyshark_encap_map.py @@ -275,7 +275,7 @@ def make_tmp_dir(case: unittest.TestCase) -> pathlib.Path: :meth:`unittest.TestCase.enterContext` would do this in one line, but it is Python 3.11+ and this repository supports back to ``requires-python = ">=3.6, <4"`` (:file:`pyproject.toml`) -- CI's own - oldest tested leg is ``Python 3.10``, where ``enterContext`` does not + oldest tested leg is ``Python 3.8 (KTLO)``, where ``enterContext`` does not exist at all (``AttributeError``). ``TemporaryDirectory.cleanup`` plus :meth:`~unittest.TestCase.addCleanup` is the same "clean up after the test regardless of outcome" guarantee, built from APIs both present since diff --git a/tests/project/test_python_matrix_tiers_1633.py b/tests/project/test_python_matrix_tiers_1633.py new file mode 100644 index 0000000000..3e8e2b4b13 --- /dev/null +++ b/tests/project/test_python_matrix_tiers_1633.py @@ -0,0 +1,392 @@ +# -*- coding: utf-8 -*- +"""#1633: every Python matrix is two tiers, and only one of them can block. + +Python 3.10 reached end-of-life on 2026-10-01 and 3.15.0 was released on +2026-10-09, so the owner's ruling on #1633 split the matrices: + +* **Supported** -- 3.11 to 3.15, in every matrix (unit tests, compatibility, + conda, PyPI), blocking as before. +* **KTLO** -- end-of-life versions that still build and test (3.10 wherever it + ran, plus 3.8 and 3.9 in the test matrices), never blocking: not CI, not a + release, not a required context. +* ``requires-python`` stays ``>=3.6, <4``. + +A KTLO leg can block in more ways than one, so each is pinned on its own: a job +that ``required-checks`` or ``release_status`` aggregates through one +``needs..result`` must keep the leg's failure inside its steps (GitHub +documents step-level ``continue-on-error``, not what that result reads under a +job-level one); a compatibility leg must never carry a required context's name; +and PyPI's completeness evidence must not count a KTLO wheel, or one failed KTLO +leg would leave every later release run retrying it. The release-side checks +execute the workflow's own shell, stubbing only ``curl``. The last class reads +the prose that states the tiers back against the matrices. + +""" + +from __future__ import annotations + +import ast +import json +import os +import pathlib +import re +import shutil +import subprocess +import tempfile +import unittest +from typing import TYPE_CHECKING + +try: + import yaml +except ImportError: # pragma: no cover + yaml = None # type: ignore[assignment] + +if TYPE_CHECKING: + from typing import Any + +ROOT = pathlib.Path(__file__).resolve().parents[2] +WORKFLOWS = ROOT / '.github' / 'workflows' + +SUPPORTED = ('3.11', '3.12', '3.13', '3.14', '3.15') +#: The step-level guard every failure-prone step of an aggregated job carries. +KTLO_GUARD = '${{ matrix.ktlo == true }}' +#: Ruleset 23497679's required contexts once the owner applies #1633's edit. +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 + ('python-compatibility.yml', 'compatibility', SUPPORTED), + ('create-release.yml', 'pypi', SUPPORTED + ('pypy3.11',)), + ('create-release.yml', 'conda', SUPPORTED), + ('cron-conda.yml', 'conda-dist', SUPPORTED), +) +#: The jobs a single ``needs..result`` aggregates, so KTLO must stay step-level. +AGGREGATED = (('unit-tests.yml', 'test'), ('unit-tests.yml', 'integration'), + ('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.8', '3.9', '3.10')), + ('unit-tests.yml', 'integration', ('3.8', '3.9', '3.10')), + ('unit-tests.yml', 'pypcap-parity', ('3.10',)), + ('python-compatibility.yml', 'compatibility', ('3.8', '3.9', '3.10')), + ('create-release.yml', 'pypi', ('3.10', 'pypy3.10')), +) + +#: Claims the tier split made false, each worded as it was somewhere before #1633. +#: A denylist: it catches these phrasings coming back and examines no others. +RETIRED_CLAIMS = re.compile('|'.join(( + r'3\.15 as an allowed-to-fail leg', + r'3\.15 runs allowed-to-fail', + r'supported range starts at 3\.10', + r'CI matrix starts at 3\.10', + r'CI covers \**3\.10', + r'runs on 3\.10 through 3\.1[45]', + r'repository supports 3\.10', + r'matrix floor is 3\.10', +))) +#: Where :data:`RETIRED_CLAIMS` is looked for, as (directory, globs), plus root files. +#: The changelog is history and is left out. +PROSE_SCAN = ( + ('.github', ('*.yml', '*.md')), + ('docs', ('*.rst', '*.py')), + ('examples', ('*.rst', '*.txt', '*.py', '*.sh')), + ('pcapkit', ('*.py',)), + ('tests', ('*.py',)), + ('util', ('*.py',)), +) +PROSE_FILES = ('pyproject.toml', 'README.md', 'SECURITY.md', 'CONTRIBUTING.md', 'Makefile') +#: Spelled-out counts, as the workflows page writes them. +WORDS = {'one': 1, 'two': 2, 'three': 3, 'four': 4, 'five': 5, + '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. + + 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 is_ktlo(leg: 'dict[str, Any]') -> 'bool': + return leg.get('ktlo') is True + + +def bash(script: 'str', env: 'dict[str, str]') -> 'subprocess.CompletedProcess[str]': + """``script`` under ``bash -e``, GitHub's default shell, with ``env`` added.""" + return subprocess.run(['bash', '-e', '-c', script], env=dict(os.environ, **env), + capture_output=True, text=True, check=False, timeout=60) + + +class TestTheSupportedTierBlocks(unittest.TestCase): + """3.11-3.15 everywhere, and nothing softens a supported leg.""" + + def test_every_matrix_runs_the_supported_tier_as_blocking(self) -> None: + 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)}) + self.assertEqual(versions, sorted(blocking)) + self.assertIn(spec.get('continue-on-error', False), (False, KTLO_GUARD)) + + 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') + self.assertNotIn('continue-on-error', loop) + self.assertNotIn('if', loop) + self.assertIn(f"for py in {' '.join(SUPPORTED)};", loop['run']) + + def test_the_supported_tier_is_what_the_classifiers_list(self) -> None: + text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8') + listed = re.findall(r'"Programming Language :: Python :: (3\.\d+)"', text) + self.assertEqual(listed, list(SUPPORTED)) + + def test_requires_python_is_unchanged(self) -> None: + """The owner's ruling: KTLO keeps old versions installable, it does not raise the floor.""" + text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8') + 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]) + + +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: + 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)) + self.assertEqual(found, sorted(versions)) + + def test_38_and_39_run_where_setup_python_still_builds_them(self) -> None: + """No Ubuntu 26.04 build of either, and ``ubuntu-latest`` moves to 26.04.""" + for name, job_id, _ in KTLO_LEGS: + spec = job(name, job_id) + for leg in legs(spec): + if leg['python-version'] in ('3.8', '3.9'): + with self.subTest(workflow=name, job=job_id, python=leg['python-version']): + self.assertEqual(leg.get('os'), 'ubuntu-24.04') + self.assertEqual(spec['runs-on'], "${{ matrix.os || 'ubuntu-latest' }}") + + def test_no_ktlo_leg_can_fail_an_aggregated_job(self) -> None: + """Every step that can run and fail on a KTLO leg keeps that failure to itself.""" + for name, job_id in AGGREGATED: + spec = job(name, job_id) + with self.subTest(workflow=name, job=job_id): + self.assertNotIn('continue-on-error', spec) + for step in spec['steps']: + label = step.get('name') or step.get('uses') + if str(step.get('uses', '')).startswith('actions/checkout@'): + continue + condition = str(step.get('if', '')) + if 'matrix.coverage' in condition or 'APT_CACHE_SAVE' in condition: + continue # only ever on a supported leg: the coverage one, the cache saver + with self.subTest(workflow=name, job=job_id, step=label): + self.assertIn(step.get('continue-on-error'), (True, KTLO_GUARD)) + + def test_engine_tests_isolates_the_ktlo_interpreter_and_cell(self) -> None: + 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)') + self.assertIs(cell.get('continue-on-error'), True) + self.assertIn('engine-cell.sh" 3.10', cell['run']) + + def test_a_compatibility_ktlo_leg_shows_red_without_failing_the_run(self) -> None: + """No aggregate reads this job, so job-level ``continue-on-error`` is the right form.""" + spec = job('python-compatibility.yml', 'compatibility') + self.assertEqual(spec.get('continue-on-error'), KTLO_GUARD) + + 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): + 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): + """``release_status`` and the PyPI evidence, run as the workflow runs them.""" + + 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') + tmp = tempfile.TemporaryDirectory(prefix='pcapkit-1633-') # pylint: disable=consider-using-with + self.addCleanup(tmp.cleanup) + self.tmp = pathlib.Path(tmp.name) + + def pypi_complete(self, files: 'list[str]') -> 'str': + """``PCAPKIT_PYPI_COMPLETE`` for a release holding ``files``, ``curl`` stubbed.""" + steps = job('create-release.yml', 'version_check')['steps'] + script = next(step for step in steps if step.get('name') == 'Check PyPI publish completeness')['run'] + 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' + bin_dir.mkdir(exist_ok=True) + curl = bin_dir / '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 = self.tmp / 'output' + 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'}) + 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'] + sdist = ['pypcapkit-1.5.0.tar.gz'] + self.assertEqual(self.pypi_complete(supported + sdist), 'true') + self.assertEqual(self.pypi_complete(supported + ktlo + sdist), 'true') + self.assertEqual(self.pypi_complete(supported[1:] + ktlo + sdist), 'false') + self.assertEqual(self.pypi_complete(supported), 'false') + + 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)): + key = 'ktlo_' + leg['python-version'].replace('.', '') + with self.subTest(leg=leg['python-version']): + self.assertEqual(spec['outputs'][key], f'${{{{ steps.ktlo.outputs.{key} }}}}') + self.assertIn(f'${{{{ needs.pypi.outputs.{key} }}}}', status.values()) + for outcomes, expected in (('success success failure success', 'failure'), + ('success skipped success', 'success')): + output = self.tmp / 'output' + output.write_text('', encoding='utf-8') + result = bash(record['run'], {'LEG': leg['python-version'], 'OUTCOMES': outcomes, + 'GITHUB_OUTPUT': str(output)}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(output.read_text(encoding='utf-8'), f'{key}={expected}\n') + + def test_release_status_reports_a_failed_ktlo_leg_without_warning(self) -> None: + script = job('create-release.yml', 'release_status')['steps'][0]['run'] + base = {'PCAPKIT_EVENT_NAME': 'push', 'PCAPKIT_WORKFLOW_RUN_CONCLUSION': '', + 'PCAPKIT_UNIT_TESTS': 'success', 'PCAPKIT_VERSION_CHECK': 'success', + 'PCAPKIT_GITHUB_JOB': 'success', 'PCAPKIT_TAG_JOB': 'success', + 'PCAPKIT_PYPI_JOB': 'success', 'PCAPKIT_CONDA_JOB': 'success', + 'PCAPKIT_VERSION': '1.5.0', 'PCAPKIT_TAG_EXISTS': 'false', + 'PCAPKIT_CONDA_TAG_EXISTS': 'false', 'PCAPKIT_PYPI_COMPLETE': 'false', + 'PCAPKIT_CONDA_COMPLETE': 'false'} + clean = bash(script, base) + failed = bash(script, dict(base, PCAPKIT_PYPI_KTLO_310='failure', + PCAPKIT_PYPI_KTLO_PYPY310='failure')) + self.assertEqual((clean.returncode, failed.returncode), (0, 0), failed.stderr) + self.assertIn('::notice title=Release complete::', failed.stdout) + self.assertRegex(failed.stdout, r'::notice title=KTLO legs \(non-blocking\)::pypi 3\.10=failure ' + r'pypy3\.10=failure') + self.assertNotRegex(failed.stdout, r'(?m)^::(warning|error)') + self.assertNotIn('KTLO', clean.stdout) + + def test_no_conda_leg_is_ktlo_and_315_leaves_base_python_alone(self) -> None: + """A KTLO 3.10 conda leg could only fail (soupsieve 3 needs >=3.11.5); 3.15 needs base left alone.""" + 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)]) + setup = next(step for step in spec['steps'] + if str(step.get('uses', '')).startswith('conda-incubator/setup-miniconda@')) + self.assertEqual(setup['with']['python-version'], + "${{ matrix.python-version != '3.15' && matrix.python-version || '' }}") + + +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', errors='replace')) + + +class TestTheProseFollowsTheTiers(unittest.TestCase): + """What the docs and comments say about the matrices, read against the matrices. + + Nothing executes these, so a tier change leaves them stale without failing + anything else: round one of #1633 left the workflows page counting five + ``test`` legs where there are eight, and six comments naming 3.10 as CI's floor + or 3.15 as allowed to fail. + + """ + + 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()} + 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'each looping over (\w+) Python versions': len(engines), + } + for pattern, count in claims.items(): + with self.subTest(claim=pattern): + self.assertEqual([WORDS.get(word) for word in re.findall(pattern, page)], [count]) + + def test_no_retired_tier_claim_is_left_in_the_tree(self) -> None: + paths = [ROOT / name for name in PROSE_FILES if (ROOT / name).is_file()] + for directory, globs in PROSE_SCAN: + paths.extend(path for pattern in globs for path in (ROOT / directory).rglob(pattern)) + found = [] + for path in sorted(set(paths)): + relative = path.relative_to(ROOT) + if path == pathlib.Path(__file__).resolve() or any( + part.startswith('changelog') for part in relative.parts): + continue + found.extend(f'{relative}: {match.group(0)!r}' for match in RETIRED_CLAIMS.finditer(prose(path))) + self.assertEqual(found, []) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/project/test_unit_tests_queue.py b/tests/project/test_unit_tests_queue.py index 5807e36a22..ed04860b98 100644 --- a/tests/project/test_unit_tests_queue.py +++ b/tests/project/test_unit_tests_queue.py @@ -11,9 +11,9 @@ 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 3.15 cell stays non-blocking through a step-level - ``continue-on-error`` rather than a job-level one, runs on ``main`` pushes - only, and the job has no ``DPKT`` cell (#1538). + ``engine-tests``'s KTLO 3.10 cell stays 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 classifier against a scratch merge commit, the others with their commands stubbed on ``PATH``. @@ -213,18 +213,18 @@ def test_unittest_ordering_runs_verbose_under_a_step_cap(self) -> None: self.assertIn('--verbose', run_step) self.assertRegex(run_step, r'(?m)^ timeout-minutes: \d+$') - def test_engine_tests_is_one_job_per_engine_with_a_non_blocking_315_step(self) -> None: - """E: matrix on engine only; 3.15 blocks nothing, 3.10-3.14 do.""" + 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).""" section = job('engine-tests') head = header(section) self.assertNotIn('python-version', head) self.assertNotIn('continue-on-error', head) self.assertRegex(section, r'(?m)^ engine:$') - experimental = section.split('- name: Run the 3.15 cell', 1)[1] - self.assertRegex(experimental, r'(?m)^ continue-on-error: true$') - gating = section.split('- name: Run the 3.10-3.14 cells', 1)[1].split(' - name:', 1)[0] + ktlo = section.split('- name: Run the 3.10 cell', 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] self.assertNotIn('continue-on-error', gating) - self.assertIn('for py in 3.10 3.11 3.12 3.13 3.14;', gating) + self.assertIn('for py in 3.11 3.12 3.13 3.14 3.15;', 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. @@ -238,10 +238,10 @@ def test_unittest_ordering_queues_behind_test_and_survives_its_failure(self) -> self.assertIn(" if: ${{ !cancelled() && inputs.gate-only != true" " && github.event_name != 'pull_request' }}\n", head) - def test_the_315_cell_runs_on_main_pushes_only(self) -> None: - """#1538 P6: a step-level ``if:``, which narrows #845's ruling to ``main`` pushes.""" - experimental = step(job('engine-tests'), 'Run the 3.15 cell (experimental, non-blocking)') - self.assertIn(" if: ${{ github.event_name != 'pull_request' }}\n", experimental) + 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)') + self.assertNotRegex(ktlo, r'(?m)^ if:') def test_engine_tests_has_no_dpkt_cell(self) -> None: """#1538 P8: its only path, test_dpkt_unit.py, already runs in ``test`` with DPKT.""" @@ -403,22 +403,21 @@ def run_loop(self, name: 'str', fail: 'str') -> 'tuple[int, list[str]]': 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.10-3.14 cells', fail='none') + status, calls = self.run_loop('Run the 3.11-3.15 cells', fail='none') self.assertEqual(status, 0) - self.assertEqual(calls, ['3.10', '3.11', '3.12', '3.13', '3.14']) + self.assertEqual(calls, ['3.11', '3.12', '3.13', '3.14', '3.15']) 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.10-3.14 cells', fail='3.12') + status, calls = self.run_loop('Run the 3.11-3.15 cells', fail='3.12') self.assertNotEqual(status, 0) - self.assertEqual(calls, ['3.10', '3.11', '3.12', '3.13', '3.14']) + self.assertEqual(calls, ['3.11', '3.12', '3.13', '3.14', '3.15']) - def test_a_failing_315_cell_fails_its_own_step(self) -> None: + def test_a_failing_ktlo_cell_fails_its_own_step(self) -> None: """The step is red; only its ``continue-on-error`` keeps the job green.""" - status, calls = self.run_loop('Run the 3.15 cell (experimental, non-blocking)', - fail='3.15') + status, calls = self.run_loop('Run the 3.10 cell (KTLO, non-blocking)', fail='3.10') self.assertNotEqual(status, 0) - self.assertEqual(calls, ['3.15']) + self.assertEqual(calls, ['3.10']) #: The two apt steps, as (job, step name, extra env). @@ -562,12 +561,11 @@ def test_the_scan_is_not_blind(self) -> None: 'docs/source/contributing/workflows.rst': [ ('# Deliberately *not* gated on ``gate-only``', '# this is neither expensive'), ('name: Required checks passed', None), - ('five for `Compat Python 3.10`', None), + ('five for `Compat Python 3.11`', 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), - ('# 3.15 deliberately excluded', '(non-blocking, schedule-only)'), - ('# See the `test` job above for why 3.15', None), + ('# The KTLO tier (#1633)', '# `ubuntu-latest` moves to 26.04'), ], } diff --git a/util/ci_timing.py b/util/ci_timing.py index 6bca91d44d..7d8a52172e 100644 --- a/util/ci_timing.py +++ b/util/ci_timing.py @@ -93,11 +93,15 @@ #: 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 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.10', 'Python 3.11', 'Python 3.12', 'Python 3.13') +PAIRED_JOBS = ('Python 3.11', 'Python 3.12', 'Python 3.13', 'Python 3.15') TESTS_STEP = 'Run unit tests' -#: The leg ms/test is measured on: the newest version not under coverage. +#: 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 +#: one interpreter with itself week over week. MS_PER_TEST_LEG = '3.13' #: The JUnit artifacts read, by leg, and from how many push runs. JUNIT_LEGS = ('3.13', '3.14') @@ -111,7 +115,7 @@ r'Report coverage', r'Run full test suite', r'Run tests/.* under plain unittest', - r'Run the 3\.15 cell.*', + r'Run the 3\.10 cell.*', ) # Thresholds. Each is anchored to a value measured on 2026-10-05..09 for #1538,