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.
- ft_readiness/__init__.py +5 -0
- ft_readiness/checks.py +183 -0
- ft_readiness/cli.py +125 -0
- ft_readiness/metadata.py +96 -0
- ft_readiness/pytest_plugin.py +86 -0
- ft_readiness/runtime.py +102 -0
- ft_readiness/wheels.py +98 -0
- ft_readiness-0.1.0.dist-info/METADATA +131 -0
- ft_readiness-0.1.0.dist-info/RECORD +12 -0
- ft_readiness-0.1.0.dist-info/WHEEL +4 -0
- ft_readiness-0.1.0.dist-info/entry_points.txt +5 -0
- ft_readiness-0.1.0.dist-info/licenses/LICENSE +21 -0
ft_readiness/__init__.py
ADDED
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())
|
ft_readiness/metadata.py
ADDED
|
@@ -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
|
+
)
|
ft_readiness/runtime.py
ADDED
|
@@ -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,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.
|