Repository: pytest-dev/pyfakefs (python)

Issue:
additional_skip_names is ignored for Path().open
**Describe the bug**
I noticed `additional_skip_names` works for builtin `open`, `io.open` and `os.open` but it doesn't work for `Path().open` unless I add `pathlib` to `additional_skip_names` too. I guess it is because `Path.open` itself is not monitored and `io.open` is monitored instead as `Path.open` uses `io.open` internally.

**How To Reproduce**

This works:

```python
with Patcher(
    additional_skip_names=[
        'pathlib',
    ],
) as patcher:
    if patcher.fs:
      from x import x
      x()
```

x.py
```python
def x():
  print(Path('some_path').open('r').read())
```

But this doesn't work:

```python
with Patcher(
    additional_skip_names=[
        'x',
    ],
) as patcher:
    if patcher.fs:
      from x import x
      x()
```

x.py
```python
def x():
  print(Path('some_path').open('r').read())
```

**Your environment**
macOS-14.4.1-arm64-arm-64bit
Python 3.11.9 (main, Apr  2 2024, 08:25:04) [Clang 15.0.0 (clang-1500.3.9.4)]
pyfakefs 5.4.1
pytest 8.2.0

Pull request description:
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
- fixes #1012

I'm not too happy about the skip module handling, may revisit this later.

#### Describe the changes
The related issue or a description of the bug or feature that this PR addresses.

#### Tasks
- [x] Unit tests added that reproduce the issue or prove feature is working
- [x] Fix or feature added
- [x] Entry to release notes added
- [x] Pre-commit CI shows no errors
- [x] Unit tests passing


Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 34aefc3..38db211 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -15,6 +15,9 @@ The released versions correspond to PyPI releases.
   and their path-parsing behaviors are now consistent regardless of runtime platform
   and/or faked filesystem customization (see [#1006](../../issues/1006)).
 
+### Fixes
+* correctly use real open calls in pathlib for skipped modules (see [#1012](../../issues/1012))
+
 ## [Version 5.4.1](https://pypi.python.org/pypi/pyfakefs/5.4.0) (2024-04-11)
 Fixes a regression.
 
diff --git a/pyfakefs/fake_io.py b/pyfakefs/fake_io.py
index 1313e73..116153d 100644
--- a/pyfakefs/fake_io.py
+++ b/pyfakefs/fake_io.py
@@ -18,9 +18,7 @@ fake ``io`` module replacement.
 
 import _io  # pytype: disable=import-error
 import io
-import os
 import sys
-import traceback
 from enum import Enum
 from typing import (
     List,
@@ -34,7 +32,7 @@ from typing import (
 )
 
 from pyfakefs.fake_file import AnyFileWrapper
-from pyfakefs.fake_open import FakeFileOpen
+from pyfakefs.fake_open import fake_open
 from pyfakefs.helpers import IS_PYPY
 
 if TYPE_CHECKING:
@@ -92,39 +90,17 @@ class FakeIoModule:
         """Redirect the call to FakeFileOpen.
         See FakeFileOpen.call() for description.
         """
-        # workaround for built-in open called from skipped modules (see #552)
-        # as open is not imported explicitly, we cannot patch it for
-        # specific modules; instead we check if the caller is a skipped
-        # module (should work in most cases)
-        stack = traceback.extract_stack(limit=2)
-        # handle the case that we try to call the original `open_code`
-        # and get here instead (since Python 3.12)
-        from_open_code = (
-            sys.version_info >= (3, 12)
-            and stack[0].name == "open_code"
-            and stack[0].line == "return self._io_module.open_code(path)"
-        )
-        module_name = os.path.splitext(stack[0].filename)[0]
-        module_name = module_name.replace(os.sep, ".")
-        if from_open_code or any(
-            [
-                module_name == sn or module_name.endswith("." + sn)
-                for sn in self.skip_names
-            ]
-        ):
-            return io.open(  # pytype: disable=wrong-arg-count
-                file,
-                mode,
-                buffering,
-                encoding,
-                errors,
-                newline,
-                closefd,
-                opener,
-            )
-        fake_open = FakeFileOpen(self.filesystem)
         return fake_open(
-            file, mode, buffering, encoding, errors, newline, closefd, opener
+            self.filesystem,
+            self.skip_names,
+            file,
+            mode,
+            buffering,
+            encoding,
+            errors,
+            newline,
+            closefd,
+            opener,
         )
 
     if sys.version_info >= (3, 8):
diff --git a/pyfakefs/fake_open.py b/pyfakefs/fake_open.py
index 15b4747..72a7ae0 100644
--- a/pyfakefs/fake_open.py
+++ b/pyfakefs/fake_open.py
@@ -15,8 +15,10 @@
 """A fake open() function replacement. See ``fake_filesystem`` for usage."""
 
 import errno
+import io
 import os
 import sys
+import traceback
 from stat import (
     S_ISDIR,
 )
@@ -28,6 +30,9 @@ from typing import (
     cast,
     AnyStr,
     TYPE_CHECKING,
+    Callable,
+    IO,
+    List,
 )
 
 from pyfakefs import helpers
@@ -63,6 +68,54 @@ _OPEN_MODE_MAP = {
 }
 
 
+def fake_open(
+    filesystem: "FakeFilesystem",
+    skip_names: List[str],
+    file: Union[AnyStr, int],
+    mode: str = "r",
+    buffering: int = -1,
+    encoding: Optional[str] = None,
+    errors: Optional[str] = None,
+    newline: Optional[str] = None,
+    closefd: bool = True,
+    opener: Optional[Callable] = None,
+) -> Union[AnyFileWrapper, IO[Any]]:
+    """Redirect the call to FakeFileOpen.
+    See FakeFileOpen.call() for description.
+    """
+    # workaround for built-in open called from skipped modules (see #552)
+    # as open is not imported explicitly, we cannot patch it for
+    # specific modules; instead we check if the caller is a skipped
+    # module (should work in most cases)
+    stack = traceback.extract_stack(limit=3)
+    # handle the case that we try to call the original `open_code`
+    # and get here instead (since Python 3.12)
+    from_open_code = (
+        sys.version_info >= (3, 12)
+        and stack[0].name == "open_code"
+        and stack[0].line == "return self._io_module.open_code(path)"
+    )
+    module_name = os.path.splitext(stack[0].filename)[0]
+    module_name = module_name.replace(os.sep, ".")
+    if from_open_code or any(
+        [module_name == sn or module_name.endswith("." + sn) for sn in skip_names]
+    ):
+        return io.open(  # pytype: disable=wrong-arg-count
+            file,
+            mode,
+            buffering,
+            encoding,
+            errors,
+            newline,
+            closefd,
+            opener,
+        )
+    fake_file_open = FakeFileOpen(filesystem)
+    return fake_file_open(
+        file, mode, buffering, encoding, errors, newline, closefd, opener
+    )
+
+
 class FakeFileOpen:
     """Faked `file()` and `open()` function replacements.
 
@@ -288,7 +341,6 @@ class FakeFileOpen:
             if open_modes.can_write:
                 if open_modes.truncate:
                     file_object.set_contents("")
-            file_object
         else:
             if open_modes.must_exist:
                 self.filesystem.raise_os_error(errno.ENOENT, file_path)
@@ -344,7 +396,7 @@ class FakeFileOpen:
                 can_write,
             )
 
-        # open a file file by path
+        # open a file by path
         file_path = cast(AnyStr, file_)  # pytype: disable=invalid-annotation
         if file_path == self.filesystem.dev_null.name:
             file_object = self.filesystem.dev_null
diff --git a/pyfakefs/fake_pathlib.py b/pyfakefs/fake_pathlib.py
index dc474c8..a30ae5d 100644
--- a/pyfakefs/fake_pathlib.py
+++ b/pyfakefs/fake_pathlib.py
@@ -40,12 +40,12 @@ import posixpath
 import re
 import sys
 from pathlib import PurePath
-from typing import Callable
+from typing import Callable, List
 from urllib.parse import quote_from_bytes as urlquote_from_bytes
 
 from pyfakefs import fake_scandir
 from pyfakefs.fake_filesystem import FakeFilesystem
-from pyfakefs.fake_open import FakeFileOpen
+from pyfakefs.fake_open import FakeFileOpen, fake_open
 from pyfakefs.fake_os import FakeOsModule, use_original_os
 from pyfakefs.helpers import IS_PYPY
 
@@ -532,6 +532,7 @@ class FakePath(pathlib.Path):
 
     # the underlying fake filesystem
     filesystem = None
+    skip_names: List[str] = []
 
     def __new__(cls, *args, **kwargs):
         """Creates the correct subclass based on OS."""
@@ -631,8 +632,15 @@ class FakePath(pathlib.Path):
                 or permission is denied.
         """
         self._raise_on_closed()
-        return FakeFileOpen(self.filesystem)(
-            self._path(), mode, buffering, encoding, errors, newline
+        return fake_open(
+            self.filesystem,
+            self.skip_names,
+            self._path(),
+            mode,
+            buffering,
+            encoding,
+            errors,
+            newline,
         )
 
     def read_bytes(self):
@@ -879,6 +887,15 @@ class FakePathlibPathModule:
         if self.fake_pathlib is None:
             self.__class__.fake_pathlib = FakePathlibModule(filesystem)
 
+    @property
+    def skip_names(self):
+        return []  # not used, here to allow a setter
+
+    @skip_names.setter
+    def skip_names(self, value):
+        # this is set from the patcher and passed to the fake Path class
+        self.fake_pathlib.Path.skip_names = value
+
     def __call__(self, *args, **kwargs):
         return self.fake_pathlib.Path(*args, **kwargs)
 


Test patch:
diff --git a/pyfakefs/tests/fake_open_test.py b/pyfakefs/tests/fake_open_test.py
index de4bf9a..f0a8ff2 100644
--- a/pyfakefs/tests/fake_open_test.py
+++ b/pyfakefs/tests/fake_open_test.py
@@ -26,7 +26,8 @@ import unittest
 from pyfakefs import fake_filesystem, helpers
 from pyfakefs.helpers import is_root, IS_PYPY, get_locale_encoding
 from pyfakefs.fake_io import FakeIoModule
-from pyfakefs.fake_filesystem_unittest import PatchMode
+from pyfakefs.fake_filesystem_unittest import PatchMode, Patcher
+from pyfakefs.tests.skip_open import read_open
 from pyfakefs.tests.test_utils import RealFsTestCase
 
 
@@ -2104,5 +2105,12 @@ class RealResolvePathTest(ResolvePathTest):
         return True
 
 
+class SkipOpenTest(unittest.TestCase):
+    def test_open_in_skipped_module(self):
+        with Patcher(additional_skip_names=["skip_open"]):
+            contents = read_open("skip_open.py")
+            self.assertTrue(contents.startswith("# Licensed under the Apache License"))
+
+
 if __name__ == "__main__":
     unittest.main()
diff --git a/pyfakefs/tests/fake_pathlib_test.py b/pyfakefs/tests/fake_pathlib_test.py
index f142361..5096ac3 100644
--- a/pyfakefs/tests/fake_pathlib_test.py
+++ b/pyfakefs/tests/fake_pathlib_test.py
@@ -31,7 +31,9 @@ from unittest.mock import patch
 
 from pyfakefs import fake_pathlib, fake_filesystem, fake_filesystem_unittest, fake_os
 from pyfakefs.fake_filesystem import OSType
+from pyfakefs.fake_filesystem_unittest import Patcher
 from pyfakefs.helpers import IS_PYPY, is_root
+from pyfakefs.tests.skip_open import read_pathlib
 from pyfakefs.tests.test_utils import RealFsTestMixin
 
 is_windows = sys.platform == "win32"
@@ -1312,5 +1314,13 @@ class FakePathlibModulePurePathTest(unittest.TestCase):
         )
 
 
+class SkipOpenTest(unittest.TestCase):
+    def test_open_pathlib_in_skipped_module(self):
+        # regression test for #1012
+        with Patcher(additional_skip_names=["skip_open"]):
+            contents = read_pathlib("skip_open.py")
+            self.assertTrue(contents.startswith("# Licensed under the Apache License"))
+
+
 if __name__ == "__main__":
     unittest.main(verbosity=2)
diff --git a/pyfakefs/tests/skip_open.py b/pyfakefs/tests/skip_open.py
new file mode 100644
index 0000000..1a9a2ce
--- /dev/null
+++ b/pyfakefs/tests/skip_open.py
@@ -0,0 +1,26 @@
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""
+Provides functions for testing additional_skip_names functionality.
+"""
+
+import os
+from pathlib import Path
+
+
+def read_pathlib(file_name):
+    return (Path(__file__).parent / file_name).open("r").read()
+
+
+def read_open(file_name):
+    with open(os.path.join(os.path.dirname(__file__), file_name)) as f:
+        return f.read()