runledger-cli 0.1.3__tar.gz → 0.1.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runledger-cli
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Zero‑instrumentation experiment registry CLI
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -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
- 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
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
- 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
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
- # ── 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="")
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
- # ── Step 2: Scan if needed ──
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
- # ── Step 3: Build conditions ──
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
- 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:
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
- # ── Step 4: Block depth ──
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
- # ── Step 5: Search & Results ──
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
- # ── Post‑search menu ──
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 ledgger import build_export_blocks, format_blocks_as_text
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
- API_BASE = "http://localhost:8080/api/runs"
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()
@@ -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
- label = "-- full run (full‑text/fuzzy match)"
357
- try:
358
- pretty = json.dumps(payload, indent=2)
359
- except Exception:
360
- pretty = str(payload)
361
- console.print(Panel(pretty, title=f"Run {run['id']} · {source_file}"))
362
- return
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
- def wait_for_health(timeout=120):
18
- start = time.time()
19
- while time.time() - start < timeout:
20
- try:
21
- resp = requests.get(HEALTH_URL, timeout=2)
22
- if resp.status_code == 200:
23
- return True
24
- except Exception:
25
- pass
26
- time.sleep(2)
27
- return False
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 the Docker Compose stack and wait for it to be healthy."""
32
- subprocess.run(
33
- ["docker", "compose", "-f", str(COMPOSE_FILE), "up", "-d", "--build"],
34
- cwd=str(PROJECT_ROOT),
35
- check=True,
36
- capture_output=True,
37
- )
38
- if not wait_for_health():
39
- subprocess.run(
40
- ["docker", "compose", "-f", str(COMPOSE_FILE), "down"],
41
- cwd=str(PROJECT_ROOT),
42
- capture_output=True,
43
- )
44
- pytest.fail("Backend did not become healthy in time")
45
- yield
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(cmd, capture_output=True, encoding='utf-8', cwd=str(PROJECT_ROOT))
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 (non‑interactive)
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}"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "runledger-cli"
7
- version = "0.1.3"
7
+ version = "0.1.4"
8
8
  description = "Zero‑instrumentation experiment registry CLI"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runledger-cli
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Zero‑instrumentation experiment registry CLI
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
File without changes
File without changes