gh-127647: Add typing.Reader and Writer protocols (#127648)

This commit is contained in:
Sebastian Rittau 2025-03-06 16:36:19 +01:00 committed by GitHub
parent 9c691500f9
commit c6dd2348ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 192 additions and 9 deletions

View file

@ -1147,6 +1147,55 @@ Text I/O
It inherits from :class:`codecs.IncrementalDecoder`. It inherits from :class:`codecs.IncrementalDecoder`.
Static Typing
-------------
The following protocols can be used for annotating function and method
arguments for simple stream reading or writing operations. They are decorated
with :deco:`typing.runtime_checkable`.
.. class:: Reader[T]
Generic protocol for reading from a file or other input stream. ``T`` will
usually be :class:`str` or :class:`bytes`, but can be any type that is
read from the stream.
.. versionadded:: next
.. method:: read()
read(size, /)
Read data from the input stream and return it. If *size* is
specified, it should be an integer, and at most *size* items
(bytes/characters) will be read.
For example::
def read_it(reader: Reader[str]):
data = reader.read(11)
assert isinstance(data, str)
.. class:: Writer[T]
Generic protocol for writing to a file or other output stream. ``T`` will
usually be :class:`str` or :class:`bytes`, but can be any type that can be
written to the stream.
.. versionadded:: next
.. method:: write(data, /)
Write *data* to the output stream and return the number of items
(bytes/characters) written.
For example::
def write_binary(writer: Writer[bytes]):
writer.write(b"Hello world!\n")
See :ref:`typing-io` for other I/O related protocols and classes that can be
used for static type checking.
Performance Performance
----------- -----------

View file

@ -2834,17 +2834,35 @@ with :func:`@runtime_checkable <runtime_checkable>`.
An ABC with one abstract method ``__round__`` An ABC with one abstract method ``__round__``
that is covariant in its return type. that is covariant in its return type.
ABCs for working with IO .. _typing-io:
------------------------
.. class:: IO ABCs and Protocols for working with I/O
TextIO ---------------------------------------
BinaryIO
Generic type ``IO[AnyStr]`` and its subclasses ``TextIO(IO[str])`` .. class:: IO[AnyStr]
TextIO[AnyStr]
BinaryIO[AnyStr]
Generic class ``IO[AnyStr]`` and its subclasses ``TextIO(IO[str])``
and ``BinaryIO(IO[bytes])`` and ``BinaryIO(IO[bytes])``
represent the types of I/O streams such as returned by represent the types of I/O streams such as returned by
:func:`open`. :func:`open`. Please note that these classes are not protocols, and
their interface is fairly broad.
The protocols :class:`io.Reader` and :class:`io.Writer` offer a simpler
alternative for argument types, when only the ``read()`` or ``write()``
methods are accessed, respectively::
def read_and_write(reader: Reader[str], writer: Writer[bytes]):
data = reader.read()
writer.write(data.encode())
Also consider using :class:`collections.abc.Iterable` for iterating over
the lines of an input stream::
def read_config(stream: Iterable[str]):
for line in stream:
...
Functions and decorators Functions and decorators
------------------------ ------------------------

View file

@ -619,6 +619,11 @@ io
:exc:`BlockingIOError` if the operation cannot immediately return bytes. :exc:`BlockingIOError` if the operation cannot immediately return bytes.
(Contributed by Giovanni Siragusa in :gh:`109523`.) (Contributed by Giovanni Siragusa in :gh:`109523`.)
* Add protocols :class:`io.Reader` and :class:`io.Writer` as a simpler
alternatives to the pseudo-protocols :class:`typing.IO`,
:class:`typing.TextIO`, and :class:`typing.BinaryIO`.
(Contributed by Sebastian Rittau in :gh:`127648`.)
json json
---- ----

View file

@ -16,7 +16,7 @@ else:
_setmode = None _setmode = None
import io import io
from io import (__all__, SEEK_SET, SEEK_CUR, SEEK_END) # noqa: F401 from io import (__all__, SEEK_SET, SEEK_CUR, SEEK_END, Reader, Writer) # noqa: F401
valid_seek_flags = {0, 1, 2} # Hardwired values valid_seek_flags = {0, 1, 2} # Hardwired values
if hasattr(os, 'SEEK_HOLE') : if hasattr(os, 'SEEK_HOLE') :

View file

@ -46,12 +46,14 @@ __all__ = ["BlockingIOError", "open", "open_code", "IOBase", "RawIOBase",
"BufferedReader", "BufferedWriter", "BufferedRWPair", "BufferedReader", "BufferedWriter", "BufferedRWPair",
"BufferedRandom", "TextIOBase", "TextIOWrapper", "BufferedRandom", "TextIOBase", "TextIOWrapper",
"UnsupportedOperation", "SEEK_SET", "SEEK_CUR", "SEEK_END", "UnsupportedOperation", "SEEK_SET", "SEEK_CUR", "SEEK_END",
"DEFAULT_BUFFER_SIZE", "text_encoding", "IncrementalNewlineDecoder"] "DEFAULT_BUFFER_SIZE", "text_encoding", "IncrementalNewlineDecoder",
"Reader", "Writer"]
import _io import _io
import abc import abc
from _collections_abc import _check_methods
from _io import (DEFAULT_BUFFER_SIZE, BlockingIOError, UnsupportedOperation, from _io import (DEFAULT_BUFFER_SIZE, BlockingIOError, UnsupportedOperation,
open, open_code, FileIO, BytesIO, StringIO, BufferedReader, open, open_code, FileIO, BytesIO, StringIO, BufferedReader,
BufferedWriter, BufferedRWPair, BufferedRandom, BufferedWriter, BufferedRWPair, BufferedRandom,
@ -97,3 +99,55 @@ except ImportError:
pass pass
else: else:
RawIOBase.register(_WindowsConsoleIO) RawIOBase.register(_WindowsConsoleIO)
#
# Static Typing Support
#
GenericAlias = type(list[int])
class Reader(metaclass=abc.ABCMeta):
"""Protocol for simple I/O reader instances.
This protocol only supports blocking I/O.
"""
__slots__ = ()
@abc.abstractmethod
def read(self, size=..., /):
"""Read data from the input stream and return it.
If *size* is specified, at most *size* items (bytes/characters) will be
read.
"""
@classmethod
def __subclasshook__(cls, C):
if cls is Reader:
return _check_methods(C, "read")
return NotImplemented
__class_getitem__ = classmethod(GenericAlias)
class Writer(metaclass=abc.ABCMeta):
"""Protocol for simple I/O writer instances.
This protocol only supports blocking I/O.
"""
__slots__ = ()
@abc.abstractmethod
def write(self, data, /):
"""Write *data* to the output stream and return the number of items written."""
@classmethod
def __subclasshook__(cls, C):
if cls is Writer:
return _check_methods(C, "write")
return NotImplemented
__class_getitem__ = classmethod(GenericAlias)

View file

@ -4916,6 +4916,24 @@ class PySignalsTest(SignalsTest):
test_reentrant_write_text = None test_reentrant_write_text = None
class ProtocolsTest(unittest.TestCase):
class MyReader:
def read(self, sz=-1):
return b""
class MyWriter:
def write(self, b: bytes):
pass
def test_reader_subclass(self):
self.assertIsSubclass(MyReader, io.Reader[bytes])
self.assertNotIsSubclass(str, io.Reader[bytes])
def test_writer_subclass(self):
self.assertIsSubclass(MyWriter, io.Writer[bytes])
self.assertNotIsSubclass(str, io.Writer[bytes])
def load_tests(loader, tests, pattern): def load_tests(loader, tests, pattern):
tests = (CIOTest, PyIOTest, APIMismatchTest, tests = (CIOTest, PyIOTest, APIMismatchTest,
CBufferedReaderTest, PyBufferedReaderTest, CBufferedReaderTest, PyBufferedReaderTest,

View file

@ -6,6 +6,7 @@ from collections import defaultdict
from functools import lru_cache, wraps, reduce from functools import lru_cache, wraps, reduce
import gc import gc
import inspect import inspect
import io
import itertools import itertools
import operator import operator
import os import os
@ -4294,6 +4295,40 @@ class ProtocolTests(BaseTestCase):
self.assertNotIsSubclass(C, ReleasableBuffer) self.assertNotIsSubclass(C, ReleasableBuffer)
self.assertNotIsInstance(C(), ReleasableBuffer) self.assertNotIsInstance(C(), ReleasableBuffer)
def test_io_reader_protocol_allowed(self):
@runtime_checkable
class CustomReader(io.Reader[bytes], Protocol):
def close(self): ...
class A: pass
class B:
def read(self, sz=-1):
return b""
def close(self):
pass
self.assertIsSubclass(B, CustomReader)
self.assertIsInstance(B(), CustomReader)
self.assertNotIsSubclass(A, CustomReader)
self.assertNotIsInstance(A(), CustomReader)
def test_io_writer_protocol_allowed(self):
@runtime_checkable
class CustomWriter(io.Writer[bytes], Protocol):
def close(self): ...
class A: pass
class B:
def write(self, b):
pass
def close(self):
pass
self.assertIsSubclass(B, CustomWriter)
self.assertIsInstance(B(), CustomWriter)
self.assertNotIsSubclass(A, CustomWriter)
self.assertNotIsInstance(A(), CustomWriter)
def test_builtin_protocol_allowlist(self): def test_builtin_protocol_allowlist(self):
with self.assertRaises(TypeError): with self.assertRaises(TypeError):
class CustomProtocol(TestCase, Protocol): class CustomProtocol(TestCase, Protocol):

View file

@ -1876,6 +1876,7 @@ _PROTO_ALLOWLIST = {
'Reversible', 'Buffer', 'Reversible', 'Buffer',
], ],
'contextlib': ['AbstractContextManager', 'AbstractAsyncContextManager'], 'contextlib': ['AbstractContextManager', 'AbstractAsyncContextManager'],
'io': ['Reader', 'Writer'],
'os': ['PathLike'], 'os': ['PathLike'],
} }

View file

@ -0,0 +1,3 @@
Add protocols :class:`io.Reader` and :class:`io.Writer` as
alternatives to :class:`typing.IO`, :class:`typing.TextIO`, and
:class:`typing.BinaryIO`.