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
29 changes: 28 additions & 1 deletion spec-draft.rst
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ Object inspection and creation


* ``Members[T]`` produces a ``tuple`` of ``Member`` types.
* ``Member[N: Literal[str], T, Q: MemberQuals, D]`` - ``N`` is the name, ``T`` is the type, ``Q`` is a union of qualifiers, ``D`` is the defining class of the member
* ``Member[N: Literal[str], T, Q: MemberQuals, Init, D]`` - ``N`` is the name, ``T`` is the type, ``Q`` is a union of qualifiers, ``Init`` is the literal type of the class initializer (under what conditions!?) and ``D`` is the defining class of the member
* ``MemberQuals = Literal['ClassVar', 'Final']`` - ``MemberQuals`` is the type of "qualifiers" that can apply to a member; currently ClassVar and Final

Methods are returned as callables using the new ``Param`` based extended callables. staticmethod and classmethod will return ``staticmethod`` and ``classmethod`` types, which are subscriptable as of 3.14.
Expand All @@ -159,6 +159,7 @@ We also have helpers for extracting those names; they are all definable in terms
* ``GetName[T: Member | Param]``
* ``GetType[T: Member | Param]``
* ``GetQuals[T: Member | Param]``
* ``GetInit[T: Member]``
* ``GetDefiner[T: Member]``

* ``NewProtocolWithBases[Bases, Ps: tuple[Member]]`` - A variant that allows specifying bases too. (UNIMPLEMENTED)
Expand All @@ -178,6 +179,8 @@ Callable inspection and creation

The names, type, and qualifiers share getter operations with ``Member``.

TODO: Should we make ``GetInit`` be literal types of default parameter values too?


----

Expand Down Expand Up @@ -205,6 +208,30 @@ We understand that this may be controversial, as currently Annotated may be full
DropAnnotations[int] = int


---------
InitField
---------

We want to be able to support transforming types based on dataclasses/attrs/pydantic style field descriptors. In order to do that, we need to be able to consume things like calls to ``Field``.

Our strategy for this is to introduce a new type ``InitField[KwargDict]`` that collects arguments defined by a ``KwargDict: TypedDict``::

class InitField[KwargDict: BaseTypedDict]:
def __init__(self, **kwargs: typing.Unpack[KwargDict]) -> None:
...

def _get_kwargs(self) -> KwargDict:
...

When ``InitField`` or (more likely) a subtype of it is instantiated inside a class body, we infer a *more specific* type for it, based on ``Literal`` types for all the arguments passed.

So if we write::

class A:
foo: int = InitField(default=0)

then we would infer the type ``InitField[TypedDict('...', {'default': Literal[0]})]`` for the initializer, and that would be made available as the ``Init`` field of the ``Member``.

-------------------
String manipulation
-------------------
Expand Down
6 changes: 5 additions & 1 deletion tests/format_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ def format_meth(name, meth):
code = f"class {cls.__name__}:\n"
for attr_name, attr_type in cls.__annotations__.items():
attr_type_s = annotationlib.type_repr(attr_type)
code += f" {attr_name}: {attr_type_s}\n"
if attr_name in cls.__dict__:
eq = f' = {cls.__dict__[attr_name]!r}'
else:
eq = ''
code += f" {attr_name}: {attr_type_s}{eq}\n"

for name, attr in cls.__dict__.items():
if attr is typing._no_init_or_replace_init:
Expand Down
9 changes: 1 addition & 8 deletions tests/test_fastapilike_1.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from . import format_helper


class PropQuals(enum.StrEnum):
class PropQuals(enum.Enum):
HIDDEN = "HIDDEN"
PRIMARY = "PRIMARY"
HAS_DEFAULT = "HAS_DEFAULT"
Expand Down Expand Up @@ -101,13 +101,6 @@ class _Default:
]
"""

type AllOptional[T] = NewProtocol[
*[
Member[GetName[p], GetType[p] | None, GetQuals[p]]
for p in Iter[Attrs[T]]
]
]

# Strip `| None` from a type by iterating over its union components
# and filtering
type NotOptional[T] = Union[*[x for x in Iter[FromUnion[T]] if not Is[x, None]]]
Expand Down
238 changes: 238 additions & 0 deletions tests/test_fastapilike_2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import textwrap

from typing import (
Callable,
Literal,
Union,
ReadOnly,
TypedDict,
Never,
Self,
)

from typemap.type_eval import eval_typing
from typemap.typing import (
NewProtocol,
Iter,
Attrs,
Is,
FromUnion,
GetArg,
GetAttr,
GetType,
GetName,
GetQuals,
GetInit,
InitField,
Member,
Members,
Param,
)

from . import format_helper


class FieldArgs(TypedDict, total=False):
hidden: ReadOnly[bool]
primary_key: ReadOnly[bool]
index: ReadOnly[bool]
default: ReadOnly[object]


class Field[T: FieldArgs](InitField[T]):
pass


####

# TODO: Should this go into the stdlib?
type GetFieldItem[T: InitField, K] = GetAttr[GetArg[T, InitField, 0], K]


##

# Strip `| None` from a type by iterating over its union components
# and filtering
type NotOptional[T] = Union[*[x for x in Iter[FromUnion[T]] if not Is[x, None]]]

# Adjust an attribute type for use in Public below by dropping | None for
# primary keys and stripping all annotations.
type FixPublicType[T, Init] = (
NotOptional[T]
if Is[Literal[True], GetFieldItem[Init, Literal["primary_key"]]]
else T
)

# Extract the default type from an Init field.
# If it is a Field, then we try pulling out the "default" field,
# otherwise we return the type itself.
type GetDefault[Init] = (
GetFieldItem[Init, Literal["default"]] if Is[Init, Field] else Init
)

# Strip out everything that is Hidden and also make the primary key required
# Drop all the annotations, since this is for data getting returned to users
# from the DB, so we don't need default values.
type Public[T] = NewProtocol[
*[
Member[GetName[p], FixPublicType[GetType[p], GetInit[p]], GetQuals[p]]
for p in Iter[Attrs[T]]
if not Is[Literal[True], GetFieldItem[GetInit[p], Literal["hidden"]]]
]
]

# Create takes everything but the primary key and preserves defaults
type Create[T] = NewProtocol[
*[
Member[GetName[p], GetType[p], GetQuals[p], GetDefault[GetInit[p]]]
for p in Iter[Attrs[T]]
if not Is[
Literal[True], GetFieldItem[GetInit[p], Literal["primary_key"]]
]
]
]

# Update takes everything but the primary key, but makes them all have
# None defaults
type Update[T] = NewProtocol[
*[
Member[
GetName[p],
GetType[p] | None,
GetQuals[p],
Literal[None],
]
for p in Iter[Attrs[T]]
if not Is[
Literal[True], GetFieldItem[GetInit[p], Literal["primary_key"]]
]
]
]

##

# Generate the Member field for __init__ for a class
type InitFnType[T] = Member[
Literal["__init__"],
Callable[
[
Param[Literal["self"], Self],
*[
Param[
GetName[p],
GetType[p],
# All arguments are keyword-only
# It takes a default if a default is specified in the class
Literal["keyword"]
if Is[
GetDefault[GetInit[p]],
Never,
]
else Literal["keyword", "default"],
]
for p in Iter[Attrs[T]]
],
],
None,
],
Literal["ClassVar"],
]
type AddInit[T] = NewProtocol[
InitFnType[T],
*[x for x in Iter[Members[T]]],
]


####

# This is the FastAPI example code that we are trying to repair!
# Adapted from https://fastapi.tiangolo.com/tutorial/sql-databases/#heroupdate-the-data-model-to-update-a-hero
"""
class HeroBase(SQLModel):
name: str = Field(index=True)
age: int | None = Field(default=None, index=True)


class Hero(HeroBase, table=True):
id: int | None = Field(default=None, primary_key=True)
secret_name: str


class HeroPublic(HeroBase):
id: int


class HeroCreate(HeroBase):
secret_name: str


class HeroUpdate(HeroBase):
name: str | None = None
age: int | None = None
secret_name: str | None = None
"""


class Hero:
id: int | None = Field(default=None, primary_key=True)

name: str = Field(index=True)
age: int | None = Field(default=None, index=True)

secret_name: str = Field(hidden=True)


#######


def test_fastapi_like_0():
tgt = eval_typing(AddInit[Hero])
fmt = format_helper.format_class(tgt)

assert fmt == textwrap.dedent("""\
class AddInit[tests.test_fastapilike_2.Hero]:
id: int | None = Field(default=None, primary_key=True)
name: str = Field(index=True)
age: int | None = Field(default=None, index=True)
secret_name: str = Field(hidden=True)
def __init__(self: Self, *, id: int | None = ..., name: str, age: int | None = ..., secret_name: str) -> None: ...
""")


def test_fastapi_like_1():
tgt = eval_typing(AddInit[Public[Hero]])
fmt = format_helper.format_class(tgt)

assert fmt == textwrap.dedent("""\
class AddInit[tests.test_fastapilike_2.Public[tests.test_fastapilike_2.Hero]]:
id: int
name: str
age: int | None
def __init__(self: Self, *, id: int, name: str, age: int | None) -> None: ...
""")


def test_fastapi_like_2():
tgt = eval_typing(AddInit[Create[Hero]])
fmt = format_helper.format_class(tgt)

assert fmt == textwrap.dedent("""\
class AddInit[tests.test_fastapilike_2.Create[tests.test_fastapilike_2.Hero]]:
name: str
age: int | None = None
secret_name: str
def __init__(self: Self, *, name: str, age: int | None = ..., secret_name: str) -> None: ...
""")


def test_fastapi_like_3():
tgt = eval_typing(AddInit[Update[Hero]])
fmt = format_helper.format_class(tgt)

assert fmt == textwrap.dedent("""\
class AddInit[tests.test_fastapilike_2.Update[tests.test_fastapilike_2.Hero]]:
name: str | None = None
age: int | None = None
secret_name: str | None = None
def __init__(self: Self, *, name: str | None = ..., age: int | None = ..., secret_name: str | None = ...) -> None: ...
""")
Loading