bindings/python: free-threaded Python (3.14t) support (#2041)
* bindings/python: free-threaded Python (3.14t) support
Adds dedicated 3.14t support to the python bindings without breaking
the regular CPython API surface.
Key changes:
- Wrap PyTokenizer's inner Tokenizer in std::sync::RwLock<Tokenizer>.
Setters take &self + write guard; readers take a read guard. This
removes the per-pyclass `&mut self` borrow check that races under
free-threaded Python (`RuntimeError: Already borrowed`) and replaces
it with a stdlib RwLock that admits concurrent encode operations
while serializing mutations.
- Each #[pymodule] is now declared as
`#[cfg_attr(Py_GIL_DISABLED, pymodule(gil_used = false))]` /
`#[cfg_attr(not(Py_GIL_DISABLED), pymodule)]`. 3.14t builds opt
into Py_MOD_GIL_NOT_USED so importing tokenizers does not re-enable
the GIL; regular CPython behaviour is unchanged.
- Add bindings/python/build.rs calling pyo3_build_config::use_pyo3_cfgs().
PyO3 detects free-threaded Python and emits Py_GIL_DISABLED on its
own crate, but cargo's rustc-cfg directives don't propagate to
dependents — use_pyo3_cfgs re-emits them so our cfg_attr fires.
- Promote `abi3` from a hardcoded pyo3 dep-feature to a project-level
cargo feature (default on). Allows building without abi3 on
free-threaded Python (`maturin develop --no-default-features
--features ext-module`) — abi3 / limited API is not available
under free-threading.
- Add bindings/python/docs/free-threading-audit.md walking through
every mutation surface (single-field setter, top-level swap,
compound mutation, sequence components, trainer-during-train,
encode hot path) with verdicts and audit-trail references.
- Add bindings/python/tests/test_freethreaded.py: stress tests racing
N encoders against M setters on the same Tokenizer. All pass on
3.14t (4/4) and regular 3.14 (3 pass + 1 skip for the 3.14t-specific
GIL check).
- Update README and __init__.py docstring describing the 3.14t
behaviour and the documented compound-mutation caveat
(`tokenizer.post_processor.special_tokens = X` is two Python steps,
not atomic — same class as `dict[k]=v` racing `dict.clear()`).
Building 3.14t wheels:
maturin develop --release --no-default-features --features ext-module
Regular CPython wheels are unchanged — keep `default-features = true`.
* Delete bindings/python/docs/free-threading-audit.md
* bindings/python: declare gil_used = false unconditionally
Drop the cfg_attr(Py_GIL_DISABLED, …) gating on every #[pymodule].
PyO3 0.28's `pymodule(gil_used = false)` emits the Py_mod_gil slot
only when the target Python recognizes it (3.13+); on older Python
versions the slot is simply not emitted. So always declaring
`gil_used = false` is a no-op on 3.10–3.12, the right thing on 3.13,
and the load-bearing thing on 3.14t.
Verified by building a single abi3 wheel and importing it on stock
CPython 3.10 / 3.11 / 3.12 / 3.13 (all clean: import + setter work)
and re-running the 3.14t stress suite (still 4/4 passing, GIL stays
off as before).
* bindings/python: cargo fmt + ruff format
Mechanical: run `cargo fmt` over tokenizer.rs (the .read().unwrap() /
.write().unwrap() chains it produced were too long for one line) and
`ruff format` over test_freethreaded.py.
No behavioural change. 3.14t stress suite still 4/4 passing; abi3
wheel still imports cleanly on 3.10–3.13.
The .pyi stub regeneration from `make style` is intentionally NOT
included — the current pipeline emits stubs without docstrings (the
`stub.py` enrichment step the README documents isn't actually wired
up), so re-running it on this branch would shrink every .pyi by ~80%
and lose all the inline doc text. Pre-existing issue, separate PR.
* bindings/python: regenerate .pyi stubs
Output of `cargo run --manifest-path ./tools/stub-gen/Cargo.toml`
against the current FT-aware build.
* bindings/python: realign pyo3 version with stub-gen patch + sanity check
Two related changes that fix the silent docstring-stripping in the
stub generation pipeline.
1. bindings/python/Cargo.toml: pin pyo3 back to 0.28.2 (was 0.28.3).
The Makefile's `make style` injects a `[patch.crates-io]` block in
`.cargo/config.toml` pointing pyo3 at git rev 2ba9cda5 (which is
pyo3 0.28.2 with the introspection metadata pyo3-introspection
needs to read docstrings out of the cdylib). Cargo only honours a
patch when the requested version matches, so requiring 0.28.3 in
our deps caused cargo to silently ignore the patch — the cdylib
then built against vanilla 0.28.3 from crates.io, with no
docstring metadata for pyo3-introspection to find.
2. tools/stub-gen/src/main.rs: walk the introspected module and
abort if no docstrings are present anywhere. The previous
behaviour was to write out 7 docstring-less stubs and exit
successfully, which only got noticed when the .pyi diff in a
PR was -2800 lines. The new check fails loudly with a pointer
at `[patch.crates-io]` drift, which is the root cause when this
regresses.
3. py_src/tokenizers/*.pyi: regenerated against the patched build,
so the docstring contents are back in.
* ci(python): wire CI to build correctly for 3.14t
The 3.14t job in `python.yml` was hitting `SystemError: init function
of tokenizers returned uninitialized object` because the install step
ran `pip install -e .[dev]`, which goes through maturin's PEP 660
editable path and keeps the `abi3` cargo feature on regardless of the
target interpreter. Free-threaded Python can't load an abi3 extension
(no limited API), so the resulting .so failed to initialize.
Fix the install + test steps to detect free-threading and switch
build/test behavior:
- Install: use `maturin develop --release` directly. On a GIL-enabled
interpreter, defaults are fine (abi3 on). On free-threaded, pass
`--no-default-features --features ext-module` so the abi3 cargo
feature is dropped and the resulting wheel is `cp314t`-tagged
rather than abi3-tagged.
- Run tests: `make test` runs `cargo test --no-default-features`
which uses pyo3's `auto-initialize` and links libpython. Free-
threaded Python on the macOS runner doesn't ship libpython3.14t
in the framework path, so on 3.14t we run only `make test-py` and
skip the cargo half.
- Makefile: split `test` into `test-py` (just pytest) and `test-rs`
(cargo test); keep the original `test` target as `test-py + test-rs`
for parity. Lets CI pick the appropriate subset per interpreter
without duplicating the test command line.
Verified locally on 3.14t: 195 pytest items pass (4 new
test_freethreaded.py stress tests included). The 2 documentation-test
failures are a pre-existing truncated `tokenizer-wiki.json` fixture
issue, unrelated to this PR.
* ci(python): gate check-style to canonical combo (skip on 3.14t)
`make check-style` runs the stub-gen tool, which calls
`maturin develop --release` with the default cargo features (abi3 on)
and then imports the cdylib for introspection. abi3 extensions can't
load on free-threaded Python, so on 3.14t the import fails with the
familiar `SystemError: init function of tokenizers returned
uninitialized object`.
Style checks (rustfmt, ruff, ty, stub-gen) are matrix-invariant, so
gate to a single canonical combo (ubuntu-latest + 3.14) — avoids the
3.14t failure and also drops 3 redundant runs from the matrix.
* bindings/python: pin pyo3* exactly to 0.28.2 to make stub-gen patch apply
Even with the dependency requirement at "0.28.2" (i.e. ^0.28.2),
cargo's resolver picks the highest matching version on crates.io —
0.28.3 — and the patched git source at rev 2ba9cda5 has manifest
version 0.28.2, so the patch's source-version pair doesn't match the
resolved 0.28.3 and cargo emits:
warning: patch `pyo3 v0.28.2 (...)` was not used in the crate graph
The Makefile's `cargo update` doesn't downgrade — it only refreshes
within the existing requirement. Pinning exactly (`=0.28.2`) forces
the resolver to that version, which then matches the patch's source.
Switched the three pyo3 dep entries:
pyo3 0.28.2 -> =0.28.2
pyo3-ffi 0.28 -> =0.28.2
pyo3-build-config 0.28 -> =0.28.2 (build-dep)
Verified: `make style` now shows
Docstring coverage: 188/483 items carry a docstring
with no "patch was not used" warning, and the regenerated stubs are
docstring-rich. 3.14t stress suite still 4/4 passing.
* Delete bindings/python/build.rs
* bindings/python: Arc<RwLock<Tokenizer>> + propagate poison errors
Two follow-ups on the PyTokenizer locking work, per review.
1. Wrap the inner tokenizer in Arc<RwLock<…>> instead of just RwLock<…>.
Restores the cheap Clone semantics the pre-RwLock PyTokenizer had:
`clone()` is now a refcount bump rather than a deep copy of the
entire Tokenizer (model, normalizer, post-processor, etc.). Matches
how component wrappers elsewhere in the bindings already share
their inner state.
2. Stop unwrapping lock acquisitions; propagate errors instead. Add
`read_inner()` / `write_inner()` helpers that map a poisoned RwLock
to a `PyException` and return `PyResult<RwLock*Guard>`. Every call
site goes through them with `?`, including the one in decoders.rs
used by `step_decode_stream`.
Methods that previously returned a plain type and now use one of the
helpers were widened to `PyResult<T>` accordingly. PyO3 treats `T`
and `PyResult<T>` identically on the Python side, so there's no
public API change — just an explicit failure path for the (rare)
case of lock poisoning, instead of an opaque process panic.
Verified: 190 regular tests pass on CPython 3.14, 4/4 stress tests
pass on 3.14t. The Arc::clone is observable as a faster `t = clone(t)`
for any caller that does it. A
Arthur committed
decd8e07dad15f296c0adc2bc3a560f62d3de2eb
Parent: 3992692
Committed by GitHub <noreply@github.com>
on 4/27/2026, 9:20:51 AM