jstdata 0.2.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.
@@ -0,0 +1,502 @@
1
+ """Discover step — metric candidates for session entities, with a preview chart."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Optional
7
+
8
+ from textual import on, work
9
+ from textual.app import ComposeResult
10
+ from textual.binding import Binding
11
+ from textual.containers import Horizontal, ScrollableContainer, Vertical
12
+ from textual.screen import Screen
13
+ from textual.widgets import Input, Label, ListItem, ListView, Static
14
+
15
+ from ..client import JSTDataClient
16
+ from ..models import Metric, TimeSeries
17
+ from ..session import Session
18
+ from .base import StepArgument, StepBinding, StepSpec, label_for, register
19
+ from .chart import LABEL_WIDTH, pick_frequency, pick_series_per_entity, render_preview
20
+
21
+ PAGE_SIZE = 20
22
+ PREVIEW_TAIL = 20
23
+ MODES = ("union", "intersect")
24
+
25
+
26
+ class EntityRow(ListItem):
27
+ can_focus = False
28
+
29
+ def __init__(self, entity_id: str, caption: str) -> None:
30
+ super().__init__()
31
+ self.entity_id = entity_id
32
+ self.caption = caption
33
+
34
+ def compose(self) -> ComposeResult:
35
+ yield Label(self.caption)
36
+
37
+
38
+ class MetricRow(ListItem):
39
+ def __init__(self, metric: Metric, staged: bool = False) -> None:
40
+ super().__init__()
41
+ self.metric = metric
42
+ self.staged = staged
43
+
44
+ def compose(self) -> ComposeResult:
45
+ mark = "● " if self.staged else " "
46
+ yield Label(f"{mark}{self.metric.name} [{self.metric.id}]")
47
+
48
+
49
+ class DiscoverScreen(Screen):
50
+ """Entities roster, candidate metrics, and a recent-observation bar preview."""
51
+
52
+ BINDINGS = [
53
+ Binding("slash", "focus_filter", "Filter metrics", key_display="/"),
54
+ Binding("escape", "leave_filter", "Leave filter", show=False),
55
+ Binding("tab", "cycle_panes", "Switch list / preview", priority=True),
56
+ Binding(
57
+ "shift+tab",
58
+ "cycle_panes",
59
+ "Switch panes reverse",
60
+ show=False,
61
+ priority=True,
62
+ ),
63
+ Binding("enter", "preview_metric", "Preview metric", priority=True),
64
+ Binding("space", "add_metric", "Add metric to session", priority=True),
65
+ Binding("j", "cursor_down", "Move highlight down", show=False),
66
+ Binding("k", "cursor_up", "Move highlight up", show=False),
67
+ ]
68
+
69
+ DEFAULT_CSS = """
70
+ #discover-body { height: 1fr; }
71
+
72
+ .pane-container {
73
+ border: solid #222;
74
+ background: #0f0f0f;
75
+ }
76
+ #top-row { height: 55%; }
77
+ #entities-pane { width: 32%; }
78
+ #metrics-pane { width: 1fr; }
79
+ #preview-pane { height: 1fr; border-top: solid #333; }
80
+
81
+ .pane-header {
82
+ background: #1a1a1a;
83
+ color: #4ade80;
84
+ padding: 0 1;
85
+ text-style: bold;
86
+ height: 1;
87
+ }
88
+
89
+ #entities-list, #metrics-list {
90
+ height: 1fr;
91
+ background: #0f0f0f;
92
+ }
93
+
94
+ ListView > ListItem,
95
+ EntityRow,
96
+ MetricRow {
97
+ height: 1;
98
+ min-height: 1;
99
+ max-height: 1;
100
+ width: 100%;
101
+ padding: 0 1;
102
+ overflow: hidden;
103
+ }
104
+
105
+ MetricRow:focus,
106
+ MetricRow.-highlighted {
107
+ background: #1a3a1a;
108
+ color: #4ade80;
109
+ }
110
+
111
+ #empty-prompt {
112
+ height: 1fr;
113
+ padding: 2 2;
114
+ color: #4ade80;
115
+ text-style: bold;
116
+ background: #111111;
117
+ }
118
+
119
+ #metrics-filter {
120
+ height: 3;
121
+ background: transparent;
122
+ border: none;
123
+ border-bottom: solid #333;
124
+ padding: 0 1;
125
+ }
126
+
127
+ #metrics-status {
128
+ height: 1;
129
+ color: #888;
130
+ padding: 0 1;
131
+ }
132
+
133
+ #preview-scroll { height: 1fr; background: #0f0f0f; }
134
+ #preview-chart {
135
+ width: 100%;
136
+ height: auto;
137
+ padding: 0 1;
138
+ color: #e0e0e0;
139
+ }
140
+
141
+ #cmd-bar {
142
+ height: 3;
143
+ background: #0f0f0f;
144
+ border-top: solid #4ade80;
145
+ align: left middle;
146
+ padding: 0 1;
147
+ }
148
+ #help-hint { color: #888; }
149
+ """
150
+
151
+ def __init__(self, client: JSTDataClient, session: Session, mode: str = "union"):
152
+ super().__init__()
153
+ self.client = client
154
+ self.session = session
155
+ self.mode = mode if mode in MODES else "union"
156
+ self.filter_query = ""
157
+ self.page_offset = 0
158
+ self.has_more = False
159
+ self.loading_more = False
160
+ self.search_task: asyncio.Task[None] | None = None
161
+ self._entity_snapshot: tuple[str, ...] = ()
162
+ self._metrics: list[Metric] = []
163
+
164
+ def compose(self) -> ComposeResult:
165
+ with Vertical(id="discover-body"):
166
+ with Horizontal(id="top-row"):
167
+ with Vertical(classes="pane-container", id="entities-pane"):
168
+ yield Label("ENTITIES // SESSION", classes="pane-header")
169
+ yield ListView(id="entities-list")
170
+ yield Static(
171
+ "No entities in session.\n\n"
172
+ "Press [bold]f[/bold] to find entities and add them.\n"
173
+ "Metrics in this step are generated from that set.",
174
+ id="empty-prompt",
175
+ markup=True,
176
+ )
177
+ with Vertical(classes="pane-container", id="metrics-pane"):
178
+ yield Label(
179
+ "METRICS // CANDIDATES",
180
+ classes="pane-header",
181
+ id="metrics-header",
182
+ )
183
+ yield Input(
184
+ placeholder="/ filter metrics in this set…",
185
+ id="metrics-filter",
186
+ )
187
+ yield ListView(id="metrics-list")
188
+ yield Label("", id="metrics-status")
189
+ with Vertical(classes="pane-container", id="preview-pane"):
190
+ yield Label(
191
+ "PREVIEW // LAST 20 OBS",
192
+ classes="pane-header",
193
+ id="preview-header",
194
+ )
195
+ with ScrollableContainer(id="preview-scroll"):
196
+ yield Static(
197
+ "Highlight a metric and press Enter to preview.",
198
+ id="preview-chart",
199
+ )
200
+ with Horizontal(id="cmd-bar"):
201
+ yield Label("", id="help-hint")
202
+
203
+ def on_mount(self) -> None:
204
+ self.query_one("#entities-list", ListView).can_focus = False
205
+ self._refresh_help_hint()
206
+ self._sync_entities(force=True)
207
+ self.query_one("#metrics-list", ListView).focus()
208
+
209
+ def on_screen_resume(self) -> None:
210
+ self._sync_entities()
211
+
212
+ def _labels(self) -> dict[str, str]:
213
+ return getattr(self.app, "labels", {})
214
+
215
+ def _refresh_help_hint(self) -> None:
216
+ n = getattr(self.app, "step_count", 1)
217
+ i = getattr(self.app, "step_index", 0) + 1
218
+ parts = [
219
+ f"discover {i}/{n}",
220
+ f"mode {self.mode}",
221
+ "[bold]/[/bold] filter",
222
+ "[bold]enter[/bold] preview",
223
+ "[bold]space[/bold] add",
224
+ "[bold]tab[/bold] panes",
225
+ "[bold]j/k[/bold] move",
226
+ "[bold]s[/bold] session",
227
+ "[bold]f[/bold] find",
228
+ "[bold]e[/bold] export",
229
+ "[bold]n[/bold]/[bold]p[/bold]",
230
+ "[bold]q[/bold] quit",
231
+ "[bold]?[/bold]",
232
+ ]
233
+ self.query_one("#help-hint").update(" // ".join(parts))
234
+
235
+ def _sync_entities(self, force: bool = False) -> None:
236
+ snapshot = tuple(self.session.entity)
237
+ if not force and snapshot == self._entity_snapshot:
238
+ return
239
+ self._entity_snapshot = snapshot
240
+ empty = self.query_one("#empty-prompt")
241
+ roster = self.query_one("#entities-list", ListView)
242
+ roster.clear()
243
+ if not snapshot:
244
+ empty.display = True
245
+ roster.display = False
246
+ self.query_one("#metrics-list", ListView).clear()
247
+ self._metrics = []
248
+ self.query_one("#metrics-status").update(
249
+ "Add entities to generate metrics."
250
+ )
251
+ return
252
+ empty.display = False
253
+ roster.display = True
254
+ labels = self._labels()
255
+ for eid in snapshot:
256
+ roster.append(EntityRow(eid, label_for(labels, eid)))
257
+ self.page_offset = 0
258
+ self._metrics = []
259
+ self.query_one("#metrics-list", ListView).clear()
260
+ self.load_metrics(reset=True)
261
+
262
+ @work(exclusive=True, group="metrics")
263
+ async def load_metrics(self, reset: bool = False) -> None:
264
+ if not self.session.entity:
265
+ self.loading_more = False
266
+ return
267
+ if reset:
268
+ self.page_offset = 0
269
+ self.has_more = False
270
+ status = self.query_one("#metrics-status")
271
+ status.update("Loading metrics…")
272
+ query = self.filter_query.strip() or None
273
+ try:
274
+ metrics = await asyncio.to_thread(
275
+ self.client.search_metrics,
276
+ query,
277
+ entity=list(self.session.entity),
278
+ mode=self.mode,
279
+ limit=PAGE_SIZE,
280
+ offset=self.page_offset,
281
+ )
282
+ except Exception as e:
283
+ status.update(f"Error: {e}")
284
+ self.loading_more = False
285
+ return
286
+ if reset:
287
+ self._metrics = []
288
+ self.query_one("#metrics-list", ListView).clear()
289
+ self._metrics.extend(metrics)
290
+ self.has_more = len(metrics) == PAGE_SIZE
291
+ list_view = self.query_one("#metrics-list", ListView)
292
+ staged = set(self.session.metric)
293
+ for metric in metrics:
294
+ list_view.append(MetricRow(metric, staged=metric.id in staged))
295
+ shown = len(self._metrics)
296
+ extra = "+" if self.has_more else ""
297
+ qlabel = f"“{query}”" if query else "all"
298
+ status.update(f"{shown}{extra} · {qlabel} · {self.mode}")
299
+ self.query_one("#metrics-header").update(f"METRICS // {self.mode.upper()}")
300
+ self.loading_more = False
301
+
302
+ def action_focus_filter(self) -> None:
303
+ filt = self.query_one("#metrics-filter", Input)
304
+ filt.focus()
305
+
306
+ def action_leave_filter(self) -> None:
307
+ filt = self.query_one("#metrics-filter", Input)
308
+ if self.focused is filt:
309
+ self.query_one("#metrics-list", ListView).focus()
310
+
311
+ def action_cycle_panes(self) -> None:
312
+ if isinstance(self.focused, Input):
313
+ self.query_one("#metrics-list", ListView).focus()
314
+ return
315
+ if self.focused is self.query_one("#metrics-list"):
316
+ self.query_one("#preview-scroll").focus()
317
+ else:
318
+ self.query_one("#metrics-list", ListView).focus()
319
+
320
+ def _preview_is_focused(self) -> bool:
321
+ node = self.focused
322
+ while node is not None:
323
+ if getattr(node, "id", None) == "preview-scroll":
324
+ return True
325
+ node = getattr(node, "parent", None)
326
+ return False
327
+
328
+ def action_cursor_down(self) -> None:
329
+ if isinstance(self.focused, Input):
330
+ return
331
+ if self._preview_is_focused():
332
+ self.query_one("#preview-scroll", ScrollableContainer).scroll_relative(
333
+ y=1, animate=False
334
+ )
335
+ return
336
+ self.query_one("#metrics-list", ListView).action_cursor_down()
337
+
338
+ def action_cursor_up(self) -> None:
339
+ if isinstance(self.focused, Input):
340
+ return
341
+ if self._preview_is_focused():
342
+ self.query_one("#preview-scroll", ScrollableContainer).scroll_relative(
343
+ y=-1, animate=False
344
+ )
345
+ return
346
+ self.query_one("#metrics-list", ListView).action_cursor_up()
347
+
348
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
349
+ if action == "add_metric" and isinstance(self.focused, Input):
350
+ return False
351
+ return True
352
+
353
+ def _highlighted_metric(self) -> Optional[Metric]:
354
+ child = self.query_one("#metrics-list", ListView).highlighted_child
355
+ if isinstance(child, MetricRow):
356
+ return child.metric
357
+ return None
358
+
359
+ def action_preview_metric(self) -> None:
360
+ if isinstance(self.focused, Input):
361
+ return
362
+ metric = self._highlighted_metric()
363
+ if metric is not None:
364
+ self.run_preview(metric)
365
+
366
+ def action_add_metric(self) -> None:
367
+ if isinstance(self.focused, Input):
368
+ return
369
+ list_view = self.query_one("#metrics-list", ListView)
370
+ if list_view.index is None and self._metrics:
371
+ list_view.index = 0
372
+ metric = self._highlighted_metric()
373
+ if metric is None:
374
+ self.notify("Highlight a metric first", severity="warning")
375
+ return
376
+ if not self.session.add_metric(metric.id):
377
+ self.notify(f"{metric.id} is already in session", severity="warning")
378
+ return
379
+ if hasattr(self.app, "remember_label"):
380
+ self.app.remember_label(metric.id, metric.name or metric.id)
381
+ self.notify(f"Added {metric.id} to session")
382
+ child = self.query_one("#metrics-list", ListView).highlighted_child
383
+ if isinstance(child, MetricRow):
384
+ child.staged = True
385
+ try:
386
+ child.query_one(Label).update(f"● {metric.name} [{metric.id}]")
387
+ except Exception:
388
+ pass
389
+
390
+ @on(Input.Changed, "#metrics-filter")
391
+ def on_filter_changed(self, event: Input.Changed) -> None:
392
+ if self.search_task:
393
+ self.search_task.cancel()
394
+ self.search_task = asyncio.create_task(self._debounced_filter(event.value))
395
+
396
+ async def _debounced_filter(self, value: str) -> None:
397
+ try:
398
+ await asyncio.sleep(0.3)
399
+ self.filter_query = value
400
+ self.load_metrics(reset=True)
401
+ except asyncio.CancelledError:
402
+ pass
403
+
404
+ @on(Input.Submitted, "#metrics-filter")
405
+ def on_filter_submit(self) -> None:
406
+ self.query_one("#metrics-list", ListView).focus()
407
+
408
+ @on(ListView.Highlighted, "#metrics-list")
409
+ def on_metric_highlighted(self, event: ListView.Highlighted) -> None:
410
+ if self.loading_more or not self.has_more:
411
+ return
412
+ list_view = self.query_one("#metrics-list", ListView)
413
+ if event.item is None:
414
+ return
415
+ index = list_view.index
416
+ if index is None or index < len(self._metrics) - 1:
417
+ return
418
+ self.loading_more = True
419
+ self.page_offset = len(self._metrics)
420
+ self.load_metrics(reset=False)
421
+
422
+ @work(exclusive=True, group="preview")
423
+ async def run_preview(self, metric: Metric) -> None:
424
+ chart = self.query_one("#preview-chart", Static)
425
+ header = self.query_one("#preview-header")
426
+ if not self.session.entity:
427
+ chart.update("Add entities before previewing.")
428
+ return
429
+ header.update(f"PREVIEW // {metric.name}")
430
+ chart.update("Loading observations…")
431
+ try:
432
+ series_list: list[TimeSeries] = await asyncio.to_thread(
433
+ self.client.query,
434
+ metric=metric.id,
435
+ entity=list(self.session.entity),
436
+ tail=PREVIEW_TAIL,
437
+ )
438
+ except Exception as e:
439
+ chart.update(f"Preview failed: {e}")
440
+ return
441
+ frequency = pick_frequency(series_list)
442
+ if not frequency:
443
+ chart.update("No observations in the preview window.")
444
+ return
445
+ picked = pick_series_per_entity(series_list, self.session.entity, frequency)
446
+ units = next(
447
+ (ts.series.units for ts in picked.values() if ts is not None),
448
+ "",
449
+ )
450
+ width = max(24, (self.size.width or 80) - LABEL_WIDTH - 2)
451
+ text = render_preview(
452
+ metric.name,
453
+ frequency,
454
+ units,
455
+ {eid: label_for(self._labels(), eid) for eid in self.session.entity},
456
+ picked,
457
+ bar_width=width,
458
+ )
459
+ chart.update(text)
460
+
461
+
462
+ def create_discover_screen(client, session, mode="union", **kwargs):
463
+ return DiscoverScreen(client, session, mode=mode)
464
+
465
+
466
+ DISCOVER = register(
467
+ StepSpec(
468
+ id="discover",
469
+ name="Discover",
470
+ description=(
471
+ "Find metrics related to session entities, preview recent coverage, "
472
+ "and stage promising metrics."
473
+ ),
474
+ create_screen=create_discover_screen,
475
+ arguments=(
476
+ StepArgument(
477
+ name="mode",
478
+ type="string",
479
+ description="union (any entity) or intersect (every entity)",
480
+ default="union",
481
+ choices=MODES,
482
+ ),
483
+ ),
484
+ bindings=(
485
+ StepBinding("/", "focus_filter", "Filter the generated metric list"),
486
+ StepBinding(
487
+ "enter",
488
+ "preview_metric",
489
+ "Preview the latest observations for the highlighted metric",
490
+ ),
491
+ StepBinding(
492
+ "space", "add_metric", "Add highlighted metric to the session"
493
+ ),
494
+ StepBinding(
495
+ "tab", "cycle_panes", "Switch focus between metrics and preview"
496
+ ),
497
+ StepBinding("j / k", "cursor", "Move metric highlight or scroll preview"),
498
+ StepBinding("escape", "leave_filter", "Leave the metric filter"),
499
+ ),
500
+ example="jst run console --taxonomy country : discover --mode union",
501
+ )
502
+ )
@@ -0,0 +1,171 @@
1
+ """Shared export modal for all workflow steps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import sys
7
+
8
+ from textual import on
9
+ from textual.app import ComposeResult
10
+ from textual.binding import Binding
11
+ from textual.containers import Horizontal, Vertical
12
+ from textual.screen import ModalScreen
13
+ from textual.widgets import Button, Input, Label, Static
14
+
15
+ from ..session import Session
16
+
17
+
18
+ def copy_to_clipboard(app, text: str) -> bool:
19
+ """Best-effort clipboard write. Returns True on apparent success."""
20
+ if sys.platform == "darwin":
21
+ try:
22
+ subprocess.run(["pbcopy"], input=text, text=True, check=True)
23
+ return True
24
+ except Exception:
25
+ pass
26
+ try:
27
+ app.copy_to_clipboard(text)
28
+ return True
29
+ except Exception:
30
+ return False
31
+
32
+
33
+ class ExportModal(ModalScreen[None]):
34
+ """Host export surface: Python snippet, CLI command, write session file."""
35
+
36
+ DEFAULT_CSS = """
37
+ ExportModal {
38
+ align: center middle;
39
+ background: rgba(0, 0, 0, 0.7);
40
+ }
41
+
42
+ #export-container {
43
+ width: 90;
44
+ max-width: 100%;
45
+ height: auto;
46
+ max-height: 90%;
47
+ border: thick #4ade80;
48
+ background: #111111;
49
+ padding: 1 2;
50
+ }
51
+
52
+ #export-header {
53
+ height: 1;
54
+ margin-bottom: 1;
55
+ width: 100%;
56
+ }
57
+
58
+ #export-title {
59
+ text-style: bold;
60
+ color: #4ade80;
61
+ width: 1fr;
62
+ text-align: center;
63
+ }
64
+
65
+ .export-section {
66
+ color: #4ade80;
67
+ text-style: bold;
68
+ margin-top: 1;
69
+ }
70
+
71
+ .export-code {
72
+ background: #0a0a0a;
73
+ color: #e0e0e0;
74
+ padding: 1;
75
+ height: auto;
76
+ max-height: 12;
77
+ border: solid #333;
78
+ }
79
+
80
+ .export-row {
81
+ height: auto;
82
+ margin-top: 1;
83
+ }
84
+
85
+ #export-path {
86
+ width: 1fr;
87
+ }
88
+
89
+ #export-hint {
90
+ color: #888;
91
+ height: 1;
92
+ margin-top: 1;
93
+ }
94
+ """
95
+
96
+ BINDINGS = [
97
+ Binding("escape", "dismiss", "Close"),
98
+ ]
99
+
100
+ def __init__(self, session: Session, default_path: str, on_write=None) -> None:
101
+ super().__init__()
102
+ self.session = session
103
+ self.default_path = default_path
104
+ self.on_write = on_write
105
+ self.python_code = session.to_python()
106
+ self.cli_command = session.to_cli()
107
+
108
+ def compose(self) -> ComposeResult:
109
+ with Vertical(id="export-container"):
110
+ with Horizontal(id="export-header"):
111
+ yield Label("EXPORT // TAKE IT WITH YOU", id="export-title")
112
+
113
+ yield Label("PYTHON", classes="export-section")
114
+ yield Static(self.python_code, classes="export-code", id="export-python")
115
+ yield Button("COPY PYTHON", id="export-copy-python", variant="primary")
116
+
117
+ yield Label("CLI", classes="export-section")
118
+ yield Static(self.cli_command, classes="export-code", id="export-cli")
119
+ yield Button("COPY COMMAND", id="export-copy-cli", variant="primary")
120
+
121
+ yield Label("WRITE SESSION", classes="export-section")
122
+ with Horizontal(classes="export-row"):
123
+ yield Input(
124
+ value=self.default_path,
125
+ placeholder="session.json",
126
+ id="export-path",
127
+ )
128
+ yield Button("WRITE", id="export-write", variant="success")
129
+
130
+ yield Label(
131
+ "[bold]tab[/bold] move // [bold]enter[/bold] activate // [bold]esc[/bold] close",
132
+ id="export-hint",
133
+ markup=True,
134
+ )
135
+
136
+ @on(Button.Pressed, "#export-copy-python")
137
+ def copy_python(self) -> None:
138
+ if copy_to_clipboard(self.app, self.python_code):
139
+ self.notify("Python snippet copied")
140
+ else:
141
+ self.notify("Could not copy to clipboard", severity="error")
142
+
143
+ @on(Button.Pressed, "#export-copy-cli")
144
+ def copy_cli(self) -> None:
145
+ if copy_to_clipboard(self.app, self.cli_command):
146
+ self.notify("CLI command copied")
147
+ else:
148
+ self.notify("Could not copy to clipboard", severity="error")
149
+
150
+ @on(Button.Pressed, "#export-write")
151
+ def write_session(self) -> None:
152
+ path = self.query_one("#export-path", Input).value.strip()
153
+ if not path:
154
+ self.notify("Enter a session file path", severity="warning")
155
+ return
156
+ try:
157
+ self.session.save(path)
158
+ if hasattr(self.app, "output_path"):
159
+ self.app.output_path = path
160
+ self.notify(f"Session written to {path}")
161
+ if self.on_write:
162
+ self.on_write(path)
163
+ except Exception as e:
164
+ self.notify(f"Failed to write session: {e}", severity="error")
165
+
166
+ def action_dismiss(self) -> None:
167
+ self.dismiss()
168
+
169
+
170
+ # Back-compat alias during rename
171
+ OfframpModal = ExportModal