https://github.com/python/cpython/commit/0c38e057d0279c10a8cd7fd916ba1fa3c767170a
commit: 0c38e057d0279c10a8cd7fd916ba1fa3c767170a
branch: main
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-08-29T21:17:47+03:00
summary:
gh-102475: Fix os.path.realpath() for names which look like a drive (GH-155399)
The unresolved part of the path was joined with the resolved part using
join(), so a component containing a colon (e.g. "spam:eggs") reset the path.
It is now simply appended.
A path relative to a drive which does not exist (e.g. "Z:spam") is now
resolved against the root directory of that drive, as the Windows path
normalization does.
files:
A Misc/NEWS.d/next/Library/2026-08-09-01-30-00.gh-issue-102475.rpjoin.rst
M Lib/ntpath.py
M Lib/test/test_ntpath.py
diff --git a/Lib/ntpath.py b/Lib/ntpath.py
index b3c23f0abc2d88..071941d3676712 100644
--- a/Lib/ntpath.py
+++ b/Lib/ntpath.py
@@ -626,12 +626,23 @@ def _getfinalpathname_nonstrict(path,
ignored_error=OSError):
allowed_winerror = 1, 2, 3, 5, 21, 32, 50, 53, 65, 67, 87, 123, 161,
1005, 1920, 1921
# Non-strict algorithm is to find as much of the target directory
- # as we can and join the rest.
+ # as we can and join the rest. join() is not used, because the tail
+ # can contain a colon and be mistaken for a drive (gh-102475).
+ if isinstance(path, bytes):
+ sep = b'\\'
+ else:
+ sep = '\\'
+
+ def join(path, tail):
+ if path[-1:] == sep or not tail:
+ return path + tail
+ return path + sep + tail
+
tail = path[:0]
while path:
try:
path = _getfinalpathname(path)
- return join(path, tail) if tail else path
+ return join(path, tail)
except ignored_error as ex:
if ex.winerror not in allowed_winerror:
raise
@@ -642,7 +653,7 @@ def _getfinalpathname_nonstrict(path,
ignored_error=OSError):
new_path = _readlink_deep(path,
ignored_error=ignored_error)
if new_path != path:
- return join(new_path, tail) if tail else new_path
+ return join(new_path, tail)
except ignored_error:
# If we fail to readlink(), let's keep traversing
pass
@@ -657,7 +668,7 @@ def _getfinalpathname_nonstrict(path,
ignored_error=OSError):
path, name = split(path)
if path and not name:
return path + tail
- tail = join(name, tail) if tail else name
+ tail = join(name, tail)
return tail
def realpath(path, /, *, strict=False):
@@ -666,7 +677,6 @@ def realpath(path, /, *, strict=False):
prefix = b'\\\\?\\'
unc_prefix = b'\\\\?\\UNC\\'
new_unc_prefix = b'\\\\'
- cwd = os.getcwdb()
# bpo-38081: Special case for realpath(b'nul')
devnull = b'nul'
if normcase(path) == devnull:
@@ -675,7 +685,6 @@ def realpath(path, /, *, strict=False):
prefix = '\\\\?\\'
unc_prefix = '\\\\?\\UNC\\'
new_unc_prefix = '\\\\'
- cwd = os.getcwd()
# bpo-38081: Special case for realpath('nul')
devnull = 'nul'
if normcase(path) == devnull:
@@ -692,7 +701,9 @@ def realpath(path, /, *, strict=False):
ignored_error = OSError
if not had_prefix and not isabs(path):
- path = join(cwd, path)
+ # abspath() is used instead of join(cwd, path), because the path
+ # can be relative to another drive (gh-102475).
+ path = abspath(path)
try:
path = _getfinalpathname(path)
initial_winerror = 0
diff --git a/Lib/test/test_ntpath.py b/Lib/test/test_ntpath.py
index 936332bf94ffe7..fcf98ad88a0f95 100644
--- a/Lib/test/test_ntpath.py
+++ b/Lib/test/test_ntpath.py
@@ -1535,6 +1535,35 @@ def test_isjunction(self):
self.assertFalse(ntpath.isjunction('tmpdir'))
self.assertPathEqual(ntpath.realpath('testjunc'),
ntpath.realpath('tmpdir'))
+ @unittest.skipIf(sys.platform != 'win32', "Can only test on win32.")
+ def test_realpath_drive_like_names(self):
+ # gh-102475: the unresolved tail is appended, not joined, so a name
+ # which looks like a drive does not reset the path.
+ drive = ntpath.splitroot(os.getcwd())[0]
+ for path, expected in [
+ ('C:/spam:eggs', 'C:\\spam:eggs'),
+ ('C:/nonexistent/spam:eggs', 'C:\\nonexistent\\spam:eggs'),
+ ('C:/spam:eggs/ham', 'C:\\spam:eggs\\ham'),
+ ('C:/nonexistent/spam:eggs/ham',
'C:\\nonexistent\\spam:eggs\\ham'),
+ ]:
+ with self.subTest(path=path):
+ self.assertEqual(ntpath.realpath(path), expected)
+ self.assertEqual(ntpath.realpath(os.fsencode(path)),
+ os.fsencode(expected))
+
+ @unittest.skipIf(sys.platform != 'win32', "Can only test on win32.")
+ def test_realpath_drive_relative(self):
+ # gh-102475: the working directory of a drive which does not exist
+ # is its root directory.
+ for drive in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ':
+ if not ntpath.exists(drive + ':'):
+ break
+ else:
+ raise unittest.SkipTest('all drives exist')
+ self.assertEqual(ntpath.realpath(drive + ':spam'),
+ drive + ':\\spam')
+ self.assertEqual(ntpath.realpath(drive + ':'), drive + ':\\')
+
def test_isfile_invalid_paths(self):
isfile = ntpath.isfile
self.assertIs(isfile('/tmp\udfffabcds'), False)
diff --git
a/Misc/NEWS.d/next/Library/2026-08-09-01-30-00.gh-issue-102475.rpjoin.rst
b/Misc/NEWS.d/next/Library/2026-08-09-01-30-00.gh-issue-102475.rpjoin.rst
new file mode 100644
index 00000000000000..fb647720791dac
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-08-09-01-30-00.gh-issue-102475.rpjoin.rst
@@ -0,0 +1,4 @@
+Fix :func:`os.path.realpath` on Windows: the unresolved part of the path is
+now appended, not joined, so a file name which looks like a drive (e.g.
+``spam:eggs``) no longer discards the resolved part. A path relative to
+another drive is now resolved against the root directory of that drive.
_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]