dynamicforms-fastapi-viewsets 0.15.0__tar.gz → 0.15.2__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 (75) hide show
  1. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/PKG-INFO +4 -3
  2. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/README.md +3 -2
  3. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/conf.py +13 -1
  4. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/cursor.py +13 -1
  5. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/build_schema.py +4 -3
  6. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/celery_viewset/transport.py +5 -0
  7. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/route_viewset.py +105 -8
  8. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/endpoint_docs.py +14 -1
  9. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/changes.py +8 -1
  10. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/viewset.py +35 -12
  11. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/filters/declaration.py +27 -5
  12. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/list_query.py +39 -2
  13. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/list_shapes.py +1 -0
  14. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/middleware/__init__.py +20 -0
  15. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/middleware/idempotency.py +16 -0
  16. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/mixins.py +105 -28
  17. dynamicforms_fastapi_viewsets-0.15.2/fastapi_viewsets/response_classes.py +191 -0
  18. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/routing.py +25 -0
  19. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/pyproject.toml +1 -1
  20. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/pyproject.toml.orig +1 -1
  21. dynamicforms_fastapi_viewsets-0.15.0/fastapi_viewsets/response_classes.py +0 -113
  22. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/LICENSE +0 -0
  23. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/__init__.py +0 -0
  24. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/action_configuration.py +0 -0
  25. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/backends/__init__.py +0 -0
  26. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/backends/django_orm.py +0 -0
  27. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/collection_viewset.py +0 -0
  28. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/context/__init__.py +0 -0
  29. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/context/auth/__init__.py +0 -0
  30. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/context/auth/django.py +0 -0
  31. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/context/auth/jwt.py +0 -0
  32. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/context/auth/static.py +0 -0
  33. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/__init__.py +0 -0
  34. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -0
  35. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/celery_viewset/client.py +0 -0
  36. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/celery_viewset/payload.py +0 -0
  37. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +0 -0
  38. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/celery_viewset/server.py +0 -0
  39. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/conftest.py +0 -0
  40. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/lifecycle_runner.py +0 -0
  41. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
  42. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/query_model.py +0 -0
  43. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/decorators/sub_viewsets.py +0 -0
  44. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/error_namespace.py +0 -0
  45. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/error_responses.py +0 -0
  46. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/__init__.py +0 -0
  47. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/authorizers.py +0 -0
  48. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/conftest.py +0 -0
  49. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/delivery.py +0 -0
  50. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/event.py +0 -0
  51. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/lifecycle.py +0 -0
  52. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/redis.py +0 -0
  53. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/scopes.py +0 -0
  54. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/sources.py +0 -0
  55. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/store.py +0 -0
  56. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/events/topics.py +0 -0
  57. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/exceptions.py +0 -0
  58. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/filters/__init__.py +0 -0
  59. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/filters/base.py +0 -0
  60. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/filters/operators.py +0 -0
  61. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/filters/registry.py +0 -0
  62. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
  63. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
  64. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/middleware/cookies.py +0 -0
  65. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
  66. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/mux_ws/__init__.py +0 -0
  67. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/mux_ws/asgi.py +0 -0
  68. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/mux_ws/protocol.py +0 -0
  69. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/mux_ws/registry.py +0 -0
  70. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/mux_ws/server.py +0 -0
  71. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/mux_ws/transports.py +0 -0
  72. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/py.typed +0 -0
  73. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/save_state/__init__.py +0 -0
  74. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/fastapi_viewsets/save_state/save_state.py +0 -0
  75. {dynamicforms_fastapi_viewsets-0.15.0 → dynamicforms_fastapi_viewsets-0.15.2}/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.2
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
@@ -130,8 +130,9 @@ list pipeline and pagination, the muxws transport, events, and the Vue client.
130
130
  ## Demo
131
131
 
132
132
  ```bash
133
- python demo.py # backend on :8000, frontend on :5173
134
- python demo.py --celery # ... with every viewset call routed through a Celery worker
133
+ uv sync && npm ci
134
+ uv run python demo.py # backend on :8000, frontend on :5173
135
+ uv run python demo.py --celery # ... with every viewset call routed through a Celery worker
135
136
  npm run test:e2e # drives the demo in a browser
136
137
  ```
137
138
 
@@ -91,8 +91,9 @@ list pipeline and pagination, the muxws transport, events, and the Vue client.
91
91
  ## Demo
92
92
 
93
93
  ```bash
94
- python demo.py # backend on :8000, frontend on :5173
95
- python demo.py --celery # ... with every viewset call routed through a Celery worker
94
+ uv sync && npm ci
95
+ uv run python demo.py # backend on :8000, frontend on :5173
96
+ uv run python demo.py --celery # ... with every viewset call routed through a Celery worker
96
97
  npm run test:e2e # drives the demo in a browser
97
98
  ```
98
99
 
@@ -21,9 +21,16 @@ class Settings:
21
21
 
22
22
  def __init__(self):
23
23
  self.viewsets_context_processors: list[ContextProcessor] = []
24
+ """Builds each request's context, in order."""
24
25
  self.viewsets_context_json_encoder: type[json.JSONEncoder] | None = None
26
+ """Encodes context values that cross to a Celery worker; None is the standard encoder."""
25
27
  self.viewsets_context_json_decoder: type[json.JSONDecoder] | None = None
28
+ """Decodes context values on the Celery worker; None is the standard decoder."""
26
29
  self.viewsets_command_middleware: list[CommandMiddleware] = []
30
+ """
31
+ Wraps every request-facing execution. The list is read on every call; the `modifies_response_shape`
32
+ and `openapi_parameters` of its entries when a viewset is decorated.
33
+ """
27
34
  self.viewsets_idempotency_ttl: float = 86400
28
35
  """Seconds an `Idempotency` outcome is kept; a later repeat runs the action again."""
29
36
  self.viewsets_idempotency_lock_ttl: float = 600
@@ -32,7 +39,12 @@ class Settings:
32
39
  it); once expired, a repeat runs the action again.
33
40
  """
34
41
  self.viewsets_auth_processors: list[AuthBackend] = []
42
+ """
43
+ Tried in order by `auth_context_processor` to resolve `context.user`; the first backend that claims
44
+ the request decides.
45
+ """
35
46
  self.default_action_configuration: dict[Any, Any] = {}
47
+ """The configuration every action starts from."""
36
48
  self.default_list_shape: str = "plain"
37
49
  """List shape of a viewset that sets none: "plain", "paginated" or "cursor"."""
38
50
  self.viewsets_register_muxws: bool = True
@@ -43,7 +55,7 @@ class Settings:
43
55
  self.viewsets_security_scheme: SecurityBase | None = None
44
56
  """
45
57
  Security scheme added to every route for the OpenAPI "Authorize" flow only. Read at decoration
46
- time, as is `viewsets_command_middleware`.
58
+ time.
47
59
  """
48
60
  self.viewsets_event_store: EventStore | None = None
49
61
  """
@@ -15,11 +15,12 @@ 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
22
22
  from .filters import Filter
23
+ from .list_query import _with_description
23
24
 
24
25
  if TYPE_CHECKING:
25
26
  from .mixins import SortState
@@ -157,14 +158,25 @@ class CursorPage(BaseModel, Generic[TItem]):
157
158
  are the same anchors read inclusively, present whenever the page is non-empty, for polling an edge.
158
159
  """
159
160
 
161
+ # A parametrised model (`CursorPage[Track]`) has no docstring of its own; a subclass keeps its own.
162
+ model_config = ConfigDict(use_attribute_docstrings=True, json_schema_extra=_with_description(__doc__))
163
+
160
164
  results: list[TItem]
165
+ """The records of the page."""
161
166
  limit: int
167
+ """Page size the page was read with."""
162
168
  has_more: bool = False
169
+ """Whether records follow the page."""
163
170
  has_previous: bool = False
171
+ """Whether records precede the page."""
164
172
  next: str | None = None
173
+ """Cursor of the records after the page; null when there are none."""
165
174
  previous: str | None = None
175
+ """Cursor of the records before the page; null when there are none."""
166
176
  first: str | None = None
177
+ """Cursor of the page's first record onwards (that record included); null for an empty page."""
167
178
  last: str | None = None
179
+ """Cursor of the page's last record backwards (that record included); null for an empty page."""
168
180
 
169
181
 
170
182
  @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
 
@@ -328,6 +328,7 @@ def encode_value(hint: Any, value: Any, what: str = "the value") -> tuple[Any, b
328
328
 
329
329
 
330
330
  def decode_value(adapter: TypeAdapter | None, data: Any, typed: bool) -> Any:
331
+ """`data` validated by `adapter` when `typed`, else rebuilt with `from_jsonable`; inverse of `encode_value`."""
331
332
  if typed and adapter is not None:
332
333
  return _decode_typed(adapter, data)
333
334
  return from_jsonable(data)
@@ -354,6 +355,8 @@ def is_model_hint(hint: Any) -> bool:
354
355
 
355
356
  @dataclass(frozen=True)
356
357
  class ParamCodec:
358
+ """How one parameter of an action is encoded for the worker."""
359
+
357
360
  name: str
358
361
  hint: Any
359
362
  kind: str
@@ -424,6 +427,7 @@ class ActionCodec:
424
427
  raise TypeError(f"{self.task_name}: {error}") from error
425
428
 
426
429
  def decode_argument(self, name: str, data: Any, typed: bool) -> Any:
430
+ """The value of parameter `name` from its encoded form; inverse of `encode_argument`."""
427
431
  codec = self.params.get(name)
428
432
  return decode_value(adapter_for(codec.hint) if codec is not None else None, data, typed)
429
433
 
@@ -435,6 +439,7 @@ class ActionCodec:
435
439
  raise TypeError(f"{self.task_name}: {error}") from error
436
440
 
437
441
  def decode_result(self, result: EncodedResult) -> Any:
442
+ """The result from its `EncodedResult`; inverse of `encode_result`."""
438
443
  return _decode_result(result.data, result.encoding, self.result_hint)
439
444
 
440
445
 
@@ -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,13 +21,22 @@ 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
- from fastapi_viewsets.middleware import any_modifies_response_shape, Middleware, unwrap_viewset_result_type
27
- from fastapi_viewsets.mixins import FilterParam
28
+ from fastapi_viewsets.middleware import (
29
+ any_modifies_response_shape,
30
+ Middleware,
31
+ middleware_openapi_parameters,
32
+ unwrap_viewset_result_type,
33
+ )
34
+ from fastapi_viewsets.mixins import (
35
+ FilterParam,
36
+ T as ITEM_TYPE_VAR, # noqa: N811
37
+ )
28
38
  from fastapi_viewsets.mux_ws import register_viewset, resolve_register_muxws, resolve_register_rest
29
- from fastapi_viewsets.response_classes import with_error_responses
39
+ from fastapi_viewsets.response_classes import CURSOR_ERROR_RESPONSES, with_error_responses
30
40
  from fastapi_viewsets.routing import MUXWS, openapi_endpoint, REST, transport_route_class
31
41
 
32
42
  from .build_schema import _pydantic_generic_args, build_schema, route_to_add_api_route_kwargs
@@ -116,7 +126,8 @@ def resolve_typevars(type_map, annotation):
116
126
  def _make_middleware_depends(cls: type, action_name: str):
117
127
  """
118
128
  A per-route FastAPI dependency running `Middleware.depends()` before the body is parsed.
119
- `@action_configuration` and settings are resolved per request.
129
+ `@action_configuration`, the context processors and the command middleware are resolved per request,
130
+ `viewsets_security_scheme` when the route is built.
120
131
  """
121
132
 
122
133
  async def _run(request: Request) -> None:
@@ -172,6 +183,48 @@ def _with_security_scheme(run):
172
183
  return dependency
173
184
 
174
185
 
186
+ PK_DESCRIPTION = "Primary key of the record."
187
+ """The `pk` path parameter's description when the model's primary key field has none."""
188
+
189
+
190
+ def _pk_description(model, pk_field_name: str | None) -> str:
191
+ """The description of the `pk` path parameter: that of the model's primary key field, else `PK_DESCRIPTION`."""
192
+ fields = getattr(model, "model_fields", None) or {}
193
+ field = fields.get(pk_field_name) if pk_field_name else None
194
+ return field.description if field is not None and field.description else PK_DESCRIPTION
195
+
196
+
197
+ def _published_name(name: str, annotation) -> str | None:
198
+ """The schema name of a path, query, header or cookie parameter; None for any other parameter."""
199
+ for metadata in getattr(annotation, "__metadata__", ()):
200
+ if isinstance(metadata, Param):
201
+ if metadata.alias:
202
+ return metadata.alias
203
+ if isinstance(metadata, HeaderParam) and metadata.convert_underscores:
204
+ return name.replace("_", "-")
205
+ return name
206
+ return None
207
+
208
+
209
+ def _with_description(annotation, description: str):
210
+ """`annotation` (`Annotated[X, Param]`) with the parameter's description replaced."""
211
+ metadata = []
212
+ for item in annotation.__metadata__:
213
+ if isinstance(item, Param):
214
+ item = copy.copy(item)
215
+ item.description = description
216
+ item._attributes_set = {**getattr(item, "_attributes_set", {}), "description": description}
217
+ metadata.append(item)
218
+ return Annotated[(annotation.__origin__, *metadata)]
219
+
220
+
221
+ def _is_absent_model_param(annotation) -> bool:
222
+ """Whether `annotation` is `Annotated[None, Query()]`: a filter parameter of a viewset declaring no filter."""
223
+ args = getattr(annotation, "__args__", None)
224
+ metadata = getattr(annotation, "__metadata__", None)
225
+ return bool(args and metadata) and args[0] in (None, type(None)) and any(isinstance(m, FieldInfo) for m in metadata)
226
+
227
+
175
228
  def route_viewset(
176
229
  router: APIRouter,
177
230
  base_path: str,
@@ -276,6 +329,14 @@ def _route(
276
329
  ),
277
330
  ]
278
331
  )
332
+ if name == "limit":
333
+ parameter = parameter.replace(
334
+ annotation=_with_description(
335
+ parameter.annotation,
336
+ f"Page size. Omit for {cls.default_page_size}; a larger value than {cls.max_page_size}"
337
+ f" returns {cls.max_page_size}.",
338
+ )
339
+ )
279
340
  if name == "sort" and cls.default_sort is not None:
280
341
  check_declared_sort(cls.default_sort, item_type)
281
342
  parameter = parameter.replace(default=cls.default_sort)
@@ -286,6 +347,26 @@ def _route(
286
347
  return sig.replace(parameters=params, return_annotation=response_model(allowed, item_type))
287
348
 
288
349
  shape_openapi_extra = None
350
+ item_model = type_map.get(ITEM_TYPE_VAR)
351
+
352
+ def _describe_parameters(params: list[inspect.Parameter], descriptions: dict[str, str]) -> None:
353
+ """Replaces the descriptions of `params` named in `descriptions` (`@endpoint_docs` `parameters`)."""
354
+ if not descriptions:
355
+ return
356
+ published = {}
357
+ for index, parameter in enumerate(params):
358
+ name = _published_name(parameter.name, parameter.annotation)
359
+ if name is not None:
360
+ published[name] = index
361
+ unknown = set(descriptions) - set(published)
362
+ if unknown:
363
+ raise ValueError(
364
+ f"@endpoint_docs on {cls.__name__} describes parameter(s) its endpoint does not have: "
365
+ f"{', '.join(sorted(unknown))}; it has {', '.join(sorted(published)) or 'none'}"
366
+ )
367
+ for name, description in descriptions.items():
368
+ index = published[name]
369
+ params[index] = params[index].replace(annotation=_with_description(params[index].annotation, description))
289
370
 
290
371
  def get_wrapper(original_endpoint, route_path, route_methods):
291
372
  nonlocal shape_openapi_extra
@@ -303,6 +384,10 @@ def _route(
303
384
  continue
304
385
 
305
386
  annotation = resolve_typevars(type_map, p.annotation)
387
+ if _is_absent_model_param(annotation):
388
+ continue
389
+ if name == "pk" and "{pk}" in route_path and _published_name(name, annotation) is None:
390
+ annotation = Annotated[annotation, Path(description=_pk_description(item_model, pk_field_name))]
306
391
  new_p = p.replace(annotation=annotation)
307
392
 
308
393
  # `context` (detected by name) never reaches FastAPI; lifecycle_runner builds and injects it.
@@ -335,6 +420,8 @@ def _route(
335
420
 
336
421
  new_params.append(new_p)
337
422
 
423
+ _describe_parameters(new_params, docs_for(cls, original_endpoint.__name__).get("parameters", {}))
424
+
338
425
  # Reserved: FastAPI injects the real Response, onto which lifecycle_runner applies headers/cookies.
339
426
  new_params.append(inspect.Parameter("response", inspect.Parameter.KEYWORD_ONLY, annotation=Response))
340
427
 
@@ -443,6 +530,7 @@ def _route(
443
530
  ),
444
531
  )
445
532
 
533
+ middleware_parameters = middleware_openapi_parameters(settings.viewsets_command_middleware)
446
534
  rest_routes = []
447
535
  muxws_routes = []
448
536
  documented = set()
@@ -457,11 +545,20 @@ def _route(
457
545
  *gates,
458
546
  Depends(_make_middleware_depends(cls, action_name)),
459
547
  ]
548
+ responses = route.responses
549
+ if getattr(route.endpoint, "__list_shape_aware__", False) and "cursor" not in cls.resolve_shapes()[1]:
550
+ responses = {
551
+ status: entry for status, entry in responses.items() if CURSOR_ERROR_RESPONSES.get(status) is not entry
552
+ }
460
553
  route_kwargs = route_to_add_api_route_kwargs(
461
- route, dependencies=dependencies, responses=with_error_responses(route.responses)
554
+ route, dependencies=dependencies, responses=with_error_responses(responses)
462
555
  )
463
556
  # Per-viewset wording from @endpoint_docs.
464
- route_kwargs.update(docs_for(cls, action_name))
557
+ route_kwargs.update(operation_docs_for(cls, action_name))
558
+ if middleware_parameters and not is_schema_route(route):
559
+ extra = dict(route_kwargs.get("openapi_extra") or {})
560
+ extra["parameters"] = [*extra.get("parameters", []), *middleware_parameters]
561
+ route_kwargs["openapi_extra"] = extra
465
562
  if parent is not None and not route_kwargs.get("summary"):
466
563
  # Prefix the mount, telling a sub-viewset's `List Items` apart from the parent's in its group.
467
564
  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,
@@ -25,6 +26,7 @@ from ..decorators.route_viewset import route_viewset
25
26
  from ..error_responses import ApiErrorBody, error_body
26
27
  from ..exceptions import EventCursorsExceededError, EventTopicInvalidError
27
28
  from ..mux_ws.transports import transports
29
+ from ..response_classes import POLL_ERROR_RESPONSES, SUBSCRIBE_ERROR_RESPONSES
28
30
  from ..routing import PEER_SCOPE_KEY
29
31
  from . import delivery
30
32
  from .authorizers import authorize_topic
@@ -44,16 +46,21 @@ def _leave_out_none(data: Any, names: tuple[str, ...]) -> Any:
44
46
  class TopicRequest(BaseModel):
45
47
  """The body of `subscribe` and `unsubscribe`."""
46
48
 
49
+ model_config = ConfigDict(use_attribute_docstrings=True)
50
+
47
51
  topic: str
52
+ """The topic: a viewset's routed base path or a declared source's `name[:key]`."""
48
53
 
49
54
 
50
55
  class SubscribeAnswer(BaseModel):
51
- """The answer of `subscribe`. `scope` is left out of the serialized answer when it is None."""
56
+ """The answer of `subscribe`. `scope` is absent for an unscoped topic."""
57
+
58
+ model_config = ConfigDict(use_attribute_docstrings=True)
52
59
 
53
60
  rev: int
54
61
  """The topic's current rev, 0 for a topic without events."""
55
62
  scope: str | None = None
56
- """The scope key of a scoped viewset topic; None for an unscoped topic."""
63
+ """The scope key of a scoped viewset topic; absent for an unscoped topic."""
57
64
 
58
65
  @model_serializer(mode="wrap")
59
66
  def _leave_out_no_scope(self, handler: SerializerFunctionWrapHandler, _info: SerializationInfo) -> Any:
@@ -66,40 +73,52 @@ class SubscribeAnswer(BaseModel):
66
73
 
67
74
 
68
75
  class PollRequest(BaseModel):
69
- """The body of `poll`: each topic mapped to the rev the caller holds, or None to start from now."""
76
+ """The body of `poll`: each topic mapped to the rev the caller holds, or null to start from now."""
77
+
78
+ model_config = ConfigDict(use_attribute_docstrings=True)
70
79
 
71
80
  cursors: dict[str, int | None]
81
+ """The rev the caller holds per topic; null to start from the topic's current rev."""
72
82
  scopes: dict[str, str] | None = None
73
83
  """The scope key the caller holds per topic; a cursor whose scope key differs from the server's answers
74
- `events: null`. None trusts every cursor."""
84
+ `events: null`. Absent or null trusts every cursor."""
75
85
 
76
86
 
77
87
  class PolledEvent(BaseModel):
78
88
  """One event of a poll answer."""
79
89
 
90
+ model_config = ConfigDict(use_attribute_docstrings=True)
91
+
80
92
  rev: int
93
+ """The event's rev in its topic."""
81
94
  prev: int
82
95
  """The rev of the event before it in the answer; for the first event, the cursor."""
83
96
  event: str
84
97
  """The event's `event_name`."""
85
98
  version: int = Field(ge=1, description="The event's `event_version`: the version of the payload's shape.")
86
99
  payload: Any = None
100
+ """The event's fields."""
87
101
 
88
102
 
89
103
  class TopicAnswer(BaseModel):
90
104
  """
91
- The answer of `poll` for one topic. `events` is None when the log cannot answer the cursor; the
92
- caller then re-reads its state. A refused or invalid topic has `rev` and `events` None and `error`
93
- and `status` set. `error`, `status` and `scope` are left out when None.
105
+ The answer of `poll` for one topic. `events` is null when the log cannot answer the cursor; the
106
+ caller then re-reads its state. A refused or invalid topic has `rev` and `events` null and `error`
107
+ and `status` set. `error`, `status` and `scope` are absent when they have no value.
94
108
  """
95
109
 
110
+ model_config = ConfigDict(use_attribute_docstrings=True)
111
+
96
112
  rev: int | None
113
+ """The topic's current rev; null for a refused or invalid topic."""
97
114
  events: list[PolledEvent] | None
115
+ """The events after the cursor, oldest first; null when the log cannot answer the cursor or the topic is refused."""
98
116
  error: ApiErrorBody | None = None
117
+ """Why the topic is refused or invalid."""
99
118
  status: int | None = None
100
119
  """The HTTP status of the refusal; set on every answer with `error`."""
101
120
  scope: str | None = None
102
- """The scope key of a scoped viewset topic; None for an unscoped topic."""
121
+ """The scope key of a scoped viewset topic; absent for an unscoped topic."""
103
122
 
104
123
  @model_serializer(mode="wrap")
105
124
  def _leave_out_absent(self, handler: SerializerFunctionWrapHandler, _info: SerializationInfo) -> Any:
@@ -114,6 +133,8 @@ class TopicAnswer(BaseModel):
114
133
  class PollAnswer(BaseModel):
115
134
  """The answer of `poll`."""
116
135
 
136
+ model_config = ConfigDict(use_attribute_docstrings=True)
137
+
117
138
  topics: dict[str, TopicAnswer]
118
139
  """One answer per cursor of the request, in the request's order."""
119
140
  poll_after_ms: int
@@ -148,7 +169,7 @@ class EventsViewSet:
148
169
  __router = APIRouter()
149
170
 
150
171
  @transports(rest=False, muxws=True)
151
- @__router.post("/subscribe")
172
+ @__router.post("/subscribe", responses=SUBSCRIBE_ERROR_RESPONSES)
152
173
  async def subscribe(self, request: Request, context: Context, body: TopicRequest) -> SubscribeAnswer:
153
174
  """
154
175
  Subscribes the socket to `topic` until it closes or unsubscribes, and answers the topic's current
@@ -156,7 +177,7 @@ class EventsViewSet:
156
177
 
157
178
  Errors: 422 `fastapi-viewsets.event_topic_invalid` (malformed topic); 403
158
179
  `fastapi-viewsets.event_topic_refused` or the authorizer's `HTTPException`; 403
159
- `fastapi-viewsets.event_scope_missing` (scope resolver answers None); 409
180
+ `fastapi-viewsets.event_scope_missing` (the topic has no scope key for the caller); 409
160
181
  `fastapi-viewsets.event_subscriptions_exceeded` (`settings.viewsets_event_max_subscriptions`
161
182
  reached). A 4xx refusal other than 429 also drops an existing subscription to the topic.
162
183
  """
@@ -185,10 +206,12 @@ class EventsViewSet:
185
206
  await delivery.unsubscribe(_peer_of(request, "unsubscribe"), body.topic)
186
207
 
187
208
  @transports(rest=True, muxws=True)
188
- @__router.post("/poll")
209
+ @__router.post(
210
+ "/poll", responses=POLL_ERROR_RESPONSES, response_description="The answer of each topic of the body."
211
+ )
189
212
  async def poll(self, request: Request, context: Context, body: PollRequest) -> PollAnswer:
190
213
  """
191
- Answers every cursor: a None cursor gets the current rev and no events, any other the events
214
+ Answers every cursor: a null cursor gets the current rev and no events, any other the events
192
215
  since it, or `events: null` when the log cannot answer it (or its `scopes` entry differs from
193
216
  the server's scope key). Events refused by a delivery filter are left out; `prev` chains the
194
217
  events returned and `rev` covers all of them.
@@ -22,6 +22,30 @@ _LIST_LOOKUPS = frozenset({"in", "overlaps"})
22
22
 
23
23
  _LIST_DESCRIPTION = "One or more values; the parameter repeats for each (`?{parameter}=a&{parameter}=b`)."
24
24
 
25
+ _OPERATOR_DESCRIPTIONS = {
26
+ "exact": "Records whose `{field}` equals the value.",
27
+ "iexact": "Records whose `{field}` equals the value, case-insensitive.",
28
+ "contains": "Records whose `{field}` contains the value.",
29
+ "icontains": "Records whose `{field}` contains the value, case-insensitive.",
30
+ "startswith": "Records whose `{field}` starts with the value.",
31
+ "gt": "Records whose `{field}` is greater than the value.",
32
+ "gte": "Records whose `{field}` is greater than or equal to the value.",
33
+ "lt": "Records whose `{field}` is less than the value.",
34
+ "lte": "Records whose `{field}` is less than or equal to the value.",
35
+ "in": "Records whose `{field}` is one of the values.",
36
+ "isnull": "`true`: records whose `{field}` is null; `false`: records whose `{field}` is set.",
37
+ "overlaps": "Records whose `{field}` shares at least one member with the values.",
38
+ }
39
+ """The description of a filter query parameter by operator; another operator gets a generic one."""
40
+
41
+
42
+ def _parameter_description(field_name: str, lookup: str, parameter: str) -> str:
43
+ text = _OPERATOR_DESCRIPTIONS.get(lookup, "Records `{field}` matches with the `" + lookup + "` operator.")
44
+ text = text.format(field=field_name)
45
+ if lookup in _LIST_LOOKUPS:
46
+ text += " " + _LIST_DESCRIPTION.format(parameter=parameter)
47
+ return text + " Omit to not filter."
48
+
25
49
 
26
50
  def _optional(annotation: Any) -> Any:
27
51
  if get_origin(annotation) is Union and type(None) in get_args(annotation):
@@ -69,12 +93,10 @@ def make_filter_model(model: type[BaseModel], declaration: FilterDeclaration) ->
69
93
  )
70
94
  parameter = field_name if lookup == "exact" else f"{field_name}__{lookup}"
71
95
  annotation = _parameter_annotation(field_annotation, lookup)
72
- default = (
73
- Field(None, description=_LIST_DESCRIPTION.format(parameter=parameter))
74
- if lookup in _LIST_LOOKUPS
75
- else None
96
+ fields[parameter] = (
97
+ annotation,
98
+ Field(None, description=_parameter_description(field_name, lookup, parameter)),
76
99
  )
77
- fields[parameter] = (annotation, default)
78
100
  spec[parameter] = (field_name, lookup, _item_annotation(field_annotation, lookup))
79
101
 
80
102
  filter_model = create_model(f"{model.__name__}Filter", **fields)