Skip to content

gh-71189: Support all-but-last mode in os.path.realpath() #117562

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,8 @@ the :mod:`glob` module.)
re-raised.
In particular, :exc:`FileNotFoundError` is raised if *path* does not exist,
or another :exc:`OSError` if it is otherwise inaccessible.
If *strict* is :data:`ALL_BUT_LAST`, the last component of the path
might be missing, but other errors are not ignored.

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

.. versionchanged:: next
The :py:data:`~os.path.ALLOW_MISSING` value for the *strict* parameter
was added.
The :data:`ALL_BUT_LAST` and :data:`ALLOW_MISSING` values for
the *strict* parameter was added.

.. data:: ALL_BUT_LAST

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

.. versionadded:: next

.. data:: ALLOW_MISSING

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

.. versionadded:: next


.. function:: relpath(path, start=os.curdir)

Return a relative filepath to *path* either from the current directory or
Expand Down
4 changes: 3 additions & 1 deletion Doc/whatsnew/3.15.rst
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,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
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
Tests common to genericpath, ntpath and posixpath
"""

import copy
import genericpath
import os
import pickle
import sys
import unittest
import warnings
Expand Down Expand Up @@ -320,6 +322,21 @@ def test_sameopenfile(self):
fd2 = fp2.fileno()
self.assertTrue(self.pathmodule.sameopenfile(fd1, fd2))

def test_realpath_mode_values(self):
for name in 'ALL_BUT_LAST', 'ALLOW_MISSING':
with self.subTest(name):
mode = getattr(self.pathmodule, name)
self.assertEqual(repr(mode), 'os.path.' + name)
self.assertEqual(str(mode), 'os.path.' + name)
self.assertTrue(mode)
self.assertIs(copy.copy(mode), mode)
self.assertIs(copy.deepcopy(mode), mode)
for proto in range(pickle.HIGHEST_PROTOCOL+1):
with self.subTest(protocol=proto):
pickled = pickle.dumps(mode, proto)
unpickled = pickle.loads(pickled)
self.assertIs(unpickled, mode)


class TestGenericTest(GenericTest, unittest.TestCase):
# Issue 16852: GenericTest can't inherit from unittest.TestCase
Expand Down
Loading
Loading