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.
Files changed (74) hide show
  1. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/PKG-INFO +4 -1
  2. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/README.md +2 -0
  3. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/__init__.py +1 -1
  4. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/context/__init__.py +9 -6
  5. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/decorators/__init__.py +3 -0
  6. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/decorators/build_schema.py +11 -6
  7. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/decorators/celery_viewset/client.py +57 -13
  8. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/decorators/celery_viewset/payload.py +6 -2
  9. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +7 -3
  10. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/decorators/celery_viewset/server.py +31 -35
  11. dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/decorators/celery_viewset/transport.py +523 -0
  12. dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/decorators/query_model.py +90 -0
  13. dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/decorators/route_viewset.py +620 -0
  14. dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/decorators/sub_viewsets.py +150 -0
  15. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/events/redis.py +3 -4
  16. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/events/scopes.py +42 -12
  17. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/filters/declaration.py +15 -41
  18. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/middleware/__init__.py +3 -4
  19. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/mixins.py +56 -5
  20. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/pyproject.toml +1 -0
  21. dynamicforms_fastapi_viewsets-0.13.0/fastapi_viewsets/decorators/route_viewset.py +0 -478
  22. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/.gitignore +0 -0
  23. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/LICENSE +0 -0
  24. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/action_configuration.py +0 -0
  25. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/backends/__init__.py +0 -0
  26. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/backends/django_orm.py +0 -0
  27. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/collection_viewset.py +0 -0
  28. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/conf.py +0 -0
  29. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/context/auth/__init__.py +0 -0
  30. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/context/auth/django.py +0 -0
  31. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/context/auth/jwt.py +0 -0
  32. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/context/auth/static.py +0 -0
  33. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/cursor.py +0 -0
  34. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -0
  35. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/decorators/conftest.py +0 -0
  36. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/decorators/lifecycle_runner.py +0 -0
  37. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
  38. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/endpoint_docs.py +0 -0
  39. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/error_namespace.py +0 -0
  40. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/error_responses.py +0 -0
  41. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/events/__init__.py +0 -0
  42. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/events/authorizers.py +0 -0
  43. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/events/changes.py +0 -0
  44. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/events/conftest.py +0 -0
  45. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/events/delivery.py +0 -0
  46. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/events/event.py +0 -0
  47. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/events/lifecycle.py +0 -0
  48. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/events/sources.py +0 -0
  49. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/events/store.py +0 -0
  50. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/events/topics.py +0 -0
  51. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/events/viewset.py +0 -0
  52. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/exceptions.py +0 -0
  53. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/filters/__init__.py +0 -0
  54. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/filters/base.py +0 -0
  55. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/filters/operators.py +0 -0
  56. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/filters/registry.py +0 -0
  57. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/list_query.py +0 -0
  58. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/list_shapes.py +0 -0
  59. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
  60. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
  61. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/middleware/idempotency.py +0 -0
  62. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
  63. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/mux_ws/__init__.py +0 -0
  64. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/mux_ws/asgi.py +0 -0
  65. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/mux_ws/protocol.py +0 -0
  66. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/mux_ws/registry.py +0 -0
  67. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/mux_ws/server.py +0 -0
  68. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/mux_ws/transports.py +0 -0
  69. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/py.typed +0 -0
  70. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/response_classes.py +0 -0
  71. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/routing.py +0 -0
  72. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/save_state/__init__.py +0 -0
  73. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.14.0}/fastapi_viewsets/save_state/save_state.py +0 -0
  74. {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.13.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
@@ -1,4 +1,4 @@
1
- __version__ = "0.13.0"
1
+ __version__ = "0.14.0"
2
2
 
3
3
  from .error_namespace import ERROR_NAMESPACE, ERROR_NAMESPACE_PYDANTIC
4
4
 
@@ -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 that need custom (de)serialization across the Celery/Redis
47
- boundary - e.g. because reconstructing them requires domain-specific logic, not just decoding
48
- a JSON literal (see settings.viewsets_context_json_encoder/decoder for the simpler case of
49
- normalising a plain value type like datetime).
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 worker/client payloads. from_jsonable() is its inverse for
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. Idempotent: if already called for `cls` itself, returns
108
- immediately; a subclass of a class it was called for gets routes of its own.
109
- If get_wrapper is None, only __router is built (no FastAPI app, no endpoint wrapping).
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 (get_wrapper is None or own.get("__router_full", False)):
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. The action's `TimeLimits`
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
- serializable_args = [await _serialize_value(v) for v in args]
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
- **serializable_kwargs,
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)", task_name, correlation_id
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=serializable_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
- return await asyncio.wait_for(future, result_timeout)
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 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.
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
- # Undoes _to_jsonable's SerializableObject tagging (see server.py) at any depth - an
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, get_type_hints, TYPE_CHECKING, TypeVar, Union
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, to_jsonable
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 ..route_viewset import build_type_map, resolve_typevars
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(original_endpoint, kwargs: dict, cls: type = None) -> dict:
120
- """Reconstruct dict values back into Pydantic BaseModel instances based on endpoint type hints.
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
- remaining = dict(kwargs)
127
-
128
- try:
129
- hints = get_type_hints(original_endpoint)
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
- result[key] = from_partial_body(TypeAdapter(partial_annotation).validate_python(value, by_name=True), hint)
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] = _reconstruct_value(hint, value)
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
- kwargs = _reconstruct_kwargs(original_endpoint, kwargs, cls)
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": _to_jsonable(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