dynamicforms-fastapi-viewsets 0.8.0__tar.gz → 0.9.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 (57) hide show
  1. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/PKG-INFO +6 -9
  2. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/README.md +3 -6
  3. dynamicforms_fastapi_viewsets-0.9.0/fastapi_viewsets/__init__.py +1 -0
  4. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/conf.py +10 -0
  5. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/decorators/__init__.py +0 -4
  6. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/decorators/build_schema.py +2 -3
  7. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/decorators/celery_viewset/__init__.py +1 -4
  8. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/decorators/celery_viewset/client.py +44 -41
  9. dynamicforms_fastapi_viewsets-0.9.0/fastapi_viewsets/decorators/celery_viewset/payload.py +56 -0
  10. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/decorators/celery_viewset/server.py +44 -28
  11. dynamicforms_fastapi_viewsets-0.9.0/fastapi_viewsets/decorators/lifecycle_runner.py +173 -0
  12. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/decorators/route_viewset.py +42 -9
  13. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/exceptions.py +23 -0
  14. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/middleware/__init__.py +92 -0
  15. dynamicforms_fastapi_viewsets-0.9.0/fastapi_viewsets/middleware/idempotency.py +465 -0
  16. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/mux_ws/__init__.py +5 -10
  17. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/mux_ws/asgi.py +29 -10
  18. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/mux_ws/registry.py +6 -61
  19. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/mux_ws/server.py +59 -16
  20. dynamicforms_fastapi_viewsets-0.9.0/fastapi_viewsets/routing.py +74 -0
  21. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/pyproject.toml +5 -2
  22. dynamicforms_fastapi_viewsets-0.8.0/fastapi_viewsets/__init__.py +0 -1
  23. dynamicforms_fastapi_viewsets-0.8.0/fastapi_viewsets/decorators/lifecycle_runner.py +0 -117
  24. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/.gitignore +0 -0
  25. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/LICENSE +0 -0
  26. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/action_configuration.py +0 -0
  27. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/backends/__init__.py +0 -0
  28. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/backends/django_orm.py +0 -0
  29. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/collection_viewset.py +0 -0
  30. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/context/__init__.py +0 -0
  31. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/context/auth/__init__.py +0 -0
  32. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/context/auth/django.py +0 -0
  33. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/context/auth/jwt.py +0 -0
  34. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/context/auth/static.py +0 -0
  35. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/cursor.py +0 -0
  36. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +0 -0
  37. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/decorators/conftest.py +0 -0
  38. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
  39. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/endpoint_docs.py +0 -0
  40. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/filters/__init__.py +0 -0
  41. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/filters/base.py +0 -0
  42. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/filters/declaration.py +0 -0
  43. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/filters/operators.py +0 -0
  44. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/filters/registry.py +0 -0
  45. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/list_query.py +0 -0
  46. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/list_shapes.py +0 -0
  47. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
  48. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
  49. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
  50. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/mixins.py +0 -0
  51. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/mux_ws/protocol.py +0 -0
  52. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/mux_ws/transports.py +0 -0
  53. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/py.typed +0 -0
  54. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/response_classes.py +0 -0
  55. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/save_state/__init__.py +0 -0
  56. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/save_state/save_state.py +0 -0
  57. {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/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.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
@@ -34,7 +34,7 @@ Requires-Dist: celery; extra == 'dev'
34
34
  Requires-Dist: coverage; extra == 'dev'
35
35
  Requires-Dist: django; extra == 'dev'
36
36
  Requires-Dist: httpx; extra == 'dev'
37
- Requires-Dist: muxws[starlette,websockets]>=0.3.1; extra == 'dev'
37
+ Requires-Dist: muxws[starlette,websockets]>=0.4.0; 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.0; extra == 'muxws'
50
50
  Description-Content-Type: text/markdown
51
51
 
52
52
  # DynamicForms FastAPI Viewsets
@@ -68,12 +68,9 @@ 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.
77
74
  - **Three list shapes** — a bare array, offset paging, or cursor paging. A viewset declares which,
78
75
  and may let a client pick per request with an `X-List-Shape` header.
79
76
  - **Declarative filters** — declare which fields accept which operators and get query parameters,
@@ -17,12 +17,9 @@ 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.
26
23
  - **Three list shapes** — a bare array, offset paging, or cursor paging. A viewset declares which,
27
24
  and may let a client pick per request with an `X-List-Shape` header.
28
25
  - **Declarative filters** — declare which fields accept which operators and get query parameters,
@@ -0,0 +1 @@
1
+ __version__ = "0.9.0"
@@ -30,6 +30,16 @@ class Settings:
30
30
  self.viewsets_context_json_encoder: type[json.JSONEncoder] | None = None
31
31
  self.viewsets_context_json_decoder: type[json.JSONDecoder] | None = None
32
32
  self.viewsets_command_middleware: list[CommandMiddleware] = []
33
+ self.viewsets_idempotency_ttl: float = 86400
34
+ """
35
+ Seconds an outcome recorded by `fastapi_viewsets.middleware.idempotency.Idempotency` is kept
36
+ after it was stored. A repeat after that runs the action again.
37
+ """
38
+ self.viewsets_idempotency_lock_ttl: float = 600
39
+ """
40
+ Seconds a running record of `Idempotency` is kept after it was written, unless the action
41
+ finishes first. Once it expires, a repeat runs the action again.
42
+ """
33
43
  self.viewsets_auth_processors: list[AuthBackend] = []
34
44
  self.default_action_configuration: dict[Any, Any] = {}
35
45
  self.default_list_shape: str = "plain"
@@ -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(
@@ -0,0 +1,173 @@
1
+ import functools
2
+
3
+ from collections.abc import Callable
4
+ from contextvars import ContextVar
5
+ from dataclasses import dataclass
6
+ from typing import Any, Literal, TYPE_CHECKING, TypeVar
7
+
8
+ from ..action_configuration import extra_middlewares_for, resolve_action_configuration
9
+ from ..conf import settings
10
+ from ..context import Context, get_shared_context
11
+ from ..middleware import ActionExecution, ExecutionRef, run_command_chain, run_execution_chain, ViewSetResult
12
+
13
+ if TYPE_CHECKING:
14
+ from fastapi import Request
15
+
16
+ T = TypeVar("T")
17
+
18
+ LifecycleType = Literal["singleton", "per-request", "instance-key"]
19
+
20
+ DEFERRED_ACTIONS_ATTRIBUTE = "__viewset_deferred_actions__"
21
+ """
22
+ The class attribute holding the names of the actions whose method on that class hands the call to
23
+ an executor instead of executing it (`celery_viewset_client` sets it). `lifecycle_runner` runs no
24
+ `Middleware.execute()` around such a method, its override in a subclass included; the executor's
25
+ `lifecycle_runner` runs it around the real execution.
26
+ """
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class CurrentCall:
31
+ """The call `lifecycle_runner` is running in this task, for a method that hands it on."""
32
+
33
+ context: Context
34
+ request: "Request | None"
35
+ execution: ActionExecution | None
36
+ """Set while the action executes in this process; None while a deferred action hands it on."""
37
+ parent: ExecutionRef | None
38
+
39
+ def parent_of_dispatch(self) -> ExecutionRef | None:
40
+ """The `parent` of an action handed to an executor now: this execution when one runs, else
41
+ this call's own parent (the deferred method handing its own call on)."""
42
+ return self.execution.ref() if self.execution is not None else self.parent
43
+
44
+
45
+ _current_call: ContextVar[CurrentCall | None] = ContextVar("viewsets_current_call", default=None)
46
+
47
+
48
+ def current_call() -> CurrentCall | None:
49
+ """The call `lifecycle_runner` is running in this task; None outside any call."""
50
+ return _current_call.get()
51
+
52
+
53
+ def effective_middlewares(action_configuration: dict) -> list:
54
+ return settings.viewsets_command_middleware + extra_middlewares_for(
55
+ action_configuration, settings.viewsets_command_middleware
56
+ )
57
+
58
+
59
+ async def lifecycle_runner(
60
+ original_endpoint: Callable,
61
+ singleton_instance: T,
62
+ cls: type[T],
63
+ lifecycle: LifecycleType = "singleton",
64
+ *args,
65
+ response: Any = None,
66
+ needs_context: bool = False,
67
+ request: "Request | None" = None,
68
+ forwards_request: bool = False,
69
+ context: Context | None = None,
70
+ parent: ExecutionRef | None = None,
71
+ **kwargs,
72
+ ):
73
+ """
74
+ `response` is reserved for the command middleware chain (see below) - callers that have no
75
+ live Response object (e.g. `celery_viewset_server`, running in a worker process) simply omit
76
+ it, and the chain is never invoked - the worker just executes the action and returns the plain
77
+ result; command middleware only ever runs once that result is back in the FastAPI process.
78
+
79
+ `context` is the call's data when the caller already has it (`celery_viewset_server`, from the
80
+ task payload); without it a Context is built via settings.viewsets_context_processors against
81
+ whichever instance ends up handling this lifecycle. Either way it is bound to this action's
82
+ configuration and is the context the middleware chains read. `needs_context` injects it into
83
+ kwargs as `context`, for a method declaring that parameter. `forwards_request` passes `request`
84
+ on to a method that declares a `request` parameter. `parent` is the execution in which the call
85
+ was dispatched.
86
+
87
+ `Middleware.execute()` of the effective middlewares runs around the method call itself, unless
88
+ the action is deferred on `cls` (`DEFERRED_ACTIONS_ATTRIBUTE`). While the method runs, the call
89
+ is readable through `current_call()`.
90
+ """
91
+
92
+ if forwards_request:
93
+ kwargs["request"] = request
94
+
95
+ action = original_endpoint.__name__
96
+ action_configuration: dict = {}
97
+ chain_context: Context | None = None
98
+
99
+ async def inject_context(req_instance: Any) -> None:
100
+ nonlocal action_configuration, chain_context
101
+ action_configuration = resolve_action_configuration(cls, action)
102
+ if context is None:
103
+ chain_context = await get_shared_context(request, req_instance, action_configuration, action)
104
+ else:
105
+ chain_context = Context(context.raw(), action_configuration=action_configuration, action_name=action)
106
+ if needs_context:
107
+ kwargs["context"] = chain_context
108
+
109
+ async def call_bound(req_instance: Any) -> Any:
110
+ bound_method = getattr(req_instance, action, None)
111
+ method = bound_method if bound_method is not None else functools.partial(original_endpoint, req_instance)
112
+ if action in getattr(cls, DEFERRED_ACTIONS_ATTRIBUTE, ()):
113
+ marker = _current_call.set(CurrentCall(chain_context, request, None, parent))
114
+ try:
115
+ return await method(*args, **kwargs)
116
+ finally:
117
+ _current_call.reset(marker)
118
+ execution = ActionExecution(cls, req_instance, action, args, dict(kwargs), chain_context, request, parent)
119
+ marker = _current_call.set(CurrentCall(chain_context, request, execution, parent))
120
+ try:
121
+ return await run_execution_chain(
122
+ effective_middlewares(action_configuration), execution, lambda: method(*args, **kwargs)
123
+ )
124
+ finally:
125
+ _current_call.reset(marker)
126
+
127
+ async def execute(req_instance: Any) -> Any:
128
+ if response is None:
129
+ # Worker-only (celery_viewset_server) invocation - no live Response to shape, so the
130
+ # command middleware chain (whose whole job is producing/mutating response-level side
131
+ # effects) never runs here; just execute and return the plain result.
132
+ return await call_bound(req_instance)
133
+
134
+ async def final_handler() -> ViewSetResult:
135
+ raw = await call_bound(req_instance)
136
+ # An endpoint may return a ViewSetResult directly (see ViewSetResult in
137
+ # fastapi_viewsets/middleware) to reach status_code/headers/cookies itself - passed
138
+ # through as-is.
139
+ return raw if isinstance(raw, ViewSetResult) else ViewSetResult(body=raw)
140
+
141
+ result = await run_command_chain(
142
+ effective_middlewares(action_configuration), request, req_instance, chain_context, final_handler
143
+ )
144
+ for key, value in result.headers.items():
145
+ response.headers[key] = str(value)
146
+ for key, value in result.cookies.items():
147
+ response.set_cookie(key, str(value))
148
+ if result.status_code is not None:
149
+ response.status_code = result.status_code
150
+ return result.body
151
+
152
+ async def run_with_state(req_instance: Any) -> Any:
153
+ if hasattr(req_instance, "load_state"):
154
+ await req_instance.load_state()
155
+
156
+ try:
157
+ await inject_context(req_instance)
158
+ return await execute(req_instance)
159
+ finally:
160
+ if hasattr(req_instance, "save_state"):
161
+ await req_instance.save_state()
162
+
163
+ if lifecycle == "singleton":
164
+ return await run_with_state(singleton_instance)
165
+ elif lifecycle == "per-request":
166
+ req_instance = cls()
167
+ await inject_context(req_instance)
168
+ return await execute(req_instance)
169
+ elif lifecycle == "instance-key":
170
+ req_instance = cls()
171
+ return await run_with_state(req_instance)
172
+
173
+ raise ValueError(f"unknown lifecycle {lifecycle!r}; one of: singleton, per-request, instance-key")