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