Skip to content

Commit 9970480

Browse files
committed
GH-128520: pathlib ABCs: improve protocol for 'openable' objects
Rename `pathlib._os.magic_open()` to `vfsopen()`. The new name is a bit less abstract, and it aligns with the `vfspath()` method added in 5dbd27d. Per discussion on discourse[^1], adjust `vfsopen()` so that the following methods may be called: - `__open_reader__()` - `__open_writer__(mode)` - `__open_updater__(mode)` These three methods return readable, writable, and full duplex file objects respectively. In the 'writer' method, *mode* is either 'a', 'w' or 'x'. In the 'updater' method, *mode* is either 'r' or 'w'. Also stop trying built-in `open()` first. I don't know whether this is a good idea or not, so it's best to leave it out for now. In the pathlib ABCs, replace `ReadablePath.__open_rb__()` with `__open_reader__()`, and replace `WritablePath.__open_wb__()` with `__open_writer__()`. [^1]: https://discuss.python.org/t/open-able-objects/90238
1 parent 5dbd27d commit 9970480

8 files changed

Lines changed: 114 additions & 63 deletions

File tree

Lib/pathlib/__init__.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828

2929
from pathlib._os import (
3030
PathInfo, DirEntryInfo,
31-
magic_open, vfspath,
31+
vfsopen, vfspath,
3232
ensure_different_files, ensure_distinct_paths,
3333
copyfile2, copyfileobj, copy_info,
3434
)
@@ -766,6 +766,27 @@ def samefile(self, other_path):
766766
return (st.st_ino == other_st.st_ino and
767767
st.st_dev == other_st.st_dev)
768768

769+
def __open_reader__(self):
770+
"""
771+
Open the file pointed to by this path for reading in binary mode and
772+
return a file object.
773+
"""
774+
return io.open(self, 'rb')
775+
776+
def __open_writer__(self, mode):
777+
"""
778+
Open the file pointed to by this path for writing in binary mode and
779+
return a file object.
780+
"""
781+
return io.open(self, f'{mode}b')
782+
783+
def __open_updater__(self, mode):
784+
"""
785+
Open the file pointed to by this path for updating in binary mode and
786+
return a file object.
787+
"""
788+
return io.open(self, f'{mode}+b')
789+
769790
def open(self, mode='r', buffering=-1, encoding=None,
770791
errors=None, newline=None):
771792
"""
@@ -1141,7 +1162,7 @@ def _copy_from(self, source, follow_symlinks=True, preserve_metadata=False):
11411162

11421163
def _copy_from_file(self, source, preserve_metadata=False):
11431164
ensure_different_files(source, self)
1144-
with magic_open(source, 'rb') as source_f:
1165+
with vfsopen(source, 'rb') as source_f:
11451166
with open(self, 'wb') as target_f:
11461167
copyfileobj(source_f, target_f)
11471168
if preserve_metadata:

Lib/pathlib/_os.py

Lines changed: 57 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -166,48 +166,78 @@ def copyfileobj(source_f, target_f):
166166
write_target(buf)
167167

168168

169-
def magic_open(path, mode='r', buffering=-1, encoding=None, errors=None,
170-
newline=None):
169+
def _open_reader(obj):
170+
cls = type(obj)
171+
try:
172+
return cls.__open_reader__(obj)
173+
except AttributeError:
174+
if hasattr(cls, '__open_reader__'):
175+
raise
176+
raise TypeError(f"{cls.__name__} can't be opened for reading")
177+
178+
179+
def _open_writer(obj, mode):
180+
cls = type(obj)
181+
try:
182+
return cls.__open_writer__(obj, mode)
183+
except AttributeError:
184+
if hasattr(cls, '__open_writer__'):
185+
raise
186+
raise TypeError(f"{cls.__name__} can't be opened for writing")
187+
188+
189+
def _open_updater(obj, mode):
190+
cls = type(obj)
191+
try:
192+
return cls.__open_updater__(obj, mode)
193+
except AttributeError:
194+
if hasattr(cls, '__open_updater__'):
195+
raise
196+
raise TypeError(f"{cls.__name__} can't be opened for updating")
197+
198+
199+
def vfsopen(obj, mode='r', buffering=-1, encoding=None, errors=None,
200+
newline=None):
171201
"""
172202
Open the file pointed to by this path and return a file object, as
173203
the built-in open() function does.
204+
205+
Unlike the built-in open() function, this function accepts 'openable'
206+
objects, which are objects with any of these magic methods:
207+
208+
__open_reader__()
209+
__open_writer__(mode)
210+
__open_updater__(mode)
211+
212+
'__open_reader__' is called for 'r' mode; '__open_writer__' for 'a', 'w'
213+
and 'x' modes; and '__open_updater__' for 'r+' and 'w+' modes. If text
214+
mode is requested, the result is wrapped in an io.TextIOWrapper object.
174215
"""
175216
text = 'b' not in mode
176-
if text:
217+
if buffering != -1:
218+
raise ValueError("buffer size can't be customized")
219+
elif text:
177220
# Call io.text_encoding() here to ensure any warning is raised at an
178221
# appropriate stack level.
179222
encoding = text_encoding(encoding)
180-
try:
181-
return open(path, mode, buffering, encoding, errors, newline)
182-
except TypeError:
183-
pass
184-
cls = type(path)
185-
mode = ''.join(sorted(c for c in mode if c not in 'bt'))
186-
if text:
187-
try:
188-
attr = getattr(cls, f'__open_{mode}__')
189-
except AttributeError:
190-
pass
191-
else:
192-
return attr(path, buffering, encoding, errors, newline)
193223
elif encoding is not None:
194224
raise ValueError("binary mode doesn't take an encoding argument")
195225
elif errors is not None:
196226
raise ValueError("binary mode doesn't take an errors argument")
197227
elif newline is not None:
198228
raise ValueError("binary mode doesn't take a newline argument")
199-
200-
try:
201-
attr = getattr(cls, f'__open_{mode}b__')
202-
except AttributeError:
203-
pass
229+
mode = ''.join(sorted(c for c in mode if c not in 'bt'))
230+
if mode == 'r':
231+
stream = _open_reader(obj)
232+
elif mode in ('a', 'w', 'x'):
233+
stream = _open_writer(obj, mode)
234+
elif mode in ('+r', '+w'):
235+
stream = _open_updater(obj, mode[1])
204236
else:
205-
stream = attr(path, buffering)
206-
if text:
207-
stream = TextIOWrapper(stream, encoding, errors, newline)
208-
return stream
209-
210-
raise TypeError(f"{cls.__name__} can't be opened with mode {mode!r}")
237+
raise ValueError(f'invalid mode: {mode}')
238+
if text:
239+
stream = TextIOWrapper(stream, encoding, errors, newline)
240+
return stream
211241

212242

213243
def vfspath(path):

Lib/pathlib/types.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from abc import ABC, abstractmethod
1414
from glob import _GlobberBase
1515
from io import text_encoding
16-
from pathlib._os import (magic_open, vfspath, ensure_distinct_paths,
16+
from pathlib._os import (vfsopen, vfspath, ensure_distinct_paths,
1717
ensure_different_files, copyfileobj)
1818
from pathlib import PurePath, Path
1919
from typing import Optional, Protocol, runtime_checkable
@@ -264,18 +264,18 @@ def info(self):
264264
raise NotImplementedError
265265

266266
@abstractmethod
267-
def __open_rb__(self, buffering=-1):
267+
def __open_reader__(self):
268268
"""
269269
Open the file pointed to by this path for reading in binary mode and
270-
return a file object, like open(mode='rb').
270+
return a file object.
271271
"""
272272
raise NotImplementedError
273273

274274
def read_bytes(self):
275275
"""
276276
Open the file in bytes mode, read it, and close the file.
277277
"""
278-
with magic_open(self, mode='rb', buffering=0) as f:
278+
with vfsopen(self, mode='rb') as f:
279279
return f.read()
280280

281281
def read_text(self, encoding=None, errors=None, newline=None):
@@ -285,7 +285,7 @@ def read_text(self, encoding=None, errors=None, newline=None):
285285
# Call io.text_encoding() here to ensure any warning is raised at an
286286
# appropriate stack level.
287287
encoding = text_encoding(encoding)
288-
with magic_open(self, mode='r', encoding=encoding, errors=errors, newline=newline) as f:
288+
with vfsopen(self, mode='r', encoding=encoding, errors=errors, newline=newline) as f:
289289
return f.read()
290290

291291
@abstractmethod
@@ -394,10 +394,10 @@ def mkdir(self):
394394
raise NotImplementedError
395395

396396
@abstractmethod
397-
def __open_wb__(self, buffering=-1):
397+
def __open_writer__(self, mode):
398398
"""
399399
Open the file pointed to by this path for writing in binary mode and
400-
return a file object, like open(mode='wb').
400+
return a file object.
401401
"""
402402
raise NotImplementedError
403403

@@ -407,7 +407,7 @@ def write_bytes(self, data):
407407
"""
408408
# type-check for the buffer interface before truncating the file
409409
view = memoryview(data)
410-
with magic_open(self, mode='wb') as f:
410+
with vfsopen(self, mode='wb') as f:
411411
return f.write(view)
412412

413413
def write_text(self, data, encoding=None, errors=None, newline=None):
@@ -420,7 +420,7 @@ def write_text(self, data, encoding=None, errors=None, newline=None):
420420
if not isinstance(data, str):
421421
raise TypeError('data must be str, not %s' %
422422
data.__class__.__name__)
423-
with magic_open(self, mode='w', encoding=encoding, errors=errors, newline=newline) as f:
423+
with vfsopen(self, mode='w', encoding=encoding, errors=errors, newline=newline) as f:
424424
return f.write(data)
425425

426426
def _copy_from(self, source, follow_symlinks=True):
@@ -439,8 +439,8 @@ def _copy_from(self, source, follow_symlinks=True):
439439
stack.append((child, dst.joinpath(child.name)))
440440
else:
441441
ensure_different_files(src, dst)
442-
with magic_open(src, 'rb') as source_f:
443-
with magic_open(dst, 'wb') as target_f:
442+
with vfsopen(src, 'rb') as source_f:
443+
with vfsopen(dst, 'wb') as target_f:
444444
copyfileobj(source_f, target_f)
445445

446446

Lib/test/test_pathlib/support/local_path.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ def __init__(self, *pathsegments):
145145
super().__init__(*pathsegments)
146146
self.info = LocalPathInfo(self)
147147

148-
def __open_rb__(self, buffering=-1):
148+
def __open_reader__(self):
149149
return open(self, 'rb')
150150

151151
def iterdir(self):
@@ -163,8 +163,8 @@ class WritableLocalPath(_WritablePath, LexicalPath):
163163
__slots__ = ()
164164
__fspath__ = LexicalPath.__vfspath__
165165

166-
def __open_wb__(self, buffering=-1):
167-
return open(self, 'wb')
166+
def __open_writer__(self, mode):
167+
return open(self, f'{mode}b')
168168

169169
def mkdir(self, mode=0o777):
170170
os.mkdir(self, mode)

Lib/test/test_pathlib/support/zip_path.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -264,13 +264,13 @@ def info(self):
264264
tree = self.zip_file.filelist.tree
265265
return tree.resolve(vfspath(self), follow_symlinks=False)
266266

267-
def __open_rb__(self, buffering=-1):
267+
def __open_reader__(self):
268268
info = self.info.resolve()
269269
if not info.exists():
270270
raise FileNotFoundError(errno.ENOENT, "File not found", self)
271271
elif info.is_dir():
272272
raise IsADirectoryError(errno.EISDIR, "Is a directory", self)
273-
return self.zip_file.open(info.zip_info, 'r')
273+
return self.zip_file.open(info.zip_info)
274274

275275
def iterdir(self):
276276
info = self.info.resolve()
@@ -320,8 +320,8 @@ def __repr__(self):
320320
def with_segments(self, *pathsegments):
321321
return type(self)(*pathsegments, zip_file=self.zip_file)
322322

323-
def __open_wb__(self, buffering=-1):
324-
return self.zip_file.open(vfspath(self), 'w')
323+
def __open_writer__(self, mode):
324+
return self.zip_file.open(vfspath(self), mode)
325325

326326
def mkdir(self, mode=0o777):
327327
zinfo = zipfile.ZipInfo(vfspath(self) + '/')

Lib/test/test_pathlib/test_os.py

Whitespace-only changes.

Lib/test/test_pathlib/test_read.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@
1313

1414
if is_pypi:
1515
from pathlib_abc import PathInfo, _ReadablePath
16-
from pathlib_abc._os import magic_open
16+
from pathlib_abc._os import vfsopen
1717
else:
1818
from pathlib.types import PathInfo, _ReadablePath
19-
from pathlib._os import magic_open
19+
from pathlib._os import vfsopen
2020

2121

2222
class ReadTestBase:
@@ -32,7 +32,7 @@ def test_is_readable(self):
3232

3333
def test_open_r(self):
3434
p = self.root / 'fileA'
35-
with magic_open(p, 'r', encoding='utf-8') as f:
35+
with vfsopen(p, 'r', encoding='utf-8') as f:
3636
self.assertIsInstance(f, io.TextIOBase)
3737
self.assertEqual(f.read(), 'this is file A\n')
3838

@@ -43,17 +43,17 @@ def test_open_r(self):
4343
def test_open_r_encoding_warning(self):
4444
p = self.root / 'fileA'
4545
with self.assertWarns(EncodingWarning) as wc:
46-
with magic_open(p, 'r'):
46+
with vfsopen(p, 'r'):
4747
pass
4848
self.assertEqual(wc.filename, __file__)
4949

5050
def test_open_rb(self):
5151
p = self.root / 'fileA'
52-
with magic_open(p, 'rb') as f:
52+
with vfsopen(p, 'rb') as f:
5353
self.assertEqual(f.read(), b'this is file A\n')
54-
self.assertRaises(ValueError, magic_open, p, 'rb', encoding='utf8')
55-
self.assertRaises(ValueError, magic_open, p, 'rb', errors='strict')
56-
self.assertRaises(ValueError, magic_open, p, 'rb', newline='')
54+
self.assertRaises(ValueError, vfsopen, p, 'rb', encoding='utf8')
55+
self.assertRaises(ValueError, vfsopen, p, 'rb', errors='strict')
56+
self.assertRaises(ValueError, vfsopen, p, 'rb', newline='')
5757

5858
def test_read_bytes(self):
5959
p = self.root / 'fileA'

Lib/test/test_pathlib/test_write.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@
1313

1414
if is_pypi:
1515
from pathlib_abc import _WritablePath
16-
from pathlib_abc._os import magic_open
16+
from pathlib_abc._os import vfsopen
1717
else:
1818
from pathlib.types import _WritablePath
19-
from pathlib._os import magic_open
19+
from pathlib._os import vfsopen
2020

2121

2222
class WriteTestBase:
@@ -31,7 +31,7 @@ def test_is_writable(self):
3131

3232
def test_open_w(self):
3333
p = self.root / 'fileA'
34-
with magic_open(p, 'w', encoding='utf-8') as f:
34+
with vfsopen(p, 'w', encoding='utf-8') as f:
3535
self.assertIsInstance(f, io.TextIOBase)
3636
f.write('this is file A\n')
3737
self.assertEqual(self.ground.readtext(p), 'this is file A\n')
@@ -43,19 +43,19 @@ def test_open_w(self):
4343
def test_open_w_encoding_warning(self):
4444
p = self.root / 'fileA'
4545
with self.assertWarns(EncodingWarning) as wc:
46-
with magic_open(p, 'w'):
46+
with vfsopen(p, 'w'):
4747
pass
4848
self.assertEqual(wc.filename, __file__)
4949

5050
def test_open_wb(self):
5151
p = self.root / 'fileA'
52-
with magic_open(p, 'wb') as f:
52+
with vfsopen(p, 'wb') as f:
5353
#self.assertIsInstance(f, io.BufferedWriter)
5454
f.write(b'this is file A\n')
5555
self.assertEqual(self.ground.readbytes(p), b'this is file A\n')
56-
self.assertRaises(ValueError, magic_open, p, 'wb', encoding='utf8')
57-
self.assertRaises(ValueError, magic_open, p, 'wb', errors='strict')
58-
self.assertRaises(ValueError, magic_open, p, 'wb', newline='')
56+
self.assertRaises(ValueError, vfsopen, p, 'wb', encoding='utf8')
57+
self.assertRaises(ValueError, vfsopen, p, 'wb', errors='strict')
58+
self.assertRaises(ValueError, vfsopen, p, 'wb', newline='')
5959

6060
def test_write_bytes(self):
6161
p = self.root / 'fileA'

0 commit comments

Comments
 (0)