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 +0 -0
- cli/guided.py +444 -0
- cli/ledger.py +1276 -0
- cli/tests/__init__.py +0 -0
- cli/tests/test_data_features.py +254 -0
- cli/tests/test_integration.py +177 -0
- cli/tests/test_ledger.py +378 -0
- cli/tests/test_real_results.py +153 -0
- runledger_cli-0.1.0.dist-info/METADATA +198 -0
- runledger_cli-0.1.0.dist-info/RECORD +13 -0
- runledger_cli-0.1.0.dist-info/WHEEL +5 -0
- runledger_cli-0.1.0.dist-info/entry_points.txt +2 -0
- runledger_cli-0.1.0.dist-info/top_level.txt +1 -0
cli/tests/test_ledger.py
ADDED
|
@@ -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")
|