From f0eedc1d6c4b4586e46b6f32e13b0f0105d6a417 Mon Sep 17 00:00:00 2001 From: mrbean-bremen Date: Wed, 30 Nov 2022 19:41:49 +0100 Subject: [PATCH] Add setUpClassPyfakefs convenience method - only available from Python 3.8 onwards - also add class-scoped 'fs_class' fixture - handle patcher for doc tests separately --- docs/usage.rst | 45 ++++- pyfakefs/fake_filesystem_unittest.py | 156 +++++++++++++++--- pyfakefs/pytest_plugin.py | 12 ++ .../tests/fake_filesystem_unittest_test.py | 19 +++ 4 files changed, 200 insertions(+), 32 deletions(-) diff --git a/docs/usage.rst b/docs/usage.rst index 2a079561..7dd3afbb 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -37,15 +37,17 @@ tests: """ yield fs -Module- and session scoped fixtures -................................... -For convenience, module- and session-scoped fixtures with the same -functionality are provided, named ``fs_module`` and ``fs_session``, +Class-, module- and session-scoped fixtures +........................................... +For convenience, class-, module- and session-scoped fixtures with the same +functionality are provided, named ``fake_fs``, ``fs_module`` and ``fs_session``, respectively. .. caution:: If any of these fixtures is active, any other ``fs`` fixture will not setup / tear down the fake filesystem in the current scope; instead, it - will just serve as a reference to the active fake filesystem. + will just serve as a reference to the active fake filesystem. That means that changes + done in the fake filesystem inside a test will remain there until the respective scope + ends. Patch using fake_filesystem_unittest ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -74,6 +76,39 @@ with the fake file system functions and modules: The usage is explained in more detail in :ref:`auto_patch` and demonstrated in the files `example.py`_ and `example_test.py`_. +If your setup is the same for all tests in a class, you can use the class setup +method ``setUpClassPyfakefs`` instead: + +.. code:: python + + from pyfakefs.fake_filesystem_unittest import TestCase + + + class ExampleTestCase(TestCase): + def setUpClass(cls): + self.setUpClassPyfakefs() + cls.file_path = "/test/file.txt" + # you can access the fake fs via fake_fs() here + cls.fake_fs().create_file(file_path) + + def test1(self): + self.assertTrue(os.path.exists(self.file_path)) + + def test2(self): + self.assertTrue(os.path.exists(self.file_path)) + file_path = "/test/file2.txt" + # self.fs is the same instance as cls.fake_fs() above + self.fs.create_file(file_path) + self.assertTrue(os.path.exists(file_path)) + +.. note:: This feature cannot be used with a Python version before Python 3.8 due to + a missing feature in ``unittest``. + +.. caution:: If this is used, any changes made in the fake filesystem inside a test + will remain there for all following tests, if they are not reverted in the test + itself. + + Patch using fake_filesystem_unittest.Patcher ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If you are using other means of testing like `nose`_, diff --git a/pyfakefs/fake_filesystem_unittest.py b/pyfakefs/fake_filesystem_unittest.py index 3aaafd75..2280d51d 100644 --- a/pyfakefs/fake_filesystem_unittest.py +++ b/pyfakefs/fake_filesystem_unittest.py @@ -156,6 +156,9 @@ def wrapped(*args, **kwargs): return wrap_patchfs +DOCTEST_PATCHER = None + + def load_doctests( loader: Any, tests: TestSuite, @@ -177,22 +180,26 @@ def load_doctests( File `example_test.py` in the pyfakefs release provides a usage example. """ - _patcher = Patcher( - additional_skip_names=additional_skip_names, - modules_to_reload=modules_to_reload, - modules_to_patch=modules_to_patch, - allow_root_user=allow_root_user, - use_known_patches=use_known_patches, - patch_open_code=patch_open_code, - patch_default_args=patch_default_args, - ) - globs = _patcher.replace_globs(vars(module)) + has_patcher = Patcher.DOC_PATCHER is not None + if not has_patcher: + Patcher.DOC_PATCHER = Patcher( + additional_skip_names=additional_skip_names, + modules_to_reload=modules_to_reload, + modules_to_patch=modules_to_patch, + allow_root_user=allow_root_user, + use_known_patches=use_known_patches, + patch_open_code=patch_open_code, + patch_default_args=patch_default_args, + is_doc_test=True, + ) + assert Patcher.DOC_PATCHER is not None + globs = Patcher.DOC_PATCHER.replace_globs(vars(module)) tests.addTests( doctest.DocTestSuite( module, globs=globs, - setUp=_patcher.setUp, - tearDown=_patcher.tearDown, + setUp=Patcher.DOC_PATCHER.setUp, + tearDown=Patcher.DOC_PATCHER.tearDown, ) ) return tests @@ -242,9 +249,15 @@ def __init__(self, methodName='runTest'): modules_to_reload: Optional[List[ModuleType]] = None modules_to_patch: Optional[Dict[str, ModuleType]] = None + @property + def patcher(self): + if hasattr(self, "_patcher"): + return self._patcher or Patcher.PATCHER + return Patcher.PATCHER + @property def fs(self) -> FakeFilesystem: - return cast(FakeFilesystem, self._stubber.fs) + return cast(FakeFilesystem, self.patcher.fs) def setUpPyfakefs( self, @@ -268,13 +281,17 @@ def setUpPyfakefs( the current test case. Settings the arguments here may be a more convenient way to adapt the setting than overwriting `__init__()`. """ + # if the class has already a patcher setup, we use this one + if Patcher.PATCHER is not None: + return + if additional_skip_names is None: additional_skip_names = self.additional_skip_names if modules_to_reload is None: modules_to_reload = self.modules_to_reload if modules_to_patch is None: modules_to_patch = self.modules_to_patch - self._stubber = Patcher( + self._patcher = Patcher( additional_skip_names=additional_skip_names, modules_to_reload=modules_to_reload, modules_to_patch=modules_to_patch, @@ -285,8 +302,69 @@ def setUpPyfakefs( use_cache=use_cache, ) - self._stubber.setUp() - cast(TestCase, self).addCleanup(self._stubber.tearDown) + self._patcher.setUp() + cast(TestCase, self).addCleanup(self._patcher.tearDown) + + @classmethod + def setUpClassPyfakefs( + cls, + additional_skip_names: Optional[List[Union[str, ModuleType]]] = None, + modules_to_reload: Optional[List[ModuleType]] = None, + modules_to_patch: Optional[Dict[str, ModuleType]] = None, + allow_root_user: bool = True, + use_known_patches: bool = True, + patch_open_code: PatchMode = PatchMode.OFF, + patch_default_args: bool = False, + use_cache: bool = True, + ) -> None: + """Similar to :py:func:`setUpPyfakefs`, but as a class method that + can be used in `setUpClass` instead of in `setUp`. + The fake filesystem will live in all test methods in the test class + and can be used in the usual way. + Note that using both :py:func:`setUpClassPyfakefs` and + :py:func:`setUpPyfakefs` in the same class will not work correctly. + + .. note:: This method is only available from Python 3.8 onwards. + """ + if sys.version_info < (3, 8): + raise NotImplementedError( + "setUpClassPyfakefs is only available in " + "Python versions starting from 3.8" + ) + + # if the class has already a patcher setup, we use this one + if Patcher.PATCHER is not None: + return + + if additional_skip_names is None: + additional_skip_names = cls.additional_skip_names + if modules_to_reload is None: + modules_to_reload = cls.modules_to_reload + if modules_to_patch is None: + modules_to_patch = cls.modules_to_patch + Patcher.PATCHER = Patcher( + additional_skip_names=additional_skip_names, + modules_to_reload=modules_to_reload, + modules_to_patch=modules_to_patch, + allow_root_user=allow_root_user, + use_known_patches=use_known_patches, + patch_open_code=patch_open_code, + patch_default_args=patch_default_args, + use_cache=use_cache, + ) + + Patcher.PATCHER.setUp() + cast(TestCase, cls).addClassCleanup(Patcher.PATCHER.tearDown) + + @classmethod + def fake_fs(cls): + """Convenience class method for accessing the fake filesystem. + For use inside `setUpClass`, after :py:func:`setUpClassPyfakefs` + has been called. + """ + if Patcher.PATCHER: + return Patcher.PATCHER.fs + return None def pause(self) -> None: """Pause the patching of the file system modules until `resume` is @@ -295,7 +373,7 @@ def pause(self) -> None: Calling pause() twice is silently ignored. """ - self._stubber.pause() + self.patcher.pause() def resume(self) -> None: """Resume the patching of the file system modules if `pause` has @@ -303,7 +381,7 @@ def resume(self) -> None: executed in the fake file system. Does nothing if patching is not paused. """ - self._stubber.resume() + self.patcher.resume() class TestCase(unittest.TestCase, TestCaseMixin): @@ -408,10 +486,16 @@ class Patcher: PATCHED_MODULE_NAMES: Set[str] = set() ADDITIONAL_SKIP_NAMES: Set[str] = set() PATCH_DEFAULT_ARGS = False - PATCHER = None + PATCHER: Optional["Patcher"] = None + DOC_PATCHER: Optional["Patcher"] = None REF_COUNT = 0 + DOC_REF_COUNT = 0 def __new__(cls, *args, **kwargs): + if kwargs.get("is_doc_test", False): + if cls.DOC_PATCHER is None: + cls.DOC_PATCHER = super().__new__(cls) + return cls.DOC_PATCHER if cls.PATCHER is None: cls.PATCHER = super().__new__(cls) return cls.PATCHER @@ -426,6 +510,7 @@ def __init__( patch_open_code: PatchMode = PatchMode.OFF, patch_default_args: bool = False, use_cache: bool = True, + is_doc_test: bool = False, ) -> None: """ Args: @@ -458,7 +543,11 @@ def __init__( feature, this argument allows to turn it off in case it causes any problems. """ - if self.REF_COUNT > 0: + self.is_doc_test = is_doc_test + if is_doc_test: + if self.DOC_REF_COUNT > 0: + return + elif self.REF_COUNT > 0: return if not allow_root_user: # set non-root IDs even if the real user is root @@ -764,9 +853,14 @@ def setUp(self, doctester: Any = None) -> None: """Bind the file-related modules to the :py:mod:`pyfakefs` fake modules real ones. Also bind the fake `file()` and `open()` functions. """ - self.__class__.REF_COUNT += 1 - if self.__class__.REF_COUNT > 1: - return + if self.is_doc_test: + self.__class__.DOC_REF_COUNT += 1 + if self.__class__.DOC_REF_COUNT > 1: + return + else: + self.__class__.REF_COUNT += 1 + if self.__class__.REF_COUNT > 1: + return self.has_fcopy_file = ( sys.platform == "darwin" and hasattr(shutil, "_HAS_FCOPYFILE") @@ -853,15 +947,23 @@ def replace_globs(self, globs_: Dict[str, Any]) -> Dict[str, Any]: def tearDown(self, doctester: Any = None): """Clear the fake filesystem bindings created by `setUp()`.""" - self.__class__.REF_COUNT -= 1 - if self.__class__.REF_COUNT > 0: - return + if self.is_doc_test: + self.__class__.DOC_REF_COUNT -= 1 + if self.__class__.DOC_REF_COUNT > 0: + return + else: + self.__class__.REF_COUNT -= 1 + if self.__class__.REF_COUNT > 0: + return self.stop_patching() if self.has_fcopy_file: shutil._HAS_FCOPYFILE = True # type: ignore[attr-defined] reset_ids() - self.__class__.PATCHER = None + if self.is_doc_test: + self.__class__.DOC_PATCHER = None + else: + self.__class__.PATCHER = None def stop_patching(self) -> None: if self._patching: diff --git a/pyfakefs/pytest_plugin.py b/pyfakefs/pytest_plugin.py index f2f77da6..da9cf3d4 100644 --- a/pyfakefs/pytest_plugin.py +++ b/pyfakefs/pytest_plugin.py @@ -30,6 +30,18 @@ def fs(request): patcher.tearDown() +@pytest.fixture(scope="class") +def fs_class(request): + """Class-scoped fake filesystem fixture.""" + if hasattr(request, "param"): + patcher = Patcher(*request.param) + else: + patcher = Patcher() + patcher.setUp() + yield patcher.fs + patcher.tearDown() + + @pytest.fixture(scope="module") def fs_module(request): """Module-scoped fake filesystem fixture.""" diff --git a/pyfakefs/tests/fake_filesystem_unittest_test.py b/pyfakefs/tests/fake_filesystem_unittest_test.py index 57fe4d80..e7f6e360 100644 --- a/pyfakefs/tests/fake_filesystem_unittest_test.py +++ b/pyfakefs/tests/fake_filesystem_unittest_test.py @@ -850,5 +850,24 @@ def test_is_absolute(self, fs): self.assertTrue(pathlib.Path(".").absolute().is_absolute()) +@unittest.skipIf(sys.version_info < (3, 8), "Not available before Python 3.8") +class TestClassSetup(fake_filesystem_unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.setUpClassPyfakefs() + cls.fake_fs().create_file("foo/bar", contents="test") + + def test_using_fs_functions(self): + self.assertTrue(os.path.exists("foo/bar")) + with open("foo/bar") as f: + contents = f.read() + self.assertEqual("test", contents) + + def test_using_fakefs(self): + self.assertTrue(self.fs.exists("foo/bar")) + f = self.fs.get_object("foo/bar") + self.assertEqual("test", f.contents) + + if __name__ == "__main__": unittest.main()