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.
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/PKG-INFO +6 -9
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/README.md +3 -6
- dynamicforms_fastapi_viewsets-0.9.0/fastapi_viewsets/__init__.py +1 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/conf.py +10 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/decorators/__init__.py +0 -4
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/decorators/build_schema.py +2 -3
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/decorators/celery_viewset/__init__.py +1 -4
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/decorators/celery_viewset/client.py +44 -41
- dynamicforms_fastapi_viewsets-0.9.0/fastapi_viewsets/decorators/celery_viewset/payload.py +56 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/decorators/celery_viewset/server.py +44 -28
- dynamicforms_fastapi_viewsets-0.9.0/fastapi_viewsets/decorators/lifecycle_runner.py +173 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/decorators/route_viewset.py +42 -9
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/exceptions.py +23 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/middleware/__init__.py +92 -0
- dynamicforms_fastapi_viewsets-0.9.0/fastapi_viewsets/middleware/idempotency.py +465 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/mux_ws/__init__.py +5 -10
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/mux_ws/asgi.py +29 -10
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/mux_ws/registry.py +6 -61
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/mux_ws/server.py +59 -16
- dynamicforms_fastapi_viewsets-0.9.0/fastapi_viewsets/routing.py +74 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/pyproject.toml +5 -2
- dynamicforms_fastapi_viewsets-0.8.0/fastapi_viewsets/__init__.py +0 -1
- dynamicforms_fastapi_viewsets-0.8.0/fastapi_viewsets/decorators/lifecycle_runner.py +0 -117
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/.gitignore +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/LICENSE +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/action_configuration.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/backends/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/backends/django_orm.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/collection_viewset.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/context/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/context/auth/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/context/auth/django.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/context/auth/jwt.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/context/auth/static.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/cursor.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/decorators/conftest.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/endpoint_docs.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/filters/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/filters/base.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/filters/declaration.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/filters/operators.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/filters/registry.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/list_query.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/list_shapes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/mixins.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/mux_ws/protocol.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/mux_ws/transports.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/py.typed +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/response_classes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/save_state/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/save_state/save_state.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
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"
|
{dynamicforms_fastapi_viewsets-0.8.0 → dynamicforms_fastapi_viewsets-0.9.0}/fastapi_viewsets/conf.py
RENAMED
|
@@ -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.
|
|
155
|
-
#
|
|
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
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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 =
|
|
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")
|