dynamicforms-fastapi-viewsets 0.14.0__tar.gz → 0.14.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.
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/PKG-INFO +17 -33
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/__init__.py +3 -1
- dynamicforms_fastapi_viewsets-0.14.2/fastapi_viewsets/action_configuration.py +94 -0
- dynamicforms_fastapi_viewsets-0.14.2/fastapi_viewsets/backends/__init__.py +6 -0
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/backends/django_orm.py +50 -160
- dynamicforms_fastapi_viewsets-0.14.2/fastapi_viewsets/conf.py +89 -0
- dynamicforms_fastapi_viewsets-0.14.2/fastapi_viewsets/context/__init__.py +373 -0
- dynamicforms_fastapi_viewsets-0.14.2/fastapi_viewsets/context/auth/__init__.py +37 -0
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/context/auth/django.py +8 -35
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/context/auth/jwt.py +8 -29
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/context/auth/static.py +9 -26
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/cursor.py +24 -88
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/decorators/build_schema.py +21 -54
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/decorators/celery_viewset/__init__.py +7 -21
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/decorators/celery_viewset/client.py +16 -34
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/decorators/celery_viewset/payload.py +4 -8
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +15 -30
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/decorators/celery_viewset/server.py +39 -83
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/decorators/celery_viewset/transport.py +31 -75
- dynamicforms_fastapi_viewsets-0.14.2/fastapi_viewsets/decorators/conftest.py +12 -0
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/decorators/lifecycle_runner.py +20 -35
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/decorators/primary_key_model_helper.py +10 -21
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/decorators/query_model.py +8 -21
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/decorators/route_viewset.py +43 -109
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/decorators/sub_viewsets.py +14 -40
- dynamicforms_fastapi_viewsets-0.14.2/fastapi_viewsets/endpoint_docs.py +89 -0
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/error_namespace.py +3 -8
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/error_responses.py +19 -66
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/events/__init__.py +3 -17
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/events/authorizers.py +18 -51
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/events/changes.py +31 -104
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/events/conftest.py +6 -17
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/events/delivery.py +30 -64
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/events/event.py +4 -26
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/events/redis.py +19 -56
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/events/scopes.py +27 -87
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/events/sources.py +7 -12
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/events/store.py +5 -8
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/events/topics.py +6 -22
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/events/viewset.py +31 -61
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/exceptions.py +18 -46
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/filters/__init__.py +4 -17
- dynamicforms_fastapi_viewsets-0.14.2/fastapi_viewsets/filters/base.py +69 -0
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/filters/declaration.py +5 -27
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/filters/operators.py +4 -14
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/filters/registry.py +6 -21
- dynamicforms_fastapi_viewsets-0.14.2/fastapi_viewsets/list_query.py +129 -0
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/list_shapes.py +10 -60
- dynamicforms_fastapi_viewsets-0.14.2/fastapi_viewsets/middleware/__init__.py +329 -0
- dynamicforms_fastapi_viewsets-0.14.2/fastapi_viewsets/middleware/auth/__init__.py +37 -0
- dynamicforms_fastapi_viewsets-0.14.2/fastapi_viewsets/middleware/auth/authorization.py +47 -0
- dynamicforms_fastapi_viewsets-0.14.2/fastapi_viewsets/middleware/cookies.py +89 -0
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/middleware/idempotency.py +12 -55
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/middleware/rate_limiter.py +5 -27
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/mixins.py +77 -237
- dynamicforms_fastapi_viewsets-0.14.2/fastapi_viewsets/mux_ws/__init__.py +37 -0
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/mux_ws/asgi.py +8 -31
- dynamicforms_fastapi_viewsets-0.14.2/fastapi_viewsets/mux_ws/protocol.py +82 -0
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/mux_ws/registry.py +10 -30
- dynamicforms_fastapi_viewsets-0.14.2/fastapi_viewsets/mux_ws/server.py +140 -0
- dynamicforms_fastapi_viewsets-0.14.2/fastapi_viewsets/mux_ws/transports.py +24 -0
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/routing.py +11 -32
- dynamicforms_fastapi_viewsets-0.14.2/pyproject.toml +169 -0
- dynamicforms_fastapi_viewsets-0.14.0/pyproject.toml → dynamicforms_fastapi_viewsets-0.14.2/pyproject.toml.orig +14 -22
- dynamicforms_fastapi_viewsets-0.14.0/.gitignore +0 -21
- dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/action_configuration.py +0 -137
- dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/backends/__init__.py +0 -7
- dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/conf.py +0 -135
- dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/context/__init__.py +0 -482
- dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/context/auth/__init__.py +0 -55
- dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/decorators/conftest.py +0 -14
- dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/endpoint_docs.py +0 -148
- dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/filters/base.py +0 -93
- dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/list_query.py +0 -172
- dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/middleware/__init__.py +0 -407
- dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/middleware/auth/__init__.py +0 -52
- dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/middleware/auth/authorization.py +0 -72
- dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/mux_ws/__init__.py +0 -64
- dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/mux_ws/protocol.py +0 -123
- dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/mux_ws/server.py +0 -195
- dynamicforms_fastapi_viewsets-0.14.0/fastapi_viewsets/mux_ws/transports.py +0 -40
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/LICENSE +0 -0
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/README.md +0 -0
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/collection_viewset.py +0 -0
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/decorators/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/events/lifecycle.py +0 -0
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/py.typed +0 -0
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/response_classes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/save_state/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/save_state/save_state.py +0 -0
- {dynamicforms_fastapi_viewsets-0.14.0 → dynamicforms_fastapi_viewsets-0.14.2}/fastapi_viewsets/save_state/serialize_state.py +0 -0
|
@@ -1,18 +1,14 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: dynamicforms-fastapi-viewsets
|
|
3
|
-
Version: 0.14.
|
|
3
|
+
Version: 0.14.2
|
|
4
4
|
Summary: Django REST Framework-style viewsets for FastAPI, with optional Celery-backed async execution and a Vue/TypeScript client counterpart.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
Author-email: Jure Erznožnik <jure.erznoznik@gmail.com>
|
|
10
|
-
License: MIT
|
|
5
|
+
Keywords: fastapi,viewsets,rest,dynamicforms,celery,django-rest-framework
|
|
6
|
+
Author: Jure Erznožnik
|
|
7
|
+
Author-email: Jure Erznožnik <jure.erznoznik@velis.si>
|
|
8
|
+
License-Expression: MIT
|
|
11
9
|
License-File: LICENSE
|
|
12
|
-
Keywords: celery,django-rest-framework,dynamicforms,fastapi,rest,viewsets
|
|
13
10
|
Classifier: Development Status :: 3 - Alpha
|
|
14
11
|
Classifier: Intended Audience :: Developers
|
|
15
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
16
12
|
Classifier: Operating System :: OS Independent
|
|
17
13
|
Classifier: Programming Language :: Python :: 3
|
|
18
14
|
Classifier: Programming Language :: Python :: 3.10
|
|
@@ -21,36 +17,24 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
21
17
|
Classifier: Programming Language :: Python :: 3.13
|
|
22
18
|
Classifier: Topic :: Internet :: WWW/HTTP
|
|
23
19
|
Classifier: Typing :: Typed
|
|
24
|
-
Requires-Python: >=3.10
|
|
25
20
|
Requires-Dist: fastapi>=0.100
|
|
26
21
|
Requires-Dist: pydantic>=2.11
|
|
27
22
|
Requires-Dist: typing-extensions>=4.10
|
|
23
|
+
Requires-Dist: celery>=5.3 ; extra == 'celery'
|
|
24
|
+
Requires-Dist: redis>=5.0 ; extra == 'celery'
|
|
25
|
+
Requires-Dist: django>=4.2 ; extra == 'django'
|
|
26
|
+
Requires-Dist: asgiref>=3.6 ; extra == 'django'
|
|
27
|
+
Requires-Dist: pyjwt>=2.8 ; extra == 'jwt'
|
|
28
|
+
Requires-Dist: muxws[starlette]>=0.4.2 ; extra == 'muxws'
|
|
29
|
+
Requires-Python: >=3.10
|
|
30
|
+
Project-URL: Homepage, https://github.com/dynamicforms/fastapi-viewsets
|
|
31
|
+
Project-URL: Repository, https://github.com/dynamicforms/fastapi-viewsets
|
|
32
|
+
Project-URL: Issues, https://github.com/dynamicforms/fastapi-viewsets/issues
|
|
33
|
+
Project-URL: Documentation, https://docs.velis.si/dynamicforms/fastapi-viewsets/
|
|
28
34
|
Provides-Extra: celery
|
|
29
|
-
Requires-Dist: celery>=5.3; extra == 'celery'
|
|
30
|
-
Requires-Dist: redis>=5.0; extra == 'celery'
|
|
31
|
-
Provides-Extra: dev
|
|
32
|
-
Requires-Dist: asgiref; extra == 'dev'
|
|
33
|
-
Requires-Dist: celery; extra == 'dev'
|
|
34
|
-
Requires-Dist: coverage; extra == 'dev'
|
|
35
|
-
Requires-Dist: django; extra == 'dev'
|
|
36
|
-
Requires-Dist: gevent; extra == 'dev'
|
|
37
|
-
Requires-Dist: httpx2; extra == 'dev'
|
|
38
|
-
Requires-Dist: muxws[starlette,websockets]>=0.4.2; extra == 'dev'
|
|
39
|
-
Requires-Dist: pyjwt; extra == 'dev'
|
|
40
|
-
Requires-Dist: pytest; extra == 'dev'
|
|
41
|
-
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
42
|
-
Requires-Dist: pytest-cov; extra == 'dev'
|
|
43
|
-
Requires-Dist: python-multipart; extra == 'dev'
|
|
44
|
-
Requires-Dist: redis; extra == 'dev'
|
|
45
|
-
Requires-Dist: ruff; extra == 'dev'
|
|
46
|
-
Requires-Dist: uvicorn; extra == 'dev'
|
|
47
35
|
Provides-Extra: django
|
|
48
|
-
Requires-Dist: asgiref>=3.6; extra == 'django'
|
|
49
|
-
Requires-Dist: django>=4.2; extra == 'django'
|
|
50
36
|
Provides-Extra: jwt
|
|
51
|
-
Requires-Dist: pyjwt>=2.8; extra == 'jwt'
|
|
52
37
|
Provides-Extra: muxws
|
|
53
|
-
Requires-Dist: muxws[starlette]>=0.4.2; extra == 'muxws'
|
|
54
38
|
Description-Content-Type: text/markdown
|
|
55
39
|
|
|
56
40
|
# DynamicForms FastAPI Viewsets
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
from importlib.metadata import version
|
|
2
2
|
|
|
3
3
|
from .error_namespace import ERROR_NAMESPACE, ERROR_NAMESPACE_PYDANTIC
|
|
4
4
|
|
|
5
|
+
__version__ = version("dynamicforms-fastapi-viewsets")
|
|
6
|
+
|
|
5
7
|
__all__ = ["ERROR_NAMESPACE", "ERROR_NAMESPACE_PYDANTIC", "__version__"]
|
|
@@ -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
|
|
@@ -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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
The cursor
|
|
116
|
-
|
|
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
|
|
163
|
-
|
|
164
|
-
declined
|
|
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
|
|
201
|
-
in-memory default.
|
|
154
|
+
Translates the filter set into `.filter(...)`, or leaves all of it to the in-memory default.
|
|
202
155
|
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
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`
|
|
247
|
-
#
|
|
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
|
|
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
|
|
303
|
-
|
|
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
|
|
311
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
|
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
|
|
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()
|