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 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]")