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/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._send_binary(
354
- self._wid, "set_binary_image", [format], data)
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.
@@ -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 == "file-chunk":
297
- self._handle_file_chunk(msg)
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 _handle_file_chunk(self, msg):
325
- """Handle a file-chunk message: buffer data and fire callbacks."""
326
- tid = msg["transfer_id"]
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 = msg["file_index"]
332
- fc = msg["file_count"]
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] = msg["num_chunks"]
336
- transfer["file_data"][fi].append(msg["data"])
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
- len(transfer["file_data"][i]) >= transfer["num_chunks"][i]
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
- nc = transfer["num_chunks"].get(i)
355
- if nc:
356
- received = len(transfer["file_data"].get(i, []))
357
- transferred_bytes += fsize * received // nc
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": msg["chunk_index"],
363
- "num_chunks": msg["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
- file_meta["data"] = "".join(
380
- transfer["file_data"].get(i, []))
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
- # widget = self._widget_map.get(wid)
388
- # cls_name = widget._js_class if widget is not None else "<missing>"
389
- # print(f"[PY-CB] receive wid={wid} action={action} "
390
- # f"registered_class={cls_name}", flush=True)
391
- if self._reconstructing:
392
- return # suppress callbacks during reconstruction
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
- self._push(wid, "resize",
409
- d["width"], d["height"])
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
- setter = (self._STATE_KEY_TO_SETTER.get(state_key)
415
- or f"set_{state_key}")
416
- self._push(wid, setter, *args)
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
- # so position/size changes are tracked for reconstruction.
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
- key = f"{wid}:{action}"
811
- if key not in self._callbacks:
812
- self._listen(wid, action, lambda wid, *a: None)
813
- widget._auto_sync_actions.add(action)
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 a raw frame, not embedded
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._send_binary(widget._wid, method_name, [fmt], data)
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
- self._ws_port = ws_port
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
- async with websockets.serve(self._ws_handler, self._host,
1519
- self._ws_port):
1520
- await asyncio.Future()
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
- if req_opt and req_opt not in opt_names_set:
184
- continue
185
- if req_opt is None and action not in all_callbacks:
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
- session._listen(wid, action, lambda wid, *a: None)
188
- self._auto_sync_actions.add(action)
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}"