diff options
author | Sebastian Rittau <srittau@rittau.biz> | 2025-03-06 15:36:19 (GMT) |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-03-06 15:36:19 (GMT) |
commit | c6dd2348ca61436fc1444ecc0343cb24932f6fa7 (patch) | |
tree | 549d12e443ddcdd38425e26ac8896b8ab5780006 | |
parent | 9c691500f9412ecd8f6221c20984dc7a55a8a9e8 (diff) | |
download | cpython-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.rst | 49 | ||||
-rw-r--r-- | Doc/library/typing.rst | 32 | ||||
-rw-r--r-- | Doc/whatsnew/3.14.rst | 5 | ||||
-rw-r--r-- | Lib/_pyio.py | 2 | ||||
-rw-r--r-- | Lib/io.py | 56 | ||||
-rw-r--r-- | Lib/test/test_io.py | 18 | ||||
-rw-r--r-- | Lib/test/test_typing.py | 35 | ||||
-rw-r--r-- | Lib/typing.py | 1 | ||||
-rw-r--r-- | Misc/NEWS.d/next/Library/2024-12-05-19-54-16.gh-issue-127647.Xd78Vs.rst | 3 |
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') : @@ -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`. |