pgwidgets-python 0.3.4__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,356 @@
1
+ """Python-side model of TreeView / TableView content.
2
+
3
+ pgwidgets-python drives the browser over a websocket: the browser is a
4
+ *view* that can be discarded and rebuilt at any moment -- a page reload, a
5
+ network blip, a second browser attaching to the same session. Everything
6
+ needed to rebuild it therefore has to live on the Python side, which is the
7
+ source of truth.
8
+
9
+ Historically only the bulk calls (``set_tree`` / ``set_rows`` /
10
+ ``set_columns``) were tracked, and every later mutation -- ``set_cell``,
11
+ ``add_item``, ``add_tree``, ``delete_tree``, the row and column edits --
12
+ was fire-and-forget. A reconnect then replayed the *original* bulk
13
+ snapshot, silently reverting every change made since. These helpers keep
14
+ the model current as those calls go by, so reconstruction replays one
15
+ accurate ``set_tree`` / ``set_rows``.
16
+
17
+ Having the model on hand also makes ``update_tree`` incremental: rather
18
+ than shipping the whole tree and having the browser rebuild it (losing
19
+ expansion state, cell styles and any open editor), :func:`diff_tree`
20
+ computes the minimal set of operations against the model and only those go
21
+ over the wire.
22
+
23
+ Everything here is a pure function over plain dicts and lists, so the sync
24
+ and async wrappers share one implementation and it can be tested without a
25
+ session or a browser.
26
+
27
+ Tree shape (mirroring the JS side)
28
+ ----------------------------------
29
+ A tree is a dict keyed by stable string ids. A node's children are its
30
+ dict-valued entries; its own column values are either the ``__values__``
31
+ entry, when present, or its primitive entries. A node with no dict-valued
32
+ entries is a leaf, and the dict *is* its column values.
33
+ """
34
+
35
+ import copy
36
+
37
+ VALUES_KEY = '__values__'
38
+
39
+ _MISSING = object()
40
+
41
+ __all__ = ['VALUES_KEY', 'node_at', 'children_of', 'values_of',
42
+ 'values_target', 'copy_tree', 'set_cell', 'add_item',
43
+ 'remove_item', 'remove_items', 'merge_tree', 'delete_tree',
44
+ 'diff_tree', 'row_set_cell', 'insert_row', 'append_row',
45
+ 'delete_row', 'column_key', 'insert_column', 'append_column',
46
+ 'delete_column']
47
+
48
+
49
+ # ----- tree navigation ---------------------------------------------
50
+
51
+ def copy_tree(tree):
52
+ """Return a deep copy, so the model can't be mutated behind our back
53
+ by a caller that keeps and edits the structure it handed us."""
54
+ try:
55
+ return copy.deepcopy(tree)
56
+ except Exception:
57
+ # exotic values (e.g. an unpicklable object stashed in a row)
58
+ # -- fall back to sharing rather than failing the call
59
+ return tree
60
+
61
+
62
+ def node_at(tree, path):
63
+ """Return the node at `path` (a sequence of keys), or None.
64
+
65
+ An empty path denotes the root, i.e. `tree` itself.
66
+ """
67
+ node = tree
68
+ for key in (path or ()):
69
+ if not isinstance(node, dict) or key not in node:
70
+ return None
71
+ node = node[key]
72
+ return node
73
+
74
+
75
+ def children_of(node):
76
+ """Return {key: child} for a node's dict-valued entries."""
77
+ if not isinstance(node, dict):
78
+ return {}
79
+ return {k: v for k, v in node.items()
80
+ if k != VALUES_KEY and isinstance(v, dict)}
81
+
82
+
83
+ def values_of(node):
84
+ """Return a node's column values (read-only use)."""
85
+ if not isinstance(node, dict):
86
+ return {}
87
+ vals = node.get(VALUES_KEY)
88
+ if isinstance(vals, dict):
89
+ return vals
90
+ return {k: v for k, v in node.items() if not isinstance(v, dict)}
91
+
92
+
93
+ def values_target(node):
94
+ """Return the dict a node's column values should be *written* to.
95
+
96
+ That is ``__values__`` for an interior node that carries one, else the
97
+ node itself (a leaf, or an interior whose values sit alongside its
98
+ children).
99
+ """
100
+ if not isinstance(node, dict):
101
+ return None
102
+ vals = node.get(VALUES_KEY)
103
+ if isinstance(vals, dict):
104
+ return vals
105
+ return node
106
+
107
+
108
+ # ----- tree mutation -----------------------------------------------
109
+
110
+ def set_cell(tree, path, col_key, value):
111
+ """Write one column value. Returns True if the path resolved."""
112
+ node = node_at(tree, path)
113
+ target = values_target(node)
114
+ if target is None:
115
+ return False
116
+ target[col_key] = value
117
+ return True
118
+
119
+
120
+ def add_item(tree, parent, key, values):
121
+ """Add/replace a single child under `parent` (None/[] = root)."""
122
+ node = node_at(tree, parent)
123
+ if not isinstance(node, dict):
124
+ return False
125
+ node[key] = copy_tree(values) if isinstance(values, dict) else values
126
+ return True
127
+
128
+
129
+ def remove_item(tree, path):
130
+ """Remove the node at `path` together with its subtree."""
131
+ if not path:
132
+ return False
133
+ parent = node_at(tree, path[:-1])
134
+ if not isinstance(parent, dict) or path[-1] not in parent:
135
+ return False
136
+ del parent[path[-1]]
137
+ return True
138
+
139
+
140
+ def remove_items(tree, paths):
141
+ return sum(1 for p in (paths or []) if remove_item(tree, p))
142
+
143
+
144
+ def merge_tree(tree, subtree, parent=None):
145
+ """Merge `subtree` under `parent`, replacing same-key children
146
+ subtree-deep -- the semantics of the JS ``add_tree``."""
147
+ node = node_at(tree, parent)
148
+ if not isinstance(node, dict) or not isinstance(subtree, dict):
149
+ return False
150
+ for key, value in subtree.items():
151
+ node[key] = copy_tree(value) if isinstance(value, dict) else value
152
+ return True
153
+
154
+
155
+ def delete_tree(tree, spec, prune_empty=True):
156
+ """Delete the nodes named by `spec`, mirroring the JS ``_deleteSpec``.
157
+
158
+ A key mapping to an empty dict (or a non-dict) removes that node and
159
+ its whole subtree; a key mapping to a non-empty dict is descended into
160
+ so only the named descendants go. Keys absent from the tree are
161
+ ignored. With `prune_empty`, a branch left childless is removed too,
162
+ cascading upward. Returns the number of nodes removed.
163
+ """
164
+ def _delete(node, sub_spec):
165
+ count = 0
166
+ for key in list(sub_spec.keys()):
167
+ if not isinstance(node, dict) or key not in node:
168
+ continue # not present; skip
169
+ child = node[key]
170
+ sub = sub_spec[key]
171
+ descend = (isinstance(sub, dict) and len(sub) > 0
172
+ and len(children_of(child)) > 0)
173
+ if not descend:
174
+ del node[key]
175
+ count += 1
176
+ else:
177
+ count += _delete(child, sub)
178
+ if prune_empty and len(children_of(child)) == 0:
179
+ del node[key]
180
+ count += 1
181
+ return count
182
+
183
+ return _delete(tree, spec or {})
184
+
185
+
186
+ # ----- tree diffing -------------------------------------------------
187
+
188
+ def _count_nodes(tree):
189
+ total = 0
190
+ for child in children_of(tree).values():
191
+ total += 1 + _count_nodes(child)
192
+ return total
193
+
194
+
195
+ def diff_tree(old, new, max_ops=None):
196
+ """Return the operations that turn `old` into `new`.
197
+
198
+ Each operation is a tuple ``(method_name, *args)`` ready to dispatch
199
+ at the browser: ``remove_items``, ``add_tree`` and ``set_cell``.
200
+ Deletions are emitted first so additions never collide with a node on
201
+ its way out.
202
+
203
+ Returns None when the trees differ so broadly that replacing the whole
204
+ thing is cheaper than patching it -- the caller should fall back to
205
+ ``set_tree``. `max_ops` defaults to the node count of `new`.
206
+ """
207
+ removals = []
208
+ additions = []
209
+ updates = []
210
+
211
+ def walk(old_node, new_node, path):
212
+ old_children = children_of(old_node)
213
+ new_children = children_of(new_node)
214
+
215
+ for key in old_children:
216
+ if key not in new_children:
217
+ removals.append(list(path) + [key])
218
+
219
+ for key, new_child in new_children.items():
220
+ old_child = old_children.get(key, _MISSING)
221
+ if old_child is _MISSING:
222
+ additions.append((list(path), key, new_child))
223
+ else:
224
+ walk(old_child, new_child, tuple(path) + (key,))
225
+
226
+ if path: # the root has no column values of its own
227
+ old_vals = values_of(old_node)
228
+ new_vals = values_of(new_node)
229
+ for col_key, value in new_vals.items():
230
+ if old_vals.get(col_key, _MISSING) != value:
231
+ updates.append((list(path), col_key, value))
232
+
233
+ walk(old if isinstance(old, dict) else {},
234
+ new if isinstance(new, dict) else {}, ())
235
+
236
+ if max_ops is None:
237
+ max_ops = max(_count_nodes(new), 1)
238
+ if len(removals) + len(additions) + len(updates) > max_ops:
239
+ return None # wholesale replacement is cheaper
240
+
241
+ ops = []
242
+ if removals:
243
+ ops.append(('remove_items', removals))
244
+ for parent, key, node in additions:
245
+ ops.append(('add_tree', {key: node}, parent or None))
246
+ for path, col_key, value in updates:
247
+ ops.append(('set_cell', path, col_key, value))
248
+ return ops
249
+
250
+
251
+ # ----- flat rows (TableView) ----------------------------------------
252
+
253
+ def column_key(column, index=None):
254
+ """Extract a column's key from any descriptor form."""
255
+ if isinstance(column, dict):
256
+ return column.get('key') or column.get('label') or (
257
+ f'col{index}' if index is not None else None)
258
+ if isinstance(column, (list, tuple)):
259
+ return column[1] if len(column) > 1 else column[0]
260
+ return column
261
+
262
+
263
+ def row_set_cell(rows, columns, row, col, value):
264
+ """Write one cell of a flat row list.
265
+
266
+ `row` is an index; `col` is a column key or index. Rows may be dicts
267
+ (keyed by column key) or positional sequences.
268
+ """
269
+ if not isinstance(rows, list) or not isinstance(row, int):
270
+ return False
271
+ if not (0 <= row < len(rows)):
272
+ return False
273
+ keys = [column_key(c, i) for i, c in enumerate(columns or [])]
274
+ target = rows[row]
275
+ if isinstance(target, dict):
276
+ key = col if not isinstance(col, int) else (
277
+ keys[col] if 0 <= col < len(keys) else None)
278
+ if key is None:
279
+ return False
280
+ target[key] = value
281
+ return True
282
+ if isinstance(target, list):
283
+ idx = col if isinstance(col, int) else (
284
+ keys.index(col) if col in keys else None)
285
+ if idx is None or not (0 <= idx < len(target)):
286
+ return False
287
+ target[idx] = value
288
+ return True
289
+ return False
290
+
291
+
292
+ def insert_row(rows, index, values):
293
+ if not isinstance(rows, list):
294
+ return False
295
+ if not isinstance(index, int) or index < 0 or index > len(rows):
296
+ index = len(rows) # out of range clamps to append, as in JS
297
+ rows.insert(index, copy_tree(values))
298
+ return True
299
+
300
+
301
+ def append_row(rows, values):
302
+ if not isinstance(rows, list):
303
+ return False
304
+ rows.append(copy_tree(values))
305
+ return True
306
+
307
+
308
+ def delete_row(rows, index):
309
+ if not isinstance(rows, list) or not isinstance(index, int):
310
+ return False
311
+ if not (0 <= index < len(rows)):
312
+ return False
313
+ del rows[index]
314
+ return True
315
+
316
+
317
+ # ----- columns -------------------------------------------------------
318
+
319
+ def insert_column(columns, column, before=None, index=None):
320
+ """Insert a column.
321
+
322
+ TreeView names the insertion point by column key (`before`); TableView
323
+ by position (`index`). Pass whichever the caller supplied.
324
+ """
325
+ if not isinstance(columns, list):
326
+ return False
327
+ if index is None and before is not None:
328
+ keys = [column_key(c, i) for i, c in enumerate(columns)]
329
+ index = keys.index(before) if before in keys else len(columns)
330
+ if not isinstance(index, int) or index < 0 or index > len(columns):
331
+ index = len(columns)
332
+ columns.insert(index, copy_tree(column))
333
+ return True
334
+
335
+
336
+ def append_column(columns, column):
337
+ if not isinstance(columns, list):
338
+ return False
339
+ columns.append(copy_tree(column))
340
+ return True
341
+
342
+
343
+ def delete_column(columns, col):
344
+ """Delete by column key (TreeView) or by index (TableView)."""
345
+ if not isinstance(columns, list):
346
+ return False
347
+ if isinstance(col, int):
348
+ if not (0 <= col < len(columns)):
349
+ return False
350
+ del columns[col]
351
+ return True
352
+ keys = [column_key(c, i) for i, c in enumerate(columns)]
353
+ if col not in keys:
354
+ return False
355
+ del columns[keys.index(col)]
356
+ return True
@@ -1,15 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgwidgets-python
3
- Version: 0.3.4
3
+ Version: 0.4.0
4
4
  Summary: Python bindings for the pgwidgets JavaScript widget library
5
5
  Author: PGWidgets Developers
6
- License: BSD-3-Clause
6
+ License-Expression: BSD-3-Clause
7
7
  Project-URL: Homepage, https://github.com/naojsoft/pgwidgets-python
8
8
  Project-URL: Repository, https://github.com/naojsoft/pgwidgets-python
9
9
  Keywords: widgets,ui,gui,websocket,browser
10
10
  Classifier: Development Status :: 3 - Alpha
11
11
  Classifier: Intended Audience :: Developers
12
- Classifier: License :: OSI Approved :: BSD License
13
12
  Classifier: Programming Language :: Python :: 3
14
13
  Classifier: Programming Language :: Python :: 3.12
15
14
  Classifier: Programming Language :: Python :: 3.13
@@ -17,7 +16,7 @@ Classifier: Topic :: Software Development :: User Interfaces
17
16
  Requires-Python: >=3.12
18
17
  Description-Content-Type: text/markdown
19
18
  License-File: LICENSE.md
20
- Requires-Dist: pgwidgets-js>=0.3.4
19
+ Requires-Dist: pgwidgets-js>=0.4.0
21
20
  Requires-Dist: websockets>=12
22
21
  Provides-Extra: dev
23
22
  Requires-Dist: sphinx; extra == "dev"
@@ -3,24 +3,23 @@ pgwidgets/_json.py,sha256=o21qywJ6yAldbqxTq3nLwgK9O67r3a8JxhvI_WAJnMY,2184
3
3
  pgwidgets/buffer.py,sha256=BYj_nb6fuyGsBqUp3_Z1YrzUm4h5VmoWPGKvpTjWrYk,4048
4
4
  pgwidgets/callbacks.py,sha256=gA2FnX0N5BmbmOMyEV35yHySgAfVjfAZcZhZbo7j4-c,3314
5
5
  pgwidgets/defs.py,sha256=Q8qhvTeansFU1Av6j5ncdwF2xSNHrpXuKErdHWQ3HiA,436
6
- pgwidgets/method_types.py,sha256=m4jyBchh3CbS-n1vaKoyMkw0Vd5IVeHIKgUEeMiJMu0,18328
6
+ pgwidgets/method_types.py,sha256=XDT8EfE6UWFcs_S2moqZs3SulUDLIjwNFx6UigNAeRk,24811
7
7
  pgwidgets/text_model.py,sha256=xlSAoh1ikIG9ZYVwx5wHWrohkGkyu7R6Cbs36_xnbMg,29313
8
+ pgwidgets/tree_model.py,sha256=agif6vSeLzcGfLe1tB-O2MIkXv7Qgb53uWMoTXDSgAc,12144
8
9
  pgwidgets/async_/Widgets.py,sha256=vld2xkvusBBEVbhbezaJsjBRRj3L7c17Up0pOVs8PGo,723
9
10
  pgwidgets/async_/__init__.py,sha256=rXB-v9XRrYt1imYuYikhkzIyiRqaYhlTpQei2LHaQ18,564
10
- pgwidgets/async_/application.py,sha256=2RJNRerXu5LXG_UcMSi9HWOCAlEOu96TrRaoIcUZ86Q,80957
11
- pgwidgets/async_/widget.py,sha256=gO7eJh9LV3UY7yRLdeml_0gqmHnFB5VU3NWlDURLtfc,38042
11
+ pgwidgets/async_/application.py,sha256=U-D1WzazOnuuZQ1djQS2vZyS-xtcRY0RVWtbgr0bCrQ,85368
12
+ pgwidgets/async_/widget.py,sha256=nHWszUHXgwv37SOmvjA2AqGNYdxOVaEugPOjfRrwB48,57043
12
13
  pgwidgets/extras/__init__.py,sha256=AXUmFtnn4RSlp6pJX6z55j-m_29VmfzpYIjQvAy7pO0,325
13
14
  pgwidgets/extras/file_browser.py,sha256=Lkz_n56_YW7rV_k8pmhgZrwulGsujk9vrzv66zgIgec,17347
14
15
  pgwidgets/icons/pgw-python-logo.png,sha256=_Rxicp9EKy2Pzz1HtobxEnntSI-y5TtAVPlIzZIsfYs,2428840
15
16
  pgwidgets/sync/Widgets.py,sha256=7SaocMVMzFHhi51pjuEAr47Wez90b_a61kDbiv0jOfM,706
16
17
  pgwidgets/sync/__init__.py,sha256=SF5RTAvtu8BbYBWzpiCPipy6DJzNXf6nqk9xdvwxUhQ,542
17
- pgwidgets/sync/application.py,sha256=cjF7Ql3Ukkpb_wv39kdOxkwImmr30tuRNRaaCwnDcc8,96832
18
+ pgwidgets/sync/application.py,sha256=WOWQAR8FeieVWjwc9yHFHqGposBdZk8AcGqIKj27bwM,102478
18
19
  pgwidgets/sync/text_source.py,sha256=Vz8mJsQXLFOAVwZCOrAXSixNfYQHjYZxxAIZjWBCAnY,8341
19
- pgwidgets/sync/widget.py,sha256=Kw2CQFOaa7rfroKfn0MwNwXT-PjoecqCErav5BEc0UI,38652
20
- pgwidgets_python-0.3.4.dist-info/licenses/LICENSE.md,sha256=LoM3fMTiMnQuHRCJghdjOtjnCrL8soBpu2PFk24Xvyg,1528
21
- pgwidgets_python-0.3.4.dist-info/METADATA,sha256=mYbCrbwXb_7AJTTFzc8lx0wekc5xXCvtPob88f6qQn4,4683
22
- pgwidgets_python-0.3.4.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
23
- pgwidgets_python-0.3.4.dist-info/scm_file_list.json,sha256=IYU1Sj8wmNZV5NJbOcAqZMFFo3cJDmHEHVO9yNzJeoA,2044
24
- pgwidgets_python-0.3.4.dist-info/scm_version.json,sha256=1gVS2afyYI_kYay3yptOGjKz_RV3I2woNhdQjYuuLNU,160
25
- pgwidgets_python-0.3.4.dist-info/top_level.txt,sha256=wwL6fBq0gU-JwzlM6TdduY1qYpu39ysqnnbQT-1bqAs,10
26
- pgwidgets_python-0.3.4.dist-info/RECORD,,
20
+ pgwidgets/sync/widget.py,sha256=gp7cio4uB822hrk-AcqoGWsOsABaweB5wUpIro3yV0g,57455
21
+ pgwidgets_python-0.4.0.dist-info/licenses/LICENSE.md,sha256=LoM3fMTiMnQuHRCJghdjOtjnCrL8soBpu2PFk24Xvyg,1528
22
+ pgwidgets_python-0.4.0.dist-info/METADATA,sha256=-lBgjLX8ifsJ7rtF7HqniV8I_JN2fmBGv8mr11kCnEs,4643
23
+ pgwidgets_python-0.4.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
24
+ pgwidgets_python-0.4.0.dist-info/top_level.txt,sha256=wwL6fBq0gU-JwzlM6TdduY1qYpu39ysqnnbQT-1bqAs,10
25
+ pgwidgets_python-0.4.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (83.0.0)
2
+ Generator: setuptools (84.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,71 +0,0 @@
1
- {
2
- "files": [
3
- ".flake8",
4
- "test_pg2.py",
5
- "README.md",
6
- "pyproject.toml",
7
- "LICENSE.md",
8
- ".readthedocs.yaml",
9
- ".gitignore",
10
- "docs/web-servers.rst",
11
- "docs/WhatsNew.rst",
12
- "docs/subclassing.rst",
13
- "docs/sync.rst",
14
- "docs/utilities.rst",
15
- "docs/extras.rst",
16
- "docs/async.rst",
17
- "docs/architecture.rst",
18
- "docs/widgets.rst",
19
- "docs/conf.py",
20
- "docs/Makefile",
21
- "docs/index.rst",
22
- "docs/callbacks.rst",
23
- "docs/getting-started.rst",
24
- "docs/api/async.rst",
25
- "docs/api/sync.rst",
26
- "docs/api/index.rst",
27
- "tests/test_protocol.py",
28
- "tests/__init__.py",
29
- "tests/test_stateful.py",
30
- "tests/test_resolve_kwargs.py",
31
- "tests/test_text_source.py",
32
- "tests/test_async_session.py",
33
- "tests/test_defs.py",
34
- "tests/test_fonts.py",
35
- "tests/test_session.py",
36
- "tests/test_extras.py",
37
- "tests/test_widget_classes.py",
38
- "tests/test_reconstruct.py",
39
- "pgwidgets/_json.py",
40
- "pgwidgets/__init__.py",
41
- "pgwidgets/callbacks.py",
42
- "pgwidgets/method_types.py",
43
- "pgwidgets/buffer.py",
44
- "pgwidgets/text_model.py",
45
- "pgwidgets/defs.py",
46
- "pgwidgets/sync/Widgets.py",
47
- "pgwidgets/sync/__init__.py",
48
- "pgwidgets/sync/application.py",
49
- "pgwidgets/sync/text_source.py",
50
- "pgwidgets/sync/widget.py",
51
- "pgwidgets/extras/__init__.py",
52
- "pgwidgets/extras/file_browser.py",
53
- "pgwidgets/icons/pgw-python-logo.png",
54
- "pgwidgets/async_/Widgets.py",
55
- "pgwidgets/async_/application.py",
56
- "pgwidgets/async_/__init__.py",
57
- "pgwidgets/async_/widget.py",
58
- "examples/demo_sync.py",
59
- "examples/all_widgets.py",
60
- "examples/all_widgets_async.py",
61
- "examples/README.md",
62
- "examples/demo_treeview.py",
63
- "examples/demo_async.py",
64
- "examples/flask-multi-process/server.py",
65
- "examples/flask-multi-process/README.md",
66
- "examples/flask-multi-process/nginx.conf",
67
- "examples/flask-multi-process/gunicorn.conf.py",
68
- "examples/flask-multi-process/user_app.py",
69
- ".github/workflows/tests.yml"
70
- ]
71
- }
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.3.4",
3
- "distance": 0,
4
- "node": "g723f82b1856fa1a276455b903e3c9131cb62d9a8",
5
- "dirty": false,
6
- "branch": "main",
7
- "node_date": "2026-07-17"
8
- }