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,21 @@
1
+ """JSONRunner – thin backward-compatible wrapper around ConfigRunner."""
2
+ import json
3
+ import logging
4
+ from typing import Optional
5
+
6
+ from .config_runner import ConfigRunner
7
+
8
+ logger = logging.getLogger("symtest.runners.json_runner")
9
+
10
+
11
+ class JSONRunner(ConfigRunner):
12
+ """Sequential JSON test runner (backward-compatible thin wrapper)."""
13
+
14
+ def __init__(self, config_file="test_cases.json",
15
+ workspace: Optional[str] = None, **kwargs):
16
+ super().__init__(
17
+ config_file=config_file,
18
+ workspace=workspace,
19
+ config_loader=json.load,
20
+ **kwargs,
21
+ )
@@ -0,0 +1,278 @@
1
+ """Generic parallel config-driven test runner with injectable config loader.
2
+
3
+ The only difference between ParallelJSONRunner and ParallelYAMLRunner was the
4
+ ``load`` call. This module extracts the common scheduling / resource-management
5
+ logic into ``ParallelConfigRunner``, accepting a ``config_loader`` callable.
6
+ """
7
+ import sys
8
+ import os
9
+ import logging
10
+ from typing import Optional, Dict, Any, Callable, BinaryIO
11
+
12
+ from ..core.parallel_runner import ParallelRunner, AtomicSemaphore
13
+ from ..core.config_loader import parse_test_cases, execute_sequence
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.parallel_config_runner")
22
+
23
+
24
+ class ParallelConfigRunner(ParallelRunner):
25
+ """Generic parallel test runner with injectable config loader.
26
+
27
+ Subclasses inject a ``config_loader`` instead of hardcoding
28
+ ``json.load`` / ``yaml.safe_load``. All scheduling (CPU-aware semaphore,
29
+ heuristic ordering, proportional core assignment) lives here.
30
+ """
31
+
32
+ def __init__(self, config_file: str = "test_cases.json",
33
+ workspace: Optional[str] = None,
34
+ max_workers: Optional[int] = None,
35
+ execution_mode: str = "thread",
36
+ config_loader: Optional[Callable[[BinaryIO], Dict[str, Any]]] = None,
37
+ variables: Optional[Dict[str, Any]] = None,
38
+ **kwargs):
39
+ # Auto-detect physical CPU cores (reserve 2 for OS)
40
+ self.total_physical = os.cpu_count() or 4
41
+ self.safe_capacity = max(1, self.total_physical - 2)
42
+
43
+ if max_workers is None:
44
+ max_workers = self.total_physical
45
+
46
+ super().__init__(config_file, workspace, max_workers,
47
+ execution_mode, **kwargs)
48
+ self._config_loader = config_loader
49
+ self._variables = variables or {}
50
+ # Backward-compatible attribute for tests that patch path_resolver
51
+ self.path_resolver = PathResolver(self.workspace)
52
+
53
+ # Resource pool – only meaningful in thread mode
54
+ self.cpu_semaphore = (
55
+ AtomicSemaphore(self.safe_capacity)
56
+ if execution_mode == "thread" else None
57
+ )
58
+
59
+ logger.info(
60
+ "✅ [Resource Manager] Detected %d CPUs. Pool size set to %d.",
61
+ self.total_physical, self.safe_capacity,
62
+ )
63
+
64
+ # ------------------------------------------------------------------
65
+ # CPU allocation helpers
66
+ # ------------------------------------------------------------------
67
+
68
+ def _assign_relative_cpu_cores(self) -> None:
69
+ """Assign ``cpu_cores`` proportionally based on *estimated_time*
70
+ and *min_memory_mb* for cases without an explicit ``cpu_cores``.
71
+
72
+ Weight = estimated_time (s) + min_memory_mb / 100.
73
+ """
74
+ candidates = [
75
+ c for c in self.test_cases
76
+ if not (c.resources and "cpu_cores" in c.resources)
77
+ ]
78
+ if not candidates:
79
+ return
80
+
81
+ def weight(case: TestCase) -> float:
82
+ res = case.resources or {}
83
+ est = float(res.get("estimated_time") or 0)
84
+ mem = float(res.get("min_memory_mb") or 0)
85
+ return est + mem / 100.0
86
+
87
+ weights = [weight(c) for c in candidates]
88
+ total_weight = sum(weights)
89
+
90
+ if total_weight <= 0:
91
+ for case in candidates:
92
+ if not case.resources:
93
+ case.resources = {}
94
+ case.resources["cpu_cores"] = 1
95
+ return
96
+
97
+ allocated = 0
98
+ indexed = sorted(
99
+ enumerate(zip(candidates, weights)),
100
+ key=lambda x: x[1][1], reverse=True,
101
+ )
102
+ for rank, (_idx, (case, w)) in enumerate(indexed):
103
+ if rank == len(indexed) - 1:
104
+ share = max(1, self.safe_capacity - allocated)
105
+ else:
106
+ share = max(
107
+ 1, int(round(self.safe_capacity * w / total_weight)),
108
+ )
109
+ if not case.resources:
110
+ case.resources = {}
111
+ case.resources["cpu_cores"] = share
112
+ allocated += share
113
+
114
+ # ------------------------------------------------------------------
115
+ # Test loading (format-agnostic)
116
+ # ------------------------------------------------------------------
117
+
118
+ def load_test_cases(self) -> None:
119
+ """Load test cases from config file using the injected loader."""
120
+ if self._config_loader is None:
121
+ raise RuntimeError(
122
+ "config_loader must be set before loading test cases"
123
+ )
124
+ try:
125
+ with open(self.config_path, 'r', encoding='utf-8') as f:
126
+ config = self._config_loader(f)
127
+
128
+ # Expand import references (no-op if none present)
129
+ config = expand_imports(config, self.config_path)
130
+ # Resolve extends inheritance (no-op if none present)
131
+ config = resolve_inheritance(config)
132
+ # Per-case + global variable substitution
133
+ config = apply_variables(config, self._variables)
134
+
135
+ self.load_setup_from_config(config)
136
+ self.test_cases = parse_test_cases(
137
+ config, self.workspace, self.path_resolver,
138
+ )
139
+
140
+ logger.info("Successfully loaded %d test cases",
141
+ len(self.test_cases))
142
+
143
+ # Heuristic scheduling: longest-estimated first
144
+ if self.test_cases:
145
+ history_cases: Dict[str, Any] = {}
146
+ if self.history_dir:
147
+ from ..core.history_store import load_history
148
+ hist = load_history(self.history_dir)
149
+ history_cases = hist.get("cases", {})
150
+
151
+ def get_estimated_time(case: TestCase) -> float:
152
+ if case.name in history_cases:
153
+ return float(
154
+ history_cases[case.name]["avg_duration"],
155
+ )
156
+ return float(
157
+ (case.resources or {}).get("estimated_time", 0),
158
+ )
159
+
160
+ logger.info(
161
+ "Optimizing execution order based on estimated duration...",
162
+ )
163
+ self.test_cases.sort(key=get_estimated_time, reverse=True)
164
+ top_case = self.test_cases[0]
165
+ top_est = get_estimated_time(top_case)
166
+ source = (
167
+ "history" if top_case.name in history_cases else "config"
168
+ )
169
+ logger.info(
170
+ "Heaviest task: %s (Est: %.2fs, source: %s)",
171
+ top_case.name, top_est, source,
172
+ )
173
+
174
+ self._assign_relative_cpu_cores()
175
+ except Exception as e:
176
+ sys.exit(f"Failed to load configuration file: {str(e)}")
177
+
178
+ # ------------------------------------------------------------------
179
+ # Execution
180
+ # ------------------------------------------------------------------
181
+
182
+ def _run_sequence(self, case: TestCase) -> Dict[str, Any]:
183
+ """Run a sequence test case with fail-fast semantics."""
184
+ return execute_sequence(
185
+ case_name=case.name,
186
+ steps=case.steps,
187
+ workspace=str(self.workspace) if self.workspace else None,
188
+ print_prefix="[Worker]",
189
+ case_expected=case.expected if case.expected else None,
190
+ update_baseline=self.update_baseline,
191
+ error_analysis=self.error_analysis,
192
+ resume=self.resume,
193
+ )
194
+
195
+ def run_single_test(self, case: TestCase) -> Dict[str, Any]:
196
+ """Thread-safe, resource-aware execution of a single test case.
197
+
198
+ In *thread* mode this uses an ``AtomicSemaphore`` to cap concurrent
199
+ CPU core usage and sets ``OMP_NUM_THREADS`` / ``MKL_NUM_THREADS``
200
+ environment variables accordingly.
201
+ """
202
+ # 1. Determine required core count
203
+ required_cores = 1
204
+ if case.resources and "cpu_cores" in case.resources:
205
+ required_cores = case.resources["cpu_cores"]
206
+
207
+ if required_cores > self.safe_capacity:
208
+ required_cores = self.safe_capacity
209
+
210
+ tokens_acquired = 0
211
+ task_env = None
212
+
213
+ # 2. Acquire resource tokens (thread mode only)
214
+ if self.execution_mode == "thread" and self.cpu_semaphore is not None:
215
+ if not self.cpu_semaphore.acquire(required_cores, timeout=10.0):
216
+ required_cores = 1
217
+ self.cpu_semaphore.acquire(1)
218
+ tokens_acquired = 1
219
+ else:
220
+ tokens_acquired = required_cores
221
+
222
+ task_env = {
223
+ "OMP_NUM_THREADS": str(required_cores),
224
+ "MKL_NUM_THREADS": str(required_cores),
225
+ "NPROC": str(required_cores),
226
+ }
227
+
228
+ logger.info(
229
+ " [Scheduler] Task '%s' acquired %d cores. Running...",
230
+ case.name, tokens_acquired,
231
+ )
232
+
233
+ # 3. Execute
234
+ if case.steps:
235
+ result = self._run_sequence(case)
236
+ else:
237
+ case_data = case.to_execution_dict()
238
+
239
+ command_preview = (
240
+ f"{case_data['command']} {' '.join(case_data['args'])}".strip()
241
+ )
242
+ if self.execution_mode != "thread" or self.cpu_semaphore is None:
243
+ logger.info(
244
+ " [Worker] Executing command: %s", command_preview,
245
+ )
246
+
247
+ result = execute_single_test_case(
248
+ case_data,
249
+ str(self.workspace) if self.workspace else None,
250
+ env=task_env,
251
+ update_baseline=self.update_baseline,
252
+ error_analysis=self.error_analysis,
253
+ )
254
+
255
+ if result["output"].strip():
256
+ logger.debug(
257
+ " [Worker] Command output for %s:", case.name,
258
+ )
259
+ for line in result["output"].splitlines():
260
+ logger.debug(" %s", line)
261
+
262
+ if result["status"] != "passed" and result.get("message"):
263
+ logger.error(
264
+ " [Worker] Error for %s: %s",
265
+ case.name, result["message"],
266
+ )
267
+
268
+ # 4. Release tokens
269
+ if (self.execution_mode == "thread"
270
+ and self.cpu_semaphore is not None
271
+ and tokens_acquired > 0):
272
+ self.cpu_semaphore.release(tokens_acquired)
273
+ logger.info(
274
+ " [Scheduler] Task '%s' released %d cores.",
275
+ case.name, tokens_acquired,
276
+ )
277
+
278
+ return result
@@ -0,0 +1,26 @@
1
+ """ParallelJSONRunner – thin backward-compatible wrapper around ParallelConfigRunner."""
2
+ import json
3
+ import logging
4
+ from typing import Optional
5
+
6
+ from .parallel_config_runner import ParallelConfigRunner
7
+
8
+ logger = logging.getLogger("symtest.runners.parallel_json_runner")
9
+
10
+
11
+ class ParallelJSONRunner(ParallelConfigRunner):
12
+ """Parallel JSON test runner (backward-compatible thin wrapper)."""
13
+
14
+ def __init__(self, config_file="test_cases.json",
15
+ workspace: Optional[str] = None,
16
+ max_workers: Optional[int] = None,
17
+ execution_mode: str = "thread",
18
+ **kwargs):
19
+ super().__init__(
20
+ config_file=config_file,
21
+ workspace=workspace,
22
+ max_workers=max_workers,
23
+ execution_mode=execution_mode,
24
+ config_loader=json.load,
25
+ **kwargs,
26
+ )
@@ -0,0 +1,31 @@
1
+ """ParallelYAMLRunner – thin backward-compatible wrapper around ParallelConfigRunner."""
2
+ import logging
3
+ from typing import Optional
4
+
5
+ from .parallel_config_runner import ParallelConfigRunner
6
+
7
+ logger = logging.getLogger("symtest.runners.parallel_yaml_runner")
8
+
9
+
10
+ def _yaml_load(f):
11
+ """Lazy-load yaml.safe_load so the package is importable without PyYAML."""
12
+ import yaml
13
+ return yaml.safe_load(f)
14
+
15
+
16
+ class ParallelYAMLRunner(ParallelConfigRunner):
17
+ """Parallel YAML test runner (backward-compatible thin wrapper)."""
18
+
19
+ def __init__(self, config_file="test_cases.yaml",
20
+ workspace: Optional[str] = None,
21
+ max_workers: Optional[int] = None,
22
+ execution_mode: str = "thread",
23
+ **kwargs):
24
+ super().__init__(
25
+ config_file=config_file,
26
+ workspace=workspace,
27
+ max_workers=max_workers,
28
+ execution_mode=execution_mode,
29
+ config_loader=_yaml_load,
30
+ **kwargs,
31
+ )
@@ -0,0 +1,26 @@
1
+ """YAMLRunner – thin backward-compatible wrapper around ConfigRunner."""
2
+ import logging
3
+ from typing import Optional
4
+
5
+ from .config_runner import ConfigRunner
6
+
7
+ logger = logging.getLogger("symtest.runners.yaml_runner")
8
+
9
+
10
+ def _yaml_load(f):
11
+ """Lazy-load yaml.safe_load so the package is importable without PyYAML."""
12
+ import yaml
13
+ return yaml.safe_load(f)
14
+
15
+
16
+ class YAMLRunner(ConfigRunner):
17
+ """Sequential YAML test runner (backward-compatible thin wrapper)."""
18
+
19
+ def __init__(self, config_file="test_cases.yaml",
20
+ workspace: Optional[str] = None, **kwargs):
21
+ super().__init__(
22
+ config_file=config_file,
23
+ workspace=workspace,
24
+ config_loader=_yaml_load,
25
+ **kwargs,
26
+ )
@@ -0,0 +1,11 @@
1
+ """
2
+ TUI Manager for interactive test case CRUD.
3
+
4
+ Provides a Textual-based terminal UI for browsing, editing and running
5
+ test cases. Relies on :mod:`symtest.config.config_io` for
6
+ configuration I/O and :mod:`symtest.core` for execution.
7
+
8
+ Usage::
9
+
10
+ symtest tui test_cases.json
11
+ """
symtest/tui/app.py ADDED
@@ -0,0 +1,90 @@
1
+ """Textual App entry point for the TUI manager.
2
+
3
+ Usage::
4
+
5
+ from symtest.tui.app import run_tui
6
+ run_tui("test_cases.json", workspace="/path/to/project")
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from textual.app import App, ComposeResult
15
+ from textual.binding import Binding
16
+
17
+ from .controllers.case_controller import CaseController
18
+ from .screens.case_list import CaseListScreen
19
+
20
+
21
+ class CaseManagerApp(App):
22
+ """Terminal UI for browsing / editing / running test cases."""
23
+
24
+ TITLE = "CLI Test Framework - Case Manager"
25
+
26
+ BINDINGS = [
27
+ Binding("ctrl+q", "quit", "Quit", show=True, priority=True),
28
+ Binding("q", "quit", "Quit", show=True, priority=True),
29
+ ]
30
+
31
+ CSS = """
32
+ Screen {
33
+ background: $surface;
34
+ }
35
+ """
36
+
37
+ def __init__(self, config_file: str, workspace: Optional[str] = None,
38
+ update_baseline: bool = False,
39
+ history_dir: Optional[str] = None,
40
+ update_history: bool = False):
41
+ super().__init__()
42
+ self._config_file = config_file
43
+ self._workspace = workspace
44
+ self._controller = CaseController()
45
+ self._controller.update_baseline = update_baseline
46
+ self._controller.history_dir = history_dir
47
+ self._controller.update_history = update_history
48
+
49
+ def on_mount(self) -> None:
50
+ try:
51
+ count = self._controller.load(self._config_file, self._workspace)
52
+ self.notify(f"Loaded {count} test cases from {Path(self._config_file).name}")
53
+ except Exception as e:
54
+ self.notify(f"Failed to load config: {e}", severity="error")
55
+
56
+ self.push_screen(CaseListScreen(self._controller))
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Public entry point
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ def run_tui(config_file: str, workspace: Optional[str] = None,
65
+ update_baseline: bool = False,
66
+ history_dir: Optional[str] = None,
67
+ update_history: bool = False) -> None:
68
+ """Launch the TUI manager.
69
+
70
+ Safe to call without installing textual — raises :exc:`ImportError`
71
+ with a friendly message if textual is not available.
72
+ """
73
+ try:
74
+ import textual # noqa: F401 — verify textual is importable
75
+ except ImportError:
76
+ import sys
77
+
78
+ print(
79
+ "TUI 功能需要安装 textual。请运行:\n"
80
+ " pip install symtest[tui]\n"
81
+ "或:\n"
82
+ " pip install textual"
83
+ )
84
+ sys.exit(1)
85
+
86
+ app = CaseManagerApp(config_file, workspace,
87
+ update_baseline=update_baseline,
88
+ history_dir=history_dir,
89
+ update_history=update_history)
90
+ app.run()
File without changes