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,182 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
@file script_comparator.py
|
|
6
|
+
@brief Built-in script comparator – delegates comparison to an external script
|
|
7
|
+
@author Xiaotong Wang
|
|
8
|
+
@date 2025
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import List, Optional
|
|
17
|
+
|
|
18
|
+
from .base_comparator import BaseComparator
|
|
19
|
+
from .result import ComparisonResult, Difference
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("symtest.file_comparator.script")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ScriptComparator(BaseComparator):
|
|
25
|
+
"""Run an external script as a comparison step.
|
|
26
|
+
|
|
27
|
+
The script is invoked as a subprocess. By default exit code 0 signals
|
|
28
|
+
*pass*. Optionally, ``pass_pattern`` / ``fail_pattern`` regexes can be
|
|
29
|
+
used to refine the verdict from ``stdout``.
|
|
30
|
+
|
|
31
|
+
Configuration example::
|
|
32
|
+
|
|
33
|
+
{"type": "script", "script": "analyze_xxx.py",
|
|
34
|
+
"actual": "...", "baseline": "...", "cwd": ".", "pass_pattern": "PASS",
|
|
35
|
+
"fail_pattern": "(MISMATCH|FAILED)"}
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
script: str = "",
|
|
41
|
+
cwd: Optional[str] = None,
|
|
42
|
+
args: Optional[List[str]] = None,
|
|
43
|
+
interpreter: Optional[str] = None,
|
|
44
|
+
pass_exit_code: int = 0,
|
|
45
|
+
pass_pattern: Optional[str] = None,
|
|
46
|
+
fail_pattern: Optional[str] = None,
|
|
47
|
+
timeout: int = 3600,
|
|
48
|
+
encoding: str = "utf-8",
|
|
49
|
+
**kwargs,
|
|
50
|
+
):
|
|
51
|
+
super().__init__(encoding=encoding, **kwargs)
|
|
52
|
+
self.script = script
|
|
53
|
+
self.cwd = cwd
|
|
54
|
+
self.args = args or []
|
|
55
|
+
self.interpreter = interpreter or sys.executable
|
|
56
|
+
self.pass_exit_code = pass_exit_code
|
|
57
|
+
self.pass_pattern = re.compile(pass_pattern) if pass_pattern else None
|
|
58
|
+
self.fail_pattern = re.compile(fail_pattern) if fail_pattern else None
|
|
59
|
+
self.timeout = timeout
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
# Abstract method stubs (not used by this comparator)
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
def read_content(self, file_path, **kwargs):
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
def compare_content(self, content1, content2):
|
|
68
|
+
return True, [], False
|
|
69
|
+
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
# Core comparison
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
def compare_files( # type: ignore[override]
|
|
74
|
+
self,
|
|
75
|
+
file1=None,
|
|
76
|
+
file2=None,
|
|
77
|
+
**kwargs,
|
|
78
|
+
):
|
|
79
|
+
"""Execute the external script and evaluate its output."""
|
|
80
|
+
result = ComparisonResult(
|
|
81
|
+
file1=str(file1) if file1 else "",
|
|
82
|
+
file2=str(file2) if file2 else "",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
cmd = [self.interpreter, self.script, *self.args]
|
|
87
|
+
if file1:
|
|
88
|
+
cmd.append(str(file1))
|
|
89
|
+
if file2:
|
|
90
|
+
cmd.append(str(file2))
|
|
91
|
+
|
|
92
|
+
cwd = self.cwd
|
|
93
|
+
if cwd and not Path(cwd).is_absolute():
|
|
94
|
+
cwd = str(Path(cwd).resolve())
|
|
95
|
+
|
|
96
|
+
self.logger.info("Executing script: %s", " ".join(cmd))
|
|
97
|
+
proc = subprocess.run(
|
|
98
|
+
cmd,
|
|
99
|
+
capture_output=True,
|
|
100
|
+
text=True,
|
|
101
|
+
timeout=self.timeout,
|
|
102
|
+
cwd=cwd,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
stdout = proc.stdout or ""
|
|
106
|
+
stderr = proc.stderr or ""
|
|
107
|
+
command_output = stdout + ("\n" + stderr if stderr else "")
|
|
108
|
+
result.command_output = command_output
|
|
109
|
+
|
|
110
|
+
# ── Verdict ──
|
|
111
|
+
exit_ok = proc.returncode == self.pass_exit_code
|
|
112
|
+
|
|
113
|
+
pass_match = None
|
|
114
|
+
fail_match = None
|
|
115
|
+
if self.pass_pattern:
|
|
116
|
+
pass_match = self.pass_pattern.search(stdout)
|
|
117
|
+
if self.fail_pattern:
|
|
118
|
+
fail_match = self.fail_pattern.search(stdout)
|
|
119
|
+
|
|
120
|
+
if fail_match:
|
|
121
|
+
result.identical = False
|
|
122
|
+
elif self.pass_pattern and not pass_match:
|
|
123
|
+
# pass_pattern was set but did not match → fail
|
|
124
|
+
result.identical = False
|
|
125
|
+
elif pass_match:
|
|
126
|
+
result.identical = exit_ok
|
|
127
|
+
else:
|
|
128
|
+
result.identical = exit_ok
|
|
129
|
+
|
|
130
|
+
if not result.identical:
|
|
131
|
+
differences: List[Difference] = []
|
|
132
|
+
if not exit_ok:
|
|
133
|
+
differences.append(
|
|
134
|
+
Difference(
|
|
135
|
+
position="exit_code",
|
|
136
|
+
expected=str(self.pass_exit_code),
|
|
137
|
+
actual=str(proc.returncode),
|
|
138
|
+
diff_type="exit_code_mismatch",
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
if fail_match:
|
|
142
|
+
differences.append(
|
|
143
|
+
Difference(
|
|
144
|
+
position="stdout pattern",
|
|
145
|
+
expected=f"no match for fail_pattern '{self.fail_pattern.pattern}'",
|
|
146
|
+
actual=f"matched: {fail_match.group()}",
|
|
147
|
+
diff_type="fail_pattern_match",
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
elif pass_match is None and self.pass_pattern:
|
|
151
|
+
differences.append(
|
|
152
|
+
Difference(
|
|
153
|
+
position="stdout pattern",
|
|
154
|
+
expected=f"match for pass_pattern '{self.pass_pattern.pattern}'",
|
|
155
|
+
actual="no match found in stdout",
|
|
156
|
+
diff_type="pass_pattern_missing",
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
if not differences:
|
|
160
|
+
differences.append(
|
|
161
|
+
Difference(
|
|
162
|
+
position="stdout",
|
|
163
|
+
expected="script determined failure",
|
|
164
|
+
actual=stdout[:500] if stdout else "(empty)",
|
|
165
|
+
diff_type="script_failure",
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
result.differences = differences
|
|
169
|
+
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
except subprocess.TimeoutExpired:
|
|
173
|
+
result.error = f"Script timed out after {self.timeout}s: {self.script}"
|
|
174
|
+
result.identical = False
|
|
175
|
+
result.command_output = result.error
|
|
176
|
+
return result
|
|
177
|
+
except Exception as exc:
|
|
178
|
+
self.logger.error("Error executing script %s: %s", self.script, exc)
|
|
179
|
+
result.error = str(exc)
|
|
180
|
+
result.identical = False
|
|
181
|
+
result.command_output = result.error
|
|
182
|
+
return result
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
@file text_comparator.py
|
|
6
|
+
@brief Text file comparator implementation with line-by-line comparison
|
|
7
|
+
@author Xiaotong Wang
|
|
8
|
+
@date 2025
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import difflib
|
|
12
|
+
import re
|
|
13
|
+
from .base_comparator import BaseComparator
|
|
14
|
+
from .result import Difference
|
|
15
|
+
|
|
16
|
+
class TextComparator(BaseComparator):
|
|
17
|
+
"""
|
|
18
|
+
@brief Comparator for text files with line-by-line comparison
|
|
19
|
+
@details This class implements text file comparison using Python's difflib
|
|
20
|
+
for detailed difference detection. It supports line and column-based
|
|
21
|
+
range selection for comparison.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def read_content(self, file_path, start_line=0, end_line=None, start_column=0, end_column=None):
|
|
25
|
+
"""
|
|
26
|
+
@brief Read text content with specified range
|
|
27
|
+
@param file_path Path: Path to the text file to read
|
|
28
|
+
@param start_line int: Starting line number (0-based)
|
|
29
|
+
@param end_line int: Ending line number (0-based, None for end of file)
|
|
30
|
+
@param start_column int: Starting column number (0-based)
|
|
31
|
+
@param end_column int: Ending column number (0-based, None for end of line)
|
|
32
|
+
@return list: List of text lines within the specified range
|
|
33
|
+
@throws ValueError: If line or column ranges are invalid
|
|
34
|
+
@throws UnicodeDecodeError: If file encoding is incorrect
|
|
35
|
+
@throws FileNotFoundError: If file doesn't exist
|
|
36
|
+
@throws IOError: If there are other file reading errors
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
self.logger.debug(f"Reading text file: {file_path}")
|
|
40
|
+
with open(file_path, 'r', encoding=self.encoding) as f:
|
|
41
|
+
lines = f.readlines()
|
|
42
|
+
|
|
43
|
+
if start_line < 0:
|
|
44
|
+
raise ValueError("Start line cannot be negative")
|
|
45
|
+
|
|
46
|
+
if end_line is not None:
|
|
47
|
+
if end_line < start_line:
|
|
48
|
+
raise ValueError("End line cannot be before start line")
|
|
49
|
+
if end_line >= len(lines):
|
|
50
|
+
self.logger.warning(f"End line {end_line} exceeds file length {len(lines)}, capping at {len(lines)-1}")
|
|
51
|
+
end_line = len(lines) - 1
|
|
52
|
+
else:
|
|
53
|
+
end_line = len(lines) - 1
|
|
54
|
+
|
|
55
|
+
if start_line >= len(lines):
|
|
56
|
+
raise ValueError(f"Start line {start_line} is beyond file length {len(lines)}")
|
|
57
|
+
|
|
58
|
+
selected_lines = lines[start_line:end_line+1]
|
|
59
|
+
|
|
60
|
+
if start_column < 0:
|
|
61
|
+
raise ValueError("Start column cannot be negative")
|
|
62
|
+
|
|
63
|
+
if start_column > 0 or end_column is not None:
|
|
64
|
+
self.logger.debug(f"Applying column range: {start_column} to {end_column}")
|
|
65
|
+
processed_lines = []
|
|
66
|
+
for line in selected_lines:
|
|
67
|
+
if end_column is not None and end_column < start_column:
|
|
68
|
+
raise ValueError("End column cannot be before start column")
|
|
69
|
+
# Make sure we don't exceed line length
|
|
70
|
+
effective_end = end_column
|
|
71
|
+
if effective_end is not None and effective_end >= len(line):
|
|
72
|
+
effective_end = len(line) - 1
|
|
73
|
+
# Apply column range, handle if start_column is beyond line length
|
|
74
|
+
if start_column >= len(line):
|
|
75
|
+
processed_lines.append("")
|
|
76
|
+
else:
|
|
77
|
+
processed_lines.append(line[start_column:None if effective_end is None else effective_end+1])
|
|
78
|
+
return processed_lines
|
|
79
|
+
|
|
80
|
+
return selected_lines
|
|
81
|
+
|
|
82
|
+
except UnicodeDecodeError as e:
|
|
83
|
+
raise ValueError(f"File encoding error for {file_path}. Try specifying a different encoding. Error: {str(e)}")
|
|
84
|
+
except FileNotFoundError:
|
|
85
|
+
raise ValueError(f"File not found: {file_path}")
|
|
86
|
+
except IOError as e:
|
|
87
|
+
raise ValueError(f"Error reading file {file_path}: {str(e)}")
|
|
88
|
+
|
|
89
|
+
def compare_content(self, content1, content2):
|
|
90
|
+
"""
|
|
91
|
+
@brief Compare text content and return detailed differences
|
|
92
|
+
@param content1 list: First list of text lines to compare
|
|
93
|
+
@param content2 list: Second list of text lines to compare
|
|
94
|
+
@return tuple: (bool, list, bool) - (identical, differences, truncated)
|
|
95
|
+
@details Uses difflib to generate a detailed comparison of the text content.
|
|
96
|
+
Returns a tuple containing a boolean indicating if the content is identical
|
|
97
|
+
and a list of Difference objects describing any differences found.
|
|
98
|
+
Limits the number of differences reported to 10 to avoid overwhelming output.
|
|
99
|
+
"""
|
|
100
|
+
self.logger.debug(f"Comparing text content")
|
|
101
|
+
|
|
102
|
+
if content1 == content2:
|
|
103
|
+
return True, [], False
|
|
104
|
+
|
|
105
|
+
differences = []
|
|
106
|
+
|
|
107
|
+
# Use difflib for more detailed comparison
|
|
108
|
+
diff = list(difflib.unified_diff(content1, content2, n=0))
|
|
109
|
+
|
|
110
|
+
# Process the diff output to create structured differences
|
|
111
|
+
line_diffs = []
|
|
112
|
+
for line in diff[2:]: # Skip the first two header lines
|
|
113
|
+
if line.startswith('@@'):
|
|
114
|
+
# Parse hunk header for real line numbers: @@ -start1,c1 +start2,c2 @@
|
|
115
|
+
match = re.match(r'@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@', line)
|
|
116
|
+
if match:
|
|
117
|
+
line_diffs.append(('hunk', (int(match.group(1)), int(match.group(3)))))
|
|
118
|
+
continue
|
|
119
|
+
elif line.startswith('-'):
|
|
120
|
+
line_diffs.append(('remove', line[1:]))
|
|
121
|
+
elif line.startswith('+'):
|
|
122
|
+
line_diffs.append(('add', line[1:]))
|
|
123
|
+
else:
|
|
124
|
+
line_diffs.append(('context', line[1:]))
|
|
125
|
+
|
|
126
|
+
# Convert diff to our difference format
|
|
127
|
+
line_num1 = 0
|
|
128
|
+
line_num2 = 0
|
|
129
|
+
for i, (action, line) in enumerate(line_diffs):
|
|
130
|
+
if action == 'hunk':
|
|
131
|
+
# Reset to real line numbers from the hunk header (convert 1-based to 0-based)
|
|
132
|
+
line_num1 = line[0] - 1
|
|
133
|
+
line_num2 = line[1] - 1
|
|
134
|
+
continue
|
|
135
|
+
elif action == 'remove':
|
|
136
|
+
# Look ahead for a corresponding 'add'
|
|
137
|
+
add_match = None
|
|
138
|
+
for j in range(i+1, min(i+5, len(line_diffs))):
|
|
139
|
+
if line_diffs[j][0] == 'add':
|
|
140
|
+
add_match = line_diffs[j][1]
|
|
141
|
+
del line_diffs[j]
|
|
142
|
+
break
|
|
143
|
+
|
|
144
|
+
if add_match is not None:
|
|
145
|
+
# Content difference
|
|
146
|
+
differences.append(Difference(
|
|
147
|
+
position=f"line {line_num1+1}",
|
|
148
|
+
expected=line,
|
|
149
|
+
actual=add_match,
|
|
150
|
+
diff_type="content"
|
|
151
|
+
))
|
|
152
|
+
else:
|
|
153
|
+
# Missing line
|
|
154
|
+
differences.append(Difference(
|
|
155
|
+
position=f"line {line_num1+1}",
|
|
156
|
+
expected=line,
|
|
157
|
+
actual=None,
|
|
158
|
+
diff_type="missing"
|
|
159
|
+
))
|
|
160
|
+
line_num1 += 1
|
|
161
|
+
|
|
162
|
+
elif action == 'add':
|
|
163
|
+
# Extra line
|
|
164
|
+
differences.append(Difference(
|
|
165
|
+
position=f"line {line_num2+1}",
|
|
166
|
+
expected=None,
|
|
167
|
+
actual=line,
|
|
168
|
+
diff_type="extra"
|
|
169
|
+
))
|
|
170
|
+
line_num2 += 1
|
|
171
|
+
|
|
172
|
+
elif action == 'context':
|
|
173
|
+
line_num1 += 1
|
|
174
|
+
line_num2 += 1
|
|
175
|
+
|
|
176
|
+
# Limit the number of differences reported
|
|
177
|
+
max_diffs = 10
|
|
178
|
+
truncated = len(differences) > max_diffs
|
|
179
|
+
if truncated:
|
|
180
|
+
differences = differences[:max_diffs]
|
|
181
|
+
|
|
182
|
+
return False, differences, truncated
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
@file xml_comparator.py
|
|
6
|
+
@brief XML file comparator implementation with structural comparison
|
|
7
|
+
@author Xiaotong Wang
|
|
8
|
+
@date 2025
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import xml.etree.ElementTree as ET
|
|
12
|
+
from .text_comparator import TextComparator
|
|
13
|
+
from .result import Difference
|
|
14
|
+
|
|
15
|
+
class XmlComparator(TextComparator):
|
|
16
|
+
"""
|
|
17
|
+
@brief Comparator for XML files with structural comparison
|
|
18
|
+
@details This class extends TextComparator to provide specialized XML comparison
|
|
19
|
+
capabilities, including:
|
|
20
|
+
- Tag comparison
|
|
21
|
+
- Attribute comparison
|
|
22
|
+
- Text content comparison
|
|
23
|
+
- Child element comparison
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def read_content(self, file_path, start_line=0, end_line=None, start_column=0, end_column=None):
|
|
27
|
+
"""
|
|
28
|
+
@brief Read and parse XML content from file
|
|
29
|
+
@param file_path Path: Path to the XML file
|
|
30
|
+
@param start_line int: Starting line number
|
|
31
|
+
@param end_line int: Ending line number
|
|
32
|
+
@param start_column int: Starting column number
|
|
33
|
+
@param end_column int: Ending column number
|
|
34
|
+
@return ET.Element: Parsed XML element tree
|
|
35
|
+
@throws ValueError: If XML is invalid
|
|
36
|
+
"""
|
|
37
|
+
# First read the file as text
|
|
38
|
+
text_content = super().read_content(file_path, start_line, end_line, start_column, end_column)
|
|
39
|
+
|
|
40
|
+
# Join the lines
|
|
41
|
+
xml_text = ''.join(text_content)
|
|
42
|
+
try:
|
|
43
|
+
return ET.fromstring(xml_text)
|
|
44
|
+
except ET.ParseError as e:
|
|
45
|
+
raise ValueError(f"Invalid XML in {file_path}: {str(e)}")
|
|
46
|
+
|
|
47
|
+
def compare_content(self, content1, content2):
|
|
48
|
+
"""
|
|
49
|
+
@brief Compare XML content structurally
|
|
50
|
+
@param content1 ET.Element: First XML element to compare
|
|
51
|
+
@param content2 ET.Element: Second XML element to compare
|
|
52
|
+
@return tuple: (bool, list, bool) - (identical, differences, truncated)
|
|
53
|
+
@details Performs structural comparison of XML elements, including tags,
|
|
54
|
+
attributes, text content, and child elements
|
|
55
|
+
"""
|
|
56
|
+
# Convert back to strings for comparison
|
|
57
|
+
xml_str1 = ET.tostring(content1, encoding='unicode')
|
|
58
|
+
xml_str2 = ET.tostring(content2, encoding='unicode')
|
|
59
|
+
|
|
60
|
+
if xml_str1 == xml_str2:
|
|
61
|
+
return True, [], False
|
|
62
|
+
|
|
63
|
+
# Use a recursive function to find differences in XML structures
|
|
64
|
+
differences = []
|
|
65
|
+
self._compare_elements(content1, content2, "", differences)
|
|
66
|
+
|
|
67
|
+
return False, differences, False
|
|
68
|
+
|
|
69
|
+
def _compare_elements(self, elem1, elem2, path, differences, max_diffs=10):
|
|
70
|
+
"""
|
|
71
|
+
@brief Recursively compare XML elements and collect differences
|
|
72
|
+
@param elem1 ET.Element: First XML element to compare
|
|
73
|
+
@param elem2 ET.Element: Second XML element to compare
|
|
74
|
+
@param path str: Current path in the XML structure
|
|
75
|
+
@param differences list: List to store found differences
|
|
76
|
+
@param max_diffs int: Maximum number of differences to report
|
|
77
|
+
@details Compares XML elements recursively, checking for:
|
|
78
|
+
- Tag mismatches
|
|
79
|
+
- Missing or extra attributes
|
|
80
|
+
- Text content differences
|
|
81
|
+
- Child element count mismatches
|
|
82
|
+
- Child element differences
|
|
83
|
+
"""
|
|
84
|
+
if len(differences) >= max_diffs:
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
# Compare tags
|
|
88
|
+
if elem1.tag != elem2.tag:
|
|
89
|
+
differences.append(Difference(
|
|
90
|
+
position=path or "/",
|
|
91
|
+
expected=elem1.tag,
|
|
92
|
+
actual=elem2.tag,
|
|
93
|
+
diff_type="tag_mismatch"
|
|
94
|
+
))
|
|
95
|
+
return # If tags don't match, don't compare further
|
|
96
|
+
|
|
97
|
+
# Compare attributes
|
|
98
|
+
attrib1 = set(elem1.attrib.items())
|
|
99
|
+
attrib2 = set(elem2.attrib.items())
|
|
100
|
+
|
|
101
|
+
for attr, value in attrib1 - attrib2:
|
|
102
|
+
differences.append(Difference(
|
|
103
|
+
position=f"{path}/@{attr}" if path else f"/@{attr}",
|
|
104
|
+
expected=value,
|
|
105
|
+
actual="missing attribute",
|
|
106
|
+
diff_type="missing_attribute"
|
|
107
|
+
))
|
|
108
|
+
if len(differences) >= max_diffs:
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
for attr, value in attrib2 - attrib1:
|
|
112
|
+
differences.append(Difference(
|
|
113
|
+
position=f"{path}/@{attr}" if path else f"/@{attr}",
|
|
114
|
+
expected="missing attribute",
|
|
115
|
+
actual=value,
|
|
116
|
+
diff_type="extra_attribute"
|
|
117
|
+
))
|
|
118
|
+
if len(differences) >= max_diffs:
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
# Compare text content if leaf nodes
|
|
122
|
+
if len(elem1) == 0 and len(elem2) == 0:
|
|
123
|
+
text1 = elem1.text.strip() if elem1.text else ""
|
|
124
|
+
text2 = elem2.text.strip() if elem2.text else ""
|
|
125
|
+
|
|
126
|
+
if text1 != text2:
|
|
127
|
+
differences.append(Difference(
|
|
128
|
+
position=path or "/",
|
|
129
|
+
expected=text1,
|
|
130
|
+
actual=text2,
|
|
131
|
+
diff_type="text_mismatch"
|
|
132
|
+
))
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
# Compare children elements
|
|
136
|
+
children1 = list(elem1)
|
|
137
|
+
children2 = list(elem2)
|
|
138
|
+
|
|
139
|
+
if len(children1) != len(children2):
|
|
140
|
+
differences.append(Difference(
|
|
141
|
+
position=path or "/",
|
|
142
|
+
expected=f"{len(children1)} child elements",
|
|
143
|
+
actual=f"{len(children2)} child elements",
|
|
144
|
+
diff_type="children_count_mismatch"
|
|
145
|
+
))
|
|
146
|
+
|
|
147
|
+
# Compare matching children
|
|
148
|
+
for i, (child1, child2) in enumerate(zip(children1, children2)):
|
|
149
|
+
new_path = f"{path}/{child1.tag}[{i}]" if path else f"/{child1.tag}[{i}]"
|
|
150
|
+
self._compare_elements(child1, child2, new_path, differences, max_diffs)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Central logging configuration for the CLI test framework.
|
|
3
|
+
|
|
4
|
+
Provides a unified ``get_logger(name)`` helper that returns a logger
|
|
5
|
+
under the ``symtest`` namespace. By default only a
|
|
6
|
+
``NullHandler`` is attached — no output is produced when the package
|
|
7
|
+
is imported as a library. Applications that want console logging
|
|
8
|
+
should call ``setup_console_logging()`` (the CLI entry point does
|
|
9
|
+
this automatically).
|
|
10
|
+
|
|
11
|
+
Replaces the previous ad-hoc ``print()`` / ``_print_lock`` pattern.
|
|
12
|
+
The ``logging`` module is already thread-safe, so the manual locking
|
|
13
|
+
is no longer needed.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
DEFAULT_FORMAT = "%(levelname)-7s %(name)-35s %(message)s"
|
|
22
|
+
|
|
23
|
+
# ── package-level logger (used as parent for all child loggers) ──────────────
|
|
24
|
+
_logger = logging.getLogger("symtest")
|
|
25
|
+
_logger.setLevel(logging.DEBUG) # let handlers decide; do not block
|
|
26
|
+
|
|
27
|
+
# ── default: NullHandler (polite library behaviour) ──────────────────────────
|
|
28
|
+
_logger.addHandler(logging.NullHandler())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def setup_console_logging(level: int = logging.INFO) -> None:
|
|
32
|
+
"""Attach a console handler (stderr) for CLI usage.
|
|
33
|
+
|
|
34
|
+
Replaces any existing NullHandler with a StreamHandler writing
|
|
35
|
+
to ``sys.stderr`` so that logs never pollute the ``stdout``
|
|
36
|
+
stream (which is reserved for machine-readable output such as
|
|
37
|
+
``--output-format json``).
|
|
38
|
+
"""
|
|
39
|
+
# Remove any NullHandler so we don't have duplicate handlers.
|
|
40
|
+
_logger.handlers = [
|
|
41
|
+
h for h in _logger.handlers
|
|
42
|
+
if not isinstance(h, logging.NullHandler)
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
# Only add if no real handler is already attached (idempotent).
|
|
46
|
+
if not _logger.handlers:
|
|
47
|
+
_handler = logging.StreamHandler(sys.stderr)
|
|
48
|
+
_handler.setLevel(level)
|
|
49
|
+
_handler.setFormatter(logging.Formatter(DEFAULT_FORMAT))
|
|
50
|
+
_logger.addHandler(_handler)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_logger(name: str) -> logging.Logger:
|
|
54
|
+
"""Return a child logger under the ``symtest`` namespace.
|
|
55
|
+
|
|
56
|
+
Usage::
|
|
57
|
+
|
|
58
|
+
from symtest.logging_config import get_logger
|
|
59
|
+
logger = get_logger(__name__)
|
|
60
|
+
logger.info("Hello")
|
|
61
|
+
"""
|
|
62
|
+
# Ensure the logger is a child of symtest so it inherits
|
|
63
|
+
# the handler (unless the user removes / replaces it).
|
|
64
|
+
if not name.startswith("symtest"):
|
|
65
|
+
name = "symtest." + name
|
|
66
|
+
return logging.getLogger(name)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test runners for the CLI Testing Framework
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .json_runner import JSONRunner
|
|
6
|
+
from .parallel_json_runner import ParallelJSONRunner
|
|
7
|
+
from .parallel_yaml_runner import ParallelYAMLRunner
|
|
8
|
+
from .yaml_runner import YAMLRunner
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
'JSONRunner',
|
|
12
|
+
'ParallelJSONRunner',
|
|
13
|
+
'ParallelYAMLRunner',
|
|
14
|
+
'YAMLRunner'
|
|
15
|
+
]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Generic config-driven test runner with injectable config loader.
|
|
2
|
+
|
|
3
|
+
The only difference between JSON and YAML runners was the ``load`` call.
|
|
4
|
+
This module extracts the common logic into ``ConfigRunner``, accepting a
|
|
5
|
+
``config_loader`` callable. ``JSONRunner`` and ``YAMLRunner`` are now thin
|
|
6
|
+
wrappers that inject ``json.load`` / ``yaml.safe_load`` respectively.
|
|
7
|
+
"""
|
|
8
|
+
import sys
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Optional, Dict, Any, Callable, BinaryIO
|
|
11
|
+
|
|
12
|
+
from ..core.base_runner import BaseRunner
|
|
13
|
+
from ..core.config_loader import parse_test_cases
|
|
14
|
+
from ..config.inheritance_expander import resolve_inheritance, apply_variables
|
|
15
|
+
from ..core.test_case import TestCase
|
|
16
|
+
from ..core.execution import execute_single_test_case
|
|
17
|
+
from ..core.types import TestCaseData
|
|
18
|
+
from ..utils.path_resolver import PathResolver
|
|
19
|
+
from ..config.import_expander import expand_imports
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("symtest.runners.config_runner")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConfigRunner(BaseRunner):
|
|
25
|
+
"""Generic sequential test runner.
|
|
26
|
+
|
|
27
|
+
Instead of hardcoding ``json.load`` / ``yaml.safe_load``, subclasses (or
|
|
28
|
+
direct callers) inject a ``config_loader`` – any callable that accepts an
|
|
29
|
+
open file-like object and returns a ``dict``.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, config_file: str = "test_cases.json",
|
|
33
|
+
workspace: Optional[str] = None,
|
|
34
|
+
config_loader: Optional[Callable[[BinaryIO], Dict[str, Any]]] = None,
|
|
35
|
+
variables: Optional[Dict[str, Any]] = None,
|
|
36
|
+
**kwargs):
|
|
37
|
+
super().__init__(config_file, workspace, **kwargs)
|
|
38
|
+
self._config_loader = config_loader
|
|
39
|
+
self._variables = variables or {}
|
|
40
|
+
# Backward-compatible attribute for tests that patch path_resolver
|
|
41
|
+
self.path_resolver = PathResolver(self.workspace)
|
|
42
|
+
|
|
43
|
+
def load_test_cases(self) -> None:
|
|
44
|
+
"""Load test cases from config file using the injected loader."""
|
|
45
|
+
if self._config_loader is None:
|
|
46
|
+
raise RuntimeError(
|
|
47
|
+
"config_loader must be set before loading test cases"
|
|
48
|
+
)
|
|
49
|
+
try:
|
|
50
|
+
with open(self.config_path, 'r', encoding='utf-8') as f:
|
|
51
|
+
config = self._config_loader(f)
|
|
52
|
+
|
|
53
|
+
# Expand import references (no-op if none present)
|
|
54
|
+
config = expand_imports(config, self.config_path)
|
|
55
|
+
# Resolve extends inheritance (no-op if none present)
|
|
56
|
+
config = resolve_inheritance(config)
|
|
57
|
+
# Per-case + global variable substitution
|
|
58
|
+
config = apply_variables(config, self._variables)
|
|
59
|
+
|
|
60
|
+
self.load_setup_from_config(config)
|
|
61
|
+
self.test_cases = parse_test_cases(
|
|
62
|
+
config, self.workspace, self.path_resolver,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
logger.info("Successfully loaded %d test cases",
|
|
66
|
+
len(self.test_cases))
|
|
67
|
+
except Exception as e:
|
|
68
|
+
sys.exit(f"Failed to load configuration file: {str(e)}")
|
|
69
|
+
|
|
70
|
+
def run_single_test(self, case: TestCase) -> Dict[str, Any]:
|
|
71
|
+
if case.steps:
|
|
72
|
+
return self._run_sequence(case)
|
|
73
|
+
|
|
74
|
+
case_data = case.to_execution_dict()
|
|
75
|
+
|
|
76
|
+
command_preview = (
|
|
77
|
+
f"{case_data['command']} {' '.join(case_data['args'])}".strip()
|
|
78
|
+
)
|
|
79
|
+
logger.info(" Executing command: %s", command_preview)
|
|
80
|
+
|
|
81
|
+
result = execute_single_test_case(
|
|
82
|
+
case_data,
|
|
83
|
+
str(self.workspace) if self.workspace else None,
|
|
84
|
+
update_baseline=self.update_baseline,
|
|
85
|
+
error_analysis=self.error_analysis,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if result["output"].strip():
|
|
89
|
+
logger.debug(" Command output:")
|
|
90
|
+
for line in result["output"].splitlines():
|
|
91
|
+
logger.debug(" %s", line)
|
|
92
|
+
|
|
93
|
+
if result["status"] != "passed" and result.get("message"):
|
|
94
|
+
logger.error(" Error: %s", result["message"])
|
|
95
|
+
|
|
96
|
+
return result
|