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.
- {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/PKG-INFO +20 -4
- {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/README.md +19 -3
- {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/custom_components/reflex_perspective/__init__.py +1 -1
- {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/custom_components/reflex_perspective/perspective_viewer.jsx +68 -5
- {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/custom_components/reflex_perspective/server.py +101 -5
- {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/custom_components/reflex_perspective/viewer.py +10 -2
- {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/custom_components/reflex_perspective/viewer.pyi +4 -2
- {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/custom_components/reflex_perspective.egg-info/PKG-INFO +20 -4
- {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/custom_components/reflex_perspective.egg-info/SOURCES.txt +1 -0
- {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/pyproject.toml +1 -1
- reflex_perspective-0.3.0/tests/test_bridge_js.py +57 -0
- reflex_perspective-0.3.0/tests/test_demo_access.py +82 -0
- {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/tests/test_server.py +147 -0
- {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/tests/test_specs.py +3 -1
- {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/tests/test_viewer.py +32 -0
- reflex_perspective-0.2.0/tests/test_demo_access.py +0 -70
- {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/LICENSE +0 -0
- {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/custom_components/reflex_perspective/py.typed +0 -0
- {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/custom_components/reflex_perspective.egg-info/dependency_links.txt +0 -0
- {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/custom_components/reflex_perspective.egg-info/requires.txt +0 -0
- {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/custom_components/reflex_perspective.egg-info/top_level.txt +0 -0
- {reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/setup.cfg +0 -0
- {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.
|
|
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
|
|
189
|
-
the
|
|
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`
|
|
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
|
|
155
|
-
the
|
|
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`
|
|
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
|
| --- | --- |
|
|
@@ -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);
|
{reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/custom_components/reflex_perspective/server.py
RENAMED
|
@@ -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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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:
|
{reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/custom_components/reflex_perspective/viewer.py
RENAMED
|
@@ -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 (
|
|
291
|
-
|
|
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[
|
|
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 (
|
|
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.
|
|
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
|
|
189
|
-
the
|
|
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`
|
|
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.
|
|
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,
|
|
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
|
|
File without changes
|
{reflex_perspective-0.2.0 → reflex_perspective-0.3.0}/custom_components/reflex_perspective/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|