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/tests/__init__.py ADDED
File without changes
@@ -0,0 +1,254 @@
1
+ """
2
+ Feature showcase tests using test-data/sample-runs.
3
+ Each test is labelled with a version number (v1, v2, v3) to reflect
4
+ the order in which features were added.
5
+
6
+ Run with:
7
+ python -m pytest cli/tests/test_data_features.py -v -s
8
+
9
+ Assumes the RunLedger backend is already running (docker compose up -d).
10
+ """
11
+
12
+ import json
13
+ import subprocess
14
+ import time
15
+ import uuid
16
+ from pathlib import Path
17
+ import tempfile
18
+
19
+ import pytest
20
+ import requests
21
+
22
+ # ------------------------------------------------------------
23
+ # Configuration
24
+ # ------------------------------------------------------------
25
+ PROJECT_ROOT = Path(__file__).parent.parent.parent # run-ledger/
26
+ HEALTH_URL = "http://localhost:8080/actuator/health"
27
+ API_BASE = "http://localhost:8080/api/runs"
28
+ SAMPLE_FOLDER = PROJECT_ROOT / "test-data" / "sample-runs"
29
+ # Unique batch per session to avoid cross‑test interference
30
+ BATCH_NAME = f"feature-test-{uuid.uuid4().hex[:8]}"
31
+
32
+ # ------------------------------------------------------------
33
+ # Helper: run the CLI script
34
+ # ------------------------------------------------------------
35
+ def run_cli(*args):
36
+ cmd = ["python", str(PROJECT_ROOT / "cli" / "ledger.py"), *args]
37
+ return subprocess.run(cmd, capture_output=True, encoding='utf-8', cwd=str(PROJECT_ROOT))
38
+
39
+ # ------------------------------------------------------------
40
+ # Ensure backend is available, clean batch, and ingest sample data
41
+ # ------------------------------------------------------------
42
+ @pytest.fixture(scope="session")
43
+ def setup_data():
44
+ """Wait for backend, clear any old mapping for this batch, then scan."""
45
+ # Wait for backend health
46
+ for _ in range(30):
47
+ try:
48
+ resp = requests.get(HEALTH_URL, timeout=2)
49
+ if resp.status_code == 200:
50
+ break
51
+ except Exception:
52
+ pass
53
+ time.sleep(2)
54
+ else:
55
+ pytest.fail("Backend is not reachable. Start it with 'docker compose up -d'")
56
+
57
+ # Ingest sample runs into the fresh batch
58
+ result = run_cli("scan", str(SAMPLE_FOLDER), "--batch", BATCH_NAME)
59
+ assert result.returncode == 0, result.stderr
60
+ assert "Successfully ingested 9 run(s)" in result.stdout
61
+
62
+ # ------------------------------------------------------------
63
+ # v1 – Basic metric block search
64
+ # ------------------------------------------------------------
65
+ def test_v1_metric_discovery(setup_data):
66
+ """Metrics endpoint returns keys from the batch (via mapping)."""
67
+ result = run_cli("metrics", "--batch", BATCH_NAME)
68
+ assert result.returncode == 0
69
+ keys = json.loads(result.stdout)
70
+ # Keys present in the sample files
71
+ assert "accuracy" in keys
72
+ assert "loss" in keys
73
+ assert "f1_score" in keys
74
+ assert "experiment" in keys
75
+ print("✅ Metric discovery (batch) --- SUCCESS")
76
+
77
+ def test_v1_numeric_block_search_dot_path(setup_data):
78
+ """Numeric greater-than search using explicit dot-path metrics.accuracy."""
79
+ # Only run4 and run5 have a metrics.accuracy field (0.91 and 0.93 > 0.8)
80
+ result = run_cli("search", "--metric", "metrics.accuracy", "--op", "gt",
81
+ "--value", "0.8", "--batch", BATCH_NAME, "--json")
82
+ assert result.returncode == 0
83
+ data = json.loads(result.stdout)
84
+ assert data["totalElements"] == 2 # run4, run5
85
+ print(f"✅ metrics.accuracy > 0.8 --- {data['totalElements']} runs found")
86
+
87
+ def test_v1_text_equality_search_root_key(setup_data):
88
+ """Text equality search on the top-level 'status' field."""
89
+ # run5.json has "status": "completed" at the root
90
+ result = run_cli("search", "--metric", "status", "--op", "eq",
91
+ "--value", "completed", "--batch", BATCH_NAME, "--json")
92
+ assert result.returncode == 0
93
+ data = json.loads(result.stdout)
94
+ assert data["totalElements"] == 1
95
+ assert data["content"][0]["payload"]["experiment"] == "deep_config"
96
+ print("✅ status = completed (root key) --- 1 run found")
97
+
98
+ # ------------------------------------------------------------
99
+ # v2 – Generalised path, shorthand keys, batch isolation
100
+ # ------------------------------------------------------------
101
+ def test_v2_dot_path_search(setup_data):
102
+ """Explicit dot‑path works for deeply nested fields."""
103
+ result = run_cli("search", "--metric", "config.optimizer.settings.learning_rate",
104
+ "--op", "eq", "--value", "0.0001", "--batch", BATCH_NAME, "--json")
105
+ assert result.returncode == 0
106
+ data = json.loads(result.stdout)
107
+ # run5 has learning_rate = 0.0001
108
+ assert data["totalElements"] == 1
109
+ print("✅ config.optimizer.settings.learning_rate = 0.0001 --- 1 run found")
110
+
111
+ def test_v2_shorthand_key_resolution_shallowest_wins(setup_data):
112
+ """Shorthand 'accuracy' resolves to the shallowest occurrence (root in run3.json)."""
113
+ result = run_cli("search", "--metric", "accuracy", "--op", "gt",
114
+ "--value", "0.8", "--batch", BATCH_NAME, "--json")
115
+ assert result.returncode == 0
116
+ data = json.loads(result.stdout)
117
+ # Only run3 has a root-level accuracy (0.88)
118
+ assert data["totalElements"] == 1
119
+ assert data["content"][0]["payload"]["experiment"] == "simple_top_level"
120
+ print("✅ Shorthand 'accuracy' (shallowest wins) > 0.8 --- 1 run found")
121
+
122
+ def test_v2_batch_isolation_empty(setup_data):
123
+ """Searching a different batch returns no results."""
124
+ result = run_cli("search", "--metric", "accuracy", "--op", "gt",
125
+ "--value", "0.5", "--batch", "nonexistent-batch", "--json")
126
+ assert result.returncode == 0
127
+ data = json.loads(result.stdout)
128
+ assert data["totalElements"] == 0
129
+ print("✅ accuracy > 0.5 in non‑existent batch --- 0 runs (correct)")
130
+
131
+ # ------------------------------------------------------------
132
+ # v3 – Full‑text phrase search, fuzzy search, summary output
133
+ # ------------------------------------------------------------
134
+ def test_v3_fulltext_phrase_search(setup_data):
135
+ """Full‑text search finds a phrase inside notes (using stemming)."""
136
+ # run1 notes contain "calibration samples"
137
+ result = run_cli("search", "--q", "calibration", "--batch", BATCH_NAME, "--json")
138
+ assert result.returncode == 0
139
+ data = json.loads(result.stdout)
140
+ # Both run1 and run2 mention "calibration"
141
+ assert data["totalElements"] >= 1, "Expected at least one run with 'calibration'"
142
+ print(f"✅ Full‑text: 'calibration' --- {data['totalElements']} runs found")
143
+
144
+ def test_v3_fuzzy_search(setup_data):
145
+ """Fuzzy search with a typo still finds the original word."""
146
+ # "calibraation" is a misspelling of "calibration"
147
+ result = run_cli("search", "--q", "calibraation", "--fuzzy",
148
+ "--batch", BATCH_NAME, "--json")
149
+ assert result.returncode == 0
150
+ data = json.loads(result.stdout)
151
+ assert data["totalElements"] >= 1, "Fuzzy search should find runs with 'calibration'"
152
+ print(f"✅ Fuzzy: 'calibraation' --- {data['totalElements']} runs found")
153
+
154
+ def test_v3_summary_output(setup_data):
155
+ """Summary flag returns only IDs and source files."""
156
+ # Use dot-path to metrics.accuracy to get results
157
+ result = run_cli("search", "--metric", "metrics.accuracy", "--op", "gt",
158
+ "--value", "0.9", "--batch", BATCH_NAME, "--summary")
159
+ assert result.returncode == 0
160
+ stdout = result.stdout
161
+ # Should find run4 and run5
162
+ assert "duplicate_keys" in stdout or "deep_config" in stdout, "Summary should list at least one source file"
163
+ print("✅ metrics.accuracy > 0.9 (summary) --- table displayed")
164
+
165
+
166
+ # ------------------------------------------------------------
167
+ # v4 – Array‑aware search (new)
168
+ # ------------------------------------------------------------
169
+ def test_array_basic_search(setup_data):
170
+ """Search inside an array: client.results[].threshold > 0.96"""
171
+ result = run_cli("search", "--metric", "client.results[].threshold",
172
+ "--op", "gt", "--value", "0.96",
173
+ "--batch", BATCH_NAME, "--json")
174
+ assert result.returncode == 0, result.stderr
175
+ data = json.loads(result.stdout)
176
+ # run7 has 0.99, run8 has 0.99 (plus non‑numeric values safely ignored)
177
+ assert data["totalElements"] == 2
178
+ print("✅ Array basic search (threshold > 0.96) --- 2 runs found")
179
+
180
+ def test_array_text_search(setup_data):
181
+ """Search inside an array: client.results[].status = 'error'"""
182
+ result = run_cli("search", "--metric", "client.results[].status",
183
+ "--op", "eq", "--value", "error",
184
+ "--batch", BATCH_NAME, "--json")
185
+ assert result.returncode == 0, result.stderr
186
+ data = json.loads(result.stdout)
187
+ # Only run8 has status = "error"
188
+ assert data["totalElements"] == 1
189
+ print("✅ Array text search (status = 'error') --- 1 run found")
190
+
191
+ def test_array_stress_nested_search(setup_data):
192
+ """Search inside nested arrays: phases[].calibration.points[].threshold > 0.97"""
193
+ result = run_cli("search", "--metric", "phases[].calibration.points[].threshold",
194
+ "--op", "gt", "--value", "0.97",
195
+ "--batch", BATCH_NAME, "--json")
196
+ assert result.returncode == 0, result.stderr
197
+ data = json.loads(result.stdout)
198
+ # run9 has 0.99 (phase1) and 0.98 (phase2) – both > 0.97
199
+ assert data["totalElements"] == 1
200
+ print("✅ Array stress nested search (threshold > 0.97) --- 1 run found")
201
+
202
+ def test_array_mixed_types_safe(setup_data):
203
+ """Search inside an array with mixed types: client.results[].threshold > 0.7"""
204
+ result = run_cli("search", "--metric", "client.results[].threshold",
205
+ "--op", "gt", "--value", "0.7",
206
+ "--batch", BATCH_NAME, "--json")
207
+ assert result.returncode == 0, result.stderr
208
+ data = json.loads(result.stdout)
209
+ # run7 (0.99,0.95) and run8 (0.99,'none',0.5) – non‑numeric safely ignored
210
+ assert data["totalElements"] == 2
211
+ print("✅ Array mixed‑type safety (threshold > 0.7) --- 2 runs found")
212
+
213
+ # ------------------------------------------------------------
214
+ # JSONL ingestion
215
+ # ------------------------------------------------------------
216
+ def test_jsonl_ingestion(setup_data):
217
+ """Ingest a JSONL file line by line and verify correct _source metadata."""
218
+ batch = f"jsonl-test-{uuid.uuid4().hex[:8]}"
219
+
220
+ with tempfile.TemporaryDirectory() as tmpdir:
221
+ # Create a JSONL file with 3 records
222
+ jl_file = Path(tmpdir) / "steps.jsonl"
223
+ lines = [
224
+ '{"step": 0, "loss": 0.95}',
225
+ '{"step": 1, "loss": 0.82}',
226
+ '{"step": 2, "loss": 0.67}',
227
+ ]
228
+ jl_file.write_text("\n".join(lines), encoding="utf-8")
229
+
230
+ # Scan with --jsonl
231
+ result = run_cli("scan", str(tmpdir), "--batch", batch, "--jsonl")
232
+ assert result.returncode == 0, f"scan failed: {result.stderr}"
233
+
234
+ # Verify via API
235
+ resp = requests.get(API_BASE, params={"batch": batch, "size": 50})
236
+ assert resp.status_code == 200, f"API error: {resp.text}"
237
+ data = resp.json()
238
+ content = data.get("content", [])
239
+ assert len(content) == 3, f"Expected 3 runs, got {len(content)}"
240
+
241
+ # Check _source metadata
242
+ for run in content:
243
+ payload = run.get("payload", {})
244
+ src = payload.get("_source", {})
245
+ assert src.get("file") == "steps.jsonl", f"Unexpected file: {src}"
246
+ idx = src.get("index")
247
+ assert idx is not None and 0 <= idx <= 2, f"Index out of range: {idx}"
248
+ # Verify the actual data lines up
249
+ expected = json.loads(lines[idx])
250
+ for k, v in expected.items():
251
+ assert payload.get(k) == v, f"Mismatch at line {idx}: {k} {v} != {payload.get(k)}"
252
+
253
+ # Clean up the temporary batch
254
+ requests.delete(f"{API_BASE}/batch/{batch}") # if this endpoint exists, otherwise manual cleanup
@@ -0,0 +1,177 @@
1
+ import json
2
+ import subprocess
3
+ import tempfile
4
+ import time
5
+ import uuid
6
+ import os
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+ import requests
11
+
12
+ PROJECT_ROOT = Path(__file__).parent.parent.parent
13
+ COMPOSE_FILE = PROJECT_ROOT / "docker-compose.yml"
14
+ HEALTH_URL = "http://localhost:8080/actuator/health"
15
+ API_BASE = "http://localhost:8080/api/runs"
16
+
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
28
+
29
+ @pytest.fixture(scope="session")
30
+ 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
46
+ subprocess.run(
47
+ ["docker", "compose", "-f", str(COMPOSE_FILE), "down"],
48
+ cwd=str(PROJECT_ROOT),
49
+ capture_output=True,
50
+ )
51
+
52
+ def run_cli(*args):
53
+ cmd = ["python", str(PROJECT_ROOT / "cli" / "ledger.py"), *args]
54
+ return subprocess.run(cmd, capture_output=True, encoding='utf-8', cwd=str(PROJECT_ROOT))
55
+
56
+ @pytest.fixture
57
+ def sample_folder():
58
+ """Create a temp folder with valid and corrupt JSON files."""
59
+ with tempfile.TemporaryDirectory() as tmpdir:
60
+ folder = Path(tmpdir)
61
+ (folder / "run1.json").write_text(json.dumps({
62
+ "experiment": "exp1", "metrics": {"accuracy": 0.95, "loss": 0.1}
63
+ }))
64
+ (folder / "run2.json").write_text(json.dumps({
65
+ "experiment": "exp2", "metrics": {"accuracy": 0.80, "loss": 0.2}
66
+ }))
67
+ (folder / "batch.json").write_text(json.dumps([
68
+ {"experiment": "exp3", "metrics": {"f1": 0.88}},
69
+ {"experiment": "exp4", "metrics": {"f1": 0.92}}
70
+ ]))
71
+ (folder / "bad.json").write_text("this is not json")
72
+ yield folder
73
+
74
+ pytestmark = pytest.mark.integration
75
+
76
+ def test_full_golden_path(docker_stack, sample_folder):
77
+ batch = f"golden-{uuid.uuid4()}"
78
+
79
+ # 1. scan folder (non‑interactive)
80
+ result = run_cli("scan", str(sample_folder), "--batch", batch)
81
+ assert result.returncode == 0, result.stderr
82
+ assert "Successfully ingested 4 run(s)" in result.stdout
83
+ assert f"'{batch}'" in result.stdout
84
+
85
+ # 2. metrics
86
+ result = run_cli("metrics", "--batch", batch)
87
+ assert result.returncode == 0, result.stderr
88
+ metrics = json.loads(result.stdout)
89
+ assert set(metrics).issuperset({"accuracy", "loss", "f1"})
90
+
91
+ # 3. search: accuracy > 0.9
92
+ result = run_cli("search", "--metric", "accuracy", "--op", "gt", "--value", "0.9", "--batch", batch, "--json")
93
+ assert result.returncode == 0, result.stderr
94
+ data = json.loads(result.stdout)
95
+ assert data["totalElements"] == 1
96
+ run = data["content"][0]
97
+ assert run["payload"]["experiment"] == "exp1"
98
+ assert run["payload"]["metrics"]["accuracy"] == 0.95
99
+ assert "_source" in run["payload"], "source metadata missing"
100
+ assert run["payload"]["_source"]["file"] == "run1.json"
101
+
102
+ # 4. search: loss < 0.15
103
+ result = run_cli("search", "--metric", "loss", "--op", "lt", "--value", "0.15", "--batch", batch, "--json")
104
+ assert result.returncode == 0, result.stderr
105
+ data = json.loads(result.stdout)
106
+ assert data["totalElements"] == 1
107
+ assert data["content"][0]["payload"]["experiment"] == "exp1"
108
+
109
+ # 5. status
110
+ result = run_cli("status")
111
+ assert result.returncode == 0, result.stderr
112
+ assert "running" in result.stdout.lower()
113
+
114
+ # 6. deep API verification
115
+ resp = requests.get(API_BASE, params={"batch": batch})
116
+ assert resp.status_code == 200
117
+ runs = resp.json()["content"]
118
+ assert len(runs) == 4
119
+ ids = [r["id"] for r in runs]
120
+ assert len(set(ids)) == 4, "IDs must be unique"
121
+ exp_names = {r["payload"]["experiment"] for r in runs}
122
+ assert exp_names == {"exp1", "exp2", "exp3", "exp4"}
123
+ for r in runs:
124
+ assert r["payload"]["_source"]["file"] in ["run1.json", "run2.json", "batch.json"]
125
+ assert r["createdAt"] is not None
126
+ # Verify ordering (descending by createdAt)
127
+ timestamps = [r["createdAt"] for r in runs]
128
+ assert timestamps == sorted(timestamps, reverse=True), "runs must be ordered by createdAt desc"
129
+
130
+ def test_scan_nonexistent_folder(docker_stack):
131
+ result = run_cli("scan", "/no/such/path")
132
+ assert result.returncode != 0 or "Folder not found" in result.stdout
133
+
134
+ def test_scan_corrupt_file_skipped(docker_stack, sample_folder):
135
+ batch = f"corrupt-{uuid.uuid4()}"
136
+ result = run_cli("scan", str(sample_folder), "--batch", batch)
137
+ assert result.returncode == 0, result.stderr
138
+ assert "Successfully ingested 4 run(s)" in result.stdout
139
+
140
+ def test_metrics_empty_batch(docker_stack):
141
+ result = run_cli("metrics", "--batch", f"nonexistent-{uuid.uuid4()}")
142
+ assert result.returncode == 0, result.stderr
143
+ assert json.loads(result.stdout) == []
144
+
145
+ def test_search_no_results(docker_stack, sample_folder):
146
+ batch = f"noresults-{uuid.uuid4()}"
147
+ run_cli("scan", str(sample_folder), "--batch", batch)
148
+ result = run_cli("search", "--metric", "accuracy", "--op", "gt", "--value", "0.99", "--batch", batch, "--json")
149
+ assert result.returncode == 0, result.stderr
150
+ data = json.loads(result.stdout)
151
+ assert data["totalElements"] == 0
152
+ assert data["content"] == []
153
+
154
+ def test_multi_filter_and_returns_results():
155
+ # Ensure the compound test data folder exists
156
+ compound_dir = "test-data/sample-compound-runs"
157
+ if not os.path.isdir(compound_dir):
158
+ pytest.skip(f"Folder {compound_dir} not found")
159
+
160
+ # Scan the folder with explicit UTF-8 encoding to avoid UnicodeDecodeError
161
+ scan_result = subprocess.run(
162
+ ["python", "cli/ledger.py", "scan", compound_dir, "--batch", "compound-int"],
163
+ capture_output=True, text=True, encoding="utf-8", errors="replace"
164
+ )
165
+ assert scan_result.returncode == 0, f"Scan failed: {scan_result.stderr}"
166
+
167
+ # Search with AND
168
+ result = subprocess.run(
169
+ ["python", "cli/ledger.py", "search", "--metric", "accuracy", "--op", "gt", "--value", "0.9",
170
+ "--metric", "loss", "--op", "lt", "--value", "0.2", "--combine", "and", "--batch", "compound-int"],
171
+ capture_output=True, text=True, encoding="utf-8", errors="replace"
172
+ )
173
+ assert result.returncode == 0, f"Search failed: {result.stderr}"
174
+ 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
+ assert "compound_run1" in result.stdout, f"Output: {result.stdout}"