Skip to content

Commit

Permalink
GH-128520: Divide pathlib ABCs into three classes
Browse files Browse the repository at this point in the history
In the private pathlib ABCs, rename `PurePathBase` to `JoinablePath`, and
split `PathBase` into `ReadablePath` and `WritablePath`. This improves the
API fit for read-only virtual filesystems.

The split of `PathBase` entails a similar split of `CopyWorker` (implements
copying) and the test cases in `test_pathlib_abc`. For a couple of reasons,
this isn't quite possible yet.

In a later patch, we'll make `WritablePath` inherit directly from
`JoinablePath` rather than `ReadablePath`.
  • Loading branch information
barneygale committed Jan 5, 2025
1 parent fd94c6a commit 53d3db7
Show file tree
Hide file tree
Showing 5 changed files with 369 additions and 307 deletions.
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

0 comments on commit 53d3db7

Please sign in to comment.