pgwidgets-python 0.2.3__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 CHANGED
@@ -24,6 +24,8 @@ Version:
24
24
 
25
25
  from importlib.metadata import version as _pkg_version, PackageNotFoundError
26
26
 
27
+ from pgwidgets.buffer import Buffer, is_buffer # noqa: F401
28
+
27
29
  try:
28
30
  __version__ = _pkg_version("pgwidgets")
29
31
  except PackageNotFoundError:
@@ -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
 
@@ -125,6 +125,9 @@ class Session:
125
125
 
126
126
  self._widget_classes = app._widget_classes
127
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 = []
128
131
  self._callback_source_ws = None # ws that sent current callback
129
132
 
130
133
  self._reconstructing = False # suppress callbacks during reconstruction
@@ -194,6 +197,19 @@ class Session:
194
197
  # -- Message handling --
195
198
 
196
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
197
213
  msg = json.loads(data)
198
214
  if isinstance(msg, list):
199
215
  for m in msg:
@@ -216,8 +232,18 @@ class Session:
216
232
  elif msg_type == "viewport":
217
233
  self._screen_size = (msg.get("width", 0), msg.get("height", 0))
218
234
 
219
- elif msg_type == "file-chunk":
220
- self._handle_file_chunk(msg)
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)
221
247
 
222
248
  elif msg_type == "callback":
223
249
  # If the payload has a transfer_id, stash the metadata —
@@ -244,25 +270,40 @@ class Session:
244
270
  self._dispatch_callback(
245
271
  msg["wid"], msg["action"], *msg.get("args", []))
246
272
 
247
- def _handle_file_chunk(self, msg):
248
- """Handle a file-chunk message: buffer data and fire callbacks."""
249
- tid = msg["transfer_id"]
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"]
250
281
  transfer = self._transfers.get(tid)
251
282
  if transfer is None:
252
283
  return
253
284
 
254
- fi = msg["file_index"]
255
- fc = msg["file_count"]
285
+ fi = header.get("file_index", 0)
286
+ fc = header.get("file_count", 1)
287
+ ci = header["chunk_index"]
288
+ nc = header["num_chunks"]
256
289
  if fi not in transfer["file_data"]:
257
- transfer["file_data"][fi] = []
258
- transfer["num_chunks"][fi] = msg["num_chunks"]
259
- transfer["file_data"][fi].append(msg["data"])
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
260
301
 
261
302
  # Check if all files have received all their chunks.
262
303
  all_complete = (
263
304
  len(transfer["num_chunks"]) == fc
264
305
  and all(
265
- len(transfer["file_data"][i]) >= transfer["num_chunks"][i]
306
+ None not in transfer["file_data"][i]
266
307
  for i in range(fc)
267
308
  )
268
309
  )
@@ -274,16 +315,17 @@ class Session:
274
315
  for i, fmeta in enumerate(files_meta):
275
316
  fsize = fmeta.get("size", 0)
276
317
  total_bytes += fsize
277
- nc = transfer["num_chunks"].get(i)
278
- if nc:
279
- received = len(transfer["file_data"].get(i, []))
280
- transferred_bytes += fsize * received // nc
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
281
323
 
282
324
  progress_info = {
283
325
  "transfer_id": tid,
284
326
  "file_index": fi,
285
- "chunk_index": msg["chunk_index"],
286
- "num_chunks": msg["num_chunks"],
327
+ "chunk_index": ci,
328
+ "num_chunks": nc,
287
329
  "transferred_bytes": transferred_bytes,
288
330
  "total_bytes": total_bytes,
289
331
  "complete": all_complete,
@@ -299,8 +341,8 @@ class Session:
299
341
  # Reassemble file data and fire the original callback.
300
342
  payload = transfer["payload"]
301
343
  for i, file_meta in enumerate(payload["files"]):
302
- file_meta["data"] = "".join(
303
- transfer["file_data"].get(i, []))
344
+ slots = transfer["file_data"].get(i, [])
345
+ file_meta["data"] = b"".join(slots)
304
346
  del self._transfers[tid]
305
347
  self._dispatch_callback(
306
348
  transfer["wid"], action, payload)
@@ -662,6 +704,69 @@ class Session:
662
704
  task = asyncio.ensure_future(_pair(ws))
663
705
  task.add_done_callback(_drain_send_exception)
664
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
+
665
770
  async def _listen(self, wid, action, handler):
666
771
  """Register a callback listener.
667
772
 
@@ -1079,12 +1184,13 @@ class Session:
1079
1184
  continue
1080
1185
 
1081
1186
  # Binary-payload state (e.g. set_binary_image) replays via
1082
- # _send_binary so the bytes go in a raw frame, not embedded
1083
- # as base64 in JSON.
1187
+ # _send_binary / _send_binary_chunked. Large payloads
1188
+ # switch to chunked transport automatically.
1084
1189
  if key in BINARY_STATE_KEYS:
1085
1190
  method_name = BINARY_STATE_KEYS[key]
1086
1191
  fmt, data = value
1087
- self._send_binary(widget._wid, method_name, [fmt], data)
1192
+ _send_binary_auto(self, widget._wid, method_name,
1193
+ [fmt], data)
1088
1194
  continue
1089
1195
 
1090
1196
  if key in self._STATE_KEY_TO_SETTER:
@@ -1289,13 +1395,20 @@ class Application:
1289
1395
 
1290
1396
  def __init__(self, ws_port=9500, http_port=9501, host="127.0.0.1",
1291
1397
  http_server=True, concurrency_handling="per_session",
1292
- max_sessions=1, logger=None):
1398
+ max_sessions=1, logger=None, ws_sock=None):
1293
1399
  if concurrency_handling not in _CONCURRENCY_MODES:
1294
1400
  raise ValueError(
1295
1401
  f"concurrency_handling must be one of "
1296
1402
  f"{_CONCURRENCY_MODES!r}, got {concurrency_handling!r}")
1297
1403
  self._host = host
1298
- self._ws_port = ws_port
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
1299
1412
  self._http_port = http_port
1300
1413
  self._use_http_server = http_server
1301
1414
  self._concurrency = concurrency_handling
@@ -1652,8 +1765,12 @@ class Application:
1652
1765
  self._logger.info(
1653
1766
  f"WebSocket on ws://{self._host}:{self._ws_port}")
1654
1767
 
1655
- self._ws_server = await websockets.serve(
1656
- self._ws_handler, self._host, self._ws_port)
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)
1657
1774
 
1658
1775
  async def close(self):
1659
1776
  """Close all sessions and shut down the application.
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)
pgwidgets/method_types.py CHANGED
@@ -338,6 +338,34 @@ def _menuaction_get_state(self):
338
338
  """Alias for MenuAction.get_checked — keeps a single state key."""
339
339
  return self.get_checked()
340
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
+
341
369
  def _image_set_binary_image(self, data, format="jpeg"):
342
370
  """Set the image from raw bytes via a WebSocket binary frame.
343
371
 
@@ -358,6 +386,10 @@ def _image_set_binary_image(self, data, format="jpeg"):
358
386
  that reconstruction after a browser reconnect re-sends the most
359
387
  recently set image. Earlier set_image (URL-based) state is
360
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.
361
393
  """
362
394
  if not isinstance(data, (bytes, bytearray, memoryview)):
363
395
  raise TypeError(
@@ -365,8 +397,8 @@ def _image_set_binary_image(self, data, format="jpeg"):
365
397
  data = bytes(data)
366
398
  self._state.pop("image", None)
367
399
  self._state["binary_image"] = (format, data)
368
- self._session._send_binary(
369
- self._wid, "set_binary_image", [format], data)
400
+ _send_binary_auto(self._session, self._wid,
401
+ "set_binary_image", [format], data)
370
402
 
371
403
  # State keys whose value is a (format, bytes) tuple that must be
372
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,8 +428,8 @@ 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)
@@ -718,6 +770,91 @@ class Session:
718
770
  continue
719
771
  fut.add_done_callback(_drain_send_exception)
720
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
+
721
858
  def _listen(self, wid, action, handler):
722
859
  """Register a callback listener.
723
860
 
@@ -1219,12 +1356,14 @@ class Session:
1219
1356
  continue
1220
1357
 
1221
1358
  # Binary-payload state (e.g. set_binary_image) replays via
1222
- # _send_binary so the bytes go in a raw frame, not embedded
1223
- # 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.
1224
1362
  if key in BINARY_STATE_KEYS:
1225
1363
  method_name = BINARY_STATE_KEYS[key]
1226
1364
  fmt, data = value
1227
- self._send_binary(widget._wid, method_name, [fmt], data)
1365
+ _send_binary_auto(self, widget._wid, method_name,
1366
+ [fmt], data)
1228
1367
  continue
1229
1368
 
1230
1369
  if key in self._STATE_KEY_TO_SETTER:
@@ -1466,13 +1605,24 @@ class Application:
1466
1605
 
1467
1606
  def __init__(self, ws_port=9500, http_port=9501, host="127.0.0.1",
1468
1607
  http_server=True, concurrency_handling="per_session",
1469
- max_sessions=1, logger=None):
1608
+ max_sessions=1, logger=None, ws_sock=None):
1470
1609
  if concurrency_handling not in _CONCURRENCY_MODES:
1471
1610
  raise ValueError(
1472
1611
  f"concurrency_handling must be one of "
1473
1612
  f"{_CONCURRENCY_MODES!r}, got {concurrency_handling!r}")
1474
1613
  self._host = host
1475
- 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
1476
1626
  self._http_port = http_port
1477
1627
  self._use_http_server = http_server
1478
1628
  self._concurrency = concurrency_handling
@@ -1595,9 +1745,14 @@ class Application:
1595
1745
  self._loop.run_until_complete(self._serve_ws())
1596
1746
 
1597
1747
  async def _serve_ws(self):
1598
- async with websockets.serve(self._ws_handler, self._host,
1599
- self._ws_port):
1600
- 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()
1601
1756
 
1602
1757
  async def _ws_handler(self, ws):
1603
1758
  # Init handshake: send init, receive ack which may contain
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgwidgets-python
3
- Version: 0.2.3
3
+ Version: 0.3.0
4
4
  Summary: Python bindings for the pgwidgets JavaScript widget library
5
5
  Author: PGWidgets Developers
6
6
  License: BSD-3-Clause
@@ -1,20 +1,21 @@
1
- pgwidgets/__init__.py,sha256=fKKHAt9GxLbficihVyImWsqrnQvdQZQAmeGaRUF8cBE,857
1
+ pgwidgets/__init__.py,sha256=z6QFxTtjwnduH-s0zT8u_GJb0rT7OZsgPawMIlIzfQE,919
2
2
  pgwidgets/_json.py,sha256=o21qywJ6yAldbqxTq3nLwgK9O67r3a8JxhvI_WAJnMY,2184
3
+ pgwidgets/buffer.py,sha256=BYj_nb6fuyGsBqUp3_Z1YrzUm4h5VmoWPGKvpTjWrYk,4048
3
4
  pgwidgets/callbacks.py,sha256=gA2FnX0N5BmbmOMyEV35yHySgAfVjfAZcZhZbo7j4-c,3314
4
5
  pgwidgets/defs.py,sha256=Q8qhvTeansFU1Av6j5ncdwF2xSNHrpXuKErdHWQ3HiA,436
5
- pgwidgets/method_types.py,sha256=S5V6EvJ9DZnSQaiaEhPkAuCtqrlHde5wj16TZd0vHvQ,16327
6
+ pgwidgets/method_types.py,sha256=E_f0CQtDwWKgiPmIPGEftm3OXvntErAY4JZH0C7Dwuo,17699
6
7
  pgwidgets/async_/Widgets.py,sha256=vld2xkvusBBEVbhbezaJsjBRRj3L7c17Up0pOVs8PGo,723
7
8
  pgwidgets/async_/__init__.py,sha256=rXB-v9XRrYt1imYuYikhkzIyiRqaYhlTpQei2LHaQ18,564
8
- pgwidgets/async_/application.py,sha256=mab8nRl7dNwnvNx64F0RapIJvJXy4HW_eJ1Bbuhw83w,69612
9
+ pgwidgets/async_/application.py,sha256=xLt7ZF7jgRYKT5-L95sahohT4Omq2TfW7y8V1f6GM9g,74443
9
10
  pgwidgets/async_/widget.py,sha256=H27v7AzHNg5obzr2e8UyOyj2lbGfWhEhZUrIDqrZNbg,37612
10
11
  pgwidgets/extras/__init__.py,sha256=AXUmFtnn4RSlp6pJX6z55j-m_29VmfzpYIjQvAy7pO0,325
11
12
  pgwidgets/extras/file_browser.py,sha256=PWzJltjQZbmsD1ykbbC2xJ6ErEkJj7KafhmVkd9q9Ac,16963
12
13
  pgwidgets/sync/Widgets.py,sha256=7SaocMVMzFHhi51pjuEAr47Wez90b_a61kDbiv0jOfM,706
13
14
  pgwidgets/sync/__init__.py,sha256=SF5RTAvtu8BbYBWzpiCPipy6DJzNXf6nqk9xdvwxUhQ,542
14
- pgwidgets/sync/application.py,sha256=a1Xu5VRif9WKHxMWvPN3oyTqjPsrdw54Smp4Vz16ObY,80309
15
+ pgwidgets/sync/application.py,sha256=0xZDEywx7L7vUe1QAs29iufIqqvmSzOLeu9QQIGiSj0,87160
15
16
  pgwidgets/sync/widget.py,sha256=tQDkCOkcevDrGnG4LhBf69law82-UB1fF-r08GUZPSE,37913
16
- pgwidgets_python-0.2.3.dist-info/licenses/LICENSE.md,sha256=LoM3fMTiMnQuHRCJghdjOtjnCrL8soBpu2PFk24Xvyg,1528
17
- pgwidgets_python-0.2.3.dist-info/METADATA,sha256=e5O9EkVS7VKRrgwxNwtKgLOcs6emH7hpkFqyfJOKAUE,4568
18
- pgwidgets_python-0.2.3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
19
- pgwidgets_python-0.2.3.dist-info/top_level.txt,sha256=wwL6fBq0gU-JwzlM6TdduY1qYpu39ysqnnbQT-1bqAs,10
20
- pgwidgets_python-0.2.3.dist-info/RECORD,,
17
+ pgwidgets_python-0.3.0.dist-info/licenses/LICENSE.md,sha256=LoM3fMTiMnQuHRCJghdjOtjnCrL8soBpu2PFk24Xvyg,1528
18
+ pgwidgets_python-0.3.0.dist-info/METADATA,sha256=LtcjQx8O8cbpQ_1HcXVm8Jpo7UyFLfVkPCEe8fEmJIY,4568
19
+ pgwidgets_python-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
20
+ pgwidgets_python-0.3.0.dist-info/top_level.txt,sha256=wwL6fBq0gU-JwzlM6TdduY1qYpu39ysqnnbQT-1bqAs,10
21
+ pgwidgets_python-0.3.0.dist-info/RECORD,,