Repository: pytest-dev/pyfakefs
Pull request #1025 (2024-05-27 23:41:13)

[WIP] Make fake filesystem, fake `os` and fake `os.path` respect `additional_skip_names`
#### Describe the changes
Add a call stack check for fake filesystem, `os` and `os.path` methods in their `handle_original_call` wrapper to see if they are being called from a module listed in `skip_modules`.

#### 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
- [x] For documentation changes: The Read the Docs preview builds and looks as expected


sassanh: This is mostly brainstorm material to see if we want to go in this direction.

Implementation details:
Most of the job is done as a filter in wrapper functions, `is_called_from_skipped_module` starts from the end of the call stack and goes up until it reaches a module that is:
1. Is not a frozen importlib
2. outside of Python's stdlib
3. outside of pyfakefs library itself (except for `tests/` and `pytest_tests/` directories)
This module is considered the module that is calling the wrapped function and if it is listed in `skip_names`, then it will try to run the original function instead of the fake wrapped function.

It is working for Python 3.12, 3.11 and mostly 3.10, but 3.9, 3.8 and 3.7 have yet different internals for pathlib.
sassanh: @mrbean-bremen 
When you got the time, please check it and let me know if you approve this approach.
If it looks good to you, I will work on tests for other Python versions and Windows.
mrbean-bremen: @sassanh - are you still working on this, or have you given up?
sassanh: I'm not actively working on it but I have it in my radar.
If the open pull request is causing trouble in organization we can close it.
The blocking issue is still updating the tests for older python versions.
mrbean-bremen: The PR isn't blocking anything - I just wanted to know if you are still on it, or if you need help. Take all the time you need - and thanks for the effort! 
sassanh: Feel free to add any commits or make any changes to this pull request as needed. Your help is greatly appreciated! I might not have the capacity to address tests for older versions in the new future.
mrbean-bremen: @sassanh - I've rebased against main and fixed the failing tests (disabled one test for PyPy 3.7, as I didn't want to invest time in that outdated version), but other than that, only the release notes are missing as far as I can see.
Please check my commit and add the release notes (and anything you still want to add), and we can go and merge this.
sassanh: @mrbean-bremen Thank you so much! I appreciate your help!
I will check your commit and add the release notes in the weekend.
sassanh: @mrbean-bremen I checked the changes and it all looks good to me. I added the release notes too. Let's merge it if it looks good to you.

Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 53ed438..f50c5ae 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -12,6 +12,12 @@ The released versions correspond to PyPI releases.
 * the default for `FakeFilesystem.shuffle_listdir_results` will change to `True` to reflect
   the real filesystem behavior
 
+## Unreleased
+
+### Enhancements
+
+- refactor the implementation of `additional_skip_names` parameter to make it work with more modules (see [#1023](../../issues/1023))
+
 ## [Version 5.6.0](https://pypi.python.org/pypi/pyfakefs/5.6.0) (2024-07-12)
 Adds preliminary Python 3.13 support.
 
diff --git a/pyfakefs/fake_file.py b/pyfakefs/fake_file.py
index 200014c..5be1598 100644
--- a/pyfakefs/fake_file.py
+++ b/pyfakefs/fake_file.py
@@ -603,8 +603,8 @@ class FakeDirectory(FakeFile):
                     st = traceback.extract_stack(limit=6)
                     if sys.version_info < (3, 10):
                         if (
-                            st[1].name == "TemporaryFile"
-                            and st[1].line == "_os.unlink(name)"
+                            st[0].name == "TemporaryFile"
+                            and st[0].line == "_os.unlink(name)"
                         ):
                             raise_error = False
                     else:
diff --git a/pyfakefs/fake_io.py b/pyfakefs/fake_io.py
index 116153d..0c756a5 100644
--- a/pyfakefs/fake_io.py
+++ b/pyfakefs/fake_io.py
@@ -33,7 +33,7 @@ from typing import (
 
 from pyfakefs.fake_file import AnyFileWrapper
 from pyfakefs.fake_open import fake_open
-from pyfakefs.helpers import IS_PYPY
+from pyfakefs.helpers import IS_PYPY, is_called_from_skipped_module
 
 if TYPE_CHECKING:
     from pyfakefs.fake_filesystem import FakeFilesystem
@@ -180,6 +180,16 @@ if sys.platform != "win32":
         ) -> Any:
             pass
 
-        def __getattr__(self, name):
+        def __getattribute__(self, name):
             """Forwards any unfaked calls to the standard fcntl module."""
-            return getattr(self._fcntl_module, name)
+            fs: FakeFilesystem = object.__getattribute__(self, "filesystem")
+            fnctl_module = object.__getattribute__(self, "_fcntl_module")
+            if fs.patcher:
+                if is_called_from_skipped_module(
+                    skip_names=fs.patcher.skip_names,
+                    case_sensitive=fs.is_case_sensitive,
+                ):
+                    # remove the `self` argument for FakeOsModule methods
+                    return getattr(fnctl_module, name)
+
+            return object.__getattribute__(self, name)
diff --git a/pyfakefs/fake_open.py b/pyfakefs/fake_open.py
index ef60d5b..46cb1e9 100644
--- a/pyfakefs/fake_open.py
+++ b/pyfakefs/fake_open.py
@@ -18,7 +18,6 @@ import errno
 import io
 import os
 import sys
-import traceback
 from stat import (
     S_ISDIR,
 )
@@ -44,6 +43,7 @@ from pyfakefs.fake_file import (
 )
 from pyfakefs.helpers import (
     AnyString,
+    is_called_from_skipped_module,
     is_root,
     PERM_READ,
     PERM_WRITE,
@@ -86,40 +86,9 @@ def fake_open(
     """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)
-    # TODO: use a more generic approach (see PR #1025)
-    if sys.version_info >= (3, 12):
-        from_open_code = (
-            stack[0].name == "open_code"
-            and stack[0].line == "return self._io_module.open_code(path)"
-        )
-    else:
-        from_open_code = False
-
-    module_name = os.path.splitext(stack[0].filename)[0]
-    module_name = module_name.replace(os.sep, ".")
-    if sys.version_info >= (3, 13) and module_name.endswith(
-        ("pathlib._abc", "pathlib._local")
-    ):
-        stack = traceback.extract_stack(limit=6)
-        frame = 2
-        # in Python 3.13, pathlib is implemented in 2 sub-modules that may call
-        # each other, so we have to look further in the stack
-        while frame >= 0 and module_name.endswith(("pathlib._abc", "pathlib._local")):
-            module_name = os.path.splitext(stack[frame].filename)[0]
-            module_name = module_name.replace(os.sep, ".")
-            frame -= 1
-
-    if from_open_code or any(
-        [module_name == sn or module_name.endswith("." + sn) for sn in skip_names]
+    if is_called_from_skipped_module(
+        skip_names=skip_names,
+        case_sensitive=filesystem.is_case_sensitive,
     ):
         return io_open(  # pytype: disable=wrong-arg-count
             file,
diff --git a/pyfakefs/fake_os.py b/pyfakefs/fake_os.py
index c57de0d..c2da5d0 100644
--- a/pyfakefs/fake_os.py
+++ b/pyfakefs/fake_os.py
@@ -54,6 +54,7 @@ from pyfakefs.fake_path import FakePathModule
 from pyfakefs.fake_scandir import scandir, walk, ScanDirIter
 from pyfakefs.helpers import (
     FakeStatResult,
+    is_called_from_skipped_module,
     is_int_type,
     is_byte_string,
     make_string_path,
@@ -1409,27 +1410,40 @@ class FakeOsModule:
         return getattr(self.os_module, name)
 
 
-if sys.version_info > (3, 10):
+def handle_original_call(f: Callable) -> Callable:
+    """Decorator used for real pathlib Path methods to ensure that
+    real os functions instead of faked ones are used.
+    Applied to all non-private methods of `FakeOsModule`."""
 
-    def handle_original_call(f: Callable) -> Callable:
-        """Decorator used for real pathlib Path methods to ensure that
-        real os functions instead of faked ones are used.
-        Applied to all non-private methods of `FakeOsModule`."""
+    @functools.wraps(f)
+    def wrapped(*args, **kwargs):
+        should_use_original = FakeOsModule.use_original
 
-        @functools.wraps(f)
-        def wrapped(*args, **kwargs):
-            if FakeOsModule.use_original:
-                # remove the `self` argument for FakeOsModule methods
-                if args and isinstance(args[0], FakeOsModule):
-                    args = args[1:]
-                return getattr(os, f.__name__)(*args, **kwargs)
-            return f(*args, **kwargs)
+        if not should_use_original and args:
+            self = args[0]
+            fs: FakeFilesystem = self.filesystem
+            if self.filesystem.patcher:
+                skip_names = fs.patcher.skip_names
+                if is_called_from_skipped_module(
+                    skip_names=skip_names,
+                    case_sensitive=fs.is_case_sensitive,
+                ):
+                    should_use_original = True
 
-        return wrapped
+        if should_use_original:
+            # remove the `self` argument for FakeOsModule methods
+            if args and isinstance(args[0], FakeOsModule):
+                args = args[1:]
+            return getattr(os, f.__name__)(*args, **kwargs)
 
-    for name, fn in inspect.getmembers(FakeOsModule, inspect.isfunction):
-        if not fn.__name__.startswith("_"):
-            setattr(FakeOsModule, name, handle_original_call(fn))
+        return f(*args, **kwargs)
+
+    return wrapped
+
+
+for name, fn in inspect.getmembers(FakeOsModule, inspect.isfunction):
+    if not fn.__name__.startswith("_"):
+        setattr(FakeOsModule, name, handle_original_call(fn))
 
 
 @contextmanager
diff --git a/pyfakefs/fake_path.py b/pyfakefs/fake_path.py
index 9c5d786..013b9e7 100644
--- a/pyfakefs/fake_path.py
+++ b/pyfakefs/fake_path.py
@@ -15,6 +15,8 @@
 """Faked ``os.path`` module replacement. See ``fake_filesystem`` for usage."""
 
 import errno
+import functools
+import inspect
 import os
 import sys
 from stat import (
@@ -23,6 +25,7 @@ from stat import (
 )
 from types import ModuleType
 from typing import (
+    Callable,
     List,
     Optional,
     Union,
@@ -36,6 +39,7 @@ from typing import (
 )
 
 from pyfakefs.helpers import (
+    is_called_from_skipped_module,
     make_string_path,
     to_string,
     matching_string,
@@ -553,3 +557,37 @@ if sys.platform == "win32":
         def __getattr__(self, name: str) -> Any:
             """Forwards any non-faked calls to the real nt module."""
             return getattr(self.nt_module, name)
+
+
+def handle_original_call(f: Callable) -> Callable:
+    """Decorator used for real pathlib Path methods to ensure that
+    real os functions instead of faked ones are used.
+    Applied to all non-private methods of `FakePathModule`."""
+
+    @functools.wraps(f)
+    def wrapped(*args, **kwargs):
+        if args:
+            self = args[0]
+            should_use_original = self.os.use_original
+            if not should_use_original and self.filesystem.patcher:
+                skip_names = self.filesystem.patcher.skip_names
+                if is_called_from_skipped_module(
+                    skip_names=skip_names,
+                    case_sensitive=self.filesystem.is_case_sensitive,
+                ):
+                    should_use_original = True
+
+            if should_use_original:
+                # remove the `self` argument for FakePathModule methods
+                if args and isinstance(args[0], FakePathModule):
+                    args = args[1:]
+                return getattr(os.path, f.__name__)(*args, **kwargs)
+
+        return f(*args, **kwargs)
+
+    return wrapped
+
+
+for name, fn in inspect.getmembers(FakePathModule, inspect.isfunction):
+    if not fn.__name__.startswith("_"):
+        setattr(FakePathModule, name, handle_original_call(fn))
diff --git a/pyfakefs/fake_pathlib.py b/pyfakefs/fake_pathlib.py
index f56ee4f..55942b8 100644
--- a/pyfakefs/fake_pathlib.py
+++ b/pyfakefs/fake_pathlib.py
@@ -49,7 +49,7 @@ from pyfakefs import fake_scandir
 from pyfakefs.fake_filesystem import FakeFilesystem
 from pyfakefs.fake_open import fake_open
 from pyfakefs.fake_os import FakeOsModule, use_original_os
-from pyfakefs.helpers import IS_PYPY
+from pyfakefs.helpers import IS_PYPY, is_called_from_skipped_module
 
 
 def init_module(filesystem):
@@ -93,26 +93,47 @@ def init_module(filesystem):
         setattr(FakePathlibModule.PureWindowsPath, parser_name, fake_pure_nt_os.path)
 
 
-def _wrap_strfunc(strfunc):
-    @functools.wraps(strfunc)
+def _wrap_strfunc(fake_fct, original_fct):
+    @functools.wraps(fake_fct)
     def _wrapped(pathobj, *args, **kwargs):
-        return strfunc(pathobj.filesystem, str(pathobj), *args, **kwargs)
+        fs: FakeFilesystem = pathobj.filesystem
+        if fs.patcher:
+            if is_called_from_skipped_module(
+                skip_names=fs.patcher.skip_names,
+                case_sensitive=fs.is_case_sensitive,
+            ):
+                return original_fct(str(pathobj), *args, **kwargs)
+        return fake_fct(fs, str(pathobj), *args, **kwargs)
 
     return staticmethod(_wrapped)
 
 
-def _wrap_binary_strfunc(strfunc):
-    @functools.wraps(strfunc)
+def _wrap_binary_strfunc(fake_fct, original_fct):
+    @functools.wraps(fake_fct)
     def _wrapped(pathobj1, pathobj2, *args):
-        return strfunc(pathobj1.filesystem, str(pathobj1), str(pathobj2), *args)
+        fs: FakeFilesystem = pathobj1.filesystem
+        if fs.patcher:
+            if is_called_from_skipped_module(
+                skip_names=fs.patcher.skip_names,
+                case_sensitive=fs.is_case_sensitive,
+            ):
+                return original_fct(str(pathobj1), str(pathobj2), *args)
+        return fake_fct(fs, str(pathobj1), str(pathobj2), *args)
 
     return staticmethod(_wrapped)
 
 
-def _wrap_binary_strfunc_reverse(strfunc):
-    @functools.wraps(strfunc)
+def _wrap_binary_strfunc_reverse(fake_fct, original_fct):
+    @functools.wraps(fake_fct)
     def _wrapped(pathobj1, pathobj2, *args):
-        return strfunc(pathobj2.filesystem, str(pathobj2), str(pathobj1), *args)
+        fs: FakeFilesystem = pathobj2.filesystem
+        if fs.patcher:
+            if is_called_from_skipped_module(
+                skip_names=fs.patcher.skip_names,
+                case_sensitive=fs.is_case_sensitive,
+            ):
+                return original_fct(str(pathobj2), str(pathobj1), *args)
+        return fake_fct(fs, str(pathobj2), str(pathobj1), *args)
 
     return staticmethod(_wrapped)
 
@@ -128,20 +149,21 @@ class _FakeAccessor(accessor):  # type: ignore[valid-type, misc]
     methods.
     """
 
-    stat = _wrap_strfunc(FakeFilesystem.stat)
+    stat = _wrap_strfunc(FakeFilesystem.stat, os.stat)
 
     lstat = _wrap_strfunc(
-        lambda fs, path: FakeFilesystem.stat(fs, path, follow_symlinks=False)
+        lambda fs, path: FakeFilesystem.stat(fs, path, follow_symlinks=False), os.lstat
     )
 
-    listdir = _wrap_strfunc(FakeFilesystem.listdir)
-    scandir = _wrap_strfunc(fake_scandir.scandir)
+    listdir = _wrap_strfunc(FakeFilesystem.listdir, os.listdir)
+    scandir = _wrap_strfunc(fake_scandir.scandir, os.scandir)
 
     if hasattr(os, "lchmod"):
         lchmod = _wrap_strfunc(
             lambda fs, path, mode: FakeFilesystem.chmod(
                 fs, path, mode, follow_symlinks=False
-            )
+            ),
+            os.lchmod,
         )
     else:
 
@@ -164,47 +186,51 @@ class _FakeAccessor(accessor):  # type: ignore[valid-type, misc]
                 )
         return pathobj.filesystem.chmod(str(pathobj), *args, **kwargs)
 
-    mkdir = _wrap_strfunc(FakeFilesystem.makedir)
+    mkdir = _wrap_strfunc(FakeFilesystem.makedir, os.mkdir)
 
-    unlink = _wrap_strfunc(FakeFilesystem.remove)
+    unlink = _wrap_strfunc(FakeFilesystem.remove, os.unlink)
 
-    rmdir = _wrap_strfunc(FakeFilesystem.rmdir)
+    rmdir = _wrap_strfunc(FakeFilesystem.rmdir, os.rmdir)
 
-    rename = _wrap_binary_strfunc(FakeFilesystem.rename)
+    rename = _wrap_binary_strfunc(FakeFilesystem.rename, os.rename)
 
     replace = _wrap_binary_strfunc(
         lambda fs, old_path, new_path: FakeFilesystem.rename(
             fs, old_path, new_path, force_replace=True
-        )
+        ),
+        os.replace,
     )
 
     symlink = _wrap_binary_strfunc_reverse(
         lambda fs, fpath, target, target_is_dir: FakeFilesystem.create_symlink(
             fs, fpath, target, create_missing_dirs=False
-        )
+        ),
+        os.symlink,
     )
 
     if (3, 8) <= sys.version_info:
         link_to = _wrap_binary_strfunc(
             lambda fs, file_path, link_target: FakeFilesystem.link(
                 fs, file_path, link_target
-            )
+            ),
+            os.link,
         )
 
     if sys.version_info >= (3, 10):
         link = _wrap_binary_strfunc(
             lambda fs, file_path, link_target: FakeFilesystem.link(
                 fs, file_path, link_target
-            )
+            ),
+            os.link,
         )
 
         # this will use the fake filesystem because os is patched
         def getcwd(self):
             return os.getcwd()
 
-    readlink = _wrap_strfunc(FakeFilesystem.readlink)
+    readlink = _wrap_strfunc(FakeFilesystem.readlink, os.readlink)
 
-    utime = _wrap_strfunc(FakeFilesystem.utime)
+    utime = _wrap_strfunc(FakeFilesystem.utime, os.utime)
 
 
 _fake_accessor = _FakeAccessor()
diff --git a/pyfakefs/helpers.py b/pyfakefs/helpers.py
index cf84936..674ce8f 100644
--- a/pyfakefs/helpers.py
+++ b/pyfakefs/helpers.py
@@ -19,7 +19,9 @@ import os
 import platform
 import stat
 import sys
+import sysconfig
 import time
+import traceback
 from collections import namedtuple
 from copy import copy
 from stat import S_IFLNK
@@ -39,6 +41,13 @@ PERM_DEF = 0o777  # Default permission bits.
 PERM_DEF_FILE = 0o666  # Default permission bits (regular file)
 PERM_ALL = 0o7777  # All permission bits.
 
+STDLIB_PATH = os.path.realpath(sysconfig.get_path("stdlib"))
+PYFAKEFS_PATH = os.path.dirname(__file__)
+PYFAKEFS_TEST_PATHS = [
+    os.path.join(PYFAKEFS_PATH, "tests"),
+    os.path.join(PYFAKEFS_PATH, "pytest_tests"),
+]
+
 _OpenModes = namedtuple(
     "_OpenModes",
     "must_exist can_read can_write truncate append must_not_exist",
@@ -429,3 +438,56 @@ class TextBufferIO(io.TextIOWrapper):
 
     def putvalue(self, value: bytes) -> None:
         self._bytestream.write(value)
+
+
+def is_called_from_skipped_module(skip_names: list, case_sensitive: bool) -> bool:
+    def starts_with(path, string):
+        if case_sensitive:
+            return path.startswith(string)
+        return path.lower().startswith(string.lower())
+
+    stack = traceback.extract_stack()
+
+    # handle the case that we try to call the original `open_code`
+    # (since Python 3.12)
+    # The stack in this case is:
+    # -1: helpers.is_called_from_skipped_module: 'stack = traceback.extract_stack()'
+    # -2: fake_open.fake_open: 'if is_called_from_skipped_module('
+    # -3: fake_io.open: 'return fake_open('
+    # -4: fake_io.open_code : 'return self._io_module.open_code(path)'
+    if (
+        sys.version_info >= (3, 12)
+        and stack[-4].name == "open_code"
+        and stack[-4].line == "return self._io_module.open_code(path)"
+    ):
+        return True
+
+    caller_filename = next(
+        (
+            frame.filename
+            for frame in stack[::-1]
+            if not frame.filename.startswith("<frozen ")
+            and not starts_with(frame.filename, STDLIB_PATH)
+            and (
+                not starts_with(frame.filename, PYFAKEFS_PATH)
+                or any(
+                    starts_with(frame.filename, test_path)
+                    for test_path in PYFAKEFS_TEST_PATHS
+                )
+            )
+        ),
+        None,
+    )
+
+    if caller_filename:
+        caller_module_name = os.path.splitext(caller_filename)[0]
+        caller_module_name = caller_module_name.replace(os.sep, ".")
+
+        if any(
+            [
+                caller_module_name == sn or caller_module_name.endswith("." + sn)
+                for sn in skip_names
+            ]
+        ):
+            return True
+    return False


Test patch:
diff --git a/pyfakefs/fake_filesystem_unittest.py b/pyfakefs/fake_filesystem_unittest.py
index 1fc9e47..ac53b6c 100644
--- a/pyfakefs/fake_filesystem_unittest.py
+++ b/pyfakefs/fake_filesystem_unittest.py
@@ -570,7 +570,7 @@ class Patcher:
             set_uid(1)
             set_gid(1)
 
-        self._skip_names = self.SKIPNAMES.copy()
+        self.skip_names = self.SKIPNAMES.copy()
         # save the original open function for use in pytest plugin
         self.original_open = open
         self.patch_open_code = patch_open_code
@@ -582,7 +582,7 @@ class Patcher:
                 cast(ModuleType, m).__name__ if inspect.ismodule(m) else cast(str, m)
                 for m in additional_skip_names
             ]
-            self._skip_names.update(skip_names)
+            self.skip_names.update(skip_names)
 
         self._fake_module_classes: Dict[str, Any] = {}
         self._unfaked_module_classes: Dict[str, Any] = {}
@@ -628,8 +628,8 @@ class Patcher:
             if patched_module_names != self.PATCHED_MODULE_NAMES:
                 self.__class__.PATCHED_MODULE_NAMES = patched_module_names
                 clear_cache = True
-            if self._skip_names != self.ADDITIONAL_SKIP_NAMES:
-                self.__class__.ADDITIONAL_SKIP_NAMES = self._skip_names
+            if self.skip_names != self.ADDITIONAL_SKIP_NAMES:
+                self.__class__.ADDITIONAL_SKIP_NAMES = self.skip_names
                 clear_cache = True
             if patch_default_args != self.PATCH_DEFAULT_ARGS:
                 self.__class__.PATCH_DEFAULT_ARGS = patch_default_args
@@ -875,7 +875,7 @@ class Patcher:
                         pass
                 continue
             skipped = module in self.SKIPMODULES or any(
-                [sn.startswith(module.__name__) for sn in self._skip_names]
+                [sn.startswith(module.__name__) for sn in self.skip_names]
             )
             module_items = module.__dict__.copy().items()
 
@@ -922,7 +922,7 @@ class Patcher:
         for name in self._fake_module_classes:
             self.fake_modules[name] = self._fake_module_classes[name](self.fs)
             if hasattr(self.fake_modules[name], "skip_names"):
-                self.fake_modules[name].skip_names = self._skip_names
+                self.fake_modules[name].skip_names = self.skip_names
         self.fake_modules[PATH_MODULE] = self.fake_modules["os"].path
         for name in self._unfaked_module_classes:
             self.unfaked_modules[name] = self._unfaked_module_classes[name]()
diff --git a/pyfakefs/tests/fake_pathlib_test.py b/pyfakefs/tests/fake_pathlib_test.py
index c3824eb..afd3efe 100644
--- a/pyfakefs/tests/fake_pathlib_test.py
+++ b/pyfakefs/tests/fake_pathlib_test.py
@@ -34,6 +34,7 @@ from pyfakefs import fake_pathlib, fake_filesystem, fake_filesystem_unittest, fa
 from pyfakefs.fake_filesystem import OSType
 from pyfakefs.helpers import IS_PYPY, is_root
 from pyfakefs.tests.skipped_pathlib import (
+    check_exists_pathlib,
     read_bytes_pathlib,
     read_pathlib,
     read_text_pathlib,
@@ -1356,6 +1357,12 @@ class SkipPathlibTest(fake_filesystem_unittest.TestCase):
         contents = read_bytes_pathlib("skipped_pathlib.py")
         self.assertTrue(contents.startswith(b"# Licensed under the Apache License"))
 
+    @unittest.skipIf(
+        IS_PYPY and sys.version_info < (3, 8), "Ignoring error in outdated version"
+    )
+    def test_exists(self):
+        self.assertTrue(check_exists_pathlib())
+
 
 if __name__ == "__main__":
     unittest.main(verbosity=2)
diff --git a/pyfakefs/tests/skipped_pathlib.py b/pyfakefs/tests/skipped_pathlib.py
index 5477f93..5659a71 100644
--- a/pyfakefs/tests/skipped_pathlib.py
+++ b/pyfakefs/tests/skipped_pathlib.py
@@ -29,6 +29,10 @@ def read_bytes_pathlib(file_name):
     return (Path(__file__).parent / file_name).read_bytes()
 
 
+def check_exists_pathlib():
+    return os.path.exists(__file__) and Path(__file__).exists()
+
+
 def read_open(file_name):
     with open(os.path.join(os.path.dirname(__file__), file_name)) as f:
         return f.read()