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 bigfoot -- 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 bigfoot.popen_mock proxy. It auto-creates the plugin for the current test on first use:
import bigfoot
def test_run_command():
(bigfoot.popen_mock
.new_session()
.expect("spawn", returns=None)
.expect("communicate", returns=(b"hello\n", b"", 0)))
with bigfoot:
import subprocess
proc = subprocess.Popen(["echo", "hello"], stdout=subprocess.PIPE)
stdout, stderr = proc.communicate()
assert stdout == b"hello\n"
assert proc.returncode == 0
bigfoot.popen_mock.assert_spawn(command=["echo", "hello"], stdin=None)
bigfoot.popen_mock.assert_communicate(input=None)
For manual use outside pytest, construct PopenPlugin explicitly:
from bigfoot import StrictVerifier
from bigfoot.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:
(bigfoot.popen_mock
.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 bigfoot.popen_mock:
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: communicate()¶
import subprocess
import bigfoot
def run_linter(path):
proc = subprocess.Popen(
["ruff", "check", path],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, stderr = proc.communicate()
return proc.returncode, stdout.decode()
def test_linter_clean():
(bigfoot.popen_mock
.new_session()
.expect("spawn", returns=None)
.expect("communicate", returns=(b"All checks passed.\n", b"", 0)))
with bigfoot:
rc, output = run_linter("src/")
assert rc == 0
assert output == "All checks passed.\n"
bigfoot.popen_mock.assert_spawn(command=["ruff", "check", "src/"], stdin=None)
bigfoot.popen_mock.assert_communicate(input=None)
Full example: wait()¶
import subprocess
import bigfoot
def test_wait_for_process():
(bigfoot.popen_mock
.new_session()
.expect("spawn", returns=None)
.expect("wait", returns=0))
with bigfoot:
proc = subprocess.Popen(["sleep", "1"])
rc = proc.wait()
assert rc == 0
assert proc.returncode == 0
bigfoot.popen_mock.assert_spawn(command=["sleep", "1"], stdin=None)
bigfoot.popen_mock.assert_wait()
Non-zero exit code¶
def test_failing_command():
(bigfoot.popen_mock
.new_session()
.expect("spawn", returns=None)
.expect("communicate", returns=(b"", b"command not found\n", 127)))
with bigfoot:
proc = subprocess.Popen(["bogus-cmd"])
stdout, stderr = proc.communicate()
assert proc.returncode == 127
assert stderr == b"command not found\n"
bigfoot.popen_mock.assert_spawn(command=["bogus-cmd"], stdin=None)
bigfoot.popen_mock.assert_communicate(input=None)
Passing input to communicate()¶
def test_communicate_with_input():
(bigfoot.popen_mock
.new_session()
.expect("spawn", returns=None)
.expect("communicate", returns=(b"response\n", b"", 0)))
with bigfoot:
proc = subprocess.Popen(["cat"], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
stdout, stderr = proc.communicate(input=b"hello\n")
assert stdout == b"response\n"
bigfoot.popen_mock.assert_spawn(command=["cat"], stdin=None)
bigfoot.popen_mock.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), bigfoot raises ConflictError:
Nested bigfoot sandboxes use reference counting and do not conflict with each other.