Skip to content

Commit d34983b

Browse files
committed
add timeout parameter and allow passing additional subprocess.popen kwargs
1 parent d5b09a3 commit d34983b

File tree

4 files changed

+58
-5
lines changed

4 files changed

+58
-5
lines changed

pkg/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ namespaces = true
1515

1616
# ----------------------------------------- Project Metadata -------------------------------------
1717
[project]
18-
version = "0.2.0"
18+
version = "0.2.1"
1919
name = "PyShellMan"
2020
requires-python = ">=3.10"
2121
dependencies = [

pkg/src/pyshellman/exception.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,17 @@ def __init__(self, output: ShellOutput):
4646
return
4747

4848

49+
class PyShellManTimeoutError(PyShellManError):
50+
"""Exception raised for timeout in the execution of a command."""
51+
def __init__(self, output: ShellOutput):
52+
super().__init__(
53+
title="Timeout Error",
54+
intro=f"Shell command timed out.",
55+
output=output
56+
)
57+
return
58+
59+
4960
class PyShellManNonZeroExitCodeError(PyShellManError):
5061
"""Exception raised for non-zero exit code in the execution of a command."""
5162
def __init__(self, output: ShellOutput):

pkg/src/pyshellman/output.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
if _TYPE_CHECKING:
1010
from pathlib import Path
11+
from typing import Literal
1112

1213

1314
class ShellOutput:
@@ -17,7 +18,7 @@ def __init__(
1718
title: str,
1819
command: list[str],
1920
cwd: Path,
20-
code: int | None = None,
21+
code: int | Literal["TIMEOUT"] | None = None,
2122
out: str | bytes | None = None,
2223
err: str | bytes | None = None,
2324
):
@@ -33,6 +34,10 @@ def __init__(
3334
def executed(self) -> bool:
3435
return self.code is not None
3536

37+
@property
38+
def timeout(self) -> bool:
39+
return self.code == "TIMEOUT"
40+
3641
@property
3742
def succeeded(self) -> bool:
3843
return self.code == 0

pkg/src/pyshellman/shell.py

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,12 @@ def __init__(
2323
stream_stdout: bool = False,
2424
stream_stderr: bool = False,
2525
raise_execution: bool = True,
26+
raise_timeout: bool = True,
2627
raise_exit_code: bool = True,
2728
raise_stderr: bool = False,
2829
text_output: bool = True,
30+
timeout: float | None = None,
31+
subprocess_kwargs: dict | None = None,
2932
logger = None,
3033
log_title: str = "Shell Process",
3134
log_level_execution: LogLevel = "critical",
@@ -39,9 +42,12 @@ def __init__(
3942
self.stream_stdout = stream_stdout
4043
self.stream_stderr = stream_stderr
4144
self.raise_execution = raise_execution
45+
self.raise_timeout = raise_timeout
4246
self.raise_exit_code = raise_exit_code
4347
self.raise_stderr = raise_stderr
4448
self.text_output = text_output
49+
self.timeout = timeout
50+
self.subprocess_kwargs = subprocess_kwargs or {}
4551
self.logger = logger
4652
self.log_title = log_title
4753
self.log_level_execution = log_level_execution
@@ -59,9 +65,12 @@ def run(
5965
stream_stdout: bool | None = None,
6066
stream_stderr: bool | None = None,
6167
raise_execution: bool | None = None,
68+
raise_timeout: bool | None = None,
6269
raise_exit_code: bool | None = None,
6370
raise_stderr: bool | None = None,
6471
text_output: bool | None = None,
72+
timeout: float | None = None,
73+
subprocess_kwargs: dict | None = None,
6574
log_title: str | None = None,
6675
log_level_execution: LogLevel | None = None,
6776
log_level_exit_code: LogLevel | None = None,
@@ -108,6 +117,8 @@ def run(
108117
)
109118
if not output.executed and args.raise_execution:
110119
raise _exception.PyShellManExecutionError(output=output)
120+
if output.timeout and args.raise_timeout:
121+
raise _exception.PyShellManTimeoutError(output=output)
111122
if not output.succeeded and args.raise_exit_code:
112123
raise _exception.PyShellManNonZeroExitCodeError(output=output)
113124
if stderr and args.raise_stderr:
@@ -127,9 +138,18 @@ def _run_nostream(
127138
args: _SimpleNamespace
128139
) -> tuple[str | bytes | None, str | bytes | None, int | None]:
129140
try:
130-
process = _subprocess.run(command, text=args.text_output, cwd=args.cwd, capture_output=True)
141+
process = _subprocess.run(
142+
command,
143+
text=args.text_output,
144+
cwd=args.cwd,
145+
capture_output=True,
146+
timeout=args.timeout,
147+
**args.subprocess_kwargs,
148+
)
131149
except FileNotFoundError:
132150
return None, None, None
151+
except _subprocess.TimeoutExpired as e:
152+
return e.stdout, e.stderr, "TIMEOUT"
133153
stdout = (process.stdout.strip() if args.text_output else process.stdout) or None
134154
stderr = (process.stderr.strip() if args.text_output else process.stderr) or None
135155
code = process.returncode
@@ -151,6 +171,7 @@ def _run_stream(
151171
stdout=_subprocess.PIPE,
152172
stderr=_subprocess.PIPE,
153173
bufsize=1 if args.text_output else 0,
174+
**args.subprocess_kwargs,
154175
)
155176
except FileNotFoundError:
156177
return None, None, None
@@ -190,16 +211,26 @@ def read_stream(stream, chunks, live: bool):
190211

191212
for t in threads:
192213
t.start()
193-
process.wait()
214+
215+
try:
216+
process.wait(timeout=args.timeout)
217+
except TimeoutError:
218+
process.kill()
219+
process.wait() # Allow reader threads to finish draining killed process’s output
220+
returncode = "TIMEOUT"
221+
else:
222+
returncode = process.returncode
223+
194224
for t in threads:
195225
t.join()
226+
196227
if args.text_output:
197228
stdout = ''.join(stdout_chunks)
198229
stderr = ''.join(stderr_chunks)
199230
else:
200231
stdout = b''.join(stdout_chunks)
201232
stderr = b''.join(stderr_chunks)
202-
return stdout, stderr, process.returncode
233+
return stdout, stderr, returncode
203234

204235

205236
def run(
@@ -209,9 +240,12 @@ def run(
209240
stream_stdout: bool = False,
210241
stream_stderr: bool = False,
211242
raise_execution: bool = True,
243+
raise_timeout: bool | None = None,
212244
raise_exit_code: bool = True,
213245
raise_stderr: bool = False,
214246
text_output: bool = True,
247+
timeout: float | None = None,
248+
subprocess_kwargs: dict | None = None,
215249
logger=None,
216250
log_title: str = "Shell SubProcess",
217251
log_level_execution: LogLevel = "critical",
@@ -225,9 +259,12 @@ def run(
225259
stream_stdout=stream_stdout,
226260
stream_stderr=stream_stderr,
227261
raise_execution=raise_execution,
262+
raise_timeout=raise_timeout,
228263
raise_exit_code=raise_exit_code,
229264
raise_stderr=raise_stderr,
230265
text_output=text_output,
266+
timeout=timeout,
267+
subprocess_kwargs=subprocess_kwargs,
231268
logger=logger,
232269
log_title=log_title,
233270
log_level_execution=log_level_execution,

0 commit comments

Comments
 (0)