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