pgwidgets-python 0.2.1__py3-none-any.whl → 0.3.0__py3-none-any.whl
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.
- pgwidgets/__init__.py +2 -0
- pgwidgets/async_/application.py +245 -43
- pgwidgets/async_/widget.py +38 -6
- pgwidgets/buffer.py +125 -0
- pgwidgets/method_types.py +49 -2
- pgwidgets/sync/application.py +281 -46
- pgwidgets/sync/widget.py +42 -6
- {pgwidgets_python-0.2.1.dist-info → pgwidgets_python-0.3.0.dist-info}/METADATA +1 -1
- pgwidgets_python-0.3.0.dist-info/RECORD +21 -0
- pgwidgets_python-0.2.1.dist-info/RECORD +0 -20
- {pgwidgets_python-0.2.1.dist-info → pgwidgets_python-0.3.0.dist-info}/WHEEL +0 -0
- {pgwidgets_python-0.2.1.dist-info → pgwidgets_python-0.3.0.dist-info}/licenses/LICENSE.md +0 -0
- {pgwidgets_python-0.2.1.dist-info → pgwidgets_python-0.3.0.dist-info}/top_level.txt +0 -0
pgwidgets/method_types.py
CHANGED
|
@@ -235,6 +235,10 @@ STATE_DEFAULTS = {
|
|
|
235
235
|
"TabWidget": {"index": -1},
|
|
236
236
|
"StackWidget": {"index": -1},
|
|
237
237
|
"MDIWidget": {"index": -1},
|
|
238
|
+
# ScrollBar uses 1-arg set_scroll_percent / set_thumb_percent,
|
|
239
|
+
# so a scalar default is correct (not the (0.0, 0.0) tuple used
|
|
240
|
+
# by the 2-arg scroll widgets in STATE_KEY_DEFAULTS).
|
|
241
|
+
"ScrollBar": {"scroll_percent": 0.0, "thumb_percent": 0.0},
|
|
238
242
|
}
|
|
239
243
|
|
|
240
244
|
# Cross-widget default values for state keys, used as a fallback when
|
|
@@ -245,6 +249,17 @@ STATE_KEY_DEFAULTS = {
|
|
|
245
249
|
"size": (0, 0),
|
|
246
250
|
"position": (0, 0),
|
|
247
251
|
"index": -1,
|
|
252
|
+
# Scroll widgets that take (h_pct, v_pct). ScrollBar overrides
|
|
253
|
+
# these in STATE_DEFAULTS with scalar 0.0.
|
|
254
|
+
"scroll_position": (0.0, 0.0),
|
|
255
|
+
"scroll_percent": (0.0, 0.0),
|
|
256
|
+
"thumb_percent": (0.0, 0.0),
|
|
257
|
+
# set_expanding(horizontal, vertical) → tuple of bools
|
|
258
|
+
"expanding": (False, False),
|
|
259
|
+
"enabled": True,
|
|
260
|
+
"state": False,
|
|
261
|
+
# HTMLMediaElement convention: 0.0 (muted) to 1.0 (full).
|
|
262
|
+
"volume": 1.0,
|
|
248
263
|
}
|
|
249
264
|
|
|
250
265
|
# Widgets with incrementally-built item lists.
|
|
@@ -323,6 +338,34 @@ def _menuaction_get_state(self):
|
|
|
323
338
|
"""Alias for MenuAction.get_checked — keeps a single state key."""
|
|
324
339
|
return self.get_checked()
|
|
325
340
|
|
|
341
|
+
# Above this size (bytes), set_binary_image switches from the single-
|
|
342
|
+
# frame _send_binary transport to the chunked _send_binary_chunked
|
|
343
|
+
# transport. Keeps small frames cheap and lets multi-megabyte frames
|
|
344
|
+
# yield to other WebSocket traffic between chunks.
|
|
345
|
+
_BINARY_CHUNK_THRESHOLD = 1 * 1024 * 1024 # 1 MiB
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _send_binary_auto(session, wid, method, args, data):
|
|
349
|
+
"""Pick single-frame or chunked transport based on payload size.
|
|
350
|
+
|
|
351
|
+
If ``data`` is a :class:`pgwidgets.Buffer`, its bytes ship via the
|
|
352
|
+
chunked transport (regardless of size) with ``shape`` and
|
|
353
|
+
``dtype`` attached to the announce so the JS receiver constructs
|
|
354
|
+
a typed array. Plain bytes-like input falls back to the size
|
|
355
|
+
heuristic (chunked above ~1 MiB, single-frame below).
|
|
356
|
+
"""
|
|
357
|
+
from pgwidgets.buffer import Buffer # local import to avoid cycle
|
|
358
|
+
if isinstance(data, Buffer):
|
|
359
|
+
session._send_binary_chunked(
|
|
360
|
+
wid, method, args, data.data,
|
|
361
|
+
shape=data.shape, dtype=data.dtype)
|
|
362
|
+
return
|
|
363
|
+
if len(data) > _BINARY_CHUNK_THRESHOLD:
|
|
364
|
+
session._send_binary_chunked(wid, method, args, data)
|
|
365
|
+
else:
|
|
366
|
+
session._send_binary(wid, method, args, data)
|
|
367
|
+
|
|
368
|
+
|
|
326
369
|
def _image_set_binary_image(self, data, format="jpeg"):
|
|
327
370
|
"""Set the image from raw bytes via a WebSocket binary frame.
|
|
328
371
|
|
|
@@ -343,6 +386,10 @@ def _image_set_binary_image(self, data, format="jpeg"):
|
|
|
343
386
|
that reconstruction after a browser reconnect re-sends the most
|
|
344
387
|
recently set image. Earlier set_image (URL-based) state is
|
|
345
388
|
cleared since the two methods are mutually exclusive.
|
|
389
|
+
|
|
390
|
+
Large payloads (above ~1 MiB) automatically use the chunked
|
|
391
|
+
binary transport so the WebSocket can interleave other messages
|
|
392
|
+
while the image streams.
|
|
346
393
|
"""
|
|
347
394
|
if not isinstance(data, (bytes, bytearray, memoryview)):
|
|
348
395
|
raise TypeError(
|
|
@@ -350,8 +397,8 @@ def _image_set_binary_image(self, data, format="jpeg"):
|
|
|
350
397
|
data = bytes(data)
|
|
351
398
|
self._state.pop("image", None)
|
|
352
399
|
self._state["binary_image"] = (format, data)
|
|
353
|
-
self._session.
|
|
354
|
-
|
|
400
|
+
_send_binary_auto(self._session, self._wid,
|
|
401
|
+
"set_binary_image", [format], data)
|
|
355
402
|
|
|
356
403
|
# State keys whose value is a (format, bytes) tuple that must be
|
|
357
404
|
# replayed via _send_binary instead of _call during reconstruction.
|
pgwidgets/sync/application.py
CHANGED
|
@@ -33,7 +33,7 @@ from pgwidgets.method_types import (
|
|
|
33
33
|
STATE_SYNC_CALLBACKS, STATE_SYNC_REQUIRES_OPTION,
|
|
34
34
|
WIDGET_CALLBACK_SYNC, POST_CHILDREN_STATE_KEYS, ITEM_LIST_CONFIG,
|
|
35
35
|
CHILD_CLOSE_CALLBACKS, REPLAY_METHODS, TREE_VIEW_WIDGETS,
|
|
36
|
-
BINARY_STATE_KEYS,
|
|
36
|
+
BINARY_STATE_KEYS, _send_binary_auto,
|
|
37
37
|
)
|
|
38
38
|
|
|
39
39
|
_CONCURRENCY_MODES = ("serialized", "per_session", "concurrent")
|
|
@@ -169,6 +169,11 @@ class Session:
|
|
|
169
169
|
|
|
170
170
|
self._widget_classes = app._widget_classes
|
|
171
171
|
self._transfers = {} # transfer_id -> transfer state dict
|
|
172
|
+
# FIFO of binary-chunk JSON headers (encoding="binary") still
|
|
173
|
+
# awaiting their paired raw binary frame. Each connection's
|
|
174
|
+
# send order on the JS side is single-threaded, so a simple
|
|
175
|
+
# FIFO suffices on the receive side too.
|
|
176
|
+
self._pending_binary_headers = []
|
|
172
177
|
self._callback_source_ws = None # ws that sent current callback
|
|
173
178
|
|
|
174
179
|
self._reconstructing = False # suppress callbacks during reconstruction
|
|
@@ -273,6 +278,20 @@ class Session:
|
|
|
273
278
|
# -- Message handling --
|
|
274
279
|
|
|
275
280
|
def _handle_message(self, data):
|
|
281
|
+
# Raw binary frames are chunk payloads — pair with the head of
|
|
282
|
+
# the pending-binary FIFO (a list of binary-chunk JSON headers
|
|
283
|
+
# whose encoding == "binary"). This is symmetric with the
|
|
284
|
+
# JS-side intake.
|
|
285
|
+
if isinstance(data, (bytes, bytearray, memoryview)):
|
|
286
|
+
queue = self._pending_binary_headers
|
|
287
|
+
if not queue:
|
|
288
|
+
self._logger.warning(
|
|
289
|
+
"Session %s: unexpected binary frame with no "
|
|
290
|
+
"queued header (ignored).", self.id)
|
|
291
|
+
return
|
|
292
|
+
header = queue.pop(0)
|
|
293
|
+
self._handle_binary_chunk(header, bytes(data))
|
|
294
|
+
return
|
|
276
295
|
msg = json.loads(data)
|
|
277
296
|
if isinstance(msg, list):
|
|
278
297
|
for m in msg:
|
|
@@ -293,8 +312,20 @@ class Session:
|
|
|
293
312
|
elif msg_type == "viewport":
|
|
294
313
|
self._screen_size = (msg.get("width", 0), msg.get("height", 0))
|
|
295
314
|
|
|
296
|
-
elif msg_type == "
|
|
297
|
-
|
|
315
|
+
elif msg_type == "binary-chunk":
|
|
316
|
+
# Either reserves the next binary frame (encoding="binary")
|
|
317
|
+
# or carries an inline base64 payload (encoding="base64").
|
|
318
|
+
encoding = msg.get("encoding", "binary")
|
|
319
|
+
if encoding == "binary":
|
|
320
|
+
self._pending_binary_headers.append(msg)
|
|
321
|
+
elif encoding == "base64":
|
|
322
|
+
import base64 as _b64
|
|
323
|
+
payload = _b64.b64decode(msg.get("data") or "")
|
|
324
|
+
self._handle_binary_chunk(msg, payload)
|
|
325
|
+
else:
|
|
326
|
+
self._logger.warning(
|
|
327
|
+
"Session %s: unknown binary-chunk encoding %r "
|
|
328
|
+
"(ignored).", self.id, encoding)
|
|
298
329
|
|
|
299
330
|
elif msg_type == "callback":
|
|
300
331
|
# If the payload has a transfer_id, stash the metadata —
|
|
@@ -321,25 +352,45 @@ class Session:
|
|
|
321
352
|
self._dispatch_callback(
|
|
322
353
|
msg["wid"], msg["action"], *msg.get("args", []))
|
|
323
354
|
|
|
324
|
-
def
|
|
325
|
-
"""
|
|
326
|
-
|
|
355
|
+
def _handle_binary_chunk(self, header, data):
|
|
356
|
+
"""Buffer one chunk of an in-flight transfer.
|
|
357
|
+
|
|
358
|
+
``header`` is the parsed binary-chunk JSON; ``data`` is the
|
|
359
|
+
chunk's raw bytes (either from the paired binary WebSocket
|
|
360
|
+
frame or decoded from an inline base64 ``data`` field).
|
|
361
|
+
|
|
362
|
+
For file-upload transfers (drag-drop, FileDialog), the header
|
|
363
|
+
also carries ``file_index`` / ``file_count`` so multiple files
|
|
364
|
+
can be reassembled in parallel. For other future server-bound
|
|
365
|
+
chunked transports, those fields can be omitted.
|
|
366
|
+
"""
|
|
367
|
+
tid = header["transfer_id"]
|
|
327
368
|
transfer = self._transfers.get(tid)
|
|
328
369
|
if transfer is None:
|
|
329
370
|
return
|
|
330
371
|
|
|
331
|
-
fi =
|
|
332
|
-
fc =
|
|
372
|
+
fi = header.get("file_index", 0)
|
|
373
|
+
fc = header.get("file_count", 1)
|
|
374
|
+
ci = header["chunk_index"]
|
|
375
|
+
nc = header["num_chunks"]
|
|
333
376
|
if fi not in transfer["file_data"]:
|
|
334
|
-
transfer["file_data"][fi] = []
|
|
335
|
-
transfer["num_chunks"][fi] =
|
|
336
|
-
transfer["file_data"][fi]
|
|
377
|
+
transfer["file_data"][fi] = [None] * nc
|
|
378
|
+
transfer["num_chunks"][fi] = nc
|
|
379
|
+
slot = transfer["file_data"][fi]
|
|
380
|
+
if 0 <= ci < len(slot):
|
|
381
|
+
slot[ci] = data
|
|
382
|
+
else:
|
|
383
|
+
self._logger.warning(
|
|
384
|
+
"Session %s: chunk_index %d out of range for "
|
|
385
|
+
"transfer_id %s (num_chunks=%d).",
|
|
386
|
+
self.id, ci, tid, nc)
|
|
387
|
+
return
|
|
337
388
|
|
|
338
389
|
# Check if all files have received all their chunks.
|
|
339
390
|
all_complete = (
|
|
340
391
|
len(transfer["num_chunks"]) == fc
|
|
341
392
|
and all(
|
|
342
|
-
|
|
393
|
+
None not in transfer["file_data"][i]
|
|
343
394
|
for i in range(fc)
|
|
344
395
|
)
|
|
345
396
|
)
|
|
@@ -351,16 +402,17 @@ class Session:
|
|
|
351
402
|
for i, fmeta in enumerate(files_meta):
|
|
352
403
|
fsize = fmeta.get("size", 0)
|
|
353
404
|
total_bytes += fsize
|
|
354
|
-
|
|
355
|
-
if
|
|
356
|
-
|
|
357
|
-
|
|
405
|
+
n = transfer["num_chunks"].get(i)
|
|
406
|
+
if n:
|
|
407
|
+
slots = transfer["file_data"].get(i, [])
|
|
408
|
+
received = sum(1 for s in slots if s is not None)
|
|
409
|
+
transferred_bytes += fsize * received // n
|
|
358
410
|
|
|
359
411
|
progress_info = {
|
|
360
412
|
"transfer_id": tid,
|
|
361
413
|
"file_index": fi,
|
|
362
|
-
"chunk_index":
|
|
363
|
-
"num_chunks":
|
|
414
|
+
"chunk_index": ci,
|
|
415
|
+
"num_chunks": nc,
|
|
364
416
|
"transferred_bytes": transferred_bytes,
|
|
365
417
|
"total_bytes": total_bytes,
|
|
366
418
|
"complete": all_complete,
|
|
@@ -376,26 +428,36 @@ class Session:
|
|
|
376
428
|
# Reassemble file data and fire the original callback.
|
|
377
429
|
payload = transfer["payload"]
|
|
378
430
|
for i, file_meta in enumerate(payload["files"]):
|
|
379
|
-
|
|
380
|
-
|
|
431
|
+
slots = transfer["file_data"].get(i, [])
|
|
432
|
+
file_meta["data"] = b"".join(slots)
|
|
381
433
|
del self._transfers[tid]
|
|
382
434
|
self._dispatch_callback(
|
|
383
435
|
transfer["wid"], action, payload)
|
|
384
436
|
|
|
385
437
|
def _dispatch_callback(self, wid, action, *args):
|
|
386
438
|
"""Dispatch a callback through the configured concurrency mode."""
|
|
387
|
-
#
|
|
388
|
-
#
|
|
389
|
-
#
|
|
390
|
-
#
|
|
391
|
-
|
|
392
|
-
|
|
439
|
+
# Suppress callbacks during reconstruction — they are side
|
|
440
|
+
# effects of state replay, not user actions. Exception: 'map'
|
|
441
|
+
# is a one-shot lifecycle event that fires when a widget first
|
|
442
|
+
# gains a visible layout box; the JS-side observers may fire
|
|
443
|
+
# it during the reconstruction window and we MUST forward it
|
|
444
|
+
# or the user's map handler never runs until something later
|
|
445
|
+
# triggers a re-fire (e.g. a window resize).
|
|
446
|
+
if self._reconstructing and action != 'map':
|
|
447
|
+
return
|
|
393
448
|
# Auto-sync: some callbacks carry state that should be reflected
|
|
394
449
|
# in the Python-side widget (e.g. move -> position, resize -> size).
|
|
450
|
+
# We always *capture* the value so get_size()/get_position() return
|
|
451
|
+
# current values, but only *push* it to other browsers (and replay
|
|
452
|
+
# it on reconstruction) when the widget opted in via auto-sync.
|
|
453
|
+
# Otherwise a layout-determined size would replay as a literal
|
|
454
|
+
# resize(W, H) — pinning the widget to pixel dimensions and
|
|
455
|
+
# killing flex growth.
|
|
395
456
|
state_key = STATE_SYNC_CALLBACKS.get(action)
|
|
396
457
|
if state_key is not None:
|
|
397
458
|
widget = self._widget_map.get(wid)
|
|
398
459
|
if widget is not None:
|
|
460
|
+
auto = action in widget._auto_sync_actions
|
|
399
461
|
# Normalize: resize sends {width, height} dict, move
|
|
400
462
|
# sends (x, y) as separate args. Store as a flat tuple
|
|
401
463
|
# matching the corresponding setter's signature.
|
|
@@ -405,15 +467,17 @@ class Session:
|
|
|
405
467
|
new_val = (d["width"], d["height"])
|
|
406
468
|
if widget._state.get(state_key) != new_val:
|
|
407
469
|
widget._state[state_key] = new_val
|
|
408
|
-
|
|
409
|
-
|
|
470
|
+
if auto:
|
|
471
|
+
self._push(wid, "resize",
|
|
472
|
+
d["width"], d["height"])
|
|
410
473
|
else:
|
|
411
474
|
new_val = tuple(args)
|
|
412
475
|
if widget._state.get(state_key) != new_val:
|
|
413
476
|
widget._state[state_key] = new_val
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
477
|
+
if auto:
|
|
478
|
+
setter = (self._STATE_KEY_TO_SETTER.get(state_key)
|
|
479
|
+
or f"set_{state_key}")
|
|
480
|
+
self._push(wid, setter, *args)
|
|
417
481
|
# If this widget wraps a child (e.g. MDISubWindow),
|
|
418
482
|
# propagate geometry into the parent's children options
|
|
419
483
|
# so reconstruction replays with the current pos/size.
|
|
@@ -706,6 +770,91 @@ class Session:
|
|
|
706
770
|
continue
|
|
707
771
|
fut.add_done_callback(_drain_send_exception)
|
|
708
772
|
|
|
773
|
+
def _send_binary_chunked(self, wid, method, args, data,
|
|
774
|
+
chunk_size=512 * 1024,
|
|
775
|
+
shape=None, dtype=None):
|
|
776
|
+
"""Fire-and-forget chunked binary call.
|
|
777
|
+
|
|
778
|
+
Splits ``data`` (bytes) into ``chunk_size`` chunks and sends
|
|
779
|
+
them as a ``binary-call-chunked`` announce followed by N
|
|
780
|
+
(``binary-chunk`` JSON + raw binary frame) pairs. The JS side
|
|
781
|
+
reassembles into a single ``ArrayBuffer`` and dispatches as
|
|
782
|
+
``widget[method](buffer, *args)``.
|
|
783
|
+
|
|
784
|
+
When ``shape`` and ``dtype`` are provided, they are attached to
|
|
785
|
+
the announce header and the JS receiver constructs a typed
|
|
786
|
+
array (``Uint8Array``, ``Float32Array``, …) instead of a raw
|
|
787
|
+
``ArrayBuffer`` before dispatch. This is what :class:`Buffer`
|
|
788
|
+
arguments end up doing.
|
|
789
|
+
|
|
790
|
+
All chunks for a single transfer ship atomically on each
|
|
791
|
+
connection (one coroutine per connection) so they can't
|
|
792
|
+
interleave with other binary sends from another thread.
|
|
793
|
+
"""
|
|
794
|
+
if not self._connections:
|
|
795
|
+
return
|
|
796
|
+
if not isinstance(data, (bytes, bytearray, memoryview)):
|
|
797
|
+
raise TypeError(
|
|
798
|
+
"_send_binary_chunked: data must be bytes-like, got "
|
|
799
|
+
+ type(data).__name__)
|
|
800
|
+
data = bytes(data)
|
|
801
|
+
n = len(data)
|
|
802
|
+
if chunk_size <= 0:
|
|
803
|
+
raise ValueError("chunk_size must be positive")
|
|
804
|
+
num_chunks = max(1, (n + chunk_size - 1) // chunk_size)
|
|
805
|
+
with self._lock:
|
|
806
|
+
msg_id = self._next_id
|
|
807
|
+
self._next_id += 1
|
|
808
|
+
transfer_id = self._next_id
|
|
809
|
+
self._next_id += 1
|
|
810
|
+
announce_obj = {
|
|
811
|
+
"type": "binary-call-chunked",
|
|
812
|
+
"id": msg_id,
|
|
813
|
+
"wid": wid,
|
|
814
|
+
"method": method,
|
|
815
|
+
"args": list(args),
|
|
816
|
+
"transfer_id": transfer_id,
|
|
817
|
+
"num_chunks": num_chunks,
|
|
818
|
+
}
|
|
819
|
+
if shape is not None:
|
|
820
|
+
announce_obj["shape"] = list(shape)
|
|
821
|
+
if dtype is not None:
|
|
822
|
+
announce_obj["dtype"] = dtype
|
|
823
|
+
announce = json.dumps(announce_obj, cls=JsonEncoder)
|
|
824
|
+
# Pre-build chunk headers + slices so JSON encoding cost is
|
|
825
|
+
# paid once even if multiple browsers are connected.
|
|
826
|
+
pairs = []
|
|
827
|
+
for ci in range(num_chunks):
|
|
828
|
+
start = ci * chunk_size
|
|
829
|
+
end = min(start + chunk_size, n)
|
|
830
|
+
header = json.dumps({
|
|
831
|
+
"type": "binary-chunk",
|
|
832
|
+
"transfer_id": transfer_id,
|
|
833
|
+
"chunk_index": ci,
|
|
834
|
+
"num_chunks": num_chunks,
|
|
835
|
+
"encoding": "binary",
|
|
836
|
+
}, cls=JsonEncoder)
|
|
837
|
+
pairs.append((header, data[start:end]))
|
|
838
|
+
|
|
839
|
+
async def _send_all(ws):
|
|
840
|
+
await ws.send(announce)
|
|
841
|
+
for header, payload in pairs:
|
|
842
|
+
await ws.send(header)
|
|
843
|
+
await ws.send(payload)
|
|
844
|
+
|
|
845
|
+
for ws in self._connections:
|
|
846
|
+
coro = _send_all(ws)
|
|
847
|
+
try:
|
|
848
|
+
fut = asyncio.run_coroutine_threadsafe(coro,
|
|
849
|
+
self._app._loop)
|
|
850
|
+
except RuntimeError as e:
|
|
851
|
+
self._logger.warning(
|
|
852
|
+
"Session %s: loop refused chunked-binary coroutine: %r",
|
|
853
|
+
self.id, e)
|
|
854
|
+
coro.close()
|
|
855
|
+
continue
|
|
856
|
+
fut.add_done_callback(_drain_send_exception)
|
|
857
|
+
|
|
709
858
|
def _listen(self, wid, action, handler):
|
|
710
859
|
"""Register a callback listener.
|
|
711
860
|
|
|
@@ -804,13 +953,33 @@ class Session:
|
|
|
804
953
|
cls = self._widget_classes.get(cls_name, Widget) if cls_name else Widget
|
|
805
954
|
widget = cls._from_existing(self, wid, cls_name or "Widget")
|
|
806
955
|
self._widget_map[wid] = widget
|
|
807
|
-
# Auto-listen for state-syncing callbacks (move, resize)
|
|
808
|
-
#
|
|
956
|
+
# Auto-listen for state-syncing callbacks (move, resize).
|
|
957
|
+
# For visual widgets we always listen for 'resize' so that
|
|
958
|
+
# get_size() can return a current value, but we only mark
|
|
959
|
+
# the action as auto-syncing (which triggers push-to-peers
|
|
960
|
+
# and replay-on-reconstruction) when the widget defn
|
|
961
|
+
# actually opts in. Non-visual Callback-base objects (e.g.
|
|
962
|
+
# TextBufferRef, Timer) get nothing.
|
|
963
|
+
defn = WIDGETS.get(cls_name, {}) if cls_name else {}
|
|
964
|
+
opt_names_set = set(defn.get("options", []))
|
|
965
|
+
all_callbacks = defn.get("callbacks", [])
|
|
966
|
+
is_visual = defn.get("base") != "callback"
|
|
809
967
|
for action in STATE_SYNC_CALLBACKS:
|
|
810
|
-
|
|
811
|
-
if
|
|
812
|
-
|
|
813
|
-
|
|
968
|
+
req_opt = STATE_SYNC_REQUIRES_OPTION.get(action)
|
|
969
|
+
if req_opt is not None:
|
|
970
|
+
opted_in = req_opt in opt_names_set
|
|
971
|
+
else:
|
|
972
|
+
opted_in = action in all_callbacks
|
|
973
|
+
if not is_visual:
|
|
974
|
+
continue
|
|
975
|
+
if action == "resize" or opted_in:
|
|
976
|
+
key = f"{wid}:{action}"
|
|
977
|
+
if key not in self._callbacks:
|
|
978
|
+
self._listen(wid, action, lambda wid, *a: None)
|
|
979
|
+
if opted_in:
|
|
980
|
+
widget._auto_sync_actions.add(action)
|
|
981
|
+
elif action == "resize":
|
|
982
|
+
widget._passive_sync_actions.add(action)
|
|
814
983
|
return widget
|
|
815
984
|
if isinstance(val, list):
|
|
816
985
|
return [self._resolve_return(v) for v in val]
|
|
@@ -907,6 +1076,13 @@ class Session:
|
|
|
907
1076
|
_STATE_KEY_TO_SETTER = {v: k for k, v in SPECIAL_SETTERS.items()}
|
|
908
1077
|
# e.g. {"size": "resize"}
|
|
909
1078
|
|
|
1079
|
+
# Reverse map: state_key -> callback action that auto-syncs it
|
|
1080
|
+
# (e.g. "size" -> "resize", "position" -> "move"). Used during
|
|
1081
|
+
# state replay to skip keys that weren't actively opted into via
|
|
1082
|
+
# _auto_sync_actions — those were captured passively for getter
|
|
1083
|
+
# support but must not be replayed (would pin layout).
|
|
1084
|
+
_STATE_KEY_TO_SYNC_ACTION = {v: k for k, v in STATE_SYNC_CALLBACKS.items()}
|
|
1085
|
+
|
|
910
1086
|
# State keys handled by fixed-value methods (show/hide)
|
|
911
1087
|
_FIXED_STATE_KEYS = {}
|
|
912
1088
|
for _mname, (_key, _val) in FIXED_SETTERS.items():
|
|
@@ -952,12 +1128,23 @@ class Session:
|
|
|
952
1128
|
self._listen(new_widget._wid, act,
|
|
953
1129
|
lambda wid, *a: None)
|
|
954
1130
|
new_widget._auto_sync_actions.add(act)
|
|
955
|
-
# Replay any state the proxy accumulated (e.g. set_tooltip)
|
|
1131
|
+
# Replay any state the proxy accumulated (e.g. set_tooltip).
|
|
1132
|
+
# Skip passively-captured auto-sync state (size, position): we
|
|
1133
|
+
# capture those from callbacks so getters work, but replaying
|
|
1134
|
+
# would pin the widget to layout-determined pixel dimensions
|
|
1135
|
+
# (same logic as _reconstruct_widget).
|
|
1136
|
+
user_set = getattr(old_widget, "_user_set_state", set())
|
|
1137
|
+
auto = getattr(old_widget, "_auto_sync_actions", set())
|
|
956
1138
|
for key, value in old_widget._state.items():
|
|
957
1139
|
if key.startswith("_"):
|
|
958
1140
|
continue
|
|
959
1141
|
if key in sync_keys:
|
|
960
1142
|
continue
|
|
1143
|
+
sync_action = self._STATE_KEY_TO_SYNC_ACTION.get(key)
|
|
1144
|
+
if (sync_action is not None
|
|
1145
|
+
and key not in user_set
|
|
1146
|
+
and sync_action not in auto):
|
|
1147
|
+
continue
|
|
961
1148
|
method_name = (self._STATE_KEY_TO_SETTER.get(key)
|
|
962
1149
|
or f"set_{key}")
|
|
963
1150
|
if isinstance(value, tuple):
|
|
@@ -965,6 +1152,13 @@ class Session:
|
|
|
965
1152
|
else:
|
|
966
1153
|
self._call(new_widget._wid, method_name, value)
|
|
967
1154
|
new_widget._state[key] = value
|
|
1155
|
+
# Propagate user-set / auto-sync membership to the new
|
|
1156
|
+
# widget so subsequent reconstructions replay consistently.
|
|
1157
|
+
if sync_action is not None:
|
|
1158
|
+
if key in user_set:
|
|
1159
|
+
new_widget._user_set_state.add(key)
|
|
1160
|
+
if sync_action in auto:
|
|
1161
|
+
new_widget._auto_sync_actions.add(sync_action)
|
|
968
1162
|
|
|
969
1163
|
def _ensure_reconstructed(self, widget):
|
|
970
1164
|
"""Ensure a widget has been created on the JS side.
|
|
@@ -1147,13 +1341,29 @@ class Session:
|
|
|
1147
1341
|
if key.startswith("_"):
|
|
1148
1342
|
continue
|
|
1149
1343
|
|
|
1344
|
+
# Skip auto-sync state (size, position) that came in
|
|
1345
|
+
# passively via a callback (e.g. layout-determined size).
|
|
1346
|
+
# We capture those so getters like get_size()/
|
|
1347
|
+
# get_position() work, but replaying them would pin the
|
|
1348
|
+
# widget to pixel dimensions and override flex/expanding
|
|
1349
|
+
# layout. Replay only if the user explicitly set the
|
|
1350
|
+
# value, or if the widget opted into the sync action
|
|
1351
|
+
# (e.g. an interactively-resizable widget).
|
|
1352
|
+
sync_action = self._STATE_KEY_TO_SYNC_ACTION.get(key)
|
|
1353
|
+
if (sync_action is not None
|
|
1354
|
+
and key not in widget._user_set_state
|
|
1355
|
+
and sync_action not in widget._auto_sync_actions):
|
|
1356
|
+
continue
|
|
1357
|
+
|
|
1150
1358
|
# Binary-payload state (e.g. set_binary_image) replays via
|
|
1151
|
-
# _send_binary so the bytes go in
|
|
1152
|
-
# as base64 in JSON.
|
|
1359
|
+
# _send_binary / _send_binary_chunked so the bytes go in
|
|
1360
|
+
# raw frame(s), not embedded as base64 in JSON. Large
|
|
1361
|
+
# payloads switch to chunked transport automatically.
|
|
1153
1362
|
if key in BINARY_STATE_KEYS:
|
|
1154
1363
|
method_name = BINARY_STATE_KEYS[key]
|
|
1155
1364
|
fmt, data = value
|
|
1156
|
-
self
|
|
1365
|
+
_send_binary_auto(self, widget._wid, method_name,
|
|
1366
|
+
[fmt], data)
|
|
1157
1367
|
continue
|
|
1158
1368
|
|
|
1159
1369
|
if key in self._STATE_KEY_TO_SETTER:
|
|
@@ -1193,10 +1403,13 @@ class Session:
|
|
|
1193
1403
|
# _listen calls treat them as first-time registrations and
|
|
1194
1404
|
# actually send the "listen" message to the browser.
|
|
1195
1405
|
wid = widget._wid
|
|
1406
|
+
passive = getattr(widget, "_passive_sync_actions", set())
|
|
1196
1407
|
for action in list(saved_cbs.keys()):
|
|
1197
1408
|
self._callbacks.pop(f"{wid}:{action}", None)
|
|
1198
1409
|
for action in widget._auto_sync_actions:
|
|
1199
1410
|
self._callbacks.pop(f"{wid}:{action}", None)
|
|
1411
|
+
for action in passive:
|
|
1412
|
+
self._callbacks.pop(f"{wid}:{action}", None)
|
|
1200
1413
|
|
|
1201
1414
|
for action, entries in saved_cbs.items():
|
|
1202
1415
|
for handler, extra_args, extra_kwargs, style in entries:
|
|
@@ -1211,6 +1424,12 @@ class Session:
|
|
|
1211
1424
|
for action in widget._auto_sync_actions:
|
|
1212
1425
|
if action not in widget._registered_callbacks:
|
|
1213
1426
|
self._listen(widget._wid, action, lambda wid, *a: None)
|
|
1427
|
+
# Passive listeners (e.g. 'resize' for getter support on
|
|
1428
|
+
# widgets that didn't opt into auto-sync).
|
|
1429
|
+
for action in passive:
|
|
1430
|
+
if (action not in widget._registered_callbacks
|
|
1431
|
+
and action not in widget._auto_sync_actions):
|
|
1432
|
+
self._listen(widget._wid, action, lambda wid, *a: None)
|
|
1214
1433
|
|
|
1215
1434
|
def _child_method_for(self, parent):
|
|
1216
1435
|
"""Determine which child method a container uses."""
|
|
@@ -1386,13 +1605,24 @@ class Application:
|
|
|
1386
1605
|
|
|
1387
1606
|
def __init__(self, ws_port=9500, http_port=9501, host="127.0.0.1",
|
|
1388
1607
|
http_server=True, concurrency_handling="per_session",
|
|
1389
|
-
max_sessions=1, logger=None):
|
|
1608
|
+
max_sessions=1, logger=None, ws_sock=None):
|
|
1390
1609
|
if concurrency_handling not in _CONCURRENCY_MODES:
|
|
1391
1610
|
raise ValueError(
|
|
1392
1611
|
f"concurrency_handling must be one of "
|
|
1393
1612
|
f"{_CONCURRENCY_MODES!r}, got {concurrency_handling!r}")
|
|
1394
1613
|
self._host = host
|
|
1395
|
-
|
|
1614
|
+
# ws_sock, if provided, is a bound TCP socket the WebSocket
|
|
1615
|
+
# server should adopt directly. This removes the TOCTOU race
|
|
1616
|
+
# that would otherwise exist between "find a free port" and
|
|
1617
|
+
# "bind that port" — the caller binds, hands the socket in,
|
|
1618
|
+
# and we never release the port between the two steps.
|
|
1619
|
+
# ``ws_port`` is read back from the socket so logging /
|
|
1620
|
+
# introspection still report a useful value.
|
|
1621
|
+
self._ws_sock = ws_sock
|
|
1622
|
+
if ws_sock is not None:
|
|
1623
|
+
self._ws_port = ws_sock.getsockname()[1]
|
|
1624
|
+
else:
|
|
1625
|
+
self._ws_port = ws_port
|
|
1396
1626
|
self._http_port = http_port
|
|
1397
1627
|
self._use_http_server = http_server
|
|
1398
1628
|
self._concurrency = concurrency_handling
|
|
@@ -1515,9 +1745,14 @@ class Application:
|
|
|
1515
1745
|
self._loop.run_until_complete(self._serve_ws())
|
|
1516
1746
|
|
|
1517
1747
|
async def _serve_ws(self):
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1748
|
+
if self._ws_sock is not None:
|
|
1749
|
+
async with websockets.serve(self._ws_handler,
|
|
1750
|
+
sock=self._ws_sock):
|
|
1751
|
+
await asyncio.Future()
|
|
1752
|
+
else:
|
|
1753
|
+
async with websockets.serve(self._ws_handler, self._host,
|
|
1754
|
+
self._ws_port):
|
|
1755
|
+
await asyncio.Future()
|
|
1521
1756
|
|
|
1522
1757
|
async def _ws_handler(self, ws):
|
|
1523
1758
|
# Init handshake: send init, receive ack which may contain
|
pgwidgets/sync/widget.py
CHANGED
|
@@ -75,6 +75,16 @@ class Widget:
|
|
|
75
75
|
self._constructor_options = {}
|
|
76
76
|
self._registered_callbacks = {}
|
|
77
77
|
self._auto_sync_actions = set()
|
|
78
|
+
# Actions we listen to passively for getter support
|
|
79
|
+
# (e.g. 'resize' on every visual widget so get_size() returns
|
|
80
|
+
# a current value), but that aren't in _auto_sync_actions and
|
|
81
|
+
# therefore don't push to peers or replay on reconstruction.
|
|
82
|
+
self._passive_sync_actions = set()
|
|
83
|
+
# State keys the user explicitly set via a setter call.
|
|
84
|
+
# Used during reconstruction to decide whether a state key
|
|
85
|
+
# should be replayed: passively-captured callback state
|
|
86
|
+
# (e.g. layout-determined size) is NOT in this set.
|
|
87
|
+
self._user_set_state = set()
|
|
78
88
|
self._replay_calls = []
|
|
79
89
|
self._add_seq = 0 # insertion order across _children + _replay_calls
|
|
80
90
|
|
|
@@ -160,6 +170,8 @@ class Widget:
|
|
|
160
170
|
obj._constructor_options = {}
|
|
161
171
|
obj._registered_callbacks = {}
|
|
162
172
|
obj._auto_sync_actions = set()
|
|
173
|
+
obj._passive_sync_actions = set()
|
|
174
|
+
obj._user_set_state = set()
|
|
163
175
|
obj._replay_calls = []
|
|
164
176
|
obj._add_seq = 0
|
|
165
177
|
obj._stale = False
|
|
@@ -177,15 +189,35 @@ class Widget:
|
|
|
177
189
|
opt_names_set = set(defn.get("options", []))
|
|
178
190
|
all_callbacks = defn.get("callbacks", [])
|
|
179
191
|
|
|
180
|
-
# State-sync callbacks (move -> position, resize -> size)
|
|
192
|
+
# State-sync callbacks (move -> position, resize -> size).
|
|
193
|
+
# For visual widgets we always *listen* so getters like
|
|
194
|
+
# get_size() / get_position() can return current values.
|
|
195
|
+
# But we only add the action to _auto_sync_actions — which
|
|
196
|
+
# controls push-to-peers and replay-on-reconstruction — when
|
|
197
|
+
# the widget actually opted in (e.g. via the 'resizable' option
|
|
198
|
+
# or by declaring the callback in its defn). This keeps
|
|
199
|
+
# layout-determined sizes from being replayed as literal
|
|
200
|
+
# resize() calls that would pin flex/expanding widgets.
|
|
201
|
+
is_visual = defn.get("base") != "callback"
|
|
181
202
|
for action in STATE_SYNC_CALLBACKS:
|
|
182
203
|
req_opt = STATE_SYNC_REQUIRES_OPTION.get(action)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
204
|
+
opted_in = False
|
|
205
|
+
if req_opt is not None:
|
|
206
|
+
opted_in = req_opt in opt_names_set
|
|
207
|
+
else:
|
|
208
|
+
opted_in = action in all_callbacks
|
|
209
|
+
# Visual widgets get the listen unconditionally for resize
|
|
210
|
+
# (it's a universal base-class callback) and for any move
|
|
211
|
+
# they declare. Non-visual Callback-base objects get
|
|
212
|
+
# nothing here.
|
|
213
|
+
if not is_visual:
|
|
186
214
|
continue
|
|
187
|
-
|
|
188
|
-
|
|
215
|
+
if action == "resize" or opted_in:
|
|
216
|
+
session._listen(wid, action, lambda wid, *a: None)
|
|
217
|
+
if opted_in:
|
|
218
|
+
self._auto_sync_actions.add(action)
|
|
219
|
+
elif action == "resize":
|
|
220
|
+
self._passive_sync_actions.add(action)
|
|
189
221
|
|
|
190
222
|
# Per-widget-class state sync (e.g. Slider "activated" -> value)
|
|
191
223
|
cls_sync = WIDGET_CALLBACK_SYNC.get(js_class, {})
|
|
@@ -432,6 +464,9 @@ def _make_setter(method_name, param_names, state_key):
|
|
|
432
464
|
self._state[state_key] = args[0]
|
|
433
465
|
else:
|
|
434
466
|
self._state[state_key] = args
|
|
467
|
+
# Mark as user-set so reconstruction knows to replay this key
|
|
468
|
+
# (callback-captured values for the same key don't get marked).
|
|
469
|
+
self._user_set_state.add(state_key)
|
|
435
470
|
return self._call(method_name, *args)
|
|
436
471
|
method.__name__ = method_name
|
|
437
472
|
method.__qualname__ = f"Widget.{method_name}"
|
|
@@ -444,6 +479,7 @@ def _make_fixed_setter(method_name, state_key, fixed_value):
|
|
|
444
479
|
"""Create a no-arg method that sets a fixed state value (show/hide)."""
|
|
445
480
|
def method(self):
|
|
446
481
|
self._state[state_key] = fixed_value
|
|
482
|
+
self._user_set_state.add(state_key)
|
|
447
483
|
return self._call(method_name)
|
|
448
484
|
method.__name__ = method_name
|
|
449
485
|
method.__qualname__ = f"Widget.{method_name}"
|