Skip to content

Commit 7ba4899

Browse files
codewizdaveclaude
andcommitted
feat: Implement NewTypedDict[*Ps: Member] type operator
Add the NewTypedDict type operator as specified in PEP 827 for creating TypedDict types from Member type arguments. Changes: - Add NewTypedDict class definition to typing.py - Add evaluator in _eval_operators.py - Add comprehensive tests (7 test cases) - Handle NotRequired qualifier for optional fields Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c8cabb5 commit 7ba4899

3 files changed

Lines changed: 157 additions & 0 deletions

File tree

packages/typemap/src/typemap/type_eval/_eval_operators.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
Member,
4444
Members,
4545
NewProtocol,
46+
NewTypedDict,
4647
Omit,
4748
Overloaded,
4849
Param,
@@ -1291,6 +1292,38 @@ def _eval_NewProtocol(*etyps: Member, ctx):
12911292
return cls
12921293

12931294

1295+
@type_eval.register_evaluator(NewTypedDict)
1296+
@_lift_evaluated
1297+
def _eval_NewTypedDict(*etyps: Member, ctx):
1298+
"""Evaluate NewTypedDict to create a TypedDict from Member types."""
1299+
annos: dict[str, object] = {}
1300+
1301+
members = [typing.get_args(prop) for prop in etyps]
1302+
for tname, typ, quals, init, _ in members:
1303+
name = _eval_literal(tname, ctx)
1304+
typ = _eval_types(typ, ctx)
1305+
tquals = _eval_types(quals, ctx)
1306+
1307+
# Handle NotRequired qualifier for TypedDict
1308+
if type_eval.issubtype(typing.Literal["NotRequired"], tquals):
1309+
# NotRequired means the field is optional
1310+
# In TypedDict, this is handled differently
1311+
annos[name] = typing.Annotated[typ, typing.NotRequired]
1312+
else:
1313+
annos[name] = typ
1314+
1315+
# Get the name from context if available
1316+
name = "NewTypedDict"
1317+
1318+
ctx = type_eval._get_current_context()
1319+
if ctx.current_generic_alias:
1320+
if isinstance(ctx.current_generic_alias, types.GenericAlias):
1321+
name = f"{ctx.current_generic_alias.__name__}[...]"
1322+
1323+
# Create the TypedDict
1324+
return typing.TypedDict(name, annos)
1325+
1326+
12941327
@type_eval.register_evaluator(KeyOf)
12951328
@_lift_over_unions
12961329
def _eval_KeyOf(tp, *, ctx):

packages/typemap/src/typemap/typing.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,21 @@ class NewProtocol[*T]:
292292
pass
293293

294294

295+
class NewTypedDict[*T]:
296+
"""Create a new TypedDict from Member types.
297+
298+
Usage:
299+
type MyDict = NewTypedDict[
300+
Member[Literal["name"], str],
301+
Member[Literal["age"], int],
302+
]
303+
304+
This creates a TypedDict with name: str and age: int fields.
305+
"""
306+
307+
pass
308+
309+
295310
class UpdateClass[*Ms]:
296311
pass
297312

packages/typemap/tests/test_type_eval.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import collections
44
import textwrap
5+
import typing
56
import unittest
67
from typing import (
78
Annotated,
@@ -44,6 +45,7 @@
4445
Member,
4546
Members,
4647
NewProtocol,
48+
NewTypedDict,
4749
Omit,
4850
Overloaded,
4951
Param,
@@ -3215,3 +3217,110 @@ class User:
32153217
result = eval_typing(Omit[User, tuple["name", "age"]])
32163218
# All fields should be omitted, resulting in empty annotations
32173219
assert len(result.__annotations__) == 0
3220+
3221+
3222+
###############
3223+
# NewTypedDict tests
3224+
3225+
3226+
def test_newtypeddict_basic():
3227+
"""Test NewTypedDict creates a TypedDict from Member types."""
3228+
3229+
result = eval_typing(
3230+
NewTypedDict[
3231+
Member[Literal["name"], str],
3232+
Member[Literal["age"], int],
3233+
]
3234+
)
3235+
assert result.__annotations__["name"] is str
3236+
assert result.__annotations__["age"] is int
3237+
3238+
3239+
def test_newtypeddict_single_field():
3240+
"""Test NewTypedDict with a single field."""
3241+
3242+
result = eval_typing(NewTypedDict[Member[Literal["value"], int],])
3243+
assert "value" in result.__annotations__
3244+
assert result.__annotations__["value"] is int
3245+
3246+
3247+
def test_newtypeddict_preserves_types():
3248+
"""Test NewTypedDict preserves correct types."""
3249+
3250+
result = eval_typing(
3251+
NewTypedDict[
3252+
Member[Literal["name"], str],
3253+
Member[Literal["age"], int],
3254+
Member[Literal["active"], bool],
3255+
]
3256+
)
3257+
assert result.__annotations__["name"] is str
3258+
assert result.__annotations__["age"] is int
3259+
assert result.__annotations__["active"] is bool
3260+
3261+
3262+
def test_newtypeddict_with_complex_types():
3263+
"""Test NewTypedDict with complex types."""
3264+
3265+
result = eval_typing(
3266+
NewTypedDict[
3267+
Member[Literal["users"], list[str]],
3268+
Member[Literal["metadata"], dict[str, int]],
3269+
]
3270+
)
3271+
assert result.__annotations__["users"] == list[str]
3272+
assert result.__annotations__["metadata"] == dict[str, int]
3273+
3274+
3275+
def test_newtypeddict_multiple_fields():
3276+
"""Test NewTypedDict with multiple fields."""
3277+
3278+
result = eval_typing(
3279+
NewTypedDict[
3280+
Member[Literal["id"], int],
3281+
Member[Literal["title"], str],
3282+
Member[Literal["description"], str],
3283+
Member[Literal["count"], int],
3284+
Member[Literal["enabled"], bool],
3285+
]
3286+
)
3287+
assert "id" in result.__annotations__
3288+
assert "title" in result.__annotations__
3289+
assert "description" in result.__annotations__
3290+
assert "count" in result.__annotations__
3291+
assert "enabled" in result.__annotations__
3292+
assert len(result.__annotations__) == 5
3293+
3294+
3295+
def test_newtypeddict_optional_field():
3296+
"""Test NewTypedDict with NotRequired qualifier makes field optional."""
3297+
3298+
result = eval_typing(
3299+
NewTypedDict[
3300+
Member[Literal["required_field"], str],
3301+
Member[Literal["optional_field"], int, Literal["NotRequired"]],
3302+
]
3303+
)
3304+
assert result.__annotations__["required_field"] is str
3305+
# NotRequired fields should be wrapped with Annotated
3306+
assert hasattr(result.__annotations__["optional_field"], "__metadata__")
3307+
3308+
3309+
def test_newtypeddict_with_iter_attrs():
3310+
"""Test NewTypedDict using Iter and Attrs to create from existing class."""
3311+
3312+
class User:
3313+
name: str
3314+
age: int
3315+
email: str
3316+
3317+
# Get the Member types from the User class
3318+
members = list(eval_typing(Iter[Attrs[User]]))
3319+
3320+
# Create NewTypedDict from those members
3321+
result = eval_typing(
3322+
NewTypedDict[*[Member[m.name, m.type] for m in members]]
3323+
)
3324+
assert "name" in result.__annotations__
3325+
assert "age" in result.__annotations__
3326+
assert "email" in result.__annotations__

0 commit comments

Comments
 (0)