Skip to content

Commit 22a4421

Browse files
authored
GH-128520: Divide pathlib ABCs into three classes (#128523)
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`. In a later patch, we'll make `WritablePath` inherit directly from `JoinablePath` rather than `ReadablePath`. For a couple of reasons, this isn't quite possible yet.
1 parent 0946ed2 commit 22a4421

File tree

5 files changed

+317
-307
lines changed

5 files changed

+317
-307
lines changed

Lib/pathlib/_abc.py

+62-48
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
it's developed alongside pathlib. If it finds success and maturity as a PyPI
88
package, it could become a public part of the standard library.
99
10-
Two base classes are defined here -- PurePathBase and PathBase -- that
11-
resemble pathlib's PurePath and Path respectively.
10+
Three base classes are defined here -- JoinablePath, ReadablePath and
11+
WritablePath.
1212
"""
1313

1414
import functools
@@ -56,13 +56,13 @@ def concat_path(path, text):
5656
return path.with_segments(str(path) + text)
5757

5858

59-
class CopyWorker:
59+
class CopyReader:
6060
"""
6161
Class that implements copying between path objects. An instance of this
62-
class is available from the PathBase.copy property; it's made callable so
63-
that PathBase.copy() can be treated as a method.
62+
class is available from the ReadablePath.copy property; it's made callable
63+
so that ReadablePath.copy() can be treated as a method.
6464
65-
The target path's CopyWorker drives the process from its _create() method.
65+
The target path's CopyWriter drives the process from its _create() method.
6666
Files and directories are exchanged by calling methods on the source and
6767
target paths, and metadata is exchanged by calling
6868
source.copy._read_metadata() and target.copy._write_metadata().
@@ -77,11 +77,15 @@ def __call__(self, target, follow_symlinks=True, dirs_exist_ok=False,
7777
"""
7878
Recursively copy this file or directory tree to the given destination.
7979
"""
80-
if not isinstance(target, PathBase):
80+
if not isinstance(target, ReadablePath):
8181
target = self._path.with_segments(target)
8282

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

8690
_readable_metakeys = frozenset()
8791

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

98+
99+
class CopyWriter(CopyReader):
100+
__slots__ = ()
101+
94102
_writable_metakeys = frozenset()
95103

96104
def _write_metadata(self, metadata, *, follow_symlinks=True):
@@ -182,7 +190,7 @@ def _ensure_distinct_path(self, source):
182190
raise err
183191

184192

185-
class PurePathBase:
193+
class JoinablePath:
186194
"""Base class for pure path objects.
187195
188196
This class *does not* provide several magic methods that are defined in
@@ -334,7 +342,7 @@ def match(self, path_pattern, *, case_sensitive=None):
334342
is matched. The recursive wildcard '**' is *not* supported by this
335343
method.
336344
"""
337-
if not isinstance(path_pattern, PurePathBase):
345+
if not isinstance(path_pattern, JoinablePath):
338346
path_pattern = self.with_segments(path_pattern)
339347
if case_sensitive is None:
340348
case_sensitive = _is_case_sensitive(self.parser)
@@ -359,7 +367,7 @@ def full_match(self, pattern, *, case_sensitive=None):
359367
Return True if this path matches the given glob-style pattern. The
360368
pattern is matched against the entire path.
361369
"""
362-
if not isinstance(pattern, PurePathBase):
370+
if not isinstance(pattern, JoinablePath):
363371
pattern = self.with_segments(pattern)
364372
if case_sensitive is None:
365373
case_sensitive = _is_case_sensitive(self.parser)
@@ -369,7 +377,7 @@ def full_match(self, pattern, *, case_sensitive=None):
369377

370378

371379

372-
class PathBase(PurePathBase):
380+
class ReadablePath(JoinablePath):
373381
"""Base class for concrete path objects.
374382
375383
This class provides dummy implementations for many methods that derived
@@ -434,25 +442,6 @@ def read_text(self, encoding=None, errors=None, newline=None):
434442
with self.open(mode='r', encoding=encoding, errors=errors, newline=newline) as f:
435443
return f.read()
436444

437-
def write_bytes(self, data):
438-
"""
439-
Open the file in bytes mode, write to it, and close the file.
440-
"""
441-
# type-check for the buffer interface before truncating the file
442-
view = memoryview(data)
443-
with self.open(mode='wb') as f:
444-
return f.write(view)
445-
446-
def write_text(self, data, encoding=None, errors=None, newline=None):
447-
"""
448-
Open the file in text mode, write to it, and close the file.
449-
"""
450-
if not isinstance(data, str):
451-
raise TypeError('data must be str, not %s' %
452-
data.__class__.__name__)
453-
with self.open(mode='w', encoding=encoding, errors=errors, newline=newline) as f:
454-
return f.write(data)
455-
456445
def _scandir(self):
457446
"""Yield os.DirEntry-like objects of the directory contents.
458447
@@ -474,7 +463,7 @@ def glob(self, pattern, *, case_sensitive=None, recurse_symlinks=True):
474463
"""Iterate over this subtree and yield all existing files (of any
475464
kind, including directories) matching the given relative pattern.
476465
"""
477-
if not isinstance(pattern, PurePathBase):
466+
if not isinstance(pattern, JoinablePath):
478467
pattern = self.with_segments(pattern)
479468
anchor, parts = _explode_path(pattern)
480469
if anchor:
@@ -496,7 +485,7 @@ def rglob(self, pattern, *, case_sensitive=None, recurse_symlinks=True):
496485
directories) matching the given relative pattern, anywhere in
497486
this subtree.
498487
"""
499-
if not isinstance(pattern, PurePathBase):
488+
if not isinstance(pattern, JoinablePath):
500489
pattern = self.with_segments(pattern)
501490
pattern = '**' / pattern
502491
return self.glob(pattern, case_sensitive=case_sensitive, recurse_symlinks=recurse_symlinks)
@@ -543,6 +532,28 @@ def readlink(self):
543532
"""
544533
raise NotImplementedError
545534

535+
copy = property(CopyReader, doc=CopyReader.__call__.__doc__)
536+
537+
def copy_into(self, target_dir, *, follow_symlinks=True,
538+
dirs_exist_ok=False, preserve_metadata=False):
539+
"""
540+
Copy this file or directory tree into the given existing directory.
541+
"""
542+
name = self.name
543+
if not name:
544+
raise ValueError(f"{self!r} has an empty name")
545+
elif isinstance(target_dir, ReadablePath):
546+
target = target_dir / name
547+
else:
548+
target = self.with_segments(target_dir, name)
549+
return self.copy(target, follow_symlinks=follow_symlinks,
550+
dirs_exist_ok=dirs_exist_ok,
551+
preserve_metadata=preserve_metadata)
552+
553+
554+
class WritablePath(ReadablePath):
555+
__slots__ = ()
556+
546557
def symlink_to(self, target, target_is_directory=False):
547558
"""
548559
Make this path a symlink pointing to the target path.
@@ -556,20 +567,23 @@ def mkdir(self, mode=0o777, parents=False, exist_ok=False):
556567
"""
557568
raise NotImplementedError
558569

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

561-
def copy_into(self, target_dir, *, follow_symlinks=True,
562-
dirs_exist_ok=False, preserve_metadata=False):
579+
def write_text(self, data, encoding=None, errors=None, newline=None):
563580
"""
564-
Copy this file or directory tree into the given existing directory.
581+
Open the file in text mode, write to it, and close the file.
565582
"""
566-
name = self.name
567-
if not name:
568-
raise ValueError(f"{self!r} has an empty name")
569-
elif isinstance(target_dir, PathBase):
570-
target = target_dir / name
571-
else:
572-
target = self.with_segments(target_dir, name)
573-
return self.copy(target, follow_symlinks=follow_symlinks,
574-
dirs_exist_ok=dirs_exist_ok,
575-
preserve_metadata=preserve_metadata)
583+
if not isinstance(data, str):
584+
raise TypeError('data must be str, not %s' %
585+
data.__class__.__name__)
586+
with self.open(mode='w', encoding=encoding, errors=errors, newline=newline) as f:
587+
return f.write(data)
588+
589+
copy = property(CopyWriter, doc=CopyWriter.__call__.__doc__)

Lib/pathlib/_local.py

+11-11
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
grp = None
2121

2222
from pathlib._os import copyfile
23-
from pathlib._abc import CopyWorker, PurePathBase, PathBase
23+
from pathlib._abc import CopyWriter, JoinablePath, WritablePath
2424

2525

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

6767

68-
class _LocalCopyWorker(CopyWorker):
68+
class _LocalCopyWriter(CopyWriter):
6969
"""This object implements the Path.copy callable. Don't try to construct
7070
it yourself."""
7171
__slots__ = ()
@@ -158,7 +158,7 @@ def _create_file(self, source, metakeys):
158158
try:
159159
source = os.fspath(source)
160160
except TypeError:
161-
if not isinstance(source, PathBase):
161+
if not isinstance(source, WritablePath):
162162
raise
163163
super()._create_file(source, metakeys)
164164
else:
@@ -190,7 +190,7 @@ def _ensure_different_file(self, source):
190190
raise err
191191

192192

193-
class PurePath(PurePathBase):
193+
class PurePath(JoinablePath):
194194
"""Base class for manipulating paths without I/O.
195195
196196
PurePath represents a filesystem path and offers operations which
@@ -646,7 +646,7 @@ def full_match(self, pattern, *, case_sensitive=None):
646646
Return True if this path matches the given glob-style pattern. The
647647
pattern is matched against the entire path.
648648
"""
649-
if not isinstance(pattern, PurePathBase):
649+
if not isinstance(pattern, PurePath):
650650
pattern = self.with_segments(pattern)
651651
if case_sensitive is None:
652652
case_sensitive = self.parser is posixpath
@@ -683,7 +683,7 @@ class PureWindowsPath(PurePath):
683683
__slots__ = ()
684684

685685

686-
class Path(PathBase, PurePath):
686+
class Path(WritablePath, PurePath):
687687
"""PurePath subclass that can make system calls.
688688
689689
Path represents a filesystem path but unlike PurePath, also offers
@@ -830,7 +830,7 @@ def read_text(self, encoding=None, errors=None, newline=None):
830830
# Call io.text_encoding() here to ensure any warning is raised at an
831831
# appropriate stack level.
832832
encoding = io.text_encoding(encoding)
833-
return PathBase.read_text(self, encoding, errors, newline)
833+
return super().read_text(encoding, errors, newline)
834834

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

844844
_remove_leading_dot = operator.itemgetter(slice(2, None))
845845
_remove_trailing_slash = operator.itemgetter(slice(-1))
@@ -1122,7 +1122,7 @@ def replace(self, target):
11221122
os.replace(self, target)
11231123
return self.with_segments(target)
11241124

1125-
copy = property(_LocalCopyWorker, doc=_LocalCopyWorker.__call__.__doc__)
1125+
copy = property(_LocalCopyWriter, doc=_LocalCopyWriter.__call__.__doc__)
11261126

11271127
def move(self, target):
11281128
"""
@@ -1134,7 +1134,7 @@ def move(self, target):
11341134
except TypeError:
11351135
pass
11361136
else:
1137-
if not isinstance(target, PathBase):
1137+
if not isinstance(target, WritablePath):
11381138
target = self.with_segments(target_str)
11391139
target.copy._ensure_different_file(self)
11401140
try:
@@ -1155,7 +1155,7 @@ def move_into(self, target_dir):
11551155
name = self.name
11561156
if not name:
11571157
raise ValueError(f"{self!r} has an empty name")
1158-
elif isinstance(target_dir, PathBase):
1158+
elif isinstance(target_dir, WritablePath):
11591159
target = target_dir / name
11601160
else:
11611161
target = self.with_segments(target_dir, name)

Lib/pathlib/_types.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ class Parser(Protocol):
99
"""Protocol for path parsers, which do low-level path manipulation.
1010
1111
Path parsers provide a subset of the os.path API, specifically those
12-
functions needed to provide PurePathBase functionality. Each PurePathBase
12+
functions needed to provide JoinablePath functionality. Each JoinablePath
1313
subclass references its path parser via a 'parser' class attribute.
1414
"""
1515

Lib/test/test_pathlib/test_pathlib.py

+7-7
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ def test_is_notimplemented(self):
7575
# Tests for the pure classes.
7676
#
7777

78-
class PurePathTest(test_pathlib_abc.DummyPurePathTest):
78+
class PurePathTest(test_pathlib_abc.DummyJoinablePathTest):
7979
cls = pathlib.PurePath
8080

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

927-
class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
927+
class PathTest(test_pathlib_abc.DummyWritablePathTest, PurePathTest):
928928
"""Tests for the FS-accessing functionalities of the Path classes."""
929929
cls = pathlib.Path
930930
can_symlink = os_helper.can_symlink()
@@ -980,15 +980,15 @@ def tempdir(self):
980980
self.addCleanup(os_helper.rmtree, d)
981981
return d
982982

983-
def test_matches_pathbase_docstrings(self):
984-
path_names = {name for name in dir(pathlib._abc.PathBase) if name[0] != '_'}
983+
def test_matches_writablepath_docstrings(self):
984+
path_names = {name for name in dir(pathlib._abc.WritablePath) if name[0] != '_'}
985985
for attr_name in path_names:
986986
if attr_name == 'parser':
987-
# On Windows, Path.parser is ntpath, but PathBase.parser is
987+
# On Windows, Path.parser is ntpath, but WritablePath.parser is
988988
# posixpath, and so their docstrings differ.
989989
continue
990990
our_attr = getattr(self.cls, attr_name)
991-
path_attr = getattr(pathlib._abc.PathBase, attr_name)
991+
path_attr = getattr(pathlib._abc.WritablePath, attr_name)
992992
self.assertEqual(our_attr.__doc__, path_attr.__doc__)
993993

994994
def test_concrete_class(self):
@@ -3019,7 +3019,7 @@ def test_group_windows(self):
30193019
P('c:/').group()
30203020

30213021

3022-
class PathWalkTest(test_pathlib_abc.DummyPathWalkTest):
3022+
class PathWalkTest(test_pathlib_abc.DummyReadablePathWalkTest):
30233023
cls = pathlib.Path
30243024
base = PathTest.base
30253025
can_symlink = PathTest.can_symlink

0 commit comments

Comments
 (0)