dynamicforms-fastapi-viewsets 0.11.0__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.
Files changed (72) hide show
  1. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/PKG-INFO +1 -1
  2. dynamicforms_fastapi_viewsets-0.11.2/fastapi_viewsets/__init__.py +1 -0
  3. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/conf.py +10 -0
  4. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/context/__init__.py +26 -0
  5. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/decorators/build_schema.py +4 -2
  6. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/decorators/celery_viewset/client.py +6 -1
  7. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/decorators/celery_viewset/server.py +23 -4
  8. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/events/__init__.py +41 -2
  9. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/events/authorizers.py +21 -5
  10. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/events/changes.py +140 -14
  11. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/events/conftest.py +4 -0
  12. dynamicforms_fastapi_viewsets-0.11.2/fastapi_viewsets/events/delivery.py +346 -0
  13. dynamicforms_fastapi_viewsets-0.11.2/fastapi_viewsets/events/scopes.py +305 -0
  14. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/events/sources.py +5 -3
  15. dynamicforms_fastapi_viewsets-0.11.2/fastapi_viewsets/events/topics.py +110 -0
  16. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/events/viewset.py +104 -39
  17. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/exceptions.py +8 -0
  18. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/middleware/__init__.py +3 -1
  19. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/middleware/idempotency.py +106 -19
  20. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/mux_ws/__init__.py +8 -1
  21. dynamicforms_fastapi_viewsets-0.11.2/fastapi_viewsets/mux_ws/origin.py +103 -0
  22. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/mux_ws/server.py +3 -0
  23. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/routing.py +4 -0
  24. dynamicforms_fastapi_viewsets-0.11.0/fastapi_viewsets/__init__.py +0 -1
  25. dynamicforms_fastapi_viewsets-0.11.0/fastapi_viewsets/events/delivery.py +0 -203
  26. dynamicforms_fastapi_viewsets-0.11.0/fastapi_viewsets/events/topics.py +0 -47
  27. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/.gitignore +0 -0
  28. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/LICENSE +0 -0
  29. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/README.md +0 -0
  30. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/action_configuration.py +0 -0
  31. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/backends/__init__.py +0 -0
  32. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/backends/django_orm.py +0 -0
  33. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/collection_viewset.py +0 -0
  34. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/context/auth/__init__.py +0 -0
  35. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/context/auth/django.py +0 -0
  36. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/context/auth/jwt.py +0 -0
  37. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/context/auth/static.py +0 -0
  38. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/cursor.py +0 -0
  39. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/decorators/__init__.py +0 -0
  40. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -0
  41. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/decorators/celery_viewset/payload.py +0 -0
  42. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +0 -0
  43. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/decorators/conftest.py +0 -0
  44. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/decorators/lifecycle_runner.py +0 -0
  45. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
  46. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/decorators/route_viewset.py +0 -0
  47. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/endpoint_docs.py +0 -0
  48. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/events/event.py +0 -0
  49. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/events/lifecycle.py +0 -0
  50. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/events/redis.py +0 -0
  51. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/events/store.py +0 -0
  52. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/filters/__init__.py +0 -0
  53. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/filters/base.py +0 -0
  54. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/filters/declaration.py +0 -0
  55. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/filters/operators.py +0 -0
  56. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/filters/registry.py +0 -0
  57. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/list_query.py +0 -0
  58. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/list_shapes.py +0 -0
  59. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
  60. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
  61. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
  62. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/mixins.py +0 -0
  63. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/mux_ws/asgi.py +0 -0
  64. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/mux_ws/protocol.py +0 -0
  65. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/mux_ws/registry.py +0 -0
  66. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/mux_ws/transports.py +0 -0
  67. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/py.typed +0 -0
  68. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/response_classes.py +0 -0
  69. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/save_state/__init__.py +0 -0
  70. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/save_state/save_state.py +0 -0
  71. {dynamicforms_fastapi_viewsets-0.11.0 → dynamicforms_fastapi_viewsets-0.11.2}/fastapi_viewsets/save_state/serialize_state.py +0 -0
  72. {dynamicforms_fastapi_viewsets-0.11.0 → 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.0
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 immediately.
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
- if hasattr(cls, "__router") and (get_wrapper is None or getattr(cls, "__router_full", False)):
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 = {
@@ -200,16 +200,21 @@ class _Run:
200
200
  One execution in this process. Maps the exceptions the executor raises into it to this
201
201
  package's: a soft time limit and the gevent pool's hard time limit (`gevent.Timeout`) to
202
202
  `ActionInterruptedError`, any other celery, billiard or kombu exception to `ActionFailedError`, and
203
- the `asyncio.CancelledError` of an execution an interruption cancelled to that interruption.
203
+ the `asyncio.CancelledError` of an execution an interruption cancelled to that interruption, or
204
+ to the `SystemExit` / `KeyboardInterrupt` of a process exit.
204
205
  """
205
206
 
206
207
  def __init__(self, task: str):
207
208
  self.task = task
208
- self.interruption: ActionInterruptedError | None = None
209
+ self.interruption: BaseException | None = None
209
210
  self._interrupting: BaseException | None = None
210
211
 
211
212
  def interrupted(self, error: BaseException) -> None:
212
213
  """Records `error`, which interrupted the loop while the execution was suspended."""
214
+ if isinstance(error, (KeyboardInterrupt, SystemExit)):
215
+ self.interruption = error
216
+ self._interrupting = error
217
+ return
213
218
  translated = self.translate(error)
214
219
  if isinstance(translated, ActionInterruptedError):
215
220
  self.interruption = translated
@@ -286,6 +291,13 @@ def _event_loop() -> asyncio.AbstractEventLoop:
286
291
  return loop
287
292
 
288
293
 
294
+ def _supervised() -> bool:
295
+ """Whether this process is a pool child that billiard started, whose supervisor outlives it."""
296
+ from billiard.process import current_process
297
+
298
+ return getattr(current_process(), "_parent_pid", None) is not None
299
+
300
+
289
301
  _started_pid: int | None = None
290
302
  _started_loops: "weakref.WeakSet[asyncio.AbstractEventLoop]" = weakref.WeakSet()
291
303
  _started_lock = threading.Lock()
@@ -564,7 +576,8 @@ def celery_viewset_server(
564
576
  one as `ActionFailedError`. The
565
577
  worker's supervisor reports what ends an execution from outside it (a hard time limit, a
566
578
  termination, a lost process) and a call that never runs (revoked, expired, unknown, rejected)
567
- to the caller and to `Middleware.on_transition()`.
579
+ to the caller and to `Middleware.on_transition()`; a process no supervisor outlives (any pool but
580
+ `prefork`) reports its own exit as `interrupted` with reason `terminated`.
568
581
  """
569
582
  if redis_client is None:
570
583
  raise ValueError("redis_client is required for celery_viewset_server")
@@ -641,6 +654,10 @@ def celery_viewset_server(
641
654
  translated error, or a process exit as it is. An `HTTPException` the action raised is its
642
655
  answer and is not re-raised.
643
656
 
657
+ A process exit is reported as `interrupted` with reason `terminated` where no supervisor
658
+ outlives the process (any process but a prefork pool child); a prefork pool child's
659
+ supervisor reports it.
660
+
644
661
  The gevent pool's hard time limit is reported here as the supervisor reports another
645
662
  pool's, as `interrupted` with reason `time_limit`, and its `gevent.Timeout` is re-raised
646
663
  as it is: the pool handles the time limit only when that exception reaches it."""
@@ -678,7 +695,9 @@ def celery_viewset_server(
678
695
  pushed.status_code,
679
696
  pushed.detail,
680
697
  )
681
- if correlation_id and result_queue_key:
698
+ if process_exit and not _supervised():
699
+ _report("interrupted", task_name, call, lambda _t: pushed)
700
+ elif correlation_id and result_queue_key:
682
701
  push_payload(redis_client, result_queue_key, error_payload(correlation_id, task_name, pushed))
683
702
  if process_exit:
684
703
  raise caught
@@ -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 is_valid_topic, is_viewset_topic, SOURCE_NAME_PATTERN, split_custom_topic, validate_topic
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: `/clubs/{club_id}/tracks`
133
- matches the topic `/clubs/7/tracks`. None when no route matches.
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`). For the viewset found, the checks a request to that action passes run against
217
- the calling request, which carries the path parameters read from the topic as `path_params`:
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. When none raises, the topic is admitted.
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. `/clubs/7/tracks` for a viewset at
76
- `/clubs/{club_id}/tracks`.
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. `execute()` runs where the action executes and publishes
111
- after `call_next()` returns. An exception of the publish, `store.append` failing included,
112
- propagates from `execute()` after the action has run.
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, # noqa: ARG002
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 = validate_topic(self.topic_for(execution))
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 configured `topic` (a callable is called with `execution`); else, for an execution a
195
- request called (`execution.parent` None), the routed base path in the context under
196
- `TOPIC_CONTEXT_KEY`; else `__viewset_metadata__["base_path"]`, for an execution dispatched
197
- outside any call or in the execution of another action, whose context carries the topic of
198
- that other action. Raises `TypeError` when there is none of the three.
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