Description
The pytester fixture takes a snapshot of sys.modules at setup and restores it at teardown, undoing any imports made within the test. While this is normally fine, it may cause resource leaks with the clobbered modules.
One such example is when multiprocessing.resource_tracker, which spins up a server process to monitor multiprocessing resource use. In cases where:
multiprocessing.resource_tracker hasn't already been loaded into sys.modules by the time pytester is set up (e.g. by executing some prior multiprocessing code in the process), and is only loaded mid-test, and
- The test runs
multiprocessing code with a parallel workload which errors out,
The server process associated with the copy of multiprocessing.resource_tracker orphaned by pytester's teardown would error out as the parent process exits, emitting hard-to-debug error messages; see Example below.
Obviously there is no one-size-fit-all approach to protecting sys.modules: the current solution already works for most cases,1 and the issue can easily be sidestepped by pre-importing problematic modules. But I wonder if the option to ignore-list certain modules and packages for snapshot restoration would be helpful.
Example
conftest.py
from __future__ import annotations
from collections.abc import Generator
import pytest
pytest_plugins = 'pytester'
def pytest_addoption(parser: pytest.Parser) -> None:
parser.addini(
'skip_by_default',
'skip the tests marked with `@pytest.mark.skip_by_default`',
type='bool',
default=True,
)
def pytest_configure(config: pytest.Config) -> None:
config.addinivalue_line(
'markers',
'skip_by_default: '
'skip the test by default, '
'unless the eponymous ini option is set to false',
)
@pytest.hookimpl(wrapper=True)
def pytest_collection_modifyitems(
items: list[pytest.Item], config: pytest.Config,
) -> Generator[None, None, Exception | None]:
"""
Drop the `@pytest.mark.skip_by_default` tests by default.
"""
result: Exception | None = (yield)
if not config.getini('skip_by_default'):
return result
n = len(items)
for offset, item in enumerate(reversed(items), 1):
if any(
mark.name == 'skip_by_default' for mark in item.iter_markers()
):
del items[n - offset]
return result
test_demo.py
from __future__ import annotations
import os
import subprocess
import sys
import textwrap
from collections.abc import Callable
from contextlib import AbstractContextManager, nullcontext
from pathlib import Path
from typing import Any, Literal
import pytest
def failing_workload() -> int:
raise RuntimeError('fail')
def passing_workload() -> int:
return 1
THIS_TEST_SUITE = str(Path(__file__).parent)
fuzz_workload = pytest.mark.parametrize('workload', ['pass', 'fail'])
fuzz_import_mp = pytest.mark.parametrize(
'import_mp', ['pre-import', 'deferred-import'],
)
@pytest.mark.skip_by_default
@fuzz_workload
def test_mp_workload(
workload: Literal['pass', 'fail'],
) -> None:
"""
Dummy test using `multiprocessing`.
"""
import multiprocessing
passing = workload == 'pass'
if passing:
ctx: AbstractContextManager[Any] = nullcontext()
func: Callable[[], int] = passing_workload
else:
ctx = pytest.raises(RuntimeError)
func = failing_workload
n = 2
with ctx:
with multiprocessing.Pool(n) as pool:
result = pool.starmap(func, [()] * n)
if passing:
assert result == [1] * n
@pytest.mark.skip_by_default
@fuzz_workload
@fuzz_import_mp
def test_run_mp_test_in_process(
request: pytest.FixtureRequest,
workload: Literal['pass', 'fail'],
import_mp: Literal['pre-import', 'deferred-import'],
) -> None:
"""
Dummy test running `test_mp_workload()` in-process with `pytester`.
"""
if import_mp == 'pre-import':
import multiprocessing.resource_tracker # noqa: F401
# Make sure that `multiprocessing` is imported before the `Pytester`
# instance is created and the `sys.modules` snapshot is taken
pytester: pytest.Pytester = request.getfixturevalue('pytester')
_test_run_mp_test_repeatedly_in_process(pytester, workload, 1)
@fuzz_workload
@fuzz_import_mp
def test_run_mp_test(
workload: Literal['pass', 'fail'],
import_mp: Literal['pre-import', 'deferred-import'],
) -> None:
"""
Test what happens when a test using `multiprocessing` is run
in-process with `pytester`.
"""
cmd = [
sys.executable, '-m', 'pytest',
'-k', f'test_run_mp_test_in_process and {workload} and {import_mp}',
'-o', 'skip_by_default=false',
THIS_TEST_SUITE,
]
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
env={
**os.environ,
'COLUMNS': (
str(int(os.environ['COLUMNS']) - 2)
if 'COLUMNS' in os.environ else
'80'
),
},
)
try:
proc.check_returncode()
assert '1 passed' in proc.stdout
assert 'resource_tracker' not in proc.stderr
finally:
for stream in 'stdout', 'stderr':
value = getattr(proc, stream)
print(
f'Command {stream}:',
textwrap.indent(value, ' ') if value else '<nil>',
sep='\n' if value else ' ',
)
def _test_run_mp_test_repeatedly_in_process(
pytester: pytest.Pytester,
workload: Literal['pass', 'fail'],
n: int,
) -> None:
runs: list[pytest.RunResult] = []
for _ in range(n):
runs.append(pytester.runpytest_inprocess(
'-k', f'test_mp_workload and {workload}',
'-o', 'skip_by_default=false',
THIS_TEST_SUITE,
))
for run in runs:
run.assert_outcomes(passed=1, skipped=0)
Environment
(.venv) $ python --version --version
Python 3.14.4 (main, Apr 7 2026, 13:13:20) [Clang 17.0.0 (clang-1700.6.4.2)]
(.venv) $ python -c "import platform; print(platform.platform())"
macOS-15.6.1-arm64-arm-64bit-Mach-O
(.venv) $ pip list
Package Version
--------- -------
iniconfig 2.3.0
packaging 26.3
pip 26.0.1
pluggy 1.6.0
Pygments 2.20.0
pytest 9.1.1
Test output
(.venv) $ pytest --verbose --tb=short test_demo.py
======================================= test session starts ========================================
platform darwin -- Python 3.14.4, pytest-9.1.1, pluggy-1.6.0 -- [...]/.venv/bin/python3.14
cachedir: .pytest_cache
rootdir: [...]
collected 10 items
test_demo.py::test_run_mp_test[pre-import-pass] PASSED [ 25%]
test_demo.py::test_run_mp_test[pre-import-fail] PASSED [ 50%]
test_demo.py::test_run_mp_test[deferred-import-pass] PASSED [ 75%]
test_demo.py::test_run_mp_test[deferred-import-fail] FAILED [100%]
============================================= FAILURES =============================================
______________________________ test_run_mp_test[deferred-import-fail] ______________________________
test_demo.py:112: in test_run_mp_test
assert 'resource_tracker' not in proc.stderr
E assert 'resource_tracker' not in "Traceback (...-6t1blhst'\n"
E
E 'resource_tracker' is contained here:
E Traceback (most recent call last):
E File "/opt/homebrew/Cellar/python@3.14/3.14.4/Frameworks/Python.framework/Versions/3.14/lib/python3.14/multiprocessing/resource_tracker.py", line 374, in main
E ? ++++++++++++++++
E cache[rtype].remove(name)
E ~~~~~~~~~~~~~~~~~~~^^^^^^...
E
E ...Full output truncated (26 lines hidden), use '-vv' to show
--------------------------------------- Captured stdout call ---------------------------------------
Command stdout:
====================================== test session starts =======================================
platform darwin -- Python 3.14.4, pytest-9.1.1, pluggy-1.6.0
rootdir: [...]
collected 10 items / 9 deselected / 1 selected
test_demo.py . [100%]
================================ 1 passed, 9 deselected in 0.12s =================================
Command stderr:
Traceback (most recent call last):
File "/opt/homebrew/Cellar/python@3.14/3.14.4/Frameworks/Python.framework/Versions/3.14/lib/python3.14/multiprocessing/resource_tracker.py", line 374, in main
cache[rtype].remove(name)
~~~~~~~~~~~~~~~~~~~^^^^^^
KeyError: '/mp-y_qlg7be'
Traceback (most recent call last):
File "/opt/homebrew/Cellar/python@3.14/3.14.4/Frameworks/Python.framework/Versions/3.14/lib/python3.14/multiprocessing/resource_tracker.py", line 374, in main
cache[rtype].remove(name)
~~~~~~~~~~~~~~~~~~~^^^^^^
KeyError: '/mp-aokkgdb7'
Traceback (most recent call last):
File "/opt/homebrew/Cellar/python@3.14/3.14.4/Frameworks/Python.framework/Versions/3.14/lib/python3.14/multiprocessing/resource_tracker.py", line 374, in main
cache[rtype].remove(name)
~~~~~~~~~~~~~~~~~~~^^^^^^
KeyError: '/mp-cgk04txe'
Traceback (most recent call last):
File "/opt/homebrew/Cellar/python@3.14/3.14.4/Frameworks/Python.framework/Versions/3.14/lib/python3.14/multiprocessing/resource_tracker.py", line 374, in main
cache[rtype].remove(name)
~~~~~~~~~~~~~~~~~~~^^^^^^
KeyError: '/mp-stfjt80a'
Traceback (most recent call last):
File "/opt/homebrew/Cellar/python@3.14/3.14.4/Frameworks/Python.framework/Versions/3.14/lib/python3.14/multiprocessing/resource_tracker.py", line 374, in main
cache[rtype].remove(name)
~~~~~~~~~~~~~~~~~~~^^^^^^
KeyError: '/mp-qguy9x0s'
Traceback (most recent call last):
File "/opt/homebrew/Cellar/python@3.14/3.14.4/Frameworks/Python.framework/Versions/3.14/lib/python3.14/multiprocessing/resource_tracker.py", line 374, in main
cache[rtype].remove(name)
~~~~~~~~~~~~~~~~~~~^^^^^^
KeyError: '/mp-6t1blhst'
===================================== short test summary info ======================================
FAILED test_demo.py::test_run_mp_test[deferred-import-fail] - assert 'resource_tracker' not in "Traceback (...-6t1blhst'\n"
=================================== 1 failed, 3 passed in 1.01s ====================================
Checklist
Description
The
pytesterfixture takes a snapshot ofsys.modulesat setup and restores it at teardown, undoing any imports made within the test. While this is normally fine, it may cause resource leaks with the clobbered modules.One such example is when
multiprocessing.resource_tracker, which spins up a server process to monitormultiprocessingresource use. In cases where:multiprocessing.resource_trackerhasn't already been loaded intosys.modulesby the timepytesteris set up (e.g. by executing some priormultiprocessingcode in the process), and is only loaded mid-test, andmultiprocessingcode with a parallel workload which errors out,The server process associated with the copy of
multiprocessing.resource_trackerorphaned bypytester's teardown would error out as the parent process exits, emitting hard-to-debug error messages; see Example below.Obviously there is no one-size-fit-all approach to protecting
sys.modules: the current solution already works for most cases,1 and the issue can easily be sidestepped by pre-importing problematic modules. But I wonder if the option to ignore-list certain modules and packages for snapshot restoration would be helpful.Example
conftest.py
test_demo.py
Environment
(.venv) $ python --version --version Python 3.14.4 (main, Apr 7 2026, 13:13:20) [Clang 17.0.0 (clang-1700.6.4.2)] (.venv) $ python -c "import platform; print(platform.platform())" macOS-15.6.1-arm64-arm-64bit-Mach-O (.venv) $ pip list Package Version --------- ------- iniconfig 2.3.0 packaging 26.3 pip 26.0.1 pluggy 1.6.0 Pygments 2.20.0 pytest 9.1.1Test output
Checklist
pip listfrom the virtual environment you are usingFootnotes
Another perhaps related edge case is when a preexisting package retains references to clobbered copies of its submodules (or object therefrom). For example, I have been burnt by
concurrent.futuresbefore (Revertingsys.modulescauses pickling issues withconcurrent.futures.ProcessPoolWorkerpyutils/line_profiler#436), which lazy-imports its concreteExecutorsubclasses. After revertingsys.modules, the lazy-imported copy ofProcessPoolExecutorfrom the clobbered copy ofconcurrent.futures.processwould be retained. When attempting to use the now-orphanedProcessPoolExecutor, because its copy ofconcurrent.futures.process._process_worker()(the workload passed to<some multiproc context>.Process(target=...)) is now a different object from (the nonexistent)sys.modules['concurrent.futures.process']._process_worker(), a pickling error ensues. ↩