ft-readiness 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ """Author-side readiness gate for free-threaded Python (3.14t)."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .checks import Finding, run_static_checks # noqa: E402,F401
ft_readiness/checks.py ADDED
@@ -0,0 +1,183 @@
1
+ """Static free-threading readiness checks over a built dist/ + project metadata.
2
+
3
+ These run on any interpreter (no free-threaded build required). They catch the
4
+ two genuinely-silent author-side failures: shipping no cp3XXt wheel for an
5
+ extension package, and a Free Threading classifier that doesn't match the wheels
6
+ actually shipped. The runtime GIL-slot check lives in runtime.py / the pytest
7
+ plugin.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import List, Optional
15
+
16
+ from .metadata import ProjectMeta, resolve_meta
17
+ from .wheels import Wheel, collect_wheels, find_sdists
18
+
19
+ ERROR = "error"
20
+ WARNING = "warning"
21
+ INFO = "info"
22
+
23
+
24
+ @dataclass
25
+ class Finding:
26
+ code: str
27
+ level: str
28
+ message: str
29
+ hint: str = ""
30
+
31
+ def to_dict(self) -> dict:
32
+ return {
33
+ "code": self.code,
34
+ "level": self.level,
35
+ "message": self.message,
36
+ "hint": self.hint,
37
+ }
38
+
39
+
40
+ def _has_ext(wheels: List[Wheel]) -> bool:
41
+ return any(w.is_extension for w in wheels)
42
+
43
+
44
+ def run_static_checks(
45
+ project_dir: Path,
46
+ dist_dir: Path,
47
+ meta: Optional[ProjectMeta] = None,
48
+ ) -> List[Finding]:
49
+ findings: List[Finding] = []
50
+ wheels = collect_wheels(dist_dir)
51
+ sdists = find_sdists(dist_dir)
52
+ if meta is None:
53
+ meta = resolve_meta(project_dir, dist_dir)
54
+
55
+ if not wheels and not sdists and meta is None:
56
+ findings.append(
57
+ Finding(
58
+ "FT000",
59
+ ERROR,
60
+ f"Nothing to inspect: no wheels/sdists in {dist_dir} and no "
61
+ "pyproject.toml with a [project] table.",
62
+ "Run this after `python -m build` (or point --dist / --project "
63
+ "at your artifacts).",
64
+ )
65
+ )
66
+ return findings
67
+
68
+ ft_wheels = [w for w in wheels if w.is_freethreaded]
69
+ ext_wheels = [w for w in wheels if w.is_extension]
70
+ abi3_wheels = [w for w in wheels if w.is_abi3]
71
+ has_ft_wheel = bool(ft_wheels)
72
+ has_ext = bool(ext_wheels)
73
+
74
+ ft_classifier = meta.ft_classifier if meta else None
75
+ ft_level = meta.ft_level if meta else None
76
+
77
+ # FT001 — classifier claims free-threading, but no cp3XXt wheel ships for an
78
+ # extension package. Users on a free-threaded build get zero parallelism.
79
+ if ft_classifier and has_ext and not has_ft_wheel:
80
+ findings.append(
81
+ Finding(
82
+ "FT001",
83
+ ERROR,
84
+ f"Declares '{ft_classifier}' but ships no free-threaded "
85
+ "(cp3XXt) wheel for its compiled extension.",
86
+ "Build a cp3XXt wheel (cibuildwheel with CIBW_ENABLE=cpython-"
87
+ "freethreading, or CPython 3.14t + your build backend), or drop "
88
+ "the Free Threading classifier until you ship one.",
89
+ )
90
+ )
91
+
92
+ # FT002 — ships a free-threaded wheel but no classifier => FT support is
93
+ # undiscoverable on PyPI; users can't filter for it.
94
+ if has_ft_wheel and not ft_classifier:
95
+ findings.append(
96
+ Finding(
97
+ "FT002",
98
+ WARNING,
99
+ "Ships a free-threaded (cp3XXt) wheel but declares no "
100
+ "'Programming Language :: Python :: Free Threading' classifier.",
101
+ "Add e.g. 'Programming Language :: Python :: Free Threading :: "
102
+ "2 - Beta' to [project].classifiers so PyPI surfaces it.",
103
+ )
104
+ )
105
+
106
+ # FT003 — extension package with per-platform CPython wheels but no
107
+ # free-threaded wheel at all: the silent missing-wheel case (users fall back
108
+ # to an sdist build, or get the GIL if they can't build).
109
+ if has_ext and not has_ft_wheel and not ft_classifier:
110
+ findings.append(
111
+ Finding(
112
+ "FT003",
113
+ WARNING,
114
+ "Ships compiled extension wheels but none for free-threaded "
115
+ "Python (cp3XXt). Users on 3.14t fall back to an sdist build or "
116
+ "lose parallelism.",
117
+ "Add a cp3XXt target to your wheel matrix once the extension is "
118
+ "free-threading ready.",
119
+ )
120
+ )
121
+
122
+ # FT004 — abi3/limited-API wheels but no FT wheel. The limited C API / stable
123
+ # ABI is NOT supported on the free-threaded build, so abi3 wheels do not
124
+ # cover 3.14t users — a dedicated cp3XXt wheel is required.
125
+ if abi3_wheels and not has_ft_wheel:
126
+ findings.append(
127
+ Finding(
128
+ "FT004",
129
+ WARNING,
130
+ "Ships abi3 (limited-API) wheels but no cp3XXt wheel. The stable "
131
+ "ABI is unsupported on the free-threaded build, so abi3 wheels "
132
+ "do NOT cover 3.14t users.",
133
+ "Build a separate free-threaded wheel; you can keep abi3 for the "
134
+ "GIL build (e.g. py_limited_api=not sysconfig.get_config_var("
135
+ "'Py_GIL_DISABLED')).",
136
+ )
137
+ )
138
+
139
+ # FT005 — requires-python excludes the versions that have free-threading
140
+ # while the package advertises FT support.
141
+ if (ft_classifier or has_ft_wheel) and meta and meta.requires_python:
142
+ if _excludes_free_threading(meta.requires_python):
143
+ findings.append(
144
+ Finding(
145
+ "FT005",
146
+ WARNING,
147
+ f"Advertises free-threading but requires-python "
148
+ f"'{meta.requires_python}' excludes 3.13+/3.14 where the "
149
+ "free-threaded build exists.",
150
+ "Widen requires-python to include the interpreter versions "
151
+ "that ship a free-threaded build.",
152
+ )
153
+ )
154
+
155
+ # FT006 — pure-Python package declaring a Free Threading classifier is fine,
156
+ # but flag over-claiming Stable/Resilient with no test signal we can see.
157
+ if ft_classifier and not has_ext and ft_level and ft_level >= 3:
158
+ findings.append(
159
+ Finding(
160
+ "FT006",
161
+ INFO,
162
+ f"Pure-Python package declares '{ft_classifier}'. Pure Python "
163
+ "runs on the free-threaded build without a dedicated wheel; make "
164
+ "sure the Stable/Resilient claim is backed by thread-safety "
165
+ "tests.",
166
+ "Run your suite under 3.14t (pytest-run-parallel is useful) "
167
+ "before claiming level 3 - Stable or higher.",
168
+ )
169
+ )
170
+
171
+ return findings
172
+
173
+
174
+ def _excludes_free_threading(requires_python: str) -> bool:
175
+ """True if the requires-python specifier admits NO version >= 3.13."""
176
+ try:
177
+ from packaging.specifiers import SpecifierSet
178
+ from packaging.version import Version
179
+
180
+ spec = SpecifierSet(requires_python)
181
+ except Exception:
182
+ return False
183
+ return not any(spec.contains(Version(v), prereleases=True) for v in ("3.13", "3.14", "3.15"))
ft_readiness/cli.py ADDED
@@ -0,0 +1,125 @@
1
+ """Command-line entry point for ft-readiness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import List
10
+
11
+ from . import __version__
12
+ from .checks import ERROR, INFO, WARNING, Finding, run_static_checks
13
+ from .metadata import resolve_meta
14
+ from .runtime import check_imports, is_free_threaded
15
+
16
+ _LEVEL_ORDER = {ERROR: 0, WARNING: 1, INFO: 2}
17
+
18
+
19
+ def _build_parser() -> argparse.ArgumentParser:
20
+ p = argparse.ArgumentParser(
21
+ prog="ft-readiness",
22
+ description="Pre-publish free-threading readiness gate for Python "
23
+ "packages (catch a re-enabled GIL, a missing cp314t wheel, and a stale "
24
+ "Free Threading classifier before you publish).",
25
+ )
26
+ p.add_argument("project", nargs="?", default=".",
27
+ help="project directory (default: current dir)")
28
+ p.add_argument("--dist", default=None,
29
+ help="dist directory with built wheels/sdists (default: "
30
+ "<project>/dist)")
31
+ p.add_argument("--import", dest="modules", action="append", default=[],
32
+ metavar="MODULE",
33
+ help="runtime check: import MODULE on a free-threaded build "
34
+ "and fail if it re-enables the GIL (repeatable, or "
35
+ "comma-separated)")
36
+ p.add_argument("--strict", action="store_true",
37
+ help="treat warnings as failures (exit 1)")
38
+ p.add_argument("--format", choices=("text", "json"), default="text",
39
+ help="output format (default: text)")
40
+ p.add_argument("--version", action="version",
41
+ version=f"ft-readiness {__version__}")
42
+ return p
43
+
44
+
45
+ def _split_modules(items: List[str]) -> List[str]:
46
+ out: List[str] = []
47
+ for it in items:
48
+ out.extend(m.strip() for m in it.split(",") if m.strip())
49
+ return out
50
+
51
+
52
+ def main(argv: List[str] | None = None) -> int:
53
+ args = _build_parser().parse_args(argv)
54
+ project_dir = Path(args.project).resolve()
55
+ dist_dir = Path(args.dist).resolve() if args.dist else project_dir / "dist"
56
+
57
+ meta = resolve_meta(project_dir, dist_dir)
58
+ findings: List[Finding] = run_static_checks(project_dir, dist_dir, meta)
59
+
60
+ modules = _split_modules(args.modules)
61
+ runtime_results = check_imports(modules) if modules else []
62
+ for r in runtime_results:
63
+ if r.status == "gil_reenabled":
64
+ findings.append(Finding(
65
+ "FT010", ERROR,
66
+ f"Importing '{r.module}' re-enabled the GIL: {r.detail}",
67
+ "Add the Py_mod_gil = Py_MOD_GIL_NOT_USED slot (multi-phase "
68
+ "init) or call PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED) "
69
+ "(single-phase) in the C extension, then rebuild.",
70
+ ))
71
+ elif r.status == "import_error":
72
+ findings.append(Finding(
73
+ "FT011", WARNING,
74
+ f"Could not import '{r.module}' for the runtime GIL check: "
75
+ f"{r.detail}",
76
+ ))
77
+
78
+ findings.sort(key=lambda f: (_LEVEL_ORDER.get(f.level, 9), f.code))
79
+
80
+ if args.format == "json":
81
+ payload = {
82
+ "version": __version__,
83
+ "project": str(project_dir),
84
+ "dist": str(dist_dir),
85
+ "meta_source": meta.source if meta else None,
86
+ "free_threaded_interpreter": is_free_threaded(),
87
+ "runtime_checks": [
88
+ {"module": r.module, "status": r.status, "detail": r.detail}
89
+ for r in runtime_results
90
+ ],
91
+ "findings": [f.to_dict() for f in findings],
92
+ }
93
+ print(json.dumps(payload, indent=2))
94
+ else:
95
+ _print_text(findings, runtime_results, modules)
96
+
97
+ errors = [f for f in findings if f.level == ERROR]
98
+ warnings_ = [f for f in findings if f.level == WARNING]
99
+ if errors or (args.strict and warnings_):
100
+ return 1
101
+ return 0
102
+
103
+
104
+ def _print_text(findings, runtime_results, modules) -> None:
105
+ icon = {ERROR: "✗", WARNING: "!", INFO: "·"}
106
+ if not findings:
107
+ print("ft-readiness: no issues found.")
108
+ for f in findings:
109
+ print(f"{icon.get(f.level, '?')} {f.code} [{f.level}] {f.message}")
110
+ if f.hint:
111
+ print(f" → {f.hint}")
112
+ if modules and not is_free_threaded():
113
+ print()
114
+ print("note: runtime --import checks were SKIPPED — this interpreter is "
115
+ "not a free-threaded build.")
116
+ print(" run under python3.14t (or CIBW/tox with a t-ABI env) to "
117
+ "exercise them.")
118
+ n_err = sum(1 for f in findings if f.level == ERROR)
119
+ n_warn = sum(1 for f in findings if f.level == WARNING)
120
+ print()
121
+ print(f"ft-readiness {__version__}: {n_err} error(s), {n_warn} warning(s).")
122
+
123
+
124
+ if __name__ == "__main__": # pragma: no cover
125
+ sys.exit(main())
@@ -0,0 +1,96 @@
1
+ """Read project classifiers + requires-python from pyproject.toml, a wheel's
2
+ METADATA, or an sdist's PKG-INFO.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import email
8
+ import re
9
+ import zipfile
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import List, Optional
13
+
14
+ try: # 3.11+
15
+ import tomllib as _toml
16
+ except ModuleNotFoundError: # pragma: no cover - exercised on <3.11
17
+ try:
18
+ import tomli as _toml # type: ignore
19
+ except ModuleNotFoundError: # pragma: no cover
20
+ _toml = None # type: ignore
21
+
22
+ _FT_CLASSIFIER_RE = re.compile(
23
+ r"^Programming Language :: Python :: Free Threading :: (\d)\s*-\s*(.+)$"
24
+ )
25
+
26
+
27
+ @dataclass
28
+ class ProjectMeta:
29
+ source: str
30
+ classifiers: List[str] = field(default_factory=list)
31
+ requires_python: Optional[str] = None
32
+
33
+ @property
34
+ def ft_classifier(self) -> Optional[str]:
35
+ for c in self.classifiers:
36
+ if _FT_CLASSIFIER_RE.match(c.strip()):
37
+ return c.strip()
38
+ return None
39
+
40
+ @property
41
+ def ft_level(self) -> Optional[int]:
42
+ c = self.ft_classifier
43
+ if not c:
44
+ return None
45
+ m = _FT_CLASSIFIER_RE.match(c)
46
+ return int(m.group(1)) if m else None
47
+
48
+
49
+ def from_pyproject(path: Path) -> Optional[ProjectMeta]:
50
+ if not path.is_file() or _toml is None:
51
+ return None
52
+ with path.open("rb") as fh:
53
+ data = _toml.load(fh)
54
+ project = data.get("project")
55
+ if not isinstance(project, dict):
56
+ return None
57
+ return ProjectMeta(
58
+ source=str(path),
59
+ classifiers=list(project.get("classifiers", []) or []),
60
+ requires_python=project.get("requires-python"),
61
+ )
62
+
63
+
64
+ def _from_metadata_text(text: str, source: str) -> ProjectMeta:
65
+ msg = email.message_from_string(text)
66
+ return ProjectMeta(
67
+ source=source,
68
+ classifiers=[v for v in msg.get_all("Classifier", []) or []],
69
+ requires_python=msg.get("Requires-Python"),
70
+ )
71
+
72
+
73
+ def from_wheel(path: Path) -> Optional[ProjectMeta]:
74
+ try:
75
+ with zipfile.ZipFile(path) as zf:
76
+ names = [n for n in zf.namelist() if n.endswith(".dist-info/METADATA")]
77
+ if not names:
78
+ return None
79
+ text = zf.read(names[0]).decode("utf-8", "replace")
80
+ except (zipfile.BadZipFile, OSError):
81
+ return None
82
+ return _from_metadata_text(text, str(path))
83
+
84
+
85
+ def resolve_meta(project_dir: Path, dist_dir: Path) -> Optional[ProjectMeta]:
86
+ """Prefer pyproject.toml (the source of truth an author edits), then fall
87
+ back to a built wheel's METADATA."""
88
+ pp = from_pyproject(project_dir / "pyproject.toml")
89
+ if pp is not None:
90
+ return pp
91
+ if dist_dir.is_dir():
92
+ for whl in sorted(dist_dir.glob("*.whl")):
93
+ m = from_wheel(whl)
94
+ if m is not None:
95
+ return m
96
+ return None
@@ -0,0 +1,86 @@
1
+ """pytest plugin: assert your extension modules keep the GIL disabled on a
2
+ free-threaded build.
3
+
4
+ Usage (in CI, ideally on a 3.14t interpreter)::
5
+
6
+ pytest --ft-import mypkg._core --ft-import mypkg._fast
7
+
8
+ Each module is imported in a fresh subprocess; a module that re-enables the GIL
9
+ fails. On a non-free-threaded interpreter every check is skipped, so the same
10
+ command is safe to run across your whole CI matrix.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import pytest
16
+
17
+ from .runtime import check_import, is_free_threaded
18
+
19
+
20
+ def pytest_addoption(parser):
21
+ group = parser.getgroup("ft-readiness")
22
+ group.addoption(
23
+ "--ft-import",
24
+ action="append",
25
+ dest="ft_import",
26
+ default=[],
27
+ metavar="MODULE",
28
+ help="import MODULE on a free-threaded build and fail if it re-enables "
29
+ "the GIL (repeatable, or comma-separated)",
30
+ )
31
+
32
+
33
+ def _requested_modules(config):
34
+ mods = []
35
+ for it in config.getoption("ft_import") or []:
36
+ mods.extend(m.strip() for m in it.split(",") if m.strip())
37
+ return mods
38
+
39
+
40
+ class FTImportItem(pytest.Item):
41
+ def __init__(self, *, module, **kwargs):
42
+ super().__init__(**kwargs)
43
+ self.module = module
44
+
45
+ def runtest(self):
46
+ if not is_free_threaded():
47
+ pytest.skip("interpreter is not a free-threaded build")
48
+ result = check_import(self.module)
49
+ if result.status == "gil_reenabled":
50
+ raise FTGilReenabled(self.module, result.detail)
51
+ if result.status == "import_error":
52
+ raise AssertionError(
53
+ f"could not import {self.module!r} for the GIL check: "
54
+ f"{result.detail}"
55
+ )
56
+
57
+ def repr_failure(self, excinfo):
58
+ if isinstance(excinfo.value, FTGilReenabled):
59
+ return str(excinfo.value)
60
+ return super().repr_failure(excinfo)
61
+
62
+ def reportinfo(self):
63
+ return self.path, 0, f"ft-gil import check: {self.module}"
64
+
65
+
66
+ class FTGilReenabled(Exception):
67
+ def __init__(self, module, detail):
68
+ self.module = module
69
+ self.detail = detail
70
+ super().__init__(
71
+ f"Importing {module!r} re-enabled the GIL on a free-threaded build: "
72
+ f"{detail}\n"
73
+ "Add Py_mod_gil = Py_MOD_GIL_NOT_USED (multi-phase init) or call "
74
+ "PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED) (single-phase) in "
75
+ "the C extension, then rebuild."
76
+ )
77
+
78
+
79
+ def pytest_collection_modifyitems(session, config, items):
80
+ modules = _requested_modules(config)
81
+ for module in modules:
82
+ items.append(
83
+ FTImportItem.from_parent(
84
+ session, name=f"ft-gil::{module}", module=module
85
+ )
86
+ )
@@ -0,0 +1,102 @@
1
+ """Runtime check: import a module on a free-threaded interpreter and verify it
2
+ did NOT force the GIL back on.
3
+
4
+ An extension that omits the ``Py_mod_gil = Py_MOD_GIL_NOT_USED`` slot (or the
5
+ ``PyUnstable_Module_SetGIL`` call for single-phase init) makes CPython
6
+ re-enable the GIL at import and print a warning. Every free-threaded user then
7
+ silently loses parallelism. This can only be observed at runtime, on a
8
+ free-threaded build, so it is separate from the static wheel/classifier checks.
9
+
10
+ Each module is imported in a fresh subprocess so the GIL-state transition is
11
+ attributable to that one module (the GIL, once re-enabled, stays on for the
12
+ process).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import subprocess
19
+ import sys
20
+ import sysconfig
21
+ from dataclasses import dataclass
22
+ from typing import List, Optional
23
+
24
+
25
+ def is_free_threaded(executable: Optional[str] = None) -> bool:
26
+ """Whether the given interpreter (default: current) is a free-threaded build."""
27
+ if executable is None or executable == sys.executable:
28
+ return bool(sysconfig.get_config_var("Py_GIL_DISABLED"))
29
+ try:
30
+ out = subprocess.run(
31
+ [executable, "-c", "import sysconfig,sys;"
32
+ "sys.stdout.write('1' if sysconfig.get_config_var('Py_GIL_DISABLED') else '0')"],
33
+ capture_output=True, text=True, timeout=30,
34
+ )
35
+ return out.stdout.strip() == "1"
36
+ except (OSError, subprocess.SubprocessError):
37
+ return False
38
+
39
+
40
+ @dataclass
41
+ class ImportResult:
42
+ module: str
43
+ status: str # "ok" | "gil_reenabled" | "import_error" | "skipped"
44
+ detail: str = ""
45
+
46
+ @property
47
+ def ok(self) -> bool:
48
+ return self.status in ("ok", "skipped")
49
+
50
+
51
+ # Runs inside the target interpreter. Prints one JSON line.
52
+ _PROBE = r"""
53
+ import json, sys, warnings, importlib
54
+ mod = sys.argv[1]
55
+ before = sys._is_gil_enabled()
56
+ caught = []
57
+ try:
58
+ with warnings.catch_warnings(record=True) as w:
59
+ warnings.simplefilter("always")
60
+ importlib.import_module(mod)
61
+ caught = [str(x.message) for x in w]
62
+ except BaseException as e:
63
+ print(json.dumps({"status": "import_error", "detail": f"{type(e).__name__}: {e}"}))
64
+ sys.exit(0)
65
+ after = sys._is_gil_enabled()
66
+ gil_msg = next((m for m in caught if "GIL" in m or "global interpreter lock" in m), "")
67
+ if (after and not before) or gil_msg:
68
+ print(json.dumps({"status": "gil_reenabled",
69
+ "detail": gil_msg or "GIL was enabled during import"}))
70
+ else:
71
+ print(json.dumps({"status": "ok", "detail": ""}))
72
+ """
73
+
74
+
75
+ def check_import(module: str, executable: Optional[str] = None,
76
+ timeout: float = 60.0) -> ImportResult:
77
+ exe = executable or sys.executable
78
+ if not is_free_threaded(exe):
79
+ return ImportResult(module, "skipped",
80
+ "interpreter is not a free-threaded build")
81
+ try:
82
+ proc = subprocess.run(
83
+ [exe, "-c", _PROBE, module],
84
+ capture_output=True, text=True, timeout=timeout,
85
+ )
86
+ except subprocess.TimeoutExpired:
87
+ return ImportResult(module, "import_error", "import timed out")
88
+ line = (proc.stdout or "").strip().splitlines()
89
+ if not line:
90
+ return ImportResult(module, "import_error",
91
+ (proc.stderr or "no output from probe").strip()[:500])
92
+ try:
93
+ data = json.loads(line[-1])
94
+ except json.JSONDecodeError:
95
+ return ImportResult(module, "import_error", line[-1][:500])
96
+ return ImportResult(module, data.get("status", "import_error"),
97
+ data.get("detail", ""))
98
+
99
+
100
+ def check_imports(modules: List[str], executable: Optional[str] = None,
101
+ timeout: float = 60.0) -> List[ImportResult]:
102
+ return [check_import(m, executable, timeout) for m in modules]
ft_readiness/wheels.py ADDED
@@ -0,0 +1,98 @@
1
+ """Parse wheel/sdist filenames in a dist directory and classify their tags.
2
+
3
+ A wheel filename is:
4
+ {name}-{version}(-{build})?-{pytag}-{abitag}-{plattag}.whl
5
+ where each tag field may be a compressed set joined by '.'.
6
+ See https://packaging.python.org/en/latest/specifications/binary-distribution-format/
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import List, Set
15
+
16
+ # A free-threaded CPython ABI tag looks like cp314t, cp315t, ...
17
+ _FT_ABI_RE = re.compile(r"^cp\d{2,}t$")
18
+ # A versioned CPython ABI tag (non-FT) looks like cp313, cp314, cp39, ...
19
+ _CP_ABI_RE = re.compile(r"^cp\d{2,}$")
20
+
21
+
22
+ @dataclass
23
+ class Wheel:
24
+ filename: str
25
+ name: str
26
+ version: str
27
+ pytags: Set[str] = field(default_factory=set)
28
+ abitags: Set[str] = field(default_factory=set)
29
+ plattags: Set[str] = field(default_factory=set)
30
+
31
+ @property
32
+ def is_freethreaded(self) -> bool:
33
+ return any(_FT_ABI_RE.match(t) for t in self.abitags) or any(
34
+ _FT_ABI_RE.match(t) for t in self.pytags
35
+ )
36
+
37
+ @property
38
+ def is_abi3(self) -> bool:
39
+ return "abi3" in self.abitags
40
+
41
+ @property
42
+ def is_pure_python(self) -> bool:
43
+ # abi 'none' + platform 'any' => no compiled extension in this wheel
44
+ return self.abitags == {"none"} and self.plattags == {"any"}
45
+
46
+ @property
47
+ def is_extension(self) -> bool:
48
+ # Ships a compiled extension: has a real platform tag (not 'any')
49
+ return self.plattags != {"any"} and "any" not in self.plattags
50
+
51
+ @property
52
+ def cpython_versions(self) -> Set[str]:
53
+ """Extract the numeric CPython versions this wheel's tags name (e.g. {'314'})."""
54
+ out: Set[str] = set()
55
+ for t in list(self.abitags) + list(self.pytags):
56
+ m = re.match(r"^cp(\d{2,})t?$", t)
57
+ if m:
58
+ out.add(m.group(1))
59
+ return out
60
+
61
+
62
+ def parse_wheel_filename(filename: str) -> Wheel | None:
63
+ stem = filename[:-4] if filename.endswith(".whl") else filename
64
+ parts = stem.split("-")
65
+ if len(parts) < 5:
66
+ return None
67
+ # last three fields are the tag triple; name is parts[0], version parts[1],
68
+ # anything between version and the tag triple is the optional build tag.
69
+ plat = parts[-1]
70
+ abi = parts[-2]
71
+ py = parts[-3]
72
+ name = parts[0]
73
+ version = parts[1]
74
+ return Wheel(
75
+ filename=filename,
76
+ name=name,
77
+ version=version,
78
+ pytags=set(py.split(".")),
79
+ abitags=set(abi.split(".")),
80
+ plattags=set(plat.split(".")),
81
+ )
82
+
83
+
84
+ def collect_wheels(dist_dir: Path) -> List[Wheel]:
85
+ wheels: List[Wheel] = []
86
+ if not dist_dir.is_dir():
87
+ return wheels
88
+ for p in sorted(dist_dir.glob("*.whl")):
89
+ w = parse_wheel_filename(p.name)
90
+ if w is not None:
91
+ wheels.append(w)
92
+ return wheels
93
+
94
+
95
+ def find_sdists(dist_dir: Path) -> List[str]:
96
+ if not dist_dir.is_dir():
97
+ return []
98
+ return sorted(p.name for p in dist_dir.glob("*.tar.gz"))
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.4
2
+ Name: ft-readiness
3
+ Version: 0.1.0
4
+ Summary: Author-side pre-publish gate for free-threaded Python (3.14t): catch the silently re-enabled GIL, a missing cp314t wheel, and a stale Free Threading trove classifier before you ship.
5
+ Project-URL: Homepage, https://github.com/fernforge/ft-readiness
6
+ Project-URL: Source, https://github.com/fernforge/ft-readiness
7
+ Project-URL: Issues, https://github.com/fernforge/ft-readiness/issues
8
+ Author: fernforge
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ci,cp314t,free-threaded,free-threading,gil,linter,packaging,pytest,python-3.14,wheels
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Framework :: Pytest
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
25
+ Classifier: Topic :: Software Development :: Build Tools
26
+ Classifier: Topic :: Software Development :: Quality Assurance
27
+ Requires-Python: >=3.9
28
+ Requires-Dist: packaging>=21.0
29
+ Provides-Extra: tomli
30
+ Requires-Dist: tomli>=1.1.0; (python_version < '3.11') and extra == 'tomli'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # ft-readiness
34
+
35
+ Catch a broken free-threading build before you publish it, not after your users file the bug.
36
+
37
+ 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:
38
+
39
+ 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.
40
+ 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.
41
+ 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.
42
+
43
+ `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.
44
+
45
+ ```console
46
+ $ ft-readiness .
47
+ ✗ FT001 [error] Declares 'Programming Language :: Python :: Free Threading :: 2 - Beta' but ships no free-threaded (cp3XXt) wheel for its compiled extension.
48
+ → 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.
49
+ ! 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.
50
+ → 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')).
51
+
52
+ ft-readiness 0.1.0: 1 error(s), 1 warning(s).
53
+ ```
54
+
55
+ ## Install
56
+
57
+ ```console
58
+ pip install ft-readiness
59
+ ```
60
+
61
+ Python 3.9+. Pure Python, one runtime dependency (`packaging`).
62
+
63
+ ## What it checks
64
+
65
+ 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.
66
+
67
+ | Code | Level | Fires when |
68
+ |------|-------|-----------|
69
+ | 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. |
70
+ | FT002 | warning | A `cp3XXt` wheel ships but no `Free Threading ::` classifier is declared. Support is real but undiscoverable on PyPI. |
71
+ | FT003 | warning | The package ships compiled-extension wheels but none for free-threaded Python. The silent missing-wheel case. |
72
+ | 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. |
73
+ | FT005 | warning | The package advertises free-threading but `requires-python` excludes the versions (3.13+) that have a free-threaded build. |
74
+ | FT006 | info | A pure-Python package claims `3 - Stable`/`4 - Resilient` — a reminder to back the claim with thread-safety tests. |
75
+
76
+ 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):
77
+
78
+ | Code | Level | Fires when |
79
+ |------|-------|-----------|
80
+ | FT010 | error | Importing a module you named re-enabled the GIL — the `Py_mod_gil` slot is missing. |
81
+ | FT011 | warning | A module named for the runtime check couldn't be imported. |
82
+
83
+ ```console
84
+ # on a python3.14t interpreter, in CI:
85
+ ft-readiness . --import mypkg._core --import mypkg._fast
86
+ ```
87
+
88
+ On a non-free-threaded interpreter the `--import` checks are skipped with a note, so the same command is safe everywhere in your matrix.
89
+
90
+ ## pytest plugin
91
+
92
+ If you already run a 3.14t leg in CI, wire the runtime check into pytest instead:
93
+
94
+ ```console
95
+ pytest --ft-import mypkg._core --ft-import mypkg._fast
96
+ ```
97
+
98
+ Each module is imported in a fresh subprocess and reported as its own test. On a non-free-threaded build the checks skip.
99
+
100
+ ## GitHub Action
101
+
102
+ ```yaml
103
+ - uses: fernforge/ft-readiness@v1
104
+ with:
105
+ project: . # optional, default '.'
106
+ strict: 'false' # optional, treat warnings as errors
107
+ ```
108
+
109
+ 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.
110
+
111
+ ## Why these three failures and not others
112
+
113
+ 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.
114
+
115
+ ## Options
116
+
117
+ ```
118
+ ft-readiness [PROJECT] [--dist DIR] [--import MODULE ...] [--strict] [--format text|json]
119
+ ```
120
+
121
+ - `PROJECT` — project directory (default `.`); its `dist/` is inspected unless `--dist` is given.
122
+ - `--dist DIR` — directory of built wheels/sdists.
123
+ - `--import MODULE` — runtime GIL check for a module (repeatable or comma-separated).
124
+ - `--strict` — exit 1 on warnings too.
125
+ - `--format json` — machine-readable output for CI dashboards.
126
+
127
+ Exit code is 1 if there are any errors (or any warnings under `--strict`), else 0.
128
+
129
+ ## License
130
+
131
+ MIT
@@ -0,0 +1,12 @@
1
+ ft_readiness/__init__.py,sha256=5E4CSl3RbGvkeMfJmPKZBwpCOjUixJtp1jV-WFr0OpI,157
2
+ ft_readiness/checks.py,sha256=NjCaB-GR3hGN_7i8GASE92WRUWOWvL40bmZKOABuaxU,6938
3
+ ft_readiness/cli.py,sha256=Ey24Ub3BOnJcD2Ps1gjrc2NqxWg6llJNtmn4WpS5ero,4838
4
+ ft_readiness/metadata.py,sha256=pJcg8qTI0aTHawUNY1m7msuvbM_oiXEeCCVaK6n7TPg,2849
5
+ ft_readiness/pytest_plugin.py,sha256=WF2P31ELWoCQohG-GwKDau2mJdi_Oejx6o_JcLurDHA,2716
6
+ ft_readiness/runtime.py,sha256=AcOOkeaf6FfM0l6yru7jdh9pANQirMA1zhHfenMLdH8,3773
7
+ ft_readiness/wheels.py,sha256=6jagzvigFRzfsI3PMeKjUa8I6mCcUXYdSuXRzGAmxUY,3036
8
+ ft_readiness-0.1.0.dist-info/METADATA,sha256=aPt_wQOtCUaIORl6QyAPbF8g60mSYc31p7lcq8tfLSs,6938
9
+ ft_readiness-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
10
+ ft_readiness-0.1.0.dist-info/entry_points.txt,sha256=9jV5cRaZFZaMJZrXxz7jzztJjpW-2KuSzeHF8yQoHXk,109
11
+ ft_readiness-0.1.0.dist-info/licenses/LICENSE,sha256=lw6uzq8NaSvXCl8vGq2IL9jyMWk4TjiYAz6VEm_Fphw,1066
12
+ ft_readiness-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ ft-readiness = ft_readiness.cli:main
3
+
4
+ [pytest11]
5
+ ft_readiness = ft_readiness.pytest_plugin
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 fernforge
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.