Skip to content

Commit 42f913f

Browse files
committed
Handle total=False TypedDicts by injecting NotRequired qual
Use __optional_keys__ from the outermost TypedDict class to identify fields that are implicitly NotRequired due to total=False, since TypedDict's metaclass flattens parent annotations into subclasses.
1 parent 533b09c commit 42f913f

2 files changed

Lines changed: 55 additions & 1 deletion

File tree

tests/test_ts_utility.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"""
77

88
import textwrap
9-
from typing import Literal, NotRequired, TypedDict
9+
from typing import Literal, Never, NotRequired, Required, TypedDict
1010

1111
import typemap_extensions as typing
1212
from typemap.type_eval import eval_typing
@@ -119,3 +119,40 @@ def test_partial_td():
119119
"description": NotRequired[str],
120120
"completed": NotRequired[bool],
121121
}
122+
123+
124+
class OptionalTD(TypedDict, total=False):
125+
x: int
126+
y: Required[str]
127+
128+
129+
class ChildTD(OptionalTD):
130+
z: bool
131+
132+
133+
def _get_quals(cls):
134+
"""Return {name: quals} for all Attrs of cls."""
135+
result = {}
136+
for p in eval_typing(typing.Iter[typing.Attrs[cls]]):
137+
name = eval_typing(p.name).__args__[0]
138+
quals = eval_typing(p.quals)
139+
result[name] = quals
140+
return result
141+
142+
143+
def test_td_total_false():
144+
quals = _get_quals(OptionalTD)
145+
# x is bare in total=False -> NotRequired
146+
assert quals["x"] == Literal["NotRequired"]
147+
# y has explicit Required -> no qual
148+
assert quals["y"] is Never
149+
150+
151+
def test_td_total_false_inherited():
152+
quals = _get_quals(ChildTD)
153+
# x inherited from total=False parent -> still NotRequired
154+
assert quals["x"] == Literal["NotRequired"]
155+
# y had explicit Required in parent -> no qual
156+
assert quals["y"] is Never
157+
# z defined in total=True child -> no qual
158+
assert quals["z"] is Never

typemap/type_eval/_eval_operators.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,12 @@ def get_annotated_type_hints(cls, *, ctx, attrs_only=False, **kwargs):
109109

110110
box = cached_box(cls, ctx=ctx)
111111

112+
# For TypedDicts with total=False, use __optional_keys__ to
113+
# identify which fields are NotRequired. TypedDict's metaclass
114+
# flattens parent annotations into subclasses, so we can't
115+
# reliably check __total__ per-class in our own MRO walk.
116+
td_optional_keys = getattr(cls, "__optional_keys__", frozenset())
117+
112118
hints = {}
113119
for abox in reversed(box.mro):
114120
acls = abox.alias_type()
@@ -119,6 +125,7 @@ def get_annotated_type_hints(cls, *, ctx, attrs_only=False, **kwargs):
119125

120126
# Strip ClassVar/Final/NotRequired/ReadOnly/Required from ty
121127
# and add them to quals
128+
had_required_marker = False
122129
while True:
123130
for form in [
124131
typing.ClassVar,
@@ -128,6 +135,11 @@ def get_annotated_type_hints(cls, *, ctx, attrs_only=False, **kwargs):
128135
typing.Required,
129136
]:
130137
if _typing_inspect.is_special_form(ty, form):
138+
if form in (
139+
typing.Required,
140+
typing.NotRequired,
141+
):
142+
had_required_marker = True
131143
# Required is the default; strip but don't add a qual
132144
if form is not typing.Required:
133145
quals.add(form.__name__)
@@ -140,6 +152,11 @@ def get_annotated_type_hints(cls, *, ctx, attrs_only=False, **kwargs):
140152
else:
141153
break
142154

155+
# For TypedDict fields without explicit Required/NotRequired,
156+
# check if they're optional (from total=False)
157+
if not had_required_marker and k in td_optional_keys:
158+
quals.add("NotRequired")
159+
143160
# Skip method-like ClassVars when only attributes are wanted
144161
if attrs_only and "ClassVar" in quals and _is_method_like(ty):
145162
continue

0 commit comments

Comments
 (0)