@@ -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+
393422class 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
0 commit comments