gh-71189: Support all-but-last mode in os.path.realpath() by serhiy-storchaka · Pull Request #117562 · python/cpython · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions Doc/library/os.path.rst
4 changes: 3 additions & 1 deletion Doc/whatsnew/3.15.rst
Original file line number Diff line number Diff line change
Expand Up @@ -264,13 +264,15 @@ math
os.path
-------

* Add support of the all-but-last mode in :func:`~os.path.realpath`.
(Contributed by Serhiy Storchaka in :gh:`71189`.)

* The *strict* parameter to :func:`os.path.realpath` accepts a new value,
:data:`os.path.ALLOW_MISSING`.
If used, errors other than :exc:`FileNotFoundError` will be re-raised;
the resulting path can be missing but it will be free of symlinks.
(Contributed by Petr Viktorin for :cve:`2025-4517`.)


shelve
------

Expand Down
15 changes: 13 additions & 2 deletions Lib/genericpath.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@

__all__ = ['commonprefix', 'exists', 'getatime', 'getctime', 'getmtime',
'getsize', 'isdevdrive', 'isdir', 'isfile', 'isjunction', 'islink',
'lexists', 'samefile', 'sameopenfile', 'samestat', 'ALLOW_MISSING']
'lexists', 'samefile', 'sameopenfile', 'samestat',
'ALL_BUT_LAST', 'ALLOW_MISSING']


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

# A singleton with a true boolean value.

# Singletons with a true boolean value.

@object.__new__
class ALL_BUT_LAST:
"""Special value for use in realpath()."""
def __repr__(self):
return 'os.path.ALL_BUT_LAST'
def __reduce__(self):
return self.__class__.__name__

@object.__new__
class ALLOW_MISSING:
"""Special value for use in realpath()."""
Expand Down
11 changes: 9 additions & 2 deletions Lib/ntpath.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"abspath","curdir","pardir","sep","pathsep","defpath","altsep",
"extsep","devnull","realpath","supports_unicode_filenames","relpath",
"samefile", "sameopenfile", "samestat", "commonpath", "isjunction",
"isdevdrive", "ALLOW_MISSING"]
"isdevdrive", "ALL_BUT_LAST", "ALLOW_MISSING"]

def _get_bothseps(path):
if isinstance(path, bytes):
Expand Down Expand Up @@ -726,7 +726,8 @@ def realpath(path, /, *, strict=False):

if strict is ALLOW_MISSING:
ignored_error = FileNotFoundError
strict = True
elif strict is ALL_BUT_LAST:
ignored_error = FileNotFoundError
elif strict:
ignored_error = ()
else:
Expand All @@ -746,6 +747,12 @@ def realpath(path, /, *, strict=False):
raise OSError(str(ex)) from None
path = normpath(path)
except ignored_error as ex:
if strict is ALL_BUT_LAST:
dirname, basename = split(path)
if not basename:
dirname, basename = split(path)
if not isdir(dirname):
raise
initial_winerror = ex.winerror
path = _getfinalpathname_nonstrict(path,
ignored_error=ignored_error)
Expand Down
15 changes: 10 additions & 5 deletions Lib/posixpath.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
"samefile","sameopenfile","samestat",
"curdir","pardir","sep","pathsep","defpath","altsep","extsep",
"devnull","realpath","supports_unicode_filenames","relpath",
"commonpath", "isjunction","isdevdrive","ALLOW_MISSING"]
"commonpath","isjunction","isdevdrive",
"ALL_BUT_LAST","ALLOW_MISSING"]


def _get_sep(path):
Expand Down Expand Up @@ -404,7 +405,8 @@ def realpath(filename, /, *, strict=False):
getcwd = os.getcwd
if strict is ALLOW_MISSING:
ignored_error = FileNotFoundError
strict = True
elif strict is ALL_BUT_LAST:
ignored_error = FileNotFoundError
elif strict:
ignored_error = ()
else:
Expand All @@ -418,7 +420,7 @@ def realpath(filename, /, *, strict=False):
# indicates that a symlink target has been resolved, and that the original
# symlink path can be retrieved by popping again. The [::-1] slice is a
# very fast way of spelling list(reversed(...)).
rest = filename.split(sep)[::-1]
rest = filename.rstrip(sep).split(sep)[::-1]

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

# Mapping from symlink paths to *fully resolved* symlink targets. If a
# symlink is encountered but not yet resolved, the value is None. This is
Expand Down Expand Up @@ -459,7 +462,8 @@ def realpath(filename, /, *, strict=False):
try:
st_mode = lstat(newpath).st_mode
if not stat.S_ISLNK(st_mode):
if strict and part_count and not stat.S_ISDIR(st_mode):
if (strict and (part_count or trailing_sep)
and not stat.S_ISDIR(st_mode)):
raise OSError(errno.ENOTDIR, os.strerror(errno.ENOTDIR),
newpath)
path = newpath
Expand All @@ -486,7 +490,8 @@ def realpath(filename, /, *, strict=False):
continue
target = readlink(newpath)
except ignored_error:
pass
if strict is ALL_BUT_LAST and part_count:
raise
else:
# Resolve the symbolic link
if target.startswith(sep):
Expand Down
17 changes: 17 additions & 0 deletions Lib/test/test_genericpath.py
Loading
Loading