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,518 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import types
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Annotated, Any, Generic, Optional, Union, get_args, get_origin
|
|
5
|
+
|
|
6
|
+
import pydantic
|
|
7
|
+
from fastapi import HTTPException
|
|
8
|
+
from pydantic.fields import Field, FieldInfo
|
|
9
|
+
from sqlalchemy import select
|
|
10
|
+
from sqlalchemy.exc import NoResultFound
|
|
11
|
+
from sqlalchemy.ext.asyncio.session import AsyncSession as SA_AsyncSession
|
|
12
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
13
|
+
from sqlalchemy.orm.session import Session as SA_Session
|
|
14
|
+
from typing_extensions import TypeVar
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BaseSchema(pydantic.BaseModel):
|
|
18
|
+
"""Thin Pydantic base for ORM-facing Restly schemas.
|
|
19
|
+
|
|
20
|
+
Equivalent to::
|
|
21
|
+
|
|
22
|
+
class BaseSchema(pydantic.BaseModel):
|
|
23
|
+
model_config = pydantic.ConfigDict(from_attributes=True)
|
|
24
|
+
|
|
25
|
+
``from_attributes=True`` lets Pydantic/FastAPI validate objects by
|
|
26
|
+
attribute when the schema is used directly. Generated Restly routes still
|
|
27
|
+
serialize through ``to_response_schema()`` so Restly-specific behavior such
|
|
28
|
+
as ``WriteOnly`` filtering and relationship-id normalization is applied.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
model_config = pydantic.ConfigDict(from_attributes=True)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _Marker:
|
|
35
|
+
def __init__(self, name: str):
|
|
36
|
+
self.name = name
|
|
37
|
+
|
|
38
|
+
def __repr__(self):
|
|
39
|
+
return f"fr.{self.name}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
readonly_marker = _Marker("ReadOnly")
|
|
43
|
+
writeonly_marker = _Marker("WriteOnly")
|
|
44
|
+
|
|
45
|
+
_T = TypeVar("_T")
|
|
46
|
+
|
|
47
|
+
ReadOnly = Annotated[_T, readonly_marker, Field(json_schema_extra={"readOnly": True})]
|
|
48
|
+
WriteOnly = Annotated[
|
|
49
|
+
_T, writeonly_marker, Field(json_schema_extra={"writeOnly": True})
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TimestampsSchemaMixin(pydantic.BaseModel):
|
|
54
|
+
created_at: ReadOnly[datetime]
|
|
55
|
+
updated_at: ReadOnly[datetime]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
SQLAlchemyModel = TypeVar(
|
|
59
|
+
"SQLAlchemyModel", bound=DeclarativeBase, default=DeclarativeBase
|
|
60
|
+
)
|
|
61
|
+
_IDREF_UNSET = object()
|
|
62
|
+
_SCHEMA_RESOURCE_SUFFIX = "Read"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@functools.cache
|
|
66
|
+
def _id_type_adapter(id_type: Any) -> pydantic.TypeAdapter[Any]:
|
|
67
|
+
return pydantic.TypeAdapter(id_type)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _schema_resource_name(model_cls: type[pydantic.BaseModel]) -> str:
|
|
71
|
+
"""Return the resource name used to derive role-specific API schemas."""
|
|
72
|
+
name = model_cls.__name__
|
|
73
|
+
if name.endswith(_SCHEMA_RESOURCE_SUFFIX) and len(name) > len(
|
|
74
|
+
_SCHEMA_RESOURCE_SUFFIX
|
|
75
|
+
):
|
|
76
|
+
return name[: -len(_SCHEMA_RESOURCE_SUFFIX)]
|
|
77
|
+
return name
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _schema_role_name(model_cls: type[pydantic.BaseModel], role: str) -> str:
|
|
81
|
+
return f"{_schema_resource_name(model_cls)}{role}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class IDSchema(BaseSchema, Generic[SQLAlchemyModel]):
|
|
85
|
+
"""Generic schema useful for serializing only the id of objects.
|
|
86
|
+
Can be used as IDSchema[MyModel].
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
# Keep this broad so relation-id payloads can target non-int primary keys.
|
|
90
|
+
id: ReadOnly[Any]
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def _get_sql_model_annotation(cls) -> type[DeclarativeBase] | None:
|
|
94
|
+
# `__pydantic_generic_metadata__` is set on parameterised subclasses;
|
|
95
|
+
# on the bare `IDSchema` class the "args" tuple may be missing or empty.
|
|
96
|
+
try:
|
|
97
|
+
sql_model = cls.__pydantic_generic_metadata__["args"][0]
|
|
98
|
+
except (KeyError, IndexError, TypeError):
|
|
99
|
+
return None
|
|
100
|
+
return sql_model if isinstance(sql_model, type) else None
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def _get_sql_model_id_type(cls) -> Any:
|
|
104
|
+
sql_model = cls._get_sql_model_annotation()
|
|
105
|
+
if sql_model is None:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
for model_cls in sql_model.mro():
|
|
109
|
+
annotation = getattr(model_cls, "__annotations__", {}).get("id")
|
|
110
|
+
if annotation is None:
|
|
111
|
+
continue
|
|
112
|
+
origin = get_origin(annotation)
|
|
113
|
+
if origin is not None:
|
|
114
|
+
args = get_args(annotation)
|
|
115
|
+
if args:
|
|
116
|
+
return args[0]
|
|
117
|
+
return annotation
|
|
118
|
+
|
|
119
|
+
# Fallback: ask the SA mapper. `python_type` raises NotImplementedError
|
|
120
|
+
# for column types without a Python equivalent (e.g. some user types),
|
|
121
|
+
# and accessing `__mapper__` may fail with AttributeError if the class
|
|
122
|
+
# has not been mapped yet.
|
|
123
|
+
try:
|
|
124
|
+
return sql_model.__mapper__.primary_key[0].type.python_type
|
|
125
|
+
except (AttributeError, NotImplementedError, IndexError):
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
@pydantic.field_validator("id", mode="before", check_fields=False)
|
|
129
|
+
@classmethod
|
|
130
|
+
def _coerce_id_to_model_primary_key_type(cls, value: Any) -> Any:
|
|
131
|
+
id_type = cls._get_sql_model_id_type()
|
|
132
|
+
if id_type in (None, Any):
|
|
133
|
+
return value
|
|
134
|
+
return _id_type_adapter(id_type).validate_python(value)
|
|
135
|
+
|
|
136
|
+
def get_sql_model_annotation(self) -> type[SQLAlchemyModel] | None:
|
|
137
|
+
"""
|
|
138
|
+
Return the annotation on IDSchema when used as:
|
|
139
|
+
|
|
140
|
+
foo: IDSchema[Foo]
|
|
141
|
+
|
|
142
|
+
This property will return "Foo".
|
|
143
|
+
"""
|
|
144
|
+
# The runtime introspection returns the bound type; cast through the
|
|
145
|
+
# generic parameter so callers see the concrete model class.
|
|
146
|
+
return self._get_sql_model_annotation() # type: ignore[return-value]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class IDRef(IDSchema[SQLAlchemyModel], Generic[SQLAlchemyModel]):
|
|
150
|
+
"""Reference to a row of T by id.
|
|
151
|
+
|
|
152
|
+
Wire format is the raw id value (e.g. ``5``); accepts both scalars and
|
|
153
|
+
``{"id": N}`` dicts on input. The framework validates the referenced row
|
|
154
|
+
exists and resolves it to the FK column on the way in.
|
|
155
|
+
|
|
156
|
+
Use this for typical REST APIs where you want ``task_id: 5`` on the wire.
|
|
157
|
+
For JSON-API or React-Admin-style nested wire format ``{"id": N}``, use
|
|
158
|
+
``IDSchema[T]`` instead.
|
|
159
|
+
|
|
160
|
+
products: list[IDRef[Product]] # serializes as ["uuid1", "uuid2"]
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def __init__(self, value: Any = _IDREF_UNSET, **data: Any) -> None:
|
|
164
|
+
if value is not _IDREF_UNSET:
|
|
165
|
+
if data:
|
|
166
|
+
raise TypeError(
|
|
167
|
+
"IDRef accepts either a positional id or keyword fields"
|
|
168
|
+
)
|
|
169
|
+
data = value if isinstance(value, dict) else {"id": value}
|
|
170
|
+
super().__init__(**data)
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def __get_pydantic_json_schema__(
|
|
174
|
+
cls, core_schema: Any, handler: pydantic.GetJsonSchemaHandler
|
|
175
|
+
) -> dict[str, Any]:
|
|
176
|
+
id_type = cls._get_sql_model_id_type()
|
|
177
|
+
if id_type in (None, Any):
|
|
178
|
+
return {}
|
|
179
|
+
return pydantic.TypeAdapter(id_type).json_schema(
|
|
180
|
+
mode=getattr(handler, "mode", "validation")
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
@pydantic.model_validator(mode="before")
|
|
184
|
+
@classmethod
|
|
185
|
+
def _coerce_scalar(cls, v: Any) -> Any:
|
|
186
|
+
if not isinstance(v, dict):
|
|
187
|
+
return {"id": v}
|
|
188
|
+
return v
|
|
189
|
+
|
|
190
|
+
@pydantic.model_serializer
|
|
191
|
+
def _serialize_flat(self) -> Any:
|
|
192
|
+
return self.id if hasattr(self, "id") else self
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def _async_resolve_ids_to_sqlalchemy_objects(
|
|
196
|
+
session: SA_AsyncSession, schema_obj: pydantic.BaseModel
|
|
197
|
+
) -> None:
|
|
198
|
+
"""
|
|
199
|
+
Go over the Pydantic fields and turn any IDSchema objects into SQLAlchemy instances.
|
|
200
|
+
A database request is made for each IDSchema to look up the related row in the database.
|
|
201
|
+
If an id is not found in the database `sqlalchemy.orm.exc.NoResultFound` is raised.
|
|
202
|
+
"""
|
|
203
|
+
# Go over all Pydantic fields and check if any of them are an IDSchema object or
|
|
204
|
+
# a list of IDSchema objects.
|
|
205
|
+
for field in schema_obj.model_fields_set:
|
|
206
|
+
value = getattr(schema_obj, field, None)
|
|
207
|
+
|
|
208
|
+
if isinstance(value, IDSchema):
|
|
209
|
+
sql_model = value.get_sql_model_annotation()
|
|
210
|
+
if not sql_model:
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
# Replace the IDSchema object with a SQLAlchemy instance from the database
|
|
214
|
+
try:
|
|
215
|
+
sql_model_obj = await session.get_one(sql_model, value.id)
|
|
216
|
+
except NoResultFound as e:
|
|
217
|
+
raise HTTPException(
|
|
218
|
+
status_code=404, detail=f"Id not found for {field}: {value.id}"
|
|
219
|
+
) from e
|
|
220
|
+
setattr(schema_obj, field, sql_model_obj)
|
|
221
|
+
|
|
222
|
+
elif isinstance(value, list) and any(isinstance(i, IDSchema) for i in value):
|
|
223
|
+
# Assume all IdSchemas are for the same model
|
|
224
|
+
sql_model = value[0].get_sql_model_annotation()
|
|
225
|
+
if not sql_model:
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
# Replace all IDSchema objects with SQLAlchemy instances
|
|
229
|
+
ids = [obj.id for obj in value]
|
|
230
|
+
query = select(sql_model).where(sql_model.id.in_(ids))
|
|
231
|
+
sql_model_objs = list(await session.scalars(query))
|
|
232
|
+
|
|
233
|
+
if len(ids) != len(sql_model_objs):
|
|
234
|
+
missing_ids = set(ids).difference(o.id for o in sql_model_objs)
|
|
235
|
+
raise HTTPException(
|
|
236
|
+
status_code=404, detail=f"Id not found for {field}: {missing_ids}"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
setattr(schema_obj, field, sql_model_objs)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _resolve_ids_to_sqlalchemy_objects(
|
|
243
|
+
session: SA_Session, schema_obj: pydantic.BaseModel
|
|
244
|
+
) -> None:
|
|
245
|
+
"""
|
|
246
|
+
Go over the Pydantic fields and turn any IDSchema objects into SQLAlchemy instances.
|
|
247
|
+
A database request is made for each IDSchema to look up the related row in the database.
|
|
248
|
+
If an id is not found in the database `sqlalchemy.orm.exc.NoResultFound` is raised.
|
|
249
|
+
"""
|
|
250
|
+
# Go over all Pydantic fields and check if any of them are an IDSchema object or
|
|
251
|
+
# a list of IDSchema objects.
|
|
252
|
+
for field in schema_obj.model_fields_set:
|
|
253
|
+
value = getattr(schema_obj, field, None)
|
|
254
|
+
|
|
255
|
+
if isinstance(value, IDSchema):
|
|
256
|
+
sql_model = value.get_sql_model_annotation()
|
|
257
|
+
if not sql_model:
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
# Replace the IDSchema object with a SQLAlchemy instance from the database
|
|
261
|
+
try:
|
|
262
|
+
sql_model_obj = session.get_one(sql_model, value.id)
|
|
263
|
+
except NoResultFound as e:
|
|
264
|
+
raise HTTPException(
|
|
265
|
+
status_code=404, detail=f"Id not found for {field}: {value.id}"
|
|
266
|
+
) from e
|
|
267
|
+
setattr(schema_obj, field, sql_model_obj)
|
|
268
|
+
|
|
269
|
+
elif isinstance(value, list) and any(isinstance(i, IDSchema) for i in value):
|
|
270
|
+
# Assume all IdSchemas are for the same model
|
|
271
|
+
sql_model = value[0].get_sql_model_annotation()
|
|
272
|
+
if not sql_model:
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
# Replace all IDSchema objects with SQLAlchemy instances
|
|
276
|
+
ids = [obj.id for obj in value]
|
|
277
|
+
query = select(sql_model).where(sql_model.id.in_(ids))
|
|
278
|
+
sql_model_objs = list(session.scalars(query))
|
|
279
|
+
|
|
280
|
+
if len(ids) != len(sql_model_objs):
|
|
281
|
+
missing_ids = set(ids).difference(o.id for o in sql_model_objs)
|
|
282
|
+
raise HTTPException(
|
|
283
|
+
status_code=404, detail=f"Id not found for {field}: {missing_ids}"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
setattr(schema_obj, field, sql_model_objs)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def get_read_only_fields(model_cls: type[pydantic.BaseModel]) -> list[str]:
|
|
290
|
+
"""Get all fields from a model annotated as ReadOnly[]"""
|
|
291
|
+
read_only_fields: list[str] = []
|
|
292
|
+
# Get read-only fields from Annotated metadata
|
|
293
|
+
for field_name, field_info in model_cls.model_fields.items():
|
|
294
|
+
metadata = getattr(field_info, "metadata", None)
|
|
295
|
+
if metadata and readonly_marker in metadata:
|
|
296
|
+
read_only_fields.append(field_name)
|
|
297
|
+
return read_only_fields
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def is_readonly_field(
|
|
301
|
+
model: pydantic.BaseModel | type[pydantic.BaseModel], field_name: str
|
|
302
|
+
) -> bool:
|
|
303
|
+
"""Check if a specific field is marked as readonly."""
|
|
304
|
+
if isinstance(model, pydantic.BaseModel):
|
|
305
|
+
model = model.__class__
|
|
306
|
+
field_info = model.model_fields.get(field_name)
|
|
307
|
+
return _is_readonly(field_info)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _is_readonly(field_info: FieldInfo | None) -> bool:
|
|
311
|
+
if field_info is None:
|
|
312
|
+
return False
|
|
313
|
+
metadata = getattr(field_info, "metadata", None)
|
|
314
|
+
if not metadata:
|
|
315
|
+
return False
|
|
316
|
+
return readonly_marker in metadata
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _is_writeonly(field_info: FieldInfo | None) -> bool:
|
|
320
|
+
if field_info is None:
|
|
321
|
+
return False
|
|
322
|
+
metadata = getattr(field_info, "metadata", None)
|
|
323
|
+
if not metadata:
|
|
324
|
+
return False
|
|
325
|
+
return writeonly_marker in metadata
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def get_write_only_fields(model_cls: type[pydantic.BaseModel]) -> list[str]:
|
|
329
|
+
"""Get all fields from a model annotated as WriteOnly[]"""
|
|
330
|
+
write_only_fields: list[str] = []
|
|
331
|
+
# Get write-only fields from Annotated metadata
|
|
332
|
+
for field_name, field_info in model_cls.model_fields.items():
|
|
333
|
+
if _is_writeonly(field_info):
|
|
334
|
+
write_only_fields.append(field_name)
|
|
335
|
+
return write_only_fields
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def is_writeonly_field(
|
|
339
|
+
model_cls: pydantic.BaseModel | type[pydantic.BaseModel], field_name: str
|
|
340
|
+
) -> bool:
|
|
341
|
+
"""Check if a specific field is marked as writeonly."""
|
|
342
|
+
if isinstance(model_cls, pydantic.BaseModel):
|
|
343
|
+
model_cls = model_cls.__class__
|
|
344
|
+
field_info = model_cls.model_fields.get(field_name)
|
|
345
|
+
return _is_writeonly(field_info)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def create_model_without_read_only_fields(
|
|
349
|
+
model_cls: type[pydantic.BaseModel],
|
|
350
|
+
) -> type[pydantic.BaseModel]:
|
|
351
|
+
"""
|
|
352
|
+
Create a subclass of the given pydantic model class with a new name.
|
|
353
|
+
"""
|
|
354
|
+
new_model_name = _schema_role_name(model_cls, "Create")
|
|
355
|
+
new_doc = (model_cls.__doc__ or "") + "\nRead-only fields have been removed."
|
|
356
|
+
|
|
357
|
+
# Create a subclass that mixes in OmitReadOnlyMixin
|
|
358
|
+
new_model_cls = type(
|
|
359
|
+
new_model_name,
|
|
360
|
+
(OmitReadOnlyMixin, model_cls),
|
|
361
|
+
{"__module__": model_cls.__module__, "__doc__": new_doc},
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
return new_model_cls
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
class OmitReadOnlyMixin(pydantic.BaseModel):
|
|
368
|
+
"""
|
|
369
|
+
Mixin for pydantic models that removes all fields marked as ReadOnly.
|
|
370
|
+
|
|
371
|
+
Implementation note: this mutates ``cls.model_fields`` in place and then
|
|
372
|
+
calls ``model_rebuild(force=True)`` to regenerate the validator/serializer.
|
|
373
|
+
Pydantic v2 does not officially document mutation of ``model_fields`` as
|
|
374
|
+
a supported customisation hook, but this approach has been stable since
|
|
375
|
+
pydantic 2.0 and works on the pinned minimum (``pydantic>=2.11.4``). If
|
|
376
|
+
a future pydantic release freezes the dict, switch to constructing a new
|
|
377
|
+
model via ``pydantic.create_model(...)`` over the kept fields. The
|
|
378
|
+
regression test ``tests/test_pydantic_model_fields_mutation.py`` exercises
|
|
379
|
+
this contract on the currently-installed pydantic.
|
|
380
|
+
"""
|
|
381
|
+
|
|
382
|
+
@classmethod
|
|
383
|
+
def __pydantic_init_subclass__(cls, **kwargs: Any) -> None:
|
|
384
|
+
super().__pydantic_init_subclass__(**kwargs)
|
|
385
|
+
|
|
386
|
+
# Collect readonly fields to delete first
|
|
387
|
+
readonly_fields = []
|
|
388
|
+
for name, field_info in cls.model_fields.items():
|
|
389
|
+
if _is_readonly(field_info):
|
|
390
|
+
readonly_fields.append(name)
|
|
391
|
+
|
|
392
|
+
# Delete readonly fields after iteration is complete
|
|
393
|
+
for name in readonly_fields:
|
|
394
|
+
del cls.model_fields[name]
|
|
395
|
+
|
|
396
|
+
cls.model_rebuild(force=True)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def rebase_with_model_config(
|
|
400
|
+
base: tuple[type, ...], model_cls: type[pydantic.BaseModel]
|
|
401
|
+
) -> type[pydantic.BaseModel]:
|
|
402
|
+
def class_body(ns: dict[str, Any]) -> None:
|
|
403
|
+
ns["model_config"] = model_cls.model_config.copy()
|
|
404
|
+
|
|
405
|
+
return types.new_class(
|
|
406
|
+
f"{model_cls.__name__}ModelConfig", base, exec_body=class_body
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def create_model_with_optional_fields(
|
|
411
|
+
model_cls: type[pydantic.BaseModel],
|
|
412
|
+
) -> type[pydantic.BaseModel]:
|
|
413
|
+
"""
|
|
414
|
+
Create a subclass of the given pydantic model class with a new name.
|
|
415
|
+
Read-only fields are removed and all writable fields are made optional with None as default.
|
|
416
|
+
"""
|
|
417
|
+
new_model_name = _schema_role_name(model_cls, "Update")
|
|
418
|
+
new_doc = (
|
|
419
|
+
model_cls.__doc__ or ""
|
|
420
|
+
) + "\nRead-only fields have been removed and all fields are optional."
|
|
421
|
+
|
|
422
|
+
# Create a subclass that mixes in both OmitReadOnlyMixin and PatchMixin
|
|
423
|
+
new_model_cls = type(
|
|
424
|
+
new_model_name,
|
|
425
|
+
(PatchMixin, OmitReadOnlyMixin, model_cls),
|
|
426
|
+
{"__module__": model_cls.__module__, "__doc__": new_doc},
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
return new_model_cls
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
class PatchMixin(pydantic.BaseModel):
|
|
433
|
+
"""
|
|
434
|
+
A mixin for pydantic classes that makes all fields optional and replaces defaults
|
|
435
|
+
with None.
|
|
436
|
+
|
|
437
|
+
Implementation note: like :class:`OmitReadOnlyMixin` this mutates
|
|
438
|
+
``cls.model_fields`` (specifically ``FieldInfo.default`` and
|
|
439
|
+
``FieldInfo.annotation``) and then calls ``model_rebuild(force=True)``.
|
|
440
|
+
This relies on pydantic v2 keeping ``FieldInfo`` mutable; verified on the
|
|
441
|
+
pinned ``pydantic>=2.11.4`` minimum and exercised by
|
|
442
|
+
``tests/test_pydantic_model_fields_mutation.py``.
|
|
443
|
+
"""
|
|
444
|
+
|
|
445
|
+
@classmethod
|
|
446
|
+
def __pydantic_init_subclass__(cls, **kwargs: Any) -> None:
|
|
447
|
+
super().__pydantic_init_subclass__(**kwargs)
|
|
448
|
+
|
|
449
|
+
for field in cls.model_fields.values():
|
|
450
|
+
field.default = None
|
|
451
|
+
# Only wrap if not already Optional, to avoid Optional[Optional[T]]
|
|
452
|
+
annotation = field.annotation
|
|
453
|
+
if isinstance(annotation, types.UnionType):
|
|
454
|
+
# Python 3.10+ `X | Y` syntax - check if None is already a member.
|
|
455
|
+
# Convert to typing.Optional form so FieldInfo.annotation stays compatible.
|
|
456
|
+
union_args = get_args(annotation)
|
|
457
|
+
if type(None) not in union_args:
|
|
458
|
+
non_none = [a for a in union_args if a is not type(None)]
|
|
459
|
+
inner = (
|
|
460
|
+
non_none[0] if len(non_none) == 1 else Union[tuple(non_none)]
|
|
461
|
+
)
|
|
462
|
+
field.annotation = Optional[inner] # type: ignore[assignment]
|
|
463
|
+
else:
|
|
464
|
+
origin = getattr(annotation, "__origin__", None)
|
|
465
|
+
if origin is not Union or type(None) not in get_args(annotation):
|
|
466
|
+
field.annotation = Optional[annotation] # type: ignore[assignment]
|
|
467
|
+
|
|
468
|
+
cls.model_rebuild(force=True)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def getattrs(obj: Any, *attrs: str, default: Any = None) -> Any:
|
|
472
|
+
"""
|
|
473
|
+
Try access a chain of attributes and return the default if any of the attrs is not defined.
|
|
474
|
+
"""
|
|
475
|
+
for attr in attrs:
|
|
476
|
+
if not hasattr(obj, attr):
|
|
477
|
+
return default
|
|
478
|
+
obj = getattr(obj, attr)
|
|
479
|
+
return obj
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def set_schema_title(schema_cls: type[pydantic.BaseModel]) -> None:
|
|
483
|
+
"""Set the title of a schema class to its name.
|
|
484
|
+
This is used to make the schema title match the model name in the OpenAPI schema.
|
|
485
|
+
"""
|
|
486
|
+
schema_cls.model_config["title"] = schema_cls.__name__
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def get_writable_inputs(
|
|
490
|
+
schema_obj: pydantic.BaseModel, schema_cls: type[pydantic.BaseModel] | None = None
|
|
491
|
+
) -> dict[str, Any]:
|
|
492
|
+
"""
|
|
493
|
+
Return a dictionary of field_name: value pairs for writable input fields.
|
|
494
|
+
|
|
495
|
+
Filters out:
|
|
496
|
+
- ReadOnly fields
|
|
497
|
+
- fields not provided with input (using Pydantic model_fields_set)
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
schema_obj: The schema object to extract writable fields from
|
|
501
|
+
schema_cls: The schema class to check for readonly fields. If None, uses schema_obj.__class__
|
|
502
|
+
|
|
503
|
+
Returns:
|
|
504
|
+
Dictionary mapping field names to their values for writable input fields only
|
|
505
|
+
"""
|
|
506
|
+
if schema_cls is None:
|
|
507
|
+
schema_cls = schema_obj.__class__
|
|
508
|
+
|
|
509
|
+
updated_fields: dict[str, Any] = {}
|
|
510
|
+
for field_name, value in schema_obj:
|
|
511
|
+
if field_name not in schema_obj.model_fields_set:
|
|
512
|
+
continue
|
|
513
|
+
# Skip readonly fields
|
|
514
|
+
if is_readonly_field(schema_cls, field_name):
|
|
515
|
+
continue
|
|
516
|
+
updated_fields[field_name] = value
|
|
517
|
+
|
|
518
|
+
return updated_fields
|