dynamicforms-fastapi-viewsets 0.9.0__tar.gz → 0.9.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/PKG-INFO +18 -6
  2. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/README.md +14 -2
  3. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/__init__.py +1 -0
  4. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/collection_viewset.py +5 -0
  5. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/conf.py +48 -0
  6. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/route_viewset.py +38 -14
  7. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/__init__.py +92 -0
  8. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/authorizers.py +252 -0
  9. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/changes.py +275 -0
  10. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/conftest.py +219 -0
  11. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/delivery.py +251 -0
  12. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/event.py +56 -0
  13. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/lifecycle.py +32 -0
  14. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/redis.py +259 -0
  15. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/sources.py +92 -0
  16. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/store.py +168 -0
  17. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/topics.py +47 -0
  18. dynamicforms_fastapi_viewsets-0.9.1/fastapi_viewsets/events/viewset.py +224 -0
  19. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/exceptions.py +31 -0
  20. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/middleware/__init__.py +5 -6
  21. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/middleware/auth/__init__.py +3 -3
  22. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/middleware/rate_limiter.py +2 -2
  23. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/mux_ws/__init__.py +12 -12
  24. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/mux_ws/asgi.py +4 -1
  25. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/mux_ws/registry.py +1 -6
  26. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/mux_ws/server.py +7 -1
  27. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/routing.py +4 -0
  28. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/pyproject.toml +3 -3
  29. dynamicforms_fastapi_viewsets-0.9.0/fastapi_viewsets/__init__.py +0 -1
  30. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/.gitignore +0 -0
  31. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/LICENSE +0 -0
  32. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/action_configuration.py +0 -0
  33. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/backends/__init__.py +0 -0
  34. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/backends/django_orm.py +0 -0
  35. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/context/__init__.py +0 -0
  36. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/context/auth/__init__.py +0 -0
  37. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/context/auth/django.py +0 -0
  38. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/context/auth/jwt.py +0 -0
  39. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/context/auth/static.py +0 -0
  40. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/cursor.py +0 -0
  41. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/__init__.py +0 -0
  42. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/build_schema.py +0 -0
  43. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -0
  44. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/celery_viewset/client.py +0 -0
  45. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/celery_viewset/payload.py +0 -0
  46. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +0 -0
  47. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/celery_viewset/server.py +0 -0
  48. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/conftest.py +0 -0
  49. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/lifecycle_runner.py +0 -0
  50. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
  51. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/endpoint_docs.py +0 -0
  52. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/filters/__init__.py +0 -0
  53. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/filters/base.py +0 -0
  54. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/filters/declaration.py +0 -0
  55. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/filters/operators.py +0 -0
  56. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/filters/registry.py +0 -0
  57. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/list_query.py +0 -0
  58. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/list_shapes.py +0 -0
  59. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
  60. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/middleware/idempotency.py +0 -0
  61. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/mixins.py +0 -0
  62. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/mux_ws/protocol.py +0 -0
  63. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/mux_ws/transports.py +0 -0
  64. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/py.typed +0 -0
  65. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/response_classes.py +0 -0
  66. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/save_state/__init__.py +0 -0
  67. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/save_state/save_state.py +0 -0
  68. {dynamicforms_fastapi_viewsets-0.9.0 → dynamicforms_fastapi_viewsets-0.9.1}/fastapi_viewsets/save_state/serialize_state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dynamicforms-fastapi-viewsets
3
- Version: 0.9.0
3
+ Version: 0.9.1
4
4
  Summary: Django REST Framework-style viewsets for FastAPI, with optional Celery-backed async execution and a Vue/TypeScript client counterpart.
5
5
  Project-URL: Homepage, https://github.com/dynamicforms/fastapi-viewsets
6
6
  Project-URL: Repository, https://github.com/dynamicforms/fastapi-viewsets
@@ -33,8 +33,8 @@ Requires-Dist: asgiref; extra == 'dev'
33
33
  Requires-Dist: celery; extra == 'dev'
34
34
  Requires-Dist: coverage; extra == 'dev'
35
35
  Requires-Dist: django; extra == 'dev'
36
- Requires-Dist: httpx; extra == 'dev'
37
- Requires-Dist: muxws[starlette,websockets]>=0.4.0; extra == 'dev'
36
+ Requires-Dist: httpx2; extra == 'dev'
37
+ Requires-Dist: muxws[starlette,websockets]>=0.4.1; extra == 'dev'
38
38
  Requires-Dist: pyjwt; extra == 'dev'
39
39
  Requires-Dist: pytest; extra == 'dev'
40
40
  Requires-Dist: pytest-asyncio; extra == 'dev'
@@ -46,7 +46,7 @@ Requires-Dist: django>=4.2; extra == 'django'
46
46
  Provides-Extra: jwt
47
47
  Requires-Dist: pyjwt>=2.8; extra == 'jwt'
48
48
  Provides-Extra: muxws
49
- Requires-Dist: muxws[starlette]>=0.4.0; extra == 'muxws'
49
+ Requires-Dist: muxws[starlette]>=0.4.1; extra == 'muxws'
50
50
  Description-Content-Type: text/markdown
51
51
 
52
52
  # DynamicForms FastAPI Viewsets
@@ -71,6 +71,11 @@ Celery-backed async execution and a matching Vue/TypeScript client counterpart.
71
71
  per call. A command goes through your application as an HTTP request does - its middleware,
72
72
  exception handlers, router dependencies and state - so both transports behave identically. In the
73
73
  demo, a burst of 100 requests takes 156 ms over REST and 37 ms over muxws.
74
+ - **Events** — publish an event to a topic and every frontend following it learns of it, as a muxws
75
+ push or on its next poll. `ChangeEvents` publishes every create, update and destroy of a viewset to
76
+ its routed base path; custom events are declared with `source()`. Every event is recorded before it
77
+ is pushed, so a dropped push costs latency, not an event. On the frontend, `watch()` on a ViewSet
78
+ follows its topic and `applyChanges()` applies each change to a list of rows.
74
79
  - **Three list shapes** — a bare array, offset paging, or cursor paging. A viewset declares which,
75
80
  and may let a client pick per request with an `X-List-Shape` header.
76
81
  - **Declarative filters** — declare which fields accept which operators and get query parameters,
@@ -145,8 +150,15 @@ An infinite-scrolling grid over a 5000-track library, cursor-paged. Switch the w
145
150
  the REST and muxws transports and between the in-memory and SQLite backends, and compare their
146
151
  latency side by side. Sorting and filtering are server-side.
147
152
 
148
- `--celery` needs Redis on localhost:6379. The end-to-end suite starts its own backend and dev
149
- server on their own ports.
153
+ The grid follows its viewset's change events (`ChangeEvents` on the backend, `watch()` in the
154
+ grid). Click a Favorite cell to toggle it: every tab showing the library applies the change, over
155
+ muxws as a push, over REST on its next poll. A sorted or filtered grid reloads its list on the
156
+ change: the row may have moved or left it.
157
+
158
+ `--celery` needs Redis on localhost:6379. The worker runs the viewset calls of both backends and
159
+ publishes their events through Redis. It runs as one process, because the in-memory library is a
160
+ dict held by each process; the SQLite library is one file shared by every process. The end-to-end
161
+ suite starts its own backend and dev server on their own ports.
150
162
 
151
163
  ## License
152
164
 
@@ -20,6 +20,11 @@ Celery-backed async execution and a matching Vue/TypeScript client counterpart.
20
20
  per call. A command goes through your application as an HTTP request does - its middleware,
21
21
  exception handlers, router dependencies and state - so both transports behave identically. In the
22
22
  demo, a burst of 100 requests takes 156 ms over REST and 37 ms over muxws.
23
+ - **Events** — publish an event to a topic and every frontend following it learns of it, as a muxws
24
+ push or on its next poll. `ChangeEvents` publishes every create, update and destroy of a viewset to
25
+ its routed base path; custom events are declared with `source()`. Every event is recorded before it
26
+ is pushed, so a dropped push costs latency, not an event. On the frontend, `watch()` on a ViewSet
27
+ follows its topic and `applyChanges()` applies each change to a list of rows.
23
28
  - **Three list shapes** — a bare array, offset paging, or cursor paging. A viewset declares which,
24
29
  and may let a client pick per request with an `X-List-Shape` header.
25
30
  - **Declarative filters** — declare which fields accept which operators and get query parameters,
@@ -94,8 +99,15 @@ An infinite-scrolling grid over a 5000-track library, cursor-paged. Switch the w
94
99
  the REST and muxws transports and between the in-memory and SQLite backends, and compare their
95
100
  latency side by side. Sorting and filtering are server-side.
96
101
 
97
- `--celery` needs Redis on localhost:6379. The end-to-end suite starts its own backend and dev
98
- server on their own ports.
102
+ The grid follows its viewset's change events (`ChangeEvents` on the backend, `watch()` in the
103
+ grid). Click a Favorite cell to toggle it: every tab showing the library applies the change, over
104
+ muxws as a push, over REST on its next poll. A sorted or filtered grid reloads its list on the
105
+ change: the row may have moved or left it.
106
+
107
+ `--celery` needs Redis on localhost:6379. The worker runs the viewset calls of both backends and
108
+ publishes their events through Redis. It runs as one process, because the in-memory library is a
109
+ dict held by each process; the SQLite library is one file shared by every process. The end-to-end
110
+ suite starts its own backend and dev server on their own ports.
99
111
 
100
112
  ## License
101
113
 
@@ -0,0 +1 @@
1
+ __version__ = "0.9.1"
@@ -102,6 +102,11 @@ class CollectionViewSet(AsyncCollectionViewSet[K, T]):
102
102
  self._update_func = self._update_mapping
103
103
  self._delete_func = container.__delitem__
104
104
 
105
+ @property
106
+ def pk_field_name(self) -> str:
107
+ """`pk_field`, the name the list's cursor shape and `ChangeEvents` read the pk under."""
108
+ return self.pk_field
109
+
105
110
  def _check_mutable(self):
106
111
  if not self.is_mutable:
107
112
  raise ReadOnlyCollectionError()
@@ -8,6 +8,9 @@ if TYPE_CHECKING:
8
8
  from fastapi.security.base import SecurityBase
9
9
 
10
10
  from .context.auth import AuthBackend
11
+ from .events.authorizers import EventAuthorizer
12
+ from .events.delivery import DeliveryFilter
13
+ from .events.store import EventStore
11
14
  from .middleware import CommandMiddleware
12
15
 
13
16
  ContextProcessor = Callable[["Request", Any], Awaitable[dict[str, Any]]]
@@ -64,6 +67,51 @@ class Settings:
64
67
  class is decorated with route_viewset, as `viewsets_command_middleware` must, which decides
65
68
  `disable_response_model` when the routes are built.
66
69
  """
70
+ self.viewsets_event_store: EventStore | None = None
71
+ """
72
+ The `EventStore` of `fastapi_viewsets.events`: it assigns each event its rev, keeps the
73
+ topic's log for `poll` and carries the event to `deliver()` in every process that watches
74
+ the topic. None is one `MemoryEventStore` for this process. A deployment of more than one
75
+ process, a Celery worker included, sets a `RedisEventStore` in every process.
76
+ """
77
+ self.viewsets_event_authorizers: list[EventAuthorizer] = []
78
+ """
79
+ The functions that decide whether a caller may follow a topic, consulted in list order on
80
+ every `subscribe` and on every `poll`, per topic. The first `True` admits the topic, the first
81
+ `False` refuses it, and no decision refuses it: an empty list refuses every topic.
82
+ """
83
+ self.viewsets_event_delivery_filters: list[DeliveryFilter] = []
84
+ """
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.
87
+ """
88
+ self.viewsets_event_log_size: int = 32
89
+ """
90
+ The number of a topic's most recent events the store keeps for `poll`. 0 keeps none, and a
91
+ poll from any cursor behind the topic's rev answers `events: null`.
92
+ """
93
+ self.viewsets_event_log_ttl: float = 60.0
94
+ """
95
+ Seconds a topic's log is kept after the topic's last event. The topic's rev counter is kept
96
+ without expiry. `RedisEventStore` sets it as the log key's expiry; `MemoryEventStore` empties
97
+ an expired log when it next reads or appends to it.
98
+ """
99
+ self.viewsets_event_push_timeout: float = 2.0
100
+ """
101
+ Seconds the delivery filters and `peer.notify()` of one push of one event to one socket may
102
+ take together. `notify()` queues the push on the socket and returns without waiting for the
103
+ socket to send it. A push that takes longer is cancelled and logged at debug level.
104
+ """
105
+ self.viewsets_event_max_subscriptions: int = 256
106
+ """
107
+ The number of topics one socket may hold; a subscription beyond it raises
108
+ `EventSubscriptionsExceededError` (409). Also the number of cursors one poll may carry.
109
+ """
110
+ self.viewsets_event_poll_after_ms: int = 0
111
+ """
112
+ `poll_after_ms` of every poll answer: the shortest interval, in milliseconds, at which a
113
+ client polls.
114
+ """
67
115
 
68
116
 
69
117
  settings = Settings()
@@ -38,6 +38,14 @@ from .primary_key_model_helper import (
38
38
 
39
39
  T = TypeVar("T")
40
40
 
41
+ MIDDLEWARE_DEPENDS_ATTRIBUTE = "__viewset_middleware_depends__"
42
+ """
43
+ Set to True on the dependency `route_viewset` appends to each route's `dependencies`, which runs
44
+ `Middleware.depends()` of the effective middlewares. The other entries of a route's `dependencies`
45
+ are the application's: those of `FastAPI(dependencies=...)`, of `include_router`, of the
46
+ `APIRouter` and of the route itself.
47
+ """
48
+
41
49
 
42
50
  def build_type_map(cls: type[T], current_map: dict = None) -> dict:
43
51
  if current_map is None:
@@ -131,11 +139,12 @@ def _make_middleware_depends(cls: type, action_name: str):
131
139
  async def middleware_depends(request: Request) -> None:
132
140
  await _run(request)
133
141
 
134
- return middleware_depends
142
+ else:
135
143
 
136
- async def middleware_depends(request: Request, _credential=Depends(scheme)) -> None: # noqa: B008
137
- await _run(request)
144
+ async def middleware_depends(request: Request, _credential=Depends(scheme)) -> None: # noqa: B008
145
+ await _run(request)
138
146
 
147
+ setattr(middleware_depends, MIDDLEWARE_DEPENDS_ATTRIBUTE, True)
139
148
  return middleware_depends
140
149
 
141
150
 
@@ -148,14 +157,17 @@ def route_viewset(
148
157
  register_rest: bool = None,
149
158
  ):
150
159
  """
160
+ Sets `cls.__viewset_metadata__`, a dict of `base_path`, `lifecycle`, `router` (the argument),
161
+ `pk_field_name` (the argument, None without it) and `paths`: each action name, the endpoint
162
+ method's name, mapped to its route's path relative to `base_path` (`""` for `list_items`,
163
+ `"/{pk}"` for `retrieve`, `"/bulk"` for `bulk_create`). Every endpoint added to `router` carries
164
+ `__viewset_class__` (`cls`) and `__viewset_action__` (the action name); the `/schema` endpoints
165
+ carry the action name `"schema"`.
166
+
151
167
  `register_rest` and `register_muxws` decide which transports this viewset is published on.
152
168
  True registers, False does not, and None - the default - defers: REST to "yes", muxws to
153
169
  `settings.viewsets_register_muxws`. Individual endpoints override either again with
154
170
  `@transports(...)`.
155
-
156
- Two parameters rather than one because there are two transports. If a third ever arrives, the
157
- replacement is a single mapping - `transports={"rest": False}`, absent key meaning defer -
158
- rather than a third `register_*`; see GAPS.md for why a set of flags is the worse shape.
159
171
  """
160
172
 
161
173
  if lifecycle not in get_args(LifecycleType):
@@ -172,8 +184,7 @@ def route_viewset(
172
184
  cls_name = cls_name[: -len("ViewSet")]
173
185
  default_tags = [cls_name] if cls_name else None
174
186
  # The viewset's own docstring becomes the description of the group its endpoints appear
175
- # under - the section intro that was otherwise blank however well each endpoint was
176
- # documented. See endpoint_docs.apply_viewset_tags.
187
+ # under. See endpoint_docs.apply_viewset_tags.
177
188
  if cls_name:
178
189
  register_tag(cls_name, viewset_description(cls))
179
190
 
@@ -378,11 +389,13 @@ def route_viewset(
378
389
  def is_schema_route(route) -> bool:
379
390
  return getattr(route.endpoint, "__viewset_schema_endpoint__", False)
380
391
 
381
- def add_route(route_kwargs: dict, transports: set[str]) -> None:
392
+ def add_route(route_kwargs: dict, transports: set[str], action_name: str) -> None:
382
393
  # One route per endpoint on the application's router, whichever transports publish it,
383
394
  # so both transports go through the application's middleware, exception handlers and
384
395
  # router dependencies. The route matches only its own transports (see routing.py), and
385
396
  # only a route published on REST appears in the application's OpenAPI document.
397
+ route_kwargs["endpoint"].__viewset_class__ = cls
398
+ route_kwargs["endpoint"].__viewset_action__ = action_name
386
399
  router.add_api_route(
387
400
  **{**route_kwargs, "include_in_schema": route_kwargs["include_in_schema"] and REST in transports},
388
401
  route_class_override=transport_route_class(router.route_class, frozenset(transports)),
@@ -396,9 +409,14 @@ def route_viewset(
396
409
  rest_routes = []
397
410
  muxws_routes = []
398
411
  documented = set()
412
+ # action name -> the route's path after base_path, so that a route's path on the application
413
+ # is the routed base path (include_router prefix + base_path) followed by it
414
+ paths: dict[str, str] = {}
415
+ base = base_path.rstrip("/")
399
416
  for route in cls.__router.routes:
400
417
  action_name = route.endpoint.__name__ # survives @wraps(original_endpoint) in get_wrapper
401
418
  documented.add(action_name)
419
+ paths.setdefault(action_name, route.path[len(base) :] if route.path.startswith(base) else route.path)
402
420
  dependencies = list(route.dependencies or []) + [Depends(_make_middleware_depends(cls, action_name))]
403
421
  route_kwargs = route_to_add_api_route_kwargs(route, dependencies=dependencies)
404
422
  # Per-viewset wording for an endpoint the mixin provided - the mixin's own docstring is
@@ -412,9 +430,9 @@ def route_viewset(
412
430
  if on_rest:
413
431
  rest_schema_kwargs = {**route_kwargs, "endpoint": openapi_endpoint(rest_routes)}
414
432
  rest_routes.append(rest_schema_kwargs)
415
- add_route(rest_schema_kwargs, {REST})
433
+ add_route(rest_schema_kwargs, {REST}, action_name)
416
434
  if on_muxws_any:
417
- add_route({**route_kwargs, "endpoint": openapi_endpoint(muxws_routes)}, {MUXWS})
435
+ add_route({**route_kwargs, "endpoint": openapi_endpoint(muxws_routes)}, {MUXWS}, action_name)
418
436
  continue
419
437
 
420
438
  on_muxws = resolve_register_muxws(route.endpoint, register_muxws)
@@ -426,7 +444,7 @@ def route_viewset(
426
444
  transports.add(MUXWS)
427
445
  muxws_routes.append(route_kwargs)
428
446
  if transports:
429
- add_route(route_kwargs, transports)
447
+ add_route(route_kwargs, transports, action_name)
430
448
 
431
449
  # Checked here rather than in the decorator, which runs before any route exists. A name
432
450
  # that matches nothing would otherwise leave that endpoint on the mixin's generic
@@ -441,7 +459,13 @@ def route_viewset(
441
459
  if muxws_routes:
442
460
  register_viewset(cls, base_path, muxws_routes, default_tags)
443
461
 
444
- cls.__viewset_metadata__ = {"base_path": base_path, "lifecycle": lifecycle, "router": router}
462
+ cls.__viewset_metadata__ = {
463
+ "base_path": base_path,
464
+ "lifecycle": lifecycle,
465
+ "router": router,
466
+ "pk_field_name": pk_field_name,
467
+ "paths": paths,
468
+ }
445
469
 
446
470
  return cls
447
471
 
@@ -0,0 +1,92 @@
1
+ """
2
+ Events: the backend publishes an event to a topic, and every frontend following the topic learns of
3
+ it, as a push over its muxws socket or on its next poll.
4
+
5
+ from fastapi_viewsets.events import Event, source
6
+
7
+ class ImportFinished(Event):
8
+ event_name = "import_finished"
9
+
10
+ file: str
11
+ rows: int
12
+
13
+ imports = source("imports")
14
+
15
+ await imports.publish(ImportFinished(file="members.csv", rows=412), key="7") # topic "imports:7"
16
+
17
+ Every event is recorded in the store (`settings.viewsets_event_store`) before it is pushed. The
18
+ application calls `start_events()` and `stop_events()` from its lifespan.
19
+ """
20
+
21
+ from .authorizers import (
22
+ allow_all,
23
+ allow_authenticated,
24
+ authorize_topic,
25
+ DeclaredSources,
26
+ EventAuthorizer,
27
+ ViewsetTopics,
28
+ )
29
+ from .changes import Change, ChangeEvents, RecordsChanged, routed_base_path, TOPIC_CONTEXT_KEY
30
+ from .delivery import (
31
+ deliver,
32
+ DeliveryFilter,
33
+ drop_peer,
34
+ reset_subscriptions,
35
+ subscribe,
36
+ Subscription,
37
+ subscriptions,
38
+ topics_of,
39
+ unsubscribe,
40
+ wait_for_pushes,
41
+ )
42
+ from .event import Event
43
+ from .lifecycle import check_events, start_events, stop_events
44
+ from .sources import declared_source, publish, reset_sources, Source, source
45
+ from .store import EventStore, get_store, LoggedEvent, MAX_REV, MemoryEventStore
46
+ from .topics import is_valid_topic, is_viewset_topic, SOURCE_NAME_PATTERN, split_custom_topic, validate_topic
47
+ from .viewset import EventsViewSet, register_events
48
+
49
+ __all__ = [
50
+ "MAX_REV",
51
+ "SOURCE_NAME_PATTERN",
52
+ "TOPIC_CONTEXT_KEY",
53
+ "Change",
54
+ "ChangeEvents",
55
+ "DeclaredSources",
56
+ "DeliveryFilter",
57
+ "Event",
58
+ "EventAuthorizer",
59
+ "EventStore",
60
+ "EventsViewSet",
61
+ "LoggedEvent",
62
+ "MemoryEventStore",
63
+ "RecordsChanged",
64
+ "Source",
65
+ "Subscription",
66
+ "ViewsetTopics",
67
+ "allow_all",
68
+ "allow_authenticated",
69
+ "authorize_topic",
70
+ "check_events",
71
+ "declared_source",
72
+ "deliver",
73
+ "drop_peer",
74
+ "get_store",
75
+ "is_valid_topic",
76
+ "is_viewset_topic",
77
+ "publish",
78
+ "register_events",
79
+ "reset_sources",
80
+ "reset_subscriptions",
81
+ "routed_base_path",
82
+ "source",
83
+ "split_custom_topic",
84
+ "start_events",
85
+ "stop_events",
86
+ "subscribe",
87
+ "subscriptions",
88
+ "topics_of",
89
+ "unsubscribe",
90
+ "validate_topic",
91
+ "wait_for_pushes",
92
+ ]
@@ -0,0 +1,252 @@
1
+ """
2
+ The functions that decide whether a caller may follow a topic, and the authorizers shipped with the
3
+ package. `settings.viewsets_event_authorizers` lists the ones an application uses:
4
+
5
+ settings.viewsets_event_authorizers = [ViewsetTopics(), DeclaredSources()]
6
+ """
7
+
8
+ import inspect
9
+
10
+ from collections.abc import Awaitable, Callable, Iterator
11
+ from contextlib import AsyncExitStack
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ from fastapi import Request
16
+ from fastapi.dependencies.models import Dependant
17
+ from fastapi.dependencies.utils import get_parameterless_sub_dependant, solve_dependencies
18
+ from starlette.routing import compile_path
19
+
20
+ from ..action_configuration import extra_middlewares_for, resolve_action_configuration
21
+ from ..conf import settings
22
+ from ..context import Context
23
+ from ..decorators.route_viewset import MIDDLEWARE_DEPENDS_ATTRIBUTE
24
+ from ..exceptions import EventTopicRefusedError
25
+ from ..middleware import Middleware
26
+ from .sources import declared_source
27
+ from .topics import is_viewset_topic, split_custom_topic
28
+
29
+ EventAuthorizer = Callable[[Request, Context, str], Awaitable[bool | None]]
30
+ """
31
+ `await authorizer(request, context, topic)`: True admits the topic, False refuses it, None leaves
32
+ the decision to the next authorizer in `settings.viewsets_event_authorizers`. An `HTTPException` it
33
+ raises refuses the topic with that exception.
34
+ """
35
+
36
+
37
+ async def authorize_topic(request: Request, context: Context, topic: str) -> None:
38
+ """
39
+ Runs `settings.viewsets_event_authorizers` in list order for `topic` and returns when one
40
+ admits it. The first True admits the topic; the first False, or no decision at all, raises
41
+ `EventTopicRefusedError` (403). An exception an authorizer raises, an `HTTPException` included,
42
+ propagates and the authorizers after it do not run. An authorizer may also return its decision
43
+ without an awaitable.
44
+ """
45
+ for authorizer in settings.viewsets_event_authorizers:
46
+ decision = authorizer(request, context, topic)
47
+ if inspect.isawaitable(decision):
48
+ decision = await decision
49
+ if decision is None:
50
+ continue
51
+ if decision:
52
+ return
53
+ break
54
+ raise EventTopicRefusedError(topic)
55
+
56
+
57
+ async def allow_all(_request: Request, _context: Context, _topic: str) -> bool:
58
+ """Admits every topic."""
59
+ return True
60
+
61
+
62
+ async def allow_authenticated(_request: Request, context: Context, _topic: str) -> bool | None:
63
+ """Admits a topic when `await context.user` is truthy; None otherwise, and for a context without
64
+ `user`."""
65
+ if "user" not in context:
66
+ return None
67
+ return True if await context.user else None
68
+
69
+
70
+ class DeclaredSources:
71
+ """
72
+ Admits a custom topic through the `authorizer` of the source that declared its name: the answer
73
+ is that authorizer's. A viewset topic, a name no source declared, and a source declared without
74
+ an authorizer get None.
75
+ """
76
+
77
+ async def __call__(self, request: Request, context: Context, topic: str) -> bool | None:
78
+ parts = split_custom_topic(topic)
79
+ if parts is None:
80
+ return None
81
+ declared = declared_source(parts[0])
82
+ if declared is None or declared.authorizer is None:
83
+ return None
84
+ decision = declared.authorizer(request, context, topic)
85
+ if inspect.isawaitable(decision):
86
+ decision = await decision
87
+ return decision
88
+
89
+ def __repr__(self) -> str:
90
+ return "DeclaredSources()"
91
+
92
+
93
+ def _routes(app: Any) -> Iterator[Any]:
94
+ """Every route of `app` with every `include_router` prefix and dependency applied: its `path`,
95
+ `endpoint` and `dependencies` are those the application serves the route with."""
96
+ try:
97
+ from fastapi.routing import iter_route_contexts
98
+ except ImportError: # a FastAPI whose include_router copies the routes, prefixed, onto the app
99
+ yield from app.routes
100
+ return
101
+ yield from iter_route_contexts(app.routes)
102
+
103
+
104
+ def _gate_action(cls: type) -> str | None:
105
+ """The action whose admission admits a caller to `cls`'s topic: `list_items`, else `retrieve`."""
106
+ paths = getattr(cls, "__viewset_metadata__", {}).get("paths", {})
107
+ for action in ("list_items", "retrieve"):
108
+ if action in paths:
109
+ return action
110
+ return None
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class ViewsetMatch:
115
+ """The viewset a topic belongs to, as `match_viewset` finds it."""
116
+
117
+ viewset_class: type
118
+ action: str
119
+ """`list_items`, or `retrieve` for a class without `list_items`."""
120
+ route: Any
121
+ """The application's route of `action`."""
122
+ path_params: dict[str, Any]
123
+ """The values of the path parameters of the routed base path, read from the topic and converted
124
+ as Starlette converts them for a request."""
125
+
126
+
127
+ def match_viewset(app: Any, topic: str) -> ViewsetMatch | None:
128
+ """
129
+ The first route of `app`, in routing order, whose endpoint carries `__viewset_class__` and, as
130
+ `__viewset_action__`, the class's `list_items`, or `retrieve` for a class without `list_items`,
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.
134
+ """
135
+ for route in _routes(app):
136
+ endpoint = getattr(route, "endpoint", None)
137
+ cls = getattr(endpoint, "__viewset_class__", None)
138
+ path = getattr(route, "path", None)
139
+ if cls is None or not isinstance(path, str):
140
+ continue
141
+ action = _gate_action(cls)
142
+ if action is None or getattr(endpoint, "__viewset_action__", None) != action:
143
+ continue
144
+ relative = cls.__viewset_metadata__["paths"][action]
145
+ if not path.endswith(relative):
146
+ continue
147
+ regex, _format, convertors = compile_path(path[: len(path) - len(relative)] if relative else path)
148
+ matched = regex.match(topic)
149
+ if matched is None:
150
+ continue
151
+ params = {name: convertors[name].convert(value) for name, value in matched.groupdict().items()}
152
+ return ViewsetMatch(cls, action, route, params)
153
+ return None
154
+
155
+
156
+ def find_viewset(app: Any, topic: str) -> tuple[type, str] | None:
157
+ """`(viewset class, action)` of `match_viewset(app, topic)`, or None."""
158
+ found = match_viewset(app, topic)
159
+ return (found.viewset_class, found.action) if found is not None else None
160
+
161
+
162
+ def _with_path_params(request: Request, path_params: dict[str, Any]) -> Request:
163
+ """`request`, or a copy of it whose `path_params` are `path_params` when there are any."""
164
+ if not path_params:
165
+ return request
166
+ return Request({**request.scope, "path_params": path_params}, request.receive)
167
+
168
+
169
+ async def _route_dependencies_admit(request: Request, route: Any) -> bool:
170
+ """
171
+ Resolves the route's `dependencies` other than the one that runs `Middleware.depends()` (those of
172
+ the application, of `include_router`, of the `APIRouter` and of the route) against `request`,
173
+ as FastAPI resolves them before the endpoint, honouring `app.dependency_overrides`. False when a
174
+ parameter of one does not validate; an exception one raises propagates.
175
+ """
176
+ dependencies = [
177
+ depends
178
+ for depends in getattr(route, "dependencies", None) or ()
179
+ if not getattr(depends.dependency, MIDDLEWARE_DEPENDS_ATTRIBUTE, False)
180
+ ]
181
+ if not dependencies:
182
+ return True
183
+ path = getattr(route, "path_format", None) or route.path
184
+ dependant = Dependant(
185
+ path=path,
186
+ dependencies=[get_parameterless_sub_dependant(depends=depends, path=path) for depends in dependencies],
187
+ )
188
+ accepted = inspect.signature(solve_dependencies).parameters
189
+ async with AsyncExitStack() as stack:
190
+ scope = {
191
+ **request.scope,
192
+ "fastapi_astack": stack,
193
+ "fastapi_inner_astack": stack,
194
+ "fastapi_function_astack": stack,
195
+ }
196
+ arguments: dict[str, Any] = {
197
+ "request": Request(scope, request.receive),
198
+ "dependant": dependant,
199
+ "dependency_overrides_provider": getattr(route, "dependency_overrides_provider", None),
200
+ }
201
+ if "async_exit_stack" in accepted:
202
+ arguments["async_exit_stack"] = stack
203
+ if "embed_body_fields" in accepted:
204
+ arguments["embed_body_fields"] = False
205
+ solved = await solve_dependencies(**arguments)
206
+ errors = solved.errors if hasattr(solved, "errors") else solved[1]
207
+ return not errors
208
+
209
+
210
+ class ViewsetTopics:
211
+ """
212
+ Admits a viewset's topic to a caller the viewset itself admits to its `list_items`, or to its
213
+ `retrieve` when it has no `list_items`.
214
+
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`:
218
+
219
+ 1. the route's `dependencies` other than `route_viewset`'s own, those of the application, of
220
+ `include_router`, of the `APIRouter` and of the route itself; a parameter of one that does not
221
+ validate refuses the topic (False);
222
+ 2. `depends()` of every effective middleware, with the viewset class and the calling context
223
+ rebound to the action's `@action_configuration`, `Context(context.raw(),
224
+ action_configuration=..., action_name=...)`: `settings.viewsets_command_middleware` followed
225
+ by the middleware instances the configuration adds.
226
+
227
+ 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.
229
+ """
230
+
231
+ async def __call__(self, request: Request, context: Context, topic: str) -> bool | None:
232
+ if not is_viewset_topic(topic):
233
+ return None
234
+ found = match_viewset(request.app, topic)
235
+ if found is None:
236
+ return None
237
+ cls, action = found.viewset_class, found.action
238
+ checked = _with_path_params(request, found.path_params)
239
+ if not await _route_dependencies_admit(checked, found.route):
240
+ return False
241
+ action_configuration = resolve_action_configuration(cls, action)
242
+ bound = Context(context.raw(), action_configuration=action_configuration, action_name=action)
243
+ middlewares = settings.viewsets_command_middleware + extra_middlewares_for(
244
+ action_configuration, settings.viewsets_command_middleware
245
+ )
246
+ for middleware in middlewares:
247
+ if isinstance(middleware, Middleware):
248
+ await middleware.depends(checked, cls, bound)
249
+ return True
250
+
251
+ def __repr__(self) -> str:
252
+ return "ViewsetTopics()"