dynamicforms-fastapi-viewsets 0.8.0__tar.gz → 0.9.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 (71) hide show
  1. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/PKG-INFO +21 -12
  2. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/README.md +17 -8
  3. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/__init__.py +1 -0
  4. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/collection_viewset.py +5 -0
  5. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/conf.py +117 -0
  6. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/__init__.py +0 -4
  7. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/build_schema.py +2 -3
  8. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/celery_viewset/__init__.py +1 -4
  9. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/celery_viewset/client.py +44 -41
  10. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/decorators/celery_viewset/payload.py +56 -0
  11. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/celery_viewset/server.py +44 -28
  12. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/decorators/lifecycle_runner.py +173 -0
  13. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/route_viewset.py +76 -19
  14. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/__init__.py +92 -0
  15. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/authorizers.py +252 -0
  16. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/changes.py +275 -0
  17. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/conftest.py +219 -0
  18. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/delivery.py +251 -0
  19. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/event.py +56 -0
  20. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/lifecycle.py +32 -0
  21. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/redis.py +259 -0
  22. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/sources.py +92 -0
  23. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/store.py +168 -0
  24. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/topics.py +47 -0
  25. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/viewset.py +224 -0
  26. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/exceptions.py +54 -0
  27. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/middleware/__init__.py +97 -6
  28. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/middleware/auth/__init__.py +3 -3
  29. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/middleware/idempotency.py +465 -0
  30. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/middleware/rate_limiter.py +2 -2
  31. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/mux_ws/__init__.py +63 -0
  32. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/mux_ws/asgi.py +32 -10
  33. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/mux_ws/registry.py +7 -67
  34. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/mux_ws/server.py +66 -17
  35. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/routing.py +78 -0
  36. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/pyproject.toml +6 -3
  37. dynamicforms_fastapi_viewsets-0.8.0/fastapi_viewsets/__init__.py +0 -1
  38. dynamicforms_fastapi_viewsets-0.8.0/fastapi_viewsets/conf.py +0 -59
  39. dynamicforms_fastapi_viewsets-0.8.0/fastapi_viewsets/decorators/lifecycle_runner.py +0 -117
  40. dynamicforms_fastapi_viewsets-0.8.0/fastapi_viewsets/mux_ws/__init__.py +0 -68
  41. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/.gitignore +0 -0
  42. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/LICENSE +0 -0
  43. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/action_configuration.py +0 -0
  44. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/backends/__init__.py +0 -0
  45. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/backends/django_orm.py +0 -0
  46. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/context/__init__.py +0 -0
  47. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/context/auth/__init__.py +0 -0
  48. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/context/auth/django.py +0 -0
  49. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/context/auth/jwt.py +0 -0
  50. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/context/auth/static.py +0 -0
  51. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/cursor.py +0 -0
  52. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +0 -0
  53. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/conftest.py +0 -0
  54. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
  55. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/endpoint_docs.py +0 -0
  56. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/filters/__init__.py +0 -0
  57. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/filters/base.py +0 -0
  58. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/filters/declaration.py +0 -0
  59. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/filters/operators.py +0 -0
  60. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/filters/registry.py +0 -0
  61. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/list_query.py +0 -0
  62. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/list_shapes.py +0 -0
  63. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
  64. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/mixins.py +0 -0
  65. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/mux_ws/protocol.py +0 -0
  66. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/mux_ws/transports.py +0 -0
  67. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/py.typed +0 -0
  68. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/response_classes.py +0 -0
  69. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/save_state/__init__.py +0 -0
  70. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/save_state/save_state.py +0 -0
  71. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/save_state/serialize_state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dynamicforms-fastapi-viewsets
3
- Version: 0.8.0
3
+ Version: 0.9.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
  Project-URL: Homepage, https://github.com/dynamicforms/fastapi-viewsets
6
6
  Project-URL: Repository, https://github.com/dynamicforms/fastapi-viewsets
@@ -33,8 +33,8 @@ Requires-Dist: asgiref; extra == 'dev'
33
33
  Requires-Dist: celery; extra == 'dev'
34
34
  Requires-Dist: coverage; extra == 'dev'
35
35
  Requires-Dist: django; extra == 'dev'
36
- Requires-Dist: httpx; extra == 'dev'
37
- Requires-Dist: muxws[starlette,websockets]>=0.3.1; extra == 'dev'
36
+ Requires-Dist: httpx2; extra == 'dev'
37
+ Requires-Dist: muxws[starlette,websockets]>=0.4.1; extra == 'dev'
38
38
  Requires-Dist: pyjwt; extra == 'dev'
39
39
  Requires-Dist: pytest; extra == 'dev'
40
40
  Requires-Dist: pytest-asyncio; extra == 'dev'
@@ -46,7 +46,7 @@ Requires-Dist: django>=4.2; extra == 'django'
46
46
  Provides-Extra: jwt
47
47
  Requires-Dist: pyjwt>=2.8; extra == 'jwt'
48
48
  Provides-Extra: muxws
49
- Requires-Dist: muxws[starlette]>=0.3.1; extra == 'muxws'
49
+ Requires-Dist: muxws[starlette]>=0.4.1; extra == 'muxws'
50
50
  Description-Content-Type: text/markdown
51
51
 
52
52
  # DynamicForms FastAPI Viewsets
@@ -68,12 +68,14 @@ Celery-backed async execution and a matching Vue/TypeScript client counterpart.
68
68
  extra).
69
69
  - **Bulk operations** — first-class support for bulk create, update, partial update and destroy.
70
70
  - **muxws transport** — reach the same viewsets over a single WebSocket instead of one HTTP request
71
- per call. A command is dispatched into an app built from the endpoints published on muxws, each
72
- carrying the route kwargs REST is given, so validation, dependencies, response models and the
73
- command middleware from `settings.viewsets_command_middleware` behave identically; that app is the
74
- library's own, so what you attached anywhere but the endpoint itself sees a command only if you
75
- pass your app to `process_command`. In the demo, a burst of 100 requests takes 156 ms over REST
76
- and 37 ms over muxws.
71
+ per call. A command goes through your application as an HTTP request does - its middleware,
72
+ exception handlers, router dependencies and state - so both transports behave identically. In the
73
+ demo, a burst of 100 requests takes 156 ms over REST and 37 ms over muxws.
74
+ - **Events** — publish an event to a topic and every frontend following it learns of it, as a muxws
75
+ push or on its next poll. `ChangeEvents` publishes every create, update and destroy of a viewset to
76
+ its routed base path; custom events are declared with `source()`. Every event is recorded before it
77
+ is pushed, so a dropped push costs latency, not an event. On the frontend, `watch()` on a ViewSet
78
+ follows its topic and `applyChanges()` applies each change to a list of rows.
77
79
  - **Three list shapes** — a bare array, offset paging, or cursor paging. A viewset declares which,
78
80
  and may let a client pick per request with an `X-List-Shape` header.
79
81
  - **Declarative filters** — declare which fields accept which operators and get query parameters,
@@ -148,8 +150,15 @@ An infinite-scrolling grid over a 5000-track library, cursor-paged. Switch the w
148
150
  the REST and muxws transports and between the in-memory and SQLite backends, and compare their
149
151
  latency side by side. Sorting and filtering are server-side.
150
152
 
151
- `--celery` needs Redis on localhost:6379. The end-to-end suite starts its own backend and dev
152
- server on their own ports.
153
+ The grid follows its viewset's change events (`ChangeEvents` on the backend, `watch()` in the
154
+ grid). Click a Favorite cell to toggle it: every tab showing the library applies the change, over
155
+ muxws as a push, over REST on its next poll. A sorted or filtered grid reloads its list on the
156
+ change: the row may have moved or left it.
157
+
158
+ `--celery` needs Redis on localhost:6379. The worker runs the viewset calls of both backends and
159
+ publishes their events through Redis. It runs as one process, because the in-memory library is a
160
+ dict held by each process; the SQLite library is one file shared by every process. The end-to-end
161
+ suite starts its own backend and dev server on their own ports.
153
162
 
154
163
  ## License
155
164
 
@@ -17,12 +17,14 @@ Celery-backed async execution and a matching Vue/TypeScript client counterpart.
17
17
  extra).
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
- per call. A command is dispatched into an app built from the endpoints published on muxws, each
21
- carrying the route kwargs REST is given, so validation, dependencies, response models and the
22
- command middleware from `settings.viewsets_command_middleware` behave identically; that app is the
23
- library's own, so what you attached anywhere but the endpoint itself sees a command only if you
24
- pass your app to `process_command`. In the demo, a burst of 100 requests takes 156 ms over REST
25
- and 37 ms over muxws.
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.
23
+ - **Events** — publish an event to a topic and every frontend following it learns of it, as a muxws
24
+ push or on its next poll. `ChangeEvents` publishes every create, update and destroy of a viewset to
25
+ its routed base path; custom events are declared with `source()`. Every event is recorded before it
26
+ is pushed, so a dropped push costs latency, not an event. On the frontend, `watch()` on a ViewSet
27
+ follows its topic and `applyChanges()` applies each change to a list of rows.
26
28
  - **Three list shapes** — a bare array, offset paging, or cursor paging. A viewset declares which,
27
29
  and may let a client pick per request with an `X-List-Shape` header.
28
30
  - **Declarative filters** — declare which fields accept which operators and get query parameters,
@@ -97,8 +99,15 @@ An infinite-scrolling grid over a 5000-track library, cursor-paged. Switch the w
97
99
  the REST and muxws transports and between the in-memory and SQLite backends, and compare their
98
100
  latency side by side. Sorting and filtering are server-side.
99
101
 
100
- `--celery` needs Redis on localhost:6379. The end-to-end suite starts its own backend and dev
101
- server on their own ports.
102
+ The grid follows its viewset's change events (`ChangeEvents` on the backend, `watch()` in the
103
+ grid). Click a Favorite cell to toggle it: every tab showing the library applies the change, over
104
+ muxws as a push, over REST on its next poll. A sorted or filtered grid reloads its list on the
105
+ change: the row may have moved or left it.
106
+
107
+ `--celery` needs Redis on localhost:6379. The worker runs the viewset calls of both backends and
108
+ publishes their events through Redis. It runs as one process, because the in-memory library is a
109
+ dict held by each process; the SQLite library is one file shared by every process. The end-to-end
110
+ suite starts its own backend and dev server on their own ports.
102
111
 
103
112
  ## License
104
113
 
@@ -0,0 +1 @@
1
+ __version__ = "0.9.1"
@@ -102,6 +102,11 @@ class CollectionViewSet(AsyncCollectionViewSet[K, T]):
102
102
  self._update_func = self._update_mapping
103
103
  self._delete_func = container.__delitem__
104
104
 
105
+ @property
106
+ def pk_field_name(self) -> str:
107
+ """`pk_field`, the name the list's cursor shape and `ChangeEvents` read the pk under."""
108
+ return self.pk_field
109
+
105
110
  def _check_mutable(self):
106
111
  if not self.is_mutable:
107
112
  raise ReadOnlyCollectionError()
@@ -0,0 +1,117 @@
1
+ import json
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from typing import Any, TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from fastapi import Request
8
+ from fastapi.security.base import SecurityBase
9
+
10
+ from .context.auth import AuthBackend
11
+ from .events.authorizers import EventAuthorizer
12
+ from .events.delivery import DeliveryFilter
13
+ from .events.store import EventStore
14
+ from .middleware import CommandMiddleware
15
+
16
+ ContextProcessor = Callable[["Request", Any], Awaitable[dict[str, Any]]]
17
+
18
+
19
+ class Settings:
20
+ """
21
+ Global, mutable settings for fastapi_viewsets - configured once by the application, e.g.:
22
+
23
+ from fastapi_viewsets.conf import settings
24
+
25
+ async def auth_processor(request, viewset) -> dict:
26
+ return {"user": await authenticate(request)}
27
+
28
+ settings.viewsets_context_processors = [auth_processor]
29
+ """
30
+
31
+ def __init__(self):
32
+ self.viewsets_context_processors: list[ContextProcessor] = []
33
+ self.viewsets_context_json_encoder: type[json.JSONEncoder] | None = None
34
+ self.viewsets_context_json_decoder: type[json.JSONDecoder] | None = None
35
+ self.viewsets_command_middleware: list[CommandMiddleware] = []
36
+ self.viewsets_idempotency_ttl: float = 86400
37
+ """
38
+ Seconds an outcome recorded by `fastapi_viewsets.middleware.idempotency.Idempotency` is kept
39
+ after it was stored. A repeat after that runs the action again.
40
+ """
41
+ self.viewsets_idempotency_lock_ttl: float = 600
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.
45
+ """
46
+ self.viewsets_auth_processors: list[AuthBackend] = []
47
+ self.default_action_configuration: dict[Any, Any] = {}
48
+ self.default_list_shape: str = "plain"
49
+ """
50
+ The shape a list endpoint answers in when the viewset does not say - "plain", "paginated"
51
+ or "cursor". An application that pages everything sets it once here instead of on every
52
+ viewset.
53
+ """
54
+ self.viewsets_register_muxws: bool = True
55
+ """
56
+ Whether a viewset is reachable over the muxws transport (see fastapi_viewsets/mux_ws/) when
57
+ it does not say either way itself. `route_viewset(register_muxws=...)` overrides this per
58
+ viewset, and `@transports(muxws=...)` overrides it again per endpoint - each
59
+ level only decides for the levels below it that left the question open (None). Read at
60
+ decoration time, so it must be set before any viewset class is decorated.
61
+ """
62
+ self.viewsets_security_scheme: SecurityBase | None = None
63
+ """
64
+ Purely for OpenAPI/Swagger discoverability (a lock icon + testable Authorize flow) - attached
65
+ as an extra sub-dependency alongside the per-route Middleware.depends() bridge (see
66
+ route_viewset.py), not otherwise consumed by this library. Must be set before any viewset
67
+ class is decorated with route_viewset, as `viewsets_command_middleware` must, which decides
68
+ `disable_response_model` when the routes are built.
69
+ """
70
+ self.viewsets_event_store: EventStore | None = None
71
+ """
72
+ The `EventStore` of `fastapi_viewsets.events`: it assigns each event its rev, keeps the
73
+ topic's log for `poll` and carries the event to `deliver()` in every process that watches
74
+ the topic. None is one `MemoryEventStore` for this process. A deployment of more than one
75
+ process, a Celery worker included, sets a `RedisEventStore` in every process.
76
+ """
77
+ self.viewsets_event_authorizers: list[EventAuthorizer] = []
78
+ """
79
+ The functions that decide whether a caller may follow a topic, consulted in list order on
80
+ every `subscribe` and on every `poll`, per topic. The first `True` admits the topic, the first
81
+ `False` refuses it, and no decision refuses it: an empty list refuses every topic.
82
+ """
83
+ self.viewsets_event_delivery_filters: list[DeliveryFilter] = []
84
+ """
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.
87
+ """
88
+ self.viewsets_event_log_size: int = 32
89
+ """
90
+ The number of a topic's most recent events the store keeps for `poll`. 0 keeps none, and a
91
+ poll from any cursor behind the topic's rev answers `events: null`.
92
+ """
93
+ self.viewsets_event_log_ttl: float = 60.0
94
+ """
95
+ Seconds a topic's log is kept after the topic's last event. The topic's rev counter is kept
96
+ without expiry. `RedisEventStore` sets it as the log key's expiry; `MemoryEventStore` empties
97
+ an expired log when it next reads or appends to it.
98
+ """
99
+ self.viewsets_event_push_timeout: float = 2.0
100
+ """
101
+ Seconds the delivery filters and `peer.notify()` of one push of one event to one socket may
102
+ take together. `notify()` queues the push on the socket and returns without waiting for the
103
+ socket to send it. A push that takes longer is cancelled and logged at debug level.
104
+ """
105
+ self.viewsets_event_max_subscriptions: int = 256
106
+ """
107
+ The number of topics one socket may hold; a subscription beyond it raises
108
+ `EventSubscriptionsExceededError` (409). Also the number of cursors one poll may carry.
109
+ """
110
+ self.viewsets_event_poll_after_ms: int = 0
111
+ """
112
+ `poll_after_ms` of every poll answer: the shortest interval, in milliseconds, at which a
113
+ client polls.
114
+ """
115
+
116
+
117
+ settings = Settings()
@@ -2,8 +2,6 @@ from .celery_viewset import (
2
2
  celery_viewset,
3
3
  celery_viewset_client,
4
4
  celery_viewset_server,
5
- set_celery_dispatch_hook,
6
- set_celery_kwargs_hook,
7
5
  set_is_celery_worker,
8
6
  )
9
7
  from .route_viewset import route_viewset
@@ -13,7 +11,5 @@ __all__ = [
13
11
  "celery_viewset_client",
14
12
  "celery_viewset_server",
15
13
  "route_viewset",
16
- "set_celery_dispatch_hook",
17
- "set_celery_kwargs_hook",
18
14
  "set_is_celery_worker",
19
15
  ]
@@ -151,9 +151,8 @@ def build_schema(cls, base_path: str = "", default_tags=None, get_wrapper=None,
151
151
  def schema():
152
152
  return app.openapi()
153
153
 
154
- # Marks this route as the framework's own /schema endpoint. The muxws transport has to
155
- # skip it and substitute its own (which reports the muxws endpoint set, not this one) -
156
- # see fastapi_viewsets/mux_ws/registry.py.
154
+ # Marks this route as the framework's own /schema endpoint. route_viewset replaces it with
155
+ # one /schema route per transport, each reporting the endpoints published on that transport.
157
156
  schema.__viewset_schema_endpoint__ = True
158
157
 
159
158
  schema_route_kwargs = dict(
@@ -6,7 +6,6 @@ from .client import (
6
6
  celery_viewset_client,
7
7
  DEFAULT_RESULT_TIMEOUT,
8
8
  get_registered_queue_keys,
9
- set_celery_dispatch_hook,
10
9
  )
11
10
  from .result_reader import (
12
11
  get_process_queue_key,
@@ -16,7 +15,7 @@ from .result_reader import (
16
15
  start_result_reader,
17
16
  stop_result_reader,
18
17
  )
19
- from .server import celery_viewset_server, set_celery_kwargs_hook
18
+ from .server import celery_viewset_server
20
19
 
21
20
  logger = logging.getLogger(__name__)
22
21
 
@@ -92,8 +91,6 @@ __all__ = [
92
91
  "celery_viewset_server",
93
92
  "DEFAULT_RESULT_TIMEOUT",
94
93
  "set_is_celery_worker",
95
- "set_celery_kwargs_hook",
96
- "set_celery_dispatch_hook",
97
94
  "start_result_reader",
98
95
  "stop_result_reader",
99
96
  "check_result_readers",
@@ -1,18 +1,20 @@
1
1
  import asyncio
2
+ import inspect
2
3
  import logging
3
4
  import uuid
4
5
 
5
- from collections.abc import Awaitable, Callable
6
6
  from functools import wraps
7
7
  from typing import TYPE_CHECKING, TypeVar
8
8
 
9
9
  from pydantic import BaseModel
10
10
 
11
11
  from ...context import Context, SerializableObject, serialize_context, to_jsonable
12
- from ...exceptions import ResultTimeoutError
12
+ from ...exceptions import ActionDispatchError, ResultTimeoutError
13
13
  from ..build_schema import build_schema
14
+ from ..lifecycle_runner import current_call, DEFERRED_ACTIONS_ATTRIBUTE
14
15
  from ..primary_key_model_helper import PARTIAL_UPDATE_ACTIONS
15
16
  from . import result_reader
17
+ from .payload import CONTEXT_KWARG, PARENT_KWARG, REQUEST_KWARG, request_to_jsonable
16
18
  from .result_reader import get_process_queue_key, get_result_queue_key
17
19
 
18
20
  if TYPE_CHECKING:
@@ -25,20 +27,6 @@ logger = logging.getLogger(__name__)
25
27
 
26
28
  DEFAULT_RESULT_TIMEOUT = 3600.0
27
29
 
28
- _celery_dispatch_hook: Callable[[dict], Awaitable[dict]] | None = None
29
-
30
-
31
- def set_celery_dispatch_hook(hook: Callable[[dict], Awaitable[dict]] | None) -> None:
32
- """Register a callable awaited just before send_task; its return dict is merged into kwargs.
33
-
34
- Receives the call's current kwargs (context included, if the action declares one) so a
35
- registered hook can read what a context processor already put there instead of needing its own
36
- request-level plumbing. Returns {} when there is nothing to add.
37
- """
38
- global _celery_dispatch_hook
39
- _celery_dispatch_hook = hook
40
-
41
-
42
30
  # queue_keys registered by celery_viewset_client so far - populated at decoration (import) time,
43
31
  # used by check_result_readers() to spot a queue_key with no running result reader.
44
32
  _registered_queue_keys: set[str] = set()
@@ -111,8 +99,13 @@ async def _serialize_value(value, partial: bool = False):
111
99
  def _patch_method(
112
100
  cls: type, original_endpoint, task_name: str, celery_app, queue_key: str, result_timeout: float | None
113
101
  ):
114
- """Replace the method on cls with an async version that sends a Celery task and awaits the result."""
102
+ """Replace the method on cls with an async version that sends a Celery task and awaits the result.
103
+
104
+ The task carries the call's context, request and parent execution (see `payload`) from
105
+ `current_call()`; a `context` the caller passes replaces the call's. A send that fails raises
106
+ `ActionDispatchError`. The action's name joins the class's `DEFERRED_ACTIONS_ATTRIBUTE`."""
115
107
  method_name = original_endpoint.__name__
108
+ declares_request = "request" in inspect.signature(original_endpoint).parameters
116
109
 
117
110
  @wraps(original_endpoint)
118
111
  async def async_client_wrapper(_self, *args, **kwargs):
@@ -122,32 +115,41 @@ def _patch_method(
122
115
  result_reader.register_future(correlation_id, future, queue_key)
123
116
 
124
117
  try:
118
+ call = current_call()
119
+ context = kwargs.pop("context", None)
120
+ if context is None:
121
+ context = call.context if call is not None else Context({})
122
+ if declares_request:
123
+ kwargs.pop("request", None)
124
+ parent = call.parent_of_dispatch() if call is not None else None
125
125
  serializable_args = [await _serialize_value(v) for v in args]
126
- serializable_kwargs = {}
127
- for k, v in kwargs.items():
128
- if k == "context":
129
- serializable_kwargs[k] = await serialize_context(v.raw())
130
- else:
131
- serializable_kwargs[k] = await _serialize_value(v, method_name in PARTIAL_UPDATE_ACTIONS)
126
+ serializable_kwargs = {
127
+ k: await _serialize_value(v, method_name in PARTIAL_UPDATE_ACTIONS) for k, v in kwargs.items()
128
+ }
132
129
  logger.info("Celery task scheduling: %s (correlation_id=%s)", task_name, correlation_id)
133
- extra_kwargs = await _celery_dispatch_hook(kwargs) if _celery_dispatch_hook is not None else {}
134
- # send_task() blocks while it connects and publishes to the broker.
135
- await asyncio.to_thread(
136
- celery_app.send_task,
137
- task_name,
138
- args=serializable_args,
139
- kwargs={
140
- **serializable_kwargs,
141
- "_correlation_id": correlation_id,
142
- "_result_queue_key": get_process_queue_key(queue_key),
143
- **extra_kwargs,
144
- },
145
- # send_task() defaults ignore_result to False regardless of what the task was
146
- # registered with (celery_viewset_server sets ignore_result=True) - Celery's
147
- # per-request value overrides the task's own, so it must be repeated here or the
148
- # worker still tries to JSON-encode the raw return value into its result backend.
149
- ignore_result=True,
150
- )
130
+ try:
131
+ # send_task() blocks while it connects and publishes to the broker.
132
+ await asyncio.to_thread(
133
+ celery_app.send_task,
134
+ task_name,
135
+ args=serializable_args,
136
+ kwargs={
137
+ **serializable_kwargs,
138
+ CONTEXT_KWARG: await serialize_context(context.raw()),
139
+ REQUEST_KWARG: request_to_jsonable(call.request if call is not None else None),
140
+ PARENT_KWARG: parent.to_jsonable() if parent is not None else None,
141
+ "_correlation_id": correlation_id,
142
+ "_result_queue_key": get_process_queue_key(queue_key),
143
+ },
144
+ # send_task() defaults ignore_result to False regardless of what the task was
145
+ # registered with (celery_viewset_server sets ignore_result=True) - Celery's
146
+ # per-request value overrides the task's own, so it must be repeated here or the
147
+ # worker still tries to JSON-encode the raw return value into its result backend.
148
+ ignore_result=True,
149
+ )
150
+ except Exception as error:
151
+ logger.error("Celery task not sent: %s (correlation_id=%s)", task_name, correlation_id)
152
+ raise ActionDispatchError(task_name) from error
151
153
  try:
152
154
  return await asyncio.wait_for(future, result_timeout)
153
155
  except asyncio.TimeoutError:
@@ -157,3 +159,4 @@ def _patch_method(
157
159
  result_reader.unregister_future(correlation_id)
158
160
 
159
161
  setattr(cls, method_name, async_client_wrapper)
162
+ setattr(cls, DEFERRED_ACTIONS_ATTRIBUTE, frozenset(getattr(cls, DEFERRED_ACTIONS_ATTRIBUTE, ())) | {method_name})
@@ -0,0 +1,56 @@
1
+ """
2
+ The call data a `celery_viewset` task carries beside the action's own arguments: the call's context,
3
+ its request and the execution it was dispatched in. `celery_viewset_client` writes them under the
4
+ reserved kwargs below, and `celery_viewset_server` consumes them before the action is called.
5
+ """
6
+
7
+ from typing import Any
8
+
9
+ from fastapi import Request
10
+ from fastapi.encoders import jsonable_encoder
11
+
12
+ CONTEXT_KWARG = "_context"
13
+ """The call's context, as `serialize_context` encodes it."""
14
+ REQUEST_KWARG = "_request"
15
+ """The call's request, as `request_to_jsonable` encodes it; None without one."""
16
+ PARENT_KWARG = "_parent"
17
+ """The `ExecutionRef` the call was dispatched in, as `ExecutionRef.to_jsonable` encodes it; None
18
+ for a call a request made."""
19
+
20
+
21
+ def _address(value: Any) -> list | None:
22
+ return list(value) if value is not None else None
23
+
24
+
25
+ def request_to_jsonable(request: Request | None) -> dict[str, Any] | None:
26
+ """The ASGI scope of `request` without its body, `app` and `state`, JSON-safe."""
27
+ if request is None:
28
+ return None
29
+ scope = request.scope
30
+ return {
31
+ "type": scope.get("type", "http"),
32
+ "http_version": scope.get("http_version", "1.1"),
33
+ "method": scope.get("method", "GET"),
34
+ "scheme": scope.get("scheme", "http"),
35
+ "path": scope.get("path", ""),
36
+ "root_path": scope.get("root_path", ""),
37
+ "query_string": scope.get("query_string", b"").decode("latin-1"),
38
+ "headers": [[name.decode("latin-1"), value.decode("latin-1")] for name, value in scope.get("headers", [])],
39
+ "path_params": jsonable_encoder(scope.get("path_params", {})),
40
+ "client": _address(scope.get("client")),
41
+ "server": _address(scope.get("server")),
42
+ }
43
+
44
+
45
+ def request_from_jsonable(data: dict[str, Any] | None) -> Request | None:
46
+ """A `Request` over the scope `request_to_jsonable` encoded. Reading its body raises."""
47
+ if data is None:
48
+ return None
49
+ scope = {
50
+ **data,
51
+ "query_string": data["query_string"].encode("latin-1"),
52
+ "headers": [(name.encode("latin-1"), value.encode("latin-1")) for name, value in data["headers"]],
53
+ "client": tuple(data["client"]) if data["client"] is not None else None,
54
+ "server": tuple(data["server"]) if data["server"] is not None else None,
55
+ }
56
+ return Request(scope)
@@ -1,12 +1,12 @@
1
1
  import asyncio
2
+ import contextlib
2
3
  import functools
3
4
  import inspect
4
5
  import logging
5
6
  import types
6
7
 
7
- from collections.abc import Callable
8
8
  from functools import wraps
9
- from typing import get_args, get_origin, get_type_hints, TYPE_CHECKING, TypeVar, Union
9
+ from typing import Any, get_args, get_origin, get_type_hints, TYPE_CHECKING, TypeVar, Union
10
10
 
11
11
  from fastapi import HTTPException
12
12
  from fastapi.encoders import jsonable_encoder
@@ -14,6 +14,7 @@ from fastapi.exceptions import RequestValidationError
14
14
  from pydantic import BaseModel, TypeAdapter, ValidationError
15
15
 
16
16
  from ...context import Context, deserialize_context, to_jsonable
17
+ from ...middleware import ExecutionRef
17
18
  from ..build_schema import build_schema
18
19
  from ..lifecycle_runner import lifecycle_runner, LifecycleType
19
20
  from ..primary_key_model_helper import (
@@ -24,6 +25,7 @@ from ..primary_key_model_helper import (
24
25
  typecast_to_original_model,
25
26
  )
26
27
  from ..route_viewset import build_type_map, resolve_typevars
28
+ from .payload import CONTEXT_KWARG, PARENT_KWARG, request_from_jsonable, REQUEST_KWARG
27
29
  from .result_reader import push_payload
28
30
 
29
31
  if TYPE_CHECKING:
@@ -35,19 +37,6 @@ logger = logging.getLogger(__name__)
35
37
 
36
38
  T = TypeVar("T")
37
39
 
38
- _celery_kwargs_hook: Callable[[Callable, dict], tuple[Callable, dict]] | None = None
39
-
40
-
41
- def set_celery_kwargs_hook(hook: Callable[[Callable, dict], tuple[Callable, dict]] | None) -> None:
42
- """Register a callable that may wrap the runner and/or consume kwargs before reconstruction.
43
-
44
- Receives (runner, kwargs), must return (runner, kwargs). Called with kwargs still raw - before
45
- _reconstruct_kwargs, which passes through any key it has no type hint for, so a key the hook
46
- does not consume would otherwise reach the action as an argument it never declared.
47
- """
48
- global _celery_kwargs_hook
49
- _celery_kwargs_hook = hook
50
-
51
40
 
52
41
  def _unwrap_optional(hint):
53
42
  """Before Python 3.11, `get_type_hints()` implicitly wraps a None-defaulted parameter's
@@ -119,17 +108,11 @@ _model_without_pk = functools.lru_cache(create_model_without_pk)
119
108
  def _reconstruct_kwargs(original_endpoint, kwargs: dict, cls: type = None) -> dict:
120
109
  """Reconstruct dict values back into Pydantic BaseModel instances based on endpoint type hints.
121
110
 
122
- `context` is handled unconditionally (by name, not by type hint) via deserialize_context - see
123
- fastapi_viewsets/context - since its declared type is a plain Context and its actual values
124
- may need SerializableObject-aware reconstruction.
111
+ The call's context, request and parent arrive under the reserved kwargs of `payload`, which the
112
+ task consumes before this runs.
125
113
  """
126
114
  result = {}
127
- remaining = {}
128
- for key, value in kwargs.items():
129
- if key == "context" and isinstance(value, dict):
130
- result[key] = Context(deserialize_context(value))
131
- else:
132
- remaining[key] = value
115
+ remaining = dict(kwargs)
133
116
 
134
117
  try:
135
118
  hints = get_type_hints(original_endpoint)
@@ -154,6 +137,22 @@ def _reconstruct_kwargs(original_endpoint, kwargs: dict, cls: type = None) -> di
154
137
  return result
155
138
 
156
139
 
140
+ def _run_to_end(loop: asyncio.AbstractEventLoop, coroutine) -> Any:
141
+ """Runs `coroutine` on `loop` as a task and returns its result. An exception that interrupts the
142
+ loop while the task is suspended (Celery's time limit, raised from a signal handler) cancels the
143
+ task and runs it until it has unwound, so its `finally` blocks and `Middleware.execute()` see the
144
+ interruption; the exception is then raised."""
145
+ task = loop.create_task(coroutine)
146
+ try:
147
+ return loop.run_until_complete(task)
148
+ except BaseException:
149
+ if not task.done():
150
+ task.cancel()
151
+ with contextlib.suppress(BaseException): # the interruption is what propagates
152
+ loop.run_until_complete(task)
153
+ raise
154
+
155
+
157
156
  def celery_viewset_server(
158
157
  celery_app: "Celery",
159
158
  task_prefix: str,
@@ -165,6 +164,8 @@ def celery_viewset_server(
165
164
  instance = cls() if lifecycle == "singleton" else None
166
165
 
167
166
  def get_sync_wrapper(original_endpoint, task_name: str):
167
+ parameters = inspect.signature(original_endpoint).parameters
168
+
168
169
  @wraps(original_endpoint)
169
170
  def sync_wrapper(*args, **kwargs):
170
171
  nonlocal instance
@@ -186,11 +187,26 @@ def celery_viewset_server(
186
187
 
187
188
  logger.info("Celery task executing: %s (correlation_id=%s)", task_name, correlation_id)
188
189
  try:
189
- run = loop.run_until_complete
190
- if _celery_kwargs_hook is not None:
191
- run, kwargs = _celery_kwargs_hook(run, kwargs)
190
+ context = Context(deserialize_context(kwargs.pop(CONTEXT_KWARG, None) or {}))
191
+ request = request_from_jsonable(kwargs.pop(REQUEST_KWARG, None))
192
+ parent = ExecutionRef.from_jsonable(kwargs.pop(PARENT_KWARG, None))
192
193
  kwargs = _reconstruct_kwargs(original_endpoint, kwargs, cls)
193
- result = run(lifecycle_runner(original_endpoint, instance, cls, lifecycle, *args, **kwargs))
194
+ result = _run_to_end(
195
+ loop,
196
+ lifecycle_runner(
197
+ original_endpoint,
198
+ instance,
199
+ cls,
200
+ lifecycle,
201
+ *args,
202
+ needs_context="context" in parameters,
203
+ request=request,
204
+ forwards_request="request" in parameters,
205
+ context=context,
206
+ parent=parent,
207
+ **kwargs,
208
+ ),
209
+ )
194
210
  logger.info("Celery task completed: %s (correlation_id=%s)", task_name, correlation_id)
195
211
  if correlation_id and result_queue_key and redis_client is not None:
196
212
  push_payload(