From f381a5e2cf16fc661885207afe9e1bb4310b7344 Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Tue, 3 Feb 2026 09:43:33 -0600 Subject: [PATCH 1/7] gh-74453: Deprecate os.path.commonprefix --- Doc/library/os.path.rst | 4 + Doc/library/string.rst | 10 ++ Lib/genericpath.py | 20 +-- Lib/posixpath.py | 3 +- Lib/string/__init__.py | 24 +++- Lib/test/test_genericpath.py | 136 +++++++++--------- Lib/test/test_ntpath.py | 14 +- Lib/test/test_string/test_string.py | 92 ++++++++++++ Lib/textwrap.py | 2 +- Lib/unittest/util.py | 2 +- ...6-02-02-12-09-38.gh-issue-74453.19h4Z5.rst | 4 + 11 files changed, 220 insertions(+), 91 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-02-02-12-09-38.gh-issue-74453.19h4Z5.rst diff --git a/Doc/library/os.path.rst b/Doc/library/os.path.rst index bfd59fc5a82049..d1e5e2d2a5b397 100644 --- a/Doc/library/os.path.rst +++ b/Doc/library/os.path.rst @@ -117,6 +117,10 @@ the :mod:`glob` module.) >>> os.path.commonpath(['/usr/lib', '/usr/local/lib']) '/usr' + .. versionchanged:: 3.15 + Deprecated in favor of :func:`os.path.commonpath` for path prefixes and + :func:`string.commonprefix` for string prefixes. + .. versionchanged:: 3.6 Accepts a :term:`path-like object`. diff --git a/Doc/library/string.rst b/Doc/library/string.rst index e3ad018d1d073b..9a4547953e5b4a 100644 --- a/Doc/library/string.rst +++ b/Doc/library/string.rst @@ -990,3 +990,13 @@ Helper functions or ``None``, runs of whitespace characters are replaced by a single space and leading and trailing whitespace are removed, otherwise *sep* is used to split and join the words. + +.. function:: commonprefix(list, /) + + Return the longest string prefix (taken character-by-character) that is a + prefix of all string in *list*. If *list* is empty, return the empty string + (``''``). + + .. versionadded:: 3.15 + + Moved to the :mod:`string` module from the :mod:`os.path` module. diff --git a/Lib/genericpath.py b/Lib/genericpath.py index 7588fe5e8020f9..bce9d29185d5a4 100644 --- a/Lib/genericpath.py +++ b/Lib/genericpath.py @@ -105,19 +105,13 @@ def getctime(filename, /): # Return the longest prefix of all list elements. def commonprefix(m, /): "Given a list of pathnames, returns the longest common leading component" - if not m: return '' - # Some people pass in a list of pathname parts to operate in an OS-agnostic - # fashion; don't try to translate in that case as that's an abuse of the - # API and they are already doing what they need to be OS-agnostic and so - # they most likely won't be using an os.PathLike object in the sublists. - if not isinstance(m[0], (list, tuple)): - m = tuple(map(os.fspath, m)) - s1 = min(m) - s2 = max(m) - for i, c in enumerate(s1): - if c != s2[i]: - return s1[:i] - return s1 + import warnings + warnings.warn('os.path.commonprefix() is deprecated. Use ' + 'os.path.commonpath() or string.commonprefix() instead.', + category=DeprecationWarning, + stacklevel=2) + import string + return string.commonprefix(m) # Are two stat buffers (obtained from stat, fstat or lstat) # describing the same file? diff --git a/Lib/posixpath.py b/Lib/posixpath.py index 1ee27de3206c7f..0355d0e722a234 100644 --- a/Lib/posixpath.py +++ b/Lib/posixpath.py @@ -26,6 +26,7 @@ import os import sys import stat +import string import genericpath from genericpath import * @@ -542,7 +543,7 @@ def relpath(path, start=None): start_list = start_tail.split(sep) if start_tail else [] path_list = path_tail.split(sep) if path_tail else [] # Work out how much of the filepath is shared by start and path. - i = len(commonprefix([start_list, path_list])) + i = len(string.commonprefix([start_list, path_list])) rel_list = [pardir] * (len(start_list)-i) + path_list[i:] if not rel_list: diff --git a/Lib/string/__init__.py b/Lib/string/__init__.py index b788d7136f1ae3..cd1dbf10e29d24 100644 --- a/Lib/string/__init__.py +++ b/Lib/string/__init__.py @@ -15,8 +15,8 @@ """ __all__ = ["ascii_letters", "ascii_lowercase", "ascii_uppercase", "capwords", - "digits", "hexdigits", "octdigits", "printable", "punctuation", - "whitespace", "Formatter", "Template"] + "commonprefix", "digits", "hexdigits", "octdigits", "printable", + "punctuation", "whitespace", "Formatter", "Template"] import _string @@ -48,6 +48,26 @@ def capwords(s, sep=None): return (sep or ' ').join(map(str.capitalize, s.split(sep))) +def commonprefix(m, /): + "Given a list of strings, returns the longest common leading component" + if not m: return '' + # Note that previously this function was in the 'os.path' module, hence the + # handling for paths. Maintain compatibility so users have a 1-to-1 drop-in. + # Some people pass in a list of pathname parts to operate in an OS-agnostic + # fashion; don't try to translate in that case as that's an abuse of the + # API and they are already doing what they need to be OS-agnostic and so + # they most likely won't be using an os.PathLike object in the sublists. + if not isinstance(m[0], (list, tuple)): + import os + m = tuple(map(os.fspath, m)) + s1 = min(m) + s2 = max(m) + for i, c in enumerate(s1): + if c != s2[i]: + return s1[:i] + return s1 + + #################################################################### _sentinel_dict = {} diff --git a/Lib/test/test_genericpath.py b/Lib/test/test_genericpath.py index dfc0817da45fa2..93fcf7d68f8580 100644 --- a/Lib/test/test_genericpath.py +++ b/Lib/test/test_genericpath.py @@ -34,71 +34,72 @@ def test_no_argument(self): .format(self.pathmodule.__name__, attr)) def test_commonprefix(self): - commonprefix = self.pathmodule.commonprefix - self.assertEqual( - commonprefix([]), - "" - ) - self.assertEqual( - commonprefix(["/home/swenson/spam", "/home/swen/spam"]), - "/home/swen" - ) - self.assertEqual( - commonprefix(["/home/swen/spam", "/home/swen/eggs"]), - "/home/swen/" - ) - self.assertEqual( - commonprefix(["/home/swen/spam", "/home/swen/spam"]), - "/home/swen/spam" - ) - self.assertEqual( - commonprefix(["home:swenson:spam", "home:swen:spam"]), - "home:swen" - ) - self.assertEqual( - commonprefix([":home:swen:spam", ":home:swen:eggs"]), - ":home:swen:" - ) - self.assertEqual( - commonprefix([":home:swen:spam", ":home:swen:spam"]), - ":home:swen:spam" - ) - - self.assertEqual( - commonprefix([b"/home/swenson/spam", b"/home/swen/spam"]), - b"/home/swen" - ) - self.assertEqual( - commonprefix([b"/home/swen/spam", b"/home/swen/eggs"]), - b"/home/swen/" - ) - self.assertEqual( - commonprefix([b"/home/swen/spam", b"/home/swen/spam"]), - b"/home/swen/spam" - ) - self.assertEqual( - commonprefix([b"home:swenson:spam", b"home:swen:spam"]), - b"home:swen" - ) - self.assertEqual( - commonprefix([b":home:swen:spam", b":home:swen:eggs"]), - b":home:swen:" - ) - self.assertEqual( - commonprefix([b":home:swen:spam", b":home:swen:spam"]), - b":home:swen:spam" - ) - - testlist = ['', 'abc', 'Xbcd', 'Xb', 'XY', 'abcd', - 'aXc', 'abd', 'ab', 'aX', 'abcX'] - for s1 in testlist: - for s2 in testlist: - p = commonprefix([s1, s2]) - self.assertStartsWith(s1, p) - self.assertStartsWith(s2, p) - if s1 != s2: - n = len(p) - self.assertNotEqual(s1[n:n+1], s2[n:n+1]) + with warnings_helper.check_warnings((".*commonpath().*", DeprecationWarning)): + commonprefix = self.pathmodule.commonprefix + self.assertEqual( + commonprefix([]), + "" + ) + self.assertEqual( + commonprefix(["/home/swenson/spam", "/home/swen/spam"]), + "/home/swen" + ) + self.assertEqual( + commonprefix(["/home/swen/spam", "/home/swen/eggs"]), + "/home/swen/" + ) + self.assertEqual( + commonprefix(["/home/swen/spam", "/home/swen/spam"]), + "/home/swen/spam" + ) + self.assertEqual( + commonprefix(["home:swenson:spam", "home:swen:spam"]), + "home:swen" + ) + self.assertEqual( + commonprefix([":home:swen:spam", ":home:swen:eggs"]), + ":home:swen:" + ) + self.assertEqual( + commonprefix([":home:swen:spam", ":home:swen:spam"]), + ":home:swen:spam" + ) + + self.assertEqual( + commonprefix([b"/home/swenson/spam", b"/home/swen/spam"]), + b"/home/swen" + ) + self.assertEqual( + commonprefix([b"/home/swen/spam", b"/home/swen/eggs"]), + b"/home/swen/" + ) + self.assertEqual( + commonprefix([b"/home/swen/spam", b"/home/swen/spam"]), + b"/home/swen/spam" + ) + self.assertEqual( + commonprefix([b"home:swenson:spam", b"home:swen:spam"]), + b"home:swen" + ) + self.assertEqual( + commonprefix([b":home:swen:spam", b":home:swen:eggs"]), + b":home:swen:" + ) + self.assertEqual( + commonprefix([b":home:swen:spam", b":home:swen:spam"]), + b":home:swen:spam" + ) + + testlist = ['', 'abc', 'Xbcd', 'Xb', 'XY', 'abcd', + 'aXc', 'abd', 'ab', 'aX', 'abcX'] + for s1 in testlist: + for s2 in testlist: + p = commonprefix([s1, s2]) + self.assertStartsWith(s1, p) + self.assertStartsWith(s2, p) + if s1 != s2: + n = len(p) + self.assertNotEqual(s1[n:n+1], s2[n:n+1]) def test_getsize(self): filename = os_helper.TESTFN @@ -606,8 +607,9 @@ def test_path_isdir(self): self.assertPathEqual(os.path.isdir) def test_path_commonprefix(self): - self.assertEqual(os.path.commonprefix([self.file_path, self.file_name]), - self.file_name) + with warnings_helper.check_warnings((".*commonpath().*", DeprecationWarning)): + self.assertEqual(os.path.commonprefix([self.file_path, self.file_name]), + self.file_name) def test_path_getsize(self): self.assertPathEqual(os.path.getsize) diff --git a/Lib/test/test_ntpath.py b/Lib/test/test_ntpath.py index 3a3c60dea1345f..e7c6f66664d59a 100644 --- a/Lib/test/test_ntpath.py +++ b/Lib/test/test_ntpath.py @@ -10,6 +10,7 @@ from ntpath import ALL_BUT_LAST, ALLOW_MISSING from test import support from test.support import os_helper +from test.support import warnings_helper from test.support.os_helper import FakePath from test import test_genericpath from tempfile import TemporaryFile @@ -298,12 +299,13 @@ def test_isabs(self): tester('ntpath.isabs("\\\\.\\C:")', 1) def test_commonprefix(self): - tester('ntpath.commonprefix(["/home/swenson/spam", "/home/swen/spam"])', - "/home/swen") - tester('ntpath.commonprefix(["\\home\\swen\\spam", "\\home\\swen\\eggs"])', - "\\home\\swen\\") - tester('ntpath.commonprefix(["/home/swen/spam", "/home/swen/spam"])', - "/home/swen/spam") + with warnings_helper.check_warnings((".*commonpath().*", DeprecationWarning)): + tester('ntpath.commonprefix(["/home/swenson/spam", "/home/swen/spam"])', + "/home/swen") + tester('ntpath.commonprefix(["\\home\\swen\\spam", "\\home\\swen\\eggs"])', + "\\home\\swen\\") + tester('ntpath.commonprefix(["/home/swen/spam", "/home/swen/spam"])', + "/home/swen/spam") def test_join(self): tester('ntpath.join("")', '') diff --git a/Lib/test/test_string/test_string.py b/Lib/test/test_string/test_string.py index 5394fe4e12cd41..eab9f8482d4807 100644 --- a/Lib/test/test_string/test_string.py +++ b/Lib/test/test_string/test_string.py @@ -3,6 +3,7 @@ from string import Template import types from test.support import cpython_only +from test.support import os_helper from test.support.import_helper import ensure_lazy_imports @@ -40,6 +41,97 @@ def test_capwords(self): self.assertEqual(string.capwords('\taBc\tDeF\t'), 'Abc Def') self.assertEqual(string.capwords('\taBc\tDeF\t', '\t'), '\tAbc\tDef\t') + def test_commonprefix(self): + self.assertEqual( + string.commonprefix([]), + "" + ) + self.assertEqual( + string.commonprefix(["a", "b"]), + "" + ) + self.assertEqual( + string.commonprefix(["/home/swenson/spam", "/home/swen/spam"]), + "/home/swen" + ) + self.assertEqual( + string.commonprefix(["/home/swen/spam", "/home/swen/eggs"]), + "/home/swen/" + ) + self.assertEqual( + string.commonprefix(["/home/swen/spam", "/home/swen/spam"]), + "/home/swen/spam" + ) + self.assertEqual( + string.commonprefix(["home:swenson:spam", "home:swen:spam"]), + "home:swen" + ) + self.assertEqual( + string.commonprefix([":home:swen:spam", ":home:swen:eggs"]), + ":home:swen:" + ) + self.assertEqual( + string.commonprefix([":home:swen:spam", ":home:swen:spam"]), + ":home:swen:spam" + ) + + self.assertEqual( + string.commonprefix([b"/home/swenson/spam", b"/home/swen/spam"]), + b"/home/swen" + ) + self.assertEqual( + string.commonprefix([b"/home/swen/spam", b"/home/swen/eggs"]), + b"/home/swen/" + ) + self.assertEqual( + string.commonprefix([b"/home/swen/spam", b"/home/swen/spam"]), + b"/home/swen/spam" + ) + self.assertEqual( + string.commonprefix([b"home:swenson:spam", b"home:swen:spam"]), + b"home:swen" + ) + self.assertEqual( + string.commonprefix([b":home:swen:spam", b":home:swen:eggs"]), + b":home:swen:" + ) + self.assertEqual( + string.commonprefix([b":home:swen:spam", b":home:swen:spam"]), + b":home:swen:spam" + ) + + testlist = ['', 'abc', 'Xbcd', 'Xb', 'XY', 'abcd', + 'aXc', 'abd', 'ab', 'aX', 'abcX'] + for s1 in testlist: + for s2 in testlist: + p = string.commonprefix([s1, s2]) + self.assertStartsWith(s1, p) + self.assertStartsWith(s2, p) + if s1 != s2: + n = len(p) + self.assertNotEqual(s1[n:n+1], s2[n:n+1]) + + def test_commonprefix_paths(self): + # Test backwards-compatibility with os.path.commonprefix() + # This function must handle PathLike objects. + file_name = os_helper.TESTFN + file_path = os_helper.FakePath(file_name) + self.assertEqual(string.commonprefix([file_path, file_name]), + file_name) + + def test_commonprefix_sequence_of_str(self): + # Test backwards-compatibility with os.path.commonprefix() + # This function must handle lists and tuples of strings. + for type_ in (tuple, list): + seq1 = type_(["abc", "de", "fgh"]) + seq2 = type_(["abc", "def", "gh"]) + self.assertEqual(string.commonprefix([seq1, seq2]), + type_(["abc"])) + + seq1 = type_(["ab"]) + seq2 = type_(["ac"]) + self.assertEqual(string.commonprefix([seq1, seq2]), type_([])) + def test_basic_formatter(self): fmt = string.Formatter() self.assertEqual(fmt.format("foo"), "foo") diff --git a/Lib/textwrap.py b/Lib/textwrap.py index 41366fbf443a4f..eb57d09a6408e3 100644 --- a/Lib/textwrap.py +++ b/Lib/textwrap.py @@ -432,7 +432,7 @@ def dedent(text): msg = f'expected str object, not {type(text).__qualname__!r}' raise TypeError(msg) from None - # Get length of leading whitespace, inspired by ``os.path.commonprefix()``. + # Get length of leading whitespace, inspired by ``string.commonprefix()``. non_blank_lines = [l for l in lines if l and not l.isspace()] l1 = min(non_blank_lines, default='') l2 = max(non_blank_lines, default='') diff --git a/Lib/unittest/util.py b/Lib/unittest/util.py index 050eaed0b3f58f..d4f428d31da4d8 100644 --- a/Lib/unittest/util.py +++ b/Lib/unittest/util.py @@ -1,7 +1,7 @@ """Various utility functions.""" from collections import namedtuple, Counter -from os.path import commonprefix +from string import commonprefix __unittest = True diff --git a/Misc/NEWS.d/next/Library/2026-02-02-12-09-38.gh-issue-74453.19h4Z5.rst b/Misc/NEWS.d/next/Library/2026-02-02-12-09-38.gh-issue-74453.19h4Z5.rst new file mode 100644 index 00000000000000..44aab82816b76c --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-02-02-12-09-38.gh-issue-74453.19h4Z5.rst @@ -0,0 +1,4 @@ +Deprecate :func:`os.path.commonprefix` in favor of +:func:`os.path.commonpath` for path segment prefixes +and :func:`string.commonprefix` for character prefixes. + From e8ca19c9af390f7846cc1a07e062f9d0f2d3167e Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Tue, 3 Feb 2026 12:28:50 -0600 Subject: [PATCH 2/7] Address review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- Doc/library/os.path.rst | 8 ++++---- Doc/library/string.rst | 6 +++--- Lib/genericpath.py | 3 ++- Lib/string/__init__.py | 5 +++-- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/Doc/library/os.path.rst b/Doc/library/os.path.rst index d1e5e2d2a5b397..d908e302898050 100644 --- a/Doc/library/os.path.rst +++ b/Doc/library/os.path.rst @@ -117,13 +117,13 @@ the :mod:`glob` module.) >>> os.path.commonpath(['/usr/lib', '/usr/local/lib']) '/usr' - .. versionchanged:: 3.15 - Deprecated in favor of :func:`os.path.commonpath` for path prefixes and - :func:`string.commonprefix` for string prefixes. - .. versionchanged:: 3.6 Accepts a :term:`path-like object`. + .. deprecated:: next + Deprecated in favor of :func:`os.path.commonpath` for path prefixes and + :func:`string.commonprefix` for string prefixes. + .. function:: dirname(path, /) diff --git a/Doc/library/string.rst b/Doc/library/string.rst index 9a4547953e5b4a..15f7eb068f230d 100644 --- a/Doc/library/string.rst +++ b/Doc/library/string.rst @@ -994,9 +994,9 @@ Helper functions .. function:: commonprefix(list, /) Return the longest string prefix (taken character-by-character) that is a - prefix of all string in *list*. If *list* is empty, return the empty string - (``''``). + prefix of all the strings in *list*. If *list* is empty, return the empty + string (``''``). - .. versionadded:: 3.15 + .. versionadded:: next Moved to the :mod:`string` module from the :mod:`os.path` module. diff --git a/Lib/genericpath.py b/Lib/genericpath.py index bce9d29185d5a4..028bd6e56f9f35 100644 --- a/Lib/genericpath.py +++ b/Lib/genericpath.py @@ -107,7 +107,8 @@ def commonprefix(m, /): "Given a list of pathnames, returns the longest common leading component" import warnings warnings.warn('os.path.commonprefix() is deprecated. Use ' - 'os.path.commonpath() or string.commonprefix() instead.', + 'os.path.commonpath() for longest path prefix, or ' + 'string.commonprefix() for longest string prefix.', category=DeprecationWarning, stacklevel=2) import string diff --git a/Lib/string/__init__.py b/Lib/string/__init__.py index cd1dbf10e29d24..1a6140979bd446 100644 --- a/Lib/string/__init__.py +++ b/Lib/string/__init__.py @@ -49,8 +49,9 @@ def capwords(s, sep=None): def commonprefix(m, /): - "Given a list of strings, returns the longest common leading component" - if not m: return '' + """Given a list of strings, returns the longest common leading component.""" + if not m: + return '' # Note that previously this function was in the 'os.path' module, hence the # handling for paths. Maintain compatibility so users have a 1-to-1 drop-in. # Some people pass in a list of pathname parts to operate in an OS-agnostic From cc320d1a51c0cfb054a68d04ddacd85fe2af9b87 Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Tue, 3 Feb 2026 12:31:42 -0600 Subject: [PATCH 3/7] Add entry to 'pending-removal-in-future' --- Doc/deprecations/pending-removal-in-future.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Doc/deprecations/pending-removal-in-future.rst b/Doc/deprecations/pending-removal-in-future.rst index 301867416701ea..c5cc58003ce624 100644 --- a/Doc/deprecations/pending-removal-in-future.rst +++ b/Doc/deprecations/pending-removal-in-future.rst @@ -78,6 +78,9 @@ although there is currently no date scheduled for their removal. * :mod:`os`: Calling :func:`os.register_at_fork` in a multi-threaded process. +* :mod:`os.path`: :func:`os.path.commonprefix` is deprecated, use either + :func:`os.path.commonpath` or :func:`string.commonprefix` instead. + * :class:`!pydoc.ErrorDuringImport`: A tuple value for *exc_info* parameter is deprecated, use an exception instance. From 879d6ec437937354649318c924f988c546640a8c Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Tue, 3 Feb 2026 19:09:36 +0000 Subject: [PATCH 4/7] Address review comments Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- Doc/deprecations/pending-removal-in-future.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Doc/deprecations/pending-removal-in-future.rst b/Doc/deprecations/pending-removal-in-future.rst index c5cc58003ce624..96986806581bcd 100644 --- a/Doc/deprecations/pending-removal-in-future.rst +++ b/Doc/deprecations/pending-removal-in-future.rst @@ -79,7 +79,8 @@ although there is currently no date scheduled for their removal. * :mod:`os`: Calling :func:`os.register_at_fork` in a multi-threaded process. * :mod:`os.path`: :func:`os.path.commonprefix` is deprecated, use either - :func:`os.path.commonpath` or :func:`string.commonprefix` instead. + :func:`os.path.commonpath` for path prefixes or + :func:`string.commonprefix` for string prefixes. * :class:`!pydoc.ErrorDuringImport`: A tuple value for *exc_info* parameter is deprecated, use an exception instance. From 1f2086be1358077dccee30edb4c1966bd61f2582 Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Tue, 3 Feb 2026 21:56:04 +0000 Subject: [PATCH 5/7] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Doc/library/string.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/Doc/library/string.rst b/Doc/library/string.rst index 15f7eb068f230d..451291118a2810 100644 --- a/Doc/library/string.rst +++ b/Doc/library/string.rst @@ -998,5 +998,4 @@ Helper functions string (``''``). .. versionadded:: next - Moved to the :mod:`string` module from the :mod:`os.path` module. From 166c39de10e7338f1a4bcce597292f69269c62bc Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Tue, 3 Feb 2026 17:03:30 -0600 Subject: [PATCH 6/7] Remove 'string.commonprefix()' as an alternative --- .../pending-removal-in-future.rst | 5 +- Doc/library/os.path.rst | 3 +- Doc/library/string.rst | 9 -- Lib/genericpath.py | 22 ++++- Lib/posixpath.py | 3 +- Lib/string/__init__.py | 25 +---- Lib/test/test_string/test_string.py | 92 ------------------- Lib/textwrap.py | 2 +- Lib/unittest/util.py | 15 ++- ...6-02-02-12-09-38.gh-issue-74453.19h4Z5.rst | 4 +- 10 files changed, 39 insertions(+), 141 deletions(-) diff --git a/Doc/deprecations/pending-removal-in-future.rst b/Doc/deprecations/pending-removal-in-future.rst index 96986806581bcd..615845eeb03ae9 100644 --- a/Doc/deprecations/pending-removal-in-future.rst +++ b/Doc/deprecations/pending-removal-in-future.rst @@ -78,9 +78,8 @@ although there is currently no date scheduled for their removal. * :mod:`os`: Calling :func:`os.register_at_fork` in a multi-threaded process. -* :mod:`os.path`: :func:`os.path.commonprefix` is deprecated, use either - :func:`os.path.commonpath` for path prefixes or - :func:`string.commonprefix` for string prefixes. +* :mod:`os.path`: :func:`os.path.commonprefix` is deprecated, use + :func:`os.path.commonpath` for path prefixes. * :class:`!pydoc.ErrorDuringImport`: A tuple value for *exc_info* parameter is deprecated, use an exception instance. diff --git a/Doc/library/os.path.rst b/Doc/library/os.path.rst index d908e302898050..6998e268ef7373 100644 --- a/Doc/library/os.path.rst +++ b/Doc/library/os.path.rst @@ -121,8 +121,7 @@ the :mod:`glob` module.) Accepts a :term:`path-like object`. .. deprecated:: next - Deprecated in favor of :func:`os.path.commonpath` for path prefixes and - :func:`string.commonprefix` for string prefixes. + Deprecated in favor of :func:`os.path.commonpath` for path prefixes. .. function:: dirname(path, /) diff --git a/Doc/library/string.rst b/Doc/library/string.rst index 451291118a2810..e3ad018d1d073b 100644 --- a/Doc/library/string.rst +++ b/Doc/library/string.rst @@ -990,12 +990,3 @@ Helper functions or ``None``, runs of whitespace characters are replaced by a single space and leading and trailing whitespace are removed, otherwise *sep* is used to split and join the words. - -.. function:: commonprefix(list, /) - - Return the longest string prefix (taken character-by-character) that is a - prefix of all the strings in *list*. If *list* is empty, return the empty - string (``''``). - - .. versionadded:: next - Moved to the :mod:`string` module from the :mod:`os.path` module. diff --git a/Lib/genericpath.py b/Lib/genericpath.py index 028bd6e56f9f35..71ae19190839ae 100644 --- a/Lib/genericpath.py +++ b/Lib/genericpath.py @@ -107,12 +107,26 @@ def commonprefix(m, /): "Given a list of pathnames, returns the longest common leading component" import warnings warnings.warn('os.path.commonprefix() is deprecated. Use ' - 'os.path.commonpath() for longest path prefix, or ' - 'string.commonprefix() for longest string prefix.', + 'os.path.commonpath() for longest path prefix.', category=DeprecationWarning, stacklevel=2) - import string - return string.commonprefix(m) + return _commonprefix(m) + +def _commonprefix(m, /): + "Internal implementation of commonprefix()" + if not m: return '' + # Some people pass in a list of pathname parts to operate in an OS-agnostic + # fashion; don't try to translate in that case as that's an abuse of the + # API and they are already doing what they need to be OS-agnostic and so + # they most likely won't be using an os.PathLike object in the sublists. + if not isinstance(m[0], (list, tuple)): + m = tuple(map(os.fspath, m)) + s1 = min(m) + s2 = max(m) + for i, c in enumerate(s1): + if c != s2[i]: + return s1[:i] + return s1 # Are two stat buffers (obtained from stat, fstat or lstat) # describing the same file? diff --git a/Lib/posixpath.py b/Lib/posixpath.py index 0355d0e722a234..8025b063397a03 100644 --- a/Lib/posixpath.py +++ b/Lib/posixpath.py @@ -26,7 +26,6 @@ import os import sys import stat -import string import genericpath from genericpath import * @@ -543,7 +542,7 @@ def relpath(path, start=None): start_list = start_tail.split(sep) if start_tail else [] path_list = path_tail.split(sep) if path_tail else [] # Work out how much of the filepath is shared by start and path. - i = len(string.commonprefix([start_list, path_list])) + i = len(genericpath._commonprefix([start_list, path_list])) rel_list = [pardir] * (len(start_list)-i) + path_list[i:] if not rel_list: diff --git a/Lib/string/__init__.py b/Lib/string/__init__.py index 1a6140979bd446..b788d7136f1ae3 100644 --- a/Lib/string/__init__.py +++ b/Lib/string/__init__.py @@ -15,8 +15,8 @@ """ __all__ = ["ascii_letters", "ascii_lowercase", "ascii_uppercase", "capwords", - "commonprefix", "digits", "hexdigits", "octdigits", "printable", - "punctuation", "whitespace", "Formatter", "Template"] + "digits", "hexdigits", "octdigits", "printable", "punctuation", + "whitespace", "Formatter", "Template"] import _string @@ -48,27 +48,6 @@ def capwords(s, sep=None): return (sep or ' ').join(map(str.capitalize, s.split(sep))) -def commonprefix(m, /): - """Given a list of strings, returns the longest common leading component.""" - if not m: - return '' - # Note that previously this function was in the 'os.path' module, hence the - # handling for paths. Maintain compatibility so users have a 1-to-1 drop-in. - # Some people pass in a list of pathname parts to operate in an OS-agnostic - # fashion; don't try to translate in that case as that's an abuse of the - # API and they are already doing what they need to be OS-agnostic and so - # they most likely won't be using an os.PathLike object in the sublists. - if not isinstance(m[0], (list, tuple)): - import os - m = tuple(map(os.fspath, m)) - s1 = min(m) - s2 = max(m) - for i, c in enumerate(s1): - if c != s2[i]: - return s1[:i] - return s1 - - #################################################################### _sentinel_dict = {} diff --git a/Lib/test/test_string/test_string.py b/Lib/test/test_string/test_string.py index eab9f8482d4807..5394fe4e12cd41 100644 --- a/Lib/test/test_string/test_string.py +++ b/Lib/test/test_string/test_string.py @@ -3,7 +3,6 @@ from string import Template import types from test.support import cpython_only -from test.support import os_helper from test.support.import_helper import ensure_lazy_imports @@ -41,97 +40,6 @@ def test_capwords(self): self.assertEqual(string.capwords('\taBc\tDeF\t'), 'Abc Def') self.assertEqual(string.capwords('\taBc\tDeF\t', '\t'), '\tAbc\tDef\t') - def test_commonprefix(self): - self.assertEqual( - string.commonprefix([]), - "" - ) - self.assertEqual( - string.commonprefix(["a", "b"]), - "" - ) - self.assertEqual( - string.commonprefix(["/home/swenson/spam", "/home/swen/spam"]), - "/home/swen" - ) - self.assertEqual( - string.commonprefix(["/home/swen/spam", "/home/swen/eggs"]), - "/home/swen/" - ) - self.assertEqual( - string.commonprefix(["/home/swen/spam", "/home/swen/spam"]), - "/home/swen/spam" - ) - self.assertEqual( - string.commonprefix(["home:swenson:spam", "home:swen:spam"]), - "home:swen" - ) - self.assertEqual( - string.commonprefix([":home:swen:spam", ":home:swen:eggs"]), - ":home:swen:" - ) - self.assertEqual( - string.commonprefix([":home:swen:spam", ":home:swen:spam"]), - ":home:swen:spam" - ) - - self.assertEqual( - string.commonprefix([b"/home/swenson/spam", b"/home/swen/spam"]), - b"/home/swen" - ) - self.assertEqual( - string.commonprefix([b"/home/swen/spam", b"/home/swen/eggs"]), - b"/home/swen/" - ) - self.assertEqual( - string.commonprefix([b"/home/swen/spam", b"/home/swen/spam"]), - b"/home/swen/spam" - ) - self.assertEqual( - string.commonprefix([b"home:swenson:spam", b"home:swen:spam"]), - b"home:swen" - ) - self.assertEqual( - string.commonprefix([b":home:swen:spam", b":home:swen:eggs"]), - b":home:swen:" - ) - self.assertEqual( - string.commonprefix([b":home:swen:spam", b":home:swen:spam"]), - b":home:swen:spam" - ) - - testlist = ['', 'abc', 'Xbcd', 'Xb', 'XY', 'abcd', - 'aXc', 'abd', 'ab', 'aX', 'abcX'] - for s1 in testlist: - for s2 in testlist: - p = string.commonprefix([s1, s2]) - self.assertStartsWith(s1, p) - self.assertStartsWith(s2, p) - if s1 != s2: - n = len(p) - self.assertNotEqual(s1[n:n+1], s2[n:n+1]) - - def test_commonprefix_paths(self): - # Test backwards-compatibility with os.path.commonprefix() - # This function must handle PathLike objects. - file_name = os_helper.TESTFN - file_path = os_helper.FakePath(file_name) - self.assertEqual(string.commonprefix([file_path, file_name]), - file_name) - - def test_commonprefix_sequence_of_str(self): - # Test backwards-compatibility with os.path.commonprefix() - # This function must handle lists and tuples of strings. - for type_ in (tuple, list): - seq1 = type_(["abc", "de", "fgh"]) - seq2 = type_(["abc", "def", "gh"]) - self.assertEqual(string.commonprefix([seq1, seq2]), - type_(["abc"])) - - seq1 = type_(["ab"]) - seq2 = type_(["ac"]) - self.assertEqual(string.commonprefix([seq1, seq2]), type_([])) - def test_basic_formatter(self): fmt = string.Formatter() self.assertEqual(fmt.format("foo"), "foo") diff --git a/Lib/textwrap.py b/Lib/textwrap.py index eb57d09a6408e3..41366fbf443a4f 100644 --- a/Lib/textwrap.py +++ b/Lib/textwrap.py @@ -432,7 +432,7 @@ def dedent(text): msg = f'expected str object, not {type(text).__qualname__!r}' raise TypeError(msg) from None - # Get length of leading whitespace, inspired by ``string.commonprefix()``. + # Get length of leading whitespace, inspired by ``os.path.commonprefix()``. non_blank_lines = [l for l in lines if l and not l.isspace()] l1 = min(non_blank_lines, default='') l2 = max(non_blank_lines, default='') diff --git a/Lib/unittest/util.py b/Lib/unittest/util.py index d4f428d31da4d8..8382d2790eaebb 100644 --- a/Lib/unittest/util.py +++ b/Lib/unittest/util.py @@ -1,7 +1,6 @@ """Various utility functions.""" from collections import namedtuple, Counter -from string import commonprefix __unittest = True @@ -21,13 +20,25 @@ def _shorten(s, prefixlen, suffixlen): s = '%s[%d chars]%s' % (s[:prefixlen], skip, s[len(s) - suffixlen:]) return s +def _commonprefix(m, /): + if not m: + return "" + m = sorted(m) + prefix = m[0] + for item in m[1:]: + for i in range(len(prefix)): + if item[i] != prefix[i]: + prefix = prefix[:i] + break + return prefix + def _common_shorten_repr(*args): args = tuple(map(safe_repr, args)) maxlen = max(map(len, args)) if maxlen <= _MAX_LENGTH: return args - prefix = commonprefix(args) + prefix = _commonprefix(args) prefixlen = len(prefix) common_len = _MAX_LENGTH - \ diff --git a/Misc/NEWS.d/next/Library/2026-02-02-12-09-38.gh-issue-74453.19h4Z5.rst b/Misc/NEWS.d/next/Library/2026-02-02-12-09-38.gh-issue-74453.19h4Z5.rst index 44aab82816b76c..c5d96c0d0c93ac 100644 --- a/Misc/NEWS.d/next/Library/2026-02-02-12-09-38.gh-issue-74453.19h4Z5.rst +++ b/Misc/NEWS.d/next/Library/2026-02-02-12-09-38.gh-issue-74453.19h4Z5.rst @@ -1,4 +1,2 @@ Deprecate :func:`os.path.commonprefix` in favor of -:func:`os.path.commonpath` for path segment prefixes -and :func:`string.commonprefix` for character prefixes. - +:func:`os.path.commonpath` for path segment prefixes. From f58e68f9cdd371d7d8bffeca5cdd3883cb2a2b1b Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Wed, 4 Feb 2026 09:39:13 -0600 Subject: [PATCH 7/7] Explain why os.path.commonprefix() is being deprecated --- .../pending-removal-in-future.rst | 7 +- Doc/library/os.path.rst | 5 + Lib/test/test_genericpath.py | 133 +++++++++--------- Lib/test/test_ntpath.py | 15 +- Lib/unittest/util.py | 18 ++- ...6-02-02-12-09-38.gh-issue-74453.19h4Z5.rst | 6 + 6 files changed, 102 insertions(+), 82 deletions(-) diff --git a/Doc/deprecations/pending-removal-in-future.rst b/Doc/deprecations/pending-removal-in-future.rst index 615845eeb03ae9..a54f98d6866e9f 100644 --- a/Doc/deprecations/pending-removal-in-future.rst +++ b/Doc/deprecations/pending-removal-in-future.rst @@ -79,7 +79,12 @@ although there is currently no date scheduled for their removal. * :mod:`os`: Calling :func:`os.register_at_fork` in a multi-threaded process. * :mod:`os.path`: :func:`os.path.commonprefix` is deprecated, use - :func:`os.path.commonpath` for path prefixes. + :func:`os.path.commonpath` for path prefixes. The :func:`os.path.commonprefix` + function is being deprecated due to having a misleading name and module. + The function is not safe to use for path prefixes despite being included in a + module about path manipulation, meaning it is easy to accidentally + introduce path traversal vulnerabilities into Python programs by using this + function. * :class:`!pydoc.ErrorDuringImport`: A tuple value for *exc_info* parameter is deprecated, use an exception instance. diff --git a/Doc/library/os.path.rst b/Doc/library/os.path.rst index 6998e268ef7373..409fcf4adb754b 100644 --- a/Doc/library/os.path.rst +++ b/Doc/library/os.path.rst @@ -122,6 +122,11 @@ the :mod:`glob` module.) .. deprecated:: next Deprecated in favor of :func:`os.path.commonpath` for path prefixes. + The :func:`os.path.commonprefix` function is being deprecated due to + having a misleading name and module. The function is not safe to use for + path prefixes despite being included in a module about path manipulation, + meaning it is easy to accidentally introduce path traversal + vulnerabilities into Python programs by using this function. .. function:: dirname(path, /) diff --git a/Lib/test/test_genericpath.py b/Lib/test/test_genericpath.py index 93fcf7d68f8580..10d3f409d883c5 100644 --- a/Lib/test/test_genericpath.py +++ b/Lib/test/test_genericpath.py @@ -35,71 +35,74 @@ def test_no_argument(self): def test_commonprefix(self): with warnings_helper.check_warnings((".*commonpath().*", DeprecationWarning)): - commonprefix = self.pathmodule.commonprefix - self.assertEqual( - commonprefix([]), - "" - ) - self.assertEqual( - commonprefix(["/home/swenson/spam", "/home/swen/spam"]), - "/home/swen" - ) - self.assertEqual( - commonprefix(["/home/swen/spam", "/home/swen/eggs"]), - "/home/swen/" - ) - self.assertEqual( - commonprefix(["/home/swen/spam", "/home/swen/spam"]), - "/home/swen/spam" - ) - self.assertEqual( - commonprefix(["home:swenson:spam", "home:swen:spam"]), - "home:swen" - ) - self.assertEqual( - commonprefix([":home:swen:spam", ":home:swen:eggs"]), - ":home:swen:" - ) - self.assertEqual( - commonprefix([":home:swen:spam", ":home:swen:spam"]), - ":home:swen:spam" - ) - - self.assertEqual( - commonprefix([b"/home/swenson/spam", b"/home/swen/spam"]), - b"/home/swen" - ) - self.assertEqual( - commonprefix([b"/home/swen/spam", b"/home/swen/eggs"]), - b"/home/swen/" - ) - self.assertEqual( - commonprefix([b"/home/swen/spam", b"/home/swen/spam"]), - b"/home/swen/spam" - ) - self.assertEqual( - commonprefix([b"home:swenson:spam", b"home:swen:spam"]), - b"home:swen" - ) - self.assertEqual( - commonprefix([b":home:swen:spam", b":home:swen:eggs"]), - b":home:swen:" - ) - self.assertEqual( - commonprefix([b":home:swen:spam", b":home:swen:spam"]), - b":home:swen:spam" - ) - - testlist = ['', 'abc', 'Xbcd', 'Xb', 'XY', 'abcd', - 'aXc', 'abd', 'ab', 'aX', 'abcX'] - for s1 in testlist: - for s2 in testlist: - p = commonprefix([s1, s2]) - self.assertStartsWith(s1, p) - self.assertStartsWith(s2, p) - if s1 != s2: - n = len(p) - self.assertNotEqual(s1[n:n+1], s2[n:n+1]) + self.do_test_commonprefix() + + def do_test_commonprefix(self): + commonprefix = self.pathmodule.commonprefix + self.assertEqual( + commonprefix([]), + "" + ) + self.assertEqual( + commonprefix(["/home/swenson/spam", "/home/swen/spam"]), + "/home/swen" + ) + self.assertEqual( + commonprefix(["/home/swen/spam", "/home/swen/eggs"]), + "/home/swen/" + ) + self.assertEqual( + commonprefix(["/home/swen/spam", "/home/swen/spam"]), + "/home/swen/spam" + ) + self.assertEqual( + commonprefix(["home:swenson:spam", "home:swen:spam"]), + "home:swen" + ) + self.assertEqual( + commonprefix([":home:swen:spam", ":home:swen:eggs"]), + ":home:swen:" + ) + self.assertEqual( + commonprefix([":home:swen:spam", ":home:swen:spam"]), + ":home:swen:spam" + ) + + self.assertEqual( + commonprefix([b"/home/swenson/spam", b"/home/swen/spam"]), + b"/home/swen" + ) + self.assertEqual( + commonprefix([b"/home/swen/spam", b"/home/swen/eggs"]), + b"/home/swen/" + ) + self.assertEqual( + commonprefix([b"/home/swen/spam", b"/home/swen/spam"]), + b"/home/swen/spam" + ) + self.assertEqual( + commonprefix([b"home:swenson:spam", b"home:swen:spam"]), + b"home:swen" + ) + self.assertEqual( + commonprefix([b":home:swen:spam", b":home:swen:eggs"]), + b":home:swen:" + ) + self.assertEqual( + commonprefix([b":home:swen:spam", b":home:swen:spam"]), + b":home:swen:spam" + ) + + testlist = ['', 'abc', 'Xbcd', 'Xb', 'XY', 'abcd', + 'aXc', 'abd', 'ab', 'aX', 'abcX'] + for s1 in testlist: + for s2 in testlist: + p = commonprefix([s1, s2]) + self.assertStartsWith(s1, p) + self.assertStartsWith(s2, p) + if s1 != s2: + n = len(p) + self.assertNotEqual(s1[n:n+1], s2[n:n+1]) def test_getsize(self): filename = os_helper.TESTFN diff --git a/Lib/test/test_ntpath.py b/Lib/test/test_ntpath.py index e7c6f66664d59a..a3728b58335e63 100644 --- a/Lib/test/test_ntpath.py +++ b/Lib/test/test_ntpath.py @@ -300,12 +300,15 @@ def test_isabs(self): def test_commonprefix(self): with warnings_helper.check_warnings((".*commonpath().*", DeprecationWarning)): - tester('ntpath.commonprefix(["/home/swenson/spam", "/home/swen/spam"])', - "/home/swen") - tester('ntpath.commonprefix(["\\home\\swen\\spam", "\\home\\swen\\eggs"])', - "\\home\\swen\\") - tester('ntpath.commonprefix(["/home/swen/spam", "/home/swen/spam"])', - "/home/swen/spam") + self.do_test_commonprefix() + + def do_test_commonprefix(self): + tester('ntpath.commonprefix(["/home/swenson/spam", "/home/swen/spam"])', + "/home/swen") + tester('ntpath.commonprefix(["\\home\\swen\\spam", "\\home\\swen\\eggs"])', + "\\home\\swen\\") + tester('ntpath.commonprefix(["/home/swen/spam", "/home/swen/spam"])', + "/home/swen/spam") def test_join(self): tester('ntpath.join("")', '') diff --git a/Lib/unittest/util.py b/Lib/unittest/util.py index 8382d2790eaebb..c7e6b941978cd5 100644 --- a/Lib/unittest/util.py +++ b/Lib/unittest/util.py @@ -20,17 +20,15 @@ def _shorten(s, prefixlen, suffixlen): s = '%s[%d chars]%s' % (s[:prefixlen], skip, s[len(s) - suffixlen:]) return s -def _commonprefix(m, /): +def _common_prefix(m): if not m: return "" - m = sorted(m) - prefix = m[0] - for item in m[1:]: - for i in range(len(prefix)): - if item[i] != prefix[i]: - prefix = prefix[:i] - break - return prefix + s1 = min(m) + s2 = max(m) + for i, c in enumerate(s1): + if c != s2[i]: + return s1[:i] + return s1 def _common_shorten_repr(*args): args = tuple(map(safe_repr, args)) @@ -38,7 +36,7 @@ def _common_shorten_repr(*args): if maxlen <= _MAX_LENGTH: return args - prefix = _commonprefix(args) + prefix = _common_prefix(args) prefixlen = len(prefix) common_len = _MAX_LENGTH - \ diff --git a/Misc/NEWS.d/next/Library/2026-02-02-12-09-38.gh-issue-74453.19h4Z5.rst b/Misc/NEWS.d/next/Library/2026-02-02-12-09-38.gh-issue-74453.19h4Z5.rst index c5d96c0d0c93ac..8629c834e5b0cd 100644 --- a/Misc/NEWS.d/next/Library/2026-02-02-12-09-38.gh-issue-74453.19h4Z5.rst +++ b/Misc/NEWS.d/next/Library/2026-02-02-12-09-38.gh-issue-74453.19h4Z5.rst @@ -1,2 +1,8 @@ Deprecate :func:`os.path.commonprefix` in favor of :func:`os.path.commonpath` for path segment prefixes. + +The :func:`os.path.commonprefix` function is being deprecated due to +having a misleading name and module. The function is not safe to use for +path prefixes despite being included in a module about path manipulation, +meaning it is easy to accidentally introduce path traversal +vulnerabilities into Python programs by using this function.