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 +2 -0
- pgwidgets/async_/application.py +144 -27
- pgwidgets/buffer.py +125 -0
- pgwidgets/method_types.py +34 -2
- pgwidgets/sync/application.py +183 -28
- {pgwidgets_python-0.2.3.dist-info → pgwidgets_python-0.3.0.dist-info}/METADATA +1 -1
- {pgwidgets_python-0.2.3.dist-info → pgwidgets_python-0.3.0.dist-info}/RECORD +10 -9
- {pgwidgets_python-0.2.3.dist-info → pgwidgets_python-0.3.0.dist-info}/WHEEL +0 -0
- {pgwidgets_python-0.2.3.dist-info → pgwidgets_python-0.3.0.dist-info}/licenses/LICENSE.md +0 -0
- {pgwidgets_python-0.2.3.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
|
|
|
@@ -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 == "
|
|
220
|
-
|
|
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
|
|
248
|
-
"""
|
|
249
|
-
|
|
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 =
|
|
255
|
-
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"]
|
|
256
289
|
if fi not in transfer["file_data"]:
|
|
257
|
-
transfer["file_data"][fi] = []
|
|
258
|
-
transfer["num_chunks"][fi] =
|
|
259
|
-
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
|
|
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
|
-
|
|
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
|
-
|
|
278
|
-
if
|
|
279
|
-
|
|
280
|
-
|
|
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":
|
|
286
|
-
"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
|
-
|
|
303
|
-
|
|
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
|
|
1083
|
-
#
|
|
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
|
|
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
|
-
|
|
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.
|
|
1656
|
-
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)
|
|
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.
|
|
369
|
-
|
|
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.
|
pgwidgets/sync/application.py
CHANGED
|
@@ -33,7 +33,7 @@ from pgwidgets.method_types import (
|
|
|
33
33
|
STATE_SYNC_CALLBACKS, STATE_SYNC_REQUIRES_OPTION,
|
|
34
34
|
WIDGET_CALLBACK_SYNC, POST_CHILDREN_STATE_KEYS, ITEM_LIST_CONFIG,
|
|
35
35
|
CHILD_CLOSE_CALLBACKS, REPLAY_METHODS, TREE_VIEW_WIDGETS,
|
|
36
|
-
BINARY_STATE_KEYS,
|
|
36
|
+
BINARY_STATE_KEYS, _send_binary_auto,
|
|
37
37
|
)
|
|
38
38
|
|
|
39
39
|
_CONCURRENCY_MODES = ("serialized", "per_session", "concurrent")
|
|
@@ -169,6 +169,11 @@ class Session:
|
|
|
169
169
|
|
|
170
170
|
self._widget_classes = app._widget_classes
|
|
171
171
|
self._transfers = {} # transfer_id -> transfer state dict
|
|
172
|
+
# FIFO of binary-chunk JSON headers (encoding="binary") still
|
|
173
|
+
# awaiting their paired raw binary frame. Each connection's
|
|
174
|
+
# send order on the JS side is single-threaded, so a simple
|
|
175
|
+
# FIFO suffices on the receive side too.
|
|
176
|
+
self._pending_binary_headers = []
|
|
172
177
|
self._callback_source_ws = None # ws that sent current callback
|
|
173
178
|
|
|
174
179
|
self._reconstructing = False # suppress callbacks during reconstruction
|
|
@@ -273,6 +278,20 @@ class Session:
|
|
|
273
278
|
# -- Message handling --
|
|
274
279
|
|
|
275
280
|
def _handle_message(self, data):
|
|
281
|
+
# Raw binary frames are chunk payloads — pair with the head of
|
|
282
|
+
# the pending-binary FIFO (a list of binary-chunk JSON headers
|
|
283
|
+
# whose encoding == "binary"). This is symmetric with the
|
|
284
|
+
# JS-side intake.
|
|
285
|
+
if isinstance(data, (bytes, bytearray, memoryview)):
|
|
286
|
+
queue = self._pending_binary_headers
|
|
287
|
+
if not queue:
|
|
288
|
+
self._logger.warning(
|
|
289
|
+
"Session %s: unexpected binary frame with no "
|
|
290
|
+
"queued header (ignored).", self.id)
|
|
291
|
+
return
|
|
292
|
+
header = queue.pop(0)
|
|
293
|
+
self._handle_binary_chunk(header, bytes(data))
|
|
294
|
+
return
|
|
276
295
|
msg = json.loads(data)
|
|
277
296
|
if isinstance(msg, list):
|
|
278
297
|
for m in msg:
|
|
@@ -293,8 +312,20 @@ class Session:
|
|
|
293
312
|
elif msg_type == "viewport":
|
|
294
313
|
self._screen_size = (msg.get("width", 0), msg.get("height", 0))
|
|
295
314
|
|
|
296
|
-
elif msg_type == "
|
|
297
|
-
|
|
315
|
+
elif msg_type == "binary-chunk":
|
|
316
|
+
# Either reserves the next binary frame (encoding="binary")
|
|
317
|
+
# or carries an inline base64 payload (encoding="base64").
|
|
318
|
+
encoding = msg.get("encoding", "binary")
|
|
319
|
+
if encoding == "binary":
|
|
320
|
+
self._pending_binary_headers.append(msg)
|
|
321
|
+
elif encoding == "base64":
|
|
322
|
+
import base64 as _b64
|
|
323
|
+
payload = _b64.b64decode(msg.get("data") or "")
|
|
324
|
+
self._handle_binary_chunk(msg, payload)
|
|
325
|
+
else:
|
|
326
|
+
self._logger.warning(
|
|
327
|
+
"Session %s: unknown binary-chunk encoding %r "
|
|
328
|
+
"(ignored).", self.id, encoding)
|
|
298
329
|
|
|
299
330
|
elif msg_type == "callback":
|
|
300
331
|
# If the payload has a transfer_id, stash the metadata —
|
|
@@ -321,25 +352,45 @@ class Session:
|
|
|
321
352
|
self._dispatch_callback(
|
|
322
353
|
msg["wid"], msg["action"], *msg.get("args", []))
|
|
323
354
|
|
|
324
|
-
def
|
|
325
|
-
"""
|
|
326
|
-
|
|
355
|
+
def _handle_binary_chunk(self, header, data):
|
|
356
|
+
"""Buffer one chunk of an in-flight transfer.
|
|
357
|
+
|
|
358
|
+
``header`` is the parsed binary-chunk JSON; ``data`` is the
|
|
359
|
+
chunk's raw bytes (either from the paired binary WebSocket
|
|
360
|
+
frame or decoded from an inline base64 ``data`` field).
|
|
361
|
+
|
|
362
|
+
For file-upload transfers (drag-drop, FileDialog), the header
|
|
363
|
+
also carries ``file_index`` / ``file_count`` so multiple files
|
|
364
|
+
can be reassembled in parallel. For other future server-bound
|
|
365
|
+
chunked transports, those fields can be omitted.
|
|
366
|
+
"""
|
|
367
|
+
tid = header["transfer_id"]
|
|
327
368
|
transfer = self._transfers.get(tid)
|
|
328
369
|
if transfer is None:
|
|
329
370
|
return
|
|
330
371
|
|
|
331
|
-
fi =
|
|
332
|
-
fc =
|
|
372
|
+
fi = header.get("file_index", 0)
|
|
373
|
+
fc = header.get("file_count", 1)
|
|
374
|
+
ci = header["chunk_index"]
|
|
375
|
+
nc = header["num_chunks"]
|
|
333
376
|
if fi not in transfer["file_data"]:
|
|
334
|
-
transfer["file_data"][fi] = []
|
|
335
|
-
transfer["num_chunks"][fi] =
|
|
336
|
-
transfer["file_data"][fi]
|
|
377
|
+
transfer["file_data"][fi] = [None] * nc
|
|
378
|
+
transfer["num_chunks"][fi] = nc
|
|
379
|
+
slot = transfer["file_data"][fi]
|
|
380
|
+
if 0 <= ci < len(slot):
|
|
381
|
+
slot[ci] = data
|
|
382
|
+
else:
|
|
383
|
+
self._logger.warning(
|
|
384
|
+
"Session %s: chunk_index %d out of range for "
|
|
385
|
+
"transfer_id %s (num_chunks=%d).",
|
|
386
|
+
self.id, ci, tid, nc)
|
|
387
|
+
return
|
|
337
388
|
|
|
338
389
|
# Check if all files have received all their chunks.
|
|
339
390
|
all_complete = (
|
|
340
391
|
len(transfer["num_chunks"]) == fc
|
|
341
392
|
and all(
|
|
342
|
-
|
|
393
|
+
None not in transfer["file_data"][i]
|
|
343
394
|
for i in range(fc)
|
|
344
395
|
)
|
|
345
396
|
)
|
|
@@ -351,16 +402,17 @@ class Session:
|
|
|
351
402
|
for i, fmeta in enumerate(files_meta):
|
|
352
403
|
fsize = fmeta.get("size", 0)
|
|
353
404
|
total_bytes += fsize
|
|
354
|
-
|
|
355
|
-
if
|
|
356
|
-
|
|
357
|
-
|
|
405
|
+
n = transfer["num_chunks"].get(i)
|
|
406
|
+
if n:
|
|
407
|
+
slots = transfer["file_data"].get(i, [])
|
|
408
|
+
received = sum(1 for s in slots if s is not None)
|
|
409
|
+
transferred_bytes += fsize * received // n
|
|
358
410
|
|
|
359
411
|
progress_info = {
|
|
360
412
|
"transfer_id": tid,
|
|
361
413
|
"file_index": fi,
|
|
362
|
-
"chunk_index":
|
|
363
|
-
"num_chunks":
|
|
414
|
+
"chunk_index": ci,
|
|
415
|
+
"num_chunks": nc,
|
|
364
416
|
"transferred_bytes": transferred_bytes,
|
|
365
417
|
"total_bytes": total_bytes,
|
|
366
418
|
"complete": all_complete,
|
|
@@ -376,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
|
-
|
|
380
|
-
|
|
431
|
+
slots = transfer["file_data"].get(i, [])
|
|
432
|
+
file_meta["data"] = b"".join(slots)
|
|
381
433
|
del self._transfers[tid]
|
|
382
434
|
self._dispatch_callback(
|
|
383
435
|
transfer["wid"], action, payload)
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
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,20 +1,21 @@
|
|
|
1
|
-
pgwidgets/__init__.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
15
|
+
pgwidgets/sync/application.py,sha256=0xZDEywx7L7vUe1QAs29iufIqqvmSzOLeu9QQIGiSj0,87160
|
|
15
16
|
pgwidgets/sync/widget.py,sha256=tQDkCOkcevDrGnG4LhBf69law82-UB1fF-r08GUZPSE,37913
|
|
16
|
-
pgwidgets_python-0.
|
|
17
|
-
pgwidgets_python-0.
|
|
18
|
-
pgwidgets_python-0.
|
|
19
|
-
pgwidgets_python-0.
|
|
20
|
-
pgwidgets_python-0.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|