Skip to content

Commit e089f23

Browse files
authored
gh-84538: add strict argument to pathlib.PurePath.relative_to (GH-19813)
By default, :meth:`pathlib.PurePath.relative_to` doesn't deal with paths that are not a direct prefix of the other, raising an exception in that instance. This change adds a *walk_up* parameter that can be set to allow for using ``..`` to calculate the relative path. example: ``` >>> p = PurePosixPath('/etc/passwd') >>> p.relative_to('/etc') PurePosixPath('passwd') >>> p.relative_to('/usr') Traceback (most recent call last): File "<stdin>", line 1, in <module> File "pathlib.py", line 940, in relative_to raise ValueError(error_message.format(str(self), str(formatted))) ValueError: '/etc/passwd' does not start with '/usr' >>> p.relative_to('/usr', strict=False) PurePosixPath('../etc/passwd') ``` https://bugs.python.org/issue40358 Automerge-Triggered-By: GH:brettcannon
1 parent 72fa57a commit e089f23

File tree

6 files changed

+150
-21
lines changed

6 files changed

+150
-21
lines changed

Doc/library/pathlib.rst

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -564,10 +564,10 @@ Pure paths provide the following methods and properties:
564564
True
565565

566566

567-
.. method:: PurePath.relative_to(*other)
567+
.. method:: PurePath.relative_to(*other, walk_up=False)
568568

569569
Compute a version of this path relative to the path represented by
570-
*other*. If it's impossible, ValueError is raised::
570+
*other*. If it's impossible, :exc:`ValueError` is raised::
571571

572572
>>> p = PurePosixPath('/etc/passwd')
573573
>>> p.relative_to('/')
@@ -577,11 +577,33 @@ Pure paths provide the following methods and properties:
577577
>>> p.relative_to('/usr')
578578
Traceback (most recent call last):
579579
File "<stdin>", line 1, in <module>
580-
File "pathlib.py", line 694, in relative_to
581-
.format(str(self), str(formatted)))
582-
ValueError: '/etc/passwd' is not in the subpath of '/usr' OR one path is relative and the other absolute.
580+
File "pathlib.py", line 941, in relative_to
581+
raise ValueError(error_message.format(str(self), str(formatted)))
582+
ValueError: '/etc/passwd' is not in the subpath of '/usr' OR one path is relative and the other is absolute.
583+
584+
When *walk_up* is False (the default), the path must start with *other*.
585+
When the argument is True, ``..`` entries may be added to form the
586+
relative path. In all other cases, such as the paths referencing
587+
different drives, :exc:`ValueError` is raised.::
588+
589+
>>> p.relative_to('/usr', walk_up=True)
590+
PurePosixPath('../etc/passwd')
591+
>>> p.relative_to('foo', walk_up=True)
592+
Traceback (most recent call last):
593+
File "<stdin>", line 1, in <module>
594+
File "pathlib.py", line 941, in relative_to
595+
raise ValueError(error_message.format(str(self), str(formatted)))
596+
ValueError: '/etc/passwd' is not on the same drive as 'foo' OR one path is relative and the other is absolute.
583597

584-
NOTE: This function is part of :class:`PurePath` and works with strings. It does not check or access the underlying file structure.
598+
.. warning::
599+
This function is part of :class:`PurePath` and works with strings.
600+
It does not check or access the underlying file structure.
601+
This can impact the *walk_up* option as it assumes that no symlinks
602+
are present in the path; call :meth:`~Path.resolve` first if
603+
necessary to resolve symlinks.
604+
605+
.. versionadded:: 3.12
606+
The *walk_up* argument (old behavior is the same as ``walk_up=False``).
585607

586608

587609
.. method:: PurePath.with_name(name)

Doc/whatsnew/3.12.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,11 @@ pathlib
149149
all file or directory names within them, similar to :func:`os.walk`.
150150
(Contributed by Stanislav Zmiev in :gh:`90385`.)
151151

152+
* Add *walk_up* optional parameter to :meth:`pathlib.PurePath.relative_to`
153+
to allow the insertion of ``..`` entries in the result; this behavior is
154+
more consistent with :func:`os.path.relpath`.
155+
(Contributed by Domenico Ragusa in :issue:`40358`.)
156+
152157
dis
153158
---
154159

Lib/pathlib.py

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -626,10 +626,13 @@ def with_suffix(self, suffix):
626626
return self._from_parsed_parts(self._drv, self._root,
627627
self._parts[:-1] + [name])
628628

629-
def relative_to(self, *other):
629+
def relative_to(self, *other, walk_up=False):
630630
"""Return the relative path to another path identified by the passed
631631
arguments. If the operation is not possible (because this is not
632-
a subpath of the other path), raise ValueError.
632+
related to the other path), raise ValueError.
633+
634+
The *walk_up* parameter controls whether `..` may be used to resolve
635+
the path.
633636
"""
634637
# For the purpose of this method, drive and root are considered
635638
# separate parts, i.e.:
@@ -644,20 +647,35 @@ def relative_to(self, *other):
644647
abs_parts = [drv, root] + parts[1:]
645648
else:
646649
abs_parts = parts
647-
to_drv, to_root, to_parts = self._parse_args(other)
648-
if to_root:
649-
to_abs_parts = [to_drv, to_root] + to_parts[1:]
650+
other_drv, other_root, other_parts = self._parse_args(other)
651+
if other_root:
652+
other_abs_parts = [other_drv, other_root] + other_parts[1:]
653+
else:
654+
other_abs_parts = other_parts
655+
num_parts = len(other_abs_parts)
656+
casefold = self._flavour.casefold_parts
657+
num_common_parts = 0
658+
for part, other_part in zip(casefold(abs_parts), casefold(other_abs_parts)):
659+
if part != other_part:
660+
break
661+
num_common_parts += 1
662+
if walk_up:
663+
failure = root != other_root
664+
if drv or other_drv:
665+
failure = casefold([drv]) != casefold([other_drv]) or (failure and num_parts > 1)
666+
error_message = "{!r} is not on the same drive as {!r}"
667+
up_parts = (num_parts-num_common_parts)*['..']
650668
else:
651-
to_abs_parts = to_parts
652-
n = len(to_abs_parts)
653-
cf = self._flavour.casefold_parts
654-
if (root or drv) if n == 0 else cf(abs_parts[:n]) != cf(to_abs_parts):
655-
formatted = self._format_parsed_parts(to_drv, to_root, to_parts)
656-
raise ValueError("{!r} is not in the subpath of {!r}"
657-
" OR one path is relative and the other is absolute."
658-
.format(str(self), str(formatted)))
659-
return self._from_parsed_parts('', root if n == 1 else '',
660-
abs_parts[n:])
669+
failure = (root or drv) if num_parts == 0 else num_common_parts != num_parts
670+
error_message = "{!r} is not in the subpath of {!r}"
671+
up_parts = []
672+
error_message += " OR one path is relative and the other is absolute."
673+
if failure:
674+
formatted = self._format_parsed_parts(other_drv, other_root, other_parts)
675+
raise ValueError(error_message.format(str(self), str(formatted)))
676+
path_parts = up_parts + abs_parts[num_common_parts:]
677+
new_root = root if num_common_parts == 1 else ''
678+
return self._from_parsed_parts('', new_root, path_parts)
661679

662680
def is_relative_to(self, *other):
663681
"""Return True if the path is relative to another path or False.

Lib/test/test_pathlib.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,13 +640,29 @@ def test_relative_to_common(self):
640640
self.assertEqual(p.relative_to('a/'), P('b'))
641641
self.assertEqual(p.relative_to(P('a/b')), P())
642642
self.assertEqual(p.relative_to('a/b'), P())
643+
self.assertEqual(p.relative_to(P(), walk_up=True), P('a/b'))
644+
self.assertEqual(p.relative_to('', walk_up=True), P('a/b'))
645+
self.assertEqual(p.relative_to(P('a'), walk_up=True), P('b'))
646+
self.assertEqual(p.relative_to('a', walk_up=True), P('b'))
647+
self.assertEqual(p.relative_to('a/', walk_up=True), P('b'))
648+
self.assertEqual(p.relative_to(P('a/b'), walk_up=True), P())
649+
self.assertEqual(p.relative_to('a/b', walk_up=True), P())
650+
self.assertEqual(p.relative_to(P('a/c'), walk_up=True), P('../b'))
651+
self.assertEqual(p.relative_to('a/c', walk_up=True), P('../b'))
652+
self.assertEqual(p.relative_to(P('a/b/c'), walk_up=True), P('..'))
653+
self.assertEqual(p.relative_to('a/b/c', walk_up=True), P('..'))
654+
self.assertEqual(p.relative_to(P('c'), walk_up=True), P('../a/b'))
655+
self.assertEqual(p.relative_to('c', walk_up=True), P('../a/b'))
643656
# With several args.
644657
self.assertEqual(p.relative_to('a', 'b'), P())
658+
self.assertEqual(p.relative_to('a', 'b', walk_up=True), P())
645659
# Unrelated paths.
646660
self.assertRaises(ValueError, p.relative_to, P('c'))
647661
self.assertRaises(ValueError, p.relative_to, P('a/b/c'))
648662
self.assertRaises(ValueError, p.relative_to, P('a/c'))
649663
self.assertRaises(ValueError, p.relative_to, P('/a'))
664+
self.assertRaises(ValueError, p.relative_to, P('/'), walk_up=True)
665+
self.assertRaises(ValueError, p.relative_to, P('/a'), walk_up=True)
650666
p = P('/a/b')
651667
self.assertEqual(p.relative_to(P('/')), P('a/b'))
652668
self.assertEqual(p.relative_to('/'), P('a/b'))
@@ -655,13 +671,28 @@ def test_relative_to_common(self):
655671
self.assertEqual(p.relative_to('/a/'), P('b'))
656672
self.assertEqual(p.relative_to(P('/a/b')), P())
657673
self.assertEqual(p.relative_to('/a/b'), P())
674+
self.assertEqual(p.relative_to(P('/'), walk_up=True), P('a/b'))
675+
self.assertEqual(p.relative_to('/', walk_up=True), P('a/b'))
676+
self.assertEqual(p.relative_to(P('/a'), walk_up=True), P('b'))
677+
self.assertEqual(p.relative_to('/a', walk_up=True), P('b'))
678+
self.assertEqual(p.relative_to('/a/', walk_up=True), P('b'))
679+
self.assertEqual(p.relative_to(P('/a/b'), walk_up=True), P())
680+
self.assertEqual(p.relative_to('/a/b', walk_up=True), P())
681+
self.assertEqual(p.relative_to(P('/a/c'), walk_up=True), P('../b'))
682+
self.assertEqual(p.relative_to('/a/c', walk_up=True), P('../b'))
683+
self.assertEqual(p.relative_to(P('/a/b/c'), walk_up=True), P('..'))
684+
self.assertEqual(p.relative_to('/a/b/c', walk_up=True), P('..'))
685+
self.assertEqual(p.relative_to(P('/c'), walk_up=True), P('../a/b'))
686+
self.assertEqual(p.relative_to('/c', walk_up=True), P('../a/b'))
658687
# Unrelated paths.
659688
self.assertRaises(ValueError, p.relative_to, P('/c'))
660689
self.assertRaises(ValueError, p.relative_to, P('/a/b/c'))
661690
self.assertRaises(ValueError, p.relative_to, P('/a/c'))
662691
self.assertRaises(ValueError, p.relative_to, P())
663692
self.assertRaises(ValueError, p.relative_to, '')
664693
self.assertRaises(ValueError, p.relative_to, P('a'))
694+
self.assertRaises(ValueError, p.relative_to, P(''), walk_up=True)
695+
self.assertRaises(ValueError, p.relative_to, P('a'), walk_up=True)
665696

666697
def test_is_relative_to_common(self):
667698
P = self.cls
@@ -1124,6 +1155,16 @@ def test_relative_to(self):
11241155
self.assertEqual(p.relative_to('c:foO/'), P('Bar'))
11251156
self.assertEqual(p.relative_to(P('c:foO/baR')), P())
11261157
self.assertEqual(p.relative_to('c:foO/baR'), P())
1158+
self.assertEqual(p.relative_to(P('c:'), walk_up=True), P('Foo/Bar'))
1159+
self.assertEqual(p.relative_to('c:', walk_up=True), P('Foo/Bar'))
1160+
self.assertEqual(p.relative_to(P('c:foO'), walk_up=True), P('Bar'))
1161+
self.assertEqual(p.relative_to('c:foO', walk_up=True), P('Bar'))
1162+
self.assertEqual(p.relative_to('c:foO/', walk_up=True), P('Bar'))
1163+
self.assertEqual(p.relative_to(P('c:foO/baR'), walk_up=True), P())
1164+
self.assertEqual(p.relative_to('c:foO/baR', walk_up=True), P())
1165+
self.assertEqual(p.relative_to(P('C:Foo/Bar/Baz'), walk_up=True), P('..'))
1166+
self.assertEqual(p.relative_to(P('C:Foo/Baz'), walk_up=True), P('../Bar'))
1167+
self.assertEqual(p.relative_to(P('C:Baz/Bar'), walk_up=True), P('../../Foo/Bar'))
11271168
# Unrelated paths.
11281169
self.assertRaises(ValueError, p.relative_to, P())
11291170
self.assertRaises(ValueError, p.relative_to, '')
@@ -1134,6 +1175,13 @@ def test_relative_to(self):
11341175
self.assertRaises(ValueError, p.relative_to, P('C:/Foo'))
11351176
self.assertRaises(ValueError, p.relative_to, P('C:Foo/Bar/Baz'))
11361177
self.assertRaises(ValueError, p.relative_to, P('C:Foo/Baz'))
1178+
self.assertRaises(ValueError, p.relative_to, P(), walk_up=True)
1179+
self.assertRaises(ValueError, p.relative_to, '', walk_up=True)
1180+
self.assertRaises(ValueError, p.relative_to, P('d:'), walk_up=True)
1181+
self.assertRaises(ValueError, p.relative_to, P('/'), walk_up=True)
1182+
self.assertRaises(ValueError, p.relative_to, P('Foo'), walk_up=True)
1183+
self.assertRaises(ValueError, p.relative_to, P('/Foo'), walk_up=True)
1184+
self.assertRaises(ValueError, p.relative_to, P('C:/Foo'), walk_up=True)
11371185
p = P('C:/Foo/Bar')
11381186
self.assertEqual(p.relative_to(P('c:')), P('/Foo/Bar'))
11391187
self.assertEqual(p.relative_to('c:'), P('/Foo/Bar'))
@@ -1146,6 +1194,20 @@ def test_relative_to(self):
11461194
self.assertEqual(p.relative_to('c:/foO/'), P('Bar'))
11471195
self.assertEqual(p.relative_to(P('c:/foO/baR')), P())
11481196
self.assertEqual(p.relative_to('c:/foO/baR'), P())
1197+
self.assertEqual(p.relative_to(P('c:'), walk_up=True), P('/Foo/Bar'))
1198+
self.assertEqual(p.relative_to('c:', walk_up=True), P('/Foo/Bar'))
1199+
self.assertEqual(str(p.relative_to(P('c:'), walk_up=True)), '\\Foo\\Bar')
1200+
self.assertEqual(str(p.relative_to('c:', walk_up=True)), '\\Foo\\Bar')
1201+
self.assertEqual(p.relative_to(P('c:/'), walk_up=True), P('Foo/Bar'))
1202+
self.assertEqual(p.relative_to('c:/', walk_up=True), P('Foo/Bar'))
1203+
self.assertEqual(p.relative_to(P('c:/foO'), walk_up=True), P('Bar'))
1204+
self.assertEqual(p.relative_to('c:/foO', walk_up=True), P('Bar'))
1205+
self.assertEqual(p.relative_to('c:/foO/', walk_up=True), P('Bar'))
1206+
self.assertEqual(p.relative_to(P('c:/foO/baR'), walk_up=True), P())
1207+
self.assertEqual(p.relative_to('c:/foO/baR', walk_up=True), P())
1208+
self.assertEqual(p.relative_to('C:/Baz', walk_up=True), P('../Foo/Bar'))
1209+
self.assertEqual(p.relative_to('C:/Foo/Bar/Baz', walk_up=True), P('..'))
1210+
self.assertEqual(p.relative_to('C:/Foo/Baz', walk_up=True), P('../Bar'))
11491211
# Unrelated paths.
11501212
self.assertRaises(ValueError, p.relative_to, P('C:/Baz'))
11511213
self.assertRaises(ValueError, p.relative_to, P('C:/Foo/Bar/Baz'))
@@ -1156,6 +1218,12 @@ def test_relative_to(self):
11561218
self.assertRaises(ValueError, p.relative_to, P('/'))
11571219
self.assertRaises(ValueError, p.relative_to, P('/Foo'))
11581220
self.assertRaises(ValueError, p.relative_to, P('//C/Foo'))
1221+
self.assertRaises(ValueError, p.relative_to, P('C:Foo'), walk_up=True)
1222+
self.assertRaises(ValueError, p.relative_to, P('d:'), walk_up=True)
1223+
self.assertRaises(ValueError, p.relative_to, P('d:/'), walk_up=True)
1224+
self.assertRaises(ValueError, p.relative_to, P('/'), walk_up=True)
1225+
self.assertRaises(ValueError, p.relative_to, P('/Foo'), walk_up=True)
1226+
self.assertRaises(ValueError, p.relative_to, P('//C/Foo'), walk_up=True)
11591227
# UNC paths.
11601228
p = P('//Server/Share/Foo/Bar')
11611229
self.assertEqual(p.relative_to(P('//sErver/sHare')), P('Foo/Bar'))
@@ -1166,11 +1234,25 @@ def test_relative_to(self):
11661234
self.assertEqual(p.relative_to('//sErver/sHare/Foo/'), P('Bar'))
11671235
self.assertEqual(p.relative_to(P('//sErver/sHare/Foo/Bar')), P())
11681236
self.assertEqual(p.relative_to('//sErver/sHare/Foo/Bar'), P())
1237+
self.assertEqual(p.relative_to(P('//sErver/sHare'), walk_up=True), P('Foo/Bar'))
1238+
self.assertEqual(p.relative_to('//sErver/sHare', walk_up=True), P('Foo/Bar'))
1239+
self.assertEqual(p.relative_to('//sErver/sHare/', walk_up=True), P('Foo/Bar'))
1240+
self.assertEqual(p.relative_to(P('//sErver/sHare/Foo'), walk_up=True), P('Bar'))
1241+
self.assertEqual(p.relative_to('//sErver/sHare/Foo', walk_up=True), P('Bar'))
1242+
self.assertEqual(p.relative_to('//sErver/sHare/Foo/', walk_up=True), P('Bar'))
1243+
self.assertEqual(p.relative_to(P('//sErver/sHare/Foo/Bar'), walk_up=True), P())
1244+
self.assertEqual(p.relative_to('//sErver/sHare/Foo/Bar', walk_up=True), P())
1245+
self.assertEqual(p.relative_to(P('//sErver/sHare/bar'), walk_up=True), P('../Foo/Bar'))
1246+
self.assertEqual(p.relative_to('//sErver/sHare/bar', walk_up=True), P('../Foo/Bar'))
11691247
# Unrelated paths.
11701248
self.assertRaises(ValueError, p.relative_to, P('/Server/Share/Foo'))
11711249
self.assertRaises(ValueError, p.relative_to, P('c:/Server/Share/Foo'))
11721250
self.assertRaises(ValueError, p.relative_to, P('//z/Share/Foo'))
11731251
self.assertRaises(ValueError, p.relative_to, P('//Server/z/Foo'))
1252+
self.assertRaises(ValueError, p.relative_to, P('/Server/Share/Foo'), walk_up=True)
1253+
self.assertRaises(ValueError, p.relative_to, P('c:/Server/Share/Foo'), walk_up=True)
1254+
self.assertRaises(ValueError, p.relative_to, P('//z/Share/Foo'), walk_up=True)
1255+
self.assertRaises(ValueError, p.relative_to, P('//Server/z/Foo'), walk_up=True)
11741256

11751257
def test_is_relative_to(self):
11761258
P = self.cls

Misc/ACKS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1440,6 +1440,7 @@ Pierre Quentel
14401440
Brian Quinlan
14411441
Anders Qvist
14421442
Thomas Rachel
1443+
Domenico Ragusa
14431444
Ram Rachum
14441445
Jeffrey Rackauckas
14451446
Jérôme Radix
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add walk_up argument in :meth:`pathlib.PurePath.relative_to`.

0 commit comments

Comments
 (0)