dynamicforms-fastapi-viewsets 0.14.2__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.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/PKG-INFO +1 -1
  2. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/backends/django_orm.py +2 -5
  3. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/collection_viewset.py +1 -8
  4. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/cursor.py +11 -1
  5. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/build_schema.py +4 -3
  6. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/route_viewset.py +85 -4
  7. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/endpoint_docs.py +14 -1
  8. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/events/changes.py +18 -4
  9. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/events/viewset.py +20 -0
  10. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/list_query.py +9 -1
  11. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/mixins.py +51 -20
  12. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/routing.py +25 -0
  13. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/pyproject.toml +1 -1
  14. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/pyproject.toml.orig +1 -1
  15. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/LICENSE +0 -0
  16. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/README.md +0 -0
  17. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/__init__.py +0 -0
  18. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/action_configuration.py +0 -0
  19. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/backends/__init__.py +0 -0
  20. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/conf.py +0 -0
  21. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/context/__init__.py +0 -0
  22. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/context/auth/__init__.py +0 -0
  23. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/context/auth/django.py +0 -0
  24. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/context/auth/jwt.py +0 -0
  25. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/context/auth/static.py +0 -0
  26. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/__init__.py +0 -0
  27. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -0
  28. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/celery_viewset/client.py +0 -0
  29. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/celery_viewset/payload.py +0 -0
  30. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +0 -0
  31. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/celery_viewset/server.py +0 -0
  32. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/celery_viewset/transport.py +0 -0
  33. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/conftest.py +0 -0
  34. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/lifecycle_runner.py +0 -0
  35. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
  36. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/query_model.py +0 -0
  37. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/decorators/sub_viewsets.py +0 -0
  38. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/error_namespace.py +0 -0
  39. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/error_responses.py +0 -0
  40. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/events/__init__.py +0 -0
  41. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/events/authorizers.py +0 -0
  42. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/events/conftest.py +0 -0
  43. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/events/delivery.py +0 -0
  44. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/events/event.py +0 -0
  45. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/events/lifecycle.py +0 -0
  46. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/events/redis.py +0 -0
  47. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/events/scopes.py +0 -0
  48. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/events/sources.py +0 -0
  49. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/events/store.py +0 -0
  50. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/events/topics.py +0 -0
  51. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/exceptions.py +0 -0
  52. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/filters/__init__.py +0 -0
  53. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/filters/base.py +0 -0
  54. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/filters/declaration.py +0 -0
  55. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/filters/operators.py +0 -0
  56. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/filters/registry.py +0 -0
  57. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/list_shapes.py +0 -0
  58. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/middleware/__init__.py +0 -0
  59. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
  60. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
  61. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/middleware/cookies.py +0 -0
  62. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/middleware/idempotency.py +0 -0
  63. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
  64. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/mux_ws/__init__.py +0 -0
  65. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/mux_ws/asgi.py +0 -0
  66. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/mux_ws/protocol.py +0 -0
  67. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/mux_ws/registry.py +0 -0
  68. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/mux_ws/server.py +0 -0
  69. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/mux_ws/transports.py +0 -0
  70. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/py.typed +0 -0
  71. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/response_classes.py +0 -0
  72. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/save_state/__init__.py +0 -0
  73. {dynamicforms_fastapi_viewsets-0.14.2 → dynamicforms_fastapi_viewsets-0.15.1}/fastapi_viewsets/save_state/save_state.py +0 -0
  74. {dynamicforms_fastapi_viewsets-0.14.2 → 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.14.2
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
@@ -286,13 +286,10 @@ class DjangoORMViewSet(ImplMixin[K, T], Generic[K, T]):
286
286
  instance.save()
287
287
  return [self.to_record(rows[pk]) for pk in records]
288
288
 
289
- async def perform_destroy(self, context: Context, pk: K) -> dict:
289
+ async def perform_destroy(self, context: Context, pk: K) -> dict[K, None]:
290
290
  instance = await self._get_or_404(context, pk)
291
291
  await instance.adelete()
292
- return {"id": pk}
293
-
294
- async def perform_bulk_destroy(self, context: Context, pk: list[K]) -> list[dict]:
295
- return [await self.perform_destroy(context, one) for one in pk]
292
+ return {pk: None}
296
293
 
297
294
  # -----------------------------------------------------------------------
298
295
 
@@ -66,17 +66,10 @@ class AsyncCollectionViewSet(
66
66
  results.append(await self.container_update(pk, data, partial))
67
67
  return results
68
68
 
69
- async def perform_destroy(self, _context: Context, pk: K) -> dict[K, Any]:
69
+ async def perform_destroy(self, _context: Context, pk: K) -> dict[K, None]:
70
70
  await self.container_delete(pk)
71
71
  return {pk: None}
72
72
 
73
- async def perform_bulk_destroy(self, _context: Context, pk: list[K]) -> list[dict[K, Any]]:
74
- results = []
75
- for p in pk:
76
- await self.container_delete(p)
77
- results.append({p: None})
78
- return results
79
-
80
73
 
81
74
  class CollectionViewSet(AsyncCollectionViewSet[K, T]):
82
75
  def __init__(self, container: ReadableCollection[T] | MutableCollection[K, T], pk_field: str = "id"):
@@ -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:
@@ -241,12 +248,19 @@ class ChangeEvents(Middleware):
241
248
  return name
242
249
 
243
250
  def changes_for(self, execution: ActionExecution, op: str, result: Any) -> list[Change]:
244
- """One change per pk of the `pk` argument for `deleted`, else per returned record (`ViewSetResult.body`)."""
251
+ """
252
+ For `deleted`, one change per pk of the `pk` argument; of a bulk, only the pks the result maps to None
253
+ (destroyed). Otherwise one per returned record. A `ViewSetResult` contributes its `body`.
254
+ """
245
255
  bulk = execution.action in _BULK_ACTIONS
256
+ body = result.body if isinstance(result, ViewSetResult) else result
246
257
  if op == "deleted":
247
258
  pk = _argument(execution, "pk")
248
- return [Change(pk=one) for one in (pk if bulk else [pk])]
249
- body = result.body if isinstance(result, ViewSetResult) else result
259
+ if not bulk:
260
+ return [Change(pk=pk)]
261
+ if isinstance(body, Mapping):
262
+ return [Change(pk=one) for one in dict.fromkeys(pk) if one in body and body[one] is None]
263
+ return [Change(pk=one) for one in pk]
250
264
  field = self.pk_field_for(execution)
251
265
  return [Change(pk=_read_pk(execution, record, field)) for record in (body if bulk else [body])]
252
266
 
@@ -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:
@@ -5,8 +5,10 @@ from enum import Enum
5
5
  from typing import Annotated, Any, final, Generic, get_args, get_origin, Union
6
6
 
7
7
  from fastapi import APIRouter, Header, Query
8
- from pydantic import BaseModel, ConfigDict, create_model
8
+ from fastapi.exceptions import RequestValidationError
9
+ from pydantic import BaseModel, ConfigDict, create_model, Field
9
10
  from pydantic.alias_generators import to_camel
11
+ from starlette.exceptions import HTTPException
10
12
  from typing_extensions import TypeVar
11
13
 
12
14
  from fastapi_viewsets.conf import settings
@@ -23,6 +25,7 @@ from fastapi_viewsets.cursor import (
23
25
  make_predicate,
24
26
  position_of,
25
27
  )
28
+ from fastapi_viewsets.error_responses import ApiErrorBody, error_body
26
29
  from fastapi_viewsets.exceptions import CursorRequestError, UnsupportedListShapeError
27
30
  from fastapi_viewsets.filters import filters_from, FilterSet
28
31
  from fastapi_viewsets.list_query import (
@@ -95,18 +98,29 @@ class ImplMixin(Generic[K, T], ABC):
95
98
  raise NotImplementedError("Method 'perform_bulk_update' must be implemented.")
96
99
 
97
100
  @abstractmethod
98
- async def perform_destroy(self, context: Context, pk: K) -> dict[K, Any]:
101
+ async def perform_destroy(self, context: Context, pk: K) -> dict[K, None]:
99
102
  """
100
- Subclasses should implement this method to perform the actual destruction.
103
+ Destroys record `pk` and returns `{pk: None}`; raises `NotFoundError` for a missing record.
101
104
  """
102
105
  raise NotImplementedError("Method 'perform_destroy' must be implemented.")
103
106
 
104
- @abstractmethod
105
- async def perform_bulk_destroy(self, context: Context, pk: list[K]) -> list[dict[K, Any]]:
107
+ async def perform_bulk_destroy(self, context: Context, pk: list[K]) -> dict[K, ApiErrorBody | None]:
106
108
  """
107
- Subclasses should implement this method to perform the actual bulk destruction.
109
+ Destroys each key with `perform_destroy`, independently of the others: None for a destroyed key, the
110
+ `ApiErrorBody` of an `HTTPException` or `RequestValidationError` its destruction raised. Any other
111
+ exception propagates. A repeated key is destroyed once.
108
112
  """
109
- raise NotImplementedError("Method 'perform_bulk_destroy' must be implemented.")
113
+ results: dict[K, ApiErrorBody | None] = {}
114
+ for one in pk:
115
+ if one in results:
116
+ continue
117
+ try:
118
+ await self.perform_destroy(context, one)
119
+ except (HTTPException, RequestValidationError) as error:
120
+ results[one] = ApiErrorBody.model_validate(error_body(error))
121
+ else:
122
+ results[one] = None
123
+ return results
110
124
 
111
125
 
112
126
  ###################################################################################################
@@ -186,13 +200,22 @@ class SortStateColumn(BaseModel):
186
200
 
187
201
  model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
188
202
 
189
- column_name: str
190
- 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`.")
191
205
 
192
206
 
193
207
  SortState = list[SortStateColumn]
194
208
 
195
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
+
196
219
  SORT_DESCRIPTION = (
197
220
  "Ordering: comma-separated `column:direction` entries in priority order (`name:asc,score:desc`)."
198
221
  " The direction is `asc` or `desc`, `asc` when omitted; an entry with any other direction is"
@@ -304,9 +327,9 @@ class ListMixin(Generic[T, TFilter], ABC):
304
327
  context: Context,
305
328
  fltr: Annotated[TFilter, Query()] = None,
306
329
  sort: Annotated[str | None, Query(description=SORT_DESCRIPTION)] = None,
307
- offset: Annotated[int, Query(ge=0)] = 0,
308
- limit: Annotated[int | None, Query(ge=1)] = None,
309
- 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,
310
333
  x_list_shape: Annotated[str | None, Header()] = None,
311
334
  ) -> Union[list[T], PaginatedList[T], CursorPage[T]]: # noqa: UP007 - see list_shapes.response_model
312
335
  """List the collection."""
@@ -649,14 +672,15 @@ class BulkUpdateMixin(UpdateMixin[K, T], BulkOnlyUpdateMixin[K, T]):
649
672
  ###################################################################################################
650
673
  class DestroyMixin(Generic[K, T], ABC):
651
674
  """
652
- Destroy a model instance. Return the destroyed key and any additional data about its destruction
675
+ Destroy a model instance.
653
676
  """
654
677
 
655
678
  __router = APIRouter()
656
679
 
657
680
  @final
658
681
  @__router.delete("/{pk}", responses=NOT_FOUND_RESPONSE)
659
- async def destroy(self: "ImplMixin[K, T] | DestroyMixin[K, T]", context: Context, pk: K) -> dict[K, Any]:
682
+ async def destroy(self: "ImplMixin[K, T] | DestroyMixin[K, T]", context: Context, pk: K) -> dict[K, None]:
683
+ """Destroy one record. Answers `{pk: null}`; 404 `fastapi-viewsets.not_found` for a missing record."""
660
684
  return await self.perform_destroy(context, pk)
661
685
 
662
686
 
@@ -671,7 +695,12 @@ class BulkOnlyDestroyMixin(Generic[K, T], ABC):
671
695
  @__router.delete("bulk")
672
696
  async def bulk_destroy(
673
697
  self: "ImplMixin[K, T] | BulkOnlyDestroyMixin[K, T]", context: Context, pk: list[K]
674
- ) -> list[dict[K, Any]]:
698
+ ) -> dict[K, ApiErrorBody | None]:
699
+ """
700
+ Destroy the records of the keys in the body, each independently of the others. Answers 200 with an
701
+ object keyed by pk: `null` for a destroyed record, the `ApiErrorBody` of the failure otherwise
702
+ (`fastapi-viewsets.not_found` for a missing record). A failed key does not stop the others.
703
+ """
675
704
  return await self.perform_bulk_destroy(context, pk)
676
705
 
677
706
 
@@ -685,16 +714,18 @@ class BulkDestroyMixin(DestroyMixin[K, T], BulkOnlyDestroyMixin[K, T]):
685
714
  # LOOKUP
686
715
  ###################################################################################################
687
716
  class LookupItem(BaseModel):
688
- group: Any = None
689
- pk: object
690
- title: str
691
- 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.")
692
723
 
693
724
 
694
725
  class LookupFilter(BaseModel):
695
726
  """Default filter model for LookupMixin. Provides case-insensitive title search via q."""
696
727
 
697
- q: str | None = None
728
+ q: str | None = Field(None, description="Text the choice's title contains, case-insensitive. Omit for all choices.")
698
729
 
699
730
 
700
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.14.2"
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.14.2"
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"