dynamicforms-fastapi-viewsets 0.6.4__tar.gz → 0.7.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/PKG-INFO +1 -1
  2. dynamicforms_fastapi_viewsets-0.7.1/fastapi_viewsets/__init__.py +1 -0
  3. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/context/__init__.py +40 -0
  4. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/decorators/build_schema.py +15 -6
  5. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/decorators/celery_viewset/client.py +6 -7
  6. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +5 -1
  7. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/decorators/celery_viewset/server.py +7 -8
  8. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/decorators/lifecycle_runner.py +5 -1
  9. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/decorators/route_viewset.py +8 -5
  10. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/middleware/__init__.py +78 -4
  11. dynamicforms_fastapi_viewsets-0.6.4/fastapi_viewsets/__init__.py +0 -1
  12. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/.gitignore +0 -0
  13. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/LICENSE +0 -0
  14. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/README.md +0 -0
  15. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/action_configuration.py +0 -0
  16. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/backends/__init__.py +0 -0
  17. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/backends/django_orm.py +0 -0
  18. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/collection_viewset.py +0 -0
  19. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/conf.py +0 -0
  20. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/context/auth/__init__.py +0 -0
  21. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/context/auth/django.py +0 -0
  22. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/context/auth/jwt.py +0 -0
  23. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/context/auth/static.py +0 -0
  24. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/cursor.py +0 -0
  25. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/decorators/__init__.py +0 -0
  26. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -0
  27. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/decorators/conftest.py +0 -0
  28. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
  29. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/endpoint_docs.py +0 -0
  30. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/exceptions.py +0 -0
  31. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/filters/__init__.py +0 -0
  32. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/filters/base.py +0 -0
  33. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/filters/declaration.py +0 -0
  34. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/filters/operators.py +0 -0
  35. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/filters/registry.py +0 -0
  36. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/list_query.py +0 -0
  37. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/list_shapes.py +0 -0
  38. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
  39. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
  40. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
  41. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/mixins.py +0 -0
  42. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/mux_ws/__init__.py +0 -0
  43. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/mux_ws/asgi.py +0 -0
  44. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/mux_ws/protocol.py +0 -0
  45. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/mux_ws/registry.py +0 -0
  46. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/mux_ws/server.py +0 -0
  47. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/mux_ws/transports.py +0 -0
  48. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/py.typed +0 -0
  49. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/response_classes.py +0 -0
  50. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/save_state/__init__.py +0 -0
  51. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/save_state/save_state.py +0 -0
  52. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/fastapi_viewsets/save_state/serialize_state.py +0 -0
  53. {dynamicforms_fastapi_viewsets-0.6.4 → dynamicforms_fastapi_viewsets-0.7.1}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dynamicforms-fastapi-viewsets
3
- Version: 0.6.4
3
+ Version: 0.7.1
4
4
  Summary: Django REST Framework-style viewsets for FastAPI, with optional Celery-backed async execution and a Vue/TypeScript client counterpart.
5
5
  Project-URL: Homepage, https://github.com/dynamicforms/fastapi-viewsets
6
6
  Project-URL: Repository, https://github.com/dynamicforms/fastapi-viewsets
@@ -0,0 +1 @@
1
+ __version__ = "0.7.1"
@@ -6,6 +6,8 @@ import json
6
6
  from abc import ABC, abstractmethod
7
7
  from typing import Any, TYPE_CHECKING
8
8
 
9
+ from pydantic import BaseModel
10
+
9
11
  from ..conf import settings
10
12
 
11
13
  if TYPE_CHECKING:
@@ -300,6 +302,44 @@ def _resolve_class(dotted_path: str) -> type[SerializableObject]:
300
302
  return cls
301
303
 
302
304
 
305
+ def serialize_value(value: Any) -> Any:
306
+ """
307
+ Tag a single SerializableObject for JSON transport, the same convention serialize_context()
308
+ applies per context key (see _TYPE_KEY/_VALUE_KEY above); anything else passes through
309
+ unchanged. For a value crossing the Celery/Redis boundary outside a Context dict - e.g. a
310
+ command-middleware ViewSetResult returned by a celery_viewset-dispatched action (see
311
+ fastapi_viewsets.middleware.ViewSetResult).
312
+ """
313
+ if isinstance(value, SerializableObject):
314
+ return {_TYPE_KEY: _class_tag(type(value)), _VALUE_KEY: value.__serialize__()}
315
+ return value
316
+
317
+
318
+ def to_jsonable(value: Any) -> Any:
319
+ """
320
+ Recursively convert a value into a JSON-safe structure: a SerializableObject is tagged (see
321
+ serialize_value()), a Pydantic model is dumped, a list/tuple is recursed into element-wise -
322
+ anything else passes through unchanged. The one shared implementation of a conversion several
323
+ call sites need on their own single (non-Context, non-dict) return/body value - ViewSetResult's
324
+ own (de)serialization, and a celery_viewset worker's task result - rather than each keeping a
325
+ slightly different copy that drifts out of sync with what the others handle.
326
+ """
327
+ if isinstance(value, SerializableObject):
328
+ return serialize_value(value)
329
+ if isinstance(value, BaseModel):
330
+ return value.model_dump(mode="json")
331
+ if isinstance(value, (list, tuple)):
332
+ return [to_jsonable(v) for v in value]
333
+ return value
334
+
335
+
336
+ def deserialize_value(value: Any) -> Any:
337
+ """Inverse of serialize_value() - passes through anything that isn't a tagged payload."""
338
+ if isinstance(value, dict) and _TYPE_KEY in value and _VALUE_KEY in value:
339
+ return _resolve_class(value[_TYPE_KEY]).__deserialize__(value[_VALUE_KEY])
340
+ return value
341
+
342
+
303
343
  async def serialize_context(data: dict[str, Any]) -> dict[str, Any]:
304
344
  """
305
345
  Returns a JSON-safe dict: SerializableObject values are swapped for a tagged payload (see
@@ -6,6 +6,7 @@ from fastapi import APIRouter, FastAPI
6
6
  from fastapi.routing import APIRoute
7
7
 
8
8
  from ..endpoint_docs import docs_for, viewset_tags
9
+ from ..middleware import unwrap_viewset_result_type
9
10
 
10
11
 
11
12
  def _generic_args(annotation) -> tuple:
@@ -94,12 +95,15 @@ def build_schema(cls, base_path: str = "", default_tags=None, get_wrapper=None,
94
95
  and attaches them to the class. Idempotent: if already called, returns immediately.
95
96
  If get_wrapper is None, only __router is built (no FastAPI app, no endpoint wrapping).
96
97
 
97
- disable_response_model: pass True when command middleware is configured (see
98
- fastapi_viewsets/middleware.py and route_viewset.py) - middleware can change the shape of what's
99
- actually served (e.g. strip an internal-only field via ViewSetResult.body), so FastAPI must not
100
- coerce/re-validate the endpoint's return value against the ORIGINAL method's declared return
101
- type, which would silently undo those changes (missing fields reappear with their model
102
- defaults).
98
+ disable_response_model: pass True when a configured command middleware declares
99
+ modifies_response_shape=True (see Middleware.modifies_response_shape and
100
+ any_modifies_response_shape() in fastapi_viewsets/middleware/__init__.py, and route_viewset.py) -
101
+ such a middleware can change the shape of what's actually served (e.g. strip an internal-only
102
+ field via ViewSetResult.body), so FastAPI must not coerce/re-validate the endpoint's return value
103
+ against the ORIGINAL method's declared return type, which would silently undo those changes
104
+ (missing fields reappear with their model defaults). Middleware that doesn't declare this (the
105
+ default) leaves the endpoint's declared return type in place, both in the OpenAPI docs and for
106
+ FastAPI's own response validation.
103
107
  """
104
108
  if hasattr(cls, "__router") and (get_wrapper is None or getattr(cls, "__router_full", False)):
105
109
  return
@@ -163,6 +167,11 @@ def build_schema(cls, base_path: str = "", default_tags=None, get_wrapper=None,
163
167
  # answer: only route_viewset knows what this viewset bound it to.
164
168
  use_resolved = route.response_model is None or has_typevars(route.response_model)
165
169
  response_model = resolved_response_model if use_resolved else route.response_model
170
+ # route.response_model is FastAPI's own inference from the ORIGINAL declared return
171
+ # type (e.g. -> ViewSetResult[Item]), untouched by get_wrapper's own unwrap - must be
172
+ # unwrapped here too, or an endpoint declared this way would document/validate as the
173
+ # wrapper instead of the wire body whenever use_resolved is False.
174
+ response_model = unwrap_viewset_result_type(response_model)
166
175
 
167
176
  # Per-viewset wording, applied here because this is where the schema-serving app is
168
177
  # built; patching the caller's router afterwards would leave /schema showing the
@@ -6,9 +6,7 @@ from collections.abc import Awaitable, Callable
6
6
  from functools import wraps
7
7
  from typing import TYPE_CHECKING, TypeVar
8
8
 
9
- from pydantic import BaseModel
10
-
11
- from ...context import Context, serialize_context
9
+ from ...context import Context, serialize_context, to_jsonable
12
10
  from ..build_schema import build_schema
13
11
  from . import result_reader
14
12
  from .result_reader import get_result_queue_key
@@ -84,16 +82,17 @@ def celery_viewset_client(
84
82
 
85
83
 
86
84
  async def _serialize_value(value):
87
- """Convert a Context, BaseModel, list, or dict value into a JSON-safe structure for Celery/Kombu transport."""
85
+ """Convert a Context, dict, or list/tuple value into a JSON-safe structure for Celery/Kombu
86
+ transport, recursing async since Context serialization needs to await context values. Anything
87
+ else - a Pydantic model, a SerializableObject, or an already JSON-native value - is handled by
88
+ the shared, synchronous fastapi_viewsets.context.to_jsonable()."""
88
89
  if isinstance(value, Context):
89
90
  return await serialize_context(value.raw())
90
- if isinstance(value, BaseModel):
91
- return value.model_dump(mode="json")
92
91
  if isinstance(value, dict):
93
92
  return {k: await _serialize_value(v) for k, v in value.items()}
94
93
  if isinstance(value, (list, tuple)):
95
94
  return [await _serialize_value(v) for v in value]
96
- return value
95
+ return to_jsonable(value)
97
96
 
98
97
 
99
98
  def _patch_method(cls: type, original_endpoint, task_name: str, celery_app, queue_key: str):
@@ -6,6 +6,8 @@ from typing import TYPE_CHECKING
6
6
 
7
7
  from fastapi import HTTPException
8
8
 
9
+ from ...context import deserialize_value
10
+
9
11
  if TYPE_CHECKING:
10
12
  import redis
11
13
 
@@ -59,7 +61,9 @@ async def result_reader_loop(redis_client: "redis.Redis", queue_key: str, poll_i
59
61
  else:
60
62
  future.set_exception(Exception(error))
61
63
  else:
62
- future.set_result(result)
64
+ # Undoes _to_jsonable's SerializableObject tagging (see server.py) - a plain
65
+ # domain result has no _TYPE_KEY/_VALUE_KEY pair and passes through unchanged.
66
+ future.set_result(deserialize_value(result))
63
67
 
64
68
  except asyncio.CancelledError:
65
69
  break
@@ -11,7 +11,7 @@ from typing import get_args, get_origin, get_type_hints, TYPE_CHECKING, TypeVar,
11
11
  from fastapi import HTTPException
12
12
  from pydantic import BaseModel
13
13
 
14
- from ...context import Context, deserialize_context
14
+ from ...context import Context, deserialize_context, to_jsonable
15
15
  from ..build_schema import build_schema
16
16
  from ..lifecycle_runner import lifecycle_runner, LifecycleType
17
17
  from ..route_viewset import build_type_map, resolve_typevars
@@ -50,13 +50,12 @@ def _unwrap_optional(hint):
50
50
  return hint
51
51
 
52
52
 
53
- def _to_jsonable(value):
54
- """Recursively convert Pydantic models and lists to JSON-serializable structures."""
55
- if isinstance(value, BaseModel):
56
- return value.model_dump(mode="json")
57
- if isinstance(value, list):
58
- return [_to_jsonable(v) for v in value]
59
- return value
53
+ # Alias, not a re-implementation: the actual conversion (Pydantic models, lists/tuples,
54
+ # SerializableObject values - e.g. a ViewSetResult a worker-side action returned directly, see
55
+ # fastapi_viewsets.middleware) lives in fastapi_viewsets.context.to_jsonable, shared with
56
+ # ViewSetResult's own (de)serialization. Kept under this name since call sites/tests here already
57
+ # import it as `_to_jsonable`.
58
+ _to_jsonable = to_jsonable
60
59
 
61
60
 
62
61
  def _reconstruct_value(hint, value):
@@ -68,7 +68,11 @@ async def lifecycle_runner(
68
68
  return await call_bound(req_instance)
69
69
 
70
70
  async def final_handler() -> ViewSetResult:
71
- return ViewSetResult(body=await call_bound(req_instance))
71
+ raw = await call_bound(req_instance)
72
+ # An endpoint may return a ViewSetResult directly (see ViewSetResult in
73
+ # fastapi_viewsets/middleware) to reach status_code/headers/cookies itself - passed
74
+ # through as-is.
75
+ return raw if isinstance(raw, ViewSetResult) else ViewSetResult(body=raw)
72
76
 
73
77
  effective_middlewares = settings.viewsets_command_middleware + extra_middlewares_for(
74
78
  action_configuration, settings.viewsets_command_middleware
@@ -21,7 +21,7 @@ from fastapi_viewsets.endpoint_docs import (
21
21
  register_tag,
22
22
  viewset_description,
23
23
  )
24
- from fastapi_viewsets.middleware import Middleware
24
+ from fastapi_viewsets.middleware import any_modifies_response_shape, Middleware, unwrap_viewset_result_type
25
25
  from fastapi_viewsets.mixins import FilterParam
26
26
  from fastapi_viewsets.mux_ws import register_viewset, resolve_register_muxws, resolve_register_rest
27
27
 
@@ -298,7 +298,8 @@ def route_viewset(
298
298
  # right before the endpoint runs - never forwarded to the endpoint itself.
299
299
  new_params.append(inspect.Parameter("request", inspect.Parameter.KEYWORD_ONLY, annotation=Request))
300
300
 
301
- new_return_annotation = resolve_typevars(type_map, sig.return_annotation)
301
+ # -> ViewSetResult[X] documents/validates as X - see unwrap_viewset_result_type.
302
+ new_return_annotation = unwrap_viewset_result_type(resolve_typevars(type_map, sig.return_annotation))
302
303
  new_sig = sig.replace(parameters=new_params, return_annotation=new_return_annotation)
303
304
 
304
305
  @wraps(original_endpoint)
@@ -342,14 +343,16 @@ def route_viewset(
342
343
  return wrapper, new_return_annotation
343
344
 
344
345
  # Command middleware (see fastapi_viewsets/middleware.py) can reshape the response body via
345
- # ViewSetResult - if any is configured, the endpoint's declared return type can no longer
346
- # be trusted as the actual response_model, same reasoning as the old finalize_response hook.
346
+ # ViewSetResult - only middleware that actually declares modifies_response_shape=True
347
+ # untrusts the endpoint's declared return type as the response_model (same reasoning as the
348
+ # old finalize_response hook); typical middleware (auth, rate limiting, sessions) only adds
349
+ # headers/cookies and leaves the declared model - and therefore the OpenAPI docs - in place.
347
350
  build_schema(
348
351
  cls,
349
352
  base_path,
350
353
  default_tags,
351
354
  get_wrapper,
352
- disable_response_model=bool(settings.viewsets_command_middleware),
355
+ disable_response_model=any_modifies_response_shape(settings.viewsets_command_middleware),
353
356
  )
354
357
 
355
358
  muxws_routes = []
@@ -1,16 +1,18 @@
1
1
  from abc import ABC, abstractmethod
2
2
  from collections.abc import Awaitable, Callable
3
3
  from dataclasses import dataclass, field
4
- from typing import Any, TYPE_CHECKING
4
+ from typing import Any, Generic, get_args, get_origin, TYPE_CHECKING, TypeVar
5
5
 
6
- from ..context import Context
6
+ from ..context import Context, SerializableObject, to_jsonable
7
7
 
8
8
  if TYPE_CHECKING:
9
9
  from fastapi import Request
10
10
 
11
+ T = TypeVar("T")
12
+
11
13
 
12
14
  @dataclass
13
- class ViewSetResult:
15
+ class ViewSetResult(SerializableObject, Generic[T]):
14
16
  """
15
17
  Transport-agnostic result of running the command middleware chain (see run_command_chain()
16
18
  below). `body` is the actual domain value (whatever perform_* returned); `headers`/`cookies`
@@ -22,13 +24,44 @@ class ViewSetResult:
22
24
  onto real Set-Cookie, and `status_code` onto the real Response's status code; a future WS
23
25
  adapter would fold all three into the outgoing message payload as plain JSON keys instead (no
24
26
  real headers/cookies/status line exist over WS).
27
+
28
+ A registered route endpoint (a mixin-provided one, or a custom `__router` method - never a
29
+ `perform_*` hook, which is called by the actual route method and never registered as a route
30
+ itself) may also return a `ViewSetResult` directly instead of a plain body, getting the same
31
+ `status_code`/`headers`/`cookies` access normally reserved for command middleware (e.g. a
32
+ redirect: `ViewSetResult(body=None, status_code=302, headers={"Location": url})`) -
33
+ `lifecycle_runner.final_handler` passes it through unchanged. Declare the return type as
34
+ `ViewSetResult[X]` (X being whatever the wire body actually is) rather than bare `X` -
35
+ `route_viewset`/`build_schema` unwrap this to `X` for the OpenAPI response_model and FastAPI's
36
+ own response validation (see unwrap_viewset_result_type below), so the two forms
37
+ document/validate identically.
38
+
39
+ Inherits `SerializableObject` so it survives the Celery/Redis boundary: a
40
+ `celery_viewset`-dispatched action's worker-side return value is passed through
41
+ `serialize_value()`/`deserialize_value()` (see fastapi_viewsets.context) exactly like any other
42
+ `SerializableObject`, the same mechanism `LazyObject`/`Context` values already rely on. Only
43
+ `__serialize__`/`__deserialize__` are overridden here - a `ViewSetResult` is never itself placed
44
+ in a `Context` and never awaited, so `SerializableObject`'s `value`/`__await__`/`__init__` go
45
+ unused, the same way `LazyObject` replaces those with its own.
25
46
  """
26
47
 
27
- body: Any
48
+ body: T
28
49
  headers: dict[str, Any] = field(default_factory=dict)
29
50
  cookies: dict[str, Any] = field(default_factory=dict)
30
51
  status_code: int | None = None
31
52
 
53
+ def __serialize__(self) -> Any:
54
+ return {
55
+ "body": to_jsonable(self.body),
56
+ "headers": self.headers,
57
+ "cookies": self.cookies,
58
+ "status_code": self.status_code,
59
+ }
60
+
61
+ @classmethod
62
+ def __deserialize__(cls, data: Any) -> "ViewSetResult":
63
+ return cls(body=data["body"], headers=data["headers"], cookies=data["cookies"], status_code=data["status_code"])
64
+
32
65
 
33
66
  CommandMiddleware = Callable[
34
67
  ["Request | None", Any, Context, Callable[[], Awaitable[ViewSetResult]]],
@@ -57,6 +90,20 @@ class Middleware(ABC):
57
90
  concrete example) - a bare function would need extra indirection (closures, partials) for that.
58
91
  """
59
92
 
93
+ modifies_response_shape: bool = False
94
+ """
95
+ Whether this middleware can change the shape of ViewSetResult.body (strip/rename/restructure
96
+ fields, not just add side-channel headers/cookies or a status code) - see
97
+ `any_modifies_response_shape()` below. False by default: typical middleware (auth, rate
98
+ limiting, sessions) only inspects the result or attaches headers/cookies, so the endpoint's
99
+ declared return type stays a trustworthy response_model. Override to True on a middleware whose
100
+ __call__ actually reassigns `result.body` to something that no longer matches the original
101
+ return annotation - otherwise FastAPI's response_model validation will coerce the reshaped body
102
+ back towards the original model (e.g. reintroducing a stripped field with its default value).
103
+ A plain function middleware declares this the same way, by setting the attribute on the
104
+ function object: `my_middleware.modifies_response_shape = True`.
105
+ """
106
+
60
107
  def config_from(self, context: Context) -> Any:
61
108
  """
62
109
  This middleware's own @action_configuration value for the current call (see
@@ -104,6 +151,33 @@ class Middleware(ABC):
104
151
  raise NotImplementedError
105
152
 
106
153
 
154
+ def any_modifies_response_shape(middlewares: list) -> bool:
155
+ """
156
+ Whether any entry in `middlewares` (a mix of Middleware instances and/or plain functions, as
157
+ settings.viewsets_command_middleware holds) declares modifies_response_shape=True. A bare
158
+ function that never set the attribute is treated as False, same default as the Middleware
159
+ class - see Middleware.modifies_response_shape above. Used by route_viewset/build_schema to
160
+ decide whether an endpoint's declared response_model can still be trusted for OpenAPI docs and
161
+ FastAPI's own response validation.
162
+ """
163
+ return any(getattr(middleware, "modifies_response_shape", False) for middleware in middlewares)
164
+
165
+
166
+ def unwrap_viewset_result_type(annotation: Any) -> Any:
167
+ """
168
+ An endpoint declared `-> ViewSetResult[X]` (see ViewSetResult above) documents/validates as
169
+ `X` - the wire body a client actually receives, not the side-channel wrapper the endpoint
170
+ returns internally to reach `status_code`/`headers`/`cookies`. `route_viewset`/`build_schema`
171
+ call this wherever a return annotation becomes an OpenAPI response_model, so `-> ViewSetResult[X]`
172
+ and plain `-> X` document/validate identically. Anything that isn't `ViewSetResult[...]` passes
173
+ through unchanged.
174
+ """
175
+ if get_origin(annotation) is ViewSetResult:
176
+ args = get_args(annotation)
177
+ return args[0] if args else None
178
+ return annotation
179
+
180
+
107
181
  async def run_command_chain(
108
182
  middlewares: list[CommandMiddleware],
109
183
  request: "Request | None",
@@ -1 +0,0 @@
1
- __version__ = "0.6.4"