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,386 @@
1
+ """Host-level find modal: search metrics or entities and add to the session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from typing import Any, Literal
8
+
9
+ from textual import on, work
10
+ from textual.app import ComposeResult
11
+ from textual.binding import Binding
12
+ from textual.containers import Horizontal, Vertical
13
+ from textual.screen import ModalScreen
14
+ from textual.widgets import Input, Label, ListItem, ListView, Static
15
+
16
+ from ..client import JSTDataClient
17
+ from ..models import Entity, Metric
18
+ from ..session import Session
19
+
20
+ _PLACEHOLDER = "Highlight a result and press i to inspect."
21
+ FindMode = Literal["metric", "entity"]
22
+ _MODES: tuple[FindMode, ...] = ("metric", "entity")
23
+
24
+
25
+ def _resource_type(resource: Any) -> str:
26
+ if isinstance(resource, Entity):
27
+ return "entity"
28
+ if isinstance(resource, Metric):
29
+ return "metric"
30
+ return "metric"
31
+
32
+
33
+ def _resource_label(resource: Any) -> str:
34
+ return (
35
+ getattr(resource, "label", None)
36
+ or getattr(resource, "name", None)
37
+ or getattr(resource, "id", "Unknown")
38
+ )
39
+
40
+
41
+ def next_find_mode(mode: FindMode) -> FindMode:
42
+ """Cycle metric → entity → metric."""
43
+ return "entity" if mode == "metric" else "metric"
44
+
45
+
46
+ class FindResultRow(ListItem):
47
+ """One search hit in the find modal."""
48
+
49
+ def __init__(self, resource: Any) -> None:
50
+ super().__init__()
51
+ self.resource = resource
52
+ self.resource_type = _resource_type(resource)
53
+
54
+ def compose(self) -> ComposeResult:
55
+ name = _resource_label(self.resource)
56
+ source = getattr(self.resource, "source", "") or ""
57
+ yield Label(
58
+ f"{self.resource_type.upper():<8} {name} [{self.resource.id}]"
59
+ + (f" {source}" if source else ""),
60
+ classes="find-row-label",
61
+ )
62
+
63
+
64
+ class FindModal(ModalScreen[None]):
65
+ """Shared find surface: metric/entity search, add to session, inspect."""
66
+
67
+ DEFAULT_CSS = """
68
+ FindModal {
69
+ align: center middle;
70
+ background: rgba(0, 0, 0, 0.7);
71
+ }
72
+
73
+ #find-container {
74
+ width: 90%;
75
+ height: 80%;
76
+ max-width: 140;
77
+ max-height: 40;
78
+ border: thick #4ade80;
79
+ background: #111111;
80
+ padding: 1 1;
81
+ }
82
+
83
+ #find-title {
84
+ text-style: bold;
85
+ color: #4ade80;
86
+ text-align: center;
87
+ height: 1;
88
+ margin-bottom: 0;
89
+ }
90
+
91
+ #find-mode-bar {
92
+ height: 1;
93
+ text-align: center;
94
+ margin-bottom: 1;
95
+ color: #888;
96
+ }
97
+
98
+ #find-search-input {
99
+ width: 100%;
100
+ background: #0a0a0a;
101
+ border: solid #333;
102
+ margin-bottom: 1;
103
+ }
104
+
105
+ #find-body {
106
+ height: 1fr;
107
+ width: 100%;
108
+ }
109
+
110
+ #find-results-pane {
111
+ width: 1fr;
112
+ height: 100%;
113
+ border-right: solid #333;
114
+ padding-right: 1;
115
+ }
116
+
117
+ #find-inspect-pane {
118
+ width: 1fr;
119
+ height: 100%;
120
+ padding-left: 1;
121
+ }
122
+
123
+ .find-pane-header {
124
+ color: #4ade80;
125
+ text-style: bold;
126
+ height: 1;
127
+ margin-bottom: 1;
128
+ }
129
+
130
+ #find-results-list {
131
+ height: 1fr;
132
+ width: 100%;
133
+ background: #0a0a0a;
134
+ border: solid #222;
135
+ }
136
+
137
+ FindResultRow {
138
+ height: 1;
139
+ min-height: 1;
140
+ max-height: 1;
141
+ width: 100%;
142
+ padding: 0 1;
143
+ overflow: hidden;
144
+ }
145
+
146
+ FindResultRow:hover,
147
+ FindResultRow.-highlighted {
148
+ background: #1a3a1a;
149
+ }
150
+
151
+ .find-row-label {
152
+ width: 100%;
153
+ height: 1;
154
+ color: #e0e0e0;
155
+ overflow: hidden;
156
+ }
157
+
158
+ ListView > ListItem {
159
+ height: 1;
160
+ min-height: 1;
161
+ max-height: 1;
162
+ }
163
+
164
+ #find-inspect-payload {
165
+ height: 1fr;
166
+ width: 100%;
167
+ background: #0a0a0a;
168
+ border: solid #222;
169
+ color: #e0e0e0;
170
+ padding: 1;
171
+ overflow-y: auto;
172
+ overflow-x: auto;
173
+ }
174
+
175
+ #find-hint {
176
+ color: #888;
177
+ height: 1;
178
+ margin-top: 1;
179
+ dock: bottom;
180
+ }
181
+ """
182
+
183
+ BINDINGS = [
184
+ Binding("escape", "dismiss", "Close"),
185
+ Binding(
186
+ "shift+tab",
187
+ "toggle_mode",
188
+ "Toggle metric/entity",
189
+ priority=True,
190
+ ),
191
+ Binding("i", "inspect", "Inspect"),
192
+ Binding("j", "cursor_down", "Down", show=False),
193
+ Binding("k", "cursor_up", "Up", show=False),
194
+ Binding("down", "cursor_down", "Down", show=False),
195
+ Binding("up", "cursor_up", "Up", show=False),
196
+ ]
197
+
198
+ def __init__(
199
+ self,
200
+ client: JSTDataClient,
201
+ session: Session,
202
+ mode: FindMode = "metric",
203
+ ) -> None:
204
+ super().__init__()
205
+ self.client = client
206
+ self.session = session
207
+ self.mode: FindMode = mode if mode in _MODES else "metric"
208
+ self.search_task: asyncio.Task[None] | None = None
209
+ self._inspected_id: str | None = None
210
+
211
+ def compose(self) -> ComposeResult:
212
+ with Vertical(id="find-container"):
213
+ yield Label("FIND // ADD TO SESSION", id="find-title", markup=True)
214
+ yield Label("", id="find-mode-bar", markup=True)
215
+ yield Input(
216
+ placeholder="Search metrics…",
217
+ id="find-search-input",
218
+ )
219
+ with Horizontal(id="find-body"):
220
+ with Vertical(id="find-results-pane"):
221
+ yield Label("RESULTS", classes="find-pane-header")
222
+ yield ListView(id="find-results-list")
223
+ with Vertical(id="find-inspect-pane"):
224
+ yield Label("INSPECT", classes="find-pane-header")
225
+ yield Static(_PLACEHOLDER, id="find-inspect-payload", markup=False)
226
+ yield Label("", id="find-hint", markup=True)
227
+
228
+ def on_mount(self) -> None:
229
+ self._refresh_mode_chrome()
230
+ self.query_one("#find-search-input", Input).focus()
231
+
232
+ def _refresh_mode_chrome(self) -> None:
233
+ if self.mode == "metric":
234
+ self.query_one("#find-title").update(
235
+ "FIND // [bold #38bdf8]METRIC[/]"
236
+ )
237
+ mode_bar = (
238
+ "[bold #38bdf8]METRIC[/] · [dim #fbbf24]ENTITY[/] "
239
+ "(shift+tab to switch)"
240
+ )
241
+ else:
242
+ self.query_one("#find-title").update(
243
+ "FIND // [bold #fbbf24]ENTITY[/]"
244
+ )
245
+ mode_bar = (
246
+ "[dim #38bdf8]METRIC[/] · [bold #fbbf24]ENTITY[/] "
247
+ "(shift+tab to switch)"
248
+ )
249
+ self.query_one("#find-mode-bar").update(mode_bar)
250
+ placeholder = (
251
+ "Search metrics…" if self.mode == "metric" else "Search entities…"
252
+ )
253
+ self.query_one("#find-search-input", Input).placeholder = placeholder
254
+ other = next_find_mode(self.mode)
255
+ self.query_one("#find-hint").update(
256
+ f"[bold]shift+tab[/bold] switch to {other} // "
257
+ "[bold]tab[/bold]/[bold]enter[/bold] results // "
258
+ "[bold]i[/bold] inspect // "
259
+ "[bold]j[/bold]/[bold]k[/bold] // [bold]esc[/bold] close"
260
+ )
261
+
262
+ def _show_placeholder(self) -> None:
263
+ self._inspected_id = None
264
+ self.query_one("#find-inspect-payload", Static).update(_PLACEHOLDER)
265
+
266
+ def _highlighted_row(self) -> FindResultRow | None:
267
+ list_view = self.query_one("#find-results-list", ListView)
268
+ child = list_view.highlighted_child
269
+ if isinstance(child, FindResultRow):
270
+ return child
271
+ return None
272
+
273
+ def _add_resource(self, resource: Any) -> bool:
274
+ if isinstance(resource, Metric):
275
+ added = self.session.add_metric(resource.id)
276
+ elif isinstance(resource, Entity):
277
+ added = self.session.add_entity(resource.id)
278
+ elif resource.id in self.session.resource_ids():
279
+ return False
280
+ else:
281
+ return False
282
+ if added and hasattr(self.app, "remember_label"):
283
+ self.app.remember_label(resource.id, _resource_label(resource))
284
+ return added
285
+
286
+ def action_toggle_mode(self) -> None:
287
+ self.mode = next_find_mode(self.mode)
288
+ self._refresh_mode_chrome()
289
+ self.query_one("#find-results-list", ListView).clear()
290
+ self._show_placeholder()
291
+ query = self.query_one("#find-search-input", Input).value.strip()
292
+ if self.search_task:
293
+ self.search_task.cancel()
294
+ if len(query) >= 2:
295
+ self.search_task = asyncio.create_task(self._do_search(query))
296
+
297
+ @on(Input.Changed, "#find-search-input")
298
+ def on_search_changed(self, event: Input.Changed) -> None:
299
+ if self.search_task:
300
+ self.search_task.cancel()
301
+ if len(event.value.strip()) < 2:
302
+ self.query_one("#find-results-list", ListView).clear()
303
+ self._show_placeholder()
304
+ return
305
+ self.search_task = asyncio.create_task(self._do_search(event.value.strip()))
306
+
307
+ async def _do_search(self, query: str) -> None:
308
+ try:
309
+ await asyncio.sleep(0.3)
310
+ if self.mode == "entity":
311
+ results = await asyncio.to_thread(
312
+ self.client.search_entities, query, limit=20
313
+ )
314
+ else:
315
+ results = await asyncio.to_thread(
316
+ self.client.search_metrics, query, limit=20
317
+ )
318
+ list_view = self.query_one("#find-results-list", ListView)
319
+ list_view.clear()
320
+ self._show_placeholder()
321
+ for r in results:
322
+ list_view.append(FindResultRow(r))
323
+ except asyncio.CancelledError:
324
+ pass
325
+ except Exception as e:
326
+ self.notify(f"Search error: {e}", severity="error")
327
+
328
+ @on(Input.Submitted, "#find-search-input")
329
+ def on_search_submit(self) -> None:
330
+ self.query_one("#find-results-list", ListView).focus()
331
+
332
+ @on(ListView.Selected, "#find-results-list")
333
+ def on_result_selected(self, event: ListView.Selected) -> None:
334
+ if not isinstance(event.item, FindResultRow):
335
+ return
336
+ resource = event.item.resource
337
+ if not self._add_resource(resource):
338
+ self.notify(f"{resource.id} is already in session", severity="warning")
339
+ return
340
+ self.notify(f"Added {resource.id} to session")
341
+
342
+ @on(ListView.Highlighted, "#find-results-list")
343
+ def on_highlight_changed(self, event: ListView.Highlighted) -> None:
344
+ row = event.item if isinstance(event.item, FindResultRow) else None
345
+ if row is None or row.resource.id != self._inspected_id:
346
+ self._show_placeholder()
347
+
348
+ def action_inspect(self) -> None:
349
+ if isinstance(self.focused, Input):
350
+ return
351
+ row = self._highlighted_row()
352
+ if row is None:
353
+ self.notify("Select a result to inspect", severity="warning")
354
+ return
355
+ self.query_one("#find-inspect-payload", Static).update("Fetching...")
356
+ self._fetch_payload(row.resource_type, row.resource.id)
357
+
358
+ @work(exclusive=True)
359
+ async def _fetch_payload(self, resource_type: str, resource_id: str) -> None:
360
+ try:
361
+ data = await asyncio.to_thread(
362
+ self.client.make_request, f"{resource_type}/{resource_id}"
363
+ )
364
+ text = json.dumps(data, indent=2, default=str)
365
+ self._inspected_id = resource_id
366
+ self.query_one("#find-inspect-payload", Static).update(text)
367
+ except Exception as e:
368
+ self._inspected_id = None
369
+ self.query_one("#find-inspect-payload", Static).update(
370
+ f"Inspect failed: {e}"
371
+ )
372
+
373
+ def action_cursor_down(self) -> None:
374
+ focused = self.focused
375
+ if isinstance(focused, Input):
376
+ return
377
+ self.query_one("#find-results-list", ListView).action_cursor_down()
378
+
379
+ def action_cursor_up(self) -> None:
380
+ focused = self.focused
381
+ if isinstance(focused, Input):
382
+ return
383
+ self.query_one("#find-results-list", ListView).action_cursor_up()
384
+
385
+ def action_dismiss(self) -> None:
386
+ self.dismiss()
@@ -0,0 +1,255 @@
1
+ """Workflow host: shared pipeline runtime and universal modals wiring."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.app import App, ComposeResult
6
+ from textual.binding import Binding
7
+ from textual.containers import Horizontal, Vertical
8
+ from textual.screen import ModalScreen
9
+ from textual.widgets import Input, Label
10
+
11
+ from ..client import JSTDataClient
12
+ from .base import (
13
+ ResolvedStep,
14
+ default_session_path,
15
+ hydrate_labels,
16
+ load_session_or_empty,
17
+ )
18
+ from .export import ExportModal
19
+ from .find import FindModal
20
+ from .session_manager import SessionManagerModal
21
+
22
+
23
+ class HelpScreen(ModalScreen):
24
+ """Step-specific keybindings from the current StepSpec."""
25
+
26
+ DEFAULT_CSS = """
27
+ HelpScreen {
28
+ align: center middle;
29
+ background: rgba(0, 0, 0, 0.7);
30
+ }
31
+
32
+ #help-container {
33
+ width: 60;
34
+ height: auto;
35
+ max-height: 90%;
36
+ border: thick #4ade80;
37
+ background: #111111;
38
+ padding: 1 2;
39
+ }
40
+
41
+ #help-title {
42
+ text-style: bold;
43
+ color: #4ade80;
44
+ margin-bottom: 1;
45
+ text-align: center;
46
+ }
47
+
48
+ #help-note {
49
+ color: #888;
50
+ margin-bottom: 1;
51
+ text-align: center;
52
+ }
53
+
54
+ .key-row {
55
+ height: auto;
56
+ margin-bottom: 0;
57
+ }
58
+
59
+ .key-col {
60
+ color: #4ade80;
61
+ text-style: bold;
62
+ width: 16;
63
+ }
64
+
65
+ .desc-col {
66
+ color: #e0e0e0;
67
+ width: 1fr;
68
+ }
69
+
70
+ #help-footer {
71
+ color: #888;
72
+ margin-top: 1;
73
+ height: 1;
74
+ }
75
+ """
76
+
77
+ BINDINGS = [
78
+ Binding("escape", "dismiss", "Close"),
79
+ Binding("question_mark", "dismiss", "Close", show=False),
80
+ ]
81
+
82
+ def __init__(self, step_name: str, bindings: list) -> None:
83
+ super().__init__()
84
+ self.step_name = step_name
85
+ self.step_bindings = bindings
86
+
87
+ def compose(self) -> ComposeResult:
88
+ with Vertical(id="help-container"):
89
+ yield Label(f"{self.step_name.upper()} // KEYBINDINGS", id="help-title")
90
+ yield Label(
91
+ "Universal keys stay in the status bar (s/f/e/n/p/q).",
92
+ id="help-note",
93
+ )
94
+ if not self.step_bindings:
95
+ yield Label("(no step-specific bindings)", classes="desc-col")
96
+ else:
97
+ for b in self.step_bindings:
98
+ if not getattr(b, "show_in_help", True):
99
+ continue
100
+ yield Horizontal(
101
+ Label(b.key, classes="key-col"),
102
+ Label(b.description, classes="desc-col"),
103
+ classes="key-row",
104
+ )
105
+ yield Label("[bold]esc[/bold] / [bold]?[/bold] close", id="help-footer", markup=True)
106
+
107
+ def action_dismiss(self) -> None:
108
+ self.dismiss()
109
+
110
+
111
+ class WorkflowHost(App):
112
+ """One-process host for a pipeline of steps sharing a Session."""
113
+
114
+ CSS = """
115
+ Screen {
116
+ background: #0a0a0a;
117
+ color: #e0e0e0;
118
+ }
119
+ """
120
+
121
+ BINDINGS = [
122
+ Binding("q", "quit", "Quit"),
123
+ Binding("ctrl+c", "quit", "Quit"),
124
+ Binding("escape", "back", "Back"),
125
+ Binding("question_mark", "show_help", "Step help", key_display="?"),
126
+ Binding("s", "session", "Session"),
127
+ Binding("f", "find", "Find"),
128
+ Binding("e", "export", "Export"),
129
+ Binding("n", "next_step", "Next"),
130
+ Binding("p", "prev_step", "Prev"),
131
+ ]
132
+
133
+ def __init__(
134
+ self,
135
+ client: JSTDataClient,
136
+ steps: list[ResolvedStep],
137
+ session_path: str | None = None,
138
+ output_path: str | None = None,
139
+ ) -> None:
140
+ super().__init__()
141
+ if not steps:
142
+ raise ValueError("WorkflowHost requires at least one step")
143
+ self.client = client
144
+ self.steps = steps
145
+ self.step_index = 0
146
+ prefix = "-".join(s.spec.id for s in steps)
147
+ self.output_path = output_path or default_session_path(prefix)
148
+ self.session = load_session_or_empty(session_path)
149
+ self.labels: dict[str, str] = {}
150
+ hydrate_labels(self.client, self.session, self.labels)
151
+
152
+ def remember_label(self, resource_id: str, label: str) -> None:
153
+ """Cache a display label for UI (session remains source of truth)."""
154
+ self.labels[resource_id] = label or resource_id
155
+
156
+ @property
157
+ def step_count(self) -> int:
158
+ return len(self.steps)
159
+
160
+ @property
161
+ def current_step_id(self) -> str:
162
+ return self.steps[self.step_index].spec.id
163
+
164
+ @property
165
+ def current_step_spec(self):
166
+ return self.steps[self.step_index].spec
167
+
168
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
169
+ # Let printable host keys fall through to focused Inputs (search, path, etc.).
170
+ if action in {"session", "find", "export", "next_step", "prev_step", "show_help"}:
171
+ if isinstance(self.focused, Input):
172
+ return False
173
+ return True
174
+
175
+ def on_mount(self) -> None:
176
+ self._push_current_step()
177
+
178
+ def _make_current_screen(self):
179
+ resolved = self.steps[self.step_index]
180
+ return resolved.spec.create_screen(
181
+ self.client,
182
+ self.session,
183
+ **resolved.kwargs,
184
+ )
185
+
186
+ def _push_current_step(self) -> None:
187
+ self.push_screen(self._make_current_screen())
188
+
189
+ def _pop_overlays(self) -> None:
190
+ """Dismiss modals. Keep the default screen plus the current step."""
191
+ while len(self.screen_stack) > 2:
192
+ self.pop_screen()
193
+
194
+ def action_next_step(self) -> None:
195
+ if self.step_index >= len(self.steps) - 1:
196
+ self.notify("Last step in the pipeline", severity="warning")
197
+ return
198
+ self._pop_overlays()
199
+ self.step_index += 1
200
+ self.switch_screen(self._make_current_screen())
201
+
202
+ def action_prev_step(self) -> None:
203
+ if self.step_index <= 0:
204
+ self.notify("First step in the pipeline", severity="warning")
205
+ return
206
+ self._pop_overlays()
207
+ self.step_index -= 1
208
+ self.switch_screen(self._make_current_screen())
209
+
210
+ def action_session(self) -> None:
211
+ """Shared session manager: remove / inspect staged resources."""
212
+ self.push_screen(
213
+ SessionManagerModal(self.client, self.session, self.labels)
214
+ )
215
+
216
+ def action_find(self) -> None:
217
+ """Shared find: search catalog and add resources to the session."""
218
+ self.push_screen(FindModal(self.client, self.session))
219
+
220
+ def action_export(self) -> None:
221
+ """Shared export: copy Python/CLI or write session file."""
222
+ self.push_screen(ExportModal(self.session, default_path=self.output_path))
223
+
224
+ def action_inspect(self) -> None:
225
+ screen = self.screen
226
+ if hasattr(screen, "action_inspect"):
227
+ screen.action_inspect()
228
+
229
+ def action_back(self) -> None:
230
+ """Leave step-local filter / inspector focus when applicable."""
231
+ focused = self.focused
232
+ screen = self.screen
233
+ if getattr(focused, "id", None) in (
234
+ "metrics-filter",
235
+ "search-input",
236
+ ) and hasattr(screen, "action_leave_filter"):
237
+ screen.action_leave_filter()
238
+ return
239
+ if focused and getattr(focused, "id", None) in (
240
+ "inspector-search-input",
241
+ "inspector-results-list",
242
+ ):
243
+ try:
244
+ screen.query_one("#search-input", Input).focus()
245
+ except Exception:
246
+ pass
247
+
248
+ def action_show_help(self) -> None:
249
+ """Show step-specific keybindings from the current StepSpec."""
250
+ spec = self.current_step_spec
251
+ self.push_screen(HelpScreen(spec.name, list(spec.bindings)))
252
+
253
+
254
+ # Back-compat alias
255
+ JSTDataApp = WorkflowHost