Skip to content

Commit

Permalink
Add setUpClassPyfakefs convenience method
Browse files Browse the repository at this point in the history
- only available from Python 3.8 onwards
- also add class-scoped 'fs_class' fixture
- handle patcher for doc tests separately
  • Loading branch information
mrbean-bremen committed Dec 8, 2022
1 parent 58c6325 commit f0eedc1
Show file tree
Hide file tree
Showing 4 changed files with 200 additions and 32 deletions.
45 changes: 40 additions & 5 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down Expand Up @@ -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`_,
Expand Down
156 changes: 129 additions & 27 deletions pyfakefs/fake_filesystem_unittest.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ def wrapped(*args, **kwargs):
return wrap_patchfs


DOCTEST_PATCHER = None


def load_doctests(
loader: Any,
tests: TestSuite,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -295,15 +373,15 @@ 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
been called before. After that call, all file system calls are
executed in the fake file system.
Does nothing if patching is not paused.
"""
self._stubber.resume()
self.patcher.resume()


class TestCase(unittest.TestCase, TestCaseMixin):
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions pyfakefs/pytest_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
19 changes: 19 additions & 0 deletions pyfakefs/tests/fake_filesystem_unittest_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

0 comments on commit f0eedc1

Please sign in to comment.