Skip to content

Commit 9d3b53c

Browse files
gh-71189: Support all-but-last mode in os.path.realpath() (GH-117562)
1 parent 5236b02 commit 9d3b53c

File tree

9 files changed

+332
-37
lines changed

9 files changed

+332
-37
lines changed

Doc/library/os.path.rst

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,8 @@ the :mod:`glob` module.)
424424
re-raised.
425425
In particular, :exc:`FileNotFoundError` is raised if *path* does not exist,
426426
or another :exc:`OSError` if it is otherwise inaccessible.
427+
If *strict* is :data:`ALL_BUT_LAST`, the last component of the path
428+
is allowed to be missing, but all other errors are raised.
427429

428430
If *strict* is :py:data:`os.path.ALLOW_MISSING`, errors other than
429431
:exc:`FileNotFoundError` are re-raised (as with ``strict=True``).
@@ -448,15 +450,22 @@ the :mod:`glob` module.)
448450
The *strict* parameter was added.
449451

450452
.. versionchanged:: next
451-
The :py:data:`~os.path.ALLOW_MISSING` value for the *strict* parameter
452-
was added.
453+
The :data:`ALL_BUT_LAST` and :data:`ALLOW_MISSING` values for
454+
the *strict* parameter was added.
455+
456+
.. data:: ALL_BUT_LAST
457+
458+
Special value used for the *strict* argument in :func:`realpath`.
459+
460+
.. versionadded:: next
453461

454462
.. data:: ALLOW_MISSING
455463

456464
Special value used for the *strict* argument in :func:`realpath`.
457465

458466
.. versionadded:: next
459467

468+
460469
.. function:: relpath(path, start=os.curdir)
461470

462471
Return a relative filepath to *path* either from the current directory or

Doc/whatsnew/3.15.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,13 +264,15 @@ math
264264
os.path
265265
-------
266266

267+
* Add support of the all-but-last mode in :func:`~os.path.realpath`.
268+
(Contributed by Serhiy Storchaka in :gh:`71189`.)
269+
267270
* The *strict* parameter to :func:`os.path.realpath` accepts a new value,
268271
:data:`os.path.ALLOW_MISSING`.
269272
If used, errors other than :exc:`FileNotFoundError` will be re-raised;
270273
the resulting path can be missing but it will be free of symlinks.
271274
(Contributed by Petr Viktorin for :cve:`2025-4517`.)
272275

273-
274276
shelve
275277
------
276278

Lib/genericpath.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88

99
__all__ = ['commonprefix', 'exists', 'getatime', 'getctime', 'getmtime',
1010
'getsize', 'isdevdrive', 'isdir', 'isfile', 'isjunction', 'islink',
11-
'lexists', 'samefile', 'sameopenfile', 'samestat', 'ALLOW_MISSING']
11+
'lexists', 'samefile', 'sameopenfile', 'samestat',
12+
'ALL_BUT_LAST', 'ALLOW_MISSING']
1213

1314

1415
# Does a path exist?
@@ -190,7 +191,17 @@ def _check_arg_types(funcname, *args):
190191
if hasstr and hasbytes:
191192
raise TypeError("Can't mix strings and bytes in path components") from None
192193

193-
# A singleton with a true boolean value.
194+
195+
# Singletons with a true boolean value.
196+
197+
@object.__new__
198+
class ALL_BUT_LAST:
199+
"""Special value for use in realpath()."""
200+
def __repr__(self):
201+
return 'os.path.ALL_BUT_LAST'
202+
def __reduce__(self):
203+
return self.__class__.__name__
204+
194205
@object.__new__
195206
class ALLOW_MISSING:
196207
"""Special value for use in realpath()."""

Lib/ntpath.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"abspath","curdir","pardir","sep","pathsep","defpath","altsep",
3030
"extsep","devnull","realpath","supports_unicode_filenames","relpath",
3131
"samefile", "sameopenfile", "samestat", "commonpath", "isjunction",
32-
"isdevdrive", "ALLOW_MISSING"]
32+
"isdevdrive", "ALL_BUT_LAST", "ALLOW_MISSING"]
3333

3434
def _get_bothseps(path):
3535
if isinstance(path, bytes):
@@ -726,7 +726,8 @@ def realpath(path, /, *, strict=False):
726726

727727
if strict is ALLOW_MISSING:
728728
ignored_error = FileNotFoundError
729-
strict = True
729+
elif strict is ALL_BUT_LAST:
730+
ignored_error = FileNotFoundError
730731
elif strict:
731732
ignored_error = ()
732733
else:
@@ -746,6 +747,12 @@ def realpath(path, /, *, strict=False):
746747
raise OSError(str(ex)) from None
747748
path = normpath(path)
748749
except ignored_error as ex:
750+
if strict is ALL_BUT_LAST:
751+
dirname, basename = split(path)
752+
if not basename:
753+
dirname, basename = split(path)
754+
if not isdir(dirname):
755+
raise
749756
initial_winerror = ex.winerror
750757
path = _getfinalpathname_nonstrict(path,
751758
ignored_error=ignored_error)

Lib/posixpath.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@
3636
"samefile","sameopenfile","samestat",
3737
"curdir","pardir","sep","pathsep","defpath","altsep","extsep",
3838
"devnull","realpath","supports_unicode_filenames","relpath",
39-
"commonpath", "isjunction","isdevdrive","ALLOW_MISSING"]
39+
"commonpath","isjunction","isdevdrive",
40+
"ALL_BUT_LAST","ALLOW_MISSING"]
4041

4142

4243
def _get_sep(path):
@@ -404,7 +405,8 @@ def realpath(filename, /, *, strict=False):
404405
getcwd = os.getcwd
405406
if strict is ALLOW_MISSING:
406407
ignored_error = FileNotFoundError
407-
strict = True
408+
elif strict is ALL_BUT_LAST:
409+
ignored_error = FileNotFoundError
408410
elif strict:
409411
ignored_error = ()
410412
else:
@@ -418,7 +420,7 @@ def realpath(filename, /, *, strict=False):
418420
# indicates that a symlink target has been resolved, and that the original
419421
# symlink path can be retrieved by popping again. The [::-1] slice is a
420422
# very fast way of spelling list(reversed(...)).
421-
rest = filename.split(sep)[::-1]
423+
rest = filename.rstrip(sep).split(sep)[::-1]
422424

423425
# Number of unprocessed parts in 'rest'. This can differ from len(rest)
424426
# later, because 'rest' might contain markers for unresolved symlinks.
@@ -427,6 +429,7 @@ def realpath(filename, /, *, strict=False):
427429
# The resolved path, which is absolute throughout this function.
428430
# Note: getcwd() returns a normalized and symlink-free path.
429431
path = sep if filename.startswith(sep) else getcwd()
432+
trailing_sep = filename.endswith(sep)
430433

431434
# Mapping from symlink paths to *fully resolved* symlink targets. If a
432435
# symlink is encountered but not yet resolved, the value is None. This is
@@ -459,7 +462,8 @@ def realpath(filename, /, *, strict=False):
459462
try:
460463
st_mode = lstat(newpath).st_mode
461464
if not stat.S_ISLNK(st_mode):
462-
if strict and part_count and not stat.S_ISDIR(st_mode):
465+
if (strict and (part_count or trailing_sep)
466+
and not stat.S_ISDIR(st_mode)):
463467
raise OSError(errno.ENOTDIR, os.strerror(errno.ENOTDIR),
464468
newpath)
465469
path = newpath
@@ -486,7 +490,8 @@ def realpath(filename, /, *, strict=False):
486490
continue
487491
target = readlink(newpath)
488492
except ignored_error:
489-
pass
493+
if strict is ALL_BUT_LAST and part_count:
494+
raise
490495
else:
491496
# Resolve the symbolic link
492497
if target.startswith(sep):

Lib/test/test_genericpath.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
Tests common to genericpath, ntpath and posixpath
33
"""
44

5+
import copy
56
import genericpath
67
import os
8+
import pickle
79
import sys
810
import unittest
911
import warnings
@@ -320,6 +322,21 @@ def test_sameopenfile(self):
320322
fd2 = fp2.fileno()
321323
self.assertTrue(self.pathmodule.sameopenfile(fd1, fd2))
322324

325+
def test_realpath_mode_values(self):
326+
for name in 'ALL_BUT_LAST', 'ALLOW_MISSING':
327+
with self.subTest(name):
328+
mode = getattr(self.pathmodule, name)
329+
self.assertEqual(repr(mode), 'os.path.' + name)
330+
self.assertEqual(str(mode), 'os.path.' + name)
331+
self.assertTrue(mode)
332+
self.assertIs(copy.copy(mode), mode)
333+
self.assertIs(copy.deepcopy(mode), mode)
334+
for proto in range(pickle.HIGHEST_PROTOCOL+1):
335+
with self.subTest(protocol=proto):
336+
pickled = pickle.dumps(mode, proto)
337+
unpickled = pickle.loads(pickled)
338+
self.assertIs(unpickled, mode)
339+
323340

324341
class TestGenericTest(GenericTest, unittest.TestCase):
325342
# Issue 16852: GenericTest can't inherit from unittest.TestCase

0 commit comments

Comments
 (0)