summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSebastian Rittau <srittau@rittau.biz>2025-03-06 15:36:19 (GMT)
committerGitHub <noreply@github.com>2025-03-06 15:36:19 (GMT)
commitc6dd2348ca61436fc1444ecc0343cb24932f6fa7 (patch)
tree549d12e443ddcdd38425e26ac8896b8ab5780006
parent9c691500f9412ecd8f6221c20984dc7a55a8a9e8 (diff)
downloadcpython-c6dd2348ca61436fc1444ecc0343cb24932f6fa7.zip
cpython-c6dd2348ca61436fc1444ecc0343cb24932f6fa7.tar.gz
cpython-c6dd2348ca61436fc1444ecc0343cb24932f6fa7.tar.bz2
gh-127647: Add typing.Reader and Writer protocols (#127648)
-rw-r--r--Doc/library/io.rst49
-rw-r--r--Doc/library/typing.rst32
-rw-r--r--Doc/whatsnew/3.14.rst5
-rw-r--r--Lib/_pyio.py2
-rw-r--r--Lib/io.py56
-rw-r--r--Lib/test/test_io.py18
-rw-r--r--Lib/test/test_typing.py35
-rw-r--r--Lib/typing.py1
-rw-r--r--Misc/NEWS.d/next/Library/2024-12-05-19-54-16.gh-issue-127647.Xd78Vs.rst3
9 files changed, 192 insertions, 9 deletions
diff --git a/Doc/library/io.rst b/Doc/library/io.rst
index 0d8cc51..cb21823 100644
--- a/Doc/library/io.rst
+++ b/Doc/library/io.rst
@@ -1147,6 +1147,55 @@ Text I/O
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
-----------
diff --git a/Doc/library/typing.rst b/Doc/library/typing.rst
index aa613ee..3bbc8c0 100644
--- a/Doc/library/typing.rst
+++ b/Doc/library/typing.rst
@@ -2834,17 +2834,35 @@ with :func:`@runtime_checkable <runtime_checkable>`.
An ABC with one abstract method ``__round__``
that is covariant in its return type.
-ABCs for working with IO
-------------------------
+.. _typing-io:
+
+ABCs and Protocols for working with I/O
+---------------------------------------
-.. class:: IO
- TextIO
- BinaryIO
+.. class:: IO[AnyStr]
+ TextIO[AnyStr]
+ BinaryIO[AnyStr]
- Generic type ``IO[AnyStr]`` and its subclasses ``TextIO(IO[str])``
+ Generic class ``IO[AnyStr]`` and its subclasses ``TextIO(IO[str])``
and ``BinaryIO(IO[bytes])``
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
------------------------
diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst
index 52a0bd2..2402fb2 100644
--- a/Doc/whatsnew/3.14.rst
+++ b/Doc/whatsnew/3.14.rst
@@ -619,6 +619,11 @@ io
:exc:`BlockingIOError` if the operation cannot immediately return bytes.
(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
----
diff --git a/Lib/_pyio.py b/Lib/_pyio.py
index f7370df..e915e5b 100644
--- a/Lib/_pyio.py
+++ b/Lib/_pyio.py
@@ -16,7 +16,7 @@ else:
_setmode = None
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
if hasattr(os, 'SEEK_HOLE') :
diff --git a/Lib/io.py b/Lib/io.py
index f0e2fa1..e9fe619 100644
--- a/Lib/io.py
+++ b/Lib/io.py
@@ -46,12 +46,14 @@ __all__ = ["BlockingIOError", "open", "open_code", "IOBase", "RawIOBase",
"BufferedReader", "BufferedWriter", "BufferedRWPair",
"BufferedRandom", "TextIOBase", "TextIOWrapper",
"UnsupportedOperation", "SEEK_SET", "SEEK_CUR", "SEEK_END",
- "DEFAULT_BUFFER_SIZE", "text_encoding", "IncrementalNewlineDecoder"]
+ "DEFAULT_BUFFER_SIZE", "text_encoding", "IncrementalNewlineDecoder",
+ "Reader", "Writer"]
import _io
import abc
+from _collections_abc import _check_methods
from _io import (DEFAULT_BUFFER_SIZE, BlockingIOError, UnsupportedOperation,
open, open_code, FileIO, BytesIO, StringIO, BufferedReader,
BufferedWriter, BufferedRWPair, BufferedRandom,
@@ -97,3 +99,55 @@ except ImportError:
pass
else:
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)
diff --git a/Lib/test/test_io.py b/Lib/test/test_io.py
index e59d397..3b8ff1d 100644
--- a/Lib/test/test_io.py
+++ b/Lib/test/test_io.py
@@ -4916,6 +4916,24 @@ class PySignalsTest(SignalsTest):
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):
tests = (CIOTest, PyIOTest, APIMismatchTest,
CBufferedReaderTest, PyBufferedReaderTest,
diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py
index a7901df..4023534 100644
--- a/Lib/test/test_typing.py
+++ b/Lib/test/test_typing.py
@@ -6,6 +6,7 @@ from collections import defaultdict
from functools import lru_cache, wraps, reduce
import gc
import inspect
+import io
import itertools
import operator
import os
@@ -4294,6 +4295,40 @@ class ProtocolTests(BaseTestCase):
self.assertNotIsSubclass(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):
with self.assertRaises(TypeError):
class CustomProtocol(TestCase, Protocol):
diff --git a/Lib/typing.py b/Lib/typing.py
index 1dd1154..9621155 100644
--- a/Lib/typing.py
+++ b/Lib/typing.py
@@ -1876,6 +1876,7 @@ _PROTO_ALLOWLIST = {
'Reversible', 'Buffer',
],
'contextlib': ['AbstractContextManager', 'AbstractAsyncContextManager'],
+ 'io': ['Reader', 'Writer'],
'os': ['PathLike'],
}
diff --git a/Misc/NEWS.d/next/Library/2024-12-05-19-54-16.gh-issue-127647.Xd78Vs.rst b/Misc/NEWS.d/next/Library/2024-12-05-19-54-16.gh-issue-127647.Xd78Vs.rst
new file mode 100644
index 0000000..8f0b812
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2024-12-05-19-54-16.gh-issue-127647.Xd78Vs.rst
@@ -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`.