Skip to content

Commit 3f9183b

Browse files
committed
Issue #26027, #27524: Add PEP 519/__fspath__() support to os and
os.path. Thanks to Jelle Zijlstra for the initial patch against posixmodule.c.
1 parent 6ed442c commit 3f9183b

File tree

11 files changed

+424
-52
lines changed

11 files changed

+424
-52
lines changed

Lib/genericpath.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ def getctime(filename):
6969
def commonprefix(m):
7070
"Given a list of pathnames, returns the longest common leading component"
7171
if not m: return ''
72+
# Some people pass in a list of pathname parts to operate in an OS-agnostic
73+
# fashion; don't try to translate in that case as that's an abuse of the
74+
# API and they are already doing what they need to be OS-agnostic and so
75+
# they most likely won't be using an os.PathLike object in the sublists.
76+
if not isinstance(m[0], (list, tuple)):
77+
m = tuple(map(os.fspath, m))
7278
s1 = min(m)
7379
s2 = max(m)
7480
for i, c in enumerate(s1):

Lib/ntpath.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def normcase(s):
4646
"""Normalize case of pathname.
4747
4848
Makes all characters lowercase and all slashes into backslashes."""
49+
s = os.fspath(s)
4950
try:
5051
if isinstance(s, bytes):
5152
return s.replace(b'/', b'\\').lower()
@@ -66,12 +67,14 @@ def normcase(s):
6667

6768
def isabs(s):
6869
"""Test whether a path is absolute"""
70+
s = os.fspath(s)
6971
s = splitdrive(s)[1]
7072
return len(s) > 0 and s[0] in _get_bothseps(s)
7173

7274

7375
# Join two (or more) paths.
7476
def join(path, *paths):
77+
path = os.fspath(path)
7578
if isinstance(path, bytes):
7679
sep = b'\\'
7780
seps = b'\\/'
@@ -84,7 +87,7 @@ def join(path, *paths):
8487
if not paths:
8588
path[:0] + sep #23780: Ensure compatible data type even if p is null.
8689
result_drive, result_path = splitdrive(path)
87-
for p in paths:
90+
for p in map(os.fspath, paths):
8891
p_drive, p_path = splitdrive(p)
8992
if p_path and p_path[0] in seps:
9093
# Second path is absolute
@@ -136,6 +139,7 @@ def splitdrive(p):
136139
Paths cannot contain both a drive letter and a UNC path.
137140
138141
"""
142+
p = os.fspath(p)
139143
if len(p) >= 2:
140144
if isinstance(p, bytes):
141145
sep = b'\\'
@@ -199,7 +203,7 @@ def split(p):
199203
200204
Return tuple (head, tail) where tail is everything after the final slash.
201205
Either part may be empty."""
202-
206+
p = os.fspath(p)
203207
seps = _get_bothseps(p)
204208
d, p = splitdrive(p)
205209
# set i to index beyond p's last slash
@@ -218,6 +222,7 @@ def split(p):
218222
# It is always true that root + ext == p.
219223

220224
def splitext(p):
225+
p = os.fspath(p)
221226
if isinstance(p, bytes):
222227
return genericpath._splitext(p, b'\\', b'/', b'.')
223228
else:
@@ -278,6 +283,7 @@ def lexists(path):
278283
def ismount(path):
279284
"""Test whether a path is a mount point (a drive root, the root of a
280285
share, or a mounted volume)"""
286+
path = os.fspath(path)
281287
seps = _get_bothseps(path)
282288
path = abspath(path)
283289
root, rest = splitdrive(path)
@@ -305,6 +311,7 @@ def expanduser(path):
305311
"""Expand ~ and ~user constructs.
306312
307313
If user or $HOME is unknown, do nothing."""
314+
path = os.fspath(path)
308315
if isinstance(path, bytes):
309316
tilde = b'~'
310317
else:
@@ -354,6 +361,7 @@ def expandvars(path):
354361
"""Expand shell variables of the forms $var, ${var} and %var%.
355362
356363
Unknown variables are left unchanged."""
364+
path = os.fspath(path)
357365
if isinstance(path, bytes):
358366
if b'$' not in path and b'%' not in path:
359367
return path
@@ -464,6 +472,7 @@ def expandvars(path):
464472

465473
def normpath(path):
466474
"""Normalize path, eliminating double slashes, etc."""
475+
path = os.fspath(path)
467476
if isinstance(path, bytes):
468477
sep = b'\\'
469478
altsep = b'/'
@@ -518,6 +527,7 @@ def normpath(path):
518527
except ImportError: # not running on Windows - mock up something sensible
519528
def abspath(path):
520529
"""Return the absolute version of a path."""
530+
path = os.fspath(path)
521531
if not isabs(path):
522532
if isinstance(path, bytes):
523533
cwd = os.getcwdb()
@@ -531,6 +541,7 @@ def abspath(path):
531541
"""Return the absolute version of a path."""
532542

533543
if path: # Empty path must return current working directory.
544+
path = os.fspath(path)
534545
try:
535546
path = _getfullpathname(path)
536547
except OSError:
@@ -549,6 +560,7 @@ def abspath(path):
549560

550561
def relpath(path, start=None):
551562
"""Return a relative version of a path"""
563+
path = os.fspath(path)
552564
if isinstance(path, bytes):
553565
sep = b'\\'
554566
curdir = b'.'
@@ -564,6 +576,7 @@ def relpath(path, start=None):
564576
if not path:
565577
raise ValueError("no path specified")
566578

579+
start = os.fspath(start)
567580
try:
568581
start_abs = abspath(normpath(start))
569582
path_abs = abspath(normpath(path))
@@ -607,6 +620,7 @@ def commonpath(paths):
607620
if not paths:
608621
raise ValueError('commonpath() arg is an empty sequence')
609622

623+
paths = tuple(map(os.fspath, paths))
610624
if isinstance(paths[0], bytes):
611625
sep = b'\\'
612626
altsep = b'/'

Lib/os.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ def walk(top, topdown=True, onerror=None, followlinks=False):
353353
dirs.remove('CVS') # don't visit CVS directories
354354
355355
"""
356-
356+
top = fspath(top)
357357
dirs = []
358358
nondirs = []
359359
walk_dirs = []
@@ -536,6 +536,8 @@ def fwalk(top=".", topdown=True, onerror=None, *, follow_symlinks=False, dir_fd=
536536
if 'CVS' in dirs:
537537
dirs.remove('CVS') # don't visit CVS directories
538538
"""
539+
if not isinstance(top, int) or not hasattr(top, '__index__'):
540+
top = fspath(top)
539541
# Note: To guard against symlink races, we use the standard
540542
# lstat()/open()/fstat() trick.
541543
orig_st = stat(top, follow_symlinks=False, dir_fd=dir_fd)

Lib/posixpath.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def _get_sep(path):
4949

5050
def normcase(s):
5151
"""Normalize case of pathname. Has no effect under Posix"""
52+
s = os.fspath(s)
5253
if not isinstance(s, (bytes, str)):
5354
raise TypeError("normcase() argument must be str or bytes, "
5455
"not '{}'".format(s.__class__.__name__))
@@ -60,6 +61,7 @@ def normcase(s):
6061

6162
def isabs(s):
6263
"""Test whether a path is absolute"""
64+
s = os.fspath(s)
6365
sep = _get_sep(s)
6466
return s.startswith(sep)
6567

@@ -73,12 +75,13 @@ def join(a, *p):
7375
If any component is an absolute path, all previous path components
7476
will be discarded. An empty last part will result in a path that
7577
ends with a separator."""
78+
a = os.fspath(a)
7679
sep = _get_sep(a)
7780
path = a
7881
try:
7982
if not p:
8083
path[:0] + sep #23780: Ensure compatible data type even if p is null.
81-
for b in p:
84+
for b in map(os.fspath, p):
8285
if b.startswith(sep):
8386
path = b
8487
elif not path or path.endswith(sep):
@@ -99,6 +102,7 @@ def join(a, *p):
99102
def split(p):
100103
"""Split a pathname. Returns tuple "(head, tail)" where "tail" is
101104
everything after the final slash. Either part may be empty."""
105+
p = os.fspath(p)
102106
sep = _get_sep(p)
103107
i = p.rfind(sep) + 1
104108
head, tail = p[:i], p[i:]
@@ -113,6 +117,7 @@ def split(p):
113117
# It is always true that root + ext == p.
114118

115119
def splitext(p):
120+
p = os.fspath(p)
116121
if isinstance(p, bytes):
117122
sep = b'/'
118123
extsep = b'.'
@@ -128,13 +133,15 @@ def splitext(p):
128133
def splitdrive(p):
129134
"""Split a pathname into drive and path. On Posix, drive is always
130135
empty."""
136+
p = os.fspath(p)
131137
return p[:0], p
132138

133139

134140
# Return the tail (basename) part of a path, same as split(path)[1].
135141

136142
def basename(p):
137143
"""Returns the final component of a pathname"""
144+
p = os.fspath(p)
138145
sep = _get_sep(p)
139146
i = p.rfind(sep) + 1
140147
return p[i:]
@@ -144,6 +151,7 @@ def basename(p):
144151

145152
def dirname(p):
146153
"""Returns the directory component of a pathname"""
154+
p = os.fspath(p)
147155
sep = _get_sep(p)
148156
i = p.rfind(sep) + 1
149157
head = p[:i]
@@ -222,6 +230,7 @@ def ismount(path):
222230
def expanduser(path):
223231
"""Expand ~ and ~user constructions. If user or $HOME is unknown,
224232
do nothing."""
233+
path = os.fspath(path)
225234
if isinstance(path, bytes):
226235
tilde = b'~'
227236
else:
@@ -267,6 +276,7 @@ def expanduser(path):
267276
def expandvars(path):
268277
"""Expand shell variables of form $var and ${var}. Unknown variables
269278
are left unchanged."""
279+
path = os.fspath(path)
270280
global _varprog, _varprogb
271281
if isinstance(path, bytes):
272282
if b'$' not in path:
@@ -318,6 +328,7 @@ def expandvars(path):
318328

319329
def normpath(path):
320330
"""Normalize path, eliminating double slashes, etc."""
331+
path = os.fspath(path)
321332
if isinstance(path, bytes):
322333
sep = b'/'
323334
empty = b''
@@ -355,6 +366,7 @@ def normpath(path):
355366

356367
def abspath(path):
357368
"""Return an absolute path."""
369+
path = os.fspath(path)
358370
if not isabs(path):
359371
if isinstance(path, bytes):
360372
cwd = os.getcwdb()
@@ -370,6 +382,7 @@ def abspath(path):
370382
def realpath(filename):
371383
"""Return the canonical path of the specified filename, eliminating any
372384
symbolic links encountered in the path."""
385+
filename = os.fspath(filename)
373386
path, ok = _joinrealpath(filename[:0], filename, {})
374387
return abspath(path)
375388

@@ -434,6 +447,7 @@ def relpath(path, start=None):
434447
if not path:
435448
raise ValueError("no path specified")
436449

450+
path = os.fspath(path)
437451
if isinstance(path, bytes):
438452
curdir = b'.'
439453
sep = b'/'
@@ -445,6 +459,8 @@ def relpath(path, start=None):
445459

446460
if start is None:
447461
start = curdir
462+
else:
463+
start = os.fspath(start)
448464

449465
try:
450466
start_list = [x for x in abspath(start).split(sep) if x]
@@ -472,6 +488,7 @@ def commonpath(paths):
472488
if not paths:
473489
raise ValueError('commonpath() arg is an empty sequence')
474490

491+
paths = tuple(map(os.fspath, paths))
475492
if isinstance(paths[0], bytes):
476493
sep = b'/'
477494
curdir = b'.'

Lib/test/test_genericpath.py

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -450,16 +450,15 @@ def test_join_errors(self):
450450
with self.assertRaisesRegex(TypeError, errmsg):
451451
self.pathmodule.join('str', b'bytes')
452452
# regression, see #15377
453-
errmsg = r'join\(\) argument must be str or bytes, not %r'
454-
with self.assertRaisesRegex(TypeError, errmsg % 'int'):
453+
with self.assertRaisesRegex(TypeError, 'int'):
455454
self.pathmodule.join(42, 'str')
456-
with self.assertRaisesRegex(TypeError, errmsg % 'int'):
455+
with self.assertRaisesRegex(TypeError, 'int'):
457456
self.pathmodule.join('str', 42)
458-
with self.assertRaisesRegex(TypeError, errmsg % 'int'):
457+
with self.assertRaisesRegex(TypeError, 'int'):
459458
self.pathmodule.join(42)
460-
with self.assertRaisesRegex(TypeError, errmsg % 'list'):
459+
with self.assertRaisesRegex(TypeError, 'list'):
461460
self.pathmodule.join([])
462-
with self.assertRaisesRegex(TypeError, errmsg % 'bytearray'):
461+
with self.assertRaisesRegex(TypeError, 'bytearray'):
463462
self.pathmodule.join(bytearray(b'foo'), bytearray(b'bar'))
464463

465464
def test_relpath_errors(self):
@@ -471,14 +470,59 @@ def test_relpath_errors(self):
471470
self.pathmodule.relpath(b'bytes', 'str')
472471
with self.assertRaisesRegex(TypeError, errmsg):
473472
self.pathmodule.relpath('str', b'bytes')
474-
errmsg = r'relpath\(\) argument must be str or bytes, not %r'
475-
with self.assertRaisesRegex(TypeError, errmsg % 'int'):
473+
with self.assertRaisesRegex(TypeError, 'int'):
476474
self.pathmodule.relpath(42, 'str')
477-
with self.assertRaisesRegex(TypeError, errmsg % 'int'):
475+
with self.assertRaisesRegex(TypeError, 'int'):
478476
self.pathmodule.relpath('str', 42)
479-
with self.assertRaisesRegex(TypeError, errmsg % 'bytearray'):
477+
with self.assertRaisesRegex(TypeError, 'bytearray'):
480478
self.pathmodule.relpath(bytearray(b'foo'), bytearray(b'bar'))
481479

482480

481+
class PathLikeTests(unittest.TestCase):
482+
483+
class PathLike:
484+
def __init__(self, path=''):
485+
self.path = path
486+
def __fspath__(self):
487+
if isinstance(self.path, BaseException):
488+
raise self.path
489+
else:
490+
return self.path
491+
492+
def setUp(self):
493+
self.file_name = support.TESTFN.lower()
494+
self.file_path = self.PathLike(support.TESTFN)
495+
self.addCleanup(support.unlink, self.file_name)
496+
create_file(self.file_name, b"test_genericpath.PathLikeTests")
497+
498+
def assertPathEqual(self, func):
499+
self.assertEqual(func(self.file_path), func(self.file_name))
500+
501+
def test_path_exists(self):
502+
self.assertPathEqual(os.path.exists)
503+
504+
def test_path_isfile(self):
505+
self.assertPathEqual(os.path.isfile)
506+
507+
def test_path_isdir(self):
508+
self.assertPathEqual(os.path.isdir)
509+
510+
def test_path_commonprefix(self):
511+
self.assertEqual(os.path.commonprefix([self.file_path, self.file_name]),
512+
self.file_name)
513+
514+
def test_path_getsize(self):
515+
self.assertPathEqual(os.path.getsize)
516+
517+
def test_path_getmtime(self):
518+
self.assertPathEqual(os.path.getatime)
519+
520+
def test_path_getctime(self):
521+
self.assertPathEqual(os.path.getctime)
522+
523+
def test_path_samefile(self):
524+
self.assertTrue(os.path.samefile(self.file_path, self.file_name))
525+
526+
483527
if __name__=="__main__":
484528
unittest.main()

0 commit comments

Comments
 (0)