Skip to content

Commit c071145

Browse files
committed
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
1 parent 541cd09 commit c071145

File tree

3 files changed

+59
-49
lines changed

3 files changed

+59
-49
lines changed

CHANGES.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ The released versions correspond to PyPi releases.
33

44
## Version 4.4.0 (as yet unreleased)
55

6+
### Changes
7+
* Added caching of patched modules to avoid lookup overhead
8+
* Added `use_cache` option and `clear_cache` method to be able
9+
to deal with unwanted side-effects of the newly introduced caching
10+
611
### Infrastructure
712
* Moved CI builds to GitHub Actions for performance reasons
813

docs/usage.rst

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -494,16 +494,18 @@ system function is not patched automatically:
494494
495495
As this is rarely needed, and the check to patch this automatically is quite
496496
expansive, it is not done by default. Using ``patch_default_args`` will
497-
search for this kind of default arguments and patch them automatically.
497+
search for this kind of default arguments and patch them automatically.h
498498
You could also use the ``modules_to_reload`` option with the module that
499499
contains the default argument instead, if you want to avoid the overhead.
500500

501501
use_cache
502502
.........
503-
If True (default), non-patched modules are cached between tests for performance
504-
reasons. As this is a new feature, this argument allows to turn it off in case
505-
it causes any problems. Note that this parameter may be removed in a later
506-
version. If you want to clear the cache just for a specific test, you can call
503+
If True (default), patched and non-patched modules are cached between tests
504+
to avoid the performance hit of the file system function lookup (the
505+
patching is self is reverted after each test as before). As this is a new
506+
feature, this argument allows to turn it off in case it causes any problems.
507+
Note that this parameter may be removed in a later version. If you want to
508+
clear the cache just for a specific test instead, you can call
507509
``clear_cache`` on the ``Patcher`` or the ``fake_filesystem`` instance:
508510

509511
.. code:: python

pyfakefs/fake_filesystem_unittest.py

Lines changed: 47 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,11 @@ class Patcher:
367367
}
368368
# caches all modules that do not have file system modules or function
369369
# to speed up _find_modules
370-
CACHED_SKIPMODULES = set()
370+
CACHED_MODULES = set()
371+
FS_MODULES = {}
372+
FS_FUNCTIONS = {}
373+
FS_DEFARGS = []
374+
SKIPPED_FS_MODULES = {}
371375

372376
assert None in SKIPMODULES, ("sys.modules contains 'None' values;"
373377
" must skip them.")
@@ -379,6 +383,7 @@ class Patcher:
379383
# hold values from last call - if changed, the cache in
380384
# CACHED_SKIPMODULES has to be invalidated
381385
PATCHED_MODULE_NAMES = {}
386+
ADDITIONAL_SKIP_NAMES = set()
382387
PATCH_DEFAULT_ARGS = False
383388

384389
def __init__(self, additional_skip_names=None,
@@ -413,8 +418,8 @@ def __init__(self, additional_skip_names=None,
413418
patch_default_args: If True, default arguments are checked for
414419
file system functions, which are patched. This check is
415420
expansive, so it is off by default.
416-
use_cache: If True (default), non-patched modules are cached
417-
between tests for performance reasons. As this is a new
421+
use_cache: If True (default), patched and non-patched modules are
422+
cached between tests for performance reasons. As this is a new
418423
feature, this argument allows to turn it off in case it
419424
causes any problems.
420425
"""
@@ -446,9 +451,6 @@ def __init__(self, additional_skip_names=None,
446451
self.modules_to_reload.extend(modules_to_reload)
447452
self.patch_default_args = patch_default_args
448453
self.use_cache = use_cache
449-
if patch_default_args != self.PATCH_DEFAULT_ARGS:
450-
self.__class__.PATCH_DEFAULT_ARGS = patch_default_args
451-
self.clear_cache()
452454

453455
if use_known_patches:
454456
modules_to_patch = modules_to_patch or {}
@@ -460,33 +462,41 @@ def __init__(self, additional_skip_names=None,
460462
for name, fake_module in modules_to_patch.items():
461463
self._fake_module_classes[name] = fake_module
462464
patched_module_names = set(modules_to_patch)
463-
if patched_module_names != self.PATCHED_MODULE_NAMES:
464-
self.__class__.PATCHED_MODULE_NAMES = patched_module_names
465+
clear_cache = not use_cache
466+
if use_cache:
467+
if patched_module_names != self.PATCHED_MODULE_NAMES:
468+
self.__class__.PATCHED_MODULE_NAMES = patched_module_names
469+
clear_cache = True
470+
if self._skip_names != self.ADDITIONAL_SKIP_NAMES:
471+
self.__class__.ADDITIONAL_SKIP_NAMES = self._skip_names
472+
clear_cache = True
473+
if patch_default_args != self.PATCH_DEFAULT_ARGS:
474+
self.__class__.PATCH_DEFAULT_ARGS = patch_default_args
475+
clear_cache = True
476+
477+
if clear_cache:
465478
self.clear_cache()
466-
467479
self._fake_module_functions = {}
468480
self._init_fake_module_functions()
469481

470482
# Attributes set by _refresh()
471-
self._modules = {}
472-
self._skipped_modules = {}
473-
self._fct_modules = {}
474-
self._def_functions = []
475-
self._open_functions = {}
476483
self._stubs = None
477484
self.fs = None
478485
self.fake_modules = {}
479486
self.unfaked_modules = {}
480-
self._dyn_patcher = None
481487

482488
# _isStale is set by tearDown(), reset by _refresh()
483489
self._isStale = True
490+
self._dyn_patcher = None
484491
self._patching = False
485-
self.found_fs_module = False
486492

487493
def clear_cache(self):
488-
"""Clear the cache of non-patched modules."""
489-
self.__class__.CACHED_SKIPMODULES = set()
494+
"""Clear the module cache."""
495+
self.__class__.CACHED_MODULES = set()
496+
self.__class__.FS_MODULES = {}
497+
self.__class__.FS_FUNCTIONS = {}
498+
self.__class__.FS_DEFARGS = []
499+
self.__class__.SKIPPED_FS_MODULES = {}
490500

491501
def _init_fake_module_classes(self):
492502
# IMPORTANT TESTING NOTE: Whenever you add a new module below, test
@@ -567,17 +577,13 @@ def _is_fs_module(self, mod, name, module_names):
567577
# check for __name__ first and ignore the AttributeException
568578
# if it does not exist - avoids calling expansive ismodule
569579
if mod.__name__ in module_names and inspect.ismodule(mod):
570-
self.found_fs_module = True
571580
return True
572581
except Exception:
573582
pass
574583
try:
575584
if (name in self._class_modules and
576585
mod.__module__ in self._class_modules[name]):
577-
if inspect.isclass(mod):
578-
self.found_fs_module = True
579-
return True
580-
return False
586+
return inspect.isclass(mod)
581587
except Exception:
582588
# handle AttributeError and any other exception possibly triggered
583589
# by side effects of inspect methods
@@ -588,13 +594,10 @@ def _is_fs_function(self, fct):
588594
# check for __name__ first and ignore the AttributeException
589595
# if it does not exist - avoids calling expansive inspect
590596
# methods in most cases
591-
if (fct.__name__ in self._fake_module_functions and
597+
return (fct.__name__ in self._fake_module_functions and
592598
fct.__module__ in self._fake_module_functions[
593599
fct.__name__] and
594-
(inspect.isfunction(fct) or inspect.isbuiltin(fct))):
595-
self.found_fs_module = True
596-
return True
597-
return False
600+
(inspect.isfunction(fct) or inspect.isbuiltin(fct)))
598601
except Exception:
599602
# handle AttributeError and any other exception possibly triggered
600603
# by side effects of inspect methods
@@ -632,7 +635,7 @@ def _def_values(self, item):
632635
def _find_def_values(self, module_items):
633636
for _, fct in module_items:
634637
for f, i, d in self._def_values(fct):
635-
self._def_functions.append((f, i, d))
638+
self.__class__.FS_DEFARGS.append((f, i, d))
636639

637640
def _find_modules(self):
638641
"""Find and cache all modules that import file system modules.
@@ -642,7 +645,7 @@ def _find_modules(self):
642645
module_names = list(self._fake_module_classes.keys()) + [PATH_MODULE]
643646
for name, module in list(sys.modules.items()):
644647
try:
645-
if (self.use_cache and module in self.CACHED_SKIPMODULES or
648+
if (self.use_cache and module in self.CACHED_MODULES or
646649
module in self.SKIPMODULES or
647650
not inspect.ismodule(module)):
648651
continue
@@ -652,9 +655,8 @@ def _find_modules(self):
652655
# see https://github.com/pytest-dev/py/issues/73
653656
# and any other exception triggered by inspect.ismodule
654657
if self.use_cache:
655-
self.__class__.CACHED_SKIPMODULES.add(module)
658+
self.__class__.CACHED_MODULES.add(module)
656659
continue
657-
self.found_fs_module = False
658660
skipped = (any([sn.startswith(module.__name__)
659661
for sn in self._skip_names]))
660662
module_items = module.__dict__.copy().items()
@@ -664,27 +666,27 @@ def _find_modules(self):
664666

665667
if skipped:
666668
for name, mod in modules.items():
667-
self._skipped_modules.setdefault(name, set()).add(
668-
(module, mod.__name__))
669+
self.__class__.SKIPPED_FS_MODULES.setdefault(
670+
name, set()).add((module, mod.__name__))
669671
continue
670672

671673
for name, mod in modules.items():
672-
self._modules.setdefault(name, set()).add(
674+
self.__class__.FS_MODULES.setdefault(name, set()).add(
673675
(module, mod.__name__))
674676
functions = {name: fct for name, fct in
675677
module_items
676678
if self._is_fs_function(fct)}
677679

678680
for name, fct in functions.items():
679-
self._fct_modules.setdefault(
681+
self.__class__.FS_FUNCTIONS.setdefault(
680682
(name, fct.__name__, fct.__module__), set()).add(module)
681683

682684
# find default arguments that are file system functions
683685
if self.patch_default_args:
684686
self._find_def_values(module_items)
685687

686-
if not self.found_fs_module and self.use_cache:
687-
self.__class__.CACHED_SKIPMODULES.add(module)
688+
if self.use_cache:
689+
self.__class__.CACHED_MODULES.add(module)
688690

689691
def _refresh(self):
690692
"""Renew the fake file system and set the _isStale flag to `False`."""
@@ -723,6 +725,7 @@ def setUp(self, doctester=None):
723725
category=DeprecationWarning
724726
)
725727
self._find_modules()
728+
726729
self._refresh()
727730

728731
if doctester is not None:
@@ -752,26 +755,26 @@ def start_patching(self):
752755
reload(module)
753756

754757
def patch_functions(self):
755-
for (name, ft_name, ft_mod), modules in self._fct_modules.items():
758+
for (name, ft_name, ft_mod), modules in self.FS_FUNCTIONS.items():
756759
method, mod_name = self._fake_module_functions[ft_name][ft_mod]
757760
fake_module = self.fake_modules[mod_name]
758761
attr = method.__get__(fake_module, fake_module.__class__)
759762
for module in modules:
760763
self._stubs.smart_set(module, name, attr)
761764

762765
def patch_modules(self):
763-
for name, modules in self._modules.items():
766+
for name, modules in self.FS_MODULES.items():
764767
for module, attr in modules:
765768
self._stubs.smart_set(
766769
module, name, self.fake_modules[attr])
767-
for name, modules in self._skipped_modules.items():
770+
for name, modules in self.SKIPPED_FS_MODULES.items():
768771
for module, attr in modules:
769772
if attr in self.unfaked_modules:
770773
self._stubs.smart_set(
771774
module, name, self.unfaked_modules[attr])
772775

773776
def patch_defaults(self):
774-
for (fct, idx, ft) in self._def_functions:
777+
for (fct, idx, ft) in self.FS_DEFARGS:
775778
method, mod_name = self._fake_module_functions[
776779
ft.__name__][ft.__module__]
777780
fake_module = self.fake_modules[mod_name]
@@ -811,15 +814,15 @@ def stop_patching(self):
811814
sys.meta_path.pop(0)
812815

813816
def unset_defaults(self):
814-
for (fct, idx, ft) in self._def_functions:
817+
for (fct, idx, ft) in self.FS_DEFARGS:
815818
new_defaults = []
816819
for i, d in enumerate(fct.__defaults__):
817820
if i == idx:
818821
new_defaults.append(ft)
819822
else:
820823
new_defaults.append(d)
821824
fct.__defaults__ = tuple(new_defaults)
822-
self._def_functions = []
825+
# self._def_functions = []
823826

824827
def pause(self):
825828
"""Pause the patching of the file system modules until `resume` is

0 commit comments

Comments
 (0)