diff --git a/docs/source/reference-io.rst b/docs/source/reference-io.rst index d1eb083b08..546a8975b3 100644 --- a/docs/source/reference-io.rst +++ b/docs/source/reference-io.rst @@ -657,6 +657,8 @@ Asynchronous file objects .. autofunction:: wrap_file(file) +.. autoclass:: AsyncIOWrapper() + .. interface:: Asynchronous file interface Trio's asynchronous file objects have an interface that diff --git a/newsfragments/3390.feature.rst b/newsfragments/3390.feature.rst new file mode 100644 index 0000000000..4e37792b2c --- /dev/null +++ b/newsfragments/3390.feature.rst @@ -0,0 +1,2 @@ +The type returned by `trio.open_file`, `trio.wrap_file`, and `trio.Path.open` +is now publicly available as `trio.AsyncIOWrapper` for use in type annotations. diff --git a/src/trio/__init__.py b/src/trio/__init__.py index b937ac5b93..1f4b30c209 100644 --- a/src/trio/__init__.py +++ b/src/trio/__init__.py @@ -53,7 +53,11 @@ DTLSChannelStatistics as DTLSChannelStatistics, DTLSEndpoint as DTLSEndpoint, ) -from ._file_io import open_file as open_file, wrap_file as wrap_file +from ._file_io import ( + AsyncIOWrapper as AsyncIOWrapper, + open_file as open_file, + wrap_file as wrap_file, +) from ._highlevel_generic import ( StapledStream as StapledStream, aclose_forcefully as aclose_forcefully, diff --git a/src/trio/_file_io.py b/src/trio/_file_io.py index d9305ef4ff..17e15ea974 100644 --- a/src/trio/_file_io.py +++ b/src/trio/_file_io.py @@ -18,7 +18,7 @@ import trio -from ._util import async_wraps +from ._util import async_wraps, final from .abc import AsyncResource if TYPE_CHECKING: @@ -216,6 +216,7 @@ def close(self) -> None: ... # FileT needs to be covariant for the protocol trick to work - the real IO types are effectively a # subtype of the protocols. +@final class AsyncIOWrapper(AsyncResource, Generic[FileT_co]): """A generic :class:`~io.IOBase` wrapper that implements the :term:`asynchronous file object` interface. Wrapped methods that could block are executed in @@ -223,6 +224,11 @@ class AsyncIOWrapper(AsyncResource, Generic[FileT_co]): All properties and methods defined in :mod:`~io` are exposed by this wrapper, if they exist in the wrapped file object. + + Obtain a wrapper using :func:`open_file`, :func:`wrap_file`, or + :meth:`Path.open`. For type annotations, parameterize this class with the + type of the wrapped synchronous file object, for example + ``AsyncIOWrapper[io.TextIOWrapper]`` or ``AsyncIOWrapper[io.BytesIO]``. """ def __init__(self, file: FileT_co) -> None: diff --git a/src/trio/_tests/test_exports.py b/src/trio/_tests/test_exports.py index 1c2b46ca47..25ec707c51 100644 --- a/src/trio/_tests/test_exports.py +++ b/src/trio/_tests/test_exports.py @@ -21,7 +21,7 @@ import trio.testing from trio._tests.pytest_plugin import RUN_SLOW, skip_if_optional_else_raise -from .. import _core, _util +from .. import _core, _file_io, _util from .._core._tests.tutil import slow if TYPE_CHECKING: @@ -414,6 +414,15 @@ def lookup_symbol(symbol: str) -> dict[str, Any]: # type: ignore[misc, explicit missing = runtime_names - static_names extra = static_names - runtime_names + if class_ is trio.AsyncIOWrapper: + # The stubs describe methods and properties supplied by __getattr__ + # on instances. Only detach is also defined on the runtime class. + missing.remove("__getattr__") + for name in (_file_io._FILE_SYNC_ATTRS | _file_io._FILE_ASYNC_METHODS) - { + "detach" + }: + extra.remove(name) + # using .remove() instead of .delete() to get an error in case they start not # being missing diff --git a/src/trio/_tests/test_file_io.py b/src/trio/_tests/test_file_io.py index 390a81ce61..721d503516 100644 --- a/src/trio/_tests/test_file_io.py +++ b/src/trio/_tests/test_file_io.py @@ -11,8 +11,8 @@ import pytest import trio -from trio import _core, _file_io -from trio._file_io import _FILE_ASYNC_METHODS, _FILE_SYNC_ATTRS, AsyncIOWrapper +from trio import AsyncIOWrapper, _core, _file_io +from trio._file_io import _FILE_ASYNC_METHODS, _FILE_SYNC_ATTRS if TYPE_CHECKING: import pathlib diff --git a/src/trio/_tests/type_tests/file_io.py b/src/trio/_tests/type_tests/file_io.py new file mode 100644 index 0000000000..7bd3dbb9c3 --- /dev/null +++ b/src/trio/_tests/type_tests/file_io.py @@ -0,0 +1,29 @@ +"""File wrappers can be annotated using the public generic type.""" + +import io + +import trio +from trio import AsyncIOWrapper +from typing_extensions import assert_type + + +async def open_results(path: str) -> None: + async with await trio.open_file(path) as text_file: + assert_type(text_file, AsyncIOWrapper[io.TextIOWrapper]) + assert_type(await text_file.read(), str) + async with await trio.open_file(path, "rb") as binary_file: + assert_type(binary_file, AsyncIOWrapper[io.BufferedReader]) + assert_type(await binary_file.read(), bytes) + async with await trio.open_file(path, "rb", buffering=0) as raw_file: + assert_type(raw_file, AsyncIOWrapper[io.FileIO]) + + +async def wrapped_results(text: io.StringIO, binary: io.BytesIO) -> None: + async with trio.wrap_file(text) as text_file: + assert_type(text_file, AsyncIOWrapper[io.StringIO]) + assert_type(text_file.wrapped, io.StringIO) + assert_type(await text_file.read(), str) + async with trio.wrap_file(binary) as binary_file: + assert_type(binary_file, AsyncIOWrapper[io.BytesIO]) + assert_type(binary_file.wrapped, io.BytesIO) + assert_type(await binary_file.read(), bytes) diff --git a/src/trio/_tests/type_tests/path.py b/src/trio/_tests/type_tests/path.py index 2b956c9315..0d74f6f3bd 100644 --- a/src/trio/_tests/type_tests/path.py +++ b/src/trio/_tests/type_tests/path.py @@ -7,7 +7,7 @@ from typing import IO, Any, BinaryIO import trio -from trio._file_io import AsyncIOWrapper +from trio import AsyncIOWrapper from typing_extensions import assert_type