Skip to content

Commit c40e08a

Browse files
committed
refactor: simplify histogram processing by consolidating file handling and enhancing error reporting
1 parent 989700a commit c40e08a

2 files changed

Lines changed: 104 additions & 77 deletions

File tree

src/khisto/core/backend.py

Lines changed: 65 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@
77
from __future__ import annotations
88

99
import json
10-
import pathlib
11-
import shutil
1210
import subprocess
1311
import tempfile
1412
from dataclasses import dataclass, field
@@ -161,19 +159,22 @@ def _to_result(h: _HistogramPayload, **kwargs: Any) -> HistogramResult:
161159
)
162160

163161

164-
def _parse_file_type(file_path: pathlib.Path) -> None:
165-
"""Validate the exploratory JSON output file name."""
166-
if file_path.suffix != ".json" or ".series" not in file_path.stem:
167-
raise ValueError(f"Unrecognized histogram file name: {file_path.name}")
162+
def _format_runtime_error(
163+
summary: str, cmd: list[str], details: str | None = None
164+
) -> str:
165+
"""Build a consistent runtime error message for khisto failures."""
166+
message = f"{summary} while running: {' '.join(cmd)}"
167+
if details:
168+
message = f"{message}\n{details}"
169+
return message
168170

169171

170-
def _process_histogram_files(temp_dir: str, base_name: str) -> list[HistogramResult]:
172+
def _process_histogram_file(
173+
temp_output_file: tempfile._TemporaryFileWrapper[str],
174+
) -> list[HistogramResult]:
171175
"""Process exploratory JSON generated by khisto CLI."""
172-
output_file = pathlib.Path(temp_dir) / f"{base_name}.json"
173-
_parse_file_type(output_file)
174-
175-
with output_file.open("r", encoding="utf-8") as f:
176-
khisto_output: _KhistoOutput = _KhistoOutput.from_dict(json.load(f))
176+
temp_output_file.seek(0)
177+
khisto_output: _KhistoOutput = _KhistoOutput.from_dict(json.load(temp_output_file))
177178

178179
histogram_series = khisto_output.histogramSeries
179180
best_idx = histogram_series.interpretableHistogramNumber - 1
@@ -210,7 +211,7 @@ def compute_histograms(x: np.ndarray) -> list[HistogramResult]:
210211
Raises
211212
------
212213
RuntimeError
213-
If khisto CLI execution fails.
214+
If khisto CLI execution fails or returns invalid output.
214215
ValueError
215216
If input array is empty after filtering.
216217
"""
@@ -220,37 +221,54 @@ def compute_histograms(x: np.ndarray) -> list[HistogramResult]:
220221
if len(x) == 0:
221222
raise ValueError("Input array is empty after filtering")
222223

223-
with tempfile.NamedTemporaryFile(
224-
mode="wb", suffix=".bin", delete=False
225-
) as temp_input:
226-
x.tofile(temp_input)
227-
temp_input_path = temp_input.name
228-
229-
temp_dir = tempfile.mkdtemp(prefix="khisto_output_")
230-
output_file = pathlib.Path(temp_dir) / "histogram.series.json"
231-
cmd: list[str] = []
232-
233-
try:
234-
cmd = [
235-
str(KHISTO_BIN_DIR),
236-
"-b",
237-
"-e",
238-
"-j",
239-
temp_input_path,
240-
str(output_file),
241-
]
242-
subprocess.run(cmd, capture_output=True, text=True, check=True)
243-
return _process_histogram_files(temp_dir, output_file.stem)
244-
245-
except subprocess.CalledProcessError as e:
246-
stdout = e.stdout.strip()
247-
stderr = e.stderr.strip()
248-
details = "\n".join(part for part in (stdout, stderr) if part)
249-
message = f"khisto failed with exit code {e.returncode} while running: {' '.join(cmd)}"
250-
if details:
251-
message = f"{message}\n{details}"
252-
logger.error(message)
253-
raise RuntimeError(message) from e
254-
finally:
255-
pathlib.Path(temp_input_path).unlink(missing_ok=True)
256-
shutil.rmtree(temp_dir, ignore_errors=True)
224+
with tempfile.NamedTemporaryFile(mode="wb", suffix=".bin") as temp_input_file:
225+
x.tofile(temp_input_file)
226+
with tempfile.NamedTemporaryFile(mode="r", suffix=".json") as temp_output_file:
227+
cmd = [
228+
str(KHISTO_BIN_DIR),
229+
"-b",
230+
"-e",
231+
"-j",
232+
temp_input_file.name,
233+
temp_output_file.name,
234+
]
235+
try:
236+
subprocess.run(cmd, capture_output=True, text=True, check=True)
237+
except subprocess.CalledProcessError as e:
238+
stdout = e.stdout.strip()
239+
stderr = e.stderr.strip()
240+
details = "\n".join(part for part in (stdout, stderr) if part)
241+
message = _format_runtime_error(
242+
f"khisto failed with exit code {e.returncode}",
243+
cmd,
244+
details or None,
245+
)
246+
logger.error(message)
247+
raise RuntimeError(message) from e
248+
except OSError as e:
249+
message = _format_runtime_error(
250+
"khisto could not be started",
251+
cmd,
252+
str(e),
253+
)
254+
logger.error(message)
255+
raise RuntimeError(message) from e
256+
257+
try:
258+
return _process_histogram_file(temp_output_file)
259+
except json.JSONDecodeError as e:
260+
message = _format_runtime_error(
261+
"khisto produced invalid JSON output",
262+
cmd,
263+
str(e),
264+
)
265+
logger.error(message)
266+
raise RuntimeError(message) from e
267+
except (AttributeError, IndexError, TypeError, ValueError) as e:
268+
message = _format_runtime_error(
269+
"khisto produced an invalid histogram payload",
270+
cmd,
271+
str(e),
272+
)
273+
logger.error(message)
274+
raise RuntimeError(message) from e

tests/core/test_backend.py

Lines changed: 39 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@
1313

1414
from khisto.core import compute_histograms
1515
from khisto.core.backend import (
16-
_parse_file_type,
17-
_process_histogram_files,
16+
_process_histogram_file,
1817
HistogramResult,
1918
)
2019

@@ -40,29 +39,6 @@ def _histogram_payload(
4039
}
4140

4241

43-
class TestParseFileType:
44-
"""Tests for _parse_file_type function."""
45-
46-
def test_series_json_file(self):
47-
"""Test validation of histogram.series.json file."""
48-
_parse_file_type(pathlib.Path("histogram.series.json"))
49-
50-
def test_binary_series_json_file(self):
51-
"""Test validation of histogram.series.bin.json file."""
52-
_parse_file_type(pathlib.Path("gaussian_histogram.series.bin.json"))
53-
54-
def test_invalid_file_name(self):
55-
"""Test that invalid file names raise ValueError."""
56-
with pytest.raises(ValueError, match="Unrecognized histogram file name"):
57-
_parse_file_type(pathlib.Path("histogram.json"))
58-
59-
with pytest.raises(ValueError, match="Unrecognized histogram file name"):
60-
_parse_file_type(pathlib.Path("histogram.csv"))
61-
62-
with pytest.raises(ValueError, match="Unrecognized histogram file name"):
63-
_parse_file_type(pathlib.Path("histogram.series.csv"))
64-
65-
6642
class TestHistogramResult:
6743
"""Tests for HistogramResult dataclass."""
6844

@@ -161,6 +137,37 @@ def fail_run(*args, **kwargs):
161137
assert "khisto stderr message" in message
162138
assert "-j" in message
163139

140+
def test_compute_histograms_reports_cli_launch_failure(self, monkeypatch):
141+
"""Test that khisto launch failures raise a descriptive runtime error."""
142+
143+
def fail_run(*args, **kwargs):
144+
raise FileNotFoundError("[Errno 2] No such file or directory: 'khisto'")
145+
146+
monkeypatch.setattr("khisto.core.backend.subprocess.run", fail_run)
147+
148+
with pytest.raises(RuntimeError, match="could not be started") as excinfo:
149+
compute_histograms(np.array([0.0, 1.0]))
150+
151+
message = str(excinfo.value)
152+
assert "No such file or directory" in message
153+
assert "-j" in message
154+
155+
def test_compute_histograms_reports_invalid_json_output(self, monkeypatch):
156+
"""Test that malformed JSON output raises a descriptive runtime error."""
157+
158+
def fake_run(cmd, **kwargs):
159+
output_path = pathlib.Path(cmd[-1])
160+
output_path.write_text("{not json", encoding="utf-8")
161+
162+
monkeypatch.setattr("khisto.core.backend.subprocess.run", fake_run)
163+
164+
with pytest.raises(RuntimeError, match="invalid JSON output") as excinfo:
165+
compute_histograms(np.array([0.0, 1.0]))
166+
167+
message = str(excinfo.value)
168+
assert "Expecting property name enclosed in double quotes" in message
169+
assert "-j" in message
170+
164171
def test_compute_histograms_writes_binary_input(self, monkeypatch):
165172
"""Test that compute_histograms writes binary input and requests JSON output."""
166173

@@ -178,15 +185,15 @@ def test_compute_histograms_writes_binary_input(self, monkeypatch):
178185
captured_values = None
179186

180187
monkeypatch.setattr(
181-
"khisto.core.backend._process_histogram_files",
182-
lambda temp_dir, base_name: expected_result,
188+
"khisto.core.backend._process_histogram_file",
189+
lambda temp_output_file: expected_result,
183190
)
184191

185192
def fake_run(cmd, **kwargs):
186193
nonlocal captured_values
187194
assert "-b" in cmd
188195
assert "-j" in cmd
189-
assert cmd[-1].endswith("histogram.series.json")
196+
assert cmd[-1].endswith(".json")
190197
input_path = pathlib.Path(cmd[-2])
191198
captured_values = np.fromfile(input_path, dtype=np.float64)
192199

@@ -233,7 +240,8 @@ def test_series_selects_finest_interpretable_histogram(self, tmp_path):
233240
}
234241
self._write_json(tmp_path / "histogram.series.json", payload)
235242

236-
results = _process_histogram_files(str(tmp_path), "histogram.series")
243+
with (tmp_path / "histogram.series.json").open("r", encoding="utf-8") as stream:
244+
results = _process_histogram_file(stream)
237245

238246
assert len(results) == 3
239247
assert [result.granularity for result in results] == [0, 2, 3]
@@ -270,7 +278,8 @@ def test_series_keeps_finest_histogram_when_all_interpretable(self, tmp_path):
270278
}
271279
self._write_json(tmp_path / "histogram.series.json", payload)
272280

273-
results = _process_histogram_files(str(tmp_path), "histogram.series")
281+
with (tmp_path / "histogram.series.json").open("r", encoding="utf-8") as stream:
282+
results = _process_histogram_file(stream)
274283

275284
assert len(results) == 2
276285
assert [result.is_best for result in results] == [False, True]

0 commit comments

Comments
 (0)