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/__init__.py
CHANGED
pgwidgets/async_/application.py
CHANGED
|
@@ -30,7 +30,7 @@ from pgwidgets.method_types import (
|
|
|
30
30
|
STATE_SYNC_CALLBACKS, STATE_SYNC_REQUIRES_OPTION,
|
|
31
31
|
WIDGET_CALLBACK_SYNC, POST_CHILDREN_STATE_KEYS, ITEM_LIST_CONFIG,
|
|
32
32
|
CHILD_CLOSE_CALLBACKS, REPLAY_METHODS, TREE_VIEW_WIDGETS,
|
|
33
|
-
BINARY_STATE_KEYS,
|
|
33
|
+
BINARY_STATE_KEYS, _send_binary_auto,
|
|
34
34
|
)
|
|
35
35
|
from pgwidgets.async_.widget import Widget, build_all_widget_classes
|
|
36
36
|
|
|
@@ -91,6 +91,13 @@ class Session:
|
|
|
91
91
|
_STATE_KEY_TO_SETTER = {v: k for k, v in SPECIAL_SETTERS.items()}
|
|
92
92
|
# e.g. {"size": "resize"}
|
|
93
93
|
|
|
94
|
+
# Reverse map: state_key -> callback action that auto-syncs it
|
|
95
|
+
# (e.g. "size" -> "resize", "position" -> "move"). Used during
|
|
96
|
+
# state replay to skip keys that weren't actively opted into via
|
|
97
|
+
# _auto_sync_actions — those were captured passively for getter
|
|
98
|
+
# support but must not be replayed (would pin layout).
|
|
99
|
+
_STATE_KEY_TO_SYNC_ACTION = {v: k for k, v in STATE_SYNC_CALLBACKS.items()}
|
|
100
|
+
|
|
94
101
|
# State keys handled by fixed-value methods (show/hide)
|
|
95
102
|
_FIXED_STATE_KEYS = {}
|
|
96
103
|
for _mname, (_key, _val) in FIXED_SETTERS.items():
|
|
@@ -118,6 +125,9 @@ class Session:
|
|
|
118
125
|
|
|
119
126
|
self._widget_classes = app._widget_classes
|
|
120
127
|
self._transfers = {} # transfer_id -> transfer state dict
|
|
128
|
+
# FIFO of binary-chunk JSON headers (encoding="binary") still
|
|
129
|
+
# awaiting their paired raw binary frame.
|
|
130
|
+
self._pending_binary_headers = []
|
|
121
131
|
self._callback_source_ws = None # ws that sent current callback
|
|
122
132
|
|
|
123
133
|
self._reconstructing = False # suppress callbacks during reconstruction
|
|
@@ -187,6 +197,19 @@ class Session:
|
|
|
187
197
|
# -- Message handling --
|
|
188
198
|
|
|
189
199
|
def _handle_message(self, data):
|
|
200
|
+
# Raw binary frames pair with the head of the binary-chunk
|
|
201
|
+
# JSON header FIFO (encoding="binary"). See the sync
|
|
202
|
+
# equivalent for the rationale.
|
|
203
|
+
if isinstance(data, (bytes, bytearray, memoryview)):
|
|
204
|
+
queue = self._pending_binary_headers
|
|
205
|
+
if not queue:
|
|
206
|
+
self._logger.warning(
|
|
207
|
+
"Session %s: unexpected binary frame with no "
|
|
208
|
+
"queued header (ignored).", self.id)
|
|
209
|
+
return
|
|
210
|
+
header = queue.pop(0)
|
|
211
|
+
self._handle_binary_chunk(header, bytes(data))
|
|
212
|
+
return
|
|
190
213
|
msg = json.loads(data)
|
|
191
214
|
if isinstance(msg, list):
|
|
192
215
|
for m in msg:
|
|
@@ -209,8 +232,18 @@ class Session:
|
|
|
209
232
|
elif msg_type == "viewport":
|
|
210
233
|
self._screen_size = (msg.get("width", 0), msg.get("height", 0))
|
|
211
234
|
|
|
212
|
-
elif msg_type == "
|
|
213
|
-
|
|
235
|
+
elif msg_type == "binary-chunk":
|
|
236
|
+
encoding = msg.get("encoding", "binary")
|
|
237
|
+
if encoding == "binary":
|
|
238
|
+
self._pending_binary_headers.append(msg)
|
|
239
|
+
elif encoding == "base64":
|
|
240
|
+
import base64 as _b64
|
|
241
|
+
payload = _b64.b64decode(msg.get("data") or "")
|
|
242
|
+
self._handle_binary_chunk(msg, payload)
|
|
243
|
+
else:
|
|
244
|
+
self._logger.warning(
|
|
245
|
+
"Session %s: unknown binary-chunk encoding %r "
|
|
246
|
+
"(ignored).", self.id, encoding)
|
|
214
247
|
|
|
215
248
|
elif msg_type == "callback":
|
|
216
249
|
# If the payload has a transfer_id, stash the metadata —
|
|
@@ -237,25 +270,40 @@ class Session:
|
|
|
237
270
|
self._dispatch_callback(
|
|
238
271
|
msg["wid"], msg["action"], *msg.get("args", []))
|
|
239
272
|
|
|
240
|
-
def
|
|
241
|
-
"""
|
|
242
|
-
|
|
273
|
+
def _handle_binary_chunk(self, header, data):
|
|
274
|
+
"""Buffer one chunk of an in-flight transfer.
|
|
275
|
+
|
|
276
|
+
``header`` is the parsed binary-chunk JSON; ``data`` is the
|
|
277
|
+
chunk's raw bytes (paired binary frame, or decoded inline
|
|
278
|
+
base64). See the sync equivalent for the full description.
|
|
279
|
+
"""
|
|
280
|
+
tid = header["transfer_id"]
|
|
243
281
|
transfer = self._transfers.get(tid)
|
|
244
282
|
if transfer is None:
|
|
245
283
|
return
|
|
246
284
|
|
|
247
|
-
fi =
|
|
248
|
-
fc =
|
|
285
|
+
fi = header.get("file_index", 0)
|
|
286
|
+
fc = header.get("file_count", 1)
|
|
287
|
+
ci = header["chunk_index"]
|
|
288
|
+
nc = header["num_chunks"]
|
|
249
289
|
if fi not in transfer["file_data"]:
|
|
250
|
-
transfer["file_data"][fi] = []
|
|
251
|
-
transfer["num_chunks"][fi] =
|
|
252
|
-
transfer["file_data"][fi]
|
|
290
|
+
transfer["file_data"][fi] = [None] * nc
|
|
291
|
+
transfer["num_chunks"][fi] = nc
|
|
292
|
+
slot = transfer["file_data"][fi]
|
|
293
|
+
if 0 <= ci < len(slot):
|
|
294
|
+
slot[ci] = data
|
|
295
|
+
else:
|
|
296
|
+
self._logger.warning(
|
|
297
|
+
"Session %s: chunk_index %d out of range for "
|
|
298
|
+
"transfer_id %s (num_chunks=%d).",
|
|
299
|
+
self.id, ci, tid, nc)
|
|
300
|
+
return
|
|
253
301
|
|
|
254
302
|
# Check if all files have received all their chunks.
|
|
255
303
|
all_complete = (
|
|
256
304
|
len(transfer["num_chunks"]) == fc
|
|
257
305
|
and all(
|
|
258
|
-
|
|
306
|
+
None not in transfer["file_data"][i]
|
|
259
307
|
for i in range(fc)
|
|
260
308
|
)
|
|
261
309
|
)
|
|
@@ -267,16 +315,17 @@ class Session:
|
|
|
267
315
|
for i, fmeta in enumerate(files_meta):
|
|
268
316
|
fsize = fmeta.get("size", 0)
|
|
269
317
|
total_bytes += fsize
|
|
270
|
-
|
|
271
|
-
if
|
|
272
|
-
|
|
273
|
-
|
|
318
|
+
n = transfer["num_chunks"].get(i)
|
|
319
|
+
if n:
|
|
320
|
+
slots = transfer["file_data"].get(i, [])
|
|
321
|
+
received = sum(1 for s in slots if s is not None)
|
|
322
|
+
transferred_bytes += fsize * received // n
|
|
274
323
|
|
|
275
324
|
progress_info = {
|
|
276
325
|
"transfer_id": tid,
|
|
277
326
|
"file_index": fi,
|
|
278
|
-
"chunk_index":
|
|
279
|
-
"num_chunks":
|
|
327
|
+
"chunk_index": ci,
|
|
328
|
+
"num_chunks": nc,
|
|
280
329
|
"transferred_bytes": transferred_bytes,
|
|
281
330
|
"total_bytes": total_bytes,
|
|
282
331
|
"complete": all_complete,
|
|
@@ -292,38 +341,54 @@ class Session:
|
|
|
292
341
|
# Reassemble file data and fire the original callback.
|
|
293
342
|
payload = transfer["payload"]
|
|
294
343
|
for i, file_meta in enumerate(payload["files"]):
|
|
295
|
-
|
|
296
|
-
|
|
344
|
+
slots = transfer["file_data"].get(i, [])
|
|
345
|
+
file_meta["data"] = b"".join(slots)
|
|
297
346
|
del self._transfers[tid]
|
|
298
347
|
self._dispatch_callback(
|
|
299
348
|
transfer["wid"], action, payload)
|
|
300
349
|
|
|
301
350
|
def _dispatch_callback(self, wid, action, *args):
|
|
302
351
|
"""Dispatch a callback through the configured concurrency mode."""
|
|
303
|
-
|
|
304
|
-
|
|
352
|
+
# Suppress callbacks during reconstruction — they are side
|
|
353
|
+
# effects of state replay, not user actions. Exception: 'map'
|
|
354
|
+
# is a one-shot lifecycle event that the JS-side observers fire
|
|
355
|
+
# when a widget first gains a visible layout box; the timing
|
|
356
|
+
# can land inside the reconstruction window, and dropping it
|
|
357
|
+
# means the user's map handler never runs until a later layout
|
|
358
|
+
# change (e.g. window resize) triggers a re-fire.
|
|
359
|
+
if self._reconstructing and action != 'map':
|
|
360
|
+
return
|
|
305
361
|
|
|
306
362
|
# Auto-sync: some callbacks carry state that should be reflected
|
|
307
363
|
# in the Python-side widget (e.g. move -> position, resize -> size).
|
|
364
|
+
# We always *capture* the value so get_size()/get_position() return
|
|
365
|
+
# current values, but only *push* it to other browsers (and replay
|
|
366
|
+
# it on reconstruction) when the widget opted in via auto-sync.
|
|
367
|
+
# Otherwise a layout-determined size would replay as a literal
|
|
368
|
+
# resize(W, H) — pinning the widget to pixel dimensions and
|
|
369
|
+
# killing flex growth.
|
|
308
370
|
state_key = STATE_SYNC_CALLBACKS.get(action)
|
|
309
371
|
if state_key is not None:
|
|
310
372
|
widget = self._widget_map.get(wid)
|
|
311
373
|
if widget is not None:
|
|
374
|
+
auto = action in widget._auto_sync_actions
|
|
312
375
|
if len(args) == 1 and isinstance(args[0], dict):
|
|
313
376
|
d = args[0]
|
|
314
377
|
if "width" in d and "height" in d:
|
|
315
378
|
new_val = (d["width"], d["height"])
|
|
316
379
|
if widget._state.get(state_key) != new_val:
|
|
317
380
|
widget._state[state_key] = new_val
|
|
318
|
-
|
|
319
|
-
|
|
381
|
+
if auto:
|
|
382
|
+
self._push(wid, "resize",
|
|
383
|
+
d["width"], d["height"])
|
|
320
384
|
else:
|
|
321
385
|
new_val = tuple(args)
|
|
322
386
|
if widget._state.get(state_key) != new_val:
|
|
323
387
|
widget._state[state_key] = new_val
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
388
|
+
if auto:
|
|
389
|
+
setter = (self._STATE_KEY_TO_SETTER.get(state_key)
|
|
390
|
+
or f"set_{state_key}")
|
|
391
|
+
self._push(wid, setter, *args)
|
|
327
392
|
# If this widget wraps a child (e.g. MDISubWindow),
|
|
328
393
|
# propagate geometry into the parent's children options
|
|
329
394
|
# so reconstruction replays with the current pos/size.
|
|
@@ -639,6 +704,69 @@ class Session:
|
|
|
639
704
|
task = asyncio.ensure_future(_pair(ws))
|
|
640
705
|
task.add_done_callback(_drain_send_exception)
|
|
641
706
|
|
|
707
|
+
def _send_binary_chunked(self, wid, method, args, data,
|
|
708
|
+
chunk_size=512 * 1024,
|
|
709
|
+
shape=None, dtype=None):
|
|
710
|
+
"""Fire-and-forget chunked binary call (async variant).
|
|
711
|
+
|
|
712
|
+
See the sync :meth:`Session._send_binary_chunked` for the full
|
|
713
|
+
description, including the optional ``shape``/``dtype`` that
|
|
714
|
+
promote the receiver's payload from a raw ``ArrayBuffer`` to
|
|
715
|
+
a typed array. Each connection's chunks are scheduled as a
|
|
716
|
+
single coroutine so they ship atomically.
|
|
717
|
+
"""
|
|
718
|
+
if not self._connections:
|
|
719
|
+
return
|
|
720
|
+
if not isinstance(data, (bytes, bytearray, memoryview)):
|
|
721
|
+
raise TypeError(
|
|
722
|
+
"_send_binary_chunked: data must be bytes-like, got "
|
|
723
|
+
+ type(data).__name__)
|
|
724
|
+
data = bytes(data)
|
|
725
|
+
n = len(data)
|
|
726
|
+
if chunk_size <= 0:
|
|
727
|
+
raise ValueError("chunk_size must be positive")
|
|
728
|
+
num_chunks = max(1, (n + chunk_size - 1) // chunk_size)
|
|
729
|
+
msg_id = self._next_id
|
|
730
|
+
self._next_id += 1
|
|
731
|
+
transfer_id = self._next_id
|
|
732
|
+
self._next_id += 1
|
|
733
|
+
announce_obj = {
|
|
734
|
+
"type": "binary-call-chunked",
|
|
735
|
+
"id": msg_id,
|
|
736
|
+
"wid": wid,
|
|
737
|
+
"method": method,
|
|
738
|
+
"args": list(args),
|
|
739
|
+
"transfer_id": transfer_id,
|
|
740
|
+
"num_chunks": num_chunks,
|
|
741
|
+
}
|
|
742
|
+
if shape is not None:
|
|
743
|
+
announce_obj["shape"] = list(shape)
|
|
744
|
+
if dtype is not None:
|
|
745
|
+
announce_obj["dtype"] = dtype
|
|
746
|
+
announce = json.dumps(announce_obj, cls=JsonEncoder)
|
|
747
|
+
pairs = []
|
|
748
|
+
for ci in range(num_chunks):
|
|
749
|
+
start = ci * chunk_size
|
|
750
|
+
end = min(start + chunk_size, n)
|
|
751
|
+
header = json.dumps({
|
|
752
|
+
"type": "binary-chunk",
|
|
753
|
+
"transfer_id": transfer_id,
|
|
754
|
+
"chunk_index": ci,
|
|
755
|
+
"num_chunks": num_chunks,
|
|
756
|
+
"encoding": "binary",
|
|
757
|
+
}, cls=JsonEncoder)
|
|
758
|
+
pairs.append((header, data[start:end]))
|
|
759
|
+
|
|
760
|
+
async def _send_all(ws):
|
|
761
|
+
await ws.send(announce)
|
|
762
|
+
for hdr, payload in pairs:
|
|
763
|
+
await ws.send(hdr)
|
|
764
|
+
await ws.send(payload)
|
|
765
|
+
|
|
766
|
+
for ws in self._connections:
|
|
767
|
+
task = asyncio.ensure_future(_send_all(ws))
|
|
768
|
+
task.add_done_callback(_drain_send_exception)
|
|
769
|
+
|
|
642
770
|
async def _listen(self, wid, action, handler):
|
|
643
771
|
"""Register a callback listener.
|
|
644
772
|
|
|
@@ -735,17 +863,37 @@ class Session:
|
|
|
735
863
|
cls = self._widget_classes.get(cls_name, Widget) if cls_name else Widget
|
|
736
864
|
widget = cls._from_existing(self, wid, cls_name or "Widget")
|
|
737
865
|
self._widget_map[wid] = widget
|
|
738
|
-
# Auto-listen for state-syncing callbacks (move, resize)
|
|
739
|
-
# so position/size changes are tracked for reconstruction.
|
|
866
|
+
# Auto-listen for state-syncing callbacks (move, resize).
|
|
740
867
|
# Register locally (sync) and send the listen message as
|
|
741
868
|
# true fire-and-forget (no result awaited) since
|
|
742
|
-
# _resolve_return is not async.
|
|
869
|
+
# _resolve_return is not async. For visual widgets we
|
|
870
|
+
# always listen for 'resize' so get_size() can return a
|
|
871
|
+
# current value, but we only mark the action as
|
|
872
|
+
# auto-syncing (which triggers push-to-peers and
|
|
873
|
+
# replay-on-reconstruction) when the widget defn opts in.
|
|
874
|
+
# Non-visual Callback-base objects (Timer, TextBufferRef,
|
|
875
|
+
# …) get nothing.
|
|
876
|
+
defn = WIDGETS.get(cls_name, {}) if cls_name else {}
|
|
877
|
+
opt_names_set = set(defn.get("options", []))
|
|
878
|
+
all_callbacks = defn.get("callbacks", [])
|
|
879
|
+
is_visual = defn.get("base") != "callback"
|
|
743
880
|
for action in STATE_SYNC_CALLBACKS:
|
|
744
|
-
|
|
745
|
-
if
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
881
|
+
req_opt = STATE_SYNC_REQUIRES_OPTION.get(action)
|
|
882
|
+
if req_opt is not None:
|
|
883
|
+
opted_in = req_opt in opt_names_set
|
|
884
|
+
else:
|
|
885
|
+
opted_in = action in all_callbacks
|
|
886
|
+
if not is_visual:
|
|
887
|
+
continue
|
|
888
|
+
if action == "resize" or opted_in:
|
|
889
|
+
key = f"{wid}:{action}"
|
|
890
|
+
if key not in self._callbacks:
|
|
891
|
+
self._callbacks[key] = [lambda wid, *a: None]
|
|
892
|
+
self._fire_and_forget_listen(wid, action)
|
|
893
|
+
if opted_in:
|
|
894
|
+
widget._auto_sync_actions.add(action)
|
|
895
|
+
elif action == "resize":
|
|
896
|
+
widget._passive_sync_actions.add(action)
|
|
749
897
|
return widget
|
|
750
898
|
if isinstance(val, list):
|
|
751
899
|
return [self._resolve_return(v) for v in val]
|
|
@@ -839,12 +987,23 @@ class Session:
|
|
|
839
987
|
await self._listen(new_widget._wid, act,
|
|
840
988
|
lambda wid, *a: None)
|
|
841
989
|
new_widget._auto_sync_actions.add(act)
|
|
842
|
-
# Replay any state the proxy accumulated (e.g. set_tooltip)
|
|
990
|
+
# Replay any state the proxy accumulated (e.g. set_tooltip).
|
|
991
|
+
# Skip passively-captured auto-sync state (size, position): we
|
|
992
|
+
# capture those from callbacks so getters work, but replaying
|
|
993
|
+
# would pin the widget to layout-determined pixel dimensions
|
|
994
|
+
# (same logic as _reconstruct_widget).
|
|
995
|
+
user_set = getattr(old_widget, "_user_set_state", set())
|
|
996
|
+
auto = getattr(old_widget, "_auto_sync_actions", set())
|
|
843
997
|
for key, value in old_widget._state.items():
|
|
844
998
|
if key.startswith("_"):
|
|
845
999
|
continue
|
|
846
1000
|
if key in sync_keys:
|
|
847
1001
|
continue
|
|
1002
|
+
sync_action = self._STATE_KEY_TO_SYNC_ACTION.get(key)
|
|
1003
|
+
if (sync_action is not None
|
|
1004
|
+
and key not in user_set
|
|
1005
|
+
and sync_action not in auto):
|
|
1006
|
+
continue
|
|
848
1007
|
method_name = (self._STATE_KEY_TO_SETTER.get(key)
|
|
849
1008
|
or f"set_{key}")
|
|
850
1009
|
if isinstance(value, tuple):
|
|
@@ -852,6 +1011,13 @@ class Session:
|
|
|
852
1011
|
else:
|
|
853
1012
|
await self._call(new_widget._wid, method_name, value)
|
|
854
1013
|
new_widget._state[key] = value
|
|
1014
|
+
# Propagate user-set / auto-sync membership to the new
|
|
1015
|
+
# widget so subsequent reconstructions replay consistently.
|
|
1016
|
+
if sync_action is not None:
|
|
1017
|
+
if key in user_set:
|
|
1018
|
+
new_widget._user_set_state.add(key)
|
|
1019
|
+
if sync_action in auto:
|
|
1020
|
+
new_widget._auto_sync_actions.add(sync_action)
|
|
855
1021
|
|
|
856
1022
|
async def _ensure_reconstructed(self, widget):
|
|
857
1023
|
"""Ensure a widget has been created on the JS side."""
|
|
@@ -1003,13 +1169,28 @@ class Session:
|
|
|
1003
1169
|
if key.startswith("_"):
|
|
1004
1170
|
continue
|
|
1005
1171
|
|
|
1172
|
+
# Skip auto-sync state (size, position) that came in
|
|
1173
|
+
# passively via a callback (e.g. layout-determined size).
|
|
1174
|
+
# We capture those so getters like get_size()/
|
|
1175
|
+
# get_position() work, but replaying them would pin the
|
|
1176
|
+
# widget to pixel dimensions and override flex/expanding
|
|
1177
|
+
# layout. Replay only if the user explicitly set the
|
|
1178
|
+
# value, or if the widget opted into the sync action
|
|
1179
|
+
# (e.g. an interactively-resizable widget).
|
|
1180
|
+
sync_action = self._STATE_KEY_TO_SYNC_ACTION.get(key)
|
|
1181
|
+
if (sync_action is not None
|
|
1182
|
+
and key not in widget._user_set_state
|
|
1183
|
+
and sync_action not in widget._auto_sync_actions):
|
|
1184
|
+
continue
|
|
1185
|
+
|
|
1006
1186
|
# Binary-payload state (e.g. set_binary_image) replays via
|
|
1007
|
-
# _send_binary
|
|
1008
|
-
#
|
|
1187
|
+
# _send_binary / _send_binary_chunked. Large payloads
|
|
1188
|
+
# switch to chunked transport automatically.
|
|
1009
1189
|
if key in BINARY_STATE_KEYS:
|
|
1010
1190
|
method_name = BINARY_STATE_KEYS[key]
|
|
1011
1191
|
fmt, data = value
|
|
1012
|
-
self
|
|
1192
|
+
_send_binary_auto(self, widget._wid, method_name,
|
|
1193
|
+
[fmt], data)
|
|
1013
1194
|
continue
|
|
1014
1195
|
|
|
1015
1196
|
if key in self._STATE_KEY_TO_SETTER:
|
|
@@ -1047,10 +1228,13 @@ class Session:
|
|
|
1047
1228
|
# _listen calls treat them as first-time registrations and
|
|
1048
1229
|
# actually send the "listen" message to the browser.
|
|
1049
1230
|
wid = widget._wid
|
|
1231
|
+
passive = getattr(widget, "_passive_sync_actions", set())
|
|
1050
1232
|
for action in list(saved_cbs.keys()):
|
|
1051
1233
|
self._callbacks.pop(f"{wid}:{action}", None)
|
|
1052
1234
|
for action in widget._auto_sync_actions:
|
|
1053
1235
|
self._callbacks.pop(f"{wid}:{action}", None)
|
|
1236
|
+
for action in passive:
|
|
1237
|
+
self._callbacks.pop(f"{wid}:{action}", None)
|
|
1054
1238
|
|
|
1055
1239
|
for action, entries in saved_cbs.items():
|
|
1056
1240
|
for handler, extra_args, extra_kwargs, style in entries:
|
|
@@ -1067,6 +1251,13 @@ class Session:
|
|
|
1067
1251
|
if action not in widget._registered_callbacks:
|
|
1068
1252
|
await self._listen(widget._wid, action,
|
|
1069
1253
|
lambda wid, *a: None)
|
|
1254
|
+
# Passive listeners (e.g. 'resize' for getter support on
|
|
1255
|
+
# widgets that didn't opt into auto-sync).
|
|
1256
|
+
for action in passive:
|
|
1257
|
+
if (action not in widget._registered_callbacks
|
|
1258
|
+
and action not in widget._auto_sync_actions):
|
|
1259
|
+
await self._listen(widget._wid, action,
|
|
1260
|
+
lambda wid, *a: None)
|
|
1070
1261
|
|
|
1071
1262
|
async def reconstruct(self):
|
|
1072
1263
|
"""Replay the entire widget tree to all connected browsers.
|
|
@@ -1204,13 +1395,20 @@ class Application:
|
|
|
1204
1395
|
|
|
1205
1396
|
def __init__(self, ws_port=9500, http_port=9501, host="127.0.0.1",
|
|
1206
1397
|
http_server=True, concurrency_handling="per_session",
|
|
1207
|
-
max_sessions=1, logger=None):
|
|
1398
|
+
max_sessions=1, logger=None, ws_sock=None):
|
|
1208
1399
|
if concurrency_handling not in _CONCURRENCY_MODES:
|
|
1209
1400
|
raise ValueError(
|
|
1210
1401
|
f"concurrency_handling must be one of "
|
|
1211
1402
|
f"{_CONCURRENCY_MODES!r}, got {concurrency_handling!r}")
|
|
1212
1403
|
self._host = host
|
|
1213
|
-
|
|
1404
|
+
# ws_sock, if provided, is a bound TCP socket the WebSocket
|
|
1405
|
+
# server should adopt directly — see the sync :class:`Application`
|
|
1406
|
+
# for the rationale (TOCTOU-free port allocation).
|
|
1407
|
+
self._ws_sock = ws_sock
|
|
1408
|
+
if ws_sock is not None:
|
|
1409
|
+
self._ws_port = ws_sock.getsockname()[1]
|
|
1410
|
+
else:
|
|
1411
|
+
self._ws_port = ws_port
|
|
1214
1412
|
self._http_port = http_port
|
|
1215
1413
|
self._use_http_server = http_server
|
|
1216
1414
|
self._concurrency = concurrency_handling
|
|
@@ -1567,8 +1765,12 @@ class Application:
|
|
|
1567
1765
|
self._logger.info(
|
|
1568
1766
|
f"WebSocket on ws://{self._host}:{self._ws_port}")
|
|
1569
1767
|
|
|
1570
|
-
self.
|
|
1571
|
-
self.
|
|
1768
|
+
if self._ws_sock is not None:
|
|
1769
|
+
self._ws_server = await websockets.serve(
|
|
1770
|
+
self._ws_handler, sock=self._ws_sock)
|
|
1771
|
+
else:
|
|
1772
|
+
self._ws_server = await websockets.serve(
|
|
1773
|
+
self._ws_handler, self._host, self._ws_port)
|
|
1572
1774
|
|
|
1573
1775
|
async def close(self):
|
|
1574
1776
|
"""Close all sessions and shut down the application.
|
pgwidgets/async_/widget.py
CHANGED
|
@@ -79,6 +79,16 @@ class Widget:
|
|
|
79
79
|
self._constructor_options = {}
|
|
80
80
|
self._registered_callbacks = {}
|
|
81
81
|
self._auto_sync_actions = set()
|
|
82
|
+
# Actions we listen to passively for getter support
|
|
83
|
+
# (e.g. 'resize' on every visual widget so get_size() returns
|
|
84
|
+
# a current value), but that aren't in _auto_sync_actions and
|
|
85
|
+
# therefore don't push to peers or replay on reconstruction.
|
|
86
|
+
self._passive_sync_actions = set()
|
|
87
|
+
# State keys the user explicitly set via a setter call.
|
|
88
|
+
# Used during reconstruction to decide whether a state key
|
|
89
|
+
# should be replayed: passively-captured callback state
|
|
90
|
+
# (e.g. layout-determined size) is NOT in this set.
|
|
91
|
+
self._user_set_state = set()
|
|
82
92
|
self._replay_calls = []
|
|
83
93
|
self._add_seq = 0
|
|
84
94
|
|
|
@@ -196,6 +206,8 @@ class Widget:
|
|
|
196
206
|
obj._constructor_options = {}
|
|
197
207
|
obj._registered_callbacks = {}
|
|
198
208
|
obj._auto_sync_actions = set()
|
|
209
|
+
obj._passive_sync_actions = set()
|
|
210
|
+
obj._user_set_state = set()
|
|
199
211
|
obj._replay_calls = []
|
|
200
212
|
obj._stale = False
|
|
201
213
|
return obj
|
|
@@ -212,15 +224,31 @@ class Widget:
|
|
|
212
224
|
opt_names_set = set(defn.get("options", []))
|
|
213
225
|
all_callbacks = defn.get("callbacks", [])
|
|
214
226
|
|
|
215
|
-
# State-sync callbacks (move -> position, resize -> size)
|
|
227
|
+
# State-sync callbacks (move -> position, resize -> size).
|
|
228
|
+
# For visual widgets we always *listen* so getters like
|
|
229
|
+
# get_size() / get_position() can return current values.
|
|
230
|
+
# But we only add the action to _auto_sync_actions — which
|
|
231
|
+
# controls push-to-peers and replay-on-reconstruction — when
|
|
232
|
+
# the widget actually opted in (e.g. via the 'resizable' option
|
|
233
|
+
# or by declaring the callback in its defn). This keeps
|
|
234
|
+
# layout-determined sizes from being replayed as literal
|
|
235
|
+
# resize() calls that would pin flex/expanding widgets.
|
|
236
|
+
is_visual = defn.get("base") != "callback"
|
|
216
237
|
for action in STATE_SYNC_CALLBACKS:
|
|
217
238
|
req_opt = STATE_SYNC_REQUIRES_OPTION.get(action)
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
239
|
+
opted_in = False
|
|
240
|
+
if req_opt is not None:
|
|
241
|
+
opted_in = req_opt in opt_names_set
|
|
242
|
+
else:
|
|
243
|
+
opted_in = action in all_callbacks
|
|
244
|
+
if not is_visual:
|
|
221
245
|
continue
|
|
222
|
-
|
|
223
|
-
|
|
246
|
+
if action == "resize" or opted_in:
|
|
247
|
+
await session._listen(wid, action, lambda wid, *a: None)
|
|
248
|
+
if opted_in:
|
|
249
|
+
self._auto_sync_actions.add(action)
|
|
250
|
+
elif action == "resize":
|
|
251
|
+
self._passive_sync_actions.add(action)
|
|
224
252
|
|
|
225
253
|
# Per-widget-class state sync (e.g. Slider "activated" -> value)
|
|
226
254
|
cls_sync = WIDGET_CALLBACK_SYNC.get(js_class, {})
|
|
@@ -461,6 +489,9 @@ def _make_setter(method_name, param_names, state_key):
|
|
|
461
489
|
self._state[state_key] = args[0]
|
|
462
490
|
else:
|
|
463
491
|
self._state[state_key] = args
|
|
492
|
+
# Mark as user-set so reconstruction knows to replay this key
|
|
493
|
+
# (callback-captured values for the same key don't get marked).
|
|
494
|
+
self._user_set_state.add(state_key)
|
|
464
495
|
return await self._call(method_name, *args)
|
|
465
496
|
method.__name__ = method_name
|
|
466
497
|
method.__qualname__ = f"Widget.{method_name}"
|
|
@@ -473,6 +504,7 @@ def _make_fixed_setter(method_name, state_key, fixed_value):
|
|
|
473
504
|
"""Create a no-arg async method that sets a fixed state value (show/hide)."""
|
|
474
505
|
async def method(self):
|
|
475
506
|
self._state[state_key] = fixed_value
|
|
507
|
+
self._user_set_state.add(state_key)
|
|
476
508
|
return await self._call(method_name)
|
|
477
509
|
method.__name__ = method_name
|
|
478
510
|
method.__qualname__ = f"Widget.{method_name}"
|
pgwidgets/buffer.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Typed binary buffer descriptors for the remote interface.
|
|
2
|
+
|
|
3
|
+
When a method on the browser side expects a typed n-dimensional array
|
|
4
|
+
(image pixels, scientific data, vertex buffers, …) the Python side can
|
|
5
|
+
wrap the raw bytes in a :class:`Buffer` so the receiver gets a typed
|
|
6
|
+
view with shape/dtype information without each method re-implementing
|
|
7
|
+
its own ad-hoc convention.
|
|
8
|
+
|
|
9
|
+
Example::
|
|
10
|
+
|
|
11
|
+
from pgwidgets import Buffer
|
|
12
|
+
|
|
13
|
+
pixels = bytes(...) # 2048 * 2048 * 4 bytes
|
|
14
|
+
buf = Buffer(pixels,
|
|
15
|
+
shape=(2048, 2048, 4),
|
|
16
|
+
dtype="uint8")
|
|
17
|
+
viewer.load_buffer(buf, [2048, 2048], cache)
|
|
18
|
+
|
|
19
|
+
A widget method's binding can recognize :class:`Buffer` args and ship
|
|
20
|
+
them via the chunked binary transport, attaching ``shape`` and
|
|
21
|
+
``dtype`` to the chunk announce so the JavaScript receiver builds the
|
|
22
|
+
correct typed array (``Uint8Array``, ``Float32Array``, …) before
|
|
23
|
+
dispatching to the method.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from typing import Sequence, Union
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Subset of NumPy / DLPack dtype names mapped to the JavaScript
|
|
30
|
+
# TypedArray constructor on the receiving side.
|
|
31
|
+
_DTYPES = frozenset({
|
|
32
|
+
"uint8", "uint16", "uint32",
|
|
33
|
+
"int8", "int16", "int32",
|
|
34
|
+
"float32", "float64",
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
_DTYPE_BYTES = {
|
|
38
|
+
"uint8": 1,
|
|
39
|
+
"int8": 1,
|
|
40
|
+
"uint16": 2,
|
|
41
|
+
"int16": 2,
|
|
42
|
+
"uint32": 4,
|
|
43
|
+
"int32": 4,
|
|
44
|
+
"float32": 4,
|
|
45
|
+
"float64": 8,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Buffer:
|
|
50
|
+
"""Raw bytes plus shape + dtype, addressable as a typed array.
|
|
51
|
+
|
|
52
|
+
Parameters
|
|
53
|
+
----------
|
|
54
|
+
data : bytes-like
|
|
55
|
+
``bytes``, ``bytearray``, ``memoryview``, or anything that
|
|
56
|
+
exposes ``__bytes__`` / the buffer protocol. Converted to
|
|
57
|
+
``bytes`` at construction time.
|
|
58
|
+
shape : tuple of int
|
|
59
|
+
Logical dimensions of the array, e.g. ``(height, width, 4)``
|
|
60
|
+
for an RGBA8 image. Each entry must be a positive integer.
|
|
61
|
+
dtype : str
|
|
62
|
+
One of ``"uint8"``, ``"uint16"``, ``"uint32"``, ``"int8"``,
|
|
63
|
+
``"int16"``, ``"int32"``, ``"float32"``, ``"float64"``.
|
|
64
|
+
Default ``"uint8"``.
|
|
65
|
+
|
|
66
|
+
Raises
|
|
67
|
+
------
|
|
68
|
+
TypeError
|
|
69
|
+
If ``data`` is not bytes-like.
|
|
70
|
+
ValueError
|
|
71
|
+
If ``dtype`` is unsupported, ``shape`` contains non-positive
|
|
72
|
+
entries, or the byte length disagrees with
|
|
73
|
+
``prod(shape) * itemsize`` (rounded up for non-evenly divisible
|
|
74
|
+
cases).
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
__slots__ = ("data", "shape", "dtype")
|
|
78
|
+
|
|
79
|
+
def __init__(self,
|
|
80
|
+
data: Union[bytes, bytearray, memoryview],
|
|
81
|
+
shape: Sequence[int],
|
|
82
|
+
dtype: str = "uint8") -> None:
|
|
83
|
+
if dtype not in _DTYPES:
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"Buffer: unsupported dtype {dtype!r}; "
|
|
86
|
+
f"supported: {sorted(_DTYPES)}")
|
|
87
|
+
try:
|
|
88
|
+
data = bytes(data)
|
|
89
|
+
except TypeError as e:
|
|
90
|
+
raise TypeError(
|
|
91
|
+
f"Buffer: data must be bytes-like, got "
|
|
92
|
+
f"{type(data).__name__}") from e
|
|
93
|
+
shape_tuple = tuple(int(d) for d in shape)
|
|
94
|
+
if not shape_tuple or any(d <= 0 for d in shape_tuple):
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"Buffer: shape must be a non-empty tuple of "
|
|
97
|
+
f"positive ints, got {shape!r}")
|
|
98
|
+
itemsize = _DTYPE_BYTES[dtype]
|
|
99
|
+
expected = itemsize
|
|
100
|
+
for d in shape_tuple:
|
|
101
|
+
expected *= d
|
|
102
|
+
if len(data) != expected:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"Buffer: data length {len(data)} bytes does not "
|
|
105
|
+
f"match shape {shape_tuple} * dtype {dtype} "
|
|
106
|
+
f"(expected {expected} bytes)")
|
|
107
|
+
self.data = data
|
|
108
|
+
self.shape = shape_tuple
|
|
109
|
+
self.dtype = dtype
|
|
110
|
+
|
|
111
|
+
def __len__(self) -> int:
|
|
112
|
+
return len(self.data)
|
|
113
|
+
|
|
114
|
+
def __repr__(self) -> str:
|
|
115
|
+
return (f"Buffer(<{len(self.data)} bytes>, "
|
|
116
|
+
f"shape={self.shape}, dtype={self.dtype!r})")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def is_buffer(obj) -> bool:
|
|
120
|
+
"""Return True if *obj* is a :class:`Buffer` instance.
|
|
121
|
+
|
|
122
|
+
Convenience for code that branches on whether an argument carries
|
|
123
|
+
binary-payload metadata.
|
|
124
|
+
"""
|
|
125
|
+
return isinstance(obj, Buffer)
|