symtest-cli 1.3.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.
- symtest/__init__.py +45 -0
- symtest/cli.py +549 -0
- symtest/commands/__init__.py +9 -0
- symtest/commands/compare.py +221 -0
- symtest/config/__init__.py +7 -0
- symtest/config/config_io.py +346 -0
- symtest/config/config_schema.py +330 -0
- symtest/config/import_expander.py +149 -0
- symtest/config/inheritance_expander.py +197 -0
- symtest/core/__init__.py +15 -0
- symtest/core/assertions.py +253 -0
- symtest/core/base_runner.py +299 -0
- symtest/core/config_loader.py +536 -0
- symtest/core/execution.py +498 -0
- symtest/core/history_store.py +96 -0
- symtest/core/last_run_store.py +109 -0
- symtest/core/parallel_runner.py +251 -0
- symtest/core/process_worker.py +93 -0
- symtest/core/sequence_state.py +143 -0
- symtest/core/setup.py +137 -0
- symtest/core/test_case.py +76 -0
- symtest/core/types.py +92 -0
- symtest/file_comparator/__init__.py +10 -0
- symtest/file_comparator/base_comparator.py +109 -0
- symtest/file_comparator/binary_comparator.py +399 -0
- symtest/file_comparator/csv_comparator.py +241 -0
- symtest/file_comparator/factory.py +191 -0
- symtest/file_comparator/h5_comparator.py +777 -0
- symtest/file_comparator/json_comparator.py +323 -0
- symtest/file_comparator/result.py +213 -0
- symtest/file_comparator/script_comparator.py +182 -0
- symtest/file_comparator/text_comparator.py +182 -0
- symtest/file_comparator/xml_comparator.py +150 -0
- symtest/logging_config.py +66 -0
- symtest/runners/__init__.py +15 -0
- symtest/runners/config_runner.py +96 -0
- symtest/runners/json_runner.py +21 -0
- symtest/runners/parallel_config_runner.py +278 -0
- symtest/runners/parallel_json_runner.py +26 -0
- symtest/runners/parallel_yaml_runner.py +31 -0
- symtest/runners/yaml_runner.py +26 -0
- symtest/tui/__init__.py +11 -0
- symtest/tui/app.py +90 -0
- symtest/tui/controllers/__init__.py +0 -0
- symtest/tui/controllers/case_controller.py +322 -0
- symtest/tui/screens/__init__.py +0 -0
- symtest/tui/screens/case_editor.py +244 -0
- symtest/tui/screens/case_list.py +255 -0
- symtest/tui/widgets/__init__.py +0 -0
- symtest/tui/widgets/case_table.py +113 -0
- symtest/tui/widgets/expected_editor.py +159 -0
- symtest/tui/widgets/search_bar.py +160 -0
- symtest/tui/widgets/steps_editor.py +243 -0
- symtest/utils/__init__.py +21 -0
- symtest/utils/junit_xml_writer.py +137 -0
- symtest/utils/path_resolver.py +124 -0
- symtest/utils/report_generator.py +208 -0
- symtest_cli-1.3.0.dist-info/METADATA +316 -0
- symtest_cli-1.3.0.dist-info/RECORD +63 -0
- symtest_cli-1.3.0.dist-info/WHEEL +5 -0
- symtest_cli-1.3.0.dist-info/entry_points.txt +4 -0
- symtest_cli-1.3.0.dist-info/licenses/LICENSE +21 -0
- symtest_cli-1.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
@file compare.py
|
|
6
|
+
@brief Command for comparing files in symtest
|
|
7
|
+
@author Xiaotong Wang
|
|
8
|
+
@date 2024
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
import os
|
|
13
|
+
import argparse
|
|
14
|
+
import logging
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from ..file_comparator.factory import ComparatorFactory
|
|
17
|
+
from ..file_comparator.result import ComparisonResult
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("symtest.commands.compare")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_arguments():
|
|
23
|
+
"""Parse command line arguments"""
|
|
24
|
+
parser = argparse.ArgumentParser(description="Compare two files.")
|
|
25
|
+
parser.add_argument("file1", help="Path to the first file")
|
|
26
|
+
parser.add_argument("file2", help="Path to the second file")
|
|
27
|
+
parser.add_argument("--start-line", type=int, default=1, help="Starting line number (1-based)")
|
|
28
|
+
parser.add_argument("--end-line", type=int, help="Ending line number (1-based)")
|
|
29
|
+
parser.add_argument("--start-column", type=int, default=1, help="Starting column number (1-based)")
|
|
30
|
+
parser.add_argument("--end-column", type=int, help="Ending column number (1-based)")
|
|
31
|
+
parser.add_argument("--file-type", help="Type of the files to compare", default="auto")
|
|
32
|
+
parser.add_argument("--encoding", default="utf-8", help="File encoding for text files")
|
|
33
|
+
parser.add_argument("--chunk-size", type=int, default=8192, help="Chunk size for binary comparison")
|
|
34
|
+
parser.add_argument("--output-format", choices=["text", "json", "html"], default="text",
|
|
35
|
+
help="Output format for the comparison result")
|
|
36
|
+
parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose output")
|
|
37
|
+
parser.add_argument("--debug", action="store_true", help="Enable debug mode with detailed logging")
|
|
38
|
+
parser.add_argument("--similarity", action="store_true",
|
|
39
|
+
help="When comparing binary files, compute and show similarity index")
|
|
40
|
+
parser.add_argument("--num-threads", type=int, default=4, help="Number of threads for parallel processing")
|
|
41
|
+
|
|
42
|
+
# CSV comparison options
|
|
43
|
+
csv_group = parser.add_argument_group('CSV comparison options')
|
|
44
|
+
csv_group.add_argument("--csv-rtol", type=float, default=1e-5,
|
|
45
|
+
help="Relative tolerance for numerical comparison in CSV files")
|
|
46
|
+
csv_group.add_argument("--csv-atol", type=float, default=1e-8,
|
|
47
|
+
help="Absolute tolerance for numerical comparison in CSV files")
|
|
48
|
+
csv_group.add_argument("--csv-delimiter", default=",",
|
|
49
|
+
help="CSV field delimiter (default: comma)")
|
|
50
|
+
csv_group.add_argument("--csv-quotechar", default='"',
|
|
51
|
+
help="Character used for quoting fields in CSV (default: double quote)")
|
|
52
|
+
csv_group.add_argument("--csv-data-filter", type=str,
|
|
53
|
+
help="Data filter to apply before comparison. "
|
|
54
|
+
"Example: '>1e-6', '<=0.01', 'abs>1e-9'. "
|
|
55
|
+
"Filters out numeric cells that do not meet the criteria "
|
|
56
|
+
"from BOTH files before comparison.")
|
|
57
|
+
|
|
58
|
+
# JSON comparison options
|
|
59
|
+
json_group = parser.add_argument_group('JSON comparison options')
|
|
60
|
+
json_group.add_argument("--json-compare-mode", choices=["exact", "key-based"], default="exact",
|
|
61
|
+
help="JSON comparison mode: exact (default) or key-based")
|
|
62
|
+
json_group.add_argument("--json-key-field", help="Key field(s) to use for key-based JSON comparison")
|
|
63
|
+
|
|
64
|
+
# H5 comparison options
|
|
65
|
+
h5_group = parser.add_argument_group('HDF5 comparison options')
|
|
66
|
+
h5_group.add_argument("--h5-table", help="Comma-separated list of table names to compare in HDF5 files")
|
|
67
|
+
h5_group.add_argument("--h5-table-regex", help="Comma-separated list of regular expression patterns to match table names in HDF5 files. Each pattern is matched independently.")
|
|
68
|
+
h5_group.add_argument("--h5-structure-only", action="store_true",
|
|
69
|
+
help="Only compare HDF5 file structure without comparing content")
|
|
70
|
+
h5_group.add_argument("--h5-show-content-diff", action="store_true",
|
|
71
|
+
help="Show detailed content differences when content differs")
|
|
72
|
+
h5_group.add_argument("--h5-rtol", type=float, default=1e-5,
|
|
73
|
+
help="Relative tolerance for numerical comparison in HDF5 files")
|
|
74
|
+
h5_group.add_argument("--h5-atol", type=float, default=1e-8,
|
|
75
|
+
help="Absolute tolerance for numerical comparison in HDF5 files")
|
|
76
|
+
h5_group.add_argument("--h5-data-filter", type=str,
|
|
77
|
+
help="Data filter to apply before comparison. "
|
|
78
|
+
"Example: '>1e-6', '<=0.01', 'abs>1e-9'. "
|
|
79
|
+
"Filters out data that does not meet the criteria from BOTH files before comparison.")
|
|
80
|
+
h5_group.add_argument("--h5-no-expand-path", dest="h5_expand_path", action="store_false",
|
|
81
|
+
help="Do not expand HDF5 group paths to compare all sub-items.")
|
|
82
|
+
|
|
83
|
+
return parser.parse_args()
|
|
84
|
+
|
|
85
|
+
def detect_file_type(file_path):
|
|
86
|
+
"""Detect the type of file based on its extension"""
|
|
87
|
+
ext = Path(file_path).suffix.lower()
|
|
88
|
+
if ext == '.json':
|
|
89
|
+
return 'json'
|
|
90
|
+
elif ext == '.xml':
|
|
91
|
+
return 'xml'
|
|
92
|
+
elif ext in ['.h5', '.hdf5']:
|
|
93
|
+
return 'h5'
|
|
94
|
+
elif ext == '.csv':
|
|
95
|
+
return 'csv'
|
|
96
|
+
elif ext in ['.txt', '.py', '.md', '.html', '.css', '.js']:
|
|
97
|
+
return 'text'
|
|
98
|
+
else:
|
|
99
|
+
return 'binary'
|
|
100
|
+
|
|
101
|
+
def format_result(result, output_format):
|
|
102
|
+
"""Format the comparison result according to the specified output format"""
|
|
103
|
+
if output_format == "json":
|
|
104
|
+
return result.to_json()
|
|
105
|
+
elif output_format == "html":
|
|
106
|
+
return result.to_html()
|
|
107
|
+
else:
|
|
108
|
+
return str(result)
|
|
109
|
+
|
|
110
|
+
def run_comparison(args, logger=None):
|
|
111
|
+
"""Execute file comparison from a parsed arguments namespace.
|
|
112
|
+
|
|
113
|
+
This function can be called from both the standalone compare-files entry point
|
|
114
|
+
and the symtest compare subcommand.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
int: Exit code (0 if files are identical, 1 otherwise)
|
|
118
|
+
"""
|
|
119
|
+
if logger is None:
|
|
120
|
+
logger = logging.getLogger("symtest.commands.compare")
|
|
121
|
+
|
|
122
|
+
if args.debug:
|
|
123
|
+
logger.setLevel(logging.DEBUG)
|
|
124
|
+
logger.debug("Debug mode enabled")
|
|
125
|
+
|
|
126
|
+
# Adjust for 0-based indexing
|
|
127
|
+
start_line = max(0, args.start_line - 1)
|
|
128
|
+
end_line = None if args.end_line is None else max(0, args.end_line - 1)
|
|
129
|
+
start_column = max(0, args.start_column - 1)
|
|
130
|
+
end_column = None if args.end_column is None else max(0, args.end_column - 1)
|
|
131
|
+
|
|
132
|
+
# Resolve file paths
|
|
133
|
+
file1_path = Path(args.file1).resolve()
|
|
134
|
+
file2_path = Path(args.file2).resolve()
|
|
135
|
+
|
|
136
|
+
if not file1_path.exists():
|
|
137
|
+
raise ValueError(f"File not found: {file1_path}")
|
|
138
|
+
if not file2_path.exists():
|
|
139
|
+
raise ValueError(f"File not found: {file2_path}")
|
|
140
|
+
|
|
141
|
+
# Determine file type
|
|
142
|
+
file_type = args.file_type
|
|
143
|
+
if file_type == "auto":
|
|
144
|
+
file_type = detect_file_type(file1_path)
|
|
145
|
+
logger.info(f"Auto-detected file type: {file_type}")
|
|
146
|
+
|
|
147
|
+
# Prepare comparator kwargs
|
|
148
|
+
comparator_kwargs = {
|
|
149
|
+
"encoding": args.encoding,
|
|
150
|
+
"chunk_size": args.chunk_size,
|
|
151
|
+
"verbose": args.verbose or args.debug,
|
|
152
|
+
"num_threads": args.num_threads
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
# Add file type specific arguments
|
|
156
|
+
if file_type == "json":
|
|
157
|
+
comparator_kwargs["compare_mode"] = args.json_compare_mode
|
|
158
|
+
if args.json_key_field:
|
|
159
|
+
key_fields = [field.strip() for field in args.json_key_field.split(',')]
|
|
160
|
+
comparator_kwargs["key_field"] = key_fields[0] if len(key_fields) == 1 else key_fields
|
|
161
|
+
|
|
162
|
+
if file_type == "csv":
|
|
163
|
+
comparator_kwargs["rtol"] = args.csv_rtol
|
|
164
|
+
comparator_kwargs["atol"] = args.csv_atol
|
|
165
|
+
comparator_kwargs["delimiter"] = args.csv_delimiter
|
|
166
|
+
comparator_kwargs["quotechar"] = args.csv_quotechar
|
|
167
|
+
if args.csv_data_filter:
|
|
168
|
+
comparator_kwargs["data_filter"] = args.csv_data_filter
|
|
169
|
+
|
|
170
|
+
if file_type == "h5":
|
|
171
|
+
if args.h5_table:
|
|
172
|
+
tables = [table.strip() for table in args.h5_table.split(',')]
|
|
173
|
+
comparator_kwargs["tables"] = tables
|
|
174
|
+
if args.h5_table_regex:
|
|
175
|
+
comparator_kwargs["table_regex"] = args.h5_table_regex
|
|
176
|
+
comparator_kwargs["structure_only"] = args.h5_structure_only
|
|
177
|
+
comparator_kwargs["show_content_diff"] = args.h5_show_content_diff
|
|
178
|
+
comparator_kwargs["rtol"] = args.h5_rtol
|
|
179
|
+
comparator_kwargs["atol"] = args.h5_atol
|
|
180
|
+
if args.h5_data_filter:
|
|
181
|
+
comparator_kwargs["data_filter"] = args.h5_data_filter
|
|
182
|
+
comparator_kwargs["expand_path"] = args.h5_expand_path
|
|
183
|
+
|
|
184
|
+
if file_type == "binary":
|
|
185
|
+
comparator_kwargs["similarity"] = args.similarity
|
|
186
|
+
|
|
187
|
+
# Create comparator and perform comparison
|
|
188
|
+
comparator = ComparatorFactory.create_comparator(file_type, **comparator_kwargs)
|
|
189
|
+
result = comparator.compare_files(
|
|
190
|
+
file1_path,
|
|
191
|
+
file2_path,
|
|
192
|
+
start_line,
|
|
193
|
+
end_line,
|
|
194
|
+
start_column,
|
|
195
|
+
end_column
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Output result
|
|
199
|
+
output = format_result(result, args.output_format)
|
|
200
|
+
print(output)
|
|
201
|
+
|
|
202
|
+
return 0 if result.identical else 1
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def main():
|
|
206
|
+
"""Main entry point for the compare-files command"""
|
|
207
|
+
try:
|
|
208
|
+
args = parse_arguments()
|
|
209
|
+
exit_code = run_comparison(args)
|
|
210
|
+
sys.exit(exit_code)
|
|
211
|
+
|
|
212
|
+
except ValueError as ve:
|
|
213
|
+
logger.error(f"ValueError: {ve}")
|
|
214
|
+
sys.exit(1)
|
|
215
|
+
except Exception as e:
|
|
216
|
+
logger.exception(f"An unexpected error occurred")
|
|
217
|
+
sys.exit(1)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if __name__ == "__main__":
|
|
221
|
+
main()
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unified configuration I/O with import-expansion support.
|
|
3
|
+
|
|
4
|
+
Provides ``load_config`` and ``save_config`` as the canonical read/write
|
|
5
|
+
entry points for test configuration files, with transparent import expansion.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import shutil
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, List, Optional, Union
|
|
15
|
+
|
|
16
|
+
from .import_expander import expand_imports, _load_raw_config
|
|
17
|
+
from .inheritance_expander import resolve_inheritance
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("symtest.config.config_io")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Public API
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
def load_config(
|
|
27
|
+
config_file_path: Union[str, Path],
|
|
28
|
+
*,
|
|
29
|
+
expand: bool = True,
|
|
30
|
+
) -> Dict[str, Any]:
|
|
31
|
+
"""Load and (optionally) expand import references in a config file.
|
|
32
|
+
|
|
33
|
+
Parameters
|
|
34
|
+
----------
|
|
35
|
+
config_file_path:
|
|
36
|
+
Path to the config file (JSON or YAML).
|
|
37
|
+
expand:
|
|
38
|
+
If ``True`` (default), recursively expand ``import`` references.
|
|
39
|
+
|
|
40
|
+
Returns
|
|
41
|
+
-------
|
|
42
|
+
A config dict with ``test_cases`` (and optionally ``setup``).
|
|
43
|
+
"""
|
|
44
|
+
path = Path(config_file_path).resolve()
|
|
45
|
+
|
|
46
|
+
if not path.exists():
|
|
47
|
+
raise FileNotFoundError(f"Config file not found: {path}")
|
|
48
|
+
|
|
49
|
+
config = _load_raw_config(path)
|
|
50
|
+
|
|
51
|
+
if expand:
|
|
52
|
+
config = expand_imports(config, path)
|
|
53
|
+
config = resolve_inheritance(config)
|
|
54
|
+
|
|
55
|
+
return config
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def save_config(
|
|
59
|
+
config: Dict[str, Any],
|
|
60
|
+
file_path: Union[str, Path],
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Save a config dict to file, choosing the format by extension.
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
config:
|
|
67
|
+
Config dict (must contain at least ``test_cases``).
|
|
68
|
+
file_path:
|
|
69
|
+
Output path; ``.json`` → JSON, ``.yaml``/``.yml`` → YAML.
|
|
70
|
+
"""
|
|
71
|
+
path = Path(file_path)
|
|
72
|
+
ext = path.suffix.lower()
|
|
73
|
+
|
|
74
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
|
|
76
|
+
if ext == ".json":
|
|
77
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
78
|
+
json.dump(config, f, indent=2, ensure_ascii=False)
|
|
79
|
+
elif ext in (".yaml", ".yml"):
|
|
80
|
+
import yaml
|
|
81
|
+
|
|
82
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
83
|
+
yaml.dump(config, f, default_flow_style=False, allow_unicode=True)
|
|
84
|
+
else:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"Unsupported output format: {ext} (expected .json, .yaml, or .yml)"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
logger.info("Config saved to: %s", path)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# Validation
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def _validate_required_fields(test_case: Dict[str, Any], index: int, source: str) -> List[str]:
|
|
97
|
+
"""Check required fields for a single test case dict. Returns list of error messages."""
|
|
98
|
+
errors: List[str] = []
|
|
99
|
+
prefix = f"[{source}] case #{index}"
|
|
100
|
+
|
|
101
|
+
name = test_case.get("name", "<unnamed>")
|
|
102
|
+
prefix = f"[{source}] case '{name}'"
|
|
103
|
+
|
|
104
|
+
if "steps" in test_case:
|
|
105
|
+
# Sequence mode
|
|
106
|
+
for si, step in enumerate(test_case["steps"]):
|
|
107
|
+
for field in ("command", "args", "expected"):
|
|
108
|
+
if field not in step:
|
|
109
|
+
errors.append(f"{prefix} step {si}: missing required field '{field}'")
|
|
110
|
+
else:
|
|
111
|
+
# Single-command mode
|
|
112
|
+
for field in ("name", "command", "args", "expected"):
|
|
113
|
+
if field not in test_case:
|
|
114
|
+
errors.append(f"{prefix}: missing required field '{field}'")
|
|
115
|
+
|
|
116
|
+
return errors
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Warning-level checks (do not affect ``valid``)
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def _command_warning(command: Any, workspace: Path) -> Optional[str]:
|
|
124
|
+
"""Return a warning message if a command is clearly not executable.
|
|
125
|
+
|
|
126
|
+
Heuristic only — shell builtins, ``{placeholder}`` commands, and anything
|
|
127
|
+
resolvable via PATH pass silently.
|
|
128
|
+
"""
|
|
129
|
+
if not isinstance(command, str) or not command.strip():
|
|
130
|
+
return "empty command"
|
|
131
|
+
|
|
132
|
+
from ..core.execution import _SHELL_BUILTINS
|
|
133
|
+
|
|
134
|
+
first = command.strip().split()[0]
|
|
135
|
+
if "{" in first or "}" in first:
|
|
136
|
+
return None # variable placeholder; cannot resolve statically
|
|
137
|
+
if first.lower() in _SHELL_BUILTINS:
|
|
138
|
+
return None
|
|
139
|
+
if Path(first).is_absolute() or "/" in first or "\\" in first:
|
|
140
|
+
# Path-like command: resolve relative to the workspace
|
|
141
|
+
candidate = Path(first)
|
|
142
|
+
if not candidate.is_absolute():
|
|
143
|
+
candidate = workspace / candidate
|
|
144
|
+
if not candidate.exists():
|
|
145
|
+
return f"command not found: '{first}'"
|
|
146
|
+
return None
|
|
147
|
+
if shutil.which(first) is None:
|
|
148
|
+
return f"command not found on PATH: '{first}'"
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _baseline_warnings(expected: Any, prefix: str, workspace: Path) -> List[str]:
|
|
153
|
+
"""Warn when a compare_files baseline file does not exist.
|
|
154
|
+
|
|
155
|
+
``actual`` files are produced by the test command, so only ``baseline``
|
|
156
|
+
is checked. Placeholders (``{var}``) are skipped.
|
|
157
|
+
"""
|
|
158
|
+
warnings: List[str] = []
|
|
159
|
+
if not isinstance(expected, dict):
|
|
160
|
+
return warnings
|
|
161
|
+
compare_specs = expected.get("compare_files") or []
|
|
162
|
+
for spec in compare_specs:
|
|
163
|
+
if not isinstance(spec, dict):
|
|
164
|
+
continue
|
|
165
|
+
baseline = spec.get("baseline")
|
|
166
|
+
if not baseline or not isinstance(baseline, str) or "{" in baseline:
|
|
167
|
+
continue
|
|
168
|
+
candidate = Path(baseline)
|
|
169
|
+
if not candidate.is_absolute():
|
|
170
|
+
candidate = workspace / candidate
|
|
171
|
+
if not candidate.exists():
|
|
172
|
+
warnings.append(f"{prefix}: baseline file not found: '{baseline}'")
|
|
173
|
+
return warnings
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _validate_case_warnings(
|
|
177
|
+
test_case: Dict[str, Any], source: str, workspace: Path,
|
|
178
|
+
) -> List[str]:
|
|
179
|
+
"""Warning-level checks for a single test case dict."""
|
|
180
|
+
warnings: List[str] = []
|
|
181
|
+
name = test_case.get("name", "<unnamed>")
|
|
182
|
+
prefix = f"[{source}] case '{name}'"
|
|
183
|
+
|
|
184
|
+
if "steps" in test_case:
|
|
185
|
+
for si, step in enumerate(test_case.get("steps", [])):
|
|
186
|
+
if not isinstance(step, dict):
|
|
187
|
+
continue
|
|
188
|
+
step_prefix = f"{prefix} step {si}"
|
|
189
|
+
cmd_warn = _command_warning(step.get("command"), workspace)
|
|
190
|
+
if cmd_warn:
|
|
191
|
+
warnings.append(f"{step_prefix}: {cmd_warn}")
|
|
192
|
+
warnings.extend(
|
|
193
|
+
_baseline_warnings(step.get("expected"), step_prefix, workspace)
|
|
194
|
+
)
|
|
195
|
+
warnings.extend(
|
|
196
|
+
_baseline_warnings(test_case.get("expected"), prefix, workspace)
|
|
197
|
+
)
|
|
198
|
+
else:
|
|
199
|
+
cmd_warn = _command_warning(test_case.get("command"), workspace)
|
|
200
|
+
if cmd_warn:
|
|
201
|
+
warnings.append(f"{prefix}: {cmd_warn}")
|
|
202
|
+
warnings.extend(
|
|
203
|
+
_baseline_warnings(test_case.get("expected"), prefix, workspace)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
return warnings
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def validate_config(
|
|
210
|
+
config_file_path: Union[str, Path],
|
|
211
|
+
workspace: Optional[str] = None,
|
|
212
|
+
) -> Dict[str, Any]:
|
|
213
|
+
"""Validate a test config file (with import expansion) without running tests.
|
|
214
|
+
|
|
215
|
+
Checks (errors — affect ``valid``):
|
|
216
|
+
- JSON/YAML syntax correctness (implicit from successful load)
|
|
217
|
+
- Required fields on every test case
|
|
218
|
+
- Import target existence
|
|
219
|
+
- Circular import detection
|
|
220
|
+
- extends target existence
|
|
221
|
+
- Circular extends inheritance detection
|
|
222
|
+
|
|
223
|
+
Warning-level checks (reported in ``warnings``, do not affect ``valid``):
|
|
224
|
+
- ``command`` executability (PATH lookup / path existence)
|
|
225
|
+
- ``compare_files`` baseline file existence
|
|
226
|
+
|
|
227
|
+
Returns a dict with keys ``valid`` (bool), ``errors`` (list),
|
|
228
|
+
``warnings`` (list), and ``summary`` (dict with file/case counts).
|
|
229
|
+
"""
|
|
230
|
+
path = Path(config_file_path).resolve()
|
|
231
|
+
errors: List[str] = []
|
|
232
|
+
warnings: List[str] = []
|
|
233
|
+
files_loaded: List[str] = []
|
|
234
|
+
total_cases = 0
|
|
235
|
+
ws = Path(workspace).resolve() if workspace else Path.cwd()
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
config = _load_raw_config(path)
|
|
239
|
+
except Exception as exc:
|
|
240
|
+
return {
|
|
241
|
+
"valid": False,
|
|
242
|
+
"errors": [f"Syntax error in {path}: {exc}"],
|
|
243
|
+
"warnings": [],
|
|
244
|
+
"summary": {"files": 0, "cases": 0},
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
# Collect all files and cases (walk the import tree)
|
|
248
|
+
all_cases: List[tuple] = [] # (case_dict, source_path)
|
|
249
|
+
|
|
250
|
+
def _walk(current_path: Path, current_config: Dict[str, Any], visited: set) -> None:
|
|
251
|
+
nonlocal total_cases
|
|
252
|
+
canonical = str(current_path.resolve())
|
|
253
|
+
if canonical in visited:
|
|
254
|
+
errors.append(f"Circular import detected: {canonical}")
|
|
255
|
+
return
|
|
256
|
+
visited.add(canonical)
|
|
257
|
+
files_loaded.append(str(current_path))
|
|
258
|
+
|
|
259
|
+
for idx, item in enumerate(current_config.get("test_cases", [])):
|
|
260
|
+
if "import" in item:
|
|
261
|
+
sub_path = (current_path.parent / item["import"]).resolve()
|
|
262
|
+
if not sub_path.exists():
|
|
263
|
+
errors.append(
|
|
264
|
+
f"Import target not found: {sub_path} "
|
|
265
|
+
f"(referenced by {current_path})"
|
|
266
|
+
)
|
|
267
|
+
continue
|
|
268
|
+
try:
|
|
269
|
+
sub_config = _load_raw_config(sub_path)
|
|
270
|
+
_walk(sub_path, sub_config, visited)
|
|
271
|
+
except Exception as exc:
|
|
272
|
+
errors.append(f"Error loading {sub_path}: {exc}")
|
|
273
|
+
else:
|
|
274
|
+
is_abstract = item.get("abstract", False)
|
|
275
|
+
has_extends = "extends" in item
|
|
276
|
+
source = str(current_path)
|
|
277
|
+
|
|
278
|
+
all_cases.append((item, source))
|
|
279
|
+
|
|
280
|
+
if is_abstract:
|
|
281
|
+
# Abstract cases are templates — skip counting and field validation
|
|
282
|
+
continue
|
|
283
|
+
|
|
284
|
+
total_cases += 1
|
|
285
|
+
# extends cases inherit fields from parent; skip required-field checks
|
|
286
|
+
if not has_extends:
|
|
287
|
+
errors.extend(_validate_required_fields(item, idx, source))
|
|
288
|
+
warnings.extend(_validate_case_warnings(item, source, ws))
|
|
289
|
+
|
|
290
|
+
_walk(path, config, set())
|
|
291
|
+
|
|
292
|
+
# ── Inheritance validation (extends targets + cycle detection) ──
|
|
293
|
+
def _validate_inheritance() -> None:
|
|
294
|
+
"""Check extends target existence and circular inheritance chains."""
|
|
295
|
+
names: Dict[str, Dict[str, Any]] = {}
|
|
296
|
+
for case_dict, source in all_cases:
|
|
297
|
+
name = case_dict.get("name")
|
|
298
|
+
if name:
|
|
299
|
+
if name in names:
|
|
300
|
+
# Duplicate names are ambiguous for extends; flag as error
|
|
301
|
+
errors.append(
|
|
302
|
+
f"Duplicate case name '{name}' "
|
|
303
|
+
f"(found in multiple config files; "
|
|
304
|
+
f"extends references must be unambiguous)"
|
|
305
|
+
)
|
|
306
|
+
names[name] = case_dict
|
|
307
|
+
|
|
308
|
+
for case_dict, source in all_cases:
|
|
309
|
+
extends_target = case_dict.get("extends")
|
|
310
|
+
if not extends_target:
|
|
311
|
+
continue
|
|
312
|
+
case_name = case_dict.get("name", "<unnamed>")
|
|
313
|
+
if extends_target not in names:
|
|
314
|
+
errors.append(
|
|
315
|
+
f"[{source}] case '{case_name}': "
|
|
316
|
+
f"extends target '{extends_target}' not found"
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# Cycle detection: follow extends chain from each case
|
|
320
|
+
for name, case_dict in names.items():
|
|
321
|
+
visited_chain: List[str] = []
|
|
322
|
+
current = name
|
|
323
|
+
while current in names:
|
|
324
|
+
target = names[current].get("extends")
|
|
325
|
+
if not target:
|
|
326
|
+
break
|
|
327
|
+
if target in visited_chain:
|
|
328
|
+
chain = " -> ".join(visited_chain + [target])
|
|
329
|
+
errors.append(f"Circular extends detected: {chain}")
|
|
330
|
+
break
|
|
331
|
+
visited_chain.append(current)
|
|
332
|
+
current = target
|
|
333
|
+
|
|
334
|
+
_validate_inheritance()
|
|
335
|
+
|
|
336
|
+
valid = len(errors) == 0
|
|
337
|
+
return {
|
|
338
|
+
"valid": valid,
|
|
339
|
+
"errors": errors,
|
|
340
|
+
"warnings": warnings,
|
|
341
|
+
"summary": {
|
|
342
|
+
"files": len(files_loaded),
|
|
343
|
+
"cases": total_cases,
|
|
344
|
+
"files_loaded": files_loaded,
|
|
345
|
+
},
|
|
346
|
+
}
|