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
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,51 @@ Each block gets its own dedicated directory, so blocks do not share state throug
> **Note for PowerShell users:** In `pwsh` command lines, environment variables use the `$env:` prefix. Reference the directory as `$env:tmpdir` instead of `${tmpdir}`. The `${tmpdir}` syntax in `cwd:` always works regardless of the target shell, since it is expanded by the plugin before the shell runs.


### Use your own fixtures

For more complex setup — seeding a config file, creating a mock, or running any other preparation logic — you can declare pytest fixtures and name them in a `fixtures:` directive. The fixtures run before the block's commands.

The plugin provides a `markdown_console_tmpdir` fixture that returns the block's `tmpdir` as a `pathlib.Path`, so your fixtures can write files that the block can read via `${tmpdir}`:

```python
# conftest.py
import pytest

@pytest.fixture
def write_config(markdown_console_tmpdir):
(markdown_console_tmpdir / "config.ini").write_text("[settings]\nkey=value\n")
```

````markdown
<!-- pytest-markdown-console: fixtures:write_config -->
```console
$ uv run myapp.py --config "${tmpdir}/config.ini"
Done.
```
````

A fixture can also return a `dict[str, str]` to inject additional environment variables into the block's subprocess. If it returns `None` (or nothing), the return value is ignored:

```python
@pytest.fixture
def inject_env(markdown_console_tmpdir):
(markdown_console_tmpdir / "seed.db").write_bytes(b"...")
return {"DB_PATH": str(markdown_console_tmpdir / "seed.db")}
```

Multiple fixtures can be listed, comma-separated:

````markdown
<!-- pytest-markdown-console: fixtures:write_config,inject_env -->
```console
$ uv run myapp.py
Done.
```
````

`yield` fixtures work normally — teardown runs after the block completes.


### Exclude a block from testing

To exclude a block from being collected as a test at all, use the `notest` directive:
Expand Down
2 changes: 2 additions & 0 deletions src/pytest_markdown_console/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""pytest plugin for testing console code blocks in Markdown files."""

from .plugin import markdown_console_tmpdir as markdown_console_tmpdir
from .plugin import pytest_addoption as pytest_addoption
from .plugin import pytest_collect_file as pytest_collect_file
from .plugin import pytest_configure as pytest_configure
Expand All @@ -9,6 +10,7 @@
"ConsoleCommandFailed",
"ConsoleOutputMismatch",
"ConsoleUnexpectedSuccess",
"markdown_console_tmpdir",
"pytest_addoption",
"pytest_collect_file",
"pytest_configure",
Expand Down
1 change: 1 addition & 0 deletions src/pytest_markdown_console/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ class ConsoleBlock:
only_platforms: frozenset[str] = field(default_factory=frozenset) # empty = all
skip_platforms: frozenset[str] = field(default_factory=frozenset) # empty = none
shell: str | None = None
fixtures: tuple[str, ...] = field(default_factory=tuple)
18 changes: 11 additions & 7 deletions src/pytest_markdown_console/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,14 @@ def _strip_error_comment(cmd: str) -> tuple[str, bool]:

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 directive token string."""
) -> tuple[bool, str | None, frozenset[str], frozenset[str], str | None, tuple[str, ...]]:
"""Return (notest, cwd_override, only_platforms, skip_platforms, shell, fixtures) from a directive token string."""
notest = False
cwd_override: str | None = None
only: set[str] = set()
skip: set[str] = set()
shell: str | None = None
fixtures: tuple[str, ...] = ()
for token in tokens_str.split():
if token == "notest": # noqa: S105
notest = True
Expand All @@ -65,7 +66,9 @@ def _parse_directive_tokens(
skip.add(name[1:])
elif name:
only.add(name)
return notest, cwd_override, frozenset(only), frozenset(skip), shell
elif token.startswith("fixtures:"):
fixtures = tuple(n.strip() for n in token[len("fixtures:") :].split(",") if n.strip())
return notest, cwd_override, frozenset(only), frozenset(skip), shell, fixtures


def _flush_cmd(current_cmd: Command | None, current_block: ConsoleBlock | None) -> None:
Expand Down Expand Up @@ -97,22 +100,22 @@ def _handle_dollar_line(
def _parse_file_config(
lines: list[str],
directive_tag: str,
) -> tuple[bool, str | None, frozenset[str], frozenset[str], str | None]:
) -> tuple[bool, str | None, frozenset[str], frozenset[str], str | None, tuple[str, ...]]:
"""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
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)
f_notest, f_cwd, f_only, f_skip, f_shell, f_fixtures = _parse_file_config(lines, directive)
in_block = False
current_block: ConsoleBlock | None = None
current_cmd: Command | None = None
Expand All @@ -128,14 +131,15 @@ def parse_blocks(source: str, directive: str = "pytest-markdown-console") -> lis
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)
notest, cwd_override, only, skip, shell, fixtures = _parse_directive_tokens(tokens_str)
current_block = ConsoleBlock(
line_number=lineno,
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,
fixtures=fixtures or f_fixtures,
)
current_cmd = None
continue
Expand Down
68 changes: 63 additions & 5 deletions src/pytest_markdown_console/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@
run_command,
)

try:
from _pytest.fixtures import FixtureLookupError as _FixtureLookupError
from _pytest.fixtures import TopRequest as _TopRequest
except ImportError:
_TopRequest = None # ty: ignore[invalid-assignment] # optional private API, may not exist
_FixtureLookupError = None # ty: ignore[invalid-assignment] # optional private API, may not exist

if TYPE_CHECKING:
from collections.abc import Generator
from pathlib import Path
Expand Down Expand Up @@ -47,6 +54,41 @@ def __init__(self, *, block: ConsoleBlock, **kwargs: Any) -> None: # noqa: ANN4
self.block = block
self.add_marker("markdown_console")

factory = cast(
"pytest.TempPathFactory | None",
getattr(self.config, "_tmp_path_factory", None),
)
self._md_tmpdir: Path | None = factory.mktemp("md_console") if factory is not None else None

if block.fixtures:
self.add_marker(pytest.mark.usefixtures("markdown_console_tmpdir", *block.fixtures))
self._setup_fixture_request()

def _setup_fixture_request(self) -> None:
"""Set up pytest fixture infrastructure for user-declared fixtures."""
if _TopRequest is None:
return
fm = self.session._fixturemanager # type: ignore[attr-defined]
fixtureinfo = fm.getfixtureinfo(node=self, func=None, cls=None)
self._fixtureinfo = fixtureinfo # type: ignore[attr-defined]
self.fixturenames: list[str] = fixtureinfo.names_closure
self.funcargs: dict[str, object] = {}
self._request = _TopRequest(self, _ispytest=True) # ty: ignore[invalid-argument-type] # ConsoleBlockItem satisfies the duck type

def setup(self) -> None:
"""Resolve user-declared fixtures, if any."""
if not self.block.fixtures:
return
request = getattr(self, "_request", None)
if request is None:
return
try:
request._fillfixtures()
except Exception as e:
if _FixtureLookupError is not None and isinstance(e, _FixtureLookupError):
raise LookupError(str(e)) from None
raise

def runtest(self) -> None:
"""Run all commands in the block and assert their output."""
current = sys.platform
Expand All @@ -58,13 +100,15 @@ def runtest(self) -> None:
if current in self.block.skip_platforms:
pytest.skip(f"console block is skipped on platform {current!r}")

factory = cast(
"pytest.TempPathFactory | None",
getattr(self.config, "_tmp_path_factory", None),
)
tmpdir_path = str(factory.mktemp("md_console")) if factory is not None else None
tmpdir_path = str(self._md_tmpdir) if self._md_tmpdir is not None else None
extra_env: dict[str, str] = {"tmpdir": tmpdir_path} if tmpdir_path is not None else {}

funcargs: dict[str, object] = getattr(self, "funcargs", {})
for name in self.block.fixtures:
result = funcargs.get(name)
if isinstance(result, dict):
extra_env.update(result) # ty: ignore[no-matching-overload] # runtime isinstance check guarantees dict[str, str]

base_dir = self.path.parent
if self.block.cwd_override is not None:
raw_cwd = self.block.cwd_override
Expand Down Expand Up @@ -104,6 +148,20 @@ def reportinfo(self) -> tuple[Path, int, str]:
return self.path, self.block.line_number - 1, f"console block @ line {self.block.line_number}"


@pytest.fixture
def markdown_console_tmpdir(request: pytest.FixtureRequest) -> Path:
"""The temporary directory for the current console block test.

Only available when running inside a console block item. Intended for use
by fixtures declared via the ``fixtures:`` directive — files written here
are accessible inside the block via the ``${tmpdir}`` environment variable.
"""
node = request.node
if isinstance(node, ConsoleBlockItem) and node._md_tmpdir is not None:
return node._md_tmpdir
pytest.skip("markdown_console_tmpdir is only available inside a console block item")


def pytest_addoption(parser: pytest.Parser) -> None:
"""Register ini options for pytest-markdown-console."""
parser.addini(
Expand Down
113 changes: 113 additions & 0 deletions tests/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,3 +311,116 @@ def test_cwd_tmpdir_mkdir_in_block(pytester, md):
)
result = pytester.runpytest("-v")
result.assert_outcomes(passed=1)


# ---------------------------------------------------------------------------
# fixtures: directive
# ---------------------------------------------------------------------------


def test_fixture_writes_to_tmpdir(pytester, md):
"""A fixture that writes to markdown_console_tmpdir makes the file accessible via ${tmpdir}."""
pytester.makeconftest(
"import pytest\n"
"\n"
"@pytest.fixture\n"
"def write_greeting(markdown_console_tmpdir):\n"
" (markdown_console_tmpdir / 'hello.txt').write_text('hi')\n",
)
md(
"<!-- pytest-markdown-console: fixtures:write_greeting -->\n"
"```console\n"
"$ python -c \"import pathlib,os;assert(pathlib.Path(os.environ['tmpdir'])/'hello.txt').read_text()=='hi'\"\n"
"```\n",
)
result = pytester.runpytest("-v")
result.assert_outcomes(passed=1)


@pytest.mark.skipif(sys.platform == "win32", reason="uses POSIX shell variable expansion")
def test_fixture_returns_env_dict_posix(pytester, md):
"""A fixture returning dict[str, str] injects those keys as environment variables (POSIX)."""
pytester.makeconftest(
"import pytest\n"
"\n"
"@pytest.fixture\n"
"def inject_var(markdown_console_tmpdir):\n"
" return {'MY_GREETING': 'hello'}\n",
)
md(
'<!-- pytest-markdown-console: fixtures:inject_var -->\n```console\n$ test "${MY_GREETING}" = "hello"\n```\n',
)
result = pytester.runpytest("-v")
result.assert_outcomes(passed=1)


@pytest.mark.skipif(sys.platform != "win32", reason="uses PowerShell $env: syntax")
def test_fixture_returns_env_dict_windows(pytester, md):
"""A fixture returning dict[str, str] injects those keys as environment variables (Windows)."""
pytester.makeconftest(
"import pytest\n"
"\n"
"@pytest.fixture\n"
"def inject_var(markdown_console_tmpdir):\n"
" return {'MY_GREETING': 'hello'}\n",
)
md(
"<!-- pytest-markdown-console: fixtures:inject_var -->\n"
"```console\n"
"$ if ($env:MY_GREETING -ne 'hello') { exit 1 }\n"
"```\n",
)
result = pytester.runpytest("-v")
result.assert_outcomes(passed=1)


def test_fixture_side_effect_only(pytester, md):
"""A fixture returning None (side-effect only) is tolerated; files it wrote are accessible."""
pytester.makeconftest(
"import pytest\n"
"\n"
"@pytest.fixture\n"
"def write_file(markdown_console_tmpdir):\n"
" (markdown_console_tmpdir / 'data.txt').write_text('ok')\n"
" # returns None implicitly\n",
)
md(
"<!-- pytest-markdown-console: fixtures:write_file -->\n"
"```console\n"
"$ python -c \"import pathlib,os;assert(pathlib.Path(os.environ['tmpdir'])/'data.txt').read_text()=='ok'\"\n"
"```\n",
)
result = pytester.runpytest("-v")
result.assert_outcomes(passed=1)


def test_multiple_fixtures(pytester, md):
"""Multiple fixtures listed in fixtures: all run and their env dicts are merged."""
pytester.makeconftest(
"import pytest\n"
"\n"
"@pytest.fixture\n"
"def setup_a(markdown_console_tmpdir):\n"
" return {'VAR_A': 'alpha'}\n"
"\n"
"@pytest.fixture\n"
"def setup_b(markdown_console_tmpdir):\n"
" return {'VAR_B': 'beta'}\n",
)
md(
"<!-- pytest-markdown-console: fixtures:setup_a,setup_b -->\n"
"```console\n"
"$ python -c \"import os; assert (os.environ.get('VAR_A'), os.environ.get('VAR_B')) == ('alpha', 'beta')\"\n"
"```\n",
)
result = pytester.runpytest("-v")
result.assert_outcomes(passed=1)


def test_unknown_fixture_fails(pytester, md):
"""Naming a fixture that does not exist causes the test to fail."""
md(
'<!-- pytest-markdown-console: fixtures:does_not_exist -->\n```console\n$ python -c "pass"\n```\n',
)
result = pytester.runpytest("-v")
result.assert_outcomes(errors=1)
Loading