Skip to content

Commit c6dd234

Browse files
authored
gh-127647: Add typing.Reader and Writer protocols (#127648)
1 parent 9c69150 commit c6dd234

File tree

9 files changed

+192
-9
lines changed

9 files changed

+192
-9
lines changed

Doc/library/io.rst

+49
Original file line numberDiff line numberDiff line change
@@ -1147,6 +1147,55 @@ Text I/O
11471147
It inherits from :class:`codecs.IncrementalDecoder`.
11481148

11491149

1150+
Static Typing
1151+
-------------
1152+
1153+
The following protocols can be used for annotating function and method
1154+
arguments for simple stream reading or writing operations. They are decorated
1155+
with :deco:`typing.runtime_checkable`.
1156+
1157+
.. class:: Reader[T]
1158+
1159+
Generic protocol for reading from a file or other input stream. ``T`` will
1160+
usually be :class:`str` or :class:`bytes`, but can be any type that is
1161+
read from the stream.
1162+
1163+
.. versionadded:: next
1164+
1165+
.. method:: read()
1166+
read(size, /)
1167+
1168+
Read data from the input stream and return it. If *size* is
1169+
specified, it should be an integer, and at most *size* items
1170+
(bytes/characters) will be read.
1171+
1172+
For example::
1173+
1174+
def read_it(reader: Reader[str]):
1175+
data = reader.read(11)
1176+
assert isinstance(data, str)
1177+
1178+
.. class:: Writer[T]
1179+
1180+
Generic protocol for writing to a file or other output stream. ``T`` will
1181+
usually be :class:`str` or :class:`bytes`, but can be any type that can be
1182+
written to the stream.
1183+
1184+
.. versionadded:: next
1185+
1186+
.. method:: write(data, /)
1187+
1188+
Write *data* to the output stream and return the number of items
1189+
(bytes/characters) written.
1190+
1191+
For example::
1192+
1193+
def write_binary(writer: Writer[bytes]):
1194+
writer.write(b"Hello world!\n")
1195+
1196+
See :ref:`typing-io` for other I/O related protocols and classes that can be
1197+
used for static type checking.
1198+
11501199
Performance
11511200
-----------
11521201

Doc/library/typing.rst

+25-7
Original file line numberDiff line numberDiff line change
@@ -2834,17 +2834,35 @@ with :func:`@runtime_checkable <runtime_checkable>`.
28342834
An ABC with one abstract method ``__round__``
28352835
that is covariant in its return type.
28362836

2837-
ABCs for working with IO
2838-
------------------------
2837+
.. _typing-io:
2838+
2839+
ABCs and Protocols for working with I/O
2840+
---------------------------------------
28392841

2840-
.. class:: IO
2841-
TextIO
2842-
BinaryIO
2842+
.. class:: IO[AnyStr]
2843+
TextIO[AnyStr]
2844+
BinaryIO[AnyStr]
28432845

2844-
Generic type ``IO[AnyStr]`` and its subclasses ``TextIO(IO[str])``
2846+
Generic class ``IO[AnyStr]`` and its subclasses ``TextIO(IO[str])``
28452847
and ``BinaryIO(IO[bytes])``
28462848
represent the types of I/O streams such as returned by
2847-
:func:`open`.
2849+
:func:`open`. Please note that these classes are not protocols, and
2850+
their interface is fairly broad.
2851+
2852+
The protocols :class:`io.Reader` and :class:`io.Writer` offer a simpler
2853+
alternative for argument types, when only the ``read()`` or ``write()``
2854+
methods are accessed, respectively::
2855+
2856+
def read_and_write(reader: Reader[str], writer: Writer[bytes]):
2857+
data = reader.read()
2858+
writer.write(data.encode())
2859+
2860+
Also consider using :class:`collections.abc.Iterable` for iterating over
2861+
the lines of an input stream::
2862+
2863+
def read_config(stream: Iterable[str]):
2864+
for line in stream:
2865+
...
28482866

28492867
Functions and decorators
28502868
------------------------

Doc/whatsnew/3.14.rst

+5
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,11 @@ io
619619
:exc:`BlockingIOError` if the operation cannot immediately return bytes.
620620
(Contributed by Giovanni Siragusa in :gh:`109523`.)
621621

622+
* Add protocols :class:`io.Reader` and :class:`io.Writer` as a simpler
623+
alternatives to the pseudo-protocols :class:`typing.IO`,
624+
:class:`typing.TextIO`, and :class:`typing.BinaryIO`.
625+
(Contributed by Sebastian Rittau in :gh:`127648`.)
626+
622627

623628
json
624629
----

Lib/_pyio.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
_setmode = None
1717

1818
import io
19-
from io import (__all__, SEEK_SET, SEEK_CUR, SEEK_END) # noqa: F401
19+
from io import (__all__, SEEK_SET, SEEK_CUR, SEEK_END, Reader, Writer) # noqa: F401
2020

2121
valid_seek_flags = {0, 1, 2} # Hardwired values
2222
if hasattr(os, 'SEEK_HOLE') :

Lib/io.py

+55-1
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,14 @@
4646
"BufferedReader", "BufferedWriter", "BufferedRWPair",
4747
"BufferedRandom", "TextIOBase", "TextIOWrapper",
4848
"UnsupportedOperation", "SEEK_SET", "SEEK_CUR", "SEEK_END",
49-
"DEFAULT_BUFFER_SIZE", "text_encoding", "IncrementalNewlineDecoder"]
49+
"DEFAULT_BUFFER_SIZE", "text_encoding", "IncrementalNewlineDecoder",
50+
"Reader", "Writer"]
5051

5152

5253
import _io
5354
import abc
5455

56+
from _collections_abc import _check_methods
5557
from _io import (DEFAULT_BUFFER_SIZE, BlockingIOError, UnsupportedOperation,
5658
open, open_code, FileIO, BytesIO, StringIO, BufferedReader,
5759
BufferedWriter, BufferedRWPair, BufferedRandom,
@@ -97,3 +99,55 @@ class TextIOBase(_io._TextIOBase, IOBase):
9799
pass
98100
else:
99101
RawIOBase.register(_WindowsConsoleIO)
102+
103+
#
104+
# Static Typing Support
105+
#
106+
107+
GenericAlias = type(list[int])
108+
109+
110+
class Reader(metaclass=abc.ABCMeta):
111+
"""Protocol for simple I/O reader instances.
112+
113+
This protocol only supports blocking I/O.
114+
"""
115+
116+
__slots__ = ()
117+
118+
@abc.abstractmethod
119+
def read(self, size=..., /):
120+
"""Read data from the input stream and return it.
121+
122+
If *size* is specified, at most *size* items (bytes/characters) will be
123+
read.
124+
"""
125+
126+
@classmethod
127+
def __subclasshook__(cls, C):
128+
if cls is Reader:
129+
return _check_methods(C, "read")
130+
return NotImplemented
131+
132+
__class_getitem__ = classmethod(GenericAlias)
133+
134+
135+
class Writer(metaclass=abc.ABCMeta):
136+
"""Protocol for simple I/O writer instances.
137+
138+
This protocol only supports blocking I/O.
139+
"""
140+
141+
__slots__ = ()
142+
143+
@abc.abstractmethod
144+
def write(self, data, /):
145+
"""Write *data* to the output stream and return the number of items written."""
146+
147+
@classmethod
148+
def __subclasshook__(cls, C):
149+
if cls is Writer:
150+
return _check_methods(C, "write")
151+
return NotImplemented
152+
153+
__class_getitem__ = classmethod(GenericAlias)

Lib/test/test_io.py

+18
Original file line numberDiff line numberDiff line change
@@ -4916,6 +4916,24 @@ class PySignalsTest(SignalsTest):
49164916
test_reentrant_write_text = None
49174917

49184918

4919+
class ProtocolsTest(unittest.TestCase):
4920+
class MyReader:
4921+
def read(self, sz=-1):
4922+
return b""
4923+
4924+
class MyWriter:
4925+
def write(self, b: bytes):
4926+
pass
4927+
4928+
def test_reader_subclass(self):
4929+
self.assertIsSubclass(MyReader, io.Reader[bytes])
4930+
self.assertNotIsSubclass(str, io.Reader[bytes])
4931+
4932+
def test_writer_subclass(self):
4933+
self.assertIsSubclass(MyWriter, io.Writer[bytes])
4934+
self.assertNotIsSubclass(str, io.Writer[bytes])
4935+
4936+
49194937
def load_tests(loader, tests, pattern):
49204938
tests = (CIOTest, PyIOTest, APIMismatchTest,
49214939
CBufferedReaderTest, PyBufferedReaderTest,

Lib/test/test_typing.py

+35
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from functools import lru_cache, wraps, reduce
77
import gc
88
import inspect
9+
import io
910
import itertools
1011
import operator
1112
import os
@@ -4294,6 +4295,40 @@ def __release_buffer__(self, mv: memoryview) -> None:
42944295
self.assertNotIsSubclass(C, ReleasableBuffer)
42954296
self.assertNotIsInstance(C(), ReleasableBuffer)
42964297

4298+
def test_io_reader_protocol_allowed(self):
4299+
@runtime_checkable
4300+
class CustomReader(io.Reader[bytes], Protocol):
4301+
def close(self): ...
4302+
4303+
class A: pass
4304+
class B:
4305+
def read(self, sz=-1):
4306+
return b""
4307+
def close(self):
4308+
pass
4309+
4310+
self.assertIsSubclass(B, CustomReader)
4311+
self.assertIsInstance(B(), CustomReader)
4312+
self.assertNotIsSubclass(A, CustomReader)
4313+
self.assertNotIsInstance(A(), CustomReader)
4314+
4315+
def test_io_writer_protocol_allowed(self):
4316+
@runtime_checkable
4317+
class CustomWriter(io.Writer[bytes], Protocol):
4318+
def close(self): ...
4319+
4320+
class A: pass
4321+
class B:
4322+
def write(self, b):
4323+
pass
4324+
def close(self):
4325+
pass
4326+
4327+
self.assertIsSubclass(B, CustomWriter)
4328+
self.assertIsInstance(B(), CustomWriter)
4329+
self.assertNotIsSubclass(A, CustomWriter)
4330+
self.assertNotIsInstance(A(), CustomWriter)
4331+
42974332
def test_builtin_protocol_allowlist(self):
42984333
with self.assertRaises(TypeError):
42994334
class CustomProtocol(TestCase, Protocol):

Lib/typing.py

+1
Original file line numberDiff line numberDiff line change
@@ -1876,6 +1876,7 @@ def _allow_reckless_class_checks(depth=2):
18761876
'Reversible', 'Buffer',
18771877
],
18781878
'contextlib': ['AbstractContextManager', 'AbstractAsyncContextManager'],
1879+
'io': ['Reader', 'Writer'],
18791880
'os': ['PathLike'],
18801881
}
18811882

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

0 commit comments

Comments
 (0)