Skip to content

Commit e13ed9c

Browse files
committed
GH-128520: Make pathlib._abc.WritablePath a sibling of ReadablePath
In the private pathlib ABCs, support write-only virtual filesystems by making `WritablePath` inherit directly from `JoinablePath`, rather than subclassing `ReadablePath`. There are two complications: - `ReadablePath.open()` applies to both reading and writing - `ReadablePath.copy` is secretly an object that supports the *read* side of copying, whereas `WritablePath.copy` is a different kind of object supporting the *write* side We untangle these as follow: - A new `pathlib._abc.magic_open()` function replaces the `open()` method, which is dropped from the ABCs but remains in `pathlib.Path`. The function works like `io.open()`, but additionally accepts objects with `__open_rb__()` or `__open_wb__()` methods as appropriate for the mode. These new dunders are made abstract methods of `ReadablePath` and `WritablePath` respectively. If the pathlib ABCs are made public, we could consider blessing an "openable" protocol and supporting it in `io.open()`, removing the need for `pathlib._abc.magic_open()`. - `ReadablePath.copy` becomes a true method, whereas `WritablePath.copy` is deleted. A new `ReadablePath._copy_reader` property provides a `CopyReader` object, and similarly `WritablePath._copy_writer` is a `CopyWriter` object. Once GH-125413 is resolved, we'll be able to move the `CopyReader` functionality into `ReadablePath.info` and eliminate `ReadablePath._copy_reader`.
1 parent 61b35f7 commit e13ed9c

File tree

4 files changed

+175
-112
lines changed

4 files changed

+175
-112
lines changed

Lib/pathlib/_abc.py

Lines changed: 89 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"""
1313

1414
import functools
15+
import io
1516
import operator
1617
import posixpath
1718
from errno import EINVAL
@@ -41,6 +42,40 @@ def _explode_path(path):
4142
return path, names
4243

4344

45+
def magic_open(path, mode='r', buffering=-1, encoding=None, errors=None,
46+
newline=None):
47+
"""
48+
Open the file pointed to by this path and return a file object, as
49+
the built-in open() function does.
50+
"""
51+
try:
52+
return io.open(path, mode, buffering, encoding, errors, newline)
53+
except TypeError:
54+
pass
55+
cls = type(path)
56+
text = 'b' not in mode
57+
mode = ''.join(sorted(c for c in mode if c not in 'bt'))
58+
if text:
59+
try:
60+
attr = getattr(cls, f'__open_{mode}__')
61+
except AttributeError:
62+
pass
63+
else:
64+
return attr(path, buffering, encoding, errors, newline)
65+
66+
try:
67+
attr = getattr(cls, f'__open_{mode}b__')
68+
except AttributeError:
69+
pass
70+
else:
71+
stream = attr(path, buffering)
72+
if text:
73+
stream = io.TextIOWrapper(stream, encoding, errors, newline)
74+
return stream
75+
76+
raise TypeError(f"{cls.__name__} can't be opened with mode {mode!r}")
77+
78+
4479
class PathGlobber(_GlobberBase):
4580
"""
4681
Class providing shell-style globbing for path objects.
@@ -58,35 +93,15 @@ def concat_path(path, text):
5893

5994
class CopyReader:
6095
"""
61-
Class that implements copying between path objects. An instance of this
62-
class is available from the ReadablePath.copy property; it's made callable
63-
so that ReadablePath.copy() can be treated as a method.
64-
65-
The target path's CopyWriter drives the process from its _create() method.
66-
Files and directories are exchanged by calling methods on the source and
67-
target paths, and metadata is exchanged by calling
68-
source.copy._read_metadata() and target.copy._write_metadata().
96+
Class that implements the "read" part of copying between path objects.
97+
An instance of this class is available from the ReadablePath._copy_reader
98+
property.
6999
"""
70100
__slots__ = ('_path',)
71101

72102
def __init__(self, path):
73103
self._path = path
74104

75-
def __call__(self, target, follow_symlinks=True, dirs_exist_ok=False,
76-
preserve_metadata=False):
77-
"""
78-
Recursively copy this file or directory tree to the given destination.
79-
"""
80-
if not isinstance(target, ReadablePath):
81-
target = self._path.with_segments(target)
82-
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)
89-
90105
_readable_metakeys = frozenset()
91106

92107
def _read_metadata(self, metakeys, *, follow_symlinks=True):
@@ -96,8 +111,16 @@ def _read_metadata(self, metakeys, *, follow_symlinks=True):
96111
raise NotImplementedError
97112

98113

99-
class CopyWriter(CopyReader):
100-
__slots__ = ()
114+
class CopyWriter:
115+
"""
116+
Class that implements the "write" part of copying between path objects. An
117+
instance of this class is available from the WritablePath._copy_writer
118+
property.
119+
"""
120+
__slots__ = ('_path',)
121+
122+
def __init__(self, path):
123+
self._path = path
101124

102125
_writable_metakeys = frozenset()
103126

@@ -110,7 +133,7 @@ def _write_metadata(self, metadata, *, follow_symlinks=True):
110133
def _create(self, source, follow_symlinks, dirs_exist_ok, preserve_metadata):
111134
self._ensure_distinct_path(source)
112135
if preserve_metadata:
113-
metakeys = self._writable_metakeys & source.copy._readable_metakeys
136+
metakeys = self._writable_metakeys & source._copy_reader._readable_metakeys
114137
else:
115138
metakeys = None
116139
if not follow_symlinks and source.is_symlink():
@@ -128,22 +151,22 @@ def _create_dir(self, source, metakeys, follow_symlinks, dirs_exist_ok):
128151
for src in children:
129152
dst = self._path.joinpath(src.name)
130153
if not follow_symlinks and src.is_symlink():
131-
dst.copy._create_symlink(src, metakeys)
154+
dst._copy_writer._create_symlink(src, metakeys)
132155
elif src.is_dir():
133-
dst.copy._create_dir(src, metakeys, follow_symlinks, dirs_exist_ok)
156+
dst._copy_writer._create_dir(src, metakeys, follow_symlinks, dirs_exist_ok)
134157
else:
135-
dst.copy._create_file(src, metakeys)
158+
dst._copy_writer._create_file(src, metakeys)
136159
if metakeys:
137-
metadata = source.copy._read_metadata(metakeys)
160+
metadata = source._copy_reader._read_metadata(metakeys)
138161
if metadata:
139162
self._write_metadata(metadata)
140163

141164
def _create_file(self, source, metakeys):
142165
"""Copy the given file to our path."""
143166
self._ensure_different_file(source)
144-
with source.open('rb') as source_f:
167+
with magic_open(source, 'rb') as source_f:
145168
try:
146-
with self._path.open('wb') as target_f:
169+
with magic_open(self._path, 'wb') as target_f:
147170
copyfileobj(source_f, target_f)
148171
except IsADirectoryError as e:
149172
if not self._path.exists():
@@ -152,15 +175,15 @@ def _create_file(self, source, metakeys):
152175
f'Directory does not exist: {self._path}') from e
153176
raise
154177
if metakeys:
155-
metadata = source.copy._read_metadata(metakeys)
178+
metadata = source._copy_reader._read_metadata(metakeys)
156179
if metadata:
157180
self._write_metadata(metadata)
158181

159182
def _create_symlink(self, source, metakeys):
160183
"""Copy the given symbolic link to our path."""
161184
self._path.symlink_to(source.readlink())
162185
if metakeys:
163-
metadata = source.copy._read_metadata(metakeys, follow_symlinks=False)
186+
metadata = source._copy_reader._read_metadata(metakeys, follow_symlinks=False)
164187
if metadata:
165188
self._write_metadata(metadata, follow_symlinks=False)
166189

@@ -420,26 +443,25 @@ def is_symlink(self):
420443
"""
421444
raise NotImplementedError
422445

423-
def open(self, mode='r', buffering=-1, encoding=None,
424-
errors=None, newline=None):
446+
def __open_rb__(self, buffering=-1):
425447
"""
426-
Open the file pointed to by this path and return a file object, as
427-
the built-in open() function does.
448+
Open the file pointed to by this path for reading in binary mode and
449+
return a file object, like open(mode='rb').
428450
"""
429451
raise NotImplementedError
430452

431453
def read_bytes(self):
432454
"""
433455
Open the file in bytes mode, read it, and close the file.
434456
"""
435-
with self.open(mode='rb', buffering=0) as f:
457+
with magic_open(self, mode='rb', buffering=0) as f:
436458
return f.read()
437459

438460
def read_text(self, encoding=None, errors=None, newline=None):
439461
"""
440462
Open the file in text mode, read it, and close the file.
441463
"""
442-
with self.open(mode='r', encoding=encoding, errors=errors, newline=newline) as f:
464+
with magic_open(self, mode='r', encoding=encoding, errors=errors, newline=newline) as f:
443465
return f.read()
444466

445467
def _scandir(self):
@@ -532,7 +554,22 @@ def readlink(self):
532554
"""
533555
raise NotImplementedError
534556

535-
copy = property(CopyReader, doc=CopyReader.__call__.__doc__)
557+
_copy_reader = property(CopyReader)
558+
559+
def copy(self, target, follow_symlinks=True, dirs_exist_ok=False,
560+
preserve_metadata=False):
561+
"""
562+
Recursively copy this file or directory tree to the given destination.
563+
"""
564+
if not isinstance(target, ReadablePath):
565+
target = self.with_segments(target)
566+
567+
# Delegate to the target path's CopyWriter object.
568+
try:
569+
create = target._copy_writer._create
570+
except AttributeError:
571+
raise TypeError(f"Target is not writable: {target}") from None
572+
return create(self, follow_symlinks, dirs_exist_ok, preserve_metadata)
536573

537574
def copy_into(self, target_dir, *, follow_symlinks=True,
538575
dirs_exist_ok=False, preserve_metadata=False):
@@ -551,7 +588,7 @@ def copy_into(self, target_dir, *, follow_symlinks=True,
551588
preserve_metadata=preserve_metadata)
552589

553590

554-
class WritablePath(ReadablePath):
591+
class WritablePath(JoinablePath):
555592
__slots__ = ()
556593

557594
def symlink_to(self, target, target_is_directory=False):
@@ -567,13 +604,20 @@ def mkdir(self, mode=0o777, parents=False, exist_ok=False):
567604
"""
568605
raise NotImplementedError
569606

607+
def __open_wb__(self, buffering=-1):
608+
"""
609+
Open the file pointed to by this path for writing in binary mode and
610+
return a file object, like open(mode='wb').
611+
"""
612+
raise NotImplementedError
613+
570614
def write_bytes(self, data):
571615
"""
572616
Open the file in bytes mode, write to it, and close the file.
573617
"""
574618
# type-check for the buffer interface before truncating the file
575619
view = memoryview(data)
576-
with self.open(mode='wb') as f:
620+
with magic_open(self, mode='wb') as f:
577621
return f.write(view)
578622

579623
def write_text(self, data, encoding=None, errors=None, newline=None):
@@ -583,7 +627,7 @@ def write_text(self, data, encoding=None, errors=None, newline=None):
583627
if not isinstance(data, str):
584628
raise TypeError('data must be str, not %s' %
585629
data.__class__.__name__)
586-
with self.open(mode='w', encoding=encoding, errors=errors, newline=newline) as f:
630+
with magic_open(self, mode='w', encoding=encoding, errors=errors, newline=newline) as f:
587631
return f.write(data)
588632

589-
copy = property(CopyWriter, doc=CopyWriter.__call__.__doc__)
633+
_copy_writer = property(CopyWriter)

Lib/pathlib/_local.py

Lines changed: 43 additions & 11 deletions
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 CopyWriter, JoinablePath, WritablePath
23+
from pathlib._abc import CopyReader, CopyWriter, JoinablePath, ReadablePath, WritablePath
2424

2525

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

6767

68-
class _LocalCopyWriter(CopyWriter):
69-
"""This object implements the Path.copy callable. Don't try to construct
70-
it yourself."""
68+
class _LocalCopyReader(CopyReader):
69+
"""This object implements the "read" part of copying local paths. Don't
70+
try to construct it yourself.
71+
"""
7172
__slots__ = ()
7273

7374
_readable_metakeys = {'mode', 'times_ns'}
7475
if hasattr(os.stat_result, 'st_flags'):
7576
_readable_metakeys.add('flags')
7677
if hasattr(os, 'listxattr'):
7778
_readable_metakeys.add('xattrs')
78-
_readable_metakeys = _writable_metakeys = frozenset(_readable_metakeys)
79+
_readable_metakeys = frozenset(_readable_metakeys)
7980

8081
def _read_metadata(self, metakeys, *, follow_symlinks=True):
8182
metadata = {}
@@ -97,6 +98,15 @@ def _read_metadata(self, metakeys, *, follow_symlinks=True):
9798
raise
9899
return metadata
99100

101+
102+
class _LocalCopyWriter(CopyWriter):
103+
"""This object implements the "write" part of copying local paths. Don't
104+
try to construct it yourself.
105+
"""
106+
__slots__ = ()
107+
108+
_writable_metakeys = _LocalCopyReader._readable_metakeys
109+
100110
def _write_metadata(self, metadata, *, follow_symlinks=True):
101111
def _nop(*args, ns=None, follow_symlinks=None):
102112
pass
@@ -171,7 +181,7 @@ def _create_symlink(self, source, metakeys):
171181
"""Copy the given symlink to the given target."""
172182
self._path.symlink_to(source.readlink(), source.is_dir())
173183
if metakeys:
174-
metadata = source.copy._read_metadata(metakeys, follow_symlinks=False)
184+
metadata = source._copy_reader._read_metadata(metakeys, follow_symlinks=False)
175185
if metadata:
176186
self._write_metadata(metadata, follow_symlinks=False)
177187

@@ -683,7 +693,7 @@ class PureWindowsPath(PurePath):
683693
__slots__ = ()
684694

685695

686-
class Path(WritablePath, PurePath):
696+
class Path(WritablePath, ReadablePath, PurePath):
687697
"""PurePath subclass that can make system calls.
688698
689699
Path represents a filesystem path but unlike PurePath, also offers
@@ -823,14 +833,31 @@ def open(self, mode='r', buffering=-1, encoding=None,
823833
encoding = io.text_encoding(encoding)
824834
return io.open(self, mode, buffering, encoding, errors, newline)
825835

836+
def read_bytes(self):
837+
"""
838+
Open the file in bytes mode, read it, and close the file.
839+
"""
840+
with self.open(mode='rb', buffering=0) as f:
841+
return f.read()
842+
826843
def read_text(self, encoding=None, errors=None, newline=None):
827844
"""
828845
Open the file in text mode, read it, and close the file.
829846
"""
830847
# Call io.text_encoding() here to ensure any warning is raised at an
831848
# appropriate stack level.
832849
encoding = io.text_encoding(encoding)
833-
return super().read_text(encoding, errors, newline)
850+
with self.open(mode='r', encoding=encoding, errors=errors, newline=newline) as f:
851+
return f.read()
852+
853+
def write_bytes(self, data):
854+
"""
855+
Open the file in bytes mode, write to it, and close the file.
856+
"""
857+
# type-check for the buffer interface before truncating the file
858+
view = memoryview(data)
859+
with self.open(mode='wb') as f:
860+
return f.write(view)
834861

835862
def write_text(self, data, encoding=None, errors=None, newline=None):
836863
"""
@@ -839,7 +866,11 @@ def write_text(self, data, encoding=None, errors=None, newline=None):
839866
# Call io.text_encoding() here to ensure any warning is raised at an
840867
# appropriate stack level.
841868
encoding = io.text_encoding(encoding)
842-
return super().write_text(data, encoding, errors, newline)
869+
if not isinstance(data, str):
870+
raise TypeError('data must be str, not %s' %
871+
data.__class__.__name__)
872+
with self.open(mode='w', encoding=encoding, errors=errors, newline=newline) as f:
873+
return f.write(data)
843874

844875
_remove_leading_dot = operator.itemgetter(slice(2, None))
845876
_remove_trailing_slash = operator.itemgetter(slice(-1))
@@ -1122,7 +1153,8 @@ def replace(self, target):
11221153
os.replace(self, target)
11231154
return self.with_segments(target)
11241155

1125-
copy = property(_LocalCopyWriter, doc=_LocalCopyWriter.__call__.__doc__)
1156+
_copy_reader = property(_LocalCopyReader)
1157+
_copy_writer = property(_LocalCopyWriter)
11261158

11271159
def move(self, target):
11281160
"""
@@ -1136,7 +1168,7 @@ def move(self, target):
11361168
else:
11371169
if not isinstance(target, WritablePath):
11381170
target = self.with_segments(target_str)
1139-
target.copy._ensure_different_file(self)
1171+
target._copy_writer._ensure_different_file(self)
11401172
try:
11411173
os.replace(self, target_str)
11421174
return target

0 commit comments

Comments
 (0)