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,560 @@
1
+ """Console step — one-pane catalog search that stages into the session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import TypeAlias
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, Vertical
12
+ from textual.screen import Screen
13
+ from textual.widgets import Input, Label, ListItem, ListView
14
+
15
+ from ..client import JSTDataClient
16
+ from ..models import Entity, Metric, Series, Resource as ApiResource
17
+ from ..session import Session
18
+ from .base import StepArgument, StepBinding, StepSpec, register
19
+
20
+ Resource: TypeAlias = Series | Entity | Metric | ApiResource
21
+
22
+ PREFETCH_LIMIT = 201
23
+ LOCAL_CAP = 200
24
+
25
+
26
+ def _normalize_relations(relation: str | list[str] | tuple[str, ...] | None) -> list[str] | None:
27
+ if relation is None:
28
+ return None
29
+ if isinstance(relation, str):
30
+ value = relation.strip()
31
+ return [value] if value else None
32
+ items = [str(v).strip() for v in relation if str(v).strip()]
33
+ return items or None
34
+
35
+
36
+ class SearchResultRow(ListItem):
37
+ """A row in the search results list."""
38
+
39
+ def __init__(self, resource: Resource):
40
+ super().__init__()
41
+ self.resource = resource
42
+
43
+ def compose(self) -> ComposeResult:
44
+ res = self.resource
45
+ name = getattr(res, "label", getattr(res, "name", "Unknown"))
46
+ res_type = (
47
+ "SERIES"
48
+ if isinstance(res, Series)
49
+ else "ENTITY"
50
+ if isinstance(res, Entity)
51
+ else "METRIC"
52
+ )
53
+ source = getattr(res, "source", "N/A")
54
+
55
+ yield Horizontal(
56
+ Label(f"{name}", classes="col-name"),
57
+ Label(f"{res.id[:15]:<15}", classes="col-id"),
58
+ Label(f"{source[:10]:<10}", classes="col-src"),
59
+ Label(f"{res_type:<10}", classes="col-type"),
60
+ )
61
+
62
+
63
+ class WorkspaceScreen(Screen):
64
+ """Single-pane search: prefetch a catalog, filter locally when it fits."""
65
+
66
+ BINDINGS = [
67
+ Binding("slash", "focus_filter", "Search", key_display="/"),
68
+ Binding("escape", "leave_filter", "Leave search", show=False),
69
+ Binding("j", "cursor_down", "Cursor Down", show=False),
70
+ Binding("k", "cursor_up", "Cursor Up", show=False),
71
+ ]
72
+
73
+ DEFAULT_CSS = """
74
+ #workspace-body {
75
+ height: 1fr;
76
+ }
77
+
78
+ .pane-container {
79
+ border: solid #222;
80
+ background: #0f0f0f;
81
+ height: 1fr;
82
+ }
83
+
84
+ .pane-header {
85
+ background: #1a1a1a;
86
+ color: #4ade80;
87
+ padding: 0 1;
88
+ text-style: bold;
89
+ height: 1;
90
+ }
91
+ .table-header {
92
+ height: 1;
93
+ background: #111;
94
+ border-bottom: solid #333;
95
+ padding: 0 1;
96
+ }
97
+ .table-header Label {
98
+ color: #888;
99
+ text-style: bold;
100
+ }
101
+
102
+ #search-input {
103
+ height: 3;
104
+ background: transparent;
105
+ border: none;
106
+ border-bottom: solid #333;
107
+ padding: 0 1;
108
+ }
109
+
110
+ #results-list {
111
+ height: 1fr;
112
+ background: #0f0f0f;
113
+ }
114
+
115
+ .col-name { width: 55%; height: 1; overflow: hidden; }
116
+ .col-id { width: 25%; height: 1; overflow: hidden; }
117
+ .col-src { width: 10%; height: 1; overflow: hidden; }
118
+ .col-type { width: 10%; height: 1; overflow: hidden; }
119
+
120
+ ListView > ListItem,
121
+ SearchResultRow {
122
+ height: 1;
123
+ min-height: 1;
124
+ max-height: 1;
125
+ width: 100%;
126
+ padding: 0 1;
127
+ overflow: hidden;
128
+ }
129
+
130
+ SearchResultRow > Horizontal {
131
+ height: 1;
132
+ min-height: 1;
133
+ max-height: 1;
134
+ overflow: hidden;
135
+ }
136
+
137
+ SearchResultRow:focus,
138
+ SearchResultRow.-highlighted {
139
+ background: #1a3a1a;
140
+ color: #4ade80;
141
+ }
142
+
143
+ #cmd-bar {
144
+ height: 3;
145
+ background: #0f0f0f;
146
+ border-top: solid #4ade80;
147
+ align: left middle;
148
+ padding: 0 1;
149
+ }
150
+ #help-hint { color: #888; }
151
+ """
152
+
153
+ def __init__(
154
+ self,
155
+ client: JSTDataClient,
156
+ session: Session,
157
+ taxonomy: str | None = None,
158
+ resource_type: str | None = None,
159
+ relation: str | list[str] | None = None,
160
+ ) -> None:
161
+ super().__init__()
162
+ self.client = client
163
+ self.session = session
164
+ self.taxonomy = taxonomy or None
165
+ self.taxonomy_name = taxonomy
166
+ self.resource_type = (resource_type or "").strip().lower() or None
167
+ self.relations = _normalize_relations(relation)
168
+ if self.resource_type not in (None, "entity", "metric", "series"):
169
+ self.resource_type = None
170
+
171
+ self.search_task: asyncio.Task[None] | None = None
172
+ self.catalog: list[Resource] = []
173
+ self.large_search_space = False
174
+
175
+ def _relation_label(self) -> str:
176
+ if not self.relations:
177
+ return ""
178
+ if len(self.relations) == 1:
179
+ return self.relations[0]
180
+ return f"{len(self.relations)} relations"
181
+
182
+ def compose(self) -> ComposeResult:
183
+ with Vertical(id="workspace-body"):
184
+ with Vertical(classes="pane-container", id="results-pane"):
185
+ yield Label(
186
+ "RESULTS // SEARCH",
187
+ classes="pane-header",
188
+ id="results-header",
189
+ )
190
+ yield Input(
191
+ placeholder="/ search catalog…",
192
+ id="search-input",
193
+ )
194
+ with Horizontal(classes="table-header"):
195
+ yield Label("NAME", classes="col-name")
196
+ yield Label("ID", classes="col-id")
197
+ yield Label("SRC", classes="col-src")
198
+ yield Label("TYPE", classes="col-type")
199
+ yield ListView(id="results-list")
200
+ with Horizontal(id="cmd-bar"):
201
+ yield Label("", id="help-hint")
202
+
203
+ def on_mount(self) -> None:
204
+ self._apply_search_chrome()
205
+ self._refresh_help_hint()
206
+ self.run_prefetch()
207
+ self.query_one("#results-list", ListView).focus()
208
+
209
+ def _apply_search_chrome(self) -> None:
210
+ tax = self.taxonomy_name or self.taxonomy or ""
211
+ rtype = self.resource_type.upper() if self.resource_type else ""
212
+ rel = self._relation_label()
213
+ if rel and rtype:
214
+ header = f"RESULTS // {rtype} // {rel}"
215
+ placeholder = f"/ search {rtype.lower()} linked via {rel}…"
216
+ elif rel:
217
+ header = f"RESULTS // ENTITY // {rel}"
218
+ placeholder = f"/ search entities linked via {rel}…"
219
+ elif tax and rtype:
220
+ header = f"RESULTS // {rtype} IN {tax}"
221
+ placeholder = f"/ search {rtype.lower()} in {tax}…"
222
+ elif tax:
223
+ header = f"RESULTS // TAXONOMY {tax}"
224
+ placeholder = f"/ search within {tax}…"
225
+ elif rtype:
226
+ header = f"RESULTS // {rtype}"
227
+ placeholder = f"/ search {rtype.lower()}…"
228
+ else:
229
+ header = "RESULTS // SEARCH"
230
+ placeholder = "/ search catalog…"
231
+ self.query_one("#results-header").update(header)
232
+ self.query_one("#search-input", Input).placeholder = placeholder
233
+
234
+ def _refresh_help_hint(self) -> None:
235
+ n = getattr(self.app, "step_count", 1)
236
+ i = getattr(self.app, "step_index", 0) + 1
237
+ step_id = getattr(self.app, "current_step_id", "console")
238
+ parts = [f"{step_id} {i}/{n}"]
239
+ if self.taxonomy:
240
+ parts.append(f"tax {self.taxonomy}")
241
+ if self.relations:
242
+ rel = self._relation_label()
243
+ if len(rel) > 28:
244
+ rel = rel[:25] + "…"
245
+ parts.append(f"rel {rel}")
246
+ if self.resource_type:
247
+ parts.append(self.resource_type)
248
+ if self.large_search_space:
249
+ parts.append("server search")
250
+ else:
251
+ parts.append("local")
252
+ parts.extend(
253
+ [
254
+ "[bold]/[/bold] search",
255
+ "[bold]enter[/bold] add",
256
+ "[bold]s[/bold] session",
257
+ "[bold]f[/bold] find",
258
+ "[bold]e[/bold] export",
259
+ "[bold]n[/bold]/[bold]p[/bold]",
260
+ "[bold]q[/bold] quit",
261
+ "[bold]?[/bold]",
262
+ ]
263
+ )
264
+ self.query_one("#help-hint").update(" // ".join(parts))
265
+
266
+ def _show_rows(self, resources: list[Resource]) -> None:
267
+ list_view = self.query_one("#results-list", ListView)
268
+ list_view.clear()
269
+ for resource in resources:
270
+ list_view.append(SearchResultRow(resource))
271
+
272
+ def _filter_catalog(self, query: str) -> list[Resource]:
273
+ q = query.lower().strip()
274
+ if not q:
275
+ return list(self.catalog)
276
+ hits: list[Resource] = []
277
+ for resource in self.catalog:
278
+ name = (
279
+ getattr(resource, "label", None)
280
+ or getattr(resource, "name", None)
281
+ or ""
282
+ )
283
+ if q in name.lower() or q in resource.id.lower():
284
+ hits.append(resource)
285
+ return hits
286
+
287
+ @work(exclusive=True, group="prefetch")
288
+ async def run_prefetch(self) -> None:
289
+ """Load a first page so the pane is never empty; note if the set is huge."""
290
+ try:
291
+ entities, metrics, series = await self._fetch_catalog()
292
+ except Exception as e:
293
+ self.notify(f"Could not load catalog: {e}", severity="error")
294
+ return
295
+ self.catalog = [*metrics, *entities, *series]
296
+ self.large_search_space = (
297
+ len(entities) > LOCAL_CAP
298
+ or len(metrics) > LOCAL_CAP
299
+ or len(series) > LOCAL_CAP
300
+ )
301
+ self._show_rows(self.catalog)
302
+ self._refresh_help_hint()
303
+
304
+ async def _fetch_catalog(
305
+ self,
306
+ ) -> tuple[list[Entity], list[Metric], list[Series]]:
307
+ rtype = self.resource_type
308
+ tax = self.taxonomy
309
+ rel = self.relations
310
+ if rtype == "entity":
311
+ entities = await asyncio.to_thread(
312
+ self.client.search_entities,
313
+ None,
314
+ taxonomy=tax,
315
+ relation=rel,
316
+ limit=PREFETCH_LIMIT,
317
+ )
318
+ return list(entities), [], []
319
+ if rtype == "metric":
320
+ metrics = await asyncio.to_thread(
321
+ self.client.search_metrics,
322
+ None,
323
+ taxonomy=tax,
324
+ limit=PREFETCH_LIMIT,
325
+ )
326
+ return [], list(metrics), []
327
+ if rtype == "series":
328
+ series = await asyncio.to_thread(
329
+ self.client.list_series, PREFETCH_LIMIT
330
+ )
331
+ return [], [], list(series)
332
+ if rel:
333
+ entities = await asyncio.to_thread(
334
+ self.client.search_entities,
335
+ None,
336
+ taxonomy=tax,
337
+ relation=rel,
338
+ limit=PREFETCH_LIMIT,
339
+ )
340
+ if tax:
341
+ metrics = await asyncio.to_thread(
342
+ self.client.get_taxonomy_metrics, tax, PREFETCH_LIMIT
343
+ )
344
+ return list(entities), list(metrics), []
345
+ return list(entities), [], []
346
+ if tax:
347
+ entities, metrics = await asyncio.gather(
348
+ asyncio.to_thread(
349
+ self.client.get_taxonomy_entities, tax, PREFETCH_LIMIT
350
+ ),
351
+ asyncio.to_thread(
352
+ self.client.get_taxonomy_metrics, tax, PREFETCH_LIMIT
353
+ ),
354
+ )
355
+ return list(entities), list(metrics), []
356
+ entities, metrics, series = await asyncio.gather(
357
+ asyncio.to_thread(
358
+ self.client.search_entities, None, limit=PREFETCH_LIMIT
359
+ ),
360
+ asyncio.to_thread(
361
+ self.client.search_metrics, None, limit=PREFETCH_LIMIT
362
+ ),
363
+ asyncio.to_thread(self.client.list_series, PREFETCH_LIMIT),
364
+ )
365
+ return list(entities), list(metrics), list(series)
366
+
367
+ def action_focus_filter(self) -> None:
368
+ self.query_one("#search-input", Input).focus()
369
+
370
+ def action_leave_filter(self) -> None:
371
+ if isinstance(self.focused, Input):
372
+ self.query_one("#results-list", ListView).focus()
373
+
374
+ def action_cursor_down(self) -> None:
375
+ focused = self.focused
376
+ if isinstance(focused, Input):
377
+ return
378
+ if focused and hasattr(focused, "action_cursor_down"):
379
+ focused.action_cursor_down()
380
+
381
+ def action_cursor_up(self) -> None:
382
+ focused = self.focused
383
+ if isinstance(focused, Input):
384
+ return
385
+ if focused and hasattr(focused, "action_cursor_up"):
386
+ focused.action_cursor_up()
387
+
388
+ @on(Input.Changed, "#search-input")
389
+ def on_search_changed(self, event: Input.Changed) -> None:
390
+ if self.search_task:
391
+ self.search_task.cancel()
392
+ query = event.value.strip()
393
+ if not query:
394
+ self._show_rows(self.catalog)
395
+ return
396
+ if self.large_search_space:
397
+ if len(query) < 2:
398
+ self._show_rows(self._filter_catalog(query))
399
+ return
400
+ self.search_task = asyncio.create_task(self._do_server_search(query))
401
+ return
402
+ self._show_rows(self._filter_catalog(query))
403
+
404
+ async def _do_server_search(self, query: str) -> None:
405
+ try:
406
+ await asyncio.sleep(0.3)
407
+ results = await self._search_remote(query)
408
+ self._show_rows(results)
409
+ except asyncio.CancelledError:
410
+ pass
411
+ except Exception as e:
412
+ self.notify(f"Search error: {e}", severity="error")
413
+
414
+ async def _search_remote(self, query: str) -> list[Resource]:
415
+ tax = self.taxonomy
416
+ rel = self.relations
417
+ rtype = self.resource_type
418
+ if rtype == "entity":
419
+ return list(
420
+ await asyncio.to_thread(
421
+ self.client.search_entities,
422
+ query,
423
+ taxonomy=tax,
424
+ relation=rel,
425
+ limit=20,
426
+ )
427
+ )
428
+ if rtype == "metric":
429
+ return list(
430
+ await asyncio.to_thread(
431
+ self.client.search_metrics, query, taxonomy=tax, limit=20
432
+ )
433
+ )
434
+ if rtype == "series":
435
+ return list(
436
+ await asyncio.to_thread(self.client.search_series, query, limit=20)
437
+ )
438
+ if rel:
439
+ entities = list(
440
+ await asyncio.to_thread(
441
+ self.client.search_entities,
442
+ query,
443
+ taxonomy=tax,
444
+ relation=rel,
445
+ limit=20,
446
+ )
447
+ )
448
+ if tax:
449
+ metrics = list(
450
+ await asyncio.to_thread(
451
+ self.client.search_metrics, query, taxonomy=tax, limit=15
452
+ )
453
+ )
454
+ return [*metrics, *entities]
455
+ return entities
456
+ if tax:
457
+ entities, metrics = await asyncio.gather(
458
+ asyncio.to_thread(
459
+ self.client.search_entities, query, taxonomy=tax, limit=15
460
+ ),
461
+ asyncio.to_thread(
462
+ self.client.search_metrics, query, taxonomy=tax, limit=15
463
+ ),
464
+ )
465
+ return [*metrics, *entities]
466
+ return list(await asyncio.to_thread(self.client.search, query, limit=20))
467
+
468
+ @on(Input.Submitted, "#search-input")
469
+ def on_search_submit(self) -> None:
470
+ self.query_one("#results-list", ListView).focus()
471
+
472
+ @on(ListView.Selected, "#results-list")
473
+ def add_to_session(self, event: ListView.Selected) -> None:
474
+ resource = event.item.resource
475
+ if not self._add_resource_to_session(resource):
476
+ self.notify(f"{resource.id} is already in session", severity="warning")
477
+ return
478
+ self.notify("Added to session")
479
+
480
+ def _resource_label(self, resource: Resource) -> str:
481
+ return (
482
+ getattr(resource, "label", None)
483
+ or getattr(resource, "name", None)
484
+ or resource.id
485
+ )
486
+
487
+ def _add_resource_to_session(self, resource: Resource) -> bool:
488
+ if isinstance(resource, Metric):
489
+ added = self.session.add_metric(resource.id)
490
+ elif isinstance(resource, Entity):
491
+ added = self.session.add_entity(resource.id)
492
+ elif isinstance(resource, Series):
493
+ added = self.session.add_series(resource.id)
494
+ elif resource.id in self.session.resource_ids():
495
+ return False
496
+ else:
497
+ added = self.session.add_series(resource.id)
498
+ if added:
499
+ self.app.remember_label(resource.id, self._resource_label(resource))
500
+ return added
501
+
502
+
503
+ def create_console_screen(
504
+ client, session, taxonomy=None, resource_type=None, relation=None, **kwargs
505
+ ):
506
+ return WorkspaceScreen(
507
+ client,
508
+ session,
509
+ taxonomy=taxonomy,
510
+ resource_type=resource_type,
511
+ relation=relation,
512
+ )
513
+
514
+
515
+ CONSOLE = register(
516
+ StepSpec(
517
+ id="console",
518
+ name="Console",
519
+ description=(
520
+ "Search metrics, entities, and series, then stage them into the session."
521
+ ),
522
+ create_screen=create_console_screen,
523
+ arguments=(
524
+ StepArgument(
525
+ name="taxonomy",
526
+ type="string",
527
+ description=(
528
+ "Restrict search to entities and metrics in this taxonomy "
529
+ "(slug, e.g. sec-central-index-key)"
530
+ ),
531
+ ),
532
+ StepArgument(
533
+ name="relation",
534
+ type="string",
535
+ multiple=True,
536
+ description=(
537
+ "Restrict entity search to those linked to an anchor via a typed "
538
+ "relationship (<relationship_type>:<to_entity_id>, "
539
+ "e.g. classified_as:sic:3674). Repeatable; OR'd."
540
+ ),
541
+ ),
542
+ StepArgument(
543
+ name="resource_type",
544
+ type="string",
545
+ description="Restrict search to series, metric, or entity",
546
+ choices=("series", "metric", "entity"),
547
+ ),
548
+ ),
549
+ bindings=(
550
+ StepBinding("/", "focus_filter", "Search the catalog"),
551
+ StepBinding("enter", "submit", "Focus results / add highlighted item"),
552
+ StepBinding("j / ↓", "cursor_down", "Move highlight down"),
553
+ StepBinding("k / ↑", "cursor_up", "Move highlight up"),
554
+ StepBinding("escape", "leave_filter", "Leave the search field"),
555
+ ),
556
+ example=(
557
+ "jst run console --relation classified_as:sic:3674 --resource-type entity"
558
+ ),
559
+ )
560
+ )