diff --git a/packages/typemap/src/typemap/type_eval/_eval_operators.py b/packages/typemap/src/typemap/type_eval/_eval_operators.py index 73db59d..232b28d 100644 --- a/packages/typemap/src/typemap/type_eval/_eval_operators.py +++ b/packages/typemap/src/typemap/type_eval/_eval_operators.py @@ -36,6 +36,7 @@ IsAssignable, IsEquivalent, Iter, + KeyOf, Length, Lowercase, Member, @@ -47,6 +48,7 @@ Slice, SpecialFormEllipsis, StrConcat, + Template, Uncapitalize, UpdateClass, Uppercase, @@ -1138,6 +1140,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 +1197,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)] + + ################################################################## diff --git a/packages/typemap/src/typemap/typing.py b/packages/typemap/src/typemap/typing.py index df3db5a..2f42152 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 diff --git a/packages/typemap/tests/test_type_eval.py b/packages/typemap/tests/test_type_eval.py index f557e4d..2543cdb 100644 --- a/packages/typemap/tests/test_type_eval.py +++ b/packages/typemap/tests/test_type_eval.py @@ -37,6 +37,7 @@ GetAnnotations, IsAssignable, Iter, + KeyOf, Length, IsEquivalent, Member, @@ -47,6 +48,7 @@ Slice, SpecialFormEllipsis, StrConcat, + Template, UpdateClass, Uppercase, ) @@ -2648,3 +2650,125 @@ 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"]