From a3bc95f2f07f7ef3fd55f5a5df3739e00e408ddb Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 8 Aug 2026 21:46:18 +0300 Subject: [PATCH 1/3] gh-85681: Make ntpath.abspath() platform independent On Windows the path normalization strips trailing dots and spaces from the last component of the path, and a single trailing dot from other components. abspath() on other platforms now does the same, so relpath() and other functions based on it no longer depend on the platform. --- Lib/ntpath.py | 22 +++++++++++++- Lib/test/test_ntpath.py | 29 +++++++++++++++++++ ...26-08-08-23-30-00.gh-issue-85681.ntabs.rst | 3 ++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2026-08-08-23-30-00.gh-issue-85681.ntabs.rst diff --git a/Lib/ntpath.py b/Lib/ntpath.py index b3c23f0abc2d88..d30b144a2e35d3 100644 --- a/Lib/ntpath.py +++ b/Lib/ntpath.py @@ -516,6 +516,26 @@ def normpath(path): from nt import _getfullpathname except ImportError: # not running on Windows - mock up something sensible + def _strip_dots_and_spaces(path): + # As the Windows path normalization does. + if isinstance(path, bytes): + sep = b'\\' + dot = b'.' + dots_and_spaces = b'. ' + else: + sep = '\\' + dot = '.' + dots_and_spaces = '. ' + drive, root, tail = splitroot(path) + if not tail: + return path + comps = tail.split(sep) + for i, comp in enumerate(comps[:-1]): + if len(comp) > 1 and comp.endswith(dot) and not comp.endswith(dot*2): + comps[i] = comp[:-1] + comps[-1] = comps[-1].rstrip(dots_and_spaces) + return drive + root + sep.join(comps) + def abspath(path): """Return the absolute version of a path.""" path = os.fspath(path) @@ -525,7 +545,7 @@ def abspath(path): else: cwd = os.getcwd() path = join(cwd, path) - return normpath(path) + return _strip_dots_and_spaces(normpath(path)) else: # use native Windows method on Windows def abspath(path): diff --git a/Lib/test/test_ntpath.py b/Lib/test/test_ntpath.py index 936332bf94ffe7..14bdb97b5ab7f6 100644 --- a/Lib/test/test_ntpath.py +++ b/Lib/test/test_ntpath.py @@ -1210,6 +1210,35 @@ def test_expanduser(self): + def test_abspath_dots_and_spaces(self): + # gh-85681: trailing dots and spaces are stripped from the last + # component, and a single trailing dot from other components. + tester('ntpath.abspath("C:/spam. . .")', "C:\\spam") + tester('ntpath.abspath("C:/spam.")', "C:\\spam") + tester('ntpath.abspath("C:/spam ")', "C:\\spam") + tester('ntpath.abspath("C:/spam..")', "C:\\spam") + tester('ntpath.abspath("C:/.spam")', "C:\\.spam") + tester('ntpath.abspath("C:/spam./eggs")', "C:\\spam\\eggs") + tester('ntpath.abspath("C:/spam.b./eggs")', "C:\\spam.b\\eggs") + tester('ntpath.abspath("C:/spam ./eggs")', "C:\\spam \\eggs") + # A trailing dot preceded by a dot is not stripped. + tester('ntpath.abspath("C:/spam../eggs")', "C:\\spam..\\eggs") + tester('ntpath.abspath("C:/spam.../eggs")', "C:\\spam...\\eggs") + # Trailing dots and spaces are stripped even in extended paths. + tester('ntpath.abspath("\\\\?\\C:/spam. . .")', "\\\\?\\C:\\spam") + tester('ntpath.abspath("\\\\.\\C:/spam. . .")', "\\\\.\\C:\\spam") + tester('ntpath.abspath("//server/share/spam. ")', + "\\\\server\\share\\spam") + + def test_relpath_dots_and_spaces(self): + # gh-85681: relpath() is based on abspath(). + tester('ntpath.relpath("foo ", "foo")', ".") + tester('ntpath.relpath("foo.", "foo")', ".") + tester('ntpath.relpath("foo/bar ", "foo")', "bar") + tester('ntpath.relpath("foo/bar.", "foo")', "bar") + # "foo.." denotes "foo" as the last component, but not as other. + tester('ntpath.relpath("foo../bar", "foo..")', "..\\foo..\\bar") + @unittest.skipUnless(nt, "abspath requires 'nt' module") def test_abspath(self): tester('ntpath.abspath("C:\\")', "C:\\") diff --git a/Misc/NEWS.d/next/Library/2026-08-08-23-30-00.gh-issue-85681.ntabs.rst b/Misc/NEWS.d/next/Library/2026-08-08-23-30-00.gh-issue-85681.ntabs.rst new file mode 100644 index 00000000000000..ffd3f0a885c938 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-08-23-30-00.gh-issue-85681.ntabs.rst @@ -0,0 +1,3 @@ +:func:`ntpath.abspath` on non-Windows platforms now strips trailing dots and +spaces like the Windows path normalization does. This makes +:func:`ntpath.relpath` and other functions based on it platform independent. From 6fc12166897396ebce48d6d1fb84bf8fcf622100 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Thu, 13 Aug 2026 12:52:15 +0300 Subject: [PATCH 2/3] Update ntpath functions for platform independence --- .../next/Library/2026-08-08-23-30-00.gh-issue-85681.ntabs.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Misc/NEWS.d/next/Library/2026-08-08-23-30-00.gh-issue-85681.ntabs.rst b/Misc/NEWS.d/next/Library/2026-08-08-23-30-00.gh-issue-85681.ntabs.rst index ffd3f0a885c938..b673a69a70620b 100644 --- a/Misc/NEWS.d/next/Library/2026-08-08-23-30-00.gh-issue-85681.ntabs.rst +++ b/Misc/NEWS.d/next/Library/2026-08-08-23-30-00.gh-issue-85681.ntabs.rst @@ -1,3 +1,3 @@ -:func:`ntpath.abspath` on non-Windows platforms now strips trailing dots and +:func:`!ntpath.abspath` on non-Windows platforms now strips trailing dots and spaces like the Windows path normalization does. This makes -:func:`ntpath.relpath` and other functions based on it platform independent. +:func:`!ntpath.relpath` and other functions based on it platform independent. From aef008e0f69c60a36410d677cf9b4837ff0f10eb Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Tue, 18 Aug 2026 21:21:56 +0300 Subject: [PATCH 3/3] Mark the emulation as a best effort, not a specification --- Lib/ntpath.py | 4 +++- Lib/test/test_ntpath.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Lib/ntpath.py b/Lib/ntpath.py index d30b144a2e35d3..06e1429b87e8f6 100644 --- a/Lib/ntpath.py +++ b/Lib/ntpath.py @@ -517,7 +517,9 @@ def normpath(path): except ImportError: # not running on Windows - mock up something sensible def _strip_dots_and_spaces(path): - # As the Windows path normalization does. + # Imitate the trailing dot and space stripping performed by the + # Windows path normalization. This is a best effort, not a + # specification. if isinstance(path, bytes): sep = b'\\' dot = b'.' diff --git a/Lib/test/test_ntpath.py b/Lib/test/test_ntpath.py index 14bdb97b5ab7f6..d194b167e2c5a8 100644 --- a/Lib/test/test_ntpath.py +++ b/Lib/test/test_ntpath.py @@ -1213,6 +1213,7 @@ def test_expanduser(self): def test_abspath_dots_and_spaces(self): # gh-85681: trailing dots and spaces are stripped from the last # component, and a single trailing dot from other components. + # This is the observed Windows behavior, not a specification. tester('ntpath.abspath("C:/spam. . .")', "C:\\spam") tester('ntpath.abspath("C:/spam.")', "C:\\spam") tester('ntpath.abspath("C:/spam ")', "C:\\spam")