Skip to content

Commit e2e0a49

Browse files
committed
pythonGH-65238: Fix stripping of trailing slash in pathlib
This brings pathlib in line with *IEEE Std 1003.1-2017*, where trailing slashes are meaningful to path resolution and should not be discarded. See https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap04.html#tag_04_13
1 parent 2c673d5 commit e2e0a49

File tree

4 files changed

+68
-17
lines changed

4 files changed

+68
-17
lines changed

Lib/importlib/metadata/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -748,7 +748,10 @@ def read_text(self, filename):
748748
NotADirectoryError,
749749
PermissionError,
750750
):
751-
return self._path.joinpath(filename).read_text(encoding='utf-8')
751+
path = self._path
752+
if filename:
753+
path /= filename
754+
return path.read_text(encoding='utf-8')
752755

753756
read_text.__doc__ = Distribution.read_text.__doc__
754757

Lib/pathlib.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,9 @@ def _parse_path(cls, path):
326326
# pathlib assumes that UNC paths always have a root.
327327
root = sep
328328
parsed = [sys.intern(str(x)) for x in rel.split(sep) if x and x != '.']
329+
if parsed and not rel.endswith(parsed[-1]):
330+
# Preserve trailing slash
331+
parsed.append('')
329332
return drv, root, parsed
330333

331334
def _load_parts(self):
@@ -578,6 +581,9 @@ def relative_to(self, other, /, *_deprecated, walk_up=False):
578581
remove=(3, 14))
579582
path_cls = type(self)
580583
other = path_cls(other, *_deprecated)
584+
if not other.name:
585+
# Ignore trailing slash.
586+
other = other.parent
581587
for step, path in enumerate([other] + list(other.parents)):
582588
if self.is_relative_to(path):
583589
break
@@ -598,6 +604,9 @@ def is_relative_to(self, other, /, *_deprecated):
598604
warnings._deprecated("pathlib.PurePath.is_relative_to(*args)",
599605
msg, remove=(3, 14))
600606
other = type(self)(other, *_deprecated)
607+
if not other.name:
608+
# Ignore trailing slash.
609+
other = other.parent
601610
return other == self or other in self.parents
602611

603612
@property
@@ -825,8 +834,6 @@ def glob(self, pattern):
825834
drv, root, pattern_parts = self._parse_path(pattern)
826835
if drv or root:
827836
raise NotImplementedError("Non-relative patterns are unsupported")
828-
if pattern[-1] in (self._flavour.sep, self._flavour.altsep):
829-
pattern_parts.append('')
830837
selector = _make_selector(tuple(pattern_parts), self._flavour)
831838
for p in selector.select_from(self):
832839
yield p
@@ -840,8 +847,6 @@ def rglob(self, pattern):
840847
drv, root, pattern_parts = self._parse_path(pattern)
841848
if drv or root:
842849
raise NotImplementedError("Non-relative patterns are unsupported")
843-
if pattern and pattern[-1] in (self._flavour.sep, self._flavour.altsep):
844-
pattern_parts.append('')
845850
selector = _make_selector(("**",) + tuple(pattern_parts), self._flavour)
846851
for p in selector.select_from(self):
847852
yield p

Lib/test/test_pathlib.py

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,14 @@ class _BasePurePathTest(object):
4242
# supposed to produce equal paths.
4343
equivalences = {
4444
'a/b': [
45-
('a', 'b'), ('a/', 'b'), ('a', 'b/'), ('a/', 'b/'),
46-
('a/b/',), ('a//b',), ('a//b//',),
45+
('a', 'b'), ('a/', 'b'), ('a//b',),
4746
# Empty components get removed.
48-
('', 'a', 'b'), ('a', '', 'b'), ('a', 'b', ''),
47+
('', 'a', 'b'), ('a', '', 'b'),
4948
],
49+
'a/b/': [
50+
('a', 'b/'), ('a/', 'b/'), ('a/b/',),
51+
('a//b//',), ('a', 'b', ''),
52+
],
5053
'/b/c/d': [
5154
('a', '/b/c', 'd'), ('/a', '/b/c', 'd'),
5255
# Empty components get removed.
@@ -154,11 +157,11 @@ def test_drive_root_parts_common(self):
154157
# Unanchored parts.
155158
check((), '', '', ())
156159
check(('a',), '', '', ('a',))
157-
check(('a/',), '', '', ('a',))
160+
check(('a/',), '', '', ('a', ''))
158161
check(('a', 'b'), '', '', ('a', 'b'))
159162
# Expansion.
160163
check(('a/b',), '', '', ('a', 'b'))
161-
check(('a/b/',), '', '', ('a', 'b'))
164+
check(('a/b/',), '', '', ('a', 'b', ''))
162165
check(('a', 'b/c', 'd'), '', '', ('a', 'b', 'c', 'd'))
163166
# Collapsing and stripping excess slashes.
164167
check(('a', 'b//c', 'd'), '', '', ('a', 'b', 'c', 'd'))
@@ -167,7 +170,7 @@ def test_drive_root_parts_common(self):
167170
check(('.',), '', '', ())
168171
check(('.', '.', 'b'), '', '', ('b',))
169172
check(('a', '.', 'b'), '', '', ('a', 'b'))
170-
check(('a', '.', '.'), '', '', ('a',))
173+
check(('a', '.', '.'), '', '', ('a', ''))
171174
# The first part is anchored.
172175
check(('/a/b',), '', sep, (sep, 'a', 'b'))
173176
check(('/a', 'b'), '', sep, (sep, 'a', 'b'))
@@ -188,6 +191,24 @@ def test_join_common(self):
188191
self.assertEqual(pp, P('a/b/c'))
189192
pp = p.joinpath('/c')
190193
self.assertEqual(pp, P('/c'))
194+
pp = p.joinpath('.')
195+
self.assertEqual(pp, P('a/b/'))
196+
pp = p.joinpath('')
197+
self.assertEqual(pp, P('a/b/'))
198+
p = P('a/b/')
199+
pp = p.joinpath('c')
200+
self.assertEqual(pp, P('a/b/c'))
201+
self.assertIs(type(pp), type(p))
202+
pp = p.joinpath('c', 'd')
203+
self.assertEqual(pp, P('a/b/c/d'))
204+
pp = p.joinpath(P('c'))
205+
self.assertEqual(pp, P('a/b/c'))
206+
pp = p.joinpath('/c')
207+
self.assertEqual(pp, P('/c'))
208+
pp = p.joinpath('.')
209+
self.assertEqual(pp, P('a/b/'))
210+
pp = p.joinpath('')
211+
self.assertEqual(pp, P('a/b/'))
191212

192213
def test_div_common(self):
193214
# Basically the same as joinpath().
@@ -389,6 +410,12 @@ def test_parent_common(self):
389410
self.assertEqual(p.parent.parent, P('/a'))
390411
self.assertEqual(p.parent.parent.parent, P('/'))
391412
self.assertEqual(p.parent.parent.parent.parent, P('/'))
413+
# Trailing slash
414+
p = P('/a/b/')
415+
self.assertEqual(p.parent, P('/a/b'))
416+
self.assertEqual(p.parent.parent, P('/a'))
417+
self.assertEqual(p.parent.parent.parent, P('/'))
418+
self.assertEqual(p.parent.parent.parent.parent, P('/'))
392419

393420
def test_parents_common(self):
394421
# Relative
@@ -436,6 +463,9 @@ def test_parents_common(self):
436463
par[-4]
437464
with self.assertRaises(IndexError):
438465
par[3]
466+
# Trailing slash
467+
self.assertEqual(P('a/b/').parents[:], (P('a/b'), P('a'), P()))
468+
self.assertEqual(P('/a/b/').parents[:], (P('/a/b'), P('/a'), P('/')))
439469

440470
def test_drive_common(self):
441471
P = self.cls
@@ -466,7 +496,7 @@ def test_name_common(self):
466496
self.assertEqual(P('/').name, '')
467497
self.assertEqual(P('a/b').name, 'b')
468498
self.assertEqual(P('/a/b').name, 'b')
469-
self.assertEqual(P('/a/b/.').name, 'b')
499+
self.assertEqual(P('/a/b/.').name, '')
470500
self.assertEqual(P('a/b.py').name, 'b.py')
471501
self.assertEqual(P('/a/b.py').name, 'b.py')
472502

@@ -534,6 +564,7 @@ def test_with_name_common(self):
534564
self.assertRaises(ValueError, P('').with_name, 'd.xml')
535565
self.assertRaises(ValueError, P('.').with_name, 'd.xml')
536566
self.assertRaises(ValueError, P('/').with_name, 'd.xml')
567+
self.assertRaises(ValueError, P('a/').with_name, 'd.xml')
537568
self.assertRaises(ValueError, P('a/b').with_name, '')
538569
self.assertRaises(ValueError, P('a/b').with_name, '/c')
539570
self.assertRaises(ValueError, P('a/b').with_name, 'c/')
@@ -551,6 +582,7 @@ def test_with_stem_common(self):
551582
self.assertRaises(ValueError, P('').with_stem, 'd')
552583
self.assertRaises(ValueError, P('.').with_stem, 'd')
553584
self.assertRaises(ValueError, P('/').with_stem, 'd')
585+
self.assertRaises(ValueError, P('a/').with_stem, 'd')
554586
self.assertRaises(ValueError, P('a/b').with_stem, '')
555587
self.assertRaises(ValueError, P('a/b').with_stem, '/c')
556588
self.assertRaises(ValueError, P('a/b').with_stem, 'c/')
@@ -569,6 +601,7 @@ def test_with_suffix_common(self):
569601
self.assertRaises(ValueError, P('').with_suffix, '.gz')
570602
self.assertRaises(ValueError, P('.').with_suffix, '.gz')
571603
self.assertRaises(ValueError, P('/').with_suffix, '.gz')
604+
self.assertRaises(ValueError, P('a/').with_suffix, '.gz')
572605
# Invalid suffix.
573606
self.assertRaises(ValueError, P('a/b').with_suffix, 'gz')
574607
self.assertRaises(ValueError, P('a/b').with_suffix, '/')
@@ -789,7 +822,8 @@ class PureWindowsPathTest(_BasePurePathTest, unittest.TestCase):
789822
equivalences = _BasePurePathTest.equivalences.copy()
790823
equivalences.update({
791824
'./a:b': [ ('./a:b',) ],
792-
'c:a': [ ('c:', 'a'), ('c:', 'a/'), ('.', 'c:', 'a') ],
825+
'c:a': [ ('c:', 'a'), ('.', 'c:', 'a') ],
826+
'c:a/': [ ('c:', 'a/') ],
793827
'c:/a': [
794828
('c:/', 'a'), ('c:', '/', 'a'), ('c:', '/a'),
795829
('/z', 'c:/', 'a'), ('//x/y', 'c:/', 'a'),
@@ -819,7 +853,7 @@ def test_drive_root_parts(self):
819853
# UNC paths.
820854
check(('a', '//b/c', 'd'), '\\\\b\\c', '\\', ('\\\\b\\c\\', 'd'))
821855
# Collapsing and stripping excess slashes.
822-
check(('a', 'Z://b//c/', 'd/'), 'Z:', '\\', ('Z:\\', 'b', 'c', 'd'))
856+
check(('a', 'Z://b//c/', 'd/'), 'Z:', '\\', ('Z:\\', 'b', 'c', 'd', ''))
823857
# UNC paths.
824858
check(('a', '//b/c//', 'd'), '\\\\b\\c', '\\', ('\\\\b\\c\\', 'd'))
825859
# Extended paths.
@@ -970,11 +1004,15 @@ def test_parent(self):
9701004
self.assertEqual(p.parent, P('//a/b/c'))
9711005
self.assertEqual(p.parent.parent, P('//a/b'))
9721006
self.assertEqual(p.parent.parent.parent, P('//a/b'))
1007+
# Trailing slash
1008+
self.assertEqual(P('z:a/b/').parent, P('z:a/b'))
1009+
self.assertEqual(P('z:/a/b/').parent, P('z:/a/b'))
1010+
self.assertEqual(P('//a/b/c/d/').parent, P('//a/b/c/d'))
9731011

9741012
def test_parents(self):
9751013
# Anchored
9761014
P = self.cls
977-
p = P('z:a/b/')
1015+
p = P('z:a/b')
9781016
par = p.parents
9791017
self.assertEqual(len(par), 2)
9801018
self.assertEqual(par[0], P('z:a'))
@@ -988,7 +1026,7 @@ def test_parents(self):
9881026
self.assertEqual(list(par), [P('z:a'), P('z:')])
9891027
with self.assertRaises(IndexError):
9901028
par[2]
991-
p = P('z:/a/b/')
1029+
p = P('z:/a/b')
9921030
par = p.parents
9931031
self.assertEqual(len(par), 2)
9941032
self.assertEqual(par[0], P('z:/a'))
@@ -1016,6 +1054,10 @@ def test_parents(self):
10161054
self.assertEqual(list(par), [P('//a/b/c'), P('//a/b')])
10171055
with self.assertRaises(IndexError):
10181056
par[2]
1057+
# Trailing slash
1058+
self.assertEqual(P('z:a/b/').parents[:], (P('z:a/b'), P('z:a'), P('z:')))
1059+
self.assertEqual(P('z:/a/b/').parents[:], (P('z:/a/b'), P('z:/a'), P('z:/')))
1060+
self.assertEqual(P('//a/b/c/d/').parents[:], (P('//a/b/c/d'), P('//a/b/c'), P('//a/b/')))
10191061

10201062
def test_drive(self):
10211063
P = self.cls
@@ -1790,7 +1832,7 @@ def _check(glob, expected):
17901832

17911833
def test_rglob_common(self):
17921834
def _check(glob, expected):
1793-
self.assertEqual(set(glob), { P(BASE, q) for q in expected })
1835+
self.assertEqual(set(glob), { P(BASE, q) if q else P(BASE) for q in expected })
17941836
P = self.cls
17951837
p = P(BASE)
17961838
it = p.rglob("fileA")
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix issue where :mod:`pathlib` did not preserve trailing slashes.

0 commit comments

Comments
 (0)