pytest-sideeffects
Find out which real files your test suite writes to — including from subprocesses your other guards cannot see.
Most test suites are less isolated than their authors believe. A helper appends to a log,
a fixture rewrites a config file, a test calls main() and main() shells out to a script
that does the real thing. Everything passes, so nobody looks.
$ pytest --sideeffects=audit
That is the whole setup. No fixtures to adopt, no code to change.
------------------------------- pytest-sideeffects -------------------------------
3 real file(s) changed while the suite ran:
added data/outbox/2026-08-17.json
modified logs/run.log
modified state/ledger.jsonl
unattributed changes are usually a subprocess, a C extension or an atexit
handler; run with --sideeffects=both to name the test when the write happens
in-process.
Why another isolation plugin
Because the two mechanisms people already use each have a blind spot, and this one has both halves.
| knows which test | sees subprocess |
sees C extensions / os.system |
|
|---|---|---|---|
| monkeypatching the filesystem | yes | no | no |
| hashing the tree before/after | no | yes | yes |
pytest-sideeffects |
yes (guard) | yes (audit) | yes (audit) |
pytest-socket blocks network calls in-process. pyfakefs replaces the filesystem wholesale.
Neither answers the question "did my suite change anything real?" — and neither follows a child process.
The subprocess gap is not theoretical. The incident this plugin came out of was a test that called
main(); main() spawned subprocess.run([sys.executable, "poster.py", "--post-next"]); the child
was a fresh interpreter with none of the parent's patches, and it published a real post to a real
social account. Every in-process guard in that repo said green.
The two modes
--sideeffects=audit — what changed
Hashes every file under rootdir before and after the session and reports the difference.
Process-agnostic, so it catches whatever wrote the file. Read-only: it never blocks anything.
Use it to find out what you are dealing with.
--sideeffects=guard — stop it happening
Wraps open, os.open, os.remove/rename/replace/mkdir/makedirs/truncate,
shutil.copy*/move/rmtree and the matching pathlib.Path methods. A write outside the
allowed roots raises SideEffectBlocked, naming the test and the path:
SideEffectBlocked: pytest-sideeffects blocked open('data/ledger.jsonl') during
tests/test_poster.py::test_main: that path is outside the roots this suite may
write to. Point the code at tmp_path, or allow it with --sideeffects-allow=<glob>
if the write is intentional.
--sideeffects=both — both, and they cooperate
The guard supplies attribution for the paths it saw; the audit still reports what the guard could not reach. Anything reported by the audit but not by the guard came from outside this process — that difference is a diagnosis, not noise.
Install
pip install pytest-sideeffects
Turn it on for everyone via pyproject.toml:
[tool.pytest.ini_options]
sideeffects = "audit"
sideeffects_allow = ["build/*", "docs/_generated/*"]
In CI, make it a hard failure:
pytest --sideeffects=both --sideeffects-strict --sideeffects-json=sideeffects.json
--sideeffects-strict exits non-zero when something leaked, even if every test passed.
Options
| flag | effect |
|---|---|
--sideeffects={off,audit,guard,both} |
mode; default off |
--sideeffects-allow=GLOB |
path glob the suite may write to (repeatable) |
--sideeffects-root=DIR |
tree to audit; defaults to rootdir (repeatable) |
--sideeffects-strict |
non-zero exit when anything leaked |
--sideeffects-warn-only |
guard records instead of raising |
--sideeffects-json=PATH |
machine-readable report |
--sideeffects-no-defaults |
drop the built-in allowlist entirely |
Allowed out of the box, because a suite writing here surprises nobody: tmp_path and the
system temp dir, __pycache__, .pytest_cache, .hypothesis, .mypy_cache, .ruff_cache,
.coverage*, site-packages and the rest of the environment Python lives in, the user cache
directories (~/.cache, ~/Library/Caches, %LOCALAPPDATA%), and whatever pytest itself was
told to write (--junitxml, --log-file, the cache dir).
For application code: running_under_test()
A guard living in the test process cannot protect a child process. This can, because it reads an inherited environment variable — put it at the point of no return:
from pytest_sideeffects import running_under_test
def publish(post):
if running_under_test():
raise RuntimeError("refusing to publish from a test run")
return api.create(post)
True when PYTEST_SIDEEFFECTS=1 (exported by this plugin for the whole session), when
PYTEST_CURRENT_TEST is set (pytest's own variable, also inherited), or when pytest is
imported in this process. PYTEST_SIDEEFFECTS_ALLOW=1 forces it back to False for the one
test that genuinely needs the real thing.
Outside pytest there is a context manager with the same guard:
from pytest_sideeffects import no_side_effects
with no_side_effects():
render_report() # raises if it writes into the repo
Design notes
Content hashes, never modification times. Measured on a real repository: an mtime-based sweep reported 1450 changed files after a run; hashing the same tree over the same window reported 2. Editors, checkouts and backup agents touch mtime without changing a byte, and a tool that cries wolf 1448 times gets uninstalled. Files above 20 MB are the one exception — they are tracked by size and mtime, and the report labels them as such rather than pretending.
Measure the background before blaming a test. Some files change on their own: a daemon, a language server, a sync client. Get a control reading first:
python -m pytest_sideeffects control 40
It watches the same tree for 40 seconds with no tests running. Whatever moves there is not your suite's fault.
False positives are the real failure mode. A guard that fires on --junitxml output or on
matplotlib's font cache gets switched off in one afternoon, and then it protects nothing. That
is why the default allowlist is generous and the strict switch is opt-in.
Requirements
Python 3.9+, pytest 7+. No dependencies beyond pytest. Linux, macOS and Windows.
Under pytest-xdist, the audit runs in the controller process only and workers keep
the guard: N workers each hashing the whole tree would cost N times as much and then
each report the other workers' writes as unexplained changes.
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pytest_sideeffects-0.1.0.tar.gz.
File metadata
- Download URL: pytest_sideeffects-0.1.0.tar.gz
- Upload date:
- Size: 24.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
563563d12b5a7fe409fd76f902b9c0f96b4595e530e34acab63cf7901f034e59
|
|
| MD5 |
c22123b068aad31fb3741c3db2b7bcb0
|
|
| BLAKE2b-256 |
9b20775b18de5c02e79469d7c7a49cedc5f93c43bc8f9521c9c42aaa82e69dfb
|
Provenance
The following attestation bundles were made for pytest_sideeffects-0.1.0.tar.gz:
Publisher:
release.yml on luandv92/pytest-sideeffects
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pytest_sideeffects-0.1.0.tar.gz -
Subject digest:
563563d12b5a7fe409fd76f902b9c0f96b4595e530e34acab63cf7901f034e59 - Sigstore transparency entry: 2510956659
- Sigstore integration time:
-
Permalink:
luandv92/pytest-sideeffects@0dad5fc9efdddf76a7d0dad40e8fb540f33d2d24 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/luandv92
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@0dad5fc9efdddf76a7d0dad40e8fb540f33d2d24 -
Trigger Event:
push
-
Statement type:
File details
Details for the file pytest_sideeffects-0.1.0-py3-none-any.whl.
File metadata
- Download URL: pytest_sideeffects-0.1.0-py3-none-any.whl
- Upload date:
- Size: 21.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
60e8fd1af602887e932416e8a79c3e8556eac5a3e26918408e5c5b2178dc265f
|
|
| MD5 |
d2de25bf4e6a5005b4dc5a4c4a73a9dc
|
|
| BLAKE2b-256 |
31c8c1ba72a31e87bfe28d6abe20250bc121fb8725e2db33b4cbc3841ea189f0
|
Provenance
The following attestation bundles were made for pytest_sideeffects-0.1.0-py3-none-any.whl:
Publisher:
release.yml on luandv92/pytest-sideeffects
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pytest_sideeffects-0.1.0-py3-none-any.whl -
Subject digest:
60e8fd1af602887e932416e8a79c3e8556eac5a3e26918408e5c5b2178dc265f - Sigstore transparency entry: 2510956745
- Sigstore integration time:
-
Permalink:
luandv92/pytest-sideeffects@0dad5fc9efdddf76a7d0dad40e8fb540f33d2d24 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/luandv92
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@0dad5fc9efdddf76a7d0dad40e8fb540f33d2d24 -
Trigger Event:
push
-
Statement type: