diff --git a/README.md b/README.md index 88f2f59..71a836a 100644 --- a/README.md +++ b/README.md @@ -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 + +```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: diff --git a/src/pytest_markdown_console/plugin.py b/src/pytest_markdown_console/plugin.py index 7c9054d..6ce161e 100644 --- a/src/pytest_markdown_console/plugin.py +++ b/src/pytest_markdown_console/plugin.py @@ -3,7 +3,7 @@ from __future__ import annotations import sys -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast import pytest @@ -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) @@ -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.""" diff --git a/src/pytest_markdown_console/runner.py b/src/pytest_markdown_console/runner.py index 4f6af93..71431c4 100644 --- a/src/pytest_markdown_console/runner.py +++ b/src/pytest_markdown_console/runner.py @@ -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") @@ -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: diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 51dc51f..fcaadc7 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -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( + '\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( + '\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( + "\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)