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.
@@ -0,0 +1,378 @@
1
+ import json
2
+ import subprocess
3
+ from unittest.mock import MagicMock, patch, mock_open
4
+
5
+ import sys
6
+ import pytest
7
+ import requests
8
+ import argparse
9
+
10
+ # Import the module under test
11
+ from cli.ledger import (
12
+ check_docker,
13
+ backend_running,
14
+ start_backend,
15
+ stop_backend,
16
+ ingest_folder,
17
+ fetch_metrics,
18
+ perform_search,
19
+ display_results,
20
+ interactive_search,
21
+ PAGE_SIZE,
22
+ API_BASE,
23
+ HEALTH_URL,
24
+ )
25
+
26
+ # ----------------------------------------------------------------------
27
+ # Helpers
28
+ # ----------------------------------------------------------------------
29
+ def fake_run_success(*args, **kwargs):
30
+ """Simulate a successful subprocess call."""
31
+ return MagicMock(returncode=0)
32
+
33
+ def fake_run_failure(*args, **kwargs):
34
+ """Simulate a failed subprocess call."""
35
+ raise subprocess.CalledProcessError(1, "mock")
36
+
37
+ # ----------------------------------------------------------------------
38
+ # Docker checks
39
+ # ----------------------------------------------------------------------
40
+ class TestCheckDocker:
41
+ def test_docker_installed_and_running(self, mocker):
42
+ mocker.patch("subprocess.run", return_value=MagicMock(returncode=0))
43
+ assert check_docker() is True
44
+
45
+ def test_docker_not_installed(self, mocker):
46
+ mocker.patch("subprocess.run", return_value=MagicMock(returncode=1, stderr="'docker' is not recognized"))
47
+ assert check_docker() is False
48
+
49
+ def test_docker_daemon_stopped(self, mocker):
50
+ mocker.patch("subprocess.run", return_value=MagicMock(returncode=1))
51
+ assert check_docker() is False
52
+
53
+ # ----------------------------------------------------------------------
54
+ # Backend health & lifecycle
55
+ # ----------------------------------------------------------------------
56
+ class TestBackendLifecycle:
57
+ def test_backend_running(self, mocker):
58
+ mock_get = mocker.patch("requests.get", return_value=MagicMock(status_code=200))
59
+ assert backend_running() is True
60
+ mock_get.assert_called_once_with(HEALTH_URL, timeout=2)
61
+
62
+ def test_backend_not_running(self, mocker):
63
+ mocker.patch("requests.get", side_effect=requests.ConnectionError)
64
+ assert backend_running() is False
65
+
66
+ def test_start_backend_creates_containers(self, mocker):
67
+ mocker.patch("os.path.exists", return_value=True)
68
+ mock_run = mocker.patch("subprocess.run")
69
+ mocker.patch("cli.ledger.wait_for_backend", return_value=True)
70
+
71
+ start_backend()
72
+ mock_run.assert_called_once() # docker compose up -d
73
+
74
+ def test_stop_backend(self, mocker):
75
+ mocker.patch("os.path.exists", return_value=True)
76
+ mock_run = mocker.patch("subprocess.run")
77
+ stop_backend()
78
+ mock_run.assert_called_once()
79
+
80
+ # ----------------------------------------------------------------------
81
+ # Ingestion
82
+ # ----------------------------------------------------------------------
83
+ class TestIngestFolder:
84
+ def test_ingest_single_json_creates_run(self, mocker):
85
+ folder = "/fake/sweep"
86
+ batch_name = "sweep"
87
+ file_path = f"{folder}/run1.json"
88
+ payload = {"accuracy": 0.95}
89
+
90
+ # Mock filesystem
91
+ mocker.patch("pathlib.Path.exists", return_value=True)
92
+ mocker.patch("pathlib.Path.glob", return_value=[MagicMock(name=file_path)])
93
+ mocker.patch("builtins.open", mock_open(read_data=json.dumps(payload)))
94
+
95
+ # Mock HTTP POST
96
+ mock_post = mocker.patch("requests.post", return_value=MagicMock(status_code=201))
97
+
98
+ ingest_folder(folder, batch=batch_name)
99
+
100
+ # Check that the POST was made with correct JSON
101
+ call_args = mock_post.call_args
102
+ sent_json = call_args[1]["json"]
103
+ assert sent_json["batch"] == batch_name
104
+ assert sent_json["payload"]["accuracy"] == 0.95
105
+
106
+ def test_ingest_array_sends_each_element(self, mocker):
107
+ folder = "/fake/sweep"
108
+ data = [{"loss": 0.1}, {"loss": 0.2}]
109
+
110
+ mocker.patch("pathlib.Path.exists", return_value=True)
111
+ mocker.patch("pathlib.Path.glob", return_value=[MagicMock(name=f"{folder}/multi.json")])
112
+ mocker.patch("builtins.open", mock_open(read_data=json.dumps(data)))
113
+ mock_post = mocker.patch("requests.post", return_value=MagicMock(status_code=201))
114
+
115
+ ingest_folder(folder)
116
+
117
+ assert mock_post.call_count == 2 # one per element
118
+
119
+ def test_ingest_skips_invalid_json(self, mocker):
120
+ mocker.patch("pathlib.Path.exists", return_value=True)
121
+ mocker.patch("pathlib.Path.glob", return_value=[MagicMock(name="bad.json")])
122
+ mocker.patch("builtins.open", mock_open(read_data="not json"))
123
+ mock_post = mocker.patch("requests.post")
124
+ ingest_folder("/fake")
125
+ mock_post.assert_not_called()
126
+
127
+ # ----------------------------------------------------------------------
128
+ # Metric discovery
129
+ # ----------------------------------------------------------------------
130
+ class TestFetchMetrics:
131
+ def test_returns_metrics_list(self, mocker):
132
+ mock_get = mocker.patch("requests.get", return_value=MagicMock(status_code=200,
133
+ json=MagicMock(return_value=["accuracy", "loss"])))
134
+ metrics = fetch_metrics(batch="test")
135
+ assert metrics == ["accuracy", "loss"]
136
+
137
+ def test_handles_failure(self, mocker):
138
+ mocker.patch("requests.get", return_value=MagicMock(status_code=500))
139
+ metrics = fetch_metrics()
140
+ assert metrics == []
141
+
142
+ # ----------------------------------------------------------------------
143
+ # Block search
144
+ # ----------------------------------------------------------------------
145
+ class TestPerformSearch:
146
+ def test_search_returns_page(self, mocker):
147
+ sample_page = {"content": [{"id": 1, "createdAt": "...", "payload": {}}],
148
+ "totalElements": 1, "totalPages": 1, "pageable": {"pageNumber": 0}}
149
+ mock_get = mocker.patch("requests.get", return_value=MagicMock(status_code=200,
150
+ json=MagicMock(return_value=sample_page)))
151
+ result = perform_search("acc", "gt", "0.9", batch="b1", page=0)
152
+ assert result == sample_page
153
+
154
+ def test_search_error_returns_none(self, mocker):
155
+ mocker.patch("requests.get", side_effect=requests.ConnectionError)
156
+ assert perform_search("acc", "gt", "0.9") is None
157
+
158
+ # ----------------------------------------------------------------------
159
+ # Display (just verify no exceptions)
160
+ # ----------------------------------------------------------------------
161
+ class TestDisplayResults:
162
+ def test_displays_table_without_error(self, mocker):
163
+ data = {"content": [{"id": 1, "createdAt": "2025-01-01", "payload": {"experiment": "test"}}],
164
+ "totalElements": 1, "totalPages": 1, "pageable": {"pageNumber": 0}}
165
+ # Mock Console to avoid real output
166
+ mocker.patch("cli.ledger.console.print")
167
+ display_results(data) # should not raise
168
+
169
+ # ----------------------------------------------------------------------
170
+ # Interactive search (mock user input and API calls)
171
+ # ----------------------------------------------------------------------
172
+ class TestInteractiveSearch:
173
+ def test_full_search_flow(self, mocker):
174
+ # Mock metrics
175
+ mocker.patch("cli.ledger.fetch_metrics", return_value=["accuracy", "loss"])
176
+ # Mock search response
177
+ mocker.patch("cli.ledger.perform_search", return_value={
178
+ "content": [{"id": 1, "createdAt": "...", "payload": {}}],
179
+ "totalPages": 1, "totalElements": 1, "pageable": {"pageNumber": 0}
180
+ })
181
+ # Mock console display
182
+ mocker.patch("cli.ledger.console.print")
183
+ # Mock prompts: select metric "1", operator ">", value "0.9", then quit
184
+ prompt_patch = mocker.patch("cli.ledger.Prompt.ask",
185
+ side_effect=["1", ">", "0.9", "q", "n"])
186
+ interactive_search(batch="test")
187
+ assert prompt_patch.call_count >= 4
188
+
189
+ def test_search_multi_filter_builds_correct_payload(mocker, monkeypatch):
190
+ mock_post = mocker.patch("requests.post")
191
+ mock_post.return_value.json.return_value = {"content": [], "totalElements": 0}
192
+ mock_post.return_value.status_code = 200
193
+
194
+ # Simulate CLI arguments as if typed on the command line
195
+ test_args = [
196
+ "ledger.py", "search",
197
+ "--metric", "accuracy", "--op", "gt", "--value", "0.9",
198
+ "--metric", "loss", "--op", "lt", "--value", "0.2",
199
+ "--combine", "and",
200
+ "--batch", "test-batch"
201
+ ]
202
+ monkeypatch.setattr("sys.argv", test_args)
203
+
204
+ from cli import ledger
205
+ ledger.main()
206
+
207
+ expected_url = "http://localhost:8080/api/runs/search"
208
+ expected_payload = {
209
+ "filters": [
210
+ {"metric": "accuracy", "op": "gt", "value": "0.9"},
211
+ {"metric": "loss", "op": "lt", "value": "0.2"}
212
+ ],
213
+ "combine": "and",
214
+ "batch": "test-batch"
215
+ }
216
+ mock_post.assert_any_call(expected_url, json=expected_payload)
217
+
218
+ def test_show_blocks_prints_matched_pointers(mocker):
219
+ """When a run has matched data, show_blocks prints Matches: lines."""
220
+ run = {
221
+ "id": 1,
222
+ "payload": {"_source": {"file": "test.json"}, "experiment": "test_exp"},
223
+ "matched": [
224
+ {"pointer": "results[0].sst2_cacc", "value": 0.924312, "snippet": {"budget": 0.0}},
225
+ {"pointer": "results[1].sst2_cacc", "value": 0.927752, "snippet": {"budget": 0.001}}
226
+ ]
227
+ }
228
+ mock_print = mocker.patch("cli.ledger.console.print")
229
+ from cli.ledger import show_blocks
230
+ show_blocks(run, "results[].sst2_cacc", [1])
231
+ # Some print calls may have no arguments (empty line) – filter them out
232
+ printed_texts = [
233
+ call.args[0] for call in mock_print.call_args_list if call.args
234
+ ]
235
+ assert any("Matches:" in str(t) for t in printed_texts)
236
+ assert any("results[0].sst2_cacc" in str(t) for t in printed_texts)
237
+ assert any("0.924312" in str(t) for t in printed_texts)
238
+ assert any("results[1].sst2_cacc" in str(t) for t in printed_texts)
239
+ assert any("0.927752" in str(t) for t in printed_texts)
240
+
241
+
242
+ def test_export_includes_pointer_fields():
243
+ """build_export_blocks and format_blocks_as_text include pointer data."""
244
+ from cli.ledger import build_export_blocks, format_blocks_as_text
245
+
246
+ content = [
247
+ {
248
+ "id": 1,
249
+ "payload": {"_source": {"file": "test.json"}, "experiment": "test_exp"},
250
+ "matched": [
251
+ {"pointer": "results[0].sst2_cacc", "value": 0.924312, "snippet": {"budget": 0.0}}
252
+ ]
253
+ }
254
+ ]
255
+
256
+ blocks = build_export_blocks(content, "results[].sst2_cacc", [0], include_pointers=True)
257
+ assert len(blocks) == 1
258
+ block = blocks[0]
259
+ assert "pointer" in block
260
+ assert block["pointer"] == "results[0].sst2_cacc"
261
+ assert "value" in block
262
+ assert block["value"] == 0.924312
263
+
264
+ text = format_blocks_as_text(blocks)
265
+ assert "match: results[0].sst2_cacc = 0.924312" in text
266
+
267
+ def test_diff_output(mocker):
268
+ """Test that the diff command fetches two runs and prints a table."""
269
+ mock_get = mocker.patch("requests.get")
270
+ run1 = mocker.Mock()
271
+ run1.status_code = 200
272
+ run1.json.return_value = {
273
+ "id": 1,
274
+ "payload": {"accuracy": 0.95, "loss": 0.12, "experiment": "A"}
275
+ }
276
+ run2 = mocker.Mock()
277
+ run2.status_code = 200
278
+ run2.json.return_value = {
279
+ "id": 2,
280
+ "payload": {"accuracy": 0.92, "loss": 0.15, "experiment": "B"}
281
+ }
282
+ mock_get.side_effect = [run1, run2]
283
+
284
+ mock_console = mocker.patch("cli.ledger.console.print")
285
+ import argparse
286
+ args = argparse.Namespace(id1=1, id2=2, json=False, history=False, batch=None)
287
+
288
+ from cli.ledger import cli_diff
289
+ cli_diff(args)
290
+
291
+ # Assert that console.print was called at least once
292
+ assert mock_console.call_count > 0
293
+ # We can also check that a Table was printed by inspecting the first argument of some call
294
+ table_printed = False
295
+ for call in mock_console.call_args_list:
296
+ if call.args and "Table" in str(type(call.args[0])):
297
+ table_printed = True
298
+ break
299
+ assert table_printed, "Expected a Rich Table to be printed"
300
+
301
+ def test_search_save_and_load(mocker):
302
+ """CLI --save and --saved send correct API payloads."""
303
+ mock_post = mocker.patch("requests.post")
304
+ mock_get = mocker.patch("requests.get")
305
+
306
+ # --save path
307
+ mock_post.return_value.status_code = 200
308
+
309
+ import argparse
310
+ save_args = argparse.Namespace(
311
+ metrics=["accuracy"], ops=["gt"], values=["0.9"],
312
+ combine=None, q=None, fuzzy=False, batch="sample",
313
+ block=None, pointers=True, summary=False, json=False,
314
+ save="my-query", saved=None
315
+ )
316
+
317
+ from cli.ledger import cli_search
318
+ cli_search(save_args)
319
+
320
+ # Verify POST was called with correct payload
321
+ save_call = mock_post.call_args
322
+ assert save_call[1]["json"]["name"] == "my-query"
323
+ assert "accuracy" in save_call[1]["json"]["paramsJson"]
324
+
325
+ # --saved path
326
+ saved_response = mocker.Mock()
327
+ saved_response.status_code = 200
328
+ saved_response.json.return_value = {
329
+ "name": "my-query",
330
+ "batch": "sample",
331
+ "paramsJson": '{"metrics":["accuracy"],"ops":["gt"],"values":["0.9"],"combine":"and","batch":"sample"}'
332
+ }
333
+ mock_get.return_value = saved_response
334
+
335
+ # Also need to mock the actual search response (the one that fetches runs)
336
+ search_response = mocker.Mock()
337
+ search_response.status_code = 200
338
+ search_response.json.return_value = {"content": [], "totalElements": 0}
339
+ mock_get.side_effect = [saved_response, search_response]
340
+
341
+ saved_args = argparse.Namespace(
342
+ metrics=None, ops=None, values=None,
343
+ combine=None, q=None, fuzzy=False, batch=None,
344
+ block=None, pointers=True, summary=False, json=False,
345
+ save=None, saved="my-query"
346
+ )
347
+
348
+ cli_search(saved_args)
349
+
350
+ # Verify the search was performed with the saved parameters
351
+ # (the second GET call should have metric=accuracy, op=gt, value=0.9)
352
+ search_call = mock_get.call_args_list[1] if len(mock_get.call_args_list) > 1 else None
353
+ assert search_call is not None
354
+ assert "metric=accuracy" in search_call[0][0] or any("accuracy" in str(p) for p in search_call[1].values())
355
+
356
+ # ----------------------------------------------------------------------
357
+ # Aggregate test (NEW)
358
+ # ----------------------------------------------------------------------
359
+ def test_aggregate_output(mocker):
360
+ """Test that the aggregate command calls the API and prints results."""
361
+ mock_get = mocker.patch("requests.get")
362
+ mock_response = mocker.Mock()
363
+ mock_response.status_code = 200
364
+ mock_response.json.return_value = [
365
+ {"group": "0.001", "result": 0.95},
366
+ {"group": "0.01", "result": 0.88}
367
+ ]
368
+ mock_get.return_value = mock_response
369
+
370
+ mock_print = mocker.patch("cli.ledger.console.print")
371
+ import argparse
372
+ args = argparse.Namespace(metric="accuracy", agg="AVG", group_by="learning_rate", batch="sample", json=False)
373
+
374
+ from cli.ledger import cli_aggregate
375
+ cli_aggregate(args)
376
+
377
+ # Check that the table was printed (at least one call to console.print)
378
+ assert mock_print.call_count > 0
@@ -0,0 +1,153 @@
1
+ """
2
+ Feature showcase tests using the real experiment data (test-data/results).
3
+
4
+ Run with:
5
+ python -m pytest cli/tests/test_real_results.py -v -s
6
+
7
+ Assumes the RunLedger backend is already running (docker compose up -d).
8
+ """
9
+
10
+ import json
11
+ import subprocess
12
+ import time
13
+ import uuid
14
+ from pathlib import Path
15
+
16
+ import pytest
17
+ import requests
18
+
19
+ # ------------------------------------------------------------
20
+ # Configuration
21
+ # ------------------------------------------------------------
22
+ PROJECT_ROOT = Path(__file__).parent.parent.parent # run-ledger/
23
+ HEALTH_URL = "http://localhost:8080/actuator/health"
24
+ API_BASE = "http://localhost:8080/api/runs"
25
+ DATA_DIR = PROJECT_ROOT / "test-data" / "results"
26
+ BATCH = f"real-results-{uuid.uuid4().hex[:8]}"
27
+
28
+ # ------------------------------------------------------------
29
+ # Helpers
30
+ # ------------------------------------------------------------
31
+ def run_cli(*args):
32
+ cmd = ["python", str(PROJECT_ROOT / "cli" / "ledger.py"), *args]
33
+ # Use encoding='utf-8' so the subprocess output is correctly decoded
34
+ return subprocess.run(cmd, capture_output=True, encoding='utf-8', cwd=str(PROJECT_ROOT))
35
+
36
+ # ------------------------------------------------------------
37
+ # Fixture – ingest once per session
38
+ # ------------------------------------------------------------
39
+ @pytest.fixture(scope="session")
40
+ def setup_data():
41
+ if not DATA_DIR.exists():
42
+ pytest.skip(f"Folder {DATA_DIR} not found – place real experiment JSONs there first.")
43
+
44
+ # Wait for backend
45
+ for _ in range(30):
46
+ try:
47
+ if requests.get(HEALTH_URL, timeout=2).status_code == 200:
48
+ break
49
+ except Exception:
50
+ pass
51
+ time.sleep(2)
52
+ else:
53
+ pytest.fail("Backend not reachable (docker compose up -d)")
54
+
55
+ result = run_cli("scan", str(DATA_DIR), "--batch", BATCH)
56
+ assert result.returncode == 0, result.stderr
57
+ assert "Successfully ingested" in result.stdout
58
+
59
+ # ------------------------------------------------------------
60
+ # v1 – Metric discovery (list every available key)
61
+ # ------------------------------------------------------------
62
+ def test_metric_discovery(setup_data):
63
+ result = run_cli("metrics", "--batch", BATCH)
64
+ assert result.returncode == 0, result.stderr
65
+ keys = json.loads(result.stdout)
66
+ assert len(keys) > 10, "Expected many distinct keys across the 60 real files"
67
+
68
+ # Keys that exist outside arrays in your real data (therefore discoverable)
69
+ must_have = ["script", "full_cacc", "full_asr", "pretrained_name", "target_label", "device"]
70
+ for k in must_have:
71
+ assert k in keys, f"Expected key '{k}' to be discovered"
72
+ print("✅ Metric discovery --- SUCCESS")
73
+
74
+ # ------------------------------------------------------------
75
+ # v1 – Numeric block search
76
+ # ------------------------------------------------------------
77
+ def test_numeric_search(setup_data):
78
+ query = "full_cacc > 0.5"
79
+ result = run_cli("search", "--metric", "full_cacc", "--op", "gt",
80
+ "--value", "0.5", "--batch", BATCH, "--json")
81
+ assert result.returncode == 0, result.stderr
82
+ data = json.loads(result.stdout)
83
+ assert data["totalElements"] > 0, f"Expected at least one run with {query}"
84
+ print(f"✅ {query} --- {data['totalElements']} runs found")
85
+
86
+ # ------------------------------------------------------------
87
+ # v1 – Text equality search
88
+ # ------------------------------------------------------------
89
+ def test_text_equality(setup_data):
90
+ query = "script = anchor_deviation_reset"
91
+ result = run_cli("search", "--metric", "script", "--op", "eq",
92
+ "--value", "anchor_deviation_reset", "--batch", BATCH, "--json")
93
+ assert result.returncode == 0, result.stderr
94
+ data = json.loads(result.stdout)
95
+ assert data["totalElements"] > 0, f"Expected at least one run with {query}"
96
+ print(f"✅ {query} --- {data['totalElements']} runs found")
97
+
98
+ # ------------------------------------------------------------
99
+ # v2 – Deeply nested dot‑path search
100
+ # ------------------------------------------------------------
101
+ def test_deep_path_search(setup_data):
102
+ query = "parameters.device = cpu"
103
+ result = run_cli("search", "--metric", "parameters.device", "--op", "eq",
104
+ "--value", "cpu", "--batch", BATCH, "--json")
105
+ assert result.returncode == 0, result.stderr
106
+ data = json.loads(result.stdout)
107
+ assert data["totalElements"] > 0, f"Expected runs with {query}"
108
+ print(f"✅ {query} --- {data['totalElements']} runs found")
109
+
110
+ # ------------------------------------------------------------
111
+ # v3 – Full‑text phrase search
112
+ # ------------------------------------------------------------
113
+ def test_fulltext_search(setup_data):
114
+ query = 'fulltext: "bert-base-uncased"'
115
+ result = run_cli("search", "--q", "bert-base-uncased", "--batch", BATCH, "--json")
116
+ assert result.returncode == 0, result.stderr
117
+ data = json.loads(result.stdout)
118
+ assert data["totalElements"] > 0, f"Expected at least one run containing {query}"
119
+ print(f"✅ {query} --- {data['totalElements']} runs found")
120
+
121
+ # ------------------------------------------------------------
122
+ # v3 – Fuzzy search
123
+ # ------------------------------------------------------------
124
+ def test_fuzzy_search(setup_data):
125
+ query = 'fuzzy: "bert-base-uncasedd"'
126
+ result = run_cli("search", "--q", "bert-base-uncasedd", "--fuzzy",
127
+ "--batch", BATCH, "--json")
128
+ assert result.returncode == 0, result.stderr
129
+ data = json.loads(result.stdout)
130
+ assert data["totalElements"] > 0, f"Fuzzy search should find runs with {query}"
131
+ print(f"✅ {query} --- {data['totalElements']} runs found")
132
+
133
+ # ------------------------------------------------------------
134
+ # v3 – Summary output
135
+ # ------------------------------------------------------------
136
+ def test_summary_output(setup_data):
137
+ query = "full_cacc > 0.9 (summary)"
138
+ result = run_cli("search", "--metric", "full_cacc", "--op", "gt",
139
+ "--value", "0.9", "--batch", BATCH, "--summary")
140
+ assert result.returncode == 0, result.stderr
141
+ stdout = result.stdout
142
+ assert ".json" in stdout, "Summary table should list source file names"
143
+ print(f"✅ {query} --- summary table displayed")
144
+
145
+ def test_array_search_on_real_data(setup_data):
146
+ """Search inside a real array: results[].X > -9999 (catch‑all)"""
147
+ result = run_cli("search", "--metric", "results[].X",
148
+ "--op", "gt", "--value", "-9999",
149
+ "--batch", BATCH, "--json")
150
+ assert result.returncode == 0, result.stderr
151
+ data = json.loads(result.stdout)
152
+ assert data["totalElements"] > 0, "Expected at least one run with results[].X"
153
+ print(f"✅ Array search on real data (results[].X > -9999) --- {data['totalElements']} runs found")