Skip to content

Fix NameError resolving generic PEP 695 TypedDicts under from __future__ import annotations - #1130

Open
michaelbilow wants to merge 1 commit into
msgspec:mainfrom
michaelbilow:fix-pep695-generic-typeddict-nameerror
Open

Fix NameError resolving generic PEP 695 TypedDicts under from __future__ import annotations#1130
michaelbilow wants to merge 1 commit into
msgspec:mainfrom
michaelbilow:fix-pep695-generic-typeddict-nameerror

Conversation

@michaelbilow

@michaelbilow michaelbilow commented Jul 22, 2026

Copy link
Copy Markdown

Besides this short paragraph, the rest of this PR is entirely AI-generated. I saw no policy on the repo and I hope this isn't annoying--it fixes an actual problem for me (and I'm a big msgspec fan). I've checked the code by hand and tried to mirror the style of the rest of the repo. If what's here is annoying, feel free to reject the PR and please accept my personal apologies. Thanks.

Short version: This fixes a NameError when decoding into a generic TypedDict defined with PEP 695 syntax (class Ex[T](TypedDict)) in a module using from __future__ import annotations, on Python 3.12 and 3.13. The type parameter T failed to resolve because TypedDict eagerly builds module-bound ForwardRefs whose evaluation discards the locals we stash the parameters in; threading the class's __type_params__ through _eval_type keeps them in scope.

What

Decoding into a generic TypedDict defined with PEP 695 type-parameter syntax
raises NameError: name 'T' is not defined on Python 3.12 and 3.13 when the
defining module uses from __future__ import annotations:

from __future__ import annotations
from typing import TypedDict
import msgspec

class Ex[T](TypedDict):
    x: T

msgspec.json.decode(b'{"x": 1}', type=Ex[int])
# NameError: name 'T' is not defined

The same class works on 3.14, and the equivalent Generic[T] / PEP 695 Struct
and dataclass forms already work on all versions.

Why

Under from __future__ import annotations, TypedDict on 3.12/3.13 eagerly
builds module-bound ForwardRef objects for its annotations. When such a
ForwardRef is evaluated, typing._eval_type replaces globalns with the owning
module's globals — discarding the cls_locals that get_class_annotations
populates with the PEP 695 type parameters, so T can't be resolved.

The _eval_type wrapper was passing type_params=(). Threading the class's
actual __type_params__ through keeps the parameters in scope regardless of
which globals the ForwardRef evaluation uses. 3.14 was unaffected because it
resolves annotations lazily via __annotate_func__; pre-3.12 has no PEP 695
syntax and no type_params argument, so it's accepted and ignored there.

Changes

  • src/msgspec/_utils.py: thread __type_params__ into _eval_type (the
    wrapper now spans 3.12+ since 3.12 added the arg as optional and 3.13 made it
    mandatory); removed the now-unused PY_312PLUS.
  • Tests: test_utils.py gets direct get_class_annotations coverage (generic
    class, generic TypedDict, generic subclass); test_common.py::TestTypedDict
    gets an end-to-end decode test mirroring the existing TestGenericStruct
    pattern. All gated @py312_plus and use from __future__ import annotations,
    which is load-bearing for the repro.
  • docs/changelog.md: entry under Unreleased.

Test plan

  • New tests fail without the fix (NameError) and pass with it,
    confirmed on both 3.12 and 3.13.
  • just test-all — 6415 passed, 113 skipped; doctests 7 passed.
  • just test-typing — mypy, pyright, pyrefly all clean.
  • just check — ruff lint, ruff format, codespell all pass.
  • just doc-build — succeeds with --fail-on-warning.
  • Verified on Python 3.12, 3.13, and 3.14.

…ations

On Python 3.12/3.13, `TypedDict` eagerly builds *module-bound* `ForwardRef`
objects for its annotations even under `from __future__ import annotations`.
When such a ForwardRef is evaluated, `_eval_type` replaces `globalns` with the
owning module's globals, dropping the PEP 695 type parameters that
`get_class_annotations` had stashed in the locals. This caused a
`NameError: name 'T' is not defined` when decoding into a generic TypedDict
defined with `class Foo[T](TypedDict)` syntax.

Thread the enclosing class's `__type_params__` through `_eval_type` as the
supported `type_params` argument so the parameters stay in scope regardless of
where `globalns` comes from. Python 3.14 was unaffected (it resolves these lazily
via `__annotate_func__`), and pre-3.12 has no PEP 695 syntax, so the argument is
accepted and ignored there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant