guidepost 0.3.3__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.3/guidepost.egg-info → guidepost-0.4.0}/PKG-INFO +1 -1
- guidepost-0.4.0/guidepost/__init__.py +13 -0
- {guidepost-0.3.3 → guidepost-0.4.0}/guidepost/guidepost.py +110 -23
- guidepost-0.4.0/guidepost/selection.py +757 -0
- {guidepost-0.3.3 → guidepost-0.4.0}/guidepost/static/guidepost.js +237 -14
- guidepost-0.4.0/guidepost/static/guidepost.js.map +7 -0
- guidepost-0.4.0/guidepost/version.py +2 -0
- {guidepost-0.3.3 → guidepost-0.4.0/guidepost.egg-info}/PKG-INFO +1 -1
- {guidepost-0.3.3 → guidepost-0.4.0}/guidepost.egg-info/SOURCES.txt +3 -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.3/guidepost/__init__.py +0 -2
- guidepost-0.3.3/guidepost/static/guidepost.js.map +0 -7
- guidepost-0.3.3/guidepost/version.py +0 -2
- {guidepost-0.3.3 → guidepost-0.4.0}/LICENSE +0 -0
- {guidepost-0.3.3 → guidepost-0.4.0}/MANIFEST.in +0 -0
- {guidepost-0.3.3 → guidepost-0.4.0}/README.md +0 -0
- {guidepost-0.3.3 → guidepost-0.4.0}/guidepost/aggregation.py +0 -0
- {guidepost-0.3.3 → guidepost-0.4.0}/guidepost/node_layout.py +0 -0
- {guidepost-0.3.3 → guidepost-0.4.0}/guidepost/seriation.py +0 -0
- {guidepost-0.3.3 → guidepost-0.4.0}/guidepost/static/trailmark.js +0 -0
- {guidepost-0.3.3 → guidepost-0.4.0}/guidepost/static/trailmark.js.map +0 -0
- {guidepost-0.3.3 → guidepost-0.4.0}/guidepost/trailmark.py +0 -0
- {guidepost-0.3.3 → guidepost-0.4.0}/guidepost/utils.py +0 -0
- {guidepost-0.3.3 → guidepost-0.4.0}/guidepost.egg-info/dependency_links.txt +0 -0
- {guidepost-0.3.3 → guidepost-0.4.0}/guidepost.egg-info/requires.txt +0 -0
- {guidepost-0.3.3 → guidepost-0.4.0}/guidepost.egg-info/top_level.txt +0 -0
- {guidepost-0.3.3 → guidepost-0.4.0}/pyproject.toml +0 -0
- {guidepost-0.3.3 → guidepost-0.4.0}/setup.cfg +0 -0
- {guidepost-0.3.3 → guidepost-0.4.0}/setup.py +0 -0
- {guidepost-0.3.3 → guidepost-0.4.0}/tests/test_aggregation.py +0 -0
- {guidepost-0.3.3 → guidepost-0.4.0}/tests/test_custom_sort.py +0 -0
- {guidepost-0.3.3 → guidepost-0.4.0}/tests/test_list_parsing.py +0 -0
- {guidepost-0.3.3 → guidepost-0.4.0}/tests/test_node_layout.py +0 -0
- {guidepost-0.3.3 → guidepost-0.4.0}/tests/test_seriation.py +0 -0
- {guidepost-0.3.3 → 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
|
|
@@ -113,6 +119,10 @@ class Guidepost(anywidget.AnyWidget):
|
|
|
113
119
|
# is loaded — keeping it None until then so widgets constructed
|
|
114
120
|
# without data don't pay the import/registration cost.
|
|
115
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 = []
|
|
116
126
|
# Route JS-originated request_aggregation / request_brush_indices
|
|
117
127
|
# messages here. anywidget calls the handler with (widget, content,
|
|
118
128
|
# buffers); content is the parsed JSON payload. Note: do NOT name
|
|
@@ -120,6 +130,8 @@ class Guidepost(anywidget.AnyWidget):
|
|
|
120
130
|
# class's comm dispatcher, and shadowing it eats every incoming
|
|
121
131
|
# message before it reaches on_msg callbacks.
|
|
122
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")
|
|
123
135
|
if records is not None:
|
|
124
136
|
self.records = records
|
|
125
137
|
|
|
@@ -133,11 +145,45 @@ class Guidepost(anywidget.AnyWidget):
|
|
|
133
145
|
|
|
134
146
|
@property
|
|
135
147
|
def records(self):
|
|
136
|
-
return self.
|
|
148
|
+
return self._vis_bytes
|
|
137
149
|
|
|
138
150
|
@records.setter
|
|
139
151
|
def records(self, df):
|
|
140
|
-
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]])
|
|
141
187
|
|
|
142
188
|
def load_data(self, in_df):
|
|
143
189
|
'''
|
|
@@ -239,7 +285,7 @@ class Guidepost(anywidget.AnyWidget):
|
|
|
239
285
|
sink = pa.BufferOutputStream()
|
|
240
286
|
with pa.ipc.new_stream(sink, table.schema) as writer:
|
|
241
287
|
writer.write_table(table)
|
|
242
|
-
|
|
288
|
+
payload = sink.getvalue().to_pybytes()
|
|
243
289
|
|
|
244
290
|
# Register the cleaned frame with DuckDB so JS-originated aggregation
|
|
245
291
|
# requests have a current source. Use the cleaned o_df (not in_cpy)
|
|
@@ -249,7 +295,11 @@ class Guidepost(anywidget.AnyWidget):
|
|
|
249
295
|
else:
|
|
250
296
|
self._agg_engine.replace(o_df)
|
|
251
297
|
|
|
252
|
-
|
|
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
|
|
253
303
|
|
|
254
304
|
def _detect_list_columns(self, df):
|
|
255
305
|
'''
|
|
@@ -312,10 +362,45 @@ class Guidepost(anywidget.AnyWidget):
|
|
|
312
362
|
|
|
313
363
|
@property
|
|
314
364
|
def selection(self):
|
|
315
|
-
#
|
|
316
|
-
#
|
|
317
|
-
#
|
|
318
|
-
|
|
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)
|
|
319
404
|
|
|
320
405
|
def retrieve_selected_data(self):
|
|
321
406
|
if self.cached_records_df is None:
|
|
@@ -351,6 +436,8 @@ class Guidepost(anywidget.AnyWidget):
|
|
|
351
436
|
)
|
|
352
437
|
grid = self._handle_aggregation(content)
|
|
353
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)
|
|
354
441
|
elif msg_type == "request_brush_indices":
|
|
355
442
|
if self._agg_engine is None:
|
|
356
443
|
raise RuntimeError("Aggregation engine not initialized.")
|