Skip to content

Commit 09e2b71

Browse files
committed
Allow passing a maximum timeout for commands executed through ctx.run()
Fixes #11 Signed-off-by: Pedro Algarvio <[email protected]>
1 parent b5a5ae2 commit 09e2b71

File tree

3 files changed

+72
-2
lines changed

3 files changed

+72
-2
lines changed

changelog/11.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Allow passing a maximum timeout for commands executed through `ctx.run()`

src/ptscripts/parser.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ def run(
145145
self,
146146
*cmdline,
147147
check=True,
148+
timeout_secs: int | None = None,
148149
no_output_timeout_secs: int | None = None,
149150
capture: bool = False,
150151
interactive: bool = False,
@@ -155,6 +156,7 @@ def run(
155156
return process.run(
156157
*cmdline,
157158
check=check,
159+
timeout_secs=timeout_secs,
158160
no_output_timeout_secs=no_output_timeout_secs,
159161
capture=capture,
160162
interactive=interactive,
@@ -238,6 +240,15 @@ def __new__(cls):
238240
run_options = instance.parser.add_argument_group(
239241
"Run Subprocess Options", description="These options apply to ctx.run() calls"
240242
)
243+
run_options.add_argument(
244+
"--timeout-secs",
245+
"--ts",
246+
default=None,
247+
type=int,
248+
help="Timeout in seconds for the command to finish.",
249+
metavar="SECONDS",
250+
dest="timeout_secs",
251+
)
241252
run_options.add_argument(
242253
"--no-output-timeout-secs",
243254
"--nots",

src/ptscripts/process.py

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,26 @@
2020

2121

2222
class Process(asyncio.subprocess.Process): # noqa: D101
23-
def __init__(self, *args, no_output_timeout_secs: int | timedelta | None = None, **kwargs):
23+
def __init__(
24+
self,
25+
*args,
26+
timeout_secs: int | timedelta | None = None,
27+
no_output_timeout_secs: int | timedelta | None = None,
28+
**kwargs,
29+
):
2430
super().__init__(*args, **kwargs)
31+
timeout_task = None
32+
if isinstance(timeout_secs, int):
33+
assert timeout_secs >= 1
34+
timeout_secs = timedelta(seconds=timeout_secs)
35+
elif isinstance(timeout_secs, timedelta):
36+
assert timeout_secs >= timedelta(seconds=1)
37+
if timeout_secs is not None:
38+
timeout_task = self._loop.create_task( # type: ignore[attr-defined]
39+
self._check_timeout()
40+
)
41+
self._timeout_secs = timeout_secs
42+
self._timeout_task = timeout_task
2543
no_output_timeout_task = None
2644
if isinstance(no_output_timeout_secs, int):
2745
assert no_output_timeout_secs >= 1
@@ -35,6 +53,34 @@ def __init__(self, *args, no_output_timeout_secs: int | timedelta | None = None,
3553
self._no_output_timeout_secs = no_output_timeout_secs
3654
self._no_output_timeout_task = no_output_timeout_task
3755

56+
async def _check_timeout(self):
57+
try:
58+
if TYPE_CHECKING:
59+
assert self._timeout_secs
60+
await asyncio.sleep(self._timeout_secs.seconds)
61+
try:
62+
self.terminate()
63+
log.warning(
64+
"The command has been running for more than %s second(s). "
65+
"Terminating process.",
66+
self._timeout_secs.seconds,
67+
)
68+
except ProcessLookupError:
69+
pass
70+
except asyncio.CancelledError:
71+
pass
72+
73+
async def _cancel_timeout_task(self):
74+
task = self._timeout_task
75+
if task is None:
76+
return
77+
self._timeout_task = None
78+
if task.done():
79+
return
80+
if not task.cancelled():
81+
task.cancel()
82+
await task
83+
3884
async def _check_no_output_timeout(self):
3985
self._protocol._last_write = datetime.utcnow() # type: ignore[attr-defined]
4086
try:
@@ -73,6 +119,7 @@ async def wait(self):
73119
Wait until the process exit and return the process return code.
74120
"""
75121
retcode = await super().wait()
122+
await self._cancel_timeout_task()
76123
await self._cancel_no_output_timeout_task()
77124
return retcode
78125

@@ -112,6 +159,7 @@ async def _create_subprocess_exec(
112159
stdout=None,
113160
stderr=None,
114161
limit=asyncio.streams._DEFAULT_LIMIT, # type: ignore[attr-defined]
162+
timeout_secs: int | None = None,
115163
no_output_timeout_secs: int | None = None,
116164
capture: bool = False,
117165
**kwds,
@@ -129,7 +177,13 @@ def protocol_factory():
129177
stderr=stderr,
130178
**kwds,
131179
)
132-
return Process(transport, protocol, loop, no_output_timeout_secs=no_output_timeout_secs)
180+
return Process(
181+
transport,
182+
protocol,
183+
loop,
184+
timeout_secs=timeout_secs,
185+
no_output_timeout_secs=no_output_timeout_secs,
186+
)
133187

134188

135189
def _handle_signal(proc, sig):
@@ -148,6 +202,7 @@ async def _subprocess_run(
148202
future,
149203
cmdline,
150204
check=True,
205+
timeout_secs: int | None = None,
151206
no_output_timeout_secs: int | None = None,
152207
capture: bool = False,
153208
interactive: bool = False,
@@ -167,6 +222,7 @@ async def _subprocess_run(
167222
stderr=stderr,
168223
stdin=sys.stdin,
169224
limit=1,
225+
timeout_secs=timeout_secs,
170226
no_output_timeout_secs=no_output_timeout_secs,
171227
capture=capture,
172228
**kwargs,
@@ -189,6 +245,7 @@ async def _subprocess_run(
189245
def run(
190246
*cmdline,
191247
check=True,
248+
timeout_secs: int | None = None,
192249
no_output_timeout_secs: int | None = None,
193250
capture: bool = False,
194251
interactive: bool = False,
@@ -204,6 +261,7 @@ def run(
204261
future,
205262
cmdline,
206263
check,
264+
timeout_secs=timeout_secs,
207265
no_output_timeout_secs=no_output_timeout_secs,
208266
capture=capture,
209267
interactive=interactive,

0 commit comments

Comments
 (0)