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.
Files changed (36) hide show
  1. {guidepost-0.3.2/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.2 → guidepost-0.4.0}/guidepost/guidepost.py +136 -24
  4. guidepost-0.4.0/guidepost/selection.py +757 -0
  5. {guidepost-0.3.2 → guidepost-0.4.0}/guidepost/seriation.py +63 -10
  6. {guidepost-0.3.2 → guidepost-0.4.0}/guidepost/static/guidepost.js +300 -33
  7. guidepost-0.4.0/guidepost/static/guidepost.js.map +7 -0
  8. guidepost-0.4.0/guidepost/version.py +2 -0
  9. {guidepost-0.3.2 → guidepost-0.4.0/guidepost.egg-info}/PKG-INFO +1 -1
  10. {guidepost-0.3.2 → guidepost-0.4.0}/guidepost.egg-info/SOURCES.txt +4 -0
  11. guidepost-0.4.0/tests/test_custom_sort.py +144 -0
  12. guidepost-0.4.0/tests/test_selection_api.py +304 -0
  13. guidepost-0.4.0/tests/test_vis_transfer.py +112 -0
  14. guidepost-0.3.2/guidepost/__init__.py +0 -2
  15. guidepost-0.3.2/guidepost/static/guidepost.js.map +0 -7
  16. guidepost-0.3.2/guidepost/version.py +0 -2
  17. {guidepost-0.3.2 → guidepost-0.4.0}/LICENSE +0 -0
  18. {guidepost-0.3.2 → guidepost-0.4.0}/MANIFEST.in +0 -0
  19. {guidepost-0.3.2 → guidepost-0.4.0}/README.md +0 -0
  20. {guidepost-0.3.2 → guidepost-0.4.0}/guidepost/aggregation.py +0 -0
  21. {guidepost-0.3.2 → guidepost-0.4.0}/guidepost/node_layout.py +0 -0
  22. {guidepost-0.3.2 → guidepost-0.4.0}/guidepost/static/trailmark.js +0 -0
  23. {guidepost-0.3.2 → guidepost-0.4.0}/guidepost/static/trailmark.js.map +0 -0
  24. {guidepost-0.3.2 → guidepost-0.4.0}/guidepost/trailmark.py +0 -0
  25. {guidepost-0.3.2 → guidepost-0.4.0}/guidepost/utils.py +0 -0
  26. {guidepost-0.3.2 → guidepost-0.4.0}/guidepost.egg-info/dependency_links.txt +0 -0
  27. {guidepost-0.3.2 → guidepost-0.4.0}/guidepost.egg-info/requires.txt +0 -0
  28. {guidepost-0.3.2 → guidepost-0.4.0}/guidepost.egg-info/top_level.txt +0 -0
  29. {guidepost-0.3.2 → guidepost-0.4.0}/pyproject.toml +0 -0
  30. {guidepost-0.3.2 → guidepost-0.4.0}/setup.cfg +0 -0
  31. {guidepost-0.3.2 → guidepost-0.4.0}/setup.py +0 -0
  32. {guidepost-0.3.2 → guidepost-0.4.0}/tests/test_aggregation.py +0 -0
  33. {guidepost-0.3.2 → guidepost-0.4.0}/tests/test_list_parsing.py +0 -0
  34. {guidepost-0.3.2 → guidepost-0.4.0}/tests/test_node_layout.py +0 -0
  35. {guidepost-0.3.2 → guidepost-0.4.0}/tests/test_seriation.py +0 -0
  36. {guidepost-0.3.2 → 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.2
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
@@ -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._vis_data
148
+ return self._vis_bytes
113
149
 
114
150
  @records.setter
115
151
  def records(self, df):
116
- 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]])
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(o_df, self._effective_list_columns)
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
- self._vis_data = sink.getvalue().to_pybytes()
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
- 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
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
- # `selection` is intentionally an object wrapper rather than the bare
291
- # DataFrame so additional selection metadata can be hung off it later
292
- # without breaking callers. The DataFrame lives on `.dataframe`.
293
- 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)
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.")