Skip to content

GH-65238: Fix stripping of trailing slash in pathlib #103595

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

Closed
wants to merge 3 commits into from
Closed
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
5 changes: 4 additions & 1 deletion Lib/importlib/metadata/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -748,7 +748,10 @@ def read_text(self, filename):
NotADirectoryError,
PermissionError,
):
return self._path.joinpath(filename).read_text(encoding='utf-8')
path = self._path
if filename:
path /= filename
return path.read_text(encoding='utf-8')

read_text.__doc__ = Distribution.read_text.__doc__

Expand Down
16 changes: 11 additions & 5 deletions Lib/pathlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,9 @@ def _parse_path(cls, path):
# pathlib assumes that UNC paths always have a root.
root = sep
parsed = [sys.intern(str(x)) for x in rel.split(sep) if x and x != '.']
if parsed and not rel.endswith(parsed[-1]):
# Preserve trailing slash
parsed.append('')
return drv, root, parsed

def _load_parts(self):
Expand Down Expand Up @@ -578,6 +581,9 @@ def relative_to(self, other, /, *_deprecated, walk_up=False):
remove=(3, 14))
path_cls = type(self)
other = path_cls(other, *_deprecated)
if not other.name:
# Ignore trailing slash.
other = other.parent
for step, path in enumerate([other] + list(other.parents)):
if self.is_relative_to(path):
break
Expand All @@ -598,6 +604,9 @@ def is_relative_to(self, other, /, *_deprecated):
warnings._deprecated("pathlib.PurePath.is_relative_to(*args)",
msg, remove=(3, 14))
other = type(self)(other, *_deprecated)
if not other.name:
# Ignore trailing slash.
other = other.parent
return other == self or other in self.parents

@property
Expand Down Expand Up @@ -741,10 +750,11 @@ def __new__(cls, *args, **kwargs):
def _make_child_relpath(self, name):
path_str = str(self)
tail = self._tail
if tail:
if tail and tail[-1]:
path_str = f'{path_str}{self._flavour.sep}{name}'
elif path_str != '.':
path_str = f'{path_str}{name}'
tail = tail[:-1]
else:
path_str = name
path = type(self)(path_str)
Expand Down Expand Up @@ -825,8 +835,6 @@ def glob(self, pattern):
drv, root, pattern_parts = self._parse_path(pattern)
if drv or root:
raise NotImplementedError("Non-relative patterns are unsupported")
if pattern[-1] in (self._flavour.sep, self._flavour.altsep):
pattern_parts.append('')
selector = _make_selector(tuple(pattern_parts), self._flavour)
for p in selector.select_from(self):
yield p
Expand All @@ -840,8 +848,6 @@ def rglob(self, pattern):
drv, root, pattern_parts = self._parse_path(pattern)
if drv or root:
raise NotImplementedError("Non-relative patterns are unsupported")
if pattern and pattern[-1] in (self._flavour.sep, self._flavour.altsep):
pattern_parts.append('')
selector = _make_selector(("**",) + tuple(pattern_parts), self._flavour)
for p in selector.select_from(self):
yield p
Expand Down
80 changes: 61 additions & 19 deletions Lib/test/test_pathlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,14 @@ class _BasePurePathTest(object):
# supposed to produce equal paths.
equivalences = {
'a/b': [
('a', 'b'), ('a/', 'b'), ('a', 'b/'), ('a/', 'b/'),
('a/b/',), ('a//b',), ('a//b//',),
('a', 'b'), ('a/', 'b'), ('a//b',),
# Empty components get removed.
('', 'a', 'b'), ('a', '', 'b'), ('a', 'b', ''),
('', 'a', 'b'), ('a', '', 'b'),
],
'a/b/': [
('a', 'b/'), ('a/', 'b/'), ('a/b/',),
('a//b//',), ('a', 'b', ''),
],
'/b/c/d': [
('a', '/b/c', 'd'), ('/a', '/b/c', 'd'),
# Empty components get removed.
Expand Down Expand Up @@ -154,11 +157,11 @@ def test_drive_root_parts_common(self):
# Unanchored parts.
check((), '', '', ())
check(('a',), '', '', ('a',))
check(('a/',), '', '', ('a',))
check(('a/',), '', '', ('a', ''))
check(('a', 'b'), '', '', ('a', 'b'))
# Expansion.
check(('a/b',), '', '', ('a', 'b'))
check(('a/b/',), '', '', ('a', 'b'))
check(('a/b/',), '', '', ('a', 'b', ''))
check(('a', 'b/c', 'd'), '', '', ('a', 'b', 'c', 'd'))
# Collapsing and stripping excess slashes.
check(('a', 'b//c', 'd'), '', '', ('a', 'b', 'c', 'd'))
Expand All @@ -167,7 +170,7 @@ def test_drive_root_parts_common(self):
check(('.',), '', '', ())
check(('.', '.', 'b'), '', '', ('b',))
check(('a', '.', 'b'), '', '', ('a', 'b'))
check(('a', '.', '.'), '', '', ('a',))
check(('a', '.', '.'), '', '', ('a', ''))
# The first part is anchored.
check(('/a/b',), '', sep, (sep, 'a', 'b'))
check(('/a', 'b'), '', sep, (sep, 'a', 'b'))
Expand All @@ -188,6 +191,24 @@ def test_join_common(self):
self.assertEqual(pp, P('a/b/c'))
pp = p.joinpath('/c')
self.assertEqual(pp, P('/c'))
pp = p.joinpath('.')
self.assertEqual(pp, P('a/b/'))
pp = p.joinpath('')
self.assertEqual(pp, P('a/b/'))
p = P('a/b/')
pp = p.joinpath('c')
self.assertEqual(pp, P('a/b/c'))
self.assertIs(type(pp), type(p))
pp = p.joinpath('c', 'd')
self.assertEqual(pp, P('a/b/c/d'))
pp = p.joinpath(P('c'))
self.assertEqual(pp, P('a/b/c'))
pp = p.joinpath('/c')
self.assertEqual(pp, P('/c'))
pp = p.joinpath('.')
self.assertEqual(pp, P('a/b/'))
pp = p.joinpath('')
self.assertEqual(pp, P('a/b/'))

def test_div_common(self):
# Basically the same as joinpath().
Expand Down Expand Up @@ -389,6 +410,12 @@ def test_parent_common(self):
self.assertEqual(p.parent.parent, P('/a'))
self.assertEqual(p.parent.parent.parent, P('/'))
self.assertEqual(p.parent.parent.parent.parent, P('/'))
# Trailing slash
p = P('/a/b/')
self.assertEqual(p.parent, P('/a/b'))
self.assertEqual(p.parent.parent, P('/a'))
self.assertEqual(p.parent.parent.parent, P('/'))
self.assertEqual(p.parent.parent.parent.parent, P('/'))

def test_parents_common(self):
# Relative
Expand Down Expand Up @@ -436,6 +463,9 @@ def test_parents_common(self):
par[-4]
with self.assertRaises(IndexError):
par[3]
# Trailing slash
self.assertEqual(P('a/b/').parents[:], (P('a/b'), P('a'), P()))
self.assertEqual(P('/a/b/').parents[:], (P('/a/b'), P('/a'), P('/')))

def test_drive_common(self):
P = self.cls
Expand Down Expand Up @@ -466,7 +496,7 @@ def test_name_common(self):
self.assertEqual(P('/').name, '')
self.assertEqual(P('a/b').name, 'b')
self.assertEqual(P('/a/b').name, 'b')
self.assertEqual(P('/a/b/.').name, 'b')
self.assertEqual(P('/a/b/.').name, '')
self.assertEqual(P('a/b.py').name, 'b.py')
self.assertEqual(P('/a/b.py').name, 'b.py')

Expand Down Expand Up @@ -534,6 +564,7 @@ def test_with_name_common(self):
self.assertRaises(ValueError, P('').with_name, 'd.xml')
self.assertRaises(ValueError, P('.').with_name, 'd.xml')
self.assertRaises(ValueError, P('/').with_name, 'd.xml')
self.assertRaises(ValueError, P('a/').with_name, 'd.xml')
self.assertRaises(ValueError, P('a/b').with_name, '')
self.assertRaises(ValueError, P('a/b').with_name, '/c')
self.assertRaises(ValueError, P('a/b').with_name, 'c/')
Expand All @@ -551,6 +582,7 @@ def test_with_stem_common(self):
self.assertRaises(ValueError, P('').with_stem, 'd')
self.assertRaises(ValueError, P('.').with_stem, 'd')
self.assertRaises(ValueError, P('/').with_stem, 'd')
self.assertRaises(ValueError, P('a/').with_stem, 'd')
self.assertRaises(ValueError, P('a/b').with_stem, '')
self.assertRaises(ValueError, P('a/b').with_stem, '/c')
self.assertRaises(ValueError, P('a/b').with_stem, 'c/')
Expand All @@ -569,6 +601,7 @@ def test_with_suffix_common(self):
self.assertRaises(ValueError, P('').with_suffix, '.gz')
self.assertRaises(ValueError, P('.').with_suffix, '.gz')
self.assertRaises(ValueError, P('/').with_suffix, '.gz')
self.assertRaises(ValueError, P('a/').with_suffix, '.gz')
# Invalid suffix.
self.assertRaises(ValueError, P('a/b').with_suffix, 'gz')
self.assertRaises(ValueError, P('a/b').with_suffix, '/')
Expand Down Expand Up @@ -789,7 +822,8 @@ class PureWindowsPathTest(_BasePurePathTest, unittest.TestCase):
equivalences = _BasePurePathTest.equivalences.copy()
equivalences.update({
'./a:b': [ ('./a:b',) ],
'c:a': [ ('c:', 'a'), ('c:', 'a/'), ('.', 'c:', 'a') ],
'c:a': [ ('c:', 'a'), ('.', 'c:', 'a') ],
'c:a/': [ ('c:', 'a/') ],
'c:/a': [
('c:/', 'a'), ('c:', '/', 'a'), ('c:', '/a'),
('/z', 'c:/', 'a'), ('//x/y', 'c:/', 'a'),
Expand Down Expand Up @@ -819,7 +853,7 @@ def test_drive_root_parts(self):
# UNC paths.
check(('a', '//b/c', 'd'), '\\\\b\\c', '\\', ('\\\\b\\c\\', 'd'))
# Collapsing and stripping excess slashes.
check(('a', 'Z://b//c/', 'd/'), 'Z:', '\\', ('Z:\\', 'b', 'c', 'd'))
check(('a', 'Z://b//c/', 'd/'), 'Z:', '\\', ('Z:\\', 'b', 'c', 'd', ''))
# UNC paths.
check(('a', '//b/c//', 'd'), '\\\\b\\c', '\\', ('\\\\b\\c\\', 'd'))
# Extended paths.
Expand Down Expand Up @@ -970,11 +1004,15 @@ def test_parent(self):
self.assertEqual(p.parent, P('//a/b/c'))
self.assertEqual(p.parent.parent, P('//a/b'))
self.assertEqual(p.parent.parent.parent, P('//a/b'))
# Trailing slash
self.assertEqual(P('z:a/b/').parent, P('z:a/b'))
self.assertEqual(P('z:/a/b/').parent, P('z:/a/b'))
self.assertEqual(P('//a/b/c/d/').parent, P('//a/b/c/d'))

def test_parents(self):
# Anchored
P = self.cls
p = P('z:a/b/')
p = P('z:a/b')
par = p.parents
self.assertEqual(len(par), 2)
self.assertEqual(par[0], P('z:a'))
Expand All @@ -988,7 +1026,7 @@ def test_parents(self):
self.assertEqual(list(par), [P('z:a'), P('z:')])
with self.assertRaises(IndexError):
par[2]
p = P('z:/a/b/')
p = P('z:/a/b')
par = p.parents
self.assertEqual(len(par), 2)
self.assertEqual(par[0], P('z:/a'))
Expand Down Expand Up @@ -1016,6 +1054,10 @@ def test_parents(self):
self.assertEqual(list(par), [P('//a/b/c'), P('//a/b')])
with self.assertRaises(IndexError):
par[2]
# Trailing slash
self.assertEqual(P('z:a/b/').parents[:], (P('z:a/b'), P('z:a'), P('z:')))
self.assertEqual(P('z:/a/b/').parents[:], (P('z:/a/b'), P('z:/a'), P('z:/')))
self.assertEqual(P('//a/b/c/d/').parents[:], (P('//a/b/c/d'), P('//a/b/c'), P('//a/b/')))

def test_drive(self):
P = self.cls
Expand Down Expand Up @@ -1732,13 +1774,13 @@ def test_write_text_with_newlines(self):

def test_iterdir(self):
P = self.cls
p = P(BASE)
it = p.iterdir()
paths = set(it)
expected = ['dirA', 'dirB', 'dirC', 'dirE', 'fileA']
if os_helper.can_symlink():
expected += ['linkA', 'linkB', 'brokenLink', 'brokenLinkLoop']
self.assertEqual(paths, { P(BASE, q) for q in expected })
for p in [P(BASE), P(BASE, '')]:
it = p.iterdir()
paths = set(it)
expected = ['dirA', 'dirB', 'dirC', 'dirE', 'fileA']
if os_helper.can_symlink():
expected += ['linkA', 'linkB', 'brokenLink', 'brokenLinkLoop']
self.assertEqual(paths, { P(BASE, q) for q in expected })

@os_helper.skip_unless_symlink
def test_iterdir_symlink(self):
Expand Down Expand Up @@ -1790,7 +1832,7 @@ def _check(glob, expected):

def test_rglob_common(self):
def _check(glob, expected):
self.assertEqual(set(glob), { P(BASE, q) for q in expected })
self.assertEqual(set(glob), { P(BASE, q) if q else P(BASE) for q in expected })
P = self.cls
p = P(BASE)
it = p.rglob("fileA")
Expand Down
13 changes: 8 additions & 5 deletions Lib/zipfile/_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,23 +298,26 @@ def open(self, mode='r', *args, pwd=None, **kwargs):

@property
def name(self):
return pathlib.Path(self.at).name or self.filename.name
return pathlib.Path(self.at.rstrip("/")).name or self.filename.name

@property
def suffix(self):
return pathlib.Path(self.at).suffix or self.filename.suffix
return pathlib.Path(self.at.rstrip("/")).suffix or self.filename.suffix

@property
def suffixes(self):
return pathlib.Path(self.at).suffixes or self.filename.suffixes
return pathlib.Path(self.at.rstrip("/")).suffixes or self.filename.suffixes

@property
def stem(self):
return pathlib.Path(self.at).stem or self.filename.stem
return pathlib.Path(self.at.rstrip("/")).stem or self.filename.stem

@property
def filename(self):
return pathlib.Path(self.root.filename).joinpath(self.at)
path = pathlib.Path(self.root.filename)
if self.at:
path = path.joinpath(self.at.rstrip("/"))
return path

def read_text(self, *args, **kwargs):
encoding, args, kwargs = _extract_text_encoding(*args, **kwargs)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix issue where :mod:`pathlib` did not preserve trailing slashes.