dynamicforms-fastapi-viewsets 0.5.0__tar.gz → 0.5.2__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.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/.gitignore +1 -0
  2. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/PKG-INFO +1 -1
  3. dynamicforms_fastapi_viewsets-0.5.2/fastapi_viewsets/__init__.py +1 -0
  4. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/backends/django_orm.py +8 -7
  5. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/collection_viewset.py +1 -3
  6. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/context/auth/jwt.py +1 -1
  7. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/cursor.py +5 -4
  8. dynamicforms_fastapi_viewsets-0.5.2/fastapi_viewsets/decorators/__init__.py +19 -0
  9. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/decorators/build_schema.py +11 -10
  10. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/decorators/celery_viewset/__init__.py +6 -3
  11. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/decorators/celery_viewset/client.py +24 -3
  12. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/decorators/celery_viewset/server.py +41 -9
  13. dynamicforms_fastapi_viewsets-0.5.2/fastapi_viewsets/decorators/conftest.py +14 -0
  14. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/decorators/lifecycle_runner.py +1 -1
  15. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/decorators/primary_key_model_helper.py +7 -2
  16. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/decorators/route_viewset.py +41 -38
  17. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/endpoint_docs.py +1 -0
  18. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/filters/declaration.py +1 -5
  19. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/filters/registry.py +1 -0
  20. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/list_shapes.py +1 -2
  21. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/mixins.py +16 -12
  22. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/mux_ws/protocol.py +1 -5
  23. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/mux_ws/registry.py +1 -0
  24. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/response_classes.py +3 -1
  25. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/save_state/save_state.py +1 -0
  26. dynamicforms_fastapi_viewsets-0.5.0/fastapi_viewsets/__init__.py +0 -1
  27. dynamicforms_fastapi_viewsets-0.5.0/fastapi_viewsets/decorators/__init__.py +0 -10
  28. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/LICENSE +0 -0
  29. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/README.md +0 -0
  30. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/action_configuration.py +0 -0
  31. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/backends/__init__.py +0 -0
  32. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/conf.py +0 -0
  33. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/context/__init__.py +0 -0
  34. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/context/auth/__init__.py +0 -0
  35. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/context/auth/django.py +0 -0
  36. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/context/auth/static.py +0 -0
  37. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +0 -0
  38. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/filters/__init__.py +0 -0
  39. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/filters/base.py +0 -0
  40. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/filters/operators.py +0 -0
  41. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/list_query.py +0 -0
  42. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/middleware/__init__.py +0 -0
  43. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
  44. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
  45. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
  46. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/mux_ws/__init__.py +0 -0
  47. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/mux_ws/asgi.py +0 -0
  48. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/mux_ws/server.py +0 -0
  49. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/mux_ws/transports.py +0 -0
  50. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/py.typed +0 -0
  51. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/save_state/__init__.py +0 -0
  52. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/fastapi_viewsets/save_state/serialize_state.py +1 -1
  53. {dynamicforms_fastapi_viewsets-0.5.0 → dynamicforms_fastapi_viewsets-0.5.2}/pyproject.toml +0 -0
@@ -19,3 +19,4 @@ build/
19
19
  .pytest_cache/
20
20
  .ruff_cache/
21
21
  .coverage
22
+ coverage.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dynamicforms-fastapi-viewsets
3
- Version: 0.5.0
3
+ Version: 0.5.2
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.5.2"
@@ -180,9 +180,7 @@ class DjangoORMViewSet(ImplMixin[K, T], Generic[K, T]):
180
180
  return await super().apply_filter(context, query, records)
181
181
 
182
182
  concrete = self._concrete_fields()
183
- translatable = all(
184
- all(name in concrete for name in fltr.fields()) for fltr in filter_set
185
- )
183
+ translatable = all(all(name in concrete for name in fltr.fields()) for fltr in filter_set)
186
184
  if not translatable or not can_compile_all(type(self), filter_set):
187
185
  return await super().apply_filter(context, query, records)
188
186
 
@@ -372,6 +370,7 @@ for _filter_type in _LOOKUPS:
372
370
  # Cursor predicate
373
371
  # ---------------------------------------------------------------------------
374
372
 
373
+
375
374
  class RowValues(Func):
376
375
  """`(a, b, c)` - a SQL row constructor."""
377
376
 
@@ -414,14 +413,16 @@ def _cursor_condition(viewset: "DjangoORMViewSet", fltr: CursorPredicate) -> Q:
414
413
  directly as a condition.
415
414
  """
416
415
  keys = fltr.keys
417
- nullable = {
418
- field.name for field in viewset.model._meta.get_fields() if getattr(field, "null", False)
419
- }
416
+ nullable = {field.name for field in viewset.model._meta.get_fields() if getattr(field, "null", False)}
420
417
  uniform = len({descending for _, descending in keys}) == 1
421
418
  if uniform and not fltr.inclusive and not any(name in nullable for name, _ in keys):
422
419
  return _row_value_condition(keys, fltr.position, fltr.backwards)
423
420
  return _segment_condition(
424
- keys, fltr.position, fltr.backwards, fltr.inclusive, fltr.nulls_first,
421
+ keys,
422
+ fltr.position,
423
+ fltr.backwards,
424
+ fltr.inclusive,
425
+ fltr.nulls_first,
425
426
  )
426
427
 
427
428
 
@@ -58,9 +58,7 @@ class AsyncCollectionViewSet(
58
58
  async def perform_update(self, _context: Context, pk: K, data: T, partial: bool = True) -> T:
59
59
  return await self.container_update(pk, data, partial)
60
60
 
61
- async def perform_bulk_update(
62
- self, _context: Context, records: dict[K, T], partial: bool = True
63
- ) -> list[T]:
61
+ async def perform_bulk_update(self, _context: Context, records: dict[K, T], partial: bool = True) -> list[T]:
64
62
  results = []
65
63
  for pk, data in records.items():
66
64
  results.append(await self.container_update(pk, data, partial))
@@ -64,7 +64,7 @@ class JWTAuthBackend(AuthBackend):
64
64
  header = request.headers.get(self.header_name, "")
65
65
  if not header.lower().startswith("bearer "):
66
66
  return None # not a bearer token - let the next backend try it
67
- return _JWTUserLazy(header[len("bearer "):].strip(), self.secret, self.algorithm)
67
+ return _JWTUserLazy(header[len("bearer ") :].strip(), self.secret, self.algorithm)
68
68
 
69
69
 
70
70
  def encode_jwt(
@@ -135,9 +135,7 @@ def decode_cursor(raw: str, keys: CursorKeys, expected_query: str, annotations:
135
135
  raise CursorError("cursor is not readable: no position in it")
136
136
 
137
137
  if payload.get("q") != expected_query:
138
- raise CursorError(
139
- "this cursor was issued for a different ordering or filter - start from the first page"
140
- )
138
+ raise CursorError("this cursor was issued for a different ordering or filter - start from the first page")
141
139
 
142
140
  position = payload["p"]
143
141
  missing = [name for name, _ in keys if name not in position]
@@ -214,7 +212,10 @@ class CursorPredicate(Filter):
214
212
  def matches(self, record: Any) -> bool:
215
213
  for name, descending in self.keys:
216
214
  order = compare_in_order(
217
- _read(record, name), self.position.get(name), descending, self.nulls_first,
215
+ _read(record, name),
216
+ self.position.get(name),
217
+ descending,
218
+ self.nulls_first,
218
219
  )
219
220
  if order != 0:
220
221
  # `order > 0` means the row sorts after the anchor; reading backwards wants before.
@@ -0,0 +1,19 @@
1
+ from .celery_viewset import (
2
+ celery_viewset,
3
+ celery_viewset_client,
4
+ celery_viewset_server,
5
+ set_celery_dispatch_hook,
6
+ set_celery_kwargs_hook,
7
+ set_is_celery_worker,
8
+ )
9
+ from .route_viewset import route_viewset
10
+
11
+ __all__ = [
12
+ "celery_viewset",
13
+ "celery_viewset_client",
14
+ "celery_viewset_server",
15
+ "route_viewset",
16
+ "set_celery_dispatch_hook",
17
+ "set_celery_kwargs_hook",
18
+ "set_is_celery_worker",
19
+ ]
@@ -167,16 +167,17 @@ def build_schema(cls, base_path: str = "", default_tags=None, get_wrapper=None,
167
167
  # Per-viewset wording, applied here because this is where the schema-serving app is
168
168
  # built; patching the caller's router afterwards would leave /schema showing the
169
169
  # mixin's generic docstring. See endpoint_docs.py.
170
- class_router.add_api_route(**route_to_add_api_route_kwargs(
171
- route,
172
- path=full_path,
173
- endpoint=endpoint_wrapper,
174
- response_model=response_model,
175
- tags=route.tags or default_tags,
176
- openapi_extra=getattr(endpoint_wrapper, "__shape_openapi_extra__", None)
177
- or route.openapi_extra,
178
- **docs_for(cls, route.endpoint.__name__),
179
- ))
170
+ class_router.add_api_route(
171
+ **route_to_add_api_route_kwargs(
172
+ route,
173
+ path=full_path,
174
+ endpoint=endpoint_wrapper,
175
+ response_model=response_model,
176
+ tags=route.tags or default_tags,
177
+ openapi_extra=getattr(endpoint_wrapper, "__shape_openapi_extra__", None) or route.openapi_extra,
178
+ **docs_for(cls, route.endpoint.__name__),
179
+ )
180
+ )
180
181
 
181
182
  app.include_router(class_router)
182
183
  cls.__app = app
@@ -2,7 +2,7 @@ import logging
2
2
  import sys
3
3
 
4
4
  from ..lifecycle_runner import LifecycleType
5
- from .client import celery_viewset_client, get_registered_queue_keys
5
+ from .client import celery_viewset_client, get_registered_queue_keys, set_celery_dispatch_hook
6
6
  from .result_reader import (
7
7
  get_result_queue_key,
8
8
  get_running_queue_keys,
@@ -10,7 +10,7 @@ from .result_reader import (
10
10
  start_result_reader,
11
11
  stop_result_reader,
12
12
  )
13
- from .server import celery_viewset_server
13
+ from .server import celery_viewset_server, set_celery_kwargs_hook
14
14
 
15
15
  logger = logging.getLogger(__name__)
16
16
 
@@ -68,7 +68,8 @@ def check_result_readers() -> frozenset[str]:
68
68
  "No result reader is running for queue_key=%s - requests dispatched to this "
69
69
  "celery_viewset will hang waiting for a result that never arrives. Call "
70
70
  "start_result_reader(redis_client, %r) in your FastAPI lifespan.",
71
- queue_key, queue_key,
71
+ queue_key,
72
+ queue_key,
72
73
  )
73
74
  return frozenset(missing)
74
75
 
@@ -78,6 +79,8 @@ __all__ = [
78
79
  "celery_viewset_client",
79
80
  "celery_viewset_server",
80
81
  "set_is_celery_worker",
82
+ "set_celery_kwargs_hook",
83
+ "set_celery_dispatch_hook",
81
84
  "start_result_reader",
82
85
  "stop_result_reader",
83
86
  "check_result_readers",
@@ -2,6 +2,7 @@ import asyncio
2
2
  import logging
3
3
  import uuid
4
4
 
5
+ from collections.abc import Awaitable, Callable
5
6
  from functools import wraps
6
7
  from typing import TYPE_CHECKING, TypeVar
7
8
 
@@ -20,6 +21,20 @@ if TYPE_CHECKING:
20
21
  T = TypeVar("T")
21
22
  logger = logging.getLogger(__name__)
22
23
 
24
+ _celery_dispatch_hook: Callable[[], Awaitable[dict]] | None = None
25
+
26
+
27
+ def set_celery_dispatch_hook(hook: Callable[[], Awaitable[dict]] | None) -> None:
28
+ """Register a callable awaited just before send_task; its return dict is merged into kwargs.
29
+
30
+ Takes no arguments - anything it needs (an operation token, a session) it reads from its own
31
+ ambient state. Returns {} when there is nothing to add, so a caller with nothing registered pays
32
+ for one no-op await and nothing else.
33
+ """
34
+ global _celery_dispatch_hook
35
+ _celery_dispatch_hook = hook
36
+
37
+
23
38
  # queue_keys registered by celery_viewset_client so far - populated at decoration (import) time,
24
39
  # used by check_result_readers() to spot a queue_key with no running result reader.
25
40
  _registered_queue_keys: set[str] = set()
@@ -97,10 +112,16 @@ def _patch_method(cls: type, original_endpoint, task_name: str, celery_app, queu
97
112
  else:
98
113
  serializable_kwargs[k] = await _serialize_value(v)
99
114
  logger.info("Celery task scheduling: %s (correlation_id=%s)", task_name, correlation_id)
115
+ extra_kwargs = await _celery_dispatch_hook() if _celery_dispatch_hook is not None else {}
100
116
  celery_app.send_task(
101
- task_name,
102
- args=serializable_args,
103
- kwargs={**serializable_kwargs, "_correlation_id": correlation_id, "_result_queue_key": queue_key},
117
+ task_name,
118
+ args=serializable_args,
119
+ kwargs={
120
+ **serializable_kwargs,
121
+ "_correlation_id": correlation_id,
122
+ "_result_queue_key": queue_key,
123
+ **extra_kwargs,
124
+ },
104
125
  )
105
126
  return await future
106
127
  except Exception:
@@ -2,9 +2,11 @@ import asyncio
2
2
  import inspect
3
3
  import json
4
4
  import logging
5
+ import types
5
6
 
7
+ from collections.abc import Callable
6
8
  from functools import wraps
7
- from typing import get_type_hints, TYPE_CHECKING, TypeVar
9
+ from typing import get_args, get_origin, get_type_hints, TYPE_CHECKING, TypeVar, Union
8
10
 
9
11
  from fastapi import HTTPException
10
12
  from pydantic import BaseModel
@@ -23,6 +25,30 @@ logger = logging.getLogger(__name__)
23
25
 
24
26
  T = TypeVar("T")
25
27
 
28
+ _celery_kwargs_hook: Callable[[Callable, dict], tuple[Callable, dict]] | None = None
29
+
30
+
31
+ def set_celery_kwargs_hook(hook: Callable[[Callable, dict], tuple[Callable, dict]] | None) -> None:
32
+ """Register a callable that may wrap the runner and/or consume kwargs before reconstruction.
33
+
34
+ Receives (runner, kwargs), must return (runner, kwargs). Called with kwargs still raw - before
35
+ _reconstruct_kwargs, which passes through any key it has no type hint for, so a key the hook
36
+ does not consume would otherwise reach the action as an argument it never declared.
37
+ """
38
+ global _celery_kwargs_hook
39
+ _celery_kwargs_hook = hook
40
+
41
+
42
+ def _unwrap_optional(hint):
43
+ """Before Python 3.11, `get_type_hints()` implicitly wraps a None-defaulted parameter's
44
+ annotation in `Optional[...]`, so a hint that is actually a bare model class arrives as
45
+ `Union[Model, None]` and the `inspect.isclass` check below sees a `Union`, not a class."""
46
+ if get_origin(hint) in (Union, types.UnionType):
47
+ args = [arg for arg in get_args(hint) if arg is not type(None)]
48
+ if len(args) == 1:
49
+ return args[0]
50
+ return hint
51
+
26
52
 
27
53
  def _to_jsonable(value):
28
54
  """Recursively convert Pydantic models and lists to JSON-serializable structures."""
@@ -59,7 +85,7 @@ def _reconstruct_kwargs(original_endpoint, kwargs: dict, cls: type = None) -> di
59
85
  for key, value in remaining.items():
60
86
  hint = hints.get(key)
61
87
  if hint is not None:
62
- hint = resolve_typevars(type_map, hint)
88
+ hint = _unwrap_optional(resolve_typevars(type_map, hint))
63
89
  if hint is not None and isinstance(value, dict) and inspect.isclass(hint) and issubclass(hint, BaseModel):
64
90
  try:
65
91
  result[key] = hint.model_validate(value)
@@ -102,16 +128,22 @@ def celery_viewset_server(
102
128
 
103
129
  logger.info("Celery task executing: %s (correlation_id=%s)", task_name, correlation_id)
104
130
  try:
131
+ run = loop.run_until_complete
132
+ if _celery_kwargs_hook is not None:
133
+ run, kwargs = _celery_kwargs_hook(run, kwargs)
105
134
  kwargs = _reconstruct_kwargs(original_endpoint, kwargs, cls)
106
- result = loop.run_until_complete(
107
- lifecycle_runner(original_endpoint, instance, cls, lifecycle, *args, **kwargs)
108
- )
135
+ result = run(lifecycle_runner(original_endpoint, instance, cls, lifecycle, *args, **kwargs))
109
136
  logger.info("Celery task completed: %s (correlation_id=%s)", task_name, correlation_id)
110
137
  if correlation_id and result_queue_key and redis_client is not None:
111
- redis_client.rpush(result_queue_key, json.dumps({
112
- "correlation_id": correlation_id,
113
- "result": _to_jsonable(result),
114
- }))
138
+ redis_client.rpush(
139
+ result_queue_key,
140
+ json.dumps(
141
+ {
142
+ "correlation_id": correlation_id,
143
+ "result": _to_jsonable(result),
144
+ }
145
+ ),
146
+ )
115
147
  return result
116
148
  except Exception as e:
117
149
  if isinstance(e, HTTPException):
@@ -0,0 +1,14 @@
1
+ def iter_routes(routes):
2
+ """Flatten a FastAPI route list, recursing into an included router's own .routes.
3
+
4
+ fastapi>=0.137 wraps each include_router() call in an internal object exposing
5
+ .original_router instead of the route directly; older fastapi puts the real route in the
6
+ list itself. Duck-typed on the .original_router attribute rather than importing fastapi's
7
+ internal class, so this keeps working if that class is renamed or moved again.
8
+ """
9
+ for route in routes:
10
+ original_router = getattr(route, "original_router", None)
11
+ if original_router is not None:
12
+ yield from iter_routes(original_router.routes)
13
+ else:
14
+ yield route
@@ -23,7 +23,7 @@ async def lifecycle_runner(
23
23
  response: Any = None,
24
24
  needs_context: bool = False,
25
25
  request: "Request | None" = None,
26
- **kwargs
26
+ **kwargs,
27
27
  ):
28
28
  """
29
29
  `response` is reserved for the command middleware chain (see below) - callers that have no
@@ -1,9 +1,10 @@
1
- from typing import Any, get_origin, TypeVar
1
+ from typing import Annotated, Any, get_args, get_origin, TypeVar
2
2
 
3
3
  from pydantic import BaseModel, create_model
4
4
 
5
5
  T = TypeVar("T", bound=BaseModel)
6
6
 
7
+
7
8
  def create_model_without_pk(model: type[T], pk_field_name: str) -> type[BaseModel]:
8
9
  """
9
10
  Creates a new Pydantic model based on the given model but without the specified primary key field.
@@ -15,12 +16,16 @@ def create_model_without_pk(model: type[T], pk_field_name: str) -> type[BaseMode
15
16
  }
16
17
  return create_model(f"{model.__name__}NoPK", **fields)
17
18
 
19
+
18
20
  def typecast_to_original_model(value: Any, original_annotation: type[T]) -> T:
19
21
  """
20
22
  Typecasts a value (usually a Pydantic model without PK) back to the original Pydantic model.
21
23
  If validation fails (e.g., due to missing PK that will be auto-incremented), it uses model_construct.
22
24
  """
23
- origin = get_origin(original_annotation) or original_annotation
25
+ annotation = original_annotation
26
+ if get_origin(annotation) is Annotated:
27
+ annotation = get_args(annotation)[0]
28
+ origin = get_origin(annotation) or annotation
24
29
  if not isinstance(value, BaseModel) or isinstance(value, origin):
25
30
  return value
26
31
 
@@ -105,6 +105,7 @@ def _make_middleware_depends(cls: type, action_name: str):
105
105
  registration time; actual @action_configuration resolution stays fully dynamic; settings can
106
106
  still change at runtime between requests.
107
107
  """
108
+
108
109
  async def _run(request: Request) -> None:
109
110
  action_configuration = resolve_action_configuration(cls, action_name)
110
111
  effective_middlewares = settings.viewsets_command_middleware + extra_middlewares_for(
@@ -119,22 +120,25 @@ def _make_middleware_depends(cls: type, action_name: str):
119
120
 
120
121
  scheme = settings.viewsets_security_scheme
121
122
  if scheme is None:
123
+
122
124
  async def middleware_depends(request: Request) -> None:
123
125
  await _run(request)
126
+
124
127
  return middleware_depends
125
128
 
126
129
  async def middleware_depends(request: Request, _credential=Depends(scheme)) -> None: # noqa: B008
127
130
  await _run(request)
131
+
128
132
  return middleware_depends
129
133
 
130
134
 
131
135
  def route_viewset(
132
- router: APIRouter,
133
- base_path: str,
134
- lifecycle: LifecycleType = "singleton",
135
- pk_field_name: str = None,
136
- register_muxws: bool = None,
137
- register_rest: bool = None,
136
+ router: APIRouter,
137
+ base_path: str,
138
+ lifecycle: LifecycleType = "singleton",
139
+ pk_field_name: str = None,
140
+ register_muxws: bool = None,
141
+ register_rest: bool = None,
138
142
  ):
139
143
  """
140
144
  `register_rest` and `register_muxws` decide which transports this viewset is published on.
@@ -146,6 +150,7 @@ def route_viewset(
146
150
  replacement is a single mapping - `transports={"rest": False}`, absent key meaning defer -
147
151
  rather than a third `register_*`; see GAPS.md for why a set of flags is the worse shape.
148
152
  """
153
+
149
154
  def decorator(cls: type[T]):
150
155
  instance = cls() if lifecycle == "singleton" else None
151
156
 
@@ -154,7 +159,7 @@ def route_viewset(
154
159
  # Derive tag from class name: strip "ViewSet" suffix if present
155
160
  cls_name = cls.__name__
156
161
  if cls_name.endswith("ViewSet"):
157
- cls_name = cls_name[:-len("ViewSet")]
162
+ cls_name = cls_name[: -len("ViewSet")]
158
163
  default_tags = [cls_name] if cls_name else None
159
164
  # The viewset's own docstring becomes the description of the group its endpoints appear
160
165
  # under - the section intro that was otherwise blank however well each endpoint was
@@ -164,9 +169,8 @@ def route_viewset(
164
169
 
165
170
  def _is_filter_param(annotation) -> bool:
166
171
  """Return True when annotation is Annotated[T, FilterParam()]."""
167
- return (
168
- hasattr(annotation, "__metadata__")
169
- and any(isinstance(m, FilterParam) for m in annotation.__metadata__)
172
+ return hasattr(annotation, "__metadata__") and any(
173
+ isinstance(m, FilterParam) for m in annotation.__metadata__
170
174
  )
171
175
 
172
176
  def _is_model_query_param(annotation) -> bool:
@@ -208,13 +212,15 @@ def route_viewset(
208
212
  if uses.get(name) is False:
209
213
  continue
210
214
  if name == "x_list_shape":
211
- parameter = parameter.replace(annotation=Annotated[
212
- Literal[(*allowed, None)],
213
- Header(
214
- alias=SHAPE_HEADER,
215
- description=f"Response shape. Omit for this endpoint's default ({default}).",
216
- ),
217
- ])
215
+ parameter = parameter.replace(
216
+ annotation=Annotated[
217
+ Literal[(*allowed, None)],
218
+ Header(
219
+ alias=SHAPE_HEADER,
220
+ description=f"Response shape. Omit for this endpoint's default ({default}).",
221
+ ),
222
+ ]
223
+ )
218
224
  params.append(parameter)
219
225
 
220
226
  item_type = type_map.get(ItemVar, ItemVar)
@@ -267,10 +273,7 @@ def route_viewset(
267
273
  continue
268
274
 
269
275
  # If we have a PK field and this parameter is a model from which we want to exclude PK
270
- if (pk_field_name and
271
- inspect.isclass(annotation) and
272
- issubclass(annotation, BaseModel)
273
- ):
276
+ if pk_field_name and inspect.isclass(annotation) and issubclass(annotation, BaseModel):
274
277
  # Check if {pk} is in the path and if the method is one that normally accepts a model in the body
275
278
  # (POST without {pk} or PUT/PATCH with {pk})
276
279
  is_create = "POST" in route_methods and "{pk}" not in route_path
@@ -286,18 +289,14 @@ def route_viewset(
286
289
  # (never forwarded to the underlying method itself, see wrapper below). Keyword-only
287
290
  # so it can be appended regardless of what defaults precede it. Used by lifecycle_runner
288
291
  # to run the command middleware chain and apply headers/cookies onto the real response.
289
- new_params.append(
290
- inspect.Parameter("response", inspect.Parameter.KEYWORD_ONLY, annotation=Response)
291
- )
292
+ new_params.append(inspect.Parameter("response", inspect.Parameter.KEYWORD_ONLY, annotation=Response))
292
293
 
293
294
  if needs_context:
294
295
  # Reserved param name, only added for endpoints that declare a `context` param.
295
296
  # FastAPI injects the REAL Request here; lifecycle_runner uses it to build the
296
297
  # context (see fastapi_viewsets/context.py) and injects the result as `context`
297
298
  # right before the endpoint runs - never forwarded to the endpoint itself.
298
- new_params.append(
299
- inspect.Parameter("request", inspect.Parameter.KEYWORD_ONLY, annotation=Request)
300
- )
299
+ new_params.append(inspect.Parameter("request", inspect.Parameter.KEYWORD_ONLY, annotation=Request))
301
300
 
302
301
  new_return_annotation = resolve_typevars(type_map, sig.return_annotation)
303
302
  new_sig = sig.replace(parameters=new_params, return_annotation=new_return_annotation)
@@ -325,8 +324,15 @@ def route_viewset(
325
324
  new_kwargs[param_name] = value
326
325
 
327
326
  return await lifecycle_runner(
328
- original_endpoint, instance, cls, lifecycle, *args, response=response_obj,
329
- needs_context=needs_context, request=request_obj, **new_kwargs
327
+ original_endpoint,
328
+ instance,
329
+ cls,
330
+ lifecycle,
331
+ *args,
332
+ response=response_obj,
333
+ needs_context=needs_context,
334
+ request=request_obj,
335
+ **new_kwargs,
330
336
  )
331
337
 
332
338
  wrapper.__signature__ = new_sig
@@ -339,7 +345,10 @@ def route_viewset(
339
345
  # ViewSetResult - if any is configured, the endpoint's declared return type can no longer
340
346
  # be trusted as the actual response_model, same reasoning as the old finalize_response hook.
341
347
  build_schema(
342
- cls, base_path, default_tags, get_wrapper,
348
+ cls,
349
+ base_path,
350
+ default_tags,
351
+ get_wrapper,
343
352
  disable_response_model=bool(settings.viewsets_command_middleware),
344
353
  )
345
354
 
@@ -348,9 +357,7 @@ def route_viewset(
348
357
  for route in cls.__router.routes:
349
358
  action_name = route.endpoint.__name__ # survives @wraps(original_endpoint) in get_wrapper
350
359
  documented.add(action_name)
351
- dependencies = list(route.dependencies or []) + [
352
- Depends(_make_middleware_depends(cls, action_name))
353
- ]
360
+ dependencies = list(route.dependencies or []) + [Depends(_make_middleware_depends(cls, action_name))]
354
361
  route_kwargs = route_to_add_api_route_kwargs(route, dependencies=dependencies)
355
362
  # Per-viewset wording for an endpoint the mixin provided - the mixin's own docstring is
356
363
  # the same sentence on every viewset in the application. See endpoint_docs.py.
@@ -379,11 +386,7 @@ def route_viewset(
379
386
  if muxws_routes:
380
387
  register_viewset(cls, base_path, muxws_routes, default_tags)
381
388
 
382
- cls.__viewset_metadata__ = {
383
- "base_path": base_path,
384
- "lifecycle": lifecycle,
385
- "router": router
386
- }
389
+ cls.__viewset_metadata__ = {"base_path": base_path, "lifecycle": lifecycle, "router": router}
387
390
 
388
391
  return cls
389
392
 
@@ -63,6 +63,7 @@ def endpoint_docs(docs: dict[str, EndpointDoc]):
63
63
  the other way round it would run too late; rather than silently document nothing, that is
64
64
  refused with a message saying which way round to put it.
65
65
  """
66
+
66
67
  def decorator(cls):
67
68
  if getattr(cls, "__viewset_metadata__", None) is not None:
68
69
  raise ValueError(
@@ -91,11 +91,7 @@ def make_filter_model(model: type[BaseModel], declaration: FilterDeclaration) ->
91
91
  )
92
92
  parameter = field_name if lookup == "exact" else f"{field_name}__{lookup}"
93
93
  annotation = _parameter_annotation(field_annotation, lookup)
94
- default = (
95
- Field(None, description="Comma-separated list of values")
96
- if lookup in _LIST_LOOKUPS
97
- else None
98
- )
94
+ default = Field(None, description="Comma-separated list of values") if lookup in _LIST_LOOKUPS else None
99
95
  fields[parameter] = (annotation, default)
100
96
  spec[parameter] = (field_name, lookup, _item_annotation(field_annotation, lookup))
101
97
 
@@ -31,6 +31,7 @@ def compiles(backend: type, filter_type: type[Filter]) -> Callable[[Compiler], C
31
31
  def _(viewset, fltr, queryset):
32
32
  return queryset.filter(**{fltr.field: fltr.value})
33
33
  """
34
+
34
35
  def decorator(compiler: Compiler) -> Compiler:
35
36
  _compilers[(backend, filter_type)] = compiler
36
37
  return compiler
@@ -56,8 +56,7 @@ def _placeholder(schema: dict, definitions: dict, depth: int = 0) -> Any:
56
56
  kind = schema.get("type")
57
57
  if kind == "object":
58
58
  return {
59
- name: _placeholder(field, definitions, depth + 1)
60
- for name, field in schema.get("properties", {}).items()
59
+ name: _placeholder(field, definitions, depth + 1) for name, field in schema.get("properties", {}).items()
61
60
  }
62
61
  if kind == "array":
63
62
  return [_placeholder(schema.get("items", {}), definitions, depth + 1)]
@@ -111,6 +111,7 @@ class CreateMixin(Generic[K, T], ABC):
111
111
  """
112
112
  Create a model instance.
113
113
  """
114
+
114
115
  __router = APIRouter()
115
116
 
116
117
  @final
@@ -123,6 +124,7 @@ class BulkOnlyCreateMixin(Generic[K, T], ABC):
123
124
  """
124
125
  Create model instances in bulk.
125
126
  """
127
+
126
128
  __router = APIRouter()
127
129
 
128
130
  @final
@@ -188,6 +190,7 @@ class SortStateColumn(BaseModel):
188
190
  Mirrors the FE SortStateColumn interface: one column in the current sort order.
189
191
  Python attribute names use snake_case; JSON serialization uses camelCase (columnName).
190
192
  """
193
+
191
194
  model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
192
195
 
193
196
  column_name: str
@@ -239,6 +242,7 @@ class ListMixin(Generic[T, TFilter], ABC):
239
242
  the response into a union of exactly those models; `route_viewset` computes both from these two
240
243
  attributes, so nothing here is written twice.
241
244
  """
245
+
242
246
  __router = APIRouter()
243
247
 
244
248
  list_shape: str | None = None
@@ -333,9 +337,7 @@ class ListMixin(Generic[T, TFilter], ABC):
333
337
  list_items.__list_shape_aware__ = True
334
338
  """Tells route_viewset to prune this signature and compute its response model per viewset."""
335
339
 
336
- async def get_list(
337
- self: "ImplMixin[Any, T] | ListMixin[T]", context: Context, query: ListQuery
338
- ) -> Any:
340
+ async def get_list(self: "ImplMixin[Any, T] | ListMixin[T]", context: Context, query: ListQuery) -> Any:
339
341
  """
340
342
  The list pipeline, in one place and overridable as a whole.
341
343
 
@@ -405,9 +407,7 @@ class ListMixin(Generic[T, TFilter], ABC):
405
407
  return records
406
408
  return await self.sort_list(query.sort, await self.land(query, records))
407
409
 
408
- async def land(
409
- self: "ImplMixin[Any, T] | ListMixin[T]", query: ListQuery, records: ListRecords
410
- ) -> list:
410
+ async def land(self: "ImplMixin[Any, T] | ListMixin[T]", query: ListQuery, records: ListRecords) -> list:
411
411
  """
412
412
  Ends the lazy part: materialises the source and converts it to the response model.
413
413
 
@@ -667,6 +667,7 @@ class RetrieveMixin(Generic[K, T], ABC):
667
667
  """
668
668
  Retrieve a model instance.
669
669
  """
670
+
670
671
  __router = APIRouter()
671
672
 
672
673
  @final
@@ -682,6 +683,7 @@ class UpdateMixin(Generic[K, T], ABC):
682
683
  """
683
684
  Update a model instance.
684
685
  """
686
+
685
687
  __router = APIRouter()
686
688
 
687
689
  @final
@@ -691,9 +693,7 @@ class UpdateMixin(Generic[K, T], ABC):
691
693
 
692
694
  @final
693
695
  @__router.patch("/{pk}", name="partial_update", responses=NOT_FOUND_RESPONSE)
694
- async def partial_update(
695
- self: "ImplMixin[K, T] | UpdateMixin[K, T]", context: Context, pk: K, data: T
696
- ) -> T:
696
+ async def partial_update(self: "ImplMixin[K, T] | UpdateMixin[K, T]", context: Context, pk: K, data: T) -> T:
697
697
  return await self.perform_update(context, pk, data, partial=True)
698
698
 
699
699
 
@@ -701,6 +701,7 @@ class BulkOnlyUpdateMixin(Generic[K, T], ABC):
701
701
  """
702
702
  Update model instances in bulk.
703
703
  """
704
+
704
705
  __router = APIRouter()
705
706
 
706
707
  @final
@@ -731,6 +732,7 @@ class DestroyMixin(Generic[K, T], ABC):
731
732
  """
732
733
  Destroy a model instance. Return the destroyed key and any additional data about its destruction
733
734
  """
735
+
734
736
  __router = APIRouter()
735
737
 
736
738
  @final
@@ -743,6 +745,7 @@ class BulkOnlyDestroyMixin(Generic[K, T], ABC):
743
745
  """
744
746
  Destroy model instances in bulk.
745
747
  """
748
+
746
749
  __router = APIRouter()
747
750
 
748
751
  @final
@@ -758,6 +761,7 @@ class BulkDestroyMixin(DestroyMixin[K, T], BulkOnlyDestroyMixin[K, T]):
758
761
  Destroy model instances (single or bulk).
759
762
  """
760
763
 
764
+
761
765
  ###################################################################################################
762
766
  # LOOKUP
763
767
  ###################################################################################################
@@ -770,6 +774,7 @@ class LookupItem(BaseModel):
770
774
 
771
775
  class LookupFilter(BaseModel):
772
776
  """Default filter model for LookupMixin. Provides case-insensitive title search via q."""
777
+
773
778
  q: str | None = None
774
779
 
775
780
 
@@ -796,6 +801,7 @@ class LookupMixin(Generic[TLookupFilter], ABC):
796
801
  The default implementation filters by fltr.q (case-insensitive title match).
797
802
  Override to customise in-memory filtering.
798
803
  """
804
+
799
805
  __router = APIRouter()
800
806
 
801
807
  @abstractmethod
@@ -832,9 +838,7 @@ class LookupMixin(Generic[TLookupFilter], ABC):
832
838
  fltr: Annotated[TLookupFilter, Query()] = None,
833
839
  ) -> list[LookupItem]:
834
840
  has_filter = (
835
- fltr is not None
836
- and hasattr(fltr, "model_dump")
837
- and any(v is not None for v in fltr.model_dump().values())
841
+ fltr is not None and hasattr(fltr, "model_dump") and any(v is not None for v in fltr.model_dump().values())
838
842
  )
839
843
  if has_filter:
840
844
  await self.setup_lookup_filter(fltr)
@@ -93,11 +93,7 @@ def parse_request(headers: dict[str, Any] | None) -> tuple[str, str, bytes, dict
93
93
  else:
94
94
  raise EnvelopeError(f"{QUERY_KEY} must be a string or a mapping, got {type(query).__name__}")
95
95
 
96
- http_headers = {
97
- str(key).lower(): str(value)
98
- for key, value in headers.items()
99
- if not str(key).startswith(":")
100
- }
96
+ http_headers = {str(key).lower(): str(value) for key, value in headers.items() if not str(key).startswith(":")}
101
97
  return method.upper(), path, query_string, http_headers
102
98
 
103
99
 
@@ -155,6 +155,7 @@ def _add_schema_route(app: FastAPI, registration: _Registration) -> None:
155
155
  built here rather than copied from there, because the REST one closes over the REST app and
156
156
  would report the wrong transport's endpoint set.
157
157
  """
158
+
158
159
  def schema() -> dict[str, Any]:
159
160
  return registration.schema()
160
161
 
@@ -7,7 +7,9 @@ from pydantic import BaseModel
7
7
  class NotFoundResponse(BaseModel):
8
8
  detail: str = "Item with pk {pk} not found"
9
9
 
10
- NOT_FOUND_RESPONSE= { "404": { "model": NotFoundResponse, "message": NotFoundResponse().detail } }
10
+
11
+ NOT_FOUND_RESPONSE = {"404": {"model": NotFoundResponse, "message": NotFoundResponse().detail}}
12
+
11
13
 
12
14
  class NotFoundError(HTTPException):
13
15
  def __init__(self, pk: Any):
@@ -6,6 +6,7 @@ from .serialize_state import SerializeState
6
6
  if TYPE_CHECKING:
7
7
  from redis.asyncio import Redis
8
8
 
9
+
9
10
  class SaveState(SerializeState, ABC):
10
11
  def __init__(self, instance_id: str):
11
12
  super().__init__()
@@ -1 +0,0 @@
1
- __version__ = "0.5.0"
@@ -1,10 +0,0 @@
1
- from .celery_viewset import celery_viewset, celery_viewset_client, celery_viewset_server, set_is_celery_worker
2
- from .route_viewset import route_viewset
3
-
4
- __all__ = [
5
- "celery_viewset",
6
- "celery_viewset_client",
7
- "celery_viewset_server",
8
- "route_viewset",
9
- "set_is_celery_worker",
10
- ]
@@ -16,8 +16,8 @@ class SerializeState(ABC):
16
16
  async def deserialize_state(self, state: str):
17
17
  pass
18
18
 
19
- class SerializeStateSlots(SerializeState):
20
19
 
20
+ class SerializeStateSlots(SerializeState):
21
21
  async def serialize_state(self) -> str: # noqa: B027
22
22
  return json.dumps(
23
23
  {slot: getattr(self, slot) for slot in self.__slots__},