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.
Files changed (63) hide show
  1. symtest/__init__.py +45 -0
  2. symtest/cli.py +549 -0
  3. symtest/commands/__init__.py +9 -0
  4. symtest/commands/compare.py +221 -0
  5. symtest/config/__init__.py +7 -0
  6. symtest/config/config_io.py +346 -0
  7. symtest/config/config_schema.py +330 -0
  8. symtest/config/import_expander.py +149 -0
  9. symtest/config/inheritance_expander.py +197 -0
  10. symtest/core/__init__.py +15 -0
  11. symtest/core/assertions.py +253 -0
  12. symtest/core/base_runner.py +299 -0
  13. symtest/core/config_loader.py +536 -0
  14. symtest/core/execution.py +498 -0
  15. symtest/core/history_store.py +96 -0
  16. symtest/core/last_run_store.py +109 -0
  17. symtest/core/parallel_runner.py +251 -0
  18. symtest/core/process_worker.py +93 -0
  19. symtest/core/sequence_state.py +143 -0
  20. symtest/core/setup.py +137 -0
  21. symtest/core/test_case.py +76 -0
  22. symtest/core/types.py +92 -0
  23. symtest/file_comparator/__init__.py +10 -0
  24. symtest/file_comparator/base_comparator.py +109 -0
  25. symtest/file_comparator/binary_comparator.py +399 -0
  26. symtest/file_comparator/csv_comparator.py +241 -0
  27. symtest/file_comparator/factory.py +191 -0
  28. symtest/file_comparator/h5_comparator.py +777 -0
  29. symtest/file_comparator/json_comparator.py +323 -0
  30. symtest/file_comparator/result.py +213 -0
  31. symtest/file_comparator/script_comparator.py +182 -0
  32. symtest/file_comparator/text_comparator.py +182 -0
  33. symtest/file_comparator/xml_comparator.py +150 -0
  34. symtest/logging_config.py +66 -0
  35. symtest/runners/__init__.py +15 -0
  36. symtest/runners/config_runner.py +96 -0
  37. symtest/runners/json_runner.py +21 -0
  38. symtest/runners/parallel_config_runner.py +278 -0
  39. symtest/runners/parallel_json_runner.py +26 -0
  40. symtest/runners/parallel_yaml_runner.py +31 -0
  41. symtest/runners/yaml_runner.py +26 -0
  42. symtest/tui/__init__.py +11 -0
  43. symtest/tui/app.py +90 -0
  44. symtest/tui/controllers/__init__.py +0 -0
  45. symtest/tui/controllers/case_controller.py +322 -0
  46. symtest/tui/screens/__init__.py +0 -0
  47. symtest/tui/screens/case_editor.py +244 -0
  48. symtest/tui/screens/case_list.py +255 -0
  49. symtest/tui/widgets/__init__.py +0 -0
  50. symtest/tui/widgets/case_table.py +113 -0
  51. symtest/tui/widgets/expected_editor.py +159 -0
  52. symtest/tui/widgets/search_bar.py +160 -0
  53. symtest/tui/widgets/steps_editor.py +243 -0
  54. symtest/utils/__init__.py +21 -0
  55. symtest/utils/junit_xml_writer.py +137 -0
  56. symtest/utils/path_resolver.py +124 -0
  57. symtest/utils/report_generator.py +208 -0
  58. symtest_cli-1.3.0.dist-info/METADATA +316 -0
  59. symtest_cli-1.3.0.dist-info/RECORD +63 -0
  60. symtest_cli-1.3.0.dist-info/WHEEL +5 -0
  61. symtest_cli-1.3.0.dist-info/entry_points.txt +4 -0
  62. symtest_cli-1.3.0.dist-info/licenses/LICENSE +21 -0
  63. 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