-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcommand_runner.py
More file actions
108 lines (93 loc) · 3.07 KB
/
Copy pathcommand_runner.py
File metadata and controls
108 lines (93 loc) · 3.07 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
#!/usr/bin/env python3
"""Subprocess execution with mandatory deadlines and deterministic termination."""
from __future__ import annotations
import subprocess
import threading
import time
from collections.abc import Mapping, Sequence
DEFAULT_COMMAND_TIMEOUT = 90.0
TERMINATION_GRACE_SECONDS = 3.0
class CommandExecutionError(RuntimeError):
def __init__(self, message: str, *, stdout: str = "", stderr: str = ""):
super().__init__(message)
self.stdout = stdout
self.stderr = stderr
class CommandTimeoutError(CommandExecutionError):
pass
class CommandCancelledError(CommandExecutionError):
pass
def _terminate(process: subprocess.Popen[str]) -> tuple[str, str]:
if process.poll() is None:
process.terminate()
try:
return process.communicate(timeout=TERMINATION_GRACE_SECONDS)
except subprocess.TimeoutExpired:
process.kill()
return process.communicate()
def run_command(
args: Sequence[str],
*,
timeout: float = DEFAULT_COMMAND_TIMEOUT,
cancel_event: threading.Event | None = None,
env: Mapping[str, str] | None = None,
cwd: str | None = None,
creationflags: int | None = None,
check: bool = False,
) -> subprocess.CompletedProcess[str]:
if not args or not all(isinstance(value, str) and value for value in args):
raise ValueError("command arguments must be non-empty strings")
timeout = float(timeout)
if timeout <= 0:
raise ValueError("command timeout must be positive")
flags = (
getattr(subprocess, "CREATE_NO_WINDOW", 0)
if creationflags is None
else int(creationflags)
)
process = subprocess.Popen(
list(args),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env=dict(env) if env is not None else None,
cwd=cwd,
creationflags=flags,
)
deadline = time.monotonic() + timeout
stdout = ""
stderr = ""
while True:
if cancel_event and cancel_event.is_set():
stdout, stderr = _terminate(process)
raise CommandCancelledError(
"command cancelled and terminated",
stdout=stdout,
stderr=stderr,
)
remaining = deadline - time.monotonic()
if remaining <= 0:
stdout, stderr = _terminate(process)
raise CommandTimeoutError(
f"command exceeded its {timeout:g}-second deadline and was terminated",
stdout=stdout,
stderr=stderr,
)
try:
stdout, stderr = process.communicate(timeout=min(0.1, remaining))
break
except subprocess.TimeoutExpired:
continue
completed = subprocess.CompletedProcess(
list(args),
process.returncode,
stdout,
stderr,
)
if check and completed.returncode:
raise subprocess.CalledProcessError(
completed.returncode,
completed.args,
output=completed.stdout,
stderr=completed.stderr,
)
return completed