Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions packages/typemap/src/typemap/type_eval/_eval_operators.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
Member,
Members,
NewProtocol,
NewTypedDict,
Omit,
Overloaded,
Param,
Expand Down Expand Up @@ -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):
Expand Down
15 changes: 15 additions & 0 deletions packages/typemap/src/typemap/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
141 changes: 141 additions & 0 deletions packages/typemap/tests/test_type_eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import collections
import textwrap
import typing
import unittest
from typing import (
Annotated,
Expand Down Expand Up @@ -44,6 +45,7 @@
Member,
Members,
NewProtocol,
NewTypedDict,
Omit,
Overloaded,
Param,
Expand Down Expand Up @@ -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__