Metadata-Version: 2.4
Name: abi3t-doctor
Version: 0.1.0
Summary: Diagnose whether a dependency tree runs on free-threaded Python 3.15 (PEP 803 abi3t)
License-Expression: 0BSD
Project-URL: Homepage, https://github.com/rladnwls122/abi3t-doctor
Project-URL: Source, https://github.com/rladnwls122/abi3t-doctor
Project-URL: Issues, https://github.com/rladnwls122/abi3t-doctor/issues
Keywords: abi3t,free-threading,pep803,wheel,stable-abi,packaging
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: System :: Software Distribution
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: toml
Requires-Dist: tomli>=2.0; python_version < "3.11" and extra == "toml"
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Dynamic: license-file

# abi3t-doctor

Answer one question: **does my dependency tree actually run on free-threaded
Python 3.15?**

Python 3.15 adds `abi3t`, a Stable ABI for free-threaded builds
([PEP 803](https://peps.python.org/pep-0803/)). The migration it enables is
blocked in a specific, checkable way, and it is not the way most people expect:

> A free-threaded interpreter's `EXTENSION_SUFFIXES` is
> `['.cpython-315t-....so', '.abi3t.so', '.so']` — **there is no `.abi3.so`
> entry.**

So an `abi3` wheel is not "slower on 3.15t" or "untested on 3.15t". It is not
loaded at all. Every compiled dependency in your tree needs an `abi3t` build, a
version-specific `cp315t` build, or no compiled code.

`abi3t-doctor` checks that from wheel tags, and — where the tags are not to be
trusted — from the symbol tables of the binaries underneath them.

```console
$ abi3t-doctor env
target: cp315t  (Python 3.15, free-threaded)

  needs-rebuild            cryptography 41.0.7
      error D410 _rust.abi3.so is an abi3 extension, which a free-threaded build will not load
        A free-threaded interpreter's EXTENSION_SUFFIXES omits '.abi3.so'. The project
        needs an abi3t or cp3XXt build.
  needs-rebuild            PyYAML 6.0.1
      error D411 _yaml.cpython-312-x86_64-linux-gnu.so is built for cp312, not cp315t

23 distribution(s) checked
  needs-rebuild            4
  pure                     19

4 distribution(s) block cp315t: charset_normalizer, cryptography, markupsafe, PyYAML
```

Existing tooling does not cover this. [abi3audit](https://github.com/trailofbits/abi3audit)
audits the old `abi3` and predates `abi3t` entirely; installers report tag
mismatches one package at a time, at install time, after you have already
committed to the upgrade.

## Install

```console
$ pip install abi3t-doctor
```

No dependencies, and no `readelf`, `nm`, `otool` or `dumpbin` either — the ELF,
Mach-O and PE readers are pure Python, so a Linux CI runner can inspect a Windows
`.pyd` without a toolchain. Runs on Python 3.9+; you do not need a 3.15 build to
ask about 3.15.

## The four commands

```console
$ abi3t-doctor env                       # will what I have installed work?
$ abi3t-doctor tree requirements.txt     # will what I depend on work?
$ abi3t-doctor wheel dist/*.whl          # does the wheel I built hold up?
$ abi3t-doctor tags <filename>           # what does this tag actually mean?
```

The target defaults to free-threaded 3.15. `--target 3.14t`, `--target 3.15` and
so on move it, which is how you find out whether the blocker is your code or the
calendar.

### env

Diagnoses what is on disk. This is the most trustworthy check available, because
the extension modules it reads are the exact binaries that would be loaded — no
guessing which wheel an installer would pick. Metadata comes from `.dist-info`
directories rather than `importlib.metadata`, so a distribution whose imports are
broken is still described rather than raising.

### tree

Asks the index what a project publishes, before you install anything:

```console
$ abi3t-doctor tree requirements.txt
target: cp315t  (Python 3.15, free-threaded)

  source-only              cryptography 50.0.1
      warning D424 cryptography ships free-threaded wheels (cp314t) but none for cp315t
        Free-threading support exists upstream; this is a release-cadence gap, not a
        porting problem. An abi3t build would close it permanently.

5 distribution(s) checked
  source-only              3
  ready                    1
  pure                     1
```

That distinction matters. "Ships `cp314t` but not `cp315t` yet" is a very
different conversation with upstream than "no free-threading support at all", and
flattening both into *source-only* would hide it.

Responses are cached for a day; `--offline` uses only the cache. `--deep`
downloads a candidate wheel per distribution and runs the symbol checks on it.

### wheel

For the other side of the problem — you are the one shipping the wheel:

```console
$ abi3t-doctor wheel dist/*.whl
mypkg-1.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.whl
  tags: cp315-abi3-manylinux_2_17_x86_64, cp315-abi3t-manylinux_2_17_x86_64
  mypkg/_speed.abi3.so  elf/64 x86_64 abi=abi3 hook=PyInit__speed imports=87
  error F301 wheel is tagged abi3t but this file is named '.abi3.so', which a
             free-threaded build never searches for
  error S203 exports PyInit__speed() but abi3t requires PEP 793's PyModExport_ hook
  error S205 imports 3 symbol(s) outside the Stable ABI, so the abi3 tag does not
             hold: PyUnicode_AsUTF8, PyFrame_GetBack, PyEval_GetFrame
```

Exit code 1 on any error, so it drops into a release pipeline as a gate.

### tags

The quick one, for when a filename is confusing:

```console
$ abi3t-doctor tags pkg-1.0-cp315-abi3.abi3t-win_amd64.whl
pkg 1.0
  no cp315-abi3-win_amd64      abi3     abi3-on-free-threaded
  ok cp315-abi3t-win_amd64     abi3t    compatible
```

## What it checks

**Tags** (`T1xx`) — the filename's claim. Compressed tag sets are expanded the way
an installer expands them, so `cp315.cp316-abi3.abi3t` becomes four tags and each
is judged on its own. `abi3t` below 3.15 is an error; PEP 803 does not define it.

**Symbols** (`S2xx`) — what the binary actually needs, read from its symbol table
and compared against CPython's own `Misc/stable_abi.toml` (967 symbols, with the
version each was added):

- A symbol outside the Stable ABI in a wheel tagged `abi3`. The tag does not hold;
  the wheel works on the version it was built against and fails elsewhere.
- A Stable ABI symbol *newer* than the wheel's `cp3XX` tag. `cp39-abi3` importing
  `PyList_GetItemRef` (stable since 3.13) fails on 3.9 through 3.12 — the
  interpreters the tag exists to promise.
- An exported `PyInit_` hook on an `abi3t` wheel. PEP 803 requires PEP 793's
  `PyModExport_`, because `PyModuleDef` is opaque under `abi3t` and a `PyInit_`
  function has to return one. An exported `PyInit_` is proof the wheel was not
  built for `abi3t`, whatever its tag says.
- A symbol `abi3t` makes unusable — `PyModuleDef_Init`, `PyModule_Create2`,
  `PyType_GetModuleByDef` and friends, all of which take or return that now-opaque
  struct. Use `PyModule_GetToken()` and `PyType_GetModuleByToken()` instead.
- A GIL-specific symbol such as `PyEval_InitThreads`, removed in 3.13.

**File layout** (`F3xx`) — whether the import system will even look for the file.
A wheel tagged `abi3.abi3t` that ships only `foo.abi3.so` installs cleanly and
then fails to import, which is the most confusing of the three failure modes.

**Distributions** (`D4xx`) — whether anything installable exists at all, and
whether a source build could produce it. As the 3.15 release notes say outright,
the mainstream build backends were still catching up on `abi3t` at release, so a
project needing one may be blocked on its toolchain rather than on its own code.

Codes are stable and grouped by prefix so you can reason about a class of finding
at a time.

## Verdicts

| verdict | meaning | blocks? |
| --- | --- | --- |
| `pure` | No compiled code; runs anywhere. | no |
| `ready` | A compatible binary exists and holds up to inspection. | no |
| `source-only` | No wheel; installing means compiling. | no |
| `unknown` | Not enough information to say. | no |
| `mistagged` | A binary claims an ABI its symbols contradict. | **yes** |
| `needs-rebuild` | Compiled, but not for this interpreter. | **yes** |
| `unavailable` | Nothing published that could work. | **yes** |

## In CI

```yaml
- run: pip install abi3t-doctor
- run: abi3t-doctor env --target 3.15t          # fails on a blocker
- run: abi3t-doctor wheel dist/*.whl            # fails on a mistagged wheel
- run: abi3t-doctor tree --format json > abi3t.json
```

## Library use

```python
from pathlib import Path
from abi3t_doctor import Target, inspect_wheel, read_symbols_from_path

report = inspect_wheel(Path("dist/pkg-1.0-cp315-abi3.abi3t-win_amd64.whl"))
for finding in report.findings:
    print(finding)

table = read_symbols_from_path(Path("pkg/_speed.abi3t.so"))
print(table.format, table.arch, len(table.imported))
```

`abi3t_doctor.tags` for tag arithmetic, `abi3t_doctor.binary` for the symbol
readers, `abi3t_doctor.stable_abi` for the manifest and the `abi3t` restrictions,
`abi3t_doctor.diagnose` for the verdicts.

## Limitations

- A wheel's tags and symbols are checked; whether the code inside is actually
  *thread-safe* is not something a symbol table can tell you. `abi3t-doctor` says
  a wheel will load, not that it is correct under free threading.
- Platform tag matching is exact. `--platform` filters by tag string; there is no
  manylinux/macOS version-compatibility ladder yet, so the default accepts any
  platform.
- Statically linked, stripped or packed binaries yield no symbols. Those are
  reported as unread rather than as clean.
- Build-backend `abi3t` support is a fast-moving target; treat the backend notes
  as a pointer to check the backend's own docs, not as current fact.
- The dependency tree is read from requirement files or the installed
  environment. There is no resolver — transitive dependencies show up only if they
  are installed or listed.

## Licence

[0BSD](LICENSE) -- public-domain-equivalent. Use it for anything, with no
attribution required.
