Skip to content
Merged
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
89 changes: 60 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,59 +26,68 @@ Each `console` code block in Markdown files collected by the plugin generates a

The test runs each command in the block and compares its actual output to the expected output written in the block.

For example:
Take this Markdown file:

````
````markdown
# README.md

Here is an example of running my app:

```console
$ uv run myapp.py
Hello, world!
```
````

This creates a passing test case if your app actually prints "Hello, world!".
Running pytest with this extension creates a test case that passes if the app actually prints "Hello, world!".

### Signal expected failures
### Match partial output

Sometimes you want to illustrate expected failures. To convey this to both the reader and the plugin, place a `# Error: `comment at the end of the failing command, or on the line preceding it:
Use `...` anywhere in the expected output to match any text in its place, including multiple lines:

````
````markdown
```console
$ uv run myapp.py --bad_option # Error: this will fail
$ # Error: this will also most definitely fail
$ uv run myapp.py --still_bad
$ python -c "print(object())"
<object object at 0x...>
```
````

### Match partial output
This is useful when part of the output is non-deterministic, such as memory addresses or timestamps, or when the output is very long.

Use `...` anywhere in the expected output to match any text in its place, including multiple lines:

````
### Signal expected failures

Sometimes you want to illustrate expected failures. To convey this to both the reader and the plugin, place a `# Error: `comment at the end of the failing command, or on the line preceding it:

````markdown
```console
$ python -c "print(object())"
<object object at 0x...>
$ uv run myapp.py --bad_option # Error: this will fail
...
$ # Error: this will also most definitely fail
$ uv run myapp.py --still_bad
...
```
````

This is useful when part of the output is non-deterministic, such as memory addresses or timestamps.
To pass the test case, the command line must fail and the error message produced by the application, if any, must match.


### Filter by platform

To restrict a test to specific platforms, use the `platform:` directive:
To restrict a test to specific platforms, use the `platform:` directive in an HTML comment immediately before the fence:

````
```console platform:linux,macos
````markdown
<!-- pytest-markdown-console: platform:linux,macos -->
```console
$ echo "This will generate a test case on Linux and macOS"
```
````

To exclude a platform, prefix its name with `!`:

````
```console platform:!windows
````markdown
<!-- pytest-markdown-console: platform:!windows -->
```console
$ echo "This will not generate a test case on Windows"
```
````
Expand All @@ -87,8 +96,9 @@ $ echo "This will not generate a test case on Windows"

By default, all commands run in the same directory as the Markdown file. To use a different directory, use the `cwd:` directive:

````
```console cwd:../
````markdown
<!-- pytest-markdown-console: cwd:../ -->
```console
$ uv run myapp.py
...
```
Expand All @@ -99,18 +109,19 @@ Relative paths are resolved relative to the Markdown file's location.

### Exclude a block from testing

To exclude a block from being collected as a test at all, add `notest` to the fence:
To exclude a block from being collected as a test at all, use the `notest` directive:

````
```console notest
$ echo "This block is for illustration only"
````markdown
<!-- pytest-markdown-console: notest -->
```console
$ echo "This block will not be tested"
```
````


### Controlling test case runs globally

By default, test cases generated by this plugin are run. To exclude them:
By default, test cases generated by this plugin are run whenever pytest is invoked. To exclude them entirely:

```bash
pytest -p no:markdown-console
Expand All @@ -123,14 +134,34 @@ pytest -m markdown_console
```


### Customising the directive tag

By default, directive comments use the `pytest-markdown-console` tag:

````markdown
<!-- pytest-markdown-console: notest -->
```console
$ echo "This block will not be tested"
```
````

You can change this tag via `pyproject.toml`, for example to keep your Markdown source shorter:

```toml
[tool.pytest.ini_options]
markdown_console_directive = "console-test"
```

With the above setting you would write `<!-- console-test: notest -->` instead.


## Why this extension?

This plugin makes your documentation testable — specifically `console` blocks — within the same pytest suite you use for the rest of your Python code.

It is similar to `pytest-markdown-docs`, which works on Python code blocks, and follows similar conventions.

Other tools can test `console` blocks in Markdown files, but we couldn't find one that is simple, supports Windows, integrates with pytest, and requires no boilerplate.


### Does it make sense to test `console` blocks?

Testing `console` blocks is admittedly niche. They often contain installation instructions or shell-specific commands that don't translate across platforms.
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,6 @@ build-backend = "uv_build"
dev = [
"pre-commit>=4.6.0",
"pytest>=9.0.3",
"pytest-cov>=7.1.0",
"ty>=0.0.40",
]
79 changes: 51 additions & 28 deletions src/pytest_markdown_console/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from .models import Command, ConsoleBlock

_FENCE_OPEN = re.compile(r"^```\s*console(?:\s+(.+?))?\s*$", re.IGNORECASE)
_FENCE_OPEN = re.compile(r"^```\s*console\b", re.IGNORECASE)
_FENCE_CLOSE = re.compile(r"^```\s*$")


Expand Down Expand Up @@ -42,29 +42,29 @@ def _strip_error_comment(cmd: str) -> tuple[str, bool]:
return cmd, False


def _parse_fence_tokens(
def _parse_directive_tokens(
tokens_str: str,
) -> tuple[bool, str | None, frozenset[str], frozenset[str], str | None]:
"""Return (notest, cwd_override, only_platforms, skip_platforms, shell) from a fence token string."""
"""Return (notest, cwd_override, only_platforms, skip_platforms, shell) from a directive token string."""
notest = False
cwd_override: str | None = None
only: set[str] = set()
skip: set[str] = set()
shell: str | None = None
for token in tokens_str.split():
if token == "notest":
if token == "notest": # noqa: S105
notest = True
elif token.startswith("cwd:"):
cwd_override = token[len("cwd:"):]
cwd_override = token[len("cwd:") :]
elif token.startswith("shell:"):
shell = token[len("shell:"):]
shell = token[len("shell:") :]
elif token.startswith("platform:"):
for p in token[len("platform:"):].split(","):
p = p.strip()
if p.startswith("!"):
skip.add(p[1:])
elif p:
only.add(p)
for p in token[len("platform:") :].split(","):
name = p.strip()
if name.startswith("!"):
skip.add(name[1:])
elif name:
only.add(name)
return notest, cwd_override, frozenset(only), frozenset(skip), shell


Expand All @@ -77,7 +77,7 @@ def _handle_dollar_line(
raw: str,
current_cmd: Command | None,
current_block: ConsoleBlock | None,
pending_failure: bool,
pending_failure: bool, # noqa: FBT001
) -> tuple[Command | None, bool]:
cmd_part = raw[2:]
stripped = cmd_part.lstrip()
Expand All @@ -94,50 +94,73 @@ def _handle_dollar_line(
), False


def parse_blocks(source: str) -> list[ConsoleBlock]:
def _parse_file_config(
lines: list[str],
directive_tag: str,
) -> tuple[bool, str | None, frozenset[str], frozenset[str], str | None]:
"""Return file-level directive defaults from the first matching ``<tag>-file:`` comment."""
file_re = re.compile(rf"^\s*<!--\s*{re.escape(directive_tag)}-file:\s*(.+?)\s*-->\s*$")
for line in lines:
m = file_re.match(line)
if m:
return _parse_directive_tokens(m.group(1))
return False, None, frozenset(), frozenset(), None


def parse_blocks(source: str, directive: str = "pytest-markdown-console") -> list[ConsoleBlock]:
"""Parse all fenced console blocks from Markdown source text."""
directive_re = re.compile(rf"^\s*<!--\s*{re.escape(directive)}:\s*(.+?)\s*-->\s*$")
blocks: list[ConsoleBlock] = []
lines = source.splitlines()
f_notest, f_cwd, f_only, f_skip, f_shell = _parse_file_config(lines, directive)
in_block = False
current_block: ConsoleBlock | None = None
current_cmd: Command | None = None
pending_failure = False
indent = ""

for lineno, raw in enumerate(lines, start=1):
if not in_block:
m = _FENCE_OPEN.match(raw)
if m:
stripped = raw.lstrip()
if _FENCE_OPEN.match(stripped):
indent = raw[: len(raw) - len(stripped)]
in_block = True
notest, cwd_override, only, skip, shell = _parse_fence_tokens(m.group(1) or "")
prev_line = lines[lineno - 2] if lineno >= 2 else "" # noqa: PLR2004
directive_match = directive_re.match(prev_line)
tokens_str = directive_match.group(1) if directive_match else ""
notest, cwd_override, only, skip, shell = _parse_directive_tokens(tokens_str)
current_block = ConsoleBlock(
line_number=lineno,
notest=notest,
cwd_override=cwd_override,
only_platforms=only,
skip_platforms=skip,
shell=shell,
notest=notest or f_notest,
cwd_override=cwd_override if cwd_override is not None else f_cwd,
only_platforms=only or f_only,
skip_platforms=skip or f_skip,
shell=shell if shell is not None else f_shell,
)
current_cmd = None
continue

if _FENCE_CLOSE.match(raw):
line = raw.removeprefix(indent)

if _FENCE_CLOSE.match(line):
_flush_cmd(current_cmd, current_block)
current_cmd = None
if current_block is not None:
blocks.append(current_block)
current_block = None
in_block = False
indent = ""
continue

if raw.startswith("$ "):
current_cmd, pending_failure = _handle_dollar_line(raw, current_cmd, current_block, pending_failure)
elif raw == "$":
if line.startswith("$ "):
current_cmd, pending_failure = _handle_dollar_line(line, current_cmd, current_block, pending_failure)
elif line == "$":
_flush_cmd(current_cmd, current_block)
current_cmd = None
elif current_cmd is not None:
if current_cmd.expected_output:
current_cmd.expected_output += "\n" + raw
current_cmd.expected_output += "\n" + line
else:
current_cmd.expected_output = raw
current_cmd.expected_output = line

return blocks
17 changes: 12 additions & 5 deletions src/pytest_markdown_console/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ class MarkdownFile(pytest.File):
def collect(self) -> Generator[ConsoleBlockItem, None, None]:
"""Yield a ConsoleBlockItem for each testable console block."""
source = self.path.read_text(encoding="utf-8")
blocks = parse_blocks(source)
directive = self.config.getini("markdown_console_directive") or "pytest-markdown-console"
blocks = parse_blocks(source, directive=directive)
for idx, block in enumerate(blocks):
if not block.commands or block.notest:
continue
Expand Down Expand Up @@ -71,8 +72,6 @@ def runtest(self) -> None:
effective_shell = self.block.shell or ini_shell

for command in self.block.commands:
if not command.cmd.strip():
continue
cwd = run_command(command, cwd, shell=effective_shell)

def repr_failure(self, excinfo: pytest.ExceptionInfo[BaseException]) -> str | pytest.TerminalRepr:
Expand All @@ -97,13 +96,21 @@ def reportinfo(self) -> tuple[Path, int, str]:


def pytest_addoption(parser: pytest.Parser) -> None:
"""Register the markdown_console_shell ini option."""
"""Register ini options for pytest-markdown-console."""
parser.addini(
"markdown_console_shell",
help="Default shell for console blocks (pwsh, cmd, sh, bash, zsh, …). Defaults to pwsh on Windows, sh elsewhere.",
help=(
"Default shell for console blocks (pwsh, cmd, sh, bash, zsh, …). Defaults to pwsh on Windows, sh elsewhere."
),
type="string",
default=None,
)
parser.addini(
"markdown_console_directive",
help='HTML comment tag for directives (default: "pytest-markdown-console").',
type="string",
default="pytest-markdown-console",
)


def pytest_configure(config: pytest.Config) -> None:
Expand Down
2 changes: 1 addition & 1 deletion src/pytest_markdown_console/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def _pwd_cmd(shell: str) -> str:
def _run(cmd: str, cwd: str, shell: str | None = None, **kwargs: object) -> subprocess.CompletedProcess:
"""Run *cmd* in the given shell (or the platform default if None)."""
resolved = shell or ("pwsh" if _WINDOWS else "sh")
return subprocess.run(_build_argv(cmd, resolved), cwd=cwd, **kwargs) # type: ignore[call-overload]
return subprocess.run(_build_argv(cmd, resolved), cwd=cwd, **kwargs) # type: ignore[call-overload] # noqa: S603 PLW1510


def matches(expected: str, actual: str) -> bool:
Expand Down
Loading
Loading