diff --git a/packages/typemap/src/typemap/type_eval/_eval_operators.py b/packages/typemap/src/typemap/type_eval/_eval_operators.py index 73db59d..8fd6ec6 100644 --- a/packages/typemap/src/typemap/type_eval/_eval_operators.py +++ b/packages/typemap/src/typemap/type_eval/_eval_operators.py @@ -24,6 +24,7 @@ Bool, Capitalize, DropAnnotations, + DeepPartial, FromUnion, GenericCallable, GetAnnotations, @@ -36,6 +37,7 @@ IsAssignable, IsEquivalent, Iter, + KeyOf, Length, Lowercase, Member, @@ -47,6 +49,7 @@ Slice, SpecialFormEllipsis, StrConcat, + Template, Uncapitalize, UpdateClass, Uppercase, @@ -1138,6 +1141,27 @@ def _eval_Length(tp, *, ctx) -> typing.Any: raise TypeError(f"Invalid type argument to Length: {tp} is not a tuple") +@type_eval.register_evaluator(KeyOf) +@_lift_over_unions +def _eval_KeyOf(tp, *, ctx): + """Evaluate KeyOf[T] to get all member names as a tuple of Literals.""" + tp = _eval_types(tp, ctx) + hints = get_annotated_type_hints( + tp, include_extras=True, attrs_only=True, ctx=ctx + ) + + if not hints: + return typing.Literal[()] + + # Extract member names and create tuple of Literal types + names = [] + for name in hints: + names.append(typing.Literal[name]) + + # Return as tuple of Literal types (use unpacking to make it hashable) + return tuple[*names] # type: ignore[return-value] + + @type_eval.register_evaluator(Slice) @_lift_over_unions def _eval_Slice(tp, start, end, *, ctx): @@ -1174,6 +1198,37 @@ def func(*args, ctx): _string_literal_op(StrConcat, op=lambda s, t: s + t) +@type_eval.register_evaluator(Template) +def _eval_Template(*parts, ctx): + """Evaluate Template to concatenate all string parts.""" + evaluated_parts = [] + for part in parts: + evaled = _eval_types(part, ctx) + if _typing_inspect.is_generic_alias(evaled): + if evaled.__origin__ is typing.Literal: + # Extract literal string value + lit_val = evaled.__args__[0] + if isinstance(lit_val, str): + evaluated_parts.append(lit_val) + else: + raise TypeError( + f"Template parts must be string literals, got {lit_val}" + ) + else: + raise TypeError( + f"Template parts must be string literals, got {evaled}" + ) + elif isinstance(evaled, str): + # Plain string (shouldn't happen but handle it) + evaluated_parts.append(evaled) + else: + raise TypeError( + f"Template parts must be string literals, got {type(evaled)}" + ) + + return typing.Literal["".join(evaluated_parts)] + + ################################################################## @@ -1198,6 +1253,75 @@ def _eval_RaiseError(msg, *extra_types, ctx): raise TypeMapError(message) +@type_eval.register_evaluator(DeepPartial) +def _eval_DeepPartial(tp, *, ctx): + """Evaluate DeepPartial[T] to create a class with all fields optional. + + For each field in T: + - If it's a primitive type, make it optional (T | None) + - If it's a complex type (class with attrs), recursively apply DeepPartial + - Create a new class with all optional fields + """ + from typing import get_args + + tp = _eval_types(tp, ctx) + + # Get attributes using Attrs to get Member objects + attrs_result = _eval_Attrs(tp, ctx=ctx) + attrs_args = get_args(attrs_result) + + if not attrs_args: + # No fields, return the original type + return tp + + new_annotations = {} + + for member in attrs_args: + # Get the member name and type + # member.name is an associated type, need to evaluate it + name_result = _eval_types(member.name, ctx) + name = ( + get_args(name_result)[0] + if hasattr(name_result, "__args__") + else name_result + ) + + # member.type is an associated type, need to evaluate it + type_result = _eval_types(member.type, ctx) + + # Check if this is a complex type (class with its own attributes) + if isinstance(type_result, type): + # Try to get attrs of this type, but don't recurse if it fails + # (nested types may not be in evaluation context) + try: + nested_attrs = _eval_Attrs(type_result, ctx=ctx) + nested_args = get_args(nested_attrs) + if nested_args: + # Has nested fields - try to recursively apply + # but catch any errors (e.g., NameError for undefined types) + try: + nested_partial = _eval_DeepPartial(type_result, ctx=ctx) + new_annotations[name] = nested_partial | None + except NameError, TypeError: + # If recursion fails, just make optional + new_annotations[name] = type_result | None + else: + # No nested fields, just make optional + new_annotations[name] = type_result | None + except NameError, TypeError, AttributeError: + # If we can't inspect it, just make optional + new_annotations[name] = type_result | None + else: + # Primitive or unknown type, just make optional + new_annotations[name] = type_result | None + + # Create a new class with optional fields + class_name = ( + f"DeepPartial_{tp.__name__ if hasattr(tp, '__name__') else 'Anonymous'}" + ) + return type(class_name, (), {"__annotations__": new_annotations}) + + ################################################################## diff --git a/packages/typemap/src/typemap/typing.py b/packages/typemap/src/typemap/typing.py index df3db5a..72ca56f 100644 --- a/packages/typemap/src/typemap/typing.py +++ b/packages/typemap/src/typemap/typing.py @@ -262,6 +262,19 @@ class Length[S: tuple]: pass +class KeyOf[T]: + """Return all member names as a tuple. + + Usage: + type Keys = KeyOf[User] + # Returns tuple[Literal['name'], Literal['email'], Literal['age']] + + This is similar to TypeScript's keyof operator. + """ + + pass + + class Slice[S: str | tuple, Start: int | None, End: int | None]( _TupleLikeOperator ): @@ -288,6 +301,19 @@ class StrConcat[S: str, T: str]: pass +class Template[*Parts]: + """Template literal string builder. + + Usage: + type Route = Template['/', Resource, '/id'] + # For Resource = 'users', returns: '/users/id' + + This allows building string templates from parts that get concatenated. + """ + + pass + + class NewProtocol[*T]: pass @@ -309,6 +335,22 @@ class RaiseError[S: str, *Ts]: pass +class DeepPartial[T]: + """Make all fields recursively optional. + + For each field in T: + - If it's a primitive type (int, str, etc.), make it optional + - If it's a complex type (class with attrs), recursively apply DeepPartial + + Usage: + type DeepUser = DeepPartial[User] + + Note: This creates a new class at runtime with all fields optional. + """ + + pass + + ################################################################## # TODO: type better diff --git a/packages/typemap/tests/test_type_eval.py b/packages/typemap/tests/test_type_eval.py index f557e4d..27cfe38 100644 --- a/packages/typemap/tests/test_type_eval.py +++ b/packages/typemap/tests/test_type_eval.py @@ -27,6 +27,7 @@ from typemap_extensions import ( Attrs, Bool, + DeepPartial, FromUnion, GenericCallable, GetArg, @@ -37,6 +38,7 @@ GetAnnotations, IsAssignable, Iter, + KeyOf, Length, IsEquivalent, Member, @@ -47,6 +49,7 @@ Slice, SpecialFormEllipsis, StrConcat, + Template, UpdateClass, Uppercase, ) @@ -2648,3 +2651,177 @@ def test_raise_error_with_literal_types(): eval_typing( RaiseError[Literal["Shape mismatch"], Literal[4], Literal[3]] ) + + +############## +# KeyOf tests + + +def test_keyof_basic(): + """Test KeyOf returns tuple of Literal member names.""" + + class User: + name: str + age: int + email: str + + result = eval_typing(KeyOf[User]) + assert ( + result + == tuple[ + Literal["name"], + Literal["age"], + Literal["email"], + ] + ) + + +def test_keyof_single_field(): + """Test KeyOf with a single field class.""" + + class Single: + value: int + + result = eval_typing(KeyOf[Single]) + assert result == tuple[Literal["value"]] + + +def test_keyof_empty_class(): + """Test KeyOf with a class with no fields.""" + + class Empty: + pass + + result = eval_typing(KeyOf[Empty]) + assert result == Literal[()] + + +def test_keyof_with_methods(): + """Test KeyOf ignores methods and only returns field names.""" + + class WithMethods: + name: str + + def method(self) -> None: ... + + result = eval_typing(KeyOf[WithMethods]) + assert result == tuple[Literal["name"]] + + +def test_keyof_with_inheritance(): + """Test KeyOf includes inherited fields.""" + + class Base: + id: int + + class Child(Base): + name: str + + result = eval_typing(KeyOf[Child]) + assert ( + result + == tuple[ + Literal["id"], + Literal["name"], + ] + ) + + +############## +# Template tests + + +def test_template_basic(): + """Test Template concatenates string literals.""" + + result = eval_typing(Template[Literal["hello"], Literal["world"]]) + assert result == Literal["helloworld"] + + +def test_template_with_variable(): + """Test Template with a type variable.""" + + Resource = Literal["users"] + result = eval_typing(Template[Literal["/"], Resource, Literal["/id"]]) + assert result == Literal["/users/id"] + + +def test_template_single_part(): + """Test Template with a single part.""" + + result = eval_typing(Template[Literal["hello"]]) + assert result == Literal["hello"] + + +def test_template_empty(): + """Test Template with empty string.""" + + result = eval_typing(Template[Literal[""], Literal["hello"]]) + assert result == Literal["hello"] + + +def test_template_multiple_parts(): + """Test Template with multiple parts forming a path.""" + + result = eval_typing( + Template[ + Literal["api"], + Literal["/"], + Literal["v1"], + Literal["/"], + Literal["users"], + ] + ) + assert result == Literal["api/v1/users"] + + +############## +# DeepPartial tests + + +def test_deep_partial_basic(): + """Test DeepPartial makes all fields optional.""" + + class User: + name: str + age: int + + result = eval_typing(DeepPartial[User]) + assert result.__annotations__["name"] == str | None + assert result.__annotations__["age"] == int | None + + +def test_deep_partial_multiple_fields(): + """Test DeepPartial with multiple fields of different types.""" + + class User: + name: str + age: int + email: str + active: bool + + result = eval_typing(DeepPartial[User]) + assert result.__annotations__["name"] == str | None + assert result.__annotations__["age"] == int | None + assert result.__annotations__["email"] == str | None + assert result.__annotations__["active"] == bool | None + + +def test_deep_partial_empty_class(): + """Test DeepPartial with a class that has no fields.""" + + class Empty: + pass + + result = eval_typing(DeepPartial[Empty]) + assert result == Empty + + +def test_deep_partial_preserves_name(): + """Test DeepPartial creates a class with meaningful name.""" + + class User: + name: str + + result = eval_typing(DeepPartial[User]) + assert "DeepPartial" in result.__name__