diff --git a/README.md b/README.md index 71a836a..bd8bcde 100644 --- a/README.md +++ b/README.md @@ -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 + +```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 + +```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: diff --git a/src/pytest_markdown_console/__init__.py b/src/pytest_markdown_console/__init__.py index 317c351..634e905 100644 --- a/src/pytest_markdown_console/__init__.py +++ b/src/pytest_markdown_console/__init__.py @@ -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 @@ -9,6 +10,7 @@ "ConsoleCommandFailed", "ConsoleOutputMismatch", "ConsoleUnexpectedSuccess", + "markdown_console_tmpdir", "pytest_addoption", "pytest_collect_file", "pytest_configure", diff --git a/src/pytest_markdown_console/models.py b/src/pytest_markdown_console/models.py index 844035c..b6c4f71 100644 --- a/src/pytest_markdown_console/models.py +++ b/src/pytest_markdown_console/models.py @@ -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) diff --git a/src/pytest_markdown_console/parsing.py b/src/pytest_markdown_console/parsing.py index 4226f32..aa6b2ce 100644 --- a/src/pytest_markdown_console/parsing.py +++ b/src/pytest_markdown_console/parsing.py @@ -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 @@ -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: @@ -97,14 +100,14 @@ 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 ``-file:`` comment.""" file_re = re.compile(rf"^\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]: @@ -112,7 +115,7 @@ def parse_blocks(source: str, directive: str = "pytest-markdown-console") -> lis directive_re = re.compile(rf"^\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 @@ -128,7 +131,7 @@ 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, @@ -136,6 +139,7 @@ def parse_blocks(source: str, directive: str = "pytest-markdown-console") -> lis 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 diff --git a/src/pytest_markdown_console/plugin.py b/src/pytest_markdown_console/plugin.py index 6ce161e..b6bdb2f 100644 --- a/src/pytest_markdown_console/plugin.py +++ b/src/pytest_markdown_console/plugin.py @@ -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 @@ -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 @@ -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 @@ -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( diff --git a/tests/test_plugin.py b/tests/test_plugin.py index fcaadc7..4509eb4 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -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( + "\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( + '\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( + "\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( + "\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( + "\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( + '\n```console\n$ python -c "pass"\n```\n', + ) + result = pytester.runpytest("-v") + result.assert_outcomes(errors=1)