The “await me maybe” pattern for Python asyncio
2nd September 2020
I’ve identified a pattern for handling potentially-asynchronous callback functions in Python which I’m calling the “await me maybe” pattern. It works by letting you return a value, a callable function that returns a value OR an awaitable function that returns that value.
Background
Datasette has been built on top of Python 3 asyncio from the very start—initially using Sanic, and as-of Datasette 0.29 using a custom mini-framework on top of ASGI 3, usually running under Uvicorn.
Datasette also has a plugin system, built on top of Pluggy.
Pluggy is a beautifully designed mechanism for plugins. It works based on decorated functions, which are called at various points by Datasette itself.
A simple plugin that injects a new JavaScript file into a page coud look like this:
from datasette import hookimpl @hookimpl def extra_js_urls(): return [ "https://code.jquery.com/jquery-3.5.1.min.js" ]
Datasette can then gather together all of the extra JavaScript URLs that should be injected into a page by running this code:
urls = [] for url in pm.hook.extra_js_urls( template=template.name, datasette=datasette, ): urls.extend(url)
What’s up with the template=
and datasette=
parameters that are passed here?
Pluggy implements a form of dependency injection, where plugin hook functions can optionally list additional parameters that they would like to have access to.
The above simple example didn’t need any extra information. But imagine a plugin that only wants to inject jQuery on the table.html
template page:
@hookimpl def extra_js_urls(template): if template == "table.html": return [ "https://code.jquery.com/jquery-3.5.1.min.js" ]
Datasette actually provides several more optional argument for these plugin functions—see the plugin hooks documentation for full details.
What if we need to await something?
The datasette object that can be passed to plugin hooks is special: it provides an object that can be used for the following:
- Executing SQL against databases connected to Datasette
- Looking up Datasette metadata and configuration settings, including plugin configuration
- Rendering templates using the template environment configured by Datasette
- Performing checks against the Datasette permissions system
Here’s the problem: many of those methods on Datasette are awaitable—await datasette.render_template(...)
for example. But Pluggy is built around regular non-awaitable Python functions.
If my def extra_js_urls()
plugin function needs to execute a SQL query to decide what JavaScript to include, it won’t be able to—because you can’t use await
inside a regular Python function.
That’s where the “await me maybe” pattern comes in.
The basic idea is that a function can return a value, OR a function-that-returns-a-value, OR an awaitable-function-that-returns-a-value.
If we want our extra_js_urls(datasette)
hook to execute a SQL query in order to decide what URLs to return, it can look like this:
@hookimpl def extra_js_urls(datasette): async def inner(): db = datasette.get_database() results = await db.execute("select url from js_files") return [r[0] for r in results] return inner
Note that Python lets you define an async def inner()
function inside the body of a regular function, which is what we’re doing here.
The code that calls the plugin hook in Datasette can then look like this:
urls = [] for url in pm.hook.extra_js_urls( template=template.name, datasette=datasette, ): if callable(url): url = url() if asyncio.iscoroutine(url): url = await url urls.append(url)
I use this pattern in a bunch of different places in Datasette, so today I refactored that into a utility function:
import asyncio async def await_me_maybe(value): if callable(value): value = value() if asyncio.iscoroutine(value): value = await value return value
This commit includes a bunch of examples where this function is called, for example this code which gathers extra body scripts to be included at the bottom of the page:
body_scripts = [] for extra_script in pm.hook.extra_body_script( template=template.name, database=context.get("database"), table=context.get("table"), columns=context.get("columns"), view_name=view_name, request=request, datasette=self, ): extra_script = await await_me_maybe(extra_script) body_scripts.append(Markup(extra_script))
More recent articles
- ChatGPT Canvas can make API requests now, but it's complicated - 10th December 2024
- I can now run a GPT-4 class model on my laptop - 9th December 2024
- Prompts.js - 7th December 2024