guidepost 0.3.2__tar.gz → 0.4.0__tar.gz
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.
- {guidepost-0.3.2/guidepost.egg-info → guidepost-0.4.0}/PKG-INFO +1 -1
- guidepost-0.4.0/guidepost/__init__.py +13 -0
- {guidepost-0.3.2 → guidepost-0.4.0}/guidepost/guidepost.py +136 -24
- guidepost-0.4.0/guidepost/selection.py +757 -0
- {guidepost-0.3.2 → guidepost-0.4.0}/guidepost/seriation.py +63 -10
- {guidepost-0.3.2 → guidepost-0.4.0}/guidepost/static/guidepost.js +300 -33
- guidepost-0.4.0/guidepost/static/guidepost.js.map +7 -0
- guidepost-0.4.0/guidepost/version.py +2 -0
- {guidepost-0.3.2 → guidepost-0.4.0/guidepost.egg-info}/PKG-INFO +1 -1
- {guidepost-0.3.2 → guidepost-0.4.0}/guidepost.egg-info/SOURCES.txt +4 -0
- guidepost-0.4.0/tests/test_custom_sort.py +144 -0
- guidepost-0.4.0/tests/test_selection_api.py +304 -0
- guidepost-0.4.0/tests/test_vis_transfer.py +112 -0
- guidepost-0.3.2/guidepost/__init__.py +0 -2
- guidepost-0.3.2/guidepost/static/guidepost.js.map +0 -7
- guidepost-0.3.2/guidepost/version.py +0 -2
- {guidepost-0.3.2 → guidepost-0.4.0}/LICENSE +0 -0
- {guidepost-0.3.2 → guidepost-0.4.0}/MANIFEST.in +0 -0
- {guidepost-0.3.2 → guidepost-0.4.0}/README.md +0 -0
- {guidepost-0.3.2 → guidepost-0.4.0}/guidepost/aggregation.py +0 -0
- {guidepost-0.3.2 → guidepost-0.4.0}/guidepost/node_layout.py +0 -0
- {guidepost-0.3.2 → guidepost-0.4.0}/guidepost/static/trailmark.js +0 -0
- {guidepost-0.3.2 → guidepost-0.4.0}/guidepost/static/trailmark.js.map +0 -0
- {guidepost-0.3.2 → guidepost-0.4.0}/guidepost/trailmark.py +0 -0
- {guidepost-0.3.2 → guidepost-0.4.0}/guidepost/utils.py +0 -0
- {guidepost-0.3.2 → guidepost-0.4.0}/guidepost.egg-info/dependency_links.txt +0 -0
- {guidepost-0.3.2 → guidepost-0.4.0}/guidepost.egg-info/requires.txt +0 -0
- {guidepost-0.3.2 → guidepost-0.4.0}/guidepost.egg-info/top_level.txt +0 -0
- {guidepost-0.3.2 → guidepost-0.4.0}/pyproject.toml +0 -0
- {guidepost-0.3.2 → guidepost-0.4.0}/setup.cfg +0 -0
- {guidepost-0.3.2 → guidepost-0.4.0}/setup.py +0 -0
- {guidepost-0.3.2 → guidepost-0.4.0}/tests/test_aggregation.py +0 -0
- {guidepost-0.3.2 → guidepost-0.4.0}/tests/test_list_parsing.py +0 -0
- {guidepost-0.3.2 → guidepost-0.4.0}/tests/test_node_layout.py +0 -0
- {guidepost-0.3.2 → guidepost-0.4.0}/tests/test_seriation.py +0 -0
- {guidepost-0.3.2 → guidepost-0.4.0}/tutorials/__init__.py +0 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .guidepost import Guidepost
|
|
2
|
+
from .trailmark import Trailmark
|
|
3
|
+
from .selection import Selection, Predicate, Clause, DistributionReport, CategoricalReport
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"Guidepost",
|
|
7
|
+
"Trailmark",
|
|
8
|
+
"Selection",
|
|
9
|
+
"Predicate",
|
|
10
|
+
"Clause",
|
|
11
|
+
"DistributionReport",
|
|
12
|
+
"CategoricalReport",
|
|
13
|
+
]
|
|
@@ -7,9 +7,11 @@ import ast
|
|
|
7
7
|
import json
|
|
8
8
|
import os
|
|
9
9
|
import sys
|
|
10
|
+
from datetime import datetime
|
|
10
11
|
from .utils import validate_and_clean_dataframe, extract_summary_statistics, convert_date_id_columns
|
|
11
12
|
from .seriation import compute_category_ordering
|
|
12
13
|
from .aggregation import AggregationEngine
|
|
14
|
+
from .selection import Selection
|
|
13
15
|
|
|
14
16
|
# Guidepost needs two categorical roles — facet_by ("Group By") and categorical
|
|
15
17
|
# ("Categorical Bar Chart"). When a dataset has fewer than two usable categorical
|
|
@@ -22,21 +24,6 @@ SYNTHETIC_FACET_COL = "__gp_no_grouping__"
|
|
|
22
24
|
SYNTHETIC_FACET_VALUE = "All records"
|
|
23
25
|
|
|
24
26
|
|
|
25
|
-
class Selection:
|
|
26
|
-
"""Wrapper around the records selected in the widget.
|
|
27
|
-
|
|
28
|
-
The selected DataFrame is exposed as `.dataframe`. This indirection
|
|
29
|
-
leaves room to attach further selection metadata in the future without
|
|
30
|
-
changing the `gp.selection` access pattern.
|
|
31
|
-
"""
|
|
32
|
-
|
|
33
|
-
def __init__(self, dataframe):
|
|
34
|
-
self.dataframe = dataframe
|
|
35
|
-
|
|
36
|
-
def __repr__(self):
|
|
37
|
-
return f"Selection(dataframe={self.dataframe!r})"
|
|
38
|
-
|
|
39
|
-
|
|
40
27
|
class Guidepost(anywidget.AnyWidget):
|
|
41
28
|
|
|
42
29
|
_esm = os.path.join(os.path.dirname(__file__), "static", "guidepost.js")
|
|
@@ -46,6 +33,18 @@ class Guidepost(anywidget.AnyWidget):
|
|
|
46
33
|
# same data ~5× smaller and skips the o_df.astype(object).where() cast
|
|
47
34
|
# that dominated load_data for >100k rows.
|
|
48
35
|
_vis_data = traitlets.Bytes(b'').tag(sync=True)
|
|
36
|
+
# Large payloads (hundreds of MB for ~400k rows) exceed what notebook
|
|
37
|
+
# frontends such as VS Code reliably accept as a single comm message — the
|
|
38
|
+
# frontend then receives an empty buffer and draws no charts. Payloads above
|
|
39
|
+
# `_INLINE_MAX_BYTES` therefore skip the `_vis_data` trait entirely and are
|
|
40
|
+
# pulled by the frontend in `_CHUNK_BYTES` pieces via `request_vis_data`.
|
|
41
|
+
# `_vis_transfer` describes the pending transfer ("{}" = inline in _vis_data).
|
|
42
|
+
_vis_transfer = traitlets.Unicode("{}").tag(sync=True)
|
|
43
|
+
_INLINE_MAX_BYTES = 8 * 1024 * 1024
|
|
44
|
+
_CHUNK_BYTES = 4 * 1024 * 1024
|
|
45
|
+
# Full Arrow IPC bytes of the loaded frame, regardless of transport mode.
|
|
46
|
+
_vis_bytes = b''
|
|
47
|
+
_vis_transfer_id = 0
|
|
49
48
|
|
|
50
49
|
vis_configs = None
|
|
51
50
|
_vis_configs = traitlets.Unicode("{}").tag(sync=True)
|
|
@@ -58,6 +57,13 @@ class Guidepost(anywidget.AnyWidget):
|
|
|
58
57
|
|
|
59
58
|
suppress_warnings = False
|
|
60
59
|
|
|
60
|
+
# Cap on the retained selection history. The analyst cycles through many
|
|
61
|
+
# brushes in a session; keeping the last N snapshots bounds memory while
|
|
62
|
+
# still giving `selection.history` useful provenance. Snapshots are cheap —
|
|
63
|
+
# only the gp_idx list + timestamp are stored; the DataFrame and predicate
|
|
64
|
+
# materialize lazily on access.
|
|
65
|
+
_SELECTION_HISTORY_MAX = 50
|
|
66
|
+
|
|
61
67
|
def __init__(self, *args, **kwargs):
|
|
62
68
|
# `records` is a property (not a traitlet) so HasTraits silently
|
|
63
69
|
# drops it from **kwargs. Pull it out before super() and apply via
|
|
@@ -77,6 +83,16 @@ class Guidepost(anywidget.AnyWidget):
|
|
|
77
83
|
# identifiers the name heuristic misses, or to override an unwanted
|
|
78
84
|
# auto-classification. Mirrors list_columns; pulled before super().
|
|
79
85
|
categorical_columns = kwargs.pop("categorical_columns", None)
|
|
86
|
+
# Optional override for list-column x-axis ordering, replacing the
|
|
87
|
+
# built-in node-layout / seriation heuristics — for ordinal-but-not-
|
|
88
|
+
# hierarchical values the heuristics can't order (e.g. nodes named
|
|
89
|
+
# "1","2","3"). Either a single callable applied to every list column,
|
|
90
|
+
# or a {column: callable} dict for per-column control. Each callable is
|
|
91
|
+
# a full-list transform: it receives the list of distinct category
|
|
92
|
+
# values and returns them reordered. A callable can't be JSON-synced,
|
|
93
|
+
# so like list_columns this is a plain Python attribute (not a trait);
|
|
94
|
+
# it runs in load_data and only its *result* ships to the frontend.
|
|
95
|
+
sort_by = kwargs.pop("sort_by", None)
|
|
80
96
|
super().__init__(*args, **kwargs)
|
|
81
97
|
self._categorical_columns = set(categorical_columns) if categorical_columns else set()
|
|
82
98
|
self._list_columns = list(list_columns) if list_columns else []
|
|
@@ -85,10 +101,28 @@ class Guidepost(anywidget.AnyWidget):
|
|
|
85
101
|
# before the first load don't fail.
|
|
86
102
|
self._effective_list_columns = list(self._list_columns)
|
|
87
103
|
self._list_delimiter = list_delimiter or ","
|
|
104
|
+
# Validate up front so a bad sort_by fails at construction, not silently
|
|
105
|
+
# at render. None | callable | {str: callable} are the only valid shapes.
|
|
106
|
+
if sort_by is not None:
|
|
107
|
+
if isinstance(sort_by, dict):
|
|
108
|
+
bad = [k for k, v in sort_by.items() if not callable(v)]
|
|
109
|
+
if bad:
|
|
110
|
+
raise TypeError(
|
|
111
|
+
"sort_by dict values must be callables; non-callable "
|
|
112
|
+
f"value(s) for column(s): {bad}")
|
|
113
|
+
elif not callable(sort_by):
|
|
114
|
+
raise TypeError(
|
|
115
|
+
"sort_by must be a callable or a {column: callable} dict, "
|
|
116
|
+
f"got {type(sort_by).__name__}")
|
|
117
|
+
self._sort_by = sort_by
|
|
88
118
|
# DuckDB-backed aggregator. Lazily created when the first DataFrame
|
|
89
119
|
# is loaded — keeping it None until then so widgets constructed
|
|
90
120
|
# without data don't pay the import/registration cost.
|
|
91
121
|
self._agg_engine = None
|
|
122
|
+
# Provenance: every distinct non-empty selection is snapshotted here so
|
|
123
|
+
# `selection.history` can surface past selections. Populated by the
|
|
124
|
+
# `selected_records` observer below.
|
|
125
|
+
self._selection_history = []
|
|
92
126
|
# Route JS-originated request_aggregation / request_brush_indices
|
|
93
127
|
# messages here. anywidget calls the handler with (widget, content,
|
|
94
128
|
# buffers); content is the parsed JSON payload. Note: do NOT name
|
|
@@ -96,6 +130,8 @@ class Guidepost(anywidget.AnyWidget):
|
|
|
96
130
|
# class's comm dispatcher, and shadowing it eats every incoming
|
|
97
131
|
# message before it reaches on_msg callbacks.
|
|
98
132
|
self.on_msg(self._dispatch_custom_msg)
|
|
133
|
+
# Record each selection as it syncs back from the frontend.
|
|
134
|
+
self.observe(self._record_selection_history, names="selected_records")
|
|
99
135
|
if records is not None:
|
|
100
136
|
self.records = records
|
|
101
137
|
|
|
@@ -109,11 +145,45 @@ class Guidepost(anywidget.AnyWidget):
|
|
|
109
145
|
|
|
110
146
|
@property
|
|
111
147
|
def records(self):
|
|
112
|
-
return self.
|
|
148
|
+
return self._vis_bytes
|
|
113
149
|
|
|
114
150
|
@records.setter
|
|
115
151
|
def records(self, df):
|
|
116
|
-
self.
|
|
152
|
+
self.load_data(df)
|
|
153
|
+
|
|
154
|
+
def _publish_vis_data(self, payload):
|
|
155
|
+
"""Expose Arrow bytes to the frontend, inline if small, else chunked."""
|
|
156
|
+
self._vis_bytes = payload
|
|
157
|
+
self._vis_transfer_id += 1
|
|
158
|
+
size = len(payload)
|
|
159
|
+
with self.hold_sync():
|
|
160
|
+
if size <= self._INLINE_MAX_BYTES:
|
|
161
|
+
self._vis_data = payload
|
|
162
|
+
self._vis_transfer = "{}"
|
|
163
|
+
else:
|
|
164
|
+
self._vis_data = b''
|
|
165
|
+
self._vis_transfer = json.dumps({
|
|
166
|
+
"id": self._vis_transfer_id,
|
|
167
|
+
"size": size,
|
|
168
|
+
"chunks": -(-size // self._CHUNK_BYTES),
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
def _send_vis_data_chunks(self, content):
|
|
172
|
+
"""Reply to `request_vis_data` with the payload as binary chunks."""
|
|
173
|
+
if content.get("transfer_id") != self._vis_transfer_id or not self._vis_bytes:
|
|
174
|
+
raise RuntimeError("stale or unknown data transfer; reload the widget view")
|
|
175
|
+
view = memoryview(self._vis_bytes)
|
|
176
|
+
size = len(view)
|
|
177
|
+
total = -(-size // self._CHUNK_BYTES)
|
|
178
|
+
for index in range(total):
|
|
179
|
+
start = index * self._CHUNK_BYTES
|
|
180
|
+
self.send({
|
|
181
|
+
"type": "vis_data_chunk",
|
|
182
|
+
"request_id": content.get("request_id"),
|
|
183
|
+
"transfer_id": self._vis_transfer_id,
|
|
184
|
+
"index": index,
|
|
185
|
+
"total": total,
|
|
186
|
+
}, buffers=[view[start:start + self._CHUNK_BYTES]])
|
|
117
187
|
|
|
118
188
|
def load_data(self, in_df):
|
|
119
189
|
'''
|
|
@@ -194,7 +264,8 @@ class Guidepost(anywidget.AnyWidget):
|
|
|
194
264
|
# that retains every node); arbitrary categories fall back to spectral
|
|
195
265
|
# seriation (ships `category_score` for the column cap). Both share
|
|
196
266
|
# `category_order` for the kept/ordered sequence.
|
|
197
|
-
ordering = compute_category_ordering(
|
|
267
|
+
ordering = compute_category_ordering(
|
|
268
|
+
o_df, self._effective_list_columns, sort_overrides=self._sort_by)
|
|
198
269
|
for col, info in ordering.items():
|
|
199
270
|
if col not in summary_stats:
|
|
200
271
|
continue
|
|
@@ -214,7 +285,7 @@ class Guidepost(anywidget.AnyWidget):
|
|
|
214
285
|
sink = pa.BufferOutputStream()
|
|
215
286
|
with pa.ipc.new_stream(sink, table.schema) as writer:
|
|
216
287
|
writer.write_table(table)
|
|
217
|
-
|
|
288
|
+
payload = sink.getvalue().to_pybytes()
|
|
218
289
|
|
|
219
290
|
# Register the cleaned frame with DuckDB so JS-originated aggregation
|
|
220
291
|
# requests have a current source. Use the cleaned o_df (not in_cpy)
|
|
@@ -224,7 +295,11 @@ class Guidepost(anywidget.AnyWidget):
|
|
|
224
295
|
else:
|
|
225
296
|
self._agg_engine.replace(o_df)
|
|
226
297
|
|
|
227
|
-
|
|
298
|
+
# Publish last: all validation/summary work above has succeeded, and a
|
|
299
|
+
# large payload is announced to the frontend only once the engine above
|
|
300
|
+
# can answer its follow-up requests.
|
|
301
|
+
self._publish_vis_data(payload)
|
|
302
|
+
return payload
|
|
228
303
|
|
|
229
304
|
def _detect_list_columns(self, df):
|
|
230
305
|
'''
|
|
@@ -287,10 +362,45 @@ class Guidepost(anywidget.AnyWidget):
|
|
|
287
362
|
|
|
288
363
|
@property
|
|
289
364
|
def selection(self):
|
|
290
|
-
#
|
|
291
|
-
#
|
|
292
|
-
#
|
|
293
|
-
|
|
365
|
+
# A first-class Selection bound to this widget for the *current*
|
|
366
|
+
# selected_records. It exposes `.dataframe` (unchanged access pattern)
|
|
367
|
+
# plus `.predicate`, `.to_pandas()/.to_sql_where()/.to_altair_filter()`,
|
|
368
|
+
# `.summary()`, `.describe_distribution(col)` and `.history`. Lazy: the
|
|
369
|
+
# DataFrame and predicate materialize on first access.
|
|
370
|
+
return Selection(self, self._current_selected_idx())
|
|
371
|
+
|
|
372
|
+
def _current_selected_idx(self):
|
|
373
|
+
"""Parse the current selected_records trait into a list of gp_idx."""
|
|
374
|
+
try:
|
|
375
|
+
return list(json.loads(self.selected_records))
|
|
376
|
+
except (ValueError, TypeError):
|
|
377
|
+
return []
|
|
378
|
+
|
|
379
|
+
def _record_selection_history(self, change):
|
|
380
|
+
"""Snapshot each new, non-empty, changed selection for provenance.
|
|
381
|
+
|
|
382
|
+
Cheap by construction — stores only the gp_idx list + a timestamp; the
|
|
383
|
+
DataFrame and predicate are derived lazily if/when the snapshot is read.
|
|
384
|
+
"""
|
|
385
|
+
try:
|
|
386
|
+
idx = list(json.loads(change.get("new", "[]")))
|
|
387
|
+
except (ValueError, TypeError):
|
|
388
|
+
return
|
|
389
|
+
if not idx:
|
|
390
|
+
return
|
|
391
|
+
# Skip no-op repeats (same set of records as the last snapshot).
|
|
392
|
+
if self._selection_history and self._selection_history[-1].selected_idx == idx:
|
|
393
|
+
return
|
|
394
|
+
snap = Selection(
|
|
395
|
+
self, idx,
|
|
396
|
+
timestamp=datetime.now(),
|
|
397
|
+
history_index=len(self._selection_history),
|
|
398
|
+
)
|
|
399
|
+
self._selection_history.append(snap)
|
|
400
|
+
if len(self._selection_history) > self._SELECTION_HISTORY_MAX:
|
|
401
|
+
# Drop the oldest; keep history_index stable-ish for display by not
|
|
402
|
+
# renumbering (indices remain monotonically increasing).
|
|
403
|
+
self._selection_history.pop(0)
|
|
294
404
|
|
|
295
405
|
def retrieve_selected_data(self):
|
|
296
406
|
if self.cached_records_df is None:
|
|
@@ -326,6 +436,8 @@ class Guidepost(anywidget.AnyWidget):
|
|
|
326
436
|
)
|
|
327
437
|
grid = self._handle_aggregation(content)
|
|
328
438
|
self.send({"type": "aggregation_result", "request_id": request_id, "grid": grid})
|
|
439
|
+
elif msg_type == "request_vis_data":
|
|
440
|
+
self._send_vis_data_chunks(content)
|
|
329
441
|
elif msg_type == "request_brush_indices":
|
|
330
442
|
if self._agg_engine is None:
|
|
331
443
|
raise RuntimeError("Aggregation engine not initialized.")
|