fastapi-restly 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastapi_restly/__init__.py +106 -0
- fastapi_restly/_exception_handlers.py +194 -0
- fastapi_restly/_pytest_fixtures.py +256 -0
- fastapi_restly/db/__init__.py +31 -0
- fastapi_restly/db/_globals.py +76 -0
- fastapi_restly/db/_proxy.py +42 -0
- fastapi_restly/db/_session.py +275 -0
- fastapi_restly/exceptions.py +12 -0
- fastapi_restly/models/__init__.py +18 -0
- fastapi_restly/models/_base.py +84 -0
- fastapi_restly/objects.py +144 -0
- fastapi_restly/py.typed +0 -0
- fastapi_restly/pytest_fixtures.py +24 -0
- fastapi_restly/query/__init__.py +13 -0
- fastapi_restly/query/_impl.py +594 -0
- fastapi_restly/query/_shared.py +22 -0
- fastapi_restly/schemas/__init__.py +29 -0
- fastapi_restly/schemas/_base.py +518 -0
- fastapi_restly/schemas/_generator.py +382 -0
- fastapi_restly/testing/__init__.py +20 -0
- fastapi_restly/testing/_client.py +98 -0
- fastapi_restly/testing/_fixtures.py +20 -0
- fastapi_restly/views/__init__.py +40 -0
- fastapi_restly/views/_async.py +216 -0
- fastapi_restly/views/_base.py +1294 -0
- fastapi_restly/views/_openapi.py +206 -0
- fastapi_restly/views/_react_admin.py +393 -0
- fastapi_restly/views/_sync.py +213 -0
- fastapi_restly-0.5.0.dist-info/METADATA +407 -0
- fastapi_restly-0.5.0.dist-info/RECORD +34 -0
- fastapi_restly-0.5.0.dist-info/WHEEL +5 -0
- fastapi_restly-0.5.0.dist-info/entry_points.txt +2 -0
- fastapi_restly-0.5.0.dist-info/licenses/LICENSE +21 -0
- fastapi_restly-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1294 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides a framework for class-based views on SQLAlchemy models.
|
|
3
|
+
|
|
4
|
+
View class:
|
|
5
|
+
This class is used to create a collection of endpoints that share an
|
|
6
|
+
APIRouter (created when calling `include_view()`) and dependencies
|
|
7
|
+
as class attributes. It uses the same mechanics as the class based
|
|
8
|
+
view decorator from fastapi-utils.
|
|
9
|
+
(https://fastapi-utils.davidmontague.xyz/user-guide/class-based-views/)
|
|
10
|
+
|
|
11
|
+
AsyncRestView:
|
|
12
|
+
Provides default reading and writing functions on the database using
|
|
13
|
+
SQLAlchemy models.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import dataclasses
|
|
17
|
+
import functools
|
|
18
|
+
import inspect
|
|
19
|
+
import types
|
|
20
|
+
from enum import Enum
|
|
21
|
+
from math import ceil
|
|
22
|
+
from typing import (
|
|
23
|
+
Annotated,
|
|
24
|
+
Any,
|
|
25
|
+
Callable,
|
|
26
|
+
ClassVar,
|
|
27
|
+
Generic,
|
|
28
|
+
Iterable,
|
|
29
|
+
Iterator,
|
|
30
|
+
Protocol,
|
|
31
|
+
Sequence,
|
|
32
|
+
Union,
|
|
33
|
+
cast,
|
|
34
|
+
get_args,
|
|
35
|
+
get_origin,
|
|
36
|
+
get_type_hints,
|
|
37
|
+
overload,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
import fastapi
|
|
41
|
+
import pydantic
|
|
42
|
+
from fastapi import BackgroundTasks, Request, Response, WebSocket
|
|
43
|
+
from fastapi.params import Depends as _DependsMarker
|
|
44
|
+
from pydantic import create_model
|
|
45
|
+
from sqlalchemy import inspect as sa_inspect
|
|
46
|
+
from sqlalchemy.orm import DeclarativeBase, selectinload
|
|
47
|
+
from starlette.datastructures import QueryParams
|
|
48
|
+
from typing_extensions import TypeVar
|
|
49
|
+
|
|
50
|
+
from .._exception_handlers import register_default_exception_handlers
|
|
51
|
+
from ..query import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE, create_list_params_schema
|
|
52
|
+
from ..schemas import BaseSchema, IDRef, IDSchema
|
|
53
|
+
from ..schemas._base import (
|
|
54
|
+
create_model_with_optional_fields,
|
|
55
|
+
create_model_without_read_only_fields,
|
|
56
|
+
get_writable_inputs,
|
|
57
|
+
is_readonly_field,
|
|
58
|
+
is_writeonly_field,
|
|
59
|
+
)
|
|
60
|
+
from ..schemas._generator import auto_generate_schema_for_view
|
|
61
|
+
from ._openapi import _register_for_resource_ref
|
|
62
|
+
|
|
63
|
+
ModelT = TypeVar("ModelT", bound=DeclarativeBase, default=DeclarativeBase)
|
|
64
|
+
SchemaT = TypeVar("SchemaT", bound=pydantic.BaseModel, default=BaseSchema)
|
|
65
|
+
CreateSchemaT = TypeVar(
|
|
66
|
+
"CreateSchemaT", bound=pydantic.BaseModel, default=pydantic.BaseModel
|
|
67
|
+
)
|
|
68
|
+
UpdateSchemaT = TypeVar(
|
|
69
|
+
"UpdateSchemaT", bound=pydantic.BaseModel, default=pydantic.BaseModel
|
|
70
|
+
)
|
|
71
|
+
IdT = TypeVar("IdT", default=int)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclasses.dataclass(frozen=True)
|
|
75
|
+
class ListingResult(Generic[ModelT]):
|
|
76
|
+
"""Result returned by ``perform_listing`` before HTTP response formatting."""
|
|
77
|
+
|
|
78
|
+
objects: Sequence[ModelT]
|
|
79
|
+
total_count: int
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ViewRoute(str, Enum):
|
|
83
|
+
"""Generated CRUD routes that can be referenced by view options."""
|
|
84
|
+
|
|
85
|
+
LIST = "listing"
|
|
86
|
+
GET = "get"
|
|
87
|
+
CREATE = "create"
|
|
88
|
+
UPDATE = "update"
|
|
89
|
+
DELETE = "delete"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _accepts_init_kwarg(model_cls: type, attr_name: str) -> bool:
|
|
93
|
+
"""Return True if attr_name can be passed as a keyword argument to model_cls.__init__.
|
|
94
|
+
|
|
95
|
+
Non-dataclass models (DeclarativeBase subclasses using mapped_column) accept all
|
|
96
|
+
kwargs. Dataclass-based models may have fields with init=False, in which case
|
|
97
|
+
passing the attribute to __init__ raises TypeError.
|
|
98
|
+
"""
|
|
99
|
+
if not dataclasses.is_dataclass(model_cls):
|
|
100
|
+
return True
|
|
101
|
+
dc_fields = {f.name: f for f in dataclasses.fields(model_cls)}
|
|
102
|
+
return attr_name not in dc_fields or dc_fields[attr_name].init
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _requires_init_kwarg(model_cls: type, attr_name: str) -> bool:
|
|
106
|
+
if not dataclasses.is_dataclass(model_cls):
|
|
107
|
+
return False
|
|
108
|
+
dc_fields = {f.name: f for f in dataclasses.fields(model_cls)}
|
|
109
|
+
field = dc_fields.get(attr_name)
|
|
110
|
+
if field is None or not field.init:
|
|
111
|
+
return False
|
|
112
|
+
return (
|
|
113
|
+
field.default is dataclasses.MISSING
|
|
114
|
+
and field.default_factory is dataclasses.MISSING
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclasses.dataclass
|
|
119
|
+
class _CreatePlan:
|
|
120
|
+
kwargs: dict[str, Any]
|
|
121
|
+
post_assignments: dict[str, Any]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _HasID(Protocol):
|
|
125
|
+
"""Anything with an ``id`` attribute. By framework convention, primary
|
|
126
|
+
keys are named ``id``; ``IDBase`` formalizes this but isn't required."""
|
|
127
|
+
|
|
128
|
+
id: Any
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _has_model_attr(model_cls: type[DeclarativeBase], attr_name: str) -> bool:
|
|
132
|
+
return hasattr(model_cls, attr_name)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _get_relationship_property(
|
|
136
|
+
model_cls: type[DeclarativeBase], relation_name: str
|
|
137
|
+
) -> Any | None:
|
|
138
|
+
try:
|
|
139
|
+
mapper = sa_inspect(model_cls)
|
|
140
|
+
except Exception:
|
|
141
|
+
return None
|
|
142
|
+
return mapper.relationships.get(relation_name)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _get_unambiguous_local_fk_name(
|
|
146
|
+
model_cls: type[DeclarativeBase], relation_name: str
|
|
147
|
+
) -> str | None:
|
|
148
|
+
relationship_property = _get_relationship_property(model_cls, relation_name)
|
|
149
|
+
if relationship_property is None:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
if getattr(relationship_property.direction, "name", None) != "MANYTOONE":
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
local_columns = list(relationship_property.local_columns)
|
|
156
|
+
if len(local_columns) != 1:
|
|
157
|
+
column_names = ", ".join(column.key for column in local_columns) or "<none>"
|
|
158
|
+
raise ValueError(
|
|
159
|
+
f"Cannot infer a single local FK for relationship "
|
|
160
|
+
f"{model_cls.__name__}.{relation_name}; found {column_names}. "
|
|
161
|
+
"Use an explicit custom handler for this relationship."
|
|
162
|
+
)
|
|
163
|
+
return local_columns[0].key
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _is_reference_schema_field(
|
|
167
|
+
schema_cls: type[pydantic.BaseModel], field_name: str
|
|
168
|
+
) -> bool:
|
|
169
|
+
field_info = schema_cls.model_fields.get(field_name)
|
|
170
|
+
if field_info is None:
|
|
171
|
+
return False
|
|
172
|
+
return _is_idschema_reference_annotation(field_info.annotation)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _add_assignment(target: dict[str, Any], field_name: str | None, value: Any) -> None:
|
|
176
|
+
if field_name:
|
|
177
|
+
target[field_name] = value
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
_EXPLICIT_NULL_REF = object()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _reference_identity(value: Any) -> tuple[type[Any] | None, Any] | object | None:
|
|
184
|
+
if value is None:
|
|
185
|
+
return _EXPLICIT_NULL_REF
|
|
186
|
+
if isinstance(value, DeclarativeBase):
|
|
187
|
+
return type(value), getattr(value, "id", None)
|
|
188
|
+
if isinstance(value, IDSchema):
|
|
189
|
+
sql_model = value.get_sql_model_annotation()
|
|
190
|
+
return sql_model, value.id
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _reference_identity_detail(identity: object) -> Any:
|
|
195
|
+
if identity is _EXPLICIT_NULL_REF:
|
|
196
|
+
return None
|
|
197
|
+
if isinstance(identity, tuple) and len(identity) == 2:
|
|
198
|
+
return identity[1]
|
|
199
|
+
return identity
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def validate_resolved_reference_consistency(
|
|
203
|
+
model_cls: type[DeclarativeBase],
|
|
204
|
+
schema_obj: pydantic.BaseModel,
|
|
205
|
+
schema_cls: type[pydantic.BaseModel] | None = None,
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Validate explicitly supplied FK and relationship fields agree.
|
|
208
|
+
|
|
209
|
+
IDRef/IDSchema resolution turns model-aware references into ORM objects before
|
|
210
|
+
object construction/update. If the client supplied both ``author_id`` and
|
|
211
|
+
``author`` independently, they must refer to the same row.
|
|
212
|
+
"""
|
|
213
|
+
if schema_cls is None:
|
|
214
|
+
schema_cls = schema_obj.__class__
|
|
215
|
+
|
|
216
|
+
for fk_field in schema_obj.model_fields_set:
|
|
217
|
+
if not fk_field.endswith("_id") or not _is_reference_schema_field(
|
|
218
|
+
schema_cls, fk_field
|
|
219
|
+
):
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
relation_field = fk_field[:-3]
|
|
223
|
+
if (
|
|
224
|
+
relation_field not in schema_obj.model_fields_set
|
|
225
|
+
or not _is_reference_schema_field(schema_cls, relation_field)
|
|
226
|
+
or not _has_model_attr(model_cls, relation_field)
|
|
227
|
+
):
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
fk_identity = _reference_identity(getattr(schema_obj, fk_field, None))
|
|
231
|
+
relation_identity = _reference_identity(
|
|
232
|
+
getattr(schema_obj, relation_field, None)
|
|
233
|
+
)
|
|
234
|
+
if fk_identity is None or relation_identity is None:
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
if fk_identity == relation_identity:
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
raise fastapi.HTTPException(
|
|
241
|
+
status_code=422,
|
|
242
|
+
detail=(
|
|
243
|
+
f"Conflicting references for {fk_field} and {relation_field}: "
|
|
244
|
+
f"{_reference_identity_detail(fk_identity)!r} != "
|
|
245
|
+
f"{_reference_identity_detail(relation_identity)!r}"
|
|
246
|
+
),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def iter_creatable_fields(
|
|
251
|
+
schema_obj: pydantic.BaseModel,
|
|
252
|
+
schema_cls: type[pydantic.BaseModel] | None = None,
|
|
253
|
+
) -> Iterator[tuple[str, Any]]:
|
|
254
|
+
"""Iterate over (field_name, value) pairs that should be used to construct a new
|
|
255
|
+
ORM object from ``schema_obj``.
|
|
256
|
+
|
|
257
|
+
Fields marked as ``ReadOnly`` are skipped. Unlike :func:`get_writable_inputs`,
|
|
258
|
+
this also includes fields that were not explicitly provided, so that
|
|
259
|
+
schema-level defaults end up on the new object.
|
|
260
|
+
"""
|
|
261
|
+
if schema_cls is None:
|
|
262
|
+
schema_cls = schema_obj.__class__
|
|
263
|
+
for field_name, value in schema_obj:
|
|
264
|
+
if is_readonly_field(schema_cls, field_name):
|
|
265
|
+
continue
|
|
266
|
+
yield field_name, value
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _add_resolved_reference_to_create_plan(
|
|
270
|
+
plan: _CreatePlan,
|
|
271
|
+
model_cls: type[DeclarativeBase],
|
|
272
|
+
field_name: str,
|
|
273
|
+
value: DeclarativeBase,
|
|
274
|
+
) -> None:
|
|
275
|
+
ref = cast(_HasID, value)
|
|
276
|
+
if field_name.endswith("_id"):
|
|
277
|
+
fk_name = field_name
|
|
278
|
+
relation_name = field_name[:-3]
|
|
279
|
+
accepts_relation = _has_model_attr(
|
|
280
|
+
model_cls, relation_name
|
|
281
|
+
) and _accepts_init_kwarg(model_cls, relation_name)
|
|
282
|
+
|
|
283
|
+
if (
|
|
284
|
+
_requires_init_kwarg(model_cls, fk_name)
|
|
285
|
+
and accepts_relation
|
|
286
|
+
and _requires_init_kwarg(model_cls, relation_name)
|
|
287
|
+
):
|
|
288
|
+
plan.kwargs[fk_name] = ref.id
|
|
289
|
+
plan.kwargs[relation_name] = value
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
if accepts_relation and _requires_init_kwarg(model_cls, relation_name):
|
|
293
|
+
plan.kwargs[relation_name] = value
|
|
294
|
+
if _has_model_attr(model_cls, fk_name):
|
|
295
|
+
plan.post_assignments[fk_name] = ref.id
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
if _accepts_init_kwarg(model_cls, fk_name):
|
|
299
|
+
plan.kwargs[fk_name] = ref.id
|
|
300
|
+
if _has_model_attr(model_cls, relation_name):
|
|
301
|
+
plan.post_assignments[relation_name] = value
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
if accepts_relation:
|
|
305
|
+
plan.kwargs[relation_name] = value
|
|
306
|
+
plan.post_assignments[fk_name] = ref.id
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
if _has_model_attr(model_cls, fk_name):
|
|
310
|
+
plan.post_assignments[fk_name] = ref.id
|
|
311
|
+
if _has_model_attr(model_cls, relation_name):
|
|
312
|
+
plan.post_assignments[relation_name] = value
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
relation_name = field_name
|
|
316
|
+
fk_name = _get_unambiguous_local_fk_name(model_cls, relation_name)
|
|
317
|
+
|
|
318
|
+
if _has_model_attr(model_cls, relation_name) and _accepts_init_kwarg(
|
|
319
|
+
model_cls, relation_name
|
|
320
|
+
):
|
|
321
|
+
plan.kwargs[relation_name] = value
|
|
322
|
+
_add_assignment(plan.post_assignments, fk_name, ref.id)
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
if fk_name and _accepts_init_kwarg(model_cls, fk_name):
|
|
326
|
+
plan.kwargs[fk_name] = ref.id
|
|
327
|
+
if _has_model_attr(model_cls, relation_name):
|
|
328
|
+
plan.post_assignments[relation_name] = value
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
if _has_model_attr(model_cls, relation_name):
|
|
332
|
+
plan.post_assignments[relation_name] = value
|
|
333
|
+
_add_assignment(plan.post_assignments, fk_name, ref.id)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def build_create_plan(
|
|
337
|
+
model_cls: type[DeclarativeBase],
|
|
338
|
+
schema_obj: pydantic.BaseModel,
|
|
339
|
+
schema_cls: type[pydantic.BaseModel] | None = None,
|
|
340
|
+
) -> _CreatePlan:
|
|
341
|
+
"""Translate ``schema_obj`` fields into kwargs for ``model_cls(**kwargs)``.
|
|
342
|
+
|
|
343
|
+
Shared by sync and async ``build_from_schema``. Assumes any nested ``IDSchema``
|
|
344
|
+
references on ``schema_obj`` have already been resolved (sync vs async).
|
|
345
|
+
"""
|
|
346
|
+
if schema_cls is None:
|
|
347
|
+
schema_cls = schema_obj.__class__
|
|
348
|
+
|
|
349
|
+
plan = _CreatePlan(kwargs={}, post_assignments={})
|
|
350
|
+
for field_name, value in iter_creatable_fields(schema_obj, schema_cls):
|
|
351
|
+
if isinstance(value, IDSchema) and field_name.endswith("_id"):
|
|
352
|
+
if _accepts_init_kwarg(model_cls, field_name):
|
|
353
|
+
plan.kwargs[field_name] = value.id
|
|
354
|
+
elif _has_model_attr(model_cls, field_name):
|
|
355
|
+
plan.post_assignments[field_name] = value.id
|
|
356
|
+
continue
|
|
357
|
+
if isinstance(value, DeclarativeBase) and _is_reference_schema_field(
|
|
358
|
+
schema_cls, field_name
|
|
359
|
+
):
|
|
360
|
+
_add_resolved_reference_to_create_plan(plan, model_cls, field_name, value)
|
|
361
|
+
continue
|
|
362
|
+
|
|
363
|
+
if _accepts_init_kwarg(model_cls, field_name):
|
|
364
|
+
plan.kwargs[field_name] = value
|
|
365
|
+
elif _has_model_attr(model_cls, field_name):
|
|
366
|
+
plan.post_assignments[field_name] = value
|
|
367
|
+
return plan
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def build_create_kwargs(
|
|
371
|
+
model_cls: type[DeclarativeBase],
|
|
372
|
+
schema_obj: pydantic.BaseModel,
|
|
373
|
+
schema_cls: type[pydantic.BaseModel] | None = None,
|
|
374
|
+
) -> dict[str, Any]:
|
|
375
|
+
return build_create_plan(model_cls, schema_obj, schema_cls).kwargs
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def apply_create_assignments(obj: DeclarativeBase, assignments: dict[str, Any]) -> None:
|
|
379
|
+
for field_name, value in assignments.items():
|
|
380
|
+
setattr(obj, field_name, value)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _apply_resolved_reference_update(
|
|
384
|
+
obj: DeclarativeBase, field_name: str, value: DeclarativeBase
|
|
385
|
+
) -> None:
|
|
386
|
+
ref = cast(_HasID, value)
|
|
387
|
+
model_cls = type(obj)
|
|
388
|
+
if field_name.endswith("_id"):
|
|
389
|
+
setattr(obj, field_name, ref.id)
|
|
390
|
+
relation_name = field_name[:-3]
|
|
391
|
+
if hasattr(obj, relation_name):
|
|
392
|
+
setattr(obj, relation_name, value)
|
|
393
|
+
return
|
|
394
|
+
|
|
395
|
+
if hasattr(obj, field_name):
|
|
396
|
+
setattr(obj, field_name, value)
|
|
397
|
+
|
|
398
|
+
fk_name = _get_unambiguous_local_fk_name(model_cls, field_name)
|
|
399
|
+
if fk_name:
|
|
400
|
+
setattr(obj, fk_name, ref.id)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def apply_update_to_object(
|
|
404
|
+
obj: DeclarativeBase,
|
|
405
|
+
schema_obj: pydantic.BaseModel,
|
|
406
|
+
schema_cls: type[pydantic.BaseModel] | None = None,
|
|
407
|
+
) -> None:
|
|
408
|
+
"""Apply writable inputs from ``schema_obj`` onto ``obj`` in place.
|
|
409
|
+
|
|
410
|
+
Shared by sync and async ``apply_schema``. Assumes any nested ``IDSchema``
|
|
411
|
+
references on ``schema_obj`` have already been resolved (sync vs async).
|
|
412
|
+
"""
|
|
413
|
+
for field_name, value in get_writable_inputs(schema_obj, schema_cls).items():
|
|
414
|
+
if isinstance(value, IDSchema) and field_name.endswith("_id"):
|
|
415
|
+
setattr(obj, field_name, value.id)
|
|
416
|
+
continue
|
|
417
|
+
if isinstance(value, DeclarativeBase) and _is_reference_schema_field(
|
|
418
|
+
schema_cls or schema_obj.__class__, field_name
|
|
419
|
+
):
|
|
420
|
+
_apply_resolved_reference_update(obj, field_name, value)
|
|
421
|
+
continue
|
|
422
|
+
setattr(obj, field_name, value)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _unwrap_optional_annotation(annotation: Any) -> Any:
|
|
426
|
+
origin = get_origin(annotation)
|
|
427
|
+
if origin not in (types.UnionType, Union, None):
|
|
428
|
+
return annotation
|
|
429
|
+
|
|
430
|
+
if origin is None:
|
|
431
|
+
return annotation
|
|
432
|
+
|
|
433
|
+
non_none_args = [arg for arg in get_args(annotation) if arg is not type(None)]
|
|
434
|
+
if len(non_none_args) == 1:
|
|
435
|
+
return non_none_args[0]
|
|
436
|
+
return annotation
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _is_idschema_reference_annotation(annotation: Any) -> bool:
|
|
440
|
+
annotation = _unwrap_optional_annotation(annotation)
|
|
441
|
+
if annotation in (IDSchema, IDRef):
|
|
442
|
+
return True
|
|
443
|
+
if not inspect.isclass(annotation):
|
|
444
|
+
return False
|
|
445
|
+
try:
|
|
446
|
+
if not issubclass(annotation, IDSchema):
|
|
447
|
+
return False
|
|
448
|
+
except TypeError:
|
|
449
|
+
return False
|
|
450
|
+
metadata = getattr(annotation, "__pydantic_generic_metadata__", {})
|
|
451
|
+
return metadata.get("origin") in (IDSchema, IDRef)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _serialize_idschema_value(annotation: Any, value: Any) -> Any:
|
|
455
|
+
if value is None:
|
|
456
|
+
return None
|
|
457
|
+
id_value = value.id if hasattr(value, "id") else value
|
|
458
|
+
if inspect.isclass(annotation) and issubclass(annotation, IDRef):
|
|
459
|
+
return id_value
|
|
460
|
+
if inspect.isclass(annotation) and issubclass(annotation, IDSchema):
|
|
461
|
+
return annotation.model_construct(id=id_value)
|
|
462
|
+
return {"id": id_value}
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _serialize_response_value(annotation: Any, value: Any) -> Any:
|
|
466
|
+
annotation = _unwrap_optional_annotation(annotation)
|
|
467
|
+
|
|
468
|
+
if _is_idschema_reference_annotation(annotation):
|
|
469
|
+
return _serialize_idschema_value(annotation, value)
|
|
470
|
+
|
|
471
|
+
origin = get_origin(annotation)
|
|
472
|
+
if origin is list:
|
|
473
|
+
item_annotation = get_args(annotation)[0] if get_args(annotation) else Any
|
|
474
|
+
if _is_idschema_reference_annotation(item_annotation) and isinstance(
|
|
475
|
+
value, Sequence
|
|
476
|
+
):
|
|
477
|
+
return [_serialize_idschema_value(item_annotation, item) for item in value]
|
|
478
|
+
|
|
479
|
+
return value
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _get_nested_schema_annotation(annotation: Any) -> type[pydantic.BaseModel] | None:
|
|
483
|
+
annotation = _unwrap_optional_annotation(annotation)
|
|
484
|
+
|
|
485
|
+
try:
|
|
486
|
+
if inspect.isclass(annotation) and issubclass(annotation, pydantic.BaseModel):
|
|
487
|
+
return annotation
|
|
488
|
+
except TypeError:
|
|
489
|
+
pass
|
|
490
|
+
|
|
491
|
+
origin = get_origin(annotation)
|
|
492
|
+
if origin is list:
|
|
493
|
+
args = get_args(annotation)
|
|
494
|
+
if args:
|
|
495
|
+
return _get_nested_schema_annotation(args[0])
|
|
496
|
+
|
|
497
|
+
return None
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
class _OmitWriteOnlyMixin(pydantic.BaseModel):
|
|
501
|
+
@classmethod
|
|
502
|
+
def __pydantic_init_subclass__(cls, **kwargs: Any) -> None:
|
|
503
|
+
super().__pydantic_init_subclass__(**kwargs)
|
|
504
|
+
|
|
505
|
+
writeonly_fields = [
|
|
506
|
+
name for name in cls.model_fields if is_writeonly_field(cls, name)
|
|
507
|
+
]
|
|
508
|
+
for name in writeonly_fields:
|
|
509
|
+
del cls.model_fields[name]
|
|
510
|
+
|
|
511
|
+
cls.model_rebuild(force=True)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
@functools.cache
|
|
515
|
+
def _create_response_validation_schema(
|
|
516
|
+
schema_cls: type[pydantic.BaseModel],
|
|
517
|
+
) -> type[pydantic.BaseModel]:
|
|
518
|
+
if not any(
|
|
519
|
+
is_writeonly_field(schema_cls, name) for name in schema_cls.model_fields
|
|
520
|
+
):
|
|
521
|
+
return schema_cls
|
|
522
|
+
|
|
523
|
+
return type(
|
|
524
|
+
f"Response{schema_cls.__name__}",
|
|
525
|
+
(_OmitWriteOnlyMixin, schema_cls),
|
|
526
|
+
{
|
|
527
|
+
"__module__": schema_cls.__module__,
|
|
528
|
+
"__doc__": (schema_cls.__doc__ or "")
|
|
529
|
+
+ "\nWrite-only fields have been removed for response validation.",
|
|
530
|
+
},
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _build_relationship_loader_options(
|
|
535
|
+
model_cls: type[DeclarativeBase],
|
|
536
|
+
schema_cls: type[pydantic.BaseModel],
|
|
537
|
+
seen: set[tuple[type[DeclarativeBase], type[pydantic.BaseModel]]] | None = None,
|
|
538
|
+
) -> list[Any]:
|
|
539
|
+
if seen is None:
|
|
540
|
+
seen = set()
|
|
541
|
+
|
|
542
|
+
visit_key = (model_cls, schema_cls)
|
|
543
|
+
if visit_key in seen:
|
|
544
|
+
return []
|
|
545
|
+
seen = seen | {visit_key}
|
|
546
|
+
|
|
547
|
+
mapper = sa_inspect(model_cls)
|
|
548
|
+
options: list[Any] = []
|
|
549
|
+
for field_name, field_info in schema_cls.model_fields.items():
|
|
550
|
+
if field_name not in mapper.relationships:
|
|
551
|
+
continue
|
|
552
|
+
|
|
553
|
+
relationship_prop = mapper.relationships[field_name]
|
|
554
|
+
loader = selectinload(getattr(model_cls, field_name))
|
|
555
|
+
nested_schema = _get_nested_schema_annotation(field_info.annotation)
|
|
556
|
+
|
|
557
|
+
if nested_schema is not None:
|
|
558
|
+
child_options = _build_relationship_loader_options(
|
|
559
|
+
relationship_prop.mapper.class_, nested_schema, seen
|
|
560
|
+
)
|
|
561
|
+
if child_options:
|
|
562
|
+
loader = loader.options(*child_options)
|
|
563
|
+
|
|
564
|
+
options.append(loader)
|
|
565
|
+
|
|
566
|
+
return options
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
class View:
|
|
570
|
+
"""
|
|
571
|
+
Class-based view primitive for FastAPI.
|
|
572
|
+
|
|
573
|
+
Group related endpoints on a class, share dependencies and metadata via
|
|
574
|
+
class attributes, and let subclasses override individual handlers. Routes
|
|
575
|
+
are bound at :func:`include_view` time, not at class-definition time, so
|
|
576
|
+
subclassing works the way Python developers expect: override a method on
|
|
577
|
+
a subclass and the override is what runs.
|
|
578
|
+
|
|
579
|
+
Most users will subclass :class:`RestView` or :class:`AsyncRestView`,
|
|
580
|
+
which extend ``View`` with CRUD scaffolding. Use ``View`` directly for
|
|
581
|
+
grouped non-CRUD endpoints (auth flows, custom RPC routes, etc.).
|
|
582
|
+
"""
|
|
583
|
+
|
|
584
|
+
prefix: ClassVar[str]
|
|
585
|
+
tags: ClassVar[Any] = None
|
|
586
|
+
dependencies: ClassVar[Any] = None
|
|
587
|
+
responses: ClassVar[dict[int | str, dict[str, Any]]] = {}
|
|
588
|
+
|
|
589
|
+
@classmethod
|
|
590
|
+
def before_include_view(cls):
|
|
591
|
+
pass
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
V = TypeVar("V", bound=type[View])
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
@overload
|
|
598
|
+
def include_view(
|
|
599
|
+
parent_router: fastapi.APIRouter | fastapi.FastAPI, view_cls: V
|
|
600
|
+
) -> V: ...
|
|
601
|
+
@overload
|
|
602
|
+
def include_view(
|
|
603
|
+
parent_router: fastapi.APIRouter | fastapi.FastAPI,
|
|
604
|
+
) -> Callable[[V], V]: ...
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def include_view(
|
|
608
|
+
parent_router: fastapi.APIRouter | fastapi.FastAPI, view_cls: V | None = None
|
|
609
|
+
) -> V | Callable[[V], V]:
|
|
610
|
+
"""
|
|
611
|
+
Add a View class's routes to a FastAPI app or APIRouter.
|
|
612
|
+
|
|
613
|
+
Prefer the direct call form from your app/router composition layer::
|
|
614
|
+
|
|
615
|
+
include_view(app, MyView)
|
|
616
|
+
|
|
617
|
+
For small apps, it can also be used as a decorator::
|
|
618
|
+
|
|
619
|
+
@include_view(app)
|
|
620
|
+
class MyView(AsyncRestView):
|
|
621
|
+
...
|
|
622
|
+
"""
|
|
623
|
+
if view_cls is not None:
|
|
624
|
+
_init_view_cls_and_add_to_router(view_cls, parent_router)
|
|
625
|
+
return view_cls
|
|
626
|
+
|
|
627
|
+
def class_decorator(view_cls: V) -> V:
|
|
628
|
+
_init_view_cls_and_add_to_router(view_cls, parent_router)
|
|
629
|
+
return view_cls
|
|
630
|
+
|
|
631
|
+
return class_decorator
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def route(path: str, **api_route_kwargs: Any) -> Callable[..., Any]:
|
|
635
|
+
"""Decorator to mark a View method as an endpoint.
|
|
636
|
+
The path and api_route_kwargs are passed into APIRouter.add_api_route(), see for example:
|
|
637
|
+
https://fastapi.tiangolo.com/reference/apirouter/#fastapi.APIRouter.get
|
|
638
|
+
|
|
639
|
+
Endpoints methods are later added as routes to the FastAPI app using `include_view()`
|
|
640
|
+
"""
|
|
641
|
+
|
|
642
|
+
def store_args_decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
643
|
+
# Create a new attribute: '_api_route_args'
|
|
644
|
+
func._api_route_args = (path, api_route_kwargs) # type: ignore[attr-defined]
|
|
645
|
+
return func
|
|
646
|
+
|
|
647
|
+
return store_args_decorator
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def get(path: str, **api_route_kwargs: Any) -> Callable[..., Any]:
|
|
651
|
+
"""Decorator to mark a View method as a GET endpoint.
|
|
652
|
+
|
|
653
|
+
Equivalent to::
|
|
654
|
+
|
|
655
|
+
@route(path, methods=["GET"], status_code=200, ... )
|
|
656
|
+
"""
|
|
657
|
+
api_route_kwargs.setdefault("methods", ["GET"])
|
|
658
|
+
api_route_kwargs.setdefault("status_code", 200)
|
|
659
|
+
return route(path, **api_route_kwargs)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def post(path: str, **api_route_kwargs: Any) -> Callable[..., Any]:
|
|
663
|
+
"""Decorator to mark a View method as a POST endpoint.
|
|
664
|
+
|
|
665
|
+
Equivalent to::
|
|
666
|
+
|
|
667
|
+
@route(path, methods=["POST"], status_code=201, ... )
|
|
668
|
+
"""
|
|
669
|
+
api_route_kwargs.setdefault("methods", ["POST"])
|
|
670
|
+
api_route_kwargs.setdefault("status_code", 201)
|
|
671
|
+
return route(path, **api_route_kwargs)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def put(path: str, **api_route_kwargs: Any) -> Callable[..., Any]:
|
|
675
|
+
"""Decorator to mark a View method as a PUT endpoint.
|
|
676
|
+
|
|
677
|
+
Equivalent to::
|
|
678
|
+
|
|
679
|
+
@route(path, methods=["PUT"], status_code=200, ... )
|
|
680
|
+
"""
|
|
681
|
+
api_route_kwargs.setdefault("methods", ["PUT"])
|
|
682
|
+
api_route_kwargs.setdefault("status_code", 200)
|
|
683
|
+
return route(path, **api_route_kwargs)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def patch(path: str, **api_route_kwargs: Any) -> Callable[..., Any]:
|
|
687
|
+
"""Decorator to mark a View method as a PATCH endpoint.
|
|
688
|
+
|
|
689
|
+
Equivalent to::
|
|
690
|
+
|
|
691
|
+
@route(path, methods=["PATCH"], status_code=200, ... )
|
|
692
|
+
"""
|
|
693
|
+
api_route_kwargs.setdefault("methods", ["PATCH"])
|
|
694
|
+
api_route_kwargs.setdefault("status_code", 200)
|
|
695
|
+
return route(path, **api_route_kwargs)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def delete(path: str, **api_route_kwargs: Any) -> Callable[..., Any]:
|
|
699
|
+
"""Decorator to mark a View method as a DELETE endpoint.
|
|
700
|
+
|
|
701
|
+
Equivalent to::
|
|
702
|
+
|
|
703
|
+
@route(path, methods=["DELETE"], status_code=204, ... )
|
|
704
|
+
"""
|
|
705
|
+
api_route_kwargs.setdefault("methods", ["DELETE"])
|
|
706
|
+
api_route_kwargs.setdefault("status_code", 204)
|
|
707
|
+
return route(path, **api_route_kwargs)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
class BaseRestView(View, Generic[ModelT, SchemaT, CreateSchemaT, UpdateSchemaT, IdT]):
|
|
711
|
+
"""
|
|
712
|
+
Base class for RestView implementations.
|
|
713
|
+
|
|
714
|
+
This class contains the common functionality shared between AsyncRestView
|
|
715
|
+
and RestView, including schema definitions, model configuration, and
|
|
716
|
+
common CRUD operation logic.
|
|
717
|
+
"""
|
|
718
|
+
|
|
719
|
+
responses: ClassVar[dict[int | str, dict[str, Any]]] = {
|
|
720
|
+
404: {"description": "Not found"}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
schema: ClassVar[type[pydantic.BaseModel]]
|
|
724
|
+
# If 'creation_schema' is not defined it will be created from 'schema'
|
|
725
|
+
# using `create_model_without_read_only_fields()`.
|
|
726
|
+
creation_schema: ClassVar[type[pydantic.BaseModel]]
|
|
727
|
+
update_schema: ClassVar[type[pydantic.BaseModel]]
|
|
728
|
+
model: ClassVar[type[DeclarativeBase]]
|
|
729
|
+
id_type: ClassVar[type[Any]] = int
|
|
730
|
+
include_pagination_metadata: ClassVar[bool] = (
|
|
731
|
+
False # Set True to include count/total in list responses
|
|
732
|
+
)
|
|
733
|
+
exclude_routes: ClassVar[Iterable[str | ViewRoute]] = ()
|
|
734
|
+
#: Extra query-parameter keys to allow on the listing endpoint in addition
|
|
735
|
+
#: to those derived from the response schema. Use this when a view
|
|
736
|
+
#: intentionally consumes a custom query parameter (e.g. an
|
|
737
|
+
#: ``?include_deleted=true`` escape hatch on a soft-delete mixin) that
|
|
738
|
+
#: isn't a filter on a schema field. Without this, the strict
|
|
739
|
+
#: unknown-key guard would reject the request with 422.
|
|
740
|
+
extra_query_params: ClassVar[Iterable[str]] = ()
|
|
741
|
+
#: Default ``page_size`` for list endpoints. ``None`` means "no implicit
|
|
742
|
+
#: cap" (the framework default). Override per-view.
|
|
743
|
+
default_page_size: ClassVar[int | None] = DEFAULT_PAGE_SIZE
|
|
744
|
+
#: Maximum ``page_size`` accepted on list endpoints. Above this returns 422.
|
|
745
|
+
max_page_size: ClassVar[int] = MAX_PAGE_SIZE
|
|
746
|
+
listing_param_schema: ClassVar[type[pydantic.BaseModel]]
|
|
747
|
+
pagination_response_schema: ClassVar[type[pydantic.BaseModel]]
|
|
748
|
+
|
|
749
|
+
request: fastapi.Request
|
|
750
|
+
|
|
751
|
+
def get_relationship_loader_options(self) -> list[Any]:
|
|
752
|
+
return _build_relationship_loader_options(self.model, self.schema)
|
|
753
|
+
|
|
754
|
+
def _reject_unknown_query_params(self) -> None:
|
|
755
|
+
"""Reject any query-string key that isn't part of ``listing_param_schema``.
|
|
756
|
+
|
|
757
|
+
FastAPI flattens ``Annotated[listing_param_schema, Query()]`` into named
|
|
758
|
+
query parameters; unknown keys are silently ignored at that layer,
|
|
759
|
+
which would let typoed filters or unsupported operators (e.g.
|
|
760
|
+
``active__gte=true`` on a boolean column where the schema does not
|
|
761
|
+
emit a range operator) widen the result set without telling the
|
|
762
|
+
caller. We treat unknown keys as a validation error instead, mirroring
|
|
763
|
+
FastAPI's 422 envelope shape so the response is consistent with
|
|
764
|
+
bound-violation errors.
|
|
765
|
+
|
|
766
|
+
No-op when there's no live request (programmatic ``view.listing(...)``
|
|
767
|
+
calls outside an HTTP request) — there's no URL surface to validate
|
|
768
|
+
and the in-process caller is responsible for what they pass.
|
|
769
|
+
"""
|
|
770
|
+
request = getattr(self, "request", None)
|
|
771
|
+
if request is None:
|
|
772
|
+
return
|
|
773
|
+
listing_schema = getattr(self, "listing_param_schema", None)
|
|
774
|
+
if listing_schema is None:
|
|
775
|
+
return
|
|
776
|
+
allowed = set(listing_schema.model_fields) | set(self.extra_query_params)
|
|
777
|
+
sent = set(request.query_params.keys())
|
|
778
|
+
unknown = sent - allowed
|
|
779
|
+
if not unknown:
|
|
780
|
+
return
|
|
781
|
+
detail = [
|
|
782
|
+
{
|
|
783
|
+
"type": "extra_forbidden",
|
|
784
|
+
"loc": ["query", key],
|
|
785
|
+
"msg": f"Unknown query parameter {key!r}",
|
|
786
|
+
"input": request.query_params.get(key),
|
|
787
|
+
}
|
|
788
|
+
for key in sorted(unknown)
|
|
789
|
+
]
|
|
790
|
+
raise fastapi.HTTPException(status_code=422, detail=detail)
|
|
791
|
+
|
|
792
|
+
def to_response_schema(self, obj: ModelT | SchemaT) -> SchemaT:
|
|
793
|
+
"""Serialize an ORM object to the configured response schema."""
|
|
794
|
+
if isinstance(obj, self.schema):
|
|
795
|
+
return cast(SchemaT, obj)
|
|
796
|
+
|
|
797
|
+
# Build a payload using canonical field names. Alias rendering happens
|
|
798
|
+
# when FastAPI serializes the response model.
|
|
799
|
+
payload: dict[str, Any] = {}
|
|
800
|
+
for field_name, field_info in self.schema.model_fields.items():
|
|
801
|
+
if is_writeonly_field(self.schema, field_name):
|
|
802
|
+
continue
|
|
803
|
+
if hasattr(obj, field_name):
|
|
804
|
+
value = getattr(obj, field_name)
|
|
805
|
+
payload[field_name] = _serialize_response_value(
|
|
806
|
+
field_info.annotation, value
|
|
807
|
+
)
|
|
808
|
+
elif field_info.alias and hasattr(obj, field_info.alias):
|
|
809
|
+
payload[field_name] = getattr(obj, field_info.alias)
|
|
810
|
+
|
|
811
|
+
response_schema = _create_response_validation_schema(self.schema)
|
|
812
|
+
return cast(
|
|
813
|
+
SchemaT,
|
|
814
|
+
response_schema.model_validate(payload, by_alias=False, by_name=True),
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
@staticmethod
|
|
818
|
+
def _to_query_params(query_params: Any) -> QueryParams:
|
|
819
|
+
if isinstance(query_params, QueryParams):
|
|
820
|
+
return query_params
|
|
821
|
+
if isinstance(query_params, pydantic.BaseModel):
|
|
822
|
+
dumped = query_params.model_dump(
|
|
823
|
+
exclude_none=True, by_alias=True, mode="json"
|
|
824
|
+
)
|
|
825
|
+
return QueryParams({k: str(v) for k, v in dumped.items()})
|
|
826
|
+
if isinstance(query_params, dict):
|
|
827
|
+
return QueryParams({k: str(v) for k, v in query_params.items()})
|
|
828
|
+
return QueryParams(query_params)
|
|
829
|
+
|
|
830
|
+
@classmethod
|
|
831
|
+
def _create_pagination_response_schema(
|
|
832
|
+
cls, response_schema: type[pydantic.BaseModel]
|
|
833
|
+
) -> type[pydantic.BaseModel]:
|
|
834
|
+
return create_model(
|
|
835
|
+
f"{cls.__name__}PaginatedResponse",
|
|
836
|
+
items=(Sequence[response_schema], ...),
|
|
837
|
+
total=(int, ...),
|
|
838
|
+
page=(int | None, None),
|
|
839
|
+
page_size=(int | None, None),
|
|
840
|
+
total_pages=(int | None, None),
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
def to_paginated_listing_response(
|
|
844
|
+
self, query_params: Any, listing_result: ListingResult[Any]
|
|
845
|
+
) -> dict[str, Any]:
|
|
846
|
+
params = self._to_query_params(query_params)
|
|
847
|
+
payload: dict[str, Any] = {
|
|
848
|
+
"items": [self.to_response_schema(obj) for obj in listing_result.objects],
|
|
849
|
+
"total": listing_result.total_count,
|
|
850
|
+
"page": None,
|
|
851
|
+
"page_size": None,
|
|
852
|
+
"total_pages": None,
|
|
853
|
+
}
|
|
854
|
+
page_size_raw = params.get("page_size")
|
|
855
|
+
if page_size_raw is None and self.default_page_size is None:
|
|
856
|
+
# No implicit cap and the client did not ask for one. Leave
|
|
857
|
+
# page/page_size/total_pages as None.
|
|
858
|
+
return payload
|
|
859
|
+
page = int(params.get("page", "1"))
|
|
860
|
+
if page_size_raw is not None:
|
|
861
|
+
page_size = int(page_size_raw)
|
|
862
|
+
else:
|
|
863
|
+
# The early return above guarantees default_page_size is non-None here.
|
|
864
|
+
page_size = cast(int, self.default_page_size)
|
|
865
|
+
payload["page"] = page
|
|
866
|
+
payload["page_size"] = page_size
|
|
867
|
+
payload["total_pages"] = (
|
|
868
|
+
ceil(listing_result.total_count / page_size) if page_size > 0 else 0
|
|
869
|
+
)
|
|
870
|
+
return payload
|
|
871
|
+
|
|
872
|
+
def to_listing_response(
|
|
873
|
+
self, query_params: Any, listing_result: ListingResult[ModelT]
|
|
874
|
+
) -> Any:
|
|
875
|
+
if not self.include_pagination_metadata:
|
|
876
|
+
return [self.to_response_schema(obj) for obj in listing_result.objects]
|
|
877
|
+
|
|
878
|
+
return self.to_paginated_listing_response(query_params, listing_result)
|
|
879
|
+
|
|
880
|
+
@classmethod
|
|
881
|
+
def before_include_view(cls):
|
|
882
|
+
"""
|
|
883
|
+
Apply type annotations needed for FastAPI, before creating an APIRouter from
|
|
884
|
+
this view and registering it.
|
|
885
|
+
|
|
886
|
+
This function can be overridden to further tweak the endpoints before they
|
|
887
|
+
are added to FastAPI.
|
|
888
|
+
"""
|
|
889
|
+
# Auto-generate schema if none is provided. Each of these guards
|
|
890
|
+
# checks ``cls.__dict__`` — not ``hasattr`` — so a subclass that
|
|
891
|
+
# changes ``schema``/``default_page_size``/``max_page_size`` regenerates
|
|
892
|
+
# the derived schemas instead of silently inheriting the parent's.
|
|
893
|
+
if "schema" not in cls.__dict__:
|
|
894
|
+
if not hasattr(cls, "model"):
|
|
895
|
+
raise ValueError(
|
|
896
|
+
f"'{cls.__name__}.model' must be specified to auto-generate schema"
|
|
897
|
+
)
|
|
898
|
+
cls.schema = cast(
|
|
899
|
+
type[SchemaT], auto_generate_schema_for_view(cls, cls.model)
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
if "listing_param_schema" not in cls.__dict__:
|
|
903
|
+
cls.listing_param_schema = create_list_params_schema(
|
|
904
|
+
cls.schema,
|
|
905
|
+
default_page_size=cls.default_page_size,
|
|
906
|
+
max_page_size=cls.max_page_size,
|
|
907
|
+
)
|
|
908
|
+
if "creation_schema" not in cls.__dict__:
|
|
909
|
+
cls.creation_schema = cast(
|
|
910
|
+
type[CreateSchemaT], create_model_without_read_only_fields(cls.schema)
|
|
911
|
+
)
|
|
912
|
+
if "update_schema" not in cls.__dict__:
|
|
913
|
+
cls.update_schema = cast(
|
|
914
|
+
type[UpdateSchemaT], create_model_with_optional_fields(cls.schema)
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
response_schema = cls.schema
|
|
918
|
+
|
|
919
|
+
# Only annotate if the methods exist (they will be overridden in subclasses)
|
|
920
|
+
listing_response_annotation: Any = Sequence[response_schema]
|
|
921
|
+
if cls.include_pagination_metadata:
|
|
922
|
+
cls.pagination_response_schema = cls._create_pagination_response_schema(
|
|
923
|
+
response_schema
|
|
924
|
+
)
|
|
925
|
+
listing_response_annotation = cls.pagination_response_schema
|
|
926
|
+
|
|
927
|
+
# ``listing``/``get``/``create``/``update``/``delete`` are defined on
|
|
928
|
+
# AsyncRestView/RestView subclasses and may be excluded by ``exclude_routes``,
|
|
929
|
+
# so they aren't visible on BaseRestView. ``getattr`` keeps pyright happy
|
|
930
|
+
# without falsely advertising them on the base class.
|
|
931
|
+
if (listing := getattr(cls, "listing", None)) is not None:
|
|
932
|
+
_annotate(
|
|
933
|
+
listing,
|
|
934
|
+
return_annotation=listing_response_annotation,
|
|
935
|
+
query_params=Annotated[cls.listing_param_schema, fastapi.Query()],
|
|
936
|
+
)
|
|
937
|
+
if (get := getattr(cls, "get", None)) is not None:
|
|
938
|
+
_annotate(get, return_annotation=response_schema, id=cls.id_type)
|
|
939
|
+
if (create := getattr(cls, "create", None)) is not None:
|
|
940
|
+
_annotate(
|
|
941
|
+
create,
|
|
942
|
+
return_annotation=response_schema,
|
|
943
|
+
schema_obj=cls.creation_schema,
|
|
944
|
+
)
|
|
945
|
+
if (update := getattr(cls, "update", None)) is not None:
|
|
946
|
+
_annotate(
|
|
947
|
+
update,
|
|
948
|
+
return_annotation=response_schema,
|
|
949
|
+
schema_obj=cls.update_schema,
|
|
950
|
+
id=cls.id_type,
|
|
951
|
+
)
|
|
952
|
+
if (delete := getattr(cls, "delete", None)) is not None:
|
|
953
|
+
_annotate(delete, return_annotation=fastapi.Response, id=cls.id_type)
|
|
954
|
+
_exclude_routes(cls)
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def _exclude_routes(cls: type[BaseRestView[Any, Any, Any, Any, Any]]):
|
|
958
|
+
for route_name in cls.exclude_routes:
|
|
959
|
+
method_name = (
|
|
960
|
+
route_name.value if isinstance(route_name, ViewRoute) else route_name
|
|
961
|
+
)
|
|
962
|
+
# @route decorator adds `_api_route_args` to a method to create the route later.
|
|
963
|
+
# By removing it from the method, the method will no longer be added as a route.
|
|
964
|
+
try:
|
|
965
|
+
view_func = getattr(cls, method_name)
|
|
966
|
+
except AttributeError:
|
|
967
|
+
raise AttributeError(f"{method_name!r} is not a route on {cls.__name__}")
|
|
968
|
+
if not hasattr(view_func, "_api_route_args"):
|
|
969
|
+
raise AttributeError(f"{method_name!r} is not a route on {cls.__name__}")
|
|
970
|
+
del view_func._api_route_args
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def _init_view_cls_and_add_to_router(
|
|
974
|
+
view_cls: type[View], parent_router: fastapi.APIRouter | fastapi.FastAPI
|
|
975
|
+
):
|
|
976
|
+
"""
|
|
977
|
+
To make View classes work in FastAPI some hacks are needed. Those hacks are
|
|
978
|
+
applied here.
|
|
979
|
+
|
|
980
|
+
FastAPI does a lot with annotations. For example, accepted or returned JSON is
|
|
981
|
+
often described with Pydantic classes like this:
|
|
982
|
+
|
|
983
|
+
def my_endpoint(foo: FooRead) -> FooRead:
|
|
984
|
+
|
|
985
|
+
Most of the hacks here are to set the correct annotations on (inherited) class
|
|
986
|
+
methods.
|
|
987
|
+
|
|
988
|
+
The class-level preparation (copying parent endpoints, renaming, annotating,
|
|
989
|
+
schema generation, dataclass-style __init__) only runs once per View class —
|
|
990
|
+
subsequent calls to ``include_view()`` reuse the prepared class and only
|
|
991
|
+
construct a fresh APIRouter to mount on the new parent. This makes
|
|
992
|
+
registering the same view on multiple routers safe.
|
|
993
|
+
"""
|
|
994
|
+
_prepare_view_class(view_cls)
|
|
995
|
+
api_router = _init_api_router(view_cls)
|
|
996
|
+
_register_for_resource_ref(parent_router, view_cls)
|
|
997
|
+
parent_router.include_router(api_router)
|
|
998
|
+
# Fallback registration for users who skip ``fr.configure(app=...)``.
|
|
999
|
+
# ``register_default_exception_handlers`` is idempotent and only acts on
|
|
1000
|
+
# FastAPI apps (it ignores nested APIRouter parents).
|
|
1001
|
+
if isinstance(parent_router, fastapi.FastAPI):
|
|
1002
|
+
register_default_exception_handlers(parent_router)
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def _prepare_view_class(view_cls: type[View]) -> None:
|
|
1006
|
+
"""Run the one-time class-level setup for a View.
|
|
1007
|
+
|
|
1008
|
+
Guarded by the ``_fr_initialised`` marker (stored in ``__dict__`` so it is
|
|
1009
|
+
not inherited from a parent class that was registered separately). Calling
|
|
1010
|
+
this multiple times is a no-op after the first run.
|
|
1011
|
+
"""
|
|
1012
|
+
if view_cls.__dict__.get("_fr_initialised", False):
|
|
1013
|
+
return
|
|
1014
|
+
_copy_all_parent_class_endpoints_into_this_subclass(view_cls)
|
|
1015
|
+
_init_all_endpoints(view_cls)
|
|
1016
|
+
view_cls.before_include_view()
|
|
1017
|
+
_init_class_based_view(view_cls)
|
|
1018
|
+
view_cls._fr_initialised = True # type: ignore[attr-defined]
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def _copy_all_parent_class_endpoints_into_this_subclass(view_cls: type[View]):
|
|
1022
|
+
"""
|
|
1023
|
+
Override all methods with a @route decorator of the parent classes of view_cls
|
|
1024
|
+
with a new copy directly on view_cls . This allows us to change the
|
|
1025
|
+
annotations on these endpoints without affecting the parent endpoints.
|
|
1026
|
+
|
|
1027
|
+
For example, FooView.get() delegates to AsyncRestView.get() if it is not
|
|
1028
|
+
overridden (this is called implicit delegation through method resolution). And if
|
|
1029
|
+
we add the annotation that FooView.get() returns FooRead but do not make a copy
|
|
1030
|
+
then AsyncRestView.get() and all other subclasses will get the FooRead
|
|
1031
|
+
annotation as well.
|
|
1032
|
+
"""
|
|
1033
|
+
for endpoint in _get_all_parent_endpoints(view_cls):
|
|
1034
|
+
# Use `cls.__dict__` to check what attributes are directly on the class.
|
|
1035
|
+
# This way we side-step the method resolution.
|
|
1036
|
+
if endpoint.__name__ in view_cls.__dict__:
|
|
1037
|
+
# This endpoint is already overridden!
|
|
1038
|
+
continue
|
|
1039
|
+
|
|
1040
|
+
# The original endpoint might be shared between subclasses.
|
|
1041
|
+
# So make a copy and put that on the view_cls.
|
|
1042
|
+
endpoint_wrapper = _make_copy(endpoint, view_cls)
|
|
1043
|
+
# Set explicit __qualname__ for debugging purposes.
|
|
1044
|
+
endpoint_wrapper.__qualname__ = (
|
|
1045
|
+
f"{view_cls.__name__}_{endpoint.__qualname__}_wrapper"
|
|
1046
|
+
)
|
|
1047
|
+
setattr(view_cls, endpoint.__name__, endpoint_wrapper)
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _make_copy(endpoint: Callable, view_cls: type[View]) -> Callable:
|
|
1051
|
+
"""
|
|
1052
|
+
Wrap the endpoint in a new function as kind of copy.
|
|
1053
|
+
|
|
1054
|
+
Fun fact: You cannot do this inside a for loop, because the closure of 'endpoint'
|
|
1055
|
+
inside the wrapper works on the variable, not on the value. And for-loops in Python
|
|
1056
|
+
do not have their own variable scope.
|
|
1057
|
+
|
|
1058
|
+
https://eev.ee/blog/2011/04/24/gotcha-python-scoping-closures/
|
|
1059
|
+
"""
|
|
1060
|
+
if inspect.iscoroutinefunction(endpoint):
|
|
1061
|
+
|
|
1062
|
+
@functools.wraps(endpoint)
|
|
1063
|
+
async def _async_wrapper(self, *args, **kwargs):
|
|
1064
|
+
return await endpoint(self, *args, **kwargs)
|
|
1065
|
+
|
|
1066
|
+
endpoint_wrapper: Callable = _async_wrapper
|
|
1067
|
+
else:
|
|
1068
|
+
|
|
1069
|
+
@functools.wraps(endpoint)
|
|
1070
|
+
def _sync_wrapper(self, *args, **kwargs):
|
|
1071
|
+
return endpoint(self, *args, **kwargs)
|
|
1072
|
+
|
|
1073
|
+
endpoint_wrapper = _sync_wrapper
|
|
1074
|
+
|
|
1075
|
+
endpoint_wrapper.__annotations__ = endpoint.__annotations__.copy()
|
|
1076
|
+
return endpoint_wrapper
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def _init_all_endpoints(view_cls: type[View]):
|
|
1080
|
+
"""
|
|
1081
|
+
Ensure every endpoint has a unique name and update the 'self' annotation.
|
|
1082
|
+
"""
|
|
1083
|
+
for attr in view_cls.__dict__.values():
|
|
1084
|
+
if not hasattr(attr, "_api_route_args"):
|
|
1085
|
+
continue
|
|
1086
|
+
endpoint = attr
|
|
1087
|
+
# Give every endpoint a unique name
|
|
1088
|
+
# This will give the FooView.create() endpoint the name "fooview_create"
|
|
1089
|
+
endpoint.__name__ = view_cls.__name__.lower() + "_" + endpoint.__name__
|
|
1090
|
+
_annotate_self(view_cls, endpoint)
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def _annotate(func: Callable, return_annotation: Any = None, **param_annotations):
|
|
1094
|
+
"""
|
|
1095
|
+
Annotate a function by setting func.__signature__ explicitly.
|
|
1096
|
+
"""
|
|
1097
|
+
sig = inspect.signature(func)
|
|
1098
|
+
new_params = []
|
|
1099
|
+
for param in sig.parameters.values():
|
|
1100
|
+
if param.name in param_annotations:
|
|
1101
|
+
annotation = param_annotations[param.name]
|
|
1102
|
+
new_param = param.replace(annotation=annotation)
|
|
1103
|
+
new_params.append(new_param)
|
|
1104
|
+
else:
|
|
1105
|
+
new_params.append(param)
|
|
1106
|
+
func.__signature__ = sig.replace( # type: ignore[attr-defined]
|
|
1107
|
+
parameters=new_params, return_annotation=return_annotation
|
|
1108
|
+
)
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def _get_all_parent_endpoints(view_cls: type[View]) -> list[Callable]:
|
|
1112
|
+
endpoints = []
|
|
1113
|
+
for cls in view_cls.mro():
|
|
1114
|
+
if cls is view_cls:
|
|
1115
|
+
continue
|
|
1116
|
+
for name, value in cls.__dict__.items():
|
|
1117
|
+
if hasattr(value, "_api_route_args"):
|
|
1118
|
+
endpoints.append(value)
|
|
1119
|
+
return endpoints
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def _init_api_router(view_cls: type[View]) -> fastapi.APIRouter:
|
|
1123
|
+
# Concatenate prefixes defined at each level of the class hierarchy (base → derived).
|
|
1124
|
+
prefix = "".join(
|
|
1125
|
+
c.__dict__["prefix"] for c in reversed(view_cls.mro()) if "prefix" in c.__dict__
|
|
1126
|
+
)
|
|
1127
|
+
tags = _get_router_tags(view_cls, prefix)
|
|
1128
|
+
api_router = fastapi.APIRouter(
|
|
1129
|
+
prefix=prefix,
|
|
1130
|
+
tags=tags,
|
|
1131
|
+
responses=view_cls.responses,
|
|
1132
|
+
dependencies=view_cls.dependencies,
|
|
1133
|
+
)
|
|
1134
|
+
|
|
1135
|
+
# Find all endpoint functions in this class and add them to the router
|
|
1136
|
+
for attr in view_cls.__dict__.values():
|
|
1137
|
+
if not hasattr(attr, "_api_route_args"):
|
|
1138
|
+
continue
|
|
1139
|
+
endpoint = attr
|
|
1140
|
+
path, route_kwargs = endpoint._api_route_args
|
|
1141
|
+
_add_api_route(api_router, view_cls, path, endpoint, route_kwargs)
|
|
1142
|
+
|
|
1143
|
+
return api_router
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def _get_router_tags(view_cls: type[View], prefix: str) -> list[str | Enum]:
|
|
1147
|
+
if view_cls.tags is not None:
|
|
1148
|
+
return list(view_cls.tags)
|
|
1149
|
+
return [_derive_tag_from_prefix(prefix) or view_cls.__name__]
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def _derive_tag_from_prefix(prefix: str) -> str | None:
|
|
1153
|
+
segments = [segment for segment in prefix.strip("/").split("/") if segment]
|
|
1154
|
+
if not segments:
|
|
1155
|
+
return None
|
|
1156
|
+
return segments[-1].replace("-", " ").replace("_", " ").title()
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def _add_api_route(
|
|
1160
|
+
api_router: fastapi.APIRouter,
|
|
1161
|
+
view_cls: type[View],
|
|
1162
|
+
path: str,
|
|
1163
|
+
endpoint: Callable,
|
|
1164
|
+
route_kwargs: dict[str, Any],
|
|
1165
|
+
) -> None:
|
|
1166
|
+
if _should_add_collection_route_alias(view_cls, path, endpoint):
|
|
1167
|
+
api_router.add_api_route("", endpoint, **route_kwargs)
|
|
1168
|
+
hidden_alias_kwargs = {**route_kwargs, "include_in_schema": False}
|
|
1169
|
+
api_router.add_api_route("/", endpoint, **hidden_alias_kwargs)
|
|
1170
|
+
return
|
|
1171
|
+
|
|
1172
|
+
api_router.add_api_route(path, endpoint, **route_kwargs)
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def _should_add_collection_route_alias(
|
|
1176
|
+
view_cls: type[View], path: str, endpoint: Callable
|
|
1177
|
+
) -> bool:
|
|
1178
|
+
if not issubclass(view_cls, BaseRestView):
|
|
1179
|
+
return False
|
|
1180
|
+
if path != "/":
|
|
1181
|
+
return False
|
|
1182
|
+
return endpoint.__name__.endswith(("_listing", "_create"))
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def _annotate_self(view_cls: type[View], endpoint: Callable) -> None:
|
|
1186
|
+
"""
|
|
1187
|
+
Annotate the 'self' argument as 'self=Depends(view_cls)'. That way FastAPI instantiates the
|
|
1188
|
+
view_cls before calling the endpoint function and passes it as 'self'.
|
|
1189
|
+
Note that it sets endpoint.__signature__ which overrides any other inspection.
|
|
1190
|
+
|
|
1191
|
+
Note: Copied (MIT license) and adjusted from: https://github.com/dmontagu/fastapi-utils/blob/master/fastapi_utils/cbv.py
|
|
1192
|
+
|
|
1193
|
+
Fixes the endpoint signature to ensure FastAPI performs dependency injection properly.
|
|
1194
|
+
"""
|
|
1195
|
+
sig = inspect.signature(endpoint)
|
|
1196
|
+
params: list[inspect.Parameter] = list(sig.parameters.values())
|
|
1197
|
+
self_param = params[0]
|
|
1198
|
+
new_self_param = self_param.replace(default=fastapi.Depends(view_cls))
|
|
1199
|
+
|
|
1200
|
+
new_params = [new_self_param] + [
|
|
1201
|
+
param.replace(kind=inspect.Parameter.KEYWORD_ONLY) for param in params[1:]
|
|
1202
|
+
]
|
|
1203
|
+
endpoint.__signature__ = sig.replace(parameters=new_params) # type: ignore[attr-defined]
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
# Bare-typed annotations FastAPI special-cases for parameter injection
|
|
1207
|
+
# (no ``Depends(...)`` marker required). Treated alongside ``Depends``-
|
|
1208
|
+
# marked annotations as DI-wired class attributes; everything else is
|
|
1209
|
+
# left as plain typing.
|
|
1210
|
+
_FASTAPI_SPECIAL_INJECTABLE: tuple[type, ...] = (
|
|
1211
|
+
Request,
|
|
1212
|
+
Response,
|
|
1213
|
+
BackgroundTasks,
|
|
1214
|
+
WebSocket,
|
|
1215
|
+
)
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def _init_class_based_view(view_cls: type[View]) -> None:
|
|
1219
|
+
"""
|
|
1220
|
+
Note: Copied (MIT license) and adjusted from: https://github.com/dmontagu/fastapi-utils/blob/master/fastapi_utils/cbv.py
|
|
1221
|
+
|
|
1222
|
+
Idempotently modifies the provided `cls`, performing the following modifications:
|
|
1223
|
+
* The `__init__` function is updated to set any class-annotated dependencies as instance attributes
|
|
1224
|
+
* The `__signature__` attribute is updated to indicate to FastAPI what arguments should be passed to the initializer
|
|
1225
|
+
"""
|
|
1226
|
+
if getattr(view_cls, "__class_based_view", False):
|
|
1227
|
+
return # Already initialized
|
|
1228
|
+
old_init: Callable[..., Any] = view_cls.__init__
|
|
1229
|
+
old_signature = inspect.signature(old_init)
|
|
1230
|
+
old_parameters = list(old_signature.parameters.values())[1:] # drop `self`
|
|
1231
|
+
new_parameters = [
|
|
1232
|
+
x
|
|
1233
|
+
for x in old_parameters
|
|
1234
|
+
if x.kind
|
|
1235
|
+
not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
|
|
1236
|
+
]
|
|
1237
|
+
# Marker-based DI with MRO-aware shadowing: walk the MRO from the
|
|
1238
|
+
# base classes upward and pick, for each name, an annotation that
|
|
1239
|
+
# either carries a ``Depends(...)`` marker or names one of FastAPI's
|
|
1240
|
+
# bare-injectable special types (``Request`` / ``Response`` etc.).
|
|
1241
|
+
# A *plain* annotation on a more-derived class (e.g. a mixin
|
|
1242
|
+
# declaring ``session: AsyncSession`` for static-typing purposes)
|
|
1243
|
+
# does NOT shadow a marker-bearing annotation from a base — the
|
|
1244
|
+
# framework prefers wiring fidelity over the most-derived hint.
|
|
1245
|
+
# Without this rule, any plain annotation a mixin adds would
|
|
1246
|
+
# silently break dependency injection.
|
|
1247
|
+
di_annotations: dict[str, Any] = {}
|
|
1248
|
+
for cls in reversed(view_cls.__mro__):
|
|
1249
|
+
try:
|
|
1250
|
+
cls_hints = get_type_hints(cls, include_extras=True)
|
|
1251
|
+
except Exception:
|
|
1252
|
+
continue
|
|
1253
|
+
for name, annotation in cls_hints.items():
|
|
1254
|
+
if get_origin(annotation) is ClassVar:
|
|
1255
|
+
continue
|
|
1256
|
+
metadata = getattr(annotation, "__metadata__", ())
|
|
1257
|
+
has_depends_marker = any(isinstance(m, _DependsMarker) for m in metadata)
|
|
1258
|
+
underlying = (
|
|
1259
|
+
annotation
|
|
1260
|
+
if get_origin(annotation) is not Annotated
|
|
1261
|
+
else (get_args(annotation)[0] if get_args(annotation) else annotation)
|
|
1262
|
+
)
|
|
1263
|
+
is_special_type = inspect.isclass(underlying) and issubclass(
|
|
1264
|
+
underlying, _FASTAPI_SPECIAL_INJECTABLE
|
|
1265
|
+
)
|
|
1266
|
+
if has_depends_marker or is_special_type:
|
|
1267
|
+
# Marker-bearing annotation wins, regardless of MRO position.
|
|
1268
|
+
di_annotations[name] = annotation
|
|
1269
|
+
# Plain annotations are silently ignored — they neither set
|
|
1270
|
+
# nor clear an entry in di_annotations.
|
|
1271
|
+
|
|
1272
|
+
dependency_names: list[str] = []
|
|
1273
|
+
for name, annotation in di_annotations.items():
|
|
1274
|
+
dependency_names.append(name)
|
|
1275
|
+
default_value = getattr(view_cls, name, inspect.Parameter.empty)
|
|
1276
|
+
new_parameters.append(
|
|
1277
|
+
inspect.Parameter(
|
|
1278
|
+
name=name,
|
|
1279
|
+
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
1280
|
+
default=default_value,
|
|
1281
|
+
annotation=annotation,
|
|
1282
|
+
)
|
|
1283
|
+
)
|
|
1284
|
+
new_signature = old_signature.replace(parameters=new_parameters)
|
|
1285
|
+
|
|
1286
|
+
def new_init(self: Any, *args: Any, **kwargs: Any) -> None:
|
|
1287
|
+
for dep_name in dependency_names:
|
|
1288
|
+
dep_value = kwargs.pop(dep_name)
|
|
1289
|
+
setattr(self, dep_name, dep_value)
|
|
1290
|
+
old_init(self, *args, **kwargs)
|
|
1291
|
+
|
|
1292
|
+
setattr(view_cls, "__signature__", new_signature)
|
|
1293
|
+
setattr(view_cls, "__init__", new_init)
|
|
1294
|
+
setattr(view_cls, "__class_based_view", True)
|