diff --git a/docs/source/reference-io.rst b/docs/source/reference-io.rst index d1eb083b08..cb492bc83a 100644 --- a/docs/source/reference-io.rst +++ b/docs/source/reference-io.rst @@ -657,6 +657,16 @@ Asynchronous file objects .. autofunction:: wrap_file(file) +.. autoclass:: AsyncIOWrapper + + This is the concrete wrapper type returned by :func:`open_file`, + :func:`wrap_file`, and :meth:`Path.open`. It does not provide every file + operation on every instance: available operations depend on the wrapped + synchronous file object, and the type hints model that relationship. If an + API only needs a small subset of the asynchronous file interface, accepting + a structural protocol can be more flexible than requiring + ``AsyncIOWrapper`` specifically. + .. 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..8bd675a629 --- /dev/null +++ b/newsfragments/3390.feature.rst @@ -0,0 +1,3 @@ +:class:`trio.AsyncIOWrapper` is now public, so values returned by +:func:`trio.open_file`, :func:`trio.wrap_file`, and :meth:`trio.Path.open` can +be annotated without importing from the private ``trio._file_io`` module. 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..ae8678a830 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. + + Instances are normally obtained from :func:`trio.open_file`, + :func:`trio.wrap_file`, or :meth:`trio.Path.open`. The generic parameter + records the type of the wrapped synchronous file object, and the available + file operations follow that wrapped type. """ 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..0c257b824d 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,17 @@ 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: + # AsyncIOWrapper intentionally has a different static class surface: + # these names model methods supplied dynamically by __getattr__ on + # instances. The file-I/O tests separately verify that the dynamic + # methods and the TYPE_CHECKING declarations stay in sync. + expected_extra = _file_io._FILE_SYNC_ATTRS | _file_io._FILE_ASYNC_METHODS + assert missing == {"__getattr__"} + assert extra == expected_extra + missing.clear() + extra.clear() + # using .remove() instead of .delete() to get an error in case they start not # being missing 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..6ea6d970d4 --- /dev/null +++ b/src/trio/_tests/type_tests/file_io.py @@ -0,0 +1,19 @@ +"""Check the public types for Trio's asynchronous file wrappers.""" + +import io + +import trio +from typing_extensions import assert_type + + +async def open_file_results(path: str) -> None: + assert_type(await trio.open_file(path), trio.AsyncIOWrapper[io.TextIOWrapper]) + assert_type( + await trio.open_file(path, "rb"), + trio.AsyncIOWrapper[io.BufferedReader], + ) + + +def wrap_file_results(text: io.StringIO, binary: io.BytesIO) -> None: + assert_type(trio.wrap_file(text), trio.AsyncIOWrapper[io.StringIO]) + assert_type(trio.wrap_file(binary), trio.AsyncIOWrapper[io.BytesIO]) 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