dynamicforms-fastapi-viewsets 0.11.1__tar.gz → 0.11.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.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/PKG-INFO +1 -1
- dynamicforms_fastapi_viewsets-0.11.2/fastapi_viewsets/__init__.py +1 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/conf.py +10 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/context/__init__.py +26 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/decorators/build_schema.py +4 -2
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/decorators/celery_viewset/client.py +6 -1
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/events/__init__.py +41 -2
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/events/authorizers.py +21 -5
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/events/changes.py +140 -14
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/events/conftest.py +4 -0
- dynamicforms_fastapi_viewsets-0.11.2/fastapi_viewsets/events/delivery.py +346 -0
- dynamicforms_fastapi_viewsets-0.11.2/fastapi_viewsets/events/scopes.py +305 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/events/sources.py +5 -3
- dynamicforms_fastapi_viewsets-0.11.2/fastapi_viewsets/events/topics.py +110 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/events/viewset.py +104 -39
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/exceptions.py +8 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/middleware/idempotency.py +106 -19
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/mux_ws/__init__.py +8 -1
- dynamicforms_fastapi_viewsets-0.11.2/fastapi_viewsets/mux_ws/origin.py +103 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/mux_ws/server.py +3 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/routing.py +4 -0
- dynamicforms_fastapi_viewsets-0.11.1/fastapi_viewsets/__init__.py +0 -1
- dynamicforms_fastapi_viewsets-0.11.1/fastapi_viewsets/events/delivery.py +0 -203
- dynamicforms_fastapi_viewsets-0.11.1/fastapi_viewsets/events/topics.py +0 -47
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/.gitignore +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/LICENSE +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/README.md +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/action_configuration.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/backends/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/backends/django_orm.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/collection_viewset.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/context/auth/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/context/auth/django.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/context/auth/jwt.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/context/auth/static.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/cursor.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/decorators/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/decorators/celery_viewset/payload.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/decorators/celery_viewset/server.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/decorators/conftest.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/decorators/lifecycle_runner.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/decorators/route_viewset.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/endpoint_docs.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/events/event.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/events/lifecycle.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/events/redis.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/events/store.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/filters/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/filters/base.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/filters/declaration.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/filters/operators.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/filters/registry.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/list_query.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/list_shapes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/middleware/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/mixins.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/mux_ws/asgi.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/mux_ws/protocol.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/mux_ws/registry.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/mux_ws/transports.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/py.typed +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/response_classes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/save_state/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/save_state/save_state.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/save_state/serialize_state.py +0 -0
- {dynamicforms_fastapi_viewsets-0.11.1 → dynamicforms_fastapi_viewsets-0.11.2}/pyproject.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: dynamicforms-fastapi-viewsets
|
|
3
|
-
Version: 0.11.
|
|
3
|
+
Version: 0.11.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.11.2"
|
|
@@ -107,6 +107,16 @@ class Settings:
|
|
|
107
107
|
The number of topics one socket may hold; a subscription beyond it raises
|
|
108
108
|
`EventSubscriptionsExceededError` (409). Also the number of cursors one poll may carry.
|
|
109
109
|
"""
|
|
110
|
+
self.viewsets_event_reauthorize_after: float | None = 300.0
|
|
111
|
+
"""
|
|
112
|
+
Seconds after which a push to a subscription of a scoped viewset topic first runs the
|
|
113
|
+
topic's gate again with the request the subscription was made with. The push is sent when the
|
|
114
|
+
gate answers the subscription's store topic; a refusal (an `HTTPException` of a status from
|
|
115
|
+
400 to 499 other than 429) or another store topic removes the subscription and skips the
|
|
116
|
+
push; any other failure, a 429 or 5xx included, skips the push and keeps the subscription for
|
|
117
|
+
the next push to try again. None never runs it. Subscriptions of unscoped topics are not
|
|
118
|
+
affected.
|
|
119
|
+
"""
|
|
110
120
|
self.viewsets_event_poll_after_ms: int = 0
|
|
111
121
|
"""
|
|
112
122
|
`poll_after_ms` of every poll answer: the shortest interval, in milliseconds, at which a
|
|
@@ -184,6 +184,24 @@ class LazyObject(SerializableObject, ABC):
|
|
|
184
184
|
raise NotImplementedError
|
|
185
185
|
|
|
186
186
|
|
|
187
|
+
class _Deferred:
|
|
188
|
+
"""
|
|
189
|
+
A context value computed once, on its first `await`, by `compute()`. `serialize_context()`
|
|
190
|
+
awaits it like any bare awaitable; `celery_viewset_client` awaits every `_Deferred` of the
|
|
191
|
+
call's context before it serializes the arguments, so an exception of `compute()` propagates
|
|
192
|
+
from the call unchanged.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
def __init__(self, compute: Any) -> None:
|
|
196
|
+
self._compute = compute
|
|
197
|
+
self._future: asyncio.Future | None = None
|
|
198
|
+
|
|
199
|
+
def __await__(self):
|
|
200
|
+
if self._future is None:
|
|
201
|
+
self._future = asyncio.ensure_future(self._compute())
|
|
202
|
+
return self._future.__await__()
|
|
203
|
+
|
|
204
|
+
|
|
187
205
|
class Context:
|
|
188
206
|
"""
|
|
189
207
|
Wraps the per-request context dict produced by running settings.viewsets_context_processors.
|
|
@@ -241,6 +259,14 @@ class Context:
|
|
|
241
259
|
def __len__(self) -> int:
|
|
242
260
|
return len(self._data)
|
|
243
261
|
|
|
262
|
+
@property
|
|
263
|
+
def action_name(self) -> "str | None":
|
|
264
|
+
"""The name of the action this context represents, the name `configuration_for` resolves
|
|
265
|
+
`ByAction` values against; None for a context built without one. Read-only. As an attribute
|
|
266
|
+
it takes precedence over a context value named `action_name`, which `context["action_name"]`
|
|
267
|
+
reads."""
|
|
268
|
+
return self._action_name
|
|
269
|
+
|
|
244
270
|
def raw(self) -> dict[str, Any]:
|
|
245
271
|
"""The underlying plain dict - for serialize_context()/deserialize_context() to walk."""
|
|
246
272
|
return self._data
|
|
@@ -104,7 +104,8 @@ def action_openapi_extra(action_name: str, openapi_extra: dict | None) -> dict:
|
|
|
104
104
|
def build_schema(cls, base_path: str = "", default_tags=None, get_wrapper=None, disable_response_model=False):
|
|
105
105
|
"""
|
|
106
106
|
Builds __router (APIRouter with all routes from mixins) and __app (FastAPI instance),
|
|
107
|
-
and attaches them to the class. Idempotent: if already called, returns
|
|
107
|
+
and attaches them to the class. Idempotent: if already called for `cls` itself, returns
|
|
108
|
+
immediately; a subclass of a class it was called for gets routes of its own.
|
|
108
109
|
If get_wrapper is None, only __router is built (no FastAPI app, no endpoint wrapping).
|
|
109
110
|
|
|
110
111
|
disable_response_model: pass True when a configured command middleware declares
|
|
@@ -117,7 +118,8 @@ def build_schema(cls, base_path: str = "", default_tags=None, get_wrapper=None,
|
|
|
117
118
|
default) leaves the endpoint's declared return type in place, both in the OpenAPI docs and for
|
|
118
119
|
FastAPI's own response validation.
|
|
119
120
|
"""
|
|
120
|
-
|
|
121
|
+
own = vars(cls)
|
|
122
|
+
if "__router" in own and (get_wrapper is None or own.get("__router_full", False)):
|
|
121
123
|
return
|
|
122
124
|
|
|
123
125
|
celery_mode = get_wrapper is None
|
|
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, TypeVar
|
|
|
9
9
|
from pydantic import BaseModel
|
|
10
10
|
|
|
11
11
|
from ...action_configuration import resolve_action_configuration, TimeLimits
|
|
12
|
-
from ...context import Context, SerializableObject, serialize_context, to_jsonable
|
|
12
|
+
from ...context import _Deferred, Context, SerializableObject, serialize_context, to_jsonable
|
|
13
13
|
from ...exceptions import ActionDispatchError, ResultTimeoutError
|
|
14
14
|
from ...middleware import ActionTransition, run_transition_listeners
|
|
15
15
|
from ..build_schema import build_schema
|
|
@@ -120,6 +120,8 @@ def _patch_method(
|
|
|
120
120
|
`current_call()`; a `context` the caller passes replaces the call's. The action's `TimeLimits`
|
|
121
121
|
configuration is sent as the task's time limits. Arguments that cannot be serialized raise
|
|
122
122
|
`ActionDispatchError` (reason `serialization`), a send that fails raises it with reason `send`.
|
|
123
|
+
A deferred context value (`ChangeEvents`'s scope of a call) is computed before the arguments
|
|
124
|
+
are serialized; its exception propagates unchanged and nothing is sent.
|
|
123
125
|
Once the task is sent, `Middleware.on_transition()` of the action's effective middlewares
|
|
124
126
|
receives `dispatched`. The action's name joins the class's `DEFERRED_ACTIONS_ATTRIBUTE`."""
|
|
125
127
|
method_name = original_endpoint.__name__
|
|
@@ -142,6 +144,9 @@ def _patch_method(
|
|
|
142
144
|
parent = call.parent_of_dispatch() if call is not None else None
|
|
143
145
|
action_configuration = resolve_action_configuration(cls, method_name)
|
|
144
146
|
bound_context = Context(context.raw(), action_configuration=action_configuration, action_name=method_name)
|
|
147
|
+
for value in list(context.raw().values()):
|
|
148
|
+
if isinstance(value, _Deferred):
|
|
149
|
+
await value
|
|
145
150
|
try:
|
|
146
151
|
serializable_args = [await _serialize_value(v) for v in args]
|
|
147
152
|
serializable_kwargs = {
|
|
@@ -26,11 +26,12 @@ from .authorizers import (
|
|
|
26
26
|
EventAuthorizer,
|
|
27
27
|
ViewsetTopics,
|
|
28
28
|
)
|
|
29
|
-
from .changes import Change, ChangeEvents, RecordsChanged, routed_base_path, TOPIC_CONTEXT_KEY
|
|
29
|
+
from .changes import Change, ChangeEvents, RecordsChanged, routed_base_path, TOPIC_CONTEXT_KEY, write_topic
|
|
30
30
|
from .delivery import (
|
|
31
31
|
deliver,
|
|
32
32
|
DeliveryFilter,
|
|
33
33
|
drop_peer,
|
|
34
|
+
drop_subscriptions,
|
|
34
35
|
passes_filters,
|
|
35
36
|
reset_subscriptions,
|
|
36
37
|
subscribe,
|
|
@@ -42,13 +43,37 @@ from .delivery import (
|
|
|
42
43
|
)
|
|
43
44
|
from .event import Event
|
|
44
45
|
from .lifecycle import check_events, start_events, stop_events
|
|
46
|
+
from .scopes import (
|
|
47
|
+
event_scope,
|
|
48
|
+
EventScope,
|
|
49
|
+
GATE_RUN_SCOPE_KEY,
|
|
50
|
+
reset_event_scopes,
|
|
51
|
+
resolve_scope,
|
|
52
|
+
resolve_topic,
|
|
53
|
+
ResolvedTopic,
|
|
54
|
+
SCOPE_CONTEXT_KEY,
|
|
55
|
+
scope_of,
|
|
56
|
+
ScopeKey,
|
|
57
|
+
ScopeResolver,
|
|
58
|
+
)
|
|
45
59
|
from .sources import declared_source, publish, reset_sources, Source, source
|
|
46
60
|
from .store import EventStore, get_store, LoggedEvent, MAX_REV, MemoryEventStore
|
|
47
|
-
from .topics import
|
|
61
|
+
from .topics import (
|
|
62
|
+
is_scoped_topic,
|
|
63
|
+
is_valid_topic,
|
|
64
|
+
is_viewset_topic,
|
|
65
|
+
scoped_topic,
|
|
66
|
+
SOURCE_NAME_PATTERN,
|
|
67
|
+
split_custom_topic,
|
|
68
|
+
split_scoped_topic,
|
|
69
|
+
validate_topic,
|
|
70
|
+
)
|
|
48
71
|
from .viewset import EventsViewSet, register_events
|
|
49
72
|
|
|
50
73
|
__all__ = [
|
|
74
|
+
"GATE_RUN_SCOPE_KEY",
|
|
51
75
|
"MAX_REV",
|
|
76
|
+
"SCOPE_CONTEXT_KEY",
|
|
52
77
|
"SOURCE_NAME_PATTERN",
|
|
53
78
|
"TOPIC_CONTEXT_KEY",
|
|
54
79
|
"Change",
|
|
@@ -57,11 +82,15 @@ __all__ = [
|
|
|
57
82
|
"DeliveryFilter",
|
|
58
83
|
"Event",
|
|
59
84
|
"EventAuthorizer",
|
|
85
|
+
"EventScope",
|
|
60
86
|
"EventStore",
|
|
61
87
|
"EventsViewSet",
|
|
62
88
|
"LoggedEvent",
|
|
63
89
|
"MemoryEventStore",
|
|
64
90
|
"RecordsChanged",
|
|
91
|
+
"ResolvedTopic",
|
|
92
|
+
"ScopeKey",
|
|
93
|
+
"ScopeResolver",
|
|
65
94
|
"Source",
|
|
66
95
|
"Subscription",
|
|
67
96
|
"ViewsetTopics",
|
|
@@ -72,17 +101,26 @@ __all__ = [
|
|
|
72
101
|
"declared_source",
|
|
73
102
|
"deliver",
|
|
74
103
|
"drop_peer",
|
|
104
|
+
"drop_subscriptions",
|
|
105
|
+
"event_scope",
|
|
75
106
|
"get_store",
|
|
107
|
+
"is_scoped_topic",
|
|
76
108
|
"is_valid_topic",
|
|
77
109
|
"is_viewset_topic",
|
|
78
110
|
"passes_filters",
|
|
79
111
|
"publish",
|
|
80
112
|
"register_events",
|
|
113
|
+
"reset_event_scopes",
|
|
81
114
|
"reset_sources",
|
|
82
115
|
"reset_subscriptions",
|
|
116
|
+
"resolve_scope",
|
|
117
|
+
"resolve_topic",
|
|
83
118
|
"routed_base_path",
|
|
119
|
+
"scope_of",
|
|
120
|
+
"scoped_topic",
|
|
84
121
|
"source",
|
|
85
122
|
"split_custom_topic",
|
|
123
|
+
"split_scoped_topic",
|
|
86
124
|
"start_events",
|
|
87
125
|
"stop_events",
|
|
88
126
|
"subscribe",
|
|
@@ -91,4 +129,5 @@ __all__ = [
|
|
|
91
129
|
"unsubscribe",
|
|
92
130
|
"validate_topic",
|
|
93
131
|
"wait_for_pushes",
|
|
132
|
+
"write_topic",
|
|
94
133
|
]
|
|
@@ -129,8 +129,8 @@ def match_viewset(app: Any, topic: str) -> ViewsetMatch | None:
|
|
|
129
129
|
The first route of `app`, in routing order, whose endpoint carries `__viewset_class__` and, as
|
|
130
130
|
`__viewset_action__`, the class's `list_items`, or `retrieve` for a class without `list_items`,
|
|
131
131
|
and whose path without that action's path in `__viewset_metadata__["paths"]` matches `topic`
|
|
132
|
-
as Starlette matches a request path, path parameters included: `/
|
|
133
|
-
matches the topic `/
|
|
132
|
+
as Starlette matches a request path, path parameters included: `/orgs/{org_id}/tracks`
|
|
133
|
+
matches the topic `/orgs/7/tracks`. None when no route matches.
|
|
134
134
|
"""
|
|
135
135
|
for route in _routes(app):
|
|
136
136
|
endpoint = getattr(route, "endpoint", None)
|
|
@@ -213,8 +213,11 @@ class ViewsetTopics:
|
|
|
213
213
|
`retrieve` when it has no `list_items`.
|
|
214
214
|
|
|
215
215
|
A topic that does not start with `/`, and a topic no viewset on `request.app` has, get None (see
|
|
216
|
-
`match_viewset`).
|
|
217
|
-
|
|
216
|
+
`match_viewset`).
|
|
217
|
+
|
|
218
|
+
For a class without `@event_scope`, the checks a request to that action passes before the
|
|
219
|
+
command chain run against the calling request, which carries the path parameters read from the
|
|
220
|
+
topic as `path_params`:
|
|
218
221
|
|
|
219
222
|
1. the route's `dependencies` other than `route_viewset`'s own, those of the application, of
|
|
220
223
|
`include_router`, of the `APIRouter` and of the route itself; a parameter of one that does not
|
|
@@ -224,17 +227,30 @@ class ViewsetTopics:
|
|
|
224
227
|
action_configuration=..., action_name=...)`: `settings.viewsets_command_middleware` followed
|
|
225
228
|
by the middleware instances the configuration adds.
|
|
226
229
|
|
|
230
|
+
A check in a middleware's `__call__` or in `perform_*` does not run for such a class. For a class
|
|
231
|
+
with `@event_scope`, the gate run of `resolve_topic` admits the topic: the route's dependencies,
|
|
232
|
+
the context processors, `depends()` and `__call__` of every effective middleware and the scope's
|
|
233
|
+
resolver, against a request of its own (see `fastapi_viewsets.events.scopes`). A viewset that is
|
|
234
|
+
not split by tenant gets the same checks with a constant resolver,
|
|
235
|
+
`@event_scope(lambda request, context: "all")`.
|
|
236
|
+
|
|
227
237
|
An `HTTPException` propagates: `Session` raises 401, `Authorization` 403, `RateLimiter` 429 and
|
|
228
|
-
counts the call as one call of that action
|
|
238
|
+
counts the call as one call of that action, `EventScopeMissingError` 403 for a resolver that
|
|
239
|
+
answers None. When none raises, the topic is admitted.
|
|
229
240
|
"""
|
|
230
241
|
|
|
231
242
|
async def __call__(self, request: Request, context: Context, topic: str) -> bool | None:
|
|
243
|
+
from .scopes import resolve_topic, scope_of
|
|
244
|
+
|
|
232
245
|
if not is_viewset_topic(topic):
|
|
233
246
|
return None
|
|
234
247
|
found = match_viewset(request.app, topic)
|
|
235
248
|
if found is None:
|
|
236
249
|
return None
|
|
237
250
|
cls, action = found.viewset_class, found.action
|
|
251
|
+
if scope_of(cls) is not None:
|
|
252
|
+
await resolve_topic(request, topic)
|
|
253
|
+
return True
|
|
238
254
|
checked = _with_path_params(request, found.path_params)
|
|
239
255
|
if not await _route_dependencies_admit(checked, found.route):
|
|
240
256
|
return False
|
|
@@ -4,6 +4,8 @@ each of its create, update and destroy actions.
|
|
|
4
4
|
|
|
5
5
|
settings.viewsets_command_middleware = [Idempotency(...), ChangeEvents(), Session(), ...]
|
|
6
6
|
|
|
7
|
+
`write_topic` answers the topic of a call for an endpoint that publishes itself.
|
|
8
|
+
|
|
7
9
|
| `execution.action` | `op` | `changes` |
|
|
8
10
|
|---|---|---|
|
|
9
11
|
| `create` | `created` | the pk of the returned record |
|
|
@@ -15,6 +17,7 @@ each of its create, update and destroy actions.
|
|
|
15
17
|
"""
|
|
16
18
|
|
|
17
19
|
import inspect
|
|
20
|
+
import logging
|
|
18
21
|
|
|
19
22
|
from collections.abc import Awaitable, Callable, Mapping
|
|
20
23
|
from typing import Any, Literal, TYPE_CHECKING
|
|
@@ -22,15 +25,19 @@ from typing import Any, Literal, TYPE_CHECKING
|
|
|
22
25
|
from pydantic import BaseModel
|
|
23
26
|
|
|
24
27
|
from ..action_configuration import resolve_action_configuration
|
|
25
|
-
from ..context import Context
|
|
28
|
+
from ..context import _Deferred, Context
|
|
29
|
+
from ..decorators.lifecycle_runner import execution_translator
|
|
26
30
|
from ..middleware import ActionExecution, Middleware, ViewSetResult
|
|
27
31
|
from .event import Event
|
|
32
|
+
from .scopes import EventScope, resolve_scope, SCOPE_CONTEXT_KEY, scope_of
|
|
28
33
|
from .sources import publish
|
|
29
|
-
from .topics import validate_topic
|
|
34
|
+
from .topics import _validate_published_topic, scoped_topic, validate_topic
|
|
30
35
|
|
|
31
36
|
if TYPE_CHECKING:
|
|
32
37
|
from fastapi import Request
|
|
33
38
|
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
34
41
|
TOPIC_CONTEXT_KEY = "fastapi_viewsets.events.topic"
|
|
35
42
|
"""The context key under which `ChangeEvents.__call__` stores the routed base path of the call's
|
|
36
43
|
viewset. The worker of a `celery_viewset` action a request called reads the topic from the context
|
|
@@ -72,8 +79,8 @@ def routed_base_path(request: "Request") -> str | None:
|
|
|
72
79
|
"""
|
|
73
80
|
The routed base path of the viewset whose endpoint `request` was routed to: the `include_router`
|
|
74
81
|
prefixes followed by `base_path`, e.g. `/api/music` for a request to `/api/music/3`, with the
|
|
75
|
-
values of the request's path parameters in place, e.g. `/
|
|
76
|
-
`/
|
|
82
|
+
values of the request's path parameters in place, e.g. `/orgs/7/tracks` for a viewset at
|
|
83
|
+
`/orgs/{org_id}/tracks`.
|
|
77
84
|
|
|
78
85
|
It is the request's path, relative to the application's `root_path`, with as many trailing
|
|
79
86
|
segments removed as the action's relative path (`__viewset_metadata__["paths"][action]`) has.
|
|
@@ -107,9 +114,26 @@ class ChangeEvents(Middleware):
|
|
|
107
114
|
module's table). An action that raises publishes nothing; any other action publishes nothing.
|
|
108
115
|
|
|
109
116
|
`__call__` runs in the web process and stores `routed_base_path(request)` in the context under
|
|
110
|
-
`TOPIC_CONTEXT_KEY`, on every action.
|
|
111
|
-
|
|
112
|
-
|
|
117
|
+
`TOPIC_CONTEXT_KEY`, on every action. For every action of a class with `@event_scope` (see
|
|
118
|
+
`fastapi_viewsets.events.scopes`) but a write action configured `False` or with a `topic`, it
|
|
119
|
+
also stores under `SCOPE_CONTEXT_KEY` the deferred resolution of the scope's key with the call's
|
|
120
|
+
request and context. The resolver runs after the whole command chain, wherever `ChangeEvents`
|
|
121
|
+
sits in it: in the web process, on the first `await` of the entry (`execute()` of a write action,
|
|
122
|
+
`write_topic()`) for an action that executes there, and before the dispatch for every
|
|
123
|
+
`celery_viewset` action, which then carries `{"name": ..., "key": ...}`, or None for a key of
|
|
124
|
+
None, to the worker. An exception of the resolver refuses the call before the action.
|
|
125
|
+
`execute()` runs where the action executes and publishes after `call_next()` returns. An
|
|
126
|
+
exception of the publish, `store.append` failing included, propagates from `execute()` after the
|
|
127
|
+
action has run.
|
|
128
|
+
|
|
129
|
+
The topic `execute()` publishes to:
|
|
130
|
+
|
|
131
|
+
1. for a class without `@event_scope`, `topic_for(execution)`;
|
|
132
|
+
2. for a class with `@event_scope`, `topic_for(execution)` of a subclass that overrides it;
|
|
133
|
+
else, for an execution a request called, `@{name}:{key}` of the key under
|
|
134
|
+
`SCOPE_CONTEXT_KEY` (resolved there in the web process); else `@{name}:{key}` of the key the
|
|
135
|
+
resolver answers in `execute()`, with the execution's request and context. A key of None
|
|
136
|
+
publishes nothing and logs a warning. A `topic` option on a class with `@event_scope` raises `TypeError`.
|
|
113
137
|
|
|
114
138
|
`@action_configuration` on a class or a `perform_*` method:
|
|
115
139
|
|
|
@@ -132,7 +156,7 @@ class ChangeEvents(Middleware):
|
|
|
132
156
|
async def __call__(
|
|
133
157
|
self,
|
|
134
158
|
request: "Request | None",
|
|
135
|
-
viewset: Any,
|
|
159
|
+
viewset: Any,
|
|
136
160
|
context: Context,
|
|
137
161
|
call_next: Callable[[], Awaitable[ViewSetResult]],
|
|
138
162
|
) -> ViewSetResult:
|
|
@@ -140,8 +164,20 @@ class ChangeEvents(Middleware):
|
|
|
140
164
|
topic = routed_base_path(request)
|
|
141
165
|
if topic is not None:
|
|
142
166
|
context.set(TOPIC_CONTEXT_KEY, topic)
|
|
167
|
+
cls = viewset if isinstance(viewset, type) else type(viewset)
|
|
168
|
+
scope = scope_of(cls)
|
|
169
|
+
if scope is not None and self._carries_scope(context):
|
|
170
|
+
context.set(SCOPE_CONTEXT_KEY, _Deferred(lambda: _scope_entry(scope, request, context)))
|
|
143
171
|
return await call_next()
|
|
144
172
|
|
|
173
|
+
def _carries_scope(self, context: Context) -> bool:
|
|
174
|
+
"""Whether `__call__` stores the scope of the call: for every action but a write action
|
|
175
|
+
configured `False` or with a `topic`."""
|
|
176
|
+
if context.action_name not in _OPS:
|
|
177
|
+
return True
|
|
178
|
+
options = self.options_for(context)
|
|
179
|
+
return options is not None and "topic" not in options
|
|
180
|
+
|
|
145
181
|
def config_from(self, context: Context) -> Any:
|
|
146
182
|
"""The value configured under this instance as the key, else under its class."""
|
|
147
183
|
own = context.configuration_for(self)
|
|
@@ -182,20 +218,46 @@ class ChangeEvents(Middleware):
|
|
|
182
218
|
options = self.options_for(execution.context) if op is not None else None
|
|
183
219
|
if options is None or self.replaced_for(execution):
|
|
184
220
|
return await call_next()
|
|
185
|
-
topic =
|
|
221
|
+
topic = await self._resolve_topic(execution)
|
|
222
|
+
if topic is None:
|
|
223
|
+
scope = scope_of(execution.viewset_class)
|
|
224
|
+
logger.warning(
|
|
225
|
+
"events: %s.%s: the event scope %r answered no key; no %s is published",
|
|
226
|
+
execution.viewset_class.__qualname__,
|
|
227
|
+
execution.action,
|
|
228
|
+
scope.name if scope is not None else None,
|
|
229
|
+
RecordsChanged.event_name,
|
|
230
|
+
)
|
|
231
|
+
return await call_next()
|
|
186
232
|
if op != "deleted":
|
|
187
233
|
self.pk_field_for(execution)
|
|
188
234
|
result = await call_next()
|
|
189
235
|
await publish(topic, RecordsChanged(op=op, changes=self.changes_for(execution, op, result)))
|
|
190
236
|
return result
|
|
191
237
|
|
|
238
|
+
async def _resolve_topic(self, execution: ActionExecution) -> str | None:
|
|
239
|
+
"""The topic `execute()` publishes to (see the class's topic order); None for a scope key of
|
|
240
|
+
None."""
|
|
241
|
+
scope = scope_of(execution.viewset_class)
|
|
242
|
+
if scope is None:
|
|
243
|
+
return validate_topic(self.topic_for(execution))
|
|
244
|
+
if "topic" in (self.options_for(execution.context) or {}):
|
|
245
|
+
raise TypeError(
|
|
246
|
+
f"{execution.viewset_class.__qualname__}: event_scope and the {type(self).__name__} 'topic' "
|
|
247
|
+
f"option exclude each other"
|
|
248
|
+
)
|
|
249
|
+
if type(self).topic_for is not ChangeEvents.topic_for:
|
|
250
|
+
return _validate_published_topic(self.topic_for(execution))
|
|
251
|
+
return await _scoped_write_topic(scope, execution.request, execution.context, execution.parent is None)
|
|
252
|
+
|
|
192
253
|
def topic_for(self, execution: ActionExecution) -> str:
|
|
193
254
|
"""
|
|
194
|
-
The
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
255
|
+
The topic of an execution of a class without `@event_scope`, and the hook a subclass
|
|
256
|
+
overrides, for any class: the configured `topic` (a callable is called with `execution`);
|
|
257
|
+
else, for an execution a request called (`execution.parent` None), the routed base path in
|
|
258
|
+
the context under `TOPIC_CONTEXT_KEY`; else `__viewset_metadata__["base_path"]`, for an
|
|
259
|
+
execution dispatched outside any call or in the execution of another action, whose context
|
|
260
|
+
carries the topic of that other action. Raises `TypeError` when there is none of the three.
|
|
199
261
|
"""
|
|
200
262
|
topic = (self.options_for(execution.context) or {}).get("topic")
|
|
201
263
|
if callable(topic):
|
|
@@ -254,6 +316,70 @@ class ChangeEvents(Middleware):
|
|
|
254
316
|
return [Change(pk=_read_pk(execution, record, field)) for record in (body if bulk else [body])]
|
|
255
317
|
|
|
256
318
|
|
|
319
|
+
async def _scope_entry(scope: EventScope, request: "Request | None", context: Context) -> dict[str, str] | None:
|
|
320
|
+
key = await resolve_scope(scope, request, context)
|
|
321
|
+
return None if key is None else {"name": scope.name, "key": key}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
_NOT_CARRIED = object()
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
async def _carried_topic(scope: EventScope, context: Context) -> Any:
|
|
328
|
+
"""The store topic of the entry under `SCOPE_CONTEXT_KEY` when the context holds one of `scope`
|
|
329
|
+
(None for an entry of a key of None), else `_NOT_CARRIED`."""
|
|
330
|
+
raw = context.raw()
|
|
331
|
+
if SCOPE_CONTEXT_KEY not in raw:
|
|
332
|
+
return _NOT_CARRIED
|
|
333
|
+
carried = raw[SCOPE_CONTEXT_KEY]
|
|
334
|
+
if isinstance(carried, _Deferred):
|
|
335
|
+
carried = await carried
|
|
336
|
+
if carried is None:
|
|
337
|
+
return None
|
|
338
|
+
if isinstance(carried, dict) and carried.get("name") == scope.name and isinstance(carried.get("key"), str):
|
|
339
|
+
return scoped_topic(scope.name, carried["key"])
|
|
340
|
+
return _NOT_CARRIED
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
async def _scoped_write_topic(
|
|
344
|
+
scope: EventScope, request: "Request | None", context: Context, carries: bool
|
|
345
|
+
) -> str | None:
|
|
346
|
+
"""The store topic of a write of a class with `scope`: from the entry under `SCOPE_CONTEXT_KEY`
|
|
347
|
+
when `carries` and the context holds one of this scope, else from the key the resolver answers
|
|
348
|
+
with `request` and `context`; None for a key of None."""
|
|
349
|
+
if carries:
|
|
350
|
+
carried = await _carried_topic(scope, context)
|
|
351
|
+
if carried is not _NOT_CARRIED:
|
|
352
|
+
return carried
|
|
353
|
+
key = await resolve_scope(scope, request, context)
|
|
354
|
+
return None if key is None else scoped_topic(scope.name, key)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
async def write_topic(viewset: Any, request: "Request | None", context: Context) -> str | None:
|
|
358
|
+
"""
|
|
359
|
+
The topic a write of the current call of `viewset` (a viewset class or instance) publishes to,
|
|
360
|
+
for an endpoint that writes outside the eight actions and publishes itself. For a class with
|
|
361
|
+
`@event_scope`, the store topic `@{name}:{key}` of the key under `SCOPE_CONTEXT_KEY` when the
|
|
362
|
+
context holds an entry of this scope (`ChangeEvents.__call__` stores one for every action of the
|
|
363
|
+
class), else of the key the scope's resolver answers with `request` and `context`; None for a key
|
|
364
|
+
of None; an exception of the resolver propagates. In a `celery_viewset` worker the resolver is
|
|
365
|
+
never called: the key is the one the web process resolved before the dispatch, and a context
|
|
366
|
+
without an entry of this scope answers None. For a class without it, the topic under
|
|
367
|
+
`TOPIC_CONTEXT_KEY`, else `__viewset_metadata__["base_path"]`, else None.
|
|
368
|
+
"""
|
|
369
|
+
cls = viewset if isinstance(viewset, type) else type(viewset)
|
|
370
|
+
scope = scope_of(cls)
|
|
371
|
+
if scope is not None:
|
|
372
|
+
if execution_translator.get() is not None:
|
|
373
|
+
carried = await _carried_topic(scope, context)
|
|
374
|
+
return None if carried is _NOT_CARRIED else carried
|
|
375
|
+
return await _scoped_write_topic(scope, request, context, True)
|
|
376
|
+
routed = context.raw().get(TOPIC_CONTEXT_KEY)
|
|
377
|
+
if routed is not None:
|
|
378
|
+
return routed
|
|
379
|
+
metadata = getattr(cls, "__viewset_metadata__", None)
|
|
380
|
+
return metadata["base_path"] if metadata is not None else None
|
|
381
|
+
|
|
382
|
+
|
|
257
383
|
def _argument(execution: ActionExecution, name: str) -> Any:
|
|
258
384
|
"""The argument `name` of the action's call, passed by keyword or by position."""
|
|
259
385
|
if name in execution.kwargs:
|
|
@@ -12,6 +12,7 @@ from ..conf import settings
|
|
|
12
12
|
from . import store as store_module
|
|
13
13
|
from .delivery import reset_subscriptions
|
|
14
14
|
from .redis import _APPEND_SCRIPT
|
|
15
|
+
from .scopes import reset_event_scopes
|
|
15
16
|
from .sources import reset_sources
|
|
16
17
|
|
|
17
18
|
EVENT_SETTINGS = (
|
|
@@ -23,6 +24,7 @@ EVENT_SETTINGS = (
|
|
|
23
24
|
"viewsets_event_push_timeout",
|
|
24
25
|
"viewsets_event_max_subscriptions",
|
|
25
26
|
"viewsets_event_poll_after_ms",
|
|
27
|
+
"viewsets_event_reauthorize_after",
|
|
26
28
|
)
|
|
27
29
|
|
|
28
30
|
|
|
@@ -33,12 +35,14 @@ def _reset_events():
|
|
|
33
35
|
settings.viewsets_event_delivery_filters = []
|
|
34
36
|
reset_subscriptions()
|
|
35
37
|
reset_sources()
|
|
38
|
+
reset_event_scopes()
|
|
36
39
|
store_module._default_store = None
|
|
37
40
|
yield
|
|
38
41
|
for name, value in saved.items():
|
|
39
42
|
setattr(settings, name, value)
|
|
40
43
|
reset_subscriptions()
|
|
41
44
|
reset_sources()
|
|
45
|
+
reset_event_scopes()
|
|
42
46
|
store_module._default_store = None
|
|
43
47
|
|
|
44
48
|
|