bounded_subprocess 2.3.1__tar.gz → 2.5.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.
Files changed (44) hide show
  1. bounded_subprocess-2.5.0/.git +1 -0
  2. bounded_subprocess-2.5.0/.github/workflows/docs.yaml +29 -0
  3. bounded_subprocess-2.5.0/AGENTS.md +10 -0
  4. {bounded_subprocess-2.3.1 → bounded_subprocess-2.5.0}/Makefile +4 -1
  5. {bounded_subprocess-2.3.1 → bounded_subprocess-2.5.0}/PKG-INFO +3 -1
  6. {bounded_subprocess-2.3.1 → bounded_subprocess-2.5.0}/README.md +2 -0
  7. {bounded_subprocess-2.3.1 → bounded_subprocess-2.5.0}/cspell.config.yaml +1 -0
  8. bounded_subprocess-2.5.0/docs/index.md +11 -0
  9. bounded_subprocess-2.5.0/mkdocs.yaml +67 -0
  10. {bounded_subprocess-2.3.1 → bounded_subprocess-2.5.0}/pyproject.toml +4 -1
  11. {bounded_subprocess-2.3.1 → bounded_subprocess-2.5.0}/src/bounded_subprocess/__init__.py +6 -9
  12. bounded_subprocess-2.5.0/src/bounded_subprocess/bounded_subprocess.py +122 -0
  13. bounded_subprocess-2.5.0/src/bounded_subprocess/bounded_subprocess_async.py +297 -0
  14. {bounded_subprocess-2.3.1 → bounded_subprocess-2.5.0}/src/bounded_subprocess/interactive.py +49 -3
  15. bounded_subprocess-2.5.0/src/bounded_subprocess/interactive_async.py +91 -0
  16. bounded_subprocess-2.5.0/src/bounded_subprocess/util.py +296 -0
  17. {bounded_subprocess-2.3.1 → bounded_subprocess-2.5.0}/test/evil_programs/dies_while_writing.py +2 -2
  18. bounded_subprocess-2.5.0/test/evil_programs/long_stdout.py +1 -0
  19. {bounded_subprocess-2.3.1 → bounded_subprocess-2.5.0}/test/evil_programs/read_one_line.py +2 -1
  20. {bounded_subprocess-2.3.1 → bounded_subprocess-2.5.0}/test/module_test.py +38 -6
  21. {bounded_subprocess-2.3.1 → bounded_subprocess-2.5.0}/test/test_async.py +99 -6
  22. {bounded_subprocess-2.3.1 → bounded_subprocess-2.5.0}/test/test_interactive.py +9 -1
  23. {bounded_subprocess-2.3.1 → bounded_subprocess-2.5.0}/test/test_interactive_async.py +11 -0
  24. bounded_subprocess-2.5.0/uv.lock +982 -0
  25. bounded_subprocess-2.3.1/src/bounded_subprocess/bounded_subprocess.py +0 -51
  26. bounded_subprocess-2.3.1/src/bounded_subprocess/bounded_subprocess_async.py +0 -56
  27. bounded_subprocess-2.3.1/src/bounded_subprocess/interactive_async.py +0 -58
  28. bounded_subprocess-2.3.1/src/bounded_subprocess/util.py +0 -268
  29. bounded_subprocess-2.3.1/test/evil_programs/long_stdout.py +0 -1
  30. bounded_subprocess-2.3.1/uv.lock +0 -200
  31. {bounded_subprocess-2.3.1 → bounded_subprocess-2.5.0}/.github/workflows/test.yml +0 -0
  32. {bounded_subprocess-2.3.1 → bounded_subprocess-2.5.0}/.gitignore +0 -0
  33. {bounded_subprocess-2.3.1 → bounded_subprocess-2.5.0}/LICENSE.txt +0 -0
  34. {bounded_subprocess-2.3.1 → bounded_subprocess-2.5.0}/test/__init__.py +0 -0
  35. {bounded_subprocess-2.3.1 → bounded_subprocess-2.5.0}/test/evil_programs/block_on_inputs.py +0 -0
  36. {bounded_subprocess-2.3.1 → bounded_subprocess-2.5.0}/test/evil_programs/close_outputs.py +0 -0
  37. {bounded_subprocess-2.3.1 → bounded_subprocess-2.5.0}/test/evil_programs/dies_shortly_after_launch.py +0 -0
  38. {bounded_subprocess-2.3.1 → bounded_subprocess-2.5.0}/test/evil_programs/does_not_read.py +0 -0
  39. {bounded_subprocess-2.3.1 → bounded_subprocess-2.5.0}/test/evil_programs/echo_stdin.py +0 -0
  40. {bounded_subprocess-2.3.1 → bounded_subprocess-2.5.0}/test/evil_programs/fork_bomb.py +0 -0
  41. {bounded_subprocess-2.3.1 → bounded_subprocess-2.5.0}/test/evil_programs/fork_once.py +0 -0
  42. {bounded_subprocess-2.3.1 → bounded_subprocess-2.5.0}/test/evil_programs/sleep_forever.py +0 -0
  43. {bounded_subprocess-2.3.1 → bounded_subprocess-2.5.0}/test/evil_programs/unbounded_output.py +0 -0
  44. {bounded_subprocess-2.3.1 → bounded_subprocess-2.5.0}/test/evil_programs/write_forever_but_no_newline.py +0 -0
@@ -0,0 +1 @@
1
+ gitdir: /media/external0/arjun-nosudo/repos/arjunguha/bounded_subprocess/bare/worktrees/main
@@ -0,0 +1,29 @@
1
+ name: Documentation
2
+ on:
3
+ push:
4
+ branches:
5
+ - main
6
+ jobs:
7
+ docs:
8
+ runs-on: ubuntu-latest
9
+ permissions:
10
+ contents: read
11
+ pages: write
12
+ id-token: write
13
+ steps:
14
+ - name: Checkout repository
15
+ uses: actions/checkout@v4
16
+ - name: Install uv
17
+ uses: astral-sh/setup-uv@v5
18
+ - name: Install project
19
+ run: uv sync --locked --all-extras --dev
20
+ - name: Build MkDocs site
21
+ run: make docs
22
+ - name: Upload GitHub Pages artifact
23
+ uses: actions/upload-pages-artifact@v3
24
+ with:
25
+ path: ./site
26
+ - name: Deploy to GitHub Pages
27
+ uses: actions/deploy-pages@v4
28
+ with:
29
+ token: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,10 @@
1
+ Use `uv run`to run and not `python`.
2
+
3
+ ## Publishing Process
4
+
5
+ Increment the version number as appropriate in pyproject.toml. Delete old
6
+ builds from dist/. Run uv sync to update the lock file. Commit the changes,
7
+ which should have just changes to pyproject.taml and uv.lock. Run `uv build`
8
+ and then `uv publish`. In the interactive prompt, you MUST enter __token__
9
+ for the username. I know it says that, but I don't read what's on the screen.
10
+ The password is in the keychain.
@@ -1,4 +1,4 @@
1
- .PHONY: test build publish
1
+ .PHONY: test build publish docs
2
2
 
3
3
  build:
4
4
  uv build
@@ -8,3 +8,6 @@ publish:
8
8
 
9
9
  test:
10
10
  uv run python -m pytest -m "not unsafe"
11
+
12
+ docs:
13
+ uv run mkdocs build
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bounded_subprocess
3
- Version: 2.3.1
3
+ Version: 2.5.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
@@ -38,6 +38,8 @@ The `bounded-subprocess` module runs a subprocess with several bounds:
38
38
  Note that the subprocess is not isolated: it can use the network, the filesystem,
39
39
  or create new sessions.
40
40
 
41
+ - Documentation: https://arjunguha.github.io/bounded_subprocess/
42
+
41
43
  ## Installation
42
44
 
43
45
  ```console
@@ -14,6 +14,8 @@ The `bounded-subprocess` module runs a subprocess with several bounds:
14
14
  Note that the subprocess is not isolated: it can use the network, the filesystem,
15
15
  or create new sessions.
16
16
 
17
+ - Documentation: https://arjunguha.github.io/bounded_subprocess/
18
+
17
19
  ## Installation
18
20
 
19
21
  ```console
@@ -18,5 +18,6 @@ words:
18
18
  - Lucchetti
19
19
  - pythonpath
20
20
  - asyncio
21
+ - bufs
21
22
  ignoreWords: []
22
23
  import: []
@@ -0,0 +1,11 @@
1
+ # bounded_subprocess
2
+
3
+ ::: bounded_subprocess
4
+
5
+ ::: bounded_subprocess.bounded_subprocess
6
+
7
+ ::: bounded_subprocess.bounded_subprocess_async
8
+
9
+ ::: bounded_subprocess.interactive
10
+
11
+ ::: bounded_subprocess.util
@@ -0,0 +1,67 @@
1
+ site_name: bounded_subprocess
2
+ site_description: Bounded subprocess execution with timeout and output limits
3
+
4
+ theme:
5
+ name: material
6
+ locale: en
7
+ palette:
8
+ - media: "(prefers-color-scheme: light)"
9
+ scheme: default
10
+ primary: indigo
11
+ accent: indigo
12
+ toggle:
13
+ icon: material/brightness-7
14
+ name: Switch to dark mode
15
+ - media: "(prefers-color-scheme: dark)"
16
+ scheme: slate
17
+ primary: indigo
18
+ accent: indigo
19
+ toggle:
20
+ icon: material/brightness-4
21
+ name: Switch to light mode
22
+ features:
23
+ - toc.integrate
24
+ - navigation.tabs
25
+ - navigation.sections
26
+ - navigation.expand
27
+ - navigation.top
28
+ - search.highlight
29
+ - search.share
30
+
31
+ markdown_extensions:
32
+ - pymdownx.highlight:
33
+ anchor_linenums: true
34
+ - pymdownx.superfences
35
+ - pymdownx.tabbed:
36
+ alternate_style: true
37
+ - admonition
38
+ - pymdownx.details
39
+ - attr_list
40
+ - md_in_html
41
+
42
+ plugins:
43
+ - search
44
+ - mkdocstrings:
45
+ handlers:
46
+ python:
47
+ paths: ["src"]
48
+ options:
49
+ show_signature_annotations: true
50
+ show_root_heading: true
51
+ heading_level: 2
52
+ docstring_style: google
53
+ merge_init_into_class: true
54
+
55
+ nav:
56
+ - Home: index.md
57
+
58
+ repo_url: https://github.com/arjunguha/bounded_subprocess
59
+ repo_name: arjunguha/bounded_subprocess
60
+ edit_uri: edit/main/docs/
61
+
62
+ extra:
63
+ social:
64
+ - icon: fontawesome/brands/github
65
+ link: https://github.com/arjunguha/bounded_subprocess
66
+ - icon: fontawesome/brands/python
67
+ link: https://pypi.org/project/bounded-subprocess/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bounded_subprocess"
3
- version = "2.3.1"
3
+ version = "2.5.0"
4
4
  authors = [
5
5
  { name="Arjun Guha" },
6
6
  { name="Ming-Ho Yee" },
@@ -39,4 +39,7 @@ dev = [
39
39
  "pytest>=8.3.5",
40
40
  "pytest-asyncio>=1.0.0",
41
41
  "pytest-timeout>=2.4.0",
42
+ "mkdocs>=1.6.1",
43
+ "mkdocs-material>=9.6.15",
44
+ "mkdocstrings[python]>=0.29.1",
42
45
  ]
@@ -8,27 +8,24 @@ execution patterns.
8
8
 
9
9
  __version__ = "1.0.0"
10
10
 
11
+
11
12
  # Lazy imports for better startup performance
12
13
  def __getattr__(name):
13
14
  if name == "run":
14
15
  from .bounded_subprocess import run
16
+
15
17
  return run
16
18
  elif name == "Result":
17
19
  from .util import Result
20
+
18
21
  return Result
19
- elif name == "BoundedSubprocessState":
20
- from .util import BoundedSubprocessState
21
- return BoundedSubprocessState
22
22
  elif name == "SLEEP_BETWEEN_READS":
23
23
  from .util import SLEEP_BETWEEN_READS
24
+
24
25
  return SLEEP_BETWEEN_READS
25
26
  else:
26
27
  raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
27
28
 
29
+
28
30
  # Expose key classes and constants for convenience
29
- __all__ = [
30
- "run",
31
- "Result",
32
- "BoundedSubprocessState",
33
- "SLEEP_BETWEEN_READS"
34
- ]
31
+ __all__ = ["run", "Result", "SLEEP_BETWEEN_READS"]
@@ -0,0 +1,122 @@
1
+ """
2
+ Synchronous subprocess execution with bounds on runtime and output size.
3
+ """
4
+
5
+ import subprocess
6
+ import os
7
+ import signal
8
+ from typing import List, Optional
9
+ import time
10
+
11
+ from .util import (
12
+ Result,
13
+ set_nonblocking,
14
+ MAX_BYTES_PER_READ,
15
+ write_nonblocking_sync,
16
+ read_to_eof_sync,
17
+ )
18
+
19
+
20
+ def run(
21
+ args: List[str],
22
+ timeout_seconds: int = 15,
23
+ max_output_size: int = 2048,
24
+ env=None,
25
+ stdin_data: Optional[str] = None,
26
+ stdin_write_timeout: Optional[int] = None,
27
+ ) -> Result:
28
+ """
29
+ Run a subprocess with a timeout and bounded stdout/stderr capture.
30
+
31
+ This helper starts the child in a new session so timeout cleanup can kill
32
+ the entire process group. Stdout and stderr are read in nonblocking mode and
33
+ truncated to `max_output_size` bytes each. If the timeout elapses, the
34
+ returned `Result.timeout` is True and `Result.exit_code` is -1. If
35
+ `stdin_data` cannot be fully written before `stdin_write_timeout`,
36
+ `Result.exit_code` is set to -1 even if the process exits normally.
37
+
38
+ Example:
39
+
40
+ ```python
41
+ from bounded_subprocess import run
42
+
43
+ result = run(
44
+ ["bash", "-lc", "echo ok; echo err 1>&2"],
45
+ timeout_seconds=5,
46
+ max_output_size=1024,
47
+ )
48
+ print(result.exit_code)
49
+ print(result.stdout.strip())
50
+ print(result.stderr.strip())
51
+ ```
52
+ """
53
+ deadline = time.time() + timeout_seconds
54
+
55
+ p = subprocess.Popen(
56
+ args,
57
+ env=env,
58
+ stdin=subprocess.PIPE if stdin_data is not None else subprocess.DEVNULL,
59
+ stdout=subprocess.PIPE,
60
+ stderr=subprocess.PIPE,
61
+ start_new_session=True,
62
+ bufsize=MAX_BYTES_PER_READ,
63
+ )
64
+ process_group_id = os.getpgid(p.pid)
65
+
66
+ set_nonblocking(p.stdout)
67
+ set_nonblocking(p.stderr)
68
+
69
+ if stdin_data is not None:
70
+ set_nonblocking(p.stdin)
71
+ write_ok = write_nonblocking_sync(
72
+ fd=p.stdin,
73
+ data=stdin_data.encode(),
74
+ timeout_seconds=stdin_write_timeout
75
+ if stdin_write_timeout is not None
76
+ else 15,
77
+ )
78
+ # From what I recall, closing stdin is not necessary, but is customary.
79
+ try:
80
+ p.stdin.close()
81
+ except (BrokenPipeError, BlockingIOError):
82
+ pass
83
+
84
+ bufs = read_to_eof_sync(
85
+ [p.stdout, p.stderr],
86
+ timeout_seconds=timeout_seconds,
87
+ max_len=max_output_size,
88
+ )
89
+
90
+ # Without this, even the trivial test fails on Linux but not on macOS. It
91
+ # seems possible for (1) both stdout and stderr to close (2) before the child
92
+ # process exits, and we can observe the instant between (1) and (2). So, we
93
+ # need to p.wait and not p.poll.
94
+ #
95
+ # Reading the above, we should be able to write a test case that just closes
96
+ # both stdout and stderr explicitly, and then sleeps for an instant before
97
+ # terminating normally. That program should not timeout.
98
+ try:
99
+ exit_code = p.wait(timeout=max(0, deadline - time.time()))
100
+ is_timeout = False
101
+ except subprocess.TimeoutExpired:
102
+ exit_code = None
103
+ is_timeout = True
104
+
105
+ try:
106
+ # Kills the process group. Without this line, test_fork_once fails.
107
+ os.killpg(process_group_id, signal.SIGKILL)
108
+ except ProcessLookupError:
109
+ pass
110
+
111
+ # Even if the process terminates normally, if we failed to write everything to
112
+ # stdin, we return -1 as the exit code.
113
+ exit_code = (
114
+ -1 if is_timeout or (stdin_data is not None and not write_ok) else exit_code
115
+ )
116
+
117
+ return Result(
118
+ timeout=is_timeout,
119
+ exit_code=exit_code,
120
+ stdout=bufs[0].decode(errors="ignore"),
121
+ stderr=bufs[1].decode(errors="ignore"),
122
+ )
@@ -0,0 +1,297 @@
1
+ """
2
+ Asynchronous subprocess execution with bounds on runtime and output size.
3
+ """
4
+
5
+ import asyncio
6
+ import os
7
+ import signal
8
+ import time
9
+ import subprocess
10
+ import tempfile
11
+ from typing import List, Optional
12
+ import logging
13
+
14
+ from .util import (
15
+ Result,
16
+ set_nonblocking,
17
+ MAX_BYTES_PER_READ,
18
+ write_nonblocking_async,
19
+ read_to_eof_async,
20
+ )
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ async def run(
26
+ args: List[str],
27
+ timeout_seconds: int = 15,
28
+ max_output_size: int = 2048,
29
+ env=None,
30
+ stdin_data: Optional[str] = None,
31
+ stdin_write_timeout: Optional[int] = None,
32
+ ) -> Result:
33
+ """
34
+ Run a subprocess asynchronously with bounded stdout/stderr capture.
35
+
36
+ The child process is started in a new session and polled until it exits or
37
+ the timeout elapses. Stdout and stderr are read in nonblocking mode and
38
+ truncated to `max_output_size` bytes each. If the timeout elapses,
39
+ `Result.timeout` is True and `Result.exit_code` is -1. If `stdin_data`
40
+ cannot be fully written before `stdin_write_timeout`, `Result.exit_code`
41
+ is set to -1 even if the process exits normally.
42
+
43
+ Example:
44
+
45
+ ```python
46
+ import asyncio
47
+ from bounded_subprocess.bounded_subprocess_async import run
48
+
49
+ async def main():
50
+ result = await run(
51
+ ["bash", "-lc", "echo ok; echo err 1>&2"],
52
+ timeout_seconds=5,
53
+ max_output_size=1024,
54
+ )
55
+ print(result.exit_code)
56
+ print(result.stdout.strip())
57
+ print(result.stderr.strip())
58
+
59
+ asyncio.run(main())
60
+ ```
61
+ """
62
+
63
+ deadline = time.time() + timeout_seconds
64
+
65
+ p = subprocess.Popen(
66
+ args,
67
+ env=env,
68
+ stdin=subprocess.PIPE if stdin_data is not None else subprocess.DEVNULL,
69
+ stdout=subprocess.PIPE,
70
+ stderr=subprocess.PIPE,
71
+ start_new_session=True,
72
+ bufsize=MAX_BYTES_PER_READ,
73
+ )
74
+ process_group_id = os.getpgid(p.pid)
75
+
76
+ set_nonblocking(p.stdout)
77
+ set_nonblocking(p.stderr)
78
+
79
+ write_ok = True
80
+ if stdin_data is not None:
81
+ set_nonblocking(p.stdin)
82
+ write_ok = await write_nonblocking_async(
83
+ fd=p.stdin,
84
+ data=stdin_data.encode(),
85
+ timeout_seconds=stdin_write_timeout
86
+ if stdin_write_timeout is not None
87
+ else 15,
88
+ )
89
+ try:
90
+ p.stdin.close()
91
+ except (BrokenPipeError, BlockingIOError):
92
+ pass
93
+
94
+ bufs = await read_to_eof_async(
95
+ [p.stdout, p.stderr],
96
+ timeout_seconds=timeout_seconds,
97
+ max_len=max_output_size,
98
+ )
99
+
100
+ exit_code = None
101
+ is_timeout = False
102
+ while True:
103
+ rc = p.poll()
104
+ if rc is not None:
105
+ exit_code = rc
106
+ break
107
+ remaining = deadline - time.time()
108
+ if remaining <= 0:
109
+ is_timeout = True
110
+ break
111
+ await asyncio.sleep(min(0.05, remaining))
112
+
113
+ try:
114
+ os.killpg(process_group_id, signal.SIGKILL)
115
+ except ProcessLookupError:
116
+ pass
117
+
118
+ exit_code = (
119
+ -1 if is_timeout or (stdin_data is not None and not write_ok) else exit_code
120
+ )
121
+
122
+ return Result(
123
+ timeout=is_timeout,
124
+ exit_code=exit_code if exit_code is not None else -1,
125
+ stdout=bufs[0].decode(errors="ignore"),
126
+ stderr=bufs[1].decode(errors="ignore"),
127
+ )
128
+
129
+
130
+ # https://docs.podman.io/en/stable/markdown/podman-rm.1.html
131
+ async def _podman_rm(cidfile_path: str):
132
+ try:
133
+ proc = await asyncio.create_subprocess_exec(
134
+ "podman",
135
+ "rm",
136
+ "-f",
137
+ "--time",
138
+ "0",
139
+ "--cidfile",
140
+ cidfile_path,
141
+ "--ignore",
142
+ stdout=subprocess.DEVNULL,
143
+ stderr=subprocess.DEVNULL,
144
+ )
145
+ # podman rm can take time. I think this will eventually complete even
146
+ # if we timeout below.
147
+ await asyncio.wait_for(proc.wait(), timeout=5.0)
148
+ except Exception as e:
149
+ logger.error(f"Error removing container: {e}")
150
+ finally:
151
+ try:
152
+ os.unlink(cidfile_path)
153
+ except OSError:
154
+ pass
155
+
156
+
157
+ async def podman_run(
158
+ args: List[str],
159
+ *,
160
+ image: str,
161
+ timeout_seconds: int,
162
+ max_output_size: int,
163
+ env=None,
164
+ stdin_data: Optional[str] = None,
165
+ stdin_write_timeout: Optional[int] = None,
166
+ volumes: List[str] = [],
167
+ cwd: Optional[str] = None,
168
+ ) -> Result:
169
+ """
170
+ Run a subprocess in a podman container asynchronously with bounded stdout/stderr capture.
171
+
172
+ This function wraps `run` but executes the command inside a podman container.
173
+ The container is automatically removed after execution. The interface is otherwise
174
+ the same as `run`, except for an additional `image` parameter to specify the
175
+ container image to use.
176
+
177
+ Args:
178
+ args: Command arguments to run in the container.
179
+ image: Container image to use.
180
+ timeout_seconds: Maximum time to wait for the process to complete.
181
+ max_output_size: Maximum size in bytes for stdout/stderr capture.
182
+ env: Optional dictionary of environment variables.
183
+ stdin_data: Optional string data to write to stdin.
184
+ stdin_write_timeout: Optional timeout for writing stdin data.
185
+ volumes: Optional list of volume mount specifications (e.g., ["/host/path:/container/path"]).
186
+ cwd: Optional working directory path inside the container.
187
+
188
+ Example:
189
+
190
+ ```python
191
+ import asyncio
192
+ from bounded_subprocess.bounded_subprocess_async import podman_run
193
+
194
+ async def main():
195
+ result = await podman_run(
196
+ ["cat"],
197
+ image="alpine:latest",
198
+ timeout_seconds=5,
199
+ max_output_size=1024,
200
+ stdin_data="hello\n",
201
+ volumes=["/host/data:/container/data"],
202
+ cwd="/container/data",
203
+ )
204
+ print(result.exit_code)
205
+ print(result.stdout.strip())
206
+
207
+ asyncio.run(main())
208
+ ```
209
+ """
210
+ deadline = time.time() + timeout_seconds
211
+
212
+ # Use --cidfile to get the container ID
213
+ with tempfile.NamedTemporaryFile(
214
+ mode="w", delete=False, prefix="bounded_subprocess_cid_"
215
+ ) as cidfile:
216
+ cidfile_path = cidfile.name
217
+
218
+ # Build podman command
219
+ podman_args = ["podman", "run", "--rm", "-i", "--cidfile", cidfile_path]
220
+
221
+ # Handle environment variables
222
+ if env is not None:
223
+ # Convert env dict to -e flags for podman
224
+ for key, value in env.items():
225
+ podman_args.extend(["-e", f"{key}={value}"])
226
+
227
+ # Handle volume mounts
228
+ for volume in volumes:
229
+ podman_args.extend(["-v", volume])
230
+
231
+ # Handle working directory
232
+ if cwd is not None:
233
+ podman_args.extend(["-w", cwd])
234
+
235
+ podman_args.append(image)
236
+ podman_args.extend(args)
237
+
238
+ p = subprocess.Popen(
239
+ podman_args,
240
+ env=None,
241
+ stdin=subprocess.PIPE if stdin_data is not None else subprocess.DEVNULL,
242
+ stdout=subprocess.PIPE,
243
+ stderr=subprocess.PIPE,
244
+ bufsize=MAX_BYTES_PER_READ,
245
+ )
246
+
247
+ set_nonblocking(p.stdout)
248
+ set_nonblocking(p.stderr)
249
+
250
+ write_ok = True
251
+ if stdin_data is not None:
252
+ set_nonblocking(p.stdin)
253
+ write_ok = await write_nonblocking_async(
254
+ fd=p.stdin,
255
+ data=stdin_data.encode(),
256
+ timeout_seconds=stdin_write_timeout
257
+ if stdin_write_timeout is not None
258
+ else 15,
259
+ )
260
+ try:
261
+ p.stdin.close()
262
+ except (BrokenPipeError, BlockingIOError):
263
+ pass
264
+
265
+ bufs = await read_to_eof_async(
266
+ [p.stdout, p.stderr],
267
+ timeout_seconds=timeout_seconds,
268
+ max_len=max_output_size,
269
+ )
270
+
271
+ # Busy-wait for the process to exit or the deadline. Why do we need this
272
+ # when read_to_eof_async seems to do this? read_to_eof_async will return
273
+ # when the process closes stdout and stderr, but the process can continue
274
+ # running even after that. So, we really need to wait for an exit code.
275
+ exit_code = None
276
+ is_timeout = False
277
+ while True:
278
+ rc = p.poll()
279
+ if rc is not None:
280
+ exit_code = rc
281
+ break
282
+ remaining = deadline - time.time()
283
+ if remaining <= 0:
284
+ is_timeout = True
285
+ break
286
+ await asyncio.sleep(min(0.05, remaining))
287
+
288
+ await _podman_rm(cidfile_path)
289
+ exit_code = (
290
+ -1 if is_timeout or (stdin_data is not None and not write_ok) else exit_code
291
+ )
292
+ return Result(
293
+ timeout=is_timeout,
294
+ exit_code=exit_code if exit_code is not None else -1,
295
+ stdout=bufs[0].decode(errors="ignore"),
296
+ stderr=bufs[1].decode(errors="ignore"),
297
+ )