Ensure Python async generator and context manager closes with contextlib.aclosing
I noticed a ResourceWarning
like this when using
aiofiles to iterate lines of a file in
an async context manager from aiofiles.open
:
ResourceWarning: unclosed file
<_io.TextIOWrapper
name='.../numbers.tmp'
mode='r'
encoding='UTF-8'>
This is only a warning, so by default it won’t error out of the Python program.
I try to have
pytest filterwarnings
set to error
whenever possible, so that any warnings during tests will cause
the test to error out. That’s how I spotted this issue.
It’s caused by not completing an async generator, which then fails to exit the
async context manager from aiofiles.open
, which then leaves the file handle
open until the end of the program.
Here’s a little Python script to reproduce the problem:
import os
from typing import AsyncGenerator
import aiofiles
# Write some lines to a file.
file_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"numbers.tmp",
)
async with aiofiles.open(file_path, "w") as wf:
for i in range(10):
await wf.write(str(i) + os.linesep)
# Use a generator to iterate all the lines in the file.
async def iter_file_lines() -> AsyncGenerator[str, None]:
async with aiofiles.open(file_path, "r") as rf:
async for rf_line in rf:
yield rf_line
# This is fine, as it completes the async generator,
# which exits the async context manager, which closes
# the file handle.
async for line in iter_file_lines():
print(line)
# Try to take only the first item from the generator.
async def get_first_line_only() -> str:
async for iter_line in iter_file_lines():
return iter_line
# This will cause a `ResourceWarning: unclosed file` warning.
first_line = await get_first_line_only()
print(first_line)
The easiest way to prevent this might be to use the
aclosing
utility from the contextlib
library to wrap the async context manager. The
aclosing
wrapper ensures that the aexit
method on the async context manager
is always called, regardless of exceptions or unfinished generators.
Continuing from the above example, that looks like this:
# Take only the first item from the generator, and correctly close the
# async generator.
async def get_first_line_only() -> str:
async with aclosing(iter_file_lines()) as iter_lines:
async for iter_line in iter_lines:
return iter_line
# This will not raise any warnings.
first_line = await get_first_line_only()
print(first_line)
Alternatively, you can take the next value from the async generator and then manually close the generator like this:
# Take only the first item from the generator,
# and correctly close the async generator.
async def get_first_line_only() -> str:
lines_generator = iter_file_lines()
next_item = await anext(lines_generator)
await lines_generator.aclose()
return next_item
# This will not raise any warnings.
first_line = await get_first_line_only()
print(first_line)
This feels a bit more verbose and procedural than using contextlib.aclosing
.
Hire me as a freelance Python developer.