dynamicforms-fastapi-viewsets 0.15.0__tar.gz → 0.15.1__tar.gz

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.
Files changed (74) hide show
  1. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/PKG-INFO +1 -1
  2. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/cursor.py +11 -1
  3. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/build_schema.py +4 -3
  4. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/route_viewset.py +85 -4
  5. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/endpoint_docs.py +14 -1
  6. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/events/changes.py +8 -1
  7. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/events/viewset.py +20 -0
  8. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/list_query.py +9 -1
  9. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/mixins.py +22 -11
  10. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/routing.py +25 -0
  11. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/pyproject.toml +1 -1
  12. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/pyproject.toml.orig +1 -1
  13. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/LICENSE +0 -0
  14. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/README.md +0 -0
  15. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/__init__.py +0 -0
  16. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/action_configuration.py +0 -0
  17. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/backends/__init__.py +0 -0
  18. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/backends/django_orm.py +0 -0
  19. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/collection_viewset.py +0 -0
  20. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/conf.py +0 -0
  21. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/context/__init__.py +0 -0
  22. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/context/auth/__init__.py +0 -0
  23. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/context/auth/django.py +0 -0
  24. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/context/auth/jwt.py +0 -0
  25. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/context/auth/static.py +0 -0
  26. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/__init__.py +0 -0
  27. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -0
  28. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/celery_viewset/client.py +0 -0
  29. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/celery_viewset/payload.py +0 -0
  30. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +0 -0
  31. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/celery_viewset/server.py +0 -0
  32. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/celery_viewset/transport.py +0 -0
  33. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/conftest.py +0 -0
  34. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/lifecycle_runner.py +0 -0
  35. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
  36. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/query_model.py +0 -0
  37. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/sub_viewsets.py +0 -0
  38. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/error_namespace.py +0 -0
  39. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/error_responses.py +0 -0
  40. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/events/__init__.py +0 -0
  41. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/events/authorizers.py +0 -0
  42. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/events/conftest.py +0 -0
  43. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/events/delivery.py +0 -0
  44. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/events/event.py +0 -0
  45. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/events/lifecycle.py +0 -0
  46. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/events/redis.py +0 -0
  47. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/events/scopes.py +0 -0
  48. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/events/sources.py +0 -0
  49. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/events/store.py +0 -0
  50. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/events/topics.py +0 -0
  51. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/exceptions.py +0 -0
  52. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/filters/__init__.py +0 -0
  53. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/filters/base.py +0 -0
  54. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/filters/declaration.py +0 -0
  55. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/filters/operators.py +0 -0
  56. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/filters/registry.py +0 -0
  57. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/list_shapes.py +0 -0
  58. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/middleware/__init__.py +0 -0
  59. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
  60. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
  61. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/middleware/cookies.py +0 -0
  62. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/middleware/idempotency.py +0 -0
  63. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
  64. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/mux_ws/__init__.py +0 -0
  65. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/mux_ws/asgi.py +0 -0
  66. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/mux_ws/protocol.py +0 -0
  67. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/mux_ws/registry.py +0 -0
  68. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/mux_ws/server.py +0 -0
  69. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/mux_ws/transports.py +0 -0
  70. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/py.typed +0 -0
  71. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/response_classes.py +0 -0
  72. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/save_state/__init__.py +0 -0
  73. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/save_state/save_state.py +0 -0
  74. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/save_state/serialize_state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dynamicforms-fastapi-viewsets
3
- Version: 0.15.0
3
+ Version: 0.15.1
4
4
  Summary: Django REST Framework-style viewsets for FastAPI, with optional Celery-backed async execution and a Vue/TypeScript client counterpart.
5
5
  Keywords: fastapi,viewsets,rest,dynamicforms,celery,django-rest-framework
6
6
  Author: Jure Erznožnik
@@ -15,7 +15,7 @@ import json
15
15
  from dataclasses import dataclass
16
16
  from typing import Any, ClassVar, Generic, TYPE_CHECKING
17
17
 
18
- from pydantic import BaseModel, TypeAdapter
18
+ from pydantic import BaseModel, ConfigDict, TypeAdapter
19
19
  from typing_extensions import TypeVar
20
20
 
21
21
  from .error_namespace import _namespaced
@@ -157,14 +157,24 @@ class CursorPage(BaseModel, Generic[TItem]):
157
157
  are the same anchors read inclusively, present whenever the page is non-empty, for polling an edge.
158
158
  """
159
159
 
160
+ model_config = ConfigDict(use_attribute_docstrings=True)
161
+
160
162
  results: list[TItem]
163
+ """The records of the page."""
161
164
  limit: int
165
+ """Page size the page was read with."""
162
166
  has_more: bool = False
167
+ """Whether records follow the page."""
163
168
  has_previous: bool = False
169
+ """Whether records precede the page."""
164
170
  next: str | None = None
171
+ """Cursor of the records after the page; null when there are none."""
165
172
  previous: str | None = None
173
+ """Cursor of the records before the page; null when there are none."""
166
174
  first: str | None = None
175
+ """Cursor of the page's first record onwards (that record included); null for an empty page."""
167
176
  last: str | None = None
177
+ """Cursor of the page's last record backwards (that record included); null for an empty page."""
168
178
 
169
179
 
170
180
  @dataclass(frozen=True)
@@ -7,8 +7,9 @@ from fastapi import APIRouter, FastAPI
7
7
  from fastapi.routing import APIRoute
8
8
  from starlette.responses import Response
9
9
 
10
- from ..endpoint_docs import docs_for, viewset_tags
10
+ from ..endpoint_docs import operation_docs_for, viewset_tags
11
11
  from ..middleware import unwrap_viewset_result_type
12
+ from ..routing import SCHEMA_ENDPOINT_DOCS
12
13
 
13
14
 
14
15
  def _generic_args(annotation) -> tuple:
@@ -144,7 +145,7 @@ def build_schema(cls, base_path: str = "", default_tags=None, get_wrapper=None,
144
145
  endpoint=schema,
145
146
  tags=default_tags,
146
147
  methods=["GET"],
147
- description="Returns OpenAPI schema for the ViewSet",
148
+ **SCHEMA_ENDPOINT_DOCS,
148
149
  )
149
150
  class_router.add_api_route(**schema_route_kwargs)
150
151
 
@@ -179,7 +180,7 @@ def build_schema(cls, base_path: str = "", default_tags=None, get_wrapper=None,
179
180
  route.endpoint.__name__,
180
181
  getattr(endpoint_wrapper, "__shape_openapi_extra__", None) or route.openapi_extra,
181
182
  ),
182
- **docs_for(cls, route.endpoint.__name__),
183
+ **operation_docs_for(cls, route.endpoint.__name__),
183
184
  )
184
185
  )
185
186
 
@@ -1,3 +1,4 @@
1
+ import copy
1
2
  import inspect
2
3
  import re
3
4
 
@@ -5,8 +6,8 @@ from dataclasses import dataclass
5
6
  from functools import wraps
6
7
  from typing import Annotated, get_args, get_origin, Literal, TypeVar
7
8
 
8
- from fastapi import APIRouter, Depends, Header, Request, Response
9
- from fastapi.params import FieldInfo
9
+ from fastapi import APIRouter, Depends, Header, Path, Request, Response
10
+ from fastapi.params import FieldInfo, Header as HeaderParam, Param
10
11
  from pydantic import BaseModel
11
12
 
12
13
  try:
@@ -20,11 +21,15 @@ from fastapi_viewsets.context import build_context, Context, get_shared_context
20
21
  from fastapi_viewsets.endpoint_docs import (
21
22
  DOCS_ATTRIBUTE,
22
23
  docs_for,
24
+ operation_docs_for,
23
25
  register_tag,
24
26
  viewset_description,
25
27
  )
26
28
  from fastapi_viewsets.middleware import any_modifies_response_shape, Middleware, unwrap_viewset_result_type
27
- from fastapi_viewsets.mixins import FilterParam
29
+ from fastapi_viewsets.mixins import (
30
+ FilterParam,
31
+ T as ITEM_TYPE_VAR, # noqa: N811
32
+ )
28
33
  from fastapi_viewsets.mux_ws import register_viewset, resolve_register_muxws, resolve_register_rest
29
34
  from fastapi_viewsets.response_classes import with_error_responses
30
35
  from fastapi_viewsets.routing import MUXWS, openapi_endpoint, REST, transport_route_class
@@ -172,6 +177,48 @@ def _with_security_scheme(run):
172
177
  return dependency
173
178
 
174
179
 
180
+ PK_DESCRIPTION = "Primary key of the record."
181
+ """The `pk` path parameter's description when the model's primary key field has none."""
182
+
183
+
184
+ def _pk_description(model, pk_field_name: str | None) -> str:
185
+ """The description of the `pk` path parameter: that of the model's primary key field, else `PK_DESCRIPTION`."""
186
+ fields = getattr(model, "model_fields", None) or {}
187
+ field = fields.get(pk_field_name) if pk_field_name else None
188
+ return field.description if field is not None and field.description else PK_DESCRIPTION
189
+
190
+
191
+ def _published_name(name: str, annotation) -> str | None:
192
+ """The schema name of a path, query, header or cookie parameter; None for any other parameter."""
193
+ for metadata in getattr(annotation, "__metadata__", ()):
194
+ if isinstance(metadata, Param):
195
+ if metadata.alias:
196
+ return metadata.alias
197
+ if isinstance(metadata, HeaderParam) and metadata.convert_underscores:
198
+ return name.replace("_", "-")
199
+ return name
200
+ return None
201
+
202
+
203
+ def _with_description(annotation, description: str):
204
+ """`annotation` (`Annotated[X, Param]`) with the parameter's description replaced."""
205
+ metadata = []
206
+ for item in annotation.__metadata__:
207
+ if isinstance(item, Param):
208
+ item = copy.copy(item)
209
+ item.description = description
210
+ item._attributes_set = {**getattr(item, "_attributes_set", {}), "description": description}
211
+ metadata.append(item)
212
+ return Annotated[(annotation.__origin__, *metadata)]
213
+
214
+
215
+ def _is_absent_model_param(annotation) -> bool:
216
+ """Whether `annotation` is `Annotated[None, Query()]`: a filter parameter of a viewset declaring no filter."""
217
+ args = getattr(annotation, "__args__", None)
218
+ metadata = getattr(annotation, "__metadata__", None)
219
+ return bool(args and metadata) and args[0] in (None, type(None)) and any(isinstance(m, FieldInfo) for m in metadata)
220
+
221
+
175
222
  def route_viewset(
176
223
  router: APIRouter,
177
224
  base_path: str,
@@ -276,6 +323,14 @@ def _route(
276
323
  ),
277
324
  ]
278
325
  )
326
+ if name == "limit":
327
+ parameter = parameter.replace(
328
+ annotation=_with_description(
329
+ parameter.annotation,
330
+ f"Page size. Omit for {cls.default_page_size}; a larger value than {cls.max_page_size}"
331
+ f" returns {cls.max_page_size}.",
332
+ )
333
+ )
279
334
  if name == "sort" and cls.default_sort is not None:
280
335
  check_declared_sort(cls.default_sort, item_type)
281
336
  parameter = parameter.replace(default=cls.default_sort)
@@ -286,6 +341,26 @@ def _route(
286
341
  return sig.replace(parameters=params, return_annotation=response_model(allowed, item_type))
287
342
 
288
343
  shape_openapi_extra = None
344
+ item_model = type_map.get(ITEM_TYPE_VAR)
345
+
346
+ def _describe_parameters(params: list[inspect.Parameter], descriptions: dict[str, str]) -> None:
347
+ """Replaces the descriptions of `params` named in `descriptions` (`@endpoint_docs` `parameters`)."""
348
+ if not descriptions:
349
+ return
350
+ published = {}
351
+ for index, parameter in enumerate(params):
352
+ name = _published_name(parameter.name, parameter.annotation)
353
+ if name is not None:
354
+ published[name] = index
355
+ unknown = set(descriptions) - set(published)
356
+ if unknown:
357
+ raise ValueError(
358
+ f"@endpoint_docs on {cls.__name__} describes parameter(s) its endpoint does not have: "
359
+ f"{', '.join(sorted(unknown))}; it has {', '.join(sorted(published)) or 'none'}"
360
+ )
361
+ for name, description in descriptions.items():
362
+ index = published[name]
363
+ params[index] = params[index].replace(annotation=_with_description(params[index].annotation, description))
289
364
 
290
365
  def get_wrapper(original_endpoint, route_path, route_methods):
291
366
  nonlocal shape_openapi_extra
@@ -303,6 +378,10 @@ def _route(
303
378
  continue
304
379
 
305
380
  annotation = resolve_typevars(type_map, p.annotation)
381
+ if _is_absent_model_param(annotation):
382
+ continue
383
+ if name == "pk" and "{pk}" in route_path and _published_name(name, annotation) is None:
384
+ annotation = Annotated[annotation, Path(description=_pk_description(item_model, pk_field_name))]
306
385
  new_p = p.replace(annotation=annotation)
307
386
 
308
387
  # `context` (detected by name) never reaches FastAPI; lifecycle_runner builds and injects it.
@@ -335,6 +414,8 @@ def _route(
335
414
 
336
415
  new_params.append(new_p)
337
416
 
417
+ _describe_parameters(new_params, docs_for(cls, original_endpoint.__name__).get("parameters", {}))
418
+
338
419
  # Reserved: FastAPI injects the real Response, onto which lifecycle_runner applies headers/cookies.
339
420
  new_params.append(inspect.Parameter("response", inspect.Parameter.KEYWORD_ONLY, annotation=Response))
340
421
 
@@ -461,7 +542,7 @@ def _route(
461
542
  route, dependencies=dependencies, responses=with_error_responses(route.responses)
462
543
  )
463
544
  # Per-viewset wording from @endpoint_docs.
464
- route_kwargs.update(docs_for(cls, action_name))
545
+ route_kwargs.update(operation_docs_for(cls, action_name))
465
546
  if parent is not None and not route_kwargs.get("summary"):
466
547
  # Prefix the mount, telling a sub-viewset's `List Items` apart from the parent's in its group.
467
548
  default_summary = (route_kwargs.get("name") or action_name).replace("_", " ").title()
@@ -19,6 +19,8 @@ class EndpointDoc(TypedDict, total=False):
19
19
  response_description: str
20
20
  deprecated: bool
21
21
  tags: list[str]
22
+ parameters: dict[str, str]
23
+ """Descriptions of the endpoint's path, query and header parameters, by their name in the schema."""
22
24
 
23
25
 
24
26
  def endpoint_docs(docs: dict[str, EndpointDoc]):
@@ -40,15 +42,26 @@ def endpoint_docs(docs: dict[str, EndpointDoc]):
40
42
 
41
43
 
42
44
  def docs_for(cls: type, action: str) -> dict[str, Any]:
43
- """The documentation of one action, merged along the MRO (subclass entries override)."""
45
+ """
46
+ The documentation of one action, merged along the MRO (subclass entries override; `parameters` merges
47
+ per parameter).
48
+ """
44
49
  merged: dict[str, Any] = {}
45
50
  for base in reversed(cls.__mro__):
46
51
  entry = base.__dict__.get(DOCS_ATTRIBUTE, {}).get(action)
47
52
  if entry:
53
+ parameters = {**merged.get("parameters", {}), **entry.get("parameters", {})}
48
54
  merged.update(entry)
55
+ if parameters:
56
+ merged["parameters"] = parameters
49
57
  return merged
50
58
 
51
59
 
60
+ def operation_docs_for(cls: type, action: str) -> dict[str, Any]:
61
+ """`docs_for` without `parameters`: the keyword arguments of `add_api_route`."""
62
+ return {key: value for key, value in docs_for(cls, action).items() if key != "parameters"}
63
+
64
+
52
65
  # ---------------------------------------------------------------------------
53
66
  # Viewset-level description
54
67
  # ---------------------------------------------------------------------------
@@ -10,7 +10,7 @@ import logging
10
10
  from collections.abc import Awaitable, Callable, Mapping
11
11
  from typing import Any, Literal, TYPE_CHECKING
12
12
 
13
- from pydantic import BaseModel
13
+ from pydantic import BaseModel, ConfigDict
14
14
 
15
15
  from ..action_configuration import resolve_action_configuration
16
16
  from ..context import _Deferred, Context
@@ -48,7 +48,10 @@ _OPTIONS = frozenset({"topic"})
48
48
  class Change(BaseModel):
49
49
  """One record an action touched, by its pk; a frontend reads the record through the viewset's retrieve."""
50
50
 
51
+ model_config = ConfigDict(use_attribute_docstrings=True)
52
+
51
53
  pk: Any
54
+ """Primary key of the record."""
52
55
 
53
56
 
54
57
  class RecordsChanged(Event):
@@ -56,8 +59,12 @@ class RecordsChanged(Event):
56
59
 
57
60
  event_name = "records_changed"
58
61
 
62
+ model_config = ConfigDict(use_attribute_docstrings=True)
63
+
59
64
  op: Literal["created", "updated", "deleted"]
65
+ """What the action did to the records."""
60
66
  changes: list[Change]
67
+ """The records the action touched."""
61
68
 
62
69
 
63
70
  def routed_base_path(request: "Request") -> str | None:
@@ -9,6 +9,7 @@ from typing import Any
9
9
  from fastapi import APIRouter, Request
10
10
  from pydantic import (
11
11
  BaseModel,
12
+ ConfigDict,
12
13
  Field,
13
14
  GetJsonSchemaHandler,
14
15
  model_serializer,
@@ -44,12 +45,17 @@ def _leave_out_none(data: Any, names: tuple[str, ...]) -> Any:
44
45
  class TopicRequest(BaseModel):
45
46
  """The body of `subscribe` and `unsubscribe`."""
46
47
 
48
+ model_config = ConfigDict(use_attribute_docstrings=True)
49
+
47
50
  topic: str
51
+ """The topic: a viewset's routed base path or a declared source's `name[:key]`."""
48
52
 
49
53
 
50
54
  class SubscribeAnswer(BaseModel):
51
55
  """The answer of `subscribe`. `scope` is left out of the serialized answer when it is None."""
52
56
 
57
+ model_config = ConfigDict(use_attribute_docstrings=True)
58
+
53
59
  rev: int
54
60
  """The topic's current rev, 0 for a topic without events."""
55
61
  scope: str | None = None
@@ -68,7 +74,10 @@ class SubscribeAnswer(BaseModel):
68
74
  class PollRequest(BaseModel):
69
75
  """The body of `poll`: each topic mapped to the rev the caller holds, or None to start from now."""
70
76
 
77
+ model_config = ConfigDict(use_attribute_docstrings=True)
78
+
71
79
  cursors: dict[str, int | None]
80
+ """The rev the caller holds per topic; null to start from the topic's current rev."""
72
81
  scopes: dict[str, str] | None = None
73
82
  """The scope key the caller holds per topic; a cursor whose scope key differs from the server's answers
74
83
  `events: null`. None trusts every cursor."""
@@ -77,13 +86,17 @@ class PollRequest(BaseModel):
77
86
  class PolledEvent(BaseModel):
78
87
  """One event of a poll answer."""
79
88
 
89
+ model_config = ConfigDict(use_attribute_docstrings=True)
90
+
80
91
  rev: int
92
+ """The event's rev in its topic."""
81
93
  prev: int
82
94
  """The rev of the event before it in the answer; for the first event, the cursor."""
83
95
  event: str
84
96
  """The event's `event_name`."""
85
97
  version: int = Field(ge=1, description="The event's `event_version`: the version of the payload's shape.")
86
98
  payload: Any = None
99
+ """The event's fields."""
87
100
 
88
101
 
89
102
  class TopicAnswer(BaseModel):
@@ -93,9 +106,14 @@ class TopicAnswer(BaseModel):
93
106
  and `status` set. `error`, `status` and `scope` are left out when None.
94
107
  """
95
108
 
109
+ model_config = ConfigDict(use_attribute_docstrings=True)
110
+
96
111
  rev: int | None
112
+ """The topic's current rev; null for a refused or invalid topic."""
97
113
  events: list[PolledEvent] | None
114
+ """The events after the cursor, oldest first; null when the log cannot answer the cursor or the topic is refused."""
98
115
  error: ApiErrorBody | None = None
116
+ """Why the topic is refused or invalid."""
99
117
  status: int | None = None
100
118
  """The HTTP status of the refusal; set on every answer with `error`."""
101
119
  scope: str | None = None
@@ -114,6 +132,8 @@ class TopicAnswer(BaseModel):
114
132
  class PollAnswer(BaseModel):
115
133
  """The answer of `poll`."""
116
134
 
135
+ model_config = ConfigDict(use_attribute_docstrings=True)
136
+
117
137
  topics: dict[str, TopicAnswer]
118
138
  """One answer per cursor of the request, in the request's order."""
119
139
  poll_after_ms: int
@@ -8,7 +8,7 @@ from collections.abc import AsyncIterable, AsyncIterator, Iterable
8
8
  from dataclasses import dataclass, field
9
9
  from typing import Any, Generic, TypeVar
10
10
 
11
- from pydantic import BaseModel
11
+ from pydantic import BaseModel, ConfigDict
12
12
 
13
13
  TItem = TypeVar("TItem")
14
14
  """
@@ -61,12 +61,20 @@ class ListQuery:
61
61
  class PaginatedList(BaseModel, Generic[TItem]):
62
62
  """One page of results; `count` is None when the source cannot be counted without draining it."""
63
63
 
64
+ model_config = ConfigDict(use_attribute_docstrings=True)
65
+
64
66
  results: list[TItem]
67
+ """The records of the page."""
65
68
  offset: int
69
+ """Number of records before the page."""
66
70
  limit: int | None
71
+ """Page size the page was read with."""
67
72
  count: int | None = None
73
+ """Number of records in the whole list; null when the source cannot be counted."""
68
74
  has_more: bool = False
75
+ """Whether records follow the page."""
69
76
  has_previous: bool = False
77
+ """Whether records precede the page."""
70
78
 
71
79
 
72
80
  def build_list_query(fltr: Any, sort: str | None, offset: int = 0, limit: int | None = None) -> ListQuery:
@@ -6,7 +6,7 @@ from typing import Annotated, Any, final, Generic, get_args, get_origin, Union
6
6
 
7
7
  from fastapi import APIRouter, Header, Query
8
8
  from fastapi.exceptions import RequestValidationError
9
- from pydantic import BaseModel, ConfigDict, create_model
9
+ from pydantic import BaseModel, ConfigDict, create_model, Field
10
10
  from pydantic.alias_generators import to_camel
11
11
  from starlette.exceptions import HTTPException
12
12
  from typing_extensions import TypeVar
@@ -200,13 +200,22 @@ class SortStateColumn(BaseModel):
200
200
 
201
201
  model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
202
202
 
203
- column_name: str
204
- direction: SortDirection = SortDirection.asc
203
+ column_name: str = Field(description="Name of the field to order by.")
204
+ direction: SortDirection = Field(SortDirection.asc, description="`asc` or `desc`.")
205
205
 
206
206
 
207
207
  SortState = list[SortStateColumn]
208
208
 
209
209
 
210
+ OFFSET_DESCRIPTION = "Number of records to skip before the page."
211
+ """The `offset` parameter's description on every paginated list endpoint."""
212
+
213
+ CURSOR_DESCRIPTION = (
214
+ "Position to read from: `next`, `previous`, `first` or `last` of a page returned by the same ordering and"
215
+ " filter. Omit for the first page."
216
+ )
217
+ """The `cursor` parameter's description on every cursor list endpoint."""
218
+
210
219
  SORT_DESCRIPTION = (
211
220
  "Ordering: comma-separated `column:direction` entries in priority order (`name:asc,score:desc`)."
212
221
  " The direction is `asc` or `desc`, `asc` when omitted; an entry with any other direction is"
@@ -318,9 +327,9 @@ class ListMixin(Generic[T, TFilter], ABC):
318
327
  context: Context,
319
328
  fltr: Annotated[TFilter, Query()] = None,
320
329
  sort: Annotated[str | None, Query(description=SORT_DESCRIPTION)] = None,
321
- offset: Annotated[int, Query(ge=0)] = 0,
322
- limit: Annotated[int | None, Query(ge=1)] = None,
323
- cursor: str | None = None,
330
+ offset: Annotated[int, Query(ge=0, description=OFFSET_DESCRIPTION)] = 0,
331
+ limit: Annotated[int | None, Query(ge=1, description="Page size.")] = None,
332
+ cursor: Annotated[str | None, Query(description=CURSOR_DESCRIPTION)] = None,
324
333
  x_list_shape: Annotated[str | None, Header()] = None,
325
334
  ) -> Union[list[T], PaginatedList[T], CursorPage[T]]: # noqa: UP007 - see list_shapes.response_model
326
335
  """List the collection."""
@@ -705,16 +714,18 @@ class BulkDestroyMixin(DestroyMixin[K, T], BulkOnlyDestroyMixin[K, T]):
705
714
  # LOOKUP
706
715
  ###################################################################################################
707
716
  class LookupItem(BaseModel):
708
- group: Any = None
709
- pk: object
710
- title: str
711
- icon: str | None = None
717
+ """One choice of a lookup."""
718
+
719
+ group: Any = Field(None, description="Group the choice is listed under; null for an ungrouped choice.")
720
+ pk: object = Field(description="Primary key of the record.")
721
+ title: str = Field(description="Text shown for the choice.")
722
+ icon: str | None = Field(None, description="Icon shown with the choice; null for none.")
712
723
 
713
724
 
714
725
  class LookupFilter(BaseModel):
715
726
  """Default filter model for LookupMixin. Provides case-insensitive title search via q."""
716
727
 
717
- q: str | None = None
728
+ q: str | None = Field(None, description="Text the choice's title contains, case-insensitive. Omit for all choices.")
718
729
 
719
730
 
720
731
  TLookupFilter = TypeVar("TLookupFilter", default=LookupFilter)
@@ -9,6 +9,7 @@ from typing import Any
9
9
 
10
10
  from fastapi import FastAPI
11
11
  from fastapi.routing import APIRoute
12
+ from pydantic import BaseModel, ConfigDict, Field
12
13
  from starlette.exceptions import HTTPException
13
14
  from starlette.routing import Match
14
15
  from starlette.types import Message, Receive, Scope, Send
@@ -78,6 +79,30 @@ def transport_route_class(base: type[APIRoute], transports: frozenset[str]) -> t
78
79
  return type(name, (TransportRoute, base), {"transports": transports})
79
80
 
80
81
 
82
+ class OpenAPIDocument(BaseModel):
83
+ """An OpenAPI 3.1 document (https://spec.openapis.org/oas/v3.1.0); members not listed here are kept."""
84
+
85
+ model_config = ConfigDict(extra="allow")
86
+
87
+ openapi: str = Field(description="Version of the OpenAPI specification the document follows.")
88
+ info: dict[str, Any] = Field(description="Title and version of the API.")
89
+ paths: dict[str, Any] = Field(description="The endpoints, by path and HTTP method.")
90
+ components: dict[str, Any] | None = Field(None, description="Schemas the endpoints refer to.")
91
+ tags: list[dict[str, Any]] | None = Field(None, description="Names and descriptions of the endpoint groups.")
92
+
93
+
94
+ SCHEMA_ENDPOINT_DOCS: dict[str, Any] = {
95
+ "description": (
96
+ "OpenAPI document of this viewset's endpoints on the transport it is requested over (REST or muxws),"
97
+ " this endpoint included."
98
+ ),
99
+ "response_description": "The OpenAPI document.",
100
+ "responses": {200: {"model": OpenAPIDocument}},
101
+ "response_model": None,
102
+ }
103
+ """`add_api_route` keyword arguments of every viewset's `/schema` endpoint; `@endpoint_docs` key `schema`."""
104
+
105
+
81
106
  def openapi_endpoint(route_kwargs: list[dict[str, Any]]) -> Callable[[], dict[str, Any]]:
82
107
  """
83
108
  A `/schema` endpoint with the OpenAPI document of `route_kwargs` (`add_api_route` form), built on
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "dynamicforms-fastapi-viewsets"
7
- version = "0.15.0"
7
+ version = "0.15.1"
8
8
  description = "Django REST Framework-style viewsets for FastAPI, with optional Celery-backed async execution and a Vue/TypeScript client counterpart."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "dynamicforms-fastapi-viewsets"
7
- version = "0.15.0"
7
+ version = "0.15.1"
8
8
  description = "Django REST Framework-style viewsets for FastAPI, with optional Celery-backed async execution and a Vue/TypeScript client counterpart."
9
9
  readme = "README.md"
10
10
  license = "MIT"