Skip to content

Commit c4df67d

Browse files
committed
Add possibility to add a cleanup hook to modules
- may be needed for modules that cannot cleanly reload - used for pandas.core.arrays.arrow.extension_types, see #947
1 parent 7f6bb1e commit c4df67d

File tree

7 files changed

+90
-6
lines changed

7 files changed

+90
-6
lines changed

.github/workflows/testsuite.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
with:
1515
python-version: "3.10"
1616
- name: install pytype
17-
run: pip install setuptools pytype pytest scandir pathlib2 pandas xlrd django
17+
run: pip install setuptools pytype pytest scandir pathlib2 pandas xlrd django pyarrow
1818
- name: Run pytype
1919
run: |
2020
pytype pyfakefs --keep-going --exclude pyfakefs/tests/* --exclude pyfakefs/pytest_tests/*
@@ -114,7 +114,7 @@ jobs:
114114
run: |
115115
pip install -r requirements.txt
116116
pip install -U pytest==${{ matrix.pytest-version }}
117-
pip install opentimelineio undefined
117+
pip install opentimelineio undefined pandas parquet pyarrow
118118
pip install -e .
119119
if [[ '${{ matrix.pytest-version }}' == '4.0.2' ]]; then
120120
pip install -U attrs==19.1.0

CHANGES.md

+6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
# pyfakefs Release Notes
22
The released versions correspond to PyPI releases.
33

4+
## Unreleased
5+
6+
### Fixes
7+
* Fixed a specific problem on reloading a pandas-related module (see [#947](../../issues/947)),
8+
added possibility for unload hooks for specific modules
9+
410
## [Version 5.3.5](https://pypi.python.org/pypi/pyfakefs/5.3.5) (2024-01-30)
511
Fixes a regression.
612

docs/modules.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Unittest module classes
3434
.. autoclass:: pyfakefs.fake_filesystem_unittest.TestCase
3535

3636
.. autoclass:: pyfakefs.fake_filesystem_unittest.Patcher
37-
:members: setUp, tearDown, pause, resume
37+
:members: setUp, tearDown, pause, resume, register_cleanup_handler
3838

3939
.. automodule:: pyfakefs.fake_filesystem_unittest
4040
:members: patchfs

pyfakefs/fake_filesystem_unittest.py

+30-2
Original file line numberDiff line numberDiff line change
@@ -619,18 +619,21 @@ def __init__(
619619
self.use_cache = use_cache
620620
self.use_dynamic_patch = use_dynamic_patch
621621
self.module_cleanup_mode = module_cleanup_mode
622+
self.cleanup_handlers: Dict[str, Callable[[], bool]] = {}
622623

623624
if use_known_patches:
624625
from pyfakefs.patched_packages import (
625626
get_modules_to_patch,
626627
get_classes_to_patch,
627628
get_fake_module_classes,
629+
get_cleanup_handlers,
628630
)
629631

630632
modules_to_patch = modules_to_patch or {}
631633
modules_to_patch.update(get_modules_to_patch())
632634
self._class_modules.update(get_classes_to_patch())
633635
self._fake_module_classes.update(get_fake_module_classes())
636+
self.cleanup_handlers.update(get_cleanup_handlers())
634637

635638
if modules_to_patch is not None:
636639
for name, fake_module in modules_to_patch.items():
@@ -680,6 +683,22 @@ def clear_cache(self) -> None:
680683
"""Clear the module cache (convenience instance method)."""
681684
self.__class__.clear_fs_cache()
682685

686+
def register_cleanup_handler(self, name: str, handler: Callable[[], bool]):
687+
"""Register a handler for cleaning up a module after it had been loaded by
688+
the dynamic patcher. This allows to handle modules that cannot be reloaded
689+
without unwanted side effects.
690+
691+
Args:
692+
name: The fully qualified module name.
693+
handler: A callable that may do any module cleanup, or do nothing
694+
and return `True` in case reloading shall be prevented.
695+
696+
Returns:
697+
`True` if no further cleanup/reload shall occur after the handler is
698+
executed, `False` if the cleanup/reload shall still happen.
699+
"""
700+
self.cleanup_handlers[name] = handler
701+
683702
def _init_fake_module_classes(self) -> None:
684703
# IMPORTANT TESTING NOTE: Whenever you add a new module below, test
685704
# it by adding an attribute in fixtures/module_with_attributes.py
@@ -946,7 +965,9 @@ def start_patching(self) -> None:
946965
self.patch_functions()
947966
self.patch_defaults()
948967

949-
self._dyn_patcher = DynamicPatcher(self)
968+
self._dyn_patcher = DynamicPatcher(
969+
self, cleanup_handlers=self.cleanup_handlers
970+
)
950971
sys.meta_path.insert(0, self._dyn_patcher)
951972
for module in self.modules_to_reload:
952973
if sys.modules.get(module.__name__) is module:
@@ -1106,11 +1127,16 @@ class DynamicPatcher(MetaPathFinder, Loader):
11061127
Implements the protocol needed for import hooks.
11071128
"""
11081129

1109-
def __init__(self, patcher: Patcher) -> None:
1130+
def __init__(
1131+
self,
1132+
patcher: Patcher,
1133+
cleanup_handlers: Optional[Dict[str, Callable[[], bool]]] = None,
1134+
) -> None:
11101135
self._patcher = patcher
11111136
self.sysmodules = {}
11121137
self.modules = self._patcher.fake_modules
11131138
self._loaded_module_names: Set[str] = set()
1139+
self.cleanup_handlers = cleanup_handlers or {}
11141140

11151141
# remove all modules that have to be patched from `sys.modules`,
11161142
# otherwise the find_... methods will not be called
@@ -1143,6 +1169,8 @@ def cleanup(self, cleanup_mode: ModuleCleanupMode) -> None:
11431169
cleanup_mode = ModuleCleanupMode.DELETE
11441170
for name in self._loaded_module_names:
11451171
if name in sys.modules and name not in reloaded_module_names:
1172+
if name in self.cleanup_handlers and self.cleanup_handlers[name]():
1173+
continue
11461174
if cleanup_mode == ModuleCleanupMode.RELOAD:
11471175
try:
11481176
reload(sys.modules[name])

pyfakefs/patched_packages.py

+33-1
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,16 @@
1818

1919
try:
2020
import pandas as pd
21-
import pandas.io.parsers as parsers
21+
22+
try:
23+
import pandas.io.parsers as parsers
24+
except ImportError:
25+
parsers = None
2226
except ImportError:
27+
pd = None
2328
parsers = None
2429

30+
2531
try:
2632
import xlrd
2733
except ImportError:
@@ -57,6 +63,15 @@ def get_classes_to_patch():
5763
return classes_to_patch
5864

5965

66+
def get_cleanup_handlers():
67+
handlers = {}
68+
if pd is not None:
69+
handlers["pandas.core.arrays.arrow.extension_types"] = (
70+
handle_extension_type_cleanup
71+
)
72+
return handlers
73+
74+
6075
def get_fake_module_classes():
6176
fake_module_classes = {}
6277
if patch_pandas:
@@ -134,6 +149,23 @@ def __getattr__(self, name):
134149
return getattr(self._parsers_module, name)
135150

136151

152+
if pd is not None:
153+
154+
def handle_extension_type_cleanup():
155+
# the module registers two extension types on load
156+
# on reload it raises if the extensions have not been unregistered before
157+
try:
158+
import pyarrow
159+
160+
# the code to register these types has been in the module
161+
# since it was created (in pandas 1.5)
162+
pyarrow.unregister_extension_type("pandas.interval")
163+
pyarrow.unregister_extension_type("pandas.period")
164+
except ImportError:
165+
pass
166+
return False
167+
168+
137169
if locks is not None:
138170

139171
class FakeLocks:
2.86 KB
Binary file not shown.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""Regression test for #947.
2+
Ensures that reloading the `pandas.core.arrays.arrow.extension_types` module succeeds.
3+
"""
4+
5+
from pathlib import Path
6+
7+
import pandas as pd
8+
9+
10+
def test_1(fs):
11+
dir_ = Path(__file__).parent / "data"
12+
fs.add_real_directory(dir_)
13+
pd.read_parquet(dir_ / "test.parquet")
14+
15+
16+
def test_2():
17+
dir_ = Path(__file__).parent / "data"
18+
pd.read_parquet(dir_ / "test.parquet")

0 commit comments

Comments
 (0)