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-128520: Divide pathlib ABCs into three classes #128523

Merged
merged 4 commits into from
Jan 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 62 additions & 48 deletions Lib/pathlib/_abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
it's developed alongside pathlib. If it finds success and maturity as a PyPI
package, it could become a public part of the standard library.

Two base classes are defined here -- PurePathBase and PathBase -- that
resemble pathlib's PurePath and Path respectively.
Three base classes are defined here -- JoinablePath, ReadablePath and
WritablePath.
"""

import functools
Expand Down Expand Up @@ -56,13 +56,13 @@ def concat_path(path, text):
return path.with_segments(str(path) + text)


class CopyWorker:
class CopyReader:
"""
Class that implements copying between path objects. An instance of this
class is available from the PathBase.copy property; it's made callable so
that PathBase.copy() can be treated as a method.
class is available from the ReadablePath.copy property; it's made callable
so that ReadablePath.copy() can be treated as a method.

The target path's CopyWorker drives the process from its _create() method.
The target path's CopyWriter drives the process from its _create() method.
Files and directories are exchanged by calling methods on the source and
target paths, and metadata is exchanged by calling
source.copy._read_metadata() and target.copy._write_metadata().
Expand All @@ -77,11 +77,15 @@ def __call__(self, target, follow_symlinks=True, dirs_exist_ok=False,
"""
Recursively copy this file or directory tree to the given destination.
"""
if not isinstance(target, PathBase):
if not isinstance(target, ReadablePath):
target = self._path.with_segments(target)

# Delegate to the target path's CopyWorker object.
return target.copy._create(self._path, follow_symlinks, dirs_exist_ok, preserve_metadata)
# Delegate to the target path's CopyWriter object.
try:
create = target.copy._create
except AttributeError:
raise TypeError(f"Target is not writable: {target}") from None
return create(self._path, follow_symlinks, dirs_exist_ok, preserve_metadata)

_readable_metakeys = frozenset()

Expand All @@ -91,6 +95,10 @@ def _read_metadata(self, metakeys, *, follow_symlinks=True):
"""
raise NotImplementedError


class CopyWriter(CopyReader):
__slots__ = ()

_writable_metakeys = frozenset()

def _write_metadata(self, metadata, *, follow_symlinks=True):
Expand Down Expand Up @@ -182,7 +190,7 @@ def _ensure_distinct_path(self, source):
raise err


class PurePathBase:
class JoinablePath:
"""Base class for pure path objects.

This class *does not* provide several magic methods that are defined in
Expand Down Expand Up @@ -334,7 +342,7 @@ def match(self, path_pattern, *, case_sensitive=None):
is matched. The recursive wildcard '**' is *not* supported by this
method.
"""
if not isinstance(path_pattern, PurePathBase):
if not isinstance(path_pattern, JoinablePath):
path_pattern = self.with_segments(path_pattern)
if case_sensitive is None:
case_sensitive = _is_case_sensitive(self.parser)
Expand All @@ -359,7 +367,7 @@ def full_match(self, pattern, *, case_sensitive=None):
Return True if this path matches the given glob-style pattern. The
pattern is matched against the entire path.
"""
if not isinstance(pattern, PurePathBase):
if not isinstance(pattern, JoinablePath):
pattern = self.with_segments(pattern)
if case_sensitive is None:
case_sensitive = _is_case_sensitive(self.parser)
Expand All @@ -369,7 +377,7 @@ def full_match(self, pattern, *, case_sensitive=None):



class PathBase(PurePathBase):
class ReadablePath(JoinablePath):
"""Base class for concrete path objects.

This class provides dummy implementations for many methods that derived
Expand Down Expand Up @@ -434,25 +442,6 @@ def read_text(self, encoding=None, errors=None, newline=None):
with self.open(mode='r', encoding=encoding, errors=errors, newline=newline) as f:
return f.read()

def write_bytes(self, data):
"""
Open the file in bytes mode, write to it, and close the file.
"""
# type-check for the buffer interface before truncating the file
view = memoryview(data)
with self.open(mode='wb') as f:
return f.write(view)

def write_text(self, data, encoding=None, errors=None, newline=None):
"""
Open the file in text mode, write to it, and close the file.
"""
if not isinstance(data, str):
raise TypeError('data must be str, not %s' %
data.__class__.__name__)
with self.open(mode='w', encoding=encoding, errors=errors, newline=newline) as f:
return f.write(data)

def _scandir(self):
"""Yield os.DirEntry-like objects of the directory contents.

Expand All @@ -474,7 +463,7 @@ def glob(self, pattern, *, case_sensitive=None, recurse_symlinks=True):
"""Iterate over this subtree and yield all existing files (of any
kind, including directories) matching the given relative pattern.
"""
if not isinstance(pattern, PurePathBase):
if not isinstance(pattern, JoinablePath):
pattern = self.with_segments(pattern)
anchor, parts = _explode_path(pattern)
if anchor:
Expand All @@ -496,7 +485,7 @@ def rglob(self, pattern, *, case_sensitive=None, recurse_symlinks=True):
directories) matching the given relative pattern, anywhere in
this subtree.
"""
if not isinstance(pattern, PurePathBase):
if not isinstance(pattern, JoinablePath):
pattern = self.with_segments(pattern)
pattern = '**' / pattern
return self.glob(pattern, case_sensitive=case_sensitive, recurse_symlinks=recurse_symlinks)
Expand Down Expand Up @@ -543,6 +532,28 @@ def readlink(self):
"""
raise NotImplementedError

copy = property(CopyReader, doc=CopyReader.__call__.__doc__)

def copy_into(self, target_dir, *, follow_symlinks=True,
dirs_exist_ok=False, preserve_metadata=False):
"""
Copy this file or directory tree into the given existing directory.
"""
name = self.name
if not name:
raise ValueError(f"{self!r} has an empty name")
elif isinstance(target_dir, ReadablePath):
target = target_dir / name
else:
target = self.with_segments(target_dir, name)
return self.copy(target, follow_symlinks=follow_symlinks,
dirs_exist_ok=dirs_exist_ok,
preserve_metadata=preserve_metadata)


class WritablePath(ReadablePath):
__slots__ = ()

def symlink_to(self, target, target_is_directory=False):
"""
Make this path a symlink pointing to the target path.
Expand All @@ -556,20 +567,23 @@ def mkdir(self, mode=0o777, parents=False, exist_ok=False):
"""
raise NotImplementedError

copy = property(CopyWorker, doc=CopyWorker.__call__.__doc__)
def write_bytes(self, data):
"""
Open the file in bytes mode, write to it, and close the file.
"""
# type-check for the buffer interface before truncating the file
view = memoryview(data)
with self.open(mode='wb') as f:
return f.write(view)

def copy_into(self, target_dir, *, follow_symlinks=True,
dirs_exist_ok=False, preserve_metadata=False):
def write_text(self, data, encoding=None, errors=None, newline=None):
"""
Copy this file or directory tree into the given existing directory.
Open the file in text mode, write to it, and close the file.
"""
name = self.name
if not name:
raise ValueError(f"{self!r} has an empty name")
elif isinstance(target_dir, PathBase):
target = target_dir / name
else:
target = self.with_segments(target_dir, name)
return self.copy(target, follow_symlinks=follow_symlinks,
dirs_exist_ok=dirs_exist_ok,
preserve_metadata=preserve_metadata)
if not isinstance(data, str):
raise TypeError('data must be str, not %s' %
data.__class__.__name__)
with self.open(mode='w', encoding=encoding, errors=errors, newline=newline) as f:
return f.write(data)

copy = property(CopyWriter, doc=CopyWriter.__call__.__doc__)
22 changes: 11 additions & 11 deletions Lib/pathlib/_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
grp = None

from pathlib._os import copyfile
from pathlib._abc import CopyWorker, PurePathBase, PathBase
from pathlib._abc import CopyWriter, JoinablePath, WritablePath


__all__ = [
Expand Down Expand Up @@ -65,7 +65,7 @@ def __repr__(self):
return "<{}.parents>".format(type(self._path).__name__)


class _LocalCopyWorker(CopyWorker):
class _LocalCopyWriter(CopyWriter):
"""This object implements the Path.copy callable. Don't try to construct
it yourself."""
__slots__ = ()
Expand Down Expand Up @@ -158,7 +158,7 @@ def _create_file(self, source, metakeys):
try:
source = os.fspath(source)
except TypeError:
if not isinstance(source, PathBase):
if not isinstance(source, WritablePath):
raise
super()._create_file(source, metakeys)
else:
Expand Down Expand Up @@ -190,7 +190,7 @@ def _ensure_different_file(self, source):
raise err


class PurePath(PurePathBase):
class PurePath(JoinablePath):
"""Base class for manipulating paths without I/O.

PurePath represents a filesystem path and offers operations which
Expand Down Expand Up @@ -646,7 +646,7 @@ def full_match(self, pattern, *, case_sensitive=None):
Return True if this path matches the given glob-style pattern. The
pattern is matched against the entire path.
"""
if not isinstance(pattern, PurePathBase):
if not isinstance(pattern, PurePath):
pattern = self.with_segments(pattern)
if case_sensitive is None:
case_sensitive = self.parser is posixpath
Expand Down Expand Up @@ -683,7 +683,7 @@ class PureWindowsPath(PurePath):
__slots__ = ()


class Path(PathBase, PurePath):
class Path(WritablePath, PurePath):
"""PurePath subclass that can make system calls.

Path represents a filesystem path but unlike PurePath, also offers
Expand Down Expand Up @@ -830,7 +830,7 @@ def read_text(self, encoding=None, errors=None, newline=None):
# Call io.text_encoding() here to ensure any warning is raised at an
# appropriate stack level.
encoding = io.text_encoding(encoding)
return PathBase.read_text(self, encoding, errors, newline)
return super().read_text(encoding, errors, newline)

def write_text(self, data, encoding=None, errors=None, newline=None):
"""
Expand All @@ -839,7 +839,7 @@ def write_text(self, data, encoding=None, errors=None, newline=None):
# Call io.text_encoding() here to ensure any warning is raised at an
# appropriate stack level.
encoding = io.text_encoding(encoding)
return PathBase.write_text(self, data, encoding, errors, newline)
return super().write_text(data, encoding, errors, newline)

_remove_leading_dot = operator.itemgetter(slice(2, None))
_remove_trailing_slash = operator.itemgetter(slice(-1))
Expand Down Expand Up @@ -1122,7 +1122,7 @@ def replace(self, target):
os.replace(self, target)
return self.with_segments(target)

copy = property(_LocalCopyWorker, doc=_LocalCopyWorker.__call__.__doc__)
copy = property(_LocalCopyWriter, doc=_LocalCopyWriter.__call__.__doc__)

def move(self, target):
"""
Expand All @@ -1134,7 +1134,7 @@ def move(self, target):
except TypeError:
pass
else:
if not isinstance(target, PathBase):
if not isinstance(target, WritablePath):
target = self.with_segments(target_str)
target.copy._ensure_different_file(self)
try:
Expand All @@ -1155,7 +1155,7 @@ def move_into(self, target_dir):
name = self.name
if not name:
raise ValueError(f"{self!r} has an empty name")
elif isinstance(target_dir, PathBase):
elif isinstance(target_dir, WritablePath):
target = target_dir / name
else:
target = self.with_segments(target_dir, name)
Expand Down
2 changes: 1 addition & 1 deletion Lib/pathlib/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class Parser(Protocol):
"""Protocol for path parsers, which do low-level path manipulation.

Path parsers provide a subset of the os.path API, specifically those
functions needed to provide PurePathBase functionality. Each PurePathBase
functions needed to provide JoinablePath functionality. Each JoinablePath
subclass references its path parser via a 'parser' class attribute.
"""

Expand Down
14 changes: 7 additions & 7 deletions Lib/test/test_pathlib/test_pathlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ def test_is_notimplemented(self):
# Tests for the pure classes.
#

class PurePathTest(test_pathlib_abc.DummyPurePathTest):
class PurePathTest(test_pathlib_abc.DummyJoinablePathTest):
cls = pathlib.PurePath

# Make sure any symbolic links in the base test path are resolved.
Expand Down Expand Up @@ -924,7 +924,7 @@ class cls(pathlib.PurePath):
# Tests for the concrete classes.
#

class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
class PathTest(test_pathlib_abc.DummyWritablePathTest, PurePathTest):
"""Tests for the FS-accessing functionalities of the Path classes."""
cls = pathlib.Path
can_symlink = os_helper.can_symlink()
Expand Down Expand Up @@ -980,15 +980,15 @@ def tempdir(self):
self.addCleanup(os_helper.rmtree, d)
return d

def test_matches_pathbase_docstrings(self):
path_names = {name for name in dir(pathlib._abc.PathBase) if name[0] != '_'}
def test_matches_writablepath_docstrings(self):
path_names = {name for name in dir(pathlib._abc.WritablePath) if name[0] != '_'}
for attr_name in path_names:
if attr_name == 'parser':
# On Windows, Path.parser is ntpath, but PathBase.parser is
# On Windows, Path.parser is ntpath, but WritablePath.parser is
# posixpath, and so their docstrings differ.
continue
our_attr = getattr(self.cls, attr_name)
path_attr = getattr(pathlib._abc.PathBase, attr_name)
path_attr = getattr(pathlib._abc.WritablePath, attr_name)
self.assertEqual(our_attr.__doc__, path_attr.__doc__)

def test_concrete_class(self):
Expand Down Expand Up @@ -3019,7 +3019,7 @@ def test_group_windows(self):
P('c:/').group()


class PathWalkTest(test_pathlib_abc.DummyPathWalkTest):
class PathWalkTest(test_pathlib_abc.DummyReadablePathWalkTest):
cls = pathlib.Path
base = PathTest.base
can_symlink = PathTest.can_symlink
Expand Down
Loading
Loading