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,243 @@
1
+ """Widget for editing a test case's ``steps`` list (sequence mode).
2
+
3
+ Uses an internal ``_steps`` list so that ``load()`` / ``to_steps()`` work
4
+ even when the widget is not mounted.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import copy
10
+ import json
11
+ from typing import Any, List
12
+
13
+ from textual.widgets import Input, TextArea, Static, Button
14
+ from textual.containers import Vertical, Horizontal
15
+ from textual.message import Message
16
+ from textual.app import ComposeResult
17
+
18
+ from ...core.test_case import TestCaseStep
19
+
20
+
21
+ class StepsEditor(Vertical):
22
+ """Editor for the ``steps`` sequence of a test case."""
23
+
24
+ DEFAULT_CSS = """
25
+ StepsEditor {
26
+ height: auto;
27
+ border: solid $primary;
28
+ padding: 1;
29
+ }
30
+ StepsEditor #steps-list {
31
+ height: auto;
32
+ min-height: 3;
33
+ }
34
+ StepsEditor .step-row {
35
+ height: auto;
36
+ padding: 1 0;
37
+ border-bottom: solid $panel;
38
+ }
39
+ StepsEditor .step-row Static {
40
+ width: 1fr;
41
+ }
42
+ StepsEditor .step-header {
43
+ color: $accent;
44
+ padding-bottom: 1;
45
+ }
46
+ StepsEditor #step-editor {
47
+ height: auto;
48
+ margin-top: 1;
49
+ border: solid $surface;
50
+ padding: 1;
51
+ }
52
+ StepsEditor #step-confirm {
53
+ margin-top: 1;
54
+ }
55
+ """
56
+
57
+ class Changed(Message):
58
+ """Emitted whenever steps are modified."""
59
+
60
+ def __init__(self, *args, **kwargs):
61
+ super().__init__(*args, **kwargs)
62
+ self._steps: List[TestCaseStep] = []
63
+ self._editing_idx: int = -1 # -1 = new step
64
+
65
+ def compose(self) -> ComposeResult:
66
+ yield Static("─ Steps ─", classes="label")
67
+ yield Vertical(id="steps-list")
68
+ yield Static("", id="step-editor-status")
69
+ yield Static("Command:", classes="label")
70
+ yield Input(placeholder="python", id="step-cmd")
71
+ yield Static("Args (space-separated):", classes="label")
72
+ yield Input(placeholder="./script.py --flag", id="step-args")
73
+ yield Static("Expected (JSON):", classes="label")
74
+ yield TextArea('{"return_code": 0}', id="step-expected")
75
+ yield Static("Timeout (seconds):", classes="label")
76
+ yield Input(placeholder="", id="step-timeout")
77
+ yield Static("Retry count:", classes="label")
78
+ yield Input(placeholder="0", id="step-retry-count")
79
+ yield Horizontal(
80
+ Button("Save Step", id="step-save", variant="primary"),
81
+ Button("Cancel", id="step-cancel"),
82
+ id="step-confirm",
83
+ )
84
+ yield Horizontal(
85
+ Button("+ Add Step", id="step-add", variant="success"),
86
+ id="step-actions",
87
+ )
88
+
89
+ def on_mount(self) -> None:
90
+ self._refresh_list()
91
+
92
+ # -- public API ----------------------------------------------------------
93
+
94
+ def load(self, steps: List[TestCaseStep]) -> None:
95
+ """Populate the editor with *steps*."""
96
+ self._steps = copy.deepcopy(steps)
97
+ self._editing_idx = -1
98
+ if self.is_mounted:
99
+ self._refresh_list()
100
+ self._clear_edit_form()
101
+
102
+ def to_steps(self) -> List[TestCaseStep]:
103
+ if self.is_mounted:
104
+ # Pull any in-progress edit from UI into steps before returning
105
+ pass # edits are applied immediately via buttons
106
+ return copy.deepcopy(self._steps)
107
+
108
+ # -- internal -----------------------------------------------------------
109
+
110
+ def _refresh_list(self) -> None:
111
+ container = self.query_one("#steps-list", Vertical)
112
+ container.remove_children()
113
+ for i, step in enumerate(self._steps):
114
+ args_str = " ".join(step.args) if step.args else ""
115
+ exp_preview = json.dumps(step.expected, ensure_ascii=False)
116
+ if len(exp_preview) > 40:
117
+ exp_preview = exp_preview[:37] + "..."
118
+ timeout_str = f", timeout={step.timeout}s" if step.timeout else ""
119
+ retry_str = f", retry={step.retry_count}" if step.retry_count else ""
120
+
121
+ row = Horizontal(
122
+ Static(f"[Step {i+1}/{len(self._steps)}]"),
123
+ Static(f"cmd: {step.command}"),
124
+ Static(f"args: {args_str[:30]}"),
125
+ Static(f"exp: {exp_preview}"),
126
+ Button("Edit", id=f"step-edit-{i}"),
127
+ Button("Del", id=f"step-del-{i}"),
128
+ Button("↑", id=f"step-up-{i}"),
129
+ Button("↓", id=f"step-down-{i}"),
130
+ classes="step-row",
131
+ )
132
+ container.mount(row)
133
+
134
+ def _clear_edit_form(self) -> None:
135
+ self._editing_idx = -1
136
+ if not self.is_mounted:
137
+ return
138
+ self.query_one("#step-cmd", Input).value = ""
139
+ self.query_one("#step-args", Input).value = ""
140
+ self.query_one("#step-expected", TextArea).text = '{"return_code": 0}'
141
+ self.query_one("#step-timeout", Input).value = ""
142
+ self.query_one("#step-retry-count", Input).value = ""
143
+ status = self.query_one("#step-editor-status", Static)
144
+ status.update("")
145
+
146
+ def _edit_step(self, idx: int) -> None:
147
+ self._editing_idx = idx
148
+ step = self._steps[idx]
149
+ self.query_one("#step-cmd", Input).value = step.command
150
+ self.query_one("#step-args", Input).value = " ".join(step.args)
151
+ self.query_one("#step-expected", TextArea).text = json.dumps(
152
+ step.expected, ensure_ascii=False
153
+ )
154
+ self.query_one("#step-timeout", Input).value = str(step.timeout) if step.timeout else ""
155
+ self.query_one("#step-retry-count", Input).value = str(step.retry_count) if step.retry_count else ""
156
+ status = self.query_one("#step-editor-status", Static)
157
+ status.update(f"Editing Step {idx+1}")
158
+
159
+ def _save_current_step(self) -> None:
160
+ cmd = self.query_one("#step-cmd", Input).value.strip()
161
+ if not cmd:
162
+ self.notify("Command cannot be empty", severity="error")
163
+ return
164
+
165
+ args_text = self.query_one("#step-args", Input).value.strip()
166
+ args = args_text.split() if args_text else []
167
+
168
+ try:
169
+ expected = json.loads(self.query_one("#step-expected", TextArea).text)
170
+ except json.JSONDecodeError:
171
+ self.notify("Expected must be valid JSON", severity="error")
172
+ return
173
+
174
+ timeout_text = self.query_one("#step-timeout", Input).value.strip()
175
+ timeout = float(timeout_text) if timeout_text else None
176
+
177
+ retry_text = self.query_one("#step-retry-count", Input).value.strip()
178
+ retry_count = int(retry_text) if retry_text else 0
179
+
180
+ new_step = TestCaseStep(
181
+ command=cmd, args=args, expected=expected, timeout=timeout, retry_count=retry_count,
182
+ )
183
+
184
+ if self._editing_idx >= 0:
185
+ self._steps[self._editing_idx] = new_step
186
+ else:
187
+ self._steps.append(new_step)
188
+
189
+ self._clear_edit_form()
190
+ self._refresh_list()
191
+ self.post_message(self.Changed())
192
+
193
+ def _delete_step(self, idx: int) -> None:
194
+ del self._steps[idx]
195
+ if self._editing_idx == idx:
196
+ self._clear_edit_form()
197
+ elif self._editing_idx > idx:
198
+ self._editing_idx -= 1
199
+ self._refresh_list()
200
+ self.post_message(self.Changed())
201
+
202
+ def _move_step(self, idx: int, direction: int) -> None:
203
+ new_idx = idx + direction
204
+ if 0 <= new_idx < len(self._steps):
205
+ self._steps[idx], self._steps[new_idx] = self._steps[new_idx], self._steps[idx]
206
+ if self._editing_idx == idx:
207
+ self._editing_idx = new_idx
208
+ elif self._editing_idx == new_idx:
209
+ self._editing_idx = idx
210
+ self._refresh_list()
211
+ self.post_message(self.Changed())
212
+
213
+ # -- button handlers -----------------------------------------------------
214
+
215
+ def on_button_pressed(self, event: Button.Pressed) -> None:
216
+ bid = str(event.button.id)
217
+
218
+ if bid == "step-add":
219
+ self._clear_edit_form()
220
+
221
+ elif bid == "step-save":
222
+ self._save_current_step()
223
+
224
+ elif bid == "step-cancel":
225
+ self._clear_edit_form()
226
+
227
+ elif bid and bid.startswith("step-edit-"):
228
+ idx = int(bid.split("-")[-1])
229
+ if 0 <= idx < len(self._steps):
230
+ self._edit_step(idx)
231
+
232
+ elif bid and bid.startswith("step-del-"):
233
+ idx = int(bid.split("-")[-1])
234
+ if 0 <= idx < len(self._steps):
235
+ self._delete_step(idx)
236
+
237
+ elif bid and bid.startswith("step-up-"):
238
+ idx = int(bid.split("-")[-1])
239
+ self._move_step(idx, -1)
240
+
241
+ elif bid and bid.startswith("step-down-"):
242
+ idx = int(bid.split("-")[-1])
243
+ self._move_step(idx, 1)
@@ -0,0 +1,21 @@
1
+ # File: /python-test-framework/python-test-framework/src/utils/__init__.py
2
+
3
+ """
4
+ Utility functions for the CLI Testing Framework
5
+ """
6
+
7
+ from .path_resolver import (
8
+ PathResolver,
9
+ resolve_paths,
10
+ resolve_command,
11
+ split_command,
12
+ )
13
+ from .junit_xml_writer import write_junit_xml
14
+
15
+ __all__ = [
16
+ 'PathResolver',
17
+ 'resolve_paths',
18
+ 'resolve_command',
19
+ 'split_command',
20
+ 'write_junit_xml',
21
+ ]
@@ -0,0 +1,137 @@
1
+ """JUnit XML report writer for CLI Test Framework.
2
+
3
+ Generates JUnit-format XML compatible with GitLab CI, Jenkins, CircleCI, etc.
4
+ """
5
+
6
+ import socket
7
+ import xml.etree.ElementTree as ET
8
+ from datetime import datetime, timezone
9
+ from typing import Any, Dict, List, Optional
10
+
11
+
12
+ def _xml_escape(text: str) -> str:
13
+ """Escape text for safe embedding in XML (handles control characters)."""
14
+ if text is None:
15
+ return ""
16
+ return "".join(
17
+ ch if ord(ch) >= 0x20 or ch in "\t\n\r" else "?"
18
+ for ch in str(text)
19
+ )
20
+
21
+
22
+ def write_junit_xml(
23
+ results: Dict[str, Any],
24
+ filepath: str,
25
+ suite_name: Optional[str] = None,
26
+ classname: Optional[str] = None,
27
+ ) -> None:
28
+ """Write JUnit XML report for CI tools.
29
+
30
+ Args:
31
+ results: The ``BaseRunner.results`` dict after ``run_tests()``.
32
+ filepath: Output path for the XML file.
33
+ suite_name: Name of the testsuite element. Defaults to ``"cli_tests"``.
34
+ classname: ``classname`` attribute for each testcase. Defaults to suite_name.
35
+ """
36
+ details: List[Dict[str, Any]] = results.get("details", [])
37
+ total = len(details)
38
+ total_time = 0.0
39
+
40
+ suite_name = suite_name or "cli_tests"
41
+ classname = classname or suite_name
42
+
43
+ # First pass: count failures/errors/skipped and total time
44
+ failures_count = 0
45
+ errors_count = 0
46
+ skipped_count = 0
47
+ for detail in details:
48
+ duration = detail.get("duration", 0.0)
49
+ total_time += duration
50
+ status = detail.get("status", "failed")
51
+
52
+ if status in ("passed", "xfailed"):
53
+ pass
54
+ elif status == "xpassed":
55
+ failures_count += 1
56
+ elif status in ("timeout",):
57
+ errors_count += 1
58
+ elif status == "failed":
59
+ message = str(detail.get("message", ""))
60
+ if "Execution error" in message:
61
+ errors_count += 1
62
+ else:
63
+ failures_count += 1
64
+ else:
65
+ errors_count += 1
66
+
67
+ if status == "xfailed":
68
+ skipped_count += 1
69
+
70
+ # Build XML tree
71
+ root = ET.Element("testsuite", {
72
+ "name": suite_name,
73
+ "tests": str(total),
74
+ "failures": str(failures_count),
75
+ "errors": str(errors_count),
76
+ "skipped": str(skipped_count),
77
+ "time": f"{total_time:.3f}",
78
+ "timestamp": datetime.now(timezone.utc).isoformat(),
79
+ "hostname": socket.gethostname(),
80
+ })
81
+
82
+ for detail in details:
83
+ duration = detail.get("duration", 0.0)
84
+ tc = ET.SubElement(root, "testcase", {
85
+ "name": detail.get("name", "unknown"),
86
+ "classname": classname,
87
+ "time": f"{duration:.3f}",
88
+ })
89
+
90
+ status = detail.get("status", "failed")
91
+ message = str(detail.get("message", ""))
92
+ output = str(detail.get("output", ""))
93
+
94
+ if status in ("passed",):
95
+ # No child element = passed in JUnit convention
96
+ pass
97
+ elif status == "xfailed":
98
+ # Expected failure → skipped in JUnit
99
+ reason = detail.get("xfail_reason", "") or ""
100
+ ET.SubElement(tc, "skipped", {
101
+ "message": _xml_escape(reason or "Expected failure"),
102
+ })
103
+ elif status == "xpassed":
104
+ # Unexpected pass → failure
105
+ full_text = f"UNEXPECTED PASS: marked as expected_failure but passed.\n{message}\n\n--- Command Output ---\n{output}" if output else f"UNEXPECTED PASS: marked as expected_failure but passed.\n{message}"
106
+ ET.SubElement(tc, "failure", {
107
+ "message": _xml_escape(message or "UNEXPECTED PASS (xpassed)"),
108
+ "type": "AssertionError",
109
+ }).text = _xml_escape(full_text)
110
+ elif status in ("timeout",):
111
+ ET.SubElement(tc, "error", {
112
+ "message": _xml_escape(message or "Test timed out"),
113
+ "type": "TimeoutExpired",
114
+ }).text = _xml_escape(f"{message}\n\n--- Command Output ---\n{output}" if output else message)
115
+ elif status == "failed":
116
+ full_text = f"{message}\n\n--- Command Output ---\n{output}" if output else message
117
+ if "Execution error" in message:
118
+ ET.SubElement(tc, "error", {
119
+ "message": _xml_escape(message),
120
+ "type": "RuntimeError",
121
+ }).text = _xml_escape(full_text)
122
+ else:
123
+ ET.SubElement(tc, "failure", {
124
+ "message": _xml_escape(message),
125
+ "type": "AssertionError",
126
+ }).text = _xml_escape(full_text)
127
+ else:
128
+ ET.SubElement(tc, "error", {
129
+ "message": _xml_escape(message or f"Unknown status: {status}"),
130
+ "type": "UnknownStatus",
131
+ }).text = _xml_escape(output)
132
+
133
+ # Write
134
+ tree = ET.ElementTree(root)
135
+ with open(filepath, "w", encoding="utf-8") as f:
136
+ f.write('<?xml version="1.0" encoding="UTF-8"?>\n')
137
+ tree.write(f, encoding="unicode")
@@ -0,0 +1,124 @@
1
+ import os
2
+ import shlex
3
+ from pathlib import Path
4
+ from typing import List, Tuple, Union
5
+ import shutil
6
+
7
+
8
+ WorkspaceLike = Union[str, Path]
9
+
10
+ # Commands that are shell builtins (not real executables).
11
+ # Recognised here so they pass through as-is rather than being treated
12
+ # as workspace-relative paths. Wrapping (cmd /d /c …) happens later
13
+ # in execution._normalize_cmd_list.
14
+ if os.name == 'nt':
15
+ _SHELL_BUILTINS = frozenset(['echo', 'dir', 'type', 'copy', 'del', 'ren',
16
+ 'cd', 'md', 'rd', 'set', 'cls', 'move'])
17
+ else:
18
+ _SHELL_BUILTINS = frozenset(['echo', 'cd', 'pwd', 'export', 'source'])
19
+
20
+
21
+ def _as_workspace_path(workspace: WorkspaceLike) -> Path:
22
+ return workspace if isinstance(workspace, Path) else Path(workspace)
23
+
24
+
25
+ def resolve_command(command: str, workspace: WorkspaceLike) -> str:
26
+ """
27
+ Resolve command path:
28
+ - absolute path: keep
29
+ - available in PATH: keep
30
+ - otherwise treat as relative to workspace
31
+ """
32
+ workspace_path = _as_workspace_path(workspace)
33
+
34
+ if Path(command).is_absolute():
35
+ return command
36
+
37
+ if shutil.which(command) is not None:
38
+ return command
39
+
40
+ return str(workspace_path / command)
41
+
42
+
43
+ def _looks_like_relative_path(arg: str) -> bool:
44
+ """Check if arg explicitly starts with a relative path prefix."""
45
+ return (arg.startswith("./") or arg.startswith(".\\") or
46
+ arg.startswith("../") or arg.startswith("..\\"))
47
+
48
+
49
+ def resolve_paths(args: List[str], workspace: WorkspaceLike) -> List[str]:
50
+ """Resolve explicitly relative paths (starting with ./ or ../) against workspace."""
51
+ workspace_path = _as_workspace_path(workspace)
52
+ resolved: List[str] = []
53
+ for arg in args:
54
+ if _looks_like_relative_path(arg):
55
+ resolved.append(str(workspace_path / arg))
56
+ else:
57
+ resolved.append(arg)
58
+ return resolved
59
+
60
+
61
+ def split_command(command_string: str, workspace: WorkspaceLike) -> Tuple[str, List[str]]:
62
+ """
63
+ Split a potentially multi-word command string into its executable and leading arguments.
64
+ Uses shlex to correctly handle quoted executables (e.g. ''"C:\\Program Files\\app.exe" -v'').
65
+
66
+ Example:
67
+ ''"C:\\Python\\python.exe" -c "print('hi')"'' -> ("C:\\Python\\python.exe", ["-c", "print('hi')"])
68
+ "python ./script.py" -> ("python", ["./script.py"])
69
+ "echo hello world" -> ("echo", ["hello", "world"])
70
+ """
71
+ s = command_string.strip()
72
+ if not s:
73
+ return "", []
74
+
75
+ # Use shlex to properly handle quoted segments.
76
+ # posix=False preserves Windows backslash-paths but leaves quotes intact.
77
+ # On Windows we strip quotes manually; on Unix posix=True strips them natively.
78
+ try:
79
+ parts = shlex.split(s, posix=(os.name != 'nt'))
80
+ except ValueError:
81
+ parts = s.split()
82
+ if os.name == 'nt':
83
+ parts = [p.strip('"') for p in parts]
84
+
85
+ cmd = parts[0]
86
+ remaining = parts[1:]
87
+
88
+ # Real executable: absolute path or found in PATH
89
+ if os.path.isabs(cmd) or shutil.which(cmd) is not None:
90
+ return cmd, remaining
91
+
92
+ # Shell builtin -> pass through as-is; wrapping happens at execution time
93
+ if cmd.lower() in _SHELL_BUILTINS:
94
+ return cmd, remaining
95
+
96
+ # Otherwise treat as workspace-relative path
97
+ return str(Path(workspace) / cmd), remaining
98
+
99
+
100
+ class PathResolver:
101
+ """
102
+ Backwards-compatible wrapper; delegates to pure functions.
103
+ """
104
+
105
+ def __init__(self, workspace: Path):
106
+ self.workspace = workspace
107
+
108
+ def resolve_paths(self, args: List[str]) -> List[str]:
109
+ return resolve_paths(args, self.workspace)
110
+
111
+ def resolve_command(self, command: str) -> str:
112
+ return resolve_command(command, self.workspace)
113
+
114
+ def split_command(self, command_string: str) -> Tuple[str, List[str]]:
115
+ """Split and resolve a command string. Returns (executable, leading_args)."""
116
+ return split_command(command_string, self.workspace)
117
+
118
+ def parse_command_string(self, command_string: str) -> str:
119
+ """
120
+ Backward-compatible: resolves and returns the executable name only.
121
+ Prefer split_command() for new code.
122
+ """
123
+ executable, _ = split_command(command_string, self.workspace)
124
+ return executable