runledger-cli 0.1.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.
- cli/__init__.py +0 -0
- cli/guided.py +444 -0
- cli/ledger.py +1276 -0
- cli/tests/__init__.py +0 -0
- cli/tests/test_data_features.py +254 -0
- cli/tests/test_integration.py +177 -0
- cli/tests/test_ledger.py +378 -0
- cli/tests/test_real_results.py +153 -0
- runledger_cli-0.1.0.dist-info/METADATA +198 -0
- runledger_cli-0.1.0.dist-info/RECORD +13 -0
- runledger_cli-0.1.0.dist-info/WHEEL +5 -0
- runledger_cli-0.1.0.dist-info/entry_points.txt +2 -0
- runledger_cli-0.1.0.dist-info/top_level.txt +1 -0
cli/__init__.py
ADDED
|
File without changes
|
cli/guided.py
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""RunLedger – interactive guided experiment search."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.progress import Progress
|
|
13
|
+
from rich.prompt import Prompt, Confirm
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
# ------------------------------------------------------------
|
|
17
|
+
# Configuration – must match backend settings
|
|
18
|
+
# ------------------------------------------------------------
|
|
19
|
+
API_BASE = "http://localhost:8080/api/runs"
|
|
20
|
+
SAVED_API = "http://localhost:8080/api/saved"
|
|
21
|
+
HEALTH_URL = "http://localhost:8080/actuator/health"
|
|
22
|
+
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
# ------------------------------------------------------------
|
|
26
|
+
# Tiny helpers (self‑contained; do NOT import ledger.py)
|
|
27
|
+
# ------------------------------------------------------------
|
|
28
|
+
def backend_ready():
|
|
29
|
+
try:
|
|
30
|
+
return requests.get(HEALTH_URL, timeout=2).status_code == 200
|
|
31
|
+
except Exception:
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
def wait_for_backend(timeout=60):
|
|
35
|
+
console.print("[dim]Waiting for backend...[/dim]", end="")
|
|
36
|
+
start = time.time()
|
|
37
|
+
while time.time() - start < timeout:
|
|
38
|
+
if backend_ready():
|
|
39
|
+
console.print("[green] ready![/green]")
|
|
40
|
+
return True
|
|
41
|
+
time.sleep(2)
|
|
42
|
+
console.print(".", end="")
|
|
43
|
+
console.print("\n[red]Backend did not start in time.[/red]")
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
def ingest_folder(folder, batch):
|
|
47
|
+
"""Scan a folder using the API, showing a progress bar."""
|
|
48
|
+
path = Path(folder).expanduser()
|
|
49
|
+
if not path.exists():
|
|
50
|
+
console.print(f"[red]Folder not found: {folder}[/red]")
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
json_files = list(path.glob("*.json")) + list(path.glob("*.jsonl"))
|
|
54
|
+
if not json_files:
|
|
55
|
+
console.print(f"[yellow]No JSON/JSONL files found in {folder}[/yellow]")
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
console.print(f"Found {len(json_files)} file(s). Ingesting into batch '{batch}'...")
|
|
59
|
+
count = 0
|
|
60
|
+
with Progress() as progress:
|
|
61
|
+
task = progress.add_task("[cyan]Ingesting...", total=len(json_files))
|
|
62
|
+
for jfile in json_files:
|
|
63
|
+
progress.update(task, description=f"Processing {jfile.name}...")
|
|
64
|
+
try:
|
|
65
|
+
with open(jfile, "r", encoding="utf-8") as fh:
|
|
66
|
+
# Decide if it's JSONL by extension
|
|
67
|
+
if jfile.suffix == ".jsonl":
|
|
68
|
+
for idx, line in enumerate(fh):
|
|
69
|
+
line = line.strip()
|
|
70
|
+
if not line:
|
|
71
|
+
continue
|
|
72
|
+
try:
|
|
73
|
+
run = json.loads(line)
|
|
74
|
+
except json.JSONDecodeError as e:
|
|
75
|
+
console.print(f"[red]Skipping {jfile.name} line {idx+1}: {e}[/red]")
|
|
76
|
+
continue
|
|
77
|
+
payload = {"payload": run, "batch": batch}
|
|
78
|
+
payload["payload"]["_source"] = {"file": jfile.name, "index": idx}
|
|
79
|
+
try:
|
|
80
|
+
resp = requests.post(API_BASE, json=payload)
|
|
81
|
+
if resp.status_code == 201:
|
|
82
|
+
count += 1
|
|
83
|
+
else:
|
|
84
|
+
console.print(f"[red]Failed to ingest line {idx+1} from {jfile.name}: {resp.status_code}[/red]")
|
|
85
|
+
except requests.RequestException as e:
|
|
86
|
+
console.print(f"[red]Error sending {jfile.name}: {e}[/red]")
|
|
87
|
+
else:
|
|
88
|
+
data = json.load(fh)
|
|
89
|
+
if isinstance(data, dict):
|
|
90
|
+
data = [data]
|
|
91
|
+
for idx, run in enumerate(data):
|
|
92
|
+
payload = {"payload": run, "batch": batch}
|
|
93
|
+
payload["payload"]["_source"] = {"file": jfile.name, "index": idx}
|
|
94
|
+
try:
|
|
95
|
+
resp = requests.post(API_BASE, json=payload)
|
|
96
|
+
if resp.status_code == 201:
|
|
97
|
+
count += 1
|
|
98
|
+
else:
|
|
99
|
+
console.print(f"[red]Failed to ingest run {idx} from {jfile.name}: {resp.status_code}[/red]")
|
|
100
|
+
except requests.RequestException as e:
|
|
101
|
+
console.print(f"[red]Error sending {jfile.name}: {e}[/red]")
|
|
102
|
+
except Exception as e:
|
|
103
|
+
console.print(f"[red]Skipping {jfile.name}: {e}[/red]")
|
|
104
|
+
progress.advance(task)
|
|
105
|
+
console.print(f"[green]Successfully ingested {count} run(s) into batch '{batch}'.[/green]")
|
|
106
|
+
return count
|
|
107
|
+
|
|
108
|
+
def fetch_verbose_metrics(batch):
|
|
109
|
+
params = {"batch": batch, "verbose": "true"}
|
|
110
|
+
resp = requests.get(f"{API_BASE}/metrics", params=params)
|
|
111
|
+
if resp.status_code == 200:
|
|
112
|
+
return resp.json()
|
|
113
|
+
return []
|
|
114
|
+
|
|
115
|
+
def perform_search(metric=None, op=None, value=None, q=None, fuzzy=False, batch=None, pointers=True, block=None):
|
|
116
|
+
params = {"page": 0, "size": 50, "sort": "created_at,desc"}
|
|
117
|
+
if batch:
|
|
118
|
+
params["batch"] = batch
|
|
119
|
+
if q:
|
|
120
|
+
params["q"] = q
|
|
121
|
+
if fuzzy:
|
|
122
|
+
params["fuzzy"] = "true"
|
|
123
|
+
elif metric and op and value is not None:
|
|
124
|
+
params["metric"] = metric
|
|
125
|
+
params["op"] = op
|
|
126
|
+
params["value"] = value
|
|
127
|
+
if pointers:
|
|
128
|
+
params["pointers"] = "true"
|
|
129
|
+
if block is not None:
|
|
130
|
+
params["block"] = block
|
|
131
|
+
try:
|
|
132
|
+
resp = requests.get(API_BASE, params=params)
|
|
133
|
+
if resp.status_code == 200:
|
|
134
|
+
return resp.json()
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
def perform_multi_search(filters, combine, batch=None, block=None):
|
|
140
|
+
body = {"filters": filters, "combine": combine}
|
|
141
|
+
if batch:
|
|
142
|
+
body["batch"] = batch
|
|
143
|
+
if block is not None:
|
|
144
|
+
body["block"] = block
|
|
145
|
+
try:
|
|
146
|
+
resp = requests.post(f"{API_BASE}/search", json=body)
|
|
147
|
+
if resp.status_code == 200:
|
|
148
|
+
return resp.json()
|
|
149
|
+
except Exception:
|
|
150
|
+
pass
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
def save_search(name, batch, params):
|
|
154
|
+
body = {"name": name, "batch": batch, "paramsJson": json.dumps(params)}
|
|
155
|
+
resp = requests.post(SAVED_API, json=body)
|
|
156
|
+
if resp.status_code == 200:
|
|
157
|
+
return True
|
|
158
|
+
console.print(f"[red]Failed to save search: {resp.text}[/red]")
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
def show_blocks(run: dict, dot_path: str = None, block_levels: list = None):
|
|
162
|
+
"""Simplified block display (identical to ledger.py's version)."""
|
|
163
|
+
payload = run.get("payload", {})
|
|
164
|
+
if isinstance(payload, dict):
|
|
165
|
+
source_file = payload.get("_source", {}).get("file") or payload.get("experiment") or f"run-{run['id']}"
|
|
166
|
+
else:
|
|
167
|
+
source_file = f"run-{run['id']}"
|
|
168
|
+
|
|
169
|
+
matched = run.get("matched")
|
|
170
|
+
if matched:
|
|
171
|
+
console.print(" Matches:")
|
|
172
|
+
for m in matched:
|
|
173
|
+
val = m.get("value")
|
|
174
|
+
if isinstance(val, float):
|
|
175
|
+
val_str = f"{val:.6f}"
|
|
176
|
+
else:
|
|
177
|
+
val_str = str(val)
|
|
178
|
+
console.print(f" • {m['pointer']} = {val_str}")
|
|
179
|
+
console.print()
|
|
180
|
+
|
|
181
|
+
if dot_path is None:
|
|
182
|
+
try:
|
|
183
|
+
pretty = json.dumps(payload, indent=2)
|
|
184
|
+
except Exception:
|
|
185
|
+
pretty = str(payload)
|
|
186
|
+
console.print(Panel(pretty, title=f"Run {run['id']} · {source_file}"))
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
parts = [seg.replace("[]", "") for seg in dot_path.split(".")]
|
|
190
|
+
max_block = len(parts) - 1
|
|
191
|
+
if block_levels is None:
|
|
192
|
+
levels = list(range(max_block + 1))
|
|
193
|
+
else:
|
|
194
|
+
levels = [l for l in block_levels if 0 <= l <= max_block]
|
|
195
|
+
if not levels:
|
|
196
|
+
console.print(f"[yellow]Requested block depth(s) out of range. Max depth is {max_block}.[/yellow]")
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
for block in sorted(levels, reverse=True):
|
|
200
|
+
if block == 0 and matched:
|
|
201
|
+
for i, m in enumerate(matched):
|
|
202
|
+
snippet = m.get("snippet")
|
|
203
|
+
try:
|
|
204
|
+
pretty = json.dumps(snippet, indent=2)
|
|
205
|
+
except Exception:
|
|
206
|
+
pretty = str(snippet)
|
|
207
|
+
title = f"Run {run['id']} · {source_file} | metric: {dot_path} | depth: 0 (match {i+1}/{len(matched)})"
|
|
208
|
+
console.print(Panel(pretty, title=title))
|
|
209
|
+
else:
|
|
210
|
+
ancestor_parts = parts[:len(parts) - (block + 1)]
|
|
211
|
+
pointer = "/" + "/".join(ancestor_parts) if ancestor_parts else ""
|
|
212
|
+
node = navigate_json_pointer(payload, pointer) if pointer else payload
|
|
213
|
+
depth_label = ""
|
|
214
|
+
if block == max_block:
|
|
215
|
+
depth_label = " (full run)"
|
|
216
|
+
elif block == 0:
|
|
217
|
+
depth_label = " (tightest match)"
|
|
218
|
+
try:
|
|
219
|
+
pretty = json.dumps(node, indent=2) if node is not None else "null"
|
|
220
|
+
except Exception:
|
|
221
|
+
pretty = str(node)
|
|
222
|
+
console.print(Panel(pretty, title=f"Run {run['id']} · {source_file} | metric: {dot_path} | depth: {block}{depth_label}"))
|
|
223
|
+
|
|
224
|
+
def navigate_json_pointer(doc, pointer: str):
|
|
225
|
+
parts = pointer.lstrip("/").split("/")
|
|
226
|
+
cur = doc
|
|
227
|
+
for p in parts:
|
|
228
|
+
if p == "":
|
|
229
|
+
continue
|
|
230
|
+
if isinstance(cur, dict):
|
|
231
|
+
cur = cur.get(p, None)
|
|
232
|
+
if cur is None:
|
|
233
|
+
return None
|
|
234
|
+
else:
|
|
235
|
+
return None
|
|
236
|
+
return cur
|
|
237
|
+
|
|
238
|
+
# ------------------------------------------------------------
|
|
239
|
+
# The guided flow itself
|
|
240
|
+
# ------------------------------------------------------------
|
|
241
|
+
def run_guided_search():
|
|
242
|
+
console.print(Panel("[bold cyan]RunLedger – Interactive Experiment Search[/bold cyan]\n"
|
|
243
|
+
"Answer a few questions and we'll find your best experiments.",
|
|
244
|
+
title="Welcome"))
|
|
245
|
+
|
|
246
|
+
# ── Step 1: Folder & Batch ──
|
|
247
|
+
console.print("\n[bold][Step 1/5] Select your experiment folder[/bold]")
|
|
248
|
+
folder = Prompt.ask("Folder path (drag‑and‑drop or type)",
|
|
249
|
+
default="")
|
|
250
|
+
batch = Prompt.ask("Batch name (optional, default: folder name)", default="")
|
|
251
|
+
if not batch:
|
|
252
|
+
batch = Path(folder).name
|
|
253
|
+
|
|
254
|
+
# ── Step 2: Scan if needed ──
|
|
255
|
+
console.print(f"\n[bold][Step 2/5] Ingest data from '{batch}'[/bold]")
|
|
256
|
+
metrics = fetch_verbose_metrics(batch)
|
|
257
|
+
if metrics:
|
|
258
|
+
console.print(f"[green]Batch '{batch}' already has {len(metrics)} metrics. Skipping scan.[/green]")
|
|
259
|
+
else:
|
|
260
|
+
console.print(f"\n[bold]No existing data for batch '{batch}'. Scanning...[/bold]")
|
|
261
|
+
ingested = ingest_folder(folder, batch)
|
|
262
|
+
if ingested == 0:
|
|
263
|
+
console.print("[red]No runs were ingested. Exiting.[/red]")
|
|
264
|
+
return
|
|
265
|
+
metrics = fetch_verbose_metrics(batch)
|
|
266
|
+
if not metrics:
|
|
267
|
+
console.print("[red]Metric discovery failed. Exiting.[/red]")
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
# Show schema summary table
|
|
271
|
+
table = Table(title="Available Metrics")
|
|
272
|
+
table.add_column("#", justify="right")
|
|
273
|
+
table.add_column("Metric", style="cyan")
|
|
274
|
+
table.add_column("Full Path", style="dim")
|
|
275
|
+
for i, m in enumerate(metrics, 1):
|
|
276
|
+
table.add_row(str(i), m["key"], m["path"])
|
|
277
|
+
console.print(table)
|
|
278
|
+
|
|
279
|
+
# ── Step 3: Build conditions ──
|
|
280
|
+
console.print(f"\n[bold][Step 3/5] Define your search conditions[/bold]")
|
|
281
|
+
filters = []
|
|
282
|
+
combine = "and"
|
|
283
|
+
first_condition = True
|
|
284
|
+
|
|
285
|
+
while True:
|
|
286
|
+
# Metric selection (filter‑as‑you‑type)
|
|
287
|
+
metric_names = [m["key"] for m in metrics]
|
|
288
|
+
selected_metric = None
|
|
289
|
+
filtered = metric_names[:]
|
|
290
|
+
while True:
|
|
291
|
+
if len(filtered) == 1:
|
|
292
|
+
console.print(f"\n[green]Auto‑selected metric: {filtered[0]}[/green]")
|
|
293
|
+
selected_metric = filtered[0]
|
|
294
|
+
break
|
|
295
|
+
if len(filtered) == 0:
|
|
296
|
+
console.print("[red]No matching metrics. Try a different filter.[/red]")
|
|
297
|
+
filtered = metric_names[:]
|
|
298
|
+
|
|
299
|
+
ftable = Table(title="Filter Metrics (type to narrow)")
|
|
300
|
+
ftable.add_column("#", justify="right")
|
|
301
|
+
ftable.add_column("Metric", style="cyan")
|
|
302
|
+
for i, m in enumerate(filtered, 1):
|
|
303
|
+
ftable.add_row(str(i), m)
|
|
304
|
+
console.print(ftable)
|
|
305
|
+
|
|
306
|
+
query = Prompt.ask("Type to filter metrics (or 'all' to reset, number to select)", default="")
|
|
307
|
+
if query.lower() == "all":
|
|
308
|
+
filtered = metric_names[:]
|
|
309
|
+
continue
|
|
310
|
+
if query.isdigit():
|
|
311
|
+
idx = int(query) - 1
|
|
312
|
+
if 0 <= idx < len(filtered):
|
|
313
|
+
selected_metric = filtered[idx]
|
|
314
|
+
break
|
|
315
|
+
filtered = [m for m in metric_names if query.lower() in m.lower()]
|
|
316
|
+
|
|
317
|
+
# Operator
|
|
318
|
+
console.print("\n[bold]Select operator:[/bold]")
|
|
319
|
+
console.print(" > (greater than)")
|
|
320
|
+
console.print(" >= (greater than or equal)")
|
|
321
|
+
console.print(" < (less than)")
|
|
322
|
+
console.print(" <= (less than or equal)")
|
|
323
|
+
console.print(" = (equal)")
|
|
324
|
+
op_map = {">": "gt", ">=": "gte", "<": "lt", "<=": "lte", "=": "eq",
|
|
325
|
+
"gt": "gt", "gte": "gte", "lt": "lt", "lte": "lte", "eq": "eq"}
|
|
326
|
+
op_input = Prompt.ask("Operator", choices=list(op_map.keys()), default=">")
|
|
327
|
+
op = op_map[op_input]
|
|
328
|
+
|
|
329
|
+
# Value
|
|
330
|
+
value = Prompt.ask("Value")
|
|
331
|
+
|
|
332
|
+
filters.append({"metric": selected_metric, "op": op, "value": value})
|
|
333
|
+
|
|
334
|
+
# Show current conditions
|
|
335
|
+
conds = " [bold yellow]" + f" {combine.upper()} ".join(f"{f['metric']} {f['op']} {f['value']}" for f in filters) + "[/bold yellow]"
|
|
336
|
+
console.print(f"\n[bold]Current filter(s):[/bold]{conds}")
|
|
337
|
+
|
|
338
|
+
if first_condition and len(metric_names) > 1:
|
|
339
|
+
another = Confirm.ask("Add another condition?", default=False)
|
|
340
|
+
if not another:
|
|
341
|
+
break
|
|
342
|
+
if len(filters) >= 2:
|
|
343
|
+
combine = Prompt.ask("Combine with", choices=["and", "or"], default="and")
|
|
344
|
+
else:
|
|
345
|
+
break
|
|
346
|
+
first_condition = False
|
|
347
|
+
|
|
348
|
+
# ── Step 4: Block depth ──
|
|
349
|
+
console.print(f"\n[bold][Step 4/5] Choose output detail[/bold]")
|
|
350
|
+
block = None
|
|
351
|
+
if Confirm.ask("Limit output to a specific block depth? (block‑0 = tightest match)", default=False):
|
|
352
|
+
block = Prompt.ask("Block depth", default="0")
|
|
353
|
+
block = int(block)
|
|
354
|
+
|
|
355
|
+
# ── Step 5: Search & Results ──
|
|
356
|
+
console.print(f"\n[bold][Step 5/5] Searching...[/bold]")
|
|
357
|
+
if len(filters) == 1:
|
|
358
|
+
f = filters[0]
|
|
359
|
+
data = perform_search(metric=f["metric"], op=f["op"], value=f["value"], batch=batch, pointers=True, block=block)
|
|
360
|
+
else:
|
|
361
|
+
data = perform_multi_search(filters, combine, batch=batch, block=block)
|
|
362
|
+
|
|
363
|
+
if data is None or not data.get("content"):
|
|
364
|
+
console.print("[yellow]No matching runs found.[/yellow]")
|
|
365
|
+
else:
|
|
366
|
+
for run in data["content"]:
|
|
367
|
+
show_blocks(run, filters[0]["metric"] if len(filters) == 1 else None, [block] if block is not None else None)
|
|
368
|
+
|
|
369
|
+
# ── Post‑search menu ──
|
|
370
|
+
while True:
|
|
371
|
+
console.print("\n[bold]What would you like to do next?[/bold]")
|
|
372
|
+
console.print(" [1] Save this search")
|
|
373
|
+
console.print(" [2] Export results (text)")
|
|
374
|
+
console.print(" [3] Run another search")
|
|
375
|
+
console.print(" [4] Compare two runs (diff)")
|
|
376
|
+
console.print(" [5] Exit")
|
|
377
|
+
choice = Prompt.ask("Enter choice", choices=["1","2","3","4","5"], default="5")
|
|
378
|
+
|
|
379
|
+
if choice == "1":
|
|
380
|
+
name = Prompt.ask("Name for this search")
|
|
381
|
+
params = {"metrics": [f["metric"] for f in filters],
|
|
382
|
+
"ops": [f["op"] for f in filters],
|
|
383
|
+
"values": [f["value"] for f in filters],
|
|
384
|
+
"combine": combine, "batch": batch}
|
|
385
|
+
if save_search(name, batch, params):
|
|
386
|
+
console.print(f"[green]Saved search '{name}'.[/green]")
|
|
387
|
+
elif choice == "2":
|
|
388
|
+
# Simple text export to a file
|
|
389
|
+
out_file = Prompt.ask("Output file path", default="runledger_export.txt")
|
|
390
|
+
if data and data.get("content"):
|
|
391
|
+
from ledgger import build_export_blocks, format_blocks_as_text
|
|
392
|
+
blocks = build_export_blocks(data["content"], filters[0]["metric"] if len(filters) == 1 else None, [block] if block is not None else None, include_pointers=True)
|
|
393
|
+
Path(out_file).write_text(format_blocks_as_text(blocks), encoding="utf-8")
|
|
394
|
+
console.print(f"[green]Exported {len(blocks)} block(s) to {out_file}[/green]")
|
|
395
|
+
else:
|
|
396
|
+
console.print("[yellow]No data to export.[/yellow]")
|
|
397
|
+
elif choice == "3":
|
|
398
|
+
# Restart the guided flow
|
|
399
|
+
run_guided_search()
|
|
400
|
+
return
|
|
401
|
+
elif choice == "4":
|
|
402
|
+
id1 = Prompt.ask("First Run ID")
|
|
403
|
+
id2 = Prompt.ask("Second Run ID")
|
|
404
|
+
# Quick diff using the API
|
|
405
|
+
resp1 = requests.get(f"{API_BASE}/{id1}")
|
|
406
|
+
resp2 = requests.get(f"{API_BASE}/{id2}")
|
|
407
|
+
if resp1.status_code == 200 and resp2.status_code == 200:
|
|
408
|
+
run1 = resp1.json()
|
|
409
|
+
run2 = resp2.json()
|
|
410
|
+
flat1 = flatten_payload(run1.get("payload", {}))
|
|
411
|
+
flat2 = flatten_payload(run2.get("payload", {}))
|
|
412
|
+
all_keys = sorted(set(flat1) | set(flat2))
|
|
413
|
+
table = Table(title=f"Run Diff: {id1} vs {id2}")
|
|
414
|
+
table.add_column("Metric", style="cyan")
|
|
415
|
+
table.add_column(f"Run {id1}", style="white")
|
|
416
|
+
table.add_column(f"Run {id2}", style="white")
|
|
417
|
+
table.add_column("Δ", style="white")
|
|
418
|
+
for key in all_keys:
|
|
419
|
+
v1 = flat1.get(key, "—")
|
|
420
|
+
v2 = flat2.get(key, "—")
|
|
421
|
+
delta_str, color = compute_delta(v1, v2)
|
|
422
|
+
style_prefix = f"[{color}]" if color != "white" else ""
|
|
423
|
+
style_suffix = "[/]" if style_prefix else ""
|
|
424
|
+
table.add_row(f"{style_prefix}{key}{style_suffix}",
|
|
425
|
+
f"{style_prefix}{v1}{style_suffix}",
|
|
426
|
+
f"{style_prefix}{v2}{style_suffix}",
|
|
427
|
+
f"{style_prefix}{delta_str}{style_suffix}")
|
|
428
|
+
console.print(table)
|
|
429
|
+
else:
|
|
430
|
+
console.print("[red]Could not fetch one or both runs. Check IDs.[/red]")
|
|
431
|
+
elif choice == "5":
|
|
432
|
+
break
|
|
433
|
+
|
|
434
|
+
# Print the equivalent CLI command
|
|
435
|
+
cli_cmd = "ledger search"
|
|
436
|
+
for f in filters:
|
|
437
|
+
cli_cmd += f" --metric {f['metric']} --op {f['op']} --value {f['value']}"
|
|
438
|
+
if len(filters) > 1:
|
|
439
|
+
cli_cmd += f" --combine {combine}"
|
|
440
|
+
if batch:
|
|
441
|
+
cli_cmd += f" --batch {batch}"
|
|
442
|
+
if block is not None:
|
|
443
|
+
cli_cmd += f" --block {block}"
|
|
444
|
+
console.print(f"\n[dim]Tip: next time you can run this search directly with:[/dim]\n[bold cyan]{cli_cmd}[/bold cyan]")
|