|
| 1 | +# Tier 1 — Free-threaded Python 3.13 wheels (design spec) |
| 2 | + |
| 3 | +**Status:** Draft — awaiting user review |
| 4 | +**Date:** 2026-04-18 |
| 5 | +**Scope:** Ship-only (option A). Build and ship `cp313t` (PEP 703 free-threaded) wheels plus an experimental, non-blocking CI job. No code audit for shared-mutable-state races in this tier. |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## Goal |
| 10 | + |
| 11 | +Give HawkAPI users installing on CPython 3.13 free-threaded (`python3.13t`) a wheel that installs and imports cleanly, with CI visibility that catches regressions but does not block mainline merges. |
| 12 | + |
| 13 | +Correctness under no-GIL is best-effort at this tier. The `src/hawkapi/_threading.py` helpers already exist for future per-module audits; actually applying them to every shared-state site is deferred. |
| 14 | + |
| 15 | +## Architecture |
| 16 | + |
| 17 | +Two interpreter ABIs ship from one codebase: |
| 18 | + |
| 19 | +| ABI tag | Build variant | Mypyc? | Status | |
| 20 | +|-----------|----------------------|--------------------|-------------------------| |
| 21 | +| `cp312` | GIL, CPython 3.12 | Yes | Stable (unchanged) | |
| 22 | +| `cp313` | GIL, CPython 3.13 | Yes | Stable (unchanged) | |
| 23 | +| `cp313t` | Free-threaded 3.13 | **No** (auto-skip) | **Experimental (new)** | |
| 24 | + |
| 25 | +The "should mypyc compile?" decision moves from cibuildwheel env branching into `build_mypyc.is_enabled()`. When the build-time interpreter is free-threaded (`sys._is_gil_enabled() is False`), `is_enabled()` returns `False` even if `HAWKAPI_BUILD_MYPYC=1`, and the hatch hook becomes a no-op. This keeps the CI config declarative — one env var for all builds, the build script self-selects. |
| 26 | + |
| 27 | +Rationale for skipping mypyc on `cp313t`: mypyc-compiled extensions historically require the GIL. Rather than discover this mid-release, we opt out deterministically and document it. If upstream mypyc ships free-threaded support later, flipping the guard is a one-line change. |
| 28 | + |
| 29 | +## Changes |
| 30 | + |
| 31 | +### 1. `build_mypyc.py` — free-threaded auto-skip |
| 32 | + |
| 33 | +Modify `is_enabled()` to additionally check the running interpreter: |
| 34 | + |
| 35 | +```python |
| 36 | +def is_enabled() -> bool: |
| 37 | + if os.environ.get("HAWKAPI_BUILD_MYPYC", "").strip().lower() not in {"1", "true", "yes", "on"}: |
| 38 | + return False |
| 39 | + # mypyc-compiled extensions require the GIL; skip on free-threaded builds. |
| 40 | + is_gil_enabled = getattr(sys, "_is_gil_enabled", None) |
| 41 | + if is_gil_enabled is not None and not is_gil_enabled(): |
| 42 | + print( |
| 43 | + "HAWKAPI_BUILD_MYPYC is set but the interpreter is free-threaded; " |
| 44 | + "skipping mypyc compilation.", |
| 45 | + file=sys.stderr, |
| 46 | + ) |
| 47 | + return False |
| 48 | + return True |
| 49 | +``` |
| 50 | + |
| 51 | +This is the **load-bearing change**. Every other CI/config edit flows from it: if this works, cibuildwheel needs no per-ABI branching. |
| 52 | + |
| 53 | +### 2. `.github/workflows/wheels.yml` — add `cp313t` to the build matrix |
| 54 | + |
| 55 | +- Extend `CIBW_BUILD` from `"cp312-* cp313-*"` to `"cp312-* cp313-* cp313t-*"`. |
| 56 | +- `CIBW_SKIP` already excludes cp36–cp311, pp*, musllinux; `cp313t` is not in the skip list, so nothing to change there. |
| 57 | +- `CIBW_ENVIRONMENT` stays `HAWKAPI_BUILD_MYPYC=1`. The build script now self-selects (per change #1). |
| 58 | +- `CIBW_TEST_COMMAND` stays the same; the unit suite runs on `cp313t` as a smoke test. |
| 59 | +- Free-threaded builds on Windows and macOS are supported by cibuildwheel ≥ 2.21; the current pin (`v2.21.3`) is sufficient. |
| 60 | + |
| 61 | +Expected artefact count increases from 5 to ~10 (5 matrix rows × 2 ABIs, minus any platform that cibuildwheel cannot provide a `cp313t` interpreter for — notably, Linux aarch64 cross-builds may take longer in QEMU). |
| 62 | + |
| 63 | +### 3. `.github/workflows/ci.yml` — new experimental job |
| 64 | + |
| 65 | +Add a job named `test-free-threaded`: |
| 66 | + |
| 67 | +```yaml |
| 68 | +test-free-threaded: |
| 69 | + name: Test (Python 3.13 free-threaded, experimental) |
| 70 | + runs-on: ubuntu-latest |
| 71 | + continue-on-error: true |
| 72 | + steps: |
| 73 | + - uses: actions/checkout@v4 |
| 74 | + - name: Install uv |
| 75 | + uses: astral-sh/setup-uv@v4 |
| 76 | + with: |
| 77 | + enable-cache: true |
| 78 | + - name: Set up Python 3.13t |
| 79 | + # uv supports free-threaded CPython via the 't' suffix. |
| 80 | + run: uv python install 3.13t |
| 81 | + - name: Install dependencies |
| 82 | + # Use the 3.13t interpreter explicitly. |
| 83 | + run: uv sync --python 3.13t --extra dev --extra pydantic |
| 84 | + - name: Run unit tests |
| 85 | + run: uv run --python 3.13t pytest tests/unit -x --tb=short -q |
| 86 | +``` |
| 87 | +
|
| 88 | +Key decisions: |
| 89 | +
|
| 90 | +- **Not added to the main `test` matrix.** Keeping it separate prevents the main matrix from flapping when a 3.13t-specific bug surfaces. |
| 91 | +- **`continue-on-error: true`** — job status shown on PR checks but does not block merges. Matches "experimental" framing. |
| 92 | +- **Unit tests only** — integration, perf, and memory suites are skipped to keep the job fast. When we graduate to Tier 1-B (full audit), we expand coverage. |
| 93 | +- **Fallback if `uv python install 3.13t` fails:** replace with `actions/setup-python@v5` using a `3.13` free-threaded variant. We will use uv by default; if CI reports "3.13t not found", we switch in a follow-up. |
| 94 | + |
| 95 | +### 4. `pyproject.toml` — trove classifier |
| 96 | + |
| 97 | +Add the PEP 779 trove classifier: |
| 98 | + |
| 99 | +```toml |
| 100 | +"Programming Language :: Python :: Free Threading :: 1 - Unstable", |
| 101 | +``` |
| 102 | + |
| 103 | +Positioned after the existing `Programming Language :: Python :: 3.13` entry. `requires-python = ">=3.12"` stays unchanged — free-threaded is a build variant, not a version. |
| 104 | + |
| 105 | +**Verification:** Before merging, confirm the classifier is live on PyPI's classifier index. If PyPI rejects it at upload time, fall back to a comment in the README and remove the classifier until PyPI catches up. |
| 106 | + |
| 107 | +### 5. `docs/guide/free-threaded.md` — new user-facing guide |
| 108 | + |
| 109 | +New page covering: |
| 110 | + |
| 111 | +- **Installation:** `pip install hawkapi` under `python3.13t` ships pure-Python (no mypyc) and works out of the box. |
| 112 | +- **Status:** Experimental. The `_threading` module already provides `FREE_THREADED`, `maybe_thread_lock()`, and `maybe_async_lock()` primitives, but internal modules have not been audited for shared mutable state yet. |
| 113 | +- **Known limitations:** |
| 114 | + - No mypyc perf boost on `cp313t` (upstream-blocked). |
| 115 | + - Routing/middleware caches may race under parallel thread-pool executors. Report observed issues; a hardening pass is planned. |
| 116 | +- **How to report bugs:** link to GitHub issues with a "free-threaded" label template. |
| 117 | +- **Roadmap pointer:** mention that full audit + required CI is tracked as a follow-up (Tier 1-B). |
| 118 | + |
| 119 | +### 6. `docs/index.md` — nav link |
| 120 | + |
| 121 | +Add a link to `guide/free-threaded.md` under the "Guide" section, ordered after `performance.md`. |
| 122 | + |
| 123 | +### 7. `tests/unit/test_threading.py` (optional smoke test) |
| 124 | + |
| 125 | +Add a test verifying `FREE_THREADED` matches `sys._is_gil_enabled()` (only runs where the attribute exists) and the null-lock helpers behave correctly under both branches. Low priority — the helpers are already covered implicitly, but an explicit test makes the free-threaded CI job meaningful even without a real audit. |
| 126 | + |
| 127 | +## Success criteria |
| 128 | + |
| 129 | +1. `cibuildwheel` produces `hawkapi-*-cp313t-cp313t-*.whl` on every OS/arch in the build matrix (Linux x86_64/aarch64, macOS x86_64/arm64, Windows AMD64). |
| 130 | +2. `pip install hawkapi` on a `python3.13t` interpreter installs a pure-Python wheel and `import hawkapi` succeeds. |
| 131 | +3. `pytest tests/unit` passes under `python3.13t` locally and in CI. |
| 132 | +4. The `test-free-threaded` job appears on every PR, passes on a clean main, and is visibly marked "experimental / non-blocking". |
| 133 | +5. PyPI accepts the release with the new trove classifier, OR the classifier is cleanly removed without other breakage. |
| 134 | + |
| 135 | +## Out of scope (explicit) |
| 136 | + |
| 137 | +- No audit of shared mutable state (route caches, DI singletons, middleware counters, OpenAPI schema cache, etc.). That audit is Tier 1-B. |
| 138 | +- No mypyc-compiled `cp313t` wheels. Reconsider when upstream mypyc ships PEP 703 support. |
| 139 | +- No promotion of `3.13t` to a required CI gate. |
| 140 | +- No change to `requires-python` or minimum supported Python version. |
| 141 | + |
| 142 | +## Risks and mitigations |
| 143 | + |
| 144 | +| Risk | Mitigation | |
| 145 | +|---------------------------------------------------------------|----------------------------------------------------------------------------| |
| 146 | +| `uv python install 3.13t` not supported on current uv version | Fallback: `actions/setup-python@v5` with free-threaded Python | |
| 147 | +| Trove classifier rejected by PyPI | Remove classifier, merge without it, revisit when PyPI catches up | |
| 148 | +| Unit test suite hits a GIL-dependent race under `cp313t` | `continue-on-error: true` absorbs the noise; follow-up issue documents fix | |
| 149 | +| cibuildwheel cannot resolve a `cp313t` interpreter on Windows | Expected supported in v2.21+; if not, narrow `CIBW_BUILD` per-OS | |
| 150 | +| Build time for `cp313t` wheels doubles the matrix runtime | Accept; artefact upload and cache limit remain within budget | |
| 151 | + |
| 152 | +## Testing approach |
| 153 | + |
| 154 | +- **Local reproduction:** install `python3.13t` via `uv python install 3.13t`, run `pytest tests/unit`. |
| 155 | +- **CI:** the new `test-free-threaded` job catches import-time and obvious runtime races on every PR. |
| 156 | +- **Release verification:** after a release, manually verify `pip install hawkapi` on a `3.13t` interpreter installs a file named `hawkapi-*-cp313t-cp313t-*.whl`, not the `cp313` wheel. |
| 157 | + |
| 158 | +## Files touched |
| 159 | + |
| 160 | +- `build_mypyc.py` — `is_enabled()` gains a free-threaded check. |
| 161 | +- `.github/workflows/wheels.yml` — `CIBW_BUILD` extended with `cp313t-*`. |
| 162 | +- `.github/workflows/ci.yml` — new `test-free-threaded` job. |
| 163 | +- `pyproject.toml` — new trove classifier. |
| 164 | +- `docs/guide/free-threaded.md` — new user guide. |
| 165 | +- `docs/index.md` — new nav entry. |
| 166 | +- `tests/unit/test_threading.py` — optional smoke test. |
| 167 | + |
| 168 | +## Rollback |
| 169 | + |
| 170 | +If the release produces broken `cp313t` wheels or breaks the main matrix: |
| 171 | + |
| 172 | +1. Revert the commit adding `cp313t-*` to `CIBW_BUILD`. One-line change, safe. |
| 173 | +2. Keep the `test-free-threaded` CI job — it's non-blocking and provides ongoing signal. |
| 174 | +3. File an issue describing the specific failure mode; Tier 1-B planning picks up from there. |
0 commit comments