Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add support for async galleries #90

Merged
merged 18 commits into from
Jan 16, 2024
4 changes: 4 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

### 0.10.0 - Support for asynchronous code

- Gallery scripts now support top-level asynchronous code. PR [#90](https://github.com/smarie/mkdocs-gallery/pull/90) by [pmeier](https://github.com/pmeier)

### 0.9.0 - Pyvista

- Pyvista can now be used in gallery examples as in `sphinx-gallery`. PR [#91](https://github.com/smarie/mkdocs-gallery/pull/91) by [Louis-Pujol](https://github.com/Louis-Pujol)
Expand Down
76 changes: 76 additions & 0 deletions docs/examples/plot_12_async.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""
# Support for asynchronous code
pmeier marked this conversation as resolved.
Show resolved Hide resolved

[PEP 429](https://peps.python.org/pep-0492), which was first implemented in
[Python 3.5](https://docs.python.org/3/whatsnew/3.5.html#whatsnew-pep-492), added initial syntax for asynchronous
programming in Python: `async` and `await`.

While this was a major improvement in particular for UX development, one major
downside is that it "poisons" the caller's code base. If you want to `await` a coroutine, you have to be inside a `async def`
context. Doing so turns the function into a coroutine function and thus forces the caller to also `await` its results.
Rinse and repeat until you reach the beginning of the stack.

Since version `0.10.0`, `mkdocs-gallery` is now able to automatically detect code blocks using async programming, and to handle them nicely so that you don't have to wrap them. This feature is enabled by default and does not require any configuration option. Generated notebooks remain consistent with [`jupyter` notebooks](https://jupyter.org/), or rather the [`IPython` kernel](https://ipython.org/) running
the code inside of them, that is equipped with
[background handling to allow top-level asynchronous code](https://ipython.readthedocs.io/en/stable/interactive/autoawait.html).
"""

import asyncio
import time


async def afn():
start = time.time()
await asyncio.sleep(0.3)
stop = time.time()
return stop - start


f"I waited for {await afn():.1f} seconds!"


# %%
# Without any handling, the snippet above would trigger a `SyntaxError`, since we are using `await` outside of an
# asynchronous context. With the handling, it works just fine.
#
# The background handling will only be applied if it is actually needed. Meaning, you can still run your asynchronous
# code manually if required.

asyncio.run(afn())


# %%
# Apart from `await` all other asynchronous syntax is supported as well.
#
# ## Asynchronous Generators


async def agen():
for chunk in "I'm an async iterator!".split():
yield chunk


async for chunk in agen():
print(chunk, end=" ")


# %%
# ## Asynchronous Comprehensions

" ".join([chunk async for chunk in agen()])

# %%
# ## Asynchronous Context Managers

import contextlib


@contextlib.asynccontextmanager
async def acm():
print("Entering asynchronous context manager!")
yield
print("Exiting asynchronous context manager!")


async with acm():
print("Inside the context!")
72 changes: 68 additions & 4 deletions src/mkdocs_gallery/gen_single.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
from io import StringIO
from pathlib import Path
from shutil import copyfile
from textwrap import indent
from textwrap import indent, dedent
from time import time
from typing import List, Set, Tuple

Expand Down Expand Up @@ -739,6 +739,72 @@ def _reset_cwd_syspath(cwd, path_to_remove):
os.chdir(cwd)


def _parse_code(bcontent, src_file, *, compiler_flags):
code_ast = compile(bcontent, src_file, "exec", compiler_flags | ast.PyCF_ONLY_AST, dont_inherit=1)
if _needs_async_handling(bcontent, src_file, compiler_flags=compiler_flags):
code_ast = _apply_async_handling(code_ast, compiler_flags=compiler_flags)
return code_ast


def _needs_async_handling(bcontent, src_file, *, compiler_flags) -> bool:
try:
compile(bcontent, src_file, "exec", compiler_flags, dont_inherit=1)
except SyntaxError as error:
# mkdocs-gallery supports top-level async code similar to jupyter notebooks.
# Without handling, this will raise a SyntaxError. In such a case, we apply a
# minimal async handling and try again. If the error persists, we bubble it up
# and let the caller handle it.
try:
compile(
f"async def __async_wrapper__():\n{indent(bcontent, ' ' * 4)}",
src_file,
"exec",
compiler_flags,
dont_inherit=1,
)
except SyntaxError:
# Raise the original error to avoid leaking the internal async handling to
# generated output.
raise error from None
else:
return True
else:
return False


def _apply_async_handling(code_ast, *, compiler_flags):
async_handling = compile(
dedent(
"""
async def __async_wrapper__():
# original AST goes here
return locals()
import asyncio as __asyncio__
__async_wrapper_locals__ = __asyncio__.run(__async_wrapper__())
__async_wrapper_result__ = __async_wrapper_locals__.pop("__async_wrapper_result__", None)
globals().update(__async_wrapper_locals__)
__async_wrapper_result__
"""
),
"<_apply_async_handling()>",
"exec",
compiler_flags | ast.PyCF_ONLY_AST,
dont_inherit=1,
)

*original_body, last_node = code_ast.body
if isinstance(last_node, ast.Expr):
last_node = ast.Assign(
targets=[ast.Name(id="__async_wrapper_result__", ctx=ast.Store())], value=last_node.value
)
original_body.append(last_node)

async_wrapper = async_handling.body[0]
async_wrapper.body = [*original_body, *async_wrapper.body]

return ast.fix_missing_locations(async_handling)


def execute_code_block(compiler, block, script: GalleryScript):
"""Execute the code block of the example file.

Expand Down Expand Up @@ -788,9 +854,7 @@ def execute_code_block(compiler, block, script: GalleryScript):

try:
ast_Module = _ast_module()
code_ast = ast_Module([bcontent])
Copy link
Owner

@smarie smarie Dec 21, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was surprised to see this but it is indeed present in sphinx-gallery: https://github.com/sphinx-gallery/sphinx-gallery/blob/master/sphinx_gallery/gen_rst.py#L965

They probably use this as a pre-validator of good syntax. Should we keep it too ? In that case I suggest to keep these original lines and simply add your if _needs_async_handling extra two lines below (dropping _parse_code). That way the change will be easier to spot when comparing with sphinx-gallery code. Of course no hurry with this one, as this is a nitpick

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They probably use this as a pre-validator of good syntax. Should we keep it too ?

That is what we basically have right now. If we find a SyntaxError there, we need to check whether this comes from using async stuff and can't just exit the try branch pre-maturely. In the light of #90 (comment), we could just bubble up a SyntaxError if our async handling doesn't resolve it.

Copy link
Contributor Author

@pmeier pmeier Dec 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've implemented my suggestion in 5e0e636. Please have a look.

flags = ast.PyCF_ONLY_AST | compiler.flags
code_ast = compile(bcontent, src_file, "exec", flags, dont_inherit=1)
code_ast = _parse_code(bcontent, src_file, compiler_flags=compiler.flags)
ast.increment_lineno(code_ast, lineno - 1)

is_last_expr, mem_max = _exec_and_get_memory(compiler, ast_Module, code_ast, script=script)
Expand Down
158 changes: 158 additions & 0 deletions tests/test_gen_single.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import ast
import codeop
import sys
from textwrap import dedent

import pytest

from mkdocs_gallery.gen_single import _needs_async_handling, _parse_code

SRC_FILE = __file__
COMPILER = codeop.Compile()
COMPILER_FLAGS = codeop.Compile().flags


needs_ast_unparse = pytest.mark.skipif(
sys.version_info < (3, 9), reason="ast.unparse is only available for Python >= 3.9"
)


def test_non_async_syntax_error():
with pytest.raises(SyntaxError, match="unexpected indent"):
_parse_code("foo = None\n bar = None", src_file=SRC_FILE, compiler_flags=COMPILER_FLAGS)


@needs_ast_unparse
@pytest.mark.parametrize(
("code", "needs"),
[
pytest.param("None", False, id="no_async"),
pytest.param(
dedent(
"""
async def afn():
return True

import asyncio
assert asyncio.run(afn())
"""
),
False,
id="asyncio_run",
),
pytest.param(
dedent(
"""
async def afn():
return True

assert await afn()
"""
),
True,
id="await",
),
pytest.param(
dedent(
"""
async def agen():
yield True

async for item in agen():
assert item
"""
),
True,
id="async_for",
),
pytest.param(
dedent(
"""
async def agen():
yield True

assert [item async for item in agen()] == [True]
"""
),
True,
id="async_comprehension",
),
pytest.param(
dedent(
"""
import contextlib

@contextlib.asynccontextmanager
async def acm():
yield True

async with acm() as ctx:
assert ctx
"""
),
True,
id="async_context_manager",
),
],
)
def test_async_handling(code, needs):
assert _needs_async_handling(code, src_file=SRC_FILE, compiler_flags=COMPILER_FLAGS) is needs

# Since AST objects are quite involved to compare, we unparse again and check that nothing has changed. Note that
# since we are dealing with AST and not CST here, all whitespace is eliminated in the process and this needs to be
# reflected in the input as well.
code_stripped = "\n".join(line for line in code.splitlines() if line)
code_unparsed = ast.unparse(_parse_code(code, src_file=SRC_FILE, compiler_flags=COMPILER_FLAGS))
assert (code_unparsed == code_stripped) ^ needs

if needs:
assert not _needs_async_handling(code_unparsed, src_file=SRC_FILE, compiler_flags=COMPILER_FLAGS)

exec(COMPILER(code_unparsed, SRC_FILE, "exec"), {})


@needs_ast_unparse
def test_async_handling_locals():
sentinel = "sentinel"
code = dedent(
"""
async def afn():
return True

sentinel = {sentinel}

assert await afn()
""".format(
sentinel=repr(sentinel)
)
)
code_unparsed = ast.unparse(_parse_code(code, src_file=SRC_FILE, compiler_flags=COMPILER_FLAGS))

locals = {}
exec(COMPILER(code_unparsed, SRC_FILE, "exec"), locals)

assert "sentinel" in locals and locals["sentinel"] == sentinel


@needs_ast_unparse
def test_async_handling_last_expression():
code = dedent(
"""
async def afn():
return True

result = await afn()
assert result
result
"""
)

code_unparsed_ast = _parse_code(code, src_file=SRC_FILE, compiler_flags=COMPILER_FLAGS)
code_unparsed = ast.unparse(code_unparsed_ast)

last = code_unparsed_ast.body[-1]
assert isinstance(last, ast.Expr)

locals = {}
exec(COMPILER(code_unparsed, SRC_FILE, "exec"), locals)
assert eval(ast.unparse(last.value), locals)
Loading