bounded_subprocess 2.0.0__py3-none-any.whl → 2.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.
Potentially problematic release.
This version of bounded_subprocess might be problematic. Click here for more details.
- bounded_subprocess/bounded_subprocess.py +19 -4
- bounded_subprocess/bounded_subprocess_async.py +19 -3
- bounded_subprocess/interactive.py +88 -95
- bounded_subprocess/interactive_async.py +31 -104
- bounded_subprocess/util.py +96 -6
- {bounded_subprocess-2.0.0.dist-info → bounded_subprocess-2.1.0.dist-info}/METADATA +1 -1
- bounded_subprocess-2.1.0.dist-info/RECORD +10 -0
- bounded_subprocess-2.0.0.dist-info/RECORD +0 -10
- {bounded_subprocess-2.0.0.dist-info → bounded_subprocess-2.1.0.dist-info}/WHEEL +0 -0
- {bounded_subprocess-2.0.0.dist-info → bounded_subprocess-2.1.0.dist-info}/licenses/LICENSE.txt +0 -0
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import time
|
|
2
|
-
from typing import List
|
|
2
|
+
from typing import List, Optional
|
|
3
3
|
|
|
4
|
-
from .util import
|
|
4
|
+
from .util import (
|
|
5
|
+
Result,
|
|
6
|
+
BoundedSubprocessState,
|
|
7
|
+
SLEEP_BETWEEN_READS,
|
|
8
|
+
write_loop_sync,
|
|
9
|
+
_STDIN_WRITE_TIMEOUT,
|
|
10
|
+
)
|
|
5
11
|
|
|
6
12
|
|
|
7
13
|
def run(
|
|
@@ -9,13 +15,22 @@ def run(
|
|
|
9
15
|
timeout_seconds: int = 15,
|
|
10
16
|
max_output_size: int = 2048,
|
|
11
17
|
env=None,
|
|
18
|
+
stdin_data: Optional[str] = None,
|
|
12
19
|
) -> Result:
|
|
13
20
|
"""
|
|
14
21
|
Runs the given program with arguments. After the timeout elapses, kills the process
|
|
15
22
|
and all other processes in the process group. Captures at most max_output_size bytes
|
|
16
23
|
of stdout and stderr each, and discards any output beyond that.
|
|
17
24
|
"""
|
|
18
|
-
state = BoundedSubprocessState(args, env, max_output_size)
|
|
25
|
+
state = BoundedSubprocessState(args, env, max_output_size, stdin_data is not None)
|
|
26
|
+
if stdin_data is not None:
|
|
27
|
+
write_loop_sync(
|
|
28
|
+
state.write_chunk,
|
|
29
|
+
stdin_data.encode(),
|
|
30
|
+
_STDIN_WRITE_TIMEOUT,
|
|
31
|
+
sleep_interval=SLEEP_BETWEEN_READS,
|
|
32
|
+
)
|
|
33
|
+
state.close_stdin()
|
|
19
34
|
|
|
20
35
|
# We sleep for 0.1 seconds in each iteration.
|
|
21
36
|
max_iterations = timeout_seconds * 10
|
|
@@ -27,4 +42,4 @@ def run(
|
|
|
27
42
|
else:
|
|
28
43
|
break
|
|
29
44
|
|
|
30
|
-
return state.terminate()
|
|
45
|
+
return state.terminate()
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
-
from typing import List
|
|
3
|
-
from .util import
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
from .util import (
|
|
4
|
+
Result,
|
|
5
|
+
BoundedSubprocessState,
|
|
6
|
+
SLEEP_BETWEEN_READS,
|
|
7
|
+
write_loop_async,
|
|
8
|
+
_STDIN_WRITE_TIMEOUT,
|
|
9
|
+
)
|
|
4
10
|
|
|
5
11
|
|
|
6
12
|
async def run(
|
|
@@ -8,6 +14,7 @@ async def run(
|
|
|
8
14
|
timeout_seconds: int = 15,
|
|
9
15
|
max_output_size: int = 2048,
|
|
10
16
|
env=None,
|
|
17
|
+
stdin_data: Optional[str] = None,
|
|
11
18
|
) -> Result:
|
|
12
19
|
"""
|
|
13
20
|
Runs the given program with arguments. After the timeout elapses, kills the process
|
|
@@ -18,7 +25,15 @@ async def run(
|
|
|
18
25
|
# were going to use asyncio.create_subprocess_exec. But, we're not. We're
|
|
19
26
|
# using subprocess.Popen because it supports non-blocking reads. What's
|
|
20
27
|
# async here? It's just the sleep between reads.
|
|
21
|
-
state = BoundedSubprocessState(args, env, max_output_size)
|
|
28
|
+
state = BoundedSubprocessState(args, env, max_output_size, stdin_data is not None)
|
|
29
|
+
if stdin_data is not None:
|
|
30
|
+
await write_loop_async(
|
|
31
|
+
state.write_chunk,
|
|
32
|
+
stdin_data.encode(),
|
|
33
|
+
_STDIN_WRITE_TIMEOUT,
|
|
34
|
+
sleep_interval=SLEEP_BETWEEN_READS,
|
|
35
|
+
)
|
|
36
|
+
state.close_stdin()
|
|
22
37
|
|
|
23
38
|
# We sleep for 0.1 seconds in each iteration.
|
|
24
39
|
max_iterations = timeout_seconds * 10
|
|
@@ -31,3 +46,4 @@ async def run(
|
|
|
31
46
|
break
|
|
32
47
|
|
|
33
48
|
return state.terminate()
|
|
49
|
+
|
|
@@ -3,129 +3,122 @@ from typing import List, Optional
|
|
|
3
3
|
import time
|
|
4
4
|
import errno
|
|
5
5
|
import subprocess
|
|
6
|
-
from .util import set_nonblocking
|
|
6
|
+
from .util import set_nonblocking, MAX_BYTES_PER_READ, write_loop_sync
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
_MAX_BYTES_PER_READ = 1024
|
|
10
8
|
_SLEEP_AFTER_WOUND_BLOCK = 0.5
|
|
11
9
|
|
|
12
10
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
I/O so that we can timeout reads and writes.
|
|
18
|
-
"""
|
|
19
|
-
|
|
20
|
-
def __init__(self, args: List[str], read_buffer_size: int):
|
|
21
|
-
"""
|
|
22
|
-
read_buffer_size is the maximum number of bytes to read from stdout
|
|
23
|
-
and stdout each. If the process writes more than this, the extra bytes
|
|
24
|
-
will be discarded.
|
|
25
|
-
"""
|
|
11
|
+
class _InteractiveState:
|
|
12
|
+
"""Shared implementation for synchronous and asynchronous interaction."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, args: List[str], read_buffer_size: int) -> None:
|
|
26
15
|
popen = subprocess.Popen(
|
|
27
16
|
args,
|
|
28
17
|
stdin=subprocess.PIPE,
|
|
29
18
|
stdout=subprocess.PIPE,
|
|
30
|
-
|
|
31
|
-
bufsize=_MAX_BYTES_PER_READ,
|
|
19
|
+
bufsize=MAX_BYTES_PER_READ,
|
|
32
20
|
)
|
|
33
21
|
set_nonblocking(popen.stdin)
|
|
34
22
|
set_nonblocking(popen.stdout)
|
|
35
|
-
self.
|
|
36
|
-
self.
|
|
37
|
-
self.
|
|
38
|
-
self._popen = popen
|
|
23
|
+
self.popen = popen
|
|
24
|
+
self.read_buffer_size = read_buffer_size
|
|
25
|
+
self.stdout_saved_bytes = bytearray()
|
|
39
26
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
27
|
+
# --- low level helpers -------------------------------------------------
|
|
28
|
+
def poll(self) -> Optional[int]:
|
|
29
|
+
return self.popen.poll()
|
|
30
|
+
|
|
31
|
+
def close_pipes(self) -> None:
|
|
44
32
|
try:
|
|
45
|
-
self.
|
|
33
|
+
self.popen.stdin.close()
|
|
46
34
|
except BlockingIOError:
|
|
47
|
-
# .close() will attempt to flush any buffered writes to stdout
|
|
48
|
-
# before the close returns. This may block, but since the file
|
|
49
|
-
# descriptor is non-blocking, we get a BlockingIOError.
|
|
50
35
|
pass
|
|
51
|
-
self.
|
|
52
|
-
for _ in range(nice_timeout_seconds):
|
|
53
|
-
if self._popen.poll() is not None:
|
|
54
|
-
break
|
|
55
|
-
time.sleep(1)
|
|
56
|
-
self._popen.kill()
|
|
57
|
-
return_code = self._popen.returncode
|
|
58
|
-
return return_code if return_code is not None else -9
|
|
59
|
-
|
|
60
|
-
def write(self, stdin_data: bytes, timeout_seconds: int):
|
|
61
|
-
"""
|
|
62
|
-
Write data to the process's stdin.
|
|
63
|
-
"""
|
|
64
|
-
if self._popen.poll() is not None:
|
|
65
|
-
return False
|
|
36
|
+
self.popen.stdout.close()
|
|
66
37
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
are certain that the the newline is not in a prefix.
|
|
92
|
-
"""
|
|
93
|
-
newline_index = self._stdout_saved_bytes.find(b"\n", newline_search_index)
|
|
38
|
+
def kill(self) -> None:
|
|
39
|
+
self.popen.kill()
|
|
40
|
+
|
|
41
|
+
def return_code(self) -> int:
|
|
42
|
+
rc = self.popen.returncode
|
|
43
|
+
return rc if rc is not None else -9
|
|
44
|
+
|
|
45
|
+
def write_chunk(self, data: memoryview) -> tuple[int, bool]:
|
|
46
|
+
try:
|
|
47
|
+
written = self.popen.stdin.write(data)
|
|
48
|
+
self.popen.stdin.flush()
|
|
49
|
+
return written, True
|
|
50
|
+
except BlockingIOError as exn:
|
|
51
|
+
if exn.errno != errno.EAGAIN:
|
|
52
|
+
return exn.characters_written, False
|
|
53
|
+
return exn.characters_written, True
|
|
54
|
+
except BrokenPipeError:
|
|
55
|
+
return 0, False
|
|
56
|
+
|
|
57
|
+
def read_chunk(self) -> Optional[bytes]:
|
|
58
|
+
return self.popen.stdout.read(MAX_BYTES_PER_READ)
|
|
59
|
+
|
|
60
|
+
def pop_line(self, start_idx: int) -> Optional[bytes]:
|
|
61
|
+
newline_index = self.stdout_saved_bytes.find(b"\n", start_idx)
|
|
94
62
|
if newline_index == -1:
|
|
95
63
|
return None
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
del self._stdout_saved_bytes[: newline_index + 1]
|
|
64
|
+
line = memoryview(self.stdout_saved_bytes)[:newline_index].tobytes()
|
|
65
|
+
del self.stdout_saved_bytes[: newline_index + 1]
|
|
99
66
|
return line
|
|
100
67
|
|
|
68
|
+
def append_stdout(self, data: bytes) -> None:
|
|
69
|
+
self.stdout_saved_bytes.extend(data)
|
|
70
|
+
|
|
71
|
+
def trim_stdout(self) -> None:
|
|
72
|
+
if len(self.stdout_saved_bytes) > self.read_buffer_size:
|
|
73
|
+
del self.stdout_saved_bytes[: len(self.stdout_saved_bytes) - self.read_buffer_size]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@typechecked
|
|
77
|
+
class Interactive:
|
|
78
|
+
"""Interact with a subprocess using non-blocking I/O."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, args: List[str], read_buffer_size: int) -> None:
|
|
81
|
+
self._state = _InteractiveState(args, read_buffer_size)
|
|
82
|
+
|
|
83
|
+
def close(self, nice_timeout_seconds: int) -> int:
|
|
84
|
+
self._state.close_pipes()
|
|
85
|
+
for _ in range(nice_timeout_seconds):
|
|
86
|
+
if self._state.poll() is not None:
|
|
87
|
+
break
|
|
88
|
+
time.sleep(1)
|
|
89
|
+
self._state.kill()
|
|
90
|
+
return self._state.return_code()
|
|
91
|
+
|
|
92
|
+
def write(self, stdin_data: bytes, timeout_seconds: int) -> bool:
|
|
93
|
+
if self._state.poll() is not None:
|
|
94
|
+
return False
|
|
95
|
+
return write_loop_sync(
|
|
96
|
+
self._state.write_chunk,
|
|
97
|
+
stdin_data,
|
|
98
|
+
timeout_seconds,
|
|
99
|
+
sleep_interval=_SLEEP_AFTER_WOUND_BLOCK,
|
|
100
|
+
)
|
|
101
|
+
|
|
101
102
|
def read_line(self, timeout_seconds: int) -> Optional[bytes]:
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if from_saved_bytes is not None:
|
|
107
|
-
return from_saved_bytes
|
|
108
|
-
if self._popen.poll() is not None:
|
|
103
|
+
line = self._state.pop_line(0)
|
|
104
|
+
if line is not None:
|
|
105
|
+
return line
|
|
106
|
+
if self._state.poll() is not None:
|
|
109
107
|
return None
|
|
110
108
|
deadline = time.time() + timeout_seconds
|
|
111
109
|
while time.time() < deadline:
|
|
112
|
-
new_bytes = self.
|
|
113
|
-
# If the read would block, we get None and not zero bytes on MacOS.
|
|
110
|
+
new_bytes = self._state.read_chunk()
|
|
114
111
|
if new_bytes is None:
|
|
115
112
|
time.sleep(_SLEEP_AFTER_WOUND_BLOCK)
|
|
116
113
|
continue
|
|
117
|
-
# If we read 0 bytes, the child has closed stdout and is likely dead.
|
|
118
114
|
if len(new_bytes) == 0:
|
|
119
115
|
return None
|
|
120
|
-
|
|
121
|
-
self.
|
|
122
|
-
|
|
123
|
-
if
|
|
124
|
-
return
|
|
125
|
-
|
|
126
|
-
del self._stdout_saved_bytes[
|
|
127
|
-
: len(self._stdout_saved_bytes) - self._read_buffer_size
|
|
128
|
-
]
|
|
116
|
+
prev_len = len(self._state.stdout_saved_bytes)
|
|
117
|
+
self._state.append_stdout(new_bytes)
|
|
118
|
+
line = self._state.pop_line(prev_len)
|
|
119
|
+
if line is not None:
|
|
120
|
+
return line
|
|
121
|
+
self._state.trim_stdout()
|
|
129
122
|
time.sleep(_SLEEP_AFTER_WOUND_BLOCK)
|
|
130
|
-
|
|
131
123
|
return None
|
|
124
|
+
|
|
@@ -1,131 +1,58 @@
|
|
|
1
1
|
from typeguard import typechecked
|
|
2
2
|
from typing import List, Optional
|
|
3
|
-
import time
|
|
4
|
-
import errno
|
|
5
|
-
import subprocess
|
|
6
|
-
from .util import set_nonblocking
|
|
7
3
|
import asyncio
|
|
4
|
+
import time
|
|
8
5
|
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
from .interactive import _InteractiveState, _SLEEP_AFTER_WOUND_BLOCK
|
|
7
|
+
from .util import write_loop_async
|
|
11
8
|
|
|
12
9
|
|
|
13
10
|
@typechecked
|
|
14
11
|
class Interactive:
|
|
15
|
-
"""
|
|
16
|
-
A class for interacting with a subprocess that is careful to use non-blocking
|
|
17
|
-
I/O so that we can timeout reads and writes.
|
|
18
|
-
"""
|
|
12
|
+
"""Asynchronous interface for interacting with a subprocess."""
|
|
19
13
|
|
|
20
|
-
def __init__(self, args: List[str], read_buffer_size: int):
|
|
21
|
-
|
|
22
|
-
read_buffer_size is the maximum number of bytes to read from stdout
|
|
23
|
-
and stdout each. If the process writes more than this, the extra bytes
|
|
24
|
-
will be discarded.
|
|
25
|
-
"""
|
|
26
|
-
popen = subprocess.Popen(
|
|
27
|
-
args,
|
|
28
|
-
stdin=subprocess.PIPE,
|
|
29
|
-
stdout=subprocess.PIPE,
|
|
30
|
-
# stderr=subprocess.PIPE,
|
|
31
|
-
bufsize=_MAX_BYTES_PER_READ,
|
|
32
|
-
)
|
|
33
|
-
set_nonblocking(popen.stdin)
|
|
34
|
-
set_nonblocking(popen.stdout)
|
|
35
|
-
self._read_buffer_size = read_buffer_size
|
|
36
|
-
self._stdout_saved_bytes = bytearray()
|
|
37
|
-
self._stderr_saved_bytes = bytearray()
|
|
38
|
-
self._popen = popen
|
|
14
|
+
def __init__(self, args: List[str], read_buffer_size: int) -> None:
|
|
15
|
+
self._state = _InteractiveState(args, read_buffer_size)
|
|
39
16
|
|
|
40
|
-
async def close(self, nice_timeout_seconds: int) ->
|
|
41
|
-
|
|
42
|
-
Close the process and wait for it to exit.
|
|
43
|
-
"""
|
|
44
|
-
try:
|
|
45
|
-
self._popen.stdin.close()
|
|
46
|
-
except BlockingIOError:
|
|
47
|
-
# .close() will attempt to flush any buffered writes to stdout
|
|
48
|
-
# before the close returns. This may block, but since the file
|
|
49
|
-
# descriptor is non-blocking, we get a BlockingIOError.
|
|
50
|
-
pass
|
|
51
|
-
self._popen.stdout.close()
|
|
17
|
+
async def close(self, nice_timeout_seconds: int) -> int:
|
|
18
|
+
self._state.close_pipes()
|
|
52
19
|
for _ in range(nice_timeout_seconds):
|
|
53
|
-
if self.
|
|
20
|
+
if self._state.poll() is not None:
|
|
54
21
|
break
|
|
55
22
|
await asyncio.sleep(1)
|
|
56
|
-
self.
|
|
57
|
-
|
|
58
|
-
return return_code if return_code is not None else -9
|
|
23
|
+
self._state.kill()
|
|
24
|
+
return self._state.return_code()
|
|
59
25
|
|
|
60
|
-
async def write(self, stdin_data: bytes, timeout_seconds: int):
|
|
61
|
-
|
|
62
|
-
Write data to the process's stdin.
|
|
63
|
-
"""
|
|
64
|
-
if self._popen.poll() is not None:
|
|
26
|
+
async def write(self, stdin_data: bytes, timeout_seconds: int) -> bool:
|
|
27
|
+
if self._state.poll() is not None:
|
|
65
28
|
return False
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
self._popen.stdin.flush()
|
|
73
|
-
except BlockingIOError as exn:
|
|
74
|
-
if exn.errno != errno.EAGAIN:
|
|
75
|
-
return False
|
|
76
|
-
bytes_written = exn.characters_written
|
|
77
|
-
await asyncio.sleep(_SLEEP_AFTER_WOUND_BLOCK)
|
|
78
|
-
except BrokenPipeError:
|
|
79
|
-
# The child has closed stdin. It is likely dead.
|
|
80
|
-
return False
|
|
81
|
-
write_start_index += bytes_written
|
|
82
|
-
if time.time() - start_time > timeout_seconds:
|
|
83
|
-
return False
|
|
84
|
-
return True
|
|
85
|
-
|
|
86
|
-
def _read_line_from_saved_bytes(self, newline_search_index: int) -> Optional[bytes]:
|
|
87
|
-
"""
|
|
88
|
-
Try to read a line of output from the buffer of stdout that this object
|
|
89
|
-
manages itself. The newline_search_index to indicate where to start
|
|
90
|
-
looking for b"\n". Typically will be 0, but there are cases where we
|
|
91
|
-
are certain that the the newline is not in a prefix.
|
|
92
|
-
"""
|
|
93
|
-
newline_index = self._stdout_saved_bytes.find(b"\n", newline_search_index)
|
|
94
|
-
if newline_index == -1:
|
|
95
|
-
return None
|
|
96
|
-
# memoryview helps avoid a pointless copy
|
|
97
|
-
line = memoryview(self._stdout_saved_bytes)[:newline_index].tobytes()
|
|
98
|
-
del self._stdout_saved_bytes[: newline_index + 1]
|
|
99
|
-
return line
|
|
29
|
+
return await write_loop_async(
|
|
30
|
+
self._state.write_chunk,
|
|
31
|
+
stdin_data,
|
|
32
|
+
timeout_seconds,
|
|
33
|
+
sleep_interval=_SLEEP_AFTER_WOUND_BLOCK,
|
|
34
|
+
)
|
|
100
35
|
|
|
101
36
|
async def read_line(self, timeout_seconds: int) -> Optional[bytes]:
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if from_saved_bytes is not None:
|
|
107
|
-
return from_saved_bytes
|
|
108
|
-
if self._popen.poll() is not None:
|
|
37
|
+
line = self._state.pop_line(0)
|
|
38
|
+
if line is not None:
|
|
39
|
+
return line
|
|
40
|
+
if self._state.poll() is not None:
|
|
109
41
|
return None
|
|
110
42
|
deadline = time.time() + timeout_seconds
|
|
111
43
|
while time.time() < deadline:
|
|
112
|
-
new_bytes = self.
|
|
113
|
-
# If the read would block, we get None and not zero bytes on MacOS.
|
|
44
|
+
new_bytes = self._state.read_chunk()
|
|
114
45
|
if new_bytes is None:
|
|
115
46
|
await asyncio.sleep(_SLEEP_AFTER_WOUND_BLOCK)
|
|
116
47
|
continue
|
|
117
|
-
# If we read 0 bytes, the child has closed stdout and is likely dead.
|
|
118
48
|
if len(new_bytes) == 0:
|
|
119
49
|
return None
|
|
120
|
-
|
|
121
|
-
self.
|
|
122
|
-
|
|
123
|
-
if
|
|
124
|
-
return
|
|
125
|
-
|
|
126
|
-
del self._stdout_saved_bytes[
|
|
127
|
-
: len(self._stdout_saved_bytes) - self._read_buffer_size
|
|
128
|
-
]
|
|
50
|
+
prev_len = len(self._state.stdout_saved_bytes)
|
|
51
|
+
self._state.append_stdout(new_bytes)
|
|
52
|
+
line = self._state.pop_line(prev_len)
|
|
53
|
+
if line is not None:
|
|
54
|
+
return line
|
|
55
|
+
self._state.trim_stdout()
|
|
129
56
|
await asyncio.sleep(_SLEEP_AFTER_WOUND_BLOCK)
|
|
130
|
-
|
|
131
57
|
return None
|
|
58
|
+
|
bounded_subprocess/util.py
CHANGED
|
@@ -2,9 +2,14 @@ import subprocess
|
|
|
2
2
|
import os
|
|
3
3
|
import fcntl
|
|
4
4
|
import signal
|
|
5
|
+
from typing import Callable, Optional
|
|
6
|
+
import errno
|
|
7
|
+
import time
|
|
8
|
+
import asyncio
|
|
5
9
|
|
|
6
10
|
MAX_BYTES_PER_READ = 1024
|
|
7
11
|
SLEEP_BETWEEN_READS = 0.1
|
|
12
|
+
_STDIN_WRITE_TIMEOUT = 1.0
|
|
8
13
|
|
|
9
14
|
|
|
10
15
|
class Result:
|
|
@@ -26,20 +31,79 @@ def set_nonblocking(reader):
|
|
|
26
31
|
fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
|
|
27
32
|
|
|
28
33
|
|
|
34
|
+
def popen_write_chunk(p: subprocess.Popen, data: memoryview) -> tuple[int, bool]:
|
|
35
|
+
"""Try writing a chunk of bytes to a non-blocking Popen stdin."""
|
|
36
|
+
try:
|
|
37
|
+
written = p.stdin.write(data)
|
|
38
|
+
p.stdin.flush()
|
|
39
|
+
if written is None:
|
|
40
|
+
written = 0
|
|
41
|
+
return written, True
|
|
42
|
+
except BlockingIOError as exn:
|
|
43
|
+
if exn.errno != errno.EAGAIN:
|
|
44
|
+
return exn.characters_written, False
|
|
45
|
+
return exn.characters_written, True
|
|
46
|
+
except BrokenPipeError:
|
|
47
|
+
return 0, False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def write_loop_sync(
|
|
51
|
+
write_chunk: Callable[[memoryview], tuple[int, bool]],
|
|
52
|
+
data: bytes,
|
|
53
|
+
timeout_seconds: float,
|
|
54
|
+
*,
|
|
55
|
+
sleep_interval: float,
|
|
56
|
+
) -> bool:
|
|
57
|
+
"""Repeatedly write data using write_chunk until complete or timeout."""
|
|
58
|
+
mv = memoryview(data)
|
|
59
|
+
start = 0
|
|
60
|
+
start_time = time.time()
|
|
61
|
+
while start < len(mv):
|
|
62
|
+
written, keep_going = write_chunk(mv[start:])
|
|
63
|
+
start += written
|
|
64
|
+
if not keep_going:
|
|
65
|
+
return False
|
|
66
|
+
if start < len(mv):
|
|
67
|
+
if time.time() - start_time > timeout_seconds:
|
|
68
|
+
return False
|
|
69
|
+
time.sleep(sleep_interval)
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def write_loop_async(
|
|
74
|
+
write_chunk: Callable[[memoryview], tuple[int, bool]],
|
|
75
|
+
data: bytes,
|
|
76
|
+
timeout_seconds: float,
|
|
77
|
+
*,
|
|
78
|
+
sleep_interval: float,
|
|
79
|
+
) -> bool:
|
|
80
|
+
"""Asynchronously write data until complete or timeout."""
|
|
81
|
+
mv = memoryview(data)
|
|
82
|
+
start = 0
|
|
83
|
+
start_time = time.time()
|
|
84
|
+
while start < len(mv):
|
|
85
|
+
written, keep_going = write_chunk(mv[start:])
|
|
86
|
+
start += written
|
|
87
|
+
if not keep_going:
|
|
88
|
+
return False
|
|
89
|
+
if start < len(mv):
|
|
90
|
+
if time.time() - start_time > timeout_seconds:
|
|
91
|
+
return False
|
|
92
|
+
await asyncio.sleep(sleep_interval)
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
|
|
29
96
|
class BoundedSubprocessState:
|
|
30
|
-
"""
|
|
31
|
-
This class lets us share code between the synchronous and asynchronous
|
|
32
|
-
implementations.
|
|
33
|
-
"""
|
|
97
|
+
"""State shared between synchronous and asynchronous subprocess helpers."""
|
|
34
98
|
|
|
35
|
-
def __init__(self, args, env, max_output_size):
|
|
99
|
+
def __init__(self, args, env, max_output_size, use_stdin_pipe: bool = False):
|
|
36
100
|
"""
|
|
37
101
|
Start the process in a new session.
|
|
38
102
|
"""
|
|
39
103
|
p = subprocess.Popen(
|
|
40
104
|
args,
|
|
41
105
|
env=env,
|
|
42
|
-
stdin=subprocess.DEVNULL,
|
|
106
|
+
stdin=subprocess.PIPE if use_stdin_pipe else subprocess.DEVNULL,
|
|
43
107
|
stdout=subprocess.PIPE,
|
|
44
108
|
stderr=subprocess.PIPE,
|
|
45
109
|
start_new_session=True,
|
|
@@ -47,6 +111,8 @@ class BoundedSubprocessState:
|
|
|
47
111
|
)
|
|
48
112
|
set_nonblocking(p.stdout)
|
|
49
113
|
set_nonblocking(p.stderr)
|
|
114
|
+
if use_stdin_pipe:
|
|
115
|
+
set_nonblocking(p.stdin)
|
|
50
116
|
|
|
51
117
|
self.process_group_id = os.getpgid(p.pid)
|
|
52
118
|
self.p = p
|
|
@@ -57,6 +123,30 @@ class BoundedSubprocessState:
|
|
|
57
123
|
self.stderr_bytes_read = 0
|
|
58
124
|
self.max_output_size = max_output_size
|
|
59
125
|
|
|
126
|
+
def write_chunk(self, data: memoryview) -> tuple[int, bool]:
|
|
127
|
+
if self.p.stdin is None:
|
|
128
|
+
return 0, False
|
|
129
|
+
try:
|
|
130
|
+
written = self.p.stdin.write(data)
|
|
131
|
+
self.p.stdin.flush()
|
|
132
|
+
if written is None:
|
|
133
|
+
written = 0
|
|
134
|
+
return written, True
|
|
135
|
+
except BlockingIOError as exn:
|
|
136
|
+
if exn.errno != errno.EAGAIN:
|
|
137
|
+
return exn.characters_written, False
|
|
138
|
+
return exn.characters_written, True
|
|
139
|
+
except BrokenPipeError:
|
|
140
|
+
return 0, False
|
|
141
|
+
|
|
142
|
+
def close_stdin(self) -> None:
|
|
143
|
+
if self.p.stdin is None:
|
|
144
|
+
return
|
|
145
|
+
try:
|
|
146
|
+
self.p.stdin.close()
|
|
147
|
+
except BrokenPipeError:
|
|
148
|
+
pass
|
|
149
|
+
|
|
60
150
|
def try_read(self) -> bool:
|
|
61
151
|
"""
|
|
62
152
|
Reads from the process. Returning False indicates that we should stop
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bounded_subprocess
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: A library to facilitate running subprocesses that may misbehave.
|
|
5
5
|
Project-URL: Homepage, https://github.com/arjunguha/bounded_subprocess
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/arjunguha/bounded_subprocess
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
bounded_subprocess/__init__.py,sha256=L88cc8vG7GE11T0fF1-tMUIbRRlOmmlCqa6wopP3Ox8,1003
|
|
2
|
+
bounded_subprocess/bounded_subprocess.py,sha256=-UuVhlbuMEWDNDjr_9tSUkcRxb-3RZKqOqup0nQI0zA,1231
|
|
3
|
+
bounded_subprocess/bounded_subprocess_async.py,sha256=XSvi-FiKZbVbREnKDGiSG4UVViVoUBCCQzEohAS5GwM,1543
|
|
4
|
+
bounded_subprocess/interactive.py,sha256=4fG4NB3eN5rqssUpfjiEQL2F-S7eDBwB2Mw1gzCL3Qk,4149
|
|
5
|
+
bounded_subprocess/interactive_async.py,sha256=WKPA2XBnq_qMh5WijyoSOpg2iJcsYcQSXNGjv6IEEfA,1979
|
|
6
|
+
bounded_subprocess/util.py,sha256=zaTl2LkhyOelRGotlgISkKy8cURQaTTNweTYoSchISA,6402
|
|
7
|
+
bounded_subprocess-2.1.0.dist-info/METADATA,sha256=sxiscwiihS4QqFDVzXhgnwDfPdFBWunmX7N8KScypCw,2664
|
|
8
|
+
bounded_subprocess-2.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
9
|
+
bounded_subprocess-2.1.0.dist-info/licenses/LICENSE.txt,sha256=UVerBV0_1vMFt8QkaXuVnZVSlOiKDiBSieK5MNLy4Ls,1086
|
|
10
|
+
bounded_subprocess-2.1.0.dist-info/RECORD,,
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
bounded_subprocess/__init__.py,sha256=L88cc8vG7GE11T0fF1-tMUIbRRlOmmlCqa6wopP3Ox8,1003
|
|
2
|
-
bounded_subprocess/bounded_subprocess.py,sha256=_lfjoVwzhlHfrysg20ESinumiThnaZaOorIbjRTySpY,854
|
|
3
|
-
bounded_subprocess/bounded_subprocess_async.py,sha256=uvwMhB0OSJ91qJXVFlIzWfNQXQzw9I8WV7RdNzNWVDc,1158
|
|
4
|
-
bounded_subprocess/interactive.py,sha256=Vn-002fH6rAekOwugGqdZUZ3O3x1wY-mLxApBtyC-DA,5016
|
|
5
|
-
bounded_subprocess/interactive_async.py,sha256=igxFfeC7zdQxX34-pQRlTNgYIpSbVFgmGYCCvpwsMPw,5085
|
|
6
|
-
bounded_subprocess/util.py,sha256=MBP34Juj-3JGJj6-XFzTaLhPZztQGk2tpONH8AvUX48,3638
|
|
7
|
-
bounded_subprocess-2.0.0.dist-info/METADATA,sha256=ckc2wxHahQwumKGSuDrhSsDg5wH1Z7-fLgHnpkaqzkU,2664
|
|
8
|
-
bounded_subprocess-2.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
9
|
-
bounded_subprocess-2.0.0.dist-info/licenses/LICENSE.txt,sha256=UVerBV0_1vMFt8QkaXuVnZVSlOiKDiBSieK5MNLy4Ls,1086
|
|
10
|
-
bounded_subprocess-2.0.0.dist-info/RECORD,,
|
|
File without changes
|
{bounded_subprocess-2.0.0.dist-info → bounded_subprocess-2.1.0.dist-info}/licenses/LICENSE.txt
RENAMED
|
File without changes
|