bounded_subprocess 1.5.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.

@@ -1,30 +1,34 @@
1
- import time
2
- from typing import List
1
+ """
2
+ Bounded subprocess execution with timeout and output limits.
3
3
 
4
- from .util import Result, BoundedSubprocessState, SLEEP_BETWEEN_READS
4
+ This package provides convenient functions for running subprocesses with bounded
5
+ execution time and output size, with support for both synchronous and asynchronous
6
+ execution patterns.
7
+ """
5
8
 
9
+ __version__ = "1.0.0"
6
10
 
7
- def run(
8
- args: List[str],
9
- timeout_seconds: int = 15,
10
- max_output_size: int = 2048,
11
- env=None,
12
- ) -> Result:
13
- """
14
- Runs the given program with arguments. After the timeout elapses, kills the process
15
- and all other processes in the process group. Captures at most max_output_size bytes
16
- of stdout and stderr each, and discards any output beyond that.
17
- """
18
- state = BoundedSubprocessState(args, env, max_output_size)
11
+ # Lazy imports for better startup performance
12
+ def __getattr__(name):
13
+ if name == "run":
14
+ from .bounded_subprocess import run
15
+ return run
16
+ elif name == "Result":
17
+ from .util import Result
18
+ return Result
19
+ elif name == "BoundedSubprocessState":
20
+ from .util import BoundedSubprocessState
21
+ return BoundedSubprocessState
22
+ elif name == "SLEEP_BETWEEN_READS":
23
+ from .util import SLEEP_BETWEEN_READS
24
+ return SLEEP_BETWEEN_READS
25
+ else:
26
+ raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
19
27
 
20
- # We sleep for 0.1 seconds in each iteration.
21
- max_iterations = timeout_seconds * 10
22
-
23
- for _ in range(max_iterations):
24
- keep_reading = state.try_read()
25
- if keep_reading:
26
- time.sleep(SLEEP_BETWEEN_READS)
27
- else:
28
- break
29
-
30
- return state.terminate()
28
+ # Expose key classes and constants for convenience
29
+ __all__ = [
30
+ "run",
31
+ "Result",
32
+ "BoundedSubprocessState",
33
+ "SLEEP_BETWEEN_READS"
34
+ ]
@@ -0,0 +1,45 @@
1
+ import time
2
+ from typing import List, Optional
3
+
4
+ from .util import (
5
+ Result,
6
+ BoundedSubprocessState,
7
+ SLEEP_BETWEEN_READS,
8
+ write_loop_sync,
9
+ _STDIN_WRITE_TIMEOUT,
10
+ )
11
+
12
+
13
+ def run(
14
+ args: List[str],
15
+ timeout_seconds: int = 15,
16
+ max_output_size: int = 2048,
17
+ env=None,
18
+ stdin_data: Optional[str] = None,
19
+ ) -> Result:
20
+ """
21
+ Runs the given program with arguments. After the timeout elapses, kills the process
22
+ and all other processes in the process group. Captures at most max_output_size bytes
23
+ of stdout and stderr each, and discards any output beyond that.
24
+ """
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()
34
+
35
+ # We sleep for 0.1 seconds in each iteration.
36
+ max_iterations = timeout_seconds * 10
37
+
38
+ for _ in range(max_iterations):
39
+ keep_reading = state.try_read()
40
+ if keep_reading:
41
+ time.sleep(SLEEP_BETWEEN_READS)
42
+ else:
43
+ break
44
+
45
+ return state.terminate()
@@ -1,6 +1,12 @@
1
1
  import asyncio
2
- from typing import List
3
- from .util import Result, BoundedSubprocessState, SLEEP_BETWEEN_READS
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
- @typechecked
14
- 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
- """
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
- # stderr=subprocess.PIPE,
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._read_buffer_size = read_buffer_size
36
- self._stdout_saved_bytes = bytearray()
37
- self._stderr_saved_bytes = bytearray()
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
- def close(self, nice_timeout_seconds: int) -> int:
41
- """
42
- Close the process and wait for it to exit.
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._popen.stdin.close()
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._popen.stdout.close()
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
- write_start_index = 0
68
- start_time = time.time()
69
- while write_start_index < len(stdin_data):
70
- try:
71
- bytes_written = self._popen.stdin.write(stdin_data[write_start_index:])
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
- time.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)
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
- # 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]
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
- Read a line from the process's stdout.
104
- """
105
- from_saved_bytes = self._read_line_from_saved_bytes(0)
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._popen.stdout.read(_MAX_BYTES_PER_READ)
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
- prev_saved_bytes_len = len(self._stdout_saved_bytes)
121
- self._stdout_saved_bytes.extend(new_bytes)
122
- from_saved_bytes = self._read_line_from_saved_bytes(prev_saved_bytes_len)
123
- if from_saved_bytes is not None:
124
- return from_saved_bytes
125
- if len(self._stdout_saved_bytes) > self._read_buffer_size:
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
- _MAX_BYTES_PER_READ = 1024
10
- _SLEEP_AFTER_WOUND_BLOCK = 0.5
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) -> 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._popen.poll() is not None:
20
+ if self._state.poll() is not None:
54
21
  break
55
22
  await asyncio.sleep(1)
56
- self._popen.kill()
57
- return_code = self._popen.returncode
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
- write_start_index = 0
68
- start_time = time.time()
69
- while write_start_index < len(stdin_data):
70
- try:
71
- bytes_written = self._popen.stdin.write(stdin_data[write_start_index:])
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
- Read a line from the process's stdout.
104
- """
105
- from_saved_bytes = self._read_line_from_saved_bytes(0)
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._popen.stdout.read(_MAX_BYTES_PER_READ)
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
- prev_saved_bytes_len = len(self._stdout_saved_bytes)
121
- self._stdout_saved_bytes.extend(new_bytes)
122
- from_saved_bytes = self._read_line_from_saved_bytes(prev_saved_bytes_len)
123
- if from_saved_bytes is not None:
124
- return from_saved_bytes
125
- if len(self._stdout_saved_bytes) > self._read_buffer_size:
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
+
@@ -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: 1.5.0
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,9 +0,0 @@
1
- bounded_subprocess/__init__.py,sha256=PHsdbPdGYopVQH_KG8vdpifyt8ZuCLd0h_JeMRCPNdI,855
2
- bounded_subprocess/bounded_subprocess_async.py,sha256=uvwMhB0OSJ91qJXVFlIzWfNQXQzw9I8WV7RdNzNWVDc,1158
3
- bounded_subprocess/interactive.py,sha256=Vn-002fH6rAekOwugGqdZUZ3O3x1wY-mLxApBtyC-DA,5016
4
- bounded_subprocess/interactive_async.py,sha256=igxFfeC7zdQxX34-pQRlTNgYIpSbVFgmGYCCvpwsMPw,5085
5
- bounded_subprocess/util.py,sha256=MBP34Juj-3JGJj6-XFzTaLhPZztQGk2tpONH8AvUX48,3638
6
- bounded_subprocess-1.5.0.dist-info/METADATA,sha256=FA0SjIq_lciG1NrdOXjF29tCXIKAuCFO2WyiAlkwkdI,2664
7
- bounded_subprocess-1.5.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
8
- bounded_subprocess-1.5.0.dist-info/licenses/LICENSE.txt,sha256=UVerBV0_1vMFt8QkaXuVnZVSlOiKDiBSieK5MNLy4Ls,1086
9
- bounded_subprocess-1.5.0.dist-info/RECORD,,