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 theTypedDictmetaclass. InheritedTypedDictannotations 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)
| File | Size | Uploaded | |
|---|---|---|---|
| deferlint-0.1.0.tar.gz | 22.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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
|