Repository: pytest-dev/pyfakefs (python)

Issue:
shutil.copystat raises AttributeError when used on symlinks
**Describe the bug**
shutil.copystat(s,d,follow_symlink=False) raises AttributeError while patched with fake_filesystem_unittest and when both s, d are symlinks.  This looks to be related to github.com/pytest-dev/pyfakefs/issues/533

```
Traceback (most recent call last):
  File "/usr/local/google/home/henniss/github/pyfakefs/pyfakefs/tests/fake_filesystem_shutil_test.py", line 241, in test_copystat_symlinks
    shutil.copystat(sym1, sym2, follow_symlinks=False)
  File "/usr/lib/python3.10/shutil.py", line 373, in copystat
    mode = stat.S_IMODE(st.st_mode)
AttributeError: 'NoneType' object has no attribute 'st_mode'
```

**How To Reproduce**
I'm including a PR which adds a (failing) test: 

**Your environment**

```
python -c "import sys; print('Python', sys.version)"
python -c "from pyfakefs.fake_filesystem import __version__; print('pyfakefs', __version__)"
python -c "import pytest; print('pytest', pytest.__version__)"
Linux-5.19.11-1rodete1-amd64-x86_64-with-glibc2.36
Python 3.10.9 (main, Dec  7 2022, 13:47:07) [GCC 12.2.0]
Traceback (most recent call last):
  File "<string>", line 1, in <module>
ImportError: cannot import name '__version__' from 'pyfakefs.fake_filesystem' (/usr/local/google/home/henniss/github/pyfakefs/pyfakefs/fake_filesystem.py)
pytest 7.2.2
```

(n.b. this fails at head, Import error above notwithstanding). 

Pull request description:
Set the os.supports_xxx properties for the fake filesystem
- return the faked functions instead of the real ones
- fixes #799

The problem was that until now, the `os.supports_..` properties have been taken from the real `os` in `pyfakefs` itself. This does not work if called by someone in the faked os who checks for a contained function, as it would have found the original instead of the faked function.

#### 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 b4ffb18..fe11296 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,6 +1,13 @@
 # pyfakefs Release Notes
 The released versions correspond to PyPI releases.
 
+## Unreleased
+
+### Fixes
+* Properties defining the capabilities of some `os` functions like
+  `os.supports_follow_symlinks` are now properly faked to contain the fake functions
+  if the real functions are faked (see [#799](../../issues/799))
+
 ## [Version 5.2.0](https://pypi.python.org/pypi/pyfakefs/5.2.0) (2023-03-31)
 Supports current Python 3.12 version (alpha 6). We plan to make patch releases in
 case of breaking changes in alpha or beta versions.
diff --git a/pyfakefs/fake_os.py b/pyfakefs/fake_os.py
index 0d638a5..912fd31 100644
--- a/pyfakefs/fake_os.py
+++ b/pyfakefs/fake_os.py
@@ -36,6 +36,7 @@ from typing import (
     cast,
     AnyStr,
     TYPE_CHECKING,
+    Set,
 )
 
 from pyfakefs.extra_packages import use_scandir
@@ -146,6 +147,10 @@ class FakeOsModule:
         self.filesystem = filesystem
         self.os_module: Any = os
         self.path = FakePathModule(self.filesystem, self)
+        self._supports_follow_symlinks: Optional[Set] = None
+        self._supports_dir_fd: Optional[Set] = None
+        self._supports_effective_ids: Optional[Set] = None
+        self._supports_fd: Optional[Set] = None
 
     @property
     def devnull(self) -> str:
@@ -890,8 +895,7 @@ class FakeOsModule:
             path = path
         if dir_fd is not None:
             # check if fd is supported for the built-in real function
-            real_fct = getattr(os, fct.__name__)
-            if real_fct not in self.supports_dir_fd:
+            if fct not in self.supports_dir_fd:
                 raise NotImplementedError("dir_fd unavailable on this platform")
             if isinstance(path, int):
                 raise ValueError(
@@ -998,7 +1002,7 @@ class FakeOsModule:
                 the link itself is queried instead of the linked object.
         """
         if not follow_symlinks and (
-            os.chmod not in os.supports_follow_symlinks or IS_PYPY
+            self.chmod not in self.supports_follow_symlinks or IS_PYPY
         ):
             raise NotImplementedError(
                 "`follow_symlinks` for chmod() is not available " "on this system"
@@ -1268,6 +1272,43 @@ class FakeOsModule:
             return written
         return 0
 
+    def fake_functions(self, original_functions) -> Set:
+        functions = set()
+        for fn in original_functions:
+            if hasattr(self, fn.__name__):
+                functions.add(getattr(self, fn.__name__))
+            else:
+                functions.add(fn)
+        return functions
+
+    @property
+    def supports_follow_symlinks(self) -> Set[Callable]:
+        if self._supports_follow_symlinks is None:
+            self._supports_follow_symlinks = self.fake_functions(
+                self.os_module.supports_follow_symlinks
+            )
+        return self._supports_follow_symlinks
+
+    @property
+    def supports_dir_fd(self) -> Set[Callable]:
+        if self._supports_dir_fd is None:
+            self._supports_dir_fd = self.fake_functions(self.os_module.supports_dir_fd)
+        return self._supports_dir_fd
+
+    @property
+    def supports_fd(self) -> Set[Callable]:
+        if self._supports_fd is None:
+            self._supports_fd = self.fake_functions(self.os_module.supports_fd)
+        return self._supports_fd
+
+    @property
+    def supports_effective_ids(self) -> Set[Callable]:
+        if self._supports_effective_ids is None:
+            self._supports_effective_ids = self.fake_functions(
+                self.os_module.supports_effective_ids
+            )
+        return self._supports_effective_ids
+
     def __getattr__(self, name: str) -> Any:
         """Forwards any unfaked calls to the standard os module."""
         return getattr(self.os_module, name)
diff --git a/pyfakefs/fake_pathlib.py b/pyfakefs/fake_pathlib.py
index 4f8ec6f..2291ddb 100644
--- a/pyfakefs/fake_pathlib.py
+++ b/pyfakefs/fake_pathlib.py
@@ -132,7 +132,7 @@ class _FakeAccessor(accessor):  # type: ignore [valid-type, misc]
 
             if (
                 not kwargs["follow_symlinks"]
-                and os.os_module.chmod not in os.supports_follow_symlinks
+                and os.os_module.chmod not in os.os_module.supports_follow_symlinks
             ):
                 raise NotImplementedError(
                     "`follow_symlinks` for chmod() is not available " "on this system"


Test patch:
diff --git a/pyfakefs/tests/fake_filesystem_shutil_test.py b/pyfakefs/tests/fake_filesystem_shutil_test.py
index c99d464..e1bfab1 100644
--- a/pyfakefs/tests/fake_filesystem_shutil_test.py
+++ b/pyfakefs/tests/fake_filesystem_shutil_test.py
@@ -231,6 +231,17 @@ class FakeShutilModuleTest(RealFsTestCase):
         self.assertAlmostEqual(src_stat.st_atime, dst_stat.st_atime, places=2)
         self.assertAlmostEqual(src_stat.st_mtime, dst_stat.st_mtime, places=2)
 
+    def test_copystat_symlinks(self):
+        """Regression test for #799"""
+        self.skip_if_symlink_not_supported()
+        f = self.make_path("xyzzy")
+        self.create_file(f)
+        sym1 = self.make_path("sym1")
+        sym2 = self.make_path("sym2")
+        self.create_symlink(sym1, f)
+        self.create_symlink(sym2, f)
+        shutil.copystat(sym1, sym2, follow_symlinks=False)
+
     def test_copy2(self):
         src_file = self.make_path("xyzzy")
         self.create_file(src_file)
diff --git a/pyfakefs/tests/fake_os_test.py b/pyfakefs/tests/fake_os_test.py
index d3727a1..e9294f6 100644
--- a/pyfakefs/tests/fake_os_test.py
+++ b/pyfakefs/tests/fake_os_test.py
@@ -2011,7 +2011,7 @@ class FakeOsModuleTest(FakeOsModuleTestBase):
         self.createTestFile(path)
         link_path = self.make_path("link_to_some_file")
         self.create_symlink(link_path, path)
-        if os.chmod not in os.supports_follow_symlinks or IS_PYPY:
+        if self.os.chmod not in self.os.supports_follow_symlinks or IS_PYPY:
             with self.assertRaises(NotImplementedError):
                 self.os.chmod(link_path, 0o6543, follow_symlinks=False)
         else:
@@ -2866,6 +2866,21 @@ class FakeOsModuleTest(FakeOsModuleTestBase):
         with self.open(file_path) as f:
             self.assertEqual("0123456789", f.read())
 
+    def test_capabilities(self):
+        """Make sure that the fake capabilities are the same as the real ones."""
+        self.assertEqual(
+            self.os.stat in self.os.supports_follow_symlinks,
+            os.stat in os.supports_follow_symlinks,
+        )
+        self.assertEqual(self.os.stat in self.os.supports_fd, os.stat in os.supports_fd)
+        self.assertEqual(
+            self.os.stat in self.os.supports_dir_fd, os.stat in os.supports_dir_fd
+        )
+        self.assertEqual(
+            self.os.stat in self.os.supports_effective_ids,
+            os.stat in os.supports_effective_ids,
+        )
+
 
 class RealOsModuleTest(FakeOsModuleTest):
     def use_real_fs(self):
@@ -4661,7 +4676,7 @@ class RealOsModuleWalkTest(FakeOsModuleWalkTest):
 class FakeOsModuleDirFdTest(FakeOsModuleTestBase):
     def setUp(self):
         super(FakeOsModuleDirFdTest, self).setUp()
-        self.os.supports_dir_fd = set()
+        self.os.supports_dir_fd.clear()
         self.filesystem.is_windows_fs = False
         self.filesystem.create_dir("/foo/bar")
         self.dir_fd = self.os.open("/foo", os.O_RDONLY)
@@ -4675,7 +4690,7 @@ class FakeOsModuleDirFdTest(FakeOsModuleTestBase):
             self.os.F_OK,
             dir_fd=self.dir_fd,
         )
-        self.os.supports_dir_fd.add(os.access)
+        self.os.supports_dir_fd.add(self.os.access)
         self.assertTrue(self.os.access("baz", self.os.F_OK, dir_fd=self.dir_fd))
 
     def test_chmod(self):
@@ -4686,7 +4701,7 @@ class FakeOsModuleDirFdTest(FakeOsModuleTestBase):
             0o6543,
             dir_fd=self.dir_fd,
         )
-        self.os.supports_dir_fd.add(os.chmod)
+        self.os.supports_dir_fd.add(self.os.chmod)
         self.os.chmod("baz", 0o6543, dir_fd=self.dir_fd)
         st = self.os.stat("/foo/baz")
         self.assert_mode_equal(0o6543, st.st_mode)
@@ -4701,7 +4716,7 @@ class FakeOsModuleDirFdTest(FakeOsModuleTestBase):
             101,
             dir_fd=self.dir_fd,
         )
-        self.os.supports_dir_fd.add(os.chown)
+        self.os.supports_dir_fd.add(self.os.chown)
         self.os.chown("baz", 100, 101, dir_fd=self.dir_fd)
         st = self.os.stat("/foo/baz")
         self.assertEqual(st[stat.ST_UID], 100)
@@ -4715,7 +4730,7 @@ class FakeOsModuleDirFdTest(FakeOsModuleTestBase):
             "/bat",
             src_dir_fd=self.dir_fd,
         )
-        self.os.supports_dir_fd.add(os.link)
+        self.os.supports_dir_fd.add(self.os.link)
         self.os.link("baz", "/bat", src_dir_fd=self.dir_fd)
         self.assertTrue(self.os.path.exists("/bat"))
 
@@ -4727,7 +4742,7 @@ class FakeOsModuleDirFdTest(FakeOsModuleTestBase):
             "/bat",
             dst_dir_fd=self.dir_fd,
         )
-        self.os.supports_dir_fd.add(os.link)
+        self.os.supports_dir_fd.add(self.os.link)
         self.os.link("/foo/baz", "bat", dst_dir_fd=self.dir_fd)
         self.assertTrue(self.os.path.exists("/foo/bat"))
 
@@ -4739,7 +4754,7 @@ class FakeOsModuleDirFdTest(FakeOsModuleTestBase):
             "/bat",
             dir_fd=self.dir_fd,
         )
-        self.os.supports_dir_fd.add(os.symlink)
+        self.os.supports_dir_fd.add(self.os.symlink)
         self.os.symlink("baz", "/bat", dir_fd=self.dir_fd)
         self.assertTrue(self.os.path.exists("/bat"))
 
@@ -4753,7 +4768,7 @@ class FakeOsModuleDirFdTest(FakeOsModuleTestBase):
             "/geo/metro/lemon/pie",
             dir_fd=self.dir_fd,
         )
-        self.os.supports_dir_fd.add(os.readlink)
+        self.os.supports_dir_fd.add(self.os.readlink)
         self.assertEqual(
             "/foo/baz",
             self.os.readlink("/geo/metro/lemon/pie", dir_fd=self.dir_fd),
@@ -4761,13 +4776,13 @@ class FakeOsModuleDirFdTest(FakeOsModuleTestBase):
 
     def test_stat(self):
         self.assertRaises(NotImplementedError, self.os.stat, "baz", dir_fd=self.dir_fd)
-        self.os.supports_dir_fd.add(os.stat)
+        self.os.supports_dir_fd.add(self.os.stat)
         st = self.os.stat("baz", dir_fd=self.dir_fd)
         self.assertEqual(st.st_mode, 0o100666)
 
     def test_lstat(self):
         self.assertRaises(NotImplementedError, self.os.lstat, "baz", dir_fd=self.dir_fd)
-        self.os.supports_dir_fd.add(os.lstat)
+        self.os.supports_dir_fd.add(self.os.lstat)
         st = self.os.lstat("baz", dir_fd=self.dir_fd)
         self.assertEqual(st.st_mode, 0o100666)
 
@@ -4775,13 +4790,13 @@ class FakeOsModuleDirFdTest(FakeOsModuleTestBase):
         self.assertRaises(
             NotImplementedError, self.os.mkdir, "newdir", dir_fd=self.dir_fd
         )
-        self.os.supports_dir_fd.add(os.mkdir)
+        self.os.supports_dir_fd.add(self.os.mkdir)
         self.os.mkdir("newdir", dir_fd=self.dir_fd)
         self.assertTrue(self.os.path.exists("/foo/newdir"))
 
     def test_rmdir(self):
         self.assertRaises(NotImplementedError, self.os.rmdir, "bar", dir_fd=self.dir_fd)
-        self.os.supports_dir_fd.add(os.rmdir)
+        self.os.supports_dir_fd.add(self.os.rmdir)
         self.os.rmdir("bar", dir_fd=self.dir_fd)
         self.assertFalse(self.os.path.exists("/foo/bar"))
 
@@ -4790,7 +4805,7 @@ class FakeOsModuleDirFdTest(FakeOsModuleTestBase):
         self.assertRaises(
             NotImplementedError, self.os.mknod, "newdir", dir_fd=self.dir_fd
         )
-        self.os.supports_dir_fd.add(os.mknod)
+        self.os.supports_dir_fd.add(self.os.mknod)
         self.os.mknod("newdir", dir_fd=self.dir_fd)
         self.assertTrue(self.os.path.exists("/foo/newdir"))
 
@@ -4802,7 +4817,7 @@ class FakeOsModuleDirFdTest(FakeOsModuleTestBase):
             "/foo/batz",
             src_dir_fd=self.dir_fd,
         )
-        self.os.supports_dir_fd.add(os.rename)
+        self.os.supports_dir_fd.add(self.os.rename)
         self.os.rename("bar", "/foo/batz", src_dir_fd=self.dir_fd)
         self.assertTrue(self.os.path.exists("/foo/batz"))
 
@@ -4814,7 +4829,7 @@ class FakeOsModuleDirFdTest(FakeOsModuleTestBase):
             "/foo/batz",
             dst_dir_fd=self.dir_fd,
         )
-        self.os.supports_dir_fd.add(os.rename)
+        self.os.supports_dir_fd.add(self.os.rename)
         self.os.rename("/foo/bar", "batz", dst_dir_fd=self.dir_fd)
         self.assertTrue(self.os.path.exists("/foo/batz"))
 
@@ -4826,7 +4841,7 @@ class FakeOsModuleDirFdTest(FakeOsModuleTestBase):
             "/foo/batz",
             src_dir_fd=self.dir_fd,
         )
-        self.os.supports_dir_fd.add(os.rename)
+        self.os.supports_dir_fd.add(self.os.rename)
         self.os.replace("bar", "/foo/batz", src_dir_fd=self.dir_fd)
         self.assertTrue(self.os.path.exists("/foo/batz"))
 
@@ -4838,7 +4853,7 @@ class FakeOsModuleDirFdTest(FakeOsModuleTestBase):
             "/foo/batz",
             dst_dir_fd=self.dir_fd,
         )
-        self.os.supports_dir_fd.add(os.rename)
+        self.os.supports_dir_fd.add(self.os.rename)
         self.os.replace("/foo/bar", "batz", dst_dir_fd=self.dir_fd)
         self.assertTrue(self.os.path.exists("/foo/batz"))
 
@@ -4846,7 +4861,7 @@ class FakeOsModuleDirFdTest(FakeOsModuleTestBase):
         self.assertRaises(
             NotImplementedError, self.os.remove, "baz", dir_fd=self.dir_fd
         )
-        self.os.supports_dir_fd.add(os.remove)
+        self.os.supports_dir_fd.add(self.os.remove)
         self.os.remove("baz", dir_fd=self.dir_fd)
         self.assertFalse(self.os.path.exists("/foo/baz"))
 
@@ -4854,7 +4869,7 @@ class FakeOsModuleDirFdTest(FakeOsModuleTestBase):
         self.assertRaises(
             NotImplementedError, self.os.unlink, "baz", dir_fd=self.dir_fd
         )
-        self.os.supports_dir_fd.add(os.unlink)
+        self.os.supports_dir_fd.add(self.os.unlink)
         self.os.unlink("baz", dir_fd=self.dir_fd)
         self.assertFalse(self.os.path.exists("/foo/baz"))
 
@@ -4866,7 +4881,7 @@ class FakeOsModuleDirFdTest(FakeOsModuleTestBase):
             (1, 2),
             dir_fd=self.dir_fd,
         )
-        self.os.supports_dir_fd.add(os.utime)
+        self.os.supports_dir_fd.add(self.os.utime)
         self.os.utime("baz", times=(1, 2), dir_fd=self.dir_fd)
         st = self.os.stat("/foo/baz")
         self.assertEqual(1, st.st_atime)
@@ -4880,7 +4895,7 @@ class FakeOsModuleDirFdTest(FakeOsModuleTestBase):
             os.O_RDONLY,
             dir_fd=self.dir_fd,
         )
-        self.os.supports_dir_fd.add(os.open)
+        self.os.supports_dir_fd.add(self.os.open)
         fd = self.os.open("baz", os.O_RDONLY, dir_fd=self.dir_fd)
         self.assertLess(0, fd)
 
diff --git a/pyfakefs/tests/fake_pathlib_test.py b/pyfakefs/tests/fake_pathlib_test.py
index 6cc7bd9..d487f32 100644
--- a/pyfakefs/tests/fake_pathlib_test.py
+++ b/pyfakefs/tests/fake_pathlib_test.py
@@ -402,7 +402,7 @@ class FakePathlibFileObjectPropertyTest(RealPathlibTestCase):
         self.skip_if_symlink_not_supported()
         file_stat = self.os.stat(self.file_path)
         link_stat = self.os.lstat(self.file_link_path)
-        if self.real_os.chmod not in os.supports_follow_symlinks or IS_PYPY:
+        if self.os.chmod not in self.os.supports_follow_symlinks or IS_PYPY:
             with self.assertRaises(NotImplementedError):
                 self.path(self.file_link_path).chmod(0o444, follow_symlinks=False)
         else: