Skip to content

Commit d00d942

Browse files
GH-100479: Add pathlib.PurePath.with_segments() (GH-103975)
Add `pathlib.PurePath.with_segments()`, which creates a path object from arguments. This method is called whenever a derivative path is created, such as from `pathlib.PurePath.parent`. Subclasses may override this method to share information between path objects. Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
1 parent 1afe0e0 commit d00d942

File tree

5 files changed

+108
-47
lines changed

5 files changed

+108
-47
lines changed

Doc/library/pathlib.rst

+26-2
Original file line numberDiff line numberDiff line change
@@ -530,10 +530,10 @@ Pure paths provide the following methods and properties:
530530
unintended effects.
531531

532532

533-
.. method:: PurePath.joinpath(*other)
533+
.. method:: PurePath.joinpath(*pathsegments)
534534

535535
Calling this method is equivalent to combining the path with each of
536-
the *other* arguments in turn::
536+
the given *pathsegments* in turn::
537537

538538
>>> PurePosixPath('/etc').joinpath('passwd')
539539
PurePosixPath('/etc/passwd')
@@ -680,6 +680,30 @@ Pure paths provide the following methods and properties:
680680
PureWindowsPath('README')
681681

682682

683+
.. method:: PurePath.with_segments(*pathsegments)
684+
685+
Create a new path object of the same type by combining the given
686+
*pathsegments*. This method is called whenever a derivative path is created,
687+
such as from :attr:`parent` and :meth:`relative_to`. Subclasses may
688+
override this method to pass information to derivative paths, for example::
689+
690+
from pathlib import PurePosixPath
691+
692+
class MyPath(PurePosixPath):
693+
def __init__(self, *pathsegments, session_id):
694+
super().__init__(*pathsegments)
695+
self.session_id = session_id
696+
697+
def with_segments(self, *pathsegments):
698+
return type(self)(*pathsegments, session_id=self.session_id)
699+
700+
etc = MyPath('/etc', session_id=42)
701+
hosts = etc / 'hosts'
702+
print(hosts.session_id) # 42
703+
704+
.. versionadded:: 3.12
705+
706+
683707
.. _concrete-paths:
684708

685709

Doc/whatsnew/3.12.rst

+5
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,11 @@ inspect
348348
pathlib
349349
-------
350350

351+
* Add support for subclassing :class:`pathlib.PurePath` and
352+
:class:`~pathlib.Path`, plus their Posix- and Windows-specific variants.
353+
Subclasses may override the :meth:`~pathlib.PurePath.with_segments` method
354+
to pass information between path instances.
355+
351356
* Add :meth:`~pathlib.Path.walk` for walking the directory trees and generating
352357
all file or directory names within them, similar to :func:`os.walk`.
353358
(Contributed by Stanislav Zmiev in :gh:`90385`.)

Lib/pathlib.py

+36-30
Original file line numberDiff line numberDiff line change
@@ -204,11 +204,10 @@ def _select_from(self, parent_path, scandir):
204204
class _PathParents(Sequence):
205205
"""This object provides sequence-like access to the logical ancestors
206206
of a path. Don't try to construct it yourself."""
207-
__slots__ = ('_pathcls', '_drv', '_root', '_tail')
207+
__slots__ = ('_path', '_drv', '_root', '_tail')
208208

209209
def __init__(self, path):
210-
# We don't store the instance to avoid reference cycles
211-
self._pathcls = type(path)
210+
self._path = path
212211
self._drv = path.drive
213212
self._root = path.root
214213
self._tail = path._tail
@@ -224,11 +223,11 @@ def __getitem__(self, idx):
224223
raise IndexError(idx)
225224
if idx < 0:
226225
idx += len(self)
227-
return self._pathcls._from_parsed_parts(self._drv, self._root,
228-
self._tail[:-idx - 1])
226+
return self._path._from_parsed_parts(self._drv, self._root,
227+
self._tail[:-idx - 1])
229228

230229
def __repr__(self):
231-
return "<{}.parents>".format(self._pathcls.__name__)
230+
return "<{}.parents>".format(type(self._path).__name__)
232231

233232

234233
class PurePath(object):
@@ -316,6 +315,13 @@ def __init__(self, *args):
316315
else:
317316
self._raw_path = self._flavour.join(*paths)
318317

318+
def with_segments(self, *pathsegments):
319+
"""Construct a new path object from any number of path-like objects.
320+
Subclasses may override this method to customize how new path objects
321+
are created from methods like `iterdir()`.
322+
"""
323+
return type(self)(*pathsegments)
324+
319325
@classmethod
320326
def _parse_path(cls, path):
321327
if not path:
@@ -342,15 +348,14 @@ def _load_parts(self):
342348
self._root = root
343349
self._tail_cached = tail
344350

345-
@classmethod
346-
def _from_parsed_parts(cls, drv, root, tail):
347-
path = cls._format_parsed_parts(drv, root, tail)
348-
self = cls(path)
349-
self._str = path or '.'
350-
self._drv = drv
351-
self._root = root
352-
self._tail_cached = tail
353-
return self
351+
def _from_parsed_parts(self, drv, root, tail):
352+
path_str = self._format_parsed_parts(drv, root, tail)
353+
path = self.with_segments(path_str)
354+
path._str = path_str or '.'
355+
path._drv = drv
356+
path._root = root
357+
path._tail_cached = tail
358+
return path
354359

355360
@classmethod
356361
def _format_parsed_parts(cls, drv, root, tail):
@@ -584,8 +589,7 @@ def relative_to(self, other, /, *_deprecated, walk_up=False):
584589
"scheduled for removal in Python {remove}")
585590
warnings._deprecated("pathlib.PurePath.relative_to(*args)", msg,
586591
remove=(3, 14))
587-
path_cls = type(self)
588-
other = path_cls(other, *_deprecated)
592+
other = self.with_segments(other, *_deprecated)
589593
for step, path in enumerate([other] + list(other.parents)):
590594
if self.is_relative_to(path):
591595
break
@@ -594,7 +598,7 @@ def relative_to(self, other, /, *_deprecated, walk_up=False):
594598
if step and not walk_up:
595599
raise ValueError(f"{str(self)!r} is not in the subpath of {str(other)!r}")
596600
parts = ['..'] * step + self._tail[len(path._tail):]
597-
return path_cls(*parts)
601+
return self.with_segments(*parts)
598602

599603
def is_relative_to(self, other, /, *_deprecated):
600604
"""Return True if the path is relative to another path or False.
@@ -605,7 +609,7 @@ def is_relative_to(self, other, /, *_deprecated):
605609
"scheduled for removal in Python {remove}")
606610
warnings._deprecated("pathlib.PurePath.is_relative_to(*args)",
607611
msg, remove=(3, 14))
608-
other = type(self)(other, *_deprecated)
612+
other = self.with_segments(other, *_deprecated)
609613
return other == self or other in self.parents
610614

611615
@property
@@ -617,13 +621,13 @@ def parts(self):
617621
else:
618622
return tuple(self._tail)
619623

620-
def joinpath(self, *args):
624+
def joinpath(self, *pathsegments):
621625
"""Combine this path with one or several arguments, and return a
622626
new path representing either a subpath (if all arguments are relative
623627
paths) or a totally different path (if one of the arguments is
624628
anchored).
625629
"""
626-
return self.__class__(self, *args)
630+
return self.with_segments(self, *pathsegments)
627631

628632
def __truediv__(self, key):
629633
try:
@@ -633,7 +637,7 @@ def __truediv__(self, key):
633637

634638
def __rtruediv__(self, key):
635639
try:
636-
return type(self)(key, self)
640+
return self.with_segments(key, self)
637641
except TypeError:
638642
return NotImplemented
639643

@@ -650,6 +654,8 @@ def parent(self):
650654
@property
651655
def parents(self):
652656
"""A sequence of this path's logical parents."""
657+
# The value of this property should not be cached on the path object,
658+
# as doing so would introduce a reference cycle.
653659
return _PathParents(self)
654660

655661
def is_absolute(self):
@@ -680,7 +686,7 @@ def match(self, path_pattern):
680686
"""
681687
Return True if this path matches the given pattern.
682688
"""
683-
pat = type(self)(path_pattern)
689+
pat = self.with_segments(path_pattern)
684690
if not pat.parts:
685691
raise ValueError("empty pattern")
686692
pat_parts = pat._parts_normcase
@@ -755,7 +761,7 @@ def _make_child_relpath(self, name):
755761
path_str = f'{path_str}{name}'
756762
else:
757763
path_str = name
758-
path = type(self)(path_str)
764+
path = self.with_segments(path_str)
759765
path._str = path_str
760766
path._drv = self.drive
761767
path._root = self.root
@@ -805,7 +811,7 @@ def samefile(self, other_path):
805811
try:
806812
other_st = other_path.stat()
807813
except AttributeError:
808-
other_st = self.__class__(other_path).stat()
814+
other_st = self.with_segments(other_path).stat()
809815
return self._flavour.samestat(st, other_st)
810816

811817
def iterdir(self):
@@ -867,7 +873,7 @@ def absolute(self):
867873
cwd = self._flavour.abspath(self.drive)
868874
else:
869875
cwd = os.getcwd()
870-
return type(self)(cwd, self)
876+
return self.with_segments(cwd, self)
871877

872878
def resolve(self, strict=False):
873879
"""
@@ -885,7 +891,7 @@ def check_eloop(e):
885891
except OSError as e:
886892
check_eloop(e)
887893
raise
888-
p = type(self)(s)
894+
p = self.with_segments(s)
889895

890896
# In non-strict mode, realpath() doesn't raise on symlink loops.
891897
# Ensure we get an exception by calling stat()
@@ -975,7 +981,7 @@ def readlink(self):
975981
"""
976982
if not hasattr(os, "readlink"):
977983
raise NotImplementedError("os.readlink() not available on this system")
978-
return type(self)(os.readlink(self))
984+
return self.with_segments(os.readlink(self))
979985

980986
def touch(self, mode=0o666, exist_ok=True):
981987
"""
@@ -1064,7 +1070,7 @@ def rename(self, target):
10641070
Returns the new Path instance pointing to the target path.
10651071
"""
10661072
os.rename(self, target)
1067-
return self.__class__(target)
1073+
return self.with_segments(target)
10681074

10691075
def replace(self, target):
10701076
"""
@@ -1077,7 +1083,7 @@ def replace(self, target):
10771083
Returns the new Path instance pointing to the target path.
10781084
"""
10791085
os.replace(self, target)
1080-
return self.__class__(target)
1086+
return self.with_segments(target)
10811087

10821088
def symlink_to(self, target, target_is_directory=False):
10831089
"""

Lib/test/test_pathlib.py

+37-15
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,12 @@
2929
#
3030

3131
class _BasePurePathSubclass(object):
32-
init_called = False
32+
def __init__(self, *pathsegments, session_id):
33+
super().__init__(*pathsegments)
34+
self.session_id = session_id
3335

34-
def __init__(self, *args):
35-
super().__init__(*args)
36-
self.init_called = True
36+
def with_segments(self, *pathsegments):
37+
return type(self)(*pathsegments, session_id=self.session_id)
3738

3839

3940
class _BasePurePathTest(object):
@@ -121,20 +122,21 @@ def test_str_subclass_common(self):
121122
self._check_str_subclass('a/b.txt')
122123
self._check_str_subclass('/a/b.txt')
123124

124-
def test_init_called_common(self):
125+
def test_with_segments_common(self):
125126
class P(_BasePurePathSubclass, self.cls):
126127
pass
127-
p = P('foo', 'bar')
128-
self.assertTrue((p / 'foo').init_called)
129-
self.assertTrue(('foo' / p).init_called)
130-
self.assertTrue(p.joinpath('foo').init_called)
131-
self.assertTrue(p.with_name('foo').init_called)
132-
self.assertTrue(p.with_stem('foo').init_called)
133-
self.assertTrue(p.with_suffix('.foo').init_called)
134-
self.assertTrue(p.relative_to('foo').init_called)
135-
self.assertTrue(p.parent.init_called)
128+
p = P('foo', 'bar', session_id=42)
129+
self.assertEqual(42, (p / 'foo').session_id)
130+
self.assertEqual(42, ('foo' / p).session_id)
131+
self.assertEqual(42, p.joinpath('foo').session_id)
132+
self.assertEqual(42, p.with_name('foo').session_id)
133+
self.assertEqual(42, p.with_stem('foo').session_id)
134+
self.assertEqual(42, p.with_suffix('.foo').session_id)
135+
self.assertEqual(42, p.with_segments('foo').session_id)
136+
self.assertEqual(42, p.relative_to('foo').session_id)
137+
self.assertEqual(42, p.parent.session_id)
136138
for parent in p.parents:
137-
self.assertTrue(parent.init_called)
139+
self.assertEqual(42, parent.session_id)
138140

139141
def _get_drive_root_parts(self, parts):
140142
path = self.cls(*parts)
@@ -1647,6 +1649,26 @@ def test_home(self):
16471649
env['HOME'] = os.path.join(BASE, 'home')
16481650
self._test_home(self.cls.home())
16491651

1652+
def test_with_segments(self):
1653+
class P(_BasePurePathSubclass, self.cls):
1654+
pass
1655+
p = P(BASE, session_id=42)
1656+
self.assertEqual(42, p.absolute().session_id)
1657+
self.assertEqual(42, p.resolve().session_id)
1658+
self.assertEqual(42, p.with_segments('~').expanduser().session_id)
1659+
self.assertEqual(42, (p / 'fileA').rename(p / 'fileB').session_id)
1660+
self.assertEqual(42, (p / 'fileB').replace(p / 'fileA').session_id)
1661+
if os_helper.can_symlink():
1662+
self.assertEqual(42, (p / 'linkA').readlink().session_id)
1663+
for path in p.iterdir():
1664+
self.assertEqual(42, path.session_id)
1665+
for path in p.glob('*'):
1666+
self.assertEqual(42, path.session_id)
1667+
for path in p.rglob('*'):
1668+
self.assertEqual(42, path.session_id)
1669+
for dirpath, dirnames, filenames in p.walk():
1670+
self.assertEqual(42, dirpath.session_id)
1671+
16501672
def test_samefile(self):
16511673
fileA_path = os.path.join(BASE, 'fileA')
16521674
fileB_path = os.path.join(BASE, 'dirB', 'fileB')
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Add :meth:`pathlib.PurePath.with_segments`, which creates a path object from
2+
arguments. This method is called whenever a derivative path is created, such
3+
as from :attr:`pathlib.PurePath.parent`. Subclasses may override this method
4+
to share information between path objects.

0 commit comments

Comments
 (0)