Skip to content

Commit 8bbf12d

Browse files
committed
A new approach to fastapi/dataclass interfaces
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`. Honestly this is pretty subtle and will probably be controversial, but maybe *less* controversial than the ``Annotated`` stuff and it will produce a better result than that, so... Take a look at test_fastapilike_2.py for the full fastapi + `__init__` generation example using this system.
1 parent 99bf1bb commit 8bbf12d

7 files changed

Lines changed: 384 additions & 22 deletions

File tree

spec-draft.rst

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ Object inspection and creation
146146

147147

148148
* ``Members[T]`` produces a ``tuple`` of ``Member`` types.
149-
* ``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
149+
* ``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
150150
* ``MemberQuals = Literal['ClassVar', 'Final']`` - ``MemberQuals`` is the type of "qualifiers" that can apply to a member; currently ClassVar and Final
151151

152152
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.
@@ -159,6 +159,7 @@ We also have helpers for extracting those names; they are all definable in terms
159159
* ``GetName[T: Member | Param]``
160160
* ``GetType[T: Member | Param]``
161161
* ``GetQuals[T: Member | Param]``
162+
* ``GetInit[T: Member]``
162163
* ``GetDefiner[T: Member]``
163164

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

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

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

182185
----
183186

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

207210

211+
---------
212+
InitField
213+
---------
214+
215+
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``.
216+
217+
Our strategy for this is to introduce a new type ``InitField[KwargDict]`` that collects arguments defined by a ``KwargDict: TypedDict``::
218+
219+
class InitField[KwargDict: BaseTypedDict]:
220+
def __init__(self, **kwargs: typing.Unpack[KwargDict]) -> None:
221+
...
222+
223+
def _get_kwargs(self) -> KwargDict:
224+
...
225+
226+
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.
227+
228+
So if we write::
229+
230+
class A:
231+
foo: int = InitField(default=0)
232+
233+
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``.
234+
208235
-------------------
209236
String manipulation
210237
-------------------

tests/format_helper.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ def format_meth(name, meth):
1818
code = f"class {cls.__name__}:\n"
1919
for attr_name, attr_type in cls.__annotations__.items():
2020
attr_type_s = annotationlib.type_repr(attr_type)
21-
code += f" {attr_name}: {attr_type_s}\n"
21+
if attr_name in cls.__dict__:
22+
eq = f' = {cls.__dict__[attr_name]!r}'
23+
else:
24+
eq = ''
25+
code += f" {attr_name}: {attr_type_s}{eq}\n"
2226

2327
for name, attr in cls.__dict__.items():
2428
if attr is typing._no_init_or_replace_init:

tests/test_fastapilike_1.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from . import format_helper
2525

2626

27-
class PropQuals(enum.StrEnum):
27+
class PropQuals(enum.Enum):
2828
HIDDEN = "HIDDEN"
2929
PRIMARY = "PRIMARY"
3030
HAS_DEFAULT = "HAS_DEFAULT"
@@ -101,13 +101,6 @@ class _Default:
101101
]
102102
"""
103103

104-
type AllOptional[T] = NewProtocol[
105-
*[
106-
Member[GetName[p], GetType[p] | None, GetQuals[p]]
107-
for p in Iter[Attrs[T]]
108-
]
109-
]
110-
111104
# Strip `| None` from a type by iterating over its union components
112105
# and filtering
113106
type NotOptional[T] = Union[*[x for x in Iter[FromUnion[T]] if not Is[x, None]]]

tests/test_fastapilike_2.py

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import textwrap
2+
3+
from typing import (
4+
Callable,
5+
Literal,
6+
Union,
7+
ReadOnly,
8+
TypedDict,
9+
Never,
10+
Self,
11+
)
12+
13+
from typemap.type_eval import eval_typing
14+
from typemap.typing import (
15+
NewProtocol,
16+
Iter,
17+
Attrs,
18+
Is,
19+
FromUnion,
20+
GetArg,
21+
GetAttr,
22+
GetType,
23+
GetName,
24+
GetQuals,
25+
GetInit,
26+
InitField,
27+
Member,
28+
Members,
29+
Param,
30+
)
31+
32+
from . import format_helper
33+
34+
35+
class FieldArgs(TypedDict, total=False):
36+
hidden: ReadOnly[bool]
37+
primary_key: ReadOnly[bool]
38+
index: ReadOnly[bool]
39+
default: ReadOnly[object]
40+
41+
42+
class Field[T: FieldArgs](InitField[T]):
43+
pass
44+
45+
46+
####
47+
48+
# TODO: Should this go into the stdlib?
49+
type GetFieldItem[T: InitField, K] = GetAttr[GetArg[T, InitField, 0], K]
50+
51+
52+
##
53+
54+
# Strip `| None` from a type by iterating over its union components
55+
# and filtering
56+
type NotOptional[T] = Union[*[x for x in Iter[FromUnion[T]] if not Is[x, None]]]
57+
58+
# Adjust an attribute type for use in Public below by dropping | None for
59+
# primary keys and stripping all annotations.
60+
type FixPublicType[T, Init] = (
61+
NotOptional[T]
62+
if Is[Literal[True], GetFieldItem[Init, Literal["primary_key"]]]
63+
else T
64+
)
65+
66+
# Extract the default type from an Init field.
67+
# If it is a Field, then we try pulling out the "default" field,
68+
# otherwise we return the type itself.
69+
type GetDefault[Init] = (
70+
GetFieldItem[Init, Literal["default"]] if Is[Init, Field] else Init
71+
)
72+
73+
# Strip out everything that is Hidden and also make the primary key required
74+
# Drop all the annotations, since this is for data getting returned to users
75+
# from the DB, so we don't need default values.
76+
type Public[T] = NewProtocol[
77+
*[
78+
Member[GetName[p], FixPublicType[GetType[p], GetInit[p]], GetQuals[p]]
79+
for p in Iter[Attrs[T]]
80+
if not Is[Literal[True], GetFieldItem[GetInit[p], Literal["hidden"]]]
81+
]
82+
]
83+
84+
# Create takes everything but the primary key and preserves defaults
85+
type Create[T] = NewProtocol[
86+
*[
87+
Member[GetName[p], GetType[p], GetQuals[p], GetDefault[GetInit[p]]]
88+
for p in Iter[Attrs[T]]
89+
if not Is[
90+
Literal[True], GetFieldItem[GetInit[p], Literal["primary_key"]]
91+
]
92+
]
93+
]
94+
95+
# Update takes everything but the primary key, but makes them all have
96+
# None defaults
97+
type Update[T] = NewProtocol[
98+
*[
99+
Member[
100+
GetName[p],
101+
GetType[p] | None,
102+
GetQuals[p],
103+
Literal[None],
104+
]
105+
for p in Iter[Attrs[T]]
106+
if not Is[
107+
Literal[True], GetFieldItem[GetInit[p], Literal["primary_key"]]
108+
]
109+
]
110+
]
111+
112+
##
113+
114+
# Generate the Member field for __init__ for a class
115+
type InitFnType[T] = Member[
116+
Literal["__init__"],
117+
Callable[
118+
[
119+
Param[Literal["self"], Self],
120+
*[
121+
Param[
122+
GetName[p],
123+
GetType[p],
124+
# All arguments are keyword-only
125+
# It takes a default if a default is specified in the class
126+
Literal["keyword"]
127+
if Is[
128+
GetDefault[GetInit[p]],
129+
Never,
130+
]
131+
else Literal["keyword", "default"],
132+
]
133+
for p in Iter[Attrs[T]]
134+
],
135+
],
136+
None,
137+
],
138+
Literal["ClassVar"],
139+
]
140+
type AddInit[T] = NewProtocol[
141+
InitFnType[T],
142+
*[x for x in Iter[Members[T]]],
143+
]
144+
145+
146+
####
147+
148+
# This is the FastAPI example code that we are trying to repair!
149+
# Adapted from https://fastapi.tiangolo.com/tutorial/sql-databases/#heroupdate-the-data-model-to-update-a-hero
150+
"""
151+
class HeroBase(SQLModel):
152+
name: str = Field(index=True)
153+
age: int | None = Field(default=None, index=True)
154+
155+
156+
class Hero(HeroBase, table=True):
157+
id: int | None = Field(default=None, primary_key=True)
158+
secret_name: str
159+
160+
161+
class HeroPublic(HeroBase):
162+
id: int
163+
164+
165+
class HeroCreate(HeroBase):
166+
secret_name: str
167+
168+
169+
class HeroUpdate(HeroBase):
170+
name: str | None = None
171+
age: int | None = None
172+
secret_name: str | None = None
173+
"""
174+
175+
176+
class Hero:
177+
id: int | None = Field(default=None, primary_key=True)
178+
179+
name: str = Field(index=True)
180+
age: int | None = Field(default=None, index=True)
181+
182+
secret_name: str = Field(hidden=True)
183+
184+
185+
#######
186+
187+
188+
def test_fastapi_like_0():
189+
tgt = eval_typing(AddInit[Hero])
190+
fmt = format_helper.format_class(tgt)
191+
192+
assert fmt == textwrap.dedent("""\
193+
class AddInit[tests.test_fastapilike_2.Hero]:
194+
id: int | None = Field(default=None, primary_key=True)
195+
name: str = Field(index=True)
196+
age: int | None = Field(default=None, index=True)
197+
secret_name: str = Field(hidden=True)
198+
def __init__(self: Self, *, id: int | None = ..., name: str, age: int | None = ..., secret_name: str) -> None: ...
199+
""")
200+
201+
202+
def test_fastapi_like_1():
203+
tgt = eval_typing(AddInit[Public[Hero]])
204+
fmt = format_helper.format_class(tgt)
205+
206+
assert fmt == textwrap.dedent("""\
207+
class AddInit[tests.test_fastapilike_2.Public[tests.test_fastapilike_2.Hero]]:
208+
id: int
209+
name: str
210+
age: int | None
211+
def __init__(self: Self, *, id: int, name: str, age: int | None) -> None: ...
212+
""")
213+
214+
215+
def test_fastapi_like_2():
216+
tgt = eval_typing(AddInit[Create[Hero]])
217+
fmt = format_helper.format_class(tgt)
218+
219+
assert fmt == textwrap.dedent("""\
220+
class AddInit[tests.test_fastapilike_2.Create[tests.test_fastapilike_2.Hero]]:
221+
name: str
222+
age: int | None = None
223+
secret_name: str
224+
def __init__(self: Self, *, name: str, age: int | None = ..., secret_name: str) -> None: ...
225+
""")
226+
227+
228+
def test_fastapi_like_3():
229+
tgt = eval_typing(AddInit[Update[Hero]])
230+
fmt = format_helper.format_class(tgt)
231+
232+
assert fmt == textwrap.dedent("""\
233+
class AddInit[tests.test_fastapilike_2.Update[tests.test_fastapilike_2.Hero]]:
234+
name: str | None = None
235+
age: int | None = None
236+
secret_name: str | None = None
237+
def __init__(self: Self, *, name: str | None = ..., age: int | None = ..., secret_name: str | None = ...) -> None: ...
238+
""")

0 commit comments

Comments
 (0)