reflex-perspective 0.2.0__tar.gz → 0.3.0__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 (23) hide show
  1. {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/PKG-INFO +20 -4
  2. {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/README.md +19 -3
  3. {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/custom_components/reflex_perspective/__init__.py +1 -1
  4. {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/custom_components/reflex_perspective/perspective_viewer.jsx +68 -5
  5. {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/custom_components/reflex_perspective/server.py +101 -5
  6. {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/custom_components/reflex_perspective/viewer.py +10 -2
  7. {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/custom_components/reflex_perspective/viewer.pyi +4 -2
  8. {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/custom_components/reflex_perspective.egg-info/PKG-INFO +20 -4
  9. {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/custom_components/reflex_perspective.egg-info/SOURCES.txt +1 -0
  10. {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/pyproject.toml +1 -1
  11. reflex_perspective-0.3.0/tests/test_bridge_js.py +57 -0
  12. reflex_perspective-0.3.0/tests/test_demo_access.py +82 -0
  13. {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/tests/test_server.py +147 -0
  14. {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/tests/test_specs.py +3 -1
  15. {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/tests/test_viewer.py +32 -0
  16. reflex_perspective-0.2.0/tests/test_demo_access.py +0 -70
  17. {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/LICENSE +0 -0
  18. {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/custom_components/reflex_perspective/py.typed +0 -0
  19. {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/custom_components/reflex_perspective.egg-info/dependency_links.txt +0 -0
  20. {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/custom_components/reflex_perspective.egg-info/requires.txt +0 -0
  21. {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/custom_components/reflex_perspective.egg-info/top_level.txt +0 -0
  22. {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/setup.cfg +0 -0
  23. {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/tests/test_versions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reflex-perspective
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Perspective for Reflex: a WebAssembly analytics viewer (datagrid + WebGL charts) for large and streaming datasets, with client-only, server-only and replicated modes.
5
5
  Author-email: Ernesto Crespo <ecrespo@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -185,9 +185,16 @@ ps.mount(
185
185
  verified read table, building the app raises `RuntimeError` unless you pass
186
186
  `read_variants=`.
187
187
  - With `read_only=True`, a server viewer with `edit_mode="EDIT"`, `update_rows`,
188
- `remove_keys` or `rp.update/remove/replace/clear` gets its socket closed with 4409 (and
189
- the viewer reconnects). Write through the hub instead. Read-only does not stop users from
188
+ `remove_keys` or `rp.update/remove/replace/clear` gets its socket closed with 4409; the
189
+ viewers stop reconnecting and the browser console says why. Write through the hub instead. Read-only does not stop users from
190
190
  exporting what they can see (`view_to_*` reads stay allowed).
191
+ - `max_sessions=N` caps the sessions open on the hub (across all its routes). Once reached,
192
+ new sockets are accepted and closed with **4429** after `authorize`; the viewers keep
193
+ retrying with backoff and connect when a slot frees up. `hub.session_count` gives the
194
+ current number.
195
+ - `on_reject=fn` is called as `fn(code, websocket)` (sync or async) before every refusal
196
+ (1008, `authorize`'s code, 1011, 4409 and 4429), for metrics. Its errors are logged and never
197
+ change the close.
191
198
  - `ps.request_variant()` and `ps.is_read_request()` are public, for apps that compose
192
199
  their own handler around `hub.serve()`.
193
200
 
@@ -283,11 +290,18 @@ Styling props (`height`, `width`, `border_radius`, ...) apply to the `<perspecti
283
290
  | `on_global_filter_update` | List of filters (workspaces) |
284
291
  | `on_layout_update` / `on_active_panel_update` | Panel ids / active panel id |
285
292
  | `on_toggle_settings` | `bool` |
286
- | `on_disconnect` / `on_error` | URL / message |
293
+ | `on_disconnect` | `(url, code)`: the server URL and the WebSocket close code (`None` if unknown); one-argument handlers get the URL |
294
+ | `on_error` | Message |
287
295
 
288
296
  A controlled loop (`config=State.cfg`, `on_config_update=State.set_cfg`) is safe: the
289
297
  component does not re-apply a config the viewer itself just emitted.
290
298
 
299
+ Server viewers reconnect with exponential backoff (0.5 s doubling, up to 10 s) after a lost
300
+ connection, except when the server refused it for good: a close code in 4400–4499 other than
301
+ 4429 (e.g. 4401/4403 from `authorize`, 4409 from `read_only`). Then every viewer of that URL
302
+ stops retrying until the page is reloaded and the browser console explains the code; 4429
303
+ ("too many sessions") keeps retrying.
304
+
291
305
  ## Actions
292
306
 
293
307
  All return an `EventSpec`, usable in triggers (`on_click=rp.download("v")`) or returned from
@@ -325,6 +339,8 @@ uv run reflex run
325
339
 
326
340
  Run it with `PERSPECTIVE_DEMO_READ_ONLY=1 uv run reflex run` to serve the WebSocket in
327
341
  read-only mode: the "Write from the browser" button on `/server` is then refused with 4409.
342
+ `PERSPECTIVE_DEMO_MAX_SESSIONS=1` caps the hub: a second tab gets 4429 until the first closes.
343
+ The access card counts refusals by code through `on_reject`.
328
344
 
329
345
  | | |
330
346
  | --- | --- |
@@ -151,9 +151,16 @@ ps.mount(
151
151
  verified read table, building the app raises `RuntimeError` unless you pass
152
152
  `read_variants=`.
153
153
  - With `read_only=True`, a server viewer with `edit_mode="EDIT"`, `update_rows`,
154
- `remove_keys` or `rp.update/remove/replace/clear` gets its socket closed with 4409 (and
155
- the viewer reconnects). Write through the hub instead. Read-only does not stop users from
154
+ `remove_keys` or `rp.update/remove/replace/clear` gets its socket closed with 4409; the
155
+ viewers stop reconnecting and the browser console says why. Write through the hub instead. Read-only does not stop users from
156
156
  exporting what they can see (`view_to_*` reads stay allowed).
157
+ - `max_sessions=N` caps the sessions open on the hub (across all its routes). Once reached,
158
+ new sockets are accepted and closed with **4429** after `authorize`; the viewers keep
159
+ retrying with backoff and connect when a slot frees up. `hub.session_count` gives the
160
+ current number.
161
+ - `on_reject=fn` is called as `fn(code, websocket)` (sync or async) before every refusal
162
+ (1008, `authorize`'s code, 1011, 4409 and 4429), for metrics. Its errors are logged and never
163
+ change the close.
157
164
  - `ps.request_variant()` and `ps.is_read_request()` are public, for apps that compose
158
165
  their own handler around `hub.serve()`.
159
166
 
@@ -249,11 +256,18 @@ Styling props (`height`, `width`, `border_radius`, ...) apply to the `<perspecti
249
256
  | `on_global_filter_update` | List of filters (workspaces) |
250
257
  | `on_layout_update` / `on_active_panel_update` | Panel ids / active panel id |
251
258
  | `on_toggle_settings` | `bool` |
252
- | `on_disconnect` / `on_error` | URL / message |
259
+ | `on_disconnect` | `(url, code)`: the server URL and the WebSocket close code (`None` if unknown); one-argument handlers get the URL |
260
+ | `on_error` | Message |
253
261
 
254
262
  A controlled loop (`config=State.cfg`, `on_config_update=State.set_cfg`) is safe: the
255
263
  component does not re-apply a config the viewer itself just emitted.
256
264
 
265
+ Server viewers reconnect with exponential backoff (0.5 s doubling, up to 10 s) after a lost
266
+ connection, except when the server refused it for good: a close code in 4400–4499 other than
267
+ 4429 (e.g. 4401/4403 from `authorize`, 4409 from `read_only`). Then every viewer of that URL
268
+ stops retrying until the page is reloaded and the browser console explains the code; 4429
269
+ ("too many sessions") keeps retrying.
270
+
257
271
  ## Actions
258
272
 
259
273
  All return an `EventSpec`, usable in triggers (`on_click=rp.download("v")`) or returned from
@@ -291,6 +305,8 @@ uv run reflex run
291
305
 
292
306
  Run it with `PERSPECTIVE_DEMO_READ_ONLY=1 uv run reflex run` to serve the WebSocket in
293
307
  read-only mode: the "Write from the browser" button on `/server` is then refused with 4409.
308
+ `PERSPECTIVE_DEMO_MAX_SESSIONS=1` caps the hub: a second tab gets 4429 until the first closes.
309
+ The access card counts refusals by code through `on_reject`.
294
310
 
295
311
  | | |
296
312
  | --- | --- |
@@ -67,4 +67,4 @@ __all__ = [
67
67
  "update",
68
68
  ]
69
69
 
70
- __version__ = "0.2.0"
70
+ __version__ = "0.3.0"
@@ -99,6 +99,63 @@ export function resolveServerUrl(url) {
99
99
  return endpoint.toString();
100
100
  }
101
101
 
102
+ /**
103
+ * WebSocket close code carried by a client error. Perspective's transport
104
+ * reports a close to `Client.on_error` as the text "WebSocket closed <code>"
105
+ * (perspective 5.5.1, `src/ts/websocket.ts`); `null` when there is none.
106
+ */
107
+ export function closeCodeFromError(error) {
108
+ const text =
109
+ typeof error === "string" ? error : String(error?.message ?? error ?? "");
110
+ const match = /WebSocket closed (\d{4})\b/.exec(text);
111
+ return match ? Number(match[1]) : null;
112
+ }
113
+
114
+ /** Server refusals a retry cannot fix: 4400-4499, except 4429 (try later). */
115
+ export function isPermanentRefusal(code) {
116
+ return Number.isInteger(code) && code >= 4400 && code <= 4499 && code !== 4429;
117
+ }
118
+
119
+ const REFUSAL_CAUSES = {
120
+ 4401: "not authenticated (the app's authorize hook refused the connection)",
121
+ 4403: "forbidden (the app's authorize hook refused the connection)",
122
+ 4409:
123
+ "read-only socket: this page sent a write (a server viewer with " +
124
+ 'edit_mode="EDIT", update_rows, remove_keys or rp.update/remove/replace/clear). ' +
125
+ "Write through the hub in Python instead",
126
+ };
127
+
128
+ /** Console text for a permanent refusal of `url`. */
129
+ export function refusalMessage(code, url) {
130
+ const cause = REFUSAL_CAUSES[code] ?? "refused by the server";
131
+ return (
132
+ `reflex-perspective: ${url} closed with ${code}: ${cause}. ` +
133
+ "Not reconnecting until the page is reloaded."
134
+ );
135
+ }
136
+
137
+ // URL -> close code of a permanent refusal; such URLs are never retried.
138
+ const WS_REFUSED = new Map();
139
+
140
+ /** Whether `url` (resolved) was permanently refused by the server. */
141
+ export function isRefused(url) {
142
+ return WS_REFUSED.has(url);
143
+ }
144
+
145
+ /**
146
+ * Record why the socket of `url` (resolved) dropped. A permanent refusal
147
+ * blocks further retries for that URL and is reported once through `warn`.
148
+ * Returns the close code, or `null` when the error does not carry one.
149
+ */
150
+ export function noteClose(url, error, warn = console.warn) {
151
+ const code = closeCodeFromError(error);
152
+ if (isPermanentRefusal(code) && !WS_REFUSED.has(url)) {
153
+ WS_REFUSED.set(url, code);
154
+ warn(refusalMessage(code, url));
155
+ }
156
+ return code;
157
+ }
158
+
102
159
  function evictWebsocketClient(url) {
103
160
  WS_CLIENTS.delete(resolveServerUrl(url));
104
161
  }
@@ -110,12 +167,13 @@ async function getWebsocketClient(url) {
110
167
  const perspective = await loadPerspective();
111
168
  const client = await perspective.websocket(resolved);
112
169
  try {
113
- await client.on_error(() => {
170
+ await client.on_error((error) => {
171
+ const code = noteClose(resolved, error);
114
172
  // Evict so the next load() opens a fresh connection.
115
173
  if (WS_CLIENTS.get(resolved) === promise) WS_CLIENTS.delete(resolved);
116
174
  window.dispatchEvent(
117
175
  new CustomEvent("reflex-perspective-disconnect", {
118
- detail: { url: resolved },
176
+ detail: { url: resolved, code },
119
177
  }),
120
178
  );
121
179
  });
@@ -503,8 +561,13 @@ export function ReflexPerspectiveViewer(props) {
503
561
  const [retryTick, setRetryTick] = useState(0);
504
562
  const retryAttempts = useRef(0);
505
563
  const retryTimer = useRef(null);
564
+ // A URL the server refused for good (4400-4499 but 4429) is never retried.
565
+ const refused = () => {
566
+ const url = propsRef.current.serverUrl;
567
+ return Boolean(url) && isRefused(resolveServerUrl(url));
568
+ };
506
569
  const scheduleRetry = useCallback(() => {
507
- if (retryTimer.current) return;
570
+ if (retryTimer.current || refused()) return;
508
571
  const delay = Math.min(
509
572
  10000,
510
573
  500 * 2 ** Math.min(retryAttempts.current, 5),
@@ -512,7 +575,7 @@ export function ReflexPerspectiveViewer(props) {
512
575
  retryAttempts.current += 1;
513
576
  retryTimer.current = setTimeout(() => {
514
577
  retryTimer.current = null;
515
- setRetryTick((t) => t + 1);
578
+ if (!refused()) setRetryTick((t) => t + 1);
516
579
  }, delay);
517
580
  }, []);
518
581
  useEffect(
@@ -869,7 +932,7 @@ export function ReflexPerspectiveViewer(props) {
869
932
  const resolved = resolveServerUrl(serverUrl);
870
933
  const handler = (e) => {
871
934
  if (e.detail?.url !== resolved) return;
872
- propsRef.current.onDisconnect?.(resolved);
935
+ propsRef.current.onDisconnect?.(resolved, e.detail?.code ?? null);
873
936
  scheduleRetry();
874
937
  };
875
938
  window.addEventListener("reflex-perspective-disconnect", handler);
@@ -76,6 +76,12 @@ Authorize = Callable[[WebSocket], Awaitable[int | None] | int | None]
76
76
  Returns ``None`` to accept, or a close code (4000-4999, or 1008) to refuse.
77
77
  """
78
78
 
79
+ OnReject = Callable[[int, WebSocket], Awaitable[None] | None]
80
+ """Hook called with the close code of every refusal (for metrics)."""
81
+
82
+ # Close code for "too many sessions, try again later" (max_sessions).
83
+ _WS_TOO_MANY_SESSIONS = 4429
84
+
79
85
  WRITE_CLOSE_CODE = 4409
80
86
  """Default close code for a frame refused in read-only mode."""
81
87
 
@@ -245,6 +251,28 @@ def _valid_refusal_code(code: object) -> bool:
245
251
  return type(code) is int and (4000 <= code <= 4999 or code == _WS_POLICY_VIOLATION)
246
252
 
247
253
 
254
+ def _check_max_sessions(max_sessions: int | None) -> None:
255
+ if max_sessions is not None and not (
256
+ type(max_sessions) is int and max_sessions >= 1
257
+ ):
258
+ msg = f"max_sessions must be an int >= 1 or None, got {max_sessions!r}"
259
+ raise ValueError(msg)
260
+
261
+
262
+ async def _notify_reject(
263
+ on_reject: OnReject | None, code: int, websocket: WebSocket
264
+ ) -> None:
265
+ """Report a refusal; a failing hook is logged and never changes the close."""
266
+ if on_reject is None:
267
+ return
268
+ try:
269
+ result = on_reject(code, websocket)
270
+ if inspect.isawaitable(result):
271
+ await result
272
+ except Exception:
273
+ logger.exception("Perspective on_reject hook failed")
274
+
275
+
248
276
  async def _run_authorize(authorize: Authorize, websocket: WebSocket) -> int | None:
249
277
  """``None`` to accept, else the close code to use (1011 if the hook fails)."""
250
278
  try:
@@ -306,6 +334,7 @@ class PerspectiveHub:
306
334
  self.client = self.server.new_local_client()
307
335
  self._lock = threading.RLock()
308
336
  self._tables: dict[str, Any] = {}
337
+ self._sessions = 0
309
338
 
310
339
  # ------------------------------------------------------------------ tables
311
340
  def table(
@@ -422,6 +451,23 @@ class PerspectiveHub:
422
451
  view.delete()
423
452
 
424
453
  # --------------------------------------------------------------- websocket
454
+ @property
455
+ def session_count(self) -> int:
456
+ """WebSocket sessions currently open on this hub."""
457
+ with self._lock:
458
+ return self._sessions
459
+
460
+ def _admit(self, max_sessions: int | None) -> bool:
461
+ with self._lock:
462
+ if max_sessions is not None and self._sessions >= max_sessions:
463
+ return False
464
+ self._sessions += 1
465
+ return True
466
+
467
+ def _release(self) -> None:
468
+ with self._lock:
469
+ self._sessions -= 1
470
+
425
471
  async def serve(
426
472
  self,
427
473
  websocket: WebSocket,
@@ -432,6 +478,8 @@ class PerspectiveHub:
432
478
  read_only: bool = False,
433
479
  write_close_code: int = WRITE_CLOSE_CODE,
434
480
  read_variants: Collection[int] | None = None,
481
+ max_sessions: int | None = None,
482
+ on_reject: OnReject | None = None,
435
483
  ) -> None:
436
484
  """Run a Perspective session over a Starlette WebSocket.
437
485
 
@@ -462,8 +510,16 @@ class PerspectiveHub:
462
510
  ``None`` uses :data:`READ_VARIANTS` for the installed
463
511
  ``perspective-python`` and raises ``RuntimeError`` if that
464
512
  version has no verified table.
513
+ max_sessions: Cap on the sessions open on this hub (all its
514
+ routes). Once reached, new sockets are accepted and closed
515
+ with 4429 ("try again later") after ``authorize``.
516
+ on_reject: Called as ``on_reject(code, websocket)`` (sync or
517
+ async) before every refusal: 1008, ``authorize``'s code, 1011,
518
+ ``write_close_code`` and 4429. Its errors are logged and never
519
+ change the close.
465
520
  """
466
521
  allowed_reads = _read_only_reads(read_only, write_close_code, read_variants)
522
+ _check_max_sessions(max_sessions)
467
523
 
468
524
  allowed = (
469
525
  _default_allowed_origins() if allowed_origins is None else allowed_origins
@@ -471,16 +527,41 @@ class PerspectiveHub:
471
527
  origin = websocket.headers.get("origin")
472
528
  if not origin_allowed(origin, allowed):
473
529
  logger.warning("Rejected Perspective websocket from origin %r", origin)
530
+ await _notify_reject(on_reject, _WS_POLICY_VIOLATION, websocket)
474
531
  await websocket.close(code=_WS_POLICY_VIOLATION)
475
532
  return
476
533
 
534
+ refusal = None
477
535
  if authorize is not None:
478
536
  refusal = await _run_authorize(authorize, websocket)
479
- if refusal is not None:
480
- await websocket.accept()
481
- await websocket.close(code=refusal)
482
- return
537
+ if refusal is None and not self._admit(max_sessions):
538
+ logger.warning(
539
+ "Refused Perspective websocket: %s sessions open (max_sessions)",
540
+ max_sessions,
541
+ )
542
+ refusal = _WS_TOO_MANY_SESSIONS
543
+ if refusal is not None:
544
+ await _notify_reject(on_reject, refusal, websocket)
545
+ await websocket.accept()
546
+ await websocket.close(code=refusal)
547
+ return
548
+
549
+ try:
550
+ await self._run_session(
551
+ websocket, executor, allowed_reads, write_close_code, on_reject
552
+ )
553
+ finally:
554
+ self._release()
483
555
 
556
+ async def _run_session(
557
+ self,
558
+ websocket: WebSocket,
559
+ executor: Executor | None,
560
+ allowed_reads: frozenset[int] | None,
561
+ write_close_code: int,
562
+ on_reject: OnReject | None,
563
+ ) -> None:
564
+ """Accept the socket and relay frames to a new Perspective session."""
484
565
  loop = asyncio.get_running_loop()
485
566
  outbox: asyncio.Queue[bytes | None] = asyncio.Queue()
486
567
 
@@ -526,6 +607,7 @@ class PerspectiveHub:
526
607
  if variant is not None
527
608
  else "unreadable",
528
609
  )
610
+ await _notify_reject(on_reject, write_close_code, websocket)
529
611
  await websocket.close(code=write_close_code)
530
612
  break
531
613
  # Requests are awaited one at a time, so ordering is preserved.
@@ -549,6 +631,8 @@ class PerspectiveHub:
549
631
  read_only: bool = False,
550
632
  write_close_code: int = WRITE_CLOSE_CODE,
551
633
  read_variants: Collection[int] | None = None,
634
+ max_sessions: int | None = None,
635
+ on_reject: OnReject | None = None,
552
636
  ) -> Starlette:
553
637
  """A Starlette app exposing this hub's WebSocket at ``path``.
554
638
 
@@ -557,11 +641,13 @@ class PerspectiveHub:
557
641
  options.
558
642
 
559
643
  Raises:
560
- ValueError: ``write_close_code`` outside 4000-4999.
644
+ ValueError: ``write_close_code`` outside 4000-4999, or
645
+ ``max_sessions`` not an int >= 1.
561
646
  RuntimeError: ``read_only=True`` without ``read_variants`` on a
562
647
  ``perspective-python`` version with no verified read table.
563
648
  """
564
649
  _read_only_reads(read_only, write_close_code, read_variants)
650
+ _check_max_sessions(max_sessions)
565
651
 
566
652
  async def endpoint(websocket: WebSocket) -> None:
567
653
  await self.serve(
@@ -572,6 +658,8 @@ class PerspectiveHub:
572
658
  read_only=read_only,
573
659
  write_close_code=write_close_code,
574
660
  read_variants=read_variants,
661
+ max_sessions=max_sessions,
662
+ on_reject=on_reject,
575
663
  )
576
664
 
577
665
  return Starlette(routes=[WebSocketRoute(path, endpoint)])
@@ -600,6 +688,8 @@ def perspective_api(
600
688
  read_only: bool = False,
601
689
  write_close_code: int = WRITE_CLOSE_CODE,
602
690
  read_variants: Collection[int] | None = None,
691
+ max_sessions: int | None = None,
692
+ on_reject: OnReject | None = None,
603
693
  ) -> Starlette:
604
694
  """Build the ``api_transformer`` that serves Perspective at ``path``.
605
695
 
@@ -617,6 +707,8 @@ def perspective_api(
617
707
  read_only=read_only,
618
708
  write_close_code=write_close_code,
619
709
  read_variants=read_variants,
710
+ max_sessions=max_sessions,
711
+ on_reject=on_reject,
620
712
  )
621
713
 
622
714
 
@@ -631,6 +723,8 @@ def mount(
631
723
  read_only: bool = False,
632
724
  write_close_code: int = WRITE_CLOSE_CODE,
633
725
  read_variants: Collection[int] | None = None,
726
+ max_sessions: int | None = None,
727
+ on_reject: OnReject | None = None,
634
728
  ) -> PerspectiveHub:
635
729
  """Add the Perspective WebSocket to an existing ``rx.App``.
636
730
 
@@ -649,6 +743,8 @@ def mount(
649
743
  read_only=read_only,
650
744
  write_close_code=write_close_code,
651
745
  read_variants=read_variants,
746
+ max_sessions=max_sessions,
747
+ on_reject=on_reject,
652
748
  )
653
749
  current = app.api_transformer
654
750
  if current is None:
@@ -170,6 +170,13 @@ def _bool_spec(value: Var[bool]) -> tuple[Var[bool]]:
170
170
  return (value,)
171
171
 
172
172
 
173
+ def _url_code_spec(
174
+ url: Var[str], code: Var[int | None]
175
+ ) -> tuple[Var[str], Var[int | None]]:
176
+ """``(url, close code)``; the code is ``None`` when the bridge cannot tell."""
177
+ return (url, code)
178
+
179
+
173
180
  class PerspectiveViewer(rx.Component):
174
181
  """A ``<perspective-viewer>`` bound to a Perspective ``Table``."""
175
182
 
@@ -287,8 +294,9 @@ class PerspectiveViewer(rx.Component):
287
294
  on_layout_update: rx.EventHandler[_list_spec]
288
295
  on_active_panel_update: rx.EventHandler[_str_spec]
289
296
  on_toggle_settings: rx.EventHandler[_bool_spec]
290
- # The WebSocket connection to the server was lost (auto-reconnects).
291
- on_disconnect: rx.EventHandler[_str_spec]
297
+ # The WebSocket connection to the server was lost: (url, close code or None).
298
+ # Reconnects unless the server refused it (4400-4499 except 4429).
299
+ on_disconnect: rx.EventHandler[_url_code_spec, _str_spec]
292
300
  on_error: rx.EventHandler[_str_spec]
293
301
 
294
302
  @classmethod
@@ -145,7 +145,9 @@ class PerspectiveViewer(rx.Component):
145
145
  on_click: Optional[EventType[()] | EventType[dict[str, Any]]] = None,
146
146
  on_config_update: Optional[EventType[()] | EventType[dict[str, Any]]] = None,
147
147
  on_context_menu: Optional[EventType[()] | EventType[PointerEventInfo]] = None,
148
- on_disconnect: Optional[EventType[()] | EventType[str]] = None,
148
+ on_disconnect: Optional[
149
+ EventType[()] | EventType[str] | EventType[str, int | None]
150
+ ] = None,
149
151
  on_double_click: Optional[EventType[()] | EventType[PointerEventInfo]] = None,
150
152
  on_error: Optional[EventType[()] | EventType[str]] = None,
151
153
  on_focus: Optional[EventType[()]] = None,
@@ -236,7 +238,7 @@ class PerspectiveViewer(rx.Component):
236
238
  on_layout_update: Panels were added/removed in a multi-panel viewer.
237
239
  on_active_panel_update: no description
238
240
  on_toggle_settings: no description
239
- on_disconnect: The WebSocket connection to the server was lost (auto-reconnects).
241
+ on_disconnect: The WebSocket connection to the server was lost: (url, close code or None). Reconnects unless the server refused it (4400-4499 except 4429).
240
242
  on_error: no description
241
243
  **props: Component props, see the class attributes.
242
244
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reflex-perspective
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Perspective for Reflex: a WebAssembly analytics viewer (datagrid + WebGL charts) for large and streaming datasets, with client-only, server-only and replicated modes.
5
5
  Author-email: Ernesto Crespo <ecrespo@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -185,9 +185,16 @@ ps.mount(
185
185
  verified read table, building the app raises `RuntimeError` unless you pass
186
186
  `read_variants=`.
187
187
  - With `read_only=True`, a server viewer with `edit_mode="EDIT"`, `update_rows`,
188
- `remove_keys` or `rp.update/remove/replace/clear` gets its socket closed with 4409 (and
189
- the viewer reconnects). Write through the hub instead. Read-only does not stop users from
188
+ `remove_keys` or `rp.update/remove/replace/clear` gets its socket closed with 4409; the
189
+ viewers stop reconnecting and the browser console says why. Write through the hub instead. Read-only does not stop users from
190
190
  exporting what they can see (`view_to_*` reads stay allowed).
191
+ - `max_sessions=N` caps the sessions open on the hub (across all its routes). Once reached,
192
+ new sockets are accepted and closed with **4429** after `authorize`; the viewers keep
193
+ retrying with backoff and connect when a slot frees up. `hub.session_count` gives the
194
+ current number.
195
+ - `on_reject=fn` is called as `fn(code, websocket)` (sync or async) before every refusal
196
+ (1008, `authorize`'s code, 1011, 4409 and 4429), for metrics. Its errors are logged and never
197
+ change the close.
191
198
  - `ps.request_variant()` and `ps.is_read_request()` are public, for apps that compose
192
199
  their own handler around `hub.serve()`.
193
200
 
@@ -283,11 +290,18 @@ Styling props (`height`, `width`, `border_radius`, ...) apply to the `<perspecti
283
290
  | `on_global_filter_update` | List of filters (workspaces) |
284
291
  | `on_layout_update` / `on_active_panel_update` | Panel ids / active panel id |
285
292
  | `on_toggle_settings` | `bool` |
286
- | `on_disconnect` / `on_error` | URL / message |
293
+ | `on_disconnect` | `(url, code)`: the server URL and the WebSocket close code (`None` if unknown); one-argument handlers get the URL |
294
+ | `on_error` | Message |
287
295
 
288
296
  A controlled loop (`config=State.cfg`, `on_config_update=State.set_cfg`) is safe: the
289
297
  component does not re-apply a config the viewer itself just emitted.
290
298
 
299
+ Server viewers reconnect with exponential backoff (0.5 s doubling, up to 10 s) after a lost
300
+ connection, except when the server refused it for good: a close code in 4400–4499 other than
301
+ 4429 (e.g. 4401/4403 from `authorize`, 4409 from `read_only`). Then every viewer of that URL
302
+ stops retrying until the page is reloaded and the browser console explains the code; 4429
303
+ ("too many sessions") keeps retrying.
304
+
291
305
  ## Actions
292
306
 
293
307
  All return an `EventSpec`, usable in triggers (`on_click=rp.download("v")`) or returned from
@@ -325,6 +339,8 @@ uv run reflex run
325
339
 
326
340
  Run it with `PERSPECTIVE_DEMO_READ_ONLY=1 uv run reflex run` to serve the WebSocket in
327
341
  read-only mode: the "Write from the browser" button on `/server` is then refused with 4409.
342
+ `PERSPECTIVE_DEMO_MAX_SESSIONS=1` caps the hub: a second tab gets 4429 until the first closes.
343
+ The access card counts refusals by code through `on_reject`.
328
344
 
329
345
  | | |
330
346
  | --- | --- |
@@ -12,6 +12,7 @@ custom_components/reflex_perspective.egg-info/SOURCES.txt
12
12
  custom_components/reflex_perspective.egg-info/dependency_links.txt
13
13
  custom_components/reflex_perspective.egg-info/requires.txt
14
14
  custom_components/reflex_perspective.egg-info/top_level.txt
15
+ tests/test_bridge_js.py
15
16
  tests/test_demo_access.py
16
17
  tests/test_server.py
17
18
  tests/test_specs.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "reflex-perspective"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Perspective for Reflex: a WebAssembly analytics viewer (datagrid + WebGL charts) for large and streaming datasets, with client-only, server-only and replicated modes."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -0,0 +1,57 @@
1
+ """Runs the JavaScript unit tests of the React bridge (tests/js, node:test).
2
+
3
+ The bridge is bundled with esbuild (the same pinned version CI uses for its
4
+ syntax check) with React and Reflex's runtime modules stubbed out; the
5
+ Perspective packages are only imported lazily and stay external.
6
+ """
7
+
8
+ import os
9
+ import shutil
10
+ import subprocess
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ ROOT = Path(__file__).resolve().parents[1]
16
+ BRIDGE = ROOT / "custom_components" / "reflex_perspective" / "perspective_viewer.jsx"
17
+ JS = ROOT / "tests" / "js"
18
+ ESBUILD = "esbuild@0.25"
19
+
20
+ pytestmark = pytest.mark.skipif(
21
+ not (shutil.which("node") and shutil.which("npx")), reason="node/npx not installed"
22
+ )
23
+
24
+
25
+ def run(cmd: list[str], **kw) -> subprocess.CompletedProcess:
26
+ return subprocess.run( # noqa: S603 - fixed argv, no shell
27
+ cmd, cwd=ROOT, capture_output=True, text=True, timeout=300, check=False, **kw
28
+ )
29
+
30
+
31
+ def test_bridge_js_unit_tests(tmp_path):
32
+ """REQ-VIEW-011/012: the bridge's pure helpers pass their node:test suite."""
33
+ bundle = tmp_path / "bridge.mjs"
34
+ built = run(
35
+ [
36
+ shutil.which("npx"),
37
+ "--yes",
38
+ ESBUILD,
39
+ str(BRIDGE),
40
+ "--bundle",
41
+ "--format=esm",
42
+ "--platform=node",
43
+ "--loader:.jsx=jsx",
44
+ "--log-level=warning",
45
+ f"--alias:react={JS / 'stubs' / 'react.mjs'}",
46
+ f"--alias:$/env.json={JS / 'stubs' / 'env.json'}",
47
+ f"--alias:$/utils/state={JS / 'stubs' / 'state.mjs'}",
48
+ "--external:@perspective-dev/*",
49
+ f"--outfile={bundle}",
50
+ ]
51
+ )
52
+ assert built.returncode == 0, built.stderr
53
+ tested = run(
54
+ [shutil.which("node"), "--test", str(JS / "bridge.test.mjs")],
55
+ env={**os.environ, "BRIDGE_BUNDLE": bundle.as_uri()},
56
+ )
57
+ assert tested.returncode == 0, tested.stdout + tested.stderr
@@ -0,0 +1,82 @@
1
+ """Demo app WebSocket access options (T-006): authorize + read-only."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ pytest.importorskip("perspective")
9
+
10
+ from starlette.testclient import TestClient
11
+ from starlette.websockets import WebSocketDisconnect
12
+
13
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "perspective_demo"))
14
+
15
+ from perspective_demo import access
16
+ from reflex_perspective import server as ps
17
+
18
+
19
+ @pytest.fixture(autouse=True)
20
+ def fresh_access():
21
+ access.Access.reset()
22
+ yield
23
+ access.Access.reset()
24
+
25
+
26
+ @pytest.mark.parametrize(
27
+ ("value", "expected"),
28
+ [("1", True), ("true", True), ("YES", True), ("0", False), ("", False)],
29
+ )
30
+ def test_read_only_from_env(value, expected):
31
+ """REQ-SRV-013 (demo): PERSPECTIVE_DEMO_READ_ONLY turns read-only on."""
32
+ options = access.ws_options({access.READ_ONLY_ENV: value})
33
+ assert options["read_only"] is expected
34
+ assert options["authorize"] is access.authorize
35
+ assert options["on_reject"] is access.on_reject
36
+
37
+
38
+ @pytest.mark.parametrize(
39
+ ("value", "expected"), [("3", 3), ("1", 1), ("", None), ("0", None), ("x", None)]
40
+ )
41
+ def test_max_sessions_from_env(value, expected):
42
+ """REQ-SRV-019 (demo): PERSPECTIVE_DEMO_MAX_SESSIONS caps the hub."""
43
+ assert (
44
+ access.ws_options({access.MAX_SESSIONS_ENV: value})["max_sessions"] == expected
45
+ )
46
+
47
+
48
+ def test_defaults_off():
49
+ """REQ-SRV-017 (demo): without variables the demo is writable and uncapped."""
50
+ options = access.ws_options({})
51
+ assert options["read_only"] is False
52
+ assert options["max_sessions"] is None
53
+
54
+
55
+ def connect_and_close_code(app, frame=None):
56
+ with (
57
+ TestClient(app) as client,
58
+ client.websocket_connect("/perspective") as ws,
59
+ pytest.raises(WebSocketDisconnect) as exc,
60
+ ):
61
+ if frame is not None:
62
+ ws.send_bytes(frame)
63
+ ws.receive_bytes()
64
+ return exc.value.code
65
+
66
+
67
+ def test_closed_door_refuses_and_is_counted():
68
+ """REQ-SRV-011/020 (demo): closing the door refuses with 4403, counted."""
69
+ hub = ps.PerspectiveHub()
70
+ app = hub.asgi_app("/perspective", **access.ws_options({}))
71
+ access.Access.accepting = False
72
+ assert connect_and_close_code(app) == 4403
73
+ assert access.Access.rejections == {4403: 1}
74
+
75
+
76
+ def test_refused_writes_are_counted():
77
+ """REQ-SRV-014/020 (demo): each read-only refusal is counted by on_reject."""
78
+ hub = ps.PerspectiveHub()
79
+ app = hub.asgi_app("/perspective", **access.ws_options({access.READ_ONLY_ENV: "1"}))
80
+ assert connect_and_close_code(app, b"\x08\x80") == 4409 # malformed (Art. 2)
81
+ assert access.Access.rejections == {4409: 1}
82
+ assert access.rejection_summary() == "4409×1"
@@ -619,6 +619,8 @@ def test_defaults_unchanged(func):
619
619
  "read_only": False,
620
620
  "write_close_code": 4409,
621
621
  "read_variants": None,
622
+ "max_sessions": None,
623
+ "on_reject": None,
622
624
  }
623
625
  for name, default in new.items():
624
626
  assert params[name].kind is inspect.Parameter.KEYWORD_ONLY, name
@@ -672,3 +674,148 @@ def test_classifier_is_public():
672
674
  "request_variant",
673
675
  ]:
674
676
  assert name in ps.__all__
677
+
678
+
679
+ # ------------------------------------------------- session cap and on_reject
680
+ def wait_for(predicate, timeout=5.0):
681
+ """Server-side cleanup runs after the client closes; poll briefly."""
682
+ import time
683
+
684
+ deadline = time.monotonic() + timeout
685
+ while not predicate():
686
+ if time.monotonic() > deadline:
687
+ return False
688
+ time.sleep(0.02)
689
+ return True
690
+
691
+
692
+ def test_session_count():
693
+ """REQ-SRV-022: open WebSocket sessions are counted and released."""
694
+ hub = seeded_hub()
695
+ assert hub.session_count == 0
696
+ with TestClient(hub.asgi_app("/perspective")) as client:
697
+ with client.websocket_connect("/perspective") as a:
698
+ live_client(a).open_table("t").size()
699
+ with client.websocket_connect("/perspective") as b:
700
+ live_client(b).open_table("t").size()
701
+ assert hub.session_count == 2
702
+ assert wait_for(lambda: hub.session_count == 0)
703
+
704
+
705
+ def test_max_sessions_refuses_with_4429():
706
+ """REQ-SRV-019: a full hub refuses new sockets with 4429, without a session."""
707
+ hub = seeded_hub(SpyServer())
708
+ app = hub.asgi_app("/perspective", max_sessions=1)
709
+ with TestClient(app) as client:
710
+ with client.websocket_connect("/perspective") as first:
711
+ assert live_client(first).open_table("t").size() == 1
712
+ with client.websocket_connect("/perspective") as second:
713
+ assert close_code(second) == 4429
714
+ assert hub.server.sessions == 1
715
+ assert wait_for(lambda: hub.session_count == 0)
716
+ with client.websocket_connect("/perspective") as third:
717
+ assert live_client(third).open_table("t").size() == 1
718
+
719
+
720
+ def test_cap_checked_after_authorize():
721
+ """REQ-SRV-019: the authorize verdict wins over a full hub."""
722
+ hub = seeded_hub()
723
+ verdict = {"code": None}
724
+ app = hub.asgi_app(
725
+ "/perspective", max_sessions=1, authorize=lambda ws: verdict["code"]
726
+ )
727
+ with TestClient(app) as client, client.websocket_connect("/perspective") as a:
728
+ live_client(a).open_table("t").size()
729
+ verdict["code"] = 4403
730
+ with client.websocket_connect("/perspective") as b:
731
+ assert close_code(b) == 4403
732
+
733
+
734
+ @pytest.mark.parametrize("value", [0, -1, 1.5, True, "2"])
735
+ @pytest.mark.parametrize("build", ["asgi_app", "perspective_api", "mount"])
736
+ def test_invalid_max_sessions(build, value):
737
+ """REQ-SRV-023: max_sessions must be an int >= 1."""
738
+ hub = make_hub()
739
+ builders = {
740
+ "asgi_app": lambda: hub.asgi_app(max_sessions=value),
741
+ "perspective_api": lambda: ps.perspective_api(hub=hub, max_sessions=value),
742
+ "mount": lambda: ps.mount(FakeApp(), hub=hub, max_sessions=value),
743
+ }
744
+ with pytest.raises(ValueError, match="max_sessions"):
745
+ builders[build]()
746
+
747
+
748
+ def _reject_scenario(name, hub, on_reject):
749
+ """Open one socket that gets refused for ``name``; return its close code."""
750
+ kw = {"on_reject": on_reject}
751
+ headers = {}
752
+ if name == "origin":
753
+ kw["allowed_origins"] = ["http://app.test"]
754
+ headers = {"origin": "http://evil.test"}
755
+ elif name == "authorize":
756
+ kw["authorize"] = lambda ws: 4401
757
+ elif name == "authorize_error":
758
+ kw["authorize"] = lambda ws: 1 / 0
759
+ elif name == "read_only":
760
+ kw["read_only"] = True
761
+ elif name == "cap":
762
+ kw["max_sessions"] = 1
763
+ app = hub.asgi_app("/perspective", **kw)
764
+ with TestClient(app) as client:
765
+ if name == "origin":
766
+ with (
767
+ pytest.raises(WebSocketDisconnect) as exc,
768
+ client.websocket_connect("/perspective", headers=headers),
769
+ ):
770
+ pass
771
+ return exc.value.code
772
+ if name == "cap":
773
+ with client.websocket_connect("/perspective") as keep:
774
+ live_client(keep).open_table("t").size()
775
+ with client.websocket_connect("/perspective") as ws:
776
+ return close_code(ws)
777
+ with client.websocket_connect("/perspective") as ws:
778
+ if name == "read_only":
779
+ ws.send_bytes(frame("update"))
780
+ return close_code(ws)
781
+
782
+
783
+ REJECTIONS = {
784
+ "origin": 1008,
785
+ "authorize": 4401,
786
+ "authorize_error": 1011,
787
+ "read_only": 4409,
788
+ "cap": 4429,
789
+ }
790
+
791
+
792
+ @pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
793
+ @pytest.mark.parametrize("name", list(REJECTIONS))
794
+ def test_on_reject_reports_each_code(name, asynchronous):
795
+ """REQ-SRV-020: every refusal reaches on_reject with its close code."""
796
+ calls = []
797
+
798
+ def hook(code, websocket):
799
+ calls.append((code, websocket))
800
+
801
+ async def hook_async(code, websocket):
802
+ await asyncio.sleep(0)
803
+ calls.append((code, websocket))
804
+
805
+ code = _reject_scenario(name, seeded_hub(), hook_async if asynchronous else hook)
806
+ assert code == REJECTIONS[name]
807
+ assert [c for c, _ in calls] == [REJECTIONS[name]]
808
+ assert isinstance(calls[0][1], WebSocket)
809
+
810
+
811
+ @pytest.mark.parametrize("name", ["authorize", "read_only", "cap"])
812
+ def test_on_reject_error_keeps_close_code(name, caplog):
813
+ """REQ-SRV-021: a failing on_reject is logged and the close code stands."""
814
+
815
+ def boom(code, websocket):
816
+ raise RuntimeError("metrics backend down")
817
+
818
+ with caplog.at_level(logging.ERROR, logger="reflex_perspective.server"):
819
+ code = _reject_scenario(name, seeded_hub(), boom)
820
+ assert code == REJECTIONS[name]
821
+ assert any(r.exc_info for r in caplog.records)
@@ -81,10 +81,12 @@ def test_changelog_lists_releases():
81
81
  assert "### Security" in changelog
82
82
 
83
83
 
84
- FOLDED_REQS = [f"REQ-SRV-0{n}" for n in range(10, 19)] + [
84
+ FOLDED_REQS = [f"REQ-SRV-0{n}" for n in range(10, 24)] + [
85
85
  "REQ-VER-003",
86
86
  "REQ-VER-004",
87
87
  "REQ-VER-005",
88
+ "REQ-VIEW-011",
89
+ "REQ-VIEW-012",
88
90
  ]
89
91
 
90
92
 
@@ -107,3 +107,35 @@ def test_constants():
107
107
  assert "Datagrid" in rp.PLUGINS and "Treemap" in rp.PLUGINS
108
108
  assert "Pro Dark" in rp.THEMES
109
109
  assert "sum" in rp.AGGREGATES
110
+
111
+
112
+ class Conn(rx.State):
113
+ url: str = ""
114
+ code: int = 0
115
+
116
+ @rx.event
117
+ def lost(self, url: str):
118
+ self.url = url
119
+
120
+ @rx.event
121
+ def lost_with_code(self, url: str, code: int | None):
122
+ self.url, self.code = url, code or 0
123
+
124
+
125
+ def test_on_disconnect_accepts_url_and_code():
126
+ """REQ-VIEW-012: on_disconnect passes (url, code); 1-arg handlers still work."""
127
+ for handler in (Conn.lost, Conn.lost_with_code):
128
+ js = render(
129
+ rp.perspective_viewer(
130
+ server_url="/perspective", server_table="t", on_disconnect=handler
131
+ )
132
+ )
133
+ assert "onDisconnect" in js
134
+ two = render(
135
+ rp.perspective_viewer(
136
+ server_url="/perspective",
137
+ server_table="t",
138
+ on_disconnect=Conn.lost_with_code,
139
+ )
140
+ )
141
+ assert "_code" in two
@@ -1,70 +0,0 @@
1
- """Demo app WebSocket access options (T-006): authorize + read-only."""
2
-
3
- import sys
4
- from pathlib import Path
5
-
6
- import pytest
7
-
8
- pytest.importorskip("perspective")
9
-
10
- from starlette.testclient import TestClient
11
- from starlette.websockets import WebSocketDisconnect
12
-
13
- sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "perspective_demo"))
14
-
15
- from perspective_demo import access
16
- from reflex_perspective import server as ps
17
-
18
-
19
- @pytest.fixture(autouse=True)
20
- def fresh_access():
21
- access.Access.reset()
22
- yield
23
- access.Access.reset()
24
-
25
-
26
- @pytest.mark.parametrize(
27
- ("value", "expected"),
28
- [("1", True), ("true", True), ("YES", True), ("0", False), ("", False)],
29
- )
30
- def test_read_only_from_env(value, expected):
31
- """REQ-SRV-013 (demo): PERSPECTIVE_DEMO_READ_ONLY turns read-only on."""
32
- options = access.ws_options({access.READ_ONLY_ENV: value})
33
- assert options["read_only"] is expected
34
- assert options["authorize"] is access.authorize
35
-
36
-
37
- def test_read_only_off_by_default():
38
- """REQ-SRV-017 (demo): without the variable the demo stays writable."""
39
- assert access.ws_options({})["read_only"] is False
40
-
41
-
42
- def test_authorize_counts_and_refuses_when_closed():
43
- """REQ-SRV-011 (demo): closing the door refuses new viewers with 4403."""
44
- hub = ps.PerspectiveHub()
45
- app = hub.asgi_app("/perspective", **access.ws_options({}))
46
- with TestClient(app) as client:
47
- with client.websocket_connect("/perspective"):
48
- pass
49
- access.Access.accepting = False
50
- with (
51
- client.websocket_connect("/perspective") as ws,
52
- pytest.raises(WebSocketDisconnect) as exc,
53
- ):
54
- ws.receive_bytes()
55
- assert exc.value.code == 4403
56
- assert (access.Access.accepted, access.Access.refused) == (1, 1)
57
-
58
-
59
- def test_refused_writes_are_counted_from_the_library_log():
60
- """REQ-SRV-014 (demo): each read-only refusal bumps the counter."""
61
- hub = ps.PerspectiveHub()
62
- app = hub.asgi_app("/perspective", **access.ws_options({access.READ_ONLY_ENV: "1"}))
63
- with (
64
- TestClient(app) as client,
65
- client.websocket_connect("/perspective") as ws,
66
- pytest.raises(WebSocketDisconnect),
67
- ):
68
- ws.send_bytes(b"\x08\x80") # malformed frame (Art. 2 allows hand-written)
69
- ws.receive_bytes()
70
- assert access.Access.writes_refused == 1