Add caching of patched modules to avoid lookup overhead
- classes and methods found to be patched are now cached between tests
- expansive lookup methods will only be called for modules that had not
been loaded before in the same test run
diff --git a/CHANGES.md b/CHANGES.md
index af22e97..6d7de6c 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -3,6 +3,11 @@
## Version 4.4.0 (as yet unreleased)
+### Changes
+* Added caching of patched modules to avoid lookup overhead
+* Added `use_cache` option and `clear_cache` method to be able
+ to deal with unwanted side-effects of the newly introduced caching
+
### Infrastructure
* Moved CI builds to GitHub Actions for performance reasons
diff --git a/docs/usage.rst b/docs/usage.rst
index 384da16..a172f0a 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -494,16 +494,18 @@
As this is rarely needed, and the check to patch this automatically is quite
expansive, it is not done by default. Using ``patch_default_args`` will
-search for this kind of default arguments and patch them automatically.
+search for this kind of default arguments and patch them automatically.h
You could also use the ``modules_to_reload`` option with the module that
contains the default argument instead, if you want to avoid the overhead.
use_cache
.........
-If True (default), non-patched modules are cached between tests for performance
-reasons. As this is a new feature, this argument allows to turn it off in case
-it causes any problems. Note that this parameter may be removed in a later
-version. If you want to clear the cache just for a specific test, you can call
+If True (default), patched and non-patched modules are cached between tests
+to avoid the performance hit of the file system function lookup (the
+patching is self is reverted after each test as before). As this is a new
+feature, this argument allows to turn it off in case it causes any problems.
+Note that this parameter may be removed in a later version. If you want to
+clear the cache just for a specific test instead, you can call
``clear_cache`` on the ``Patcher`` or the ``fake_filesystem`` instance:
.. code:: python
diff --git a/pyfakefs/fake_filesystem_unittest.py b/pyfakefs/fake_filesystem_unittest.py
index a2e3f7d..00f7b26 100644
--- a/pyfakefs/fake_filesystem_unittest.py
+++ b/pyfakefs/fake_filesystem_unittest.py
@@ -367,7 +367,11 @@
}
# caches all modules that do not have file system modules or function
# to speed up _find_modules
- CACHED_SKIPMODULES = set()
+ CACHED_MODULES = set()
+ FS_MODULES = {}
+ FS_FUNCTIONS = {}
+ FS_DEFARGS = []
+ SKIPPED_FS_MODULES = {}
assert None in SKIPMODULES, ("sys.modules contains 'None' values;"
" must skip them.")
@@ -379,6 +383,7 @@
# hold values from last call - if changed, the cache in
# CACHED_SKIPMODULES has to be invalidated
PATCHED_MODULE_NAMES = {}
+ ADDITIONAL_SKIP_NAMES = set()
PATCH_DEFAULT_ARGS = False
def __init__(self, additional_skip_names=None,
@@ -413,8 +418,8 @@
patch_default_args: If True, default arguments are checked for
file system functions, which are patched. This check is
expansive, so it is off by default.
- use_cache: If True (default), non-patched modules are cached
- between tests for performance reasons. As this is a new
+ use_cache: If True (default), patched and non-patched modules are
+ cached between tests for performance reasons. As this is a new
feature, this argument allows to turn it off in case it
causes any problems.
"""
@@ -446,9 +451,6 @@
self.modules_to_reload.extend(modules_to_reload)
self.patch_default_args = patch_default_args
self.use_cache = use_cache
- if patch_default_args != self.PATCH_DEFAULT_ARGS:
- self.__class__.PATCH_DEFAULT_ARGS = patch_default_args
- self.clear_cache()
if use_known_patches:
modules_to_patch = modules_to_patch or {}
@@ -460,33 +462,41 @@
for name, fake_module in modules_to_patch.items():
self._fake_module_classes[name] = fake_module
patched_module_names = set(modules_to_patch)
- if patched_module_names != self.PATCHED_MODULE_NAMES:
- self.__class__.PATCHED_MODULE_NAMES = patched_module_names
- self.clear_cache()
+ clear_cache = not use_cache
+ if use_cache:
+ if patched_module_names != self.PATCHED_MODULE_NAMES:
+ self.__class__.PATCHED_MODULE_NAMES = patched_module_names
+ clear_cache = True
+ if self._skip_names != self.ADDITIONAL_SKIP_NAMES:
+ self.__class__.ADDITIONAL_SKIP_NAMES = self._skip_names
+ clear_cache = True
+ if patch_default_args != self.PATCH_DEFAULT_ARGS:
+ self.__class__.PATCH_DEFAULT_ARGS = patch_default_args
+ clear_cache = True
+ if clear_cache:
+ self.clear_cache()
self._fake_module_functions = {}
self._init_fake_module_functions()
# Attributes set by _refresh()
- self._modules = {}
- self._skipped_modules = {}
- self._fct_modules = {}
- self._def_functions = []
- self._open_functions = {}
self._stubs = None
self.fs = None
self.fake_modules = {}
self.unfaked_modules = {}
- self._dyn_patcher = None
# _isStale is set by tearDown(), reset by _refresh()
self._isStale = True
+ self._dyn_patcher = None
self._patching = False
- self.found_fs_module = False
def clear_cache(self):
- """Clear the cache of non-patched modules."""
- self.__class__.CACHED_SKIPMODULES = set()
+ """Clear the module cache."""
+ self.__class__.CACHED_MODULES = set()
+ self.__class__.FS_MODULES = {}
+ self.__class__.FS_FUNCTIONS = {}
+ self.__class__.FS_DEFARGS = []
+ self.__class__.SKIPPED_FS_MODULES = {}
def _init_fake_module_classes(self):
# IMPORTANT TESTING NOTE: Whenever you add a new module below, test
@@ -567,17 +577,13 @@
# check for __name__ first and ignore the AttributeException
# if it does not exist - avoids calling expansive ismodule
if mod.__name__ in module_names and inspect.ismodule(mod):
- self.found_fs_module = True
return True
except Exception:
pass
try:
if (name in self._class_modules and
mod.__module__ in self._class_modules[name]):
- if inspect.isclass(mod):
- self.found_fs_module = True
- return True
- return False
+ return inspect.isclass(mod)
except Exception:
# handle AttributeError and any other exception possibly triggered
# by side effects of inspect methods
@@ -588,13 +594,10 @@
# check for __name__ first and ignore the AttributeException
# if it does not exist - avoids calling expansive inspect
# methods in most cases
- if (fct.__name__ in self._fake_module_functions and
+ return (fct.__name__ in self._fake_module_functions and
fct.__module__ in self._fake_module_functions[
fct.__name__] and
- (inspect.isfunction(fct) or inspect.isbuiltin(fct))):
- self.found_fs_module = True
- return True
- return False
+ (inspect.isfunction(fct) or inspect.isbuiltin(fct)))
except Exception:
# handle AttributeError and any other exception possibly triggered
# by side effects of inspect methods
@@ -632,7 +635,7 @@
def _find_def_values(self, module_items):
for _, fct in module_items:
for f, i, d in self._def_values(fct):
- self._def_functions.append((f, i, d))
+ self.__class__.FS_DEFARGS.append((f, i, d))
def _find_modules(self):
"""Find and cache all modules that import file system modules.
@@ -642,7 +645,7 @@
module_names = list(self._fake_module_classes.keys()) + [PATH_MODULE]
for name, module in list(sys.modules.items()):
try:
- if (self.use_cache and module in self.CACHED_SKIPMODULES or
+ if (self.use_cache and module in self.CACHED_MODULES or
module in self.SKIPMODULES or
not inspect.ismodule(module)):
continue
@@ -652,9 +655,8 @@
# see https://github.com/pytest-dev/py/issues/73
# and any other exception triggered by inspect.ismodule
if self.use_cache:
- self.__class__.CACHED_SKIPMODULES.add(module)
+ self.__class__.CACHED_MODULES.add(module)
continue
- self.found_fs_module = False
skipped = (any([sn.startswith(module.__name__)
for sn in self._skip_names]))
module_items = module.__dict__.copy().items()
@@ -664,27 +666,27 @@
if skipped:
for name, mod in modules.items():
- self._skipped_modules.setdefault(name, set()).add(
- (module, mod.__name__))
+ self.__class__.SKIPPED_FS_MODULES.setdefault(
+ name, set()).add((module, mod.__name__))
continue
for name, mod in modules.items():
- self._modules.setdefault(name, set()).add(
+ self.__class__.FS_MODULES.setdefault(name, set()).add(
(module, mod.__name__))
functions = {name: fct for name, fct in
module_items
if self._is_fs_function(fct)}
for name, fct in functions.items():
- self._fct_modules.setdefault(
+ self.__class__.FS_FUNCTIONS.setdefault(
(name, fct.__name__, fct.__module__), set()).add(module)
# find default arguments that are file system functions
if self.patch_default_args:
self._find_def_values(module_items)
- if not self.found_fs_module and self.use_cache:
- self.__class__.CACHED_SKIPMODULES.add(module)
+ if self.use_cache:
+ self.__class__.CACHED_MODULES.add(module)
def _refresh(self):
"""Renew the fake file system and set the _isStale flag to `False`."""
@@ -723,6 +725,7 @@
category=DeprecationWarning
)
self._find_modules()
+
self._refresh()
if doctester is not None:
@@ -752,7 +755,7 @@
reload(module)
def patch_functions(self):
- for (name, ft_name, ft_mod), modules in self._fct_modules.items():
+ for (name, ft_name, ft_mod), modules in self.FS_FUNCTIONS.items():
method, mod_name = self._fake_module_functions[ft_name][ft_mod]
fake_module = self.fake_modules[mod_name]
attr = method.__get__(fake_module, fake_module.__class__)
@@ -760,18 +763,18 @@
self._stubs.smart_set(module, name, attr)
def patch_modules(self):
- for name, modules in self._modules.items():
+ for name, modules in self.FS_MODULES.items():
for module, attr in modules:
self._stubs.smart_set(
module, name, self.fake_modules[attr])
- for name, modules in self._skipped_modules.items():
+ for name, modules in self.SKIPPED_FS_MODULES.items():
for module, attr in modules:
if attr in self.unfaked_modules:
self._stubs.smart_set(
module, name, self.unfaked_modules[attr])
def patch_defaults(self):
- for (fct, idx, ft) in self._def_functions:
+ for (fct, idx, ft) in self.FS_DEFARGS:
method, mod_name = self._fake_module_functions[
ft.__name__][ft.__module__]
fake_module = self.fake_modules[mod_name]
@@ -811,7 +814,7 @@
sys.meta_path.pop(0)
def unset_defaults(self):
- for (fct, idx, ft) in self._def_functions:
+ for (fct, idx, ft) in self.FS_DEFARGS:
new_defaults = []
for i, d in enumerate(fct.__defaults__):
if i == idx:
@@ -819,7 +822,7 @@
else:
new_defaults.append(d)
fct.__defaults__ = tuple(new_defaults)
- self._def_functions = []
+ # self._def_functions = []
def pause(self):
"""Pause the patching of the file system modules until `resume` is