PopenPlugin Guide¶
PopenPlugin intercepts subprocess.Popen by replacing the class with a fake that routes process lifecycle through a session script. It is included in core tripwire -- no extra required.
Coexistence with SubprocessPlugin¶
SubprocessPlugin patches subprocess.run and shutil.which. PopenPlugin patches subprocess.Popen. The two plugins target independent names in the subprocess module and do not interfere with each other. Both can be active in the same sandbox simultaneously.
Setup¶
In pytest, access PopenPlugin through the tripwire.popen proxy. It auto-creates the plugin for the current test on first use:
import tripwire
def test_run_command():
(tripwire.popen
.new_session()
.expect("spawn", returns=None)
.expect("communicate", returns=(b"hello\n", b"", 0)))
with tripwire:
import subprocess
proc = subprocess.Popen(["echo", "hello"], stdout=subprocess.PIPE)
stdout, stderr = proc.communicate()
assert stdout == b"hello\n"
assert proc.returncode == 0
tripwire.popen.assert_spawn(command=["echo", "hello"], stdin=None)
tripwire.popen.assert_communicate(input=None)
For manual use outside pytest, construct PopenPlugin explicitly:
from tripwire import StrictVerifier
from tripwire.plugins.popen_plugin import PopenPlugin
verifier = StrictVerifier()
popen = PopenPlugin(verifier)
Each verifier may have at most one PopenPlugin. A second PopenPlugin(verifier) raises ValueError.
State machine¶
The spawn step fires automatically during subprocess.Popen(...) construction. After that, either communicate() or wait() terminates the process.
Scripting a session¶
Use new_session() to create a SessionHandle and chain .expect() calls to build the script:
(tripwire.popen
.new_session()
.expect("spawn", returns=None)
.expect("communicate", returns=(b"output", b"errors", 0)))
expect() parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
method |
str |
required | Step name: "spawn", "communicate", or "wait" |
returns |
Any |
required | Value returned by the step (see below) |
raises |
BaseException \| None |
None |
Exception to raise instead of returning |
required |
bool |
True |
Whether an unused step causes UnusedMocksError at teardown |
Return values by step¶
| Step | returns type |
Description |
|---|---|---|
spawn |
None |
No return value; the Popen object is constructed |
communicate |
tuple[bytes, bytes, int] |
(stdout, stderr, returncode) |
wait |
int |
The process return code |
Asserting interactions¶
Each step records an interaction on the timeline. Use the typed assertion helpers on tripwire.popen:
assert_spawn(*, command, stdin)¶
Asserts the next spawn interaction. Both command and stdin are required fields.
assert_communicate(*, input)¶
Asserts the next communicate interaction. The input field is required.
assert_wait()¶
Asserts the next wait interaction. No fields are required.
Full example¶
Production code (examples/popen_example/app.py):
"""Run a linter via subprocess.Popen."""
import subprocess
def run_linter(path: str) -> tuple[int, str]:
"""Run ruff on the given path, return (returncode, output)."""
proc = subprocess.Popen(
["ruff", "check", path],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, stderr = proc.communicate()
return proc.returncode, stdout.decode()
Test (examples/popen_example/test_app.py):
"""Test run_linter using tripwire popen_mock."""
import tripwire
from .app import run_linter
def test_linter_clean():
(tripwire.popen
.new_session()
.expect("spawn", returns=None)
.expect("communicate", returns=(b"All checks passed.\n", b"", 0)))
with tripwire:
rc, output = run_linter("src/")
assert rc == 0
assert output == "All checks passed.\n"
tripwire.popen.assert_spawn(command=["ruff", "check", "src/"], stdin=None)
tripwire.popen.assert_communicate(input=None)
Non-zero exit code¶
def test_failing_command():
(tripwire.popen
.new_session()
.expect("spawn", returns=None)
.expect("communicate", returns=(b"", b"command not found\n", 127)))
with tripwire:
proc = subprocess.Popen(["bogus-cmd"])
stdout, stderr = proc.communicate()
assert proc.returncode == 127
assert stderr == b"command not found\n"
tripwire.popen.assert_spawn(command=["bogus-cmd"], stdin=None)
tripwire.popen.assert_communicate(input=None)
Passing input to communicate()¶
def test_communicate_with_input():
(tripwire.popen
.new_session()
.expect("spawn", returns=None)
.expect("communicate", returns=(b"response\n", b"", 0)))
with tripwire:
proc = subprocess.Popen(["cat"], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
stdout, stderr = proc.communicate(input=b"hello\n")
assert stdout == b"response\n"
tripwire.popen.assert_spawn(command=["cat"], stdin=None)
tripwire.popen.assert_communicate(input=b"hello\n")
ConflictError¶
At sandbox entry, PopenPlugin checks whether subprocess.Popen has already been patched by another library. If it has been modified by a third party (unittest.mock, pytest-mock, or an unknown library), tripwire raises ConflictError:
Nested tripwire sandboxes use reference counting and do not conflict with each other.