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,658 @@
1
+ """Rank step — leaderboard for one session metric across a metric population."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from typing import Optional
9
+
10
+ from textual import on, work
11
+ from textual.app import ComposeResult
12
+ from textual.binding import Binding
13
+ from textual.containers import Horizontal, Vertical
14
+ from textual.screen import Screen
15
+ from textual.widgets import Input, Label, ListItem, ListView, Static
16
+
17
+ from ..client import JSTDataClient
18
+ from ..models import TimeSeries
19
+ from ..session import Session
20
+ from .base import StepArgument, StepBinding, StepSpec, label_for, register
21
+ from .chart import (
22
+ VALUE_WIDTH,
23
+ format_rank_line,
24
+ last_observation_timestamp,
25
+ last_observation_value,
26
+ pick_entity,
27
+ shared_pane_max,
28
+ )
29
+
30
+ PAGE_SIZE = 50
31
+ RANK_DEBOUNCE_S = 0.4
32
+ TAXONOMY_PAGE = 20
33
+ TAXONOMY_ID_CAP = 2000
34
+ NAME_WIDTH = 18
35
+
36
+
37
+ @dataclass
38
+ class RankEntry:
39
+ entity_id: str
40
+ entity_label: str
41
+ series_id: str
42
+ value: float
43
+ frequency: str
44
+ units: str
45
+ as_of: Optional[datetime]
46
+
47
+
48
+ class MetricRow(ListItem):
49
+ def __init__(self, metric_id: str, caption: str) -> None:
50
+ super().__init__()
51
+ self.metric_id = metric_id
52
+ self.caption = caption
53
+
54
+ def compose(self) -> ComposeResult:
55
+ yield Label(self.caption)
56
+
57
+
58
+ class RankRow(ListItem):
59
+ def __init__(self, entry: RankEntry, rank: int, pane_max: float, bar_width: int) -> None:
60
+ super().__init__()
61
+ self.entry = entry
62
+ self.rank = rank
63
+ self.pane_max = pane_max
64
+ self.bar_width = bar_width
65
+
66
+ def compose(self) -> ComposeResult:
67
+ yield Label(
68
+ format_rank_line(
69
+ self.rank,
70
+ self.entry.entity_label,
71
+ self.entry.value,
72
+ self.pane_max,
73
+ self.bar_width,
74
+ name_width=NAME_WIDTH,
75
+ )
76
+ )
77
+
78
+
79
+ class RankScreen(Screen):
80
+ """Session metrics on the left; value-sorted leaderboard on the right."""
81
+
82
+ BINDINGS = [
83
+ Binding("slash", "focus_filter", "Filter ranking", key_display="/"),
84
+ Binding("escape", "leave_filter", "Leave filter", show=False),
85
+ Binding("tab", "cycle_panes", "Switch metrics / rank", priority=True),
86
+ Binding(
87
+ "shift+tab",
88
+ "cycle_panes",
89
+ "Switch panes reverse",
90
+ show=False,
91
+ priority=True,
92
+ ),
93
+ Binding("enter", "confirm", "Add entity / open ranking", priority=True),
94
+ Binding("shift+enter", "add_entity", "Add entity to session", show=False, priority=True),
95
+ Binding("j", "cursor_down", "Move highlight down", show=False),
96
+ Binding("k", "cursor_up", "Move highlight up", show=False),
97
+ ]
98
+
99
+ DEFAULT_CSS = """
100
+ #rank-body { height: 1fr; }
101
+
102
+ .pane-container {
103
+ border: solid #222;
104
+ background: #0f0f0f;
105
+ }
106
+ #metrics-pane { width: 36%; }
107
+ #board-pane { width: 1fr; }
108
+
109
+ .pane-header {
110
+ background: #1a1a1a;
111
+ color: #4ade80;
112
+ padding: 0 1;
113
+ text-style: bold;
114
+ height: 1;
115
+ }
116
+
117
+ #metrics-list, #rank-list {
118
+ height: 1fr;
119
+ background: #0f0f0f;
120
+ }
121
+
122
+ ListView > ListItem,
123
+ MetricRow,
124
+ RankRow {
125
+ height: 1;
126
+ min-height: 1;
127
+ max-height: 1;
128
+ width: 100%;
129
+ padding: 0 1;
130
+ overflow: hidden;
131
+ }
132
+
133
+ MetricRow:focus,
134
+ MetricRow.-highlighted,
135
+ RankRow:focus,
136
+ RankRow.-highlighted {
137
+ background: #1a3a1a;
138
+ color: #4ade80;
139
+ }
140
+
141
+ #empty-prompt {
142
+ height: 1fr;
143
+ padding: 2 2;
144
+ color: #4ade80;
145
+ text-style: bold;
146
+ background: #111111;
147
+ }
148
+
149
+ #rank-filter {
150
+ height: 3;
151
+ background: transparent;
152
+ border: none;
153
+ border-bottom: solid #333;
154
+ padding: 0 1;
155
+ }
156
+
157
+ #board-meta {
158
+ height: 1;
159
+ color: #888;
160
+ padding: 0 1;
161
+ }
162
+
163
+ #board-status {
164
+ height: 1;
165
+ color: #888;
166
+ padding: 0 1;
167
+ }
168
+
169
+ #cmd-bar {
170
+ height: 3;
171
+ background: #0f0f0f;
172
+ border-top: solid #4ade80;
173
+ align: left middle;
174
+ padding: 0 1;
175
+ }
176
+ #help-hint { color: #888; }
177
+ """
178
+
179
+ def __init__(
180
+ self,
181
+ client: JSTDataClient,
182
+ session: Session,
183
+ taxonomy: Optional[str] = None,
184
+ ):
185
+ super().__init__()
186
+ self.client = client
187
+ self.session = session
188
+ self.taxonomy = (taxonomy or "").strip() or None
189
+ self.filter_query = ""
190
+ self.page_offset = 0
191
+ self.has_more = False
192
+ self.loading_more = False
193
+ self.rank_task: asyncio.Task[None] | None = None
194
+ self._metric_snapshot: tuple[str, ...] = ()
195
+ self._active_metric_id: Optional[str] = None
196
+ self._board: list[RankEntry] = []
197
+ self._taxonomy_ids: set[str] | None = None
198
+
199
+ def compose(self) -> ComposeResult:
200
+ with Horizontal(id="rank-body"):
201
+ with Vertical(classes="pane-container", id="metrics-pane"):
202
+ yield Label("METRICS // SESSION", classes="pane-header")
203
+ yield ListView(id="metrics-list")
204
+ yield Static(
205
+ "No metrics in session.\n\n"
206
+ "Press [bold]f[/bold] to find metrics and add them.\n"
207
+ "Then highlight a metric — the ranking fills on the right.\n"
208
+ "Tab to the ranking, Enter to add an entity.",
209
+ id="empty-prompt",
210
+ markup=True,
211
+ )
212
+ with Vertical(classes="pane-container", id="board-pane"):
213
+ yield Label("RANK // —", classes="pane-header", id="board-header")
214
+ yield Label("", id="board-meta")
215
+ yield Input(
216
+ placeholder="/ filter this page by name…",
217
+ id="rank-filter",
218
+ )
219
+ yield ListView(id="rank-list")
220
+ yield Label("", id="board-status")
221
+ with Horizontal(id="cmd-bar"):
222
+ yield Label("", id="help-hint")
223
+
224
+ def on_mount(self) -> None:
225
+ self._refresh_help_hint()
226
+ if self.taxonomy:
227
+ self.load_taxonomy_ids()
228
+ self._sync_metrics(force=True)
229
+ self.query_one("#metrics-list", ListView).focus()
230
+
231
+ def on_screen_resume(self) -> None:
232
+ self._sync_metrics()
233
+
234
+ def _labels(self) -> dict[str, str]:
235
+ return getattr(self.app, "labels", {})
236
+
237
+ def _refresh_help_hint(self) -> None:
238
+ n = getattr(self.app, "step_count", 1)
239
+ i = getattr(self.app, "step_index", 0) + 1
240
+ tax = self.taxonomy or "all"
241
+ parts = [
242
+ f"rank {i}/{n}",
243
+ f"taxonomy {tax}",
244
+ "[bold]/[/bold] filter",
245
+ "[bold]enter[/bold] open rank / add",
246
+ "[bold]tab[/bold] panes",
247
+ "[bold]j/k[/bold] move",
248
+ "[bold]s[/bold] session",
249
+ "[bold]f[/bold] find",
250
+ "[bold]e[/bold] export",
251
+ "[bold]n[/bold]/[bold]p[/bold]",
252
+ "[bold]q[/bold] quit",
253
+ "[bold]?[/bold]",
254
+ ]
255
+ self.query_one("#help-hint").update(" // ".join(parts))
256
+
257
+ def _sync_metrics(self, force: bool = False) -> None:
258
+ snapshot = tuple(self.session.metric)
259
+ if not force and snapshot == self._metric_snapshot:
260
+ return
261
+ self._metric_snapshot = snapshot
262
+ empty = self.query_one("#empty-prompt")
263
+ roster = self.query_one("#metrics-list", ListView)
264
+ roster.clear()
265
+ if not snapshot:
266
+ empty.display = True
267
+ roster.display = False
268
+ self._clear_board("Add metrics to rank a population.")
269
+ return
270
+ empty.display = False
271
+ roster.display = True
272
+ labels = self._labels()
273
+ for mid in snapshot:
274
+ name = label_for(labels, mid)
275
+ roster.append(MetricRow(mid, f" {name} [{mid}]"))
276
+ # Defer highlight + first fetch until rows are mounted.
277
+ self.call_after_refresh(self._select_initial_metric)
278
+
279
+ def _select_initial_metric(self) -> None:
280
+ roster = self.query_one("#metrics-list", ListView)
281
+ if not len(roster):
282
+ return
283
+ if roster.index is None:
284
+ roster.index = 0
285
+ child = roster.highlighted_child
286
+ if isinstance(child, MetricRow):
287
+ self._active_metric_id = child.metric_id
288
+ self.load_rank(reset=True)
289
+
290
+ def _clear_board(self, status: str) -> None:
291
+ self._board = []
292
+ self._active_metric_id = None
293
+ self.has_more = False
294
+ self.page_offset = 0
295
+ self.query_one("#rank-list", ListView).clear()
296
+ self.query_one("#board-header").update("RANK // —")
297
+ self.query_one("#board-meta").update("")
298
+ self.query_one("#board-status").update(status)
299
+
300
+ def _bar_width(self) -> int:
301
+ # pane ≈ body width minus metrics pane; leave room for rank/name/value.
302
+ width = self.size.width or 80
303
+ usable = max(24, int(width * 0.64) - NAME_WIDTH - VALUE_WIDTH - 12)
304
+ return usable
305
+
306
+ def _filtered_board(self) -> list[RankEntry]:
307
+ q = self.filter_query.strip().lower()
308
+ if not q:
309
+ return list(self._board)
310
+ out: list[RankEntry] = []
311
+ for entry in self._board:
312
+ hay = f"{entry.entity_label} {entry.entity_id}".lower()
313
+ if q in hay:
314
+ out.append(entry)
315
+ return out
316
+
317
+ def _rebuild_rank_list(self) -> None:
318
+ list_view = self.query_one("#rank-list", ListView)
319
+ list_view.clear()
320
+ rows = self._filtered_board()
321
+ if not rows:
322
+ return
323
+ pane_max = shared_pane_max(e.value for e in rows)
324
+ bar_width = self._bar_width()
325
+ # Rank numbers follow absolute board order, not the filtered subset.
326
+ index_by_id = {
327
+ (e.entity_id, e.series_id): i + 1 for i, e in enumerate(self._board)
328
+ }
329
+ for entry in rows:
330
+ rank = index_by_id.get((entry.entity_id, entry.series_id), 0)
331
+ list_view.append(RankRow(entry, rank, pane_max, bar_width))
332
+ # clear() leaves index=None; without this, Enter's select_cursor is a no-op
333
+ # until the user moves with j/k.
334
+ list_view.index = 0
335
+
336
+ def _entries_from_series(self, series_list: list[TimeSeries]) -> list[RankEntry]:
337
+ entries: list[RankEntry] = []
338
+ for ts in series_list:
339
+ value = last_observation_value(ts)
340
+ if value is None:
341
+ continue
342
+ entity = pick_entity(ts.series.entities, self._taxonomy_ids)
343
+ if entity is None:
344
+ continue
345
+ entries.append(
346
+ RankEntry(
347
+ entity_id=entity.id,
348
+ entity_label=entity.label or entity.id,
349
+ series_id=ts.series.id,
350
+ value=value,
351
+ frequency=ts.series.frequency or "",
352
+ units=ts.series.units or "",
353
+ as_of=last_observation_timestamp(ts),
354
+ )
355
+ )
356
+ return entries
357
+
358
+ @work(exclusive=True, group="taxonomy")
359
+ async def load_taxonomy_ids(self) -> None:
360
+ if not self.taxonomy:
361
+ self._taxonomy_ids = None
362
+ return
363
+ ids: set[str] = set()
364
+ offset = 0
365
+ try:
366
+ while offset < TAXONOMY_ID_CAP:
367
+ page = await asyncio.to_thread(
368
+ self.client.get_taxonomy_entities,
369
+ self.taxonomy,
370
+ TAXONOMY_PAGE,
371
+ offset,
372
+ )
373
+ if not page:
374
+ break
375
+ ids.update(e.id for e in page)
376
+ if len(page) < TAXONOMY_PAGE:
377
+ break
378
+ offset += TAXONOMY_PAGE
379
+ if len(ids) >= TAXONOMY_ID_CAP:
380
+ break
381
+ except Exception:
382
+ self._taxonomy_ids = None
383
+ return
384
+ self._taxonomy_ids = ids
385
+
386
+ @work(exclusive=True, group="rank")
387
+ async def load_rank(self, reset: bool = False) -> None:
388
+ metric_id = self._active_metric_id
389
+ if not metric_id:
390
+ self.loading_more = False
391
+ return
392
+ if reset:
393
+ self.page_offset = 0
394
+ self.has_more = False
395
+ self._board = []
396
+ status = self.query_one("#board-status")
397
+ status.update("Loading ranking…")
398
+ labels = self._labels()
399
+ name = label_for(labels, metric_id)
400
+ self.query_one("#board-header").update(f"RANK // {name}")
401
+ try:
402
+ series_list: list[TimeSeries] = await asyncio.to_thread(
403
+ self.client.query,
404
+ metric=metric_id,
405
+ taxonomy=self.taxonomy,
406
+ tail=1,
407
+ sort_by="value",
408
+ limit=PAGE_SIZE,
409
+ offset=self.page_offset,
410
+ )
411
+ except Exception as e:
412
+ status.update(f"Error: {e}")
413
+ self.loading_more = False
414
+ return
415
+ if metric_id != self._active_metric_id:
416
+ self.loading_more = False
417
+ return
418
+ if reset:
419
+ self._board = []
420
+ page = self._entries_from_series(series_list)
421
+ self._board.extend(page)
422
+ self.has_more = len(series_list) == PAGE_SIZE
423
+ self._rebuild_rank_list()
424
+
425
+ meta_bits: list[str] = []
426
+ if page:
427
+ freqs = {e.frequency for e in self._board if e.frequency}
428
+ units = next((e.units for e in self._board if e.units), "")
429
+ as_ofs = [e.as_of for e in self._board if e.as_of is not None]
430
+ if len(freqs) == 1:
431
+ meta_bits.append(next(iter(freqs)))
432
+ elif freqs:
433
+ meta_bits.append("mixed freq")
434
+ if units:
435
+ meta_bits.append(units)
436
+ if as_ofs:
437
+ years = {t.year for t in as_ofs}
438
+ if len(years) == 1:
439
+ meta_bits.append(f"as of {next(iter(years))}")
440
+ else:
441
+ meta_bits.append(f"as of {min(years)}–{max(years)}")
442
+ self.query_one("#board-meta").update(" · ".join(meta_bits))
443
+
444
+ shown = len(self._filtered_board())
445
+ total = len(self._board)
446
+ extra = "+" if self.has_more else ""
447
+ tax = self.taxonomy or "all"
448
+ q = self.filter_query.strip()
449
+ qbit = f" · filter “{q}”" if q else ""
450
+ if reset and not series_list:
451
+ tax_bit = f" in taxonomy “{tax}”" if self.taxonomy else ""
452
+ status.update(
453
+ f"No series for “{metric_id}”{tax_bit}. "
454
+ "Try another metric, or check the metric id."
455
+ )
456
+ elif reset and series_list and not page:
457
+ status.update(
458
+ f"Got {len(series_list)} series but no entities to show · {tax}"
459
+ )
460
+ else:
461
+ status.update(f"{shown}/{total}{extra} · desc · {tax}{qbit}")
462
+ self.loading_more = False
463
+
464
+ def action_focus_filter(self) -> None:
465
+ self.query_one("#rank-filter", Input).focus()
466
+
467
+ def action_leave_filter(self) -> None:
468
+ filt = self.query_one("#rank-filter", Input)
469
+ if self.focused is filt:
470
+ self.query_one("#rank-list", ListView).focus()
471
+
472
+ def action_cycle_panes(self) -> None:
473
+ if isinstance(self.focused, Input):
474
+ self.query_one("#rank-list", ListView).focus()
475
+ return
476
+ if self.focused is self.query_one("#metrics-list"):
477
+ self.query_one("#rank-list", ListView).focus()
478
+ else:
479
+ self.query_one("#metrics-list", ListView).focus()
480
+
481
+ def action_cursor_down(self) -> None:
482
+ focused = self.focused
483
+ if isinstance(focused, Input):
484
+ return
485
+ if focused and hasattr(focused, "action_cursor_down"):
486
+ focused.action_cursor_down()
487
+
488
+ def action_cursor_up(self) -> None:
489
+ focused = self.focused
490
+ if isinstance(focused, Input):
491
+ return
492
+ if focused and hasattr(focused, "action_cursor_up"):
493
+ focused.action_cursor_up()
494
+
495
+ def _highlighted_rank(self) -> Optional[RankEntry]:
496
+ child = self.query_one("#rank-list", ListView).highlighted_child
497
+ if isinstance(child, RankRow):
498
+ return child.entry
499
+ return None
500
+
501
+ def _rank_list_focused(self) -> bool:
502
+ focused = self.focused
503
+ if focused is self.query_one("#rank-list"):
504
+ return True
505
+ node = focused
506
+ while node is not None:
507
+ if getattr(node, "id", None) == "rank-list":
508
+ return True
509
+ node = getattr(node, "parent", None)
510
+ return False
511
+
512
+ def _add_entity(self, entry: RankEntry) -> None:
513
+ if not self.session.add_entity(entry.entity_id):
514
+ self.notify(f"{entry.entity_id} is already in session", severity="warning")
515
+ return
516
+ if hasattr(self.app, "remember_label"):
517
+ self.app.remember_label(entry.entity_id, entry.entity_label)
518
+ self.notify(f"Added {entry.entity_id} to session")
519
+
520
+ def action_confirm(self) -> None:
521
+ """Enter: on the board, add entity; on metrics, jump to the board."""
522
+ if isinstance(self.focused, Input):
523
+ return
524
+ if self._rank_list_focused():
525
+ self.action_add_entity()
526
+ return
527
+ # Metrics list: ranking loads on highlight — just move focus over.
528
+ self.query_one("#rank-list", ListView).focus()
529
+
530
+ def action_add_entity(self) -> None:
531
+ if isinstance(self.focused, Input):
532
+ return
533
+ list_view = self.query_one("#rank-list", ListView)
534
+ entry = self._highlighted_rank()
535
+ if entry is None and len(list_view):
536
+ list_view.index = 0
537
+ entry = self._highlighted_rank()
538
+ if entry is None:
539
+ if not self._board:
540
+ self.notify(
541
+ "Ranking is empty — pick a metric that has series "
542
+ "(right pane shows why)",
543
+ severity="warning",
544
+ )
545
+ else:
546
+ self.notify("No entity selected on the ranking", severity="warning")
547
+ return
548
+ self._add_entity(entry)
549
+
550
+ @on(ListView.Selected, "#rank-list")
551
+ def on_rank_selected(self, event: ListView.Selected) -> None:
552
+ """Enter on the leaderboard adds the highlighted entity."""
553
+ if isinstance(event.item, RankRow):
554
+ self._add_entity(event.item.entry)
555
+
556
+ @on(ListView.Selected, "#metrics-list")
557
+ def on_metric_selected(self, event: ListView.Selected) -> None:
558
+ """Enter on a metric focuses the ranking for that metric."""
559
+ if isinstance(event.item, MetricRow):
560
+ self._active_metric_id = event.item.metric_id
561
+ self.load_rank(reset=True)
562
+ self.query_one("#rank-list", ListView).focus()
563
+
564
+ @on(Input.Changed, "#rank-filter")
565
+ def on_filter_changed(self, event: Input.Changed) -> None:
566
+ self.filter_query = event.value
567
+ self._rebuild_rank_list()
568
+ shown = len(self._filtered_board())
569
+ total = len(self._board)
570
+ extra = "+" if self.has_more else ""
571
+ tax = self.taxonomy or "all"
572
+ q = self.filter_query.strip()
573
+ qbit = f" · filter “{q}”" if q else ""
574
+ self.query_one("#board-status").update(
575
+ f"{shown}/{total}{extra} · desc · {tax}{qbit}"
576
+ )
577
+
578
+ @on(Input.Submitted, "#rank-filter")
579
+ def on_filter_submit(self) -> None:
580
+ self.query_one("#rank-list", ListView).focus()
581
+
582
+ @on(ListView.Highlighted, "#metrics-list")
583
+ def on_metric_highlighted(self, event: ListView.Highlighted) -> None:
584
+ if not isinstance(event.item, MetricRow):
585
+ return
586
+ metric_id = event.item.metric_id
587
+ if self.rank_task:
588
+ self.rank_task.cancel()
589
+ self.rank_task = asyncio.create_task(self._debounced_rank(metric_id))
590
+
591
+ async def _debounced_rank(self, metric_id: str) -> None:
592
+ try:
593
+ await asyncio.sleep(RANK_DEBOUNCE_S)
594
+ self._active_metric_id = metric_id
595
+ self.load_rank(reset=True)
596
+ except asyncio.CancelledError:
597
+ pass
598
+
599
+ @on(ListView.Highlighted, "#rank-list")
600
+ def on_rank_highlighted(self, event: ListView.Highlighted) -> None:
601
+ if self.loading_more or not self.has_more:
602
+ return
603
+ if self.filter_query.strip():
604
+ return
605
+ list_view = self.query_one("#rank-list", ListView)
606
+ if event.item is None:
607
+ return
608
+ index = list_view.index
609
+ if index is None or index < len(self._board) - 1:
610
+ return
611
+ self.loading_more = True
612
+ self.page_offset = len(self._board)
613
+ self.load_rank(reset=False)
614
+
615
+
616
+ def create_rank_screen(client, session, taxonomy=None, **kwargs):
617
+ return RankScreen(client, session, taxonomy=taxonomy)
618
+
619
+
620
+ RANK = register(
621
+ StepSpec(
622
+ id="rank",
623
+ name="Rank",
624
+ description=(
625
+ "Rank entities for a session metric by latest value "
626
+ "(shared-scale leaderboard)."
627
+ ),
628
+ create_screen=create_rank_screen,
629
+ arguments=(
630
+ StepArgument(
631
+ name="taxonomy",
632
+ type="string",
633
+ description=(
634
+ "Restrict ranking to entities in this taxonomy"
635
+ ),
636
+ ),
637
+ ),
638
+ bindings=(
639
+ StepBinding("/", "focus_filter", "Filter the current ranking page by name"),
640
+ StepBinding(
641
+ "enter",
642
+ "confirm",
643
+ "On ranking: add entity. On metrics: open the ranking pane.",
644
+ ),
645
+ StepBinding(
646
+ "shift+enter",
647
+ "add_entity",
648
+ "Add the highlighted ranked entity to the session",
649
+ ),
650
+ StepBinding(
651
+ "tab", "cycle_panes", "Switch focus between metrics and ranking"
652
+ ),
653
+ StepBinding("j / k", "cursor", "Move highlight in the focused list"),
654
+ StepBinding("escape", "leave_filter", "Leave the ranking filter"),
655
+ ),
656
+ example="jst run console --taxonomy country : discover : rank --taxonomy country",
657
+ )
658
+ )