diff --git a/.github/python-versions.toml b/.github/python-versions.toml index 162b9eca7f..b70dfcfdd8 100644 --- a/.github/python-versions.toml +++ b/.github/python-versions.toml @@ -22,20 +22,20 @@ latest = "3.15" "3.13" = { tier = "supported" } "3.14" = { tier = "supported" } "3.15" = { tier = "supported" } -"3.14t" = { tier = "experimental" } +"3.14t" = { tier = "supported" } [versions.pypy] "pypy3.10" = { tier = "ktlo" } "pypy3.11" = { tier = "supported" } -# Experimental (#1660): free-threaded CPython (a `t` version, above) and -# GraalPy, by its release line, spelt as setup-python reads them. Each runs as -# one non-blocking leg of experimental.yml, named `(experimental)`, a workflow -# of its own that nothing waits on (#1732), and in no other matrix: -# no wheel, conda build or required context. The weekly job has no source for -# either, so it keeps these lines as written, after the other versions of their -# table. Promoting one once its leg is green is a hand edit, which -# util/python_versions.py refuses until it has all of them. +# Hand-kept (#1660): free-threaded CPython (a `t` version, above) and GraalPy, +# by its release line, spelt as setup-python reads them. The weekly job has no +# source for either, so it keeps these lines as written, after the other +# versions of their table. #1958 promoted 3.14t: supported, it runs `test`, +# `integration` and `Compat` legs with the GIL off, and no wheel or conda build +# of its own, as 3.14's wheel is the one it installs. GraalPy stays one +# non-blocking leg of experimental.yml, which nothing waits on (#1732), until +# util/python_versions.py has its wheel, conda build and Compat leg. [versions.graalpy] "graalpy-25.4" = { tier = "experimental" } diff --git a/.github/workflows/experimental.yml b/.github/workflows/experimental.yml index 26aae2e740..be740672ff 100644 --- a/.github/workflows/experimental.yml +++ b/.github/workflows/experimental.yml @@ -1,7 +1,7 @@ name: Experimental Tests -# The experimental tier (#1660): free-threaded CPython and GraalPy, one leg each, -# here and in no other workflow. #1732, the owner's option 2: nothing waits on +# The experimental tier (#1660): GraalPy, one leg, here and in no other workflow; +# free-threaded 3.14t left for unit-tests.yml (#1958). #1732: nothing waits on # these legs. They used to be legs of unit-tests.yml, first of `test` and then of # a job of their own there, and either way the Unit Tests run stayed in progress # until GraalPy finished, up to 140 minutes. Every `workflow_run` consumer of @@ -172,8 +172,8 @@ jobs: # the tier less the round-trip sweeps, which this job did not run then (see # `Run unit tests`), held 3.5-4.5 times as much, by per-module times from a local # 3.14 run and the 3.14, 3.15 and 3.14t legs of run 38581834697: 88-113 - # minutes on GraalPy (estimate). 3.14t, which shares the caps, took 5.8 - # minutes over the whole unit tier in that run. + # minutes on GraalPy (estimate). 3.14t, then an experimental leg here, took + # 5.8 minutes over the whole unit tier in that run. # # #1740 gives GraalPy 3 workers, not 4, so at 4/3 of that time its tests should # take about 117-151 minutes (an inference from the 88-113, not measured), @@ -258,8 +258,8 @@ jobs: python -c "import os; print('cpu_count', os.cpu_count())" # #1938: the interpreter `Run unit tests` runs util/superseded.py on, a - # CPython of its own: `latest`, never the leg's GraalPy or free-threaded - # build, which would also have to take requests. `update-environment: + # CPython of its own: `latest`, never the leg's GraalPy, which would + # also have to take requests. `update-environment: # false` leaves PATH, `pythonLocation` and `LD_LIBRARY_PATH` as the leg's # setup-python set them, so the tests see the leg's interpreter alone; # this one is run by its `python-path`, and finds its own libpython by diff --git a/.github/workflows/python-compatibility.yml b/.github/workflows/python-compatibility.yml index 3b65e6cb37..bb2fc807d2 100644 --- a/.github/workflows/python-compatibility.yml +++ b/.github/workflows/python-compatibility.yml @@ -50,8 +50,8 @@ jobs: # 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. # - # #1633's tiers. The supported tier, 3.11-3.15, keeps the plain name, - # which is what ruleset 23497679 requires one context per version of. The + # #1633's tiers. The supported tier, 3.11-3.15 and 3.14t (#1958), keeps the + # plain name, which ruleset 23497679 requires one context per version of. The # KTLO tier, end-of-life 3.7-3.10, is suffixed ` (KTLO)`, so no required # context can match one. Its steps carry `continue-on-error`, as in # unit-tests.yml's `test` job: job-level, a failed leg's check run concluded @@ -121,6 +121,12 @@ jobs: echo "::${ANNOTATION_LEVEL} title=pcapkit does not import on ${LEG//,/%2C}::See the traceback in this step's log." exit 1 fi + # #1958: on a free-threaded build, with no PYTHON_GIL set, importing + # pcapkit and its dependencies must leave the GIL off. + if ! python -c 'import sys, sysconfig, pcapkit; sys.exit(sysconfig.get_config_var("Py_GIL_DISABLED") == 1 and sys._is_gil_enabled())'; then + echo "::${ANNOTATION_LEVEL} title=pcapkit turns the GIL on on ${LEG//,/%2C}::Importing pcapkit enabled the GIL on this free-threaded build; see the RuntimeWarning in this step's log." + exit 1 + fi # #1672: the KTLO leg's report -- the same step ends every KTLO matrix # job; see unit-tests.yml's `test` job for how it works. diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 12e26011ad..e676154ebb 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -100,8 +100,17 @@ jobs: # #1672: the level of every annotation a failing step writes -- a warning on # a KTLO leg, which must not show red, an error on a supported one. The # steps are shared with `test-ktlo`, whose legs carry `ktlo: true`. + # + # #1958, the owner's ruling: the free-threaded leg, 3.14t, runs with the GIL + # off. `PYTHON_GIL=0` keeps it off when an extension module that does not + # declare `Py_mod_gil` is imported, as `lxml.etree` did through bs4 in every + # experimental run (docs.python.org, Command line and environment, + # PYTHON_GIL); the install leaves `lxml` out besides (see that step), and + # tests/_gil_guard.py fails the leg if a test still finds the GIL on. Empty, + # so unset to CPython, on every other leg, where `0` would be an error. env: ANNOTATION_LEVEL: ${{ matrix.ktlo && 'warning' || 'error' }} + PYTHON_GIL: ${{ endsWith(matrix.python-version, 't') && '0' || '' }} strategy: fail-fast: false # #1660: .github/python-versions.toml's supported tier, CPython's then @@ -122,8 +131,10 @@ jobs: # coverage cost 1.8x on 3.15 locally (2.0x on 3.14): a step of about # 15-19 minutes under the job's 45. # - # The experimental tier (#1660) runs in experimental.yml, a workflow of - # its own, so that nothing here waits on it (#1732). + # Free-threaded 3.14t is a supported CPython leg since #1958, after + # CPython's others, with the GIL off (see `env` above). The experimental + # tier, GraalPy (#1660), runs in experimental.yml, a workflow of its own, + # so that nothing here waits on it (#1732). matrix: ${{ fromJSON(needs.python-versions.outputs.test) }} # `&test-steps`: `test-ktlo` runs these same steps, by YAML alias. @@ -181,6 +192,13 @@ jobs: # interpreter: if setup-python failed, the image's `python` would be # tested under this leg's name. On a supported leg either fails the job; # on a KTLO leg the annotation is a warning (#1672). + # + # #1958: a free-threaded leg uninstalls the `test` extra's `lxml`, which no + # marker can leave out. lxml 6 declares no `Py_mod_gil`, its maintainer + # reports crashes in its threading tests on 3.14t without the GIL + # (bugs.launchpad.net/lxml/+bug/2111289), and its free-threading support + # is in the 7.0 alphas alone (lxml CHANGES.txt, 7.0.0a1). So the tests + # gated on HAS_LXML skip there, each of them `LATEST` in any case. - name: Install package and test dependencies id: install if: ${{ steps.python.outcome == 'success' }} @@ -196,6 +214,9 @@ jobs: echo "::${ANNOTATION_LEVEL} title=pcapkit did not install on ${LEG//,/%2C}::${reason//%/%25}. The rest of this leg is skipped." exit 1 fi + if [ "$(python -c 'import sysconfig; print(sysconfig.get_config_var("Py_GIL_DISABLED"))')" = 1 ]; then + python -m pip uninstall -y lxml + fi # #1958, the owner's ruling E: latest CPython runs the whole suite, so its # leg installs everything the suite gates on that pip can install there -- @@ -436,9 +457,10 @@ jobs: needs: [changes, python-versions] runs-on: ${{ matrix.os || 'ubuntu-latest' }} timeout-minutes: 45 - # See the `test` job above. + # See the `test` job above, PYTHON_GIL too. env: ANNOTATION_LEVEL: ${{ matrix.ktlo && 'warning' || 'error' }} + PYTHON_GIL: ${{ endsWith(matrix.python-version, 't') && '0' || '' }} strategy: fail-fast: false # The supported tier of the `test` job above, without its coverage leg; @@ -483,7 +505,8 @@ jobs: # `pypcap-parity` job below covers those methods instead, on the same # fixture-tier selection as this job but in a venv of its own. # - # A KTLO leg that cannot install stops here, as in the `test` job above. + # A KTLO leg that cannot install stops here, as in the `test` job above, + # and a free-threaded one leaves `lxml` out, as there (#1958). - name: Install package, test and generator dependencies id: install if: ${{ steps.python.outcome == 'success' }} @@ -499,6 +522,9 @@ jobs: echo "::${ANNOTATION_LEVEL} title=pcapkit did not install on ${LEG//,/%2C}::${reason//%/%25}. The rest of this leg is skipped." exit 1 fi + if [ "$(python -c 'import sysconfig; print(sysconfig.get_config_var("Py_GIL_DISABLED"))')" = 1 ]; then + python -m pip uninstall -y lxml + fi # #1958: the `test` job's step of the same name. On `latest` this job runs # the whole fixture tier, so it installs the whole suite's set too. diff --git a/docs/source/contributing/releasing.rst b/docs/source/contributing/releasing.rst index b26503ecc6..3b770c7d3a 100644 --- a/docs/source/contributing/releasing.rst +++ b/docs/source/contributing/releasing.rst @@ -52,7 +52,7 @@ Editing ``__version__`` by hand without also moving :file:`CITATION.cff`'s (``:494-514``) asserts the two agree, and ``create-release.yml``'s ``unit-tests`` job (``:581-597``) 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:1798-1873``). +subset an ordinary push runs (its ``gate`` job, ``unit-tests.yml:1824-1899``). 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 (``:621,808,974,1210``), so a @@ -62,7 +62,7 @@ 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. Other checks run unconditionally on the same path, though none is a step in *this* -process: the ``changelog`` job (``unit-tests.yml:1641-1667``) fails outright if +process: the ``changelog`` job (``unit-tests.yml:1667-1693``) 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" diff --git a/docs/source/contributing/workflows.rst b/docs/source/contributing/workflows.rst index ceb97a6e39..532b82ed0d 100644 --- a/docs/source/contributing/workflows.rst +++ b/docs/source/contributing/workflows.rst @@ -376,7 +376,7 @@ the same commit several times over. It also skips ``changes``, Checks`_ below), which are 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:1629-1635``): ``create-release.yml`` feeds +comment (``unit-tests.yml:1655-1661``): ``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 @@ -689,7 +689,7 @@ grepping every workflow file for its name: - Where * - ``Required checks passed`` - job ``required-checks`` - - ``unit-tests.yml:1964`` + - ``unit-tests.yml:1990`` * - ``Compat Python 3.11`` - job ``compatibility``, matrix leg ``3.11`` - ``python-compatibility.yml:60``, ``.github/python-versions.toml:20`` @@ -705,28 +705,33 @@ grepping every workflow file for its name: * - ``Compat Python 3.15`` - job ``compatibility``, matrix leg ``3.15`` - ``python-compatibility.yml:60``, ``.github/python-versions.toml:24`` + * - ``Compat Python 3.14t`` + - job ``compatibility``, matrix leg ``3.14t`` + - ``python-compatibility.yml:60``, ``.github/python-versions.toml:25`` 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. +which added ``Compat Python 3.15`` and removed ``Compat Python 3.10``, and +`#1958 `__, which added ``Compat +Python 3.14t``. 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:1964``). ``Compat Python`` is defined by one, the +(``unit-tests.yml:1990``). ``Compat Python`` is defined by one, the ``compatibility`` job in ``python-compatibility.yml`` (``:60``), whose matrix (``:74``) comes from :file:`.github/python-versions.toml`: the required legs -are its supported tier (``python-versions.toml:20-24``), and its KTLO legs +are its supported tier (``python-versions.toml:20-25``), and its KTLO legs (``python-versions.toml:16-19``) have names ending in ``(KTLO)``, so no required context matches one. A comment at -``unit-tests.yml:1876`` mentions the string without defining it. +``unit-tests.yml:1902`` mentions the string without defining it. ``Required checks passed`` is an aggregate, not a single check run. The ruleset -requires it and the ``Compat Python 3.11``-``3.15`` legs, nothing else, as -``unit-tests.yml``'s own comment (``unit-tests.yml:1875-1885``) records. It +requires it and the ``Compat Python 3.11``-``3.15`` legs and ``Compat Python 3.14t``, nothing else, as +``unit-tests.yml``'s own comment (``unit-tests.yml:1901-1911``) records. It ``needs:`` the gating jobs of ``unit-tests.yml`` -- ``test``, ``integration``, ``engine-tests`` and ``pypcap-parity``, plus ``project-tests``, the one run of the project pins, and ``changes`` for the docs-only path -- and checks each result explicitly -(``unit-tests.yml:1963-2019``). Rulesets match check names literally, with no +(``unit-tests.yml:1989-2045``). 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 gating jobs as ``skipped`` only on a docs-only pull request, where @@ -735,7 +740,7 @@ It accepts the gating jobs as ``skipped`` only on a docs-only pull request, wher ``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:1966``), +``if: ${{ always() && inputs.gate-only != true }}`` (``unit-tests.yml:1992``), so it is never produced -- and never expected -- on those paths. .. _ktlo-tier: @@ -745,7 +750,8 @@ Python versions are tiered -- supported and KTLO (`#1660 `__, below) -- in :file:`.github/python-versions.toml` (see `Python Versions`_). The **supported** tier, 3.11-3.15, blocks: a failed leg fails ``Required checks passed`` or its own -required ``Compat`` context. The **KTLO** (*keep the lights on*) tier -- end-of-life 3.7-3.10, still built and +required ``Compat`` context. So does free-threaded 3.14t, supported since `#1958 +`__ (see `Free-threaded CPython`_). The **KTLO** (*keep the lights on*) tier -- end-of-life 3.7-3.10, still built and tested -- never blocks, is never a required context, and never shows a commit or a pull request red (`#1672 `__). In ``unit-tests.yml`` the KTLO legs are jobs of their own since `#1738 `__: ``test-ktlo``, @@ -759,7 +765,7 @@ and keeps its legs' check names, ``Python 3.10 (KTLO)`` and so on. In with supported legs. Every job holding KTLO legs works the same way: * Every step that can fail on a KTLO leg has an ``id`` and - ``continue-on-error: ${{ matrix.ktlo == true }}`` (``unit-tests.yml:394-411``), + ``continue-on-error: ${{ matrix.ktlo == true }}`` (``unit-tests.yml:415-432``), unless it is masked on every leg, as the timings uploads are. Its ``outcome`` is then ``failure`` but its ``conclusion`` ``success``, so the job, its check run and ``required-checks`` read ``success``. A step that needs an earlier one's work @@ -824,8 +830,36 @@ in no matrix: setup-python builds it only for retired Ubuntu images. Nor are are they in ``pypcap-parity``, which also runs on ``ubuntu-latest``; none of them was measured on that job. -The **experimental** tier is free-threaded CPython and GraalPy, spelt as -setup-python reads them (``3.14t``, ``graalpy-25.4``). Each runs a leg of +.. _free-threaded-cpython: + +**Free-threaded CPython.** 3.14t is supported (`#1958 +`__), after 21 green runs in a row +as an experimental leg: a leg of ``test`` and ``integration``, after CPython's other +legs and running the ``ALL`` test tier, and ``Compat Python 3.14t``, a required context. +It has no wheel, conda build or ``engine-tests`` cell of its own: the 3.14 wheel is +pure Python, tagged ``cp314-none-any``, and installs on it, and +:file:`util/wheel_rename.py` would give a 3.14t wheel that same name. +:file:`util/python_versions.py` holds a supported free-threaded build to a supported +default build, and the weekly job keeps its line as written. Its tests run with the +GIL off, by the owner's ruling: + +* ``test`` and ``integration`` set ``PYTHON_GIL=0`` on the leg, which keeps the GIL off + even when an extension module that does not declare ``Py_mod_gil`` is imported + (`PYTHON_GIL `__). + ``lxml.etree``, imported through bs4, turned it back on in every experimental run. +* The install uninstalls the ``test`` extra's ``lxml``, which no marker can leave out. + lxml 6 declares no ``Py_mod_gil``, its maintainer reports crashes in its threading + tests on 3.14t without the GIL (`LP#2111289 + `__), and its free-threading support + is in 7.0.0a1 alone. So the ``HAS_LXML`` tests skip there; each is ``LATEST`` anyway. +* :file:`tests/_gil_guard.py` fails the first test after which a free-threaded process + under GitHub Actions finds ``sys._is_gil_enabled()`` true, in every xdist worker, and + ``Compat Python 3.14t`` fails where importing pcapkit, with no ``PYTHON_GIL`` set, + turns the GIL on. + +The **experimental** tier holds GraalPy and any free-threaded CPython not yet promoted, spelt as +setup-python reads them (``graalpy-25.4``). +Each runs a leg of ``experimental``, the matrix job of :file:`.github/workflows/experimental.yml` ("Experimental Tests"), named ``Python (experimental)``, and nothing else: no ``integration``, ``Compat``, wheel or conda leg, so never a required @@ -899,13 +933,13 @@ fails rather than cancels, and the guard holds (actions/runner stays, changing no conclusion, as ``Process completed with exit code N.`` does. Everything but the install and the tests took under 20 seconds on every uncovered leg of runs 38569321543-38569522442. ``test``'s caps are main's: 45 minutes for the -job, and none on a step. Promoting one whose leg stays green is a hand edit, which -:file:`util/python_versions.py` refuses until the build has a wheel, a conda build +job, and none on a step. Promoting GraalPy once its leg stays green is a hand edit, which +:file:`util/python_versions.py` refuses until it has a wheel, a conda build and a ``Compat`` leg. -The tests' 120 minutes are sized for GraalPy, the slowest of the legs that share -them: its leg reached 26% of the unit tier in 25 minutes (job 115915976024), where -3.14t's ran the whole tier in 5.8 (run 38581834697). The experimental legs run the +The tests' 120 minutes are sized for GraalPy: its leg reached 26% of the unit tier in +25 minutes (job 115915976024), where 3.14t's, experimental then, ran the whole tier in +5.8 (run 38581834697). The experimental legs run the ``ALL`` test tier alone, as every leg but ``latest`` does (see `Test Tiers`_ below): ``List the LATEST tier`` asks ``latest_tier_paths()`` on the runner's own ``python3``, before setup-python, so GraalPy does not parse the tree, and ``Run unit tests`` @@ -944,7 +978,7 @@ warnings: * GraalPy alone gets ``GRAAL_PYTHON_VM_ARGS=--vm.Xmx2g --vm.XX:+ExitOnOutOfMemoryError``, a 2 GiB heap for every GraalPy process, xdist workers included, and ``-n 3``, a CPU - of the runner's 4 left to the JIT and the runner; 3.14t keeps ``-n auto``. A + of the runner's 4 left to the JIT and the runner. A worker's live heap grows with the tests it runs (`#1753 `__): in job 116172356218 all three reached 2.2-2.6 GB RSS in 16 minutes, and a ``MemoryError`` in pytest's own @@ -969,7 +1003,7 @@ warnings: caps hold over the whole tier; on ``timeout``'s SIGINT the run in progress writes its JUnit file and no later one starts. Each shard's run writes a JUnit file of its own beside ``junit.xml``, such as ``junit-tests-protocols.xml``, and ``Upload test - timings`` takes them all. 3.14t's leg runs pytest once, as before. + timings`` takes them all. * ``timeout --signal=INT --kill-after=3m 110m`` ends pytest under the step's 120-minute cap, so it still writes the junit XML, and exits 124; ``prlimit --fsize=2147483648`` caps any file at 2 GiB. The ``timeout`` is GNU's, ``gnutimeout`` where it exists diff --git a/pyproject.toml b/pyproject.toml index 519ccf858c..7990b25326 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -382,8 +382,9 @@ test = [ # util/pyshark_encap_map.py reads PDML with (#1894). CPython only, like # psutil: PyPI has no wheel for GraalPy, nor for PyPy 8.0, which # setup-python's ``pypy3.11`` is, and the image has no libxml2 headers to - # build one (#1958). The tests that parse skip there, all of them on - # ``latest`` alone, and the scripts do not fall back to a laxer parser. + # build one (#1958). unit-tests.yml's free-threaded legs uninstall it, as + # lxml 6 needs the GIL (#1958). The tests that parse skip there, all of them + # on ``latest`` alone, and the scripts do not fall back to a laxer parser. # Unpinned, like the entries above. "lxml; platform_python_implementation == 'CPython'", ] diff --git a/tests/_dependency_gates.py b/tests/_dependency_gates.py index b85952d5d4..3a8a5aa5aa 100644 --- a/tests/_dependency_gates.py +++ b/tests/_dependency_gates.py @@ -264,9 +264,10 @@ # falls back to a laxer parser. The ``test`` extra carries it with a # ``platform_python_implementation == 'CPython'`` marker, because PyPI has # no wheel for GraalPy or PyPy 8.0, so the gate skips on experimental.yml's - # GraalPy leg and on unit-tests.yml's PyPy legs (#1958). That is no + # GraalPy leg and on unit-tests.yml's PyPy legs (#1958); unit-tests.yml's + # free-threaded legs uninstall it, as lxml 6 needs the GIL (#1958). That is no # :data:`DEPENDENCY_GATE_EXCLUSIONS` entry: this module does not model - # markers, every module gated on it is ``LATEST``, which no PyPy leg runs, + # markers, every module gated on it is ``LATEST``, which none of those legs runs, # and this module does not read experimental.yml, so every job it reads # satisfies the gate, and an entry would describe a gap that is not there. 'lxml': ('lxml',), diff --git a/tests/_gil_guard.py b/tests/_gil_guard.py new file mode 100644 index 0000000000..f24133a9c4 --- /dev/null +++ b/tests/_gil_guard.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +"""The check that a free-threaded CI leg ran its tests with the GIL off (#1958). + +Free-threaded CPython 3.14t is a supported leg of :file:`unit-tests.yml`'s +``test`` and ``integration`` jobs, by the owner's ruling on #1958 that its tests +run with the GIL truly off. An extension module that does not declare +``Py_mod_gil`` turns the GIL back on when it is imported -- ``lxml.etree``, by +way of :mod:`bs4`, did so in every experimental run before the promotion -- so +those legs set ``PYTHON_GIL=0`` and install no ``lxml``. This module is what +fails such a leg if either stops holding: :func:`tests.conftest.pytest_runtest_makereport` +asks :func:`failure` after each test's teardown, in every process, xdist +workers included. + +It enforces on a free-threaded build under GitHub Actions alone +(:func:`enforced`), so a local free-threaded run that imports such a module is +left to the interpreter's own ``RuntimeWarning``. Only the first test after which +the GIL is found on fails, once per process: it stays on from then. + +""" + +from __future__ import annotations + +import os +import sys +import sysconfig +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Callable, Mapping, Optional + +#: Whether this process has already failed a test for the GIL. +_reported = False + + +def free_threaded() -> 'bool': + """Whether this interpreter is a free-threaded build, by ``Py_GIL_DISABLED``.""" + return bool(sysconfig.get_config_var('Py_GIL_DISABLED')) + + +def enforced(environ: 'Optional[Mapping[str, str]]' = None) -> 'bool': + """Whether this process must keep the GIL off: a free-threaded build, under GitHub Actions.""" + environ = os.environ if environ is None else environ + return free_threaded() and environ.get('GITHUB_ACTIONS') == 'true' + + +def gil_enabled() -> 'bool': + """``sys._is_gil_enabled()``, which every free-threaded build has (3.13+).""" + return sys._is_gil_enabled() # type: ignore[attr-defined] + + +def failure(when: 'str', nodeid: 'str', *, enforce: 'Optional[Callable[[], bool]]' = None, + probe: 'Optional[Callable[[], bool]]' = None) -> 'Optional[str]': + """The failure to report for ``nodeid``'s phase ``when``, if the GIL is on where it must not be. + + Args: + when: The report's phase; only ``'teardown'`` is checked, once the test + and its fixtures have run. + nodeid: The test just run, named in the message. + enforce: :func:`enforced`, replaceable for the tests of this module. + probe: :func:`gil_enabled`, likewise. + + Returns: + The message, the first time a teardown finds the GIL on in this + process; :data:`None` otherwise. + + """ + global _reported + if when != 'teardown' or _reported or not (enforce or enforced)(): + return None + if not (probe or gil_enabled)(): + return None + _reported = True + return (f'The GIL is on in this free-threaded process, found after {nodeid}: a module imported by now ' + 'turned it back on. The free-threaded legs keep it off with PYTHON_GIL=0 and install no lxml ' + "(#1958); check that the job's env still sets PYTHON_GIL=0, and which extension module " + 'without Py_mod_gil the tests now import.') diff --git a/tests/conftest.py b/tests/conftest.py index 345b6122b2..d8c35efe16 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,8 +4,9 @@ Besides session hooks documented where they are defined -- the warm import and coverage flush in :func:`pytest_sessionstart` and :func:`pytest_sessionfinish`, the timer release in -:func:`pytest_timeout_cancel_timer`, and the periodic garbage collection in -:func:`pytest_runtest_protocol` -- it holds the following. The first is +:func:`pytest_timeout_cancel_timer`, the periodic garbage collection in +:func:`pytest_runtest_protocol`, and the free-threaded legs' GIL check in +:func:`pytest_runtest_makereport` -- it holds the following. The first is :func:`restore_module_table`, the guard that puts the :mod:`pcapkit` region of :data:`sys.modules` back after every test; see its own docstring for why it is here rather than left to each test file. @@ -55,6 +56,7 @@ import pytest +from tests import _gil_guard from tests._support import ISOLATED_PREFIXES, restore_modules, snapshot_modules from tests._tiers import (ROOT, TierGuardWarning, audit_module, guard_unavailable_reason, is_unit_tier) @@ -342,7 +344,7 @@ def pytest_timeout_cancel_timer(item: 'pytest.Item') -> 'Iterator[None]': #: :func:`restore_module_table` drops, with its ABC caches and its ``InfoMeta`` #: and ``EnumMeta`` classes -- and the free-threaded build's collector reclaims #: them far less often than the default build's, so a free-threaded xdist -#: worker grows until the experimental leg's memory scope kills it. A fixed +#: worker grows until its runner's memory runs out (#1964). A fixed #: count rather than every test keeps nearly all of the saving for a fraction of #: the cost; see that hook for the measurements. GC_EVERY = 50 @@ -373,7 +375,7 @@ def pytest_runtest_protocol(item: 'pytest.Item', nextitem: 'Optional[pytest.Item]') -> 'Iterator[None]': """Collect cyclic garbage every :data:`GC_EVERY` tests, so workers stay in memory (#1964). - The experimental leg's 3.14t job runs its xdist workers under one 12G + 3.14t's leg, experimental until #1958, ran its xdist workers under one 12G memory scope, and was OOM-killed whenever their sum crossed it: the garbage :data:`GC_EVERY` describes is not a leak, but the free-threaded collector frees it too late to keep a worker small. A wrapper is used so the @@ -404,6 +406,28 @@ def pytest_runtest_protocol(item: 'pytest.Item', _collect_periodically() +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item: 'pytest.Item', call: 'pytest.CallInfo[None]') -> 'Iterator[None]': + """Fail the first teardown after which a free-threaded CI process finds the GIL on (#1958). + + :mod:`tests._gil_guard` decides, and says why the free-threaded legs keep the + GIL off. A teardown, so the test and its fixtures have imported what they + will; a hook rather than an autouse fixture, so every test's fixture list is + as it was. + + Args: + item: The test whose report this is. + call: The phase's call, unused. + + """ + outcome = yield + report = outcome.get_result() + message = _gil_guard.failure(report.when, item.nodeid) + if message is not None: + report.outcome = 'failed' + report.longrepr = message + + def _collected_modules(items: 'Iterable[pytest.Item]') -> 'Iterator[pathlib.Path]': """Distinct module paths behind ``items``, in collection order. diff --git a/tests/project/test_free_threaded_supported_1958.py b/tests/project/test_free_threaded_supported_1958.py new file mode 100644 index 0000000000..9acc2f10fe --- /dev/null +++ b/tests/project/test_free_threaded_supported_1958.py @@ -0,0 +1,225 @@ +# -*- coding: utf-8 -*- +"""#1958, phase 4: free-threaded CPython 3.14t is supported, and runs with the GIL off. + +By the owner's ruling 3.14t leaves experimental.yml for unit-tests.yml's ``test`` +and ``integration``, which block, and gains a ``Compat Python 3.14t`` leg, a +required context. It has no wheel, conda build or ``engine-tests`` cell of its +own: 3.14's pure-Python wheel is the one it installs. Its legs set +``PYTHON_GIL=0``, leave ``lxml`` out of the install, and fail where a test still +finds the GIL on (:mod:`tests._gil_guard`). + +""" + +from __future__ import annotations + +import importlib.util +import json +import os +import pathlib +import subprocess # nosec B404 +import tempfile +import unittest +from typing import TYPE_CHECKING + +from tests import _gil_guard +from tests.project.test_ktlo_legs_stay_green_1672 import leg_env +from tests.project.test_python_matrix_tiers_1633 import job, legs +from tests.project.test_python_versions_1660 import ROOT, live_outputs, prose, script, write_tiers +from tests.project.test_python_versions_update_1660 import FROZEN, as_of_the_file, scratch, snapshot, updater + +if TYPE_CHECKING: + from typing import Any + +#: Test tier (#1958): reads the workflows and the tier file, imports no pcapkit. +__tier__ = 'LATEST' + +FREE = '3.14t' +#: The jobs whose legs run the tests, each with its install step. +TESTED = {('unit-tests.yml', 'test'): 'Install package and test dependencies', + ('unit-tests.yml', 'integration'): 'Install package, test and generator dependencies'} +#: The free-threaded check the Compat leg's ``Verify package`` runs. +COMPAT_CHECK = ("python -c 'import sys, sysconfig, pcapkit; " + "sys.exit(sysconfig.get_config_var(\"Py_GIL_DISABLED\") == 1 and sys._is_gil_enabled())'") +#: A ``python`` that answers the install step's free-threading probe from ``$FREE`` +#: and records every other call's arguments in ``$ARGS_FILE``, one call a line. +STUB = '''#!/bin/sh +case "$*" in + *Py_GIL_DISABLED*) if [ "$FREE" = 1 ]; then echo 1; else echo None; fi; exit 0 ;; +esac +echo "$*" >> "$ARGS_FILE" +''' + + +def step(where: 'tuple[str, str]', name: 'str') -> 'dict[str, Any]': + found = [entry for entry in job(*where)['steps'] if entry.get('name') == name] + if len(found) != 1: + raise AssertionError(f'{where}: {len(found)} steps named {name!r}') + return found[0] + + +class TestTheTierFile(unittest.TestCase): + """3.14t is supported; every job but the tested ones and Compat leaves it out.""" + + def setUp(self) -> None: + tmp = tempfile.TemporaryDirectory(prefix='pcapkit-1958-') # pylint: disable=consider-using-with + self.addCleanup(tmp.cleanup) + self.tmp = pathlib.Path(tmp.name) + + def test_3_14t_is_supported_and_hand_kept(self) -> None: + data = script().load() + self.assertEqual(data['versions']['cpython'][FREE], {'tier': 'supported'}) + self.assertIn(FREE, script().tier(data, 'cpython', 'supported')) + self.assertNotIn(FREE, script().builds(data, 'cpython', 'supported')) + self.assertTrue(script().hand_kept('cpython', FREE)) + self.assertNotIn(FREE, script().tier(data, 'cpython', 'experimental')) + + def test_it_runs_test_integration_and_compat_alone(self) -> None: + values = live_outputs() + for output in ('test', 'integration', 'compatibility'): + with self.subTest(output=output): + self.assertIn(FREE, json.loads(values[output])['python-version']) + for output in ('experimental', 'test-ktlo', 'integration-ktlo', 'pypcap-parity', 'pypi', 'conda'): + with self.subTest(output=output): + self.assertNotIn(FREE, json.dumps(json.loads(values[output]))) + for output in ('engine-supported', 'engine-ktlo', 'pypi-wheels', 'conda-builds'): + with self.subTest(output=output): + self.assertNotIn(FREE, values[output].split()) + self.assertEqual(values['pypi-wheels'].split().count('cp314'), 1) + + def test_conda_vendor_refreshes_pins_for_the_conda_builds_alone(self) -> None: + """util/conda_vendor.py's Pythons are the ``conda`` output's, so no pin waits on a cp314t wheel.""" + spec = importlib.util.spec_from_file_location('conda_vendor_1958', ROOT / 'util' / 'conda_vendor.py') + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) # type: ignore[union-attr] + self.assertEqual(module.conda_pythons(), json.loads(live_outputs()['conda'])) + self.assertNotIn(FREE, module.conda_pythons()) + + def test_load_holds_a_free_threaded_build_to_its_default_build(self) -> None: + data = script().load() + cpython = dict(data['versions']['cpython']) + cases = { + 'free-threaded KTLO': dict(cpython, **{FREE: {'tier': 'ktlo'}}), + 'supported without its default build': dict(cpython, **{'3.16t': {'tier': 'supported'}}), + 'listed among the computed versions': dict( + {key: value for key, value in cpython.items() if key not in ('3.15', FREE)}, + **{FREE: {'tier': 'supported'}, '3.15': {'tier': 'supported'}}), + } + for label, versions in cases.items(): + with self.subTest(case=label), self.assertRaises(ValueError): + script().load(write_tiers(self.tmp, dict(data, versions=dict(data['versions'], cpython=versions)))) + with self.assertRaises(ValueError): + script().load(write_tiers(self.tmp, dict(data, latest=FREE))) + + def test_the_weekly_job_keeps_its_line_and_leaves_it_out_of_the_prose(self) -> None: + """Hand-kept though supported: byte for byte, no classifier, but a Compat row and context.""" + root = scratch(self) + module = updater() + tier_file = root / module.TIER_FILE + line = '"3.14t" = { tier = "supported" } # free-threaded, hand-kept\n' + text = tier_file.read_text(encoding='utf-8') + anchor = '"3.15" = { tier = "supported" }\n' + self.assertEqual(text.count(anchor), 1) + tier_file.write_text(text.replace(anchor, anchor + line), encoding='utf-8') + plan, texts = module.compute_plan(root, as_of_the_file(snapshot())) + self.assertEqual(plan['changes'], []) + self.assertNotIn(module.TIER_FILE, texts) + self.assertNotIn('pyproject.toml', texts) + page = texts['docs/source/contributing/workflows.rst'] + self.assertIn('* - ``Compat Python 3.14t``\n', page) + self.assertIn('``.github/python-versions.toml:25``', page.split('Compat Python 3.14t', 1)[1][:200]) + values = module.pv.outputs(module.validate(tier_file.read_text(encoding='utf-8'))) + self.assertIn('Compat Python 3.14t', module.required_contexts(module.load_workflows(root), values)) + + +class TestTheLegsKeepTheGilOff(unittest.TestCase): + + def test_python_gil_is_0_on_the_free_threaded_legs_alone(self) -> None: + for where in TESTED: + found = [leg['python-version'] for leg in legs(*where)] + self.assertIn(FREE, found) + for leg in legs(*where): + with self.subTest(job=where[1], python=leg['python-version']): + expected = '0' if leg['python-version'] == FREE else '' + self.assertEqual(leg_env(where[0], job(*where), leg)['PYTHON_GIL'], expected) + + def run_install(self, where: 'tuple[str, str]', free: 'bool') -> 'list[str]': + with tempfile.TemporaryDirectory() as temp: + stub = pathlib.Path(temp, 'python') + stub.write_text(STUB, encoding='utf-8') + stub.chmod(0o755) + record = pathlib.Path(temp, 'args') + record.touch() + env = dict(os.environ, FREE='1' if free else '0', ARGS_FILE=str(record), RUNNER_TEMP=temp, + LEG='Python 3.14t', ANNOTATION_LEVEL='error', + PATH=temp + os.pathsep + os.environ.get('PATH', '')) + result = subprocess.run(['bash', '-eo', 'pipefail', '-c', step(where, TESTED[where])['run']], # nosec + env=env, cwd=ROOT, capture_output=True, text=True, check=False, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return record.read_text(encoding='utf-8').splitlines() + + def test_the_install_leaves_lxml_out_on_a_free_threaded_build(self) -> None: + for where in TESTED: + for free in (True, False): + with self.subTest(job=where[1], free_threaded=free): + calls = self.run_install(where, free) + self.assertTrue(calls and calls[-1 if not free else -2].startswith('-m pip install -e '), calls) + self.assertEqual('-m pip uninstall -y lxml' in calls, free, calls) + + def test_the_compat_leg_fails_where_pcapkit_turns_the_gil_on(self) -> None: + verify = step(('python-compatibility.yml', 'compatibility'), 'Verify package') + self.assertIn(COMPAT_CHECK, verify['run']) + # With no PYTHON_GIL, so the GIL stays off only if no module pcapkit imports turns it on. + spec = job('python-compatibility.yml', 'compatibility') + for env in [spec.get('env') or {}] + [entry.get('env') or {} for entry in spec['steps']]: + self.assertNotIn('PYTHON_GIL', env) + + def test_the_conftest_asks_the_guard_after_every_teardown(self) -> None: + text = (ROOT / 'tests' / 'conftest.py').read_text(encoding='utf-8') + self.assertIn('def pytest_runtest_makereport(item', text) + self.assertIn('message = _gil_guard.failure(report.when, item.nodeid)', text) + + +class TestTheGuard(unittest.TestCase): + + def setUp(self) -> None: + # The process's own state, put back after: the conftest's guard reads it too. + self.addCleanup(setattr, _gil_guard, '_reported', _gil_guard._reported) # pylint: disable=protected-access + _gil_guard._reported = False # pylint: disable=protected-access + + def test_it_fails_the_first_teardown_that_finds_the_gil_on(self) -> None: + def on() -> bool: + return True + + self.assertIsNone(_gil_guard.failure('call', 'a', enforce=on, probe=on)) + message = _gil_guard.failure('teardown', 'tests/test_a.py::test', enforce=on, probe=on) + self.assertIsNotNone(message) + self.assertIn('tests/test_a.py::test', str(message)) + self.assertIn('PYTHON_GIL=0', str(message)) + self.assertIsNone(_gil_guard.failure('teardown', 'b', enforce=on, probe=on)) + + def test_it_passes_with_the_gil_off_or_unenforced(self) -> None: + def on() -> bool: + return True + + def off() -> bool: + return False + + self.assertIsNone(_gil_guard.failure('teardown', 'a', enforce=on, probe=off)) + self.assertIsNone(_gil_guard.failure('teardown', 'a', enforce=off, probe=on)) + + def test_it_enforces_on_a_free_threaded_build_under_actions_alone(self) -> None: + free = _gil_guard.free_threaded() + self.assertEqual(_gil_guard.enforced({'GITHUB_ACTIONS': 'true'}), free) + self.assertFalse(_gil_guard.enforced({})) + + def test_the_docs_say_so(self) -> None: + page = prose(ROOT / 'docs' / 'source' / 'contributing' / 'workflows.rst') + for phrase in ('3.14t is supported', '``PYTHON_GIL=0``', ':file:`tests/_gil_guard.py`', + '``Compat Python 3.14t``', 'LP#2111289'): + with self.subTest(phrase=phrase): + self.assertIn(phrase, page) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/project/test_graalpy_shards_1963.py b/tests/project/test_graalpy_shards_1963.py index 11f12c2000..860d8fddf6 100644 --- a/tests/project/test_graalpy_shards_1963.py +++ b/tests/project/test_graalpy_shards_1963.py @@ -62,7 +62,8 @@ def test_graalpy_alone_runs_in_shards(self) -> None: env = step(TESTS)['env'] versions = [leg['python-version'] for leg in legs(*EXPERIMENTAL)] self.assertTrue(any(version.startswith('graalpy') for version in versions), versions) - self.assertTrue(any(not version.startswith('graalpy') for version in versions), versions) + # #1958: 3.14t left for unit-tests.yml; it stands for any other build the tier may hold here. + versions += [version for version in ('3.14t',) if version not in versions] for version in versions: with self.subTest(version=version): self.assertEqual(by_impl(env['SHARDS'], version), SHARDS if version.startswith('graalpy') else '') diff --git a/tests/project/test_python_matrix_tiers_1633.py b/tests/project/test_python_matrix_tiers_1633.py index 33965dc129..505c2f65c5 100644 --- a/tests/project/test_python_matrix_tiers_1633.py +++ b/tests/project/test_python_matrix_tiers_1633.py @@ -74,6 +74,10 @@ def tier(impl: 'str', name: 'str') -> 'tuple[str, ...]': SUPPORTED = tier('cpython', 'supported') +#: The supported default builds, which have a wheel, a conda build and an +#: ``engine-tests`` cell of their own; a free-threaded build such as 3.14t, supported +#: since #1958, has its ``test``, ``integration`` and ``Compat`` legs alone. +BUILDS = tuple(v for v in SUPPORTED if not v.endswith('t')) KTLO = tier('cpython', 'ktlo') #: The image each version needs where ``ubuntu-latest`` cannot provide it: #: setup-python has no Ubuntu 26.04 build of 3.7-3.9, and ubuntu-22.04, 3.7's, is @@ -82,7 +86,7 @@ def tier(impl: 'str', name: 'str') -> 'tuple[str, ...]': #: The KTLO versions a job pinned to ``ubuntu-latest`` runs. KTLO_ON_LATEST = tuple(v for v in KTLO if v not in PINNED_OS) #: PyPCAP/PyPCAPFile's ``python_version < '3.12'`` marker bounds ``pypcap-parity``. -PARITY = tuple(v for v in SUPPORTED + KTLO_ON_LATEST if tuple(map(int, v.split('.'))) < (3, 12)) +PARITY = tuple(v for v in BUILDS + KTLO_ON_LATEST if tuple(map(int, v.split('.'))) < (3, 12)) #: The step-level guard every failure-prone step of a KTLO leg's job carries. KTLO_GUARD = '${{ matrix.ktlo == true }}' #: The job-level level of every annotation a failing step writes (#1672). @@ -110,9 +114,9 @@ def tier(impl: 'str', name: 'str') -> 'tuple[str, ...]': ('unit-tests.yml', 'integration', TESTED_SUPPORTED), ('unit-tests.yml', 'pypcap-parity', tuple(v for v in PARITY if v in SUPPORTED)), ('python-compatibility.yml', 'compatibility', SUPPORTED), - ('create-release.yml', 'pypi', SUPPORTED + tier('pypy', 'supported')), - ('create-release.yml', 'conda', SUPPORTED), - ('cron-conda.yml', 'conda-dist', SUPPORTED), + ('create-release.yml', 'pypi', BUILDS + tier('pypy', 'supported')), + ('create-release.yml', 'conda', BUILDS), + ('cron-conda.yml', 'conda-dist', BUILDS), ) #: The jobs a single ``needs..result`` aggregates, so KTLO must stay step-level. AGGREGATED = (('unit-tests.yml', 'test'), ('unit-tests.yml', 'integration'), @@ -255,17 +259,17 @@ def test_every_matrix_runs_the_supported_tier_as_blocking(self) -> None: def test_engine_tests_runs_every_supported_cell_in_its_blocking_step(self) -> None: steps = job('unit-tests.yml', 'engine-tests')['steps'] blocking = [versions for step, versions in engine_inputs() if 'continue-on-error' not in step] - self.assertEqual([versions.split() for versions in blocking], [list(SUPPORTED)]) + self.assertEqual([versions.split() for versions in blocking], [list(BUILDS)]) loop = next(step for step in steps if step.get('name') == 'Run the supported cells') self.assertNotIn('continue-on-error', loop) self.assertNotIn('if', loop) - self.assertEqual(resolve('unit-tests.yml', 'engine-tests', loop['env']['PYTHONS']).split(), list(SUPPORTED)) + self.assertEqual(resolve('unit-tests.yml', 'engine-tests', loop['env']['PYTHONS']).split(), list(BUILDS)) self.assertIn('for py in $PYTHONS;', loop['run']) def test_the_supported_tier_is_what_the_classifiers_list(self) -> None: text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8') - listed = re.findall(r'"Programming Language :: Python :: (3\.\d+)"', text) - self.assertEqual(listed, list(SUPPORTED)) + listed = re.findall(r'"Programming Language :: Python :: (3\.\d+t?)"', text) + self.assertEqual(listed, list(BUILDS)) def test_requires_python_is_unchanged(self) -> None: """The owner's ruling: KTLO keeps old versions installable, it does not raise the floor.""" @@ -279,7 +283,7 @@ def test_ci_timing_pairs_the_coverage_leg_with_supported_legs_only(self) -> None spec.loader.exec_module(ci_timing) # type: ignore[union-attr] self.assertEqual(ci_timing.COVERAGE_JOB, f"Python {TIERS['latest']}") self.assertEqual(sorted((ci_timing.COVERAGE_JOB,) + ci_timing.PAIRED_JOBS), - sorted(f'Python {v}' for v in SUPPORTED)) + sorted(f'Python {v}' for v in BUILDS)) def test_pypcap_parity_stops_where_the_engines_markers_do(self) -> None: """The script's ``PARITY_BELOW`` is pyproject.toml's PyPCAP and PyPCAPFile marker.""" @@ -396,7 +400,7 @@ def pypi_complete(self, files: 'list[str]') -> 'str': def test_pypi_completeness_counts_the_supported_tier_only(self) -> None: tags = {leg['python-version']: versions_script().wheel_tag(leg['python-version']) for leg in legs('create-release.yml', 'pypi')} - supported = [f'pypcapkit-1.5.0-{tags[v]}-none-any.whl' for v in SUPPORTED + tier('pypy', 'supported')] + supported = [f'pypcapkit-1.5.0-{tags[v]}-none-any.whl' for v in BUILDS + tier('pypy', 'supported')] ktlo = [f'pypcapkit-1.5.0-{tags[v]}-none-any.whl' for v in KTLO_ON_LATEST + tier('pypy', 'ktlo')] self.assertTrue(supported and ktlo) sdist = ['pypcapkit-1.5.0.tar.gz'] diff --git a/tests/project/test_python_versions_1660.py b/tests/project/test_python_versions_1660.py index f940e6bea2..2401dfd837 100644 --- a/tests/project/test_python_versions_1660.py +++ b/tests/project/test_python_versions_1660.py @@ -891,12 +891,17 @@ class TestTheProseFollowsTheFile(unittest.TestCase): def setUpClass(cls) -> None: data = script().load() cls.latest = data['latest'] - cls.supported = script().tier(data, 'cpython', 'supported') + # The default builds, which the classifiers and the spans name, and with + # them a supported free-threaded build, which has a Compat leg and a + # tier line of its own (#1958). + cls.supported = script().builds(data, 'cpython', 'supported') + cls.compat = script().tier(data, 'cpython', 'supported') cls.ktlo = script().tier(data, 'cpython', 'ktlo') def test_the_classifiers_name_the_supported_tier_and_both_implementations(self) -> None: + """The default builds alone: no trove classifier names a free-threaded build (#1958).""" text = (ROOT / 'pyproject.toml').read_text(encoding='utf-8') - self.assertEqual(re.findall(r'"Programming Language :: Python :: (3\.\d+)"', text), self.supported) + self.assertEqual(re.findall(r'"Programming Language :: Python :: (3\.\d+t?)"', text), self.supported) self.assertEqual(re.findall(r'"Programming Language :: Python :: Implementation :: (\w+)"', text), ['CPython', 'PyPy']) @@ -933,18 +938,18 @@ def test_each_tier_range_names_the_files_tier(self) -> None: def test_the_required_contexts_table_is_the_supported_tier(self) -> None: """The docs' copy of ruleset 23497679, which a tier change edits with it.""" page = prose(ROOT / 'docs' / 'source' / 'contributing' / 'workflows.rst') - self.assertEqual(re.findall(r'\* - ``Compat Python (3\.\d+)``', page), self.supported) + self.assertEqual(re.findall(r'\* - ``Compat Python (3\.\d+t?)``', page), self.compat) def test_the_workflows_page_cites_each_version_on_its_line(self) -> None: """The required contexts' rows, and the two tiers' spans under them, cite the file by line.""" lines = TIER_FILE.read_text(encoding='utf-8').splitlines() page = prose(ROOT / 'docs' / 'source' / 'contributing' / 'workflows.rst') - rows = re.findall(r'``Compat Python (3\.\d+)`` .*? ``\.github/python-versions\.toml:(\d+)``', page) - self.assertEqual([version for version, _ in rows], self.supported) + rows = re.findall(r'``Compat Python (3\.\d+t?)`` .*? ``\.github/python-versions\.toml:(\d+)``', page) + self.assertEqual([version for version, _ in rows], self.compat) for version, line in rows: with self.subTest(version=version): self.assertTrue(lines[int(line) - 1].startswith(f'"{version}" = '), lines[int(line) - 1]) - for label, versions in (('supported tier', self.supported), ('KTLO legs', self.ktlo)): + for label, versions in (('supported tier', self.compat), ('KTLO legs', self.ktlo)): spans = re.findall(rf'its {label} \(``python-versions\.toml:(\d+)-(\d+)``\)', page) with self.subTest(tier=label): self.assertEqual(len(spans), 1, spans) @@ -1016,14 +1021,21 @@ def outputs(self, data: 'dict[str, Any]') -> 'dict[str, str]': return script().outputs(script().load(write_tiers(self.tmp, data))) def test_the_file_lists_free_threaded_cpython_and_graalpy(self) -> None: - """Each spelt as setup-python's ``python-version`` reads it, and nothing else experimental.""" + """Each spelt as setup-python's ``python-version`` reads it, and nothing else experimental. + + GraalPy is experimental; free-threaded 3.14t is listed, supported since #1958. + + """ data = script().load() for impl, spelling in SETUP_PYTHON.items(): - found = script().tier(data, impl, 'experimental') + found = [version for version in data['versions'].get(impl, {}) if script().hand_kept(impl, version)] with self.subTest(implementation=impl): - self.assertTrue(found, f'no experimental {impl}') + self.assertTrue(found, f'no hand-kept {impl}') for version in found: self.assertRegex(version, rf'^{spelling.pattern}$') + for version in script().tier(data, impl, 'experimental'): + self.assertIn(version, found) + self.assertTrue(script().tier(data, 'graalpy', 'experimental')) self.assertEqual(script().tier(data, 'pypy', 'experimental'), []) def test_load_holds_the_tier_to_those_builds(self) -> None: @@ -1033,8 +1045,9 @@ def test_load_holds_the_tier_to_those_builds(self) -> None: versions = TIERS_WITH_EXPERIMENTAL['versions'] cpython = {key: value for key, value in versions['cpython'].items() if key != '3.14t'} cases = { - 'a free-threaded build in a blocking tier': dict(versions, cpython=dict(cpython, **{ - '3.14t': {'tier': 'supported'}})), + # #1958: supported is allowed beside its default build, KTLO never. + 'a free-threaded build in KTLO': dict(versions, cpython=dict(cpython, **{ + '3.14t': {'tier': 'ktlo'}})), 'GraalPy in a blocking tier': dict(versions, graalpy={'graalpy-25.4': {'tier': 'ktlo'}}), 'a plain CPython experimental': dict(versions, cpython=dict(cpython, **{ '3.16': {'tier': 'experimental'}})), diff --git a/tests/project/test_release_gates.py b/tests/project/test_release_gates.py index 0af90b7dbe..93817832e7 100644 --- a/tests/project/test_release_gates.py +++ b/tests/project/test_release_gates.py @@ -137,8 +137,9 @@ :func:`evaluate_condition` implements the subset this workflow uses and nothing more: ``!``, ``&&``, ``||``, parentheses, ``==``/``!=`` against -single-quoted literals, integer literals, ``startsWith``, ``contains``, -``fromJSON``, the status-check functions, and ``needs..result`` / +single-quoted literals, integer literals, ``startsWith``, ``endsWith`` (the +free-threaded legs' ``PYTHON_GIL``, #1958), ``contains``, ``fromJSON``, the +status-check functions, and ``needs..result`` / ``needs..outputs.`` / ``github.*`` lookups. ``contains``, ``fromJSON`` and ``always()`` appear in no gate on the tree -- they are implemented because the mutation table needs them *evaluated* rather than @@ -680,6 +681,7 @@ def __repr__(self) -> 'str': 'success': 0, 'failure': 0, 'startswith': 2, + 'endswith': 2, 'contains': 2, 'fromjson': 1, } @@ -981,6 +983,9 @@ def _call(name: 'str', arguments: 'list[object]', context: 'dict[str, object]') if name == 'startswith': # `startsWith` is case-insensitive and casts both operands, same as `==`. return _as_string(arguments[0]).casefold().startswith(_as_string(arguments[1]).casefold()) + if name == 'endswith': + # Likewise case-insensitive (docs.github.com, Expressions, `endsWith`). + return _as_string(arguments[0]).casefold().endswith(_as_string(arguments[1]).casefold()) if name == 'contains': haystack, needle = arguments if isinstance(haystack, list): diff --git a/tests/project/test_resource_monitor_1740.py b/tests/project/test_resource_monitor_1740.py index 74b8b20bc7..3b8f7a0163 100644 --- a/tests/project/test_resource_monitor_1740.py +++ b/tests/project/test_resource_monitor_1740.py @@ -273,8 +273,9 @@ class TestTheTestsAreContained(unittest.TestCase): def legs(self) -> 'list[str]': found = [leg['python-version'] for leg in legs(*EXPERIMENTAL)] self.assertTrue(any(version.startswith('graalpy') for version in found), found) - self.assertTrue(any(not version.startswith('graalpy') for version in found), found) - return found + # #1958: 3.14t left for unit-tests.yml, so the leg GraalPy's settings must + # not reach is the free-threaded build the tier may hold here again. + return found + [version for version in ('3.14t',) if version not in found] def test_graalpy_alone_gets_a_2g_heap_and_3_workers(self) -> None: env = step(TESTS).get('env', {}) diff --git a/tests/project/test_sweeps_on_latest_only_1732.py b/tests/project/test_sweeps_on_latest_only_1732.py index a783830eaf..0b004262a8 100644 --- a/tests/project/test_sweeps_on_latest_only_1732.py +++ b/tests/project/test_sweeps_on_latest_only_1732.py @@ -440,7 +440,8 @@ def setUp(self) -> None: def test_the_workflow_holds_every_experimental_leg_and_unit_tests_none(self) -> None: found = legs(*EXPERIMENTAL) self.assertEqual(self.own['name'], 'Experimental Tests') - self.assertEqual([leg['python-version'] for leg in found], ['3.14t', 'graalpy-25.4']) + # #1958: 3.14t is supported, a leg of unit-tests.yml's `test` and `integration`. + self.assertEqual([leg['python-version'] for leg in found], ['graalpy-25.4']) self.assertTrue(all(map(is_experimental, found))) self.assertEqual(self.own['jobs']['experimental']['name'], 'Python ${{ matrix.python-version }} (experimental)') self.assertNotIn('experimental', self.unit['jobs']) @@ -557,7 +558,7 @@ def test_ci_timing_still_reads_its_legs(self) -> None: def test_no_required_context_names_a_leg(self) -> None: names = {f'Python {leg["python-version"]} (experimental)' for leg in legs(*EXPERIMENTAL)} - self.assertEqual(len(names), 2) + self.assertEqual(len(names), 1) self.assertFalse(names & REQUIRED) diff --git a/util/ci_timing.py b/util/ci_timing.py index 4e9a1c4da1..efbe6250da 100644 --- a/util/ci_timing.py +++ b/util/ci_timing.py @@ -124,11 +124,16 @@ def _coverage_legs() -> 'tuple[str, tuple[str, ...]]': - """The ``latest`` leg's job name, and the other supported legs', from :data:`TIERS`.""" + """The ``latest`` leg's job name, and the other supported default builds' legs', from :data:`TIERS`. + + Not a free-threaded leg, such as 3.14t (#1958): the coverage ratio compares + ``latest`` with legs of the same build. + + """ with open(TIERS, 'rb') as file: data = tomllib.load(file) paired = tuple(f'Python {version}' for version, entry in data['versions']['cpython'].items() - if entry['tier'] == 'supported' and version != data['latest']) + if entry['tier'] == 'supported' and version != data['latest'] and not version.endswith('t')) return f"Python {data['latest']}", paired diff --git a/util/conda_vendor.py b/util/conda_vendor.py index 66b8402246..07fa33856f 100644 --- a/util/conda_vendor.py +++ b/util/conda_vendor.py @@ -230,9 +230,14 @@ def _load_python_versions() -> 'object': def conda_pythons() -> 'list[str]': - """The CPython versions a conda package is built for, as ``conda`` in python_versions.py.""" + """The CPython versions a conda package is built for, as ``conda`` in python_versions.py. + + Its ``builds()``, not the supported tier: a free-threaded build such as 3.14t + has no conda build of its own (#1958), so no pin waits on a wheel for it. + + """ versions = _load_python_versions() - return list(versions.tier(versions.load(), 'cpython', 'supported')) # type: ignore[attr-defined] + return list(versions.builds(versions.load(), 'cpython', 'supported')) # type: ignore[attr-defined] def environments(pythons: 'Iterable[str]') -> 'list[dict[str, str]]': diff --git a/util/python_versions.py b/util/python_versions.py index d88ee0277a..85a5057c23 100644 --- a/util/python_versions.py +++ b/util/python_versions.py @@ -17,8 +17,8 @@ Both tiers. A KTLO leg carries ``ktlo: true``, and ``os`` where ``ubuntu-latest`` cannot provide it. ``test``, ``integration`` - The supported tier alone, CPython's then PyPy's (#1958); ``test`` marks the - ``latest`` leg ``coverage: true``. + The supported tier alone, CPython's then PyPy's (#1958), free-threaded + CPython among CPython's; ``test`` marks the ``latest`` leg ``coverage: true``. ``test-ktlo``, ``integration-ktlo`` Each KTLO version, CPython's then PyPy's, as a leg carrying ``ktlo: true``, and ``os`` as above, for unit-tests.yml's jobs of the same names, which @@ -27,19 +27,21 @@ The same, for CPython's KTLO versions below 3.12. No other unit-tests.yml output holds a KTLO version. ``experimental`` - Each experimental version as a leg carrying ``experimental: true``, for + Each experimental version -- GraalPy's, since free-threaded 3.14t is + supported (#1958) -- as a leg carrying ``experimental: true``, for experimental.yml's ``experimental`` job, a workflow of its own so that nothing waits on it (#1732). No other output holds an experimental version. ``pypcap-parity`` The supported versions below 3.12. ``pypi`` - CPython, then PyPy, both tiers, on ``ubuntu-latest``. + CPython, then PyPy, both tiers, on ``ubuntu-latest``; no free-threaded build + (:func:`builds`). ``conda`` - The supported CPython versions, as one matrix axis. + The supported CPython versions, as one matrix axis; no free-threaded build. ``engine-supported``, ``engine-ktlo`` What ``engine-tests`` installs on ``ubuntu-latest``, one version per line, - as ``actions/setup-python`` reads a list. + as ``actions/setup-python`` reads a list; no free-threaded build. ``pypi-wheels``, ``conda-builds`` The supported tier's wheel tags and conda build tags, which Create Release's completeness checks count. @@ -102,15 +104,29 @@ def key(version: 'str') -> 'tuple[int, int]': return int(found['line']), int(found['release']) +def free_threaded(impl: 'str', version: 'str') -> 'bool': + """Whether ``version`` is a free-threaded CPython build, such as ``3.14t``.""" + return impl == 'cpython' and version.endswith('t') + + +def hand_kept(impl: 'str', version: 'str') -> 'bool': + """Whether the weekly job keeps ``version``'s line as written (#1660). + + Free-threaded CPython and GraalPy: :file:`util/update_python_versions.py` has + no source to compute a tier for either, whatever tier the file gives it. + + """ + return impl == 'graalpy' or free_threaded(impl, version) + + def experimental_only(impl: 'str', version: 'str') -> 'bool': """Whether ``version`` is a build the experimental tier holds, and no other (#1660). - Free-threaded CPython and GraalPy: no wheel tag, conda build or ``Compat`` leg - handles either, and the weekly job (:file:`util/update_python_versions.py`) - has no source to compute a tier for one, so it keeps their lines as written. + GraalPy: no wheel tag, conda build or ``Compat`` leg handles it. Free-threaded + CPython was one too until #1958 promoted 3.14t, which may be supported. """ - return impl == 'graalpy' or (impl == 'cpython' and version.endswith('t')) + return impl == 'graalpy' def load(path: 'pathlib.Path' = FILE) -> 'dict[str, Any]': @@ -124,11 +140,16 @@ def load(path: 'pathlib.Path' = FILE) -> 'dict[str, Any]': instead of running it as something else. An exemption's reason is the comment above it, which :mod:`tomllib` does not keep. - The experimental tier holds free-threaded CPython and GraalPy, and they hold - no other (:func:`experimental_only`): promoting one to a blocking tier needs - its wheel, conda build and ``Compat`` leg first, so that fails here too. They - are listed after the other versions of their table, which keeps each tier's - lines one run for the prose that cites them. + The experimental tier holds free-threaded CPython and GraalPy alone + (:func:`hand_kept`). GraalPy holds no other (:func:`experimental_only`): + promoting it to a blocking tier needs its wheel, conda build and ``Compat`` + leg first, so that fails here too. A free-threaded CPython may be supported + (#1958), never KTLO, and only beside its default build: it gets ``test``, + ``integration`` and ``Compat`` legs, and no wheel or conda build of its own, + as the default build's pure-Python ``cp3N-none-any`` wheel is the one it + installs (:func:`builds`). The hand-kept versions are listed after the + others of their table, which keeps each tier's lines one run for the prose + that cites them. """ data = tomllib.loads(pathlib.Path(path).read_text(encoding='utf-8')) @@ -152,20 +173,29 @@ def load(path: 'pathlib.Path' = FILE) -> 'dict[str, Any]': if entry['tier'] == 'pre-release': raise ValueError(f'{where}: no workflow runs pre-release legs yet; wire their ' 'non-blocking mechanics before listing one (#1660)') - if entry['tier'] == 'experimental' and not experimental_only(impl, version): + if entry['tier'] == 'experimental' and not hand_kept(impl, version): raise ValueError(f'{where}: the experimental tier holds free-threaded CPython and GraalPy ' 'only; the weekly job computes the tier of every other version (#1660)') if entry['tier'] != 'experimental' and experimental_only(impl, version): - raise ValueError(f'{where}: free-threaded CPython and GraalPy are experimental only; give them ' - 'a wheel, a conda build and a Compat leg before a blocking tier (#1660)') + raise ValueError(f'{where}: GraalPy is experimental only; give it a wheel, a conda build ' + 'and a Compat leg before a blocking tier (#1660)') + if free_threaded(impl, version) and entry['tier'] not in ('experimental', 'supported'): + raise ValueError(f'{where}: a free-threaded build is experimental or supported, never ' + f'{entry["tier"]} (#1958)') + if (free_threaded(impl, version) and entry['tier'] == 'supported' + and entries.get(version[:-1], {}).get('tier') != 'supported'): + raise ValueError(f'{where}: a supported free-threaded build installs {version[:-1]}\'s ' + f'wheel, so {version[:-1]} must be supported too (#1958)') if 'runner' in entry and not RUNNER.fullmatch(str(entry['runner'])): raise ValueError(f'{where}: runner {entry["runner"]!r} is not an ubuntu-YY.04 image') - rest = [version for version, entry in entries.items() if entry['tier'] != 'experimental'] - last = [version for version, entry in entries.items() if entry['tier'] == 'experimental'] + rest = [version for version in entries if not hand_kept(impl, version)] + last = [version for version in entries if hand_kept(impl, version)] if list(entries) != sorted(rest, key=key) + sorted(last, key=key): - raise ValueError(f'{impl}: list the versions oldest first, the experimental ones after the rest') - if data.get('latest') not in tier(data, 'cpython', 'supported'): - raise ValueError(f'latest {data.get("latest")!r} is not a supported CPython version') + raise ValueError(f'{impl}: list the versions oldest first, the hand-kept ones ' + '(free-threaded CPython, GraalPy) after the rest') + if data.get('latest') not in builds(data, 'cpython', 'supported'): + raise ValueError(f'latest {data.get("latest")!r} is not a supported CPython version, ' + 'free-threaded builds aside') for index, exemption in enumerate(data.get('exemptions', [])): fields = ('workflow', 'job', 'where', 'value') if (not isinstance(exemption, dict) or set(exemption) != set(fields) @@ -179,20 +209,32 @@ def tier(data: 'dict[str, Any]', impl: 'str', name: 'str') -> 'list[str]': return [version for version, entry in data['versions'].get(impl, {}).items() if entry['tier'] == name] +def builds(data: 'dict[str, Any]', impl: 'str', name: 'str') -> 'list[str]': + """:func:`tier` less the free-threaded builds: the versions with a wheel and a conda build of their own. + + A free-threaded build installs its default build's pure-Python wheel, which + :file:`util/wheel_rename.py` would tag ``cp3N`` for either, so it gets no + ``pypi`` or ``conda`` leg; nor an ``engine-tests`` one, as PyShark needs the + ``lxml`` its install leaves out (#1958). + + """ + return [version for version in tier(data, impl, name) if not free_threaded(impl, version)] + + def runner(data: 'dict[str, Any]', impl: 'str', version: 'str') -> 'Optional[str]': """The image ``version`` needs, or :data:`None` for ``ubuntu-latest``.""" return data['versions'][impl][version].get('runner') def on_latest(data: 'dict[str, Any]', impl: 'str', name: 'str', job: 'str') -> 'list[str]': - """Tier ``name`` of ``impl`` as a job pinned to ``ubuntu-latest`` can run it. + """Tier ``name`` of ``impl`` as a job pinned to ``ubuntu-latest`` can run it, free-threaded builds aside. A KTLO version needing an older image is left out. A supported one cannot be, so that raises: ``job`` would silently stop testing it. """ found = [] - for version in tier(data, impl, name): + for version in builds(data, impl, name): if runner(data, impl, version) is None: found.append(version) elif name == 'supported': @@ -246,7 +288,7 @@ def ktlo_matrix(data: 'dict[str, Any]') -> 'dict[str, Any]': def experimental_legs(data: 'dict[str, Any]') -> 'list[dict[str, Any]]': - """Each experimental version, CPython's then GraalPy's, as a leg of its own (#1660).""" + """Each experimental version, as a leg of its own (#1660): free-threaded CPython's, if any, then GraalPy's.""" found = [] for impl in SPELLING: for version in tier(data, impl, 'experimental'): @@ -294,7 +336,7 @@ def parity_ktlo_matrix(data: 'dict[str, Any]') -> 'dict[str, Any]': def pypi_matrix(data: 'dict[str, Any]') -> 'dict[str, Any]': - """create-release.yml's ``pypi``: one wheel per CPython and PyPy version, both tiers, none experimental.""" + """create-release.yml's ``pypi``: one wheel per CPython and PyPy version, both tiers, none experimental or free-threaded.""" impls = tuple(WHEEL_PREFIX) return {'python-version': [v for impl in impls for v in on_latest(data, impl, 'supported', 'pypi')], 'include': [{'python-version': v, 'ktlo': True} @@ -319,11 +361,11 @@ def wheel_tag(version: 'str') -> 'str': 'pypcap-parity': parity_matrix, 'pypcap-parity-ktlo': parity_ktlo_matrix, 'pypi': pypi_matrix, - 'conda': lambda data: tier(data, 'cpython', 'supported'), + 'conda': lambda data: builds(data, 'cpython', 'supported'), 'engine-supported': lambda data: '\n'.join(on_latest(data, 'cpython', 'supported', 'engine-tests')), 'engine-ktlo': lambda data: '\n'.join(on_latest(data, 'cpython', 'ktlo', 'engine-tests')), 'pypi-wheels': lambda data: ' '.join(map(wheel_tag, pypi_matrix(data)['python-version'])), - 'conda-builds': lambda data: ' '.join('py' + ''.join(map(str, key(v))) for v in tier(data, 'cpython', 'supported')), + 'conda-builds': lambda data: ' '.join('py' + ''.join(map(str, key(v))) for v in builds(data, 'cpython', 'supported')), } # type: dict[str, Callable[[dict[str, Any]], Any]] diff --git a/util/update_python_versions.py b/util/update_python_versions.py index 7212aafad7..a33f3283bb 100644 --- a/util/update_python_versions.py +++ b/util/update_python_versions.py @@ -40,9 +40,10 @@ ``feature`` or ``prerelease`` once setup-python has a build. No workflow runs pre-release legs yet, and :func:`python_versions.load` refuses the tier, so such a version is reported (:data:`HELD`) rather than written. -experimental +hand-kept Free-threaded CPython and GraalPy, which no source here rates (#1660), are - hand-maintained: every recompute keeps their entries as the file has them, + hand-maintained, whatever their tier (experimental, or supported for 3.14t + since #1958): every recompute keeps their entries as the file has them, after the computed versions of their table, and :func:`fingerprint` leaves them out, so a hand edit to one neither changes nor re-proposes the tiers. @@ -859,8 +860,8 @@ def validate(rendered: 'str') -> 'dict[str, Any]': def computed(versions: 'dict[str, dict[str, Any]]') -> 'dict[str, dict[str, Any]]': - """``versions`` without the experimental entries, which no rule here computes, and the tables left empty.""" - found = {impl: {version: entry for version, entry in entries.items() if entry['tier'] != 'experimental'} + """``versions`` without the hand-kept entries, which no rule here computes, and the tables left empty.""" + found = {impl: {version: entry for version, entry in entries.items() if not pv.hand_kept(impl, version)} for impl, entries in versions.items()} return {impl: entries for impl, entries in found.items() if entries} @@ -973,7 +974,7 @@ def lines_of(facts: 'dict[str, Any]', tier: 'str') -> 'Optional[tuple[int, int]] Claim(r'The \*\*supported\*\* tier, (3\.\d+)-(3\.\d+),', lambda f: span(f['supported'])), Claim(r'end-of-life (3\.\d+)-(3\.\d+), still built', lambda f: span(f['ktlo'])), Claim(r'the required legs are its supported tier \(``python-versions\.toml:(\d+)-(\d+)``\)', - lambda f: lines_of(f, 'supported')), + lambda f: lines_of(f, 'compat')), Claim(r'its KTLO legs \(``python-versions\.toml:(\d+)-(\d+)``\)', lambda f: lines_of(f, 'ktlo')), Claim(r'and the ``Compat Python (3\.\d+)``-``(3\.\d+)`` legs', lambda f: span(f['supported'])), Claim(r'the ``Python (3\.\d+)`` leg of ``test`` \(matrix key ``coverage: true``\)', @@ -997,7 +998,7 @@ def lines_of(facts: 'dict[str, Any]', tier: 'str') -> 'Optional[tuple[int, int]] #: ``"Programming Language :: Python :: 3.N",``, one line per supported version. CLASSIFIER = re.compile(r'^(?P[ \t]*)"Programming Language :: Python :: 3\.\d+",$', re.M) #: One row of the workflows page's required-contexts table. -COMPAT_ROW = re.compile(r'^(?P[ \t]*)\* - ``Compat Python (?P3\.\d+)``\n' +COMPAT_ROW = re.compile(r'^(?P[ \t]*)\* - ``Compat Python (?P3\.\d+t?)``\n' r'(?P[ \t]*)- job ``compatibility``, matrix leg ``(?P=version)``\n' r'(?P=inner)- (?P``[^`\n]+``), ``\.github/python-versions\.toml:\d+``\n', re.M) BUG_VERSIONS = re.compile(r'(Python Version: \[e\.g\. )([^\]\n]+)(\])') @@ -1026,7 +1027,7 @@ def rewrite_compat_rows(text: 'str', facts: 'dict[str, Any]') -> 'tuple[str, lis f'{first["inner"]}- job ``compatibility``, matrix leg ``{version}``\n' f'{first["inner"]}- {first["where"]}, ' f'``.github/python-versions.toml:{facts["tier_lines"][version]}``\n' - for version in facts['supported']) + for version in facts['compat']) return text[:first.start()] + block + text[matches[-1].end():], [] @@ -1061,7 +1062,11 @@ def make_facts(data: 'dict[str, Any]', values: 'dict[str, str]', workflows: 'dic tier_lines[match['version']] = number return { 'latest': data['latest'], - 'supported': pv.tier(data, 'cpython', 'supported'), + # The default builds, which the classifiers and the "3.11-3.15" spans + # name; `compat` adds a supported free-threaded build, which has a + # `Compat` leg and a tier line of its own (#1958). + 'supported': pv.builds(data, 'cpython', 'supported'), + 'compat': pv.tier(data, 'cpython', 'supported'), 'ktlo': pv.tier(data, 'cpython', 'ktlo'), 'experimental': [version for impl in data['versions'] for version in pv.tier(data, impl, 'experimental')], 'required': required_contexts(workflows, values), @@ -1133,11 +1138,11 @@ def compute_plan(root: 'pathlib.Path', sources: 'dict[str, Any]') -> 'tuple[dict notes.extend(result.notes) if result.latest is not None: latest = result.latest - # The experimental entries, kept as the file has them, after the computed ones + # The hand-kept entries, kept as the file has them, after the computed ones # (#1660); so is a table no rule computes, GraalPy's. for impl, entries in before['versions'].items(): versions.setdefault(impl, {}).update( - (version, entry) for version, entry in entries.items() if entry['tier'] == 'experimental') + (version, entry) for version, entry in entries.items() if pv.hand_kept(impl, version)) versions = {impl: versions[impl] for impl in before['versions']} after = dict(before, latest=latest, versions=versions) new_tier_text = render_tier_file(old_tier_text, latest, versions)