Repository: pytest-dev/pyfakefs
Pull request #1049 (2024-08-17 17:26:47)

Add support for os.fchmod
- restrict availability for fd argument in os.chmod (not available in Windows for Python < 3.13)

Noticed this while checking the changes in Python 3.13 - we don't support `os.fchmod` so far.

#### 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



Patch:
diff --git a/CHANGES.md b/CHANGES.md
index f50c5ae..23452ef 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -15,8 +15,9 @@ The released versions correspond to PyPI releases.
 ## Unreleased
 
 ### Enhancements
-
-- refactor the implementation of `additional_skip_names` parameter to make it work with more modules (see [#1023](../../issues/1023))
+* the `additional_skip_names` parameter now works with more modules (see [#1023](../../issues/1023))
+* added support for `os.fchmod`, allow file descriptor argument for `os.chmod` only for POSIX
+  for Python < 3.13
 
 ## [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_filesystem.py b/pyfakefs/fake_filesystem.py
index 0421859..56f0e47 100644
--- a/pyfakefs/fake_filesystem.py
+++ b/pyfakefs/fake_filesystem.py
@@ -722,7 +722,7 @@ class FakeFilesystem:
 
     def chmod(
         self,
-        path: AnyStr,
+        path: Union[AnyStr, int],
         mode: int,
         follow_symlinks: bool = True,
         force_unix_mode: bool = False,
@@ -730,15 +730,16 @@ class FakeFilesystem:
         """Change the permissions of a file as encoded in integer mode.
 
         Args:
-            path: (str) Path to the file.
+            path: (str | int) Path to the file or file descriptor.
             mode: (int) Permissions.
             follow_symlinks: If `False` and `path` points to a symlink,
                 the link itself is affected instead of the linked object.
             force_unix_mode: if True and run under Windows, the mode is not
                 adapted for Windows to allow making dirs unreadable
         """
+        allow_fd = not self.is_windows_fs or sys.version_info >= (3, 13)
         file_object = self.resolve(
-            path, follow_symlinks, allow_fd=True, check_owner=True
+            path, follow_symlinks, allow_fd=allow_fd, check_owner=True
         )
         if self.is_windows_fs and not force_unix_mode:
             if mode & helpers.PERM_WRITE:
@@ -1723,7 +1724,7 @@ class FakeFilesystem:
 
     def resolve(
         self,
-        file_path: AnyStr,
+        file_path: Union[AnyStr, int],
         follow_symlinks: bool = True,
         allow_fd: bool = False,
         check_read_perm: bool = True,
@@ -1753,7 +1754,9 @@ class FakeFilesystem:
         """
         if isinstance(file_path, int):
             if allow_fd:
-                return self.get_open_file(file_path).get_object()
+                open_file = self.get_open_file(file_path).get_object()
+                assert isinstance(open_file, FakeFile)
+                return open_file
             raise TypeError("path should be string, bytes or " "os.PathLike, not int")
 
         if follow_symlinks:
diff --git a/pyfakefs/fake_os.py b/pyfakefs/fake_os.py
index c2da5d0..0b4a2cc 100644
--- a/pyfakefs/fake_os.py
+++ b/pyfakefs/fake_os.py
@@ -210,7 +210,7 @@ class FakeOsModule:
             return 0o002
         else:
             # under Unix, we return the real umask;
-            # as there is no pure getter for umask, so we have to first
+            # there is no pure getter for umask, so we have to first
             # set a mode to get the previous one and then re-set that
             mask = os.umask(0)
             os.umask(mask)
@@ -1055,6 +1055,23 @@ class FakeOsModule:
             mode &= ~os.W_OK
         return (mode & ((stat_result.st_mode >> 6) & 7)) == mode
 
+    def fchmod(
+        self,
+        fd: int,
+        mode: int,
+    ) -> None:
+        """Change the permissions of an open file as encoded in integer mode.
+
+        Args:
+            fd: (int) File descriptor.
+            mode: (int) Permissions.
+        """
+        if self.filesystem.is_windows_fs and sys.version_info < (3, 13):
+            raise AttributeError(
+                "module 'os' has no attribute 'fchmod'. " "Did you mean: 'chmod'?"
+            )
+        self.filesystem.chmod(fd, mode)
+
     def chmod(
         self,
         path: AnyStr,


Test patch:
diff --git a/pyfakefs/tests/fake_os_test.py b/pyfakefs/tests/fake_os_test.py
index 3bd03f9..10c608e 100644
--- a/pyfakefs/tests/fake_os_test.py
+++ b/pyfakefs/tests/fake_os_test.py
@@ -2064,16 +2064,37 @@ class FakeOsModuleTest(FakeOsModuleTestBase):
         self.assertFalse(st.st_mode & stat.S_IFDIR)
 
     def test_chmod_uses_open_fd_as_path(self):
-        self.check_posix_only()
+        if sys.version_info < (3, 13):
+            self.check_posix_only()
         self.skip_real_fs()
         self.assert_raises_os_error(errno.EBADF, self.os.chmod, 5, 0o6543)
         path = self.make_path("some_file")
         self.createTestFile(path)
 
         with self.open(path, encoding="utf8") as f:
-            self.os.chmod(f.filedes, 0o6543)
+            st = self.os.stat(f.fileno())
+            # use a mode that will work under Windows
+            self.os.chmod(f.filedes, 0o444)
             st = self.os.stat(path)
-            self.assert_mode_equal(0o6543, st.st_mode)
+            self.assert_mode_equal(0o444, st.st_mode)
+            # fchmod should work the same way
+            self.os.fchmod(f.filedes, 0o666)
+            st = self.os.stat(path)
+            self.assert_mode_equal(0o666, st.st_mode)
+
+    @unittest.skipIf(
+        sys.version_info >= (3, 13), "also available under Windows since Python 3.13"
+    )
+    def test_chmod_uses_open_fd_as_path_not_available_under_windows(self):
+        self.check_windows_only()
+        self.skip_real_fs()
+        path = self.make_path("some_file")
+        self.createTestFile(path)
+        with self.open(path, encoding="utf8") as f:
+            with self.assertRaises(TypeError):
+                self.os.chmod(f.fileno(), 0o666)
+            with self.assertRaises(AttributeError):
+                self.os.fchmod(f.fileno(), 0o666)
 
     def test_chmod_follow_symlink(self):
         self.check_posix_only()