pycli-dsl 0.1.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.
- pycli/__init__.py +340 -0
- pycli/highlighter.py +122 -0
- pycli/importer.py +214 -0
- pycli/lexer.py +321 -0
- pycli/parser.py +499 -0
- pycli/repl.py +75 -0
- pycli/runtime.py +1647 -0
- pycli/transformer.py +812 -0
- pycli_dsl-0.1.0.dist-info/METADATA +601 -0
- pycli_dsl-0.1.0.dist-info/RECORD +12 -0
- pycli_dsl-0.1.0.dist-info/WHEEL +4 -0
- pycli_dsl-0.1.0.dist-info/entry_points.txt +4 -0
pycli/runtime.py
ADDED
|
@@ -0,0 +1,1647 @@
|
|
|
1
|
+
"""Runtime support for pycli: run(), run_expanded(), run_bg(), async_run(), CommandResult, cd(), env()."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import shlex
|
|
10
|
+
import signal
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
import contextvars
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Iterator, Mapping, Sequence
|
|
19
|
+
|
|
20
|
+
_current_expression: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
|
21
|
+
"_current_expression", default=None
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@contextmanager
|
|
26
|
+
def set_current_expression(expr: str) -> Iterator[None]:
|
|
27
|
+
"""Context manager to set the current .spy original command expression."""
|
|
28
|
+
token = _current_expression.set(expr)
|
|
29
|
+
try:
|
|
30
|
+
yield
|
|
31
|
+
finally:
|
|
32
|
+
_current_expression.reset(token)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _kill_process_tree(proc: subprocess.Popen) -> None:
|
|
36
|
+
"""Terminate the process and all child processes it may have spawned."""
|
|
37
|
+
if proc.poll() is not None:
|
|
38
|
+
return
|
|
39
|
+
try:
|
|
40
|
+
if sys.platform == "win32":
|
|
41
|
+
subprocess.run(
|
|
42
|
+
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
43
|
+
stdout=subprocess.DEVNULL,
|
|
44
|
+
stderr=subprocess.DEVNULL,
|
|
45
|
+
check=False,
|
|
46
|
+
)
|
|
47
|
+
else:
|
|
48
|
+
proc.kill()
|
|
49
|
+
except Exception:
|
|
50
|
+
try:
|
|
51
|
+
proc.kill()
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def shell_quote(val: Any, in_double_quotes: bool = False) -> str:
|
|
57
|
+
"""Quote a value for safe shell interpolation, respecting context and platform."""
|
|
58
|
+
s = str(val)
|
|
59
|
+
if in_double_quotes:
|
|
60
|
+
if sys.platform == "win32":
|
|
61
|
+
# Inside cmd.exe double quotes, escape internal " as \" and % as %%
|
|
62
|
+
return s.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%")
|
|
63
|
+
else:
|
|
64
|
+
# Inside POSIX double quotes, escape ", \, $, `
|
|
65
|
+
return s.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$").replace("`", "\\`")
|
|
66
|
+
else:
|
|
67
|
+
if sys.platform == "win32":
|
|
68
|
+
# Safe characters for cmd.exe unquoted arguments: strictly alphanumeric, '-', '_', '.'
|
|
69
|
+
if s and all(c.isalnum() or c in "-_." for c in s):
|
|
70
|
+
return s
|
|
71
|
+
quoted = subprocess.list2cmdline([s])
|
|
72
|
+
if not (quoted.startswith('"') and quoted.endswith('"')):
|
|
73
|
+
quoted = f'"{quoted}"'
|
|
74
|
+
return quoted.replace("%", "%%")
|
|
75
|
+
else:
|
|
76
|
+
return shlex.quote(s)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ShellOp(str):
|
|
80
|
+
"""Represents an explicit shell operator in command parts."""
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class CommandError(Exception):
|
|
85
|
+
"""Raised when a command executed with check=True fails (non-zero exit code)."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, result: CommandResult) -> None:
|
|
88
|
+
self.result = result
|
|
89
|
+
parts = [f"Command {result.command!r} failed with exit code {result.exit_code}."]
|
|
90
|
+
if result.original_expression:
|
|
91
|
+
parts.append(f"Original expression: {result.original_expression}")
|
|
92
|
+
stderr_text = result.stderr.strip()
|
|
93
|
+
if stderr_text:
|
|
94
|
+
parts.append(f"stderr: {stderr_text}")
|
|
95
|
+
super().__init__("\n".join(parts))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class CommandTimeoutError(CommandError):
|
|
99
|
+
"""Raised when a command exceeds its configured timeout."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class DynamicObj:
|
|
103
|
+
"""Provides JavaScript/PowerShell-style dot attribute access for parsed JSON dictionaries.
|
|
104
|
+
|
|
105
|
+
Supports both attribute access (obj.foo) and dict-style indexing (obj['foo']),
|
|
106
|
+
as well as nested dictionaries and lists.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(self, data: Mapping[str, Any]) -> None:
|
|
110
|
+
self._data: dict[str, Any] = dict(data)
|
|
111
|
+
for key, value in self._data.items():
|
|
112
|
+
self._data[key] = wrap_json(value)
|
|
113
|
+
|
|
114
|
+
def __getattribute__(self, name: str) -> Any:
|
|
115
|
+
if not name.startswith("_") and name != "to_dict":
|
|
116
|
+
try:
|
|
117
|
+
data = object.__getattribute__(self, "_data")
|
|
118
|
+
if name in data:
|
|
119
|
+
return data[name]
|
|
120
|
+
except AttributeError:
|
|
121
|
+
pass
|
|
122
|
+
return object.__getattribute__(self, name)
|
|
123
|
+
|
|
124
|
+
def __getattr__(self, name: str) -> Any:
|
|
125
|
+
if name in self._data:
|
|
126
|
+
return self._data[name]
|
|
127
|
+
raise AttributeError(f"'DynamicObj' object has no attribute {name!r}")
|
|
128
|
+
|
|
129
|
+
def __getitem__(self, key: str) -> Any:
|
|
130
|
+
return self._data[key]
|
|
131
|
+
|
|
132
|
+
def __contains__(self, key: str) -> bool:
|
|
133
|
+
return key in self._data
|
|
134
|
+
|
|
135
|
+
def __iter__(self) -> Iterator[str]:
|
|
136
|
+
return iter(self._data)
|
|
137
|
+
|
|
138
|
+
def __len__(self) -> int:
|
|
139
|
+
return len(self._data)
|
|
140
|
+
|
|
141
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
142
|
+
return self._data.get(key, default)
|
|
143
|
+
|
|
144
|
+
def keys(self):
|
|
145
|
+
return self._data.keys()
|
|
146
|
+
|
|
147
|
+
def values(self):
|
|
148
|
+
return self._data.values()
|
|
149
|
+
|
|
150
|
+
def items(self):
|
|
151
|
+
return self._data.items()
|
|
152
|
+
|
|
153
|
+
def to_dict(self) -> dict[str, Any]:
|
|
154
|
+
"""Convert back to primitive dict recursively."""
|
|
155
|
+
result: dict[str, Any] = {}
|
|
156
|
+
for k, v in self._data.items():
|
|
157
|
+
if isinstance(v, DynamicObj):
|
|
158
|
+
result[k] = v.to_dict()
|
|
159
|
+
elif isinstance(v, list):
|
|
160
|
+
result[k] = [
|
|
161
|
+
item.to_dict() if isinstance(item, DynamicObj) else item
|
|
162
|
+
for item in v
|
|
163
|
+
]
|
|
164
|
+
else:
|
|
165
|
+
result[k] = v
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
def __repr__(self) -> str:
|
|
169
|
+
return f"DynamicObj({self._data!r})"
|
|
170
|
+
|
|
171
|
+
def __eq__(self, other: object) -> bool:
|
|
172
|
+
if isinstance(other, DynamicObj):
|
|
173
|
+
return self._data == other._data
|
|
174
|
+
if isinstance(other, dict):
|
|
175
|
+
return self._data == other
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def wrap_json(data: Any) -> Any:
|
|
180
|
+
"""Recursively wrap dicts into DynamicObj and lists of dicts."""
|
|
181
|
+
if isinstance(data, Mapping):
|
|
182
|
+
return DynamicObj(data)
|
|
183
|
+
if isinstance(data, list):
|
|
184
|
+
return [wrap_json(item) for item in data]
|
|
185
|
+
return data
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class CommandResult:
|
|
189
|
+
"""The result of executing a shell command via pycli."""
|
|
190
|
+
|
|
191
|
+
def __init__(
|
|
192
|
+
self,
|
|
193
|
+
command: str,
|
|
194
|
+
stdout: str,
|
|
195
|
+
stderr: str,
|
|
196
|
+
exit_code: int,
|
|
197
|
+
duration: float,
|
|
198
|
+
*,
|
|
199
|
+
original_expression: str | None = None,
|
|
200
|
+
truncated: bool = False,
|
|
201
|
+
) -> None:
|
|
202
|
+
self.command = command
|
|
203
|
+
self.stdout = stdout
|
|
204
|
+
self.stderr = stderr
|
|
205
|
+
self.exit_code = exit_code
|
|
206
|
+
self.duration = duration
|
|
207
|
+
self.original_expression = original_expression
|
|
208
|
+
self.truncated = truncated
|
|
209
|
+
self._parsed_json: Any = None
|
|
210
|
+
self._json_parsed = False
|
|
211
|
+
|
|
212
|
+
def __bool__(self) -> bool:
|
|
213
|
+
"""Command results evaluate according to exit code (0 -> True, non-zero -> False)."""
|
|
214
|
+
return self.exit_code == 0
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def lines(self) -> list[str]:
|
|
218
|
+
"""Return stdout split into lines with trailing newlines stripped."""
|
|
219
|
+
return [line.rstrip("\r\n") for line in self.stdout.splitlines()]
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def text(self) -> str:
|
|
223
|
+
"""Return stripped stdout content."""
|
|
224
|
+
return self.stdout.strip()
|
|
225
|
+
|
|
226
|
+
def __iter__(self) -> Iterator[str]:
|
|
227
|
+
"""Iterate directly over output lines: for line in $(git status --porcelain):"""
|
|
228
|
+
return iter(self.lines)
|
|
229
|
+
|
|
230
|
+
def __getitem__(self, index: int | slice) -> str | list[str]:
|
|
231
|
+
"""Allow indexing into output lines: $(cmd)[0]."""
|
|
232
|
+
return self.lines[index]
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def json(self) -> Any:
|
|
236
|
+
"""Parse stdout as JSON and return dynamic structures (DynamicObj or list)."""
|
|
237
|
+
if not self._json_parsed:
|
|
238
|
+
content = self.stdout.strip()
|
|
239
|
+
if not content:
|
|
240
|
+
raise ValueError(f"Cannot parse JSON from empty stdout for command: {self.command!r}")
|
|
241
|
+
raw = json.loads(content)
|
|
242
|
+
self._parsed_json = wrap_json(raw)
|
|
243
|
+
self._json_parsed = True
|
|
244
|
+
return self._parsed_json
|
|
245
|
+
|
|
246
|
+
def __str__(self) -> str:
|
|
247
|
+
return self.stdout
|
|
248
|
+
|
|
249
|
+
def __repr__(self) -> str:
|
|
250
|
+
return (
|
|
251
|
+
f"CommandResult(command={self.command!r}, exit_code={self.exit_code}, "
|
|
252
|
+
f"duration={self.duration:.3f}s)"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class BackgroundJob:
|
|
257
|
+
"""Represents a background non-blocking shell process."""
|
|
258
|
+
|
|
259
|
+
def __init__(
|
|
260
|
+
self,
|
|
261
|
+
proc: subprocess.Popen,
|
|
262
|
+
command: str,
|
|
263
|
+
start_time: float,
|
|
264
|
+
*,
|
|
265
|
+
encoding: str = "utf-8",
|
|
266
|
+
original_expression: str | None = None,
|
|
267
|
+
upstream_procs: list[subprocess.Popen] | None = None,
|
|
268
|
+
) -> None:
|
|
269
|
+
self.proc = proc
|
|
270
|
+
self.command = command
|
|
271
|
+
self.start_time = start_time
|
|
272
|
+
self.encoding = encoding
|
|
273
|
+
self.original_expression = original_expression
|
|
274
|
+
self.upstream_procs = upstream_procs or []
|
|
275
|
+
self._result: CommandResult | None = None
|
|
276
|
+
self._upstream_stderr_chunks: list[list[str]] = [[] for _ in self.upstream_procs]
|
|
277
|
+
self._reader_threads: list[threading.Thread] = []
|
|
278
|
+
|
|
279
|
+
def _drain_stream(stream: Any, chunks: list[str]) -> None:
|
|
280
|
+
try:
|
|
281
|
+
for chunk in iter(lambda: stream.read(8192), ""):
|
|
282
|
+
chunks.append(chunk)
|
|
283
|
+
except Exception:
|
|
284
|
+
pass
|
|
285
|
+
finally:
|
|
286
|
+
try:
|
|
287
|
+
stream.close()
|
|
288
|
+
except Exception:
|
|
289
|
+
pass
|
|
290
|
+
|
|
291
|
+
for p_idx, p in enumerate(self.upstream_procs):
|
|
292
|
+
if p.stderr is not None:
|
|
293
|
+
t = threading.Thread(
|
|
294
|
+
target=_drain_stream,
|
|
295
|
+
args=(p.stderr, self._upstream_stderr_chunks[p_idx]),
|
|
296
|
+
daemon=True,
|
|
297
|
+
)
|
|
298
|
+
t.start()
|
|
299
|
+
self._reader_threads.append(t)
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def is_running(self) -> bool:
|
|
303
|
+
"""Check if process is still running."""
|
|
304
|
+
return self.proc.poll() is None
|
|
305
|
+
|
|
306
|
+
def poll(self) -> int | None:
|
|
307
|
+
"""Return exit code if terminated, otherwise None."""
|
|
308
|
+
return self.proc.poll()
|
|
309
|
+
|
|
310
|
+
def kill(self) -> None:
|
|
311
|
+
"""Terminate the process and any upstream processes in the pipeline."""
|
|
312
|
+
for p in self.upstream_procs:
|
|
313
|
+
_kill_process_tree(p)
|
|
314
|
+
_kill_process_tree(self.proc)
|
|
315
|
+
|
|
316
|
+
def wait(self, timeout: float | None = None) -> CommandResult:
|
|
317
|
+
"""Wait for command to finish and return CommandResult."""
|
|
318
|
+
if self._result is not None:
|
|
319
|
+
return self._result
|
|
320
|
+
|
|
321
|
+
deadline = (time.perf_counter() + timeout) if timeout is not None else None
|
|
322
|
+
|
|
323
|
+
def _remaining_timeout() -> float | None:
|
|
324
|
+
if deadline is None:
|
|
325
|
+
return None
|
|
326
|
+
return max(0.0, deadline - time.perf_counter())
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
stdout, stderr = self.proc.communicate(timeout=_remaining_timeout())
|
|
330
|
+
except subprocess.TimeoutExpired as e:
|
|
331
|
+
for p in self.upstream_procs:
|
|
332
|
+
_kill_process_tree(p)
|
|
333
|
+
_kill_process_tree(self.proc)
|
|
334
|
+
for t in self._reader_threads:
|
|
335
|
+
t.join(timeout=0.2)
|
|
336
|
+
try:
|
|
337
|
+
stdout, stderr = self.proc.communicate(timeout=0.5)
|
|
338
|
+
except Exception:
|
|
339
|
+
stdout, stderr = "", ""
|
|
340
|
+
duration = time.perf_counter() - self.start_time
|
|
341
|
+
upstream_err = "".join("".join(c) for c in self._upstream_stderr_chunks)
|
|
342
|
+
total_stderr = (upstream_err + (stderr or "")) if upstream_err else (stderr or "")
|
|
343
|
+
self._result = CommandResult(
|
|
344
|
+
command=self.command,
|
|
345
|
+
stdout=stdout or "",
|
|
346
|
+
stderr=total_stderr,
|
|
347
|
+
exit_code=-1,
|
|
348
|
+
duration=duration,
|
|
349
|
+
original_expression=self.original_expression,
|
|
350
|
+
)
|
|
351
|
+
raise CommandTimeoutError(self._result) from e
|
|
352
|
+
except KeyboardInterrupt:
|
|
353
|
+
try:
|
|
354
|
+
for p in self.upstream_procs:
|
|
355
|
+
_kill_process_tree(p)
|
|
356
|
+
if sys.platform == "win32":
|
|
357
|
+
_kill_process_tree(self.proc)
|
|
358
|
+
else:
|
|
359
|
+
self.proc.send_signal(signal.SIGINT)
|
|
360
|
+
self.proc.wait(timeout=2)
|
|
361
|
+
except Exception:
|
|
362
|
+
_kill_process_tree(self.proc)
|
|
363
|
+
raise
|
|
364
|
+
except BrokenPipeError:
|
|
365
|
+
stdout, stderr = "", ""
|
|
366
|
+
|
|
367
|
+
for p in self.upstream_procs:
|
|
368
|
+
try:
|
|
369
|
+
rem = _remaining_timeout()
|
|
370
|
+
p.wait(timeout=rem if rem is not None else 2.0)
|
|
371
|
+
except Exception:
|
|
372
|
+
_kill_process_tree(p)
|
|
373
|
+
|
|
374
|
+
for t in self._reader_threads:
|
|
375
|
+
t.join(timeout=0.5)
|
|
376
|
+
|
|
377
|
+
duration = time.perf_counter() - self.start_time
|
|
378
|
+
upstream_err = "".join("".join(c) for c in self._upstream_stderr_chunks)
|
|
379
|
+
total_stderr = (upstream_err + (stderr or "")) if upstream_err else (stderr or "")
|
|
380
|
+
|
|
381
|
+
self._result = CommandResult(
|
|
382
|
+
command=self.command,
|
|
383
|
+
stdout=stdout or "",
|
|
384
|
+
stderr=total_stderr,
|
|
385
|
+
exit_code=self.proc.returncode if self.proc.returncode is not None else 0,
|
|
386
|
+
duration=duration,
|
|
387
|
+
original_expression=self.original_expression,
|
|
388
|
+
)
|
|
389
|
+
return self._result
|
|
390
|
+
|
|
391
|
+
@classmethod
|
|
392
|
+
def wait_all(
|
|
393
|
+
cls,
|
|
394
|
+
*jobs: BackgroundJob | Sequence[BackgroundJob],
|
|
395
|
+
timeout: float | None = None,
|
|
396
|
+
) -> list[CommandResult]:
|
|
397
|
+
"""Wait for multiple BackgroundJob instances to finish and return their CommandResults."""
|
|
398
|
+
return wait_all(*jobs, timeout=timeout)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _truncate_output(text: str, max_bytes: int | None, encoding: str) -> tuple[str, bool]:
|
|
402
|
+
if max_bytes is None:
|
|
403
|
+
return text, False
|
|
404
|
+
raw_b = text.encode(encoding, errors="replace")
|
|
405
|
+
if len(raw_b) > max_bytes:
|
|
406
|
+
truncated_text = raw_b[:max_bytes].decode(encoding, errors="replace")
|
|
407
|
+
return truncated_text, True
|
|
408
|
+
return text, False
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def run(
|
|
412
|
+
command: str | Sequence[str],
|
|
413
|
+
*,
|
|
414
|
+
capture: bool = True,
|
|
415
|
+
check: bool = False,
|
|
416
|
+
tee: bool = False,
|
|
417
|
+
input: str | bytes | None = None,
|
|
418
|
+
suppress_errors: bool = False,
|
|
419
|
+
shell: bool | None = None,
|
|
420
|
+
cwd: str | Path | None = None,
|
|
421
|
+
env: Mapping[str, str] | None = None,
|
|
422
|
+
timeout: float | None = None,
|
|
423
|
+
encoding: str = "utf-8",
|
|
424
|
+
max_output_bytes: int | None = None,
|
|
425
|
+
original_expression: str | None = None,
|
|
426
|
+
) -> CommandResult:
|
|
427
|
+
"""Execute a shell command and return a CommandResult.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
command: The shell command string or argument list to execute.
|
|
431
|
+
capture: If False, streams stdout/stderr directly to console without capturing.
|
|
432
|
+
check: If True and exit_code != 0, raises CommandError (Strict Mode).
|
|
433
|
+
tee: If True, streams output live to console AND captures it.
|
|
434
|
+
input: Text or bytes to pipe into process stdin.
|
|
435
|
+
suppress_errors: If True, prevents CommandError from being raised even if check=True.
|
|
436
|
+
shell: Whether to run through system shell. Defaults to True for str, False for list.
|
|
437
|
+
cwd: Directory to execute command in.
|
|
438
|
+
env: Environment variables dict.
|
|
439
|
+
timeout: Timeout in seconds. If exceeded, terminates process and raises CommandTimeoutError.
|
|
440
|
+
encoding: Text encoding for command input and output (default "utf-8").
|
|
441
|
+
max_output_bytes: Maximum stdout/stderr output bytes to capture before truncating.
|
|
442
|
+
original_expression: The original .spy source expression for debugging and observability.
|
|
443
|
+
"""
|
|
444
|
+
if shell is None:
|
|
445
|
+
shell = isinstance(command, str)
|
|
446
|
+
|
|
447
|
+
cmd_str = command if isinstance(command, str) else " ".join(str(c) for c in command)
|
|
448
|
+
cwd_str = str(cwd) if cwd is not None else None
|
|
449
|
+
env_dict = dict(env) if env is not None else None
|
|
450
|
+
|
|
451
|
+
input_text = None
|
|
452
|
+
if input is not None:
|
|
453
|
+
if isinstance(input, bytes):
|
|
454
|
+
input_text = input.decode(encoding, errors="replace")
|
|
455
|
+
else:
|
|
456
|
+
input_text = str(input)
|
|
457
|
+
|
|
458
|
+
start = time.perf_counter()
|
|
459
|
+
if original_expression is None:
|
|
460
|
+
original_expression = _current_expression.get()
|
|
461
|
+
|
|
462
|
+
if not capture and not tee and input_text is None:
|
|
463
|
+
# Native direct passthrough streaming without buffering
|
|
464
|
+
proc = subprocess.Popen(
|
|
465
|
+
command,
|
|
466
|
+
shell=shell,
|
|
467
|
+
stdout=None,
|
|
468
|
+
stderr=None,
|
|
469
|
+
cwd=cwd_str,
|
|
470
|
+
env=env_dict,
|
|
471
|
+
)
|
|
472
|
+
try:
|
|
473
|
+
proc.wait(timeout=timeout)
|
|
474
|
+
except subprocess.TimeoutExpired as e:
|
|
475
|
+
_kill_process_tree(proc)
|
|
476
|
+
try:
|
|
477
|
+
proc.wait(timeout=1)
|
|
478
|
+
except Exception:
|
|
479
|
+
pass
|
|
480
|
+
duration = time.perf_counter() - start
|
|
481
|
+
result = CommandResult(
|
|
482
|
+
command=cmd_str,
|
|
483
|
+
stdout="",
|
|
484
|
+
stderr="",
|
|
485
|
+
exit_code=-1,
|
|
486
|
+
duration=duration,
|
|
487
|
+
original_expression=original_expression,
|
|
488
|
+
)
|
|
489
|
+
raise CommandTimeoutError(result) from e
|
|
490
|
+
except KeyboardInterrupt:
|
|
491
|
+
try:
|
|
492
|
+
if sys.platform == "win32":
|
|
493
|
+
_kill_process_tree(proc)
|
|
494
|
+
else:
|
|
495
|
+
proc.send_signal(signal.SIGINT)
|
|
496
|
+
proc.wait(timeout=2)
|
|
497
|
+
except Exception:
|
|
498
|
+
_kill_process_tree(proc)
|
|
499
|
+
raise
|
|
500
|
+
|
|
501
|
+
duration = time.perf_counter() - start
|
|
502
|
+
result = CommandResult(
|
|
503
|
+
command=cmd_str,
|
|
504
|
+
stdout="",
|
|
505
|
+
stderr="",
|
|
506
|
+
exit_code=proc.returncode if proc.returncode is not None else 0,
|
|
507
|
+
duration=duration,
|
|
508
|
+
original_expression=original_expression,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
elif tee:
|
|
512
|
+
# Live streaming while capturing
|
|
513
|
+
proc = subprocess.Popen(
|
|
514
|
+
command,
|
|
515
|
+
shell=shell,
|
|
516
|
+
stdin=subprocess.PIPE if input_text is not None else None,
|
|
517
|
+
stdout=subprocess.PIPE,
|
|
518
|
+
stderr=subprocess.PIPE,
|
|
519
|
+
text=True,
|
|
520
|
+
encoding=encoding,
|
|
521
|
+
errors="replace",
|
|
522
|
+
bufsize=1,
|
|
523
|
+
cwd=cwd_str,
|
|
524
|
+
env=env_dict,
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
stdout_chunks: list[str] = []
|
|
528
|
+
stderr_chunks: list[str] = []
|
|
529
|
+
|
|
530
|
+
def reader(pipe, out_stream, chunks):
|
|
531
|
+
try:
|
|
532
|
+
for line in iter(pipe.readline, ""):
|
|
533
|
+
out_stream.write(line)
|
|
534
|
+
out_stream.flush()
|
|
535
|
+
chunks.append(line)
|
|
536
|
+
finally:
|
|
537
|
+
pipe.close()
|
|
538
|
+
|
|
539
|
+
t_out = threading.Thread(target=reader, args=(proc.stdout, sys.stdout, stdout_chunks))
|
|
540
|
+
t_err = threading.Thread(target=reader, args=(proc.stderr, sys.stderr, stderr_chunks))
|
|
541
|
+
t_out.start()
|
|
542
|
+
t_err.start()
|
|
543
|
+
|
|
544
|
+
if input_text is not None and proc.stdin:
|
|
545
|
+
proc.stdin.write(input_text)
|
|
546
|
+
proc.stdin.close()
|
|
547
|
+
|
|
548
|
+
try:
|
|
549
|
+
proc.wait(timeout=timeout)
|
|
550
|
+
except subprocess.TimeoutExpired as e:
|
|
551
|
+
proc.kill()
|
|
552
|
+
t_out.join(timeout=2)
|
|
553
|
+
t_err.join(timeout=2)
|
|
554
|
+
duration = time.perf_counter() - start
|
|
555
|
+
result = CommandResult(
|
|
556
|
+
command=cmd_str,
|
|
557
|
+
stdout="".join(stdout_chunks),
|
|
558
|
+
stderr="".join(stderr_chunks),
|
|
559
|
+
exit_code=-1,
|
|
560
|
+
duration=duration,
|
|
561
|
+
original_expression=original_expression,
|
|
562
|
+
)
|
|
563
|
+
raise CommandTimeoutError(result) from e
|
|
564
|
+
except KeyboardInterrupt:
|
|
565
|
+
try:
|
|
566
|
+
if sys.platform == "win32":
|
|
567
|
+
_kill_process_tree(proc)
|
|
568
|
+
else:
|
|
569
|
+
proc.send_signal(signal.SIGINT)
|
|
570
|
+
proc.wait(timeout=2)
|
|
571
|
+
except Exception:
|
|
572
|
+
_kill_process_tree(proc)
|
|
573
|
+
t_out.join(timeout=2)
|
|
574
|
+
t_err.join(timeout=2)
|
|
575
|
+
raise
|
|
576
|
+
|
|
577
|
+
t_out.join()
|
|
578
|
+
t_err.join()
|
|
579
|
+
|
|
580
|
+
duration = time.perf_counter() - start
|
|
581
|
+
raw_stdout = "".join(stdout_chunks)
|
|
582
|
+
raw_stderr = "".join(stderr_chunks)
|
|
583
|
+
|
|
584
|
+
stdout, out_trunc = _truncate_output(raw_stdout, max_output_bytes, encoding)
|
|
585
|
+
stderr, err_trunc = _truncate_output(raw_stderr, max_output_bytes, encoding)
|
|
586
|
+
|
|
587
|
+
result = CommandResult(
|
|
588
|
+
command=cmd_str,
|
|
589
|
+
stdout=stdout,
|
|
590
|
+
stderr=stderr,
|
|
591
|
+
exit_code=proc.returncode if proc.returncode is not None else 0,
|
|
592
|
+
duration=duration,
|
|
593
|
+
original_expression=original_expression,
|
|
594
|
+
truncated=(out_trunc or err_trunc),
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
else:
|
|
598
|
+
# Standard subprocess run
|
|
599
|
+
proc = subprocess.Popen(
|
|
600
|
+
command,
|
|
601
|
+
shell=shell,
|
|
602
|
+
stdin=subprocess.PIPE if input_text is not None else None,
|
|
603
|
+
stdout=subprocess.PIPE,
|
|
604
|
+
stderr=subprocess.PIPE,
|
|
605
|
+
text=True,
|
|
606
|
+
encoding=encoding,
|
|
607
|
+
errors="replace",
|
|
608
|
+
cwd=cwd_str,
|
|
609
|
+
env=env_dict,
|
|
610
|
+
)
|
|
611
|
+
try:
|
|
612
|
+
raw_stdout, raw_stderr = proc.communicate(input=input_text, timeout=timeout)
|
|
613
|
+
except subprocess.TimeoutExpired as e:
|
|
614
|
+
_kill_process_tree(proc)
|
|
615
|
+
try:
|
|
616
|
+
raw_stdout, raw_stderr = proc.communicate(timeout=1)
|
|
617
|
+
except Exception:
|
|
618
|
+
raw_stdout, raw_stderr = "", ""
|
|
619
|
+
duration = time.perf_counter() - start
|
|
620
|
+
result = CommandResult(
|
|
621
|
+
command=cmd_str,
|
|
622
|
+
stdout=raw_stdout or "",
|
|
623
|
+
stderr=raw_stderr or "",
|
|
624
|
+
exit_code=-1,
|
|
625
|
+
duration=duration,
|
|
626
|
+
original_expression=original_expression,
|
|
627
|
+
)
|
|
628
|
+
raise CommandTimeoutError(result) from e
|
|
629
|
+
except KeyboardInterrupt:
|
|
630
|
+
try:
|
|
631
|
+
if sys.platform == "win32":
|
|
632
|
+
_kill_process_tree(proc)
|
|
633
|
+
else:
|
|
634
|
+
proc.send_signal(signal.SIGINT)
|
|
635
|
+
proc.wait(timeout=2)
|
|
636
|
+
except Exception:
|
|
637
|
+
_kill_process_tree(proc)
|
|
638
|
+
raise
|
|
639
|
+
|
|
640
|
+
duration = time.perf_counter() - start
|
|
641
|
+
|
|
642
|
+
stdout, out_trunc = _truncate_output(raw_stdout or "", max_output_bytes, encoding)
|
|
643
|
+
stderr, err_trunc = _truncate_output(raw_stderr or "", max_output_bytes, encoding)
|
|
644
|
+
|
|
645
|
+
if not capture:
|
|
646
|
+
if stdout:
|
|
647
|
+
sys.stdout.write(stdout)
|
|
648
|
+
sys.stdout.flush()
|
|
649
|
+
if stderr:
|
|
650
|
+
sys.stderr.write(stderr)
|
|
651
|
+
sys.stderr.flush()
|
|
652
|
+
|
|
653
|
+
result = CommandResult(
|
|
654
|
+
command=cmd_str,
|
|
655
|
+
stdout=stdout,
|
|
656
|
+
stderr=stderr,
|
|
657
|
+
exit_code=proc.returncode if proc.returncode is not None else 0,
|
|
658
|
+
duration=duration,
|
|
659
|
+
original_expression=original_expression,
|
|
660
|
+
truncated=(out_trunc or err_trunc),
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
if check and not suppress_errors and result.exit_code != 0:
|
|
664
|
+
raise CommandError(result)
|
|
665
|
+
|
|
666
|
+
return result
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
@dataclass
|
|
670
|
+
class _PipelineStage:
|
|
671
|
+
argv: list[str]
|
|
672
|
+
stdin_file: str | None = None
|
|
673
|
+
stdout_file: str | None = None
|
|
674
|
+
stdout_mode: str = "w"
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _parse_stages_from_parts(flat_parts: list[Any]) -> list[_PipelineStage]:
|
|
678
|
+
raw_stages: list[list[Any]] = []
|
|
679
|
+
current_raw: list[Any] = []
|
|
680
|
+
for p in flat_parts:
|
|
681
|
+
if isinstance(p, ShellOp) and str(p).strip() == "|":
|
|
682
|
+
raw_stages.append(current_raw)
|
|
683
|
+
current_raw = []
|
|
684
|
+
else:
|
|
685
|
+
current_raw.append(p)
|
|
686
|
+
raw_stages.append(current_raw)
|
|
687
|
+
|
|
688
|
+
stages: list[_PipelineStage] = []
|
|
689
|
+
for raw in raw_stages:
|
|
690
|
+
argv: list[str] = []
|
|
691
|
+
stdin_file: str | None = None
|
|
692
|
+
stdout_file: str | None = None
|
|
693
|
+
stdout_mode = "w"
|
|
694
|
+
i = 0
|
|
695
|
+
n = len(raw)
|
|
696
|
+
while i < n:
|
|
697
|
+
item = raw[i]
|
|
698
|
+
if isinstance(item, ShellOp):
|
|
699
|
+
op_str = str(item).strip()
|
|
700
|
+
if op_str == ">":
|
|
701
|
+
if i + 1 < n:
|
|
702
|
+
stdout_file = str(raw[i + 1])
|
|
703
|
+
stdout_mode = "w"
|
|
704
|
+
i += 2
|
|
705
|
+
continue
|
|
706
|
+
elif op_str == ">>":
|
|
707
|
+
if i + 1 < n:
|
|
708
|
+
stdout_file = str(raw[i + 1])
|
|
709
|
+
stdout_mode = "a"
|
|
710
|
+
i += 2
|
|
711
|
+
continue
|
|
712
|
+
elif op_str == "<":
|
|
713
|
+
if i + 1 < n:
|
|
714
|
+
stdin_file = str(raw[i + 1])
|
|
715
|
+
i += 2
|
|
716
|
+
continue
|
|
717
|
+
elif op_str.startswith(">>"):
|
|
718
|
+
stdout_file = op_str[2:].strip()
|
|
719
|
+
stdout_mode = "a"
|
|
720
|
+
i += 1
|
|
721
|
+
continue
|
|
722
|
+
elif op_str.startswith(">"):
|
|
723
|
+
stdout_file = op_str[1:].strip()
|
|
724
|
+
stdout_mode = "w"
|
|
725
|
+
i += 1
|
|
726
|
+
continue
|
|
727
|
+
elif op_str.startswith("<"):
|
|
728
|
+
stdin_file = op_str[1:].strip()
|
|
729
|
+
i += 1
|
|
730
|
+
continue
|
|
731
|
+
else:
|
|
732
|
+
argv.append(str(item))
|
|
733
|
+
i += 1
|
|
734
|
+
continue
|
|
735
|
+
else:
|
|
736
|
+
argv.append(str(item))
|
|
737
|
+
i += 1
|
|
738
|
+
stages.append(
|
|
739
|
+
_PipelineStage(
|
|
740
|
+
argv=argv,
|
|
741
|
+
stdin_file=stdin_file,
|
|
742
|
+
stdout_file=stdout_file,
|
|
743
|
+
stdout_mode=stdout_mode,
|
|
744
|
+
)
|
|
745
|
+
)
|
|
746
|
+
return stages
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _execute_pipeline_stages(
|
|
750
|
+
stages: list[_PipelineStage],
|
|
751
|
+
*,
|
|
752
|
+
flat_parts: list[Any],
|
|
753
|
+
capture: bool,
|
|
754
|
+
check: bool,
|
|
755
|
+
tee: bool,
|
|
756
|
+
input: str | bytes | None,
|
|
757
|
+
suppress_errors: bool,
|
|
758
|
+
cwd: str | Path | None,
|
|
759
|
+
env: Mapping[str, str] | None,
|
|
760
|
+
timeout: float | None,
|
|
761
|
+
encoding: str,
|
|
762
|
+
max_output_bytes: int | None,
|
|
763
|
+
original_expression: str | None,
|
|
764
|
+
) -> CommandResult:
|
|
765
|
+
opened_files: list[Any] = []
|
|
766
|
+
processes: list[subprocess.Popen] = []
|
|
767
|
+
start = time.perf_counter()
|
|
768
|
+
full_cmd_str = " ".join(str(p) for p in flat_parts)
|
|
769
|
+
|
|
770
|
+
cwd_str = str(cwd) if cwd is not None else None
|
|
771
|
+
env_dict = dict(env) if env is not None else None
|
|
772
|
+
|
|
773
|
+
input_text = None
|
|
774
|
+
if input is not None:
|
|
775
|
+
if isinstance(input, bytes):
|
|
776
|
+
input_text = input.decode(encoding, errors="replace")
|
|
777
|
+
else:
|
|
778
|
+
input_text = str(input)
|
|
779
|
+
|
|
780
|
+
feeder_threads: list[threading.Thread] = []
|
|
781
|
+
|
|
782
|
+
try:
|
|
783
|
+
prev_stdout: Any = None
|
|
784
|
+
|
|
785
|
+
for idx, stage in enumerate(stages):
|
|
786
|
+
is_first = (idx == 0)
|
|
787
|
+
is_last = (idx == len(stages) - 1)
|
|
788
|
+
|
|
789
|
+
# Determine stage_stdin
|
|
790
|
+
if is_first:
|
|
791
|
+
if stage.stdin_file:
|
|
792
|
+
f_in = open(stage.stdin_file, "r", encoding=encoding, errors="replace")
|
|
793
|
+
opened_files.append(f_in)
|
|
794
|
+
stage_stdin = f_in
|
|
795
|
+
elif input_text is not None:
|
|
796
|
+
stage_stdin = subprocess.PIPE
|
|
797
|
+
else:
|
|
798
|
+
stage_stdin = None
|
|
799
|
+
else:
|
|
800
|
+
stage_stdin = prev_stdout
|
|
801
|
+
|
|
802
|
+
# Determine stage_stdout
|
|
803
|
+
if is_last:
|
|
804
|
+
if stage.stdout_file:
|
|
805
|
+
f_out = open(stage.stdout_file, stage.stdout_mode, encoding=encoding, errors="replace")
|
|
806
|
+
opened_files.append(f_out)
|
|
807
|
+
stage_stdout = f_out
|
|
808
|
+
elif capture or tee:
|
|
809
|
+
stage_stdout = subprocess.PIPE
|
|
810
|
+
else:
|
|
811
|
+
stage_stdout = None
|
|
812
|
+
else:
|
|
813
|
+
stage_stdout = subprocess.PIPE
|
|
814
|
+
|
|
815
|
+
# Handle portable echo stage vs external process
|
|
816
|
+
if stage.argv and stage.argv[0] == "echo":
|
|
817
|
+
echo_text = " ".join(stage.argv[1:]) + "\n"
|
|
818
|
+
if is_last:
|
|
819
|
+
if stage.stdout_file:
|
|
820
|
+
f_out.write(echo_text)
|
|
821
|
+
f_out.flush()
|
|
822
|
+
proc_out = ""
|
|
823
|
+
else:
|
|
824
|
+
proc_out = echo_text
|
|
825
|
+
duration = time.perf_counter() - start
|
|
826
|
+
if tee and proc_out:
|
|
827
|
+
sys.stdout.write(proc_out)
|
|
828
|
+
sys.stdout.flush()
|
|
829
|
+
elif not capture and proc_out:
|
|
830
|
+
sys.stdout.write(proc_out)
|
|
831
|
+
sys.stdout.flush()
|
|
832
|
+
proc_out = ""
|
|
833
|
+
stdout, out_trunc = _truncate_output(proc_out, max_output_bytes, encoding)
|
|
834
|
+
res = CommandResult(
|
|
835
|
+
command=full_cmd_str,
|
|
836
|
+
stdout=stdout,
|
|
837
|
+
stderr="",
|
|
838
|
+
exit_code=0,
|
|
839
|
+
duration=duration,
|
|
840
|
+
original_expression=original_expression,
|
|
841
|
+
truncated=out_trunc,
|
|
842
|
+
)
|
|
843
|
+
return res
|
|
844
|
+
else:
|
|
845
|
+
r_fd, w_fd = os.pipe()
|
|
846
|
+
echo_bytes = echo_text.encode(encoding)
|
|
847
|
+
|
|
848
|
+
def _feed_echo(fd: int, data: bytes) -> None:
|
|
849
|
+
try:
|
|
850
|
+
with os.fdopen(fd, "wb") as f:
|
|
851
|
+
f.write(data)
|
|
852
|
+
except (BrokenPipeError, OSError):
|
|
853
|
+
pass
|
|
854
|
+
|
|
855
|
+
t_feeder = threading.Thread(
|
|
856
|
+
target=_feed_echo, args=(w_fd, echo_bytes), daemon=True
|
|
857
|
+
)
|
|
858
|
+
t_feeder.start()
|
|
859
|
+
feeder_threads.append(t_feeder)
|
|
860
|
+
prev_stdout = r_fd
|
|
861
|
+
continue
|
|
862
|
+
else:
|
|
863
|
+
proc = subprocess.Popen(
|
|
864
|
+
stage.argv,
|
|
865
|
+
shell=False,
|
|
866
|
+
stdin=stage_stdin,
|
|
867
|
+
stdout=stage_stdout,
|
|
868
|
+
stderr=subprocess.PIPE,
|
|
869
|
+
text=True,
|
|
870
|
+
encoding=encoding,
|
|
871
|
+
errors="replace",
|
|
872
|
+
cwd=cwd_str,
|
|
873
|
+
env=env_dict,
|
|
874
|
+
)
|
|
875
|
+
processes.append(proc)
|
|
876
|
+
if prev_stdout is not None:
|
|
877
|
+
if isinstance(prev_stdout, int):
|
|
878
|
+
try:
|
|
879
|
+
os.close(prev_stdout)
|
|
880
|
+
except OSError:
|
|
881
|
+
pass
|
|
882
|
+
elif hasattr(prev_stdout, "close"):
|
|
883
|
+
prev_stdout.close()
|
|
884
|
+
prev_stdout = proc.stdout
|
|
885
|
+
|
|
886
|
+
reader_threads: list[threading.Thread] = []
|
|
887
|
+
stderr_chunks: list[list[str]] = [[] for _ in processes]
|
|
888
|
+
stdout_chunks: list[str] = []
|
|
889
|
+
|
|
890
|
+
def _drain_stream(stream: Any, chunks: list[str]) -> None:
|
|
891
|
+
try:
|
|
892
|
+
for chunk in iter(lambda: stream.read(8192), ""):
|
|
893
|
+
chunks.append(chunk)
|
|
894
|
+
except Exception:
|
|
895
|
+
pass
|
|
896
|
+
finally:
|
|
897
|
+
try:
|
|
898
|
+
stream.close()
|
|
899
|
+
except Exception:
|
|
900
|
+
pass
|
|
901
|
+
|
|
902
|
+
# Concurrently drain stderr of all pipeline processes
|
|
903
|
+
for p_idx, p in enumerate(processes):
|
|
904
|
+
if p.stderr is not None:
|
|
905
|
+
t_err = threading.Thread(
|
|
906
|
+
target=_drain_stream, args=(p.stderr, stderr_chunks[p_idx]), daemon=True
|
|
907
|
+
)
|
|
908
|
+
t_err.start()
|
|
909
|
+
reader_threads.append(t_err)
|
|
910
|
+
|
|
911
|
+
# Concurrently drain stdout of the final process if piped
|
|
912
|
+
if processes and processes[-1].stdout is not None:
|
|
913
|
+
t_out = threading.Thread(
|
|
914
|
+
target=_drain_stream, args=(processes[-1].stdout, stdout_chunks), daemon=True
|
|
915
|
+
)
|
|
916
|
+
t_out.start()
|
|
917
|
+
reader_threads.append(t_out)
|
|
918
|
+
|
|
919
|
+
# Feed input to first stage if needed
|
|
920
|
+
if input_text is not None and processes and processes[0].stdin is not None:
|
|
921
|
+
def _feed_stdin(stream: Any, text: str) -> None:
|
|
922
|
+
try:
|
|
923
|
+
stream.write(text)
|
|
924
|
+
stream.close()
|
|
925
|
+
except (BrokenPipeError, OSError):
|
|
926
|
+
pass
|
|
927
|
+
|
|
928
|
+
t_in = threading.Thread(
|
|
929
|
+
target=_feed_stdin, args=(processes[0].stdin, input_text), daemon=True
|
|
930
|
+
)
|
|
931
|
+
t_in.start()
|
|
932
|
+
reader_threads.append(t_in)
|
|
933
|
+
|
|
934
|
+
deadline = (time.perf_counter() + timeout) if timeout is not None else None
|
|
935
|
+
|
|
936
|
+
def _remaining_timeout() -> float | None:
|
|
937
|
+
if deadline is None:
|
|
938
|
+
return None
|
|
939
|
+
rem = deadline - time.perf_counter()
|
|
940
|
+
return max(0.0, rem)
|
|
941
|
+
|
|
942
|
+
if processes:
|
|
943
|
+
last_proc = processes[-1]
|
|
944
|
+
try:
|
|
945
|
+
rem = _remaining_timeout()
|
|
946
|
+
last_proc.wait(timeout=rem)
|
|
947
|
+
except subprocess.TimeoutExpired as e:
|
|
948
|
+
for p in processes:
|
|
949
|
+
_kill_process_tree(p)
|
|
950
|
+
for t in reader_threads:
|
|
951
|
+
t.join(timeout=0.5)
|
|
952
|
+
for t in feeder_threads:
|
|
953
|
+
t.join(timeout=0.5)
|
|
954
|
+
duration = time.perf_counter() - start
|
|
955
|
+
res = CommandResult(
|
|
956
|
+
command=full_cmd_str,
|
|
957
|
+
stdout="",
|
|
958
|
+
stderr="",
|
|
959
|
+
exit_code=-1,
|
|
960
|
+
duration=duration,
|
|
961
|
+
original_expression=original_expression,
|
|
962
|
+
)
|
|
963
|
+
raise CommandTimeoutError(res) from e
|
|
964
|
+
except KeyboardInterrupt:
|
|
965
|
+
for p in processes:
|
|
966
|
+
if sys.platform == "win32":
|
|
967
|
+
_kill_process_tree(p)
|
|
968
|
+
else:
|
|
969
|
+
try:
|
|
970
|
+
p.send_signal(signal.SIGINT)
|
|
971
|
+
p.wait(timeout=2)
|
|
972
|
+
except Exception:
|
|
973
|
+
_kill_process_tree(p)
|
|
974
|
+
for t in reader_threads:
|
|
975
|
+
t.join(timeout=0.5)
|
|
976
|
+
for t in feeder_threads:
|
|
977
|
+
t.join(timeout=0.5)
|
|
978
|
+
raise
|
|
979
|
+
|
|
980
|
+
for p in processes[:-1]:
|
|
981
|
+
try:
|
|
982
|
+
rem = _remaining_timeout()
|
|
983
|
+
p.wait(timeout=rem if rem is not None else 2)
|
|
984
|
+
except subprocess.TimeoutExpired as e:
|
|
985
|
+
for proc_to_kill in processes:
|
|
986
|
+
_kill_process_tree(proc_to_kill)
|
|
987
|
+
for t in reader_threads:
|
|
988
|
+
t.join(timeout=0.5)
|
|
989
|
+
for t in feeder_threads:
|
|
990
|
+
t.join(timeout=0.5)
|
|
991
|
+
duration = time.perf_counter() - start
|
|
992
|
+
res = CommandResult(
|
|
993
|
+
command=full_cmd_str,
|
|
994
|
+
stdout="",
|
|
995
|
+
stderr="",
|
|
996
|
+
exit_code=-1,
|
|
997
|
+
duration=duration,
|
|
998
|
+
original_expression=original_expression,
|
|
999
|
+
)
|
|
1000
|
+
raise CommandTimeoutError(res) from e
|
|
1001
|
+
except Exception:
|
|
1002
|
+
_kill_process_tree(p)
|
|
1003
|
+
|
|
1004
|
+
for t in reader_threads:
|
|
1005
|
+
t.join(timeout=2)
|
|
1006
|
+
for t in feeder_threads:
|
|
1007
|
+
t.join(timeout=2)
|
|
1008
|
+
|
|
1009
|
+
exit_code = last_proc.returncode if last_proc.returncode is not None else 0
|
|
1010
|
+
raw_stdout = "".join(stdout_chunks)
|
|
1011
|
+
raw_stderr = "".join("".join(chunks) for chunks in stderr_chunks)
|
|
1012
|
+
else:
|
|
1013
|
+
exit_code = 0
|
|
1014
|
+
raw_stdout = ""
|
|
1015
|
+
raw_stderr = ""
|
|
1016
|
+
|
|
1017
|
+
duration = time.perf_counter() - start
|
|
1018
|
+
stdout, out_trunc = _truncate_output(raw_stdout or "", max_output_bytes, encoding)
|
|
1019
|
+
stderr, err_trunc = _truncate_output(raw_stderr or "", max_output_bytes, encoding)
|
|
1020
|
+
|
|
1021
|
+
if tee and stdout:
|
|
1022
|
+
sys.stdout.write(stdout)
|
|
1023
|
+
sys.stdout.flush()
|
|
1024
|
+
elif not capture and stdout:
|
|
1025
|
+
sys.stdout.write(stdout)
|
|
1026
|
+
sys.stdout.flush()
|
|
1027
|
+
stdout = ""
|
|
1028
|
+
|
|
1029
|
+
res = CommandResult(
|
|
1030
|
+
command=full_cmd_str,
|
|
1031
|
+
stdout=stdout,
|
|
1032
|
+
stderr=stderr,
|
|
1033
|
+
exit_code=exit_code,
|
|
1034
|
+
duration=duration,
|
|
1035
|
+
original_expression=original_expression,
|
|
1036
|
+
truncated=(out_trunc or err_trunc),
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
if check and not suppress_errors and res.exit_code != 0:
|
|
1040
|
+
raise CommandError(res)
|
|
1041
|
+
|
|
1042
|
+
return res
|
|
1043
|
+
|
|
1044
|
+
finally:
|
|
1045
|
+
for f in opened_files:
|
|
1046
|
+
try:
|
|
1047
|
+
f.close()
|
|
1048
|
+
except Exception:
|
|
1049
|
+
pass
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def run_expanded(
|
|
1053
|
+
*parts: Any,
|
|
1054
|
+
capture: bool = True,
|
|
1055
|
+
check: bool = False,
|
|
1056
|
+
tee: bool = False,
|
|
1057
|
+
input: str | bytes | None = None,
|
|
1058
|
+
suppress_errors: bool = False,
|
|
1059
|
+
shell: bool | None = None,
|
|
1060
|
+
cwd: str | Path | None = None,
|
|
1061
|
+
env: Mapping[str, str] | None = None,
|
|
1062
|
+
timeout: float | None = None,
|
|
1063
|
+
encoding: str = "utf-8",
|
|
1064
|
+
max_output_bytes: int | None = None,
|
|
1065
|
+
original_expression: str | None = None,
|
|
1066
|
+
) -> CommandResult:
|
|
1067
|
+
"""Execute a command composed of multiple parts, supporting splat list expansion."""
|
|
1068
|
+
flat_parts: list[Any] = []
|
|
1069
|
+
for part in parts:
|
|
1070
|
+
if isinstance(part, (list, tuple, set)):
|
|
1071
|
+
for item in part:
|
|
1072
|
+
flat_parts.append(item)
|
|
1073
|
+
else:
|
|
1074
|
+
flat_parts.append(part)
|
|
1075
|
+
|
|
1076
|
+
has_operator = any(isinstance(p, ShellOp) for p in flat_parts)
|
|
1077
|
+
|
|
1078
|
+
if shell is False and has_operator:
|
|
1079
|
+
raise ValueError(
|
|
1080
|
+
"run_expanded with shell=False cannot accept typed ShellOp operators "
|
|
1081
|
+
"(pipeline or redirection). Execute without shell=False or omit shell parameter."
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
if shell is True:
|
|
1085
|
+
cmd_tokens: list[str] = []
|
|
1086
|
+
for p in flat_parts:
|
|
1087
|
+
if isinstance(p, ShellOp):
|
|
1088
|
+
cmd_tokens.append(str(p))
|
|
1089
|
+
else:
|
|
1090
|
+
cmd_tokens.append(shell_quote(p))
|
|
1091
|
+
command_str = " ".join(cmd_tokens)
|
|
1092
|
+
return run(
|
|
1093
|
+
command_str,
|
|
1094
|
+
capture=capture,
|
|
1095
|
+
check=check,
|
|
1096
|
+
tee=tee,
|
|
1097
|
+
input=input,
|
|
1098
|
+
suppress_errors=suppress_errors,
|
|
1099
|
+
shell=True,
|
|
1100
|
+
cwd=cwd,
|
|
1101
|
+
env=env,
|
|
1102
|
+
timeout=timeout,
|
|
1103
|
+
encoding=encoding,
|
|
1104
|
+
max_output_bytes=max_output_bytes,
|
|
1105
|
+
original_expression=original_expression,
|
|
1106
|
+
)
|
|
1107
|
+
|
|
1108
|
+
stages = _parse_stages_from_parts(flat_parts)
|
|
1109
|
+
|
|
1110
|
+
# Single stage without redirection: execute directly with shell=False
|
|
1111
|
+
if len(stages) == 1 and stages[0].stdin_file is None and stages[0].stdout_file is None:
|
|
1112
|
+
stage = stages[0]
|
|
1113
|
+
if not stage.argv:
|
|
1114
|
+
return CommandResult(
|
|
1115
|
+
command="",
|
|
1116
|
+
stdout="",
|
|
1117
|
+
stderr="",
|
|
1118
|
+
exit_code=0,
|
|
1119
|
+
duration=0.0,
|
|
1120
|
+
original_expression=original_expression,
|
|
1121
|
+
)
|
|
1122
|
+
if stage.argv[0] == "echo":
|
|
1123
|
+
start = time.perf_counter()
|
|
1124
|
+
echo_text = " ".join(stage.argv[1:]) + "\n"
|
|
1125
|
+
duration = time.perf_counter() - start
|
|
1126
|
+
if tee:
|
|
1127
|
+
sys.stdout.write(echo_text)
|
|
1128
|
+
sys.stdout.flush()
|
|
1129
|
+
proc_out = echo_text
|
|
1130
|
+
elif not capture:
|
|
1131
|
+
sys.stdout.write(echo_text)
|
|
1132
|
+
sys.stdout.flush()
|
|
1133
|
+
proc_out = ""
|
|
1134
|
+
else:
|
|
1135
|
+
proc_out = echo_text
|
|
1136
|
+
stdout, out_trunc = _truncate_output(proc_out, max_output_bytes, encoding)
|
|
1137
|
+
return CommandResult(
|
|
1138
|
+
command=" ".join(stage.argv),
|
|
1139
|
+
stdout=stdout,
|
|
1140
|
+
stderr="",
|
|
1141
|
+
exit_code=0,
|
|
1142
|
+
duration=duration,
|
|
1143
|
+
original_expression=original_expression,
|
|
1144
|
+
truncated=out_trunc,
|
|
1145
|
+
)
|
|
1146
|
+
|
|
1147
|
+
return run(
|
|
1148
|
+
stage.argv,
|
|
1149
|
+
capture=capture,
|
|
1150
|
+
check=check,
|
|
1151
|
+
tee=tee,
|
|
1152
|
+
input=input,
|
|
1153
|
+
suppress_errors=suppress_errors,
|
|
1154
|
+
shell=False,
|
|
1155
|
+
cwd=cwd,
|
|
1156
|
+
env=env,
|
|
1157
|
+
timeout=timeout,
|
|
1158
|
+
encoding=encoding,
|
|
1159
|
+
max_output_bytes=max_output_bytes,
|
|
1160
|
+
original_expression=original_expression,
|
|
1161
|
+
)
|
|
1162
|
+
|
|
1163
|
+
# Multi-stage pipeline or redirected execution
|
|
1164
|
+
return _execute_pipeline_stages(
|
|
1165
|
+
stages,
|
|
1166
|
+
flat_parts=flat_parts,
|
|
1167
|
+
capture=capture,
|
|
1168
|
+
check=check,
|
|
1169
|
+
tee=tee,
|
|
1170
|
+
input=input,
|
|
1171
|
+
suppress_errors=suppress_errors,
|
|
1172
|
+
cwd=cwd,
|
|
1173
|
+
env=env,
|
|
1174
|
+
timeout=timeout,
|
|
1175
|
+
encoding=encoding,
|
|
1176
|
+
max_output_bytes=max_output_bytes,
|
|
1177
|
+
original_expression=original_expression,
|
|
1178
|
+
)
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def run_bg(
|
|
1182
|
+
*parts: Any,
|
|
1183
|
+
shell: bool | None = None,
|
|
1184
|
+
cwd: str | Path | None = None,
|
|
1185
|
+
env: Mapping[str, str] | None = None,
|
|
1186
|
+
encoding: str = "utf-8",
|
|
1187
|
+
original_expression: str | None = None,
|
|
1188
|
+
) -> BackgroundJob:
|
|
1189
|
+
"""Launch a non-blocking background command and return a BackgroundJob."""
|
|
1190
|
+
cwd_str = str(cwd) if cwd is not None else None
|
|
1191
|
+
env_dict = dict(env) if env is not None else None
|
|
1192
|
+
start = time.perf_counter()
|
|
1193
|
+
if original_expression is None:
|
|
1194
|
+
original_expression = _current_expression.get()
|
|
1195
|
+
|
|
1196
|
+
if len(parts) == 1 and isinstance(parts[0], str):
|
|
1197
|
+
command = parts[0]
|
|
1198
|
+
if shell is None:
|
|
1199
|
+
shell = True
|
|
1200
|
+
proc = subprocess.Popen(
|
|
1201
|
+
command,
|
|
1202
|
+
shell=shell,
|
|
1203
|
+
stdout=subprocess.PIPE,
|
|
1204
|
+
stderr=subprocess.PIPE,
|
|
1205
|
+
text=True,
|
|
1206
|
+
encoding=encoding,
|
|
1207
|
+
errors="replace",
|
|
1208
|
+
cwd=cwd_str,
|
|
1209
|
+
env=env_dict,
|
|
1210
|
+
)
|
|
1211
|
+
return BackgroundJob(
|
|
1212
|
+
proc,
|
|
1213
|
+
command,
|
|
1214
|
+
start,
|
|
1215
|
+
encoding=encoding,
|
|
1216
|
+
original_expression=original_expression,
|
|
1217
|
+
)
|
|
1218
|
+
|
|
1219
|
+
if len(parts) == 1 and isinstance(parts[0], (list, tuple, Sequence)) and not isinstance(parts[0], (str, bytes)):
|
|
1220
|
+
command = list(parts[0])
|
|
1221
|
+
if shell is None:
|
|
1222
|
+
shell = False
|
|
1223
|
+
cmd_str = " ".join(str(c) for c in command)
|
|
1224
|
+
proc = subprocess.Popen(
|
|
1225
|
+
command,
|
|
1226
|
+
shell=shell,
|
|
1227
|
+
stdout=subprocess.PIPE,
|
|
1228
|
+
stderr=subprocess.PIPE,
|
|
1229
|
+
text=True,
|
|
1230
|
+
encoding=encoding,
|
|
1231
|
+
errors="replace",
|
|
1232
|
+
cwd=cwd_str,
|
|
1233
|
+
env=env_dict,
|
|
1234
|
+
)
|
|
1235
|
+
return BackgroundJob(
|
|
1236
|
+
proc,
|
|
1237
|
+
cmd_str,
|
|
1238
|
+
start,
|
|
1239
|
+
encoding=encoding,
|
|
1240
|
+
original_expression=original_expression,
|
|
1241
|
+
)
|
|
1242
|
+
|
|
1243
|
+
# Flatten parts
|
|
1244
|
+
flat_parts: list[Any] = []
|
|
1245
|
+
for part in parts:
|
|
1246
|
+
if isinstance(part, (list, tuple, set)):
|
|
1247
|
+
for item in part:
|
|
1248
|
+
flat_parts.append(item)
|
|
1249
|
+
else:
|
|
1250
|
+
flat_parts.append(part)
|
|
1251
|
+
|
|
1252
|
+
stages = _parse_stages_from_parts(flat_parts)
|
|
1253
|
+
if not stages:
|
|
1254
|
+
proc = subprocess.Popen(
|
|
1255
|
+
[sys.executable, "-c", "pass"],
|
|
1256
|
+
shell=False,
|
|
1257
|
+
stdout=subprocess.PIPE,
|
|
1258
|
+
stderr=subprocess.PIPE,
|
|
1259
|
+
text=True,
|
|
1260
|
+
encoding=encoding,
|
|
1261
|
+
errors="replace",
|
|
1262
|
+
cwd=cwd_str,
|
|
1263
|
+
env=env_dict,
|
|
1264
|
+
)
|
|
1265
|
+
return BackgroundJob(
|
|
1266
|
+
proc,
|
|
1267
|
+
"",
|
|
1268
|
+
start,
|
|
1269
|
+
encoding=encoding,
|
|
1270
|
+
original_expression=original_expression,
|
|
1271
|
+
)
|
|
1272
|
+
|
|
1273
|
+
# Multi-stage or single stage with redirections
|
|
1274
|
+
procs: list[subprocess.Popen] = []
|
|
1275
|
+
opened_files: list[Any] = []
|
|
1276
|
+
prev_stdout = None
|
|
1277
|
+
for idx, stage in enumerate(stages):
|
|
1278
|
+
is_first = (idx == 0)
|
|
1279
|
+
is_last = (idx == len(stages) - 1)
|
|
1280
|
+
|
|
1281
|
+
# stdin
|
|
1282
|
+
if is_first:
|
|
1283
|
+
if stage.stdin_file:
|
|
1284
|
+
cur_stdin = open(stage.stdin_file, "r", encoding=encoding)
|
|
1285
|
+
opened_files.append(cur_stdin)
|
|
1286
|
+
else:
|
|
1287
|
+
cur_stdin = None
|
|
1288
|
+
else:
|
|
1289
|
+
cur_stdin = prev_stdout
|
|
1290
|
+
|
|
1291
|
+
# stdout
|
|
1292
|
+
if is_last:
|
|
1293
|
+
if stage.stdout_file:
|
|
1294
|
+
cur_stdout = open(stage.stdout_file, stage.stdout_mode, encoding=encoding)
|
|
1295
|
+
opened_files.append(cur_stdout)
|
|
1296
|
+
else:
|
|
1297
|
+
cur_stdout = subprocess.PIPE
|
|
1298
|
+
else:
|
|
1299
|
+
cur_stdout = subprocess.PIPE
|
|
1300
|
+
|
|
1301
|
+
cmd_to_run = (
|
|
1302
|
+
[
|
|
1303
|
+
sys.executable,
|
|
1304
|
+
"-c",
|
|
1305
|
+
"import sys; sys.stdout.write(' '.join(sys.argv[1:]) + '\\n')",
|
|
1306
|
+
] + stage.argv[1:]
|
|
1307
|
+
if (stage.argv and stage.argv[0] == "echo")
|
|
1308
|
+
else stage.argv
|
|
1309
|
+
)
|
|
1310
|
+
|
|
1311
|
+
p = subprocess.Popen(
|
|
1312
|
+
cmd_to_run,
|
|
1313
|
+
shell=False,
|
|
1314
|
+
stdin=cur_stdin,
|
|
1315
|
+
stdout=cur_stdout,
|
|
1316
|
+
stderr=subprocess.PIPE,
|
|
1317
|
+
text=True,
|
|
1318
|
+
encoding=encoding,
|
|
1319
|
+
errors="replace",
|
|
1320
|
+
cwd=cwd_str,
|
|
1321
|
+
env=env_dict,
|
|
1322
|
+
)
|
|
1323
|
+
procs.append(p)
|
|
1324
|
+
if prev_stdout is not None and hasattr(prev_stdout, "close"):
|
|
1325
|
+
prev_stdout.close()
|
|
1326
|
+
prev_stdout = p.stdout
|
|
1327
|
+
|
|
1328
|
+
for f in opened_files:
|
|
1329
|
+
try:
|
|
1330
|
+
f.close()
|
|
1331
|
+
except Exception:
|
|
1332
|
+
pass
|
|
1333
|
+
|
|
1334
|
+
cmd_tokens = []
|
|
1335
|
+
for p in flat_parts:
|
|
1336
|
+
if isinstance(p, ShellOp):
|
|
1337
|
+
cmd_tokens.append(str(p))
|
|
1338
|
+
else:
|
|
1339
|
+
cmd_tokens.append(str(p))
|
|
1340
|
+
cmd_str = " ".join(cmd_tokens)
|
|
1341
|
+
|
|
1342
|
+
return BackgroundJob(
|
|
1343
|
+
procs[-1],
|
|
1344
|
+
cmd_str,
|
|
1345
|
+
start,
|
|
1346
|
+
encoding=encoding,
|
|
1347
|
+
original_expression=original_expression,
|
|
1348
|
+
upstream_procs=procs[:-1],
|
|
1349
|
+
)
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
run_bg_expanded = run_bg
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
def wait_all(
|
|
1356
|
+
*jobs: BackgroundJob | Sequence[BackgroundJob],
|
|
1357
|
+
timeout: float | None = None,
|
|
1358
|
+
) -> list[CommandResult]:
|
|
1359
|
+
"""Wait for all specified BackgroundJob instances to complete and return their CommandResults.
|
|
1360
|
+
|
|
1361
|
+
Accepts jobs as positional arguments or as a sequence:
|
|
1362
|
+
results = wait_all(job1, job2, job3)
|
|
1363
|
+
results = wait_all([job1, job2, job3])
|
|
1364
|
+
"""
|
|
1365
|
+
flat_jobs: list[BackgroundJob] = []
|
|
1366
|
+
for item in jobs:
|
|
1367
|
+
if isinstance(item, (list, tuple, Sequence)) and not isinstance(item, (str, bytes)):
|
|
1368
|
+
flat_jobs.extend(item)
|
|
1369
|
+
elif isinstance(item, BackgroundJob):
|
|
1370
|
+
flat_jobs.append(item)
|
|
1371
|
+
else:
|
|
1372
|
+
raise TypeError(f"Expected BackgroundJob or sequence of BackgroundJob, got {type(item).__name__}")
|
|
1373
|
+
|
|
1374
|
+
return [job.wait(timeout=timeout) for job in flat_jobs]
|
|
1375
|
+
|
|
1376
|
+
|
|
1377
|
+
async def _cleanup_async_proc(proc: asyncio.subprocess.Process) -> None:
|
|
1378
|
+
"""Terminate async process and its process tree, ensuring event loop wait completes."""
|
|
1379
|
+
if proc.returncode is not None:
|
|
1380
|
+
return
|
|
1381
|
+
try:
|
|
1382
|
+
if sys.platform == "win32" and proc.pid:
|
|
1383
|
+
subprocess.run(
|
|
1384
|
+
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
1385
|
+
stdout=subprocess.DEVNULL,
|
|
1386
|
+
stderr=subprocess.DEVNULL,
|
|
1387
|
+
check=False,
|
|
1388
|
+
)
|
|
1389
|
+
else:
|
|
1390
|
+
try:
|
|
1391
|
+
pgid = os.getpgid(proc.pid)
|
|
1392
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
1393
|
+
except Exception:
|
|
1394
|
+
proc.kill()
|
|
1395
|
+
except ProcessLookupError:
|
|
1396
|
+
pass
|
|
1397
|
+
except Exception as cleanup_err:
|
|
1398
|
+
warnings.warn(f"Process cleanup warning: {cleanup_err}", RuntimeWarning)
|
|
1399
|
+
|
|
1400
|
+
try:
|
|
1401
|
+
await asyncio.wait_for(proc.wait(), timeout=2.0)
|
|
1402
|
+
except asyncio.TimeoutError:
|
|
1403
|
+
warnings.warn("Timed out waiting for process termination in async_run", RuntimeWarning)
|
|
1404
|
+
except Exception as wait_err:
|
|
1405
|
+
warnings.warn(f"Error waiting for process in async_run: {wait_err}", RuntimeWarning)
|
|
1406
|
+
|
|
1407
|
+
# Allow a tick for pending transport callbacks to execute before event loop teardown
|
|
1408
|
+
await asyncio.sleep(0.01)
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
async def async_run(
|
|
1412
|
+
*parts: Any,
|
|
1413
|
+
capture: bool = True,
|
|
1414
|
+
check: bool = False,
|
|
1415
|
+
tee: bool = False,
|
|
1416
|
+
input: str | bytes | None = None,
|
|
1417
|
+
suppress_errors: bool = False,
|
|
1418
|
+
shell: bool | None = None,
|
|
1419
|
+
cwd: str | Path | None = None,
|
|
1420
|
+
env: Mapping[str, str] | None = None,
|
|
1421
|
+
timeout: float | None = None,
|
|
1422
|
+
encoding: str = "utf-8",
|
|
1423
|
+
max_output_bytes: int | None = None,
|
|
1424
|
+
original_expression: str | None = None,
|
|
1425
|
+
) -> CommandResult:
|
|
1426
|
+
"""Asynchronously execute a shell or structured command using asyncio."""
|
|
1427
|
+
cwd_str = str(cwd) if cwd is not None else None
|
|
1428
|
+
env_dict = dict(env) if env is not None else None
|
|
1429
|
+
start = time.perf_counter()
|
|
1430
|
+
if original_expression is None:
|
|
1431
|
+
original_expression = _current_expression.get()
|
|
1432
|
+
|
|
1433
|
+
if len(parts) == 1 and isinstance(parts[0], str):
|
|
1434
|
+
command = parts[0]
|
|
1435
|
+
if shell is None:
|
|
1436
|
+
shell = True
|
|
1437
|
+
cmd_str = command
|
|
1438
|
+
elif len(parts) == 1 and isinstance(parts[0], (list, tuple, Sequence)) and not isinstance(parts[0], (str, bytes)):
|
|
1439
|
+
command = list(parts[0])
|
|
1440
|
+
if shell is None:
|
|
1441
|
+
shell = False
|
|
1442
|
+
cmd_str = " ".join(str(c) for c in command)
|
|
1443
|
+
else:
|
|
1444
|
+
flat_parts: list[Any] = []
|
|
1445
|
+
for part in parts:
|
|
1446
|
+
if isinstance(part, (list, tuple, set)):
|
|
1447
|
+
for item in part:
|
|
1448
|
+
flat_parts.append(item)
|
|
1449
|
+
else:
|
|
1450
|
+
flat_parts.append(part)
|
|
1451
|
+
|
|
1452
|
+
stages = _parse_stages_from_parts(flat_parts)
|
|
1453
|
+
if not stages:
|
|
1454
|
+
return CommandResult(
|
|
1455
|
+
command="",
|
|
1456
|
+
stdout="",
|
|
1457
|
+
stderr="",
|
|
1458
|
+
exit_code=0,
|
|
1459
|
+
duration=0.0,
|
|
1460
|
+
original_expression=original_expression,
|
|
1461
|
+
)
|
|
1462
|
+
|
|
1463
|
+
if len(stages) == 1 and stages[0].stdin_file is None and stages[0].stdout_file is None:
|
|
1464
|
+
stage = stages[0]
|
|
1465
|
+
if stage.argv and stage.argv[0] == "echo":
|
|
1466
|
+
duration = time.perf_counter() - start
|
|
1467
|
+
echo_text = " ".join(stage.argv[1:]) + "\n"
|
|
1468
|
+
if tee:
|
|
1469
|
+
sys.stdout.write(echo_text)
|
|
1470
|
+
sys.stdout.flush()
|
|
1471
|
+
proc_out = echo_text
|
|
1472
|
+
elif not capture:
|
|
1473
|
+
sys.stdout.write(echo_text)
|
|
1474
|
+
sys.stdout.flush()
|
|
1475
|
+
proc_out = ""
|
|
1476
|
+
else:
|
|
1477
|
+
proc_out = echo_text
|
|
1478
|
+
stdout, out_trunc = _truncate_output(proc_out, max_output_bytes, encoding)
|
|
1479
|
+
return CommandResult(
|
|
1480
|
+
command=" ".join(stage.argv),
|
|
1481
|
+
stdout=stdout,
|
|
1482
|
+
stderr="",
|
|
1483
|
+
exit_code=0,
|
|
1484
|
+
duration=duration,
|
|
1485
|
+
original_expression=original_expression,
|
|
1486
|
+
truncated=out_trunc,
|
|
1487
|
+
)
|
|
1488
|
+
command = stage.argv
|
|
1489
|
+
cmd_str = " ".join(stage.argv)
|
|
1490
|
+
if shell is None:
|
|
1491
|
+
shell = False
|
|
1492
|
+
else:
|
|
1493
|
+
return await asyncio.to_thread(
|
|
1494
|
+
_execute_pipeline_stages,
|
|
1495
|
+
stages,
|
|
1496
|
+
flat_parts=flat_parts,
|
|
1497
|
+
capture=capture,
|
|
1498
|
+
check=check,
|
|
1499
|
+
tee=tee,
|
|
1500
|
+
input=input,
|
|
1501
|
+
suppress_errors=suppress_errors,
|
|
1502
|
+
cwd=cwd,
|
|
1503
|
+
env=env,
|
|
1504
|
+
timeout=timeout,
|
|
1505
|
+
encoding=encoding,
|
|
1506
|
+
max_output_bytes=max_output_bytes,
|
|
1507
|
+
original_expression=original_expression,
|
|
1508
|
+
)
|
|
1509
|
+
|
|
1510
|
+
extra_kwargs: dict[str, Any] = {}
|
|
1511
|
+
if sys.platform != "win32":
|
|
1512
|
+
extra_kwargs["start_new_session"] = True
|
|
1513
|
+
|
|
1514
|
+
stdin_mode = asyncio.subprocess.PIPE if input is not None else None
|
|
1515
|
+
stdout_mode = asyncio.subprocess.PIPE if (capture or tee) else None
|
|
1516
|
+
stderr_mode = asyncio.subprocess.PIPE if (capture or tee) else None
|
|
1517
|
+
|
|
1518
|
+
if shell:
|
|
1519
|
+
proc = await asyncio.create_subprocess_shell(
|
|
1520
|
+
cmd_str,
|
|
1521
|
+
stdin=stdin_mode,
|
|
1522
|
+
stdout=stdout_mode,
|
|
1523
|
+
stderr=stderr_mode,
|
|
1524
|
+
cwd=cwd_str,
|
|
1525
|
+
env=env_dict,
|
|
1526
|
+
**extra_kwargs,
|
|
1527
|
+
)
|
|
1528
|
+
else:
|
|
1529
|
+
cmd_args = [str(c) for c in command] if not isinstance(command, str) else [command]
|
|
1530
|
+
proc = await asyncio.create_subprocess_exec(
|
|
1531
|
+
*cmd_args,
|
|
1532
|
+
stdin=stdin_mode,
|
|
1533
|
+
stdout=stdout_mode,
|
|
1534
|
+
stderr=stderr_mode,
|
|
1535
|
+
cwd=cwd_str,
|
|
1536
|
+
env=env_dict,
|
|
1537
|
+
**extra_kwargs,
|
|
1538
|
+
)
|
|
1539
|
+
|
|
1540
|
+
input_b = None
|
|
1541
|
+
if input is not None:
|
|
1542
|
+
if isinstance(input, str):
|
|
1543
|
+
input_b = input.encode(encoding)
|
|
1544
|
+
elif isinstance(input, (bytes, bytearray)):
|
|
1545
|
+
input_b = bytes(input)
|
|
1546
|
+
else:
|
|
1547
|
+
input_b = str(input).encode(encoding)
|
|
1548
|
+
|
|
1549
|
+
try:
|
|
1550
|
+
if timeout is not None:
|
|
1551
|
+
stdout_b, stderr_b = await asyncio.wait_for(
|
|
1552
|
+
proc.communicate(input=input_b), timeout=timeout
|
|
1553
|
+
)
|
|
1554
|
+
else:
|
|
1555
|
+
stdout_b, stderr_b = await proc.communicate(input=input_b)
|
|
1556
|
+
except asyncio.TimeoutError as e:
|
|
1557
|
+
await _cleanup_async_proc(proc)
|
|
1558
|
+
duration = time.perf_counter() - start
|
|
1559
|
+
result = CommandResult(
|
|
1560
|
+
command=cmd_str,
|
|
1561
|
+
stdout="",
|
|
1562
|
+
stderr="",
|
|
1563
|
+
exit_code=-1,
|
|
1564
|
+
duration=duration,
|
|
1565
|
+
original_expression=original_expression,
|
|
1566
|
+
)
|
|
1567
|
+
raise CommandTimeoutError(result) from e
|
|
1568
|
+
except asyncio.CancelledError:
|
|
1569
|
+
await _cleanup_async_proc(proc)
|
|
1570
|
+
raise
|
|
1571
|
+
|
|
1572
|
+
duration = time.perf_counter() - start
|
|
1573
|
+
|
|
1574
|
+
raw_stdout = stdout_b.decode(encoding, errors="replace") if stdout_b else ""
|
|
1575
|
+
raw_stderr = stderr_b.decode(encoding, errors="replace") if stderr_b else ""
|
|
1576
|
+
|
|
1577
|
+
if tee:
|
|
1578
|
+
if raw_stdout:
|
|
1579
|
+
sys.stdout.write(raw_stdout)
|
|
1580
|
+
sys.stdout.flush()
|
|
1581
|
+
if raw_stderr:
|
|
1582
|
+
sys.stderr.write(raw_stderr)
|
|
1583
|
+
sys.stderr.flush()
|
|
1584
|
+
stdout = raw_stdout
|
|
1585
|
+
elif not capture:
|
|
1586
|
+
if raw_stdout:
|
|
1587
|
+
sys.stdout.write(raw_stdout)
|
|
1588
|
+
sys.stdout.flush()
|
|
1589
|
+
if raw_stderr:
|
|
1590
|
+
sys.stderr.write(raw_stderr)
|
|
1591
|
+
sys.stderr.flush()
|
|
1592
|
+
stdout = ""
|
|
1593
|
+
else:
|
|
1594
|
+
stdout = raw_stdout
|
|
1595
|
+
|
|
1596
|
+
stdout, out_trunc = _truncate_output(stdout, max_output_bytes, encoding)
|
|
1597
|
+
stderr, err_trunc = _truncate_output(raw_stderr, max_output_bytes, encoding)
|
|
1598
|
+
|
|
1599
|
+
result = CommandResult(
|
|
1600
|
+
command=cmd_str,
|
|
1601
|
+
stdout=stdout,
|
|
1602
|
+
stderr=stderr,
|
|
1603
|
+
exit_code=proc.returncode if proc.returncode is not None else 0,
|
|
1604
|
+
duration=duration,
|
|
1605
|
+
original_expression=original_expression,
|
|
1606
|
+
truncated=(out_trunc or err_trunc),
|
|
1607
|
+
)
|
|
1608
|
+
|
|
1609
|
+
if check and not suppress_errors and result.exit_code != 0:
|
|
1610
|
+
raise CommandError(result)
|
|
1611
|
+
|
|
1612
|
+
return result
|
|
1613
|
+
|
|
1614
|
+
|
|
1615
|
+
async_run_expanded = async_run
|
|
1616
|
+
|
|
1617
|
+
|
|
1618
|
+
@contextmanager
|
|
1619
|
+
def cd(path: str | Path) -> Iterator[Path]:
|
|
1620
|
+
"""Context manager for safely and temporarily changing the current working directory."""
|
|
1621
|
+
prev_cwd = Path.cwd()
|
|
1622
|
+
target_path = Path(path).resolve()
|
|
1623
|
+
os.chdir(target_path)
|
|
1624
|
+
try:
|
|
1625
|
+
yield target_path
|
|
1626
|
+
finally:
|
|
1627
|
+
os.chdir(prev_cwd)
|
|
1628
|
+
|
|
1629
|
+
|
|
1630
|
+
@contextmanager
|
|
1631
|
+
def env(**kwargs: Any) -> Iterator[dict[str, str]]:
|
|
1632
|
+
"""Context manager for temporarily setting or overriding environment variables."""
|
|
1633
|
+
old_env: dict[str, str | None] = {}
|
|
1634
|
+
for k, v in kwargs.items():
|
|
1635
|
+
old_env[k] = os.environ.get(k)
|
|
1636
|
+
if v is None:
|
|
1637
|
+
os.environ.pop(k, None)
|
|
1638
|
+
else:
|
|
1639
|
+
os.environ[k] = str(v)
|
|
1640
|
+
try:
|
|
1641
|
+
yield dict(os.environ)
|
|
1642
|
+
finally:
|
|
1643
|
+
for k, old_val in old_env.items():
|
|
1644
|
+
if old_val is None:
|
|
1645
|
+
os.environ.pop(k, None)
|
|
1646
|
+
else:
|
|
1647
|
+
os.environ[k] = old_val
|