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
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,33 @@ $ uv run myapp.py
Relative paths are resolved relative to the Markdown file's location.


### Use a temporary directory

Each console block test automatically gets an isolated temporary directory, available as the `tmpdir` environment variable. This is useful when your app writes files during testing:

````markdown
```console
$ uv run myapp.py --logdir "${tmpdir}/logs"
Done.
```
````

You can also use `${tmpdir}` in the `cwd:` directive to run the block's commands inside the temporary directory:

````markdown
<!-- pytest-markdown-console: cwd:${tmpdir} -->
```console
$ uv run myapp.py
$ cat output.txt
Hello, world!
```
````

Each block gets its own dedicated directory, so blocks do not share state through the filesystem.

> **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.


### Exclude a block from testing

To exclude a block from being collected as a test at all, use the `notest` directive:
Expand Down
15 changes: 12 additions & 3 deletions src/pytest_markdown_console/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

import sys
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, cast

import pytest

Expand Down Expand Up @@ -58,9 +58,18 @@ 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
extra_env: dict[str, str] = {"tmpdir": tmpdir_path} if tmpdir_path is not None else {}

base_dir = self.path.parent
if self.block.cwd_override is not None:
cwd_path = (base_dir / self.block.cwd_override).resolve()
raw_cwd = self.block.cwd_override
expanded_cwd = raw_cwd.replace("${tmpdir}", tmpdir_path) if tmpdir_path is not None else raw_cwd
cwd_path = (base_dir / expanded_cwd).resolve()
if not cwd_path.is_dir():
msg = f"cwd path does not exist: {cwd_path}"
raise FileNotFoundError(msg)
Expand All @@ -72,7 +81,7 @@ def runtest(self) -> None:
effective_shell = self.block.shell or ini_shell

for command in self.block.commands:
cwd = run_command(command, cwd, shell=effective_shell)
cwd = run_command(command, cwd, shell=effective_shell, extra_env=extra_env)

def repr_failure(self, excinfo: pytest.ExceptionInfo[BaseException]) -> str | pytest.TerminalRepr:
"""Format a human-readable failure message."""
Expand Down
18 changes: 16 additions & 2 deletions src/pytest_markdown_console/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,26 @@ def __init__(self, cmd: str, expected: str, actual: str) -> None:
super().__init__(f"Output mismatch for: $ {cmd}")


def run_command(command: Command, cwd: str, shell: str | None = None) -> str:
def run_command(
command: Command,
cwd: str,
shell: str | None = None,
extra_env: dict[str, str] | None = None,
) -> str:
"""Run a single command and return the updated cwd after execution.

extra_env, if provided, is merged on top of the current process environment
and passed to the subprocess.

Raises ConsoleOutputMismatch, ConsoleUnexpectedSuccess, or ConsoleCommandFailed on failure.
"""
resolved_shell = shell or ("pwsh" if _WINDOWS else "sh")
result = _run(command.cmd, cwd, shell=resolved_shell, capture_output=True, text=True, check=False)
env: dict[str, str] | None = None
if extra_env:
env = os.environ.copy()
env.update(extra_env)

result = _run(command.cmd, cwd, shell=resolved_shell, capture_output=True, text=True, check=False, env=env)

actual = (result.stderr if command.expect_failure else result.stdout).rstrip("\n")
expected = command.expected_output.rstrip("\n")
Expand Down Expand Up @@ -116,6 +129,7 @@ def run_command(command: Command, cwd: str, shell: str | None = None) -> str:
capture_output=True,
text=True,
check=False,
env=env,
)
pwd_lines = [line for line in new_cwd_result.stdout.splitlines() if line]
if pwd_lines:
Expand Down
83 changes: 83 additions & 0 deletions tests/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,3 +228,86 @@ def test_multiple_blocks_both_run(pytester, md):
md("```console\n$ echo a\na\n```\n\n```console\n$ echo b\nb\n```\n")
result = pytester.runpytest("-v")
result.assert_outcomes(passed=2)


# ---------------------------------------------------------------------------
# tmpdir env var
# ---------------------------------------------------------------------------


def test_tmpdir_env_var_is_a_directory(pytester, md):
"""The tmpdir env var is set to an existing directory for each console block."""
md(
"```console\n"
"$ python -c \"import os, sys; v = os.environ.get('tmpdir'); sys.exit(0 if v and os.path.isdir(v) else 1)\"\n"
"```\n",
)
result = pytester.runpytest("-v")
result.assert_outcomes(passed=1)


@pytest.mark.skipif(sys.platform == "win32", reason="uses POSIX shell ${} expansion")
def test_tmpdir_expands_in_posix_commands(pytester, md):
"""On POSIX, ${tmpdir} expands to an existing directory path in shell commands."""
md('```console\n$ test -d "${tmpdir}"\n```\n')
result = pytester.runpytest("-v")
result.assert_outcomes(passed=1)


@pytest.mark.skipif(sys.platform != "win32", reason="uses PowerShell $env: syntax")
def test_tmpdir_accessible_in_powershell(pytester, md):
"""On Windows, $env:tmpdir expands to an existing directory path in pwsh commands."""
md(
"```console\n$ if (-not (Test-Path $env:tmpdir -PathType Container)) { exit 1 }\n```\n",
)
result = pytester.runpytest("-v")
result.assert_outcomes(passed=1)


def test_tmpdir_in_cwd_directive(pytester, md):
"""cwd:${tmpdir} resolves to the block's tmpdir and the command runs there."""
md(
'<!-- pytest-markdown-console: cwd:${tmpdir} -->\n```console\n$ python -c "pass"\n```\n',
)
result = pytester.runpytest("-v")
result.assert_outcomes(passed=1)


def test_tmpdir_in_cwd_missing_subdir_raises(pytester, md):
"""cwd:${tmpdir}/nonexistent fails because the subdirectory does not exist."""
md(
'<!-- pytest-markdown-console: cwd:${tmpdir}/nonexistent_subdir -->\n```console\n$ python -c "pass"\n```\n',
)
result = pytester.runpytest("-v")
result.assert_outcomes(failed=1)


def test_tmpdir_is_isolated_between_blocks(pytester, md):
"""Each console block receives a distinct tmpdir path."""
md(
"```console\n"
"$ python -c \"import os; print(os.environ['tmpdir'])\"\n"
"...\n"
"```\n"
"\n"
"```console\n"
"$ python -c \"import os; print(os.environ['tmpdir'])\"\n"
"...\n"
"```\n",
)
result = pytester.runpytest("-v")
result.assert_outcomes(passed=2)


@pytest.mark.skipif(sys.platform == "win32", reason="uses POSIX mkdir")
def test_cwd_tmpdir_mkdir_in_block(pytester, md):
"""Within a block starting in ${tmpdir}, a mkdir'd subdir is visible to the next command."""
md(
"<!-- pytest-markdown-console: cwd:${tmpdir} -->\n"
"```console\n"
"$ mkdir mysubdir\n"
"$ python -c \"import os; assert os.path.isdir('mysubdir')\"\n"
"```\n",
)
result = pytester.runpytest("-v")
result.assert_outcomes(passed=1)
Loading