From 3e94e40211598843e52bf33452d8d51121d4e82a Mon Sep 17 00:00:00 2001 From: nightcityblade Date: Mon, 13 Apr 2026 11:09:13 +0800 Subject: [PATCH 1/2] Document wrap_file behavior regarding close, GC, and concurrent access Clarify in the wrap_file docstring that closing the async wrapper closes the underlying file, that GC does not auto-close, and that the original sync file should not be used concurrently. Closes #3379 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/trio/_file_io.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/trio/_file_io.py b/src/trio/_file_io.py index d9305ef4ff..e627b11953 100644 --- a/src/trio/_file_io.py +++ b/src/trio/_file_io.py @@ -493,6 +493,19 @@ def wrap_file(file: FileT) -> AsyncIOWrapper[FileT]: Returns: An :term:`asynchronous file object` that wraps ``file`` + The returned wrapper object shares the underlying file with the original + ``file`` object; it does not copy it. Closing the wrapper (via + :meth:`~trio.abc.AsyncResource.aclose` or ``async with``) will close the + underlying file. However, if the wrapper is garbage collected without + being explicitly closed, the underlying file is *not* closed + automatically — you should always close it explicitly. + + The original synchronous file object should not be used directly while + the wrapper exists, as the wrapper may call file methods in a worker + thread, and concurrent access from multiple threads is not safe for most + file objects. If you need synchronous access, use the + :attr:`~AsyncIOWrapper.wrapped` attribute. + Example:: async_file = trio.wrap_file(StringIO('asdf')) From e411fac9e514000b97d21d349ece7df6ec60ae66 Mon Sep 17 00:00:00 2001 From: nightcityblade Date: Mon, 13 Apr 2026 23:06:37 +0800 Subject: [PATCH 2/2] test: cover documented wrap_file lifecycle behavior --- newsfragments/3379.doc.rst | 1 + src/trio/_file_io.py | 4 ++-- src/trio/_tests/test_file_io.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 newsfragments/3379.doc.rst diff --git a/newsfragments/3379.doc.rst b/newsfragments/3379.doc.rst new file mode 100644 index 0000000000..4027e298bb --- /dev/null +++ b/newsfragments/3379.doc.rst @@ -0,0 +1 @@ +Clarified :func:`trio.wrap_file` ownership and lifecycle behavior, including that closing the async wrapper closes the underlying file, garbage collection does not close it automatically, and the original synchronous file object should not be used concurrently. diff --git a/src/trio/_file_io.py b/src/trio/_file_io.py index e627b11953..05175ca4ec 100644 --- a/src/trio/_file_io.py +++ b/src/trio/_file_io.py @@ -498,13 +498,13 @@ def wrap_file(file: FileT) -> AsyncIOWrapper[FileT]: :meth:`~trio.abc.AsyncResource.aclose` or ``async with``) will close the underlying file. However, if the wrapper is garbage collected without being explicitly closed, the underlying file is *not* closed - automatically — you should always close it explicitly. + automatically, so you should always close it explicitly. The original synchronous file object should not be used directly while the wrapper exists, as the wrapper may call file methods in a worker thread, and concurrent access from multiple threads is not safe for most file objects. If you need synchronous access, use the - :attr:`~AsyncIOWrapper.wrapped` attribute. + :attr:`~trio._file_io.AsyncIOWrapper.wrapped` attribute. Example:: diff --git a/src/trio/_tests/test_file_io.py b/src/trio/_tests/test_file_io.py index 390a81ce61..feb6dce6b3 100644 --- a/src/trio/_tests/test_file_io.py +++ b/src/trio/_tests/test_file_io.py @@ -1,5 +1,6 @@ from __future__ import annotations +import gc import importlib import io import os @@ -230,6 +231,35 @@ async def test_open_context_manager(path: pathlib.Path) -> None: assert f.closed +async def test_wrap_file_aclose_closes_underlying_file() -> None: + wrapped = io.StringIO("test") + async_file = trio.wrap_file(wrapped) + + await async_file.aclose() + + assert wrapped.closed + + +async def test_wrap_file_context_manager_closes_underlying_file() -> None: + wrapped = io.StringIO("test") + + async with trio.wrap_file(wrapped) as async_file: + assert async_file.wrapped is wrapped + assert not wrapped.closed + + assert wrapped.closed + + +def test_wrap_file_garbage_collection_does_not_close_underlying_file() -> None: + wrapped = io.StringIO("test") + trio.wrap_file(wrapped) + + gc.collect() + + assert not wrapped.closed + wrapped.close() + + async def test_async_iter() -> None: async_file = trio.wrap_file(io.StringIO("test\nfoo\nbar")) expected = list(async_file.wrapped)