Catch a broken free-threading build before you publish it, not after your users file the bug.
Python 3.14 made free-threading officially supported. If you ship a C extension there are three ways to break it silently — and none of them fail your build:
- Your extension omits the
Py_mod_gilslot. CPython re-enables the GIL the moment someone imports your module on a free-threaded interpreter. Every 3.14t user loses parallelism; the only signal is a warning buried in stderr. - You ship wheels for regular CPython but no
cp314twheel. Free-threaded users fall back to an sdist build, or to the GIL build, with nothing telling you they got the slow path. - Your
Free Threading ::trove classifier says one thing and your wheels say another — you advertise support you didn't ship, or ship support nobody can discover on PyPI.
ft-readiness is a pre-publish gate for all three. Run it in CI after python -m build; it exits non-zero when your artifacts and your claims don't line up.
$ ft-readiness .
✗ FT001 [error] Declares 'Programming Language :: Python :: Free Threading :: 2 - Beta' but ships no free-threaded (cp3XXt) wheel for its compiled extension.
→ Build a cp3XXt wheel (cibuildwheel with CIBW_ENABLE=cpython-freethreading, or CPython 3.14t + your build backend), or drop the Free Threading classifier until you ship one.
! FT004 [warning] Ships abi3 (limited-API) wheels but no cp3XXt wheel. The stable ABI is unsupported on the free-threaded build, so abi3 wheels do NOT cover 3.14t users.
→ Build a separate free-threaded wheel; you can keep abi3 for the GIL build (e.g. py_limited_api=not sysconfig.get_config_var('Py_GIL_DISABLED')).
ft-readiness 0.1.0: 1 error(s), 1 warning(s).pip install ft-readinessPython 3.9+. Pure Python, one runtime dependency (packaging).
The static checks read your built dist/ and your pyproject.toml (or a wheel's METADATA). They run on any interpreter — you do not need a free-threaded build to run them.
| Code | Level | Fires when |
|---|---|---|
| FT001 | error | A Free Threading :: classifier is declared, the package has a compiled extension, but no cp3XXt wheel ships. You advertise support your artifacts don't deliver. |
| FT002 | warning | A cp3XXt wheel ships but no Free Threading :: classifier is declared. Support is real but undiscoverable on PyPI. |
| FT003 | warning | The package ships compiled-extension wheels but none for free-threaded Python. The silent missing-wheel case. |
| FT004 | warning | Only abi3 (limited-API) wheels ship, no cp3XXt. The stable ABI is unsupported on the free-threaded build, so abi3 does not cover 3.14t. |
| FT005 | warning | The package advertises free-threading but requires-python excludes the versions (3.13+) that have a free-threaded build. |
| FT006 | info | A pure-Python package claims 3 - Stable/4 - Resilient — a reminder to back the claim with thread-safety tests. |
The runtime check is the one that catches a re-enabled GIL, and it needs a free-threaded interpreter (it imports your module and watches the GIL state):
# on a python3.14t interpreter, in CI:
ft-readiness . --import mypkg._core --import mypkg._fastOn a non-free-threaded interpreter the --import checks are skipped with a note, so the same command is safe everywhere in your matrix.
If you already run a 3.14t leg in CI, wire the runtime check into pytest instead:
pytest --ft-import mypkg._core --ft-import mypkg._fastEach module is imported in a fresh subprocess and reported as its own test. On a non-free-threaded build the checks skip.
- uses: fernforge/ft-readiness@v1
with:
project: . # optional, default '.'
strict: 'false' # optional, treat warnings as errorsThe action installs ft-readiness and runs the static checks against your dist/. Run it after your build step. For the runtime GIL check, add a job on actions/setup-python with a 3.14t interpreter and call ft-readiness --import ... there.
The GIL re-enable prints a warning, so an attentive porter can catch it by reading stderr. The two failures with no signal at all are the missing cp314t wheel and the classifier that doesn't match the wheels — a user just gets the slow path and never tells you. ft-readiness turns all three into a build failure you see before the release goes out.
ft-readiness [PROJECT] [--dist DIR] [--import MODULE ...] [--strict] [--format text|json]
PROJECT— project directory (default.); itsdist/is inspected unless--distis given.--dist DIR— directory of built wheels/sdists.--import MODULE— runtime GIL check for a module (repeatable or comma-separated).--strict— exit 1 on warnings too.--format json— machine-readable output for CI dashboards.
Exit code is 1 if there are any errors (or any warnings under --strict), else 0.
MIT
