Metadata-Version: 2.5
Name: deferlint
Version: 0.1.0
Summary: Find __annotations__ reads that silently change behaviour on Python 3.14 (PEP 649).
Project-URL: Homepage, https://github.com/speedsharmaai/deferlint
Project-URL: Source, https://github.com/speedsharmaai/deferlint
Project-URL: Issues, https://github.com/speedsharmaai/deferlint/issues
Project-URL: Changelog, https://github.com/speedsharmaai/deferlint/blob/main/CHANGELOG.md
Author: Sanjeev Sharma
License-Expression: MIT
License-File: LICENSE
Keywords: annotations,linter,migration,pep649,python314,static-analysis
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown

# deferlint

Finds code that reads `__annotations__` in a way that **silently changes behaviour**
on Python 3.14.

Python 3.14 made annotations lazy ([PEP 649](https://peps.python.org/pep-0649/)).
Classes and modules no longer keep an `__annotations__` entry in their `__dict__`;
it is computed on demand from `__annotate__`. Code that went through `__dict__`
to read annotations does not crash — it just finds nothing.

```python
class Schema:
    product_id: int
    price: float

Schema.__dict__.get("__annotations__", {})
# 3.12 -> {'product_id': <class 'int'>, 'price': <class 'float'>}
# 3.14 -> {}
```

No exception, no warning, no traceback. Field registration loops stop running,
validators stop validating, serializers emit empty objects. The failure shows up
as wrong output somewhere else entirely, which is a bad thing to discover in
production.

## Install

```sh
pip install deferlint
```

It has no dependencies and runs on Python 3.9 and up, so you can run it on your
current interpreter **before** you upgrade. That is the point — it is a
pre-flight check, not a post-mortem.

## Use

```sh
deferlint .                  # scan the current tree
deferlint src/ --explain     # include reasoning and the fix for each finding
deferlint . --format json    # machine readable
```

Exit code is `0` when clean and `1` when there are findings, so it drops into CI
as-is. Use `--exit-zero` to report without failing the build.

```
src/models/base.py:169:32: DL001 [silent] `__dict__.get('__annotations__')` returns the default on Python 3.14
    subclass_annotations = cls.__dict__.get("__annotations__", {})

1 finding(s): 1 silent behaviour change(s), 0 runtime error(s).
```

## What it looks for

All three behaviours below were measured on CPython 3.12.11 and 3.14.3.

| Code | Pattern | 3.12 | 3.14 | |
|---|---|---|---|---|
| `DL001` | `x.__dict__.get("__annotations__", {})`<br>`vars(x).get("__annotations__", {})` | the annotations | `{}` | silent |
| `DL002` | `x.__dict__["__annotations__"]`<br>`vars(x)["__annotations__"]` | the annotations | `KeyError` | crash |
| `DL003` | `"__annotations__" in x.__dict__`<br>`"__annotations__" in vars(x)` | `True` | `False` | silent |

The fix in every case is `annotationlib.get_annotations(obj, format=annotationlib.Format.FORWARDREF)`
on 3.14+, with the old call kept behind a `sys.version_info` branch for older
runtimes. `--explain` prints this per finding.

`getattr(obj, "__annotations__", {})` and plain `obj.__annotations__` are **not**
reported. Those still work on 3.14.

## Why not mypy, pyright or grep

**Type checkers** are the wrong shape for this. They reason about what your
annotations *mean*. This is a bug about how your code *reads the annotations
container at runtime* — `__dict__` access is an ordinary dictionary lookup as
far as they are concerned, and they have nothing to say about it.

**grep** finds the pattern but cannot tell a bug from a deliberate fallback.
Most well-maintained libraries already handle 3.14 like this:

```python
if sys.version_info >= (3, 14):
    return annotationlib.get_annotations(cls, format=annotationlib.Format.FORWARDREF)
else:
    return cls.__dict__.get("__annotations__", {})   # correct, unreachable on 3.14
```

deferlint evaluates `sys.version_info` guards and only reports code that can
actually run on the target version. It understands `>=` / `<` / `==` on
`sys.version_info`, on slices and on `.major` / `.minor`, both branches of the
`if`, `and` / `or` / `not`, and module-level flags like
`PY314 = sys.version_info >= (3, 14)`.

On a corpus of 80 installed packages (6,354 files), grep produced 9 hits.
Seven were correct version-guarded fallbacks. deferlint reported the other two.

Both were real:

- `pandera/api/pyspark/model.py:169` — unguarded, inside `__init_subclass__`.
  On 3.14 the field-registration loop iterates over nothing and schema fields
  are silently never registered.
- `mypy_extensions.py:78` — unguarded, in the `TypedDict` metaclass. Inherited
  `TypedDict` annotations silently disappear.

## Suppressing

```python
anns = cls.__dict__.get("__annotations__", {})  # deferlint: ignore
```

Or `--ignore DL003`, repeatable. `--exclude NAME` skips a directory name.

## Scope, honestly

This checks one specific thing: reads of the annotations container that change
behaviour under deferred evaluation. It is deliberately narrow, because that is
what makes the output worth reading.

It does not attempt to find every PEP 649 interaction. It will not catch a
`__dict__` access built dynamically (`getattr(x, "__di" + "ct__")`), a key held
in a variable, or annotations read through a C extension. Those are real, and
they are also rare enough that reporting them would cost more in false positives
than it returns.

## Licence

MIT
