Skip to content

Commit 68ed3b9

Browse files
committed
Handle skipped pathlib.Path.open calls
- if a module is skipped using additional_skip_names, Path.open is now handled the same way as io.open to use real fs calls
1 parent 71044c9 commit 68ed3b9

7 files changed

+134
-42
lines changed

CHANGES.md

+3
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ The released versions correspond to PyPI releases.
1515
and their path-parsing behaviors are now consistent regardless of runtime platform
1616
and/or faked filesystem customization (see [#1006](../../issues/1006)).
1717

18+
### Fixes
19+
* correctly use real open calls in pathlib for skipped modules (see [#1012](../../issues/1012))
20+
1821
## [Version 5.4.1](https://pypi.python.org/pypi/pyfakefs/5.4.0) (2024-04-11)
1922
Fixes a regression.
2023

pyfakefs/fake_io.py

+11-35
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,7 @@
1818

1919
import _io # pytype: disable=import-error
2020
import io
21-
import os
2221
import sys
23-
import traceback
2422
from enum import Enum
2523
from typing import (
2624
List,
@@ -34,7 +32,7 @@
3432
)
3533

3634
from pyfakefs.fake_file import AnyFileWrapper
37-
from pyfakefs.fake_open import FakeFileOpen
35+
from pyfakefs.fake_open import fake_open
3836
from pyfakefs.helpers import IS_PYPY
3937

4038
if TYPE_CHECKING:
@@ -92,39 +90,17 @@ def open(
9290
"""Redirect the call to FakeFileOpen.
9391
See FakeFileOpen.call() for description.
9492
"""
95-
# workaround for built-in open called from skipped modules (see #552)
96-
# as open is not imported explicitly, we cannot patch it for
97-
# specific modules; instead we check if the caller is a skipped
98-
# module (should work in most cases)
99-
stack = traceback.extract_stack(limit=2)
100-
# handle the case that we try to call the original `open_code`
101-
# and get here instead (since Python 3.12)
102-
from_open_code = (
103-
sys.version_info >= (3, 12)
104-
and stack[0].name == "open_code"
105-
and stack[0].line == "return self._io_module.open_code(path)"
106-
)
107-
module_name = os.path.splitext(stack[0].filename)[0]
108-
module_name = module_name.replace(os.sep, ".")
109-
if from_open_code or any(
110-
[
111-
module_name == sn or module_name.endswith("." + sn)
112-
for sn in self.skip_names
113-
]
114-
):
115-
return io.open( # pytype: disable=wrong-arg-count
116-
file,
117-
mode,
118-
buffering,
119-
encoding,
120-
errors,
121-
newline,
122-
closefd,
123-
opener,
124-
)
125-
fake_open = FakeFileOpen(self.filesystem)
12693
return fake_open(
127-
file, mode, buffering, encoding, errors, newline, closefd, opener
94+
self.filesystem,
95+
self.skip_names,
96+
file,
97+
mode,
98+
buffering,
99+
encoding,
100+
errors,
101+
newline,
102+
closefd,
103+
opener,
128104
)
129105

130106
if sys.version_info >= (3, 8):

pyfakefs/fake_open.py

+54-2
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
"""A fake open() function replacement. See ``fake_filesystem`` for usage."""
1616

1717
import errno
18+
import io
1819
import os
1920
import sys
21+
import traceback
2022
from stat import (
2123
S_ISDIR,
2224
)
@@ -28,6 +30,9 @@
2830
cast,
2931
AnyStr,
3032
TYPE_CHECKING,
33+
Callable,
34+
IO,
35+
List,
3136
)
3237

3338
from pyfakefs import helpers
@@ -63,6 +68,54 @@
6368
}
6469

6570

71+
def fake_open(
72+
filesystem: "FakeFilesystem",
73+
skip_names: List[str],
74+
file: Union[AnyStr, int],
75+
mode: str = "r",
76+
buffering: int = -1,
77+
encoding: Optional[str] = None,
78+
errors: Optional[str] = None,
79+
newline: Optional[str] = None,
80+
closefd: bool = True,
81+
opener: Optional[Callable] = None,
82+
) -> Union[AnyFileWrapper, IO[Any]]:
83+
"""Redirect the call to FakeFileOpen.
84+
See FakeFileOpen.call() for description.
85+
"""
86+
# workaround for built-in open called from skipped modules (see #552)
87+
# as open is not imported explicitly, we cannot patch it for
88+
# specific modules; instead we check if the caller is a skipped
89+
# module (should work in most cases)
90+
stack = traceback.extract_stack(limit=3)
91+
# handle the case that we try to call the original `open_code`
92+
# and get here instead (since Python 3.12)
93+
from_open_code = (
94+
sys.version_info >= (3, 12)
95+
and stack[0].name == "open_code"
96+
and stack[0].line == "return self._io_module.open_code(path)"
97+
)
98+
module_name = os.path.splitext(stack[0].filename)[0]
99+
module_name = module_name.replace(os.sep, ".")
100+
if from_open_code or any(
101+
[module_name == sn or module_name.endswith("." + sn) for sn in skip_names]
102+
):
103+
return io.open( # pytype: disable=wrong-arg-count
104+
file,
105+
mode,
106+
buffering,
107+
encoding,
108+
errors,
109+
newline,
110+
closefd,
111+
opener,
112+
)
113+
fake_file_open = FakeFileOpen(filesystem)
114+
return fake_file_open(
115+
file, mode, buffering, encoding, errors, newline, closefd, opener
116+
)
117+
118+
66119
class FakeFileOpen:
67120
"""Faked `file()` and `open()` function replacements.
68121
@@ -288,7 +341,6 @@ def _init_file_object(
288341
if open_modes.can_write:
289342
if open_modes.truncate:
290343
file_object.set_contents("")
291-
file_object
292344
else:
293345
if open_modes.must_exist:
294346
self.filesystem.raise_os_error(errno.ENOENT, file_path)
@@ -344,7 +396,7 @@ def _handle_file_arg(
344396
can_write,
345397
)
346398

347-
# open a file file by path
399+
# open a file by path
348400
file_path = cast(AnyStr, file_) # pytype: disable=invalid-annotation
349401
if file_path == self.filesystem.dev_null.name:
350402
file_object = self.filesystem.dev_null

pyfakefs/fake_pathlib.py

+21-4
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,12 @@
4040
import re
4141
import sys
4242
from pathlib import PurePath
43-
from typing import Callable
43+
from typing import Callable, List
4444
from urllib.parse import quote_from_bytes as urlquote_from_bytes
4545

4646
from pyfakefs import fake_scandir
4747
from pyfakefs.fake_filesystem import FakeFilesystem
48-
from pyfakefs.fake_open import FakeFileOpen
48+
from pyfakefs.fake_open import FakeFileOpen, fake_open
4949
from pyfakefs.fake_os import FakeOsModule, use_original_os
5050
from pyfakefs.helpers import IS_PYPY
5151

@@ -532,6 +532,7 @@ class FakePath(pathlib.Path):
532532

533533
# the underlying fake filesystem
534534
filesystem = None
535+
skip_names: List[str] = []
535536

536537
def __new__(cls, *args, **kwargs):
537538
"""Creates the correct subclass based on OS."""
@@ -631,8 +632,15 @@ def open(self, mode="r", buffering=-1, encoding=None, errors=None, newline=None)
631632
or permission is denied.
632633
"""
633634
self._raise_on_closed()
634-
return FakeFileOpen(self.filesystem)(
635-
self._path(), mode, buffering, encoding, errors, newline
635+
return fake_open(
636+
self.filesystem,
637+
self.skip_names,
638+
self._path(),
639+
mode,
640+
buffering,
641+
encoding,
642+
errors,
643+
newline,
636644
)
637645

638646
def read_bytes(self):
@@ -879,6 +887,15 @@ def __init__(self, filesystem=None):
879887
if self.fake_pathlib is None:
880888
self.__class__.fake_pathlib = FakePathlibModule(filesystem)
881889

890+
@property
891+
def skip_names(self):
892+
return [] # not used, here to allow a setter
893+
894+
@skip_names.setter
895+
def skip_names(self, value):
896+
# this is set from the patcher and passed to the fake Path class
897+
self.fake_pathlib.Path.skip_names = value
898+
882899
def __call__(self, *args, **kwargs):
883900
return self.fake_pathlib.Path(*args, **kwargs)
884901

pyfakefs/tests/fake_open_test.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
from pyfakefs import fake_filesystem, helpers
2727
from pyfakefs.helpers import is_root, IS_PYPY, get_locale_encoding
2828
from pyfakefs.fake_io import FakeIoModule
29-
from pyfakefs.fake_filesystem_unittest import PatchMode
29+
from pyfakefs.fake_filesystem_unittest import PatchMode, Patcher
30+
from pyfakefs.tests.skip_open import read_open
3031
from pyfakefs.tests.test_utils import RealFsTestCase
3132

3233

@@ -2104,5 +2105,12 @@ def use_real_fs(self):
21042105
return True
21052106

21062107

2108+
class SkipOpenTest(unittest.TestCase):
2109+
def test_open_in_skipped_module(self):
2110+
with Patcher(additional_skip_names=["skip_open"]):
2111+
contents = read_open("skip_open.py")
2112+
self.assertTrue(contents.startswith("# Licensed under the Apache License"))
2113+
2114+
21072115
if __name__ == "__main__":
21082116
unittest.main()

pyfakefs/tests/fake_pathlib_test.py

+10
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@
3131

3232
from pyfakefs import fake_pathlib, fake_filesystem, fake_filesystem_unittest, fake_os
3333
from pyfakefs.fake_filesystem import OSType
34+
from pyfakefs.fake_filesystem_unittest import Patcher
3435
from pyfakefs.helpers import IS_PYPY, is_root
36+
from pyfakefs.tests.skip_open import read_pathlib
3537
from pyfakefs.tests.test_utils import RealFsTestMixin
3638

3739
is_windows = sys.platform == "win32"
@@ -1312,5 +1314,13 @@ def test_posix_pure_path_parsing(self):
13121314
)
13131315

13141316

1317+
class SkipOpenTest(unittest.TestCase):
1318+
def test_open_pathlib_in_skipped_module(self):
1319+
# regression test for #1012
1320+
with Patcher(additional_skip_names=["skip_open"]):
1321+
contents = read_pathlib("skip_open.py")
1322+
self.assertTrue(contents.startswith("# Licensed under the Apache License"))
1323+
1324+
13151325
if __name__ == "__main__":
13161326
unittest.main(verbosity=2)

pyfakefs/tests/skip_open.py

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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+
Provides functions for testing additional_skip_names functionality.
14+
"""
15+
16+
import os
17+
from pathlib import Path
18+
19+
20+
def read_pathlib(file_name):
21+
return (Path(__file__).parent / file_name).open("r").read()
22+
23+
24+
def read_open(file_name):
25+
with open(os.path.join(os.path.dirname(__file__), file_name)) as f:
26+
return f.read()

0 commit comments

Comments
 (0)