Skip to content

Commit 0d83dd1

Browse files
Merge pull request #3016 from jurko-gospodnetic/clean-up-state-after-in-process-pytest-runs
Clean up state after in process pytest runs
2 parents d872791 + d85a3ca commit 0d83dd1

File tree

5 files changed

+340
-72
lines changed

5 files changed

+340
-72
lines changed

_pytest/pytester.py

Lines changed: 97 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,35 @@ def assert_outcomes(self, passed=0, skipped=0, failed=0, error=0):
390390
assert obtained == dict(passed=passed, skipped=skipped, failed=failed, error=error)
391391

392392

393+
class CwdSnapshot:
394+
def __init__(self):
395+
self.__saved = os.getcwd()
396+
397+
def restore(self):
398+
os.chdir(self.__saved)
399+
400+
401+
class SysModulesSnapshot:
402+
def __init__(self, preserve=None):
403+
self.__preserve = preserve
404+
self.__saved = dict(sys.modules)
405+
406+
def restore(self):
407+
if self.__preserve:
408+
self.__saved.update(
409+
(k, m) for k, m in sys.modules.items() if self.__preserve(k))
410+
sys.modules.clear()
411+
sys.modules.update(self.__saved)
412+
413+
414+
class SysPathsSnapshot:
415+
def __init__(self):
416+
self.__saved = list(sys.path), list(sys.meta_path)
417+
418+
def restore(self):
419+
sys.path[:], sys.meta_path[:] = self.__saved
420+
421+
393422
class Testdir:
394423
"""Temporary test directory with tools to test/run pytest itself.
395424
@@ -414,9 +443,10 @@ def __init__(self, request, tmpdir_factory):
414443
name = request.function.__name__
415444
self.tmpdir = tmpdir_factory.mktemp(name, numbered=True)
416445
self.plugins = []
417-
self._savesyspath = (list(sys.path), list(sys.meta_path))
418-
self._savemodulekeys = set(sys.modules)
419-
self.chdir() # always chdir
446+
self._cwd_snapshot = CwdSnapshot()
447+
self._sys_path_snapshot = SysPathsSnapshot()
448+
self._sys_modules_snapshot = self.__take_sys_modules_snapshot()
449+
self.chdir()
420450
self.request.addfinalizer(self.finalize)
421451
method = self.request.config.getoption("--runpytest")
422452
if method == "inprocess":
@@ -435,23 +465,17 @@ def finalize(self):
435465
it can be looked at after the test run has finished.
436466
437467
"""
438-
sys.path[:], sys.meta_path[:] = self._savesyspath
439-
if hasattr(self, '_olddir'):
440-
self._olddir.chdir()
441-
self.delete_loaded_modules()
442-
443-
def delete_loaded_modules(self):
444-
"""Delete modules that have been loaded during a test.
445-
446-
This allows the interpreter to catch module changes in case
447-
the module is re-imported.
448-
"""
449-
for name in set(sys.modules).difference(self._savemodulekeys):
450-
# some zope modules used by twisted-related tests keeps internal
451-
# state and can't be deleted; we had some trouble in the past
452-
# with zope.interface for example
453-
if not name.startswith("zope"):
454-
del sys.modules[name]
468+
self._sys_modules_snapshot.restore()
469+
self._sys_path_snapshot.restore()
470+
self._cwd_snapshot.restore()
471+
472+
def __take_sys_modules_snapshot(self):
473+
# some zope modules used by twisted-related tests keep internal state
474+
# and can't be deleted; we had some trouble in the past with
475+
# `zope.interface` for example
476+
def preserve_module(name):
477+
return name.startswith("zope")
478+
return SysModulesSnapshot(preserve=preserve_module)
455479

456480
def make_hook_recorder(self, pluginmanager):
457481
"""Create a new :py:class:`HookRecorder` for a PluginManager."""
@@ -466,9 +490,7 @@ def chdir(self):
466490
This is done automatically upon instantiation.
467491
468492
"""
469-
old = self.tmpdir.chdir()
470-
if not hasattr(self, '_olddir'):
471-
self._olddir = old
493+
self.tmpdir.chdir()
472494

473495
def _makefile(self, ext, args, kwargs, encoding='utf-8'):
474496
items = list(kwargs.items())
@@ -683,42 +705,58 @@ def inline_run(self, *args, **kwargs):
683705
:return: a :py:class:`HookRecorder` instance
684706
685707
"""
686-
# When running py.test inline any plugins active in the main test
687-
# process are already imported. So this disables the warning which
688-
# will trigger to say they can no longer be rewritten, which is fine as
689-
# they have already been rewritten.
690-
orig_warn = AssertionRewritingHook._warn_already_imported
691-
692-
def revert():
693-
AssertionRewritingHook._warn_already_imported = orig_warn
694-
695-
self.request.addfinalizer(revert)
696-
AssertionRewritingHook._warn_already_imported = lambda *a: None
697-
698-
rec = []
699-
700-
class Collect:
701-
def pytest_configure(x, config):
702-
rec.append(self.make_hook_recorder(config.pluginmanager))
703-
704-
plugins = kwargs.get("plugins") or []
705-
plugins.append(Collect())
706-
ret = pytest.main(list(args), plugins=plugins)
707-
self.delete_loaded_modules()
708-
if len(rec) == 1:
709-
reprec = rec.pop()
710-
else:
711-
class reprec:
712-
pass
713-
reprec.ret = ret
714-
715-
# typically we reraise keyboard interrupts from the child run because
716-
# it's our user requesting interruption of the testing
717-
if ret == 2 and not kwargs.get("no_reraise_ctrlc"):
718-
calls = reprec.getcalls("pytest_keyboard_interrupt")
719-
if calls and calls[-1].excinfo.type == KeyboardInterrupt:
720-
raise KeyboardInterrupt()
721-
return reprec
708+
finalizers = []
709+
try:
710+
# When running py.test inline any plugins active in the main test
711+
# process are already imported. So this disables the warning which
712+
# will trigger to say they can no longer be rewritten, which is
713+
# fine as they have already been rewritten.
714+
orig_warn = AssertionRewritingHook._warn_already_imported
715+
716+
def revert_warn_already_imported():
717+
AssertionRewritingHook._warn_already_imported = orig_warn
718+
finalizers.append(revert_warn_already_imported)
719+
AssertionRewritingHook._warn_already_imported = lambda *a: None
720+
721+
# Any sys.module or sys.path changes done while running py.test
722+
# inline should be reverted after the test run completes to avoid
723+
# clashing with later inline tests run within the same pytest test,
724+
# e.g. just because they use matching test module names.
725+
finalizers.append(self.__take_sys_modules_snapshot().restore)
726+
finalizers.append(SysPathsSnapshot().restore)
727+
728+
# Important note:
729+
# - our tests should not leave any other references/registrations
730+
# laying around other than possibly loaded test modules
731+
# referenced from sys.modules, as nothing will clean those up
732+
# automatically
733+
734+
rec = []
735+
736+
class Collect:
737+
def pytest_configure(x, config):
738+
rec.append(self.make_hook_recorder(config.pluginmanager))
739+
740+
plugins = kwargs.get("plugins") or []
741+
plugins.append(Collect())
742+
ret = pytest.main(list(args), plugins=plugins)
743+
if len(rec) == 1:
744+
reprec = rec.pop()
745+
else:
746+
class reprec:
747+
pass
748+
reprec.ret = ret
749+
750+
# typically we reraise keyboard interrupts from the child run
751+
# because it's our user requesting interruption of the testing
752+
if ret == 2 and not kwargs.get("no_reraise_ctrlc"):
753+
calls = reprec.getcalls("pytest_keyboard_interrupt")
754+
if calls and calls[-1].excinfo.type == KeyboardInterrupt:
755+
raise KeyboardInterrupt()
756+
return reprec
757+
finally:
758+
for finalizer in finalizers:
759+
finalizer()
722760

723761
def runpytest_inprocess(self, *args, **kwargs):
724762
"""Return result of running pytest in-process, providing a similar

changelog/3016.bugfix

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fixed restoring Python state after in-process pytest runs with the ``pytester`` plugin; this may break tests using
2+
making multiple inprocess pytest runs if later ones depend on earlier ones leaking global interpreter changes.

testing/acceptance_test.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,7 @@ def test_pyargs_importerror(self, testdir, monkeypatch):
535535
path = testdir.mkpydir("tpkg")
536536
path.join("test_hello.py").write('raise ImportError')
537537

538-
result = testdir.runpytest_subprocess("--pyargs", "tpkg.test_hello")
538+
result = testdir.runpytest("--pyargs", "tpkg.test_hello", syspathinsert=True)
539539
assert result.ret != 0
540540

541541
result.stdout.fnmatch_lines([
@@ -553,7 +553,7 @@ def test_cmdline_python_package(self, testdir, monkeypatch):
553553
result.stdout.fnmatch_lines([
554554
"*2 passed*"
555555
])
556-
result = testdir.runpytest("--pyargs", "tpkg.test_hello")
556+
result = testdir.runpytest("--pyargs", "tpkg.test_hello", syspathinsert=True)
557557
assert result.ret == 0
558558
result.stdout.fnmatch_lines([
559559
"*1 passed*"
@@ -577,7 +577,7 @@ def join_pythonpath(what):
577577
])
578578

579579
monkeypatch.setenv('PYTHONPATH', join_pythonpath(testdir))
580-
result = testdir.runpytest("--pyargs", "tpkg.test_missing")
580+
result = testdir.runpytest("--pyargs", "tpkg.test_missing", syspathinsert=True)
581581
assert result.ret != 0
582582
result.stderr.fnmatch_lines([
583583
"*not*found*test_missing*",

testing/deprecated_test.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ def pytest_logwarning(self, message):
5858
warnings.append(message)
5959

6060
ret = pytest.main("%s -x" % tmpdir, plugins=[Collect()])
61-
testdir.delete_loaded_modules()
6261
msg = ('passing a string to pytest.main() is deprecated, '
6362
'pass a list of arguments instead.')
6463
assert msg in warnings

0 commit comments

Comments
 (0)