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 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:
@@ -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 == "file-chunk":
220
- self._handle_file_chunk(msg)
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 _handle_file_chunk(self, msg):
248
- """Handle a file-chunk message: buffer data and fire callbacks."""
249
- tid = msg["transfer_id"]
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 = msg["file_index"]
255
- fc = msg["file_count"]
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] = msg["num_chunks"]
259
- transfer["file_data"][fi].append(msg["data"])
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
- len(transfer["file_data"][i]) >= transfer["num_chunks"][i]
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
- nc = transfer["num_chunks"].get(i)
278
- if nc:
279
- received = len(transfer["file_data"].get(i, []))
280
- transferred_bytes += fsize * received // nc
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": msg["chunk_index"],
286
- "num_chunks": msg["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
- file_meta["data"] = "".join(
303
- transfer["file_data"].get(i, []))
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 so the bytes go in a raw frame, not embedded
1083
- # as base64 in JSON.
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._send_binary(widget._wid, method_name, [fmt], data)
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
- self._ws_port = ws_port
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
- if self._on_connect:
1506
- result = self._on_connect(session)
1507
- if hasattr(result, "__await__"):
1508
- asyncio.ensure_future(result)
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._ws_server = await websockets.serve(
1656
- self._ws_handler, self._host, self._ws_port)
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.
@@ -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
- break
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)
@@ -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()