Skip to content

Commit

Permalink
pythonGH-128520: Make pathlib._abc.WritablePath a sibling of `Reada…
Browse files Browse the repository at this point in the history
…blePath`

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 pythonGH-125413 is resolved, we'll be able to move
  the `CopyReader` functionality into `ReadablePath.info` and eliminate
  `ReadablePath._copy_reader`.
  • Loading branch information
barneygale committed Jan 19, 2025
1 parent 61b35f7 commit e13ed9c
Show file tree
Hide file tree
Showing 4 changed files with 175 additions and 112 deletions.
134 changes: 89 additions & 45 deletions Lib/pathlib/_abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"""

import functools
import io
import operator
import posixpath
from errno import EINVAL
Expand Down Expand Up @@ -41,6 +42,40 @@ def _explode_path(path):
return path, names


def magic_open(path, mode='r', buffering=-1, encoding=None, errors=None,
newline=None):
"""
Open the file pointed to by this path and return a file object, as
the built-in open() function does.
"""
try:
return io.open(path, mode, buffering, encoding, errors, newline)
except TypeError:
pass
cls = type(path)
text = 'b' not in mode
mode = ''.join(sorted(c for c in mode if c not in 'bt'))
if text:
try:
attr = getattr(cls, f'__open_{mode}__')
except AttributeError:
pass
else:
return attr(path, buffering, encoding, errors, newline)

try:
attr = getattr(cls, f'__open_{mode}b__')
except AttributeError:
pass
else:
stream = attr(path, buffering)
if text:
stream = io.TextIOWrapper(stream, encoding, errors, newline)
return stream

raise TypeError(f"{cls.__name__} can't be opened with mode {mode!r}")


class PathGlobber(_GlobberBase):
"""
Class providing shell-style globbing for path objects.
Expand All @@ -58,35 +93,15 @@ def concat_path(path, text):

class CopyReader:
"""
Class that implements copying between path objects. An instance of this
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 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().
Class that implements the "read" part of copying between path objects.
An instance of this class is available from the ReadablePath._copy_reader
property.
"""
__slots__ = ('_path',)

def __init__(self, path):
self._path = path

def __call__(self, target, follow_symlinks=True, dirs_exist_ok=False,
preserve_metadata=False):
"""
Recursively copy this file or directory tree to the given destination.
"""
if not isinstance(target, ReadablePath):
target = self._path.with_segments(target)

# 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()

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


class CopyWriter(CopyReader):
__slots__ = ()
class CopyWriter:
"""
Class that implements the "write" part of copying between path objects. An
instance of this class is available from the WritablePath._copy_writer
property.
"""
__slots__ = ('_path',)

def __init__(self, path):
self._path = path

_writable_metakeys = frozenset()

Expand All @@ -110,7 +133,7 @@ def _write_metadata(self, metadata, *, follow_symlinks=True):
def _create(self, source, follow_symlinks, dirs_exist_ok, preserve_metadata):
self._ensure_distinct_path(source)
if preserve_metadata:
metakeys = self._writable_metakeys & source.copy._readable_metakeys
metakeys = self._writable_metakeys & source._copy_reader._readable_metakeys
else:
metakeys = None
if not follow_symlinks and source.is_symlink():
Expand All @@ -128,22 +151,22 @@ def _create_dir(self, source, metakeys, follow_symlinks, dirs_exist_ok):
for src in children:
dst = self._path.joinpath(src.name)
if not follow_symlinks and src.is_symlink():
dst.copy._create_symlink(src, metakeys)
dst._copy_writer._create_symlink(src, metakeys)
elif src.is_dir():
dst.copy._create_dir(src, metakeys, follow_symlinks, dirs_exist_ok)
dst._copy_writer._create_dir(src, metakeys, follow_symlinks, dirs_exist_ok)
else:
dst.copy._create_file(src, metakeys)
dst._copy_writer._create_file(src, metakeys)
if metakeys:
metadata = source.copy._read_metadata(metakeys)
metadata = source._copy_reader._read_metadata(metakeys)
if metadata:
self._write_metadata(metadata)

def _create_file(self, source, metakeys):
"""Copy the given file to our path."""
self._ensure_different_file(source)
with source.open('rb') as source_f:
with magic_open(source, 'rb') as source_f:
try:
with self._path.open('wb') as target_f:
with magic_open(self._path, 'wb') as target_f:
copyfileobj(source_f, target_f)
except IsADirectoryError as e:
if not self._path.exists():
Expand All @@ -152,15 +175,15 @@ def _create_file(self, source, metakeys):
f'Directory does not exist: {self._path}') from e
raise
if metakeys:
metadata = source.copy._read_metadata(metakeys)
metadata = source._copy_reader._read_metadata(metakeys)
if metadata:
self._write_metadata(metadata)

def _create_symlink(self, source, metakeys):
"""Copy the given symbolic link to our path."""
self._path.symlink_to(source.readlink())
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)

Expand Down Expand Up @@ -420,26 +443,25 @@ def is_symlink(self):
"""
raise NotImplementedError

def open(self, mode='r', buffering=-1, encoding=None,
errors=None, newline=None):
def __open_rb__(self, buffering=-1):
"""
Open the file pointed to by this path and return a file object, as
the built-in open() function does.
Open the file pointed to by this path for reading in binary mode and
return a file object, like open(mode='rb').
"""
raise NotImplementedError

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:
with magic_open(self, 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.
"""
with self.open(mode='r', encoding=encoding, errors=errors, newline=newline) as f:
with magic_open(self, mode='r', encoding=encoding, errors=errors, newline=newline) as f:
return f.read()

def _scandir(self):
Expand Down Expand Up @@ -532,7 +554,22 @@ def readlink(self):
"""
raise NotImplementedError

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

def copy(self, target, follow_symlinks=True, dirs_exist_ok=False,
preserve_metadata=False):
"""
Recursively copy this file or directory tree to the given destination.
"""
if not isinstance(target, ReadablePath):
target = self.with_segments(target)

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

def copy_into(self, target_dir, *, follow_symlinks=True,
dirs_exist_ok=False, preserve_metadata=False):
Expand All @@ -551,7 +588,7 @@ def copy_into(self, target_dir, *, follow_symlinks=True,
preserve_metadata=preserve_metadata)


class WritablePath(ReadablePath):
class WritablePath(JoinablePath):
__slots__ = ()

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

def __open_wb__(self, buffering=-1):
"""
Open the file pointed to by this path for writing in binary mode and
return a file object, like open(mode='wb').
"""
raise NotImplementedError

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:
with magic_open(self, mode='wb') as f:
return f.write(view)

def write_text(self, data, encoding=None, errors=None, newline=None):
Expand All @@ -583,7 +627,7 @@ def write_text(self, data, encoding=None, errors=None, newline=None):
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:
with magic_open(self, mode='w', encoding=encoding, errors=errors, newline=newline) as f:
return f.write(data)

copy = property(CopyWriter, doc=CopyWriter.__call__.__doc__)
_copy_writer = property(CopyWriter)
54 changes: 43 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 CopyWriter, JoinablePath, WritablePath
from pathlib._abc import CopyReader, CopyWriter, JoinablePath, ReadablePath, WritablePath


__all__ = [
Expand Down Expand Up @@ -65,17 +65,18 @@ def __repr__(self):
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'}
if hasattr(os.stat_result, 'st_flags'):
_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 = {}
Expand All @@ -97,6 +98,15 @@ def _read_metadata(self, metakeys, *, follow_symlinks=True):
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
Expand Down Expand Up @@ -171,7 +181,7 @@ def _create_symlink(self, source, metakeys):
"""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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -823,14 +833,31 @@ def open(self, mode='r', buffering=-1, encoding=None,
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.
"""
# 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):
"""
Expand All @@ -839,7 +866,11 @@ 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 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))
Expand Down Expand Up @@ -1122,7 +1153,8 @@ def replace(self, target):
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):
"""
Expand All @@ -1136,7 +1168,7 @@ def move(self, target):
else:
if not isinstance(target, WritablePath):
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
Expand Down
Loading

0 comments on commit e13ed9c

Please sign in to comment.