GH-128520: Make pathlib._abc.WritablePath a sibling of ReadablePath (#129014)

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`.
This commit is contained in:
Barney Gale 2025-01-21 18:35:37 +00:00 committed by GitHub
parent 3d7c0e5366
commit 01d91500ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 178 additions and 115 deletions

View file

@ -20,7 +20,7 @@ except ImportError:
grp = None
from pathlib._os import copyfile
from pathlib._abc import CopyWriter, JoinablePath, WritablePath
from pathlib._abc import CopyReader, CopyWriter, JoinablePath, ReadablePath, WritablePath
__all__ = [
@ -65,9 +65,10 @@ class _PathParents(Sequence):
return "<{}.parents>".format(type(self._path).__name__)
class _LocalCopyWriter(CopyWriter):
"""This object implements the Path.copy callable. Don't try to construct
it yourself."""
class _LocalCopyReader(CopyReader):
"""This object implements the "read" part of copying local paths. Don't
try to construct it yourself.
"""
__slots__ = ()
_readable_metakeys = {'mode', 'times_ns'}
@ -75,7 +76,7 @@ class _LocalCopyWriter(CopyWriter):
_readable_metakeys.add('flags')
if hasattr(os, 'listxattr'):
_readable_metakeys.add('xattrs')
_readable_metakeys = _writable_metakeys = frozenset(_readable_metakeys)
_readable_metakeys = frozenset(_readable_metakeys)
def _read_metadata(self, metakeys, *, follow_symlinks=True):
metadata = {}
@ -97,6 +98,15 @@ class _LocalCopyWriter(CopyWriter):
raise
return metadata
class _LocalCopyWriter(CopyWriter):
"""This object implements the "write" part of copying local paths. Don't
try to construct it yourself.
"""
__slots__ = ()
_writable_metakeys = _LocalCopyReader._readable_metakeys
def _write_metadata(self, metadata, *, follow_symlinks=True):
def _nop(*args, ns=None, follow_symlinks=None):
pass
@ -171,7 +181,7 @@ class _LocalCopyWriter(CopyWriter):
"""Copy the given symlink to the given target."""
self._path.symlink_to(source.readlink(), source.is_dir())
if metakeys:
metadata = source.copy._read_metadata(metakeys, follow_symlinks=False)
metadata = source._copy_reader._read_metadata(metakeys, follow_symlinks=False)
if metadata:
self._write_metadata(metadata, follow_symlinks=False)
@ -683,7 +693,7 @@ class PureWindowsPath(PurePath):
__slots__ = ()
class Path(WritablePath, PurePath):
class Path(WritablePath, ReadablePath, PurePath):
"""PurePath subclass that can make system calls.
Path represents a filesystem path but unlike PurePath, also offers
@ -823,6 +833,13 @@ class Path(WritablePath, PurePath):
encoding = io.text_encoding(encoding)
return io.open(self, mode, buffering, encoding, errors, newline)
def read_bytes(self):
"""
Open the file in bytes mode, read it, and close the file.
"""
with self.open(mode='rb', buffering=0) as f:
return f.read()
def read_text(self, encoding=None, errors=None, newline=None):
"""
Open the file in text mode, read it, and close the file.
@ -830,7 +847,17 @@ class Path(WritablePath, PurePath):
# Call io.text_encoding() here to ensure any warning is raised at an
# appropriate stack level.
encoding = io.text_encoding(encoding)
return super().read_text(encoding, errors, newline)
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):
"""
@ -839,7 +866,11 @@ class Path(WritablePath, PurePath):
# Call io.text_encoding() here to ensure any warning is raised at an
# appropriate stack level.
encoding = io.text_encoding(encoding)
return super().write_text(data, encoding, errors, newline)
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)
_remove_leading_dot = operator.itemgetter(slice(2, None))
_remove_trailing_slash = operator.itemgetter(slice(-1))
@ -1122,7 +1153,8 @@ class Path(WritablePath, PurePath):
os.replace(self, target)
return self.with_segments(target)
copy = property(_LocalCopyWriter, doc=_LocalCopyWriter.__call__.__doc__)
_copy_reader = property(_LocalCopyReader)
_copy_writer = property(_LocalCopyWriter)
def move(self, target):
"""
@ -1134,9 +1166,9 @@ class Path(WritablePath, PurePath):
except TypeError:
pass
else:
if not isinstance(target, WritablePath):
if not hasattr(target, '_copy_writer'):
target = self.with_segments(target_str)
target.copy._ensure_different_file(self)
target._copy_writer._ensure_different_file(self)
try:
os.replace(self, target_str)
return target
@ -1155,7 +1187,7 @@ class Path(WritablePath, PurePath):
name = self.name
if not name:
raise ValueError(f"{self!r} has an empty name")
elif isinstance(target_dir, WritablePath):
elif hasattr(target_dir, '_copy_writer'):
target = target_dir / name
else:
target = self.with_segments(target_dir, name)