dynamicforms-fastapi-viewsets 0.14.0__tar.gz → 0.14.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 (90) hide show
  1. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/PKG-INFO +1 -1
  2. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/__init__.py +1 -1
  3. dynamicforms_fastapi_viewsets-0.14.1/fastapi_viewsets/action_configuration.py +94 -0
  4. dynamicforms_fastapi_viewsets-0.14.1/fastapi_viewsets/backends/__init__.py +6 -0
  5. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/backends/django_orm.py +50 -160
  6. dynamicforms_fastapi_viewsets-0.14.1/fastapi_viewsets/conf.py +89 -0
  7. dynamicforms_fastapi_viewsets-0.14.1/fastapi_viewsets/context/__init__.py +373 -0
  8. dynamicforms_fastapi_viewsets-0.14.1/fastapi_viewsets/context/auth/__init__.py +37 -0
  9. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/context/auth/django.py +8 -35
  10. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/context/auth/jwt.py +8 -29
  11. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/context/auth/static.py +9 -26
  12. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/cursor.py +24 -88
  13. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/decorators/build_schema.py +21 -54
  14. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/decorators/celery_viewset/__init__.py +7 -21
  15. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/decorators/celery_viewset/client.py +16 -34
  16. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/decorators/celery_viewset/payload.py +4 -8
  17. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +15 -30
  18. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/decorators/celery_viewset/server.py +39 -83
  19. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/decorators/celery_viewset/transport.py +31 -75
  20. dynamicforms_fastapi_viewsets-0.14.1/fastapi_viewsets/decorators/conftest.py +12 -0
  21. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/decorators/lifecycle_runner.py +20 -35
  22. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/decorators/primary_key_model_helper.py +10 -21
  23. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/decorators/query_model.py +8 -21
  24. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/decorators/route_viewset.py +43 -109
  25. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/decorators/sub_viewsets.py +14 -40
  26. dynamicforms_fastapi_viewsets-0.14.1/fastapi_viewsets/endpoint_docs.py +89 -0
  27. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/error_namespace.py +3 -8
  28. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/error_responses.py +19 -66
  29. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/events/__init__.py +3 -17
  30. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/events/authorizers.py +18 -51
  31. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/events/changes.py +31 -104
  32. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/events/conftest.py +6 -17
  33. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/events/delivery.py +30 -64
  34. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/events/event.py +4 -26
  35. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/events/redis.py +19 -56
  36. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/events/scopes.py +27 -87
  37. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/events/sources.py +7 -12
  38. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/events/store.py +5 -8
  39. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/events/topics.py +6 -22
  40. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/events/viewset.py +31 -61
  41. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/exceptions.py +18 -46
  42. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/filters/__init__.py +4 -17
  43. dynamicforms_fastapi_viewsets-0.14.1/fastapi_viewsets/filters/base.py +69 -0
  44. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/filters/declaration.py +5 -27
  45. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/filters/operators.py +4 -14
  46. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/filters/registry.py +6 -21
  47. dynamicforms_fastapi_viewsets-0.14.1/fastapi_viewsets/list_query.py +129 -0
  48. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/list_shapes.py +10 -60
  49. dynamicforms_fastapi_viewsets-0.14.1/fastapi_viewsets/middleware/__init__.py +329 -0
  50. dynamicforms_fastapi_viewsets-0.14.1/fastapi_viewsets/middleware/auth/__init__.py +37 -0
  51. dynamicforms_fastapi_viewsets-0.14.1/fastapi_viewsets/middleware/auth/authorization.py +47 -0
  52. dynamicforms_fastapi_viewsets-0.14.1/fastapi_viewsets/middleware/cookies.py +89 -0
  53. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/middleware/idempotency.py +12 -55
  54. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/middleware/rate_limiter.py +5 -27
  55. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/mixins.py +64 -234
  56. dynamicforms_fastapi_viewsets-0.14.1/fastapi_viewsets/mux_ws/__init__.py +37 -0
  57. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/mux_ws/asgi.py +8 -31
  58. dynamicforms_fastapi_viewsets-0.14.1/fastapi_viewsets/mux_ws/protocol.py +82 -0
  59. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/mux_ws/registry.py +10 -30
  60. dynamicforms_fastapi_viewsets-0.14.1/fastapi_viewsets/mux_ws/server.py +140 -0
  61. dynamicforms_fastapi_viewsets-0.14.1/fastapi_viewsets/mux_ws/transports.py +24 -0
  62. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/routing.py +11 -32
  63. dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/action_configuration.py +0 -137
  64. dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/backends/__init__.py +0 -7
  65. dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/conf.py +0 -135
  66. dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/context/__init__.py +0 -482
  67. dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/context/auth/__init__.py +0 -55
  68. dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/decorators/conftest.py +0 -14
  69. dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/endpoint_docs.py +0 -148
  70. dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/filters/base.py +0 -93
  71. dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/list_query.py +0 -172
  72. dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/middleware/__init__.py +0 -407
  73. dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/middleware/auth/__init__.py +0 -52
  74. dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/middleware/auth/authorization.py +0 -72
  75. dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/mux_ws/__init__.py +0 -64
  76. dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/mux_ws/protocol.py +0 -123
  77. dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/mux_ws/server.py +0 -195
  78. dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/mux_ws/transports.py +0 -40
  79. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/.gitignore +0 -0
  80. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/LICENSE +0 -0
  81. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/README.md +0 -0
  82. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/collection_viewset.py +0 -0
  83. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/decorators/__init__.py +0 -0
  84. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/events/lifecycle.py +0 -0
  85. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/py.typed +0 -0
  86. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/response_classes.py +0 -0
  87. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/save_state/__init__.py +0 -0
  88. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/save_state/save_state.py +0 -0
  89. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/fastapi_viewsets/save_state/serialize_state.py +0 -0
  90. {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.1}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dynamicforms-fastapi-viewsets
3
- Version: 0.14.0
3
+ Version: 0.14.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
@@ -1,4 +1,4 @@
1
- __version__ = "0.14.0"
1
+ __version__ = "0.14.1"
2
2
 
3
3
  from .error_namespace import ERROR_NAMESPACE, ERROR_NAMESPACE_PYDANTIC
4
4
 
@@ -0,0 +1,94 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+
4
+ from .conf import settings
5
+ from .middleware import Middleware
6
+
7
+ _PERFORM_METHOD_BY_ACTION = {
8
+ "create": "perform_create",
9
+ "bulk_create": "perform_bulk_create",
10
+ "list_items": "perform_list",
11
+ "retrieve": "perform_retrieve",
12
+ "update": "perform_update",
13
+ "partial_update": "perform_update",
14
+ "bulk_update": "perform_bulk_update",
15
+ "bulk_partial_update": "perform_bulk_update",
16
+ "destroy": "perform_destroy",
17
+ "bulk_destroy": "perform_bulk_destroy",
18
+ "lookup": "perform_lookup",
19
+ }
20
+ """The perform_* method of each action (as wired in mixins.py), whose @action_configuration also applies."""
21
+
22
+
23
+ def action_configuration(config: dict[Any, Any]):
24
+ """
25
+ Attaches configuration for context processors and command middleware to a viewset class or a
26
+ method (typically perform_*). Keys are a context processor, a globally registered Middleware
27
+ class, or a Middleware instance to inject for this viewset/action; values are interpreted by
28
+ whoever reads them. See docs/guide/action-configuration.md.
29
+
30
+ @action_configuration({Session: False})
31
+ class LoginViewSet(...): ...
32
+ """
33
+
34
+ def decorator(target):
35
+ target.__action_configuration__ = config
36
+ return target
37
+
38
+ return decorator
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class TimeLimits:
43
+ """
44
+ The time limits, in seconds, of a deferred action's execution, configured under the `TimeLimits`
45
+ key and enforced by the executor (an in-process action has none). `soft` raises
46
+ `ActionInterruptedError` inside the execution; `hard` kills its process. None leaves a limit to the
47
+ executor's own configuration. See docs/guide/action-configuration.md.
48
+ """
49
+
50
+ soft: float | None = None
51
+ hard: float | None = None
52
+
53
+
54
+ def _candidate_method_names(action_name: str) -> list[str]:
55
+ names = [action_name]
56
+ perform_name = _PERFORM_METHOD_BY_ACTION.get(action_name)
57
+ if perform_name:
58
+ names.append(perform_name)
59
+ return names
60
+
61
+
62
+ def resolve_action_configuration(cls: type, action_name: str) -> dict[Any, Any]:
63
+ """
64
+ Merges settings.default_action_configuration, then the class's, then the method's
65
+ @action_configuration, each replacing the previous layer's entry per key. The method layer is the
66
+ action method, then its perform_* method. Takes the class, so it works before an instance exists.
67
+ `ByAction` values are left for `Context.configuration_for` to resolve at read time.
68
+ """
69
+ merged: dict[Any, Any] = dict(settings.default_action_configuration)
70
+ merged.update(getattr(cls, "__action_configuration__", {}))
71
+ for candidate in _candidate_method_names(action_name):
72
+ method_config = getattr(getattr(cls, candidate, None), "__action_configuration__", None)
73
+ if method_config:
74
+ merged.update(method_config)
75
+ return merged
76
+
77
+
78
+ def extra_middlewares_for(action_configuration: dict[Any, Any], global_middlewares: list) -> list:
79
+ """
80
+ The Middleware instances among the keys of `action_configuration`, injected for this call in key
81
+ order. A Middleware class key without an instance in `global_middlewares` raises ValueError.
82
+ """
83
+ global_types = {type(mw) for mw in global_middlewares}
84
+ extras = []
85
+ for identifier in action_configuration:
86
+ if isinstance(identifier, Middleware):
87
+ extras.append(identifier)
88
+ elif isinstance(identifier, type) and issubclass(identifier, Middleware) and identifier not in global_types:
89
+ raise ValueError(
90
+ f"{identifier.__name__} is configured via @action_configuration but has no instance "
91
+ f"in settings.viewsets_command_middleware - register one globally, or pass a "
92
+ f"Middleware instance (not the class) to inject a new one just for this call."
93
+ )
94
+ return extras
@@ -0,0 +1,6 @@
1
+ """
2
+ Concrete data-source backends for viewsets.
3
+
4
+ Each backs a viewset with a store that answers part of a list query itself, translating the pipeline's
5
+ stages into the store's own query language. In-memory data uses `CollectionViewSet` instead.
6
+ """
@@ -1,23 +1,9 @@
1
1
  """
2
- A viewset backed by the Django ORM.
2
+ A viewset backed by the Django ORM, pushing filter, sort and page down into SQL.
3
3
 
4
- This is the second real backend, and it exists as much to constrain the design as to be useful: an
5
- abstraction built only against an in-memory dict inevitably assumes the records are already in
6
- memory, and you find out when the second backend arrives. A QuerySet is lazy, iterates
7
- asynchronously, and can absorb a filter, an ordering and a slice into SQL - so it exercises every
8
- push-down path the pipeline offers.
9
-
10
- class TrackViewSet(DjangoORMViewSet[int, Track], PaginatedListMixin[Track, TrackFilter]):
11
- model = TrackModel
12
- schema = Track
13
-
14
- The backend comes before the list mixin, and that order is required: `pk_field_name` is a property
15
- here and a plain class attribute on `ListMixin`, so a viewset that lists the mixin first finds the
16
- mixin's `"id"` and never asks the model.
17
-
18
- Requires the `django` extra. Django's ORM is synchronous underneath; every call here uses either
19
- its native async API (`aget`, `acount`, `__aiter__`, added in Django 4.1) or `sync_to_async`, so
20
- nothing blocks the event loop.
4
+ The backend must precede the list mixin in the bases: `pk_field_name` is a property here and a plain
5
+ class attribute on `ListMixin`. Requires the `django` extra; every ORM call uses the async API or
6
+ `sync_to_async`.
21
7
  """
22
8
 
23
9
  import operator
@@ -72,11 +58,10 @@ T = TypeVar("T", bound=BaseModel)
72
58
 
73
59
  class DjangoORMViewSet(ImplMixin[K, T], Generic[K, T]):
74
60
  """
75
- A relation field - `ForeignKey`, `OneToOneField` - appears in `schema` under its `name` and
76
- carries the related row's key: `owner: int`, not `owner_id: int` and not a nested model. The
77
- backend reads and writes the `owner_id` column for it, filters and sorts on that column, and
78
- never loads the related row. A `schema` that declares the column name (`owner_id`) raises
79
- `TypeError` when the viewset class is defined.
61
+ Viewset over `model`, converting rows into `schema`.
62
+
63
+ A relation field appears in `schema` under its name and carries the related row's key (`owner: int`);
64
+ declaring the column name (`owner_id`) raises `TypeError` at class definition.
80
65
  """
81
66
 
82
67
  model: ClassVar[type["Model"]]
@@ -101,33 +86,15 @@ class DjangoORMViewSet(ImplMixin[K, T], Generic[K, T]):
101
86
  )
102
87
 
103
88
  pk_field: ClassVar[str] = "pk"
104
- """
105
- The lookup `_get_or_404` hands to `aget()` when a record is fetched by its key. `pk` is Django's
106
- alias for whichever field carries the primary key, so a renamed primary key needs no change
107
- here; what that field is *called* is `pk_field_name`.
108
- """
89
+ """The lookup `_get_or_404` hands to `aget()`; `pk` is Django's alias for the primary key."""
109
90
 
110
91
  @property
111
92
  def pk_field_name(self) -> str:
112
93
  """
113
- The primary key's name, taken from `Model._meta.pk`.
114
-
115
- The cursor appends it to the ordering and filters on it - and this backend pushes a filter
116
- set down only when every name in it is a concrete model field - while the position the
117
- cursor emits is read off the converted *response* record. The name therefore has to be one
118
- both the model and `schema` carry, or the cursor's own key would take the whole set, the
119
- client's own filters with it, to the in-memory pass, and emit a position read off a field
120
- that is not there.
121
-
122
- That name is `_meta.pk.name`, also for a relation primary key (`artist`, carrying the
123
- related row's key). A `schema` without it names the key something else - a
124
- multi-table-inheritance child, whose `_meta.pk` is the parent link - and the default
125
- declared further along the MRO stands.
126
-
127
- Assigning `pk_field_name` on a subclass overrides this at any depth; assigning it on an
128
- instance raises `AttributeError`, because this is a read-only property. A subclass with no
129
- `model` yet takes the same default, so an abstract base is free to leave the model to its
130
- own subclasses.
94
+ `Model._meta.pk.name` when `schema` carries it, else the inherited default.
95
+
96
+ The cursor filters on this name in SQL and reads it off the response record, so it must exist in both.
97
+ A composite primary key raises `TypeError`. Read-only: assign it on the class to override.
131
98
  """
132
99
  model = getattr(self, "model", None)
133
100
  if model is None:
@@ -147,10 +114,7 @@ class DjangoORMViewSet(ImplMixin[K, T], Generic[K, T]):
147
114
  return super().pk_field_name
148
115
 
149
116
  def get_queryset(self, context: Context) -> "QuerySet": # noqa: ARG002 - for overrides to narrow per user
150
- """
151
- The base queryset. Override to scope it - by tenant, by permission, by anything on the
152
- context - and everything downstream narrows what you return here rather than replacing it.
153
- """
117
+ """The base queryset. Override to scope it (tenant, permission); every operation narrows it."""
154
118
  return self.model.objects.all()
155
119
 
156
120
  # -----------------------------------------------------------------------
@@ -159,16 +123,9 @@ class DjangoORMViewSet(ImplMixin[K, T], Generic[K, T]):
159
123
 
160
124
  def to_record(self, raw: Any) -> T:
161
125
  """
162
- Django row to pydantic model. Runs on the page alone for as long as no stage has landed the
163
- source, which means the filter and the sort each pushed down or had nothing to do; a
164
- declined `take_page` still cuts its page out of the queryset. A declined filter or sort runs
165
- this on whatever reached that stage instead - the whole table where the filter declined -
166
- because the in-memory pass goes through `land()`, which converts what it materialises. Every
167
- one of those rows and its pydantic model are held at once, before the page is cut, so
168
- `limit=10` against a table of hundreds of thousands of rows can exhaust memory.
169
-
170
- A relation field is read from its column (`owner_id` for `owner`), so the related row is
171
- never fetched.
126
+ Django row to `schema`; a relation field is read from its column, never fetching the related row.
127
+
128
+ A declined filter or sort converts every row reaching that stage, all held in memory at once.
172
129
  """
173
130
  if isinstance(raw, self.schema):
174
131
  return raw
@@ -189,20 +146,15 @@ class DjangoORMViewSet(ImplMixin[K, T], Generic[K, T]):
189
146
  # -----------------------------------------------------------------------
190
147
 
191
148
  async def perform_list(self, context: Context) -> ListRecords:
192
- """
193
- Returns the queryset itself, unevaluated. Nothing is read until a later stage asks for
194
- rows, and by then the filter, the ordering and the slice are all part of the SQL.
195
- """
149
+ """The queryset, unevaluated, so later stages can add filter, ordering and slice to the SQL."""
196
150
  return self.get_queryset(context)
197
151
 
198
152
  async def apply_filter(self, context: Context, query: ListQuery, records: ListRecords) -> ListRecords:
199
153
  """
200
- Translates the filter model into `.filter(...)` where it can, and leaves the rest to the
201
- in-memory default.
154
+ Translates the filter set into `.filter(...)`, or leaves all of it to the in-memory default.
202
155
 
203
- Only fields that map to a concrete model field are pushed down, and the stage is marked
204
- applied only when *every* set field was - a filter half-done in SQL and half-forgotten
205
- would silently return too many rows.
156
+ Pushed down only when every filter names a concrete field and has a compiler: a partial push-down
157
+ would return too many rows.
206
158
  """
207
159
  queryset = _as_queryset(records)
208
160
  if queryset is None or not query.has_filter or not query.needs("filter"):
@@ -210,8 +162,7 @@ class DjangoORMViewSet(ImplMixin[K, T], Generic[K, T]):
210
162
 
211
163
  filter_set = filter_set_for(query)
212
164
  if not filter_set and filters_from(query.fltr) is None:
213
- # A hand-written filter model, on the filter_list path. Nothing declarative to
214
- # translate, so the in-memory pass takes it.
165
+ # A hand-written filter model: nothing declarative to translate.
215
166
  return await super().apply_filter(context, query, records)
216
167
 
217
168
  concrete = self._concrete_fields()
@@ -228,13 +179,7 @@ class DjangoORMViewSet(ImplMixin[K, T], Generic[K, T]):
228
179
  return {field.name for field in _model_columns(self.model)}
229
180
 
230
181
  async def apply_sort(self, context: Context, query: ListQuery, records: ListRecords) -> ListRecords:
231
- """
232
- Translates the sort order into `.order_by(...)`, with the viewset's NULL placement.
233
-
234
- `F(col).asc(nulls_first=...)` states the placement outright, which is the same thing
235
- `nulls` means and the same thing the in-memory comparison does - so both directions push
236
- down and the two agree about where NULLs went.
237
- """
182
+ """Translates the sort into `.order_by(...)` with explicit NULL placement, matching the in-memory sort."""
238
183
  queryset = _as_queryset(records)
239
184
  if queryset is None or not query.has_sort or not query.needs("sort"):
240
185
  return await super().apply_sort(context, query, records)
@@ -243,9 +188,8 @@ class DjangoORMViewSet(ImplMixin[K, T], Generic[K, T]):
243
188
  if not all(column.column_name in concrete for column in query.sort):
244
189
  return await super().apply_sort(context, query, records)
245
190
 
246
- # `nulls` says where NULLs go when ascending, so a descending key needs the opposite SQL
247
- # placement to mean the same thing. One flag or the other, and only ever True: Django
248
- # rejects False and refuses both at once.
191
+ # `nulls` is the ascending placement, so a descending key flips it. Django accepts only one
192
+ # flag, and only True.
249
193
  ordering = []
250
194
  for column in query.sort:
251
195
  descending = column.direction == SortDirection.desc
@@ -258,14 +202,7 @@ class DjangoORMViewSet(ImplMixin[K, T], Generic[K, T]):
258
202
  return await super().apply_sort(context, query, queryset)
259
203
 
260
204
  async def take_page(self, query: ListQuery, records: ListRecords) -> tuple[list, bool]:
261
- """
262
- Slices in SQL rather than in Python.
263
-
264
- The default walks the source and stops once the page is full - which for a queryset means
265
- streaming every skipped row across the connection before throwing it away. Slicing the
266
- queryset turns that into LIMIT/OFFSET, so page 500 costs the same as page 1. One row beyond
267
- the page is still fetched, and it is what answers `has_more`.
268
- """
205
+ """Slices the queryset into LIMIT/OFFSET, fetching one extra row to answer `has_more`."""
269
206
  queryset = _as_queryset(records)
270
207
  if queryset is None:
271
208
  return await super().take_page(query, records)
@@ -275,7 +212,7 @@ class DjangoORMViewSet(ImplMixin[K, T], Generic[K, T]):
275
212
  return rows[: query.limit], len(rows) > query.limit
276
213
 
277
214
  async def count_records(self, context: Context, query: ListQuery, records: ListRecords) -> int | None:
278
- """One extra SELECT COUNT(*), rather than the null a lazy source would otherwise report."""
215
+ """One `SELECT COUNT(*)`."""
279
216
  queryset = _as_queryset(records)
280
217
  if queryset is None:
281
218
  return await super().count_records(context, query, records)
@@ -299,40 +236,27 @@ class DjangoORMViewSet(ImplMixin[K, T], Generic[K, T]):
299
236
 
300
237
  async def perform_update(self, context: Context, pk: K, data: T, partial: bool = True) -> T:
301
238
  """
302
- Writes `data` onto the row `pk` under a row lock; see `_update_locked`. A merged record that
303
- fails `schema` raises `RequestValidationError` with `loc` `("body", *field)`.
239
+ Writes `data` onto row `pk` under a row lock; see `_update_locked`. A merged record that fails
240
+ `schema` raises `RequestValidationError` with `loc` `("body", *field)`.
304
241
  """
305
242
  (record,) = await sync_to_async(self._update_locked)(context, {pk: data}, partial, bulk=False)
306
243
  return record
307
244
 
308
245
  async def perform_bulk_update(self, context: Context, records: dict[K, T], partial: bool = True) -> list[T]:
309
246
  """
310
- Writes every record in one transaction, under a lock on each row; see `_update_locked`. A
311
- missing row or a merged record that fails `schema` writes none of them. Every failing record
312
- is reported in one `RequestValidationError`, with `loc` `("body", pk, *field)`.
247
+ Writes every record in one transaction under row locks; one missing or invalid row writes none.
248
+ All failures raise one `RequestValidationError` with `loc` `("body", pk, *field)`.
313
249
  """
314
250
  return await sync_to_async(self._update_locked)(context, records, partial, bulk=True)
315
251
 
316
252
  def _update_locked(self, context: Context, records: dict[K, T], partial: bool, bulk: bool) -> list[T]:
317
253
  """
318
- Reads each row with `SELECT ... FOR UPDATE`, sets the fields of its record on it, validates
319
- the result as `schema` and saves the whole row, in one transaction.
320
-
321
- The lock holds the row from the read to the commit, so a concurrent update of the same row
322
- waits and then reads this one's result: it neither reverts the fields this one wrote nor
323
- validates against values that are no longer stored. Two updates of the same field still
324
- end with the later one's value.
325
-
326
- The rows are locked in key order, so two bulk updates over the same rows do not deadlock.
327
- The lock is taken through `_base_manager`, a query on the model's table alone: `FOR UPDATE`
328
- refuses `distinct()`, aggregation and a nullable `select_related`, any of which
329
- `get_queryset` may add. `get_queryset` decides whether the row is visible, and a row it
330
- excludes is a `NotFoundError`. On SQLite, which has no row locks, the write
331
- transaction locks the database instead.
332
-
333
- `save()` and the model's signals run inside the transaction, before the commit. Work they
334
- start that reads the row over another connection (a Celery task) is scheduled with
335
- `transaction.on_commit`; started directly, it can read the values from before the update.
254
+ In one transaction: locks each row (`SELECT ... FOR UPDATE`), sets the record's fields, validates
255
+ as `schema` and saves.
256
+
257
+ Rows lock in key order so overlapping bulk updates do not deadlock. The lock goes through
258
+ `_base_manager` because `FOR UPDATE` refuses the `distinct()`, aggregation or nullable
259
+ `select_related` that `get_queryset` may add; `get_queryset` still decides visibility (`NotFoundError`).
336
260
  """
337
261
  locked = self.model._base_manager.db_manager(router.db_for_write(self.model)).select_for_update()
338
262
  errors: list[dict] = []
@@ -382,14 +306,10 @@ class DjangoORMViewSet(ImplMixin[K, T], Generic[K, T]):
382
306
 
383
307
  def _writable_fields(self, data: T, exclude_unset: bool = False, creating: bool = False) -> dict[str, Any]:
384
308
  """
385
- The fields of `data` that are concrete model fields, keyed by column (`owner_id` for
386
- `owner`), so a relation is set from the key the body carries.
387
-
388
- The primary key is left out of an update: saving an instance under a different key writes
389
- to the row that key names. A create leaves out only a key the database or Django assigns -
390
- an auto-incrementing field, or the parent link of a multi-table-inheritance child - and
391
- keeps any other: a natural key or a relation primary key has no value unless the body
392
- supplies it.
309
+ The concrete model fields of `data`, keyed by column (`owner_id` for `owner`).
310
+
311
+ An update omits the primary key (saving under another key writes that row). A create omits only a
312
+ key the database or Django assigns (auto field, multi-table-inheritance parent link).
393
313
  """
394
314
  editable = {
395
315
  field.name: field.attname
@@ -412,10 +332,7 @@ def _assigned_key(field) -> bool:
412
332
 
413
333
 
414
334
  class _ColumnView:
415
- """
416
- A model instance as `to_record` reads it: a relation field's name answers with its column's
417
- value, every other attribute with the instance's own.
418
- """
335
+ """A model instance whose relation field names answer with their column's value."""
419
336
 
420
337
  __slots__ = ("_columns", "_row")
421
338
 
@@ -428,12 +345,7 @@ class _ColumnView:
428
345
 
429
346
 
430
347
  def _as_queryset(records: ListRecords) -> "QuerySet | None":
431
- """
432
- `records` if it is still an unevaluated QuerySet, else None.
433
-
434
- Once an earlier stage has fallen back to the in-memory path, what flows on is a list, and every
435
- push-down below has to decline - which is exactly what returning None makes them do.
436
- """
348
+ """`records` if it is still an unevaluated QuerySet, else None, which makes every push-down decline."""
437
349
  try:
438
350
  from django.db.models import QuerySet
439
351
  except ImportError: # pragma: no cover - the django extra is not installed
@@ -442,12 +354,8 @@ def _as_queryset(records: ListRecords) -> "QuerySet | None":
442
354
 
443
355
 
444
356
  # ---------------------------------------------------------------------------
445
- # Filter compilers
357
+ # Filter compilers (registered per backend; see fastapi_viewsets/filters/registry.py)
446
358
  # ---------------------------------------------------------------------------
447
- # Registered here rather than on the filters themselves. A filter knows only how to answer in
448
- # memory; how it becomes a queryset is this backend's business, and keeping it here means a new
449
- # operator needs no change to this file's imports being complete, while a new backend needs no
450
- # change to any filter. See fastapi_viewsets/filters/registry.py.
451
359
 
452
360
  _LOOKUPS = {
453
361
  Exact: "exact",
@@ -463,10 +371,7 @@ _LOOKUPS = {
463
371
  IsNull: "isnull",
464
372
  }
465
373
  """
466
- Every one of these happens to be spelled the same way in Django's own lookup language, so the
467
- compilers are one shared function rather than eleven near-identical ones. `Overlaps` is absent on
468
- purpose: it has no portable Django equivalent (ArrayField's `overlap` is Postgres-only), so a
469
- viewset using it falls back to the in-memory pass - which is the contract working, not a gap.
374
+ Filter type to Django lookup. `Overlaps` is absent: its Django equivalent is Postgres-only, so it runs in memory.
470
375
  """
471
376
 
472
377
 
@@ -507,22 +412,9 @@ def _cursor_condition(viewset: "DjangoORMViewSet", fltr: CursorPredicate) -> Q:
507
412
  """
508
413
  "Everything past this row", as SQL.
509
414
 
510
- Two forms, because one is faster and the other is always correct:
511
-
512
- * A **row-value comparison** - `WHERE (year, id) > (2003, 42)` - is a single predicate that a
513
- composite index on exactly those columns can seek with, so page 500 costs what page 1 costs.
514
- It is only usable when every key sorts the same way (a row constructor cannot express
515
- `a ASC, b DESC`) and no key is nullable, because SQL evaluates any comparison involving NULL
516
- as unknown while this library defines NULL as the smallest value there is. The two rules
517
- disagree, and SQL's wins inside the database - so where they could differ, this form is not
518
- used.
519
- * A **union of segments** - key₁ past the anchor, OR key₁ equal and key₂ past, OR … - which
520
- spells the NULL handling out and copes with mixed directions. The planner usually degrades
521
- it to a scan, which is the price of being right.
522
-
523
- Verified against Django + SQLite; `Func(..., function='ROW')`, the recipe usually cited for
524
- this, is Postgres-only, and a row constructor fails outright if annotated rather than used
525
- directly as a condition.
415
+ A row-value comparison (`(year, id) > (2003, 42)`, index-seekable) when all keys share a direction and
416
+ none is nullable, since SQL compares NULL as unknown; otherwise a union of per-key segments that spells
417
+ out NULL placement and mixed directions.
526
418
  """
527
419
  keys = [(viewset._column(name), descending) for name, descending in fltr.keys]
528
420
  position = {viewset._column(name): value for name, value in fltr.position.items()}
@@ -561,8 +453,7 @@ def _segment_condition(keys, position, backwards: bool, inclusive: bool, nulls_f
561
453
 
562
454
  value = position[name]
563
455
  greater = wants_greater(descending, backwards)
564
- # Whether NULLs come before the values as this page is read. Two reversals on top of where
565
- # `nulls` puts them ascending: a descending key, and reading the page backwards.
456
+ # Whether NULLs come first as this page is read: `nulls`, flipped by descending and by backwards.
566
457
  nulls_lead = (nulls_first != descending) != backwards
567
458
 
568
459
  if value is None:
@@ -572,8 +463,7 @@ def _segment_condition(keys, position, backwards: bool, inclusive: bool, nulls_f
572
463
  segment &= Q(**{f"{name}__isnull": False})
573
464
  else:
574
465
  beyond = Q(**{f"{name}__gt" if greater else f"{name}__lt": value})
575
- # SQL drops NULLs from every comparison - they are unknown, not true - so when the
576
- # ordering puts them past this anchor they have to be added back by hand.
466
+ # SQL comparisons never match NULL, so NULLs ordered past the anchor are added explicitly.
577
467
  segment &= beyond if nulls_lead else beyond | Q(**{f"{name}__isnull": True})
578
468
  segments.append(segment)
579
469
 
@@ -0,0 +1,89 @@
1
+ import json
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from typing import Any, TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from fastapi import Request
8
+ from fastapi.security.base import SecurityBase
9
+
10
+ from .context.auth import AuthBackend
11
+ from .events.authorizers import EventAuthorizer
12
+ from .events.delivery import DeliveryFilter, EventListener
13
+ from .events.store import EventStore
14
+ from .middleware import CommandMiddleware
15
+
16
+ ContextProcessor = Callable[["Request", Any], Awaitable[dict[str, Any]]]
17
+
18
+
19
+ class Settings:
20
+ """Global, mutable settings of fastapi_viewsets, configured once by the application (`settings`)."""
21
+
22
+ def __init__(self):
23
+ self.viewsets_context_processors: list[ContextProcessor] = []
24
+ self.viewsets_context_json_encoder: type[json.JSONEncoder] | None = None
25
+ self.viewsets_context_json_decoder: type[json.JSONDecoder] | None = None
26
+ self.viewsets_command_middleware: list[CommandMiddleware] = []
27
+ self.viewsets_idempotency_ttl: float = 86400
28
+ """Seconds an `Idempotency` outcome is kept; a later repeat runs the action again."""
29
+ self.viewsets_idempotency_lock_ttl: float = 600
30
+ """
31
+ Seconds an `Idempotency` running record lives after its last extension (extended every third of
32
+ it); once expired, a repeat runs the action again.
33
+ """
34
+ self.viewsets_auth_processors: list[AuthBackend] = []
35
+ self.default_action_configuration: dict[Any, Any] = {}
36
+ self.default_list_shape: str = "plain"
37
+ """List shape of a viewset that sets none: "plain", "paginated" or "cursor"."""
38
+ self.viewsets_register_muxws: bool = True
39
+ """
40
+ Whether a viewset is published on muxws when neither `route_viewset(register_muxws=)` nor
41
+ `@transports(muxws=)` says. Read at decoration time.
42
+ """
43
+ self.viewsets_security_scheme: SecurityBase | None = None
44
+ """
45
+ Security scheme added to every route for the OpenAPI "Authorize" flow only. Read at decoration
46
+ time, as is `viewsets_command_middleware`.
47
+ """
48
+ self.viewsets_event_store: EventStore | None = None
49
+ """
50
+ The events `EventStore`; None is a per-process `MemoryEventStore`. A deployment of several
51
+ processes (Celery workers included) sets a `RedisEventStore` in every process.
52
+ """
53
+ self.viewsets_event_authorizers: list[EventAuthorizer] = []
54
+ """
55
+ Consulted in order per topic on every `subscribe` and `poll`: the first True admits, the first
56
+ False refuses; no decision (or an empty list) refuses.
57
+ """
58
+ self.viewsets_event_delivery_filters: list[DeliveryFilter] = []
59
+ """
60
+ Decide per event whether a push is sent to a subscription or a poll answers it. False skips;
61
+ a filter that raises counts as True.
62
+ """
63
+ self.viewsets_event_listeners: list[EventListener] = []
64
+ """
65
+ Called with every event in every started process, before pushes, subscribed or not. A raising
66
+ listener is logged and the next one runs.
67
+ """
68
+ self.viewsets_event_log_size: int = 32
69
+ """Most recent events per topic kept for `poll`; with 0 a poll behind the rev answers `events: null`."""
70
+ self.viewsets_event_log_ttl: float = 60.0
71
+ """Seconds a topic's log is kept after its last event; the rev counter never expires."""
72
+ self.viewsets_event_push_timeout: float = 2.0
73
+ """
74
+ Seconds delivery filters plus `peer.notify()` (which only queues) may take for one push to one
75
+ socket; a slower push is cancelled and logged at debug level.
76
+ """
77
+ self.viewsets_event_max_subscriptions: int = 256
78
+ """Topics per socket, and cursors per poll; beyond it `EventSubscriptionsExceededError` (409)."""
79
+ self.viewsets_event_reauthorize_after: float | None = 300.0
80
+ """
81
+ Seconds after which a push to a scoped-topic subscription first reruns the topic's gate. A 4xx
82
+ refusal (not 429) or another store topic drops the subscription; other failures skip only that
83
+ push. None disables it.
84
+ """
85
+ self.viewsets_event_poll_after_ms: int = 0
86
+ """`poll_after_ms` of every poll answer: the shortest client poll interval (ms)."""
87
+
88
+
89
+ settings = Settings()