runledger-cli 0.1.3__tar.gz → 0.1.5__tar.gz
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.
- {runledger_cli-0.1.3 → runledger_cli-0.1.5}/PKG-INFO +1 -1
- {runledger_cli-0.1.3 → runledger_cli-0.1.5}/cli/guided.py +78 -31
- {runledger_cli-0.1.3 → runledger_cli-0.1.5}/cli/ledger.py +56 -12
- {runledger_cli-0.1.3 → runledger_cli-0.1.5}/cli/tests/test_integration.py +79 -40
- {runledger_cli-0.1.3 → runledger_cli-0.1.5}/pyproject.toml +1 -1
- {runledger_cli-0.1.3 → runledger_cli-0.1.5}/runledger_cli.egg-info/PKG-INFO +1 -1
- {runledger_cli-0.1.3 → runledger_cli-0.1.5}/README.md +0 -0
- {runledger_cli-0.1.3 → runledger_cli-0.1.5}/cli/__init__.py +0 -0
- {runledger_cli-0.1.3 → runledger_cli-0.1.5}/cli/docker-compose.yml +0 -0
- {runledger_cli-0.1.3 → runledger_cli-0.1.5}/cli/tests/__init__.py +0 -0
- {runledger_cli-0.1.3 → runledger_cli-0.1.5}/cli/tests/test_data_features.py +0 -0
- {runledger_cli-0.1.3 → runledger_cli-0.1.5}/cli/tests/test_ledger.py +0 -0
- {runledger_cli-0.1.3 → runledger_cli-0.1.5}/cli/tests/test_real_results.py +0 -0
- {runledger_cli-0.1.3 → runledger_cli-0.1.5}/runledger_cli.egg-info/SOURCES.txt +0 -0
- {runledger_cli-0.1.3 → runledger_cli-0.1.5}/runledger_cli.egg-info/dependency_links.txt +0 -0
- {runledger_cli-0.1.3 → runledger_cli-0.1.5}/runledger_cli.egg-info/entry_points.txt +0 -0
- {runledger_cli-0.1.3 → runledger_cli-0.1.5}/runledger_cli.egg-info/requires.txt +0 -0
- {runledger_cli-0.1.3 → runledger_cli-0.1.5}/runledger_cli.egg-info/top_level.txt +0 -0
- {runledger_cli-0.1.3 → runledger_cli-0.1.5}/setup.cfg +0 -0
|
@@ -112,6 +112,14 @@ def fetch_verbose_metrics(batch):
|
|
|
112
112
|
return resp.json()
|
|
113
113
|
return []
|
|
114
114
|
|
|
115
|
+
def build_shorthand_map(batch):
|
|
116
|
+
"""Return dict {shorthand_key: [full_path, ...]} from verbose metrics."""
|
|
117
|
+
entries = fetch_verbose_metrics(batch)
|
|
118
|
+
mapping = {}
|
|
119
|
+
for e in entries:
|
|
120
|
+
mapping.setdefault(e['key'], []).append(e['path'])
|
|
121
|
+
return mapping
|
|
122
|
+
|
|
115
123
|
def perform_search(metric=None, op=None, value=None, q=None, fuzzy=False, batch=None, pointers=True, block=None):
|
|
116
124
|
params = {"page": 0, "size": 50, "sort": "created_at,desc"}
|
|
117
125
|
if batch:
|
|
@@ -179,12 +187,18 @@ def show_blocks(run: dict, dot_path: str = None, block_levels: list = None):
|
|
|
179
187
|
console.print()
|
|
180
188
|
|
|
181
189
|
if dot_path is None:
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
190
|
+
# If we have matched pointers, derive a path from the first concrete pointer
|
|
191
|
+
if matched:
|
|
192
|
+
concrete = matched[0]['pointer']
|
|
193
|
+
parts = concrete.replace('[', '.').replace(']', '').split('.')
|
|
194
|
+
dot_path = '.'.join(parts)
|
|
195
|
+
else:
|
|
196
|
+
try:
|
|
197
|
+
pretty = json.dumps(payload, indent=2)
|
|
198
|
+
except Exception:
|
|
199
|
+
pretty = str(payload)
|
|
200
|
+
console.print(Panel(pretty, title=f"Run {run['id']} · {source_file}"))
|
|
201
|
+
return
|
|
188
202
|
|
|
189
203
|
parts = [seg.replace("[]", "") for seg in dot_path.split(".")]
|
|
190
204
|
max_block = len(parts) - 1
|
|
@@ -207,9 +221,17 @@ def show_blocks(run: dict, dot_path: str = None, block_levels: list = None):
|
|
|
207
221
|
title = f"Run {run['id']} · {source_file} | metric: {dot_path} | depth: 0 (match {i+1}/{len(matched)})"
|
|
208
222
|
console.print(Panel(pretty, title=title))
|
|
209
223
|
else:
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
224
|
+
if block > 0 and matched:
|
|
225
|
+
concrete = matched[0]['pointer']
|
|
226
|
+
segments = concrete.replace('[', '.').replace(']', '').split('.')
|
|
227
|
+
ancestor_segments = segments[:len(segments) - (block + 1)]
|
|
228
|
+
pointer = "/" + "/".join(ancestor_segments) if ancestor_segments else ""
|
|
229
|
+
node = navigate_json_pointer(payload, pointer) if pointer else payload
|
|
230
|
+
else:
|
|
231
|
+
ancestor_parts = parts[:len(parts) - (block + 1)]
|
|
232
|
+
pointer = "/" + "/".join(ancestor_parts) if ancestor_parts else ""
|
|
233
|
+
node = navigate_json_pointer(payload, pointer) if pointer else payload
|
|
234
|
+
|
|
213
235
|
depth_label = ""
|
|
214
236
|
if block == max_block:
|
|
215
237
|
depth_label = " (full run)"
|
|
@@ -243,15 +265,14 @@ def run_guided_search():
|
|
|
243
265
|
"Answer a few questions and we'll find your best experiments.",
|
|
244
266
|
title="Welcome"))
|
|
245
267
|
|
|
246
|
-
#
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
batch = Prompt.ask("Batch name (optional, default: folder name)", default="")
|
|
268
|
+
# 1. Folder
|
|
269
|
+
folder = Prompt.ask("[bold]Folder containing your JSON/JSONL files[/bold]\n"
|
|
270
|
+
"(drag‑and‑drop or type a path)")
|
|
271
|
+
batch = Prompt.ask("[bold]Batch name (optional, default: folder name)[/bold]", default="")
|
|
251
272
|
if not batch:
|
|
252
273
|
batch = Path(folder).name
|
|
253
274
|
|
|
254
|
-
#
|
|
275
|
+
# 2. Scan if needed
|
|
255
276
|
console.print(f"\n[bold][Step 2/5] Ingest data from '{batch}'[/bold]")
|
|
256
277
|
metrics = fetch_verbose_metrics(batch)
|
|
257
278
|
if metrics:
|
|
@@ -267,6 +288,9 @@ def run_guided_search():
|
|
|
267
288
|
console.print("[red]Metric discovery failed. Exiting.[/red]")
|
|
268
289
|
return
|
|
269
290
|
|
|
291
|
+
# Build shorthand -> full paths map
|
|
292
|
+
shorthand_map = build_shorthand_map(batch)
|
|
293
|
+
|
|
270
294
|
# Show schema summary table
|
|
271
295
|
table = Table(title="Available Metrics")
|
|
272
296
|
table.add_column("#", justify="right")
|
|
@@ -276,14 +300,14 @@ def run_guided_search():
|
|
|
276
300
|
table.add_row(str(i), m["key"], m["path"])
|
|
277
301
|
console.print(table)
|
|
278
302
|
|
|
279
|
-
#
|
|
303
|
+
# 3. Build one or more conditions
|
|
280
304
|
console.print(f"\n[bold][Step 3/5] Define your search conditions[/bold]")
|
|
281
305
|
filters = []
|
|
282
306
|
combine = "and"
|
|
283
307
|
first_condition = True
|
|
284
308
|
|
|
285
309
|
while True:
|
|
286
|
-
# Metric selection (filter‑as‑you‑type)
|
|
310
|
+
# Metric selection (filter‑as‑you‑type with explicit path and sub‑list support)
|
|
287
311
|
metric_names = [m["key"] for m in metrics]
|
|
288
312
|
selected_metric = None
|
|
289
313
|
filtered = metric_names[:]
|
|
@@ -304,6 +328,32 @@ def run_guided_search():
|
|
|
304
328
|
console.print(ftable)
|
|
305
329
|
|
|
306
330
|
query = Prompt.ask("Type to filter metrics (or 'all' to reset, number to select)", default="")
|
|
331
|
+
|
|
332
|
+
# Explicit dot‑path / array‑path – accept directly
|
|
333
|
+
if '.' in query or '[]' in query:
|
|
334
|
+
selected_metric = query
|
|
335
|
+
break
|
|
336
|
+
|
|
337
|
+
# Shorthand with multiple possible full paths – show sub‑list
|
|
338
|
+
if query in shorthand_map:
|
|
339
|
+
paths = shorthand_map[query]
|
|
340
|
+
if len(paths) == 1:
|
|
341
|
+
selected_metric = paths[0]
|
|
342
|
+
break
|
|
343
|
+
else:
|
|
344
|
+
sub_table = Table(title=f"Select full path for '{query}'")
|
|
345
|
+
sub_table.add_column("#", justify="right")
|
|
346
|
+
sub_table.add_column("Full Path", style="cyan")
|
|
347
|
+
for i, p in enumerate(paths, 1):
|
|
348
|
+
sub_table.add_row(str(i), p)
|
|
349
|
+
console.print(sub_table)
|
|
350
|
+
sub_choice = Prompt.ask("Choose a path", default="1")
|
|
351
|
+
if sub_choice.isdigit():
|
|
352
|
+
idx = int(sub_choice) - 1
|
|
353
|
+
if 0 <= idx < len(paths):
|
|
354
|
+
selected_metric = paths[idx]
|
|
355
|
+
break
|
|
356
|
+
|
|
307
357
|
if query.lower() == "all":
|
|
308
358
|
filtered = metric_names[:]
|
|
309
359
|
continue
|
|
@@ -335,24 +385,24 @@ def run_guided_search():
|
|
|
335
385
|
conds = " [bold yellow]" + f" {combine.upper()} ".join(f"{f['metric']} {f['op']} {f['value']}" for f in filters) + "[/bold yellow]"
|
|
336
386
|
console.print(f"\n[bold]Current filter(s):[/bold]{conds}")
|
|
337
387
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
break
|
|
342
|
-
if len(filters) >= 2:
|
|
343
|
-
combine = Prompt.ask("Combine with", choices=["and", "or"], default="and")
|
|
344
|
-
else:
|
|
388
|
+
# Ask for another condition
|
|
389
|
+
another = Confirm.ask("Add another condition?", default=False)
|
|
390
|
+
if not another:
|
|
345
391
|
break
|
|
392
|
+
|
|
393
|
+
# If this is about to be the second condition, ask for combine operator BEFORE adding it
|
|
394
|
+
if len(filters) == 1:
|
|
395
|
+
combine = Prompt.ask("Combine with", choices=["and", "or"], default="and")
|
|
346
396
|
first_condition = False
|
|
347
397
|
|
|
348
|
-
#
|
|
398
|
+
# Block depth
|
|
349
399
|
console.print(f"\n[bold][Step 4/5] Choose output detail[/bold]")
|
|
350
400
|
block = None
|
|
351
401
|
if Confirm.ask("Limit output to a specific block depth? (block‑0 = tightest match)", default=False):
|
|
352
402
|
block = Prompt.ask("Block depth", default="0")
|
|
353
403
|
block = int(block)
|
|
354
404
|
|
|
355
|
-
#
|
|
405
|
+
# 5. Run search
|
|
356
406
|
console.print(f"\n[bold][Step 5/5] Searching...[/bold]")
|
|
357
407
|
if len(filters) == 1:
|
|
358
408
|
f = filters[0]
|
|
@@ -366,7 +416,7 @@ def run_guided_search():
|
|
|
366
416
|
for run in data["content"]:
|
|
367
417
|
show_blocks(run, filters[0]["metric"] if len(filters) == 1 else None, [block] if block is not None else None)
|
|
368
418
|
|
|
369
|
-
#
|
|
419
|
+
# Post‑search menu
|
|
370
420
|
while True:
|
|
371
421
|
console.print("\n[bold]What would you like to do next?[/bold]")
|
|
372
422
|
console.print(" [1] Save this search")
|
|
@@ -385,23 +435,20 @@ def run_guided_search():
|
|
|
385
435
|
if save_search(name, batch, params):
|
|
386
436
|
console.print(f"[green]Saved search '{name}'.[/green]")
|
|
387
437
|
elif choice == "2":
|
|
388
|
-
# Simple text export to a file
|
|
389
438
|
out_file = Prompt.ask("Output file path", default="runledger_export.txt")
|
|
390
439
|
if data and data.get("content"):
|
|
391
|
-
from
|
|
440
|
+
from ledger import build_export_blocks, format_blocks_as_text
|
|
392
441
|
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
442
|
Path(out_file).write_text(format_blocks_as_text(blocks), encoding="utf-8")
|
|
394
443
|
console.print(f"[green]Exported {len(blocks)} block(s) to {out_file}[/green]")
|
|
395
444
|
else:
|
|
396
445
|
console.print("[yellow]No data to export.[/yellow]")
|
|
397
446
|
elif choice == "3":
|
|
398
|
-
# Restart the guided flow
|
|
399
447
|
run_guided_search()
|
|
400
448
|
return
|
|
401
449
|
elif choice == "4":
|
|
402
450
|
id1 = Prompt.ask("First Run ID")
|
|
403
451
|
id2 = Prompt.ask("Second Run ID")
|
|
404
|
-
# Quick diff using the API
|
|
405
452
|
resp1 = requests.get(f"{API_BASE}/{id1}")
|
|
406
453
|
resp2 = requests.get(f"{API_BASE}/{id2}")
|
|
407
454
|
if resp1.status_code == 200 and resp2.status_code == 200:
|
|
@@ -47,9 +47,10 @@ except Exception:
|
|
|
47
47
|
# ------------------------------------------------------------
|
|
48
48
|
# Configuration
|
|
49
49
|
# ------------------------------------------------------------
|
|
50
|
-
|
|
50
|
+
_BASE = os.environ.get("RUNLEDGER_BASE_URL", "http://localhost:8080")
|
|
51
|
+
API_BASE = f"{_BASE}/api/runs"
|
|
52
|
+
HEALTH_URL = f"{_BASE}/actuator/health"
|
|
51
53
|
COMPOSE_FILE = os.path.join(os.path.dirname(__file__), "docker-compose.yml")
|
|
52
|
-
HEALTH_URL = "http://localhost:8080/actuator/health"
|
|
53
54
|
PAGE_SIZE = 10
|
|
54
55
|
|
|
55
56
|
console = Console()
|
|
@@ -58,7 +59,7 @@ console = Console()
|
|
|
58
59
|
# Helpers
|
|
59
60
|
# ------------------------------------------------------------
|
|
60
61
|
def check_docker():
|
|
61
|
-
result = subprocess.run("docker version",
|
|
62
|
+
result = subprocess.run(["docker", "version"],
|
|
62
63
|
capture_output=True, text=True)
|
|
63
64
|
if result.returncode != 0:
|
|
64
65
|
console.print("[bold red]Docker is not installed or not reachable.[/bold red]")
|
|
@@ -93,7 +94,7 @@ def start_backend():
|
|
|
93
94
|
console.print("Starting RunLedger backend...")
|
|
94
95
|
subprocess.run(
|
|
95
96
|
["docker", "compose", "-f", compose_file, "-p", "runledger", "up", "-d"],
|
|
96
|
-
|
|
97
|
+
check=True
|
|
97
98
|
)
|
|
98
99
|
if wait_for_backend():
|
|
99
100
|
console.print("[green]RunLedger backend is running.[/green]")
|
|
@@ -106,7 +107,7 @@ def stop_backend():
|
|
|
106
107
|
if os.path.exists(compose_file):
|
|
107
108
|
subprocess.run(
|
|
108
109
|
["docker", "compose", "-f", compose_file, "-p", "runledger", "down"],
|
|
109
|
-
|
|
110
|
+
check=True
|
|
110
111
|
)
|
|
111
112
|
console.print("RunLedger backend stopped.")
|
|
112
113
|
else:
|
|
@@ -353,13 +354,19 @@ def show_blocks(run: dict, dot_path: str = None, block_levels: list = None):
|
|
|
353
354
|
console.print()
|
|
354
355
|
|
|
355
356
|
if dot_path is None:
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
357
|
+
# If we have matched pointers, derive a path from the first concrete pointer
|
|
358
|
+
if matched:
|
|
359
|
+
concrete = matched[0]['pointer']
|
|
360
|
+
parts = concrete.replace('[', '.').replace(']', '').split('.')
|
|
361
|
+
dot_path = '.'.join(parts)
|
|
362
|
+
else:
|
|
363
|
+
label = "-- full run (full‑text/fuzzy match)"
|
|
364
|
+
try:
|
|
365
|
+
pretty = json.dumps(payload, indent=2)
|
|
366
|
+
except Exception:
|
|
367
|
+
pretty = str(payload)
|
|
368
|
+
console.print(Panel(pretty, title=f"Run {run['id']} · {source_file}"))
|
|
369
|
+
return
|
|
363
370
|
|
|
364
371
|
# Strip "[]" so JSON Pointer navigation works
|
|
365
372
|
parts = [seg.replace("[]", "") for seg in dot_path.split(".")]
|
|
@@ -576,6 +583,14 @@ def compute_delta(val1, val2):
|
|
|
576
583
|
# ------------------------------------------------------------
|
|
577
584
|
# Non‑interactive CLI outputs
|
|
578
585
|
# ------------------------------------------------------------
|
|
586
|
+
def build_shorthand_map(batch):
|
|
587
|
+
"""Return dict {shorthand_key: [full_path, ...]} from verbose metrics."""
|
|
588
|
+
entries = fetch_verbose_metrics(batch)
|
|
589
|
+
mapping = {}
|
|
590
|
+
for e in entries:
|
|
591
|
+
mapping.setdefault(e['key'], []).append(e['path'])
|
|
592
|
+
return mapping
|
|
593
|
+
|
|
579
594
|
def cli_metrics(args):
|
|
580
595
|
if args.verbose:
|
|
581
596
|
entries = fetch_verbose_metrics(args.batch)
|
|
@@ -812,6 +827,9 @@ def interactive_search(batch: str = None):
|
|
|
812
827
|
console.print("[yellow]No metrics available yet. Ingest some runs first.[/yellow]")
|
|
813
828
|
break
|
|
814
829
|
|
|
830
|
+
# Build shorthand -> full paths map
|
|
831
|
+
shorthand_map = build_shorthand_map(batch)
|
|
832
|
+
|
|
815
833
|
selected_metric = None
|
|
816
834
|
filtered = metrics[:]
|
|
817
835
|
while True:
|
|
@@ -831,6 +849,32 @@ def interactive_search(batch: str = None):
|
|
|
831
849
|
console.print(table)
|
|
832
850
|
|
|
833
851
|
query = Prompt.ask("Type to filter metrics (or 'all' to reset, number to select)", default="")
|
|
852
|
+
|
|
853
|
+
# Explicit dot‑path / array‑path – accept directly
|
|
854
|
+
if '.' in query or '[]' in query:
|
|
855
|
+
selected_metric = query
|
|
856
|
+
break
|
|
857
|
+
|
|
858
|
+
# Shorthand with multiple possible full paths – show sub‑list
|
|
859
|
+
if query in shorthand_map:
|
|
860
|
+
paths = shorthand_map[query]
|
|
861
|
+
if len(paths) == 1:
|
|
862
|
+
selected_metric = paths[0]
|
|
863
|
+
break
|
|
864
|
+
else:
|
|
865
|
+
sub_table = Table(title=f"Select full path for '{query}'")
|
|
866
|
+
sub_table.add_column("#", justify="right")
|
|
867
|
+
sub_table.add_column("Full Path", style="cyan")
|
|
868
|
+
for i, p in enumerate(paths, 1):
|
|
869
|
+
sub_table.add_row(str(i), p)
|
|
870
|
+
console.print(sub_table)
|
|
871
|
+
sub_choice = Prompt.ask("Choose a path", default="1")
|
|
872
|
+
if sub_choice.isdigit():
|
|
873
|
+
idx = int(sub_choice) - 1
|
|
874
|
+
if 0 <= idx < len(paths):
|
|
875
|
+
selected_metric = paths[idx]
|
|
876
|
+
break
|
|
877
|
+
|
|
834
878
|
if query.lower() == "all":
|
|
835
879
|
filtered = metrics[:]
|
|
836
880
|
continue
|
|
@@ -9,49 +9,87 @@ from pathlib import Path
|
|
|
9
9
|
import pytest
|
|
10
10
|
import requests
|
|
11
11
|
|
|
12
|
+
# ------------------------------------------------------------
|
|
13
|
+
# Global settings – will be overridden dynamically by the fixture
|
|
14
|
+
# ------------------------------------------------------------
|
|
12
15
|
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
|
13
16
|
COMPOSE_FILE = PROJECT_ROOT / "docker-compose.test.yml"
|
|
14
|
-
HEALTH_URL = "http://localhost:8081/actuator/health"
|
|
15
|
-
API_BASE = "http://localhost:8080/api/runs"
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
18
|
+
# Prevent CLI from ever auto‑starting Docker
|
|
19
|
+
os.environ["RUNLEDGER_NO_DOCKER"] = "1"
|
|
20
|
+
|
|
21
|
+
# These will be set to the actual random host port once the stack is up
|
|
22
|
+
HEALTH_URL = None
|
|
23
|
+
API_BASE = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_host_port(project, service="app", container_port="8080"):
|
|
27
|
+
"""
|
|
28
|
+
Return the host port that Docker assigned to *container_port* for the
|
|
29
|
+
given service inside the Compose project.
|
|
30
|
+
"""
|
|
31
|
+
cmd = [
|
|
32
|
+
"docker", "compose", "-f", str(COMPOSE_FILE), "-p", project,
|
|
33
|
+
"port", service, container_port
|
|
34
|
+
]
|
|
35
|
+
result = subprocess.run(cmd, capture_output=True, text=True, cwd=str(PROJECT_ROOT))
|
|
36
|
+
# Example output: "0.0.0.0:54321" or "127.0.0.1:12345"
|
|
37
|
+
return result.stdout.strip().split(":")[-1]
|
|
38
|
+
|
|
28
39
|
|
|
29
40
|
@pytest.fixture(scope="session")
|
|
30
41
|
def docker_stack():
|
|
31
|
-
"""Start
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
"""Start a completely isolated test stack with random host ports."""
|
|
43
|
+
project = f"rl-test-{uuid.uuid4().hex[:8]}"
|
|
44
|
+
compose_cmd = [
|
|
45
|
+
"docker", "compose", "-f", str(COMPOSE_FILE),
|
|
46
|
+
"-p", project,
|
|
47
|
+
"up", "-d", "--build"
|
|
48
|
+
]
|
|
49
|
+
subprocess.run(compose_cmd, cwd=str(PROJECT_ROOT), check=True, capture_output=True)
|
|
50
|
+
|
|
51
|
+
# Dynamically discover the random port Docker assigned to the app
|
|
52
|
+
host_port = get_host_port(project)
|
|
53
|
+
health_url = f"http://localhost:{host_port}/actuator/health"
|
|
54
|
+
api_base = f"http://localhost:{host_port}/api/runs"
|
|
55
|
+
|
|
56
|
+
# Update global variables so all tests use the correct address
|
|
57
|
+
globals()["HEALTH_URL"] = health_url
|
|
58
|
+
globals()["API_BASE"] = api_base
|
|
59
|
+
|
|
60
|
+
# Make CLI commands (run_cli) use the same port
|
|
61
|
+
os.environ["RUNLEDGER_BASE_URL"] = f"http://localhost:{host_port}"
|
|
62
|
+
os.environ["RUNLEDGER_PORT"] = host_port
|
|
63
|
+
|
|
64
|
+
# Wait for the backend to become healthy
|
|
65
|
+
for _ in range(60):
|
|
66
|
+
try:
|
|
67
|
+
if requests.get(health_url, timeout=2).status_code == 200:
|
|
68
|
+
break
|
|
69
|
+
except Exception:
|
|
70
|
+
time.sleep(2)
|
|
71
|
+
else:
|
|
72
|
+
pytest.fail("Backend did not become healthy")
|
|
73
|
+
|
|
74
|
+
yield api_base # tests that need it can receive it, but most use globals
|
|
75
|
+
|
|
76
|
+
# Tear everything down
|
|
46
77
|
subprocess.run(
|
|
47
|
-
["docker", "compose", "-f", str(COMPOSE_FILE), "down"],
|
|
48
|
-
cwd=str(PROJECT_ROOT),
|
|
49
|
-
capture_output=True,
|
|
78
|
+
["docker", "compose", "-f", str(COMPOSE_FILE), "-p", project, "down", "-v"],
|
|
79
|
+
cwd=str(PROJECT_ROOT), capture_output=True
|
|
50
80
|
)
|
|
51
81
|
|
|
82
|
+
|
|
52
83
|
def run_cli(*args):
|
|
84
|
+
"""Run the CLI in a subprocess, inheriting the environment (port, no-docker)."""
|
|
53
85
|
cmd = ["python", str(PROJECT_ROOT / "cli" / "ledger.py"), *args]
|
|
54
|
-
return subprocess.run(
|
|
86
|
+
return subprocess.run(
|
|
87
|
+
cmd,
|
|
88
|
+
capture_output=True,
|
|
89
|
+
encoding='utf-8',
|
|
90
|
+
cwd=str(PROJECT_ROOT)
|
|
91
|
+
)
|
|
92
|
+
|
|
55
93
|
|
|
56
94
|
@pytest.fixture
|
|
57
95
|
def sample_folder():
|
|
@@ -71,12 +109,14 @@ def sample_folder():
|
|
|
71
109
|
(folder / "bad.json").write_text("this is not json")
|
|
72
110
|
yield folder
|
|
73
111
|
|
|
112
|
+
|
|
74
113
|
pytestmark = pytest.mark.integration
|
|
75
114
|
|
|
115
|
+
|
|
76
116
|
def test_full_golden_path(docker_stack, sample_folder):
|
|
77
117
|
batch = f"golden-{uuid.uuid4()}"
|
|
78
118
|
|
|
79
|
-
# 1. scan folder
|
|
119
|
+
# 1. scan folder
|
|
80
120
|
result = run_cli("scan", str(sample_folder), "--batch", batch)
|
|
81
121
|
assert result.returncode == 0, result.stderr
|
|
82
122
|
assert "Successfully ingested 4 run(s)" in result.stdout
|
|
@@ -111,7 +151,7 @@ def test_full_golden_path(docker_stack, sample_folder):
|
|
|
111
151
|
assert result.returncode == 0, result.stderr
|
|
112
152
|
assert "running" in result.stdout.lower()
|
|
113
153
|
|
|
114
|
-
# 6. deep API verification
|
|
154
|
+
# 6. deep API verification (uses dynamic API_BASE)
|
|
115
155
|
resp = requests.get(API_BASE, params={"batch": batch})
|
|
116
156
|
assert resp.status_code == 200
|
|
117
157
|
runs = resp.json()["content"]
|
|
@@ -123,25 +163,28 @@ def test_full_golden_path(docker_stack, sample_folder):
|
|
|
123
163
|
for r in runs:
|
|
124
164
|
assert r["payload"]["_source"]["file"] in ["run1.json", "run2.json", "batch.json"]
|
|
125
165
|
assert r["createdAt"] is not None
|
|
126
|
-
# Verify ordering (descending by createdAt)
|
|
127
166
|
timestamps = [r["createdAt"] for r in runs]
|
|
128
167
|
assert timestamps == sorted(timestamps, reverse=True), "runs must be ordered by createdAt desc"
|
|
129
168
|
|
|
169
|
+
|
|
130
170
|
def test_scan_nonexistent_folder(docker_stack):
|
|
131
171
|
result = run_cli("scan", "/no/such/path")
|
|
132
172
|
assert result.returncode != 0 or "Folder not found" in result.stdout
|
|
133
173
|
|
|
174
|
+
|
|
134
175
|
def test_scan_corrupt_file_skipped(docker_stack, sample_folder):
|
|
135
176
|
batch = f"corrupt-{uuid.uuid4()}"
|
|
136
177
|
result = run_cli("scan", str(sample_folder), "--batch", batch)
|
|
137
178
|
assert result.returncode == 0, result.stderr
|
|
138
179
|
assert "Successfully ingested 4 run(s)" in result.stdout
|
|
139
180
|
|
|
181
|
+
|
|
140
182
|
def test_metrics_empty_batch(docker_stack):
|
|
141
183
|
result = run_cli("metrics", "--batch", f"nonexistent-{uuid.uuid4()}")
|
|
142
184
|
assert result.returncode == 0, result.stderr
|
|
143
185
|
assert json.loads(result.stdout) == []
|
|
144
186
|
|
|
187
|
+
|
|
145
188
|
def test_search_no_results(docker_stack, sample_folder):
|
|
146
189
|
batch = f"noresults-{uuid.uuid4()}"
|
|
147
190
|
run_cli("scan", str(sample_folder), "--batch", batch)
|
|
@@ -151,20 +194,18 @@ def test_search_no_results(docker_stack, sample_folder):
|
|
|
151
194
|
assert data["totalElements"] == 0
|
|
152
195
|
assert data["content"] == []
|
|
153
196
|
|
|
197
|
+
|
|
154
198
|
def test_multi_filter_and_returns_results():
|
|
155
|
-
# Ensure the compound test data folder exists
|
|
156
199
|
compound_dir = "test-data/sample-compound-runs"
|
|
157
200
|
if not os.path.isdir(compound_dir):
|
|
158
201
|
pytest.skip(f"Folder {compound_dir} not found")
|
|
159
202
|
|
|
160
|
-
# Scan the folder with explicit UTF-8 encoding to avoid UnicodeDecodeError
|
|
161
203
|
scan_result = subprocess.run(
|
|
162
204
|
["python", "cli/ledger.py", "scan", compound_dir, "--batch", "compound-int"],
|
|
163
205
|
capture_output=True, text=True, encoding="utf-8", errors="replace"
|
|
164
206
|
)
|
|
165
207
|
assert scan_result.returncode == 0, f"Scan failed: {scan_result.stderr}"
|
|
166
208
|
|
|
167
|
-
# Search with AND
|
|
168
209
|
result = subprocess.run(
|
|
169
210
|
["python", "cli/ledger.py", "search", "--metric", "accuracy", "--op", "gt", "--value", "0.9",
|
|
170
211
|
"--metric", "loss", "--op", "lt", "--value", "0.2", "--combine", "and", "--batch", "compound-int"],
|
|
@@ -172,6 +213,4 @@ def test_multi_filter_and_returns_results():
|
|
|
172
213
|
)
|
|
173
214
|
assert result.returncode == 0, f"Search failed: {result.stderr}"
|
|
174
215
|
assert result.stdout is not None, "stdout is None (decoding issue)"
|
|
175
|
-
|
|
176
|
-
# compound_run1.json has accuracy=0.95, loss=0.15 → should match
|
|
177
216
|
assert "compound_run1" in result.stdout, f"Output: {result.stdout}"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|