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.
Files changed (36) hide show
  1. {guidepost-0.3.3/guidepost.egg-info → guidepost-0.4.0}/PKG-INFO +1 -1
  2. guidepost-0.4.0/guidepost/__init__.py +13 -0
  3. {guidepost-0.3.3 → guidepost-0.4.0}/guidepost/guidepost.py +110 -23
  4. guidepost-0.4.0/guidepost/selection.py +757 -0
  5. {guidepost-0.3.3 → guidepost-0.4.0}/guidepost/static/guidepost.js +237 -14
  6. guidepost-0.4.0/guidepost/static/guidepost.js.map +7 -0
  7. guidepost-0.4.0/guidepost/version.py +2 -0
  8. {guidepost-0.3.3 → guidepost-0.4.0/guidepost.egg-info}/PKG-INFO +1 -1
  9. {guidepost-0.3.3 → guidepost-0.4.0}/guidepost.egg-info/SOURCES.txt +3 -0
  10. guidepost-0.4.0/tests/test_selection_api.py +304 -0
  11. guidepost-0.4.0/tests/test_vis_transfer.py +112 -0
  12. guidepost-0.3.3/guidepost/__init__.py +0 -2
  13. guidepost-0.3.3/guidepost/static/guidepost.js.map +0 -7
  14. guidepost-0.3.3/guidepost/version.py +0 -2
  15. {guidepost-0.3.3 → guidepost-0.4.0}/LICENSE +0 -0
  16. {guidepost-0.3.3 → guidepost-0.4.0}/MANIFEST.in +0 -0
  17. {guidepost-0.3.3 → guidepost-0.4.0}/README.md +0 -0
  18. {guidepost-0.3.3 → guidepost-0.4.0}/guidepost/aggregation.py +0 -0
  19. {guidepost-0.3.3 → guidepost-0.4.0}/guidepost/node_layout.py +0 -0
  20. {guidepost-0.3.3 → guidepost-0.4.0}/guidepost/seriation.py +0 -0
  21. {guidepost-0.3.3 → guidepost-0.4.0}/guidepost/static/trailmark.js +0 -0
  22. {guidepost-0.3.3 → guidepost-0.4.0}/guidepost/static/trailmark.js.map +0 -0
  23. {guidepost-0.3.3 → guidepost-0.4.0}/guidepost/trailmark.py +0 -0
  24. {guidepost-0.3.3 → guidepost-0.4.0}/guidepost/utils.py +0 -0
  25. {guidepost-0.3.3 → guidepost-0.4.0}/guidepost.egg-info/dependency_links.txt +0 -0
  26. {guidepost-0.3.3 → guidepost-0.4.0}/guidepost.egg-info/requires.txt +0 -0
  27. {guidepost-0.3.3 → guidepost-0.4.0}/guidepost.egg-info/top_level.txt +0 -0
  28. {guidepost-0.3.3 → guidepost-0.4.0}/pyproject.toml +0 -0
  29. {guidepost-0.3.3 → guidepost-0.4.0}/setup.cfg +0 -0
  30. {guidepost-0.3.3 → guidepost-0.4.0}/setup.py +0 -0
  31. {guidepost-0.3.3 → guidepost-0.4.0}/tests/test_aggregation.py +0 -0
  32. {guidepost-0.3.3 → guidepost-0.4.0}/tests/test_custom_sort.py +0 -0
  33. {guidepost-0.3.3 → guidepost-0.4.0}/tests/test_list_parsing.py +0 -0
  34. {guidepost-0.3.3 → guidepost-0.4.0}/tests/test_node_layout.py +0 -0
  35. {guidepost-0.3.3 → guidepost-0.4.0}/tests/test_seriation.py +0 -0
  36. {guidepost-0.3.3 → guidepost-0.4.0}/tutorials/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: guidepost
3
- Version: 0.3.3
3
+ Version: 0.4.0
4
4
  Summary: Guidepost. An overview visualization for understanding supercomputer queue data.
5
5
  Home-page: https://github.com/cscully-allison/guidepost
6
6
  Author: Connor Scully-Allison
@@ -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._vis_data
148
+ return self._vis_bytes
137
149
 
138
150
  @records.setter
139
151
  def records(self, df):
140
- self._vis_data = self.load_data(df)
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
- self._vis_data = sink.getvalue().to_pybytes()
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
- return self._vis_data
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
- # `selection` is intentionally an object wrapper rather than the bare
316
- # DataFrame so additional selection metadata can be hung off it later
317
- # without breaking callers. The DataFrame lives on `.dataframe`.
318
- return Selection(self.retrieve_selected_data())
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.")