Skip to content
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

GH-100479: Add optional blueprint argument to pathlib.PurePath #100481

Closed
wants to merge 22 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a6fdd0e
Add `pathlib.PurePath.makepath()`; unify path object construction
barneygale Nov 20, 2022
b061747
Fix reST role name.
barneygale Dec 24, 2022
99eb8b1
Move call to `os.getcwd()` back into `Path.cwd()`
barneygale Dec 24, 2022
4759d01
Merge branch 'main' into gh-100479-add-makepath
barneygale Jan 5, 2023
ef6f4c3
Merge branch 'main' into gh-100479-add-makepath
barneygale Apr 3, 2023
595b8ae
Add news blurb.
barneygale Apr 3, 2023
dcfe70a
Merge branch 'main' into gh-100479-add-makepath
barneygale Apr 9, 2023
117fe4b
Add whatsnew entry
barneygale Apr 10, 2023
e75dedc
Merge branch 'main' into gh-100479-add-makepath
barneygale Apr 12, 2023
5a6bd3f
Merge branch 'main' into gh-100479-add-makepath
barneygale Apr 13, 2023
f2f1048
other --> pathsegments
barneygale Apr 24, 2023
3c172fb
Update Lib/pathlib.py
barneygale Apr 24, 2023
4637109
joinpath(*args) --> joinpath(*pathsegments)
barneygale Apr 24, 2023
ae48454
Restore _PathParents
barneygale Apr 25, 2023
e7a8fe3
Add note to `parents` about potential reference cycle.
barneygale Apr 25, 2023
7f12faa
Replace `makepath()` method with `template` initialiser argument.
barneygale Apr 25, 2023
687c764
Apply suggestions from code review
barneygale Apr 25, 2023
d7e326a
Fix docs for other classes.
barneygale Apr 25, 2023
a65d499
Pass template to `super()` to support diamond inheritance.
barneygale Apr 26, 2023
d4b15d7
Fixed missed `template` argument to super().
barneygale Apr 26, 2023
958b183
template --> blueprint
barneygale Apr 27, 2023
1e10188
Merge branch 'main' into gh-100479-add-makepath
barneygale May 2, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
30 changes: 28 additions & 2 deletions Doc/library/pathlib.rst
Original file line number Diff line number Diff line change
Expand Up @@ -351,8 +351,7 @@ Pure paths provide the following methods and properties:

.. data:: PurePath.parents

An immutable sequence providing access to the logical ancestors of
the path::
A tuple providing access to the logical ancestors of the path::

>>> p = PureWindowsPath('c:/foo/bar/setup.py')
>>> p.parents[0]
Expand All @@ -365,6 +364,9 @@ Pure paths provide the following methods and properties:
.. versionchanged:: 3.10
The parents sequence now supports :term:`slices <slice>` and negative index values.

.. versionchanged:: 3.12
Type changed from a tuple-like immutable sequence to a true tuple.

AlexWaygood marked this conversation as resolved.
Show resolved Hide resolved
.. data:: PurePath.parent

The logical parent of the path::
Expand Down Expand Up @@ -537,6 +539,30 @@ Pure paths provide the following methods and properties:
PureWindowsPath('c:/Program Files')


.. method:: PurePath.makepath(*other)
AlexWaygood marked this conversation as resolved.
Show resolved Hide resolved

Create a new path object of the same type by combining the *other*
arguments. This method is called whenever a derivative path is created,
such as from :data:`parent` and :method:`relative_to`. Subclasses may
override this method to pass information to derivative paths, for example::

from pathlib import PurePosixPath

class MyPath(PurePosixPath):
def __init__(self, *args, session_id):
super().__init__(*args)
self.session_id = session_id

def makepath(self, *other):
return type(self)(*other, session_id=self.session_id)

etc = MyPath('/etc', session_id=42)
hosts = etc / 'hosts'
print(hosts.session_id) # 42

.. versionadded:: 3.12


.. method:: PurePath.match(pattern)

Match this path against the provided glob-style pattern. Return ``True``
Expand Down
137 changes: 40 additions & 97 deletions Lib/pathlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
import re
import sys
import warnings
from _collections_abc import Sequence
from errno import ENOENT, ENOTDIR, EBADF, ELOOP
from operator import attrgetter
from stat import S_ISDIR, S_ISLNK, S_ISREG, S_ISSOCK, S_ISBLK, S_ISCHR, S_ISFIFO
Expand Down Expand Up @@ -121,7 +120,7 @@ def __init__(self, name, child_parts, flavour):

def _select_from(self, parent_path, is_dir, exists, scandir, normcase):
try:
path = parent_path._make_child_relpath(self.name)
path = parent_path.joinpath(self.name)
if (is_dir if self.dironly else exists)(path):
for p in self.successor._select_from(path, is_dir, exists, scandir, normcase):
yield p
Expand Down Expand Up @@ -155,7 +154,7 @@ def _select_from(self, parent_path, is_dir, exists, scandir, normcase):
continue
name = entry.name
if self.match(normcase(name)):
path = parent_path._make_child_relpath(name)
path = parent_path.joinpath(name)
for p in self.successor._select_from(path, is_dir, exists, scandir, normcase):
yield p
except PermissionError:
Expand All @@ -182,7 +181,7 @@ def _iterate_directories(self, parent_path, is_dir, scandir):
if not _ignore_error(e):
raise
if entry_is_dir and not entry.is_symlink():
path = parent_path._make_child_relpath(entry.name)
path = parent_path.joinpath(entry.name)
for p in self._iterate_directories(path, is_dir, scandir):
yield p
except PermissionError:
Expand All @@ -208,38 +207,6 @@ def _select_from(self, parent_path, is_dir, exists, scandir, normcase):
# Public API
#

class _PathParents(Sequence):
"""This object provides sequence-like access to the logical ancestors
of a path. Don't try to construct it yourself."""
__slots__ = ('_pathcls', '_drv', '_root', '_parts')

def __init__(self, path):
# We don't store the instance to avoid reference cycles
self._pathcls = type(path)
self._drv = path._drv
self._root = path._root
self._parts = path._parts

def __len__(self):
if self._drv or self._root:
return len(self._parts) - 1
else:
return len(self._parts)

def __getitem__(self, idx):
if isinstance(idx, slice):
return tuple(self[i] for i in range(*idx.indices(len(self))))

if idx >= len(self) or idx < -len(self):
raise IndexError(idx)
if idx < 0:
idx += len(self)
return self._pathcls._from_parsed_parts(self._drv, self._root,
self._parts[:-idx - 1])

def __repr__(self):
return "<{}.parents>".format(self._pathcls.__name__)


class PurePath(object):
"""Base class for manipulating paths without I/O.
Expand All @@ -256,15 +223,15 @@ class PurePath(object):
)
_flavour = os.path

def __new__(cls, *args):
def __new__(cls, *args, **kwargs):
"""Construct a PurePath from one or several strings and or existing
PurePath objects. The strings and path objects are combined so as
to yield a canonicalized path, which is incorporated into the
new PurePath object.
"""
if cls is PurePath:
cls = PureWindowsPath if os.name == 'nt' else PurePosixPath
return cls._from_parts(args)
return super().__new__(cls)

def __reduce__(self):
# Using the parts tuple helps share interned path parts
Expand Down Expand Up @@ -318,24 +285,11 @@ def _parse_args(cls, args):
% type(a))
return cls._parse_parts(parts)

@classmethod
def _from_parts(cls, args):
# We need to call _parse_args on the instance, so as to get the
# right flavour.
self = object.__new__(cls)
def __init__(self, *args):
drv, root, parts = self._parse_args(args)
self._drv = drv
self._root = root
self._parts = parts
return self

@classmethod
def _from_parsed_parts(cls, drv, root, parts):
self = object.__new__(cls)
self._drv = drv
self._root = root
self._parts = parts
return self

@classmethod
def _format_parsed_parts(cls, drv, root, parts):
Expand Down Expand Up @@ -497,8 +451,7 @@ def with_name(self, name):
if (not name or name[-1] in [self._flavour.sep, self._flavour.altsep]
or drv or root or len(parts) != 1):
raise ValueError("Invalid name %r" % (name))
return self._from_parsed_parts(self._drv, self._root,
self._parts[:-1] + [name])
return self.makepath(*self._parts[:-1], name)

def with_stem(self, stem):
"""Return a new path with the stem changed."""
Expand All @@ -522,8 +475,7 @@ def with_suffix(self, suffix):
name = name + suffix
else:
name = name[:-len(old_suffix)] + suffix
return self._from_parsed_parts(self._drv, self._root,
self._parts[:-1] + [name])
return self.makepath(*self._parts[:-1], name)

def relative_to(self, other, /, *_deprecated, walk_up=False):
"""Return the relative path to another path identified by the passed
Expand All @@ -539,8 +491,7 @@ def relative_to(self, other, /, *_deprecated, walk_up=False):
"scheduled for removal in Python {remove}")
warnings._deprecated("pathlib.PurePath.relative_to(*args)", msg,
remove=(3, 14))
path_cls = type(self)
other = path_cls(other, *_deprecated)
other = self.makepath(other, *_deprecated)
for step, path in enumerate([other] + list(other.parents)):
if self.is_relative_to(path):
break
Expand All @@ -549,7 +500,7 @@ def relative_to(self, other, /, *_deprecated, walk_up=False):
if step and not walk_up:
raise ValueError(f"{str(self)!r} is not in the subpath of {str(other)!r}")
parts = ('..',) * step + self.parts[len(path.parts):]
return path_cls(*parts)
return self.makepath(*parts)

def is_relative_to(self, other, /, *_deprecated):
"""Return True if the path is relative to another path or False.
Expand All @@ -560,7 +511,7 @@ def is_relative_to(self, other, /, *_deprecated):
"scheduled for removal in Python {remove}")
warnings._deprecated("pathlib.PurePath.is_relative_to(*args)",
msg, remove=(3, 14))
other = type(self)(other, *_deprecated)
other = self.makepath(other, *_deprecated)
return other == self or other in self.parents

@property
Expand All @@ -575,28 +526,20 @@ def parts(self):
self._parts_tuple = tuple(self._parts)
return self._parts_tuple

def makepath(self, *args):
barneygale marked this conversation as resolved.
Show resolved Hide resolved
"""Construct a new path object from any number of path-like objects.
Subclasses may override this method to customize how new path objects
are created from methods like `iterdir()`.
"""
return type(self)(*args)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, wouldn't this be simpler as a classmethod?

Suggested change
def makepath(self, *args):
"""Construct a new path object from any number of path-like objects.
Subclasses may override this method to customize how new path objects
are created from methods like `iterdir()`.
"""
return type(self)(*args)
@classmethod
def makepath(cls, *args):
"""Construct a new path object from any number of path-like objects.
Subclasses may override this method to customize how new path objects
are created from methods like `iterdir()`.
"""
return cls(*args)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Making it a classmethod means that you can't share state between paths no?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see -- sorry, just getting back to pathlib stuff after a while away!

I guess I find the "makepath" name quite unintuitive in that case. The name to me implies that it's "just" an alternative constructor, which I'd expect to be a classmethod. Maybe something like "sproutpath" (feel free to bikeshed the name), which is clearer that it is using the state of the current Path instance would be better?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with_segments(), perhaps? Complements with_name() etc.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with_segments sounds like it's kiiinda "the previous path's segements, but with some new ones added at the end".

What about "newpath"? I think that reinforces the fact that the returned object has completely different segments to the old one.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although this method is mostly called under-the-hood, it may also be useful when dealing with subclasses of pathlib classes. For example:

from tarfile import TarFile, TarPath
readme_path = TarPath('README.txt', tarfile=TarFile('blah.tar.gz'))
license_path = readme_path.newpath('LICENSE.txt')

I mention this just in case it helps us with the naming question. In the above example, readme_path.newpath() is a sort of factory for path objects sharing the same underlying TarFile object. And I suppose factories make things. But I don't mind newpath() :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of the options floated so far, I like the "newpath" name the best. But it's your module now, afterall, so I don't feel like I should have the final call here :D

You could maybe start a thread on Discord or Discuss if you'd like the opinion of more core devs?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

barneygale marked this conversation as resolved.
Show resolved Hide resolved

def joinpath(self, *args):
"""Combine this path with one or several arguments, and return a
new path representing either a subpath (if all arguments are relative
paths) or a totally different path (if one of the arguments is
anchored).
"""
drv1, root1, parts1 = self._drv, self._root, self._parts
drv2, root2, parts2 = self._parse_args(args)
if root2:
if not drv2 and drv1:
return self._from_parsed_parts(drv1, root2, [drv1 + root2] + parts2[1:])
else:
return self._from_parsed_parts(drv2, root2, parts2)
elif drv2:
if drv2 == drv1 or self._flavour.normcase(drv2) == self._flavour.normcase(drv1):
# Same drive => second path is relative to the first.
return self._from_parsed_parts(drv1, root1, parts1 + parts2[1:])
else:
return self._from_parsed_parts(drv2, root2, parts2)
else:
# Second path is non-anchored (common case).
return self._from_parsed_parts(drv1, root1, parts1 + parts2)
return self.makepath(*self._parts, *args)

def __truediv__(self, key):
try:
Expand All @@ -606,7 +549,7 @@ def __truediv__(self, key):

def __rtruediv__(self, key):
try:
return self._from_parts([key] + self._parts)
return self.makepath(key, *self._parts)
except TypeError:
return NotImplemented

Expand All @@ -618,12 +561,18 @@ def parent(self):
parts = self._parts
if len(parts) == 1 and (drv or root):
return self
return self._from_parsed_parts(drv, root, parts[:-1])
return self.makepath(*parts[:-1])

@property
def parents(self):
"""A sequence of this path's logical parents."""
return _PathParents(self)
"""A tuple of this path's logical parents."""
path = self
parent = self.parent
parents = []
while path != parent:
parents.append(parent)
path, parent = parent, parent.parent
return tuple(parents)

def is_absolute(self):
"""True if the path is absolute (has both a root and, if applicable,
Expand Down Expand Up @@ -715,18 +664,12 @@ class Path(PurePath):
def __new__(cls, *args, **kwargs):
if cls is Path:
cls = WindowsPath if os.name == 'nt' else PosixPath
self = cls._from_parts(args)
self = super().__new__(cls)
if self._flavour is not os.path:
raise NotImplementedError("cannot instantiate %r on your system"
% (cls.__name__,))
return self

def _make_child_relpath(self, part):
# This is an optimization used for dir walking. `part` must be
# a single part relative to this path.
parts = self._parts + [part]
return self._from_parsed_parts(self._drv, self._root, parts)

def __enter__(self):
# In previous versions of pathlib, __exit__() marked this path as
# closed; subsequent attempts to perform I/O would raise an IOError.
Expand All @@ -751,7 +694,7 @@ def cwd(cls):
"""Return a new path pointing to the current working directory
(as returned by os.getcwd()).
"""
return cls(os.getcwd())
return cls().absolute()
barneygale marked this conversation as resolved.
Show resolved Hide resolved

@classmethod
def home(cls):
Expand All @@ -768,7 +711,7 @@ def samefile(self, other_path):
try:
other_st = other_path.stat()
except AttributeError:
other_st = self.__class__(other_path).stat()
other_st = self.makepath(other_path).stat()
return self._flavour.samestat(st, other_st)

def iterdir(self):
Expand All @@ -778,7 +721,7 @@ def iterdir(self):
special entries '.' and '..' are not included.
"""
for name in os.listdir(self):
yield self._make_child_relpath(name)
yield self.joinpath(name)

def _scandir(self):
# bpo-24132: a future version of pathlib will support subclassing of
Expand Down Expand Up @@ -825,7 +768,7 @@ def absolute(self):
"""
if self.is_absolute():
return self
return self._from_parts([self.cwd()] + self._parts)
return self.makepath(os.getcwd(), self)

def resolve(self, strict=False):
"""
Expand All @@ -843,7 +786,7 @@ def check_eloop(e):
except OSError as e:
check_eloop(e)
raise
p = self._from_parts((s,))
p = self.makepath(s)

# In non-strict mode, realpath() doesn't raise on symlink loops.
# Ensure we get an exception by calling stat()
Expand Down Expand Up @@ -933,7 +876,7 @@ def readlink(self):
"""
if not hasattr(os, "readlink"):
raise NotImplementedError("os.readlink() not available on this system")
return self._from_parts((os.readlink(self),))
return self.makepath(os.readlink(self))

def touch(self, mode=0o666, exist_ok=True):
"""
Expand Down Expand Up @@ -1022,7 +965,7 @@ def rename(self, target):
Returns the new Path instance pointing to the target path.
"""
os.rename(self, target)
return self.__class__(target)
return self.makepath(target)

def replace(self, target):
"""
Expand All @@ -1035,7 +978,7 @@ def replace(self, target):
Returns the new Path instance pointing to the target path.
"""
os.replace(self, target)
return self.__class__(target)
return self.makepath(target)

def symlink_to(self, target, target_is_directory=False):
"""
Expand Down Expand Up @@ -1207,7 +1150,7 @@ def expanduser(self):
homedir = self._flavour.expanduser(self._parts[0])
if homedir[:1] == "~":
raise RuntimeError("Could not determine home directory.")
return self._from_parts([homedir] + self._parts[1:])
return self.makepath(homedir, *self._parts[1:])

return self

Expand Down Expand Up @@ -1248,7 +1191,7 @@ def _walk(self, top_down, on_error, follow_symlinks):
yield self, dirnames, filenames

for dirname in dirnames:
dirpath = self._make_child_relpath(dirname)
dirpath = self.joinpath(dirname)
yield from dirpath._walk(top_down, on_error, follow_symlinks)

if not top_down:
Expand Down