GitHub - fernforge/ft-readiness: Pre-publish free-threading readiness gate for Python: catch a re-enabled GIL, a missing cp314t wheel, and a stale Free Threading classifier before you ship. · GitHub
Skip to content

fernforge/ft-readiness

Repository files navigation

ft-readiness

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:

  1. Your extension omits the Py_mod_gil slot. 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.
  2. You ship wheels for regular CPython but no cp314t wheel. Free-threaded users fall back to an sdist build, or to the GIL build, with nothing telling you they got the slow path.
  3. 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).

Install

pip install ft-readiness

Python 3.9+. Pure Python, one runtime dependency (packaging).

What it checks

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):

Code Level Fires when
FT010 error Importing a module you named re-enabled the GIL — the Py_mod_gil slot is missing.
FT011 warning A module named for the runtime check couldn't be imported.
# on a python3.14t interpreter, in CI:
ft-readiness . --import mypkg._core --import mypkg._fast

On a non-free-threaded interpreter the --import checks are skipped with a note, so the same command is safe everywhere in your matrix.

pytest plugin

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._fast

Each module is imported in a fresh subprocess and reported as its own test. On a non-free-threaded build the checks skip.

GitHub Action

- uses: fernforge/ft-readiness@v1
  with:
    project: .        # optional, default '.'
    strict: 'false'   # optional, treat warnings as errors

The 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.

Why these three failures and not others

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.

Options

ft-readiness [PROJECT] [--dist DIR] [--import MODULE ...] [--strict] [--format text|json]
  • PROJECT — project directory (default .); its dist/ is inspected unless --dist is 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.

License

MIT

About

Pre-publish free-threading readiness gate for Python: catch a re-enabled GIL, a missing cp314t wheel, and a stale Free Threading classifier before you ship.

Topics

Resources

License

Stars

Watchers

Forks

Packages

Contributors

Languages