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,330 @@
|
|
|
1
|
+
"""JSON Schema (draft 2020-12) for symtest configuration files.
|
|
2
|
+
|
|
3
|
+
Single source of truth for AI agents (and humans) that *generate* test
|
|
4
|
+
configs. Exposed via ``symtest schema``.
|
|
5
|
+
|
|
6
|
+
Keep in sync with:
|
|
7
|
+
- ``core.config_loader.parse_test_cases`` (accepted fields)
|
|
8
|
+
- ``config.config_io.validate_config`` (required fields)
|
|
9
|
+
- ``docs/user_manual.md`` (documented behavior)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import copy
|
|
15
|
+
from typing import Any, Dict
|
|
16
|
+
|
|
17
|
+
CONFIG_SCHEMA: Dict[str, Any] = {
|
|
18
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
19
|
+
"title": "symtest configuration",
|
|
20
|
+
"description": (
|
|
21
|
+
"Test suite configuration for the symtest framework. "
|
|
22
|
+
"YAML files follow the same structure (YAML is a JSON superset)."
|
|
23
|
+
),
|
|
24
|
+
"type": "object",
|
|
25
|
+
"required": ["test_cases"],
|
|
26
|
+
"additionalProperties": False,
|
|
27
|
+
"properties": {
|
|
28
|
+
"setup": {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"additionalProperties": False,
|
|
31
|
+
"description": "Suite-level setup applied before any test runs.",
|
|
32
|
+
"properties": {
|
|
33
|
+
"environment_variables": {
|
|
34
|
+
"type": "object",
|
|
35
|
+
"description": "Environment variables injected into every test command.",
|
|
36
|
+
"additionalProperties": {"type": "string"},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
"test_cases": {
|
|
41
|
+
"type": "array",
|
|
42
|
+
"description": "List of test cases, sequence cases, or import references.",
|
|
43
|
+
"items": {
|
|
44
|
+
"anyOf": [
|
|
45
|
+
{"$ref": "#/$defs/singleCase"},
|
|
46
|
+
{"$ref": "#/$defs/sequenceCase"},
|
|
47
|
+
{"$ref": "#/$defs/importRef"},
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
"$defs": {
|
|
53
|
+
"expected": {
|
|
54
|
+
"type": "object",
|
|
55
|
+
"additionalProperties": False,
|
|
56
|
+
"description": (
|
|
57
|
+
"Assertions evaluated after the command finishes. "
|
|
58
|
+
"All declared assertions must pass; the combined "
|
|
59
|
+
"stdout+stderr output is used for output assertions."
|
|
60
|
+
),
|
|
61
|
+
"properties": {
|
|
62
|
+
"return_code": {
|
|
63
|
+
"type": ["integer", "null"],
|
|
64
|
+
"description": "Expected process exit code.",
|
|
65
|
+
},
|
|
66
|
+
"output_contains": {
|
|
67
|
+
"type": "array",
|
|
68
|
+
"items": {"type": "string"},
|
|
69
|
+
"description": "Strings that must each appear in the command output.",
|
|
70
|
+
},
|
|
71
|
+
"output_matches": {
|
|
72
|
+
"type": ["string", "null"],
|
|
73
|
+
"description": "Regex (re.search) the command output must match.",
|
|
74
|
+
},
|
|
75
|
+
"compare_files": {
|
|
76
|
+
"type": "array",
|
|
77
|
+
"description": "File comparison assertions against golden/baseline files.",
|
|
78
|
+
"items": {"$ref": "#/$defs/compareSpec"},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
"compareSpec": {
|
|
83
|
+
"type": "object",
|
|
84
|
+
"required": [],
|
|
85
|
+
"additionalProperties": True,
|
|
86
|
+
"description": (
|
|
87
|
+
"One file comparison rule. actual/baseline are required for built-in "
|
|
88
|
+
"file-type comparators (text/csv/json/xml/h5/binary). "
|
|
89
|
+
"They are optional for script or custom (plugin) comparator types. "
|
|
90
|
+
"Keys other than those listed below are forwarded to the comparator "
|
|
91
|
+
"as kwargs (e.g. rtol, atol, encoding, tables, data_filter, "
|
|
92
|
+
"pass_threshold, pass_pattern)."
|
|
93
|
+
),
|
|
94
|
+
"properties": {
|
|
95
|
+
"actual": {
|
|
96
|
+
"type": "string",
|
|
97
|
+
"description": "File produced by the test command. Optional when type is 'script' or a workspace plugin.",
|
|
98
|
+
},
|
|
99
|
+
"baseline": {
|
|
100
|
+
"type": "string",
|
|
101
|
+
"description": "Golden/reference file. Optional when type is 'script' or a workspace plugin.",
|
|
102
|
+
},
|
|
103
|
+
"type": {
|
|
104
|
+
"type": "string",
|
|
105
|
+
"description": (
|
|
106
|
+
"Comparator type. Built-ins: text, json, csv, xml, h5, binary, script. "
|
|
107
|
+
"Custom (workspace plugin) comparator types are also allowed. "
|
|
108
|
+
"Omit to auto-detect from the actual file extension."
|
|
109
|
+
),
|
|
110
|
+
},
|
|
111
|
+
"start_line": {"type": "integer", "minimum": 1, "description": "Only compare from this line (1-based)."},
|
|
112
|
+
"end_line": {"type": "integer", "minimum": 1, "description": "Only compare up to this line (1-based)."},
|
|
113
|
+
"start_column": {"type": "integer", "minimum": 1, "description": "Only compare from this column (1-based)."},
|
|
114
|
+
"end_column": {"type": "integer", "minimum": 1, "description": "Only compare up to this column (1-based)."},
|
|
115
|
+
"script": {"type": "string", "description": "Path to the analysis script (script / custom comparator types)."},
|
|
116
|
+
"case_dir": {"type": "string", "description": "Working directory for the analysis script."},
|
|
117
|
+
"cwd": {"type": "string", "description": "Working directory for script execution (alias for case_dir)."},
|
|
118
|
+
"pass_threshold": {"type": "number", "description": "Numeric threshold below which the comparison is considered a pass."},
|
|
119
|
+
"pass_exit_code": {"type": "integer", "default": 0, "description": "Process exit code that indicates a pass (script comparator)."},
|
|
120
|
+
"pass_pattern": {"type": "string", "description": "Regex that must match stdout for a pass (script comparator)."},
|
|
121
|
+
"fail_pattern": {"type": "string", "description": "Regex that, if matched in stdout, forces a fail (script comparator)."},
|
|
122
|
+
"interpreter": {"type": "string", "description": "Python interpreter to use for running the script."},
|
|
123
|
+
"timeout": {"type": "number", "description": "Per-comparison timeout in seconds."},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
"resources": {
|
|
127
|
+
"type": "object",
|
|
128
|
+
"additionalProperties": False,
|
|
129
|
+
"description": "Optional scheduling hints (parallel mode).",
|
|
130
|
+
"properties": {
|
|
131
|
+
"estimated_time": {"type": "number", "description": "Estimated duration in seconds; used for LPT ordering."},
|
|
132
|
+
"min_memory_mb": {"type": "number", "description": "Soft memory hint to avoid OOM."},
|
|
133
|
+
"priority": {"type": "integer", "description": "Higher value => higher priority."},
|
|
134
|
+
"cpu_cores": {"type": "integer", "minimum": 1, "description": "CPU cores required by this task."},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
"step": {
|
|
138
|
+
"type": "object",
|
|
139
|
+
"required": ["command", "args", "expected"],
|
|
140
|
+
"additionalProperties": False,
|
|
141
|
+
"properties": {
|
|
142
|
+
"command": {"type": "string"},
|
|
143
|
+
"args": {
|
|
144
|
+
"type": "array",
|
|
145
|
+
"items": {"type": ["string", "number", "boolean"]},
|
|
146
|
+
},
|
|
147
|
+
"expected": {"$ref": "#/$defs/expected"},
|
|
148
|
+
"timeout": {"type": ["number", "null"], "description": "Per-step timeout in seconds."},
|
|
149
|
+
"retry_count": {"type": "integer", "minimum": 0, "description": "Per-step retry count on failure."},
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
"singleCase": {
|
|
153
|
+
"allOf": [
|
|
154
|
+
{
|
|
155
|
+
"if": {"required": ["extends"]},
|
|
156
|
+
"then": {"required": ["name"]},
|
|
157
|
+
"else": {"required": ["name", "command", "args", "expected"]},
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
"type": "object",
|
|
161
|
+
"additionalProperties": False,
|
|
162
|
+
"properties": {
|
|
163
|
+
"name": {"type": "string", "description": "Unique test case name."},
|
|
164
|
+
"command": {
|
|
165
|
+
"type": "string",
|
|
166
|
+
"description": (
|
|
167
|
+
"Command to execute. May include leading arguments "
|
|
168
|
+
"(e.g. 'python ./run.py'); the framework splits and "
|
|
169
|
+
"path-resolves them."
|
|
170
|
+
),
|
|
171
|
+
},
|
|
172
|
+
"args": {
|
|
173
|
+
"type": "array",
|
|
174
|
+
"items": {"type": ["string", "number", "boolean"]},
|
|
175
|
+
},
|
|
176
|
+
"expected": {"$ref": "#/$defs/expected"},
|
|
177
|
+
"description": {"type": ["string", "null"]},
|
|
178
|
+
"timeout": {
|
|
179
|
+
"type": ["number", "null"],
|
|
180
|
+
"description": "Timeout in seconds (default 3600); null = no limit.",
|
|
181
|
+
},
|
|
182
|
+
"retry_count": {
|
|
183
|
+
"type": "integer",
|
|
184
|
+
"minimum": 0,
|
|
185
|
+
"description": "Retries after the first failure; passing after retry marks the result flaky.",
|
|
186
|
+
},
|
|
187
|
+
"tags": {
|
|
188
|
+
"type": "array",
|
|
189
|
+
"items": {"type": "string"},
|
|
190
|
+
"description": "Tags for --tag filtering.",
|
|
191
|
+
},
|
|
192
|
+
"resources": {"$ref": "#/$defs/resources"},
|
|
193
|
+
"expected_failure": {
|
|
194
|
+
"type": "boolean",
|
|
195
|
+
"description": (
|
|
196
|
+
"Mark this case as an expected failure (xfail). "
|
|
197
|
+
"When true, a failure counts as XFailed (not a suite failure); "
|
|
198
|
+
"an unexpected pass counts as XPassed (suite failure)."
|
|
199
|
+
),
|
|
200
|
+
},
|
|
201
|
+
"xfail_reason": {
|
|
202
|
+
"type": "string",
|
|
203
|
+
"description": "Optional reason displayed in the report alongside XFailed results.",
|
|
204
|
+
},
|
|
205
|
+
"xfail_quiet": {
|
|
206
|
+
"type": "boolean",
|
|
207
|
+
"description": (
|
|
208
|
+
"When true and the case is xfailed (expected failure confirmed), "
|
|
209
|
+
"suppress the Command Output block in reports to reduce noise."
|
|
210
|
+
),
|
|
211
|
+
},
|
|
212
|
+
"abstract": {
|
|
213
|
+
"type": "boolean",
|
|
214
|
+
"description": (
|
|
215
|
+
"When true, this case is a template (base) and is not "
|
|
216
|
+
"executed. Other cases can extend it via 'extends'."
|
|
217
|
+
),
|
|
218
|
+
},
|
|
219
|
+
"extends": {
|
|
220
|
+
"type": "string",
|
|
221
|
+
"description": (
|
|
222
|
+
"Name of the base test case to inherit from. Fields "
|
|
223
|
+
"from the base are deep-merged; the child's fields take "
|
|
224
|
+
"precedence. Supports chain inheritance with cycle detection."
|
|
225
|
+
),
|
|
226
|
+
},
|
|
227
|
+
"variables": {
|
|
228
|
+
"type": "object",
|
|
229
|
+
"additionalProperties": {"type": ["string", "number", "boolean"]},
|
|
230
|
+
"description": (
|
|
231
|
+
"Per-case placeholder variables for {key} substitution. "
|
|
232
|
+
"Merged from the ancestor chain (child overrides parent), "
|
|
233
|
+
"then overlaid by global --var flags at run time."
|
|
234
|
+
),
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
},
|
|
240
|
+
"sequenceCase": {
|
|
241
|
+
"allOf": [
|
|
242
|
+
{
|
|
243
|
+
"if": {"required": ["extends"]},
|
|
244
|
+
"then": {"required": ["name"]},
|
|
245
|
+
"else": {"required": ["name", "steps"]},
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
"type": "object",
|
|
249
|
+
"additionalProperties": False,
|
|
250
|
+
"description": (
|
|
251
|
+
"Multi-step case: steps run in order with fail-fast semantics. "
|
|
252
|
+
"The case-level 'expected' is evaluated once after all steps pass."
|
|
253
|
+
),
|
|
254
|
+
"properties": {
|
|
255
|
+
"name": {"type": "string"},
|
|
256
|
+
"steps": {
|
|
257
|
+
"type": "array",
|
|
258
|
+
"minItems": 1,
|
|
259
|
+
"items": {"$ref": "#/$defs/step"},
|
|
260
|
+
},
|
|
261
|
+
"expected": {
|
|
262
|
+
"$ref": "#/$defs/expected",
|
|
263
|
+
"description": "Optional case-level assertions (e.g. compare_files on produced files), evaluated after all steps pass.",
|
|
264
|
+
},
|
|
265
|
+
"description": {"type": ["string", "null"]},
|
|
266
|
+
"tags": {"type": "array", "items": {"type": "string"}},
|
|
267
|
+
"resources": {"$ref": "#/$defs/resources"},
|
|
268
|
+
"expected_failure": {
|
|
269
|
+
"type": "boolean",
|
|
270
|
+
"description": (
|
|
271
|
+
"Mark this case as an expected failure (xfail). "
|
|
272
|
+
"When true, a failure counts as XFailed (not a suite failure); "
|
|
273
|
+
"an unexpected pass counts as XPassed (suite failure)."
|
|
274
|
+
),
|
|
275
|
+
},
|
|
276
|
+
"xfail_reason": {
|
|
277
|
+
"type": "string",
|
|
278
|
+
"description": "Optional reason displayed in the report alongside XFailed results.",
|
|
279
|
+
},
|
|
280
|
+
"xfail_quiet": {
|
|
281
|
+
"type": "boolean",
|
|
282
|
+
"description": (
|
|
283
|
+
"When true and the case is xfailed (expected failure confirmed), "
|
|
284
|
+
"suppress the Command Output block in reports to reduce noise."
|
|
285
|
+
),
|
|
286
|
+
},
|
|
287
|
+
"abstract": {
|
|
288
|
+
"type": "boolean",
|
|
289
|
+
"description": (
|
|
290
|
+
"When true, this case is a template (base) and is not "
|
|
291
|
+
"executed. Other cases can extend it via 'extends'."
|
|
292
|
+
),
|
|
293
|
+
},
|
|
294
|
+
"extends": {
|
|
295
|
+
"type": "string",
|
|
296
|
+
"description": (
|
|
297
|
+
"Name of the base test case to inherit from. Fields "
|
|
298
|
+
"from the base are deep-merged; the child's fields take "
|
|
299
|
+
"precedence. Supports chain inheritance with cycle detection."
|
|
300
|
+
),
|
|
301
|
+
},
|
|
302
|
+
"variables": {
|
|
303
|
+
"type": "object",
|
|
304
|
+
"additionalProperties": {"type": ["string", "number", "boolean"]},
|
|
305
|
+
"description": (
|
|
306
|
+
"Per-case placeholder variables for {key} substitution. "
|
|
307
|
+
"Merged from the ancestor chain (child overrides parent), "
|
|
308
|
+
"then overlaid by global --var flags at run time."
|
|
309
|
+
),
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
},
|
|
315
|
+
"importRef": {
|
|
316
|
+
"type": "object",
|
|
317
|
+
"required": ["import"],
|
|
318
|
+
"additionalProperties": False,
|
|
319
|
+
"description": "Inline another config file (path relative to this file).",
|
|
320
|
+
"properties": {
|
|
321
|
+
"import": {"type": "string"},
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def get_config_schema() -> Dict[str, Any]:
|
|
329
|
+
"""Return a deep copy of the config JSON Schema (safe to mutate)."""
|
|
330
|
+
return copy.deepcopy(CONFIG_SCHEMA)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Import reference expander for test configuration files.
|
|
3
|
+
|
|
4
|
+
Allows a main config file to reference sub-config files via ``"import"``
|
|
5
|
+
entries inside ``test_cases``, recursively expanding them at load time.
|
|
6
|
+
|
|
7
|
+
Example::
|
|
8
|
+
|
|
9
|
+
{
|
|
10
|
+
"setup": { ... },
|
|
11
|
+
"test_cases": [
|
|
12
|
+
{ "import": "cases/text_tests.json", "tags": ["text", "fast"] },
|
|
13
|
+
{ "import": "cases/json_tests.yaml" },
|
|
14
|
+
{ "name": "inline_case", "command": "echo", ... }
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
When ``"tags"`` is specified on an import entry, the tags are injected into
|
|
19
|
+
every test case from that imported file. Tags already present on individual
|
|
20
|
+
cases are merged (import-level tags come first, deduplicated).
|
|
21
|
+
|
|
22
|
+
The expansion produces a flat ``test_cases`` list with all imported cases
|
|
23
|
+
inlined. The Runner layer never sees the ``import`` keys.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any, Dict, List, Optional, Set
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger("symtest.config.import_expander")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _load_raw_config(file_path: Path) -> Dict[str, Any]:
|
|
37
|
+
"""Load a raw config dict from a JSON or YAML file."""
|
|
38
|
+
ext = file_path.suffix.lower()
|
|
39
|
+
if ext == ".json":
|
|
40
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
41
|
+
return json.load(f)
|
|
42
|
+
elif ext in (".yaml", ".yml"):
|
|
43
|
+
import yaml
|
|
44
|
+
|
|
45
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
46
|
+
return yaml.safe_load(f)
|
|
47
|
+
else:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"Unsupported config file format: {file_path} (expected .json, .yaml, or .yml)"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _deep_merge_setup(base: Dict[str, Any], overlay: Dict[str, Any]) -> Dict[str, Any]:
|
|
54
|
+
"""Merge two setup dicts; overlay values win on conflict."""
|
|
55
|
+
merged = dict(base)
|
|
56
|
+
for key, value in overlay.items():
|
|
57
|
+
if (
|
|
58
|
+
key in merged
|
|
59
|
+
and isinstance(merged[key], dict)
|
|
60
|
+
and isinstance(value, dict)
|
|
61
|
+
):
|
|
62
|
+
# Deep merge nested dicts (e.g. environment_variables)
|
|
63
|
+
merged[key] = _deep_merge_setup(merged[key], value)
|
|
64
|
+
else:
|
|
65
|
+
merged[key] = value
|
|
66
|
+
return merged
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def expand_imports(
|
|
70
|
+
config: Dict[str, Any],
|
|
71
|
+
config_path: Path,
|
|
72
|
+
loaded_paths: Optional[Set[str]] = None,
|
|
73
|
+
) -> Dict[str, Any]:
|
|
74
|
+
"""Recursively expand ``import`` references in a config dict.
|
|
75
|
+
|
|
76
|
+
For each item in ``config["test_cases"]`` that contains an ``"import"``
|
|
77
|
+
key, loads the referenced file, recursively expands it, and inlines its
|
|
78
|
+
``test_cases`` list in place of the ``import`` entry.
|
|
79
|
+
|
|
80
|
+
Parameters
|
|
81
|
+
----------
|
|
82
|
+
config:
|
|
83
|
+
Raw config dict (already parsed from JSON/YAML).
|
|
84
|
+
config_path:
|
|
85
|
+
Absolute or resolved path to the config file (used as the base
|
|
86
|
+
for resolving relative ``import`` paths).
|
|
87
|
+
loaded_paths:
|
|
88
|
+
Set of already-loaded canonical paths (for cycle detection).
|
|
89
|
+
|
|
90
|
+
Returns
|
|
91
|
+
-------
|
|
92
|
+
A new config dict with all ``import`` references expanded.
|
|
93
|
+
"""
|
|
94
|
+
if loaded_paths is None:
|
|
95
|
+
loaded_paths = set()
|
|
96
|
+
|
|
97
|
+
canonical = str(config_path.resolve())
|
|
98
|
+
if canonical in loaded_paths:
|
|
99
|
+
raise RuntimeError(
|
|
100
|
+
f"Circular import detected: {canonical} is already loaded. "
|
|
101
|
+
f"Loaded chain: {loaded_paths}"
|
|
102
|
+
)
|
|
103
|
+
loaded_paths.add(canonical)
|
|
104
|
+
|
|
105
|
+
base_dir = config_path.parent
|
|
106
|
+
setup = config.get("setup", {})
|
|
107
|
+
raw_cases: List[Dict[str, Any]] = config.get("test_cases", [])
|
|
108
|
+
expanded_cases: List[Dict[str, Any]] = []
|
|
109
|
+
|
|
110
|
+
for item in raw_cases:
|
|
111
|
+
if "import" in item:
|
|
112
|
+
import_rel = item["import"]
|
|
113
|
+
import_tags = item.get("tags", [])
|
|
114
|
+
sub_path = (base_dir / import_rel).resolve()
|
|
115
|
+
|
|
116
|
+
if not sub_path.exists():
|
|
117
|
+
raise FileNotFoundError(
|
|
118
|
+
f"Imported config file not found: {sub_path} "
|
|
119
|
+
f"(referenced from {config_path})"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
logger.debug("Expanding import: %s -> %s", import_rel, sub_path)
|
|
123
|
+
|
|
124
|
+
sub_config = _load_raw_config(sub_path)
|
|
125
|
+
# Recursively expand (pass loaded_paths copy for cycle detection)
|
|
126
|
+
sub_config = expand_imports(sub_config, sub_path, loaded_paths)
|
|
127
|
+
|
|
128
|
+
# Merge setup from sub-file
|
|
129
|
+
sub_setup = sub_config.get("setup", {})
|
|
130
|
+
if sub_setup:
|
|
131
|
+
setup = _deep_merge_setup(setup, sub_setup)
|
|
132
|
+
|
|
133
|
+
sub_cases = sub_config.get("test_cases", [])
|
|
134
|
+
|
|
135
|
+
# Inject import-level tags into each imported case
|
|
136
|
+
if import_tags:
|
|
137
|
+
for case in sub_cases:
|
|
138
|
+
existing = case.get("tags", [])
|
|
139
|
+
case["tags"] = list(dict.fromkeys(import_tags + existing))
|
|
140
|
+
|
|
141
|
+
expanded_cases.extend(sub_cases)
|
|
142
|
+
else:
|
|
143
|
+
expanded_cases.append(item)
|
|
144
|
+
|
|
145
|
+
result: Dict[str, Any] = {"test_cases": expanded_cases}
|
|
146
|
+
if setup:
|
|
147
|
+
result["setup"] = setup
|
|
148
|
+
|
|
149
|
+
return result
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test-case inheritance resolver.
|
|
3
|
+
|
|
4
|
+
Resolves ``extends`` references in ``test_cases``, merges field definitions
|
|
5
|
+
via deep-merge (dict) / whole-replace (list), collects ``variables`` for
|
|
6
|
+
per-case placeholder substitution, and removes ``abstract`` (template) cases.
|
|
7
|
+
|
|
8
|
+
Used after ``expand_imports`` and before ``substitute_placeholders`` /
|
|
9
|
+
``parse_test_cases`` in the config loading pipeline.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import copy
|
|
15
|
+
import logging
|
|
16
|
+
from typing import Any, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("symtest.config.inheritance_expander")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Public API
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
def resolve_inheritance(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
26
|
+
"""Resolve ``extends`` chains, remove ``abstract`` cases, collect ``variables``.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
config:
|
|
31
|
+
Config dict already expanded by ``expand_imports``. Must contain a
|
|
32
|
+
``test_cases`` list; may optionally contain ``setup``.
|
|
33
|
+
|
|
34
|
+
Returns
|
|
35
|
+
-------
|
|
36
|
+
A new config dict where:
|
|
37
|
+
- every ``abstract: true`` case has been removed,
|
|
38
|
+
- every ``extends`` reference has been resolved (merged with parent),
|
|
39
|
+
- each concrete case may carry a ``variables`` dict (deep-merged from
|
|
40
|
+
ancestor chain). The ``variables`` are *not* substituted — that is
|
|
41
|
+
the job of ``apply_variables``.
|
|
42
|
+
|
|
43
|
+
Raises
|
|
44
|
+
------
|
|
45
|
+
ValueError
|
|
46
|
+
If an ``extends`` target is not found among all test-cases (including
|
|
47
|
+
abstract ones).
|
|
48
|
+
ValueError
|
|
49
|
+
If a circular ``extends`` chain is detected.
|
|
50
|
+
"""
|
|
51
|
+
result = copy.deepcopy(config)
|
|
52
|
+
raw_cases: List[Dict[str, Any]] = result.get("test_cases", [])
|
|
53
|
+
if not raw_cases:
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
# -- Build name → case lookup (includes abstract bases) --
|
|
57
|
+
all_by_name: Dict[str, Dict[str, Any]] = {}
|
|
58
|
+
for case in raw_cases:
|
|
59
|
+
name = case.get("name")
|
|
60
|
+
if name:
|
|
61
|
+
all_by_name[name] = case
|
|
62
|
+
|
|
63
|
+
# -- Resolve each case --
|
|
64
|
+
resolved_cases: List[Dict[str, Any]] = []
|
|
65
|
+
for case in raw_cases:
|
|
66
|
+
resolved_case = _resolve_one(case, all_by_name, visited=[])
|
|
67
|
+
# abstract cases are templates — only exclude them from the final list;
|
|
68
|
+
# they still serve as bases for extends resolution above
|
|
69
|
+
if not resolved_case.get("abstract"):
|
|
70
|
+
resolved_case.pop("abstract", None)
|
|
71
|
+
resolved_cases.append(resolved_case)
|
|
72
|
+
|
|
73
|
+
result["test_cases"] = resolved_cases
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def apply_variables(
|
|
78
|
+
config: Dict[str, Any],
|
|
79
|
+
global_variables: Optional[Dict[str, Any]] = None,
|
|
80
|
+
) -> Dict[str, Any]:
|
|
81
|
+
"""Per-case placeholder substitution with global ``--var`` overlay.
|
|
82
|
+
|
|
83
|
+
Parameters
|
|
84
|
+
----------
|
|
85
|
+
config:
|
|
86
|
+
Config dict already processed by ``resolve_inheritance``. Each
|
|
87
|
+
concrete case may carry a ``variables`` key.
|
|
88
|
+
global_variables:
|
|
89
|
+
Global variables from ``--var`` CLI flag. These take precedence
|
|
90
|
+
over case-level ``variables`` (global overlays).
|
|
91
|
+
|
|
92
|
+
Returns
|
|
93
|
+
-------
|
|
94
|
+
A new config dict with all ``{placeholder}`` replaced. The
|
|
95
|
+
``variables`` key is consumed (removed) from every case. ``setup``
|
|
96
|
+
is substituted with global variables only.
|
|
97
|
+
"""
|
|
98
|
+
from ..core.config_loader import substitute_placeholders
|
|
99
|
+
|
|
100
|
+
result = copy.deepcopy(config)
|
|
101
|
+
gv = global_variables or {}
|
|
102
|
+
|
|
103
|
+
# -- Substitute setup with global variables only --
|
|
104
|
+
if "setup" in result and gv:
|
|
105
|
+
result["setup"] = substitute_placeholders(result["setup"], gv)
|
|
106
|
+
|
|
107
|
+
for case in result.get("test_cases", []):
|
|
108
|
+
case_vars = case.pop("variables", {})
|
|
109
|
+
effective = dict(case_vars)
|
|
110
|
+
effective.update(gv) # global overlays
|
|
111
|
+
if effective:
|
|
112
|
+
_sub_in_place(case, effective, substitute_placeholders)
|
|
113
|
+
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Internal helpers
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def _deep_merge_dicts(base: dict, overlay: dict) -> dict:
|
|
122
|
+
"""Deep-merge *overlay* into *base*.
|
|
123
|
+
|
|
124
|
+
- Both values are dict → recurse.
|
|
125
|
+
- Otherwise overlay value wins (list / scalar are whole-replaced, not extended).
|
|
126
|
+
- Returns a new dict (does not mutate inputs).
|
|
127
|
+
"""
|
|
128
|
+
merged = dict(base)
|
|
129
|
+
for key, value in overlay.items():
|
|
130
|
+
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
|
|
131
|
+
merged[key] = _deep_merge_dicts(merged[key], value)
|
|
132
|
+
else:
|
|
133
|
+
merged[key] = value
|
|
134
|
+
return merged
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _resolve_one(
|
|
138
|
+
case: Dict[str, Any],
|
|
139
|
+
all_by_name: Dict[str, Dict[str, Any]],
|
|
140
|
+
visited: List[str],
|
|
141
|
+
) -> Dict[str, Any]:
|
|
142
|
+
"""Resolve a single case's ``extends`` chain recursively.
|
|
143
|
+
|
|
144
|
+
Returns a fully-merged dict (may have ``abstract: true`` — the caller
|
|
145
|
+
filters abstract cases from the final list). Abstract cases *can* serve
|
|
146
|
+
as extends targets; they are only excluded from the output, not from
|
|
147
|
+
inheritance resolution.
|
|
148
|
+
"""
|
|
149
|
+
extends_target = case.get("extends")
|
|
150
|
+
if extends_target is None:
|
|
151
|
+
# No inheritance — keep as-is
|
|
152
|
+
result = dict(case)
|
|
153
|
+
result.pop("extends", None)
|
|
154
|
+
return result
|
|
155
|
+
|
|
156
|
+
# -- Resolve parent chain --
|
|
157
|
+
name = case.get("name", "<unnamed>")
|
|
158
|
+
if extends_target not in all_by_name:
|
|
159
|
+
raise ValueError(
|
|
160
|
+
f"extends target not found: '{extends_target}' "
|
|
161
|
+
f"(referenced by case '{name}')"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Cycle detection
|
|
165
|
+
if extends_target in visited:
|
|
166
|
+
chain = " -> ".join(visited + [extends_target])
|
|
167
|
+
raise ValueError(
|
|
168
|
+
f"Circular extends detected: {chain}"
|
|
169
|
+
)
|
|
170
|
+
visited.append(extends_target)
|
|
171
|
+
|
|
172
|
+
parent = all_by_name[extends_target]
|
|
173
|
+
resolved_parent = _resolve_one(parent, all_by_name, visited)
|
|
174
|
+
|
|
175
|
+
visited.pop()
|
|
176
|
+
|
|
177
|
+
# -- Merge: parent fields → child fields (child wins) --
|
|
178
|
+
merged = _deep_merge_dicts(resolved_parent, case)
|
|
179
|
+
|
|
180
|
+
# Remove internal markers
|
|
181
|
+
merged.pop("extends", None)
|
|
182
|
+
|
|
183
|
+
# abstract: child's own value wins (default False), NOT inherited from parent
|
|
184
|
+
merged["abstract"] = case.get("abstract", False)
|
|
185
|
+
|
|
186
|
+
return merged
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _sub_in_place(
|
|
190
|
+
case: Dict[str, Any],
|
|
191
|
+
variables: Dict[str, Any],
|
|
192
|
+
substitute_fn,
|
|
193
|
+
) -> None:
|
|
194
|
+
"""Apply placeholder substitution to *case* in-place via *substitute_fn*."""
|
|
195
|
+
substituted = substitute_fn(case, variables)
|
|
196
|
+
case.clear()
|
|
197
|
+
case.update(substituted)
|