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,594 @@
1
+ import datetime as _dt
2
+ import decimal as _decimal
3
+ import functools
4
+ import uuid as _uuid
5
+ from collections import defaultdict
6
+ from typing import Annotated, Any, Callable, Iterator, Optional, cast
7
+
8
+ import pydantic
9
+ import sqlalchemy
10
+ from fastapi import HTTPException
11
+ from pydantic import Field
12
+ from pydantic.fields import FieldInfo
13
+ from sqlalchemy import ColumnElement, Select
14
+ from sqlalchemy.orm import DeclarativeBase
15
+ from sqlalchemy.orm.attributes import InstrumentedAttribute
16
+ from sqlalchemy.orm.properties import ColumnProperty
17
+ from starlette.datastructures import QueryParams
18
+
19
+ from ._shared import _escape_like_value, _unwrap_optional_annotation
20
+
21
+ SchemaType = type[pydantic.BaseModel]
22
+
23
+ #: Default ``page_size`` applied to list endpoints when the client does not
24
+ #: send one. ``None`` disables the implicit cap (lists return every matching
25
+ #: row and ``page`` is ignored). Override per-view via
26
+ #: :attr:`BaseRestView.default_page_size`.
27
+ DEFAULT_PAGE_SIZE: int | None = None
28
+
29
+ #: Maximum ``page_size`` accepted by list endpoints. Values above this are
30
+ #: rejected with a 422 by the FastAPI Pydantic-Query validation layer.
31
+ #: Override per-view via :attr:`BaseRestView.max_page_size`.
32
+ MAX_PAGE_SIZE = 1000
33
+
34
+ #: Reserved query-parameter names produced by the schema. Filter columns
35
+ #: literally named one of these would shadow pagination/sort, which would
36
+ #: silently break the endpoint contract. Treated as a hard error.
37
+ _RESERVED_NAMES = frozenset({"page", "page_size", "sort"})
38
+
39
+ # Types that support SQL ``<``/``<=``/``>``/``>=`` comparisons. Booleans
40
+ # deliberately don't — ordering booleans is rarely meaningful and emitting
41
+ # ``WHERE active >= true`` raises ``sqlalchemy.exc.ArgumentError`` at query
42
+ # time, which would otherwise surface to the client as a 500.
43
+ _ORDERABLE_TYPES: tuple[type, ...] = (
44
+ int,
45
+ float,
46
+ _decimal.Decimal,
47
+ _dt.date,
48
+ _dt.datetime,
49
+ _dt.time,
50
+ _dt.timedelta,
51
+ str,
52
+ )
53
+
54
+
55
+ def _is_string_field(field: FieldInfo) -> bool:
56
+ annotation = _unwrap_optional_annotation(field.annotation)
57
+ return annotation is str
58
+
59
+
60
+ def _supports_range_operators(field: FieldInfo) -> bool:
61
+ annotation = _unwrap_optional_annotation(field.annotation)
62
+ if annotation is bool:
63
+ return False
64
+ if not isinstance(annotation, type):
65
+ return True
66
+ if issubclass(annotation, bool):
67
+ return False
68
+ if issubclass(annotation, _ORDERABLE_TYPES):
69
+ return True
70
+ if issubclass(annotation, _uuid.UUID):
71
+ return False
72
+ return False
73
+
74
+
75
+ def create_list_params_schema(
76
+ schema_cls: SchemaType,
77
+ *,
78
+ default_page_size: int | None = DEFAULT_PAGE_SIZE,
79
+ max_page_size: int = MAX_PAGE_SIZE,
80
+ ) -> SchemaType:
81
+ """
82
+ Create a Pydantic model that describes and validates URL query parameters
83
+ for list endpoints.
84
+
85
+ The generated model accepts pagination (``page``, ``page_size``), sorting
86
+ (``sort``), and one filter parameter per response-schema field with
87
+ optional ``__in``/``__ne``/``__gte``/``__lte``/``__gt``/``__lt``/``__isnull``/
88
+ ``__contains``/``__icontains`` suffixes.
89
+
90
+ ``page`` and ``page_size`` are validated by Pydantic with bounds
91
+ (``page >= 1``, ``1 <= page_size <= max_page_size``); out-of-range values
92
+ produce a standard 422 response from FastAPI.
93
+
94
+ Args:
95
+ schema_cls: The response schema whose fields drive the available
96
+ filter parameters.
97
+ default_page_size: Default value for the ``page_size`` parameter.
98
+ ``None`` (the default) means "no implicit page size" — omitting
99
+ ``page_size`` returns every matching row and ``page`` is ignored.
100
+ max_page_size: Upper bound (inclusive) for the ``page_size``
101
+ parameter. Defaults to :data:`MAX_PAGE_SIZE`.
102
+ """
103
+ fields: dict[str, Any] = {
104
+ "page": (
105
+ Annotated[
106
+ int,
107
+ Field(
108
+ ge=1,
109
+ description=(
110
+ "1-based page number. Only takes effect when "
111
+ "``page_size`` is also set."
112
+ ),
113
+ ),
114
+ ],
115
+ 1,
116
+ ),
117
+ "page_size": (
118
+ Annotated[
119
+ Optional[int],
120
+ Field(
121
+ ge=1,
122
+ le=max_page_size,
123
+ description=(
124
+ f"Number of items per page (1–{max_page_size}). "
125
+ "Omit to return every matching row (no implicit cap)."
126
+ ),
127
+ ),
128
+ ],
129
+ default_page_size,
130
+ ),
131
+ "sort": (
132
+ Annotated[
133
+ Optional[str],
134
+ Field(
135
+ description=(
136
+ "Comma-separated list of fields to sort by. Prefix a "
137
+ "field with ``-`` for descending order. Example: "
138
+ "``-created_at,name``."
139
+ )
140
+ ),
141
+ ],
142
+ None,
143
+ ),
144
+ }
145
+ for name, field in _iter_fields_including_nested(schema_cls):
146
+ if name in _RESERVED_NAMES:
147
+ raise ValueError(
148
+ f"List-params schema for {schema_cls.__name__!r} cannot expose "
149
+ f"field {name!r}: it collides with a reserved pagination/sort "
150
+ "parameter. Add a Pydantic alias to expose it as a filter."
151
+ )
152
+
153
+ # Type filter parameters as ``Optional[list[str]]`` (rather than the
154
+ # column's true type) so FastAPI/Starlette preserve repeated query
155
+ # parameters as a list and downstream ``_parse_value`` can perform
156
+ # the actual type coercion. ``__isnull`` stays a scalar bool because
157
+ # repeating it makes no sense.
158
+ eq_desc = (
159
+ f"Filter by ``{name}``. Comma-separated values are OR-combined "
160
+ "(SQL ``IN``). Repeat the parameter to AND multiple predicates."
161
+ )
162
+ ne_desc = (
163
+ f"Exclude rows where ``{name}`` matches. Comma-separated values "
164
+ "are AND-combined (SQL ``NOT IN``)."
165
+ )
166
+ in_desc = (
167
+ f"Filter by ``{name}`` with explicit SQL ``IN`` semantics. "
168
+ "Provide comma-separated values."
169
+ )
170
+ fields[name] = (
171
+ Annotated[Optional[list[str]], Field(description=eq_desc)],
172
+ None,
173
+ )
174
+ fields[f"{name}__in"] = (
175
+ Annotated[Optional[list[str]], Field(description=in_desc)],
176
+ None,
177
+ )
178
+ fields[f"{name}__ne"] = (
179
+ Annotated[Optional[list[str]], Field(description=ne_desc)],
180
+ None,
181
+ )
182
+ fields[f"{name}__isnull"] = (
183
+ Annotated[
184
+ Optional[bool],
185
+ Field(
186
+ description=(
187
+ f"``true`` matches rows where ``{name}`` IS NULL; "
188
+ f"``false`` matches IS NOT NULL."
189
+ )
190
+ ),
191
+ ],
192
+ None,
193
+ )
194
+
195
+ if _supports_range_operators(field):
196
+ for suffix, sql in (
197
+ ("__gte", ">="),
198
+ ("__lte", "<="),
199
+ ("__gt", ">"),
200
+ ("__lt", "<"),
201
+ ):
202
+ fields[f"{name}{suffix}"] = (
203
+ Annotated[
204
+ Optional[list[str]],
205
+ Field(description=f"``{name} {sql} value``."),
206
+ ],
207
+ None,
208
+ )
209
+
210
+ if _is_string_field(field):
211
+ fields[f"{name}__contains"] = (
212
+ Annotated[
213
+ Optional[list[str]],
214
+ Field(
215
+ description=(
216
+ f"Case-sensitive substring search on "
217
+ f"``{name}``. Repeat the parameter to AND "
218
+ "multiple terms; whitespace inside one value is "
219
+ "also AND-split as a convenience."
220
+ )
221
+ ),
222
+ ],
223
+ None,
224
+ )
225
+ fields[f"{name}__icontains"] = (
226
+ Annotated[
227
+ Optional[list[str]],
228
+ Field(
229
+ description=(
230
+ f"Case-insensitive substring search on "
231
+ f"``{name}``. Repeat the parameter to AND "
232
+ "multiple terms; whitespace inside one value is "
233
+ "also AND-split as a convenience."
234
+ )
235
+ ),
236
+ ],
237
+ None,
238
+ )
239
+
240
+ schema_name = "ListParams" + schema_cls.__name__
241
+ return pydantic.create_model(schema_name, **fields) # type: ignore[call-overload]
242
+
243
+
244
+ def apply_list_params(
245
+ params: pydantic.BaseModel | QueryParams,
246
+ select_query: Select[Any],
247
+ model: type[DeclarativeBase],
248
+ schema_cls: SchemaType,
249
+ ) -> Select[Any]:
250
+ """
251
+ Apply pagination, sorting, and filtering on a SQL query using validated
252
+ list-endpoint query parameters.
253
+
254
+ ``params`` is normally an instance of the schema returned by
255
+ :func:`create_list_params_schema`. The generated FastAPI endpoints
256
+ always pass a validated instance, so pagination/filter bounds have
257
+ already been checked.
258
+
259
+ A raw :class:`~starlette.datastructures.QueryParams` is also accepted
260
+ for callers that build the query parameters programmatically.
261
+ **Raw inputs bypass schema validation** — the caller is responsible
262
+ for verifying ``page``/``page_size`` ranges and any per-view bounds
263
+ (``max_page_size``); this function only performs the minimum coercion
264
+ needed to apply the SQL clauses.
265
+
266
+ Examples::
267
+
268
+ # Pagination
269
+ page=2&page_size=50
270
+
271
+ # Sorting
272
+ sort=name,-created_at
273
+
274
+ # Filtering
275
+ name=Bob&status=active&created_at__gte=2024-01-01
276
+
277
+ # Contains (string fields)
278
+ name__contains=John&email__icontains=example
279
+ """
280
+ query_params = _coerce_to_query_params(params)
281
+ select_query = _apply_filtering(query_params, select_query, model, schema_cls)
282
+ select_query = _apply_sorting(query_params, select_query, model, schema_cls)
283
+ select_query = _apply_pagination(query_params, select_query)
284
+ return select_query
285
+
286
+
287
+ def _coerce_to_query_params(params: pydantic.BaseModel | QueryParams) -> QueryParams:
288
+ """Normalise a validated Pydantic model or raw QueryParams to QueryParams.
289
+
290
+ When a dumped field is a list (e.g. a repeated ``name__contains``), each
291
+ element is expanded to its own ``(key, value)`` tuple so that
292
+ ``QueryParams.multi_items()`` later returns the original repeated values.
293
+ """
294
+ if isinstance(params, QueryParams):
295
+ return params
296
+ if isinstance(params, pydantic.BaseModel):
297
+ dumped = params.model_dump(exclude_none=True, by_alias=True, mode="json")
298
+ items: list[tuple[str, str]] = []
299
+ for key, value in dumped.items():
300
+ if isinstance(value, list):
301
+ items.extend((key, str(item)) for item in value)
302
+ else:
303
+ items.append((key, str(value)))
304
+ return QueryParams(items)
305
+ return QueryParams(params)
306
+
307
+
308
+ def _apply_pagination(
309
+ query_params: QueryParams, select_query: Select[Any]
310
+ ) -> Select[Any]:
311
+ page_size = _get_int(query_params, "page_size")
312
+ if page_size is None:
313
+ return select_query
314
+ page = _get_int(query_params, "page") or 1
315
+ offset = (page - 1) * page_size
316
+ return select_query.limit(page_size).offset(offset)
317
+
318
+
319
+ def _get_int(query_params: QueryParams, param_name: str) -> Optional[int]:
320
+ value = query_params.get(param_name)
321
+ if not value:
322
+ return None
323
+ try:
324
+ return int(value)
325
+ except ValueError:
326
+ raise HTTPException(
327
+ 400,
328
+ f"Invalid value for URL query parameter {param_name}: "
329
+ f"{value} is not an integer",
330
+ )
331
+
332
+
333
+ def _apply_sorting(
334
+ query_params: QueryParams,
335
+ select_query: Select[Any],
336
+ model: type[DeclarativeBase],
337
+ schema_cls: SchemaType,
338
+ ) -> Select[Any]:
339
+ sort_string = query_params.get("sort")
340
+ if not sort_string:
341
+ id_column = getattr(model, "id", None)
342
+ if id_column is not None:
343
+ return select_query.order_by(id_column)
344
+ return select_query
345
+
346
+ for column_name in sort_string.split(","):
347
+ order = sqlalchemy.asc
348
+ if column_name.startswith("-"):
349
+ order = sqlalchemy.desc
350
+ column_name = column_name[1:]
351
+ joins, column = _resolve_column(model, column_name, schema_cls)
352
+ for join in joins:
353
+ select_query = select_query.join(join)
354
+ select_query = select_query.order_by(order(column))
355
+ return select_query
356
+
357
+
358
+ def _iter_fields_including_nested(
359
+ schema_cls: SchemaType, prefix: str = ""
360
+ ) -> Iterator[tuple[str, FieldInfo]]:
361
+ for name, field in schema_cls.model_fields.items():
362
+ public_name = field.alias or name
363
+ # Each segment of the public dotted path becomes part of the URL
364
+ # grammar. ``__`` is reserved for operator suffixes (``__gte``,
365
+ # ``__contains``, ...) and ``.`` is reserved for relation traversal,
366
+ # so a segment containing either character would create an
367
+ # ambiguous URL key. Reject at schema-generation time so the
368
+ # collision surfaces during view registration, not at request time.
369
+ if "__" in public_name:
370
+ raise ValueError(
371
+ f"List-params schema for {schema_cls.__name__!r} cannot "
372
+ f"expose field {public_name!r}: ``__`` is reserved for "
373
+ "operator suffixes. Choose a different Pydantic alias."
374
+ )
375
+ if "." in public_name:
376
+ raise ValueError(
377
+ f"List-params schema for {schema_cls.__name__!r} cannot "
378
+ f"expose field {public_name!r}: ``.`` is reserved for "
379
+ "relation traversal. Choose a different Pydantic alias."
380
+ )
381
+ full_name = f"{prefix}.{public_name}" if prefix else public_name
382
+ nested = _get_nested_schema(field)
383
+ if nested:
384
+ yield from _iter_fields_including_nested(nested, full_name)
385
+ else:
386
+ yield full_name, field
387
+
388
+
389
+ def _resolve_field_name(schema_cls: SchemaType, public_name: str) -> str | None:
390
+ """Given a schema and a public (URL-facing) name, return the canonical
391
+ Python field name to use with the model.
392
+
393
+ The public name is the field's alias when one is declared, otherwise the
394
+ field name itself. Aliased fields are *only* reachable by their alias —
395
+ Python field names are never part of the public URL contract, even when
396
+ the schema has ``populate_by_name=True`` (which only affects how Pydantic
397
+ parses input bodies, not the generated list-params query schema).
398
+ """
399
+ for field_name, field in schema_cls.model_fields.items():
400
+ if field.alias == public_name:
401
+ return field_name
402
+
403
+ if public_name in schema_cls.model_fields:
404
+ field = schema_cls.model_fields[public_name]
405
+ if field.alias is None:
406
+ return public_name
407
+ return None
408
+
409
+
410
+ def _resolve_column(
411
+ model: type[DeclarativeBase], column_path: str, schema_cls: SchemaType
412
+ ) -> tuple[list[InstrumentedAttribute[Any]], InstrumentedAttribute[Any]]:
413
+ """Resolve a (possibly dotted) public column path to its SQLAlchemy column,
414
+ plus the relationship attributes that need to be joined.
415
+
416
+ Strict: every path segment must resolve through the schema's public name
417
+ (alias when set, Python field name otherwise). Falling back to a raw
418
+ model attribute lookup would let URLs reach columns the schema didn't
419
+ expose — for example, a Python field name on an aliased schema field —
420
+ and silently bypass the public-name contract.
421
+ """
422
+ joins: list[InstrumentedAttribute[Any]] = []
423
+ current_model = model
424
+ current_schema: SchemaType | None = schema_cls
425
+ name = column_path
426
+ while "." in name:
427
+ relation, _, name = name.partition(".")
428
+ if current_schema is None:
429
+ raise HTTPException(400, f"Invalid attribute in URL query: {column_path}")
430
+ field_name = _resolve_field_name(current_schema, relation)
431
+ if field_name is None:
432
+ raise HTTPException(400, f"Invalid attribute in URL query: {column_path}")
433
+ rel = getattr(current_model, field_name, None)
434
+ if not isinstance(rel, InstrumentedAttribute) or not hasattr(
435
+ rel.property, "mapper"
436
+ ):
437
+ raise HTTPException(400, f"Invalid attribute in URL query: {column_path}")
438
+ joins.append(rel)
439
+ current_model = rel.property.mapper.class_
440
+ current_schema = _get_nested_schema(current_schema.model_fields[field_name])
441
+
442
+ if current_schema is None:
443
+ raise HTTPException(400, f"Invalid attribute in URL query: {column_path}")
444
+ field_name = _resolve_field_name(current_schema, name)
445
+ if field_name is None:
446
+ raise HTTPException(400, f"Invalid attribute in URL query: {column_path}")
447
+ column = getattr(current_model, field_name, None)
448
+ if (
449
+ column is None
450
+ or not isinstance(column, InstrumentedAttribute)
451
+ or not isinstance(column.property, ColumnProperty)
452
+ ):
453
+ raise HTTPException(400, f"Invalid attribute in URL query: {column_path}")
454
+ return joins, cast(InstrumentedAttribute[Any], column)
455
+
456
+
457
+ def _apply_filtering(
458
+ query_params: QueryParams,
459
+ select_query: Select[Any],
460
+ model: type[DeclarativeBase],
461
+ schema_cls: SchemaType,
462
+ ) -> Select[Any]:
463
+ """Apply ``key=value`` and ``key__op=value`` filters to ``select_query``.
464
+
465
+ Multiple filters on the same column are AND-combined. Comma-separated
466
+ values within one parameter are OR-combined for ``eq`` (the default),
467
+ mapped to SQL ``IN`` for ``in``, and AND-combined for ``ne`` (so
468
+ ``status__ne=a,b`` means NOT IN (a, b)). For ``contains``/``icontains``
469
+ values are split on whitespace and AND-combined.
470
+ """
471
+ filters: dict[InstrumentedAttribute[Any], list[ColumnElement[Any]]] = defaultdict(
472
+ list
473
+ )
474
+ joins: set[InstrumentedAttribute[Any]] = set()
475
+
476
+ for key, raw_value in query_params.multi_items():
477
+ if key in _RESERVED_NAMES:
478
+ continue
479
+
480
+ if "__" in key:
481
+ column_name, op = key.split("__", 1)
482
+ else:
483
+ column_name, op = key, "eq"
484
+
485
+ column_joins, column = _resolve_column(model, column_name, schema_cls)
486
+ joins.update(column_joins)
487
+ parser = functools.partial(_parse_value, schema_cls, column_name)
488
+
489
+ if op == "isnull":
490
+ try:
491
+ value = pydantic.TypeAdapter(bool).validate_python(raw_value)
492
+ except pydantic.ValidationError as exc:
493
+ raise HTTPException(
494
+ 400, f"Invalid value for URL query parameter {key}"
495
+ ) from exc
496
+ filters[column].append(column.is_(None) if value else column.isnot(None))
497
+ continue
498
+
499
+ clause = _build_clause(column, raw_value, op, parser)
500
+ if clause is not None:
501
+ filters[column].append(clause)
502
+
503
+ for join in joins:
504
+ select_query = select_query.join(join)
505
+
506
+ for column, clauses in filters.items():
507
+ and_clause = clauses[0] if len(clauses) == 1 else sqlalchemy.and_(*clauses)
508
+ select_query = select_query.where(and_clause)
509
+ return select_query
510
+
511
+
512
+ def _build_clause(
513
+ column: InstrumentedAttribute[Any],
514
+ raw_value: str,
515
+ op: str,
516
+ parser: Callable[[str], Any],
517
+ ) -> ColumnElement[Any] | None:
518
+ """Combine multiple values within one parameter according to ``op`` semantics."""
519
+ if op in {"contains", "icontains"}:
520
+ values = [v for v in raw_value.split() if v]
521
+ if not values:
522
+ return None
523
+ clauses = [_make_where_clause(column, v, op, parser) for v in values]
524
+ return clauses[0] if len(clauses) == 1 else sqlalchemy.and_(*clauses)
525
+
526
+ values = raw_value.split(",")
527
+ if not values:
528
+ return None
529
+ if op == "in":
530
+ return column.in_([parser(v) for v in values])
531
+ clauses = [_make_where_clause(column, v, op, parser) for v in values]
532
+ if len(clauses) == 1:
533
+ return clauses[0]
534
+ # ``ne`` with multiple values means NOT IN (...) — AND-combine, not OR.
535
+ if op == "ne":
536
+ return sqlalchemy.and_(*clauses)
537
+ return sqlalchemy.or_(*clauses)
538
+
539
+
540
+ def _parse_value(schema_cls: SchemaType, column_name: str, value: str) -> Any:
541
+ if "." in column_name:
542
+ relation, _, column_part = column_name.partition(".")
543
+ relation_field_name = _resolve_field_name(schema_cls, relation) or relation
544
+ field = schema_cls.model_fields.get(relation_field_name)
545
+ nested = _get_nested_schema(field)
546
+ if nested is None:
547
+ raise HTTPException(400, f"Invalid attribute in URL query: {column_name}")
548
+ return _parse_value(nested, column_part, value)
549
+
550
+ field_name = _resolve_field_name(schema_cls, column_name)
551
+ if field_name is None:
552
+ raise HTTPException(400, f"Invalid attribute in URL query: {column_name}")
553
+
554
+ try:
555
+ obj = schema_cls.__pydantic_validator__.validate_assignment(
556
+ schema_cls.model_construct(), field_name, value
557
+ )
558
+ return getattr(obj, field_name)
559
+ except Exception:
560
+ raise HTTPException(400, f"Invalid attribute in URL query: {column_name}")
561
+
562
+
563
+ def _get_nested_schema(field: FieldInfo | None) -> SchemaType | None:
564
+ if field is None:
565
+ return None
566
+ annotation = _unwrap_optional_annotation(field.annotation)
567
+ if isinstance(annotation, type) and issubclass(annotation, pydantic.BaseModel):
568
+ return annotation
569
+ return None
570
+
571
+
572
+ def _make_where_clause(
573
+ column: InstrumentedAttribute[Any],
574
+ filter_value: str,
575
+ op: str,
576
+ parser: Callable[[str], Any],
577
+ ) -> ColumnElement[Any]:
578
+ if op == "gte":
579
+ return column >= parser(filter_value)
580
+ if op == "lte":
581
+ return column <= parser(filter_value)
582
+ if op == "gt":
583
+ return column > parser(filter_value)
584
+ if op == "lt":
585
+ return column < parser(filter_value)
586
+ if op == "ne":
587
+ return column != parser(filter_value)
588
+ if op == "contains":
589
+ return column.like(f"%{_escape_like_value(filter_value)}%", escape="\\")
590
+ if op == "icontains":
591
+ return column.ilike(f"%{_escape_like_value(filter_value)}%", escape="\\")
592
+ if op == "eq":
593
+ return column == parser(filter_value)
594
+ raise HTTPException(400, f"Unsupported filter operator: {op!r}")
@@ -0,0 +1,22 @@
1
+ import types
2
+ from typing import Any, Union, get_args, get_origin
3
+
4
+
5
+ def _escape_like_value(value: str) -> str:
6
+ """Escape SQL LIKE wildcard characters for literal substring matching."""
7
+ escaped = value.replace("\\", "\\\\")
8
+ escaped = escaped.replace("%", "\\%")
9
+ escaped = escaped.replace("_", "\\_")
10
+ return escaped
11
+
12
+
13
+ def _unwrap_optional_annotation(annotation: Any) -> Any:
14
+ """Unwrap Optional[X] or X | None to X. Returns annotation unchanged otherwise."""
15
+ origin = get_origin(annotation)
16
+ if origin not in (types.UnionType, Union):
17
+ return annotation
18
+
19
+ non_none_args = [arg for arg in get_args(annotation) if arg is not type(None)]
20
+ if len(non_none_args) == 1:
21
+ return non_none_args[0]
22
+ return annotation
@@ -0,0 +1,29 @@
1
+ from ._base import (
2
+ BaseSchema,
3
+ IDRef,
4
+ IDSchema,
5
+ ReadOnly,
6
+ TimestampsSchemaMixin,
7
+ WriteOnly,
8
+ )
9
+ from ._generator import create_schema_from_model
10
+
11
+ # Public API for ``fastapi_restly.schemas``.
12
+ #
13
+ # Framework internals (``OmitReadOnlyMixin``, ``PatchMixin``,
14
+ # ``create_model_with_optional_fields``, ``create_model_without_read_only_fields``,
15
+ # ``readonly_marker``, ``writeonly_marker``, ``getattrs``,
16
+ # ``rebase_with_model_config``, ``set_schema_title``, ``get_writable_inputs``,
17
+ # ``get_read_only_fields``, ``get_write_only_fields``, ``SQLAlchemyModel``,
18
+ # the ``_generator`` introspection helpers, etc.) live in
19
+ # ``fastapi_restly.schemas._base`` / ``._generator`` and are not re-exported
20
+ # here. They may move or change without notice.
21
+ __all__ = [
22
+ "BaseSchema",
23
+ "IDRef",
24
+ "IDSchema",
25
+ "ReadOnly",
26
+ "TimestampsSchemaMixin",
27
+ "WriteOnly",
28
+ "create_schema_from_model",
29
+ ]