Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ Fence blocks (` ``` `) starting with the `python`, `python3` or `py` language de
* Python (.py) files, within docstrings of classes and functions
* `.md`, `.mdx` and `.svx` files

Other code fence languages are ignored unless they explicitly select a
registered custom runner.

## Skipping tests

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

### Custom runners

Python code fences use the built-in runner by default. You can select a
registered custom runner for an individual fence by adding `runner:<name>` to the
info string:

```python
# conftest.py
import pytest_markdown_docs._runners


@pytest_markdown_docs._runners.register_runner()
class TextRunner(pytest_markdown_docs._runners.DefaultRunner):
def runtest(self, test, args):
assert "expected output" in test.source
```

With this conftest, a `text` fence can be collected as a test by explicitly
selecting the runner:

````markdown
```text runner:TextRunner
expected output
```
````

You can also register a runner as the default for one or more fence languages:

```python
@pytest_markdown_docs._runners.register_runner(default_for=("text",))
class TextRunner(pytest_markdown_docs._runners.DefaultRunner):
def runtest(self, test, args):
assert "expected output" in test.source
```

With this conftest, plain `text` fences are collected:

````markdown
```text
expected output
```
````

Runner selection uses this order:

1. A fence with `runner:<name>` uses that named runner.
2. A fence whose language is listed in `default_for` uses that runner.
3. Built-in Python fences (`py`, `python`, `python3`) use the global default
runner.

This means `default_for=("python",)` affects only ```` ```python ```` fences,
while `default=True` changes the global default used by Python fences that do
not have a more specific runner.

### Compatibility with Material for MkDocs

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

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

Expand Down
15 changes: 13 additions & 2 deletions src/pytest_markdown_docs/_runners.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
from pytest_markdown_docs.definitions import FenceTestDefinition

_default_runner: typing.Optional["_Runner"] = None
_registered_runners = {}
_registered_runners: typing.Dict[str, "_Runner"] = {}
_registered_language_runner_names: dict[str, str] = {}


class _Runner(metaclass=abc.ABCMeta):
Expand All @@ -29,7 +30,11 @@ def repr_failure(
RUNNER_TYPE = typing.TypeVar("RUNNER_TYPE", bound=type[_Runner])


def register_runner(*, default: bool = False):
def register_runner(
*,
default: bool = False,
default_for: typing.Collection[str] = (),
):
"""Decorator for adding custom runners

e.g.
Expand All @@ -44,6 +49,8 @@ def decorator(r: RUNNER_TYPE) -> RUNNER_TYPE:
_registered_runners[r.__name__] = runner
if default:
_default_runner = runner
for language in default_for:
_registered_language_runner_names[language] = r.__name__
return r

return decorator
Expand Down Expand Up @@ -139,3 +146,7 @@ def get_runner(name: typing.Optional[str]) -> _Runner:
if name not in _registered_runners:
raise Exception(f"No such pytest-markdown-docs runner: {name}")
return _registered_runners[name]


def get_runner_name_for_language(language: str) -> typing.Optional[str]:
return _registered_language_runner_names.get(language)
5 changes: 5 additions & 0 deletions src/pytest_markdown_docs/definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ class FenceTestDefinition:
source_path: pathlib.Path
runner_name: typing.Optional[str]
max_retries: int = 0
# All options on the fence (everything after the language in the info
# string, plus any mdx-comment metadata), including ones the plugin itself
# consumes, e.g. `fixture:foo` and `continuation`. Lets custom runners
# define their own options without requiring a pytest fixture per flag.
options: typing.FrozenSet[str] = frozenset()


@dataclass(frozen=True)
Expand Down
29 changes: 17 additions & 12 deletions src/pytest_markdown_docs/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

from pytest_markdown_docs import hooks
from pytest_markdown_docs.definitions import FenceTestDefinition, ObjectTestDefinition
from pytest_markdown_docs._runners import get_runner
from pytest_markdown_docs._runners import get_runner, get_runner_name_for_language

if pytest.version_tuple >= (8, 0, 0):
from _pytest.fixtures import TopRequest
Expand All @@ -29,6 +29,7 @@
logger = logging.getLogger("pytest-markdown-docs")

MARKER_NAME = "markdown-docs"
_PYTHON_FENCE_LANGUAGES = frozenset(("py", "python", "python3"))


class FenceSyntax(Enum):
Expand Down Expand Up @@ -203,7 +204,20 @@ def extract_fence_tests(
if i >= 2 and is_mdx_comment(tokens[i - 2]):
code_options |= extract_options_from_mdx_comment(tokens[i - 2].content)

if lang in ("py", "python", "python3") and "notest" not in code_options:
if lang is not None and "notest" not in code_options:
runner_names = get_prefixed_strings(code_options, "runner:")
if len(runner_names) > 1:
raise Exception(
f"Multiple runners are not supported, use a single one instead: {runner_names}"
)
runner_name: typing.Optional[str]
if len(runner_names) == 1:
runner_name = runner_names[0]
else:
runner_name = get_runner_name_for_language(lang)
if runner_name is None and lang not in _PYTHON_FENCE_LANGUAGES:
continue

start_line = (
start_line_offset + block.map[0] + 1
) # actual code starts on +1 from the "info" line
Expand All @@ -214,16 +228,6 @@ def extract_fence_tests(
code_block = prev + ("\n" * add_blank_lines) + block.content

fixture_names = get_prefixed_strings(code_options, "fixture:")
runner_names = get_prefixed_strings(code_options, "runner:")
if len(runner_names) == 0:
runner_name = None
elif len(runner_names) > 1:
raise Exception(
f"Multiple runners are not supported, use a single one instead: {runner_names}"
)
else:
runner_name = runner_names[0]

retry_counts = get_prefixed_strings(code_options, "retry:")
if len(retry_counts) == 0:
max_retries = 0
Expand All @@ -248,6 +252,7 @@ def extract_fence_tests(
source_path=source_path,
runner_name=runner_name,
max_retries=max_retries,
options=frozenset(code_options),
)
prev = code_block

Expand Down
Loading
Loading