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.
@@ -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