Skip to main content

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). 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.

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

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

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__", {})
vars(x).get("__annotations__", {})
the annotations {} silent
DL002 x.__dict__["__annotations__"]
vars(x)["__annotations__"]
the annotations KeyError crash
DL003 "__annotations__" in x.__dict__
"__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:

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

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

Metadata

Release files for deferlint 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for deferlint 0.1.0
File Size Uploaded
deferlint-0.1.0.tar.gz 22.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for deferlint 0.1.0
File Interpreter ABI Platform
deferlint-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 34.8 kB

Release files / deferlint-0.1.0.tar.gz

Download URL deferlint-0.1.0.tar.gz
Size 22.3 kB
Tags Source
SHA-256 checksum
How to use checksums
b8a5db3999642c262d2ed05c82dacf009a864a1355c40f37d678f127d8af36ea
BLAKE2b-256 checksum
How to use checksums
2faf3a6c0b2c5aa27a2f345f1e5229ebbb2cc1ddc224b477199d7cb07faeb475
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.3

Release files / deferlint-0.1.0-py3-none-any.whl

Download URL deferlint-0.1.0-py3-none-any.whl
Size 12.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
6e8da02801db7f7a7f7fc5c32839ddef8cc01b14c7764424fcd7ac6093715581
BLAKE2b-256 checksum
How to use checksums
811994f522feb754e7c047d5e5d9d00308db7121705d40697e41154a4c4ba5c7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.3

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page