https://github.com/python/cpython/commit/34573c1cd2c3471f3dfa82c7652d26257651fc75
commit: 34573c1cd2c3471f3dfa82c7652d26257651fc75
branch: 3.13
author: Miss Islington (bot) <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-08-29T18:41:36Z
summary:

[3.13] gh-102475: Fix os.path.realpath() for names which look like a drive 
(GH-155399) (GH-156595)

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.
(cherry picked from commit 0c38e057d0279c10a8cd7fd916ba1fa3c767170a)

Co-authored-by: Serhiy Storchaka <[email protected]>

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 36da5b7cb0fb2a6..8e5eb95474e4881 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:
@@ -691,7 +700,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 2b075871ddf78b1..fcef4a95e17e575 100644
--- a/Lib/test/test_ntpath.py
+++ b/Lib/test/test_ntpath.py
@@ -1409,6 +1409,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 000000000000000..fb647720791dac0
--- /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]

Reply via email to