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,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)