From 7ba4899a308dccb10159c65994deed0fbac9285f Mon Sep 17 00:00:00 2001 From: AliiiBenn Date: Thu, 5 Mar 2026 14:45:25 +0100 Subject: [PATCH 1/4] 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 --- .../src/typemap/type_eval/_eval_operators.py | 33 ++++++ packages/typemap/src/typemap/typing.py | 15 +++ packages/typemap/tests/test_type_eval.py | 109 ++++++++++++++++++ 3 files changed, 157 insertions(+) diff --git a/packages/typemap/src/typemap/type_eval/_eval_operators.py b/packages/typemap/src/typemap/type_eval/_eval_operators.py index 68bedec..8d94b16 100644 --- a/packages/typemap/src/typemap/type_eval/_eval_operators.py +++ b/packages/typemap/src/typemap/type_eval/_eval_operators.py @@ -43,6 +43,7 @@ Member, Members, NewProtocol, + NewTypedDict, Omit, Overloaded, Param, @@ -1291,6 +1292,38 @@ def _eval_NewProtocol(*etyps: Member, ctx): return cls +@type_eval.register_evaluator(NewTypedDict) +@_lift_evaluated +def _eval_NewTypedDict(*etyps: Member, ctx): + """Evaluate NewTypedDict to create a TypedDict from Member types.""" + annos: dict[str, object] = {} + + members = [typing.get_args(prop) for prop in etyps] + for tname, typ, quals, init, _ in members: + name = _eval_literal(tname, ctx) + typ = _eval_types(typ, ctx) + tquals = _eval_types(quals, ctx) + + # Handle NotRequired qualifier for TypedDict + if type_eval.issubtype(typing.Literal["NotRequired"], tquals): + # NotRequired means the field is optional + # In TypedDict, this is handled differently + annos[name] = typing.Annotated[typ, typing.NotRequired] + else: + annos[name] = typ + + # Get the name from context if available + name = "NewTypedDict" + + ctx = type_eval._get_current_context() + if ctx.current_generic_alias: + if isinstance(ctx.current_generic_alias, types.GenericAlias): + name = f"{ctx.current_generic_alias.__name__}[...]" + + # Create the TypedDict + return typing.TypedDict(name, annos) + + @type_eval.register_evaluator(KeyOf) @_lift_over_unions def _eval_KeyOf(tp, *, ctx): diff --git a/packages/typemap/src/typemap/typing.py b/packages/typemap/src/typemap/typing.py index 74d4084..573a8bf 100644 --- a/packages/typemap/src/typemap/typing.py +++ b/packages/typemap/src/typemap/typing.py @@ -292,6 +292,21 @@ class NewProtocol[*T]: pass +class NewTypedDict[*T]: + """Create a new TypedDict from Member types. + + Usage: + type MyDict = NewTypedDict[ + Member[Literal["name"], str], + Member[Literal["age"], int], + ] + + This creates a TypedDict with name: str and age: int fields. + """ + + pass + + class UpdateClass[*Ms]: pass diff --git a/packages/typemap/tests/test_type_eval.py b/packages/typemap/tests/test_type_eval.py index 0db1ee4..1bb9a8b 100644 --- a/packages/typemap/tests/test_type_eval.py +++ b/packages/typemap/tests/test_type_eval.py @@ -2,6 +2,7 @@ import collections import textwrap +import typing import unittest from typing import ( Annotated, @@ -44,6 +45,7 @@ Member, Members, NewProtocol, + NewTypedDict, Omit, Overloaded, Param, @@ -3215,3 +3217,110 @@ class User: result = eval_typing(Omit[User, tuple["name", "age"]]) # All fields should be omitted, resulting in empty annotations assert len(result.__annotations__) == 0 + + +############### +# NewTypedDict tests + + +def test_newtypeddict_basic(): + """Test NewTypedDict creates a TypedDict from Member types.""" + + result = eval_typing( + NewTypedDict[ + Member[Literal["name"], str], + Member[Literal["age"], int], + ] + ) + assert result.__annotations__["name"] is str + assert result.__annotations__["age"] is int + + +def test_newtypeddict_single_field(): + """Test NewTypedDict with a single field.""" + + result = eval_typing(NewTypedDict[Member[Literal["value"], int],]) + assert "value" in result.__annotations__ + assert result.__annotations__["value"] is int + + +def test_newtypeddict_preserves_types(): + """Test NewTypedDict preserves correct types.""" + + result = eval_typing( + NewTypedDict[ + Member[Literal["name"], str], + Member[Literal["age"], int], + Member[Literal["active"], bool], + ] + ) + assert result.__annotations__["name"] is str + assert result.__annotations__["age"] is int + assert result.__annotations__["active"] is bool + + +def test_newtypeddict_with_complex_types(): + """Test NewTypedDict with complex types.""" + + result = eval_typing( + NewTypedDict[ + Member[Literal["users"], list[str]], + Member[Literal["metadata"], dict[str, int]], + ] + ) + assert result.__annotations__["users"] == list[str] + assert result.__annotations__["metadata"] == dict[str, int] + + +def test_newtypeddict_multiple_fields(): + """Test NewTypedDict with multiple fields.""" + + result = eval_typing( + NewTypedDict[ + Member[Literal["id"], int], + Member[Literal["title"], str], + Member[Literal["description"], str], + Member[Literal["count"], int], + Member[Literal["enabled"], bool], + ] + ) + assert "id" in result.__annotations__ + assert "title" in result.__annotations__ + assert "description" in result.__annotations__ + assert "count" in result.__annotations__ + assert "enabled" in result.__annotations__ + assert len(result.__annotations__) == 5 + + +def test_newtypeddict_optional_field(): + """Test NewTypedDict with NotRequired qualifier makes field optional.""" + + result = eval_typing( + NewTypedDict[ + Member[Literal["required_field"], str], + Member[Literal["optional_field"], int, Literal["NotRequired"]], + ] + ) + assert result.__annotations__["required_field"] is str + # NotRequired fields should be wrapped with Annotated + assert hasattr(result.__annotations__["optional_field"], "__metadata__") + + +def test_newtypeddict_with_iter_attrs(): + """Test NewTypedDict using Iter and Attrs to create from existing class.""" + + class User: + name: str + age: int + email: str + + # Get the Member types from the User class + members = list(eval_typing(Iter[Attrs[User]])) + + # Create NewTypedDict from those members + result = eval_typing( + NewTypedDict[*[Member[m.name, m.type] for m in members]] + ) + assert "name" in result.__annotations__ + assert "age" in result.__annotations__ + assert "email" in result.__annotations__ From 64992f9affddba3d638001cee802c3de5920bdc1 Mon Sep 17 00:00:00 2001 From: AliiiBenn Date: Thu, 5 Mar 2026 15:20:14 +0100 Subject: [PATCH 2/4] fix: resolve mypy error for TypedDict callable Use typing.cast to make mypy understand that TypedDict is callable when creating a NewTypedDict type. Co-Authored-By: Claude Opus 4.6 --- packages/typemap/src/typemap/type_eval/_eval_operators.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/typemap/src/typemap/type_eval/_eval_operators.py b/packages/typemap/src/typemap/type_eval/_eval_operators.py index 8d94b16..5ccd025 100644 --- a/packages/typemap/src/typemap/type_eval/_eval_operators.py +++ b/packages/typemap/src/typemap/type_eval/_eval_operators.py @@ -1321,7 +1321,12 @@ def _eval_NewTypedDict(*etyps: Member, ctx): name = f"{ctx.current_generic_alias.__name__}[...]" # Create the TypedDict - return typing.TypedDict(name, annos) + # Use cast to make mypy happy - TypedDict is callable at runtime + td_callable = typing.cast( + typing.Callable[[str, dict[str, object]], type], + typing.TypedDict, + ) + return td_callable(name, annos) @type_eval.register_evaluator(KeyOf) From c8e5fc0fec11ad5923807ce7d7f4d4c01042ba0b Mon Sep 17 00:00:00 2001 From: AliiiBenn Date: Thu, 5 Mar 2026 15:39:26 +0100 Subject: [PATCH 3/4] feat: add ReadOnly qualifier support for NewTypedDict - Handle ReadOnly qualifier in addition to NotRequired - Support multiple qualifiers (ReadOnly + NotRequired) - Add tests for ReadOnly functionality Co-Authored-By: Claude Opus 4.6 --- .../src/typemap/type_eval/_eval_operators.py | 12 +++++--- packages/typemap/tests/test_type_eval.py | 28 +++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/typemap/src/typemap/type_eval/_eval_operators.py b/packages/typemap/src/typemap/type_eval/_eval_operators.py index 5ccd025..137e645 100644 --- a/packages/typemap/src/typemap/type_eval/_eval_operators.py +++ b/packages/typemap/src/typemap/type_eval/_eval_operators.py @@ -1304,11 +1304,15 @@ def _eval_NewTypedDict(*etyps: Member, ctx): typ = _eval_types(typ, ctx) tquals = _eval_types(quals, ctx) - # Handle NotRequired qualifier for TypedDict + # Handle qualifiers for TypedDict fields + annotations = [] if type_eval.issubtype(typing.Literal["NotRequired"], tquals): - # NotRequired means the field is optional - # In TypedDict, this is handled differently - annos[name] = typing.Annotated[typ, typing.NotRequired] + annotations.append(typing.NotRequired) + if type_eval.issubtype(typing.Literal["ReadOnly"], tquals): + annotations.append(typing.ReadOnly) + + if annotations: + annos[name] = typing.Annotated[typ, *annotations] else: annos[name] = typ diff --git a/packages/typemap/tests/test_type_eval.py b/packages/typemap/tests/test_type_eval.py index 1bb9a8b..01b5275 100644 --- a/packages/typemap/tests/test_type_eval.py +++ b/packages/typemap/tests/test_type_eval.py @@ -3306,6 +3306,34 @@ def test_newtypeddict_optional_field(): assert hasattr(result.__annotations__["optional_field"], "__metadata__") +def test_newtypeddict_readonly_field(): + """Test NewTypedDict with ReadOnly qualifier marks field as read-only.""" + + result = eval_typing( + NewTypedDict[ + Member[Literal["id"], int], + Member[Literal["name"], str, Literal["ReadOnly"]], + ] + ) + assert result.__annotations__["id"] is int + # ReadOnly fields should be wrapped with Annotated + assert hasattr(result.__annotations__["name"], "__metadata__") + + +def test_newtypeddict_multiple_qualifiers(): + """Test NewTypedDict with both NotRequired and ReadOnly qualifiers.""" + + result = eval_typing( + NewTypedDict[ + Member[Literal["id"], int, Literal["ReadOnly"]], + Member[Literal["optional_name"], str, Literal["NotRequired", "ReadOnly"]], + ] + ) + # Both should have metadata + assert hasattr(result.__annotations__["id"], "__metadata__") + assert hasattr(result.__annotations__["optional_name"], "__metadata__") + + def test_newtypeddict_with_iter_attrs(): """Test NewTypedDict using Iter and Attrs to create from existing class.""" From 8b2acdcc401b7618cd7d60e4f2a8e73f534e3411 Mon Sep 17 00:00:00 2001 From: AliiiBenn Date: Thu, 5 Mar 2026 15:48:11 +0100 Subject: [PATCH 4/4] style: format code with ruff Co-Authored-By: Claude Opus 4.6 --- packages/typemap/tests/test_type_eval.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/typemap/tests/test_type_eval.py b/packages/typemap/tests/test_type_eval.py index 01b5275..171df9c 100644 --- a/packages/typemap/tests/test_type_eval.py +++ b/packages/typemap/tests/test_type_eval.py @@ -3326,7 +3326,11 @@ def test_newtypeddict_multiple_qualifiers(): result = eval_typing( NewTypedDict[ Member[Literal["id"], int, Literal["ReadOnly"]], - Member[Literal["optional_name"], str, Literal["NotRequired", "ReadOnly"]], + Member[ + Literal["optional_name"], + str, + Literal["NotRequired", "ReadOnly"], + ], ] ) # Both should have metadata