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

don't call asyncio.run for async handling #94

Merged
merged 12 commits into from
Sep 6, 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.3 - Bugfixes

- Don't use `asyncio.run` for async handling. Fixes [#93](https://github.com/smarie/mkdocs-gallery/issues/93).

### 0.10.2 - Bugfixes

- **SECURITY** removed insecure polyfill extra javascript from example. Fixes [#99](https://github.com/smarie/mkdocs-gallery/issues/99).
Expand Down
11 changes: 2 additions & 9 deletions docs/examples/plot_12_async.py → examples/plot_12_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,9 @@ async def afn():

# %%
# 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.
# asynchronous context. With the background 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.
# Apart from `await` that we used above, all other asynchronous syntax is supported as well.
#
# ## Asynchronous Generators

Expand Down
8 changes: 4 additions & 4 deletions src/mkdocs_gallery/gen_single.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
from .notebook import jupyter_notebook, save_notebook
from .py_source_parser import remove_config_comments, split_code_and_text_blocks
from .scrapers import ImageNotFoundError, _find_image_ext, clean_modules, save_figures
from .utils import _new_file, _replace_by_new_if_needed, optipng, rescale_image
from .utils import _new_file, _replace_by_new_if_needed, optipng, rescale_image, run_async

logger = mkdocs_compatibility.getLogger("mkdocs-gallery")

Expand Down Expand Up @@ -780,8 +780,7 @@ def _apply_async_handling(code_ast, *, compiler_flags):
async def __async_wrapper__():
# original AST goes here
return locals()
import asyncio as __asyncio__
__async_wrapper_locals__ = __asyncio__.run(__async_wrapper__())
__async_wrapper_locals__ = __run_async__(__async_wrapper__())
__async_wrapper_result__ = __async_wrapper_locals__.pop("__async_wrapper_result__", None)
globals().update(__async_wrapper_locals__)
__async_wrapper_result__
Expand Down Expand Up @@ -950,6 +949,7 @@ def parse_and_execute(script: GalleryScript, script_blocks):
# Don't ever support __file__: Issues #166 #212
# Don't let them use input()
"input": _check_input,
"__run_async__": run_async,
}
)
script.run_vars.example_globals = example_globals
Expand All @@ -960,7 +960,7 @@ def parse_and_execute(script: GalleryScript, script_blocks):
# Remember the original argv so that we can put them back after run
argv_orig = sys.argv[:]

# Remember the original sys.path so that we can reset it after run
# Remember the original sys.path so that we can reset it after run
sys_path_orig = deepcopy(sys.path)

# Python file is the original one (not the copy for download)
Expand Down
13 changes: 13 additions & 0 deletions src/mkdocs_gallery/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from __future__ import absolute_import, division, print_function

import asyncio
import hashlib
import os
import re
Expand Down Expand Up @@ -376,3 +377,15 @@ def is_relative_to(parentpath: Path, subpath: Path) -> bool:

except ValueError:
return False


def run_async(coro):
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = asyncio.new_event_loop()

try:
return loop.run_until_complete(coro)
finally:
loop.close()
79 changes: 47 additions & 32 deletions tests/test_gen_single.py
Original file line number Diff line number Diff line change
@@ -1,45 +1,47 @@
import ast
import asyncio
import codeop
import sys
from textwrap import dedent

import pytest

from mkdocs_gallery.gen_single import _needs_async_handling, _parse_code
from mkdocs_gallery.utils import run_async

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


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


def make_globals():
return {"__run_async__": run_async}


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
def test_no_async_roundtrip():
code = "None"
assert not _needs_async_handling(code, src_file=SRC_FILE, compiler_flags=COMPILER_FLAGS)

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

assert code_unparsed == code


@needs_ast_unparse
@pytest.mark.parametrize(
("code", "needs"),
"code",
[
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(
"""
Expand All @@ -49,7 +51,6 @@ async def afn():
assert await afn()
"""
),
True,
id="await",
),
pytest.param(
Expand All @@ -62,7 +63,6 @@ async def agen():
assert item
"""
),
True,
id="async_for",
),
pytest.param(
Expand All @@ -74,7 +74,6 @@ async def agen():
assert [item async for item in agen()] == [True]
"""
),
True,
id="async_comprehension",
),
pytest.param(
Expand All @@ -90,25 +89,23 @@ async def acm():
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
def test_async_handling(code):
assert _needs_async_handling(code, src_file=SRC_FILE, compiler_flags=COMPILER_FLAGS)

# 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
assert code_unparsed != code_stripped

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

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


@needs_ast_unparse
Expand All @@ -128,10 +125,10 @@ async def afn():
)
code_unparsed = ast.unparse(_parse_code(code, src_file=SRC_FILE, compiler_flags=COMPILER_FLAGS))

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

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


@needs_ast_unparse
Expand All @@ -153,6 +150,24 @@ async def afn():
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)
globals_ = make_globals()
exec(COMPILER(code_unparsed, SRC_FILE, "exec"), globals_)
assert eval(ast.unparse(last.value), globals_)


@needs_ast_unparse
def test_get_event_loop_after_async_handling():
# Non-regression test for https://github.com/smarie/mkdocs-gallery/issues/93
code = dedent(
"""
async def afn():
return True

assert await afn()
"""
)

code_unparsed = ast.unparse(_parse_code(code, src_file=SRC_FILE, compiler_flags=COMPILER_FLAGS))
exec(COMPILER(code_unparsed, SRC_FILE, "exec"), make_globals())

asyncio.events.get_event_loop()
Loading