Skip to content

Commit f53959a

Browse files
committed
push
1 parent 8614de6 commit f53959a

6 files changed

Lines changed: 403 additions & 12 deletions

File tree

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ Fence blocks (` ``` `) starting with the `python`, `python3` or `py` language de
6666
* Python (.py) files, within docstrings of classes and functions
6767
* `.md`, `.mdx` and `.svx` files
6868

69+
Other code fence languages are ignored unless you explicitly map them to a
70+
custom runner.
71+
6972
## Skipping tests
7073

7174
To exclude a Python code fence from testing, add a `notest` info string to the
@@ -178,6 +181,45 @@ With `retry:3`, the test runs up to 4 times total (1 initial attempt + 3 retries
178181
- All exceptions trigger retries (AssertionError, RuntimeError, etc.)
179182
- When using a continuation block, only the failing block retries
180183

184+
### Custom runners and languages
185+
186+
Python code fences use the built-in runner by default. Other languages can be
187+
collected by registering a custom runner and returning its name from the
188+
`pytest_markdown_docs_runner_name_for_language` hook:
189+
190+
```python
191+
# conftest.py
192+
import pytest_markdown_docs
193+
194+
195+
@pytest_markdown_docs.register_runner()
196+
class TextRunner(pytest_markdown_docs.DefaultRunner):
197+
def runtest(self, test, args):
198+
assert "expected output" in test.source
199+
200+
201+
def pytest_markdown_docs_runner_name_for_language(language):
202+
if language == "text":
203+
return "TextRunner"
204+
```
205+
206+
With this conftest, `text` fences are collected as tests:
207+
208+
````markdown
209+
```text
210+
expected output
211+
```
212+
````
213+
214+
You can also select a runner for a single fence by adding `runner:<name>` to the
215+
info string:
216+
217+
````markdown
218+
```text runner:TextRunner
219+
expected output
220+
```
221+
````
222+
181223
### Compatibility with Material for MkDocs
182224

183225
Material for Mkdocs is not compatible with the default syntax.
@@ -209,6 +251,7 @@ The following options can be specified using MDX comments:
209251
* fixture:<name>: Apply named pytest fixtures to the code block.
210252
* continuation: Continue from the previous code block, allowing you to carry over state.
211253
* retry:<count>: Automatically retry the test up to the specified number of times if it fails.
254+
* runner:<name>: Run the code block with a registered custom runner.
212255

213256
This approach allows you to add metadata to the code block without modifying the code fence itself, making it particularly useful in MDX environments.
214257

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from pytest_markdown_docs._runners import DefaultRunner, register_runner
2+
3+
__all__ = ["DefaultRunner", "register_runner"]

src/pytest_markdown_docs/_runners.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from pytest_markdown_docs.definitions import FenceTestDefinition
1111

1212
_default_runner: typing.Optional["_Runner"] = None
13-
_registered_runners = {}
13+
_registered_runners: typing.Dict[str, "_Runner"] = {}
1414

1515

1616
class _Runner(metaclass=abc.ABCMeta):

src/pytest_markdown_docs/hooks.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ def pytest_markdown_docs_globals() -> typing.Dict[str, typing.Any]:
99
return {}
1010

1111

12+
@pytest.hookspec(firstresult=True)
13+
def pytest_markdown_docs_runner_name_for_language(
14+
language: str,
15+
) -> typing.Optional[str]:
16+
"""Return a registered runner name for a code fence language."""
17+
18+
1219
@pytest.hookspec(firstresult=True)
1320
def pytest_markdown_docs_markdown_it() -> "MarkdownIt":
1421
"""Configure a custom markdown_it.MarkdownIt parser."""

src/pytest_markdown_docs/plugin.py

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
logger = logging.getLogger("pytest-markdown-docs")
3030

3131
MARKER_NAME = "markdown-docs"
32+
_PYTHON_FENCE_LANGUAGES = frozenset(("py", "python", "python3"))
3233

3334

3435
class FenceSyntax(Enum):
@@ -60,6 +61,15 @@ def _get_asyncio_runner(fixture_request):
6061
return None
6162

6263

64+
def _runner_name_for_language_from_config(config):
65+
def runner_name_for_language(language: str) -> typing.Optional[str]:
66+
return config.hook.pytest_markdown_docs_runner_name_for_language(
67+
language=language
68+
)
69+
70+
return runner_name_for_language
71+
72+
6373
class MarkdownInlinePythonItem(pytest.Item):
6474
def __init__(
6575
self,
@@ -173,6 +183,9 @@ def extract_fence_tests(
173183
source_path: pathlib.Path,
174184
markdown_type: str = "md",
175185
fence_syntax: FenceSyntax = FenceSyntax.default,
186+
runner_name_for_language: typing.Optional[
187+
typing.Callable[[str], typing.Optional[str]]
188+
] = None,
176189
) -> typing.Generator[FenceTestDefinition, None, None]:
177190
tokens = markdown_it_parser.parse(markdown_string)
178191

@@ -203,27 +216,31 @@ def extract_fence_tests(
203216
if i >= 2 and is_mdx_comment(tokens[i - 2]):
204217
code_options |= extract_options_from_mdx_comment(tokens[i - 2].content)
205218

206-
if lang in ("py", "python", "python3") and "notest" not in code_options:
207-
start_line = (
208-
start_line_offset + block.map[0] + 1
209-
) # actual code starts on +1 from the "info" line
210-
if "continuation" not in code_options:
211-
prev = ""
212-
213-
add_blank_lines = start_line - prev.count("\n")
214-
code_block = prev + ("\n" * add_blank_lines) + block.content
215-
216-
fixture_names = get_prefixed_strings(code_options, "fixture:")
219+
if lang is not None and "notest" not in code_options:
217220
runner_names = get_prefixed_strings(code_options, "runner:")
218221
if len(runner_names) == 0:
219222
runner_name = None
223+
if runner_name_for_language is not None:
224+
runner_name = runner_name_for_language(lang)
225+
if runner_name is None and lang not in _PYTHON_FENCE_LANGUAGES:
226+
continue
220227
elif len(runner_names) > 1:
221228
raise Exception(
222229
f"Multiple runners are not supported, use a single one instead: {runner_names}"
223230
)
224231
else:
225232
runner_name = runner_names[0]
226233

234+
start_line = (
235+
start_line_offset + block.map[0] + 1
236+
) # actual code starts on +1 from the "info" line
237+
if "continuation" not in code_options:
238+
prev = ""
239+
240+
add_blank_lines = start_line - prev.count("\n")
241+
code_block = prev + ("\n" * add_blank_lines) + block.content
242+
243+
fixture_names = get_prefixed_strings(code_options, "fixture:")
227244
retry_counts = get_prefixed_strings(code_options, "retry:")
228245
if len(retry_counts) == 0:
229246
max_retries = 0
@@ -389,6 +406,9 @@ def find_object_tests_recursive(
389406
docstring_offset,
390407
source_path=self.path,
391408
fence_syntax=fence_syntax,
409+
runner_name_for_language=_runner_name_for_language_from_config(
410+
self.config
411+
),
392412
)
393413
):
394414
found_test = ObjectTestDefinition(i, obj_name, fence_test)
@@ -420,6 +440,9 @@ def collect(self):
420440
start_line_offset=0,
421441
markdown_type=self.path.suffix.replace(".", ""),
422442
fence_syntax=fence_syntax,
443+
runner_name_for_language=_runner_name_for_language_from_config(
444+
self.config
445+
),
423446
)
424447
):
425448
yield MarkdownInlinePythonItem.from_parent(

0 commit comments

Comments
 (0)