Skip to content
Closed
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
54 changes: 54 additions & 0 deletions packages/typemap/src/typemap/type_eval/_eval_operators.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
IsAssignable,
IsEquivalent,
Iter,
KeyOf,
Length,
Lowercase,
Member,
Expand All @@ -47,6 +48,7 @@
Slice,
SpecialFormEllipsis,
StrConcat,
Template,
Uncapitalize,
UpdateClass,
Uppercase,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)]


##################################################################


Expand Down
26 changes: 26 additions & 0 deletions packages/typemap/src/typemap/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
):
Expand All @@ -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

Expand Down
124 changes: 124 additions & 0 deletions packages/typemap/tests/test_type_eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
GetAnnotations,
IsAssignable,
Iter,
KeyOf,
Length,
IsEquivalent,
Member,
Expand All @@ -47,6 +48,7 @@
Slice,
SpecialFormEllipsis,
StrConcat,
Template,
UpdateClass,
Uppercase,
)
Expand Down Expand Up @@ -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"]