Skip to content

Commit 520914f

Browse files
committed
Added partial support for pandas to work with pyfakefs
- support for read_csv and read_excel (with the default module) - see #531
1 parent d543219 commit 520914f

8 files changed

+230
-26
lines changed

CHANGES.md

+4
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ The released versions correspond to PyPi releases.
33

44
## Version 4.1.0 (as yet unreleased)
55

6+
#### New Features
7+
* Added some support for pandas (`read_csv`, `read_excel`) to work with
8+
the fake filesystem (see [#531](../../issues/531))
9+
610
#### Fixes
711
* Do not override global warnings setting in `Deprecator`
812
(see [#526](../../issues/526))

docs/usage.rst

+11-3
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,17 @@ if the real user is a root user (e.g. has the user ID 0). If you want to run
356356
your tests as a non-root user regardless of the actual user rights, you may
357357
want to set this to ``False``.
358358

359+
use_known_patches
360+
~~~~~~~~~~~~~~~~~
361+
If this is set to ``True`` (the default), ``pyfakefs`` patches some
362+
libraries that are known to not work out of the box, to be able work with the
363+
fake filesystem. Currently, this includes patches for the ``pandas`` methods
364+
``read_csv`` and ``read_excel`` - more may follow. This flag is
365+
there to be able to disable this functionality in case it causes any
366+
problems. It may be removed or replaced by a more fine-grained argument in
367+
future releases.
368+
369+
359370
Using convenience methods
360371
-------------------------
361372
While ``pyfakefs`` can be used just with the standard Python file system
@@ -605,9 +616,6 @@ A list of Python modules that are known to not work correctly with
605616
sufficient demand.
606617
- the ``Pillow`` image library does not work with pyfakefs at least if writing
607618
JPEG files (see `this issue <https://github.com/jmcgeheeiv/pyfakefs/issues/529>`__)
608-
- ``pandas`` (the Python data analysis library) uses its own internal file
609-
system access, written in C, and does therefore not work with pyfakefs
610-
(see `this issue <https://github.com/jmcgeheeiv/pyfakefs/issues/528>`__)
611619
612620
If you are not sure if a module can be handled, or how to do it, you can
613621
always write a new issue, of course!

extra_requirements.txt

+6-1
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,10 @@
99
# available at the time of writing.
1010

1111
pathlib2>=2.3.2
12-
1312
scandir>=1.8
13+
14+
# pandas + xlrd are used to test pandas-specific patches to allow
15+
# pyfakefs to work with pandas
16+
# we use the latest version to see any problems with new versions
17+
pandas
18+
xlrd

pyfakefs/fake_filesystem_unittest.py

+30-9
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@
4747
from pyfakefs.deprecator import Deprecator
4848
from pyfakefs.fake_filesystem import set_uid, set_gid, reset_ids
4949
from pyfakefs.helpers import IS_PYPY
50+
from pyfakefs.patched_packages import get_modules_to_patch, \
51+
get_classes_to_patch, get_fake_module_classes
5052

5153
try:
5254
from importlib.machinery import ModuleSpec
@@ -74,7 +76,8 @@ def patchfs(_func=None, *,
7476
additional_skip_names=None,
7577
modules_to_reload=None,
7678
modules_to_patch=None,
77-
allow_root_user=True):
79+
allow_root_user=True,
80+
use_known_patches=True):
7881
"""Convenience decorator to use patcher with additional parameters in a
7982
test function.
8083
@@ -96,7 +99,8 @@ def wrapped(*args, **kwargs):
9699
additional_skip_names=additional_skip_names,
97100
modules_to_reload=modules_to_reload,
98101
modules_to_patch=modules_to_patch,
99-
allow_root_user=allow_root_user) as p:
102+
allow_root_user=allow_root_user,
103+
use_known_patches=use_known_patches) as p:
100104
kwargs['fs'] = p.fs
101105
return f(*args, **kwargs)
102106

@@ -117,7 +121,8 @@ def load_doctests(loader, tests, ignore, module,
117121
additional_skip_names=None,
118122
modules_to_reload=None,
119123
modules_to_patch=None,
120-
allow_root_user=True): # pylint: disable=unused-argument
124+
allow_root_user=True,
125+
use_known_patches=True): # pylint: disable=unused-argument
121126
"""Load the doctest tests for the specified module into unittest.
122127
Args:
123128
loader, tests, ignore : arguments passed in from `load_tests()`
@@ -129,7 +134,8 @@ def load_doctests(loader, tests, ignore, module,
129134
_patcher = Patcher(additional_skip_names=additional_skip_names,
130135
modules_to_reload=modules_to_reload,
131136
modules_to_patch=modules_to_patch,
132-
allow_root_user=allow_root_user)
137+
allow_root_user=allow_root_user,
138+
use_known_patches=use_known_patches)
133139
globs = _patcher.replace_globs(vars(module))
134140
tests.addTests(doctest.DocTestSuite(module,
135141
globs=globs,
@@ -155,6 +161,8 @@ class TestCaseMixin:
155161
modules_to_patch: A dictionary of fake modules mapped to the
156162
fully qualified patched module names. Can be used to add patching
157163
of modules not provided by `pyfakefs`.
164+
use_known_patches: If True (the default), some patches for commonly
165+
used packges are applied which make them usable with pyfakes.
158166
159167
If you specify some of these attributes here and you have DocTests,
160168
consider also specifying the same arguments to :py:func:`load_doctests`.
@@ -190,7 +198,8 @@ def setUpPyfakefs(self,
190198
additional_skip_names=None,
191199
modules_to_reload=None,
192200
modules_to_patch=None,
193-
allow_root_user=True):
201+
allow_root_user=True,
202+
use_known_patches=True):
194203
"""Bind the file-related modules to the :py:class:`pyfakefs` fake file
195204
system instead of the real file system. Also bind the fake `open()`
196205
function.
@@ -212,7 +221,8 @@ def setUpPyfakefs(self,
212221
additional_skip_names=additional_skip_names,
213222
modules_to_reload=modules_to_reload,
214223
modules_to_patch=modules_to_patch,
215-
allow_root_user=allow_root_user
224+
allow_root_user=allow_root_user,
225+
use_known_patches=use_known_patches
216226
)
217227

218228
self._stubber.setUp()
@@ -247,7 +257,8 @@ def __init__(self, methodName='runTest',
247257
additional_skip_names=None,
248258
modules_to_reload=None,
249259
modules_to_patch=None,
250-
allow_root_user=True):
260+
allow_root_user=True,
261+
use_known_patches=True):
251262
"""Creates the test class instance and the patcher used to stub out
252263
file system related modules.
253264
@@ -261,6 +272,7 @@ def __init__(self, methodName='runTest',
261272
self.modules_to_reload = modules_to_reload
262273
self.modules_to_patch = modules_to_patch
263274
self.allow_root_user = allow_root_user
275+
self.use_known_patches = use_known_patches
264276

265277
@Deprecator('add_real_file')
266278
def copyRealFile(self, real_file_path, fake_file_path=None,
@@ -337,7 +349,7 @@ class Patcher:
337349

338350
def __init__(self, additional_skip_names=None,
339351
modules_to_reload=None, modules_to_patch=None,
340-
allow_root_user=True):
352+
allow_root_user=True, use_known_patches=True):
341353
"""For a description of the arguments, see TestCase.__init__"""
342354

343355
if not allow_root_user:
@@ -361,6 +373,12 @@ def __init__(self, additional_skip_names=None,
361373

362374
self.modules_to_reload = modules_to_reload or []
363375

376+
if use_known_patches:
377+
modules_to_patch = modules_to_patch or {}
378+
modules_to_patch.update(get_modules_to_patch())
379+
self._class_modules.update(get_classes_to_patch())
380+
self._fake_module_classes.update(get_fake_module_classes())
381+
364382
if modules_to_patch is not None:
365383
for name, fake_module in modules_to_patch.items():
366384
self._fake_module_classes[name] = fake_module
@@ -516,7 +534,8 @@ def _find_modules(self):
516534
# where py.error has no __name__ attribute
517535
# see https://github.com/pytest-dev/py/issues/73
518536
continue
519-
537+
if name == 'pandas.io.parsers':
538+
print(name)
520539
module_items = module.__dict__.copy().items()
521540

522541
# suppress specific pytest warning - see #466
@@ -588,6 +607,8 @@ def start_patching(self):
588607
self._patching = True
589608

590609
for name, modules in self._modules.items():
610+
if name == 'TextFileReader':
611+
print(name, modules)
591612
for module, attr in modules:
592613
self._stubs.smart_set(
593614
module, name, self.fake_modules[attr])

pyfakefs/patched_packages.py

+109
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
13+
"""
14+
Provides patches for some commonly used modules that enable them to work
15+
with pyfakefs.
16+
"""
17+
import sys
18+
19+
try:
20+
import pandas.io.parsers as parsers
21+
except ImportError:
22+
parsers = None
23+
24+
try:
25+
import xlrd
26+
except ImportError:
27+
xlrd = None
28+
29+
30+
def get_modules_to_patch():
31+
modules_to_patch = {}
32+
if xlrd is not None:
33+
modules_to_patch['xlrd'] = XLRDModule
34+
return modules_to_patch
35+
36+
37+
def get_classes_to_patch():
38+
classes_to_patch = {}
39+
if parsers is not None:
40+
classes_to_patch[
41+
'TextFileReader'
42+
] = 'pandas.io.parsers'
43+
return classes_to_patch
44+
45+
46+
def get_fake_module_classes():
47+
fake_module_classes = {}
48+
if parsers is not None:
49+
fake_module_classes[
50+
'TextFileReader'
51+
] = FakeTextFileReader
52+
return fake_module_classes
53+
54+
55+
if xlrd is not None:
56+
class XLRDModule:
57+
"""Patches the xlrd module, which is used as the default Excel file
58+
reader by pandas. Disables using memory mapped files, which are
59+
implemented platform-specific on OS level."""
60+
61+
def __init__(self, _):
62+
self._xlrd_module = xlrd
63+
64+
def open_workbook(self, filename=None,
65+
logfile=sys.stdout,
66+
verbosity=0,
67+
use_mmap=False,
68+
file_contents=None,
69+
encoding_override=None,
70+
formatting_info=False,
71+
on_demand=False,
72+
ragged_rows=False):
73+
return self._xlrd_module.open_workbook(
74+
filename, logfile, verbosity, False, file_contents,
75+
encoding_override, formatting_info, on_demand, ragged_rows)
76+
77+
def __getattr__(self, name):
78+
"""Forwards any unfaked calls to the standard xlrd module."""
79+
return getattr(self._xlrd_module, name)
80+
81+
if parsers is not None:
82+
# we currently need to add fake modules for both the parser module and
83+
# the contained text reader - maybe this can be simplified
84+
85+
class FakeTextFileReader:
86+
fake_parsers = None
87+
88+
def __init__(self, filesystem):
89+
if self.fake_parsers is None:
90+
self.__class__.fake_parsers = ParsersModule(filesystem)
91+
92+
def __call__(self, *args, **kwargs):
93+
return self.fake_parsers.TextFileReader(*args, **kwargs)
94+
95+
def __getattr__(self, name):
96+
return getattr(self.fake_parsers.TextFileReader, name)
97+
98+
class ParsersModule:
99+
def __init__(self, _):
100+
self._parsers_module = parsers
101+
102+
class TextFileReader(parsers.TextFileReader):
103+
def __init__(self, *args, **kwargs):
104+
kwargs['engine'] = 'python'
105+
super().__init__(*args, **kwargs)
106+
107+
def __getattr__(self, name):
108+
"""Forwards any unfaked calls to the standard xlrd module."""
109+
return getattr(self._parsers_module, name)

pyfakefs/tests/all_tests.py

+18-13
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,22 @@
1818
import sys
1919
import unittest
2020

21-
from pyfakefs.tests import dynamic_patch_test, fake_stat_time_test
22-
from pyfakefs.tests import example_test
23-
from pyfakefs.tests import fake_filesystem_glob_test
24-
from pyfakefs.tests import fake_filesystem_shutil_test
25-
from pyfakefs.tests import fake_filesystem_test
26-
from pyfakefs.tests import fake_filesystem_unittest_test
27-
from pyfakefs.tests import fake_filesystem_vs_real_test
28-
from pyfakefs.tests import fake_open_test
29-
from pyfakefs.tests import fake_os_test
30-
from pyfakefs.tests import fake_pathlib_test
31-
from pyfakefs.tests import fake_tempfile_test
32-
from pyfakefs.tests import mox3_stubout_test
21+
from pyfakefs.tests import (
22+
dynamic_patch_test,
23+
fake_stat_time_test,
24+
example_test,
25+
fake_filesystem_glob_test,
26+
fake_filesystem_shutil_test,
27+
fake_filesystem_test,
28+
fake_filesystem_unittest_test,
29+
fake_filesystem_vs_real_test,
30+
fake_open_test,
31+
fake_os_test,
32+
fake_pathlib_test,
33+
fake_tempfile_test,
34+
patched_packages_test,
35+
mox3_stubout_test
36+
)
3337

3438

3539
class AllTests(unittest.TestSuite):
@@ -50,7 +54,8 @@ def suite(self): # pylint: disable-msg=C6409
5054
loader.loadTestsFromModule(example_test),
5155
loader.loadTestsFromModule(mox3_stubout_test),
5256
loader.loadTestsFromModule(dynamic_patch_test),
53-
loader.loadTestsFromModule(fake_pathlib_test)
57+
loader.loadTestsFromModule(fake_pathlib_test),
58+
loader.loadTestsFromModule(patched_packages_test)
5459
])
5560
return self
5661

4.68 KB
Binary file not shown.
+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
13+
"""
14+
Provides patches for some commonly used modules that enable them to work
15+
with pyfakefs.
16+
"""
17+
import os
18+
19+
from pyfakefs import fake_filesystem_unittest
20+
21+
try:
22+
import pandas as pd
23+
except ImportError:
24+
pd = None
25+
26+
try:
27+
import xlrd
28+
except ImportError:
29+
xlrd = None
30+
31+
32+
class TestPatchedPackages(fake_filesystem_unittest.TestCase):
33+
def setUp(self):
34+
self.setUpPyfakefs()
35+
36+
if pd is not None:
37+
def test_load_csv(self):
38+
path = '/foo/bar.csv'
39+
self.fs.create_file(path, contents='1,2,3,4')
40+
df = pd.read_csv(path)
41+
assert (df.columns == ['1', '2', '3', '4']).all()
42+
43+
if pd is not None and xlrd is not None:
44+
def test_load_excel(self):
45+
path = '/foo/bar.xlsx'
46+
src_path = os.path.dirname(os.path.abspath(__file__))
47+
src_path = os.path.join(src_path, 'fixtures', 'excel_test.xlsx')
48+
# map the file into another location to be sure that
49+
# the real fs is not used
50+
self.fs.add_real_file(src_path, target_path=path)
51+
df = pd.read_excel(path)
52+
assert (df.columns == [1, 2, 3, 4]).all()

0 commit comments

Comments
 (0)