QT-PyQt-PySide-Custom-Widgets-Pro 1.1.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.
- custom_widgets_pro/__init__.py +73 -0
- custom_widgets_pro/_catalog.py +25 -0
- custom_widgets_pro/_cli.py +132 -0
- custom_widgets_pro/_license.py +419 -0
- custom_widgets_pro/_version.py +14 -0
- custom_widgets_pro/datatable/__init__.py +17 -0
- custom_widgets_pro/datatable/datatable_pro.py +319 -0
- custom_widgets_pro/datatable/export.py +143 -0
- custom_widgets_pro/datatable/frozen_view.py +112 -0
- custom_widgets_pro/datatable/grouping.py +309 -0
- custom_widgets_pro/datatable/pivot.py +100 -0
- custom_widgets_pro/datatable/provider.py +98 -0
- custom_widgets_pro/datatable/virtual_model.py +451 -0
- custom_widgets_pro/widgets/__init__.py +7 -0
- custom_widgets_pro/widgets/charts/QCustomBeeswarm.py +317 -0
- custom_widgets_pro/widgets/charts/QCustomBubbleChart.py +655 -0
- custom_widgets_pro/widgets/charts/QCustomCandlestickChart.py +522 -0
- custom_widgets_pro/widgets/charts/QCustomDivergingBarChart.py +430 -0
- custom_widgets_pro/widgets/charts/QCustomDotMatrix.py +266 -0
- custom_widgets_pro/widgets/charts/QCustomFunnelChart.py +474 -0
- custom_widgets_pro/widgets/charts/QCustomGanttChart.py +376 -0
- custom_widgets_pro/widgets/charts/QCustomHeatmap.py +555 -0
- custom_widgets_pro/widgets/charts/QCustomRadarChart.py +600 -0
- custom_widgets_pro/widgets/charts/QCustomRadialBars.py +441 -0
- custom_widgets_pro/widgets/charts/QCustomRadialLines.py +554 -0
- custom_widgets_pro/widgets/charts/QCustomRangeBarChart.py +504 -0
- custom_widgets_pro/widgets/charts/QCustomSankey.py +539 -0
- custom_widgets_pro/widgets/charts/QCustomScatterChart.py +623 -0
- custom_widgets_pro/widgets/charts/__init__.py +1 -0
- custom_widgets_pro/widgets/data/QCustomCodeEditor.py +391 -0
- custom_widgets_pro/widgets/data/QCustomNodeGraph.py +1030 -0
- custom_widgets_pro/widgets/data/QCustomRichTextEditor.py +189 -0
- custom_widgets_pro/widgets/data/QCustomTableToolbar.py +566 -0
- custom_widgets_pro/widgets/data/__init__.py +1 -0
- custom_widgets_pro/widgets/media/QCustomImageViewer.py +300 -0
- custom_widgets_pro/widgets/media/QCustomMediaGrid.py +197 -0
- custom_widgets_pro/widgets/media/QCustomMediaTimeline.py +607 -0
- custom_widgets_pro/widgets/media/QCustomVideoPlayer.py +326 -0
- custom_widgets_pro/widgets/media/__init__.py +1 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/METADATA +112 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/RECORD +45 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/WHEEL +5 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/entry_points.txt +2 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/licenses/LICENSE +91 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
########################################################################
|
|
2
|
+
## SPINN DESIGN CODE
|
|
3
|
+
# WEBSITE: customwidgets.org
|
|
4
|
+
########################################################################
|
|
5
|
+
"""Grouping / aggregation engine for DataTable Pro.
|
|
6
|
+
|
|
7
|
+
Pure Python - no Qt - so it is unit-testable on its own and the Qt model
|
|
8
|
+
(virtual_model.VirtualDataTableModel) is a thin adapter over it.
|
|
9
|
+
|
|
10
|
+
The engine turns a flat list of row dicts into a **grouped outline**: an ordered
|
|
11
|
+
list of *visible entries* where each entry is either a ``GroupRow`` (a group
|
|
12
|
+
header carrying the group value, a row count and per-column aggregates) or a
|
|
13
|
+
plain data-row dict. Nested grouping (group-by several keys) produces nested
|
|
14
|
+
headers; each header can be expanded or collapsed independently.
|
|
15
|
+
|
|
16
|
+
This is the "flat outline" strategy (headers interleaved with data rows in one
|
|
17
|
+
flat list) rather than a Qt tree model, so the whole DataTable Pro chain -
|
|
18
|
+
frozen-column overlay, the QTableView, the sort/filter proxy - keeps working
|
|
19
|
+
unchanged; only the model's row set changes shape.
|
|
20
|
+
|
|
21
|
+
Grouping is an aggregate-over-the-whole-result operation, so the caller
|
|
22
|
+
materialises the current (sorted/filtered) result before grouping; when grouping
|
|
23
|
+
is cleared the table returns to its lazy/virtualized flat mode.
|
|
24
|
+
|
|
25
|
+
Aggregates: a spec maps a column key to a reducer - one of the names in
|
|
26
|
+
``AGGREGATORS`` (sum/avg/count/min/max/first/last) or any callable
|
|
27
|
+
``reducer(values) -> result`` over the list of that column's raw values in the
|
|
28
|
+
group.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
AGGREGATORS = ("sum", "avg", "mean", "count", "min", "max", "first", "last")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_number(v):
|
|
35
|
+
return isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def aggregate(reducer, values):
|
|
39
|
+
"""Reduce a list of raw cell values to a single aggregate.
|
|
40
|
+
|
|
41
|
+
``reducer`` is a name from AGGREGATORS or a callable. ``count`` counts all
|
|
42
|
+
rows (including ones whose value is None); every other reducer ignores
|
|
43
|
+
Nones. Returns None when there is nothing to reduce (except sum -> 0 and
|
|
44
|
+
count -> len).
|
|
45
|
+
"""
|
|
46
|
+
if callable(reducer):
|
|
47
|
+
return reducer(list(values))
|
|
48
|
+
name = str(reducer).lower()
|
|
49
|
+
if name == "count":
|
|
50
|
+
return len(values)
|
|
51
|
+
non_null = [v for v in values if v is not None]
|
|
52
|
+
nums = [v for v in non_null if _is_number(v)]
|
|
53
|
+
if name == "sum":
|
|
54
|
+
return sum(nums) if nums else 0
|
|
55
|
+
if name in ("avg", "mean"):
|
|
56
|
+
return (sum(nums) / len(nums)) if nums else None
|
|
57
|
+
if name in ("min", "max"):
|
|
58
|
+
if not non_null:
|
|
59
|
+
return None
|
|
60
|
+
try:
|
|
61
|
+
return min(non_null) if name == "min" else max(non_null)
|
|
62
|
+
except TypeError: # mixed, non-orderable types
|
|
63
|
+
return None
|
|
64
|
+
if name == "first":
|
|
65
|
+
return non_null[0] if non_null else None
|
|
66
|
+
if name == "last":
|
|
67
|
+
return non_null[-1] if non_null else None
|
|
68
|
+
raise ValueError("unknown aggregate %r" % (reducer,))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _hkey(value):
|
|
72
|
+
"""A hashable stand-in for a (possibly unhashable) group value, used for
|
|
73
|
+
path identity. The raw value is kept separately for display."""
|
|
74
|
+
try:
|
|
75
|
+
hash(value)
|
|
76
|
+
return value
|
|
77
|
+
except TypeError:
|
|
78
|
+
return ("\0unhashable", repr(value))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class GroupRow(object):
|
|
82
|
+
"""A group-header visible entry.
|
|
83
|
+
|
|
84
|
+
depth 0-based nesting level (0 = outermost group key).
|
|
85
|
+
key the column key this level groups by.
|
|
86
|
+
keyTitle that column's display title.
|
|
87
|
+
value the raw group value (what ``key`` equals for this group).
|
|
88
|
+
count number of data rows under this group (all descendants).
|
|
89
|
+
aggregates {column_key: aggregate_value} for the configured aggregate spec.
|
|
90
|
+
path hashable identity tuple from the root to this group.
|
|
91
|
+
expanded whether this group is currently expanded.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
__slots__ = ("depth", "key", "keyTitle", "value", "count",
|
|
95
|
+
"aggregates", "path", "expanded")
|
|
96
|
+
|
|
97
|
+
def __init__(self, depth, key, keyTitle, value, count, aggregates, path,
|
|
98
|
+
expanded):
|
|
99
|
+
self.depth = depth
|
|
100
|
+
self.key = key
|
|
101
|
+
self.keyTitle = keyTitle
|
|
102
|
+
self.value = value
|
|
103
|
+
self.count = count
|
|
104
|
+
self.aggregates = aggregates
|
|
105
|
+
self.path = path
|
|
106
|
+
self.expanded = expanded
|
|
107
|
+
|
|
108
|
+
def __repr__(self):
|
|
109
|
+
return ("GroupRow(depth=%d, key=%r, value=%r, count=%d, expanded=%r)"
|
|
110
|
+
% (self.depth, self.key, self.value, self.count, self.expanded))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class _Node(object):
|
|
114
|
+
__slots__ = ("depth", "key", "keyTitle", "value", "path",
|
|
115
|
+
"children", "leaves", "aggregates", "count")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class GroupingEngine(object):
|
|
119
|
+
"""Builds and maintains the grouped outline for a set of rows.
|
|
120
|
+
|
|
121
|
+
keys ordered list of column keys to group by (outer -> inner).
|
|
122
|
+
aggregates {column_key: reducer} spec (reducer name or callable).
|
|
123
|
+
titles optional {column_key: title} for header labels.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(self, keys, aggregates=None, titles=None):
|
|
127
|
+
self._keys = [k for k in (keys or [])]
|
|
128
|
+
self._aggregates = dict(aggregates or {})
|
|
129
|
+
self._titles = dict(titles or {})
|
|
130
|
+
self._collapsed = set() # set of node paths that are collapsed
|
|
131
|
+
self._roots = [] # top-level _Node list
|
|
132
|
+
self._nodes = {} # path -> _Node
|
|
133
|
+
self.visible = [] # flat list: GroupRow | row-dict
|
|
134
|
+
self._rows = []
|
|
135
|
+
|
|
136
|
+
# ------------------------------------------------------------------ #
|
|
137
|
+
## Configuration / (re)build
|
|
138
|
+
# ------------------------------------------------------------------ #
|
|
139
|
+
def keys(self):
|
|
140
|
+
return list(self._keys)
|
|
141
|
+
|
|
142
|
+
def setRows(self, rows):
|
|
143
|
+
"""(Re)build the whole outline from ``rows``. Collapse state is reset."""
|
|
144
|
+
self._rows = list(rows or [])
|
|
145
|
+
self._collapsed = set()
|
|
146
|
+
self._buildTree()
|
|
147
|
+
self._rebuildVisible()
|
|
148
|
+
|
|
149
|
+
def _title(self, key):
|
|
150
|
+
return self._titles.get(key, key)
|
|
151
|
+
|
|
152
|
+
def _aggregatesFor(self, rows):
|
|
153
|
+
out = {}
|
|
154
|
+
for col_key, reducer in self._aggregates.items():
|
|
155
|
+
out[col_key] = aggregate(reducer, [r.get(col_key) for r in rows])
|
|
156
|
+
return out
|
|
157
|
+
|
|
158
|
+
def _buildTree(self):
|
|
159
|
+
self._nodes = {}
|
|
160
|
+
self._roots = self._buildLevel(self._rows, self._keys, 0, ())
|
|
161
|
+
|
|
162
|
+
def _buildLevel(self, rows, keys, depth, parent_path):
|
|
163
|
+
key = keys[0]
|
|
164
|
+
title = self._title(key)
|
|
165
|
+
order = [] # first-seen group order (stable)
|
|
166
|
+
buckets = {} # hkey -> (raw_value, [rows])
|
|
167
|
+
for r in rows:
|
|
168
|
+
value = r.get(key)
|
|
169
|
+
hk = _hkey(value)
|
|
170
|
+
bucket = buckets.get(hk)
|
|
171
|
+
if bucket is None:
|
|
172
|
+
buckets[hk] = (value, [r])
|
|
173
|
+
order.append(hk)
|
|
174
|
+
else:
|
|
175
|
+
bucket[1].append(r)
|
|
176
|
+
nodes = []
|
|
177
|
+
deeper = keys[1:]
|
|
178
|
+
for hk in order:
|
|
179
|
+
value, grp_rows = buckets[hk]
|
|
180
|
+
path = parent_path + (hk,)
|
|
181
|
+
node = _Node()
|
|
182
|
+
node.depth = depth
|
|
183
|
+
node.key = key
|
|
184
|
+
node.keyTitle = title
|
|
185
|
+
node.value = value
|
|
186
|
+
node.path = path
|
|
187
|
+
node.count = len(grp_rows)
|
|
188
|
+
node.aggregates = self._aggregatesFor(grp_rows)
|
|
189
|
+
if deeper:
|
|
190
|
+
node.children = self._buildLevel(grp_rows, deeper, depth + 1, path)
|
|
191
|
+
node.leaves = None
|
|
192
|
+
else:
|
|
193
|
+
node.children = None
|
|
194
|
+
node.leaves = grp_rows
|
|
195
|
+
self._nodes[path] = node
|
|
196
|
+
nodes.append(node)
|
|
197
|
+
return nodes
|
|
198
|
+
|
|
199
|
+
def _groupRow(self, node):
|
|
200
|
+
return GroupRow(node.depth, node.key, node.keyTitle, node.value,
|
|
201
|
+
node.count, node.aggregates, node.path,
|
|
202
|
+
node.path not in self._collapsed)
|
|
203
|
+
|
|
204
|
+
def _rebuildVisible(self):
|
|
205
|
+
out = []
|
|
206
|
+
for node in self._roots:
|
|
207
|
+
self._flatten(node, out)
|
|
208
|
+
self.visible = out
|
|
209
|
+
|
|
210
|
+
def _flatten(self, node, out):
|
|
211
|
+
out.append(self._groupRow(node))
|
|
212
|
+
if node.path in self._collapsed:
|
|
213
|
+
return
|
|
214
|
+
if node.children is not None:
|
|
215
|
+
for child in node.children:
|
|
216
|
+
self._flatten(child, out)
|
|
217
|
+
else:
|
|
218
|
+
out.extend(node.leaves)
|
|
219
|
+
|
|
220
|
+
# ------------------------------------------------------------------ #
|
|
221
|
+
## Expand / collapse
|
|
222
|
+
# ------------------------------------------------------------------ #
|
|
223
|
+
def isExpanded(self, path):
|
|
224
|
+
return path not in self._collapsed
|
|
225
|
+
|
|
226
|
+
def _descendantCount(self, node):
|
|
227
|
+
"""Number of visible rows shown *below* ``node``'s header when the node
|
|
228
|
+
itself is expanded, honouring the collapse state of its descendants."""
|
|
229
|
+
if node.children is None:
|
|
230
|
+
return len(node.leaves)
|
|
231
|
+
total = 0
|
|
232
|
+
for child in node.children:
|
|
233
|
+
total += 1 # the child's own header
|
|
234
|
+
if child.path not in self._collapsed:
|
|
235
|
+
total += self._descendantCount(child)
|
|
236
|
+
return total
|
|
237
|
+
|
|
238
|
+
def _visibleIndexOf(self, path):
|
|
239
|
+
for i, entry in enumerate(self.visible):
|
|
240
|
+
if isinstance(entry, GroupRow) and entry.path == path:
|
|
241
|
+
return i
|
|
242
|
+
return -1
|
|
243
|
+
|
|
244
|
+
def planToggle(self, path):
|
|
245
|
+
"""Describe the row change a toggle of ``path`` will make, WITHOUT
|
|
246
|
+
applying it: ``(kind, start, count)`` where kind is "insert" (the group
|
|
247
|
+
is collapsed and will expand) or "remove" (expanded, will collapse),
|
|
248
|
+
start is the first affected visible row, count the number of rows.
|
|
249
|
+
|
|
250
|
+
Returns None if ``path`` is not a known group. Call applyToggle() to
|
|
251
|
+
commit the change (between a Qt begin/endInsert|RemoveRows bracket)."""
|
|
252
|
+
node = self._nodes.get(path)
|
|
253
|
+
if node is None:
|
|
254
|
+
return None
|
|
255
|
+
header_idx = self._visibleIndexOf(path)
|
|
256
|
+
if header_idx < 0:
|
|
257
|
+
return None
|
|
258
|
+
count = self._descendantCount(node)
|
|
259
|
+
kind = "insert" if path in self._collapsed else "remove"
|
|
260
|
+
return kind, header_idx + 1, count
|
|
261
|
+
|
|
262
|
+
def applyToggle(self, path):
|
|
263
|
+
"""Flip the collapse state of ``path`` and rebuild the visible list.
|
|
264
|
+
Returns the new expanded state (True if now expanded)."""
|
|
265
|
+
if path in self._collapsed:
|
|
266
|
+
self._collapsed.discard(path)
|
|
267
|
+
expanded = True
|
|
268
|
+
else:
|
|
269
|
+
self._collapsed.add(path)
|
|
270
|
+
expanded = False
|
|
271
|
+
self._rebuildVisible()
|
|
272
|
+
return expanded
|
|
273
|
+
|
|
274
|
+
def toggle(self, path):
|
|
275
|
+
"""Convenience (non-Qt callers): plan + apply in one step. Returns the
|
|
276
|
+
change descriptor from planToggle."""
|
|
277
|
+
plan = self.planToggle(path)
|
|
278
|
+
if plan is not None:
|
|
279
|
+
self.applyToggle(path)
|
|
280
|
+
return plan
|
|
281
|
+
|
|
282
|
+
def setAllExpanded(self, expanded):
|
|
283
|
+
"""Expand (True) or collapse (False) every group."""
|
|
284
|
+
if expanded:
|
|
285
|
+
self._collapsed = set()
|
|
286
|
+
else:
|
|
287
|
+
self._collapsed = set(self._nodes.keys())
|
|
288
|
+
self._rebuildVisible()
|
|
289
|
+
|
|
290
|
+
# ------------------------------------------------------------------ #
|
|
291
|
+
## Aggregate refresh (after an in-place edit of a data row)
|
|
292
|
+
# ------------------------------------------------------------------ #
|
|
293
|
+
def recomputeAggregates(self):
|
|
294
|
+
"""Recompute every group's count + aggregates from its current leaf
|
|
295
|
+
rows (leaves are the same dict objects passed in, so an in-place edit is
|
|
296
|
+
reflected). Cheap relative to a full rebuild and keeps collapse state."""
|
|
297
|
+
for node in self._nodes.values():
|
|
298
|
+
leaves = self._leavesOf(node)
|
|
299
|
+
node.count = len(leaves)
|
|
300
|
+
node.aggregates = self._aggregatesFor(leaves)
|
|
301
|
+
self._rebuildVisible()
|
|
302
|
+
|
|
303
|
+
def _leavesOf(self, node):
|
|
304
|
+
if node.children is None:
|
|
305
|
+
return list(node.leaves)
|
|
306
|
+
out = []
|
|
307
|
+
for child in node.children:
|
|
308
|
+
out.extend(self._leavesOf(child))
|
|
309
|
+
return out
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
########################################################################
|
|
2
|
+
## SPINN DESIGN CODE
|
|
3
|
+
# WEBSITE: customwidgets.org
|
|
4
|
+
########################################################################
|
|
5
|
+
"""Pivot (cross-tab) transform for DataTable Pro.
|
|
6
|
+
|
|
7
|
+
Pure Python - no Qt. A pivot reshapes a flat row set into a matrix: one output
|
|
8
|
+
row per distinct combination of the *index* (row-dimension) keys, one output
|
|
9
|
+
column per distinct value of a single *columns* key, and each cell is the
|
|
10
|
+
*values* key aggregated over the rows in that (row, column) bucket. An optional
|
|
11
|
+
row-total column aggregates across the whole output row.
|
|
12
|
+
|
|
13
|
+
Because the result is just another flat ``(columns, rows)`` pair, the widget
|
|
14
|
+
renders a pivot through the ordinary table path - no special model. The value
|
|
15
|
+
columns are keyed with a namespaced prefix so they can never collide with a real
|
|
16
|
+
data key.
|
|
17
|
+
|
|
18
|
+
Reuses the grouping module's ``aggregate`` reducer (sum/avg/count/min/max/
|
|
19
|
+
first/last or a callable).
|
|
20
|
+
"""
|
|
21
|
+
from .grouping import aggregate
|
|
22
|
+
|
|
23
|
+
#: Prefix for generated pivot value-column keys (namespaced with a NUL so it
|
|
24
|
+
#: cannot clash with a genuine row-dict key).
|
|
25
|
+
PIVOT_COL_PREFIX = "\0pivot\0"
|
|
26
|
+
TOTAL_KEY = PIVOT_COL_PREFIX + "__total__"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _safe_key(value):
|
|
30
|
+
"""A total-orderable sort key for an arbitrary cell value (Nones last)."""
|
|
31
|
+
return (value is None, str(value))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _distinct_sorted(values):
|
|
35
|
+
seen = set()
|
|
36
|
+
out = []
|
|
37
|
+
for v in values:
|
|
38
|
+
k = _safe_key(v)
|
|
39
|
+
if k not in seen:
|
|
40
|
+
seen.add(k)
|
|
41
|
+
out.append(v)
|
|
42
|
+
return sorted(out, key=_safe_key)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def pivot_table(rows, index, columns, values, agg="sum", totals=True,
|
|
46
|
+
fill=None, total_title="Total"):
|
|
47
|
+
"""Build a pivot table.
|
|
48
|
+
|
|
49
|
+
rows iterable of row dicts.
|
|
50
|
+
index row-dimension key, or a list of keys (nested row headers).
|
|
51
|
+
columns a single key whose distinct values become output columns.
|
|
52
|
+
values the key aggregated into each cell.
|
|
53
|
+
agg reducer name or callable (see grouping.aggregate).
|
|
54
|
+
totals add a trailing per-row total column.
|
|
55
|
+
fill value for an empty (row, column) cell (default None).
|
|
56
|
+
total_title header text for the total column.
|
|
57
|
+
|
|
58
|
+
Returns ``(col_defs, result_rows)`` where col_defs is a list of column
|
|
59
|
+
descriptor dicts (index columns keep their key as title; value columns are
|
|
60
|
+
titled by their column value) and result_rows is a list of row dicts."""
|
|
61
|
+
rows = list(rows or [])
|
|
62
|
+
index_keys = [index] if isinstance(index, str) else list(index)
|
|
63
|
+
|
|
64
|
+
col_values = _distinct_sorted(r.get(columns) for r in rows)
|
|
65
|
+
|
|
66
|
+
col_defs = [{"key": k, "title": k} for k in index_keys]
|
|
67
|
+
value_cols = []
|
|
68
|
+
for cv in col_values:
|
|
69
|
+
key = PIVOT_COL_PREFIX + str(cv)
|
|
70
|
+
title = "" if cv is None else str(cv)
|
|
71
|
+
col_defs.append({"key": key, "title": title, "type": "number"})
|
|
72
|
+
value_cols.append((key, _safe_key(cv)))
|
|
73
|
+
if totals:
|
|
74
|
+
col_defs.append({"key": TOTAL_KEY, "title": total_title, "type": "number"})
|
|
75
|
+
|
|
76
|
+
# bucket rows by the index-key tuple, in a deterministic sorted order
|
|
77
|
+
order = []
|
|
78
|
+
buckets = {}
|
|
79
|
+
for r in rows:
|
|
80
|
+
tup = tuple(r.get(k) for k in index_keys)
|
|
81
|
+
hk = tuple(_safe_key(x) for x in tup)
|
|
82
|
+
bucket = buckets.get(hk)
|
|
83
|
+
if bucket is None:
|
|
84
|
+
buckets[hk] = (tup, [r])
|
|
85
|
+
order.append(hk)
|
|
86
|
+
else:
|
|
87
|
+
bucket[1].append(r)
|
|
88
|
+
order.sort()
|
|
89
|
+
|
|
90
|
+
result = []
|
|
91
|
+
for hk in order:
|
|
92
|
+
tup, grp = buckets[hk]
|
|
93
|
+
out = {k: tup[i] for i, k in enumerate(index_keys)}
|
|
94
|
+
for key, cv_key in value_cols:
|
|
95
|
+
cell = [r.get(values) for r in grp if _safe_key(r.get(columns)) == cv_key]
|
|
96
|
+
out[key] = aggregate(agg, cell) if cell else fill
|
|
97
|
+
if totals:
|
|
98
|
+
out[TOTAL_KEY] = aggregate(agg, [r.get(values) for r in grp])
|
|
99
|
+
result.append(out)
|
|
100
|
+
return col_defs, result
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
########################################################################
|
|
2
|
+
## SPINN DESIGN CODE
|
|
3
|
+
# WEBSITE: customwidgets.org
|
|
4
|
+
########################################################################
|
|
5
|
+
"""Data providers for DataTable Pro virtualization.
|
|
6
|
+
|
|
7
|
+
A DataProvider supplies rows to the virtualized model in windows, so the model
|
|
8
|
+
never holds more than what has been scrolled to. Sort and filter are pushed
|
|
9
|
+
DOWN to the provider - it returns rows already sorted/filtered from the source
|
|
10
|
+
(DB, API, ...) - so ordering and filtering are correct across the whole dataset,
|
|
11
|
+
not just the loaded window.
|
|
12
|
+
|
|
13
|
+
sort : (column_key, ascending) tuple, or None
|
|
14
|
+
filter : free-text string, or None
|
|
15
|
+
"""
|
|
16
|
+
import inspect
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DataProvider(object):
|
|
20
|
+
"""Abstract row source. Subclass and implement ``fetch``; override
|
|
21
|
+
``totalRowCount`` when the (filtered) total is known. Return None from
|
|
22
|
+
``totalRowCount`` for streaming/unknown sources (fetch is then called until
|
|
23
|
+
it returns a short/empty window)."""
|
|
24
|
+
|
|
25
|
+
def totalRowCount(self, filter=None):
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
def fetch(self, offset, limit, sort=None, filter=None):
|
|
29
|
+
"""Return up to ``limit`` row dicts starting at ``offset`` of the
|
|
30
|
+
sorted+filtered result. Return a short/empty list at the end."""
|
|
31
|
+
raise NotImplementedError
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ListDataProvider(DataProvider):
|
|
35
|
+
"""In-memory provider with full sort/filter push-down. A working reference
|
|
36
|
+
(and handy for tests / feeding a large materialised dataset lazily). The
|
|
37
|
+
sorted+filtered view is memoised per (sort, filter)."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, rows):
|
|
40
|
+
self._rows = list(rows or [])
|
|
41
|
+
self._cache_key = object() # sentinel: nothing cached yet
|
|
42
|
+
self._cache = None
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def _matches(row, needle):
|
|
46
|
+
return any(needle in str(v).lower() for v in row.values())
|
|
47
|
+
|
|
48
|
+
def _resolved(self, sort, filter):
|
|
49
|
+
key = (sort, filter)
|
|
50
|
+
if key == self._cache_key:
|
|
51
|
+
return self._cache
|
|
52
|
+
if filter:
|
|
53
|
+
needle = str(filter).lower()
|
|
54
|
+
rows = [r for r in self._rows if self._matches(r, needle)]
|
|
55
|
+
else:
|
|
56
|
+
rows = list(self._rows)
|
|
57
|
+
if sort:
|
|
58
|
+
col, ascending = sort
|
|
59
|
+
rows.sort(key=lambda r: (r.get(col) is None, r.get(col)),
|
|
60
|
+
reverse=not ascending)
|
|
61
|
+
self._cache_key = key
|
|
62
|
+
self._cache = rows
|
|
63
|
+
return rows
|
|
64
|
+
|
|
65
|
+
def totalRowCount(self, filter=None):
|
|
66
|
+
return len(self._resolved(None, filter))
|
|
67
|
+
|
|
68
|
+
def fetch(self, offset, limit, sort=None, filter=None):
|
|
69
|
+
return self._resolved(sort, filter)[offset:offset + limit]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class CallableDataProvider(DataProvider):
|
|
73
|
+
"""Adapt plain callables. ``fetch_fn`` may accept ``(offset, limit)`` or
|
|
74
|
+
``(offset, limit, sort, filter)`` - the arity is detected. ``total`` is an
|
|
75
|
+
int, a zero-arg callable, or None for streaming."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, fetch_fn, total=None):
|
|
78
|
+
self._fetch = fetch_fn
|
|
79
|
+
self._total = total
|
|
80
|
+
try:
|
|
81
|
+
params = list(inspect.signature(fetch_fn).parameters.values())
|
|
82
|
+
if any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in params):
|
|
83
|
+
self._arity = 4
|
|
84
|
+
else:
|
|
85
|
+
self._arity = sum(
|
|
86
|
+
1 for p in params
|
|
87
|
+
if p.kind in (inspect.Parameter.POSITIONAL_ONLY,
|
|
88
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD))
|
|
89
|
+
except (ValueError, TypeError):
|
|
90
|
+
self._arity = 2
|
|
91
|
+
|
|
92
|
+
def totalRowCount(self, filter=None):
|
|
93
|
+
return self._total() if callable(self._total) else self._total
|
|
94
|
+
|
|
95
|
+
def fetch(self, offset, limit, sort=None, filter=None):
|
|
96
|
+
if self._arity >= 4:
|
|
97
|
+
return list(self._fetch(offset, limit, sort, filter) or [])
|
|
98
|
+
return list(self._fetch(offset, limit) or [])
|