Skip to content
Open
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
138 changes: 138 additions & 0 deletions packages/typemap/src/typemap/type_eval/_eval_operators.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
Param,
Partial,
Pick,
PropsOnly,
ConvertField,
RaiseError,
Required,
Slice,
Expand Down Expand Up @@ -1478,6 +1480,142 @@ def _eval_Partial(tp, *, ctx):
return type(class_name, (), {"__annotations__": new_annotations})


@type_eval.register_evaluator(PropsOnly)
def _eval_PropsOnly(tp, *, ctx):
"""Evaluate PropsOnly[T] to extract only Property fields.

Creates a new class containing only Property fields,
excluding relation fields (Link, MultiLink).
"""
from typing import get_args

tp = _eval_types(tp, ctx)

# Get all attributes
attrs_result = _eval_Attrs(tp, ctx=ctx)
attrs_args = get_args(attrs_result)

if not attrs_args:
# No attributes, return the type as-is
return tp

new_annotations = {}

for member in attrs_args:
member_type = _eval_types(member.type, ctx)

# Get the origin type (e.g., Property from Property[int])
origin = typing.get_origin(member_type)

if origin is not None:
# Check if origin is Property or a subclass of Pointer but not Link
origin_name = getattr(origin, "__name__", "")

# Include if it's a Property (not a Link or MultiLink)
if "Property" in origin_name or (
hasattr(origin, "__mro__")
and Pointer in origin.__mro__
and Link not in origin.__mro__
):
# Extract the type argument (e.g., int from Property[int])
type_arg = get_args(member_type)
if type_arg:
# Get the member name
name_result = _eval_types(member.name, ctx)
name = (
get_args(name_result)[0]
if hasattr(name_result, "__args__")
else name_result
)
new_annotations[name] = type_arg[0]

if not new_annotations:
return tp

class_name = (
f"PropsOnly_{tp.__name__ if hasattr(tp, '__name__') else 'Anonymous'}"
)
return type(class_name, (), {"__annotations__": new_annotations})


@type_eval.register_evaluator(ConvertField)
def _eval_ConvertField(tp, key, *, ctx):
"""Evaluate ConvertField[T, K] to extract underlying type from field.

Converts ORM field descriptors to their underlying Python types:
- Property[T] -> T
- Link[T] -> T (wrapped in PropsOnly for relations)
- MultiLink[T] -> list[T] (wrapped in PropsOnly)
"""
from typing import get_args, Literal

tp = _eval_types(tp, ctx)
key = _eval_types(key, ctx)

# Get the key name (should be a Literal)
key_name = get_args(key)[0] if hasattr(key, "__args__") else key

# Get the field type using GetMemberType
field_type = _eval_types(GetMemberType[tp, Literal[key_name]], ctx)

# Get the origin (e.g., Property from Property[int])
origin = typing.get_origin(field_type)

if origin is None:
# Not a generic type, return as-is
return field_type

# Get the type argument (e.g., int from Property[int])
type_arg = get_args(field_type)
if not type_arg:
return field_type

underlying_type = type_arg[0]

# Check if it's a Link/MultiLink (relation)
is_link = False
is_multilink = False

if hasattr(origin, "__mro__"):
if Link in origin.__mro__:
is_link = True
if MultiLink in origin.__mro__:
is_multilink = True

if is_link:
# It's a relation - apply PropsOnly to get only scalar fields
props_only_type = _eval_PropsOnly(underlying_type, ctx=ctx)

# If MultiLink, wrap in list
if is_multilink:
return list[props_only_type]

# Otherwise return the props only type
return props_only_type

# Regular Property - return the underlying type
return underlying_type


# Base classes for Link type detection
class Pointer[T]:
"""Base class for pointer types (Property, Link, MultiLink)."""

pass


class Link(Pointer):
"""Base class for linked types (one-to-one or one-to-many)."""

pass


class MultiLink(Link):
"""Base class for multi-link types (one-to-many relationships)."""

pass


@type_eval.register_evaluator(Required)
def _eval_Required(tp, *, ctx):
"""Evaluate Required[T] to remove Optional from all fields."""
Expand Down
34 changes: 34 additions & 0 deletions packages/typemap/src/typemap/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,40 @@ class Omit[T, K]:
pass


class PropsOnly[T]:
"""Extract only Property fields, excluding Link/MultiLink relations.

Creates a new class containing only Property fields,
excluding relation fields (Link, MultiLink).

Usage:
type UserProps = PropsOnly[User]
# Results in: {id: int, name: str, email: str}
# (posts and profile relations are excluded)
"""

pass


class ConvertField[T, K]:
"""Convert field type to underlying Python type.

Extracts the underlying type from ORM field descriptors:
- Property[int] -> int
- Link[User] -> User
- MultiLink[Post] -> list[Post] (via AdjustLink)

Usage:
type UserId = ConvertField[User, Literal['id']]
# If id: Property[int], returns: int

type UserPosts = ConvertField[User, Literal['posts']]
# If posts: MultiLink[Post], returns: list[Post]
"""

pass


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

# TODO: type better
Expand Down
80 changes: 80 additions & 0 deletions packages/typemap/tests/test_convert_field.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Tests for ConvertField type operator."""

import textwrap

from typing import Literal

from typemap.type_eval import eval_typing
import typemap_extensions as typing

from . import format_helper


# Define ORM-like types
class Pointer[T]:
"""Base pointer type."""

pass


class Property[T](Pointer[T]):
"""Property type for scalar fields."""

pass


class Link[T](Pointer[T]):
"""Link type for one-to-one relationships."""

pass


class MultiLink[T](Link[T]):
"""MultiLink type for one-to-many relationships."""

pass


# Define target types first to avoid forward references
class Profile:
id: Property[int]
bio: Property[str]


class Post:
id: Property[int]
title: Property[str]
content: Property[str]
author: Link[Profile]


# Test model with relations to already-defined types
class User:
id: Property[int]
name: Property[str]
email: Property[str]
posts: MultiLink[Post] # one-to-many
profile: Link[Profile] # one-to-one


def test_convertfield_property():
"""ConvertField should return underlying type for Property fields."""
result = eval_typing(typing.ConvertField[User, Literal["id"]])

assert result is int


def test_convertfield_property_string():
"""ConvertField should return underlying type for string Property."""
result = eval_typing(typing.ConvertField[User, Literal["name"]])

assert result is str


def test_convertfield_with_nonexistent_key():
"""ConvertField should handle non-existent keys gracefully."""
# This tests edge cases
result = eval_typing(typing.ConvertField[User, Literal["nonexistent"]])

# Should return something (the GetMemberType result)
assert result is not None