aiofiles#
Test Data Workbench writes a deploy’s entire output, generator modules,
YAML configs, and Markdown templates, to disk in one call. aiofiles is
the library that does one narrow job in that path: give an async def
function a way to write those files without calling the blocking builtin
open() directly inside a coroutine.
Why aiofiles#
pyproject.toml pins aiofiles>=23.2.1 as a plain runtime dependency,
listed alongside sqlalchemy and faker, not under
[project.optional-dependencies]. It is imported in exactly one place in
this codebase: src/test_data_workbench/adaptation/rapid_deployment.py,
inside RapidAdapter._write_file_async.
That method exists because RapidAdapter.analyze_and_adapt is itself a
coroutine, reachable both from a one-shot CLI command and from a FastAPI
endpoint (see How it fits this project). Writing dozens of generated
files with the builtin open() inside that coroutine would work; it
would also block whatever else is sharing that coroutine’s event loop for
as long as each write takes. aiofiles exists to remove that specific
risk without rewriting the write path as callback-based or thread-managed
code by hand.
The idea underneath#
It is worth being precise about what aiofiles actually changes,
because the name invites the wrong mental model. Network sockets are
asynchronous at the operating system level: Linux’s epoll, and the
BSD/macOS kqueue that Python’s own event loop is built on, let a
process register a set of file descriptors and be woken up only when one
of them is ready, with no thread blocked in the meantime. Regular
filesystem operations, open, read, write, on most operating systems, have
no equivalent registration mechanism. There is no epoll event for “this
disk write has finished.”
libuv, the C library behind Node.js’s event loop, states this plainly in
its own design documentation, under “File I/O”: “Unlike network I/O, there
are no platform-specific file I/O primitives libuv could rely on, so the
current approach is to run blocking file I/O operations in a thread pool.”
aiofiles’s own README makes the same point for Python specifically:
“Ordinary local file IO is blocking, and cannot easily and portably be made
asynchronous,” and describes its own approach as “delegating operations to
a separate thread pool.”
That is the entire mechanism. aiofiles.open() does not make a disk
faster or a write non-blocking at the kernel level. It hands the blocking
open/read/write/close calls to a worker thread (by default,
the event loop’s own executor, though aiofiles.open() accepts optional
loop and executor arguments to override that), and returns an
awaitable that resolves once that thread finishes. The coroutine that
called it, and everything else scheduled on that same event loop, is free
to keep running while the write happens on the worker thread rather than
on the thread running the event loop.
Python’s own asyncio documentation describes exactly this pattern,
independent of aiofiles, as the general answer to blocking code inside
a coroutine. Its “Running Blocking Code” section warns that blocking code
called directly “delays all concurrent asyncio Tasks and IO operations,”
and recommends an executor, via loop.run_in_executor(), to run that
code “in a different thread… to avoid blocking the OS thread with the
event loop.” asyncio.to_thread(), added in Python 3.9, wraps the same
idea in a smaller call: “Asynchronously run function func in a separate
thread… Return a coroutine that can be awaited to get the eventual
result.” aiofiles is, underneath its file-object-shaped API, a thin
layer over this exact mechanism, specialized to file operations.
The practical consequence for a project like this one is not about disk
throughput. It is that a plain synchronous file_path.write_text(...)
call placed inside an async def function stalls that function’s entire
event loop for the duration of the write, and every other coroutine
waiting on that same loop, whether that is another concurrent HTTP
request or another task in an asyncio.gather(), waits with it.
How it fits this project#
RapidAdapter.analyze_and_adapt (adaptation/rapid_deployment.py) is
the single implementation behind three call sites:
tdw deploy, the CLI command incli/deploy.py, which wraps a single call inasyncio.run(deploy(...))and exits when it returns.POST /v4/deployment/rapid, a FastAPI route inapi/endpoints/deployment.py, mounted inapi/main.pyunder the/v4/deploymentprefix.POST /v4/deployment/rapid/benchmark, the same endpoint module’s benchmark route, which callsanalyze_and_adaptagainst a demo connection string.
All three paths converge on the same three-phase pipeline, and phase
three ends by calling _write_output_files, which in turn calls
_write_file_async once per generated file. That single method is
where aiofiles is imported and used.
Writing generated output during deployment#
_write_output_files collects everything a deploy produces, per-table
generator modules, configuration files, and team templates, into one
dictionary keyed by relative path, then fires off one write per entry
concurrently:
async def _write_output_files(self, output_path: Path,
generated_code: Dict[str, str],
config_files: Dict[str, str],
templates: Dict[str, str]) -> None:
"""Write all generated files to output directory."""
all_files = {
**{f'generators/{name}.py': code for name, code in generated_code.items()},
**config_files,
**templates
}
write_tasks = []
for relative_path, content in all_files.items():
full_path = output_path / relative_path
write_tasks.append(self._write_file_async(full_path, content))
await asyncio.gather(*write_tasks, return_exceptions=True)
generated_code holds one entry per table (generators/<table>.py),
config_files holds config.yaml, .env.template,
contribution_guide.yaml, and one scenarios/<name>.yaml per business
scenario from ConfigurationBuilder, and templates holds
README.md, CONTRIBUTING.md, three skill-level generator templates
per entity type (generators/templates/<entity>_<level>.py, for
user, customer, product, order, review), and three
scenario templates. For a schema with several tables, that is easily
twenty or more files, all handed to asyncio.gather at once rather than
written one after another. _create_fallback_result, the emergency path
taken when the main pipeline raises, calls the same
_write_output_files with a smaller, fixed set of emergency generators
and a single emergency_config.yaml.
The async context manager pattern#
_write_file_async is the only place aiofiles is imported, and the
import itself is local to the function rather than a module-level import:
async def _write_file_async(self, file_path: Path, content: str) -> None:
"""Asynchronously write file with directory creation."""
try:
file_path.parent.mkdir(parents=True, exist_ok=True)
# Use aiofiles for true async I/O if available, otherwise fallback
try:
import aiofiles
async with aiofiles.open(file_path, 'w', encoding='utf-8') as f:
await f.write(content)
except ImportError:
# Fallback to sync I/O
with open(file_path, 'w', encoding='utf-8') as f:
f.write(content)
except Exception as e:
self.logger.error(f"Failed to write {file_path}: {e}")
aiofiles.open() returns an async context manager whose shape mirrors
the builtin’s line for line: async with ... as f in place of
with ... as f, and await f.write(content) in place of
f.write(content). The directory-creation step,
file_path.parent.mkdir(parents=True, exist_ok=True), is plain
synchronous pathlib, not wrapped in anything async; only the write
itself goes through the thread pool. Both the success path and the whole
outer try are defensive: an error partway through, a bad path, a
permissions failure, is caught, logged through self.logger.error, and
swallowed rather than propagated. That behavior is exercised directly in
tests/test_rapid_deployment.py: test_write_file_async writes into a
freshly created subdirectory and asserts the content round-trips, and
test_write_file_async_bad_path writes to an unwritable path and
asserts only that no exception escapes. Because _write_output_files
gathers with return_exceptions=True on top of that, one failed file in
a batch of twenty does not stop the other nineteen from being written.
A synchronous fallback that pyproject.toml makes unreachable#
The except ImportError branch above exists to fall back to the
builtin open() if aiofiles is not installed. In this project, that
branch cannot run in practice: aiofiles>=23.2.1 is a hard entry in
pyproject.toml’s top-level dependencies list, the same list that
holds fastapi, sqlalchemy, and pydantic. Anywhere
test-data-workbench installs successfully at all, via
pip install test-data-workbench or from this repository with its
dependency closure resolved, aiofiles is already present, and
import aiofiles cannot raise ImportError. The try/except
here reads as defensive code written for a context, aiofiles as an
optional accelerator, that this project’s own packaging does not actually
create.
Learn more resources#
Official documentation#
Tinche/aiofiles README: the project’s own explanation of why file IO is blocking, how it delegates to a thread pool, the async context manager usage shown above, and the optional
loop/executorarguments toaiofiles.open().aiofiles on PyPI: current release metadata, useful for checking the installed version against this project’s
aiofiles>=23.2.1pin.asyncio.to_thread: the standard-library equivalent mechanism, available since Python 3.9.
loop.run_in_executor: the lower-level executor API that both
to_threadandaiofilesbuild on.Developing with asyncio: Running Blocking Code: Python’s own documentation on why blocking calls inside a coroutine delay every other concurrent task on the same event loop.
Tutorials and blogs#
libuv design overview, “File I/O”: a cross-language confirmation of the same underlying operating-system fact this page relies on, that there is no platform file IO primitive an event loop can poll the way it polls a socket, from the C library behind Node.js’s event loop.
Working with Files Asynchronously in Python using aiofiles and asyncio (Twilio): a broader practical tour of
aiofiles, reading, writing, and directory listing, beyond the single write path this project uses.
Videos#
aiofiles - Python Asynchronous File I/O (Awesome Python TV): a focused walkthrough of installing
aiofilesand using its asyncopen()for reads and writes, verified via YouTube’s oEmbed API.