Repository: pytest-dev/pyfakefs

Issue:
FakePathlibModule shows real files on Python 3.11+ (works on 3.10)
With Python 3.11 and 3.12 the fake pathlib no longer stays inside the fake filesystem.

Calling `Path('/').iterdir()` returns the real files from the container.

On Python 3.10 the same code returns an empty list, which is what I expect.

How to reproduce:

Python 3.10  – works

```
docker run -it --rm python:3.10-alpine sh -c "
pip install -q pyfakefs && \
python - <<'PY'
from pyfakefs.fake_filesystem import FakeFilesystem
from pyfakefs.fake_pathlib import FakePathlibModule
pathlib = FakePathlibModule(FakeFilesystem())
print(list(pathlib.Path('/').iterdir()))   # expected []
PY
"
```

Python 3.12  – broken

```
docker run -it --rm python:3.12-alpine sh -c "
pip install -q pyfakefs && \
python - <<'PY'
from pyfakefs.fake_filesystem import FakeFilesystem
from pyfakefs.fake_pathlib import FakePathlibModule
pathlib = FakePathlibModule(FakeFilesystem())
print(list(pathlib.Path('/').iterdir()))   # shows real /bin, /usr, ...
PY
"
```

Why I think it breaks: 
pathlib versions in newer Pythons use real os module so the fake filesystem is bypassed.

Discussion:
@evgenii-moriakhin: as workaround:

```python
import os
import sys

from pyfakefs.fake_filesystem import FakeFilesystem
from pyfakefs.fake_os import FakeOsModule
from pyfakefs.fake_pathlib import FakePathlibModule

filesystem = FakeFilesystem()
fake_os = FakeOsModule(filesystem)

del sys.modules["pathlib"]

sys.modules["os"] = fake_os

for module in dict(sys.modules):
    if module.startswith("pyfakefs."):
        del sys.modules[module]

from pyfakefs.fake_pathlib import FakePathlibModule

pathlib = FakePathlibModule(filesystem)

sys.modules["os"] = os

print(list(pathlib.Path("/").iterdir()))
pathlib.Path("/foo").touch()
print(list(pathlib.Path("/").iterdir()))
```
@mrbean-bremen: Thanks, I will have a look!
It is true that `pathlib` uses `os` in Python 3.11+, and we patch that if using `pyfakefs` via the Patcher (e.g. using the `fs` fixture, or `fake_filesystem_unittest.TestCase`, or the `patchfs` decorator). 
I've probably never tested instantiating `FakePathlibModule` directly, and I can see why it won't work. Maybe it should be changed to automatically create a `FakeOsModule` if non exists yet (basically what you are doing in your workaround), or something like this - I have to think about it...
@evgenii-moriakhin: My use case for creating a module directly involves complex integration tests that “simulate” several physical nodes, each with its own file system.
Because the application is multithreaded, patching or mocking would be a bad idea, so the code under test has been reorganized to allow direct injection of file-system-related libraries (open, os, pathlib, etc.) into the tested code—for example, through calls such as get_os_lib().

The injection itself is done by installing a fake module via a ContextVar (or by returning the default module if no fake one has been set).

I realize my scenario is rather complicated, but it explains why I came across this need@mrbean-bremen: Sure, this is a valid use case - as I said, I have neglected this so far, probably need at least some basic tests for this.@mrbean-bremen: @evgenii-moriakhin - shall be fixed in main branch, please check! I cannot exclude any unwanted side-effect, as this has been tested only marginally, though.

Solution patch:
diff --git a/CHANGES.md b/CHANGES.md
index 3aeb569..12c6bc9 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -27,7 +27,9 @@ The released versions correspond to PyPI releases.
   (see [#1121](../../issues/1121))
 * fixed workaround for recursion with pytest under Windows to ignore capitalization
   of pytest executable (see [#1096](../../issues/1096))
-* added missing `mode` property to fake file wrapper (see [#1162](../../issues/1096))
+* added missing `mode` property to fake file wrapper (see [#1162](../../issues/1162))
+* fixed instantiation of a standalone `FakePathlibModule` for Python >= 3.11
+  (see [#1169](../../issues/1169))
 
 ### Infrastructure
 * adapt test for increased default buffer size in Python 3.14a6
diff --git a/pyfakefs/fake_filesystem_unittest.py b/pyfakefs/fake_filesystem_unittest.py
index 273f425..de8abf0 100644
--- a/pyfakefs/fake_filesystem_unittest.py
+++ b/pyfakefs/fake_filesystem_unittest.py
@@ -775,17 +775,26 @@ class Patcher:
         # it by adding an attribute in fixtures/module_with_attributes.py
         # and a test in fake_filesystem_unittest_test.py, class
         # TestAttributesWithFakeModuleNames.
+
+        # we instantiate the fake pathlib library with `from_patcher` set
+        # to avoid faking pathlib.os (already faked by the patcher)
+        def fake_pathlib_module(fs: FakeFilesystem):
+            return fake_pathlib.FakePathlibModule(fs, from_patcher=True)
+
+        def fake_path_module(fs: FakeFilesystem):
+            return fake_pathlib.FakePathlibPathModule(fs, from_patcher=True)
+
         self._fake_module_classes = {
             "os": fake_os.FakeOsModule,
             "shutil": fake_filesystem_shutil.FakeShutilModule,
             "io": fake_io.FakeIoModule,
-            "pathlib": fake_pathlib.FakePathlibModule,
+            "pathlib": fake_pathlib_module,
         }
         if sys.version_info >= (3, 13):
             # for Python 3.13, we need both pathlib (path with __init__.py) and
             # pathlib._local (has the actual implementation);
             # depending on how pathlib is imported, either may be used
-            self._fake_module_classes["pathlib._local"] = fake_pathlib.FakePathlibModule
+            self._fake_module_classes["pathlib._local"] = fake_pathlib_module
         if IS_PYPY or sys.version_info >= (3, 12):
             # in PyPy and later cpython versions, the module is referenced as _io
             self._fake_module_classes["_io"] = fake_io.FakeIoModule2
@@ -813,7 +822,7 @@ class Patcher:
             self._unfaked_module_classes["pathlib2"] = fake_pathlib.RealPathlibModule
         if scandir:
             self._fake_module_classes["scandir"] = fake_legacy_modules.FakeScanDirModule
-        self._fake_module_classes["Path"] = fake_pathlib.FakePathlibPathModule
+        self._fake_module_classes["Path"] = fake_path_module
         self._unfaked_module_classes["Path"] = fake_pathlib.RealPathlibPathModule
 
     def _init_fake_module_functions(self) -> None:
diff --git a/pyfakefs/fake_pathlib.py b/pyfakefs/fake_pathlib.py
index c2d5278..85c5c44 100644
--- a/pyfakefs/fake_pathlib.py
+++ b/pyfakefs/fake_pathlib.py
@@ -42,6 +42,7 @@ import sys
 import warnings
 from pathlib import PurePath
 from typing import Callable, List, Optional
+from unittest import mock
 from urllib.parse import quote_from_bytes as urlquote_from_bytes
 
 from pyfakefs import fake_scandir
@@ -554,6 +555,7 @@ if sys.version_info < (3, 12):
 
         def compile_pattern(self, pattern):
             return re.compile(fnmatch.translate(pattern)).fullmatch
+
 else:  # Python >= 3.12
 
     class FakePosixPathModule(FakePathModule):
@@ -862,24 +864,48 @@ def _warn_is_reserved_deprecated():
 class FakePathlibModule:
     """Uses FakeFilesystem to provide a fake pathlib module replacement.
 
-    You need a fake_filesystem to use this::
+    Automatically created if using `fake_filesystem_unittest.TestCase`,
+    `fs` fixture, or the `patchfs` decorator.
+
+    For creating it separately, a `fake_filesystem` instance is needed::
 
         filesystem = fake_filesystem.FakeFilesystem()
         fake_pathlib_module = fake_pathlib.FakePathlibModule(filesystem)
     """
 
-    def __init__(self, filesystem):
+    def __init__(self, filesystem, from_patcher=False):
         """
         Initializes the module with the given filesystem.
 
         Args:
             filesystem: FakeFilesystem used to provide file system information
+            from_patcher: If `False` (the default), `pathlib.os` will be manually
+                patched using `FakeOsModule`. This allows to instantiate the class
+                manually for a test.
+                Will be set to `True` if instantiated from `Patcher`.
         """
         init_module(filesystem)
         self._pathlib_module = pathlib
+        self._os = None
+        self._os_patcher = None
+        if not from_patcher:
+            self.patch_os_module()
+
+    def patch_os_module(self):
+        if sys.version_info >= (3, 11) and not isinstance(os, FakeOsModule):
+            self._os = FakeOsModule(FakePath.filesystem)
+            pathlib_os = (
+                "pathlib._local.os" if sys.version_info[:2] == (3, 13) else "pathlib.os"
+            )
+            self._os_patcher = mock.patch(pathlib_os, self._os)
+            self._os_patcher.start()
+
+    def __del__(self):
+        if self._os_patcher is not None:
+            self._os_patcher.stop()
 
     class PurePosixPath(PurePath):
-        """A subclass of PurePath, that represents non-Windows filesystem
+        """A subclass of PurePath that represents non-Windows filesystem
         paths"""
 
         __slots__ = ()
@@ -898,7 +924,7 @@ class FakePathlibModule:
                     return super().joinpath(*pathsegments)
 
     class PureWindowsPath(PurePath):
-        """A subclass of PurePath, that represents Windows filesystem paths"""
+        """A subclass of PurePath that represents Windows filesystem paths"""
 
         __slots__ = ()
 
@@ -990,9 +1016,9 @@ class FakePathlibPathModule:
 
     fake_pathlib = None
 
-    def __init__(self, filesystem=None):
+    def __init__(self, filesystem=None, from_patcher=False):
         if self.fake_pathlib is None:
-            self.__class__.fake_pathlib = FakePathlibModule(filesystem)
+            self.__class__.fake_pathlib = FakePathlibModule(filesystem, from_patcher)
 
     @property
     def skip_names(self):


Test patch:
diff --git a/pyfakefs/tests/fake_filesystem_test.py b/pyfakefs/tests/fake_filesystem_test.py
index a245732..45326ac 100644
--- a/pyfakefs/tests/fake_filesystem_test.py
+++ b/pyfakefs/tests/fake_filesystem_test.py
@@ -2614,6 +2614,7 @@ class RealFileSystemAccessTest(RealFsTestCase):
 
     @unittest.skipIf(pytest is None, "pytest is not installed")
     @unittest.skipIf(sys.version_info < (3, 8), "importlib.metadata not available")
+    @unittest.skipIf("pathlib2" in sys.modules, "pathlib2 may break this test")
     def test_add_package_metadata(self):
         parent_path = pathlib.Path(pytest.__file__).parent.parent
         pytest_dist_path = parent_path / f"pytest-{pytest.__version__}.dist-info"
diff --git a/pyfakefs/tests/fake_pathlib_test.py b/pyfakefs/tests/fake_pathlib_test.py
index 2e19048..3802ad1 100644
--- a/pyfakefs/tests/fake_pathlib_test.py
+++ b/pyfakefs/tests/fake_pathlib_test.py
@@ -31,7 +31,8 @@ from unittest import mock
 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 import OSType, FakeFilesystem
+from pyfakefs.fake_pathlib import FakePathlibModule
 from pyfakefs.helpers import IS_PYPY, is_root
 from pyfakefs.tests.skipped_pathlib import (
     check_exists_pathlib,
@@ -1646,5 +1647,13 @@ class SkipPathlibTest(fake_filesystem_unittest.TestCase):
         self.assertTrue(check_exists_pathlib())
 
 
+class InstantiatedPathlibTest(unittest.TestCase):
+    def test_instantiated_pathlib(self):
+        fake_fs = FakeFilesystem()
+        fake_pathlib_module = FakePathlibModule(fake_fs)
+        top_level_dirs = list(fake_pathlib_module.Path("/").iterdir())
+        self.assertEqual(0, len(top_level_dirs))
+
+
 if __name__ == "__main__":
     unittest.main(verbosity=2)