Coverage for fastapi_restly / schemas / _base.py: 94%

540 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-09 18:41 +0000

1import dataclasses 

2import functools 

3import inspect 

4import sys 

5import types 

6from collections.abc import Iterable, Mapping 

7from datetime import datetime 

8from typing import ( 

9 TYPE_CHECKING, 

10 Annotated, 

11 Any, 

12 ClassVar, 

13 ForwardRef, 

14 Generic, 

15 Literal, 

16 Optional, 

17 Union, 

18 cast, 

19 final, 

20 get_args, 

21 get_origin, 

22) 

23 

24import pydantic 

25from pydantic.fields import Field, FieldInfo 

26from sqlalchemy import inspect as sa_inspect 

27from sqlalchemy import select 

28from sqlalchemy.exc import NoResultFound 

29from sqlalchemy.ext.asyncio.session import AsyncSession as SA_AsyncSession 

30from sqlalchemy.orm.session import Session as SA_Session 

31from typing_extensions import TypeAliasType, TypeVar 

32 

33from .._mapping import is_mapped_instance 

34from .._pagination import ( 

35 _DEFAULT_PAGINATION, 

36 Envelope, 

37 NoPagination, 

38 NumberedPagination, 

39) 

40from ..clauses import Unscoped, WhereClause, apply_clauses 

41from ..clauses._scopes import _default_scope 

42from ..exc import NotFound, RestlyConfigurationError 

43 

44if sys.version_info >= (3, 14): 44 ↛ 45line 44 didn't jump to line 45 because the condition on line 44 was never true

45 import annotationlib 

46 

47 

48class BaseSchema(pydantic.BaseModel): 

49 """Thin Pydantic base for ORM-facing Restly schemas. 

50 

51 Equivalent to:: 

52 

53 class BaseSchema(pydantic.BaseModel): 

54 model_config = pydantic.ConfigDict(from_attributes=True) 

55 

56 ``from_attributes=True`` lets Pydantic/FastAPI validate objects by 

57 attribute when the schema is used directly. The inherited CRUD endpoint 

58 methods still serialize through ``to_single_response()`` so Restly-specific 

59 behavior such as ``WriteOnly`` filtering and relationship-id normalization 

60 is applied. 

61 """ 

62 

63 model_config = pydantic.ConfigDict(from_attributes=True) 

64 

65 @classmethod 

66 def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: 

67 super().__pydantic_init_subclass__(**kwargs) 

68 # Reject a ReadOnly/WriteOnly marker buried inside a union member, where 

69 # it silently no-ops (see ``_reject_buried_markers``). This fires as the 

70 # schema class is defined, so the mistake surfaces at import time. Views 

71 # also re-check the schemas they use, to cover schemas that do not derive 

72 # from ``BaseSchema``. 

73 _reject_buried_markers(cls) 

74 

75 

76class _Marker: 

77 def __init__(self, name: str): 

78 self.name = name 

79 

80 def __repr__(self): 

81 return f"fr.{self.name}" 

82 

83 

84readonly_marker = _Marker("ReadOnly") 

85writeonly_marker = _Marker("WriteOnly") 

86 

87_T = TypeVar("_T") 

88 

89ReadOnly = Annotated[_T, readonly_marker, Field(json_schema_extra={"readOnly": True})] 

90# ``exclude=True`` strips the field from serialization at the field level, so it 

91# is dropped from every response -- recursively, including in nested schemas, and 

92# from the OpenAPI response schema -- while staying a writable request input 

93# (exclude does not affect validation). Prefer ``WriteOnly[Optional[T]]`` over 

94# ``Optional[WriteOnly[T]]`` / ``WriteOnly[T] | None``: when the marker is only a 

95# union member the exclude rides on the inner type and does NOT apply, so the 

96# field would leak. 

97WriteOnly = Annotated[ 

98 _T, writeonly_marker, Field(json_schema_extra={"writeOnly": True}, exclude=True) 

99] 

100 

101 

102# A ReadOnly/WriteOnly marker only takes effect as the OUTER annotation of a 

103# field: its ``Annotated`` metadata has to sit at the field's top level. Nested 

104# anywhere inside the field's type -- a union member (``Optional[ReadOnly[T]]``, 

105# ``WriteOnly[T] | None``) or a container element (``list[WriteOnly[T]]``) -- the 

106# marker silently no-ops: ReadOnly fails to drop the field from create/update (it 

107# stays writable) and WriteOnly fails to exclude it from responses (it leaks). 

108# The guards below detect that misuse and reject it loudly. 

109# 

110# Coverage boundary: detection reads ``field_info.annotation``, so a marker 

111# hidden behind an unresolved forward reference is not seen until the annotation 

112# resolves (the view-registration backstop re-checks resolved schemas), and that 

113# backstop only inspects a view's own read/create/update schemas -- not a custom 

114# ``response_model=`` or a non-``BaseSchema`` nested model. Those narrow cases are 

115# left uncovered by design. 

116def _annotation_buries_marker(annotation: Any, marker: _Marker) -> bool: 

117 """True if ``marker`` appears anywhere below the top level of ``annotation`` 

118 -- inside a union member, ``Annotated`` inner type, or container arg. 

119 

120 Only descends type arguments (``get_args``); it does not recurse into the 

121 fields of a nested model, so a nested schema carrying its own top-level 

122 marker is not flagged. 

123 """ 

124 if marker in getattr(annotation, "__metadata__", ()): 

125 return True 

126 return any(_annotation_buries_marker(arg, marker) for arg in get_args(annotation)) 

127 

128 

129def _find_buried_marker_fields( 

130 model_cls: type[pydantic.BaseModel], 

131) -> list[tuple[str, _Marker]]: 

132 """Return ``(field_name, marker)`` for each field whose ReadOnly/WriteOnly 

133 marker is nested inside the field's type instead of wrapping it, where it 

134 does not take effect.""" 

135 buried: list[tuple[str, _Marker]] = [] 

136 for name, field_info in model_cls.model_fields.items(): 

137 top_level = getattr(field_info, "metadata", None) or () 

138 for marker in (readonly_marker, writeonly_marker): 

139 if marker in top_level: 

140 continue 

141 if _annotation_buries_marker(field_info.annotation, marker): 

142 buried.append((name, marker)) 

143 return buried 

144 

145 

146def _reject_buried_markers(model_cls: type[pydantic.BaseModel]) -> None: 

147 """Raise if any field nests a ReadOnly/WriteOnly/RefExists marker inside 

148 its type, where the marker machinery cannot see it.""" 

149 for name, marker in _find_buried_marker_fields(model_cls): 

150 raise RestlyConfigurationError( 

151 f"{model_cls.__name__}.{name} nests the {marker.name} marker inside " 

152 f"its type (e.g. Optional[{marker.name}[T]], {marker.name}[T] | None, " 

153 f"or list[{marker.name}[T]]). The marker only takes effect as the " 

154 f"outer annotation, so it is silently ignored here and {marker.name} " 

155 f"is not applied. Wrap the whole field type instead, e.g. " 

156 f"{marker.name}[Optional[T]]." 

157 ) 

158 _reject_buried_ref_exists(model_cls) 

159 

160 

161@final 

162class _Infer: 

163 """Static-only sentinel: the default ``MustExist`` model argument. 

164 

165 ``MustExist[int]`` supplies no target model, so the second type parameter 

166 defaults to ``_Infer`` (a PEP 696 default that keeps Pyright happy about 

167 "supply all params"). It carries no runtime meaning of its own: one type 

168 argument is already unambiguous, and the runtime reads it as "resolve the 

169 target model from the column's ``ForeignKey``". 

170 """ 

171 

172 

173@final 

174class _NotGiven: 

175 """Static-only sentinel: ``RefExists`` received no ``scope`` argument. 

176 

177 Not given means the target model's ``default_scope`` applies; the 

178 explicit opt-out is ``fr.clauses.UNSCOPED``, and ``None`` is rejected 

179 so a variable that happens to be ``None`` can never silently unscope. 

180 """ 

181 

182 

183_NOT_GIVEN = _NotGiven() 

184 

185 

186class RefExists: 

187 """Marker for an existence-checked scalar foreign key. 

188 

189 Built by ``MustExist[pk]`` / ``MustExist[pk, Model]`` (or written directly as 

190 ``Annotated[pk, RefExists(Model)]``). On write, Restly batch-checks that each 

191 marked id exists in the target table and raises ``NotFound`` (404) on a miss. 

192 The field stays a plain scalar -- this only adds the check; it does not wrap 

193 the value or do any relationship routing. 

194 

195 The check applies the target model's ``default_scope``, like 

196 ``IDRef``/``IDSchema`` resolution: a reference to a row the scope hides 

197 is a miss. ``scope`` overrides that per field: a ``WhereClause`` checks 

198 against that clause instead (``RefExists(Item, scope=ItemClauses.trashed)`` 

199 for a restore target), and ``scope=fr.clauses.UNSCOPED`` checks 

200 unscoped, greppably; ``None`` is rejected. A model without a 

201 ``default_scope`` is checked unscoped, as before. 

202 

203 ``model`` is the target ORM model, or ``_Infer`` when it should be resolved 

204 from the marked column's ``ForeignKey`` (the ``MustExist[pk]`` form). 

205 """ 

206 

207 def __init__( 

208 self, 

209 model: type[Any] | type[_Infer], 

210 scope: WhereClause | Unscoped | _NotGiven = _NOT_GIVEN, 

211 ) -> None: 

212 if scope is None: 

213 raise TypeError( 

214 "RefExists scope=None says nothing; opt out explicitly with " 

215 "scope=fr.clauses.UNSCOPED, so a variable that happens to be " 

216 "None can never silently unscope the check" 

217 ) 

218 if not isinstance(scope, (WhereClause, Unscoped, _NotGiven)): 

219 raise TypeError( 

220 f"RefExists scope must be a WhereClause or UNSCOPED, got " 

221 f"{type(scope).__name__}; wrap a raw expression with " 

222 "where_clause(), and express a join-dependent predicate " 

223 "as EXISTS (.any()/.has())" 

224 ) 

225 self.model = model 

226 self.scope = scope 

227 

228 def __repr__(self) -> str: 

229 name = ( 

230 "infer" 

231 if self.model is _Infer 

232 else getattr(self.model, "__name__", self.model) 

233 ) 

234 if isinstance(self.scope, _NotGiven): 234 ↛ 236line 234 didn't jump to line 236 because the condition on line 234 was always true

235 return f"RefExists({name})" 

236 return f"RefExists({name}, scope={self.scope!r})" 

237 

238 

239def _annotation_contains_ref_exists(annotation: Any) -> bool: 

240 if any(isinstance(m, RefExists) for m in getattr(annotation, "__metadata__", ())): 

241 return True 

242 return any(_annotation_contains_ref_exists(arg) for arg in get_args(annotation)) 

243 

244 

245def _reject_buried_ref_exists(model_cls: type[pydantic.BaseModel]) -> None: 

246 """Raise for a RefExists marker the check machinery cannot see. 

247 

248 The existence check reads a field's top-level metadata (plus the 

249 Optional-unwrapped form); a marker inside a container element 

250 (``list[MustExist[int, T]]``) is invisible there, so the field would 

251 validate and write with no check at all -- a silent no-op of a check 

252 the schema explicitly asks for. 

253 """ 

254 for name, field_info in model_cls.model_fields.items(): 

255 if not _annotation_contains_ref_exists(field_info.annotation): 

256 continue 

257 if _ref_exists_marker(field_info) is not None: 

258 continue 

259 raise RestlyConfigurationError( 

260 f"{model_cls.__name__}.{name} buries a RefExists/MustExist marker " 

261 f"inside its type (e.g. list[MustExist[int, T]]), where the " 

262 f"existence check cannot see it, so the reference would go " 

263 f"unchecked. To reference several rows, use " 

264 f"list[IDRef[T]] on a relationship-named field instead." 

265 ) 

266 

267 

268class TimestampsSchemaMixin(pydantic.BaseModel): 

269 created_at: ReadOnly[datetime] 

270 updated_at: ReadOnly[datetime] 

271 

272 

273# Unbound: a mapped class need not subclass DeclarativeBase. 

274SQLAlchemyModel = TypeVar("SQLAlchemyModel", default=Any) 

275_IDREF_UNSET = object() 

276_SCHEMA_RESOURCE_SUFFIXES = ("Schema", "Response") 

277 

278 

279@functools.cache 

280def _id_type_adapter(id_type: Any) -> pydantic.TypeAdapter[Any]: 

281 return pydantic.TypeAdapter(id_type) 

282 

283 

284def _schema_resource_name(model_cls: type[pydantic.BaseModel]) -> str: 

285 """The resource part of a schema's class name: the name without a final 

286 ``Schema`` or ``Response``. ``UserSchema`` and ``UserResponse`` both give 

287 ``User``.""" 

288 name = model_cls.__name__ 

289 for suffix in _SCHEMA_RESOURCE_SUFFIXES: 

290 if name.endswith(suffix) and len(name) > len(suffix): 

291 return name[: -len(suffix)] 

292 return name 

293 

294 

295def _schema_role_name(model_cls: type[pydantic.BaseModel], role: str) -> str: 

296 """The name of a class that Restly generates from a schema: 

297 ``<Resource><Role>``, as in ``UserCreate``.""" 

298 return f"{_schema_resource_name(model_cls)}{role}" 

299 

300 

301def _own_annotations(cls: type) -> dict[str, Any]: 

302 """Return the annotations ``cls`` itself declares, without failing on them. 

303 

304 Under PEP 563 the values are strings. On 3.14 a name that is undefined at 

305 runtime, such as a ``TYPE_CHECKING`` import, comes back as a ``ForwardRef`` 

306 where reading ``__annotations__`` would raise ``NameError``. 

307 """ 

308 if sys.version_info >= (3, 14): 308 ↛ 309line 308 didn't jump to line 309 because the condition on line 308 was never true

309 return annotationlib.get_annotations( 

310 cls, format=annotationlib.Format.FORWARDREF 

311 ) 

312 return inspect.get_annotations(cls) 

313 

314 

315def _is_unresolved(annotation: Any) -> bool: 

316 """Whether ``annotation`` still holds a string or ``ForwardRef`` anywhere.""" 

317 if isinstance(annotation, (str, ForwardRef)): 

318 return True 

319 origin = get_origin(annotation) 

320 if origin is Literal: 320 ↛ 321line 320 didn't jump to line 321 because the condition on line 320 was never true

321 return False 

322 args = get_args(annotation) 

323 if origin is Annotated: 323 ↛ 325line 323 didn't jump to line 325 because the condition on line 323 was never true

324 # Metadata may be any object, strings included. 

325 args = args[:1] 

326 return any(_is_unresolved(arg) for arg in args) 

327 

328 

329def _model_id_type(sql_model: type[Any]) -> Any: 

330 """Return the Python type of ``sql_model``'s ``id`` primary key, or ``None``. 

331 

332 Reads the ``id`` annotation off the model's MRO (works before the mapper is 

333 configured), then falls back to the SA mapper -- which also covers a PEP 563 

334 / ``from __future__ import annotations`` model, whose ``id`` annotation is a 

335 string the mapper resolves authoritatively. Shared by ``IDSchema``/``IDRef`` 

336 (via ``_get_sql_model_id_type``) and ``MustExist``. 

337 """ 

338 for model_cls in sql_model.mro(): 

339 annotation = _own_annotations(model_cls).get("id") 

340 if annotation is None: 

341 continue 

342 if get_origin(annotation) is not None: 

343 args = get_args(annotation) 

344 if args: 344 ↛ 346line 344 didn't jump to line 346 because the condition on line 344 was always true

345 annotation = args[0] 

346 if _is_unresolved(annotation): 

347 # Don't return a raw string; the mapper below resolves the real 

348 # column type without eval'ing it. 

349 break 

350 return annotation 

351 

352 # Fallback: ask the SA mapper. `python_type` raises NotImplementedError for 

353 # column types without a Python equivalent (e.g. some user types), and 

354 # a class that is not mapped has no mapper to ask. 

355 try: 

356 return sa_inspect(sql_model, raiseerr=False).primary_key[0].type.python_type 

357 except (AttributeError, NotImplementedError, IndexError): 

358 return None 

359 

360 

361class IDSchema(BaseSchema, Generic[SQLAlchemyModel]): 

362 """Response-schema base that adds a read-only ``id``; parametrized as a 

363 field type, a nested-object relationship reference. 

364 

365 - As a BASE CLASS (``class UserSchema(IDSchema): ...``) it adds the resource's 

366 own read-only ``id`` -- the common use. 

367 - As a FIELD TYPE on a RELATIONSHIP-named field (``author: IDSchema[User]``) 

368 the wire format is ``{"id": N}`` (JSON-API / React-Admin); Restly resolves 

369 the id to the related ORM object, so ``data.author`` is a wrapper (read 

370 ``.id``). Use ``IDRef[User]`` for flat-id wire (``5``) instead. 

371 

372 For a scalar foreign-key COLUMN (``author_id``), use ``fr.MustExist[int, T]`` 

373 rather than ``IDSchema``/``IDRef``: it keeps the field a plain checked id 

374 instead of turning a ``*_id`` field into an object wrapper. 

375 """ 

376 

377 # Keep this broad so relation-id payloads can target non-int primary keys. 

378 id: ReadOnly[Any] 

379 

380 @classmethod 

381 def _get_sql_model_annotation(cls) -> type[Any] | None: 

382 # `__pydantic_generic_metadata__` is set on parameterised subclasses; 

383 # on the bare `IDSchema` class the "args" tuple may be missing or empty. 

384 try: 

385 sql_model = cls.__pydantic_generic_metadata__["args"][0] 

386 except (KeyError, IndexError, TypeError): 

387 return None 

388 return sql_model if isinstance(sql_model, type) else None 

389 

390 @classmethod 

391 def _get_sql_model_id_type(cls) -> Any: 

392 sql_model = cls._get_sql_model_annotation() 

393 if sql_model is None: 

394 return None 

395 return _model_id_type(sql_model) 

396 

397 @pydantic.field_validator("id", mode="before", check_fields=False) 

398 @classmethod 

399 def _coerce_id_to_model_primary_key_type(cls, value: Any) -> Any: 

400 id_type = cls._get_sql_model_id_type() 

401 if id_type in (None, Any): 

402 return value 

403 return _id_type_adapter(id_type).validate_python(value) 

404 

405 @pydantic.model_validator(mode="before") 

406 @classmethod 

407 def _coerce_scalar(cls, v: Any) -> Any: 

408 # A pure id reference (``IDSchema[Model]`` / ``IDRef[Model]``, whose only 

409 # field is ``id``) accepts a bare scalar id or a related ORM row where the 

410 # ``{"id": ...}`` mapping is expected, so the reference type is 

411 # self-sufficient under plain ``from_attributes``: a scalar FK column read 

412 # straight off the row in ``to_single_response``, a related row reached 

413 # through a relationship, or a ``{"id": N}`` payload all validate without 

414 # any view-layer pre-extraction. A subclass that adds fields (a nested 

415 # response schema) is NOT a pure reference, so it validates normally and 

416 # its row is never collapsed to just the id. 

417 if set(cls.model_fields) != {"id"}: 

418 return v 

419 if isinstance(v, dict): 

420 return v 

421 if is_mapped_instance(v): 

422 return {"id": getattr(v, "id")} 

423 return {"id": v} 

424 

425 def get_sql_model_annotation(self) -> type[SQLAlchemyModel] | None: 

426 """ 

427 Return the annotation on IDSchema when used as: 

428 

429 foo: IDSchema[Foo] 

430 

431 This property will return "Foo". 

432 """ 

433 # The runtime introspection returns the bound type; cast through the 

434 # generic parameter so callers see the concrete model class. 

435 return self._get_sql_model_annotation() # type: ignore[return-value] 

436 

437 

438class IDRef(IDSchema[SQLAlchemyModel], Generic[SQLAlchemyModel]): 

439 """Flat-id reference to a related row, for a RELATIONSHIP-named field. 

440 

441 Use ``IDRef[T]`` when the field is named after a relationship 

442 (``author: IDRef[User]``, ``products: list[IDRef[Product]]``), not after a 

443 ``*_id`` column. The wire format is the raw id (``5``); input also accepts 

444 ``{"id": N}``. Restly resolves the id to the related ORM object, so 

445 ``data.author`` is an ``IDRef`` wrapper (read ``.id``), not a plain scalar. 

446 

447 For a scalar foreign-key COLUMN (``author_id``, ``task_id``) use 

448 ``fr.MustExist[int, T]`` instead -- it keeps the field a plain checked id 

449 (``data.author_id == 1``), rather than making a ``*_id`` field an object 

450 wrapper. For nested ``{"id": N}`` wire on a relationship, use ``IDSchema[T]``. 

451 

452 author: IDRef[User] # to-one relationship, flat id 

453 products: list[IDRef[Product]] # serializes as [1, 2, 3] 

454 

455 Resolution applies the referenced model's ``default_scope``: a 

456 reference to a row the scope hides raises ``NotFound``, so a 

457 tenant scope declared once covers every reference to the model. A model 

458 without a ``default_scope`` resolves by bare primary key; gate visibility 

459 there in ``authorize`` (``data.<field>.id`` is the requested id, before 

460 resolution) or ``before_action_commit`` (the resolved row is on the built 

461 object). See the Foreign Keys and Relationships how-to, "Visibility and 

462 Multi-Tenancy". 

463 """ 

464 

465 def __init__(self, value: Any = _IDREF_UNSET, **data: Any) -> None: 

466 if value is not _IDREF_UNSET: 

467 if data: 467 ↛ 468line 467 didn't jump to line 468 because the condition on line 467 was never true

468 raise TypeError( 

469 "IDRef accepts either a positional id or keyword fields" 

470 ) 

471 data = value if isinstance(value, dict) else {"id": value} 

472 super().__init__(**data) 

473 

474 @classmethod 

475 def __get_pydantic_json_schema__( 

476 cls, core_schema: Any, handler: pydantic.GetJsonSchemaHandler 

477 ) -> dict[str, Any]: 

478 id_type = cls._get_sql_model_id_type() 

479 if id_type in (None, Any): 479 ↛ 480line 479 didn't jump to line 480 because the condition on line 479 was never true

480 return {} 

481 return pydantic.TypeAdapter(id_type).json_schema( 

482 mode=getattr(handler, "mode", "validation") 

483 ) 

484 

485 @pydantic.model_serializer 

486 def _serialize_flat(self) -> Any: 

487 return self.id if hasattr(self, "id") else self 

488 

489 

490if TYPE_CHECKING: 

491 _MustExistPK = TypeVar("_MustExistPK") 

492 _MustExistModel = TypeVar("_MustExistModel", default=_Infer) 

493 

494 # Static view: ``MustExist[pk]`` and ``MustExist[pk, Model]`` both read as the 

495 # pk scalar (the alias value is the first param), so a field stays a plain 

496 # scalar -- ``data.post_id`` is an ``int``, not a wrapper. The target model is 

497 # a runtime-only concern (second param, defaulting to ``_Infer``); the runtime 

498 # ``MustExist`` is defined below. 

499 MustExist = TypeAliasType( 

500 "MustExist", _MustExistPK, type_params=(_MustExistPK, _MustExistModel) 

501 ) 

502else: 

503 

504 class MustExist: 

505 """Existence-checked scalar foreign key. 

506 

507 ``MustExist[int]`` is a checked ``int`` foreign key; the target model is 

508 inferred from the column's ``ForeignKey``. Spell the model out with a 

509 second argument when you want it explicit (``MustExist[int, Post]``), and 

510 use the first for a non-int primary key (``MustExist[UUID, Account]``). 

511 

512 Unlike ``IDRef``/``IDSchema`` this is **not** a wrapper: the field stays 

513 the pk scalar everywhere (wire, column, ``data.<field>``), plus a batched 

514 existence check on write (404 on a miss). The check applies the target 

515 model's ``default_scope``; check against another clause, or none, by 

516 writing the marker form ``Annotated[<pk>, RefExists(Model, scope=...)]`` 

517 directly (see :class:`RefExists`). 

518 """ 

519 

520 def __class_getitem__(cls, params: Any) -> Any: 

521 pk_type, model = params if isinstance(params, tuple) else (params, _Infer) 

522 return Annotated[pk_type, RefExists(model)] 

523 

524 

525def _unwrap_optional_annotation(annotation: Any) -> Any: 

526 """Unwrap Optional[X] or X | None to X. Returns annotation unchanged otherwise.""" 

527 origin = get_origin(annotation) 

528 if origin not in (types.UnionType, Union): 

529 return annotation 

530 

531 non_none_args = [arg for arg in get_args(annotation) if arg is not type(None)] 

532 if len(non_none_args) == 1: 532 ↛ 534line 532 didn't jump to line 534 because the condition on line 532 was always true

533 return non_none_args[0] 

534 return annotation 

535 

536 

537def is_reference_annotation(annotation: Any) -> bool: 

538 """True if ``annotation`` is an ``IDRef``/``IDSchema`` reference type. 

539 

540 The canonical reference-type check, kept beside the types so callers (views, 

541 query) don't re-introspect Pydantic generics themselves. Unwraps ``Optional`` 

542 first, then matches the bare types or a parametrization whose generic origin 

543 is ``IDSchema``/``IDRef`` (``IDRef[Post]``, ``IDSchema[Post]``) -- not a user 

544 subclass that merely inherits from them. 

545 """ 

546 annotation = _unwrap_optional_annotation(annotation) 

547 if annotation in (IDSchema, IDRef): 

548 return True 

549 if not inspect.isclass(annotation): 

550 return False 

551 try: 

552 if not issubclass(annotation, IDSchema): 

553 return False 

554 except TypeError: 

555 return False 

556 metadata = getattr(annotation, "__pydantic_generic_metadata__", {}) 

557 return metadata.get("origin") in (IDSchema, IDRef) 

558 

559 

560def is_reference_field(schema_cls: type[pydantic.BaseModel], field_name: str) -> bool: 

561 """True if ``schema_cls``'s ``field_name`` is typed as an ``IDRef``/``IDSchema``.""" 

562 field_info = schema_cls.model_fields.get(field_name) 

563 if field_info is None: 563 ↛ 564line 563 didn't jump to line 564 because the condition on line 563 was never true

564 return False 

565 return is_reference_annotation(field_info.annotation) 

566 

567 

568def reference_origin_and_target(annotation: Any) -> tuple[type, type | None] | None: 

569 """For an ``IDRef``/``IDSchema`` field annotation (``Optional`` unwrapped), 

570 return ``(origin, target)``: the ``IDRef``/``IDSchema`` class and the 

571 referenced model (``None`` for a bare, unparametrized reference), or ``None`` 

572 when the annotation is not a reference type. Lets the scalar-named-reference 

573 lint name both the type and its target in the ``MustExist`` hint. 

574 """ 

575 annotation = _unwrap_optional_annotation(annotation) 

576 if annotation in (IDSchema, IDRef): 576 ↛ 577line 576 didn't jump to line 577 because the condition on line 576 was never true

577 return (annotation, None) 

578 if not inspect.isclass(annotation): 578 ↛ 579line 578 didn't jump to line 579 because the condition on line 578 was never true

579 return None 

580 try: 

581 if not issubclass(annotation, IDSchema): 

582 return None 

583 except TypeError: 

584 return None 

585 metadata = getattr(annotation, "__pydantic_generic_metadata__", {}) 

586 origin = metadata.get("origin") 

587 if origin not in (IDSchema, IDRef): 587 ↛ 588line 587 didn't jump to line 588 because the condition on line 587 was never true

588 return None 

589 args = metadata.get("args") or () 

590 target = args[0] if args and isinstance(args[0], type) else None 

591 return (origin, target) 

592 

593 

594async def _async_resolve_ids_to_sqlalchemy_objects( 

595 session: SA_AsyncSession, schema_obj: pydantic.BaseModel 

596) -> dict[str, Any]: 

597 """ 

598 Resolve any IDSchema reference fields on ``schema_obj`` to SQLAlchemy rows. 

599 A database request is made for each IDSchema to look up the related row in the database. 

600 If an id is not found in the database `sqlalchemy.orm.exc.NoResultFound` is raised. 

601 

602 Returns a ``{field_name: resolved_object_or_list}`` mapping for the fields 

603 that referenced a model; ``schema_obj`` itself is left unmodified, so it 

604 keeps its validated wire shape (``IDRef[T]`` values, not ORM rows). The 

605 write path consumes the returned mapping. 

606 

607 The lookup applies the referenced model's ``default_scope``, so a 

608 reference to a row the scope hides is ``NotFound``. A 

609 model without a ``default_scope`` resolves by bare primary key, as before; 

610 see the ``IDRef`` docstring. 

611 """ 

612 # Go over all Pydantic fields and check if any of them are an IDSchema object or 

613 # a list of IDSchema objects. 

614 resolved: dict[str, Any] = {} 

615 for field in schema_obj.model_fields_set: 

616 value = getattr(schema_obj, field, None) 

617 

618 if isinstance(value, IDSchema): 

619 sql_model = value.get_sql_model_annotation() 

620 if not sql_model: 

621 continue 

622 

623 scope = _default_scope(sql_model) 

624 if scope is None: 

625 # unscoped: keep get_one, which serves identity-map hits 

626 try: 

627 sql_model_obj = await session.get_one(sql_model, value.id) 

628 except NoResultFound as e: 

629 raise NotFound(f"Id not found for {field}: {value.id}") from e 

630 else: 

631 # mapper pk, not `.id`: the pk attribute can be named anything 

632 pk_col = sa_inspect(sql_model).primary_key[0] 

633 query = apply_clauses( 

634 select(sql_model).where(pk_col == value.id), scope 

635 ) 

636 sql_model_obj = (await session.scalars(query)).first() 

637 if sql_model_obj is None: 

638 raise NotFound(f"Id not found for {field}: {value.id}") 

639 resolved[field] = sql_model_obj 

640 

641 elif isinstance(value, list) and any(isinstance(i, IDSchema) for i in value): 

642 # Assume all IdSchemas are for the same model 

643 sql_model = value[0].get_sql_model_annotation() 

644 if not sql_model: 644 ↛ 645line 644 didn't jump to line 645 because the condition on line 644 was never true

645 continue 

646 

647 # Resolve via an id -> row map: keeps the client's order (first 

648 # appearance, deduped) and makes a missing id one absent from the map, 

649 # so a repeated id can't spuriously 404 (``IN`` dedups and reorders). 

650 ids = [obj.id for obj in value] 

651 unique_ids = list(dict.fromkeys(ids)) 

652 query = select(sql_model).where(sql_model.id.in_(unique_ids)) 

653 scope = _default_scope(sql_model) 

654 if scope is not None: 654 ↛ 655line 654 didn't jump to line 655 because the condition on line 654 was never true

655 query = apply_clauses(query, scope) 

656 by_id = {o.id: o for o in await session.scalars(query)} 

657 

658 missing = [i for i in unique_ids if i not in by_id] 

659 if missing: 

660 raise NotFound(f"Id not found for {field}: {missing}") 

661 

662 resolved[field] = [by_id[i] for i in unique_ids] 

663 

664 return resolved 

665 

666 

667def _resolve_ids_to_sqlalchemy_objects( 

668 session: SA_Session, schema_obj: pydantic.BaseModel 

669) -> dict[str, Any]: 

670 """ 

671 Resolve any IDSchema reference fields on ``schema_obj`` to SQLAlchemy rows. 

672 A database request is made for each IDSchema to look up the related row in the database. 

673 If an id is not found in the database `sqlalchemy.orm.exc.NoResultFound` is raised. 

674 

675 Returns a ``{field_name: resolved_object_or_list}`` mapping for the fields 

676 that referenced a model; ``schema_obj`` itself is left unmodified, so it 

677 keeps its validated wire shape (``IDRef[T]`` values, not ORM rows). The 

678 write path consumes the returned mapping. 

679 

680 The lookup applies the referenced model's ``default_scope``, so a 

681 reference to a row the scope hides is ``NotFound``. A 

682 model without a ``default_scope`` resolves by bare primary key, as before; 

683 see the ``IDRef`` docstring. 

684 """ 

685 # Go over all Pydantic fields and check if any of them are an IDSchema object or 

686 # a list of IDSchema objects. 

687 resolved: dict[str, Any] = {} 

688 for field in schema_obj.model_fields_set: 

689 value = getattr(schema_obj, field, None) 

690 

691 if isinstance(value, IDSchema): 

692 sql_model = value.get_sql_model_annotation() 

693 if not sql_model: 

694 continue 

695 

696 scope = _default_scope(sql_model) 

697 if scope is None: 

698 # unscoped: keep get_one, which serves identity-map hits 

699 try: 

700 sql_model_obj = session.get_one(sql_model, value.id) 

701 except NoResultFound as e: 

702 raise NotFound(f"Id not found for {field}: {value.id}") from e 

703 else: 

704 # mapper pk, not `.id`: the pk attribute can be named anything 

705 pk_col = sa_inspect(sql_model).primary_key[0] 

706 query = apply_clauses( 

707 select(sql_model).where(pk_col == value.id), scope 

708 ) 

709 sql_model_obj = session.scalars(query).first() 

710 if sql_model_obj is None: 

711 raise NotFound(f"Id not found for {field}: {value.id}") 

712 resolved[field] = sql_model_obj 

713 

714 elif isinstance(value, list) and any(isinstance(i, IDSchema) for i in value): 

715 # Assume all IdSchemas are for the same model 

716 sql_model = value[0].get_sql_model_annotation() 

717 if not sql_model: 717 ↛ 718line 717 didn't jump to line 718 because the condition on line 717 was never true

718 continue 

719 

720 # Resolve via an id -> row map: keeps the client's order (first 

721 # appearance, deduped) and makes a missing id one absent from the map, 

722 # so a repeated id can't spuriously 404 (``IN`` dedups and reorders). 

723 ids = [obj.id for obj in value] 

724 unique_ids = list(dict.fromkeys(ids)) 

725 query = select(sql_model).where(sql_model.id.in_(unique_ids)) 

726 scope = _default_scope(sql_model) 

727 if scope is not None: 

728 query = apply_clauses(query, scope) 

729 by_id = {o.id: o for o in session.scalars(query)} 

730 

731 missing = [i for i in unique_ids if i not in by_id] 

732 if missing: 

733 raise NotFound(f"Id not found for {field}: {missing}") 

734 

735 resolved[field] = [by_id[i] for i in unique_ids] 

736 

737 return resolved 

738 

739 

740def _ref_exists_marker(field_info: FieldInfo) -> RefExists | None: 

741 """Return the ``RefExists`` marker on a field, or ``None``. 

742 

743 Found at the field's top level (``MustExist[pk, M]`` / ``Annotated[pk, 

744 RefExists(M)]``), or recovered from the union member for an optional field 

745 (``MustExist[pk, M] | None``), where Pydantic keeps the marker on the inner 

746 annotation rather than ``field_info.metadata`` -- so an optional checked FK 

747 is checked like the plain form (parity with ``Optional[IDRef]``). 

748 """ 

749 for m in field_info.metadata: 

750 if isinstance(m, RefExists): 

751 return m 

752 unwrapped = _unwrap_optional_annotation(field_info.annotation) 

753 for m in getattr(unwrapped, "__metadata__", ()): 

754 if isinstance(m, RefExists): 754 ↛ 753line 754 didn't jump to line 753 because the condition on line 754 was always true

755 return m 

756 return None 

757 

758 

759def _infer_ref_model(model_cls: type[Any], field_name: str) -> type[Any]: 

760 """Resolve the target model for a ``MustExist[pk]`` field from its FK column. 

761 

762 ``MustExist[pk]`` (one argument) leaves the model to be inferred: the column 

763 named by the field must carry exactly one ``ForeignKey``, whose table maps to 

764 the target model. Raises ``RestlyConfigurationError`` when the field does not 

765 map to a single-FK column -- name the model explicitly (``MustExist[pk, 

766 Model]``) there. 

767 """ 

768 model_mapper = sa_inspect(model_cls) 

769 column = model_mapper.columns.get(field_name) 

770 foreign_keys = list(column.foreign_keys) if column is not None else [] 

771 if len(foreign_keys) == 1: 

772 target_table = foreign_keys[0].column.table 

773 for mapper in model_mapper.registry.mappers: 773 ↛ 776line 773 didn't jump to line 776 because the loop on line 773 didn't complete

774 if mapper.local_table is target_table: 

775 return mapper.class_ 

776 raise RestlyConfigurationError( 

777 f"{model_cls.__name__}.{field_name}: MustExist[...] with one argument " 

778 f"infers the target model from the column's ForeignKey, but its FK " 

779 f"target table '{target_table.name}' has no mapped model. " 

780 f"Name it explicitly: MustExist[<pk>, <Model>]." 

781 ) 

782 raise RestlyConfigurationError( 

783 f"{model_cls.__name__}.{field_name}: MustExist[...] with one argument " 

784 f"infers the target model from the column's ForeignKey, but " 

785 f"'{field_name}' does not map to a column with exactly one foreign key. " 

786 f"Name it explicitly: MustExist[<pk>, <Model>]." 

787 ) 

788 

789 

790def _effective_ref_scope(marker: RefExists, model: type[Any]) -> WhereClause | None: 

791 """The scope a ``RefExists`` check applies: the marker's own ``scope`` 

792 when given (``UNSCOPED`` meaning explicitly none), the target model's 

793 ``default_scope`` otherwise.""" 

794 if isinstance(marker.scope, _NotGiven): 

795 return _default_scope(model) 

796 if isinstance(marker.scope, Unscoped): 

797 return None 

798 return marker.scope 

799 

800 

801def _ref_exists_fields( 

802 model_cls: type[Any], schema_obj: pydantic.BaseModel 

803) -> dict[tuple[type[Any], WhereClause | None], list[tuple[str, Any]]]: 

804 """Group provided, non-``None`` ``RefExists``-marked scalar fields by 

805 target model and effective scope. 

806 

807 Returns ``{(model, scope): [(field_name, id), ...]}`` for fields whose 

808 annotation carries a ``RefExists`` marker (``MustExist[pk]`` / 

809 ``MustExist[pk, Model]``, or an explicit ``Annotated[pk, RefExists(Model)]``). 

810 A marker left to infer (``MustExist[pk]``) is resolved to the model behind 

811 the column's ``ForeignKey`` on ``model_cls``; the scope is resolved per 

812 field (see :func:`_effective_ref_scope`), so two fields on one model can 

813 check against different scopes. Only fields actually supplied 

814 (``model_fields_set``) with a non-``None`` value are included. 

815 """ 

816 by_target: dict[tuple[type[Any], WhereClause | None], list[tuple[str, Any]]] = {} 

817 model_fields = type(schema_obj).model_fields 

818 for field_name in schema_obj.model_fields_set: 

819 field_info = model_fields.get(field_name) 

820 if field_info is None: 820 ↛ 821line 820 didn't jump to line 821 because the condition on line 820 was never true

821 continue 

822 marker = _ref_exists_marker(field_info) 

823 if marker is None: 

824 continue 

825 value = getattr(schema_obj, field_name, None) 

826 if value is None: 

827 continue 

828 model = marker.model 

829 if model is _Infer: 

830 model = _infer_ref_model(model_cls, field_name) 

831 scope = _effective_ref_scope(marker, model) 

832 by_target.setdefault((model, scope), []).append((field_name, value)) 

833 return by_target 

834 

835 

836def _raise_for_missing_refs(items: list[tuple[str, Any]], found: set[Any]) -> None: 

837 for field_name, value in items: 

838 if value not in found: 

839 raise NotFound(f"Id not found for {field_name}: {value}") 

840 

841 

842def _ref_exists_query( 

843 model: type[Any], scope: WhereClause | None, items: list[tuple[str, Any]] 

844) -> Any: 

845 unique = list(dict.fromkeys(value for _, value in items)) 

846 mapper = sa_inspect(model) 

847 # the mapped attribute, not the Core column: an ORM statement, so a 

848 # session-level rule (with_loader_criteria) reaches this check too 

849 pk = getattr(model, mapper.get_property_by_column(mapper.primary_key[0]).key) 

850 query = select(pk).where(pk.in_(unique)) 

851 if scope is not None: 

852 query = apply_clauses(query, scope) 

853 return query 

854 

855 

856def _check_ref_exists( 

857 session: SA_Session, model_cls: type[Any], schema_obj: pydantic.BaseModel 

858) -> None: 

859 """Batch-validate that every ``RefExists``-marked id exists (sync). 

860 

861 One ``SELECT pk WHERE pk IN (...)`` per (referenced model, scope) pair 

862 (no N+1); raises ``NotFound`` (404) naming the field and id on a miss. 

863 The scope is the marker's own when given, the target model's 

864 ``default_scope`` otherwise, so a row the scope hides is a miss. 

865 ``model_cls`` is the model being written, used to infer the target of a 

866 ``MustExist[pk]`` field. 

867 """ 

868 for (model, scope), items in _ref_exists_fields(model_cls, schema_obj).items(): 

869 found = set(session.scalars(_ref_exists_query(model, scope, items))) 

870 _raise_for_missing_refs(items, found) 

871 

872 

873async def _async_check_ref_exists( 

874 session: SA_AsyncSession, model_cls: type[Any], schema_obj: pydantic.BaseModel 

875) -> None: 

876 """Async twin of :func:`_check_ref_exists`.""" 

877 for (model, scope), items in _ref_exists_fields(model_cls, schema_obj).items(): 

878 found = set(await session.scalars(_ref_exists_query(model, scope, items))) 

879 _raise_for_missing_refs(items, found) 

880 

881 

882def get_read_only_fields(model_cls: type[pydantic.BaseModel]) -> list[str]: 

883 """Get all fields from a model annotated as ReadOnly[]""" 

884 read_only_fields: list[str] = [] 

885 # Get read-only fields from Annotated metadata 

886 for field_name, field_info in model_cls.model_fields.items(): 

887 metadata = getattr(field_info, "metadata", None) 

888 if metadata and readonly_marker in metadata: 

889 read_only_fields.append(field_name) 

890 return read_only_fields 

891 

892 

893def is_readonly_field( 

894 model: pydantic.BaseModel | type[pydantic.BaseModel], field_name: str 

895) -> bool: 

896 """Check if a specific field is marked as readonly.""" 

897 if isinstance(model, pydantic.BaseModel): 

898 model = model.__class__ 

899 field_info = model.model_fields.get(field_name) 

900 return _is_readonly(field_info) 

901 

902 

903def _is_readonly(field_info: FieldInfo | None) -> bool: 

904 if field_info is None: 

905 return False 

906 metadata = getattr(field_info, "metadata", None) 

907 if not metadata: 

908 return False 

909 return readonly_marker in metadata 

910 

911 

912def _is_writeonly(field_info: FieldInfo | None) -> bool: 

913 if field_info is None: 

914 return False 

915 metadata = getattr(field_info, "metadata", None) 

916 if not metadata: 

917 return False 

918 return writeonly_marker in metadata 

919 

920 

921def get_write_only_fields(model_cls: type[pydantic.BaseModel]) -> list[str]: 

922 """Get all fields from a model annotated as WriteOnly[]""" 

923 write_only_fields: list[str] = [] 

924 # Get write-only fields from Annotated metadata 

925 for field_name, field_info in model_cls.model_fields.items(): 

926 if _is_writeonly(field_info): 

927 write_only_fields.append(field_name) 

928 return write_only_fields 

929 

930 

931def is_writeonly_field( 

932 model_cls: pydantic.BaseModel | type[pydantic.BaseModel], field_name: str 

933) -> bool: 

934 """Check if a specific field is marked as writeonly.""" 

935 if isinstance(model_cls, pydantic.BaseModel): 

936 model_cls = model_cls.__class__ 

937 field_info = model_cls.model_fields.get(field_name) 

938 return _is_writeonly(field_info) 

939 

940 

941def create_model_without_read_only_fields( 

942 model_cls: type[pydantic.BaseModel], *, omit: Iterable[str] = () 

943) -> type[pydantic.BaseModel]: 

944 """ 

945 Create a subclass of the given pydantic model class with a new name. 

946 Read-only fields are removed, and so are the fields named in ``omit``. 

947 """ 

948 new_model_name = _schema_role_name(model_cls, "Create") 

949 new_doc = (model_cls.__doc__ or "") + "\nRead-only fields have been removed." 

950 

951 # Create a subclass that mixes in OmitReadOnlyMixin 

952 new_model_cls = type( 

953 new_model_name, 

954 (OmitReadOnlyMixin, model_cls), 

955 { 

956 "__module__": model_cls.__module__, 

957 "__doc__": new_doc, 

958 "__restly_omit_fields__": frozenset(omit), 

959 }, 

960 ) 

961 

962 return new_model_cls 

963 

964 

965class OmitReadOnlyMixin(pydantic.BaseModel): 

966 """ 

967 Mixin for pydantic models that removes all fields marked as ReadOnly. 

968 

969 Implementation note: this mutates ``cls.model_fields`` in place and then 

970 calls ``model_rebuild(force=True)`` to regenerate the validator/serializer. 

971 Pydantic v2 does not officially document mutation of ``model_fields`` as 

972 a supported customisation hook, but this approach has been stable since 

973 pydantic 2.0 and works on the pinned minimum (``pydantic>=2.11.7``). If 

974 a future pydantic release freezes the dict, switch to constructing a new 

975 model via ``pydantic.create_model(...)`` over the kept fields. The 

976 regression test ``tests/test_pydantic_model_fields_mutation.py`` exercises 

977 this contract on the currently-installed pydantic. 

978 """ 

979 

980 @classmethod 

981 def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: 

982 super().__pydantic_init_subclass__(**kwargs) 

983 

984 # Collect readonly fields to delete first 

985 omit = getattr(cls, "__restly_omit_fields__", ()) 

986 readonly_fields = [] 

987 for name, field_info in cls.model_fields.items(): 

988 if _is_readonly(field_info) or name in omit: 

989 readonly_fields.append(name) 

990 

991 # Delete readonly fields after iteration is complete 

992 for name in readonly_fields: 

993 del cls.model_fields[name] 

994 

995 cls.model_rebuild(force=True) 

996 

997 

998class _OmitWriteOnlyMixin(pydantic.BaseModel): 

999 """Mixin for the response class: removes the WriteOnly fields.""" 

1000 

1001 #: The names and aliases of the removed WriteOnly fields. 

1002 __restly_writeonly_keys__: ClassVar[frozenset[str]] = frozenset() 

1003 

1004 @classmethod 

1005 def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: 

1006 super().__pydantic_init_subclass__(**kwargs) 

1007 

1008 writeonly_fields = { 

1009 name: field_info 

1010 for name, field_info in cls.model_fields.items() 

1011 if _is_writeonly(field_info) 

1012 } 

1013 if not writeonly_fields: 

1014 return 

1015 cls.__restly_writeonly_keys__ = frozenset( 

1016 key 

1017 for name, field_info in writeonly_fields.items() 

1018 for key in _input_keys(name, field_info) 

1019 ) 

1020 for name in writeonly_fields: 

1021 del cls.model_fields[name] 

1022 _skip_field_checks(cls, set(writeonly_fields)) 

1023 

1024 # A forward reference that is not defined yet does not fail here: 

1025 # pydantic builds the class again when it is first used. 

1026 cls.model_rebuild(force=True, raise_errors=False) 

1027 

1028 # "before", not "wrap": pydantic does not pass ``by_name`` through a wrap 

1029 # validator to the nested models. 

1030 @pydantic.model_validator(mode="before") 

1031 @classmethod 

1032 def _drop_writeonly_keys(cls, value: Any) -> Any: 

1033 """Drop WriteOnly keys from a dict. With ``extra="allow"`` they would 

1034 otherwise go out as extra fields.""" 

1035 keys = cls.__restly_writeonly_keys__ 

1036 if keys and isinstance(value, Mapping): 

1037 return {key: item for key, item in value.items() if key not in keys} 

1038 return value 

1039 

1040 

1041def _input_keys(name: str, field_info: FieldInfo) -> set[str]: 

1042 """The keys under which a field can be in a dict: its name and aliases.""" 

1043 keys = {name} 

1044 if field_info.alias is not None: 

1045 keys.add(field_info.alias) 

1046 validation_alias = field_info.validation_alias 

1047 if isinstance(validation_alias, str): 

1048 keys.add(validation_alias) 

1049 elif isinstance(validation_alias, pydantic.AliasChoices): 1049 ↛ 1050line 1049 didn't jump to line 1050 because the condition on line 1049 was never true

1050 keys.update( 

1051 choice for choice in validation_alias.choices if isinstance(choice, str) 

1052 ) 

1053 return keys 

1054 

1055 

1056def _skip_field_checks(cls: type[pydantic.BaseModel], removed: set[str]) -> None: 

1057 """Let validators and serializers of the removed fields stay on ``cls``. 

1058 

1059 Pydantic checks that each field validator and serializer names a field of 

1060 the class. A ``@field_validator("password")`` on the view's schema would 

1061 then fail the response class, which has no ``password``. Without the 

1062 check, pydantic does not use it there. 

1063 """ 

1064 decorators = cls.__pydantic_decorators__ 

1065 for group in ( 

1066 decorators.validators, 

1067 decorators.field_validators, 

1068 decorators.field_serializers, 

1069 ): 

1070 for name, decorator in list(group.items()): 

1071 if removed.intersection(decorator.info.fields): 

1072 info = dataclasses.replace(decorator.info, check_fields=False) 

1073 group[name] = dataclasses.replace(decorator, info=info) # type: ignore[assignment] 

1074 

1075 

1076def _as_response(cls: type[pydantic.BaseModel], value: Any) -> Any: 

1077 """An instance of another model as an instance of the response class 

1078 ``cls``. Fields that ``cls`` does not have stay behind, also on a route 

1079 without a response model, such as the list of a react-admin view. 

1080 

1081 Restly copies the values of an instance of a subclass of ``cls``, and of 

1082 an instance of the view's schema for a response class that 

1083 :func:`derive_schema_response` built. It does not validate them again: 

1084 they are already valid, and a validator that changes a value would 

1085 change it twice. 

1086 

1087 Any other model is validated into ``cls`` from its attributes. A response 

1088 class that a view sets itself can have other fields and types, so Restly 

1089 cannot copy into it. 

1090 

1091 A value that is not a model comes back as it is. So does an ORM object, 

1092 also a SQLModel table row, which is a model too: the view serializes it 

1093 as an ORM object. 

1094 """ 

1095 if type(value) is cls or not isinstance(value, pydantic.BaseModel): 

1096 return value 

1097 if is_mapped_instance(value): 1097 ↛ 1098line 1097 didn't jump to line 1098 because the condition on line 1097 was never true

1098 return value 

1099 schema = cls.__dict__.get("__restly_response_of__") 

1100 if not isinstance(value, cls) and (schema is None or not isinstance(value, schema)): 

1101 return _response_adapter(cls).validate_python( 

1102 value, from_attributes=True, by_alias=False, by_name=True 

1103 ) 

1104 fields = cls.model_fields 

1105 values = {name: item for name, item in value.__dict__.items() if name in fields} 

1106 writeonly_keys = getattr(cls, "__restly_writeonly_keys__", frozenset()) 

1107 extra = { 

1108 key: item 

1109 for key, item in (value.__pydantic_extra__ or {}).items() 

1110 if key not in writeonly_keys 

1111 } 

1112 copy = cls.model_construct( 

1113 _fields_set=value.model_fields_set & fields.keys(), **extra, **values 

1114 ) 

1115 if value.__pydantic_private__ is not None: 1115 ↛ 1116line 1115 didn't jump to line 1116 because the condition on line 1115 was never true

1116 object.__setattr__( 

1117 copy, "__pydantic_private__", dict(value.__pydantic_private__) 

1118 ) 

1119 return copy 

1120 

1121 

1122@functools.cache 

1123def _response_adapter( 

1124 schema_response: type[pydantic.BaseModel], 

1125) -> pydantic.TypeAdapter[pydantic.BaseModel]: 

1126 """Validates into a response class. Through an adapter, not 

1127 ``model_validate``: a schema may narrow that classmethod's signature 

1128 (SQLModel drops ``by_alias`` and ``by_name``).""" 

1129 return pydantic.TypeAdapter(schema_response) 

1130 

1131 

1132_SchemaT = TypeVar("_SchemaT", bound=pydantic.BaseModel) 

1133 

1134 

1135def derive_schema_response(schema: type[_SchemaT]) -> type[_SchemaT]: 

1136 """Build the response class of a view's schema: the schema without its 

1137 ``WriteOnly`` fields, named ``<Resource>Response``. 

1138 

1139 A view without its own 

1140 :attr:`~fastapi_restly.views.BaseRestView.schema_response` gets this 

1141 class. Restly builds it also when the schema has no ``WriteOnly`` fields, 

1142 so OpenAPI shows ``UserResponse`` and not the view's schema. 

1143 

1144 The class is a subclass of ``schema``. Each call with the same schema 

1145 returns the same class, so a custom route can name the class that the 

1146 view uses:: 

1147 

1148 UserResponse = fr.schemas.derive_schema_response(UserSchema) 

1149 

1150 

1151 class UserView(fr.AsyncRestView): 

1152 schema = UserSchema 

1153 

1154 @fr.get("/me", response_model=UserResponse) 

1155 async def me(self): ... 

1156 

1157 :param schema: The view's schema. 

1158 :returns: The response class. 

1159 """ 

1160 return cast(type[_SchemaT], _derive_schema_response(schema)) 

1161 

1162 

1163@functools.cache 

1164def _derive_schema_response( 

1165 schema: type[pydantic.BaseModel], 

1166) -> type[pydantic.BaseModel]: 

1167 """Cached, so a view, a custom route and every request use the same 

1168 class. :func:`_as_response` turns an instance of the schema into an 

1169 instance of this class.""" 

1170 cls = type( 

1171 _schema_role_name(schema, "Response"), 

1172 (_OmitWriteOnlyMixin, schema), 

1173 { 

1174 "__module__": schema.__module__, 

1175 "__doc__": schema.__doc__, 

1176 "__restly_response_of__": schema, 

1177 }, 

1178 ) 

1179 # A recursive schema refers to itself by name, like list["NodeSchema"]. 

1180 # Pydantic finds a class's own name, but not the name of a parent class 

1181 # that was defined in a function. So the response class gets that name. 

1182 namespace = {**(schema.__pydantic_parent_namespace__ or {})} 

1183 namespace[schema.__name__] = schema 

1184 cls.model_rebuild(force=True, raise_errors=False, _types_namespace=namespace) 

1185 return cls 

1186 

1187 

1188def derive_schema_list_response( 

1189 schema_response: type[pydantic.BaseModel], 

1190 *, 

1191 pagination: NumberedPagination | NoPagination | None = _DEFAULT_PAGINATION, 

1192) -> type[pydantic.BaseModel]: 

1193 """Build the list response class of a view: the envelope of its 

1194 pagination, filled with its response class, named 

1195 ``<Resource>ListResponse``. 

1196 

1197 ``GET /`` of a view returns this class, except on a react-admin view, 

1198 which returns a plain list. A custom list route names it, so OpenAPI 

1199 shows one type for both routes:: 

1200 

1201 UserResponse = fr.schemas.derive_schema_response(UserSchema) 

1202 UserListResponse = fr.schemas.derive_schema_list_response( 

1203 UserResponse, pagination=AppView.pagination 

1204 ) 

1205 

1206 

1207 class UserView(AppView): 

1208 schema = UserSchema 

1209 

1210 @fr.get("/inactive", response_model=UserListResponse) 

1211 async def inactive(self, list_params): ... 

1212 

1213 Each call with the same response class and envelope returns the same 

1214 class. Only the envelope of the pagination matters: two paginations with 

1215 the same envelope give the same class. A view has no attribute for this 

1216 class. To change it, change the view's ``schema_response`` or the 

1217 envelope of its ``pagination``. 

1218 

1219 :param schema_response: The view's response class, such as the one from 

1220 :func:`derive_schema_response`. 

1221 :param pagination: The view's pagination. ``None`` is a view without 

1222 pagination, as on the view. The default is the default pagination. 

1223 :returns: The list response class. 

1224 """ 

1225 envelope = Envelope if pagination is None else pagination.envelope 

1226 return _named_list_envelope(envelope, schema_response) 

1227 

1228 

1229@functools.cache 

1230def _named_list_envelope( 

1231 envelope: type[pydantic.BaseModel], schema_response: type[pydantic.BaseModel] 

1232) -> type[pydantic.BaseModel]: 

1233 """Cached, so the route and every request use the same class, and two 

1234 views with the same response class and envelope share it. 

1235 

1236 It is a subclass, so OpenAPI shows ``UserListResponse`` and not 

1237 ``PaginatedEnvelope_UserResponse_``. 

1238 """ 

1239 filled: Any = envelope[schema_response] # type: ignore[index] 

1240 return type( 

1241 _schema_role_name(schema_response, "ListResponse"), 

1242 (filled,), 

1243 { 

1244 "__module__": schema_response.__module__, 

1245 # For the warning about two classes with one name. 

1246 "__restly_list_response_of__": (envelope, schema_response), 

1247 }, 

1248 ) 

1249 

1250 

1251def rebase_with_model_config( 

1252 base: tuple[type, ...], model_cls: type[pydantic.BaseModel] 

1253) -> type[pydantic.BaseModel]: 

1254 def class_body(ns: dict[str, Any]) -> None: 

1255 ns["model_config"] = model_cls.model_config.copy() 

1256 

1257 return types.new_class( 

1258 f"{model_cls.__name__}ModelConfig", base, exec_body=class_body 

1259 ) 

1260 

1261 

1262def create_model_with_optional_fields( 

1263 model_cls: type[pydantic.BaseModel], *, omit: Iterable[str] = () 

1264) -> type[pydantic.BaseModel]: 

1265 """ 

1266 Create a subclass of the given pydantic model class with a new name. 

1267 Read-only fields and the fields named in ``omit`` are removed, and all 

1268 writable fields are made optional with None as default. 

1269 """ 

1270 new_model_name = _schema_role_name(model_cls, "Update") 

1271 new_doc = ( 

1272 model_cls.__doc__ or "" 

1273 ) + "\nRead-only fields have been removed and all fields are optional." 

1274 

1275 # Create a subclass that mixes in both OmitReadOnlyMixin and PatchMixin 

1276 new_model_cls = type( 

1277 new_model_name, 

1278 (PatchMixin, OmitReadOnlyMixin, model_cls), 

1279 { 

1280 "__module__": model_cls.__module__, 

1281 "__doc__": new_doc, 

1282 "__restly_omit_fields__": frozenset(omit), 

1283 }, 

1284 ) 

1285 

1286 return new_model_cls 

1287 

1288 

1289class PatchMixin(pydantic.BaseModel): 

1290 """ 

1291 A mixin for pydantic classes that makes all fields optional and replaces defaults 

1292 with None. 

1293 

1294 Implementation note: like :class:`OmitReadOnlyMixin` this mutates 

1295 ``cls.model_fields`` (specifically ``FieldInfo.default`` and 

1296 ``FieldInfo.annotation``) and then calls ``model_rebuild(force=True)``. 

1297 This relies on pydantic v2 keeping ``FieldInfo`` mutable; verified on the 

1298 pinned ``pydantic>=2.11.7`` minimum and exercised by 

1299 ``tests/test_pydantic_model_fields_mutation.py``. 

1300 """ 

1301 

1302 @classmethod 

1303 def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: 

1304 super().__pydantic_init_subclass__(**kwargs) 

1305 

1306 for field in cls.model_fields.values(): 

1307 field.default = None 

1308 # A field cannot have both. FastAPI builds the field again from 

1309 # its attributes for OpenAPI, which raises with both. 

1310 field.default_factory = None 

1311 # Only wrap if not already Optional, to avoid Optional[Optional[T]] 

1312 annotation = field.annotation 

1313 if isinstance(annotation, types.UnionType): 

1314 # Python 3.10+ `X | Y` syntax - check if None is already a member. 

1315 # Convert to typing.Optional form so FieldInfo.annotation stays compatible. 

1316 union_args = get_args(annotation) 

1317 if type(None) not in union_args: 

1318 non_none = [a for a in union_args if a is not type(None)] 

1319 inner = ( 

1320 non_none[0] if len(non_none) == 1 else Union[tuple(non_none)] 

1321 ) 

1322 field.annotation = Optional[inner] # type: ignore[assignment] 

1323 else: 

1324 origin = getattr(annotation, "__origin__", None) 

1325 if origin is not Union or type(None) not in get_args(annotation): 

1326 field.annotation = Optional[annotation] # type: ignore[assignment] 

1327 

1328 cls.model_rebuild(force=True) 

1329 

1330 

1331def getattrs(obj: Any, *attrs: str, default: Any = None) -> Any: 

1332 """ 

1333 Try access a chain of attributes and return the default if any of the attrs is not defined. 

1334 """ 

1335 for attr in attrs: 

1336 if not hasattr(obj, attr): 

1337 return default 

1338 obj = getattr(obj, attr) 

1339 return obj 

1340 

1341 

1342def set_schema_title(schema_cls: type[pydantic.BaseModel]) -> None: 

1343 """Set the title of a schema class to its name. 

1344 This is used to make the schema title match the model name in the OpenAPI schema. 

1345 """ 

1346 schema_cls.model_config["title"] = schema_cls.__name__ 

1347 

1348 

1349def get_writable_inputs(schema_obj: pydantic.BaseModel) -> dict[str, Any]: 

1350 """ 

1351 Return a dictionary of field_name: value pairs for writable input fields. 

1352 

1353 Filters out: 

1354 - ReadOnly fields 

1355 - fields not provided with input (using Pydantic model_fields_set) 

1356 

1357 :param schema_obj: The schema object to extract writable fields from. Its 

1358 own schema decides which fields are ``ReadOnly``. 

1359 :returns: Dictionary mapping field names to their values, for writable 

1360 input fields only. 

1361 """ 

1362 schema_cls = type(schema_obj) 

1363 updated_fields: dict[str, Any] = {} 

1364 for field_name, value in schema_obj: 

1365 if field_name not in schema_obj.model_fields_set: 

1366 continue 

1367 # Skip readonly fields 

1368 if is_readonly_field(schema_cls, field_name): 

1369 continue 

1370 updated_fields[field_name] = value 

1371 

1372 return updated_fields