Skip to content

Commit 49aaee7

Browse files
authored
pathlib ABCs: restore relative_to() and is_relative_to() (python#138853)
Restore `JoinablePath.[is_]relative_to()`, which were deleted in ef63cca. These methods are too useful to forgo. Restore old tests, and add new tests covering path classes with non-overridden `__eq__()` and `__hash__()`. Slightly simplify `PurePath.relative_to()` while we're in the area. No change to public APIs, because the pathlib ABCs are still private.
1 parent b881df4 commit 49aaee7

File tree

3 files changed

+87
-2
lines changed

3 files changed

+87
-2
lines changed

Lib/pathlib/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -490,16 +490,19 @@ def relative_to(self, other, *, walk_up=False):
490490
"""
491491
if not hasattr(other, 'with_segments'):
492492
other = self.with_segments(other)
493-
for step, path in enumerate(chain([other], other.parents)):
493+
parts = []
494+
for path in chain([other], other.parents):
494495
if path == self or path in self.parents:
495496
break
496497
elif not walk_up:
497498
raise ValueError(f"{str(self)!r} is not in the subpath of {str(other)!r}")
498499
elif path.name == '..':
499500
raise ValueError(f"'..' segment in {str(other)!r} cannot be walked")
501+
else:
502+
parts.append('..')
500503
else:
501504
raise ValueError(f"{str(self)!r} and {str(other)!r} have different anchors")
502-
parts = ['..'] * step + self._tail[len(path._tail):]
505+
parts.extend(self._tail[len(path._tail):])
503506
return self._from_parsed_parts('', '', parts)
504507

505508
def is_relative_to(self, other):

Lib/pathlib/types.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,33 @@ def parents(self):
234234
parent = split(path)[0]
235235
return tuple(parents)
236236

237+
def relative_to(self, other, *, walk_up=False):
238+
"""Return the relative path to another path identified by the passed
239+
arguments. If the operation is not possible (because this is not
240+
related to the other path), raise ValueError.
241+
242+
The *walk_up* parameter controls whether `..` may be used to resolve
243+
the path.
244+
"""
245+
parts = []
246+
for path in (other,) + other.parents:
247+
if self.is_relative_to(path):
248+
break
249+
elif not walk_up:
250+
raise ValueError(f"{self!r} is not in the subpath of {other!r}")
251+
elif path.name == '..':
252+
raise ValueError(f"'..' segment in {other!r} cannot be walked")
253+
else:
254+
parts.append('..')
255+
else:
256+
raise ValueError(f"{self!r} and {other!r} have different anchors")
257+
return self.with_segments(*parts, *self.parts[len(path.parts):])
258+
259+
def is_relative_to(self, other):
260+
"""Return True if the path is relative to another path or False.
261+
"""
262+
return other == self or other in self.parents
263+
237264
def full_match(self, pattern):
238265
"""
239266
Return True if this path matches the given glob-style pattern. The

Lib/test/test_pathlib/test_join.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,61 @@ def test_with_suffix(self):
354354
self.assertRaises(ValueError, P('a/b').with_suffix, '.d/.')
355355
self.assertRaises(TypeError, P('a/b').with_suffix, None)
356356

357+
def test_relative_to(self):
358+
P = self.cls
359+
p = P('a/b')
360+
self.assertEqual(p.relative_to(P('')), P('a', 'b'))
361+
self.assertEqual(p.relative_to(P('a')), P('b'))
362+
self.assertEqual(p.relative_to(P('a/b')), P(''))
363+
self.assertEqual(p.relative_to(P(''), walk_up=True), P('a', 'b'))
364+
self.assertEqual(p.relative_to(P('a'), walk_up=True), P('b'))
365+
self.assertEqual(p.relative_to(P('a/b'), walk_up=True), P(''))
366+
self.assertEqual(p.relative_to(P('a/c'), walk_up=True), P('..', 'b'))
367+
self.assertEqual(p.relative_to(P('a/b/c'), walk_up=True), P('..'))
368+
self.assertEqual(p.relative_to(P('c'), walk_up=True), P('..', 'a', 'b'))
369+
self.assertRaises(ValueError, p.relative_to, P('c'))
370+
self.assertRaises(ValueError, p.relative_to, P('a/b/c'))
371+
self.assertRaises(ValueError, p.relative_to, P('a/c'))
372+
self.assertRaises(ValueError, p.relative_to, P('/a'))
373+
self.assertRaises(ValueError, p.relative_to, P('../a'))
374+
self.assertRaises(ValueError, p.relative_to, P('a/..'))
375+
self.assertRaises(ValueError, p.relative_to, P('/a/..'))
376+
self.assertRaises(ValueError, p.relative_to, P('/'), walk_up=True)
377+
self.assertRaises(ValueError, p.relative_to, P('/a'), walk_up=True)
378+
self.assertRaises(ValueError, p.relative_to, P('../a'), walk_up=True)
379+
self.assertRaises(ValueError, p.relative_to, P('a/..'), walk_up=True)
380+
self.assertRaises(ValueError, p.relative_to, P('/a/..'), walk_up=True)
381+
class Q(self.cls):
382+
__eq__ = object.__eq__
383+
__hash__ = object.__hash__
384+
q = Q('a/b')
385+
self.assertTrue(q.relative_to(q))
386+
self.assertRaises(ValueError, q.relative_to, Q(''))
387+
self.assertRaises(ValueError, q.relative_to, Q('a'))
388+
self.assertRaises(ValueError, q.relative_to, Q('a'), walk_up=True)
389+
self.assertRaises(ValueError, q.relative_to, Q('a/b'))
390+
self.assertRaises(ValueError, q.relative_to, Q('c'))
391+
392+
def test_is_relative_to(self):
393+
P = self.cls
394+
p = P('a/b')
395+
self.assertTrue(p.is_relative_to(P('')))
396+
self.assertTrue(p.is_relative_to(P('a')))
397+
self.assertTrue(p.is_relative_to(P('a/b')))
398+
self.assertFalse(p.is_relative_to(P('c')))
399+
self.assertFalse(p.is_relative_to(P('a/b/c')))
400+
self.assertFalse(p.is_relative_to(P('a/c')))
401+
self.assertFalse(p.is_relative_to(P('/a')))
402+
class Q(self.cls):
403+
__eq__ = object.__eq__
404+
__hash__ = object.__hash__
405+
q = Q('a/b')
406+
self.assertTrue(q.is_relative_to(q))
407+
self.assertFalse(q.is_relative_to(Q('')))
408+
self.assertFalse(q.is_relative_to(Q('a')))
409+
self.assertFalse(q.is_relative_to(Q('a/b')))
410+
self.assertFalse(q.is_relative_to(Q('c')))
411+
357412

358413
class LexicalPathJoinTest(JoinTestBase, unittest.TestCase):
359414
cls = LexicalPath

0 commit comments

Comments
 (0)