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,323 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ @file json_comparator.py
6
+ @brief JSON file comparator implementation with support for exact and key-based comparison
7
+ @author Xiaotong Wang
8
+ @date 2025
9
+ """
10
+
11
+ import json
12
+ from .text_comparator import TextComparator
13
+ from .result import Difference
14
+
15
+ class JsonComparator(TextComparator):
16
+ """
17
+ @brief Comparator for JSON files with support for exact and key-based comparison
18
+ @details This class extends TextComparator to provide specialized JSON comparison
19
+ capabilities, including:
20
+ - Exact comparison of JSON structures
21
+ - Key-based comparison for lists of objects
22
+ - Detailed difference reporting with path information
23
+ """
24
+
25
+ def __init__(self, encoding="utf-8", chunk_size=8192, verbose=False, key_field=None, compare_mode="exact", **kwargs):
26
+ """
27
+ @brief Initialize the JSON comparator
28
+ @param encoding str: File encoding
29
+ @param chunk_size int: Chunk size for reading files
30
+ @param verbose bool: Enable verbose logging
31
+ @param key_field str or list: Field name(s) to use as key for comparing JSON objects in lists
32
+ @param compare_mode str: Comparison mode: 'exact' (default) or 'key-based'
33
+ @param **kwargs: Additional parameters (ignored)
34
+ """
35
+ super().__init__(encoding=encoding, chunk_size=chunk_size, verbose=verbose, **kwargs)
36
+ self.key_field = key_field
37
+ self.compare_mode = compare_mode
38
+
39
+ def read_content(self, file_path, start_line=0, end_line=None, start_column=0, end_column=None):
40
+ """
41
+ @brief Read and parse JSON content from file
42
+ @param file_path Path: Path to the JSON file
43
+ @param start_line int: Starting line number
44
+ @param end_line int: Ending line number
45
+ @param start_column int: Starting column number
46
+ @param end_column int: Ending column number
47
+ @return dict or list: Parsed JSON content
48
+ @throws ValueError: If JSON is invalid or key fields are missing
49
+ """
50
+ # Read the text content using the parent class method
51
+ text_content = super().read_content(file_path, start_line, end_line, start_column, end_column)
52
+
53
+ # Convert to a single string
54
+ json_text = ''.join(text_content)
55
+ try:
56
+ json_data = json.loads(json_text)
57
+ return json_data
58
+ except json.JSONDecodeError as e:
59
+ raise ValueError(f"Invalid JSON in {file_path}: {str(e)}")
60
+
61
+ def compare_content(self, content1, content2):
62
+ """
63
+ @brief Compare JSON content using the specified comparison mode
64
+ @param content1 dict or list: First JSON content to compare
65
+ @param content2 dict or list: Second JSON content to compare
66
+ @return tuple: (bool, list, bool) - (identical, differences, truncated)
67
+ """
68
+ # Quick check for exact equality
69
+ if content1 == content2:
70
+ return True, [], False
71
+
72
+ # Different comparison modes
73
+ differences = []
74
+ if self.compare_mode == "key-based" and self.key_field:
75
+ self._compare_json_key_based(content1, content2, "", differences)
76
+ else:
77
+ self._compare_json_exact(content1, content2, "", differences)
78
+
79
+ identical = len(differences) == 0
80
+ return identical, differences, False
81
+
82
+ def _compare_json_exact(self, obj1, obj2, path, differences, max_diffs=10):
83
+ """
84
+ @brief Perform exact JSON comparison
85
+ @param obj1: First JSON object to compare
86
+ @param obj2: Second JSON object to compare
87
+ @param path str: Current path in the JSON structure
88
+ @param differences list: List to store found differences
89
+ @param max_diffs int: Maximum number of differences to report
90
+ @details Compares JSON objects recursively, checking for:
91
+ - Type mismatches
92
+ - Missing or extra keys in dictionaries
93
+ - Length mismatches in lists
94
+ - Value mismatches
95
+ """
96
+ if len(differences) >= max_diffs:
97
+ return
98
+
99
+ # Type check
100
+ if type(obj1) != type(obj2):
101
+ differences.append(Difference(
102
+ position=path or "root",
103
+ expected=f"{type(obj1).__name__}: {obj1}",
104
+ actual=f"{type(obj2).__name__}: {obj2}",
105
+ diff_type="type_mismatch"
106
+ ))
107
+ return
108
+
109
+ # Dictionary comparison
110
+ if isinstance(obj1, dict):
111
+ keys1 = set(obj1.keys())
112
+ keys2 = set(obj2.keys())
113
+
114
+ # Check for missing keys
115
+ for key in keys1 - keys2:
116
+ differences.append(Difference(
117
+ position=f"{path}.{key}" if path else key,
118
+ expected=obj1[key],
119
+ actual=None,
120
+ diff_type="missing_key"
121
+ ))
122
+ if len(differences) >= max_diffs:
123
+ return
124
+
125
+ # Check for extra keys
126
+ for key in keys2 - keys1:
127
+ differences.append(Difference(
128
+ position=f"{path}.{key}" if path else key,
129
+ expected=None,
130
+ actual=obj2[key],
131
+ diff_type="extra_key"
132
+ ))
133
+ if len(differences) >= max_diffs:
134
+ return
135
+
136
+ # Compare common keys recursively
137
+ for key in keys1 & keys2:
138
+ new_path = f"{path}.{key}" if path else key
139
+ self._compare_json_exact(obj1[key], obj2[key], new_path, differences, max_diffs)
140
+
141
+ # List comparison
142
+ elif isinstance(obj1, list):
143
+ if len(obj1) != len(obj2):
144
+ differences.append(Difference(
145
+ position=path or "root",
146
+ expected=f"list with {len(obj1)} items",
147
+ actual=f"list with {len(obj2)} items",
148
+ diff_type="length_mismatch"
149
+ ))
150
+
151
+ # Compare items by position
152
+ for i in range(min(len(obj1), len(obj2))):
153
+ new_path = f"{path}[{i}]"
154
+ self._compare_json_exact(obj1[i], obj2[i], new_path, differences, max_diffs)
155
+
156
+ # Value comparison
157
+ elif obj1 != obj2:
158
+ differences.append(Difference(
159
+ position=path or "root",
160
+ expected=obj1,
161
+ actual=obj2,
162
+ diff_type="value_mismatch"
163
+ ))
164
+
165
+ def _compare_json_key_based(self, obj1, obj2, path, differences, max_diffs=10):
166
+ """
167
+ @brief Perform key-based JSON comparison for lists of objects
168
+ @param obj1: First JSON object to compare
169
+ @param obj2: Second JSON object to compare
170
+ @param path str: Current path in the JSON structure
171
+ @param differences list: List to store found differences
172
+ @param max_diffs int: Maximum number of differences to report
173
+ @details Similar to exact comparison but with special handling for lists
174
+ of objects, using key fields to match items instead of position
175
+ """
176
+ if len(differences) >= max_diffs:
177
+ return
178
+
179
+ # Type check
180
+ if type(obj1) != type(obj2):
181
+ differences.append(Difference(
182
+ position=path or "root",
183
+ expected=f"{type(obj1).__name__}: {obj1}",
184
+ actual=f"{type(obj2).__name__}: {obj2}",
185
+ diff_type="type_mismatch"
186
+ ))
187
+ return
188
+
189
+ # Dictionary comparison (same as exact comparison)
190
+ if isinstance(obj1, dict):
191
+ keys1 = set(obj1.keys())
192
+ keys2 = set(obj2.keys())
193
+
194
+ # Check for missing keys
195
+ for key in keys1 - keys2:
196
+ differences.append(Difference(
197
+ position=f"{path}.{key}" if path else key,
198
+ expected=obj1[key],
199
+ actual=None,
200
+ diff_type="missing_key"
201
+ ))
202
+ if len(differences) >= max_diffs:
203
+ return
204
+
205
+ # Check for extra keys
206
+ for key in keys2 - keys1:
207
+ differences.append(Difference(
208
+ position=f"{path}.{key}" if path else key,
209
+ expected=None,
210
+ actual=obj2[key],
211
+ diff_type="extra_key"
212
+ ))
213
+ if len(differences) >= max_diffs:
214
+ return
215
+
216
+ # Compare common keys recursively
217
+ for key in keys1 & keys2:
218
+ new_path = f"{path}.{key}" if path else key
219
+ self._compare_json_key_based(obj1[key], obj2[key], new_path, differences, max_diffs)
220
+
221
+ # List comparison - the key difference for key-based comparison
222
+ elif isinstance(obj1, list) and isinstance(obj2, list):
223
+ # Check if we can do key-based comparison
224
+ if self.key_field and all(isinstance(item, dict) for item in obj1 + obj2):
225
+ self._compare_lists_by_key(obj1, obj2, path, differences, max_diffs)
226
+ else:
227
+ # Fall back to position-based comparison if key-based is not possible
228
+ if len(obj1) != len(obj2):
229
+ differences.append(Difference(
230
+ position=path or "root",
231
+ expected=f"list with {len(obj1)} items",
232
+ actual=f"list with {len(obj2)} items",
233
+ diff_type="length_mismatch"
234
+ ))
235
+
236
+ # Compare items by position
237
+ for i in range(min(len(obj1), len(obj2))):
238
+ new_path = f"{path}[{i}]"
239
+ self._compare_json_key_based(obj1[i], obj2[i], new_path, differences, max_diffs)
240
+
241
+ # Value comparison
242
+ elif obj1 != obj2:
243
+ differences.append(Difference(
244
+ position=path or "root",
245
+ expected=obj1,
246
+ actual=obj2,
247
+ diff_type="value_mismatch"
248
+ ))
249
+
250
+ def _compare_lists_by_key(self, list1, list2, path, differences, max_diffs=10):
251
+ """
252
+ @brief Compare two lists of dictionaries using key field(s) to match items
253
+ @param list1 list: First list of dictionaries
254
+ @param list2 list: Second list of dictionaries
255
+ @param path str: Current path in the JSON structure
256
+ @param differences list: List to store found differences
257
+ @param max_diffs int: Maximum number of differences to report
258
+ @details Matches items in lists using specified key fields instead of position,
259
+ allowing for reordered lists with the same content
260
+ """
261
+ # Convert key_field to list if it's a string
262
+ key_fields = self.key_field if isinstance(self.key_field, list) else [self.key_field]
263
+
264
+ # Create dictionaries indexed by the key fields
265
+ dict1 = {}
266
+ dict2 = {}
267
+
268
+ # Function to create compound key from an item
269
+ def get_key(item):
270
+ if not all(k in item for k in key_fields):
271
+ return None # Skip items without all key fields
272
+ return tuple(str(item.get(k)) for k in key_fields)
273
+
274
+ # Build dictionaries from lists
275
+ for i, item in enumerate(list1):
276
+ key = get_key(item)
277
+ if key:
278
+ dict1[key] = (i, item)
279
+
280
+ for i, item in enumerate(list2):
281
+ key = get_key(item)
282
+ if key:
283
+ dict2[key] = (i, item)
284
+
285
+ # Find keys in the first list that are missing from the second
286
+ for key in set(dict1.keys()) - set(dict2.keys()):
287
+ idx, item = dict1[key]
288
+ key_str = ".".join(f"{k}={v}" for k, v in zip(key_fields, key))
289
+ differences.append(Difference(
290
+ position=f"{path}[{idx}] (key: {key_str})",
291
+ expected=item,
292
+ actual=None,
293
+ diff_type="missing_item"
294
+ ))
295
+ if len(differences) >= max_diffs:
296
+ return
297
+
298
+ # Find keys in the second list that are missing from the first
299
+ for key in set(dict2.keys()) - set(dict1.keys()):
300
+ idx, item = dict2[key]
301
+ key_str = ".".join(f"{k}={v}" for k, v in zip(key_fields, key))
302
+ differences.append(Difference(
303
+ position=f"{path}[{idx}] (key: {key_str})",
304
+ expected=None,
305
+ actual=item,
306
+ diff_type="extra_item"
307
+ ))
308
+ if len(differences) >= max_diffs:
309
+ return
310
+
311
+ # Compare matching items
312
+ for key in set(dict1.keys()) & set(dict2.keys()):
313
+ idx1, item1 = dict1[key]
314
+ idx2, item2 = dict2[key]
315
+ key_str = ".".join(f"{k}={v}" for k, v in zip(key_fields, key))
316
+ new_path = f"{path}[key:{key_str}]"
317
+
318
+ # Skip identical items
319
+ if item1 == item2:
320
+ continue
321
+
322
+ # Recursive comparison of matched items
323
+ self._compare_json_key_based(item1, item2, new_path, differences, max_diffs)
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ @file result.py
6
+ @brief Classes for representing file comparison results and differences
7
+ @author Xiaotong Wang
8
+ @date 2025
9
+ """
10
+
11
+ import json
12
+
13
+
14
+ class Difference:
15
+ """
16
+ @brief Represents a single difference between files
17
+ @details This class encapsulates information about a single difference found
18
+ during file comparison, including position, expected and actual content,
19
+ and the type of difference.
20
+ """
21
+
22
+ def __init__(self, position=None, expected=None, actual=None, diff_type="content"):
23
+ """
24
+ @brief Initialize a Difference object
25
+ @param position: Position of the difference (can be line number, byte position, etc.)
26
+ @param expected: Expected content at the position
27
+ @param actual: Actual content found at the position
28
+ @param diff_type: Type of difference ("content", "missing", "extra", etc.)
29
+ """
30
+ self.position = position # Can be line number, byte position, etc.
31
+ self.expected = expected # Expected content
32
+ self.actual = actual # Actual content
33
+ self.diff_type = diff_type # Type of difference: "content", "missing", "extra", etc.
34
+
35
+ def __str__(self):
36
+ """
37
+ @brief Convert the difference to a string representation
38
+ @return str: Human-readable description of the difference
39
+ @details Shows expected/actual values whenever they exist,
40
+ regardless of diff_type.
41
+ """
42
+ if self.expected is not None and self.actual is not None:
43
+ return f"At {self.position}: expected '{self.expected}', got '{self.actual}'"
44
+ elif self.expected is not None:
45
+ return f"At {self.position}: expected '{self.expected}'"
46
+ elif self.actual is not None:
47
+ return f"At {self.position}: got '{self.actual}'"
48
+ else:
49
+ return f"At {self.position}"
50
+
51
+ def to_dict(self):
52
+ """
53
+ @brief Convert the difference to a dictionary representation
54
+ @return dict: Dictionary containing the difference details
55
+ """
56
+ return {
57
+ "position": self.position,
58
+ "expected": self.expected,
59
+ "actual": self.actual,
60
+ "diff_type": self.diff_type
61
+ }
62
+
63
+ class ComparisonResult:
64
+ """
65
+ @brief Represents the result of a file comparison
66
+ @details This class encapsulates all information about a file comparison,
67
+ including file paths, comparison range, differences found,
68
+ and additional metadata like file sizes and similarity index.
69
+ """
70
+
71
+ def __init__(self, file1=None, file2=None, start_line=0, end_line=None,
72
+ start_column=0, end_column=None):
73
+ """
74
+ @brief Initialize a ComparisonResult object
75
+ @param file1: Path to the first file
76
+ @param file2: Path to the second file
77
+ @param start_line: Starting line number for comparison (0-based)
78
+ @param end_line: Ending line number for comparison (0-based, None for end of file)
79
+ @param start_column: Starting column number for comparison (0-based)
80
+ @param end_column: Ending column number for comparison (0-based, None for end of line)
81
+ """
82
+ self.file1 = file1
83
+ self.file2 = file2
84
+ self.file1_size = None
85
+ self.file2_size = None
86
+ self.start_line = start_line
87
+ self.end_line = end_line
88
+ self.start_column = start_column
89
+ self.end_column = end_column
90
+ self.identical = None
91
+ self.differences = []
92
+ self.error = None
93
+ self.similarity = None # Similarity index for binary comparisons
94
+ self.truncated = False # Whether differences were truncated due to max_diffs limit
95
+ self.error_stats = None # Optional error statistics from streaming numerical analysis
96
+ self.command_output = None # Optional subprocess stdout captured by script/custom comparators
97
+
98
+ def __str__(self):
99
+ """
100
+ @brief Convert the comparison result to a string representation
101
+ @return str: Human-readable description of the comparison result
102
+ """
103
+ if self.error:
104
+ return f"Error during comparison: {self.error}"
105
+ lines = []
106
+ if self.identical:
107
+ range_str = self._get_range_str()
108
+ lines.append(f"Files are identical{range_str}.")
109
+ else:
110
+ lines.append(f"Files are different. Found {len(self.differences)} differences:")
111
+ for i, diff in enumerate(self.differences, 1):
112
+ lines.append(f"{i}. {diff}")
113
+ if self.truncated:
114
+ lines.append("... more differences not shown")
115
+ if self.similarity is not None:
116
+ lines.append(f"Similarity Index: {self.similarity:.2f}")
117
+ return "\n".join(lines)
118
+
119
+ def _get_range_str(self):
120
+ """
121
+ @brief Get a string representation of the comparison range
122
+ @return str: Description of the line and column ranges being compared
123
+ """
124
+ parts = []
125
+ if self.start_line > 0 or self.end_line is not None:
126
+ line_range = f"lines {self.start_line+1}"
127
+ if self.end_line is not None:
128
+ line_range += f"-{self.end_line+1}"
129
+ parts.append(line_range)
130
+
131
+ if self.start_column > 0 or self.end_column is not None:
132
+ col_range = f"columns {self.start_column+1}"
133
+ if self.end_column is not None:
134
+ col_range += f"-{self.end_column+1}"
135
+ parts.append(col_range)
136
+
137
+ if parts:
138
+ return " in " + ", ".join(parts)
139
+ return ""
140
+
141
+ def to_dict(self):
142
+ """
143
+ @brief Convert the comparison result to a dictionary representation
144
+ @return dict: Dictionary containing all comparison details
145
+ """
146
+ return {
147
+ "file1": self.file1,
148
+ "file2": self.file2,
149
+ "file1_size": self.file1_size,
150
+ "file2_size": self.file2_size,
151
+ "range": {
152
+ "start_line": self.start_line,
153
+ "end_line": self.end_line,
154
+ "start_column": self.start_column,
155
+ "end_column": self.end_column
156
+ },
157
+ "identical": self.identical,
158
+ "differences": [diff.to_dict() for diff in self.differences],
159
+ "similarity": self.similarity,
160
+ "error": self.error,
161
+ "truncated": self.truncated,
162
+ "error_stats": self.error_stats,
163
+ "command_output": self.command_output,
164
+ }
165
+
166
+ def to_html(self):
167
+ """
168
+ @brief Convert the comparison result to HTML format
169
+ @details Generates a complete HTML document with styling for displaying
170
+ the comparison results in a web browser
171
+ @return str: HTML representation of the comparison result
172
+ """
173
+ if self.error:
174
+ return f"<div class='error'>Error during comparison: {self.error}</div>"
175
+
176
+ html = ["<html><head><style>",
177
+ "body { font-family: Arial, sans-serif; }",
178
+ ".identical { color: green; }",
179
+ ".different { color: red; }",
180
+ ".diff-item { margin: 10px 0; padding: 5px; border: 1px solid #ccc; }",
181
+ "</style></head><body>"]
182
+
183
+ if self.identical:
184
+ range_str = self._get_range_str()
185
+ html.append(f"<h2 class='identical'>Files are identical{range_str}.</h2>")
186
+ else:
187
+ html.append(f"<h2 class='different'>Files are different. Found {len(self.differences)} differences:</h2>")
188
+ if self.similarity is not None:
189
+ html.append(f"<p>Similarity Index: {self.similarity:.2f}</p>")
190
+ html.append("<div class='diff-list'>")
191
+
192
+ for i, diff in enumerate(self.differences, 1):
193
+ html.append(f"<div class='diff-item'>")
194
+ html.append(f"<h3>Difference {i}</h3>")
195
+ html.append(f"<p>Position: {diff.position}</p>")
196
+ html.append(f"<p>Expected: <pre>{diff.expected}</pre></p>")
197
+ html.append(f"<p>Actual: <pre>{diff.actual}</pre></p>")
198
+ html.append(f"<p>Type: {diff.diff_type}</p>")
199
+ html.append("</div>")
200
+
201
+ if self.truncated:
202
+ html.append("<p class='truncated'>... more differences not shown</p>")
203
+ html.append("</div>")
204
+
205
+ html.append("</body></html>")
206
+ return "\n".join(html)
207
+
208
+ def to_json(self):
209
+ """
210
+ @brief Convert the comparison result to JSON format
211
+ @return str: JSON representation of the comparison result
212
+ """
213
+ return json.dumps(self.to_dict(), ensure_ascii=False, indent=2, default=str)