diff --git a/packages/typemap/src/typemap/type_eval/_eval_operators.py b/packages/typemap/src/typemap/type_eval/_eval_operators.py index 68bedec..137e645 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,47 @@ 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 qualifiers for TypedDict fields + annotations = [] + if type_eval.issubtype(typing.Literal["NotRequired"], tquals): + 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 + + # 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 + # 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) @_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..171df9c 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,142 @@ 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_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.""" + + 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__