Skip to content

Commit fd8dbdc

Browse files
rquiduteclaude
andcommitted
refactor(cli): extract shared parse_and_validate_tc_params_file helper
Address Gemini code review comments on PR #96: 1. Eliminate duplicate file I/O and validation logic between validate_tc_params_file (validation.py) and load_tc_params_mapping (utils.py). Both functions were independently opening, parsing, and structurally validating the same mapping file. 2. Extract parse_and_validate_tc_params_file() into validation.py as the single source of truth for mapping-file I/O and structural checks. - validate_tc_params_file() now delegates to it and returns the resolved Path. - load_tc_params_mapping() imports and calls it instead of repeating the try/except file-reading block. 3. Add 7 tests for parse_and_validate_tc_params_file in test_tc_params_mapping.py, including a cross-function test that proves both callers produce identical error messages for the same bad file. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a8ee4be commit fd8dbdc

3 files changed

Lines changed: 115 additions & 45 deletions

File tree

tests/test_tc_params_mapping.py

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515
#
16-
"""Unit tests for load_tc_params_mapping (utils) and validate_tc_params_file (validation)."""
16+
"""Unit tests for load_tc_params_mapping (utils), validate_tc_params_file, and
17+
parse_and_validate_tc_params_file (validation)."""
1718

1819
import json
1920
from pathlib import Path
@@ -22,7 +23,7 @@
2223

2324
from th_cli.exceptions import CLIError
2425
from th_cli.utils import load_tc_params_mapping
25-
from th_cli.validation import validate_tc_params_file
26+
from th_cli.validation import parse_and_validate_tc_params_file, validate_tc_params_file
2627

2728

2829
# ---------------------------------------------------------------------------
@@ -36,6 +37,72 @@ def _write_mapping(tmp_path: Path, data: dict, filename: str = "mapping.json") -
3637
return p
3738

3839

40+
# ---------------------------------------------------------------------------
41+
# parse_and_validate_tc_params_file (shared helper)
42+
# ---------------------------------------------------------------------------
43+
44+
45+
@pytest.mark.unit
46+
class TestParseAndValidateTcParamsFile:
47+
"""Tests for parse_and_validate_tc_params_file — the shared parsing helper.
48+
49+
Because both validate_tc_params_file and load_tc_params_mapping delegate
50+
to this function, its error paths are exercised transitively by those
51+
tests too. These tests focus on the return value and the fact that the
52+
helper is the single source of truth.
53+
"""
54+
55+
def test_returns_parsed_dict(self, tmp_path: Path) -> None:
56+
"""Helper returns the full parsed mapping as a dict."""
57+
data = {"TC-ACE-1.1": {"int-arg": "PIXIT.ACE.EP:1"}}
58+
p = _write_mapping(tmp_path, data)
59+
result = parse_and_validate_tc_params_file(str(p))
60+
assert result == data
61+
62+
def test_empty_mapping_returns_empty_dict(self, tmp_path: Path) -> None:
63+
"""An empty JSON object {} returns an empty dict without error."""
64+
p = _write_mapping(tmp_path, {})
65+
assert parse_and_validate_tc_params_file(str(p)) == {}
66+
67+
def test_file_not_found_raises(self, tmp_path: Path) -> None:
68+
"""Missing file raises CLIError."""
69+
with pytest.raises(CLIError, match="File not found"):
70+
parse_and_validate_tc_params_file(str(tmp_path / "missing.json"))
71+
72+
def test_invalid_json_raises(self, tmp_path: Path) -> None:
73+
"""Broken JSON raises CLIError with line/column info."""
74+
p = tmp_path / "bad.json"
75+
p.write_text("{bad", encoding="utf-8")
76+
with pytest.raises(CLIError, match="Invalid JSON"):
77+
parse_and_validate_tc_params_file(str(p))
78+
79+
def test_array_root_raises(self, tmp_path: Path) -> None:
80+
"""A JSON array at root raises CLIError."""
81+
p = tmp_path / "arr.json"
82+
p.write_text("[]", encoding="utf-8")
83+
with pytest.raises(CLIError, match="Expected a JSON object at the top level"):
84+
parse_and_validate_tc_params_file(str(p))
85+
86+
def test_non_dict_entry_value_raises(self, tmp_path: Path) -> None:
87+
"""A non-dict entry value raises CLIError naming the offending TC ID."""
88+
p = _write_mapping(tmp_path, {"TC-ACE-1.1": "string-not-dict"})
89+
with pytest.raises(CLIError, match="TC-ACE-1.1"):
90+
parse_and_validate_tc_params_file(str(p))
91+
92+
def test_validate_and_load_share_same_errors(self, tmp_path: Path) -> None:
93+
"""validate_tc_params_file and load_tc_params_mapping raise the same
94+
CLIError message for the same bad file — proving they share the helper."""
95+
p = _write_mapping(tmp_path, {"TC-ACE-1.1": 42})
96+
97+
with pytest.raises(CLIError) as exc_validate:
98+
validate_tc_params_file(str(p))
99+
100+
with pytest.raises(CLIError) as exc_load:
101+
load_tc_params_mapping(str(p), ["TC-ACE-1.1"])
102+
103+
assert str(exc_validate.value) == str(exc_load.value)
104+
105+
39106
# ---------------------------------------------------------------------------
40107
# validate_tc_params_file
41108
# ---------------------------------------------------------------------------

th_cli/utils.py

Lines changed: 9 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from th_cli.colorize import colorize_dump
3030
from th_cli.config import find_git_root, get_package_root
3131
from th_cli.exceptions import CLIError, handle_file_error
32+
from th_cli.validation import parse_and_validate_tc_params_file
3233

3334
# Constants
3435
DEFAULT_FILE_ENCODING = "utf-8"
@@ -420,36 +421,15 @@ def load_tc_params_mapping(
420421
def _normalise(tc_id: str) -> str:
421422
return tc_id.replace("-", "_").replace(".", "_").upper()
422423

423-
try:
424-
with open(mapping_path, "r", encoding=DEFAULT_FILE_ENCODING) as f:
425-
raw = json.load(f)
426-
except FileNotFoundError as e:
427-
handle_file_error(e, "TC params mapping file")
428-
except json.JSONDecodeError as e:
429-
raise CLIError(
430-
f"Invalid JSON in TC params mapping file '{mapping_path}': "
431-
f"{e.msg} (line {e.lineno}, column {e.colno})"
432-
)
433-
except OSError as e:
434-
raise CLIError(f"Failed to read TC params mapping file '{mapping_path}': {e}")
424+
# Delegate file I/O and structural validation to the shared helper so the
425+
# file is never parsed twice (validate_tc_params_file already calls it
426+
# during pre-flight) and validation logic lives in one place.
427+
raw = parse_and_validate_tc_params_file(mapping_path)
435428

436-
if not isinstance(raw, dict):
437-
raise CLIError(
438-
f"Invalid TC params mapping file '{mapping_path}': "
439-
f"Expected a JSON object at the top level, got {type(raw).__name__}"
440-
)
441-
442-
# Validate that all values are dicts (params dicts), and build a
443-
# normalised-key → original-value lookup.
444-
normalised_mapping: dict[str, dict[str, Any]] = {}
445-
for key, value in raw.items():
446-
if not isinstance(value, dict):
447-
raise CLIError(
448-
f"Invalid TC params mapping file '{mapping_path}': "
449-
f"Value for TC ID '{key}' must be a JSON object (dict of parameters), "
450-
f"got {type(value).__name__}"
451-
)
452-
normalised_mapping[_normalise(key)] = value
429+
# Build a normalised-key → params lookup.
430+
normalised_mapping: dict[str, dict[str, Any]] = {
431+
_normalise(key): value for key, value in raw.items()
432+
}
453433

454434
# Match each requested test ID against the normalised mapping.
455435
merged_params: dict[str, Any] = {}

th_cli/validation.py

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -113,25 +113,20 @@ def validate_test_ids(test_ids: str) -> list[str]:
113113
return ids
114114

115115

116-
def validate_tc_params_file(file_path: str) -> Path:
117-
"""Validate that a TC params mapping file exists and has the expected structure.
116+
def parse_and_validate_tc_params_file(file_path: str) -> dict[str, dict[str, Any]]:
117+
"""Read, parse, and validate a TC params mapping file, returning its contents.
118118
119-
Performs two levels of checking:
120-
121-
1. **File-level**: path exists, is a regular file, and is readable.
122-
2. **Format-level**: the file contains valid JSON whose top-level value is
123-
a JSON object (dict), and whose values are themselves JSON objects.
124-
125-
This is a *fast pre-flight* check intended to surface obvious problems
126-
before the full run starts. Deep semantic validation (e.g. checking
127-
parameter key names or value formats) is left to
128-
``load_tc_params_mapping`` at load time.
119+
This is the single source of truth for mapping-file I/O and structural
120+
validation. Both :func:`validate_tc_params_file` (pre-flight check) and
121+
``load_tc_params_mapping`` (runtime loader) delegate here so the file is
122+
never parsed twice and validation logic lives in one place.
129123
130124
Args:
131125
file_path: Path to the JSON mapping file.
132126
133127
Returns:
134-
Resolved ``Path`` object for the validated file.
128+
The parsed mapping as a ``dict[str, dict[str, Any]]`` — TC ID keys
129+
mapping to their parameter dicts.
135130
136131
Raises:
137132
CLIError: If the file is missing, not a regular file, contains
@@ -164,7 +159,35 @@ def validate_tc_params_file(file_path: str) -> Path:
164159
f"Non-dict values found for TC IDs: {', '.join(bad_entries)}"
165160
)
166161

167-
return path
162+
return data
163+
164+
165+
def validate_tc_params_file(file_path: str) -> Path:
166+
"""Validate that a TC params mapping file exists and has the expected structure.
167+
168+
Performs two levels of checking:
169+
170+
1. **File-level**: path exists, is a regular file, and is readable.
171+
2. **Format-level**: the file contains valid JSON whose top-level value is
172+
a JSON object (dict), and whose values are themselves JSON objects.
173+
174+
This is a *fast pre-flight* check intended to surface obvious problems
175+
before the full run starts. The actual parsing is delegated to
176+
:func:`parse_and_validate_tc_params_file` so the logic is not duplicated
177+
with ``load_tc_params_mapping``.
178+
179+
Args:
180+
file_path: Path to the JSON mapping file.
181+
182+
Returns:
183+
Resolved ``Path`` object for the validated file.
184+
185+
Raises:
186+
CLIError: If the file is missing, not a regular file, contains
187+
invalid JSON, or does not match the expected top-level structure.
188+
"""
189+
parse_and_validate_tc_params_file(file_path)
190+
return Path(file_path).resolve()
168191

169192

170193
def validate_hostname(hostname: str) -> str:

0 commit comments

Comments
 (0)