Skip to content

Commit bb041f5

Browse files
authored
feat: add top-level await support for async code blocks (#62)
* feat: add top-level await support for async code blocks Collapse ast.parse() + compile(tree) into a single compile(source) call with PyCF_ALLOW_TOP_LEVEL_AWAIT flag. Detect coroutine code objects via CO_COROUTINE and run them with asyncio.run(), keeping sync path unchanged. This allows doc authors to write bare `await`, `async for`, and `async with` in code blocks without wrapping in asyncio.run() boilerplate. * fix: use pytest-asyncio's event loop for top-level await code blocks asyncio.run() creates a new event loop per call, breaking async fixtures that bind resources to pytest-asyncio's managed loop. Now fetches the _function_scoped_runner from pytest-asyncio (EAFP) and runs coroutines on the shared loop. Falls back to asyncio.run() when pytest-asyncio is absent or the fixture lookup fails. * fix: require pytest-asyncio for async code blocks instead of falling back to asyncio.run() asyncio.run() creates a new event loop, risking silent buggy behavior when the pytest-asyncio runner can't be found. Now raises a clear RuntimeError directing the user to install pytest-asyncio, mirroring how pytest itself handles async test functions without a plugin.
1 parent 69c23d5 commit bb041f5

3 files changed

Lines changed: 169 additions & 11 deletions

File tree

src/pytest_markdown_docs/_runners.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import abc
22
import ast
3+
import inspect
34
import traceback
45
import typing
56
from abc import abstractmethod
@@ -50,22 +51,29 @@ def decorator(r: RUNNER_TYPE) -> RUNNER_TYPE:
5051

5152
@register_runner(default=True)
5253
class DefaultRunner(_Runner):
53-
def runtest(self, test: FenceTestDefinition, args):
54+
def runtest(self, test: FenceTestDefinition, args, *, asyncio_runner=None):
5455
try:
55-
tree = ast.parse(test.source, filename=test.source_path)
56-
except SyntaxError:
57-
raise
58-
59-
try:
60-
# if we don't compile the code, it seems we get name lookup errors
61-
# for functions etc. when doing cross-calls across inline functions
6256
compiled = compile(
63-
tree, filename=test.source_path, mode="exec", dont_inherit=True
57+
test.source,
58+
filename=test.source_path,
59+
mode="exec",
60+
flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT,
61+
dont_inherit=True,
6462
)
6563
except SyntaxError:
6664
raise
6765

68-
exec(compiled, args)
66+
if compiled.co_flags & inspect.CO_COROUTINE:
67+
if asyncio_runner is None:
68+
raise RuntimeError(
69+
"Top-level async code in markdown code blocks is not natively supported.\n"
70+
"You need to install pytest-asyncio to run async code blocks:\n"
71+
" pip install pytest-asyncio"
72+
)
73+
coro = eval(compiled, args)
74+
asyncio_runner.run(coro)
75+
else:
76+
exec(compiled, args)
6977

7078
def repr_failure(
7179
self,

src/pytest_markdown_docs/plugin.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ def get_docstring_start_line(obj) -> typing.Optional[int]:
5252
return None # Docstring not found in source
5353

5454

55+
def _get_asyncio_runner(fixture_request):
56+
"""Try to fetch pytest-asyncio's event loop runner for shared-loop execution."""
57+
try:
58+
return fixture_request.getfixturevalue("_function_scoped_runner")
59+
except Exception:
60+
return None
61+
62+
5563
class MarkdownInlinePythonItem(pytest.Item):
5664
def __init__(
5765
self,
@@ -109,8 +117,17 @@ def runtest(self):
109117
try:
110118
# this ensures that pytest's stdout/stderr capture works during the test:
111119
capman = self.config.pluginmanager.getplugin("capturemanager")
120+
asyncio_runner = _get_asyncio_runner(self.fixture_request)
112121
with capman.global_and_fixture_disabled():
113-
self.runner.runtest(self.test_definition, all_globals)
122+
try:
123+
self.runner.runtest(
124+
self.test_definition,
125+
all_globals,
126+
asyncio_runner=asyncio_runner,
127+
)
128+
except TypeError:
129+
# Custom runner doesn't accept asyncio_runner kwarg
130+
self.runner.runtest(self.test_definition, all_globals)
114131

115132
# Success - test passed
116133
if attempt > 0:

tests/plugin_test.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -976,3 +976,136 @@ def sync_value():
976976
)
977977
result = testdir.runpytest("--markdown-docs", "-v")
978978
result.assert_outcomes(passed=1)
979+
980+
981+
# ============================================================================
982+
# Top-level await tests
983+
# ============================================================================
984+
985+
986+
def test_top_level_await(testdir):
987+
"""Test that bare await at top level works in code blocks."""
988+
testdir.makefile(
989+
".md",
990+
test_file="""
991+
```python
992+
import asyncio
993+
await asyncio.sleep(0)
994+
```
995+
""",
996+
)
997+
result = testdir.runpytest("--markdown-docs")
998+
result.assert_outcomes(passed=1)
999+
1000+
1001+
def test_top_level_async_for(testdir):
1002+
"""Test that async for at top level works in code blocks."""
1003+
testdir.makefile(
1004+
".md",
1005+
test_file="""
1006+
```python
1007+
import asyncio
1008+
1009+
async def arange(n):
1010+
for i in range(n):
1011+
yield i
1012+
1013+
results = []
1014+
async for i in arange(3):
1015+
results.append(i)
1016+
assert results == [0, 1, 2]
1017+
```
1018+
""",
1019+
)
1020+
result = testdir.runpytest("--markdown-docs")
1021+
result.assert_outcomes(passed=1)
1022+
1023+
1024+
def test_top_level_async_with(testdir):
1025+
"""Test that async with at top level works in code blocks."""
1026+
testdir.makefile(
1027+
".md",
1028+
test_file="""
1029+
```python
1030+
import contextlib
1031+
1032+
@contextlib.asynccontextmanager
1033+
async def async_ctx():
1034+
yield "hello"
1035+
1036+
async with async_ctx() as val:
1037+
assert val == "hello"
1038+
```
1039+
""",
1040+
)
1041+
result = testdir.runpytest("--markdown-docs")
1042+
result.assert_outcomes(passed=1)
1043+
1044+
1045+
def test_continuation_with_await(testdir):
1046+
"""Test that continuation blocks can use await with state from sync blocks."""
1047+
testdir.makefile(
1048+
".md",
1049+
test_file="""
1050+
```python
1051+
value = 42
1052+
```
1053+
1054+
```python continuation
1055+
import asyncio
1056+
await asyncio.sleep(0)
1057+
assert value == 42
1058+
```
1059+
""",
1060+
)
1061+
result = testdir.runpytest("--markdown-docs")
1062+
result.assert_outcomes(passed=2)
1063+
1064+
1065+
def test_async_fixture_shares_event_loop(testdir):
1066+
"""Async fixture and top-level await code block must share the same event loop."""
1067+
testdir.makeini("[pytest]\nasyncio_mode = auto\n")
1068+
testdir.makeconftest(
1069+
"""
1070+
import asyncio
1071+
import pytest
1072+
1073+
@pytest.fixture
1074+
async def loop_id():
1075+
return id(asyncio.get_running_loop())
1076+
"""
1077+
)
1078+
testdir.makefile(
1079+
".md",
1080+
test_file="""
1081+
```python fixture:loop_id
1082+
import asyncio
1083+
await asyncio.sleep(0)
1084+
assert loop_id == id(asyncio.get_running_loop())
1085+
```
1086+
""",
1087+
)
1088+
result = testdir.runpytest("--markdown-docs", "-v")
1089+
result.assert_outcomes(passed=1)
1090+
1091+
1092+
def test_top_level_await_requires_pytest_asyncio(testdir):
1093+
"""Async code blocks should error clearly when pytest-asyncio is not installed."""
1094+
testdir.makeconftest(
1095+
"""
1096+
import pytest_markdown_docs.plugin as _plugin
1097+
_plugin._get_asyncio_runner = lambda *a, **kw: None
1098+
"""
1099+
)
1100+
testdir.makefile(
1101+
".md",
1102+
test_file="""
1103+
```python
1104+
import asyncio
1105+
await asyncio.sleep(0)
1106+
```
1107+
""",
1108+
)
1109+
result = testdir.runpytest("--markdown-docs")
1110+
result.assert_outcomes(failed=1)
1111+
result.stdout.fnmatch_lines(["*pytest-asyncio*"])

0 commit comments

Comments
 (0)