]> granicus.if.org Git - python/commitdiff
Issue #26027, #27524: Add PEP 519/__fspath__() support to os and
authorBrett Cannon <brett@python.org>
Fri, 26 Aug 2016 21:44:48 +0000 (14:44 -0700)
committerBrett Cannon <brett@python.org>
Fri, 26 Aug 2016 21:44:48 +0000 (14:44 -0700)
os.path.

Thanks to Jelle Zijlstra for the initial patch against posixmodule.c.

Lib/genericpath.py
Lib/ntpath.py
Lib/os.py
Lib/posixpath.py
Lib/test/test_genericpath.py
Lib/test/test_ntpath.py
Lib/test/test_os.py
Lib/test/test_posix.py
Lib/test/test_posixpath.py
Misc/NEWS
Modules/posixmodule.c

index 671406197a7f5c67308197141f531ac96b7ecd6b..303b3b349a9f408c901bad8c569244343c125a8e 100644 (file)
@@ -69,6 +69,12 @@ def getctime(filename):
 def commonprefix(m):
     "Given a list of pathnames, returns the longest common leading component"
     if not m: return ''
+    # Some people pass in a list of pathname parts to operate in an OS-agnostic
+    # fashion; don't try to translate in that case as that's an abuse of the
+    # API and they are already doing what they need to be OS-agnostic and so
+    # they most likely won't be using an os.PathLike object in the sublists.
+    if not isinstance(m[0], (list, tuple)):
+        m = tuple(map(os.fspath, m))
     s1 = min(m)
     s2 = max(m)
     for i, c in enumerate(s1):
index af6a7091f9ab9df7fc3b15e8feee5a9e5e75a85b..1fa4448426f890a774f5b2f7dd36066e86309e69 100644 (file)
@@ -46,6 +46,7 @@ def normcase(s):
     """Normalize case of pathname.
 
     Makes all characters lowercase and all slashes into backslashes."""
+    s = os.fspath(s)
     try:
         if isinstance(s, bytes):
             return s.replace(b'/', b'\\').lower()
@@ -66,12 +67,14 @@ def normcase(s):
 
 def isabs(s):
     """Test whether a path is absolute"""
+    s = os.fspath(s)
     s = splitdrive(s)[1]
     return len(s) > 0 and s[0] in _get_bothseps(s)
 
 
 # Join two (or more) paths.
 def join(path, *paths):
+    path = os.fspath(path)
     if isinstance(path, bytes):
         sep = b'\\'
         seps = b'\\/'
@@ -84,7 +87,7 @@ def join(path, *paths):
         if not paths:
             path[:0] + sep  #23780: Ensure compatible data type even if p is null.
         result_drive, result_path = splitdrive(path)
-        for p in paths:
+        for p in map(os.fspath, paths):
             p_drive, p_path = splitdrive(p)
             if p_path and p_path[0] in seps:
                 # Second path is absolute
@@ -136,6 +139,7 @@ def splitdrive(p):
     Paths cannot contain both a drive letter and a UNC path.
 
     """
+    p = os.fspath(p)
     if len(p) >= 2:
         if isinstance(p, bytes):
             sep = b'\\'
@@ -199,7 +203,7 @@ def split(p):
 
     Return tuple (head, tail) where tail is everything after the final slash.
     Either part may be empty."""
-
+    p = os.fspath(p)
     seps = _get_bothseps(p)
     d, p = splitdrive(p)
     # set i to index beyond p's last slash
@@ -218,6 +222,7 @@ def split(p):
 # It is always true that root + ext == p.
 
 def splitext(p):
+    p = os.fspath(p)
     if isinstance(p, bytes):
         return genericpath._splitext(p, b'\\', b'/', b'.')
     else:
@@ -278,6 +283,7 @@ except ImportError:
 def ismount(path):
     """Test whether a path is a mount point (a drive root, the root of a
     share, or a mounted volume)"""
+    path = os.fspath(path)
     seps = _get_bothseps(path)
     path = abspath(path)
     root, rest = splitdrive(path)
@@ -305,6 +311,7 @@ def expanduser(path):
     """Expand ~ and ~user constructs.
 
     If user or $HOME is unknown, do nothing."""
+    path = os.fspath(path)
     if isinstance(path, bytes):
         tilde = b'~'
     else:
@@ -354,6 +361,7 @@ def expandvars(path):
     """Expand shell variables of the forms $var, ${var} and %var%.
 
     Unknown variables are left unchanged."""
+    path = os.fspath(path)
     if isinstance(path, bytes):
         if b'$' not in path and b'%' not in path:
             return path
@@ -464,6 +472,7 @@ def expandvars(path):
 
 def normpath(path):
     """Normalize path, eliminating double slashes, etc."""
+    path = os.fspath(path)
     if isinstance(path, bytes):
         sep = b'\\'
         altsep = b'/'
@@ -518,6 +527,7 @@ try:
 except ImportError: # not running on Windows - mock up something sensible
     def abspath(path):
         """Return the absolute version of a path."""
+        path = os.fspath(path)
         if not isabs(path):
             if isinstance(path, bytes):
                 cwd = os.getcwdb()
@@ -531,6 +541,7 @@ else:  # use native Windows method on Windows
         """Return the absolute version of a path."""
 
         if path: # Empty path must return current working directory.
+            path = os.fspath(path)
             try:
                 path = _getfullpathname(path)
             except OSError:
@@ -549,6 +560,7 @@ supports_unicode_filenames = (hasattr(sys, "getwindowsversion") and
 
 def relpath(path, start=None):
     """Return a relative version of a path"""
+    path = os.fspath(path)
     if isinstance(path, bytes):
         sep = b'\\'
         curdir = b'.'
@@ -564,6 +576,7 @@ def relpath(path, start=None):
     if not path:
         raise ValueError("no path specified")
 
+    start = os.fspath(start)
     try:
         start_abs = abspath(normpath(start))
         path_abs = abspath(normpath(path))
@@ -607,6 +620,7 @@ def commonpath(paths):
     if not paths:
         raise ValueError('commonpath() arg is an empty sequence')
 
+    paths = tuple(map(os.fspath, paths))
     if isinstance(paths[0], bytes):
         sep = b'\\'
         altsep = b'/'
index c31ecb2f059a27c1b4eaaf1bacfc5268fc3fe09c..307a1ded422cf205d4d1a9e6f3d4560b277a6011 100644 (file)
--- a/Lib/os.py
+++ b/Lib/os.py
@@ -353,7 +353,7 @@ def walk(top, topdown=True, onerror=None, followlinks=False):
             dirs.remove('CVS')  # don't visit CVS directories
 
     """
-
+    top = fspath(top)
     dirs = []
     nondirs = []
     walk_dirs = []
@@ -536,6 +536,8 @@ if {open, stat} <= supports_dir_fd and {listdir, stat} <= supports_fd:
             if 'CVS' in dirs:
                 dirs.remove('CVS')  # don't visit CVS directories
         """
+        if not isinstance(top, int) or not hasattr(top, '__index__'):
+            top = fspath(top)
         # Note: To guard against symlink races, we use the standard
         # lstat()/open()/fstat() trick.
         orig_st = stat(top, follow_symlinks=False, dir_fd=dir_fd)
index d9f3f993dae0eb4f44f96157d399d32e2c9e8404..6dbdab27497f2b392484a5a08c3bc25e37cdba74 100644 (file)
@@ -49,6 +49,7 @@ def _get_sep(path):
 
 def normcase(s):
     """Normalize case of pathname.  Has no effect under Posix"""
+    s = os.fspath(s)
     if not isinstance(s, (bytes, str)):
         raise TypeError("normcase() argument must be str or bytes, "
                         "not '{}'".format(s.__class__.__name__))
@@ -60,6 +61,7 @@ def normcase(s):
 
 def isabs(s):
     """Test whether a path is absolute"""
+    s = os.fspath(s)
     sep = _get_sep(s)
     return s.startswith(sep)
 
@@ -73,12 +75,13 @@ def join(a, *p):
     If any component is an absolute path, all previous path components
     will be discarded.  An empty last part will result in a path that
     ends with a separator."""
+    a = os.fspath(a)
     sep = _get_sep(a)
     path = a
     try:
         if not p:
             path[:0] + sep  #23780: Ensure compatible data type even if p is null.
-        for b in p:
+        for b in map(os.fspath, p):
             if b.startswith(sep):
                 path = b
             elif not path or path.endswith(sep):
@@ -99,6 +102,7 @@ def join(a, *p):
 def split(p):
     """Split a pathname.  Returns tuple "(head, tail)" where "tail" is
     everything after the final slash.  Either part may be empty."""
+    p = os.fspath(p)
     sep = _get_sep(p)
     i = p.rfind(sep) + 1
     head, tail = p[:i], p[i:]
@@ -113,6 +117,7 @@ def split(p):
 # It is always true that root + ext == p.
 
 def splitext(p):
+    p = os.fspath(p)
     if isinstance(p, bytes):
         sep = b'/'
         extsep = b'.'
@@ -128,6 +133,7 @@ splitext.__doc__ = genericpath._splitext.__doc__
 def splitdrive(p):
     """Split a pathname into drive and path. On Posix, drive is always
     empty."""
+    p = os.fspath(p)
     return p[:0], p
 
 
@@ -135,6 +141,7 @@ def splitdrive(p):
 
 def basename(p):
     """Returns the final component of a pathname"""
+    p = os.fspath(p)
     sep = _get_sep(p)
     i = p.rfind(sep) + 1
     return p[i:]
@@ -144,6 +151,7 @@ def basename(p):
 
 def dirname(p):
     """Returns the directory component of a pathname"""
+    p = os.fspath(p)
     sep = _get_sep(p)
     i = p.rfind(sep) + 1
     head = p[:i]
@@ -222,6 +230,7 @@ def ismount(path):
 def expanduser(path):
     """Expand ~ and ~user constructions.  If user or $HOME is unknown,
     do nothing."""
+    path = os.fspath(path)
     if isinstance(path, bytes):
         tilde = b'~'
     else:
@@ -267,6 +276,7 @@ _varprogb = None
 def expandvars(path):
     """Expand shell variables of form $var and ${var}.  Unknown variables
     are left unchanged."""
+    path = os.fspath(path)
     global _varprog, _varprogb
     if isinstance(path, bytes):
         if b'$' not in path:
@@ -318,6 +328,7 @@ def expandvars(path):
 
 def normpath(path):
     """Normalize path, eliminating double slashes, etc."""
+    path = os.fspath(path)
     if isinstance(path, bytes):
         sep = b'/'
         empty = b''
@@ -355,6 +366,7 @@ def normpath(path):
 
 def abspath(path):
     """Return an absolute path."""
+    path = os.fspath(path)
     if not isabs(path):
         if isinstance(path, bytes):
             cwd = os.getcwdb()
@@ -370,6 +382,7 @@ def abspath(path):
 def realpath(filename):
     """Return the canonical path of the specified filename, eliminating any
 symbolic links encountered in the path."""
+    filename = os.fspath(filename)
     path, ok = _joinrealpath(filename[:0], filename, {})
     return abspath(path)
 
@@ -434,6 +447,7 @@ def relpath(path, start=None):
     if not path:
         raise ValueError("no path specified")
 
+    path = os.fspath(path)
     if isinstance(path, bytes):
         curdir = b'.'
         sep = b'/'
@@ -445,6 +459,8 @@ def relpath(path, start=None):
 
     if start is None:
         start = curdir
+    else:
+        start = os.fspath(start)
 
     try:
         start_list = [x for x in abspath(start).split(sep) if x]
@@ -472,6 +488,7 @@ def commonpath(paths):
     if not paths:
         raise ValueError('commonpath() arg is an empty sequence')
 
+    paths = tuple(map(os.fspath, paths))
     if isinstance(paths[0], bytes):
         sep = b'/'
         curdir = b'.'
index 5331b241ef2cc302e131c67af0790bc4dcb4d211..c8f158d0c5add3275d2a5463521e402282597b31 100644 (file)
@@ -450,16 +450,15 @@ class CommonTest(GenericTest):
             with self.assertRaisesRegex(TypeError, errmsg):
                 self.pathmodule.join('str', b'bytes')
             # regression, see #15377
-            errmsg = r'join\(\) argument must be str or bytes, not %r'
-            with self.assertRaisesRegex(TypeError, errmsg % 'int'):
+            with self.assertRaisesRegex(TypeError, 'int'):
                 self.pathmodule.join(42, 'str')
-            with self.assertRaisesRegex(TypeError, errmsg % 'int'):
+            with self.assertRaisesRegex(TypeError, 'int'):
                 self.pathmodule.join('str', 42)
-            with self.assertRaisesRegex(TypeError, errmsg % 'int'):
+            with self.assertRaisesRegex(TypeError, 'int'):
                 self.pathmodule.join(42)
-            with self.assertRaisesRegex(TypeError, errmsg % 'list'):
+            with self.assertRaisesRegex(TypeError, 'list'):
                 self.pathmodule.join([])
-            with self.assertRaisesRegex(TypeError, errmsg % 'bytearray'):
+            with self.assertRaisesRegex(TypeError, 'bytearray'):
                 self.pathmodule.join(bytearray(b'foo'), bytearray(b'bar'))
 
     def test_relpath_errors(self):
@@ -471,14 +470,59 @@ class CommonTest(GenericTest):
                 self.pathmodule.relpath(b'bytes', 'str')
             with self.assertRaisesRegex(TypeError, errmsg):
                 self.pathmodule.relpath('str', b'bytes')
-            errmsg = r'relpath\(\) argument must be str or bytes, not %r'
-            with self.assertRaisesRegex(TypeError, errmsg % 'int'):
+            with self.assertRaisesRegex(TypeError, 'int'):
                 self.pathmodule.relpath(42, 'str')
-            with self.assertRaisesRegex(TypeError, errmsg % 'int'):
+            with self.assertRaisesRegex(TypeError, 'int'):
                 self.pathmodule.relpath('str', 42)
-            with self.assertRaisesRegex(TypeError, errmsg % 'bytearray'):
+            with self.assertRaisesRegex(TypeError, 'bytearray'):
                 self.pathmodule.relpath(bytearray(b'foo'), bytearray(b'bar'))
 
 
+class PathLikeTests(unittest.TestCase):
+
+    class PathLike:
+        def __init__(self, path=''):
+            self.path = path
+        def __fspath__(self):
+            if isinstance(self.path, BaseException):
+                raise self.path
+            else:
+                return self.path
+
+    def setUp(self):
+        self.file_name = support.TESTFN.lower()
+        self.file_path = self.PathLike(support.TESTFN)
+        self.addCleanup(support.unlink, self.file_name)
+        create_file(self.file_name, b"test_genericpath.PathLikeTests")
+
+    def assertPathEqual(self, func):
+        self.assertEqual(func(self.file_path), func(self.file_name))
+
+    def test_path_exists(self):
+        self.assertPathEqual(os.path.exists)
+
+    def test_path_isfile(self):
+        self.assertPathEqual(os.path.isfile)
+
+    def test_path_isdir(self):
+        self.assertPathEqual(os.path.isdir)
+
+    def test_path_commonprefix(self):
+        self.assertEqual(os.path.commonprefix([self.file_path, self.file_name]),
+                         self.file_name)
+
+    def test_path_getsize(self):
+        self.assertPathEqual(os.path.getsize)
+
+    def test_path_getmtime(self):
+        self.assertPathEqual(os.path.getatime)
+
+    def test_path_getctime(self):
+        self.assertPathEqual(os.path.getctime)
+
+    def test_path_samefile(self):
+        self.assertTrue(os.path.samefile(self.file_path, self.file_name))
+
+
 if __name__=="__main__":
     unittest.main()
index 580f2030a3dd5f7404b7b56f96352c9a5509f73b..90edb6d0806d2ca699fdcfc9bceedec68eb6bbf0 100644 (file)
@@ -452,5 +452,88 @@ class NtCommonTest(test_genericpath.CommonTest, unittest.TestCase):
     attributes = ['relpath', 'splitunc']
 
 
+class PathLikeTests(unittest.TestCase):
+
+    path = ntpath
+
+    class PathLike:
+        def __init__(self, path=''):
+            self.path = path
+        def __fspath__(self):
+            if isinstance(self.path, BaseException):
+                raise self.path
+            else:
+                return self.path
+
+    def setUp(self):
+        self.file_name = support.TESTFN.lower()
+        self.file_path = self.PathLike(support.TESTFN)
+        self.addCleanup(support.unlink, self.file_name)
+        with open(self.file_name, 'xb', 0) as file:
+            file.write(b"test_ntpath.PathLikeTests")
+
+    def assertPathEqual(self, func):
+        self.assertEqual(func(self.file_path), func(self.file_name))
+
+    def test_path_normcase(self):
+        self.assertPathEqual(self.path.normcase)
+
+    def test_path_isabs(self):
+        self.assertPathEqual(self.path.isabs)
+
+    def test_path_join(self):
+        self.assertEqual(self.path.join('a', self.PathLike('b'), 'c'),
+                         self.path.join('a', 'b', 'c'))
+
+    def test_path_split(self):
+        self.assertPathEqual(self.path.split)
+
+    def test_path_splitext(self):
+        self.assertPathEqual(self.path.splitext)
+
+    def test_path_splitdrive(self):
+        self.assertPathEqual(self.path.splitdrive)
+
+    def test_path_basename(self):
+        self.assertPathEqual(self.path.basename)
+
+    def test_path_dirname(self):
+        self.assertPathEqual(self.path.dirname)
+
+    def test_path_islink(self):
+        self.assertPathEqual(self.path.islink)
+
+    def test_path_lexists(self):
+        self.assertPathEqual(self.path.lexists)
+
+    def test_path_ismount(self):
+        self.assertPathEqual(self.path.ismount)
+
+    def test_path_expanduser(self):
+        self.assertPathEqual(self.path.expanduser)
+
+    def test_path_expandvars(self):
+        self.assertPathEqual(self.path.expandvars)
+
+    def test_path_normpath(self):
+        self.assertPathEqual(self.path.normpath)
+
+    def test_path_abspath(self):
+        self.assertPathEqual(self.path.abspath)
+
+    def test_path_realpath(self):
+        self.assertPathEqual(self.path.realpath)
+
+    def test_path_relpath(self):
+        self.assertPathEqual(self.path.relpath)
+
+    def test_path_commonpath(self):
+        common_path = self.path.commonpath([self.file_path, self.file_name])
+        self.assertEqual(common_path, self.file_name)
+
+    def test_path_isdir(self):
+        self.assertPathEqual(self.path.isdir)
+
+
 if __name__ == "__main__":
     unittest.main()
index d8920d99c514a0d05a9a9af0a58cc25b4268efe5..5ac4d64de288cc91c62a196528411df6083422f2 100644 (file)
@@ -874,10 +874,12 @@ class WalkTests(unittest.TestCase):
         self.assertEqual(all[2 + flipped], (self.sub11_path, [], []))
         self.assertEqual(all[3 - 2 * flipped], self.sub2_tree)
 
-    def test_walk_prune(self):
+    def test_walk_prune(self, walk_path=None):
+        if walk_path is None:
+            walk_path = self.walk_path
         # Prune the search.
         all = []
-        for root, dirs, files in self.walk(self.walk_path):
+        for root, dirs, files in self.walk(walk_path):
             all.append((root, dirs, files))
             # Don't descend into SUB1.
             if 'SUB1' in dirs:
@@ -886,11 +888,22 @@ class WalkTests(unittest.TestCase):
 
         self.assertEqual(len(all), 2)
         self.assertEqual(all[0],
-                         (self.walk_path, ["SUB2"], ["tmp1"]))
+                         (str(walk_path), ["SUB2"], ["tmp1"]))
 
         all[1][-1].sort()
         self.assertEqual(all[1], self.sub2_tree)
 
+    def test_file_like_path(self):
+        class FileLike:
+            def __init__(self, path):
+                self._path = path
+            def __str__(self):
+                return str(self._path)
+            def __fspath__(self):
+                return self._path
+
+        self.test_walk_prune(FileLike(self.walk_path))
+
     def test_walk_bottom_up(self):
         # Walk bottom-up.
         all = list(self.walk(self.walk_path, topdown=False))
@@ -2807,6 +2820,70 @@ class FDInheritanceTests(unittest.TestCase):
         self.assertEqual(os.get_inheritable(slave_fd), False)
 
 
+class PathTConverterTests(unittest.TestCase):
+    # tuples of (function name, allows fd arguments, additional arguments to
+    # function, cleanup function)
+    functions = [
+        ('stat', True, (), None),
+        ('lstat', False, (), None),
+        ('access', True, (os.F_OK,), None),
+        ('chflags', False, (0,), None),
+        ('lchflags', False, (0,), None),
+        ('open', False, (0,), getattr(os, 'close', None)),
+    ]
+
+    def test_path_t_converter(self):
+        class PathLike:
+            def __init__(self, path):
+                self.path = path
+
+            def __fspath__(self):
+                return self.path
+
+        str_filename = support.TESTFN
+        bytes_filename = support.TESTFN.encode('ascii')
+        bytearray_filename = bytearray(bytes_filename)
+        fd = os.open(PathLike(str_filename), os.O_WRONLY|os.O_CREAT)
+        self.addCleanup(os.close, fd)
+        self.addCleanup(support.unlink, support.TESTFN)
+
+        int_fspath = PathLike(fd)
+        str_fspath = PathLike(str_filename)
+        bytes_fspath = PathLike(bytes_filename)
+        bytearray_fspath = PathLike(bytearray_filename)
+
+        for name, allow_fd, extra_args, cleanup_fn in self.functions:
+            with self.subTest(name=name):
+                try:
+                    fn = getattr(os, name)
+                except AttributeError:
+                    continue
+
+                for path in (str_filename, bytes_filename, bytearray_filename,
+                             str_fspath, bytes_fspath):
+                    with self.subTest(name=name, path=path):
+                        result = fn(path, *extra_args)
+                        if cleanup_fn is not None:
+                            cleanup_fn(result)
+
+                with self.assertRaisesRegex(
+                        TypeError, 'should be string, bytes'):
+                    fn(int_fspath, *extra_args)
+                with self.assertRaisesRegex(
+                        TypeError, 'should be string, bytes'):
+                    fn(bytearray_fspath, *extra_args)
+
+                if allow_fd:
+                    result = fn(fd, *extra_args)  # should not fail
+                    if cleanup_fn is not None:
+                        cleanup_fn(result)
+                else:
+                    with self.assertRaisesRegex(
+                            TypeError,
+                            'os.PathLike'):
+                        fn(fd, *extra_args)
+
+
 @unittest.skipUnless(hasattr(os, 'get_blocking'),
                      'needs os.get_blocking() and os.set_blocking()')
 class BlockingTests(unittest.TestCase):
index de22513e34e782021f000e4a17e551ebd3a5c163..d2f58baae6b6d2b89e1f64c36adfeaa4cd53106f 100644 (file)
@@ -397,7 +397,7 @@ class PosixTester(unittest.TestCase):
             self.assertTrue(posix.stat(fp.fileno()))
 
             self.assertRaisesRegex(TypeError,
-                    'should be string, bytes or integer, not',
+                    'should be string, bytes, os.PathLike or integer, not',
                     posix.stat, float(fp.fileno()))
         finally:
             fp.close()
@@ -409,16 +409,16 @@ class PosixTester(unittest.TestCase):
         self.assertTrue(posix.stat(os.fsencode(support.TESTFN)))
 
         self.assertWarnsRegex(DeprecationWarning,
-                'should be string, bytes or integer, not',
+                'should be string, bytes, os.PathLike or integer, not',
                 posix.stat, bytearray(os.fsencode(support.TESTFN)))
         self.assertRaisesRegex(TypeError,
-                'should be string, bytes or integer, not',
+                'should be string, bytes, os.PathLike or integer, not',
                 posix.stat, None)
         self.assertRaisesRegex(TypeError,
-                'should be string, bytes or integer, not',
+                'should be string, bytes, os.PathLike or integer, not',
                 posix.stat, list(support.TESTFN))
         self.assertRaisesRegex(TypeError,
-                'should be string, bytes or integer, not',
+                'should be string, bytes, os.PathLike or integer, not',
                 posix.stat, list(os.fsencode(support.TESTFN)))
 
     @unittest.skipUnless(hasattr(posix, 'mkfifo'), "don't have mkfifo()")
index 0783c36b9f0225f65f8c11a3626df707728abe7a..8a1e33b0c89997c2d7ed98b4aa35463724fbcf82 100644 (file)
@@ -596,5 +596,85 @@ class PosixCommonTest(test_genericpath.CommonTest, unittest.TestCase):
     attributes = ['relpath', 'samefile', 'sameopenfile', 'samestat']
 
 
+class PathLikeTests(unittest.TestCase):
+
+    path = posixpath
+
+    class PathLike:
+        def __init__(self, path=''):
+            self.path = path
+        def __fspath__(self):
+            if isinstance(self.path, BaseException):
+                raise self.path
+            else:
+                return self.path
+
+    def setUp(self):
+        self.file_name = support.TESTFN.lower()
+        self.file_path = self.PathLike(support.TESTFN)
+        self.addCleanup(support.unlink, self.file_name)
+        with open(self.file_name, 'xb', 0) as file:
+            file.write(b"test_posixpath.PathLikeTests")
+
+    def assertPathEqual(self, func):
+        self.assertEqual(func(self.file_path), func(self.file_name))
+
+    def test_path_normcase(self):
+        self.assertPathEqual(self.path.normcase)
+
+    def test_path_isabs(self):
+        self.assertPathEqual(self.path.isabs)
+
+    def test_path_join(self):
+        self.assertEqual(self.path.join('a', self.PathLike('b'), 'c'),
+                         self.path.join('a', 'b', 'c'))
+
+    def test_path_split(self):
+        self.assertPathEqual(self.path.split)
+
+    def test_path_splitext(self):
+        self.assertPathEqual(self.path.splitext)
+
+    def test_path_splitdrive(self):
+        self.assertPathEqual(self.path.splitdrive)
+
+    def test_path_basename(self):
+        self.assertPathEqual(self.path.basename)
+
+    def test_path_dirname(self):
+        self.assertPathEqual(self.path.dirname)
+
+    def test_path_islink(self):
+        self.assertPathEqual(self.path.islink)
+
+    def test_path_lexists(self):
+        self.assertPathEqual(self.path.lexists)
+
+    def test_path_ismount(self):
+        self.assertPathEqual(self.path.ismount)
+
+    def test_path_expanduser(self):
+        self.assertPathEqual(self.path.expanduser)
+
+    def test_path_expandvars(self):
+        self.assertPathEqual(self.path.expandvars)
+
+    def test_path_normpath(self):
+        self.assertPathEqual(self.path.normpath)
+
+    def test_path_abspath(self):
+        self.assertPathEqual(self.path.abspath)
+
+    def test_path_realpath(self):
+        self.assertPathEqual(self.path.realpath)
+
+    def test_path_relpath(self):
+        self.assertPathEqual(self.path.relpath)
+
+    def test_path_commonpath(self):
+        common_path = self.path.commonpath([self.file_path, self.file_name])
+        self.assertEqual(common_path, self.file_name)
+
+
 if __name__=="__main__":
     unittest.main()
index 43606f3e2dbed83cb7bf16b7ec36f9492fb736d0..d5eca5b786c1d8e67c0b861f322d925ad2b7e033 100644 (file)
--- a/Misc/NEWS
+++ b/Misc/NEWS
@@ -142,7 +142,10 @@ Core and Builtins
 Library
 -------
 
-- Issue 27598: Add Collections to collections.abc.
+- Issue #26027, #27524: Add PEP 519/__fspath__() support to the os and os.path
+  modules. Includes code from Jelle Zijlstra.
+
+- Issue #27598: Add Collections to collections.abc.
   Patch by Ivan Levkivskyi, docs by Neil Girdhar.
 
 - Issue #25958: Support "anti-registration" of special methods from
index 10d6bcbba9779b642159b202ed1048dd06e2c398..f9693e8d579a3401e9266171a0c1d8e2e9513ec1 100644 (file)
@@ -834,8 +834,11 @@ static int
 path_converter(PyObject *o, void *p)
 {
     path_t *path = (path_t *)p;
-    PyObject *bytes;
+    PyObject *bytes, *to_cleanup = NULL;
     Py_ssize_t length;
+    int is_index, is_buffer, is_bytes, is_unicode;
+    /* Default to failure, forcing explicit signaling of succcess. */
+    int ret = 0;
     const char *narrow;
 
 #define FORMAT_EXCEPTION(exc, fmt) \
@@ -850,7 +853,7 @@ path_converter(PyObject *o, void *p)
         return 1;
     }
 
-    /* ensure it's always safe to call path_cleanup() */
+    /* Ensure it's always safe to call path_cleanup(). */
     path->cleanup = NULL;
 
     if ((o == Py_None) && path->nullable) {
@@ -862,21 +865,54 @@ path_converter(PyObject *o, void *p)
         return 1;
     }
 
-    if (PyUnicode_Check(o)) {
+    /* Only call this here so that we don't treat the return value of
+       os.fspath() as an fd or buffer. */
+    is_index = path->allow_fd && PyIndex_Check(o);
+    is_buffer = PyObject_CheckBuffer(o);
+    is_bytes = PyBytes_Check(o);
+    is_unicode = PyUnicode_Check(o);
+
+    if (!is_index && !is_buffer && !is_unicode && !is_bytes) {
+        /* Inline PyOS_FSPath() for better error messages. */
+        _Py_IDENTIFIER(__fspath__);
+        PyObject *func = NULL;
+
+        func = _PyObject_LookupSpecial(o, &PyId___fspath__);
+        if (NULL == func) {
+            goto error_exit;
+        }
+
+        o = to_cleanup = PyObject_CallFunctionObjArgs(func, NULL);
+        Py_DECREF(func);
+        if (NULL == o) {
+            goto error_exit;
+        }
+        else if (PyUnicode_Check(o)) {
+            is_unicode = 1;
+        }
+        else if (PyBytes_Check(o)) {
+            is_bytes = 1;
+        }
+        else {
+            goto error_exit;
+        }
+    }
+
+    if (is_unicode) {
 #ifdef MS_WINDOWS
         const wchar_t *wide;
 
         wide = PyUnicode_AsUnicodeAndSize(o, &length);
         if (!wide) {
-            return 0;
+            goto exit;
         }
         if (length > 32767) {
             FORMAT_EXCEPTION(PyExc_ValueError, "%s too long for Windows");
-            return 0;
+            goto exit;
         }
         if (wcslen(wide) != length) {
             FORMAT_EXCEPTION(PyExc_ValueError, "embedded null character in %s");
-            return 0;
+            goto exit;
         }
 
         path->wide = wide;
@@ -884,66 +920,71 @@ path_converter(PyObject *o, void *p)
         path->length = length;
         path->object = o;
         path->fd = -1;
-        return 1;
+        ret = 1;
+        goto exit;
 #else
         if (!PyUnicode_FSConverter(o, &bytes)) {
-            return 0;
+            goto exit;
         }
 #endif
     }
-    else if (PyBytes_Check(o)) {
+    else if (is_bytes) {
 #ifdef MS_WINDOWS
         if (win32_warn_bytes_api()) {
-            return 0;
+            goto exit;
         }
 #endif
         bytes = o;
         Py_INCREF(bytes);
     }
-    else if (PyObject_CheckBuffer(o)) {
+    else if (is_buffer) {
         if (PyErr_WarnFormat(PyExc_DeprecationWarning, 1,
             "%s%s%s should be %s, not %.200s",
             path->function_name ? path->function_name : "",
             path->function_name ? ": "                : "",
             path->argument_name ? path->argument_name : "path",
-            path->allow_fd && path->nullable ? "string, bytes, integer or None" :
-            path->allow_fd ? "string, bytes or integer" :
-            path->nullable ? "string, bytes or None" :
-                             "string or bytes",
+            path->allow_fd && path->nullable ? "string, bytes, os.PathLike, "
+                                               "integer or None" :
+            path->allow_fd ? "string, bytes, os.PathLike or integer" :
+            path->nullable ? "string, bytes, os.PathLike or None" :
+                             "string, bytes or os.PathLike",
             Py_TYPE(o)->tp_name)) {
-            return 0;
+            goto exit;
         }
 #ifdef MS_WINDOWS
         if (win32_warn_bytes_api()) {
-            return 0;
+            goto exit;
         }
 #endif
         bytes = PyBytes_FromObject(o);
         if (!bytes) {
-            return 0;
+            goto exit;
         }
     }
     else if (path->allow_fd && PyIndex_Check(o)) {
         if (!_fd_converter(o, &path->fd)) {
-            return 0;
+            goto exit;
         }
         path->wide = NULL;
         path->narrow = NULL;
         path->length = 0;
         path->object = o;
-        return 1;
+        ret = 1;
+        goto exit;
     }
     else {
+ error_exit:
         PyErr_Format(PyExc_TypeError, "%s%s%s should be %s, not %.200s",
             path->function_name ? path->function_name : "",
             path->function_name ? ": "                : "",
             path->argument_name ? path->argument_name : "path",
-            path->allow_fd && path->nullable ? "string, bytes, integer or None" :
-            path->allow_fd ? "string, bytes or integer" :
-            path->nullable ? "string, bytes or None" :
-                             "string or bytes",
+            path->allow_fd && path->nullable ? "string, bytes, os.PathLike, "
+                                               "integer or None" :
+            path->allow_fd ? "string, bytes, os.PathLike or integer" :
+            path->nullable ? "string, bytes, os.PathLike or None" :
+                             "string, bytes or os.PathLike",
             Py_TYPE(o)->tp_name);
-        return 0;
+        goto exit;
     }
 
     length = PyBytes_GET_SIZE(bytes);
@@ -951,7 +992,7 @@ path_converter(PyObject *o, void *p)
     if (length > MAX_PATH-1) {
         FORMAT_EXCEPTION(PyExc_ValueError, "%s too long for Windows");
         Py_DECREF(bytes);
-        return 0;
+        goto exit;
     }
 #endif
 
@@ -959,7 +1000,7 @@ path_converter(PyObject *o, void *p)
     if ((size_t)length != strlen(narrow)) {
         FORMAT_EXCEPTION(PyExc_ValueError, "embedded null character in %s");
         Py_DECREF(bytes);
-        return 0;
+        goto exit;
     }
 
     path->wide = NULL;
@@ -969,12 +1010,15 @@ path_converter(PyObject *o, void *p)
     path->fd = -1;
     if (bytes == o) {
         Py_DECREF(bytes);
-        return 1;
+        ret = 1;
     }
     else {
         path->cleanup = bytes;
-        return Py_CLEANUP_SUPPORTED;
+        ret = Py_CLEANUP_SUPPORTED;
     }
+ exit:
+    Py_XDECREF(to_cleanup);
+    return ret;
 }
 
 static void
@@ -12329,6 +12373,8 @@ error:
 PyObject *
 PyOS_FSPath(PyObject *path)
 {
+    /* For error message reasons, this function is manually inlined in
+       path_converter(). */
     _Py_IDENTIFIER(__fspath__);
     PyObject *func = NULL;
     PyObject *path_repr = NULL;