pgwidgets-python 0.2.0__py3-none-any.whl → 0.2.3__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
@@ -16,4 +16,17 @@ Usage (asynchronous):
16
16
  top = await W.TopLevel(title="Hello", resizable=True)
17
17
  ...
18
18
  await app.run()
19
+
20
+ Version:
21
+ import pgwidgets
22
+ print(pgwidgets.__version__)
19
23
  """
24
+
25
+ from importlib.metadata import version as _pkg_version, PackageNotFoundError
26
+
27
+ try:
28
+ __version__ = _pkg_version("pgwidgets")
29
+ except PackageNotFoundError:
30
+ # Package not installed (e.g. running from a source checkout
31
+ # without `pip install -e .`). Fall back to a sentinel.
32
+ __version__ = "0.0.0+unknown"
pgwidgets/_json.py ADDED
@@ -0,0 +1,63 @@
1
+ """JSON encoding helper for the remote protocol.
2
+
3
+ The wire format between Python and the browser is JSON. The stdlib
4
+ encoder has two issues for scientific data:
5
+
6
+ 1. It rejects numpy scalars other than ``np.float64`` (which inherits
7
+ from float), numpy arrays, and similar buffer-protocol objects —
8
+ silently dropping a TreeView payload that happens to contain
9
+ ``np.int64`` cell values.
10
+
11
+ 2. It writes the literal tokens ``NaN`` / ``Infinity`` / ``-Infinity``
12
+ for non-finite floats, which browsers' ``JSON.parse`` reject — a
13
+ single masked / missing cell in a float column makes the whole
14
+ payload silently fail to parse on the JS side.
15
+
16
+ ``JsonEncoder`` handles both: ``.item()`` / ``.tolist()`` fall-backs
17
+ for numpy/pandas types, and a pre-walk that replaces non-finite floats
18
+ with ``None`` (encoded as JSON ``null``). Use it via
19
+ ``json.dumps(obj, cls=JsonEncoder)``.
20
+ """
21
+
22
+ import json
23
+ import math
24
+
25
+
26
+ def _scrub_nan(obj):
27
+ """Recursively replace non-finite floats with None so the result
28
+ is RFC-8259-valid JSON when re-encoded. Cheap walk: O(n) and
29
+ only visits dicts/lists/tuples/floats."""
30
+ if isinstance(obj, float):
31
+ return obj if math.isfinite(obj) else None
32
+ if isinstance(obj, dict):
33
+ return {k: _scrub_nan(v) for k, v in obj.items()}
34
+ if isinstance(obj, (list, tuple)):
35
+ return [_scrub_nan(v) for v in obj]
36
+ return obj
37
+
38
+
39
+ def _coerce_scalar(v):
40
+ """If *v* is a float, replace non-finite values with None."""
41
+ if isinstance(v, float) and not math.isfinite(v):
42
+ return None
43
+ return v
44
+
45
+
46
+ class JsonEncoder(json.JSONEncoder):
47
+ def default(self, obj):
48
+ item = getattr(obj, "item", None)
49
+ if callable(item):
50
+ try:
51
+ return _coerce_scalar(item())
52
+ except (TypeError, ValueError):
53
+ pass
54
+ tolist = getattr(obj, "tolist", None)
55
+ if callable(tolist):
56
+ try:
57
+ return _scrub_nan(tolist())
58
+ except (TypeError, ValueError):
59
+ pass
60
+ return super().default(obj)
61
+
62
+ def iterencode(self, o, _one_shot=False):
63
+ return super().iterencode(_scrub_nan(o), _one_shot=_one_shot)
@@ -23,6 +23,7 @@ from pathlib import Path
23
23
  import websockets
24
24
 
25
25
  from pgwidgets_js import get_static_path, get_remote_html
26
+ from pgwidgets._json import JsonEncoder
26
27
  from pgwidgets.defs import WIDGETS
27
28
  from pgwidgets.method_types import (
28
29
  SPECIAL_SETTERS, FIXED_SETTERS, CHILD_METHODS as CHILD_METHOD_NAMES,
@@ -90,6 +91,13 @@ class Session:
90
91
  _STATE_KEY_TO_SETTER = {v: k for k, v in SPECIAL_SETTERS.items()}
91
92
  # e.g. {"size": "resize"}
92
93
 
94
+ # Reverse map: state_key -> callback action that auto-syncs it
95
+ # (e.g. "size" -> "resize", "position" -> "move"). Used during
96
+ # state replay to skip keys that weren't actively opted into via
97
+ # _auto_sync_actions — those were captured passively for getter
98
+ # support but must not be replayed (would pin layout).
99
+ _STATE_KEY_TO_SYNC_ACTION = {v: k for k, v in STATE_SYNC_CALLBACKS.items()}
100
+
93
101
  # State keys handled by fixed-value methods (show/hide)
94
102
  _FIXED_STATE_KEYS = {}
95
103
  for _mname, (_key, _val) in FIXED_SETTERS.items():
@@ -299,30 +307,46 @@ class Session:
299
307
 
300
308
  def _dispatch_callback(self, wid, action, *args):
301
309
  """Dispatch a callback through the configured concurrency mode."""
302
- if self._reconstructing:
303
- return # suppress callbacks during reconstruction
310
+ # Suppress callbacks during reconstruction — they are side
311
+ # effects of state replay, not user actions. Exception: 'map'
312
+ # is a one-shot lifecycle event that the JS-side observers fire
313
+ # when a widget first gains a visible layout box; the timing
314
+ # can land inside the reconstruction window, and dropping it
315
+ # means the user's map handler never runs until a later layout
316
+ # change (e.g. window resize) triggers a re-fire.
317
+ if self._reconstructing and action != 'map':
318
+ return
304
319
 
305
320
  # Auto-sync: some callbacks carry state that should be reflected
306
321
  # in the Python-side widget (e.g. move -> position, resize -> size).
322
+ # We always *capture* the value so get_size()/get_position() return
323
+ # current values, but only *push* it to other browsers (and replay
324
+ # it on reconstruction) when the widget opted in via auto-sync.
325
+ # Otherwise a layout-determined size would replay as a literal
326
+ # resize(W, H) — pinning the widget to pixel dimensions and
327
+ # killing flex growth.
307
328
  state_key = STATE_SYNC_CALLBACKS.get(action)
308
329
  if state_key is not None:
309
330
  widget = self._widget_map.get(wid)
310
331
  if widget is not None:
332
+ auto = action in widget._auto_sync_actions
311
333
  if len(args) == 1 and isinstance(args[0], dict):
312
334
  d = args[0]
313
335
  if "width" in d and "height" in d:
314
336
  new_val = (d["width"], d["height"])
315
337
  if widget._state.get(state_key) != new_val:
316
338
  widget._state[state_key] = new_val
317
- self._push(wid, "resize",
318
- d["width"], d["height"])
339
+ if auto:
340
+ self._push(wid, "resize",
341
+ d["width"], d["height"])
319
342
  else:
320
343
  new_val = tuple(args)
321
344
  if widget._state.get(state_key) != new_val:
322
345
  widget._state[state_key] = new_val
323
- setter = (self._STATE_KEY_TO_SETTER.get(state_key)
324
- or f"set_{state_key}")
325
- self._push(wid, setter, *args)
346
+ if auto:
347
+ setter = (self._STATE_KEY_TO_SETTER.get(state_key)
348
+ or f"set_{state_key}")
349
+ self._push(wid, setter, *args)
326
350
  # If this widget wraps a child (e.g. MDISubWindow),
327
351
  # propagate geometry into the parent's children options
328
352
  # so reconstruction replays with the current pos/size.
@@ -492,7 +516,7 @@ class Session:
492
516
  self._next_id += 1
493
517
  msg["id"] = msg_id
494
518
  try:
495
- payload = json.dumps(msg)
519
+ payload = json.dumps(msg, cls=JsonEncoder)
496
520
  except Exception as e:
497
521
  self._logger.error(
498
522
  f"JSON encode failed for "
@@ -510,7 +534,7 @@ class Session:
510
534
  ff_id = self._next_id
511
535
  self._next_id += 1
512
536
  msg_copy = dict(msg, id=ff_id)
513
- ff_payload = json.dumps(msg_copy)
537
+ ff_payload = json.dumps(msg_copy, cls=JsonEncoder)
514
538
  for ws in self._connections[1:]:
515
539
  _schedule_ws_send(ws, ff_payload)
516
540
  result = await future
@@ -541,7 +565,7 @@ class Session:
541
565
  "method": method,
542
566
  "args": list(args),
543
567
  "silent": True,
544
- })
568
+ }, cls=JsonEncoder)
545
569
  except Exception as e:
546
570
  self._logger.error(
547
571
  f"JSON encode failed for push {method} "
@@ -567,7 +591,7 @@ class Session:
567
591
  "id": msg_id,
568
592
  "wid": wid,
569
593
  "action": action,
570
- })
594
+ }, cls=JsonEncoder)
571
595
  for ws in self._connections:
572
596
  _schedule_ws_send(ws, payload)
573
597
 
@@ -630,7 +654,7 @@ class Session:
630
654
  "wid": wid,
631
655
  "method": method,
632
656
  "args": list(args),
633
- })
657
+ }, cls=JsonEncoder)
634
658
  async def _pair(ws):
635
659
  await ws.send(header)
636
660
  await ws.send(data)
@@ -734,17 +758,37 @@ class Session:
734
758
  cls = self._widget_classes.get(cls_name, Widget) if cls_name else Widget
735
759
  widget = cls._from_existing(self, wid, cls_name or "Widget")
736
760
  self._widget_map[wid] = widget
737
- # Auto-listen for state-syncing callbacks (move, resize)
738
- # so position/size changes are tracked for reconstruction.
761
+ # Auto-listen for state-syncing callbacks (move, resize).
739
762
  # Register locally (sync) and send the listen message as
740
763
  # true fire-and-forget (no result awaited) since
741
- # _resolve_return is not async.
764
+ # _resolve_return is not async. For visual widgets we
765
+ # always listen for 'resize' so get_size() can return a
766
+ # current value, but we only mark the action as
767
+ # auto-syncing (which triggers push-to-peers and
768
+ # replay-on-reconstruction) when the widget defn opts in.
769
+ # Non-visual Callback-base objects (Timer, TextBufferRef,
770
+ # …) get nothing.
771
+ defn = WIDGETS.get(cls_name, {}) if cls_name else {}
772
+ opt_names_set = set(defn.get("options", []))
773
+ all_callbacks = defn.get("callbacks", [])
774
+ is_visual = defn.get("base") != "callback"
742
775
  for action in STATE_SYNC_CALLBACKS:
743
- key = f"{wid}:{action}"
744
- if key not in self._callbacks:
745
- self._callbacks[key] = [lambda wid, *a: None]
746
- self._fire_and_forget_listen(wid, action)
747
- widget._auto_sync_actions.add(action)
776
+ req_opt = STATE_SYNC_REQUIRES_OPTION.get(action)
777
+ if req_opt is not None:
778
+ opted_in = req_opt in opt_names_set
779
+ else:
780
+ opted_in = action in all_callbacks
781
+ if not is_visual:
782
+ continue
783
+ if action == "resize" or opted_in:
784
+ key = f"{wid}:{action}"
785
+ if key not in self._callbacks:
786
+ self._callbacks[key] = [lambda wid, *a: None]
787
+ self._fire_and_forget_listen(wid, action)
788
+ if opted_in:
789
+ widget._auto_sync_actions.add(action)
790
+ elif action == "resize":
791
+ widget._passive_sync_actions.add(action)
748
792
  return widget
749
793
  if isinstance(val, list):
750
794
  return [self._resolve_return(v) for v in val]
@@ -838,12 +882,23 @@ class Session:
838
882
  await self._listen(new_widget._wid, act,
839
883
  lambda wid, *a: None)
840
884
  new_widget._auto_sync_actions.add(act)
841
- # Replay any state the proxy accumulated (e.g. set_tooltip)
885
+ # Replay any state the proxy accumulated (e.g. set_tooltip).
886
+ # Skip passively-captured auto-sync state (size, position): we
887
+ # capture those from callbacks so getters work, but replaying
888
+ # would pin the widget to layout-determined pixel dimensions
889
+ # (same logic as _reconstruct_widget).
890
+ user_set = getattr(old_widget, "_user_set_state", set())
891
+ auto = getattr(old_widget, "_auto_sync_actions", set())
842
892
  for key, value in old_widget._state.items():
843
893
  if key.startswith("_"):
844
894
  continue
845
895
  if key in sync_keys:
846
896
  continue
897
+ sync_action = self._STATE_KEY_TO_SYNC_ACTION.get(key)
898
+ if (sync_action is not None
899
+ and key not in user_set
900
+ and sync_action not in auto):
901
+ continue
847
902
  method_name = (self._STATE_KEY_TO_SETTER.get(key)
848
903
  or f"set_{key}")
849
904
  if isinstance(value, tuple):
@@ -851,6 +906,13 @@ class Session:
851
906
  else:
852
907
  await self._call(new_widget._wid, method_name, value)
853
908
  new_widget._state[key] = value
909
+ # Propagate user-set / auto-sync membership to the new
910
+ # widget so subsequent reconstructions replay consistently.
911
+ if sync_action is not None:
912
+ if key in user_set:
913
+ new_widget._user_set_state.add(key)
914
+ if sync_action in auto:
915
+ new_widget._auto_sync_actions.add(sync_action)
854
916
 
855
917
  async def _ensure_reconstructed(self, widget):
856
918
  """Ensure a widget has been created on the JS side."""
@@ -1002,6 +1064,20 @@ class Session:
1002
1064
  if key.startswith("_"):
1003
1065
  continue
1004
1066
 
1067
+ # Skip auto-sync state (size, position) that came in
1068
+ # passively via a callback (e.g. layout-determined size).
1069
+ # We capture those so getters like get_size()/
1070
+ # get_position() work, but replaying them would pin the
1071
+ # widget to pixel dimensions and override flex/expanding
1072
+ # layout. Replay only if the user explicitly set the
1073
+ # value, or if the widget opted into the sync action
1074
+ # (e.g. an interactively-resizable widget).
1075
+ sync_action = self._STATE_KEY_TO_SYNC_ACTION.get(key)
1076
+ if (sync_action is not None
1077
+ and key not in widget._user_set_state
1078
+ and sync_action not in widget._auto_sync_actions):
1079
+ continue
1080
+
1005
1081
  # Binary-payload state (e.g. set_binary_image) replays via
1006
1082
  # _send_binary so the bytes go in a raw frame, not embedded
1007
1083
  # as base64 in JSON.
@@ -1046,10 +1122,13 @@ class Session:
1046
1122
  # _listen calls treat them as first-time registrations and
1047
1123
  # actually send the "listen" message to the browser.
1048
1124
  wid = widget._wid
1125
+ passive = getattr(widget, "_passive_sync_actions", set())
1049
1126
  for action in list(saved_cbs.keys()):
1050
1127
  self._callbacks.pop(f"{wid}:{action}", None)
1051
1128
  for action in widget._auto_sync_actions:
1052
1129
  self._callbacks.pop(f"{wid}:{action}", None)
1130
+ for action in passive:
1131
+ self._callbacks.pop(f"{wid}:{action}", None)
1053
1132
 
1054
1133
  for action, entries in saved_cbs.items():
1055
1134
  for handler, extra_args, extra_kwargs, style in entries:
@@ -1066,6 +1145,13 @@ class Session:
1066
1145
  if action not in widget._registered_callbacks:
1067
1146
  await self._listen(widget._wid, action,
1068
1147
  lambda wid, *a: None)
1148
+ # Passive listeners (e.g. 'resize' for getter support on
1149
+ # widgets that didn't opt into auto-sync).
1150
+ for action in passive:
1151
+ if (action not in widget._registered_callbacks
1152
+ and action not in widget._auto_sync_actions):
1153
+ await self._listen(widget._wid, action,
1154
+ lambda wid, *a: None)
1069
1155
 
1070
1156
  async def reconstruct(self):
1071
1157
  """Replay the entire widget tree to all connected browsers.
@@ -1332,7 +1418,8 @@ class Application:
1332
1418
  async def _ws_handler(self, ws):
1333
1419
  # Init handshake: send init, receive ack which may contain
1334
1420
  # session_id + token for reconnection.
1335
- await ws.send(json.dumps({"type": "init", "id": 0}))
1421
+ await ws.send(json.dumps({"type": "init", "id": 0},
1422
+ cls=JsonEncoder))
1336
1423
  ack_data = await ws.recv()
1337
1424
  ack = json.loads(ack_data)
1338
1425
  reconnect_sid = ack.get("session_id")
@@ -1399,7 +1486,7 @@ class Application:
1399
1486
  "type": "session-info",
1400
1487
  "session_id": session.id,
1401
1488
  "token": session.token,
1402
- }))
1489
+ }, cls=JsonEncoder))
1403
1490
 
1404
1491
  if is_reconnect:
1405
1492
  async def do_reconstruct():
@@ -79,6 +79,16 @@ class Widget:
79
79
  self._constructor_options = {}
80
80
  self._registered_callbacks = {}
81
81
  self._auto_sync_actions = set()
82
+ # Actions we listen to passively for getter support
83
+ # (e.g. 'resize' on every visual widget so get_size() returns
84
+ # a current value), but that aren't in _auto_sync_actions and
85
+ # therefore don't push to peers or replay on reconstruction.
86
+ self._passive_sync_actions = set()
87
+ # State keys the user explicitly set via a setter call.
88
+ # Used during reconstruction to decide whether a state key
89
+ # should be replayed: passively-captured callback state
90
+ # (e.g. layout-determined size) is NOT in this set.
91
+ self._user_set_state = set()
82
92
  self._replay_calls = []
83
93
  self._add_seq = 0
84
94
 
@@ -196,6 +206,8 @@ class Widget:
196
206
  obj._constructor_options = {}
197
207
  obj._registered_callbacks = {}
198
208
  obj._auto_sync_actions = set()
209
+ obj._passive_sync_actions = set()
210
+ obj._user_set_state = set()
199
211
  obj._replay_calls = []
200
212
  obj._stale = False
201
213
  return obj
@@ -212,15 +224,31 @@ class Widget:
212
224
  opt_names_set = set(defn.get("options", []))
213
225
  all_callbacks = defn.get("callbacks", [])
214
226
 
215
- # State-sync callbacks (move -> position, resize -> size)
227
+ # State-sync callbacks (move -> position, resize -> size).
228
+ # For visual widgets we always *listen* so getters like
229
+ # get_size() / get_position() can return current values.
230
+ # But we only add the action to _auto_sync_actions — which
231
+ # controls push-to-peers and replay-on-reconstruction — when
232
+ # the widget actually opted in (e.g. via the 'resizable' option
233
+ # or by declaring the callback in its defn). This keeps
234
+ # layout-determined sizes from being replayed as literal
235
+ # resize() calls that would pin flex/expanding widgets.
236
+ is_visual = defn.get("base") != "callback"
216
237
  for action in STATE_SYNC_CALLBACKS:
217
238
  req_opt = STATE_SYNC_REQUIRES_OPTION.get(action)
218
- if req_opt and req_opt not in opt_names_set:
219
- continue
220
- if req_opt is None and action not in all_callbacks:
239
+ opted_in = False
240
+ if req_opt is not None:
241
+ opted_in = req_opt in opt_names_set
242
+ else:
243
+ opted_in = action in all_callbacks
244
+ if not is_visual:
221
245
  continue
222
- await session._listen(wid, action, lambda wid, *a: None)
223
- self._auto_sync_actions.add(action)
246
+ if action == "resize" or opted_in:
247
+ await session._listen(wid, action, lambda wid, *a: None)
248
+ if opted_in:
249
+ self._auto_sync_actions.add(action)
250
+ elif action == "resize":
251
+ self._passive_sync_actions.add(action)
224
252
 
225
253
  # Per-widget-class state sync (e.g. Slider "activated" -> value)
226
254
  cls_sync = WIDGET_CALLBACK_SYNC.get(js_class, {})
@@ -461,6 +489,9 @@ def _make_setter(method_name, param_names, state_key):
461
489
  self._state[state_key] = args[0]
462
490
  else:
463
491
  self._state[state_key] = args
492
+ # Mark as user-set so reconstruction knows to replay this key
493
+ # (callback-captured values for the same key don't get marked).
494
+ self._user_set_state.add(state_key)
464
495
  return await self._call(method_name, *args)
465
496
  method.__name__ = method_name
466
497
  method.__qualname__ = f"Widget.{method_name}"
@@ -473,6 +504,7 @@ def _make_fixed_setter(method_name, state_key, fixed_value):
473
504
  """Create a no-arg async method that sets a fixed state value (show/hide)."""
474
505
  async def method(self):
475
506
  self._state[state_key] = fixed_value
507
+ self._user_set_state.add(state_key)
476
508
  return await self._call(method_name)
477
509
  method.__name__ = method_name
478
510
  method.__qualname__ = f"Widget.{method_name}"
@@ -776,14 +808,19 @@ def _add_tree_view_methods(attrs, all_methods):
776
808
 
777
809
 
778
810
  def _init_params(pos_names, opt_names):
779
- """Build the parameter string for the generated __init__."""
811
+ """Build the parameter string for the generated __init__.
812
+
813
+ Options are exposed as positional-or-keyword (no ``*`` separator)
814
+ so that single-option widgets like Frame accept ``Frame("Title")``
815
+ in addition to ``Frame(title="Title")``, matching the pyodide
816
+ bridge's behavior. Each option still defaults to ``None`` so
817
+ omitted ones don't override state from prior calls.
818
+ """
780
819
  params = ["self", "session"]
781
820
  for name in pos_names:
782
821
  params.append(f"{name}=None")
783
- if opt_names:
784
- params.append("*")
785
- for name in opt_names:
786
- params.append(f"{name}=None")
822
+ for name in opt_names:
823
+ params.append(f"{name}=None")
787
824
  params.append("**kwargs")
788
825
  return ", ".join(params)
789
826
 
pgwidgets/method_types.py CHANGED
@@ -235,6 +235,10 @@ STATE_DEFAULTS = {
235
235
  "TabWidget": {"index": -1},
236
236
  "StackWidget": {"index": -1},
237
237
  "MDIWidget": {"index": -1},
238
+ # ScrollBar uses 1-arg set_scroll_percent / set_thumb_percent,
239
+ # so a scalar default is correct (not the (0.0, 0.0) tuple used
240
+ # by the 2-arg scroll widgets in STATE_KEY_DEFAULTS).
241
+ "ScrollBar": {"scroll_percent": 0.0, "thumb_percent": 0.0},
238
242
  }
239
243
 
240
244
  # Cross-widget default values for state keys, used as a fallback when
@@ -245,6 +249,17 @@ STATE_KEY_DEFAULTS = {
245
249
  "size": (0, 0),
246
250
  "position": (0, 0),
247
251
  "index": -1,
252
+ # Scroll widgets that take (h_pct, v_pct). ScrollBar overrides
253
+ # these in STATE_DEFAULTS with scalar 0.0.
254
+ "scroll_position": (0.0, 0.0),
255
+ "scroll_percent": (0.0, 0.0),
256
+ "thumb_percent": (0.0, 0.0),
257
+ # set_expanding(horizontal, vertical) → tuple of bools
258
+ "expanding": (False, False),
259
+ "enabled": True,
260
+ "state": False,
261
+ # HTMLMediaElement convention: 0.0 (muted) to 1.0 (full).
262
+ "volume": 1.0,
248
263
  }
249
264
 
250
265
  # Widgets with incrementally-built item lists.
@@ -25,6 +25,7 @@ from pathlib import Path
25
25
  import websockets
26
26
 
27
27
  from pgwidgets_js import get_static_path, get_remote_html
28
+ from pgwidgets._json import JsonEncoder
28
29
  from pgwidgets.defs import WIDGETS
29
30
  from pgwidgets.sync.widget import Widget, build_all_widget_classes
30
31
  from pgwidgets.method_types import (
@@ -260,6 +261,15 @@ class Session:
260
261
  if threading.current_thread() is not self._cb_thread:
261
262
  self._cb_thread.join(timeout=2)
262
263
 
264
+ def get_session_thread(self):
265
+ mode = self._app._concurrency
266
+ if mode == "serialized":
267
+ return threading.get_ident()
268
+ elif mode == "per_session":
269
+ return self._cb_thread
270
+ else:
271
+ return None
272
+
263
273
  # -- Message handling --
264
274
 
265
275
  def _handle_message(self, data):
@@ -374,18 +384,28 @@ class Session:
374
384
 
375
385
  def _dispatch_callback(self, wid, action, *args):
376
386
  """Dispatch a callback through the configured concurrency mode."""
377
- # widget = self._widget_map.get(wid)
378
- # cls_name = widget._js_class if widget is not None else "<missing>"
379
- # print(f"[PY-CB] receive wid={wid} action={action} "
380
- # f"registered_class={cls_name}", flush=True)
381
- if self._reconstructing:
382
- return # suppress callbacks during reconstruction
387
+ # Suppress callbacks during reconstruction — they are side
388
+ # effects of state replay, not user actions. Exception: 'map'
389
+ # is a one-shot lifecycle event that fires when a widget first
390
+ # gains a visible layout box; the JS-side observers may fire
391
+ # it during the reconstruction window and we MUST forward it
392
+ # or the user's map handler never runs until something later
393
+ # triggers a re-fire (e.g. a window resize).
394
+ if self._reconstructing and action != 'map':
395
+ return
383
396
  # Auto-sync: some callbacks carry state that should be reflected
384
397
  # in the Python-side widget (e.g. move -> position, resize -> size).
398
+ # We always *capture* the value so get_size()/get_position() return
399
+ # current values, but only *push* it to other browsers (and replay
400
+ # it on reconstruction) when the widget opted in via auto-sync.
401
+ # Otherwise a layout-determined size would replay as a literal
402
+ # resize(W, H) — pinning the widget to pixel dimensions and
403
+ # killing flex growth.
385
404
  state_key = STATE_SYNC_CALLBACKS.get(action)
386
405
  if state_key is not None:
387
406
  widget = self._widget_map.get(wid)
388
407
  if widget is not None:
408
+ auto = action in widget._auto_sync_actions
389
409
  # Normalize: resize sends {width, height} dict, move
390
410
  # sends (x, y) as separate args. Store as a flat tuple
391
411
  # matching the corresponding setter's signature.
@@ -395,15 +415,17 @@ class Session:
395
415
  new_val = (d["width"], d["height"])
396
416
  if widget._state.get(state_key) != new_val:
397
417
  widget._state[state_key] = new_val
398
- self._push(wid, "resize",
399
- d["width"], d["height"])
418
+ if auto:
419
+ self._push(wid, "resize",
420
+ d["width"], d["height"])
400
421
  else:
401
422
  new_val = tuple(args)
402
423
  if widget._state.get(state_key) != new_val:
403
424
  widget._state[state_key] = new_val
404
- setter = (self._STATE_KEY_TO_SETTER.get(state_key)
405
- or f"set_{state_key}")
406
- self._push(wid, setter, *args)
425
+ if auto:
426
+ setter = (self._STATE_KEY_TO_SETTER.get(state_key)
427
+ or f"set_{state_key}")
428
+ self._push(wid, setter, *args)
407
429
  # If this widget wraps a child (e.g. MDISubWindow),
408
430
  # propagate geometry into the parent's children options
409
431
  # so reconstruction replays with the current pos/size.
@@ -541,7 +563,7 @@ class Session:
541
563
  self._next_id += 1
542
564
  msg["id"] = msg_id
543
565
  try:
544
- payload = json.dumps(msg)
566
+ payload = json.dumps(msg, cls=JsonEncoder)
545
567
  except Exception as e:
546
568
  self._logger.error(
547
569
  f"JSON encode failed for "
@@ -565,7 +587,7 @@ class Session:
565
587
  ff_id = self._next_id
566
588
  self._next_id += 1
567
589
  msg_copy = dict(msg, id=ff_id)
568
- ff_payload = json.dumps(msg_copy)
590
+ ff_payload = json.dumps(msg_copy, cls=JsonEncoder)
569
591
  for ws in self._connections[1:]:
570
592
  _schedule_ws_send(self._app._loop, ws, ff_payload)
571
593
  event.wait()
@@ -605,7 +627,7 @@ class Session:
605
627
  "method": method,
606
628
  "args": list(args),
607
629
  "silent": True,
608
- })
630
+ }, cls=JsonEncoder)
609
631
  except Exception as e:
610
632
  self._logger.error(
611
633
  f"JSON encode failed for push {method} "
@@ -677,7 +699,7 @@ class Session:
677
699
  "wid": wid,
678
700
  "method": method,
679
701
  "args": list(args),
680
- })
702
+ }, cls=JsonEncoder)
681
703
  # Send header + binary as an atomic pair on each connection so
682
704
  # they can't be interleaved with other sends from another
683
705
  # thread. The pair is wrapped in a single coroutine.
@@ -794,13 +816,33 @@ class Session:
794
816
  cls = self._widget_classes.get(cls_name, Widget) if cls_name else Widget
795
817
  widget = cls._from_existing(self, wid, cls_name or "Widget")
796
818
  self._widget_map[wid] = widget
797
- # Auto-listen for state-syncing callbacks (move, resize)
798
- # so position/size changes are tracked for reconstruction.
819
+ # Auto-listen for state-syncing callbacks (move, resize).
820
+ # For visual widgets we always listen for 'resize' so that
821
+ # get_size() can return a current value, but we only mark
822
+ # the action as auto-syncing (which triggers push-to-peers
823
+ # and replay-on-reconstruction) when the widget defn
824
+ # actually opts in. Non-visual Callback-base objects (e.g.
825
+ # TextBufferRef, Timer) get nothing.
826
+ defn = WIDGETS.get(cls_name, {}) if cls_name else {}
827
+ opt_names_set = set(defn.get("options", []))
828
+ all_callbacks = defn.get("callbacks", [])
829
+ is_visual = defn.get("base") != "callback"
799
830
  for action in STATE_SYNC_CALLBACKS:
800
- key = f"{wid}:{action}"
801
- if key not in self._callbacks:
802
- self._listen(wid, action, lambda wid, *a: None)
803
- widget._auto_sync_actions.add(action)
831
+ req_opt = STATE_SYNC_REQUIRES_OPTION.get(action)
832
+ if req_opt is not None:
833
+ opted_in = req_opt in opt_names_set
834
+ else:
835
+ opted_in = action in all_callbacks
836
+ if not is_visual:
837
+ continue
838
+ if action == "resize" or opted_in:
839
+ key = f"{wid}:{action}"
840
+ if key not in self._callbacks:
841
+ self._listen(wid, action, lambda wid, *a: None)
842
+ if opted_in:
843
+ widget._auto_sync_actions.add(action)
844
+ elif action == "resize":
845
+ widget._passive_sync_actions.add(action)
804
846
  return widget
805
847
  if isinstance(val, list):
806
848
  return [self._resolve_return(v) for v in val]
@@ -897,6 +939,13 @@ class Session:
897
939
  _STATE_KEY_TO_SETTER = {v: k for k, v in SPECIAL_SETTERS.items()}
898
940
  # e.g. {"size": "resize"}
899
941
 
942
+ # Reverse map: state_key -> callback action that auto-syncs it
943
+ # (e.g. "size" -> "resize", "position" -> "move"). Used during
944
+ # state replay to skip keys that weren't actively opted into via
945
+ # _auto_sync_actions — those were captured passively for getter
946
+ # support but must not be replayed (would pin layout).
947
+ _STATE_KEY_TO_SYNC_ACTION = {v: k for k, v in STATE_SYNC_CALLBACKS.items()}
948
+
900
949
  # State keys handled by fixed-value methods (show/hide)
901
950
  _FIXED_STATE_KEYS = {}
902
951
  for _mname, (_key, _val) in FIXED_SETTERS.items():
@@ -942,12 +991,23 @@ class Session:
942
991
  self._listen(new_widget._wid, act,
943
992
  lambda wid, *a: None)
944
993
  new_widget._auto_sync_actions.add(act)
945
- # Replay any state the proxy accumulated (e.g. set_tooltip)
994
+ # Replay any state the proxy accumulated (e.g. set_tooltip).
995
+ # Skip passively-captured auto-sync state (size, position): we
996
+ # capture those from callbacks so getters work, but replaying
997
+ # would pin the widget to layout-determined pixel dimensions
998
+ # (same logic as _reconstruct_widget).
999
+ user_set = getattr(old_widget, "_user_set_state", set())
1000
+ auto = getattr(old_widget, "_auto_sync_actions", set())
946
1001
  for key, value in old_widget._state.items():
947
1002
  if key.startswith("_"):
948
1003
  continue
949
1004
  if key in sync_keys:
950
1005
  continue
1006
+ sync_action = self._STATE_KEY_TO_SYNC_ACTION.get(key)
1007
+ if (sync_action is not None
1008
+ and key not in user_set
1009
+ and sync_action not in auto):
1010
+ continue
951
1011
  method_name = (self._STATE_KEY_TO_SETTER.get(key)
952
1012
  or f"set_{key}")
953
1013
  if isinstance(value, tuple):
@@ -955,6 +1015,13 @@ class Session:
955
1015
  else:
956
1016
  self._call(new_widget._wid, method_name, value)
957
1017
  new_widget._state[key] = value
1018
+ # Propagate user-set / auto-sync membership to the new
1019
+ # widget so subsequent reconstructions replay consistently.
1020
+ if sync_action is not None:
1021
+ if key in user_set:
1022
+ new_widget._user_set_state.add(key)
1023
+ if sync_action in auto:
1024
+ new_widget._auto_sync_actions.add(sync_action)
958
1025
 
959
1026
  def _ensure_reconstructed(self, widget):
960
1027
  """Ensure a widget has been created on the JS side.
@@ -1137,6 +1204,20 @@ class Session:
1137
1204
  if key.startswith("_"):
1138
1205
  continue
1139
1206
 
1207
+ # Skip auto-sync state (size, position) that came in
1208
+ # passively via a callback (e.g. layout-determined size).
1209
+ # We capture those so getters like get_size()/
1210
+ # get_position() work, but replaying them would pin the
1211
+ # widget to pixel dimensions and override flex/expanding
1212
+ # layout. Replay only if the user explicitly set the
1213
+ # value, or if the widget opted into the sync action
1214
+ # (e.g. an interactively-resizable widget).
1215
+ sync_action = self._STATE_KEY_TO_SYNC_ACTION.get(key)
1216
+ if (sync_action is not None
1217
+ and key not in widget._user_set_state
1218
+ and sync_action not in widget._auto_sync_actions):
1219
+ continue
1220
+
1140
1221
  # Binary-payload state (e.g. set_binary_image) replays via
1141
1222
  # _send_binary so the bytes go in a raw frame, not embedded
1142
1223
  # as base64 in JSON.
@@ -1183,10 +1264,13 @@ class Session:
1183
1264
  # _listen calls treat them as first-time registrations and
1184
1265
  # actually send the "listen" message to the browser.
1185
1266
  wid = widget._wid
1267
+ passive = getattr(widget, "_passive_sync_actions", set())
1186
1268
  for action in list(saved_cbs.keys()):
1187
1269
  self._callbacks.pop(f"{wid}:{action}", None)
1188
1270
  for action in widget._auto_sync_actions:
1189
1271
  self._callbacks.pop(f"{wid}:{action}", None)
1272
+ for action in passive:
1273
+ self._callbacks.pop(f"{wid}:{action}", None)
1190
1274
 
1191
1275
  for action, entries in saved_cbs.items():
1192
1276
  for handler, extra_args, extra_kwargs, style in entries:
@@ -1201,6 +1285,12 @@ class Session:
1201
1285
  for action in widget._auto_sync_actions:
1202
1286
  if action not in widget._registered_callbacks:
1203
1287
  self._listen(widget._wid, action, lambda wid, *a: None)
1288
+ # Passive listeners (e.g. 'resize' for getter support on
1289
+ # widgets that didn't opt into auto-sync).
1290
+ for action in passive:
1291
+ if (action not in widget._registered_callbacks
1292
+ and action not in widget._auto_sync_actions):
1293
+ self._listen(widget._wid, action, lambda wid, *a: None)
1204
1294
 
1205
1295
  def _child_method_for(self, parent):
1206
1296
  """Determine which child method a container uses."""
@@ -1512,7 +1602,8 @@ class Application:
1512
1602
  async def _ws_handler(self, ws):
1513
1603
  # Init handshake: send init, receive ack which may contain
1514
1604
  # session_id + token for reconnection.
1515
- await ws.send(json.dumps({"type": "init", "id": 0}))
1605
+ await ws.send(json.dumps({"type": "init", "id": 0},
1606
+ cls=JsonEncoder))
1516
1607
  ack_data = await ws.recv()
1517
1608
  ack = json.loads(ack_data)
1518
1609
  reconnect_sid = ack.get("session_id")
@@ -1582,7 +1673,7 @@ class Application:
1582
1673
  "type": "session-info",
1583
1674
  "session_id": session.id,
1584
1675
  "token": session.token,
1585
- }))
1676
+ }, cls=JsonEncoder))
1586
1677
 
1587
1678
  if is_reconnect:
1588
1679
  # Dispatch reconstruction to the session thread — calling
pgwidgets/sync/widget.py CHANGED
@@ -75,6 +75,16 @@ class Widget:
75
75
  self._constructor_options = {}
76
76
  self._registered_callbacks = {}
77
77
  self._auto_sync_actions = set()
78
+ # Actions we listen to passively for getter support
79
+ # (e.g. 'resize' on every visual widget so get_size() returns
80
+ # a current value), but that aren't in _auto_sync_actions and
81
+ # therefore don't push to peers or replay on reconstruction.
82
+ self._passive_sync_actions = set()
83
+ # State keys the user explicitly set via a setter call.
84
+ # Used during reconstruction to decide whether a state key
85
+ # should be replayed: passively-captured callback state
86
+ # (e.g. layout-determined size) is NOT in this set.
87
+ self._user_set_state = set()
78
88
  self._replay_calls = []
79
89
  self._add_seq = 0 # insertion order across _children + _replay_calls
80
90
 
@@ -160,6 +170,8 @@ class Widget:
160
170
  obj._constructor_options = {}
161
171
  obj._registered_callbacks = {}
162
172
  obj._auto_sync_actions = set()
173
+ obj._passive_sync_actions = set()
174
+ obj._user_set_state = set()
163
175
  obj._replay_calls = []
164
176
  obj._add_seq = 0
165
177
  obj._stale = False
@@ -177,15 +189,35 @@ class Widget:
177
189
  opt_names_set = set(defn.get("options", []))
178
190
  all_callbacks = defn.get("callbacks", [])
179
191
 
180
- # State-sync callbacks (move -> position, resize -> size)
192
+ # State-sync callbacks (move -> position, resize -> size).
193
+ # For visual widgets we always *listen* so getters like
194
+ # get_size() / get_position() can return current values.
195
+ # But we only add the action to _auto_sync_actions — which
196
+ # controls push-to-peers and replay-on-reconstruction — when
197
+ # the widget actually opted in (e.g. via the 'resizable' option
198
+ # or by declaring the callback in its defn). This keeps
199
+ # layout-determined sizes from being replayed as literal
200
+ # resize() calls that would pin flex/expanding widgets.
201
+ is_visual = defn.get("base") != "callback"
181
202
  for action in STATE_SYNC_CALLBACKS:
182
203
  req_opt = STATE_SYNC_REQUIRES_OPTION.get(action)
183
- if req_opt and req_opt not in opt_names_set:
184
- continue
185
- if req_opt is None and action not in all_callbacks:
204
+ opted_in = False
205
+ if req_opt is not None:
206
+ opted_in = req_opt in opt_names_set
207
+ else:
208
+ opted_in = action in all_callbacks
209
+ # Visual widgets get the listen unconditionally for resize
210
+ # (it's a universal base-class callback) and for any move
211
+ # they declare. Non-visual Callback-base objects get
212
+ # nothing here.
213
+ if not is_visual:
186
214
  continue
187
- session._listen(wid, action, lambda wid, *a: None)
188
- self._auto_sync_actions.add(action)
215
+ if action == "resize" or opted_in:
216
+ session._listen(wid, action, lambda wid, *a: None)
217
+ if opted_in:
218
+ self._auto_sync_actions.add(action)
219
+ elif action == "resize":
220
+ self._passive_sync_actions.add(action)
189
221
 
190
222
  # Per-widget-class state sync (e.g. Slider "activated" -> value)
191
223
  cls_sync = WIDGET_CALLBACK_SYNC.get(js_class, {})
@@ -432,6 +464,9 @@ def _make_setter(method_name, param_names, state_key):
432
464
  self._state[state_key] = args[0]
433
465
  else:
434
466
  self._state[state_key] = args
467
+ # Mark as user-set so reconstruction knows to replay this key
468
+ # (callback-captured values for the same key don't get marked).
469
+ self._user_set_state.add(state_key)
435
470
  return self._call(method_name, *args)
436
471
  method.__name__ = method_name
437
472
  method.__qualname__ = f"Widget.{method_name}"
@@ -444,6 +479,7 @@ def _make_fixed_setter(method_name, state_key, fixed_value):
444
479
  """Create a no-arg method that sets a fixed state value (show/hide)."""
445
480
  def method(self):
446
481
  self._state[state_key] = fixed_value
482
+ self._user_set_state.add(state_key)
447
483
  return self._call(method_name)
448
484
  method.__name__ = method_name
449
485
  method.__qualname__ = f"Widget.{method_name}"
@@ -842,14 +878,19 @@ def __init__({_init_params(pos_names, opt_names)}):
842
878
 
843
879
 
844
880
  def _init_params(pos_names, opt_names):
845
- """Build the parameter string for the generated __init__."""
881
+ """Build the parameter string for the generated __init__.
882
+
883
+ Options are exposed as positional-or-keyword (no ``*`` separator)
884
+ so that single-option widgets like Frame accept ``Frame("Title")``
885
+ in addition to ``Frame(title="Title")``, matching the pyodide
886
+ bridge's behavior. Each option still defaults to ``None`` so
887
+ omitted ones don't override state from prior calls.
888
+ """
846
889
  params = ["self", "session"]
847
890
  for name in pos_names:
848
891
  params.append(f"{name}=None")
849
- if opt_names:
850
- params.append("*")
851
- for name in opt_names:
852
- params.append(f"{name}=None")
892
+ for name in opt_names:
893
+ params.append(f"{name}=None")
853
894
  params.append("**kwargs")
854
895
  return ", ".join(params)
855
896
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgwidgets-python
3
- Version: 0.2.0
3
+ Version: 0.2.3
4
4
  Summary: Python bindings for the pgwidgets JavaScript widget library
5
5
  Author: PGWidgets Developers
6
6
  License: BSD-3-Clause
@@ -17,7 +17,7 @@ Classifier: Topic :: Software Development :: User Interfaces
17
17
  Requires-Python: >=3.12
18
18
  Description-Content-Type: text/markdown
19
19
  License-File: LICENSE.md
20
- Requires-Dist: pgwidgets-js
20
+ Requires-Dist: pgwidgets-js>=0.2.0
21
21
  Requires-Dist: websockets>=12
22
22
  Provides-Extra: dev
23
23
  Requires-Dist: sphinx; extra == "dev"
@@ -0,0 +1,20 @@
1
+ pgwidgets/__init__.py,sha256=fKKHAt9GxLbficihVyImWsqrnQvdQZQAmeGaRUF8cBE,857
2
+ pgwidgets/_json.py,sha256=o21qywJ6yAldbqxTq3nLwgK9O67r3a8JxhvI_WAJnMY,2184
3
+ pgwidgets/callbacks.py,sha256=gA2FnX0N5BmbmOMyEV35yHySgAfVjfAZcZhZbo7j4-c,3314
4
+ pgwidgets/defs.py,sha256=Q8qhvTeansFU1Av6j5ncdwF2xSNHrpXuKErdHWQ3HiA,436
5
+ pgwidgets/method_types.py,sha256=S5V6EvJ9DZnSQaiaEhPkAuCtqrlHde5wj16TZd0vHvQ,16327
6
+ pgwidgets/async_/Widgets.py,sha256=vld2xkvusBBEVbhbezaJsjBRRj3L7c17Up0pOVs8PGo,723
7
+ pgwidgets/async_/__init__.py,sha256=rXB-v9XRrYt1imYuYikhkzIyiRqaYhlTpQei2LHaQ18,564
8
+ pgwidgets/async_/application.py,sha256=mab8nRl7dNwnvNx64F0RapIJvJXy4HW_eJ1Bbuhw83w,69612
9
+ pgwidgets/async_/widget.py,sha256=H27v7AzHNg5obzr2e8UyOyj2lbGfWhEhZUrIDqrZNbg,37612
10
+ pgwidgets/extras/__init__.py,sha256=AXUmFtnn4RSlp6pJX6z55j-m_29VmfzpYIjQvAy7pO0,325
11
+ pgwidgets/extras/file_browser.py,sha256=PWzJltjQZbmsD1ykbbC2xJ6ErEkJj7KafhmVkd9q9Ac,16963
12
+ pgwidgets/sync/Widgets.py,sha256=7SaocMVMzFHhi51pjuEAr47Wez90b_a61kDbiv0jOfM,706
13
+ pgwidgets/sync/__init__.py,sha256=SF5RTAvtu8BbYBWzpiCPipy6DJzNXf6nqk9xdvwxUhQ,542
14
+ pgwidgets/sync/application.py,sha256=a1Xu5VRif9WKHxMWvPN3oyTqjPsrdw54Smp4Vz16ObY,80309
15
+ 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,,
@@ -1,19 +0,0 @@
1
- pgwidgets/__init__.py,sha256=365yX_Iq5Uh8ZYPVUDmdTWJa0E8PfLQG82LffdZJi88,476
2
- pgwidgets/callbacks.py,sha256=gA2FnX0N5BmbmOMyEV35yHySgAfVjfAZcZhZbo7j4-c,3314
3
- pgwidgets/defs.py,sha256=Q8qhvTeansFU1Av6j5ncdwF2xSNHrpXuKErdHWQ3HiA,436
4
- pgwidgets/method_types.py,sha256=AoNXYY7iU-ttAWPqL104Dpl4AtUvyF6I2OmzpRw2HWQ,15637
5
- pgwidgets/async_/Widgets.py,sha256=vld2xkvusBBEVbhbezaJsjBRRj3L7c17Up0pOVs8PGo,723
6
- pgwidgets/async_/__init__.py,sha256=rXB-v9XRrYt1imYuYikhkzIyiRqaYhlTpQei2LHaQ18,564
7
- pgwidgets/async_/application.py,sha256=hKsa6PBh49n7AxwwXqdWjBfL_cL6eixX-MeU_lkD9CY,64651
8
- pgwidgets/async_/widget.py,sha256=bL-wL7T9j6Ic5xsAchw5BdJQtgHX1GuCOF732cJEpbQ,35589
9
- pgwidgets/extras/__init__.py,sha256=AXUmFtnn4RSlp6pJX6z55j-m_29VmfzpYIjQvAy7pO0,325
10
- pgwidgets/extras/file_browser.py,sha256=PWzJltjQZbmsD1ykbbC2xJ6ErEkJj7KafhmVkd9q9Ac,16963
11
- pgwidgets/sync/Widgets.py,sha256=7SaocMVMzFHhi51pjuEAr47Wez90b_a61kDbiv0jOfM,706
12
- pgwidgets/sync/__init__.py,sha256=SF5RTAvtu8BbYBWzpiCPipy6DJzNXf6nqk9xdvwxUhQ,542
13
- pgwidgets/sync/application.py,sha256=WNT96gG6BhRJug7Dt-AerAjV_AZFq-BE--1XFj6ObgI,75392
14
- pgwidgets/sync/widget.py,sha256=UkMgM7dKA1g-CtjukL27vjCZD7FAHyMOpJVD9mpN5-w,35655
15
- pgwidgets_python-0.2.0.dist-info/licenses/LICENSE.md,sha256=LoM3fMTiMnQuHRCJghdjOtjnCrL8soBpu2PFk24Xvyg,1528
16
- pgwidgets_python-0.2.0.dist-info/METADATA,sha256=NDMZH7W7Va2LzqYHF_kDK3ryykos_aM5rO5IczPT-uE,4561
17
- pgwidgets_python-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
18
- pgwidgets_python-0.2.0.dist-info/top_level.txt,sha256=wwL6fBq0gU-JwzlM6TdduY1qYpu39ysqnnbQT-1bqAs,10
19
- pgwidgets_python-0.2.0.dist-info/RECORD,,