pgwidgets-python 0.2.3__py3-none-any.whl → 0.3.1__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 +295 -31
- pgwidgets/async_/widget.py +9 -1
- pgwidgets/buffer.py +125 -0
- pgwidgets/extras/file_browser.py +7 -2
- pgwidgets/method_types.py +45 -2
- pgwidgets/sync/application.py +378 -30
- pgwidgets/sync/widget.py +9 -1
- {pgwidgets_python-0.2.3.dist-info → pgwidgets_python-0.3.1.dist-info}/METADATA +1 -1
- pgwidgets_python-0.3.1.dist-info/RECORD +21 -0
- pgwidgets_python-0.2.3.dist-info/RECORD +0 -20
- {pgwidgets_python-0.2.3.dist-info → pgwidgets_python-0.3.1.dist-info}/WHEEL +0 -0
- {pgwidgets_python-0.2.3.dist-info → pgwidgets_python-0.3.1.dist-info}/licenses/LICENSE.md +0 -0
- {pgwidgets_python-0.2.3.dist-info → pgwidgets_python-0.3.1.dist-info}/top_level.txt +0 -0
pgwidgets/__init__.py
CHANGED
pgwidgets/async_/application.py
CHANGED
|
@@ -16,6 +16,7 @@ import logging
|
|
|
16
16
|
import mimetypes
|
|
17
17
|
import signal
|
|
18
18
|
import secrets
|
|
19
|
+
import threading
|
|
19
20
|
import traceback
|
|
20
21
|
from http.server import SimpleHTTPRequestHandler
|
|
21
22
|
from pathlib import Path
|
|
@@ -30,13 +31,22 @@ from pgwidgets.method_types import (
|
|
|
30
31
|
STATE_SYNC_CALLBACKS, STATE_SYNC_REQUIRES_OPTION,
|
|
31
32
|
WIDGET_CALLBACK_SYNC, POST_CHILDREN_STATE_KEYS, ITEM_LIST_CONFIG,
|
|
32
33
|
CHILD_CLOSE_CALLBACKS, REPLAY_METHODS, TREE_VIEW_WIDGETS,
|
|
33
|
-
BINARY_STATE_KEYS,
|
|
34
|
+
BINARY_STATE_KEYS, _send_binary_auto,
|
|
34
35
|
)
|
|
35
36
|
from pgwidgets.async_.widget import Widget, build_all_widget_classes
|
|
36
37
|
|
|
37
38
|
_CONCURRENCY_MODES = ("serialized", "per_session", "concurrent")
|
|
38
39
|
|
|
39
40
|
|
|
41
|
+
# MIME types for the font formats register_font accepts.
|
|
42
|
+
_FONT_MIME = {
|
|
43
|
+
".ttf": "font/ttf",
|
|
44
|
+
".otf": "font/otf",
|
|
45
|
+
".woff": "font/woff",
|
|
46
|
+
".woff2": "font/woff2",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
40
50
|
class _Namespace:
|
|
41
51
|
"""Holds widget factory methods as attributes (W.Button, W.Label, etc.)."""
|
|
42
52
|
pass
|
|
@@ -125,6 +135,9 @@ class Session:
|
|
|
125
135
|
|
|
126
136
|
self._widget_classes = app._widget_classes
|
|
127
137
|
self._transfers = {} # transfer_id -> transfer state dict
|
|
138
|
+
# FIFO of binary-chunk JSON headers (encoding="binary") still
|
|
139
|
+
# awaiting their paired raw binary frame.
|
|
140
|
+
self._pending_binary_headers = []
|
|
128
141
|
self._callback_source_ws = None # ws that sent current callback
|
|
129
142
|
|
|
130
143
|
self._reconstructing = False # suppress callbacks during reconstruction
|
|
@@ -194,6 +207,19 @@ class Session:
|
|
|
194
207
|
# -- Message handling --
|
|
195
208
|
|
|
196
209
|
def _handle_message(self, data):
|
|
210
|
+
# Raw binary frames pair with the head of the binary-chunk
|
|
211
|
+
# JSON header FIFO (encoding="binary"). See the sync
|
|
212
|
+
# equivalent for the rationale.
|
|
213
|
+
if isinstance(data, (bytes, bytearray, memoryview)):
|
|
214
|
+
queue = self._pending_binary_headers
|
|
215
|
+
if not queue:
|
|
216
|
+
self._logger.warning(
|
|
217
|
+
"Session %s: unexpected binary frame with no "
|
|
218
|
+
"queued header (ignored).", self.id)
|
|
219
|
+
return
|
|
220
|
+
header = queue.pop(0)
|
|
221
|
+
self._handle_binary_chunk(header, bytes(data))
|
|
222
|
+
return
|
|
197
223
|
msg = json.loads(data)
|
|
198
224
|
if isinstance(msg, list):
|
|
199
225
|
for m in msg:
|
|
@@ -216,8 +242,18 @@ class Session:
|
|
|
216
242
|
elif msg_type == "viewport":
|
|
217
243
|
self._screen_size = (msg.get("width", 0), msg.get("height", 0))
|
|
218
244
|
|
|
219
|
-
elif msg_type == "
|
|
220
|
-
|
|
245
|
+
elif msg_type == "binary-chunk":
|
|
246
|
+
encoding = msg.get("encoding", "binary")
|
|
247
|
+
if encoding == "binary":
|
|
248
|
+
self._pending_binary_headers.append(msg)
|
|
249
|
+
elif encoding == "base64":
|
|
250
|
+
import base64 as _b64
|
|
251
|
+
payload = _b64.b64decode(msg.get("data") or "")
|
|
252
|
+
self._handle_binary_chunk(msg, payload)
|
|
253
|
+
else:
|
|
254
|
+
self._logger.warning(
|
|
255
|
+
"Session %s: unknown binary-chunk encoding %r "
|
|
256
|
+
"(ignored).", self.id, encoding)
|
|
221
257
|
|
|
222
258
|
elif msg_type == "callback":
|
|
223
259
|
# If the payload has a transfer_id, stash the metadata —
|
|
@@ -244,25 +280,40 @@ class Session:
|
|
|
244
280
|
self._dispatch_callback(
|
|
245
281
|
msg["wid"], msg["action"], *msg.get("args", []))
|
|
246
282
|
|
|
247
|
-
def
|
|
248
|
-
"""
|
|
249
|
-
|
|
283
|
+
def _handle_binary_chunk(self, header, data):
|
|
284
|
+
"""Buffer one chunk of an in-flight transfer.
|
|
285
|
+
|
|
286
|
+
``header`` is the parsed binary-chunk JSON; ``data`` is the
|
|
287
|
+
chunk's raw bytes (paired binary frame, or decoded inline
|
|
288
|
+
base64). See the sync equivalent for the full description.
|
|
289
|
+
"""
|
|
290
|
+
tid = header["transfer_id"]
|
|
250
291
|
transfer = self._transfers.get(tid)
|
|
251
292
|
if transfer is None:
|
|
252
293
|
return
|
|
253
294
|
|
|
254
|
-
fi =
|
|
255
|
-
fc =
|
|
295
|
+
fi = header.get("file_index", 0)
|
|
296
|
+
fc = header.get("file_count", 1)
|
|
297
|
+
ci = header["chunk_index"]
|
|
298
|
+
nc = header["num_chunks"]
|
|
256
299
|
if fi not in transfer["file_data"]:
|
|
257
|
-
transfer["file_data"][fi] = []
|
|
258
|
-
transfer["num_chunks"][fi] =
|
|
259
|
-
transfer["file_data"][fi]
|
|
300
|
+
transfer["file_data"][fi] = [None] * nc
|
|
301
|
+
transfer["num_chunks"][fi] = nc
|
|
302
|
+
slot = transfer["file_data"][fi]
|
|
303
|
+
if 0 <= ci < len(slot):
|
|
304
|
+
slot[ci] = data
|
|
305
|
+
else:
|
|
306
|
+
self._logger.warning(
|
|
307
|
+
"Session %s: chunk_index %d out of range for "
|
|
308
|
+
"transfer_id %s (num_chunks=%d).",
|
|
309
|
+
self.id, ci, tid, nc)
|
|
310
|
+
return
|
|
260
311
|
|
|
261
312
|
# Check if all files have received all their chunks.
|
|
262
313
|
all_complete = (
|
|
263
314
|
len(transfer["num_chunks"]) == fc
|
|
264
315
|
and all(
|
|
265
|
-
|
|
316
|
+
None not in transfer["file_data"][i]
|
|
266
317
|
for i in range(fc)
|
|
267
318
|
)
|
|
268
319
|
)
|
|
@@ -274,16 +325,17 @@ class Session:
|
|
|
274
325
|
for i, fmeta in enumerate(files_meta):
|
|
275
326
|
fsize = fmeta.get("size", 0)
|
|
276
327
|
total_bytes += fsize
|
|
277
|
-
|
|
278
|
-
if
|
|
279
|
-
|
|
280
|
-
|
|
328
|
+
n = transfer["num_chunks"].get(i)
|
|
329
|
+
if n:
|
|
330
|
+
slots = transfer["file_data"].get(i, [])
|
|
331
|
+
received = sum(1 for s in slots if s is not None)
|
|
332
|
+
transferred_bytes += fsize * received // n
|
|
281
333
|
|
|
282
334
|
progress_info = {
|
|
283
335
|
"transfer_id": tid,
|
|
284
336
|
"file_index": fi,
|
|
285
|
-
"chunk_index":
|
|
286
|
-
"num_chunks":
|
|
337
|
+
"chunk_index": ci,
|
|
338
|
+
"num_chunks": nc,
|
|
287
339
|
"transferred_bytes": transferred_bytes,
|
|
288
340
|
"total_bytes": total_bytes,
|
|
289
341
|
"complete": all_complete,
|
|
@@ -299,8 +351,8 @@ class Session:
|
|
|
299
351
|
# Reassemble file data and fire the original callback.
|
|
300
352
|
payload = transfer["payload"]
|
|
301
353
|
for i, file_meta in enumerate(payload["files"]):
|
|
302
|
-
|
|
303
|
-
|
|
354
|
+
slots = transfer["file_data"].get(i, [])
|
|
355
|
+
file_meta["data"] = b"".join(slots)
|
|
304
356
|
del self._transfers[tid]
|
|
305
357
|
self._dispatch_callback(
|
|
306
358
|
transfer["wid"], action, payload)
|
|
@@ -662,6 +714,69 @@ class Session:
|
|
|
662
714
|
task = asyncio.ensure_future(_pair(ws))
|
|
663
715
|
task.add_done_callback(_drain_send_exception)
|
|
664
716
|
|
|
717
|
+
def _send_binary_chunked(self, wid, method, args, data,
|
|
718
|
+
chunk_size=512 * 1024,
|
|
719
|
+
shape=None, dtype=None):
|
|
720
|
+
"""Fire-and-forget chunked binary call (async variant).
|
|
721
|
+
|
|
722
|
+
See the sync :meth:`Session._send_binary_chunked` for the full
|
|
723
|
+
description, including the optional ``shape``/``dtype`` that
|
|
724
|
+
promote the receiver's payload from a raw ``ArrayBuffer`` to
|
|
725
|
+
a typed array. Each connection's chunks are scheduled as a
|
|
726
|
+
single coroutine so they ship atomically.
|
|
727
|
+
"""
|
|
728
|
+
if not self._connections:
|
|
729
|
+
return
|
|
730
|
+
if not isinstance(data, (bytes, bytearray, memoryview)):
|
|
731
|
+
raise TypeError(
|
|
732
|
+
"_send_binary_chunked: data must be bytes-like, got "
|
|
733
|
+
+ type(data).__name__)
|
|
734
|
+
data = bytes(data)
|
|
735
|
+
n = len(data)
|
|
736
|
+
if chunk_size <= 0:
|
|
737
|
+
raise ValueError("chunk_size must be positive")
|
|
738
|
+
num_chunks = max(1, (n + chunk_size - 1) // chunk_size)
|
|
739
|
+
msg_id = self._next_id
|
|
740
|
+
self._next_id += 1
|
|
741
|
+
transfer_id = self._next_id
|
|
742
|
+
self._next_id += 1
|
|
743
|
+
announce_obj = {
|
|
744
|
+
"type": "binary-call-chunked",
|
|
745
|
+
"id": msg_id,
|
|
746
|
+
"wid": wid,
|
|
747
|
+
"method": method,
|
|
748
|
+
"args": list(args),
|
|
749
|
+
"transfer_id": transfer_id,
|
|
750
|
+
"num_chunks": num_chunks,
|
|
751
|
+
}
|
|
752
|
+
if shape is not None:
|
|
753
|
+
announce_obj["shape"] = list(shape)
|
|
754
|
+
if dtype is not None:
|
|
755
|
+
announce_obj["dtype"] = dtype
|
|
756
|
+
announce = json.dumps(announce_obj, cls=JsonEncoder)
|
|
757
|
+
pairs = []
|
|
758
|
+
for ci in range(num_chunks):
|
|
759
|
+
start = ci * chunk_size
|
|
760
|
+
end = min(start + chunk_size, n)
|
|
761
|
+
header = json.dumps({
|
|
762
|
+
"type": "binary-chunk",
|
|
763
|
+
"transfer_id": transfer_id,
|
|
764
|
+
"chunk_index": ci,
|
|
765
|
+
"num_chunks": num_chunks,
|
|
766
|
+
"encoding": "binary",
|
|
767
|
+
}, cls=JsonEncoder)
|
|
768
|
+
pairs.append((header, data[start:end]))
|
|
769
|
+
|
|
770
|
+
async def _send_all(ws):
|
|
771
|
+
await ws.send(announce)
|
|
772
|
+
for hdr, payload in pairs:
|
|
773
|
+
await ws.send(hdr)
|
|
774
|
+
await ws.send(payload)
|
|
775
|
+
|
|
776
|
+
for ws in self._connections:
|
|
777
|
+
task = asyncio.ensure_future(_send_all(ws))
|
|
778
|
+
task.add_done_callback(_drain_send_exception)
|
|
779
|
+
|
|
665
780
|
async def _listen(self, wid, action, handler):
|
|
666
781
|
"""Register a callback listener.
|
|
667
782
|
|
|
@@ -1079,12 +1194,13 @@ class Session:
|
|
|
1079
1194
|
continue
|
|
1080
1195
|
|
|
1081
1196
|
# Binary-payload state (e.g. set_binary_image) replays via
|
|
1082
|
-
# _send_binary
|
|
1083
|
-
#
|
|
1197
|
+
# _send_binary / _send_binary_chunked. Large payloads
|
|
1198
|
+
# switch to chunked transport automatically.
|
|
1084
1199
|
if key in BINARY_STATE_KEYS:
|
|
1085
1200
|
method_name = BINARY_STATE_KEYS[key]
|
|
1086
1201
|
fmt, data = value
|
|
1087
|
-
self
|
|
1202
|
+
_send_binary_auto(self, widget._wid, method_name,
|
|
1203
|
+
[fmt], data)
|
|
1088
1204
|
continue
|
|
1089
1205
|
|
|
1090
1206
|
if key in self._STATE_KEY_TO_SETTER:
|
|
@@ -1289,13 +1405,20 @@ class Application:
|
|
|
1289
1405
|
|
|
1290
1406
|
def __init__(self, ws_port=9500, http_port=9501, host="127.0.0.1",
|
|
1291
1407
|
http_server=True, concurrency_handling="per_session",
|
|
1292
|
-
max_sessions=1, logger=None):
|
|
1408
|
+
max_sessions=1, logger=None, ws_sock=None):
|
|
1293
1409
|
if concurrency_handling not in _CONCURRENCY_MODES:
|
|
1294
1410
|
raise ValueError(
|
|
1295
1411
|
f"concurrency_handling must be one of "
|
|
1296
1412
|
f"{_CONCURRENCY_MODES!r}, got {concurrency_handling!r}")
|
|
1297
1413
|
self._host = host
|
|
1298
|
-
|
|
1414
|
+
# ws_sock, if provided, is a bound TCP socket the WebSocket
|
|
1415
|
+
# server should adopt directly — see the sync :class:`Application`
|
|
1416
|
+
# for the rationale (TOCTOU-free port allocation).
|
|
1417
|
+
self._ws_sock = ws_sock
|
|
1418
|
+
if ws_sock is not None:
|
|
1419
|
+
self._ws_port = ws_sock.getsockname()[1]
|
|
1420
|
+
else:
|
|
1421
|
+
self._ws_port = ws_port
|
|
1299
1422
|
self._http_port = http_port
|
|
1300
1423
|
self._use_http_server = http_server
|
|
1301
1424
|
self._concurrency = concurrency_handling
|
|
@@ -1315,6 +1438,13 @@ class Application:
|
|
|
1315
1438
|
self._session_semaphore = None # initialized in start()
|
|
1316
1439
|
self._cb_lock = None # for "serialized" mode
|
|
1317
1440
|
|
|
1441
|
+
# Custom-font registry — see sync.application for details.
|
|
1442
|
+
self._fonts = []
|
|
1443
|
+
self._fonts_by_id = {}
|
|
1444
|
+
self._next_font_id = 1
|
|
1445
|
+
self._default_font = None
|
|
1446
|
+
self._font_lock = threading.Lock()
|
|
1447
|
+
|
|
1318
1448
|
self._run_future = None # set in run(), cancelled by close()
|
|
1319
1449
|
self._httpd = None # HTTP server instance
|
|
1320
1450
|
|
|
@@ -1380,6 +1510,104 @@ class Application:
|
|
|
1380
1510
|
self._widget_classes[name] = cls
|
|
1381
1511
|
return cls
|
|
1382
1512
|
|
|
1513
|
+
# ----- Custom font registration ---------------------------
|
|
1514
|
+
#
|
|
1515
|
+
# API matches the sync backend; see ``sync.application`` for
|
|
1516
|
+
# full docstrings. The async variant schedules sends on the
|
|
1517
|
+
# event loop via ``asyncio.run_coroutine_threadsafe`` so the
|
|
1518
|
+
# method is safe to call from outside the loop (e.g. from the
|
|
1519
|
+
# ``on_connect`` callback running on a worker thread).
|
|
1520
|
+
|
|
1521
|
+
def register_font(self, family, source, *,
|
|
1522
|
+
weight="normal", style="normal"):
|
|
1523
|
+
if isinstance(source, (bytes, bytearray, memoryview)):
|
|
1524
|
+
data = bytes(source)
|
|
1525
|
+
mime = "font/ttf"
|
|
1526
|
+
else:
|
|
1527
|
+
p = Path(source)
|
|
1528
|
+
data = p.read_bytes()
|
|
1529
|
+
mime = _FONT_MIME.get(p.suffix.lower(), "font/ttf")
|
|
1530
|
+
with self._font_lock:
|
|
1531
|
+
font_id = self._next_font_id
|
|
1532
|
+
self._next_font_id += 1
|
|
1533
|
+
entry = {
|
|
1534
|
+
"id": font_id,
|
|
1535
|
+
"family": str(family),
|
|
1536
|
+
"weight": str(weight),
|
|
1537
|
+
"style": str(style),
|
|
1538
|
+
"bytes": data,
|
|
1539
|
+
"mime": mime,
|
|
1540
|
+
}
|
|
1541
|
+
self._fonts.append(entry)
|
|
1542
|
+
self._fonts_by_id[font_id] = entry
|
|
1543
|
+
msg = self._font_register_msg(entry)
|
|
1544
|
+
self._broadcast_font_msg(msg)
|
|
1545
|
+
return font_id
|
|
1546
|
+
|
|
1547
|
+
def set_default_font(self, family, *, size=None,
|
|
1548
|
+
weight=None, style=None):
|
|
1549
|
+
if family is None:
|
|
1550
|
+
self._default_font = None
|
|
1551
|
+
else:
|
|
1552
|
+
self._default_font = {
|
|
1553
|
+
"family": str(family),
|
|
1554
|
+
"size": None if size is None else float(size),
|
|
1555
|
+
"weight": None if weight is None else str(weight),
|
|
1556
|
+
"style": None if style is None else str(style),
|
|
1557
|
+
}
|
|
1558
|
+
self._broadcast_font_msg(self._font_default_msg())
|
|
1559
|
+
|
|
1560
|
+
def _font_register_msg(self, entry):
|
|
1561
|
+
return {
|
|
1562
|
+
"type": "register-font",
|
|
1563
|
+
"id": entry["id"],
|
|
1564
|
+
"family": entry["family"],
|
|
1565
|
+
"weight": entry["weight"],
|
|
1566
|
+
"style": entry["style"],
|
|
1567
|
+
"url": f"/_pgwidgets/font/{entry['id']}",
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
def _font_default_msg(self):
|
|
1571
|
+
return {
|
|
1572
|
+
"type": "set-default-font",
|
|
1573
|
+
"font": self._default_font,
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
def _broadcast_font_msg(self, msg):
|
|
1577
|
+
loop = getattr(self, "_loop", None) or asyncio.get_event_loop()
|
|
1578
|
+
for session in list(self._sessions.values()):
|
|
1579
|
+
try:
|
|
1580
|
+
asyncio.run_coroutine_threadsafe(
|
|
1581
|
+
session._send(dict(msg)), loop)
|
|
1582
|
+
except Exception:
|
|
1583
|
+
pass
|
|
1584
|
+
|
|
1585
|
+
async def _replay_fonts_to_session(self, session):
|
|
1586
|
+
"""Push the registry + default font to a session before
|
|
1587
|
+
any user code runs. ``await``-ed from ``_on_session_open``
|
|
1588
|
+
so the JS side has loaded faces (or at least dispatched
|
|
1589
|
+
the load) before reconstruct / on_connect fires."""
|
|
1590
|
+
with self._font_lock:
|
|
1591
|
+
fonts = list(self._fonts)
|
|
1592
|
+
default = self._default_font
|
|
1593
|
+
for entry in fonts:
|
|
1594
|
+
try:
|
|
1595
|
+
await session._send(self._font_register_msg(entry))
|
|
1596
|
+
except Exception:
|
|
1597
|
+
pass
|
|
1598
|
+
if default is not None:
|
|
1599
|
+
try:
|
|
1600
|
+
await session._send(self._font_default_msg())
|
|
1601
|
+
except Exception:
|
|
1602
|
+
pass
|
|
1603
|
+
|
|
1604
|
+
def _get_font_bytes(self, font_id):
|
|
1605
|
+
with self._font_lock:
|
|
1606
|
+
entry = self._fonts_by_id.get(font_id)
|
|
1607
|
+
if entry is None:
|
|
1608
|
+
return None, None
|
|
1609
|
+
return entry["bytes"], entry["mime"]
|
|
1610
|
+
|
|
1383
1611
|
@property
|
|
1384
1612
|
def sessions(self):
|
|
1385
1613
|
"""Dict of active sessions (session_id -> Session)."""
|
|
@@ -1490,6 +1718,10 @@ class Application:
|
|
|
1490
1718
|
|
|
1491
1719
|
if is_reconnect:
|
|
1492
1720
|
async def do_reconstruct():
|
|
1721
|
+
# Replay the font registry before reconstruct() so
|
|
1722
|
+
# any widget reconstructed with ``set_font(...)``
|
|
1723
|
+
# finds the face already declared.
|
|
1724
|
+
await self._replay_fonts_to_session(session)
|
|
1493
1725
|
self._logger.info(
|
|
1494
1726
|
f"Session {session.id}: reconstructing UI.")
|
|
1495
1727
|
session._reconstructing = True
|
|
@@ -1502,10 +1734,16 @@ class Application:
|
|
|
1502
1734
|
asyncio.ensure_future(do_reconstruct())
|
|
1503
1735
|
else:
|
|
1504
1736
|
self._logger.info(f"Session {session.id} connected.")
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1737
|
+
async def do_connect():
|
|
1738
|
+
# Replay fonts before the user callback so any
|
|
1739
|
+
# widget the user builds with ``set_font(family,
|
|
1740
|
+
# ...)`` sees the face already declared.
|
|
1741
|
+
await self._replay_fonts_to_session(session)
|
|
1742
|
+
if self._on_connect:
|
|
1743
|
+
result = self._on_connect(session)
|
|
1744
|
+
if hasattr(result, "__await__"):
|
|
1745
|
+
await result
|
|
1746
|
+
asyncio.ensure_future(do_connect())
|
|
1509
1747
|
|
|
1510
1748
|
try:
|
|
1511
1749
|
async for message in ws:
|
|
@@ -1586,6 +1824,7 @@ class Application:
|
|
|
1586
1824
|
favicon_path = self._favicon_path
|
|
1587
1825
|
ws_host = self._host
|
|
1588
1826
|
ws_port = self._ws_port
|
|
1827
|
+
app = self
|
|
1589
1828
|
|
|
1590
1829
|
class Handler(SimpleHTTPRequestHandler):
|
|
1591
1830
|
def __init__(self, *a, **kw):
|
|
@@ -1594,6 +1833,27 @@ class Application:
|
|
|
1594
1833
|
def do_GET(self):
|
|
1595
1834
|
# Strip query string for path matching (e.g. /?session=1)
|
|
1596
1835
|
path = self.path.split("?")[0]
|
|
1836
|
+
# Custom-font registry: see sync.application for
|
|
1837
|
+
# the matching implementation.
|
|
1838
|
+
if path.startswith("/_pgwidgets/font/"):
|
|
1839
|
+
try:
|
|
1840
|
+
font_id = int(path.rsplit("/", 1)[-1])
|
|
1841
|
+
except ValueError:
|
|
1842
|
+
self.send_error(404)
|
|
1843
|
+
return
|
|
1844
|
+
data, mime = app._get_font_bytes(font_id)
|
|
1845
|
+
if data is None:
|
|
1846
|
+
self.send_error(404)
|
|
1847
|
+
return
|
|
1848
|
+
self.send_response(200)
|
|
1849
|
+
self.send_header("Content-Type", mime)
|
|
1850
|
+
self.send_header("Content-Length", str(len(data)))
|
|
1851
|
+
self.send_header(
|
|
1852
|
+
"Cache-Control", "public, max-age=31536000, immutable")
|
|
1853
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
1854
|
+
self.end_headers()
|
|
1855
|
+
self.wfile.write(data)
|
|
1856
|
+
return
|
|
1597
1857
|
if path == "/" or path == "/index.html":
|
|
1598
1858
|
html = remote_html.read_text(encoding="utf-8")
|
|
1599
1859
|
inject = (
|
|
@@ -1652,8 +1912,12 @@ class Application:
|
|
|
1652
1912
|
self._logger.info(
|
|
1653
1913
|
f"WebSocket on ws://{self._host}:{self._ws_port}")
|
|
1654
1914
|
|
|
1655
|
-
self.
|
|
1656
|
-
self.
|
|
1915
|
+
if self._ws_sock is not None:
|
|
1916
|
+
self._ws_server = await websockets.serve(
|
|
1917
|
+
self._ws_handler, sock=self._ws_sock)
|
|
1918
|
+
else:
|
|
1919
|
+
self._ws_server = await websockets.serve(
|
|
1920
|
+
self._ws_handler, self._host, self._ws_port)
|
|
1657
1921
|
|
|
1658
1922
|
async def close(self):
|
|
1659
1923
|
"""Close all sessions and shut down the application.
|
pgwidgets/async_/widget.py
CHANGED
|
@@ -446,6 +446,11 @@ def _resolve_kwargs(method_name, param_names, args, kwargs):
|
|
|
446
446
|
are bundled into a dict for that parameter (e.g.
|
|
447
447
|
``add_widget(child, title="Tab 1")`` becomes
|
|
448
448
|
``add_widget(child, {"title": "Tab 1"})``).
|
|
449
|
+
|
|
450
|
+
Skipped-positional kwargs are supported: a call like
|
|
451
|
+
``set_color(fg='red')`` against ``param_names = ['bg', 'fg']``
|
|
452
|
+
fills the omitted ``bg`` slot with ``None`` (the JS-side
|
|
453
|
+
default) instead of erroring out.
|
|
449
454
|
"""
|
|
450
455
|
if not kwargs:
|
|
451
456
|
return args
|
|
@@ -456,7 +461,10 @@ def _resolve_kwargs(method_name, param_names, args, kwargs):
|
|
|
456
461
|
if name in kwargs:
|
|
457
462
|
merged.append(kwargs.pop(name))
|
|
458
463
|
else:
|
|
459
|
-
|
|
464
|
+
# Leave a placeholder so subsequent kwargs can land in
|
|
465
|
+
# later positions. The JS side reads omitted args as
|
|
466
|
+
# null / default, which matches ``None`` here.
|
|
467
|
+
merged.append(None)
|
|
460
468
|
if kwargs and param_names and param_names[-1] == "options":
|
|
461
469
|
# Bundle remaining kwargs into the options dict
|
|
462
470
|
opts_idx = len(param_names) - 1
|
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/extras/file_browser.py
CHANGED
|
@@ -352,8 +352,13 @@ class FileBrowser(Callbacks):
|
|
|
352
352
|
self._navigate_to(d)
|
|
353
353
|
self._name_entry.set_text(os.path.basename(path))
|
|
354
354
|
|
|
355
|
-
def _on_row_activated(self, values, path):
|
|
356
|
-
"""Double-click on a row.
|
|
355
|
+
def _on_row_activated(self, values, path, col_key=None):
|
|
356
|
+
"""Double-click on a row.
|
|
357
|
+
|
|
358
|
+
``col_key`` (TableView ≥ this rev) reports which cell was
|
|
359
|
+
clicked; unused here since the browser only cares about
|
|
360
|
+
the row's filename, but the arg has to be in the signature
|
|
361
|
+
or the new 3-arg dispatch raises TypeError."""
|
|
357
362
|
name = values.get("name", "")
|
|
358
363
|
if name == "..":
|
|
359
364
|
self._go_up()
|