bounded_subprocess 1.5.0__tar.gz → 2.1.0__tar.gz

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.

Files changed (35) hide show
  1. bounded_subprocess-2.1.0/.github/workflows/test.yml +19 -0
  2. bounded_subprocess-2.1.0/Makefile +10 -0
  3. {bounded_subprocess-1.5.0 → bounded_subprocess-2.1.0}/PKG-INFO +1 -1
  4. {bounded_subprocess-1.5.0 → bounded_subprocess-2.1.0}/pyproject.toml +5 -1
  5. bounded_subprocess-2.1.0/src/bounded_subprocess/__init__.py +34 -0
  6. bounded_subprocess-1.5.0/src/bounded_subprocess/__init__.py → bounded_subprocess-2.1.0/src/bounded_subprocess/bounded_subprocess.py +18 -3
  7. {bounded_subprocess-1.5.0 → bounded_subprocess-2.1.0}/src/bounded_subprocess/bounded_subprocess_async.py +19 -3
  8. bounded_subprocess-2.1.0/src/bounded_subprocess/interactive.py +124 -0
  9. bounded_subprocess-2.1.0/src/bounded_subprocess/interactive_async.py +58 -0
  10. {bounded_subprocess-1.5.0 → bounded_subprocess-2.1.0}/src/bounded_subprocess/util.py +96 -6
  11. bounded_subprocess-2.1.0/test/evil_programs/echo_stdin.py +4 -0
  12. {bounded_subprocess-1.5.0 → bounded_subprocess-2.1.0}/test/module_test.py +31 -0
  13. {bounded_subprocess-1.5.0 → bounded_subprocess-2.1.0}/test/test_async.py +31 -0
  14. bounded_subprocess-1.5.0/Makefile +0 -10
  15. bounded_subprocess-1.5.0/src/bounded_subprocess/interactive.py +0 -131
  16. bounded_subprocess-1.5.0/src/bounded_subprocess/interactive_async.py +0 -131
  17. {bounded_subprocess-1.5.0 → bounded_subprocess-2.1.0}/.gitignore +0 -0
  18. {bounded_subprocess-1.5.0 → bounded_subprocess-2.1.0}/LICENSE.txt +0 -0
  19. {bounded_subprocess-1.5.0 → bounded_subprocess-2.1.0}/README.md +0 -0
  20. {bounded_subprocess-1.5.0 → bounded_subprocess-2.1.0}/cspell.config.yaml +0 -0
  21. {bounded_subprocess-1.5.0 → bounded_subprocess-2.1.0}/test/__init__.py +0 -0
  22. {bounded_subprocess-1.5.0 → bounded_subprocess-2.1.0}/test/evil_programs/block_on_inputs.py +0 -0
  23. {bounded_subprocess-1.5.0 → bounded_subprocess-2.1.0}/test/evil_programs/close_outputs.py +0 -0
  24. {bounded_subprocess-1.5.0 → bounded_subprocess-2.1.0}/test/evil_programs/dies_shortly_after_launch.py +0 -0
  25. {bounded_subprocess-1.5.0 → bounded_subprocess-2.1.0}/test/evil_programs/dies_while_writing.py +0 -0
  26. {bounded_subprocess-1.5.0 → bounded_subprocess-2.1.0}/test/evil_programs/does_not_read.py +0 -0
  27. {bounded_subprocess-1.5.0 → bounded_subprocess-2.1.0}/test/evil_programs/fork_bomb.py +0 -0
  28. {bounded_subprocess-1.5.0 → bounded_subprocess-2.1.0}/test/evil_programs/fork_once.py +0 -0
  29. {bounded_subprocess-1.5.0 → bounded_subprocess-2.1.0}/test/evil_programs/long_stdout.py +0 -0
  30. {bounded_subprocess-1.5.0 → bounded_subprocess-2.1.0}/test/evil_programs/sleep_forever.py +0 -0
  31. {bounded_subprocess-1.5.0 → bounded_subprocess-2.1.0}/test/evil_programs/unbounded_output.py +0 -0
  32. {bounded_subprocess-1.5.0 → bounded_subprocess-2.1.0}/test/evil_programs/write_forever_but_no_newline.py +0 -0
  33. {bounded_subprocess-1.5.0 → bounded_subprocess-2.1.0}/test/test_interactive.py +0 -0
  34. {bounded_subprocess-1.5.0 → bounded_subprocess-2.1.0}/test/test_interactive_async.py +0 -0
  35. {bounded_subprocess-1.5.0 → bounded_subprocess-2.1.0}/uv.lock +0 -0
@@ -0,0 +1,19 @@
1
+ name: Test
2
+ on:
3
+ push:
4
+ branches: [ "main" ]
5
+ pull_request:
6
+ branches: [ "main" ]
7
+ permissions:
8
+ contents: read
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - name: Install uv
15
+ uses: astral-sh/setup-uv@v5
16
+ - name: Install the project
17
+ run: uv sync --locked --all-extras --dev
18
+ - name: Run tests
19
+ run: uv run python -m pytest -m "not unsafe"
@@ -0,0 +1,10 @@
1
+ .PHONY: test build publish
2
+
3
+ build:
4
+ uv build
5
+
6
+ publish:
7
+ uv publish
8
+
9
+ test:
10
+ uv run python -m pytest -m "not unsafe"
@@ -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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bounded_subprocess"
3
- version = "1.5.0"
3
+ version = "2.1.0"
4
4
  authors = [
5
5
  { name="Arjun Guha" },
6
6
  { name="Ming-Ho Yee" },
@@ -25,6 +25,10 @@ build-backend = "hatchling.build"
25
25
 
26
26
  [tool.pytest.ini_options]
27
27
  pythonpath = ["src"]
28
+ markers = [
29
+ "unsafe: Tests that may crash the machine on failure",
30
+ ]
31
+ asyncio_default_fixture_loop_scope = "function"
28
32
 
29
33
  [project.urls]
30
34
  "Homepage" = "https://github.com/arjunguha/bounded_subprocess"
@@ -0,0 +1,34 @@
1
+ """
2
+ Bounded subprocess execution with timeout and output limits.
3
+
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
+ """
8
+
9
+ __version__ = "1.0.0"
10
+
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}'")
27
+
28
+ # Expose key classes and constants for convenience
29
+ __all__ = [
30
+ "run",
31
+ "Result",
32
+ "BoundedSubprocessState",
33
+ "SLEEP_BETWEEN_READS"
34
+ ]
@@ -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 Result, BoundedSubprocessState, SLEEP_BETWEEN_READS
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
@@ -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
+
@@ -0,0 +1,124 @@
1
+ from typeguard import typechecked
2
+ from typing import List, Optional
3
+ import time
4
+ import errno
5
+ import subprocess
6
+ from .util import set_nonblocking, MAX_BYTES_PER_READ, write_loop_sync
7
+
8
+ _SLEEP_AFTER_WOUND_BLOCK = 0.5
9
+
10
+
11
+ class _InteractiveState:
12
+ """Shared implementation for synchronous and asynchronous interaction."""
13
+
14
+ def __init__(self, args: List[str], read_buffer_size: int) -> None:
15
+ popen = subprocess.Popen(
16
+ args,
17
+ stdin=subprocess.PIPE,
18
+ stdout=subprocess.PIPE,
19
+ bufsize=MAX_BYTES_PER_READ,
20
+ )
21
+ set_nonblocking(popen.stdin)
22
+ set_nonblocking(popen.stdout)
23
+ self.popen = popen
24
+ self.read_buffer_size = read_buffer_size
25
+ self.stdout_saved_bytes = bytearray()
26
+
27
+ # --- low level helpers -------------------------------------------------
28
+ def poll(self) -> Optional[int]:
29
+ return self.popen.poll()
30
+
31
+ def close_pipes(self) -> None:
32
+ try:
33
+ self.popen.stdin.close()
34
+ except BlockingIOError:
35
+ pass
36
+ self.popen.stdout.close()
37
+
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)
62
+ if newline_index == -1:
63
+ return None
64
+ line = memoryview(self.stdout_saved_bytes)[:newline_index].tobytes()
65
+ del self.stdout_saved_bytes[: newline_index + 1]
66
+ return line
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
+
102
+ def read_line(self, timeout_seconds: int) -> Optional[bytes]:
103
+ line = self._state.pop_line(0)
104
+ if line is not None:
105
+ return line
106
+ if self._state.poll() is not None:
107
+ return None
108
+ deadline = time.time() + timeout_seconds
109
+ while time.time() < deadline:
110
+ new_bytes = self._state.read_chunk()
111
+ if new_bytes is None:
112
+ time.sleep(_SLEEP_AFTER_WOUND_BLOCK)
113
+ continue
114
+ if len(new_bytes) == 0:
115
+ return None
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()
122
+ time.sleep(_SLEEP_AFTER_WOUND_BLOCK)
123
+ return None
124
+
@@ -0,0 +1,58 @@
1
+ from typeguard import typechecked
2
+ from typing import List, Optional
3
+ import asyncio
4
+ import time
5
+
6
+ from .interactive import _InteractiveState, _SLEEP_AFTER_WOUND_BLOCK
7
+ from .util import write_loop_async
8
+
9
+
10
+ @typechecked
11
+ class Interactive:
12
+ """Asynchronous interface for interacting with a subprocess."""
13
+
14
+ def __init__(self, args: List[str], read_buffer_size: int) -> None:
15
+ self._state = _InteractiveState(args, read_buffer_size)
16
+
17
+ async def close(self, nice_timeout_seconds: int) -> int:
18
+ self._state.close_pipes()
19
+ for _ in range(nice_timeout_seconds):
20
+ if self._state.poll() is not None:
21
+ break
22
+ await asyncio.sleep(1)
23
+ self._state.kill()
24
+ return self._state.return_code()
25
+
26
+ async def write(self, stdin_data: bytes, timeout_seconds: int) -> bool:
27
+ if self._state.poll() is not None:
28
+ return False
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
+ )
35
+
36
+ async def read_line(self, timeout_seconds: int) -> Optional[bytes]:
37
+ line = self._state.pop_line(0)
38
+ if line is not None:
39
+ return line
40
+ if self._state.poll() is not None:
41
+ return None
42
+ deadline = time.time() + timeout_seconds
43
+ while time.time() < deadline:
44
+ new_bytes = self._state.read_chunk()
45
+ if new_bytes is None:
46
+ await asyncio.sleep(_SLEEP_AFTER_WOUND_BLOCK)
47
+ continue
48
+ if len(new_bytes) == 0:
49
+ return None
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()
56
+ await asyncio.sleep(_SLEEP_AFTER_WOUND_BLOCK)
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
@@ -0,0 +1,4 @@
1
+ import sys
2
+
3
+ data = sys.stdin.read()
4
+ print(data, end="")
@@ -1,6 +1,7 @@
1
1
  from bounded_subprocess import run
2
2
  import time
3
3
  from pathlib import Path
4
+ import pytest
4
5
 
5
6
  ROOT = Path(__file__).resolve().parent / "evil_programs"
6
7
 
@@ -70,6 +71,7 @@ def test_sleep_forever():
70
71
  assert_no_running_evil()
71
72
 
72
73
 
74
+ @pytest.mark.unsafe
73
75
  def test_fork_bomb():
74
76
  result = run(
75
77
  ["python3", ROOT / "fork_bomb.py"],
@@ -114,4 +116,33 @@ def test_long_stdout():
114
116
  assert result.timeout == False
115
117
  # leave some leeway space for encoding
116
118
  assert 9500 <= len(result.stdout) <= 10500
119
+ assert_no_running_evil()
120
+
121
+
122
+ def test_stdin_data_does_not_read():
123
+ data = "hello world\n"
124
+ result = run(
125
+ ["python3", ROOT / "does_not_read.py"],
126
+ timeout_seconds=2,
127
+ max_output_size=1024,
128
+ stdin_data=data,
129
+ )
130
+ assert result.exit_code == -1
131
+ assert result.timeout is True
132
+ assert len(result.stdout) == 0
133
+ assert len(result.stderr) == 0
134
+ assert_no_running_evil()
135
+
136
+
137
+ def test_stdin_data_echo():
138
+ data = "hello world\n"
139
+ result = run(
140
+ ["python3", ROOT / "echo_stdin.py"],
141
+ timeout_seconds=2,
142
+ max_output_size=1024,
143
+ stdin_data=data,
144
+ )
145
+ assert result.exit_code == 0
146
+ assert result.timeout is False
147
+ assert result.stdout == data
117
148
  assert_no_running_evil()
@@ -72,3 +72,34 @@ async def test_concurrent_sleep():
72
72
  assert all(len(r.stderr) == 0 for r in results)
73
73
  assert time.time() - start_time < 1.1
74
74
  await assert_no_running_evil()
75
+
76
+
77
+ @pytest.mark.asyncio
78
+ async def test_stdin_data_async_does_not_read():
79
+ data = "hello async\n"
80
+ result = await run(
81
+ ["python3", ROOT / "does_not_read.py"],
82
+ timeout_seconds=2,
83
+ max_output_size=1024,
84
+ stdin_data=data,
85
+ )
86
+ assert result.exit_code == -1
87
+ assert result.timeout is True
88
+ assert len(result.stdout) == 0
89
+ assert len(result.stderr) == 0
90
+ await assert_no_running_evil()
91
+
92
+
93
+ @pytest.mark.asyncio
94
+ async def test_stdin_data_async_echo():
95
+ data = "hello async\n"
96
+ result = await run(
97
+ ["python3", ROOT / "echo_stdin.py"],
98
+ timeout_seconds=2,
99
+ max_output_size=1024,
100
+ stdin_data=data,
101
+ )
102
+ assert result.exit_code == 0
103
+ assert result.timeout is False
104
+ assert result.stdout == data
105
+ await assert_no_running_evil()
@@ -1,10 +0,0 @@
1
- .PHONY: test
2
-
3
- build:
4
- uv build
5
-
6
- publish:
7
- python3 -m twine upload dist/*
8
-
9
- test:
10
- uv run python -m pytest
@@ -1,131 +0,0 @@
1
- from typeguard import typechecked
2
- from typing import List, Optional
3
- import time
4
- import errno
5
- import subprocess
6
- from .util import set_nonblocking
7
-
8
-
9
- _MAX_BYTES_PER_READ = 1024
10
- _SLEEP_AFTER_WOUND_BLOCK = 0.5
11
-
12
-
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
- """
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
39
-
40
- 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()
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
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
- 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)
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
100
-
101
- 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:
109
- return None
110
- deadline = time.time() + timeout_seconds
111
- 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.
114
- if new_bytes is None:
115
- time.sleep(_SLEEP_AFTER_WOUND_BLOCK)
116
- continue
117
- # If we read 0 bytes, the child has closed stdout and is likely dead.
118
- if len(new_bytes) == 0:
119
- 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
- ]
129
- time.sleep(_SLEEP_AFTER_WOUND_BLOCK)
130
-
131
- return None
@@ -1,131 +0,0 @@
1
- from typeguard import typechecked
2
- from typing import List, Optional
3
- import time
4
- import errno
5
- import subprocess
6
- from .util import set_nonblocking
7
- import asyncio
8
-
9
- _MAX_BYTES_PER_READ = 1024
10
- _SLEEP_AFTER_WOUND_BLOCK = 0.5
11
-
12
-
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
- """
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
39
-
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()
52
- for _ in range(nice_timeout_seconds):
53
- if self._popen.poll() is not None:
54
- break
55
- 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
59
-
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:
65
- 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
100
-
101
- 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:
109
- return None
110
- deadline = time.time() + timeout_seconds
111
- 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.
114
- if new_bytes is None:
115
- await asyncio.sleep(_SLEEP_AFTER_WOUND_BLOCK)
116
- continue
117
- # If we read 0 bytes, the child has closed stdout and is likely dead.
118
- if len(new_bytes) == 0:
119
- 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
- ]
129
- await asyncio.sleep(_SLEEP_AFTER_WOUND_BLOCK)
130
-
131
- return None