Hello World, in Asyncio

Python
Asyncio
subprocess
Asynchronous Programming
Concurrent Programming
Author

Galen Seilis

Published

July 30, 2024

We can define a “Hello, World” type of example using Asyncio as follows:

"""The asyncio "Hello, World" greeting.

``asyncio.run`` refuses to start when an event loop is already running, which is
exactly the situation inside a Jupyter kernel or a Quarto Python chunk. The
coroutine itself is unaffected; only the call that schedules it has to happen
somewhere else (see :mod:`asyncio_hello_world.runner`).
"""

from __future__ import annotations

import asyncio


async def main() -> None:
    """Print the greeting, pausing between the two lines."""
    print("Hello,")
    await asyncio.sleep(0.2018)
    print("World!")


def run() -> None:
    """Run the greeting as a program, driving its own event loop."""
    asyncio.run(main())


if __name__ == "__main__":
    run()

If you try to run the above Python code directly in a Quarto code block, it will give an error:

RuntimeError: asyncio.run() cannot be called from a running event loop

This error occurs because run expects that it will have control over the main process, but that is not the case if you are running a Jupyter notebook. That is the case if you are running Python in a Quarto block.

So similar to what I have done with other languages, we can define a script which we can import into a given blog post in order to call other processes. When we run our asynchronous “Hello, World” script we will want to also capture its standard output in our interactive session.

With Python this is pretty straightforward because we don’t need to consider compiling code ourselves, much less linking object files. The following is a good start:

"""Run a Python file in a subprocess and capture its standard output."""

from __future__ import annotations

import subprocess
import sys
from pathlib import Path


def run_python_file(
    file_path: str | Path,
    interpreter: str = sys.executable,
) -> str:
    """Run ``file_path`` with ``interpreter`` and return its standard output.

    If the child exits with a non-zero status, the error message printed to
    standard error is returned instead of raising.
    """
    try:
        result = subprocess.run(
            [interpreter, file_path],
            capture_output=True,
            text=True,
            check=True,
        )
        return result.stdout
    except subprocess.CalledProcessError as e:
        return f"An error occurred while running the file: {e.stderr}"

Putting this code in ./src/asyncio_hello_world/runner.py, we can call it like this:

"""Run the asyncio greeting in a subprocess and print its output.

Running the greeting in a separate interpreter sidesteps the
``RuntimeError: asyncio.run() cannot be called from a running event loop`` that
a notebook raises, while still letting the notebook show the captured output.
"""

from __future__ import annotations

from asyncio_hello_world import hello
from asyncio_hello_world.runner import run_python_file


def main() -> None:
    """Console-script entry point."""
    output = run_python_file(hello.__file__)
    print(output.rstrip("\n"))


if __name__ == "__main__":
    main()
Hello,
World!

This setup is fine for simple and isolated blogging examples. Anyway, that’s about as close as I think we’ll get to a “Hello, World” script with Asyncio. As with similar scripts that I have written for Rust and other languages, Quarto’s automatic freezing feature will only look to see if the source of the QMD file has changed. Quarto will ignore any example Python files outside of that.