Metadata-Version: 2.5
Name: pytest-sideeffects
Version: 0.1.0
Summary: Find out which real files your test suite writes to - including from subprocesses your other guards cannot see.
Project-URL: Homepage, https://github.com/luandv92/pytest-sideeffects
Project-URL: Issues, https://github.com/luandv92/pytest-sideeffects/issues
Project-URL: Changelog, https://github.com/luandv92/pytest-sideeffects/blob/main/CHANGELOG.md
Author: luandv92
License: MIT License
        
        Copyright (c) 2026 luandv92
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: ci,filesystem,isolation,pytest,sandbox,side-effects,subprocess,test-pollution,testing
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: Pytest
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Topic :: Software Development :: Testing
Requires-Python: >=3.9
Requires-Dist: pytest>=7.0
Description-Content-Type: text/markdown

# pytest-sideeffects

[![ci](https://github.com/luandv92/pytest-sideeffects/actions/workflows/ci.yml/badge.svg)](https://github.com/luandv92/pytest-sideeffects/actions/workflows/ci.yml)
[![python](https://img.shields.io/pypi/pyversions/pytest-sideeffects)](https://pypi.org/project/pytest-sideeffects/)
[![pypi](https://img.shields.io/pypi/v/pytest-sideeffects)](https://pypi.org/project/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.

```console
$ 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

```console
pip install pytest-sideeffects
```

Turn it on for everyone via `pyproject.toml`:

```toml
[tool.pytest.ini_options]
sideeffects = "audit"
sideeffects_allow = ["build/*", "docs/_generated/*"]
```

In CI, make it a hard failure:

```console
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:

```python
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:

```python
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:

```console
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
