dynamicforms-fastapi-viewsets 0.13.0__tar.gz → 0.14.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.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/PKG-INFO +4 -1
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/README.md +2 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/__init__.py +1 -1
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/context/__init__.py +9 -6
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/decorators/__init__.py +3 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/decorators/build_schema.py +11 -6
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/decorators/celery_viewset/client.py +57 -13
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/decorators/celery_viewset/payload.py +6 -2
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +7 -3
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/decorators/celery_viewset/server.py +31 -35
- dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/decorators/celery_viewset/transport.py +523 -0
- dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/decorators/query_model.py +90 -0
- dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/decorators/route_viewset.py +620 -0
- dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/decorators/sub_viewsets.py +150 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/events/redis.py +3 -4
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/events/scopes.py +42 -12
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/filters/declaration.py +15 -41
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/middleware/__init__.py +3 -4
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/mixins.py +56 -5
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/pyproject.toml +1 -0
- dynamicforms_fastapi_viewsets-0.13.0/fastapi_viewsets/decorators/route_viewset.py +0 -478
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/.gitignore +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/LICENSE +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/action_configuration.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/backends/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/backends/django_orm.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/collection_viewset.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/conf.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/context/auth/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/context/auth/django.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/context/auth/jwt.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/context/auth/static.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/cursor.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/decorators/conftest.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/decorators/lifecycle_runner.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/endpoint_docs.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/error_namespace.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/error_responses.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/events/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/events/authorizers.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/events/changes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/events/conftest.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/events/delivery.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/events/event.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/events/lifecycle.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/events/sources.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/events/store.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/events/topics.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/events/viewset.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/exceptions.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/filters/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/filters/base.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/filters/operators.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/filters/registry.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/list_query.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/list_shapes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/middleware/idempotency.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/mux_ws/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/mux_ws/asgi.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/mux_ws/protocol.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/mux_ws/registry.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/mux_ws/server.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/mux_ws/transports.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/py.typed +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/response_classes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/routing.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/save_state/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/save_state/save_state.py +0 -0
- {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.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.14.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
|
|
@@ -40,6 +40,7 @@ Requires-Dist: pyjwt; extra == 'dev'
|
|
|
40
40
|
Requires-Dist: pytest; extra == 'dev'
|
|
41
41
|
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
42
42
|
Requires-Dist: pytest-cov; extra == 'dev'
|
|
43
|
+
Requires-Dist: python-multipart; extra == 'dev'
|
|
43
44
|
Requires-Dist: redis; extra == 'dev'
|
|
44
45
|
Requires-Dist: ruff; extra == 'dev'
|
|
45
46
|
Requires-Dist: uvicorn; extra == 'dev'
|
|
@@ -60,6 +61,8 @@ Celery-backed async execution and a matching Vue/TypeScript client counterpart.
|
|
|
60
61
|
- **Python mixins for FastAPI** — compose CRUD and bulk endpoints from small, focused mixin classes.
|
|
61
62
|
- **`route_viewset` decorator** — register a viewset on a FastAPI router with a single decorator call.
|
|
62
63
|
Handles type resolution, lifecycle management and OpenAPI schema automatically.
|
|
64
|
+
- **Nested viewsets** — mount a viewset under another one's path (`/accounts/members`) with
|
|
65
|
+
`ViewSetRouter.sub_viewset`; a request to it passes the parent's checks and its own.
|
|
63
66
|
- **`CollectionViewSet`** — zero-boilerplate in-memory viewset backed by any Python list, set or dict.
|
|
64
67
|
Great for prototyping and testing.
|
|
65
68
|
- **`DjangoORMViewSet`** — back a viewset with a Django QuerySet. The filters it can compile, the
|
|
@@ -6,6 +6,8 @@ Celery-backed async execution and a matching Vue/TypeScript client counterpart.
|
|
|
6
6
|
- **Python mixins for FastAPI** — compose CRUD and bulk endpoints from small, focused mixin classes.
|
|
7
7
|
- **`route_viewset` decorator** — register a viewset on a FastAPI router with a single decorator call.
|
|
8
8
|
Handles type resolution, lifecycle management and OpenAPI schema automatically.
|
|
9
|
+
- **Nested viewsets** — mount a viewset under another one's path (`/accounts/members`) with
|
|
10
|
+
`ViewSetRouter.sub_viewset`; a request to it passes the parent's checks and its own.
|
|
9
11
|
- **`CollectionViewSet`** — zero-boilerplate in-memory viewset backed by any Python list, set or dict.
|
|
10
12
|
Great for prototyping and testing.
|
|
11
13
|
- **`DjangoORMViewSet`** — back a viewset with a Django QuerySet. The filters it can compile, the
|
|
@@ -43,10 +43,13 @@ def _resolve_config_value(value: Any, action_name: "str | None") -> Any:
|
|
|
43
43
|
|
|
44
44
|
class SerializableObject(ABC):
|
|
45
45
|
"""
|
|
46
|
-
Base class for context values
|
|
47
|
-
boundary - e.g. because reconstructing them
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
Base class for context values, and `celery_viewset` action arguments and results, that need
|
|
47
|
+
custom (de)serialization across the Celery/Redis boundary - e.g. because reconstructing them
|
|
48
|
+
requires domain-specific logic, not just decoding a JSON literal (see
|
|
49
|
+
settings.viewsets_context_json_encoder/decoder for the simpler case of normalising a plain
|
|
50
|
+
value type like datetime in context). An action argument or result crosses as its tagged
|
|
51
|
+
payload at any depth of lists and dicts, whatever its annotation (see
|
|
52
|
+
decorators/celery_viewset/transport.py).
|
|
50
53
|
|
|
51
54
|
`value` holds the actual wrapped value. Subclasses implement the "magic" __serialize__/
|
|
52
55
|
__deserialize__ methods; serialize_context()/deserialize_context() detect instances of this
|
|
@@ -349,8 +352,8 @@ def to_jsonable(value: Any) -> Any:
|
|
|
349
352
|
list/tuple element-wise, and any other value goes through pydantic_core.to_jsonable_python()
|
|
350
353
|
(datetime, Decimal, UUID, Enum, set, dataclass, ...) - the encoding FastAPI's response
|
|
351
354
|
serialization applies to the same value on REST/muxws. Shared by ViewSetResult's own
|
|
352
|
-
serialization and celery_viewset's
|
|
353
|
-
tagged objects.
|
|
355
|
+
serialization and celery_viewset's encoding of a value its type hint does not type (see
|
|
356
|
+
decorators/celery_viewset/transport.py). from_jsonable() is its inverse for tagged objects.
|
|
354
357
|
"""
|
|
355
358
|
if isinstance(value, SerializableObject):
|
|
356
359
|
return serialize_value(value)
|
|
@@ -5,6 +5,7 @@ from .celery_viewset import (
|
|
|
5
5
|
set_is_celery_worker,
|
|
6
6
|
)
|
|
7
7
|
from .route_viewset import route_viewset
|
|
8
|
+
from .sub_viewsets import SubViewSet, ViewSetRouter
|
|
8
9
|
|
|
9
10
|
__all__ = [
|
|
10
11
|
"celery_viewset",
|
|
@@ -12,4 +13,6 @@ __all__ = [
|
|
|
12
13
|
"celery_viewset_server",
|
|
13
14
|
"route_viewset",
|
|
14
15
|
"set_is_celery_worker",
|
|
16
|
+
"SubViewSet",
|
|
17
|
+
"ViewSetRouter",
|
|
15
18
|
]
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import inspect
|
|
1
2
|
import warnings
|
|
2
3
|
|
|
3
4
|
from typing import get_args, TypeVar
|
|
4
5
|
|
|
5
6
|
from fastapi import APIRouter, FastAPI
|
|
6
7
|
from fastapi.routing import APIRoute
|
|
8
|
+
from starlette.responses import Response
|
|
7
9
|
|
|
8
10
|
from ..endpoint_docs import docs_for, viewset_tags
|
|
9
11
|
from ..middleware import unwrap_viewset_result_type
|
|
@@ -104,9 +106,10 @@ def action_openapi_extra(action_name: str, openapi_extra: dict | None) -> dict:
|
|
|
104
106
|
def build_schema(cls, base_path: str = "", default_tags=None, get_wrapper=None, disable_response_model=False):
|
|
105
107
|
"""
|
|
106
108
|
Builds __router (APIRouter with all routes from mixins) and __app (FastAPI instance),
|
|
107
|
-
and attaches them to the class.
|
|
108
|
-
|
|
109
|
-
|
|
109
|
+
and attaches them to the class. If get_wrapper is None, only __router is built (no FastAPI app,
|
|
110
|
+
no endpoint wrapping), and only when `cls` itself has none yet. With get_wrapper the routes are
|
|
111
|
+
built on every call, so each routing of a class wraps its endpoints for its own base path and
|
|
112
|
+
instance; a subclass of a class it was called for gets routes of its own.
|
|
110
113
|
|
|
111
114
|
disable_response_model: pass True when a configured command middleware declares
|
|
112
115
|
modifies_response_shape=True (see Middleware.modifies_response_shape and
|
|
@@ -119,7 +122,7 @@ def build_schema(cls, base_path: str = "", default_tags=None, get_wrapper=None,
|
|
|
119
122
|
FastAPI's own response validation.
|
|
120
123
|
"""
|
|
121
124
|
own = vars(cls)
|
|
122
|
-
if "__router" in own and
|
|
125
|
+
if "__router" in own and get_wrapper is None:
|
|
123
126
|
return
|
|
124
127
|
|
|
125
128
|
celery_mode = get_wrapper is None
|
|
@@ -185,6 +188,10 @@ def build_schema(cls, base_path: str = "", default_tags=None, get_wrapper=None,
|
|
|
185
188
|
# unwrapped here too, or an endpoint declared this way would document/validate as the
|
|
186
189
|
# wrapper instead of the wire body whenever use_resolved is False.
|
|
187
190
|
response_model = unwrap_viewset_result_type(response_model)
|
|
191
|
+
# an endpoint returning a Response answers with it as it is, as FastAPI does for an
|
|
192
|
+
# endpoint annotated with a Response class
|
|
193
|
+
if inspect.isclass(response_model) and issubclass(response_model, Response):
|
|
194
|
+
response_model = None
|
|
188
195
|
|
|
189
196
|
# Per-viewset wording, applied here because this is where the schema-serving app is
|
|
190
197
|
# built; patching the caller's router afterwards would leave /schema showing the
|
|
@@ -212,8 +219,6 @@ def build_schema(cls, base_path: str = "", default_tags=None, get_wrapper=None,
|
|
|
212
219
|
class_router.routes.append(route)
|
|
213
220
|
|
|
214
221
|
cls.__router = class_router
|
|
215
|
-
if not celery_mode:
|
|
216
|
-
cls.__router_full = True
|
|
217
222
|
|
|
218
223
|
|
|
219
224
|
def route_sort_key(route: APIRoute):
|
|
@@ -10,14 +10,15 @@ from pydantic import BaseModel
|
|
|
10
10
|
|
|
11
11
|
from ...action_configuration import resolve_action_configuration, TimeLimits
|
|
12
12
|
from ...context import _Deferred, Context, SerializableObject, serialize_context, to_jsonable
|
|
13
|
-
from ...exceptions import ActionDispatchError, ResultTimeoutError
|
|
13
|
+
from ...exceptions import ActionDispatchError, ActionFailedError, ResultTimeoutError
|
|
14
14
|
from ...middleware import ActionTransition, run_transition_listeners
|
|
15
15
|
from ..build_schema import build_schema
|
|
16
16
|
from ..lifecycle_runner import current_call, DEFERRED_ACTIONS_ATTRIBUTE, effective_middlewares
|
|
17
17
|
from ..primary_key_model_helper import PARTIAL_UPDATE_ACTIONS
|
|
18
18
|
from . import result_reader
|
|
19
|
-
from .payload import CONTEXT_KWARG, PARENT_KWARG, REQUEST_KWARG, request_to_jsonable
|
|
19
|
+
from .payload import CONTEXT_KWARG, PARENT_KWARG, REQUEST_KWARG, request_to_jsonable, UNTYPED_KWARG
|
|
20
20
|
from .result_reader import get_process_queue_key, get_result_queue_key
|
|
21
|
+
from .transport import ActionCodec, EncodedResult
|
|
21
22
|
|
|
22
23
|
if TYPE_CHECKING:
|
|
23
24
|
import redis
|
|
@@ -51,7 +52,8 @@ def celery_viewset_client(
|
|
|
51
52
|
send Celery tasks and await results via a Redis result queue.
|
|
52
53
|
|
|
53
54
|
A call whose result does not arrive within result_timeout seconds raises ResultTimeoutError (504).
|
|
54
|
-
None waits without a limit.
|
|
55
|
+
None waits without a limit. Arguments and results are encoded by the action's type hints (see
|
|
56
|
+
`transport`).
|
|
55
57
|
"""
|
|
56
58
|
queue_key = get_result_queue_key(task_prefix)
|
|
57
59
|
|
|
@@ -99,6 +101,33 @@ async def _serialize_value(value, partial: bool = False):
|
|
|
99
101
|
return to_jsonable(value)
|
|
100
102
|
|
|
101
103
|
|
|
104
|
+
async def _encode_arguments(
|
|
105
|
+
codec: ActionCodec, positional: tuple, named: dict, partial: bool
|
|
106
|
+
) -> tuple[list, dict, list[str]]:
|
|
107
|
+
"""The Celery args and kwargs of a call's arguments, and the names of the kwargs that crossed
|
|
108
|
+
untyped (see `transport`). An argument that holds an `UploadFile` is encoded in a worker thread,
|
|
109
|
+
which reads the file."""
|
|
110
|
+
|
|
111
|
+
async def encode(name: str, value):
|
|
112
|
+
kind = codec.params[name].kind if name in codec.params else "untyped"
|
|
113
|
+
if kind in ("model", "partial"):
|
|
114
|
+
return await _serialize_value(value, partial), True
|
|
115
|
+
if isinstance(value, Context):
|
|
116
|
+
return await _serialize_value(value), False
|
|
117
|
+
if codec.reads_uploads:
|
|
118
|
+
return await asyncio.to_thread(codec.encode_argument, name, value)
|
|
119
|
+
return codec.encode_argument(name, value)
|
|
120
|
+
|
|
121
|
+
encoded_args = [await _serialize_value(value) for value in positional]
|
|
122
|
+
encoded_kwargs: dict = {}
|
|
123
|
+
untyped: list[str] = []
|
|
124
|
+
for name, value in named.items():
|
|
125
|
+
encoded_kwargs[name], typed = await encode(name, value)
|
|
126
|
+
if not typed:
|
|
127
|
+
untyped.append(name)
|
|
128
|
+
return encoded_args, encoded_kwargs, untyped
|
|
129
|
+
|
|
130
|
+
|
|
102
131
|
def _time_limit_options(context: Context) -> dict:
|
|
103
132
|
"""The `send_task` time limit options of the call's `TimeLimits` configuration."""
|
|
104
133
|
limits = context.configuration_for(TimeLimits)
|
|
@@ -118,15 +147,21 @@ def _patch_method(
|
|
|
118
147
|
"""Replace the method on cls with an async version that sends a Celery task and awaits the result.
|
|
119
148
|
|
|
120
149
|
The task carries the call's context, request and parent execution (see `payload`) from
|
|
121
|
-
`current_call()`; a `context` the caller passes replaces the call's.
|
|
150
|
+
`current_call()`; a `context` the caller passes replaces the call's. Positional arguments are
|
|
151
|
+
sent as the keyword arguments of their parameters, each encoded by its parameter's type hint
|
|
152
|
+
(see `transport`); the names of those that crossed untyped are sent under `UNTYPED_KWARG`, and
|
|
153
|
+
the result is decoded by the return annotation. The action's `TimeLimits`
|
|
122
154
|
configuration is sent as the task's time limits. Arguments that cannot be serialized raise
|
|
123
|
-
`ActionDispatchError` (reason `serialization`), a send that fails raises it with reason `send
|
|
155
|
+
`ActionDispatchError` (reason `serialization`), a send that fails raises it with reason `send`,
|
|
156
|
+
and a result that cannot be decoded raises `ActionFailedError` (reason `result`).
|
|
124
157
|
A deferred context value (`ChangeEvents`'s scope of a call) is computed before the arguments
|
|
125
158
|
are serialized; its exception propagates unchanged and nothing is sent.
|
|
126
159
|
Once the task is sent, `Middleware.on_transition()` of the action's effective middlewares
|
|
127
160
|
receives `dispatched`. The action's name joins the class's `DEFERRED_ACTIONS_ATTRIBUTE`."""
|
|
128
161
|
method_name = original_endpoint.__name__
|
|
129
162
|
declares_request = "request" in inspect.signature(original_endpoint).parameters
|
|
163
|
+
codec = ActionCodec(cls, original_endpoint)
|
|
164
|
+
partial = method_name in PARTIAL_UPDATE_ACTIONS
|
|
130
165
|
|
|
131
166
|
@wraps(original_endpoint)
|
|
132
167
|
async def async_client_wrapper(_self, *args, **kwargs):
|
|
@@ -148,13 +183,12 @@ def _patch_method(
|
|
|
148
183
|
for value in list(context.raw().values()):
|
|
149
184
|
if isinstance(value, _Deferred):
|
|
150
185
|
await value
|
|
186
|
+
positional, named = codec.bind(args, kwargs)
|
|
151
187
|
try:
|
|
152
|
-
|
|
153
|
-
serializable_kwargs = {
|
|
154
|
-
k: await _serialize_value(v, method_name in PARTIAL_UPDATE_ACTIONS) for k, v in kwargs.items()
|
|
155
|
-
}
|
|
188
|
+
encoded_args, encoded_kwargs, untyped = await _encode_arguments(codec, positional, named, partial)
|
|
156
189
|
task_kwargs = {
|
|
157
|
-
**
|
|
190
|
+
**encoded_kwargs,
|
|
191
|
+
**({UNTYPED_KWARG: untyped} if untyped else {}),
|
|
158
192
|
CONTEXT_KWARG: await serialize_context(context.raw()),
|
|
159
193
|
REQUEST_KWARG: request_to_jsonable(call.request if call is not None else None),
|
|
160
194
|
PARENT_KWARG: parent.to_jsonable() if parent is not None else None,
|
|
@@ -163,7 +197,10 @@ def _patch_method(
|
|
|
163
197
|
}
|
|
164
198
|
except Exception as error:
|
|
165
199
|
logger.error(
|
|
166
|
-
"Celery task arguments not serializable: %s (correlation_id=%s)",
|
|
200
|
+
"Celery task arguments not serializable: %s (correlation_id=%s): %s",
|
|
201
|
+
task_name,
|
|
202
|
+
correlation_id,
|
|
203
|
+
error,
|
|
167
204
|
)
|
|
168
205
|
raise ActionDispatchError(task_name, "serialization") from error
|
|
169
206
|
logger.info("Celery task scheduling: %s (correlation_id=%s)", task_name, correlation_id)
|
|
@@ -172,7 +209,7 @@ def _patch_method(
|
|
|
172
209
|
await asyncio.to_thread(
|
|
173
210
|
celery_app.send_task,
|
|
174
211
|
task_name,
|
|
175
|
-
args=
|
|
212
|
+
args=encoded_args,
|
|
176
213
|
kwargs=task_kwargs,
|
|
177
214
|
# send_task() defaults ignore_result to False regardless of what the task was
|
|
178
215
|
# registered with (celery_viewset_server sets ignore_result=True) - Celery's
|
|
@@ -189,10 +226,17 @@ def _patch_method(
|
|
|
189
226
|
ActionTransition("dispatched", cls, method_name, bound_context, parent, None),
|
|
190
227
|
)
|
|
191
228
|
try:
|
|
192
|
-
|
|
229
|
+
result = await asyncio.wait_for(future, result_timeout)
|
|
193
230
|
except asyncio.TimeoutError:
|
|
194
231
|
logger.error("Celery task timed out: %s (correlation_id=%s)", task_name, correlation_id)
|
|
195
232
|
raise ResultTimeoutError(task_name, result_timeout) from None
|
|
233
|
+
if not isinstance(result, EncodedResult):
|
|
234
|
+
return result
|
|
235
|
+
try:
|
|
236
|
+
return codec.decode_result(result)
|
|
237
|
+
except Exception as error:
|
|
238
|
+
logger.exception("Result of %s (correlation_id=%s) cannot be read", task_name, correlation_id)
|
|
239
|
+
raise ActionFailedError(task_name, "result") from error
|
|
196
240
|
finally:
|
|
197
241
|
result_reader.unregister_future(correlation_id)
|
|
198
242
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""
|
|
2
2
|
The call data a `celery_viewset` task carries beside the action's own arguments: the call's context,
|
|
3
|
-
its request
|
|
4
|
-
reserved kwargs below, and `celery_viewset_server`
|
|
3
|
+
its request, the execution it was dispatched in and the arguments that crossed untyped.
|
|
4
|
+
`celery_viewset_client` writes them under the reserved kwargs below, and `celery_viewset_server`
|
|
5
|
+
consumes them before the action is called.
|
|
5
6
|
"""
|
|
6
7
|
|
|
7
8
|
from typing import Any
|
|
@@ -13,6 +14,9 @@ CONTEXT_KWARG = "_context"
|
|
|
13
14
|
"""The call's context, as `serialize_context` encodes it."""
|
|
14
15
|
REQUEST_KWARG = "_request"
|
|
15
16
|
"""The call's request, as `request_to_jsonable` encodes it; None without one."""
|
|
17
|
+
UNTYPED_KWARG = "_untyped"
|
|
18
|
+
"""The names of the arguments that crossed untyped (see `transport`); absent when every argument
|
|
19
|
+
crossed by its type hint."""
|
|
16
20
|
PARENT_KWARG = "_parent"
|
|
17
21
|
"""The `ExecutionRef` the call was dispatched in, as `ExecutionRef.to_jsonable` encodes it; None
|
|
18
22
|
for a call a request made."""
|
|
@@ -11,6 +11,7 @@ from fastapi.exceptions import RequestValidationError
|
|
|
11
11
|
|
|
12
12
|
from ...context import from_jsonable
|
|
13
13
|
from ...exceptions import _error_from_jsonable, _error_to_jsonable, ActionFailedError, ResultReaderStoppedError
|
|
14
|
+
from .transport import EncodedResult
|
|
14
15
|
|
|
15
16
|
if TYPE_CHECKING:
|
|
16
17
|
import redis
|
|
@@ -111,9 +112,11 @@ def _resolve(future: asyncio.Future, data: dict) -> None:
|
|
|
111
112
|
try:
|
|
112
113
|
if data.get("error") is not None:
|
|
113
114
|
future.set_exception(_payload_error(data))
|
|
115
|
+
elif "result_encoding" in data:
|
|
116
|
+
# decoded by the caller, which knows the action's return annotation (see transport.py)
|
|
117
|
+
future.set_result(EncodedResult(data.get("result"), data["result_encoding"]))
|
|
114
118
|
else:
|
|
115
|
-
#
|
|
116
|
-
# untagged value passes through unchanged.
|
|
119
|
+
# a result pushed by push_result(): every SerializableObject in it is rebuilt, at any depth
|
|
117
120
|
future.set_result(from_jsonable(data.get("result")))
|
|
118
121
|
except Exception as error:
|
|
119
122
|
logger.exception("Result of %s (correlation_id=%s) cannot be read", task, data.get("correlation_id"))
|
|
@@ -172,7 +175,8 @@ def push_result(
|
|
|
172
175
|
of a class `fastapi_viewsets.exceptions` defines keeps its class), and so does a
|
|
173
176
|
`RequestValidationError`; any other error reaches it as `ActionFailedError`. `task` names the
|
|
174
177
|
call's task in that error. The FastAPI process answers each as `fastapi_viewsets.error_responses`
|
|
175
|
-
answers it.
|
|
178
|
+
answers it. A `result` reaches the caller in its JSON form, with every `SerializableObject` in it
|
|
179
|
+
rebuilt; it is not decoded by the action's return annotation.
|
|
176
180
|
"""
|
|
177
181
|
if error is not None:
|
|
178
182
|
data = error_payload(correlation_id, task, error)
|
|
@@ -17,7 +17,7 @@ from collections import OrderedDict
|
|
|
17
17
|
from collections.abc import Callable
|
|
18
18
|
from dataclasses import dataclass
|
|
19
19
|
from functools import wraps
|
|
20
|
-
from typing import Any, get_args, get_origin,
|
|
20
|
+
from typing import Any, get_args, get_origin, TYPE_CHECKING, TypeVar, Union
|
|
21
21
|
|
|
22
22
|
from fastapi import HTTPException
|
|
23
23
|
from fastapi.exceptions import RequestValidationError
|
|
@@ -25,7 +25,7 @@ from pydantic import BaseModel, TypeAdapter, ValidationError
|
|
|
25
25
|
|
|
26
26
|
from ...action_configuration import resolve_action_configuration
|
|
27
27
|
from ...conf import settings
|
|
28
|
-
from ...context import Context, deserialize_context,
|
|
28
|
+
from ...context import Context, deserialize_context, from_jsonable
|
|
29
29
|
from ...exceptions import ActionDispatchError, ActionFailedError, ActionInterruptedError, DfViewSetError
|
|
30
30
|
from ...middleware import ActionTransition, ExecutionRef, run_executor_started, run_transition_listeners
|
|
31
31
|
from ..build_schema import build_schema
|
|
@@ -34,12 +34,11 @@ from ..primary_key_model_helper import (
|
|
|
34
34
|
create_model_without_pk,
|
|
35
35
|
from_partial_body,
|
|
36
36
|
partial_body_annotation,
|
|
37
|
-
PARTIAL_UPDATE_ACTIONS,
|
|
38
37
|
typecast_to_original_model,
|
|
39
38
|
)
|
|
40
|
-
from
|
|
41
|
-
from .payload import CONTEXT_KWARG, PARENT_KWARG, request_from_jsonable, REQUEST_KWARG
|
|
39
|
+
from .payload import CONTEXT_KWARG, PARENT_KWARG, request_from_jsonable, REQUEST_KWARG, UNTYPED_KWARG
|
|
42
40
|
from .result_reader import error_payload, push_payload
|
|
41
|
+
from .transport import ActionCodec
|
|
43
42
|
|
|
44
43
|
if TYPE_CHECKING:
|
|
45
44
|
import redis
|
|
@@ -62,12 +61,6 @@ def _unwrap_optional(hint):
|
|
|
62
61
|
return hint
|
|
63
62
|
|
|
64
63
|
|
|
65
|
-
# `fastapi_viewsets.context.to_jsonable`: converts Pydantic models, dicts, lists/tuples and
|
|
66
|
-
# SerializableObject values (e.g. a ViewSetResult a worker-side action returned directly); also used
|
|
67
|
-
# by ViewSetResult's own (de)serialization.
|
|
68
|
-
_to_jsonable = to_jsonable
|
|
69
|
-
|
|
70
|
-
|
|
71
64
|
def _reconstruct_value(hint, value):
|
|
72
65
|
"""Rebuild a dict (or list/tuple of dicts) back into BaseModel instance(s) per `hint`.
|
|
73
66
|
|
|
@@ -116,35 +109,31 @@ def _sole_missing_field(model: type[BaseModel], error: ValidationError) -> str |
|
|
|
116
109
|
_model_without_pk = functools.lru_cache(create_model_without_pk)
|
|
117
110
|
|
|
118
111
|
|
|
119
|
-
def _reconstruct_kwargs(
|
|
120
|
-
|
|
112
|
+
def _reconstruct_kwargs(
|
|
113
|
+
original_endpoint, kwargs: dict, cls: type = None, untyped=(), codec: ActionCodec | None = None
|
|
114
|
+
) -> dict:
|
|
115
|
+
"""The action's keyword arguments from the task's kwargs, each decoded by its parameter's kind
|
|
116
|
+
(see `transport`): a model validated as its model, a partial update body as the partial model the
|
|
117
|
+
FastAPI side validated it with, a typed value validated by its hint, and a value named in
|
|
118
|
+
`untyped` (or of an untyped parameter) with every `SerializableObject` in it rebuilt.
|
|
121
119
|
|
|
122
120
|
The call's context, request and parent arrive under the reserved kwargs of `payload`, which the
|
|
123
121
|
task consumes before this runs.
|
|
124
122
|
"""
|
|
123
|
+
codec = codec or ActionCodec(cls, original_endpoint)
|
|
125
124
|
result = {}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
except Exception:
|
|
131
|
-
result.update(remaining)
|
|
132
|
-
return result
|
|
133
|
-
|
|
134
|
-
type_map = build_type_map(cls) if cls is not None else {}
|
|
135
|
-
|
|
136
|
-
partial = original_endpoint.__name__ in PARTIAL_UPDATE_ACTIONS
|
|
137
|
-
for key, value in remaining.items():
|
|
138
|
-
hint = hints.get(key)
|
|
139
|
-
if hint is not None:
|
|
140
|
-
hint = resolve_typevars(type_map, hint)
|
|
141
|
-
partial_annotation = partial_body_annotation(hint, None) if partial else None
|
|
142
|
-
if partial_annotation is not None:
|
|
125
|
+
for key, value in kwargs.items():
|
|
126
|
+
param = codec.params.get(key)
|
|
127
|
+
kind = param.kind if param is not None else "untyped"
|
|
128
|
+
if kind == "partial":
|
|
143
129
|
# validated as the partial model the FastAPI side validated it with, so the model holds
|
|
144
130
|
# the fields the request stated and no others
|
|
145
|
-
|
|
131
|
+
annotation = partial_body_annotation(param.hint, None)
|
|
132
|
+
result[key] = from_partial_body(TypeAdapter(annotation).validate_python(value, by_name=True), param.hint)
|
|
133
|
+
elif kind == "model":
|
|
134
|
+
result[key] = _reconstruct_value(param.hint, value)
|
|
146
135
|
else:
|
|
147
|
-
result[key] =
|
|
136
|
+
result[key] = codec.decode_argument(key, value, kind == "typed" and key not in untyped)
|
|
148
137
|
return result
|
|
149
138
|
|
|
150
139
|
|
|
@@ -561,7 +550,9 @@ def celery_viewset_server(
|
|
|
561
550
|
"""
|
|
562
551
|
Decorator for the Celery worker side. Registers a task per route of the viewset that executes
|
|
563
552
|
the action and pushes its result or error to the caller's result queue through `redis_client`
|
|
564
|
-
(a synchronous `redis.Redis`, required).
|
|
553
|
+
(a synchronous `redis.Redis`, required). The action's arguments are decoded, and its result
|
|
554
|
+
encoded, by its type hints (see `transport`); a result that cannot be encoded fails the call
|
|
555
|
+
with `ActionFailedError` (reason `result`).
|
|
565
556
|
|
|
566
557
|
The tasks are registered with `ignore_result=True`, `acks_late=False`,
|
|
567
558
|
`reject_on_worker_lost=False` and `store_errors_even_if_ignored=False`, overriding the
|
|
@@ -585,6 +576,7 @@ def celery_viewset_server(
|
|
|
585
576
|
|
|
586
577
|
def get_sync_wrapper(original_endpoint, task_name: str):
|
|
587
578
|
parameters = inspect.signature(original_endpoint).parameters
|
|
579
|
+
codec = ActionCodec(cls, original_endpoint)
|
|
588
580
|
|
|
589
581
|
@wraps(original_endpoint)
|
|
590
582
|
def sync_wrapper(*args, **kwargs):
|
|
@@ -602,7 +594,9 @@ def celery_viewset_server(
|
|
|
602
594
|
context = Context(deserialize_context(kwargs.pop(CONTEXT_KWARG, None) or {}))
|
|
603
595
|
request = request_from_jsonable(kwargs.pop(REQUEST_KWARG, None))
|
|
604
596
|
parent = ExecutionRef.from_jsonable(kwargs.pop(PARENT_KWARG, None))
|
|
605
|
-
|
|
597
|
+
untyped = kwargs.pop(UNTYPED_KWARG, None) or ()
|
|
598
|
+
args = tuple(from_jsonable(arg) for arg in args)
|
|
599
|
+
kwargs = _reconstruct_kwargs(original_endpoint, kwargs, cls, untyped, codec)
|
|
606
600
|
marker = execution_translator.set(run.translate)
|
|
607
601
|
try:
|
|
608
602
|
with _soft_limit_raises(task_name):
|
|
@@ -628,10 +622,12 @@ def celery_viewset_server(
|
|
|
628
622
|
logger.info("Celery task completed: %s (correlation_id=%s)", task_name, correlation_id)
|
|
629
623
|
if correlation_id and result_queue_key:
|
|
630
624
|
try:
|
|
625
|
+
data, encoding = codec.encode_result(result)
|
|
631
626
|
payload = {
|
|
632
627
|
"correlation_id": correlation_id,
|
|
633
628
|
"task": task_name,
|
|
634
|
-
"result":
|
|
629
|
+
"result": data,
|
|
630
|
+
"result_encoding": encoding,
|
|
635
631
|
}
|
|
636
632
|
except Exception as error:
|
|
637
633
|
raise ActionFailedError(task_name, "result") from error
|