dynamicforms-fastapi-viewsets 0.11.3__tar.gz → 0.12.0__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 (72) hide show
  1. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/PKG-INFO +3 -4
  2. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/README.md +2 -3
  3. dynamicforms_fastapi_viewsets-0.12.0/fastapi_viewsets/__init__.py +5 -0
  4. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/collection_viewset.py +1 -2
  5. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/conf.py +13 -5
  6. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/cursor.py +10 -6
  7. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/celery_viewset/client.py +1 -0
  8. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +7 -4
  9. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/celery_viewset/server.py +12 -13
  10. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/route_viewset.py +4 -1
  11. dynamicforms_fastapi_viewsets-0.12.0/fastapi_viewsets/error_namespace.py +57 -0
  12. dynamicforms_fastapi_viewsets-0.12.0/fastapi_viewsets/error_responses.py +261 -0
  13. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/__init__.py +2 -0
  14. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/conftest.py +8 -0
  15. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/delivery.py +19 -2
  16. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/redis.py +117 -14
  17. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/viewset.py +25 -22
  18. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/exceptions.py +95 -67
  19. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/middleware/__init__.py +8 -10
  20. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/middleware/idempotency.py +3 -2
  21. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/mux_ws/asgi.py +3 -6
  22. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/mux_ws/protocol.py +21 -13
  23. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/mux_ws/server.py +16 -2
  24. dynamicforms_fastapi_viewsets-0.12.0/fastapi_viewsets/response_classes.py +113 -0
  25. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/routing.py +38 -3
  26. dynamicforms_fastapi_viewsets-0.11.3/fastapi_viewsets/__init__.py +0 -1
  27. dynamicforms_fastapi_viewsets-0.11.3/fastapi_viewsets/response_classes.py +0 -8
  28. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/.gitignore +0 -0
  29. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/LICENSE +0 -0
  30. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/action_configuration.py +0 -0
  31. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/backends/__init__.py +0 -0
  32. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/backends/django_orm.py +0 -0
  33. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/context/__init__.py +0 -0
  34. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/context/auth/__init__.py +0 -0
  35. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/context/auth/django.py +0 -0
  36. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/context/auth/jwt.py +0 -0
  37. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/context/auth/static.py +0 -0
  38. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/__init__.py +0 -0
  39. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/build_schema.py +0 -0
  40. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -0
  41. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/celery_viewset/payload.py +0 -0
  42. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/conftest.py +0 -0
  43. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/lifecycle_runner.py +0 -0
  44. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
  45. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/endpoint_docs.py +0 -0
  46. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/authorizers.py +0 -0
  47. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/changes.py +0 -0
  48. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/event.py +0 -0
  49. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/lifecycle.py +0 -0
  50. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/scopes.py +0 -0
  51. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/sources.py +0 -0
  52. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/store.py +0 -0
  53. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/events/topics.py +0 -0
  54. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/filters/__init__.py +0 -0
  55. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/filters/base.py +0 -0
  56. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/filters/declaration.py +0 -0
  57. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/filters/operators.py +0 -0
  58. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/filters/registry.py +0 -0
  59. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/list_query.py +0 -0
  60. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/list_shapes.py +0 -0
  61. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
  62. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
  63. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
  64. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/mixins.py +0 -0
  65. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/mux_ws/__init__.py +0 -0
  66. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/mux_ws/registry.py +0 -0
  67. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/mux_ws/transports.py +0 -0
  68. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/py.typed +0 -0
  69. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/save_state/__init__.py +0 -0
  70. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/save_state/save_state.py +0 -0
  71. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/fastapi_viewsets/save_state/serialize_state.py +0 -0
  72. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.12.0}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dynamicforms-fastapi-viewsets
3
- Version: 0.11.3
3
+ Version: 0.12.0
4
4
  Summary: Django REST Framework-style viewsets for FastAPI, with optional Celery-backed async execution and a Vue/TypeScript client counterpart.
5
5
  Project-URL: Homepage, https://github.com/dynamicforms/fastapi-viewsets
6
6
  Project-URL: Repository, https://github.com/dynamicforms/fastapi-viewsets
@@ -72,8 +72,7 @@ Celery-backed async execution and a matching Vue/TypeScript client counterpart.
72
72
  - **Bulk operations** — first-class support for bulk create, update, partial update and destroy.
73
73
  - **muxws transport** — reach the same viewsets over a single WebSocket instead of one HTTP request
74
74
  per call. A command goes through your application as an HTTP request does - its middleware,
75
- exception handlers, router dependencies and state - so both transports behave identically. In the
76
- demo, a burst of 100 requests takes 156 ms over REST and 37 ms over muxws.
75
+ exception handlers, router dependencies and state - so both transports behave identically.
77
76
  - **Events** — publish an event to a topic and every frontend following it learns of it, as a muxws
78
77
  push or on its next poll. `ChangeEvents` publishes every create, update and destroy of a viewset to
79
78
  its routed base path; custom events are declared with `source()`. Every event is recorded before it
@@ -139,7 +138,7 @@ app.include_router(router)
139
138
 
140
139
  See the [full documentation](https://docs.velis.si/dynamicforms/fastapi-viewsets/) for guides on
141
140
  the mixin system, `route_viewset`, `CollectionViewSet`, `DjangoORMViewSet`, `celery_viewset`, the
142
- list pipeline and pagination, the muxws transport, and the Vue client.
141
+ list pipeline and pagination, the muxws transport, events, and the Vue client.
143
142
 
144
143
  ## Demo
145
144
 
@@ -18,8 +18,7 @@ Celery-backed async execution and a matching Vue/TypeScript client counterpart.
18
18
  - **Bulk operations** — first-class support for bulk create, update, partial update and destroy.
19
19
  - **muxws transport** — reach the same viewsets over a single WebSocket instead of one HTTP request
20
20
  per call. A command goes through your application as an HTTP request does - its middleware,
21
- exception handlers, router dependencies and state - so both transports behave identically. In the
22
- demo, a burst of 100 requests takes 156 ms over REST and 37 ms over muxws.
21
+ exception handlers, router dependencies and state - so both transports behave identically.
23
22
  - **Events** — publish an event to a topic and every frontend following it learns of it, as a muxws
24
23
  push or on its next poll. `ChangeEvents` publishes every create, update and destroy of a viewset to
25
24
  its routed base path; custom events are declared with `source()`. Every event is recorded before it
@@ -85,7 +84,7 @@ app.include_router(router)
85
84
 
86
85
  See the [full documentation](https://docs.velis.si/dynamicforms/fastapi-viewsets/) for guides on
87
86
  the mixin system, `route_viewset`, `CollectionViewSet`, `DjangoORMViewSet`, `celery_viewset`, the
88
- list pipeline and pagination, the muxws transport, and the Vue client.
87
+ list pipeline and pagination, the muxws transport, events, and the Vue client.
89
88
 
90
89
  ## Demo
91
90
 
@@ -0,0 +1,5 @@
1
+ __version__ = "0.12.0"
2
+
3
+ from .error_namespace import ERROR_NAMESPACE, ERROR_NAMESPACE_PYDANTIC
4
+
5
+ __all__ = ["ERROR_NAMESPACE", "ERROR_NAMESPACE_PYDANTIC", "__version__"]
@@ -211,8 +211,7 @@ class CollectionViewSet(AsyncCollectionViewSet[K, T]):
211
211
 
212
212
  for field_name, field_info in data.__class__.model_fields.items():
213
213
  if field_info.json_schema_extra and field_info.json_schema_extra.get("autoinc_int"):
214
- # If the field is already set and is not 0 or None, we leave it (unless this is the desired behavior)
215
- # However, we usually want autoinc only if the value is not yet set
214
+ # a truthy value is kept; 0 or None gets the next value after the container's maximum
216
215
  current_val = getattr(data, field_name, None)
217
216
  if current_val:
218
217
  continue
@@ -9,7 +9,7 @@ if TYPE_CHECKING:
9
9
 
10
10
  from .context.auth import AuthBackend
11
11
  from .events.authorizers import EventAuthorizer
12
- from .events.delivery import DeliveryFilter
12
+ from .events.delivery import DeliveryFilter, EventListener
13
13
  from .events.store import EventStore
14
14
  from .middleware import CommandMiddleware
15
15
 
@@ -40,8 +40,9 @@ class Settings:
40
40
  """
41
41
  self.viewsets_idempotency_lock_ttl: float = 600
42
42
  """
43
- Seconds a running record of `Idempotency` is kept after it was written, unless the action
44
- finishes first. Once it expires, a repeat runs the action again.
43
+ Seconds a running record of `Idempotency` is kept after it was last extended; the process
44
+ running the call extends it every third of this time. Once it expires, a repeat runs the
45
+ action again.
45
46
  """
46
47
  self.viewsets_auth_processors: list[AuthBackend] = []
47
48
  self.default_action_configuration: dict[Any, Any] = {}
@@ -82,8 +83,15 @@ class Settings:
82
83
  """
83
84
  self.viewsets_event_delivery_filters: list[DeliveryFilter] = []
84
85
  """
85
- The functions that decide, per subscription and per event, whether a push is sent. A filter
86
- that answers False skips that socket; a filter that raises counts as True.
86
+ The functions that decide, per subscription and per event, whether a push is sent, and per
87
+ poll and per event whether the poll answers the event. A filter that answers False skips that
88
+ socket or leaves the event out of the poll answer; a filter that raises counts as True.
89
+ """
90
+ self.viewsets_event_listeners: list[EventListener] = []
91
+ """
92
+ The functions called with every event in every process whose store is started, before its
93
+ pushes, whether or not the process holds a subscription of its topic. A listener that raises is
94
+ logged and the next one runs.
87
95
  """
88
96
  self.viewsets_event_log_size: int = 32
89
97
  """
@@ -34,6 +34,7 @@ from typing import Any, ClassVar, Generic, TYPE_CHECKING
34
34
  from pydantic import BaseModel, TypeAdapter
35
35
  from typing_extensions import TypeVar
36
36
 
37
+ from .error_namespace import _namespaced
37
38
  from .filters import Filter
38
39
 
39
40
  if TYPE_CHECKING:
@@ -43,7 +44,10 @@ TItem = TypeVar("TItem")
43
44
 
44
45
 
45
46
  class CursorError(ValueError):
46
- """The cursor is unreadable, or does not belong to the query it was sent with."""
47
+ """
48
+ The cursor is unreadable, or does not belong to the query it was sent with. `code` is one of the
49
+ cursor codes, `fastapi-viewsets.cursor_<reason>`.
50
+ """
47
51
 
48
52
  def __init__(self, message: str, code: str, params: dict[str, Any] | None = None):
49
53
  super().__init__(message)
@@ -135,16 +139,16 @@ def decode_cursor(raw: str, keys: CursorKeys, expected_query: str, annotations:
135
139
  payload = json.loads(base64.urlsafe_b64decode(padded))
136
140
  except (ValueError, binascii.Error) as error:
137
141
  raise CursorError(
138
- f"cursor is not readable: {error}", code="cursor_unreadable", params={"error": str(error)}
142
+ f"cursor is not readable: {error}", code=_namespaced("cursor_unreadable"), params={"error": str(error)}
139
143
  ) from None
140
144
 
141
145
  if not isinstance(payload, dict) or not isinstance(payload.get("p"), dict):
142
- raise CursorError("cursor is not readable: no position in it", code="cursor_missing_position")
146
+ raise CursorError("cursor is not readable: no position in it", code=_namespaced("cursor_missing_position"))
143
147
 
144
148
  if payload.get("q") != expected_query:
145
149
  raise CursorError(
146
150
  "this cursor was issued for a different ordering or filter - start from the first page",
147
- code="cursor_stale",
151
+ code=_namespaced("cursor_stale"),
148
152
  )
149
153
 
150
154
  position = payload["p"]
@@ -152,7 +156,7 @@ def decode_cursor(raw: str, keys: CursorKeys, expected_query: str, annotations:
152
156
  if missing:
153
157
  raise CursorError(
154
158
  f"cursor has no value for ordering key(s): {', '.join(missing)}",
155
- code="cursor_missing_keys",
159
+ code=_namespaced("cursor_missing_keys"),
156
160
  params={"missing": missing},
157
161
  )
158
162
 
@@ -168,7 +172,7 @@ def decode_cursor(raw: str, keys: CursorKeys, expected_query: str, annotations:
168
172
  except Exception as error:
169
173
  raise CursorError(
170
174
  f"cursor value for {name!r} does not fit the field: {error}",
171
- code="cursor_value_mismatch",
175
+ code=_namespaced("cursor_value_mismatch"),
172
176
  params={"name": name, "error": str(error)},
173
177
  ) from None
174
178
 
@@ -27,6 +27,7 @@ if TYPE_CHECKING:
27
27
  T = TypeVar("T")
28
28
  logger = logging.getLogger(__name__)
29
29
 
30
+ # Seconds a `celery_viewset_client` call waits for its result when `result_timeout` is not given.
30
31
  DEFAULT_RESULT_TIMEOUT = 3600.0
31
32
 
32
33
  # queue_keys registered by celery_viewset_client so far - populated at decoration (import) time,
@@ -136,8 +136,9 @@ def _payload_error(data: dict) -> Exception:
136
136
  def error_payload(correlation_id: str, task: str | None, error: BaseException) -> dict:
137
137
  """
138
138
  The payload that fails one call with `error`: an `HTTPException` (a `DfViewSetError` with its
139
- class), any other error as `ActionFailedError`. `http_status_code` and `http_detail` repeat the
140
- status and detail for a reader that does not decode `error`.
139
+ class), a `RequestValidationError` as a 422 with its errors as `detail` and `class`
140
+ `"RequestValidationError"`, any other error as `ActionFailedError`. `http_status_code` and
141
+ `http_detail` repeat the status and detail for a reader that does not decode `error`.
141
142
  """
142
143
  if not isinstance(error, (HTTPException, RequestValidationError)):
143
144
  error = ActionFailedError(task)
@@ -168,8 +169,10 @@ def push_result(
168
169
  """
169
170
  Push a result or an error for one call. queue_key is the `_result_queue_key` the client sent with
170
171
  the task. An `error` that is an `HTTPException` reaches the caller as it is (a `DfViewSetError`
171
- of a class `fastapi_viewsets.exceptions` defines keeps its class); any other error reaches it
172
- as `ActionFailedError`. `task` names the call's task in that error.
172
+ of a class `fastapi_viewsets.exceptions` defines keeps its class), and so does a
173
+ `RequestValidationError`; any other error reaches it as `ActionFailedError`. `task` names the
174
+ call's task in that error. The FastAPI process answers each as `fastapi_viewsets.error_responses`
175
+ answers it.
173
176
  """
174
177
  if error is not None:
175
178
  data = error_payload(correlation_id, task, error)
@@ -20,7 +20,6 @@ from functools import wraps
20
20
  from typing import Any, get_args, get_origin, get_type_hints, TYPE_CHECKING, TypeVar, Union
21
21
 
22
22
  from fastapi import HTTPException
23
- from fastapi.encoders import jsonable_encoder
24
23
  from fastapi.exceptions import RequestValidationError
25
24
  from pydantic import BaseModel, TypeAdapter, ValidationError
26
25
 
@@ -63,11 +62,9 @@ def _unwrap_optional(hint):
63
62
  return hint
64
63
 
65
64
 
66
- # Alias, not a re-implementation: the actual conversion (Pydantic models, dicts, lists/tuples,
67
- # SerializableObject values - e.g. a ViewSetResult a worker-side action returned directly, see
68
- # fastapi_viewsets.middleware) lives in fastapi_viewsets.context.to_jsonable, shared with
69
- # ViewSetResult's own (de)serialization. Kept under this name since call sites/tests here already
70
- # import it as `_to_jsonable`.
65
+ # `fastapi_viewsets.context.to_jsonable`: converts Pydantic models, dicts, lists/tuples and
66
+ # SerializableObject values (e.g. a ViewSetResult a worker-side action returned directly); also used
67
+ # by ViewSetResult's own (de)serialization.
71
68
  _to_jsonable = to_jsonable
72
69
 
73
70
 
@@ -668,14 +665,13 @@ def celery_viewset_server(
668
665
  _report("interrupted", task_name, call, lambda t: ActionInterruptedError(t, "time_limit"))
669
666
  raise pool_timeout
670
667
  error = run.translate(caught)
671
- if isinstance(error, RequestValidationError):
672
- # a request the worker found invalid (a filter value that will not convert) answers as
673
- # FastAPI answers one: 422 with the errors as `detail`
674
- error = HTTPException(status_code=422, detail=jsonable_encoder(error.errors()))
675
668
  process_exit = isinstance(error, (KeyboardInterrupt, SystemExit))
676
669
  if process_exit:
677
670
  pushed = ActionInterruptedError(task_name, "terminated")
678
- elif isinstance(error, HTTPException):
671
+ elif isinstance(error, (HTTPException, RequestValidationError)):
672
+ # a request the worker found invalid (a filter value that will not convert) is pushed as
673
+ # the `RequestValidationError` it is, and answered as a request the FastAPI process found
674
+ # invalid
679
675
  pushed = error
680
676
  else:
681
677
  pushed = ActionFailedError(task_name)
@@ -688,12 +684,15 @@ def celery_viewset_server(
688
684
  exc_info=caught,
689
685
  )
690
686
  else:
687
+ status, detail = (
688
+ (pushed.status_code, pushed.detail) if isinstance(pushed, HTTPException) else (422, pushed.errors())
689
+ )
691
690
  logger.info(
692
691
  "Celery task returned standard error: %s (correlation_id=%s): %d: %s",
693
692
  task_name,
694
693
  correlation_id,
695
- pushed.status_code,
696
- pushed.detail,
694
+ status,
695
+ detail,
697
696
  )
698
697
  if process_exit and not _supervised():
699
698
  _report("interrupted", task_name, call, lambda _t: pushed)
@@ -24,6 +24,7 @@ from fastapi_viewsets.endpoint_docs import (
24
24
  from fastapi_viewsets.middleware import any_modifies_response_shape, Middleware, unwrap_viewset_result_type
25
25
  from fastapi_viewsets.mixins import FilterParam
26
26
  from fastapi_viewsets.mux_ws import register_viewset, resolve_register_muxws, resolve_register_rest
27
+ from fastapi_viewsets.response_classes import with_error_responses
27
28
  from fastapi_viewsets.routing import MUXWS, openapi_endpoint, REST, transport_route_class
28
29
 
29
30
  from .build_schema import _pydantic_generic_args, build_schema, route_to_add_api_route_kwargs
@@ -418,7 +419,9 @@ def route_viewset(
418
419
  documented.add(action_name)
419
420
  paths.setdefault(action_name, route.path[len(base) :] if route.path.startswith(base) else route.path)
420
421
  dependencies = list(route.dependencies or []) + [Depends(_make_middleware_depends(cls, action_name))]
421
- route_kwargs = route_to_add_api_route_kwargs(route, dependencies=dependencies)
422
+ route_kwargs = route_to_add_api_route_kwargs(
423
+ route, dependencies=dependencies, responses=with_error_responses(route.responses)
424
+ )
422
425
  # Per-viewset wording for an endpoint the mixin provided - the mixin's own docstring is
423
426
  # the same sentence on every viewset in the application. See endpoint_docs.py.
424
427
  route_kwargs.update(docs_for(cls, action_name))
@@ -0,0 +1,57 @@
1
+ """
2
+ The namespaces of the error codes this package's errors carry.
3
+
4
+ An error code is `<namespace>.<code>`: `code` is snake_case without `.`, `namespace` one or more
5
+ segments of `[a-z0-9]+(-[a-z0-9]+)*` joined by `.`, and a full code splits at its last `.`. The
6
+ codes this package declares are in `ERROR_NAMESPACE`. `ERROR_NAMESPACE_PYDANTIC` is the namespace
7
+ of pydantic's error types, which the field errors of a `fastapi-viewsets.validation_failed` body carry
8
+ as `pydantic.<type>`, and of `pydantic.custom_error`, the field error of a type pydantic does not
9
+ declare. A code the application gives an error is used as given.
10
+ """
11
+
12
+ ERROR_NAMESPACE = "fastapi-viewsets"
13
+ """The namespace of every error code this package declares, e.g. `fastapi-viewsets.not_found`."""
14
+
15
+ ERROR_NAMESPACE_PYDANTIC = "pydantic"
16
+ """The namespace of pydantic's error types, e.g. `pydantic.string_too_short`."""
17
+
18
+ _PREFIX = f"{ERROR_NAMESPACE}."
19
+
20
+ _DECLARED_CODES = frozenset(
21
+ {
22
+ "not_found",
23
+ "read_only",
24
+ "session_expired",
25
+ "not_authorized",
26
+ "rate_limited",
27
+ "result_timeout",
28
+ "result_reader_stopped",
29
+ "action_dispatch_failed",
30
+ "action_interrupted",
31
+ "action_failed",
32
+ "idempotency_key_invalid",
33
+ "idempotency_key_reused",
34
+ "unsupported_list_shape",
35
+ "event_topic_invalid",
36
+ "event_topic_refused",
37
+ "event_scope_missing",
38
+ "event_subscriptions_exceeded",
39
+ "event_cursors_exceeded",
40
+ "cursor_unreadable",
41
+ "cursor_missing_position",
42
+ "cursor_stale",
43
+ "cursor_missing_keys",
44
+ "cursor_value_mismatch",
45
+ "validation_failed",
46
+ "http_error",
47
+ "unhandled_error",
48
+ "command_invalid",
49
+ "pk_assigned_by_server",
50
+ }
51
+ )
52
+ """Every code this package declares, without the namespace; each is built with `_namespaced()`."""
53
+
54
+
55
+ def _namespaced(code: str) -> str:
56
+ """The full code of `code`, an error code this package declares."""
57
+ return f"{_PREFIX}{code}"
@@ -0,0 +1,261 @@
1
+ """
2
+ The body of every error response: `ApiErrorBody`.
3
+
4
+ {
5
+ "detail": "Item with pk 42 not found",
6
+ "detail_code": "fastapi-viewsets.not_found",
7
+ "detail_params": {"pk": 42},
8
+ "field_errors": {}
9
+ }
10
+
11
+ `error_body()` builds it from an exception:
12
+
13
+ - a `DfViewSetError`: its `detail`, `code` and `params`;
14
+ - a `RequestValidationError`: `fastapi-viewsets.validation_failed`, with its errors in
15
+ `field_errors`;
16
+ - any other `HTTPException` (FastAPI's, Starlette's, one an application raises, Starlette's own
17
+ 404 and 405 of the router): `fastapi-viewsets.http_error` with `params.status`;
18
+ - any other exception: `fastapi-viewsets.unhandled_error` (`UnhandledError`), status 500, with no
19
+ part of the exception in the body.
20
+
21
+ The routes of a viewset answer every error with this body without any registration (see
22
+ `routing.TransportRoute`). `install_error_handlers(app)` answers the errors of the application's
23
+ other routes, and of a request no route matches, with it too.
24
+ """
25
+
26
+ from http import HTTPStatus
27
+ from typing import Any, get_args
28
+
29
+ from fastapi import FastAPI
30
+ from fastapi.encoders import jsonable_encoder
31
+ from fastapi.exception_handlers import http_exception_handler, request_validation_exception_handler
32
+ from fastapi.exceptions import RequestValidationError
33
+ from fastapi.utils import is_body_allowed_for_status_code
34
+ from pydantic import BaseModel, Field
35
+ from pydantic_core.core_schema import ErrorType
36
+ from starlette.exceptions import HTTPException
37
+ from starlette.requests import Request
38
+ from starlette.responses import JSONResponse, Response
39
+
40
+ from .error_namespace import _namespaced, ERROR_NAMESPACE_PYDANTIC
41
+ from .exceptions import DfViewSetError, UnhandledError
42
+
43
+
44
+ class ApiFieldError(BaseModel):
45
+ """One failure of one value of a request that failed validation."""
46
+
47
+ code: str = Field(
48
+ description=(
49
+ "pydantic's error type in the namespace `pydantic`, e.g. `pydantic.string_too_short`; "
50
+ "`pydantic.custom_error` for a type pydantic does not declare (a `PydanticCustomError` of the "
51
+ "application); `fastapi-viewsets.pk_assigned_by_server` for a primary key in a create's body."
52
+ ),
53
+ examples=["pydantic.string_too_short"],
54
+ )
55
+ params: dict[str, Any] = Field(
56
+ description=(
57
+ "The values pydantic built `detail` from (pydantic's `ctx`); `{}` without any. For "
58
+ "`pydantic.custom_error`: `{type, ctx}`, the custom error's type and its `ctx`."
59
+ ),
60
+ examples=[{"min_length": 3}, {"type": "app.slug_taken", "ctx": {"slug": "blue"}}],
61
+ )
62
+ detail: str = Field(
63
+ description="pydantic's English message (`msg`).",
64
+ examples=["String should have at least 3 characters"],
65
+ )
66
+
67
+
68
+ class ApiErrorBody(BaseModel):
69
+ """The body of every error response."""
70
+
71
+ detail: str = Field(
72
+ description="English text of the error, interpolated from `detail_params`. Not translated.",
73
+ examples=["Item with pk 42 not found"],
74
+ )
75
+ detail_code: str = Field(
76
+ description=(
77
+ "The error's full code, `<namespace>.<code>`: `fastapi-viewsets.<code>` for the errors the "
78
+ "package raises, the application's own code as given for its own `DfViewSetError`."
79
+ ),
80
+ examples=["fastapi-viewsets.not_found"],
81
+ )
82
+ detail_params: dict[str, Any] = Field(
83
+ description="The values `detail` was interpolated with, by name; `{}` without any.",
84
+ examples=[{"pk": 42}],
85
+ )
86
+ field_errors: dict[str, list[ApiFieldError]] = Field(
87
+ description=(
88
+ "The failures of a request that failed validation (`fastapi-viewsets.validation_failed`), keyed "
89
+ "by field name: pydantic's `loc` without its first element (`body`, `query`, `path`, `header`, "
90
+ "`cookie`), joined with `.`; a failure of the body as a whole is keyed `''`. `{}` for every "
91
+ "other error."
92
+ ),
93
+ examples=[{}],
94
+ )
95
+
96
+
97
+ VALIDATION_FAILED_DETAIL = "The request failed validation"
98
+ """The `detail` of `fastapi-viewsets.validation_failed`."""
99
+
100
+
101
+ def _jsonable_ctx(ctx: dict[str, Any] | None) -> dict[str, Any]:
102
+ """pydantic's `ctx` as JSON values: an exception (the `error` of `value_error`) as its message."""
103
+ return jsonable_encoder(
104
+ {name: str(value) if isinstance(value, BaseException) else value for name, value in (ctx or {}).items()}
105
+ )
106
+
107
+
108
+ PYDANTIC_ERROR_TYPES = frozenset(get_args(ErrorType))
109
+ """The error types pydantic declares: the values of `pydantic_core.core_schema.ErrorType`."""
110
+
111
+ CUSTOM_ERROR_CODE = f"{ERROR_NAMESPACE_PYDANTIC}.custom_error"
112
+ """The code of a field error whose type pydantic does not declare."""
113
+
114
+ _PACKAGE_FIELD_ERROR_CODES = {"pk_assigned_by_server": _namespaced("pk_assigned_by_server")}
115
+ """The codes of the `PydanticCustomError` types this package raises, by type."""
116
+
117
+
118
+ def _field_error(error: Any) -> dict[str, Any]:
119
+ error_type = error.get("type")
120
+ ctx = _jsonable_ctx(error.get("ctx"))
121
+ if error_type in PYDANTIC_ERROR_TYPES:
122
+ code, params = f"{ERROR_NAMESPACE_PYDANTIC}.{error_type}", ctx
123
+ elif error_type in _PACKAGE_FIELD_ERROR_CODES:
124
+ code, params = _PACKAGE_FIELD_ERROR_CODES[error_type], ctx
125
+ else:
126
+ code, params = CUSTOM_ERROR_CODE, {"type": error_type, "ctx": ctx}
127
+ return {"code": code, "params": params, "detail": str(error.get("msg", ""))}
128
+
129
+
130
+ def field_errors(errors: Any) -> dict[str, list[dict[str, Any]]]:
131
+ """
132
+ pydantic's errors (`RequestValidationError.errors()`) as `ApiErrorBody.field_errors`: keyed by
133
+ `loc` without its first element, joined with `.` (`("body", "address", "city")` is
134
+ `address.city`, `("body",)` is `''`), each `{code, params, detail}` with `msg` as `detail`.
135
+ `input` is left out.
136
+
137
+ - A `type` pydantic declares (`PYDANTIC_ERROR_TYPES`) is `code` in the namespace
138
+ `ERROR_NAMESPACE_PYDANTIC` (`pydantic.string_too_short`), with `ctx` as `params`.
139
+ - `pk_assigned_by_server`, the type this package raises, is `fastapi-viewsets.pk_assigned_by_server`,
140
+ with `ctx` as `params`.
141
+ - Any other `type` (a `PydanticCustomError` of the application) is `pydantic.custom_error`, with
142
+ `params` `{"type": <type>, "ctx": <ctx>}`: `ctx` is nested, so none of its keys collides with
143
+ `type`.
144
+ """
145
+ grouped: dict[str, list[dict[str, Any]]] = {}
146
+ for error in errors or ():
147
+ loc = error.get("loc") or ()
148
+ name = ".".join(str(part) for part in list(loc)[1:])
149
+ grouped.setdefault(name, []).append(_field_error(error))
150
+ return grouped
151
+
152
+
153
+ def _body(detail: str, code: str, params: dict[str, Any], fields: dict | None = None) -> dict[str, Any]:
154
+ return {"detail": detail, "detail_code": code, "detail_params": params, "field_errors": fields or {}}
155
+
156
+
157
+ def error_status(error: BaseException) -> int:
158
+ """The status `error` is answered with: an `HTTPException`'s own, 422 for a
159
+ `RequestValidationError`, 500 for any other exception."""
160
+ if isinstance(error, HTTPException):
161
+ return error.status_code
162
+ if isinstance(error, RequestValidationError):
163
+ return 422
164
+ return 500
165
+
166
+
167
+ def error_body(error: BaseException) -> dict[str, Any]:
168
+ """
169
+ `error` as an `ApiErrorBody`, in JSON form.
170
+
171
+ - `DfViewSetError`: `detail`, `code` as `detail_code`, `params` as `detail_params`.
172
+ - `RequestValidationError`: `fastapi-viewsets.validation_failed`, `detail_params` `{}`, its errors
173
+ in `field_errors` (see `field_errors()`).
174
+ - another `HTTPException`: `fastapi-viewsets.http_error`, `detail_params` `{"status": <status>}`.
175
+ A string `detail` is the body's `detail`; any other `detail` is `detail_params.detail` and the
176
+ body's `detail` is the status phrase (`Bad Request`).
177
+ - any other exception: the body of `UnhandledError`.
178
+ """
179
+ if isinstance(error, RequestValidationError):
180
+ return _body(VALIDATION_FAILED_DETAIL, _namespaced("validation_failed"), {}, field_errors(error.errors()))
181
+ if not isinstance(error, HTTPException):
182
+ error = UnhandledError()
183
+ if isinstance(error, DfViewSetError):
184
+ return _body(jsonable_encoder(error.detail), error.code, jsonable_encoder(error.params))
185
+ params: dict[str, Any] = {"status": error.status_code}
186
+ detail = error.detail
187
+ if not isinstance(detail, str):
188
+ params["detail"] = jsonable_encoder(detail)
189
+ try:
190
+ detail = HTTPStatus(error.status_code).phrase
191
+ except ValueError:
192
+ detail = f"HTTP {error.status_code}"
193
+ return _body(detail, _namespaced("http_error"), params)
194
+
195
+
196
+ def error_response(error: BaseException) -> Response:
197
+ """
198
+ The response that answers `error`: `error_status(error)`, `error_body(error)` as JSON, and an
199
+ `HTTPException`'s `headers`. A status that allows no body (204, 304) is answered without one.
200
+ """
201
+ status = error_status(error)
202
+ headers = getattr(error, "headers", None) if isinstance(error, HTTPException) else None
203
+ if not is_body_allowed_for_status_code(status):
204
+ return Response(status_code=status, headers=headers)
205
+ return JSONResponse(error_body(error), status_code=status, headers=headers)
206
+
207
+
208
+ async def _handle_error(_request: Request, error: Exception) -> Response:
209
+ """The handler `install_error_handlers` registers, and the one a viewset route answers
210
+ `HTTPException` and `RequestValidationError` with. For `Exception` Starlette's
211
+ `ServerErrorMiddleware` re-raises the exception after the response, and the server logs it."""
212
+ return error_response(error)
213
+
214
+
215
+ def install_error_handlers(app: FastAPI) -> None:
216
+ """
217
+ Registers the handlers that answer the errors of every route of `app` with an `ApiErrorBody`
218
+ (see `error_body()`), for `DfViewSetError`, `HTTPException` (Starlette's, which FastAPI's
219
+ subclasses), `RequestValidationError` and `Exception`. Starlette's own 404 of a path no route
220
+ matches and 405 of a method a route does not serve are `fastapi-viewsets.http_error` with
221
+ `params.status` 404 and 405. An exception without a handler of its own is answered with 500
222
+ `fastapi-viewsets.unhandled_error` and re-raised by Starlette, so the server logs it.
223
+
224
+ A handler the application registers for one of these classes afterwards replaces this one; a
225
+ handler for a more specific class, or for a status, takes precedence as Starlette's lookup
226
+ gives it.
227
+
228
+ The routes of a viewset answer with an `ApiErrorBody` whether or not this is called.
229
+ """
230
+ app.add_exception_handler(DfViewSetError, _handle_error)
231
+ app.add_exception_handler(HTTPException, _handle_error)
232
+ app.add_exception_handler(RequestValidationError, _handle_error)
233
+ app.add_exception_handler(Exception, _handle_error)
234
+
235
+
236
+ _FASTAPI_HANDLERS = (http_exception_handler, request_validation_exception_handler)
237
+ """The handlers a FastAPI application registers by default."""
238
+
239
+
240
+ EXCEPTION_HANDLERS_SCOPE_KEY = "starlette.exception_handlers"
241
+
242
+
243
+ def uniform_exception_handlers(scope: dict[str, Any]) -> None:
244
+ """
245
+ Replaces, for the request of `scope`, FastAPI's default handlers of `HTTPException` and
246
+ `RequestValidationError` with one that answers an `ApiErrorBody`. Handlers the application
247
+ registered for a class or a status are kept.
248
+ """
249
+ exception_handlers, status_handlers = scope.get(EXCEPTION_HANDLERS_SCOPE_KEY) or ({}, {})
250
+ handlers = {
251
+ key: (_handle_error if handler in _FASTAPI_HANDLERS else handler) for key, handler in exception_handlers.items()
252
+ }
253
+ handlers.setdefault(HTTPException, _handle_error)
254
+ handlers.setdefault(RequestValidationError, _handle_error)
255
+ scope[EXCEPTION_HANDLERS_SCOPE_KEY] = (handlers, status_handlers)
256
+
257
+
258
+ def answers_unhandled_itself(app: Any) -> bool:
259
+ """Whether `app` has a handler of its own, not `install_error_handlers`', for `Exception` or 500."""
260
+ handlers = getattr(app, "exception_handlers", None) or {}
261
+ return any(key in handlers and handlers[key] is not _handle_error for key in (Exception, 500))
@@ -32,6 +32,7 @@ from .delivery import (
32
32
  DeliveryFilter,
33
33
  drop_peer,
34
34
  drop_subscriptions,
35
+ EventListener,
35
36
  passes_filters,
36
37
  reset_subscriptions,
37
38
  subscribe,
@@ -80,6 +81,7 @@ __all__ = [
80
81
  "ChangeEvents",
81
82
  "DeclaredSources",
82
83
  "DeliveryFilter",
84
+ "EventListener",
83
85
  "Event",
84
86
  "EventAuthorizer",
85
87
  "EventScope",
@@ -9,6 +9,7 @@ from typing import Any
9
9
  import pytest
10
10
 
11
11
  from ..conf import settings
12
+ from ..error_namespace import ERROR_NAMESPACE
12
13
  from . import store as store_module
13
14
  from .delivery import reset_subscriptions
14
15
  from .redis import _APPEND_SCRIPT
@@ -19,6 +20,7 @@ EVENT_SETTINGS = (
19
20
  "viewsets_event_store",
20
21
  "viewsets_event_authorizers",
21
22
  "viewsets_event_delivery_filters",
23
+ "viewsets_event_listeners",
22
24
  "viewsets_event_log_size",
23
25
  "viewsets_event_log_ttl",
24
26
  "viewsets_event_push_timeout",
@@ -33,6 +35,7 @@ def _reset_events():
33
35
  saved = {name: getattr(settings, name) for name in EVENT_SETTINGS}
34
36
  settings.viewsets_event_authorizers = []
35
37
  settings.viewsets_event_delivery_filters = []
38
+ settings.viewsets_event_listeners = []
36
39
  reset_subscriptions()
37
40
  reset_sources()
38
41
  reset_event_scopes()
@@ -255,3 +258,8 @@ class FakePubSub:
255
258
  async def aclose(self) -> None:
256
259
  await self.unsubscribe(*list(self.subscribed))
257
260
  self.closed = True
261
+
262
+
263
+ def envelope(detail: str, code: str, **params: Any) -> dict[str, Any]:
264
+ """The `ApiErrorBody` of the package's error `code` (without the namespace)."""
265
+ return {"detail": detail, "detail_code": f"{ERROR_NAMESPACE}.{code}", "detail_params": params, "field_errors": {}}