Repository: jmcgeheeiv/pyfakefs
Pull request #598 (2021-05-16 19:19:09)

Add support for some Python 3.10 changes
- new strict argument for os.path.realpath()
- new follow_symlinks argument for pathlib.Path.chmod()
- new method pathlib.Path.hardlink_to
- new 'newline' argument in pathlib.Path.write_text()


Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 4fbf355..88b2c42 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -3,6 +3,14 @@ The released versions correspond to PyPi releases.
 
 ## Version 4.5.0 (as yet unreleased)
 
+### New Features
+  * added support for some Python 3.10 features:
+    * new method `pathlib.Path.hardlink_to` 
+    * new `newline` argument in `pathlib.Path.write_text`
+    * new `follow_symlinks` argument in `pathlib.Path.stat` and
+     `pathlib.Path.chmod`
+    * new 'strict' argument in `os.path.realpath`  
+
 ### Changes
   * `pathlib2` is still supported, but considered to have the same
      functionality as `pathlib` and is no longer tested separately;
diff --git a/README.md b/README.md
index 135a7a4..0bea2fe 100644
--- a/README.md
+++ b/README.md
@@ -60,7 +60,7 @@ For example, pyfakefs will not work with [`lxml`](http://lxml.de/).  In this cas
 ### Continuous integration
 
 pyfakefs is currently automatically tested on Linux, MacOS and Windows, with
-Python 3.6 to 3.9, and with PyPy3 on Linux, using
+Python 3.6 to 3.10, and with PyPy3 on Linux, using
 [GitHub Actions](https://github.com/jmcgeheeiv/pyfakefs/actions).
 
 ### Running pyfakefs unit tests
diff --git a/pyfakefs/fake_filesystem.py b/pyfakefs/fake_filesystem.py
index 971dc93..3765607 100644
--- a/pyfakefs/fake_filesystem.py
+++ b/pyfakefs/fake_filesystem.py
@@ -113,8 +113,8 @@ from pyfakefs.extra_packages import use_scandir
 from pyfakefs.fake_scandir import scandir, walk
 from pyfakefs.helpers import (
     FakeStatResult, BinaryBufferIO, TextBufferIO,
-    is_int_type, is_byte_string, is_unicode_string,
-    make_string_path, IS_WIN, to_string, matching_string, real_encoding
+    is_int_type, is_byte_string, is_unicode_string, make_string_path,
+    IS_WIN, IS_PYPY, to_string, matching_string, real_encoding
 )
 from pyfakefs import __version__  # noqa: F401 for upwards compatibility
 
@@ -3399,15 +3399,22 @@ class FakePathModule:
         path = self._os_path.relpath(path, start)
         return path.replace(self._os_path.sep, self.filesystem.path_separator)
 
-    def realpath(self, filename):
+    def realpath(self, filename, strict=None):
         """Return the canonical path of the specified filename, eliminating any
         symbolic links encountered in the path.
         """
+        if strict is not None and sys.version_info < (3, 10):
+            raise TypeError("realpath() got an unexpected "
+                            "keyword argument 'strict'")
+        if strict:
+            # raises in strict mode if the file does not exist
+            self.filesystem.resolve(filename)
         if self.filesystem.is_windows_fs:
             return self.abspath(filename)
         filename = make_string_path(filename)
         path, ok = self._joinrealpath(filename[:0], filename, {})
-        return self.abspath(path)
+        path = self.abspath(path)
+        return path
 
     def samefile(self, path1, path2):
         """Return whether path1 and path2 point to the same file.
@@ -4336,6 +4343,11 @@ class FakeOsModule:
             follow_symlinks: (bool) If `False` and `path` points to a symlink,
                 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)):
+            raise NotImplementedError(
+                "`follow_symlinks` for chmod() is not available "
+                "on this system")
         path = self._path_with_dir_fd(path, self.chmod, dir_fd)
         self.filesystem.chmod(path, mode, follow_symlinks)
 
diff --git a/pyfakefs/fake_pathlib.py b/pyfakefs/fake_pathlib.py
index 09933fa..1c6a960 100644
--- a/pyfakefs/fake_pathlib.py
+++ b/pyfakefs/fake_pathlib.py
@@ -97,19 +97,26 @@ class _FakeAccessor(accessor):
     if use_scandir:
         scandir = _wrap_strfunc(fake_scandir.scandir)
 
+    chmod = _wrap_strfunc(FakeFilesystem.chmod)
+
     if hasattr(os, "lchmod"):
         lchmod = _wrap_strfunc(lambda fs, path, mode: FakeFilesystem.chmod(
             fs, path, mode, follow_symlinks=False))
-        chmod = _wrap_strfunc(FakeFilesystem.chmod)
     else:
         def lchmod(self, pathobj,  *args, **kwargs):
             """Raises not implemented for Windows systems."""
             raise NotImplementedError("lchmod() not available on this system")
 
         def chmod(self, pathobj, *args, **kwargs):
-            if "follow_symlinks" in kwargs and not kwargs["follow_symlinks"]:
-                raise NotImplementedError(
-                    "lchmod() not available on this system")
+            if "follow_symlinks" in kwargs:
+                if sys.version_info < (3, 10):
+                    raise TypeError("chmod() got an unexpected keyword "
+                                    "argument 'follow_synlinks'")
+                if (not kwargs["follow_symlinks"] and
+                        os.chmod not in os.supports_follow_symlinks):
+                    raise NotImplementedError(
+                        "`follow_symlinks` for chmod() is not available "
+                        "on this system")
             return pathobj.filesystem.chmod(str(pathobj), *args, **kwargs)
 
     mkdir = _wrap_strfunc(FakeFilesystem.makedir)
@@ -129,7 +136,7 @@ class _FakeAccessor(accessor):
         FakeFilesystem.create_symlink(fs, file_path, link_target,
                                       create_missing_dirs=False))
 
-    if (3, 8) <= sys.version_info < (3, 10):
+    if (3, 8) <= sys.version_info:
         link_to = _wrap_binary_strfunc(
             lambda fs, file_path, link_target:
             FakeFilesystem.link(fs, file_path, link_target))
@@ -592,7 +599,7 @@ class FakePath(pathlib.Path):
         with FakeFileOpen(self.filesystem)(self._path(), mode='wb') as f:
             return f.write(view)
 
-    def write_text(self, data, encoding=None, errors=None):
+    def write_text(self, data, encoding=None, errors=None, newline=None):
         """Open the fake file in text mode, write to it, and close
         the file.
 
@@ -600,7 +607,9 @@ class FakePath(pathlib.Path):
             data: the string to be written
             encoding: the encoding used for the string; if not given, the
                 default locale encoding is used
-            errors: ignored
+            errors: (str) Defines how encoding errors are handled.
+            newline: Controls universal newlines, passed to stream object.
+                New in Python 3.10.
         Raises:
             TypeError: if data is not of type 'str'.
             OSError: if the target object is a directory, the path is
@@ -609,10 +618,14 @@ class FakePath(pathlib.Path):
         if not isinstance(data, str):
             raise TypeError('data must be str, not %s' %
                             data.__class__.__name__)
+        if newline is not None and sys.version_info < (3, 10):
+            raise TypeError("write_text() got an unexpected "
+                            "keyword argument 'newline'")
         with FakeFileOpen(self.filesystem)(self._path(),
                                            mode='w',
                                            encoding=encoding,
-                                           errors=errors) as f:
+                                           errors=errors,
+                                           newline=newline) as f:
             return f.write(data)
 
     @classmethod
diff --git a/setup.py b/setup.py
index b52bacb..a6a1508 100644
--- a/setup.py
+++ b/setup.py
@@ -42,6 +42,7 @@ CLASSIFIERS = [
     'Programming Language :: Python :: 3.7',
     'Programming Language :: Python :: 3.8',
     'Programming Language :: Python :: 3.9',
+    'Programming Language :: Python :: 3.10',
     'Programming Language :: Python :: Implementation :: CPython',
     'Programming Language :: Python :: Implementation :: PyPy',
     'Operating System :: POSIX',


Test patch:
diff --git a/pyfakefs/tests/fake_filesystem_test.py b/pyfakefs/tests/fake_filesystem_test.py
index adbd50d..d5dd5c3 100644
--- a/pyfakefs/tests/fake_filesystem_test.py
+++ b/pyfakefs/tests/fake_filesystem_test.py
@@ -952,6 +952,18 @@ class FakePathModuleTest(TestCase):
         self.assertEqual('!george!washington!bridge',
                          self.os.path.realpath('bridge'))
 
+    @unittest.skipIf(sys.version_info < (3, 10), "'strict' new in Python 3.10")
+    def test_realpath_strict(self):
+        self.filesystem.create_file('!foo!bar')
+        self.filesystem.cwd = '!foo'
+        self.assertEqual('!foo!baz',
+                         self.os.path.realpath('baz', strict=False))
+        self.assert_raises_os_error(errno.ENOENT,
+                                    self.os.path.realpath,
+                                    'baz', strict=True)
+        self.assertEqual('!foo!bar',
+                         self.os.path.realpath('bar', strict=True))
+
     def test_samefile(self):
         file_path1 = '!foo!bar!baz'
         file_path2 = '!foo!bar!boo'
diff --git a/pyfakefs/tests/fake_os_test.py b/pyfakefs/tests/fake_os_test.py
index c0bd9a9..de5bda1 100644
--- a/pyfakefs/tests/fake_os_test.py
+++ b/pyfakefs/tests/fake_os_test.py
@@ -23,7 +23,7 @@ import sys
 import time
 import unittest
 
-from pyfakefs.helpers import IN_DOCKER
+from pyfakefs.helpers import IN_DOCKER, IS_PYPY
 
 from pyfakefs import fake_filesystem
 from pyfakefs.fake_filesystem import FakeFileOpen, is_root
@@ -1934,8 +1934,6 @@ class FakeOsModuleTest(FakeOsModuleTestBase):
 
     def test_chmod_follow_symlink(self):
         self.check_posix_only()
-        if self.use_real_fs() and 'chmod' not in os.supports_follow_symlinks:
-            raise unittest.SkipTest('follow_symlinks not available')
         path = self.make_path('some_file')
         self.createTestFile(path)
         link_path = self.make_path('link_to_some_file')
@@ -1945,22 +1943,24 @@ class FakeOsModuleTest(FakeOsModuleTestBase):
         st = self.os.stat(link_path)
         self.assert_mode_equal(0o6543, st.st_mode)
         st = self.os.stat(link_path, follow_symlinks=False)
-        self.assert_mode_equal(0o777, st.st_mode)
+        # the exact mode depends on OS and Python version
+        self.assertEqual(stat.S_IMODE(0o700), stat.S_IMODE(st.st_mode) & 0o700)
 
     def test_chmod_no_follow_symlink(self):
         self.check_posix_only()
-        if self.use_real_fs() and 'chmod' not in os.supports_follow_symlinks:
-            raise unittest.SkipTest('follow_symlinks not available')
         path = self.make_path('some_file')
         self.createTestFile(path)
         link_path = self.make_path('link_to_some_file')
         self.create_symlink(link_path, path)
-        self.os.chmod(link_path, 0o6543, follow_symlinks=False)
-
-        st = self.os.stat(link_path)
-        self.assert_mode_equal(0o666, st.st_mode)
-        st = self.os.stat(link_path, follow_symlinks=False)
-        self.assert_mode_equal(0o6543, st.st_mode)
+        if os.chmod not in os.supports_follow_symlinks or IS_PYPY:
+            with self.assertRaises(NotImplementedError):
+                self.os.chmod(link_path, 0o6543, follow_symlinks=False)
+        else:
+            self.os.chmod(link_path, 0o6543, follow_symlinks=False)
+            st = self.os.stat(link_path)
+            self.assert_mode_equal(0o666, st.st_mode)
+            st = self.os.stat(link_path, follow_symlinks=False)
+            self.assert_mode_equal(0o6543, st.st_mode)
 
     def test_lchmod(self):
         """lchmod shall behave like chmod with follow_symlinks=True."""
diff --git a/pyfakefs/tests/fake_pathlib_test.py b/pyfakefs/tests/fake_pathlib_test.py
index efea509..ce0ea48 100644
--- a/pyfakefs/tests/fake_pathlib_test.py
+++ b/pyfakefs/tests/fake_pathlib_test.py
@@ -30,6 +30,7 @@ import unittest
 from pyfakefs.fake_filesystem import is_root
 
 from pyfakefs import fake_pathlib, fake_filesystem
+from pyfakefs.helpers import IS_PYPY
 from pyfakefs.tests.test_utils import RealFsTestCase
 
 is_windows = sys.platform == 'win32'
@@ -380,9 +381,6 @@ class FakePathlibFileObjectPropertyTest(RealPathlibTestCase):
 
     def test_lchmod(self):
         self.skip_if_symlink_not_supported()
-        if (sys.version_info >= (3, 10) and self.use_real_fs() and
-                'chmod' not in os.supports_follow_symlinks):
-            raise unittest.SkipTest('follow_symlinks not available for chmod')
         file_stat = self.os.stat(self.file_path)
         link_stat = self.os.lstat(self.file_link_path)
         if not hasattr(os, "lchmod"):
@@ -395,6 +393,23 @@ class FakePathlibFileObjectPropertyTest(RealPathlibTestCase):
             self.assertEqual(link_stat.st_mode & 0o777700,
                              stat.S_IFLNK | 0o700)
 
+    @unittest.skipIf(sys.version_info < (3, 10),
+                     "follow_symlinks argument new in Python 3.10")
+    def test_chmod_no_followsymlinks(self):
+        self.skip_if_symlink_not_supported()
+        file_stat = self.os.stat(self.file_path)
+        link_stat = self.os.lstat(self.file_link_path)
+        if os.chmod not in os.supports_follow_symlinks or IS_PYPY:
+            with self.assertRaises(NotImplementedError):
+                self.path(self.file_link_path).chmod(0o444,
+                                                     follow_symlinks=False)
+        else:
+            self.path(self.file_link_path).chmod(0o444, follow_symlinks=False)
+            self.assertEqual(file_stat.st_mode, stat.S_IFREG | 0o666)
+            # the exact mode depends on OS and Python version
+            self.assertEqual(link_stat.st_mode & 0o777700,
+                             stat.S_IFLNK | 0o700)
+
     def test_resolve(self):
         self.create_dir(self.make_path('antoine', 'docs'))
         self.create_file(self.make_path('antoine', 'setup.py'))
@@ -526,6 +541,19 @@ class FakePathlibPathFileOperationTest(RealPathlibTestCase):
         self.assertTrue(self.os.path.exists(path_name))
         self.check_contents(path_name, 'ανοησίες'.encode('greek'))
 
+    @unittest.skipIf(sys.version_info < (3, 10),
+                     "newline argument new in Python 3.10")
+    def test_write_with_newline_arg(self):
+        path = self.path(self.make_path('some_file'))
+        path.write_text('1\r\n2\n3\r4', newline='')
+        self.check_contents(path, b'1\r\n2\n3\r4')
+        path.write_text('1\r\n2\n3\r4', newline='\n')
+        self.check_contents(path, b'1\r\n2\n3\r4')
+        path.write_text('1\r\n2\n3\r4', newline='\r\n')
+        self.check_contents(path, b'1\r\r\n2\r\n3\r4')
+        path.write_text('1\r\n2\n3\r4', newline='\r')
+        self.check_contents(path, b'1\r\r2\r3\r4')
+
     def test_read_bytes(self):
         path_name = self.make_path('binary_file')
         self.create_file(path_name, contents=b'Binary file contents')
@@ -627,6 +655,20 @@ class FakePathlibPathFileOperationTest(RealPathlibTestCase):
         self.assertFalse(path.is_symlink())
         self.assertEqual(2, self.os.stat(file_name).st_nlink)
 
+    @unittest.skipIf(sys.version_info < (3, 10),
+                     'hardlink_to new in Python 3.10')
+    def test_hardlink_to(self):
+        self.skip_if_symlink_not_supported()
+        file_name = self.make_path('foo', 'bar.txt')
+        self.create_file(file_name)
+        self.assertEqual(1, self.os.stat(file_name).st_nlink)
+        link_path = self.path(self.make_path('link_to_bar'))
+        path = self.path(file_name)
+        link_path.hardlink_to(path)
+        self.assertTrue(self.os.path.exists(link_path))
+        self.assertFalse(path.is_symlink())
+        self.assertEqual(2, self.os.stat(file_name).st_nlink)
+
     @unittest.skipIf(sys.version_info < (3, 9),
                      'readlink new in Python 3.9')
     def test_readlink(self):