dynamicforms-fastapi-viewsets 0.11.3__tar.gz → 0.11.4__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 (69) hide show
  1. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/PKG-INFO +3 -4
  2. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/README.md +2 -3
  3. dynamicforms_fastapi_viewsets-0.11.4/fastapi_viewsets/__init__.py +1 -0
  4. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/collection_viewset.py +1 -2
  5. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/conf.py +13 -5
  6. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/decorators/celery_viewset/client.py +1 -0
  7. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +6 -4
  8. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/decorators/celery_viewset/server.py +3 -5
  9. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/events/__init__.py +2 -0
  10. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/events/conftest.py +2 -0
  11. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/events/delivery.py +19 -2
  12. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/events/redis.py +117 -14
  13. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/middleware/__init__.py +8 -10
  14. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/mux_ws/asgi.py +3 -6
  15. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/mux_ws/protocol.py +7 -9
  16. dynamicforms_fastapi_viewsets-0.11.3/fastapi_viewsets/__init__.py +0 -1
  17. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/.gitignore +0 -0
  18. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/LICENSE +0 -0
  19. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/action_configuration.py +0 -0
  20. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/backends/__init__.py +0 -0
  21. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/backends/django_orm.py +0 -0
  22. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/context/__init__.py +0 -0
  23. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/context/auth/__init__.py +0 -0
  24. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/context/auth/django.py +0 -0
  25. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/context/auth/jwt.py +0 -0
  26. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/context/auth/static.py +0 -0
  27. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/cursor.py +0 -0
  28. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/decorators/__init__.py +0 -0
  29. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/decorators/build_schema.py +0 -0
  30. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -0
  31. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/decorators/celery_viewset/payload.py +0 -0
  32. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/decorators/conftest.py +0 -0
  33. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/decorators/lifecycle_runner.py +0 -0
  34. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
  35. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/decorators/route_viewset.py +0 -0
  36. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/endpoint_docs.py +0 -0
  37. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/events/authorizers.py +0 -0
  38. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/events/changes.py +0 -0
  39. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/events/event.py +0 -0
  40. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/events/lifecycle.py +0 -0
  41. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/events/scopes.py +0 -0
  42. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/events/sources.py +0 -0
  43. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/events/store.py +0 -0
  44. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/events/topics.py +0 -0
  45. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/events/viewset.py +0 -0
  46. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/exceptions.py +0 -0
  47. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/filters/__init__.py +0 -0
  48. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/filters/base.py +0 -0
  49. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/filters/declaration.py +0 -0
  50. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/filters/operators.py +0 -0
  51. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/filters/registry.py +0 -0
  52. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/list_query.py +0 -0
  53. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/list_shapes.py +0 -0
  54. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
  55. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
  56. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/middleware/idempotency.py +0 -0
  57. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
  58. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/mixins.py +0 -0
  59. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/mux_ws/__init__.py +0 -0
  60. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/mux_ws/registry.py +0 -0
  61. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/mux_ws/server.py +0 -0
  62. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/mux_ws/transports.py +0 -0
  63. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/py.typed +0 -0
  64. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/response_classes.py +0 -0
  65. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/routing.py +0 -0
  66. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/save_state/__init__.py +0 -0
  67. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/save_state/save_state.py +0 -0
  68. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/fastapi_viewsets/save_state/serialize_state.py +0 -0
  69. {dynamicforms_fastapi_viewsets-0.11.3 → dynamicforms_fastapi_viewsets-0.11.4}/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
3
+ Version: 0.11.4
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
@@ -72,8 +72,7 @@ Celery-backed async execution and a matching Vue/TypeScript client counterpart.
72
72
  - **Bulk operations** — first-class support for bulk create, update, partial update and destroy.
73
73
  - **muxws transport** — reach the same viewsets over a single WebSocket instead of one HTTP request
74
74
  per call. A command goes through your application as an HTTP request does - its middleware,
75
- exception handlers, router dependencies and state - so both transports behave identically. In the
76
- demo, a burst of 100 requests takes 156 ms over REST and 37 ms over muxws.
75
+ exception handlers, router dependencies and state - so both transports behave identically.
77
76
  - **Events** — publish an event to a topic and every frontend following it learns of it, as a muxws
78
77
  push or on its next poll. `ChangeEvents` publishes every create, update and destroy of a viewset to
79
78
  its routed base path; custom events are declared with `source()`. Every event is recorded before it
@@ -139,7 +138,7 @@ app.include_router(router)
139
138
 
140
139
  See the [full documentation](https://docs.velis.si/dynamicforms/fastapi-viewsets/) for guides on
141
140
  the mixin system, `route_viewset`, `CollectionViewSet`, `DjangoORMViewSet`, `celery_viewset`, the
142
- list pipeline and pagination, the muxws transport, and the Vue client.
141
+ list pipeline and pagination, the muxws transport, events, and the Vue client.
143
142
 
144
143
  ## Demo
145
144
 
@@ -18,8 +18,7 @@ Celery-backed async execution and a matching Vue/TypeScript client counterpart.
18
18
  - **Bulk operations** — first-class support for bulk create, update, partial update and destroy.
19
19
  - **muxws transport** — reach the same viewsets over a single WebSocket instead of one HTTP request
20
20
  per call. A command goes through your application as an HTTP request does - its middleware,
21
- exception handlers, router dependencies and state - so both transports behave identically. In the
22
- demo, a burst of 100 requests takes 156 ms over REST and 37 ms over muxws.
21
+ exception handlers, router dependencies and state - so both transports behave identically.
23
22
  - **Events** — publish an event to a topic and every frontend following it learns of it, as a muxws
24
23
  push or on its next poll. `ChangeEvents` publishes every create, update and destroy of a viewset to
25
24
  its routed base path; custom events are declared with `source()`. Every event is recorded before it
@@ -85,7 +84,7 @@ app.include_router(router)
85
84
 
86
85
  See the [full documentation](https://docs.velis.si/dynamicforms/fastapi-viewsets/) for guides on
87
86
  the mixin system, `route_viewset`, `CollectionViewSet`, `DjangoORMViewSet`, `celery_viewset`, the
88
- list pipeline and pagination, the muxws transport, and the Vue client.
87
+ list pipeline and pagination, the muxws transport, events, and the Vue client.
89
88
 
90
89
  ## Demo
91
90
 
@@ -0,0 +1 @@
1
+ __version__ = "0.11.4"
@@ -211,8 +211,7 @@ class CollectionViewSet(AsyncCollectionViewSet[K, T]):
211
211
 
212
212
  for field_name, field_info in data.__class__.model_fields.items():
213
213
  if field_info.json_schema_extra and field_info.json_schema_extra.get("autoinc_int"):
214
- # If the field is already set and is not 0 or None, we leave it (unless this is the desired behavior)
215
- # However, we usually want autoinc only if the value is not yet set
214
+ # a truthy value is kept; 0 or None gets the next value after the container's maximum
216
215
  current_val = getattr(data, field_name, None)
217
216
  if current_val:
218
217
  continue
@@ -9,7 +9,7 @@ if TYPE_CHECKING:
9
9
 
10
10
  from .context.auth import AuthBackend
11
11
  from .events.authorizers import EventAuthorizer
12
- from .events.delivery import DeliveryFilter
12
+ from .events.delivery import DeliveryFilter, EventListener
13
13
  from .events.store import EventStore
14
14
  from .middleware import CommandMiddleware
15
15
 
@@ -40,8 +40,9 @@ class Settings:
40
40
  """
41
41
  self.viewsets_idempotency_lock_ttl: float = 600
42
42
  """
43
- Seconds a running record of `Idempotency` is kept after it was written, unless the action
44
- finishes first. Once it expires, a repeat runs the action again.
43
+ Seconds a running record of `Idempotency` is kept after it was last extended; the process
44
+ running the call extends it every third of this time. Once it expires, a repeat runs the
45
+ action again.
45
46
  """
46
47
  self.viewsets_auth_processors: list[AuthBackend] = []
47
48
  self.default_action_configuration: dict[Any, Any] = {}
@@ -82,8 +83,15 @@ class Settings:
82
83
  """
83
84
  self.viewsets_event_delivery_filters: list[DeliveryFilter] = []
84
85
  """
85
- The functions that decide, per subscription and per event, whether a push is sent. A filter
86
- that answers False skips that socket; a filter that raises counts as True.
86
+ The functions that decide, per subscription and per event, whether a push is sent, and per
87
+ poll and per event whether the poll answers the event. A filter that answers False skips that
88
+ socket or leaves the event out of the poll answer; a filter that raises counts as True.
89
+ """
90
+ self.viewsets_event_listeners: list[EventListener] = []
91
+ """
92
+ The functions called with every event in every process whose store is started, before its
93
+ pushes, whether or not the process holds a subscription of its topic. A listener that raises is
94
+ logged and the next one runs.
87
95
  """
88
96
  self.viewsets_event_log_size: int = 32
89
97
  """
@@ -27,6 +27,7 @@ if TYPE_CHECKING:
27
27
  T = TypeVar("T")
28
28
  logger = logging.getLogger(__name__)
29
29
 
30
+ # Seconds a `celery_viewset_client` call waits for its result when `result_timeout` is not given.
30
31
  DEFAULT_RESULT_TIMEOUT = 3600.0
31
32
 
32
33
  # queue_keys registered by celery_viewset_client so far - populated at decoration (import) time,
@@ -136,8 +136,9 @@ def _payload_error(data: dict) -> Exception:
136
136
  def error_payload(correlation_id: str, task: str | None, error: BaseException) -> dict:
137
137
  """
138
138
  The payload that fails one call with `error`: an `HTTPException` (a `DfViewSetError` with its
139
- class), any other error as `ActionFailedError`. `http_status_code` and `http_detail` repeat the
140
- status and detail for a reader that does not decode `error`.
139
+ class), a `RequestValidationError` as a 422 with its errors as `detail`, any other error as
140
+ `ActionFailedError`. `http_status_code` and `http_detail` repeat the status and detail for a
141
+ reader that does not decode `error`.
141
142
  """
142
143
  if not isinstance(error, (HTTPException, RequestValidationError)):
143
144
  error = ActionFailedError(task)
@@ -168,8 +169,9 @@ def push_result(
168
169
  """
169
170
  Push a result or an error for one call. queue_key is the `_result_queue_key` the client sent with
170
171
  the task. An `error` that is an `HTTPException` reaches the caller as it is (a `DfViewSetError`
171
- of a class `fastapi_viewsets.exceptions` defines keeps its class); any other error reaches it
172
- as `ActionFailedError`. `task` names the call's task in that error.
172
+ of a class `fastapi_viewsets.exceptions` defines keeps its class); a `RequestValidationError`
173
+ reaches it as an `HTTPException` 422 with its errors as `detail`; any other error reaches it as
174
+ `ActionFailedError`. `task` names the call's task in that error.
173
175
  """
174
176
  if error is not None:
175
177
  data = error_payload(correlation_id, task, error)
@@ -63,11 +63,9 @@ def _unwrap_optional(hint):
63
63
  return hint
64
64
 
65
65
 
66
- # Alias, not a re-implementation: the actual conversion (Pydantic models, dicts, lists/tuples,
67
- # SerializableObject values - e.g. a ViewSetResult a worker-side action returned directly, see
68
- # fastapi_viewsets.middleware) lives in fastapi_viewsets.context.to_jsonable, shared with
69
- # ViewSetResult's own (de)serialization. Kept under this name since call sites/tests here already
70
- # import it as `_to_jsonable`.
66
+ # `fastapi_viewsets.context.to_jsonable`: converts Pydantic models, dicts, lists/tuples and
67
+ # SerializableObject values (e.g. a ViewSetResult a worker-side action returned directly); also used
68
+ # by ViewSetResult's own (de)serialization.
71
69
  _to_jsonable = to_jsonable
72
70
 
73
71
 
@@ -32,6 +32,7 @@ from .delivery import (
32
32
  DeliveryFilter,
33
33
  drop_peer,
34
34
  drop_subscriptions,
35
+ EventListener,
35
36
  passes_filters,
36
37
  reset_subscriptions,
37
38
  subscribe,
@@ -80,6 +81,7 @@ __all__ = [
80
81
  "ChangeEvents",
81
82
  "DeclaredSources",
82
83
  "DeliveryFilter",
84
+ "EventListener",
83
85
  "Event",
84
86
  "EventAuthorizer",
85
87
  "EventScope",
@@ -19,6 +19,7 @@ EVENT_SETTINGS = (
19
19
  "viewsets_event_store",
20
20
  "viewsets_event_authorizers",
21
21
  "viewsets_event_delivery_filters",
22
+ "viewsets_event_listeners",
22
23
  "viewsets_event_log_size",
23
24
  "viewsets_event_log_ttl",
24
25
  "viewsets_event_push_timeout",
@@ -33,6 +34,7 @@ def _reset_events():
33
34
  saved = {name: getattr(settings, name) for name in EVENT_SETTINGS}
34
35
  settings.viewsets_event_authorizers = []
35
36
  settings.viewsets_event_delivery_filters = []
37
+ settings.viewsets_event_listeners = []
36
38
  reset_subscriptions()
37
39
  reset_sources()
38
40
  reset_event_scopes()
@@ -62,6 +62,14 @@ None for a poll over REST. `topic` is the client topic; `subscription.store_topi
62
62
  `subscription.scope` name the scope of a scoped topic.
63
63
  """
64
64
 
65
+ EventListener = Callable[[str, int, int, str, Any, int], "None | Awaitable[None]"]
66
+ """
67
+ `listener(topic, rev, prev, event_name, payload, version)`, sync or async; `topic` is the store
68
+ topic. `deliver()` calls the listeners with every event, in every process whose store is started,
69
+ whether or not the process holds a subscription of the topic, before it pushes the event; a listener
70
+ that removes subscriptions (`drop_subscriptions`) removes them before the push.
71
+ """
72
+
65
73
  # store topic -> (id(peer), client topic) -> Subscription
66
74
  _by_topic: dict[str, dict[tuple[int, str], Subscription]] = {}
67
75
  # id(peer) -> client topic -> Subscription
@@ -316,8 +324,10 @@ async def _push(
316
324
 
317
325
  async def deliver(topic: str, rev: int, prev: int, name: str, payload: Any, version: int) -> None:
318
326
  """
319
- Pushes one event of the store topic `topic` to every subscription delivered under it in this
320
- process, each push in a task of its own, and returns without waiting for them. A push runs the
327
+ Calls `settings.viewsets_event_listeners` in list order with the event, awaiting each; a listener
328
+ that raises is logged and the next one runs. Then pushes the event of the store topic `topic` to
329
+ every subscription delivered under it in this process, each push in a task of its own, and
330
+ returns without waiting for them. A push runs the
321
331
  subscription's reauthorization when it is due (see `settings.viewsets_event_reauthorize_after`),
322
332
  the delivery filters with the client topic, and then `peer.notify(payload, headers={":event":
323
333
  name, ":version": version, ":topic": subscription.topic, ":rev": rev, ":prev": prev})`, with
@@ -325,6 +335,13 @@ async def deliver(topic: str, rev: int, prev: int, name: str, payload: Any, vers
325
335
  `settings.viewsets_event_push_timeout` seconds. A push that raises a `ConnectionLost` drops
326
336
  every subscription of its socket; any other failure is logged at debug level.
327
337
  """
338
+ for listener in settings.viewsets_event_listeners:
339
+ try:
340
+ result = listener(topic, rev, prev, name, payload, version)
341
+ if inspect.isawaitable(result):
342
+ await result
343
+ except Exception:
344
+ logger.exception("events: a listener raised for topic %s rev %s", topic, rev)
328
345
  for subscription in subscriptions(topic):
329
346
  _spawn(_push(subscription, topic, rev, prev, name, payload, version))
330
347
 
@@ -1,13 +1,16 @@
1
1
  """
2
2
  `RedisEventStore`: the event store shared by every process that reaches one Redis.
3
3
 
4
- The only module of `fastapi_viewsets.events` that uses `redis`. It takes a `redis.asyncio.Redis`
5
- client the application constructs, and imports the `redis` package for type checking only.
4
+ The only module of `fastapi_viewsets.events` that uses `redis`. `RedisEventStore` takes a
5
+ `redis.asyncio.Redis` client the application constructs, or builds one client per event loop from a
6
+ URL (`RedisEventStore.from_url`, `PerLoopRedis`); the `redis` package is imported by `PerLoopRedis`
7
+ only.
6
8
  """
7
9
 
8
10
  import asyncio
9
11
  import json
10
12
  import logging
13
+ import threading
11
14
 
12
15
  from typing import Any, TYPE_CHECKING
13
16
 
@@ -47,10 +50,78 @@ return {rev_text, prev_text}
47
50
  """
48
51
 
49
52
  # Seconds the reader waits for a message before it checks again, and waits after a failed read.
50
- _READ_TIMEOUT = 1.0
53
+ DEFAULT_READ_TIMEOUT = 1.0
51
54
 
52
55
  # Seconds `start` waits for Redis to confirm the SUBSCRIBE of the events channel.
53
- _SUBSCRIBE_TIMEOUT = 5.0
56
+ DEFAULT_SUBSCRIBE_TIMEOUT = 5.0
57
+
58
+ # The `redis.asyncio.Redis.from_url` options `PerLoopRedis` sets unless given: seconds a connect and
59
+ # a single command may take.
60
+ DEFAULT_CLIENT_OPTIONS: dict[str, Any] = {"socket_connect_timeout": 2.0, "socket_timeout": 5.0}
61
+
62
+
63
+ class PerLoopRedis:
64
+ """
65
+ Stands in for a `redis.asyncio.Redis`: every attribute whose name does not start with `_` is
66
+ read off the client of the running event loop, created with
67
+ `Redis.from_url(url, **(DEFAULT_CLIENT_OPTIONS | options))` on its first use in that loop. A
68
+ `redis.asyncio` connection belongs to the loop it was opened on, and one process runs the store
69
+ on several loops: the server's, the loops of a Celery worker's executions, and the loop
70
+ `asgiref`'s `async_to_sync()` opens for a call without a running loop to hand it to (WSGI,
71
+ management commands, plain threads).
72
+
73
+ A loop's client is closed and forgotten when the loop's tasks are cancelled at its end, as
74
+ `asyncio.run()` does. A client used on that loop by another task's cancellation cleanup may be
75
+ closed already. The client of a loop closed without that, and of a loop alive at process exit, is
76
+ not closed: it is forgotten by the next call that creates a client, and its connections close
77
+ when it is garbage-collected.
78
+ """
79
+
80
+ def __init__(self, url: str, **options: Any):
81
+ from redis.asyncio import Redis
82
+
83
+ self.url = url
84
+ self.options = DEFAULT_CLIENT_OPTIONS | options
85
+ self._redis_class = Redis
86
+ self._clients: dict[asyncio.AbstractEventLoop, Redis] = {}
87
+ self._lock = threading.Lock()
88
+
89
+ def client(self) -> "Redis":
90
+ """The client of the running loop. Raises `RuntimeError` outside a running loop."""
91
+ loop = asyncio.get_running_loop()
92
+ with self._lock:
93
+ client = self._clients.get(loop)
94
+ if client is not None:
95
+ return client
96
+ for closed in [held for held in self._clients if held.is_closed()]:
97
+ del self._clients[closed]
98
+ client = self._clients[loop] = self._redis_class.from_url(self.url, **self.options)
99
+ closer = loop.create_task(self._close_with_loop(loop, client), name="PerLoopRedis: close with the loop")
100
+ # A loop that ends without cancelling its tasks destroys this one without a warning.
101
+ closer._log_destroy_pending = False # type: ignore[attr-defined]
102
+ return client
103
+
104
+ async def _close_with_loop(self, loop: asyncio.AbstractEventLoop, client: "Redis") -> None:
105
+ try:
106
+ await loop.create_future()
107
+ except asyncio.CancelledError:
108
+ self._forget(loop, client)
109
+ await client.aclose()
110
+ raise
111
+ except GeneratorExit:
112
+ # The task is destroyed with its loop: there is no loop to close the client on.
113
+ self._forget(loop, client)
114
+ raise
115
+
116
+ def _forget(self, loop: asyncio.AbstractEventLoop, client: "Redis") -> None:
117
+ with self._lock:
118
+ if self._clients.get(loop) is client:
119
+ del self._clients[loop]
120
+
121
+ def __getattr__(self, name: str) -> Any:
122
+ if name.startswith("_"):
123
+ raise AttributeError(name)
124
+ return getattr(self.client(), name)
54
125
 
55
126
 
56
127
  class RedisEventStore:
@@ -67,19 +138,52 @@ class RedisEventStore:
67
138
  `append` is one Lua script. `start()` SUBSCRIBEs the events channel on one pub/sub connection,
68
139
  returns once Redis has confirmed it, and runs the reader, a task that calls `deliver()` for each
69
140
  message; `deliver()` pushes the event to the subscriptions of its topic in this process and
70
- leaves out a topic without one. `stop()` closes the connection.
141
+ leaves out a topic without one. `stop()` closes the connection. `start()` and `stop()` run on
142
+ one loop, the loop the reader runs on.
143
+
144
+ `subscribe_timeout` is the seconds `start()` waits for Redis to confirm the SUBSCRIBE;
145
+ `read_timeout` the seconds the reader waits for a message before it checks again, and waits after
146
+ a failed read.
71
147
 
72
148
  When redis-py reconnects the pub/sub connection it SUBSCRIBEs the channel again. An event
73
149
  published while the connection is down does not reach this process; the event client finds it
74
150
  by poll, on the topic's next push or its check poll.
75
151
  """
76
152
 
77
- def __init__(self, redis_client: "Redis", key_prefix: str = "events:"):
153
+ def __init__(
154
+ self,
155
+ redis_client: "Redis | PerLoopRedis",
156
+ key_prefix: str = "events:",
157
+ *,
158
+ subscribe_timeout: float = DEFAULT_SUBSCRIBE_TIMEOUT,
159
+ read_timeout: float = DEFAULT_READ_TIMEOUT,
160
+ ):
78
161
  self.redis_client = redis_client
79
162
  self.key_prefix = key_prefix
163
+ self.subscribe_timeout = subscribe_timeout
164
+ self.read_timeout = read_timeout
80
165
  self._pubsub: Any = None
81
166
  self._reader: asyncio.Task | None = None
82
167
 
168
+ @classmethod
169
+ def from_url(
170
+ cls,
171
+ url: str,
172
+ key_prefix: str = "events:",
173
+ *,
174
+ subscribe_timeout: float = DEFAULT_SUBSCRIBE_TIMEOUT,
175
+ read_timeout: float = DEFAULT_READ_TIMEOUT,
176
+ **options: Any,
177
+ ) -> "RedisEventStore":
178
+ """A store on `PerLoopRedis(url, **options)`: one client per event loop. `options` are
179
+ `redis.asyncio.Redis.from_url` options over `DEFAULT_CLIENT_OPTIONS`."""
180
+ return cls(
181
+ PerLoopRedis(url, **options),
182
+ key_prefix,
183
+ subscribe_timeout=subscribe_timeout,
184
+ read_timeout=read_timeout,
185
+ )
186
+
83
187
  def rev_key(self, topic: str) -> str:
84
188
  return f"{self.key_prefix}rev:{topic}"
85
189
 
@@ -99,25 +203,24 @@ class RedisEventStore:
99
203
  async def start(self) -> None:
100
204
  """
101
205
  SUBSCRIBEs the events channel and starts the reader once Redis has confirmed the SUBSCRIBE.
102
- Raises `asyncio.TimeoutError` when the confirmation does not arrive within 5 seconds, and
103
- the error of the connection when it fails; the store is then not started.
206
+ Raises `asyncio.TimeoutError` when the confirmation does not arrive within `subscribe_timeout`
207
+ seconds, and the error of the connection when it fails; the store is then not started.
104
208
  """
105
209
  if self.reader_running:
106
210
  return
107
211
  pubsub = self.redis_client.pubsub()
108
212
  try:
109
213
  await pubsub.subscribe(self.channel)
110
- await asyncio.wait_for(self._confirmed(pubsub), _SUBSCRIBE_TIMEOUT)
214
+ await asyncio.wait_for(self._confirmed(pubsub), self.subscribe_timeout)
111
215
  except BaseException:
112
216
  await _close(pubsub)
113
217
  raise
114
218
  self._pubsub = pubsub
115
219
  self._reader = asyncio.create_task(self._read())
116
220
 
117
- @staticmethod
118
- async def _confirmed(pubsub: Any) -> None:
221
+ async def _confirmed(self, pubsub: Any) -> None:
119
222
  while True:
120
- message = await pubsub.get_message(ignore_subscribe_messages=False, timeout=_READ_TIMEOUT)
223
+ message = await pubsub.get_message(ignore_subscribe_messages=False, timeout=self.read_timeout)
121
224
  if message is not None and message.get("type") == "subscribe":
122
225
  return
123
226
 
@@ -185,12 +288,12 @@ class RedisEventStore:
185
288
  async def _read(self) -> None:
186
289
  while True:
187
290
  try:
188
- message = await self._pubsub.get_message(ignore_subscribe_messages=True, timeout=_READ_TIMEOUT)
291
+ message = await self._pubsub.get_message(ignore_subscribe_messages=True, timeout=self.read_timeout)
189
292
  except asyncio.CancelledError:
190
293
  raise
191
294
  except Exception:
192
295
  logger.exception("events: reading the events channel failed")
193
- await asyncio.sleep(_READ_TIMEOUT)
296
+ await asyncio.sleep(self.read_timeout)
194
297
  continue
195
298
  if message is None or message.get("type") != "message":
196
299
  continue
@@ -216,16 +216,14 @@ class Middleware(ABC):
216
216
  the onion chain below, exactly as if this method didn't exist.
217
217
 
218
218
  `cls` is always the viewset *class*, never an instance - for `per-request`/`instance-key`
219
- lifecycles no instance exists yet this early (uniform across all 3 lifecycle modes, rather
220
- than sometimes a real instance and sometimes not, depending on which one). `context` is
221
- real and usable here (see `fastapi_viewsets.context.get_shared_context`) - built as early as
222
- this method can possibly run, which for `per-request`/`instance-key` lifecycles is *before*
223
- `load_state()`; no shipped context processor reads viewset instance state.
224
-
225
- Put here *only* logic that might actually reject - there's no benefit to running
226
- unconditional work (e.g. enriching context with no reject path - see
227
- `middleware.auth.authorization.Authorization`, which splits across both methods for exactly
228
- this reason) before body parsing, so that kind of work stays in `__call__` as it does today.
219
+ lifecycles no instance exists yet this early. `context` is real and usable here (see
220
+ `fastapi_viewsets.context.get_shared_context`) - built as early as this method can possibly
221
+ run, which for `per-request`/`instance-key` lifecycles is *before* `load_state()`; no shipped
222
+ context processor reads viewset instance state.
223
+
224
+ Put here *only* logic that may reject. Unconditional work (e.g. enriching context with no
225
+ reject path) belongs in `__call__`; `middleware.auth.authorization.Authorization` splits
226
+ across both methods this way.
229
227
  For middleware whose entire job is reject-or-allow with nothing unconditional (`Session`,
230
228
  `RateLimiter`), `__call__` is `return await call_next()`: `depends()` makes the only
231
229
  decision, and if it rejects, `__call__`/the onion chain is not reached (the exception
@@ -5,9 +5,7 @@ The muxws transport deliberately does not re-implement parameter binding, valida
5
5
  resolution or response-model serialisation - it hands the request to the application, the same one
6
6
  an HTTP server hands a request to, so the two transports cannot drift apart on anything the
7
7
  application declares. The scope marks the request as a muxws one (`fastapi_viewsets.routing`).
8
-
9
- What that costs is one synthetic ASGI round trip per call, which is still nothing next to an
10
- actual HTTP request: no connection setup, no header parsing, no socket at all.
8
+ Each call is one in-process ASGI round trip.
11
9
  """
12
10
 
13
11
  import json
@@ -129,9 +127,8 @@ def payload_to_body(payload: Any) -> bytes:
129
127
  """
130
128
  Re-encodes an already-decoded muxws payload as the JSON bytes FastAPI expects to parse.
131
129
 
132
- This decode-then-re-encode round trip is the one real inefficiency of dispatching through
133
- ASGI, and it is deliberate: skipping it would mean binding and validating parameters
134
- ourselves, which is exactly the duplication this transport exists to avoid. See GAPS.md.
130
+ FastAPI parses the request body from bytes, so a decoded payload is encoded again as JSON;
131
+ `bytes` pass through unchanged and an absent or `None` payload is an empty body.
135
132
  """
136
133
  from muxws.frames import ABSENT
137
134
 
@@ -1,15 +1,13 @@
1
1
  """
2
2
  The request/response envelope carried over a muxws stream.
3
3
 
4
- muxws deliberately does no routing of its own - SPEC WSM-AUT-004 forbids it from looking at the
5
- opening payload to pick a handler - so the addressing has to come from us. Rather than invent a
6
- vocabulary, we mirror HTTP/2, which muxws already models: an `open` frame carries `headers` plus a
7
- `payload`, exactly as HTTP/2 carries a HEADERS frame followed by DATA.
8
-
9
- Method, path and query string therefore travel as pseudo-headers, `:`-prefixed the way HTTP/2
10
- prefixes its own. The prefix is not decoration - it is what keeps them from colliding with real
11
- HTTP headers, since a header field name may not contain a colon. Everything in the open frame's
12
- headers that is *not* `:`-prefixed is passed through as an ordinary HTTP header.
4
+ muxws does no routing (SPEC WSM-AUT-004: it does not look at the opening payload to pick a
5
+ handler), so the open frame carries the address. The envelope follows HTTP/2: an `open` frame
6
+ carries `headers` plus a `payload`, as HTTP/2 carries a HEADERS frame followed by DATA.
7
+
8
+ Method, path and query string travel as HTTP/2-style pseudo-headers, `:`-prefixed. They cannot
9
+ collide with HTTP headers, because a header field name may not contain a colon. Every header of
10
+ the open frame that is not `:`-prefixed is passed through as an ordinary HTTP header.
13
11
 
14
12
  peer.open(
15
13
  payload={"title": "Kind of Blue"}, # the request body
@@ -1 +0,0 @@
1
- __version__ = "0.11.3"