Skip to content

Commit ab4474f

Browse files
authored
Initial stages of PEP drafting (#37)
Basically just working on the motivation section now Also reworked the qb test a bit to mimic prisma a bit more for the example
1 parent fb16d9d commit ab4474f

4 files changed

Lines changed: 537 additions & 8 deletions

File tree

pre-pep.rst

Lines changed: 382 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
1+
PEP: <REQUIRED: pep number>
2+
Title: Type-level Computation
3+
Author: Michael J. Sullivan <sully@msully.net>, Daniel Park <dnwpark@protonmail.com>, Yury Selivanov <yury@vercel.com>
4+
Sponsor: <name of sponsor>
5+
PEP-Delegate: <PEP delegate's name>
6+
Discussions-To: Pending
7+
Status: DRAFT
8+
Type: Standards Track
9+
Topic: Typing
10+
Requires: <pep numbers>
11+
Created: <date created on, in dd-mmm-yyyy format>
12+
Python-Version: 3.15 or 3.16
13+
Post-History: Pending
14+
Resolution: <url>
15+
16+
17+
Abstract
18+
========
19+
20+
We propose to add powerful type-level type introspection and type
21+
construction facilities to the type system, inspired in large part by
22+
TypeScript's conditional and mapping types, but adapted to the quite
23+
different conditions of Python typing.
24+
25+
Motivation
26+
==========
27+
28+
Python has a gradual type system, but at the heart of it is a fairly
29+
conventional and tame static type system. In Python as a language, on
30+
the other hand, it is not unusual to perform complex metaprogramming,
31+
especially at the library layer.
32+
33+
Typically, type safety is lost when doing these sorts of things. Some
34+
libraries come with custom mypy plugins, and a special-case
35+
``@dataclass_transform`` decorator was added specifically to cover the
36+
case of dataclass-like transformations (:pep:`PEP 681 <681>`).
37+
38+
Examples: pydantic/fastapi, dataclasses, sqlalchemy
39+
40+
Automatically deriving FastAPI CRUD models
41+
------------------------------------------
42+
43+
In the `FastAPI tutorial <#fastapi-tutorial_>`_, they show how to
44+
build CRUD endpoints for a simple ``Hero`` type. At its heart is a
45+
series of class definitions used both to define the database interface
46+
and to perform validation/filtering of the data in the endpoint::
47+
48+
class HeroBase(SQLModel):
49+
name: str = Field(index=True)
50+
age: int | None = Field(default=None, index=True)
51+
52+
53+
class Hero(HeroBase, table=True):
54+
id: int | None = Field(default=None, primary_key=True)
55+
secret_name: str
56+
57+
58+
class HeroPublic(HeroBase):
59+
id: int
60+
61+
62+
class HeroCreate(HeroBase):
63+
secret_name: str
64+
65+
66+
class HeroUpdate(HeroBase):
67+
name: str | None = None
68+
age: int | None = None
69+
secret_name: str | None = None
70+
71+
72+
The ``HeroPublic`` type is used as the return types of the read
73+
endpoint (and is validated while being output, including having extra
74+
fields stripped), while ``HeroCreate`` and ``HeroUpdate`` serve as
75+
input types (automatically converted from JSON and validated based on
76+
the types, using `Pydantic <#pydantic_>`_).
77+
78+
Despite all multiple types and duplication here, mechanical rules
79+
could be written for deriving these types:
80+
* Public should include all non-"hidden" fields, and the primary key
81+
should be made non-optional
82+
* Create should include all fields except the primary key
83+
* Update should include all fields except the primary key, but they
84+
should all be made optional and given a default value
85+
86+
With the definition of appropriate helpers, this proposal would allow writing::
87+
88+
class Hero(NewSQLModel, table=True):
89+
id: int | None = Field(default=None, primary_key=True)
90+
91+
name: str = Field(index=True)
92+
age: int | None = Field(default=None, index=True)
93+
94+
secret_name: str = Field(hidden=True)
95+
96+
type HeroPublic = Public[Hero]
97+
type HeroCreate = Create[Hero]
98+
type HeroUpdate = Update[Hero]
99+
100+
Those types, evaluated, would look something like::
101+
102+
class HeroPublic:
103+
id: int
104+
name: str
105+
age: int | None
106+
107+
108+
class HeroCreate:
109+
name: str
110+
age: int | None = None
111+
secret_name: str
112+
113+
114+
class HeroUpdate:
115+
name: str | None = None
116+
age: int | None = None
117+
secret_name: str | None = None
118+
119+
120+
121+
While the implementation of ``Public``, ``Create``, and ``Update``
122+
(presented in the next subsection) are certainly more complex than
123+
duplicating code would be, they perform quite mechanical operations
124+
and could be included in the framework library.
125+
126+
A notable feature of this use case is that it **depends on performing
127+
runtime evaluation of the type annotations**. FastAPI uses the
128+
Pydantic models to validate and convert to/from JSON for both input
129+
and output from endpoints.
130+
131+
132+
Implementation
133+
''''''''''''''
134+
135+
We have a more `fully-worked example <#fastapi-test_>`_ in our test
136+
suite, but here is a possible implementation of just ``Public``::
137+
138+
# Extract the default type from an Init field.
139+
# If it is a Field, then we try pulling out the "default" field,
140+
# otherwise we return the type itself.
141+
type GetDefault[Init] = (
142+
GetFieldItem[Init, Literal["default"]] if Sub[Init, Field] else Init
143+
)
144+
145+
# Create takes everything but the primary key and preserves defaults
146+
type Create[T] = NewProtocol[
147+
*[
148+
Member[GetName[p], GetType[p], GetQuals[p], GetDefault[GetInit[p]]]
149+
for p in Iter[Attrs[T]]
150+
if not Sub[
151+
Literal[True], GetFieldItem[GetInit[p], Literal["primary_key"]]
152+
]
153+
]
154+
]
155+
156+
The ``Create`` type alias creates a new type (via ``NewProtocol``) by
157+
iterating over the attributes of the original type. It has access to
158+
names, types, qualifiers, and the literal types of initializers (in
159+
part through new facilities to handle the extremely common
160+
``= Field(...)`` like pattern used here.
161+
162+
Here, we filter out attributes that have ``primary_key=True`` in their
163+
``Field`` as well as extracting default arguments (which may be either
164+
from a ``default`` argument to a field or specified directly as an
165+
initializer).
166+
167+
168+
Prisma-style ORMs
169+
-----------------
170+
171+
`Prisma <#prisma_>`_, a popular ORM for TypeScript, allows writing
172+
queries like (adapted from `this example <#prisma-example_>`_::
173+
174+
const user = await prisma.user.findMany({
175+
select: {
176+
name: true,
177+
email: true,
178+
posts: true,
179+
},
180+
});
181+
182+
for which the inferred type will be something like::
183+
184+
{
185+
email: string;
186+
name: string | null;
187+
posts: {
188+
id: number;
189+
title: string;
190+
content: string | null;
191+
authorId: number | null;
192+
}[];
193+
}[]
194+
195+
Here, the output type is a combination of both existing information
196+
about the type of ``prisma.user`` and the type of the argument to
197+
``findMany``. It returns an array of objects containing the properties
198+
of ``user`` that were requested; one of the requested elements,
199+
``posts``, is a "relation" referencing another model; it has *all* of
200+
its properties fetched but not its relations.
201+
202+
We would like to be able to do something similar in Python, perhaps
203+
with a schema defined like::
204+
205+
class Comment:
206+
id: Property[int]
207+
name: Property[str]
208+
poster: Link[User]
209+
210+
211+
class Post:
212+
id: Property[int]
213+
214+
title: Property[str]
215+
content: Property[str]
216+
217+
comments: MultiLink[Comment]
218+
author: Link[Comment]
219+
220+
221+
class User:
222+
id: Property[int]
223+
224+
name: Property[str]
225+
email: Property[str]
226+
posts: Link[Post]
227+
228+
(In Prisma, a code generator generates type definitions based on a
229+
prisma schema in its own custom format; you could imagine something
230+
similar here, or that the definitions were hand written)
231+
232+
and a call like::
233+
234+
db.select(
235+
User,
236+
name=True,
237+
email=True,
238+
posts=True,
239+
)
240+
241+
which would have return type ``list[<User>]`` where::
242+
243+
class <User>:
244+
name: str
245+
email: str
246+
posts: list[<Post>]
247+
248+
class <Post>
249+
id: int
250+
title: str
251+
content: str
252+
253+
254+
Implementation
255+
''''''''''''''
256+
257+
We have a more `worked example <#qb-test_>`_ in our test suite.
258+
259+
dataclasses-style method generation
260+
-----------------------------------
261+
262+
We would additionally like to be able to generate method signatures
263+
based on the attributes of an object. The most well-known example of
264+
this is probably generating ``__init__`` methods for dataclasses,
265+
which we present a simplified example of. (In our test suites, this is
266+
merged with the FastAPI-style example above, but it need not be).
267+
268+
This kind of pattern is widespread enough that :pep:`PEP 681 <681>`
269+
was created to represent a lowest-common denominator subset of what
270+
existing libraries do.
271+
272+
::
273+
# Generate the Member field for __init__ for a class
274+
type InitFnType[T] = Member[
275+
Literal["__init__"],
276+
Callable[
277+
[
278+
Param[Literal["self"], Self],
279+
*[
280+
Param[
281+
GetName[p],
282+
GetType[p],
283+
# All arguments are keyword-only
284+
# It takes a default if a default is specified in the class
285+
Literal["keyword"]
286+
if Sub[
287+
GetDefault[GetInit[p]],
288+
Never,
289+
]
290+
else Literal["keyword", "default"],
291+
]
292+
for p in Iter[Attrs[T]]
293+
],
294+
],
295+
None,
296+
],
297+
Literal["ClassVar"],
298+
]
299+
type AddInit[T] = NewProtocol[
300+
InitFnType[T],
301+
*[x for x in Iter[Members[T]]],
302+
]
303+
304+
305+
Rationale
306+
=========
307+
308+
[Describe why particular design decisions were made.]
309+
310+
311+
Specification
312+
=============
313+
314+
[Describe the syntax and semantics of any new language feature.]
315+
316+
317+
Backwards Compatibility
318+
=======================
319+
320+
[Describe potential impact and severity on pre-existing code.]
321+
322+
323+
Security Implications
324+
=====================
325+
326+
None are expected.
327+
328+
329+
How to Teach This
330+
=================
331+
332+
Honestly this seems very hard!
333+
334+
335+
Reference Implementation
336+
========================
337+
338+
[Link to any existing implementation and details about its state, e.g. proof-of-concept.]
339+
340+
341+
Rejected Ideas
342+
==============
343+
344+
[Why certain ideas that were brought while discussing this PEP were not ultimately pursued.]
345+
346+
347+
Open Issues
348+
===========
349+
350+
* What is the best way to type base-class driven transformations using
351+
``__init_subclass__`` or (*shudder* metaclasses).
352+
353+
* How to deal with situations where we are building new *nominal*
354+
types and might want to reference them?
355+
356+
[Any points that are still being decided/discussed.]
357+
358+
359+
Acknowledgements
360+
================
361+
362+
Jukka Lehtosalo
363+
364+
[Thank anyone who has helped with the PEP.]
365+
366+
367+
Footnotes
368+
=========
369+
370+
.. _#fastapi: https://fastapi.tiangolo.com/
371+
.. _#pydantic: https://docs.pydantic.dev/latest/
372+
.. _#fastapi-tutorial: https://fastapi.tiangolo.com/tutorial/sql-databases/#heroupdate-the-data-model-to-update-a-hero
373+
.. _#fastapi-test: https://github.com/geldata/typemap/blob/main/tests/test_fastapilike_2.py
374+
.. _#prisma: https://www.prisma.io/
375+
.. _#prisma-example: https://github.com/prisma/prisma-examples/tree/latest/orm/express
376+
.. _#qb-test: https://github.com/geldata/typemap/blob/main/tests/test_qblike_2.py
377+
378+
Copyright
379+
=========
380+
381+
This document is placed in the public domain or under the
382+
CC0-1.0-Universal license, whichever is more permissive.

0 commit comments

Comments
 (0)