├── src └── pytest_markdown_docs │ ├── py.typed │ ├── __init__.py │ ├── hooks.py │ ├── definitions.py │ ├── _runners.py │ └── plugin.py ├── Makefile ├── tests ├── conftest.py ├── support │ ├── docstring_error_after.py │ └── docstring_error_before.py └── plugin_test.py ├── .github ├── pull_request_template.md └── workflows │ ├── check.yml │ ├── ci.yml │ └── codeql.yml ├── .pre-commit-config.yaml ├── pyproject.toml ├── LICENSE ├── .gitignore └── README.md /src/pytest_markdown_docs/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pytest_markdown_docs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | uv build 3 | 4 | clean: 5 | rm -rf dist 6 | 7 | publish: clean build 8 | uv publish 9 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | pytest_plugins = ["pytester"] 6 | 7 | 8 | @pytest.fixture() 9 | def support_dir(): 10 | return Path(__file__).parent / "support" 11 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 4 | 5 | **Issue:** https://github.com/modal-labs/pytest-markdown-docs/issues/XX or N/A 6 | -------------------------------------------------------------------------------- /tests/support/docstring_error_after.py: -------------------------------------------------------------------------------- 1 | def func(): 2 | """ 3 | ```python 4 | import docstring_error_after 5 | docstring_error_after.error_after() 6 | ``` 7 | """ 8 | 9 | 10 | def error_after(): 11 | raise Exception("bar") 12 | -------------------------------------------------------------------------------- /tests/support/docstring_error_before.py: -------------------------------------------------------------------------------- 1 | def error_before(): 2 | raise Exception("foo") 3 | 4 | 5 | def func(): 6 | """ 7 | ```python 8 | import docstring_error_before 9 | docstring_error_before.error_before() 10 | ``` 11 | """ 12 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/charliermarsh/ruff-pre-commit 3 | rev: "v0.9.10" 4 | hooks: 5 | - id: ruff 6 | # Autofix, and respect `exclude` and `extend-exclude` settings. 7 | args: [--fix, --exit-non-zero-on-fix] 8 | - id: ruff-format 9 | -------------------------------------------------------------------------------- /src/pytest_markdown_docs/hooks.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import typing 3 | 4 | if typing.TYPE_CHECKING: 5 | from markdown_it import MarkdownIt 6 | 7 | 8 | def pytest_markdown_docs_globals() -> typing.Dict[str, typing.Any]: 9 | return {} 10 | 11 | 12 | @pytest.hookspec(firstresult=True) 13 | def pytest_markdown_docs_markdown_it() -> "MarkdownIt": 14 | """Configure a custom markdown_it.MarkdownIt parser.""" 15 | return MarkdownIt() 16 | -------------------------------------------------------------------------------- /src/pytest_markdown_docs/definitions.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import typing 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass(frozen=True) 7 | class FenceTestDefinition: 8 | source: str 9 | fixture_names: typing.Sequence[str] 10 | start_line: int 11 | source_path: pathlib.Path 12 | runner_name: typing.Optional[str] 13 | 14 | 15 | @dataclass(frozen=True) 16 | class ObjectTestDefinition: 17 | intra_object_index: int 18 | object_name: str 19 | fence_test: FenceTestDefinition 20 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: ruff & mypy 2 | on: push 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3 9 | - name: Install uv 10 | uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 11 | 12 | - name: Install Python (3.11) 13 | uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5 14 | with: 15 | python-version: 3.11 16 | 17 | - name: Install the project 18 | run: uv sync --all-extras --dev --no-install-project 19 | 20 | - name: Ruff check 21 | run: uv run ruff check --diff 22 | 23 | - name: Ruff format 24 | run: uv run ruff format --diff 25 | 26 | - name: Mypy 27 | run: uv run mypy . 28 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "pytest-markdown-docs" 3 | version = "0.9.0" 4 | description = "Run markdown code fences through pytest" 5 | readme = "README.md" 6 | authors = [ 7 | { name = "Modal Labs"}, 8 | { name = "Elias Freider", email = "elias@modal.com" } 9 | ] 10 | license = "MIT" 11 | requires-python = ">=3.9" 12 | dependencies = [ 13 | "markdown-it-py>=2.2.0,<4.0", 14 | "pytest>=7.0.0", 15 | ] 16 | include = ["LICENSE"] 17 | 18 | [project.entry-points.pytest11] 19 | pytest_markdown_docs = "pytest_markdown_docs.plugin" 20 | 21 | [build-system] 22 | requires = ["hatchling"] 23 | build-backend = "hatchling.build" 24 | 25 | [tool.uv] 26 | package=true 27 | dev-dependencies = [ 28 | "mypy>=1.12.1", 29 | "pre-commit>=3.5.0", 30 | "pytest~=8.1.0", 31 | "ruff~=0.9.10", 32 | "mdit-py-plugins~=0.4.2" 33 | ] 34 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: push 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | strategy: 8 | matrix: 9 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 10 | steps: 11 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 12 | 13 | - name: Install uv 14 | uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 15 | 16 | - name: Install Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | 21 | - name: Install the project 22 | run: uv sync --all-extras --dev 23 | 24 | - name: Run tests with pytest 25 | run: uv run pytest 26 | 27 | - name: Downgrade to pytest 7 28 | run: uv pip install "pytest<8" 29 | 30 | - name: Run tests with pytest 7 31 | run: uv run pytest 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Modal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | schedule: 19 | - cron: '42 12 * * 0' 20 | 21 | jobs: 22 | analyze: 23 | name: Analyze 24 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 25 | permissions: 26 | actions: read 27 | contents: read 28 | security-events: write 29 | 30 | strategy: 31 | fail-fast: false 32 | matrix: 33 | language: [ 'python' ] 34 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 35 | # Use only 'java' to analyze code written in Java, Kotlin or both 36 | # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@b8d3b6e8af63cde30bdc382c0bc28114f4346c88 # v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@b8d3b6e8af63cde30bdc382c0bc28114f4346c88 # v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@b8d3b6e8af63cde30bdc382c0bc28114f4346c88 # v2 73 | with: 74 | category: "/language:${{matrix.language}}" 75 | -------------------------------------------------------------------------------- /src/pytest_markdown_docs/_runners.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import ast 3 | import traceback 4 | import typing 5 | from abc import abstractmethod 6 | 7 | import pytest 8 | 9 | from pytest_markdown_docs.definitions import FenceTestDefinition 10 | 11 | _default_runner: typing.Optional["_Runner"] = None 12 | _registered_runners = {} 13 | 14 | 15 | class _Runner(metaclass=abc.ABCMeta): 16 | @abstractmethod 17 | def runtest(self, test: FenceTestDefinition, args: dict[str, typing.Any]): ... 18 | 19 | @abstractmethod 20 | def repr_failure( 21 | self, 22 | test: FenceTestDefinition, 23 | excinfo: pytest.ExceptionInfo[BaseException], 24 | style=None, 25 | ): ... 26 | 27 | 28 | RUNNER_TYPE = typing.TypeVar("RUNNER_TYPE", bound=type[_Runner]) 29 | 30 | 31 | def register_runner(*, default: bool = False): 32 | """Decorator for adding custom runners 33 | 34 | e.g. 35 | @register_runner() 36 | def my_runner(src): 37 | exec(src) 38 | """ 39 | 40 | def decorator(r: RUNNER_TYPE) -> RUNNER_TYPE: 41 | global _default_runner 42 | runner = r() 43 | _registered_runners[r.__name__] = runner 44 | if default: 45 | _default_runner = runner 46 | return r 47 | 48 | return decorator 49 | 50 | 51 | @register_runner(default=True) 52 | class DefaultRunner(_Runner): 53 | def runtest(self, test: FenceTestDefinition, args): 54 | try: 55 | tree = ast.parse(test.source, filename=test.source_path) 56 | except SyntaxError: 57 | raise 58 | 59 | try: 60 | # if we don't compile the code, it seems we get name lookup errors 61 | # for functions etc. when doing cross-calls across inline functions 62 | compiled = compile( 63 | tree, filename=test.source_path, mode="exec", dont_inherit=True 64 | ) 65 | except SyntaxError: 66 | raise 67 | 68 | exec(compiled, args) 69 | 70 | def repr_failure( 71 | self, 72 | test: FenceTestDefinition, 73 | excinfo: pytest.ExceptionInfo[BaseException], 74 | style=None, 75 | ): 76 | """This renders a traceback starting at the stack from of the code fence 77 | 78 | Also displays a line-numbered excerpt of the code fence that ran. 79 | """ 80 | 81 | rawlines = test.source.rstrip("\n").split("\n") 82 | 83 | # custom formatted traceback to translate line numbers and markdown files 84 | traceback_lines = [] 85 | stack_summary = traceback.StackSummary.extract(traceback.walk_tb(excinfo.tb)) 86 | start_capture = False 87 | 88 | start_line = test.start_line 89 | 90 | for frame_summary in stack_summary: 91 | if frame_summary.filename == str(test.source_path): 92 | # start capturing frames the first time we enter user code 93 | start_capture = True 94 | 95 | if start_capture: 96 | lineno = frame_summary.lineno 97 | line = frame_summary.line or "" 98 | linespec = f"line {lineno}" 99 | traceback_lines.append( 100 | f""" File "{frame_summary.filename}", {linespec}, in {frame_summary.name}""" 101 | ) 102 | traceback_lines.append(f" {line.lstrip()}") 103 | 104 | maxdigits = len(str(len(rawlines))) 105 | code_margin = " " 106 | numbered_code = "\n".join( 107 | [ 108 | f"{i:>{maxdigits}}{code_margin}{line}" 109 | for i, line in enumerate(rawlines[start_line:], start_line + 1) 110 | ] 111 | ) 112 | 113 | pretty_traceback = "\n".join(traceback_lines) 114 | pt = f"""Traceback (most recent call last): 115 | {pretty_traceback} 116 | {excinfo.exconly()}""" 117 | 118 | return f"""Error in code block: 119 | {maxdigits * " "}{code_margin}``` 120 | {numbered_code} 121 | {maxdigits * " "}{code_margin}``` 122 | {pt} 123 | """ 124 | 125 | 126 | def get_runner(name: typing.Optional[str]) -> _Runner: 127 | if name is None: 128 | assert _default_runner is not None 129 | return _default_runner 130 | 131 | if name not in _registered_runners: 132 | raise Exception(f"No such pytest-markdown-docs runner: {name}") 133 | return _registered_runners[name] 134 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pytest Markdown Docs 2 | 3 | A plugin for [pytest](https://docs.pytest.org) that uses markdown code snippets from markdown files and docstrings as tests. 4 | 5 | Detects Python code fences (triple backtick escaped blocks) in markdown files as 6 | well as inline Python docstrings (similar to doctests) and runs them as tests. 7 | 8 | Python file example: 9 | 10 | ````python 11 | # mymodule.py 12 | class Foo: 13 | def bar(self): 14 | """Bar the foo 15 | 16 | This is a sample docstring for the bar method 17 | 18 | Usage: 19 | ```python 20 | import mymodule 21 | result = mymodule.Foo().bar() 22 | assert result == "hello" 23 | ``` 24 | """ 25 | return "hello" 26 | ```` 27 | 28 | Markdown file examples: 29 | 30 | ````markdown 31 | # Title 32 | 33 | Lorem ipsum yada yada yada 34 | 35 | ```python 36 | import mymodule 37 | result = mymodule.Foo().bar() 38 | assert result == "hello" 39 | ``` 40 | ```` 41 | 42 | ## Usage 43 | 44 | First, make sure to install the plugin: 45 | 46 | ```shell 47 | pip install pytest-markdown-docs 48 | ``` 49 | 50 | To enable markdown python tests, pass the `--markdown-docs` flag to `pytest`: 51 | 52 | ```shell 53 | pytest --markdown-docs 54 | ``` 55 | 56 | You can also use the `markdown-docs` flag to filter *only* markdown-docs tests: 57 | 58 | ```shell 59 | pytest --markdown-docs -m markdown-docs 60 | ``` 61 | 62 | ### Detection conditions 63 | 64 | Fence blocks (` ``` `) starting with the `python`, `python3` or `py` language definitions are detected as tests in: 65 | 66 | * Python (.py) files, within docstrings of classes and functions 67 | * `.md`, `.mdx` and `.svx` files 68 | 69 | ## Skipping tests 70 | 71 | To exclude a Python code fence from testing, add a `notest` info string to the 72 | code fence, e.g: 73 | 74 | ````markdown 75 | ```python notest 76 | print("this will not be run") 77 | ``` 78 | ```` 79 | 80 | ## Code block dependencies 81 | 82 | Sometimes you might wish to run code blocks that depend on entities to already 83 | be declared in the scope of the code, without explicitly declaring them. There 84 | are currently two ways you can do this with pytest-markdown: 85 | 86 | ### Injecting global/local variables 87 | 88 | If you have some common imports or other common variables that you want to make 89 | use of in snippets, you can add them by creating a `pytest_markdown_docs_globals` 90 | hook in your `conftest.py`: 91 | 92 | ```python 93 | def pytest_markdown_docs_globals(): 94 | import math 95 | return {"math": math, "myvar": "hello"} 96 | ``` 97 | 98 | With this conftest, you would be able to run the following markdown snippet as a 99 | test, without causing an error: 100 | 101 | ````markdown 102 | ```python 103 | print(myvar, math.pi) 104 | ``` 105 | ```` 106 | 107 | ### Fixtures 108 | 109 | You can use both `autouse=True` pytest fixtures in a conftest.py or named fixtures with 110 | your markdown tests. To specify named fixtures, add `fixture:` markers to the code 111 | fence info string, e.g., 112 | 113 | ````markdown 114 | ```python fixture:capsys 115 | print("hello") 116 | captured = capsys.readouterr() 117 | assert captured.out == "hello\n" 118 | ``` 119 | ```` 120 | 121 | As you can see above, the fixture value will be injected as a global. For `autouse=True` fixtures, the value is only injected as a global if it's explicitly added using a `fixture:` marker. 122 | 123 | ### Depending on previous snippets 124 | 125 | If you have multiple snippets following each other and want to keep the side 126 | effects from the previous snippets, you can do so by adding the `continuation` 127 | info string to your code fence: 128 | 129 | ````markdown 130 | ```python 131 | a = "hello" 132 | ``` 133 | 134 | ```python continuation 135 | assert a + " world" == "hello world" 136 | ``` 137 | ```` 138 | 139 | ### Compatibility with Material for MkDocs 140 | 141 | Material for Mkdocs is not compatible with the default syntax. 142 | 143 | But if the extension `pymdownx.superfences` is configured for mkdocs, the brace format can be used: 144 | ````markdown 145 | ```{.python continuation} 146 | ```` 147 | 148 | You will need to call pytest with the `--markdown-docs-syntax` option: 149 | ```shell 150 | pytest --markdown-docs --markdown-docs-syntax=superfences 151 | ``` 152 | 153 | ## MDX Comments for Metadata Options 154 | In .mdx files, you can use MDX comments to provide additional options for code blocks. These comments should be placed immediately before the code block and take the following form: 155 | 156 | ```mdx 157 | {/* pmd-metadata: notest fixture:capsys */} 158 | ```python 159 | print("hello") 160 | captured = capsys.readouterr() 161 | assert captured.out == "hello\n" 162 | ``` 163 | 164 | The following options can be specified using MDX comments: 165 | 166 | * notest: Exclude the code block from testing. 167 | * fixture:: Apply named pytest fixtures to the code block. 168 | * continuation: Continue from the previous code block, allowing you to carry over state. 169 | 170 | This approach allows you to add metadata to the code block without modifying the code fence itself, making it particularly useful in MDX environments. 171 | 172 | ## Customizing your own custom MarkdownIt parser 173 | 174 | You can configure your own [Markdown-it-py](https://pypi.org/project/markdown-it-py/) parser used by `pytest-markdown-docs` by defining a `pytest_markdown_docs_markdown_it`. For example, you can support 175 | `mkdocs`'s admonitions with: 176 | 177 | ```python 178 | def pytest_markdown_docs_markdown_it(): 179 | import markdown_it 180 | from mdit_py_plugins.admon import admon_plugin 181 | 182 | mi = markdown_it.MarkdownIt(config="commonmark") 183 | mi.use(admon_plugin) 184 | return mi 185 | ``` 186 | 187 | ## Testing of this plugin 188 | 189 | You can test this module itself (sadly not using markdown tests at the moment) using pytest: 190 | 191 | ```shell 192 | > poetry run pytest 193 | ``` 194 | 195 | Or for fun, you can use this plugin to include testing of the validity of snippets in this README.md file: 196 | 197 | ```shell 198 | > poetry run pytest --markdown-docs 199 | ``` 200 | 201 | ## Known issues 202 | * Code for docstring-inlined test discovery can probably be done better (similar to how doctest does it). Currently, seems to sometimes traverse into Python's standard library which isn't great... 203 | * Traceback logic is extremely hacky, wouldn't be surprised if the tracebacks look weird sometimes 204 | * Line numbers are "wrong" for docstring-inlined snippets (since we don't know where in the file the docstring starts) 205 | * Line numbers are "wrong" for continuation blocks even in pure markdown files (can be worked out with some refactoring) 206 | * There are probably more appropriate ways to use pytest internal APIs to get more features "for free" - current state of the code is a bit "patch it til' it works". 207 | * Assertions are not rewritten w/ pretty data structure inspection like they are with regular pytest tests by default 208 | -------------------------------------------------------------------------------- /tests/plugin_test.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from _pytest.pytester import LineMatcher 4 | 5 | import pytest_markdown_docs # hack: used for storing a side effect in one of the tests 6 | 7 | 8 | def test_docstring_markdown(testdir): 9 | testdir.makeconftest( 10 | """ 11 | def pytest_markdown_docs_globals(): 12 | return {"a": "hello"} 13 | """ 14 | ) 15 | testdir.makepyfile( 16 | """ 17 | def simple(): 18 | \"\"\" 19 | ```python 20 | import pytest_markdown_docs 21 | pytest_markdown_docs.side_effect = "hello" 22 | ``` 23 | 24 | ``` 25 | not a python block 26 | ``` 27 | \"\"\" 28 | 29 | 30 | class Parent: 31 | def using_global(self): 32 | \"\"\" 33 | ```python 34 | assert a + " world" == "hello world" 35 | ``` 36 | \"\"\" 37 | 38 | def failing(): 39 | \"\"\" 40 | ```python 41 | assert False 42 | ``` 43 | \"\"\" 44 | 45 | def error(): 46 | \"\"\" 47 | ```python 48 | raise Exception("oops") 49 | ``` 50 | \"\"\" 51 | """ 52 | ) 53 | result = testdir.runpytest("--markdown-docs") 54 | result.assert_outcomes(passed=2, failed=2) 55 | assert ( 56 | getattr(pytest_markdown_docs, "side_effect", None) == "hello" 57 | ) # hack to make sure the test actually does something 58 | 59 | 60 | def test_markdown_text_file(testdir): 61 | testdir.makeconftest( 62 | """ 63 | def pytest_markdown_docs_globals(): 64 | return {"a": "hello"} 65 | """ 66 | ) 67 | 68 | testdir.makefile( 69 | ".md", 70 | """ 71 | ```python 72 | assert a + " world" == "hello world" 73 | ``` 74 | 75 | ```python 76 | assert False 77 | ``` 78 | 79 | ```python 80 | **@ # this is a syntax error 81 | ``` 82 | """, 83 | ) 84 | result = testdir.runpytest("--markdown-docs") 85 | result.assert_outcomes(passed=1, failed=2) 86 | 87 | 88 | def test_continuation(testdir): 89 | testdir.makefile( 90 | ".md", 91 | """ 92 | ```python 93 | b = "hello" 94 | ``` 95 | 96 | ```python continuation 97 | assert b + " world" == "hello world" 98 | ``` 99 | """, 100 | ) 101 | result = testdir.runpytest("--markdown-docs") 102 | result.assert_outcomes(passed=2) 103 | 104 | 105 | def test_traceback(testdir): 106 | testdir.makefile( 107 | ".md", 108 | """ 109 | yada yada yada 110 | 111 | ```python 112 | def foo(): 113 | raise Exception("doh") 114 | 115 | def bar(): 116 | foo() 117 | 118 | foo() 119 | ``` 120 | """, 121 | ) 122 | result = testdir.runpytest("--markdown-docs") 123 | result.assert_outcomes(passed=0, failed=1) 124 | 125 | # we check the traceback vs a regex pattern since the file paths can change 126 | expected_output_pattern = r""" 127 | Error in code block: 128 | ``` 129 | 4 def foo\(\): 130 | 5 raise Exception\("doh"\) 131 | 6 132 | 7 def bar\(\): 133 | 8 foo\(\) 134 | 9 135 | 10 foo\(\) 136 | ``` 137 | Traceback \(most recent call last\): 138 | File ".*/test_traceback.md", line 10, in 139 | foo\(\) 140 | File ".*/test_traceback.md", line 5, in foo 141 | raise Exception\("doh"\) 142 | Exception: doh 143 | """.strip() 144 | pytest_output = "\n".join(line.rstrip() for line in result.outlines).strip() 145 | assert re.search(expected_output_pattern, pytest_output) is not None, ( 146 | "Output traceback doesn't match expected value" 147 | ) 148 | 149 | 150 | def test_autouse_fixtures(testdir): 151 | testdir.makeconftest( 152 | """ 153 | import pytest 154 | 155 | @pytest.fixture(autouse=True) 156 | def initialize(): 157 | import pytest_markdown_docs 158 | pytest_markdown_docs.bump = getattr(pytest_markdown_docs, "bump", 0) + 1 159 | yield 160 | pytest_markdown_docs.bump -= 1 161 | """ 162 | ) 163 | 164 | testdir.makefile( 165 | ".md", 166 | """ 167 | ```python 168 | import pytest_markdown_docs 169 | assert pytest_markdown_docs.bump == 1 170 | ``` 171 | """, 172 | ) 173 | result = testdir.runpytest("--markdown-docs") 174 | result.assert_outcomes(passed=1) 175 | 176 | 177 | def test_specific_fixtures(testdir): 178 | testdir.makeconftest( 179 | """ 180 | import pytest 181 | 182 | @pytest.fixture() 183 | def initialize_specific(): 184 | import pytest_markdown_docs 185 | pytest_markdown_docs.bump = getattr(pytest_markdown_docs, "bump", 0) + 1 186 | yield "foobar" 187 | pytest_markdown_docs.bump -= 1 188 | """ 189 | ) 190 | 191 | testdir.makefile( 192 | ".md", 193 | """ 194 | \"\"\" 195 | ```python fixture:initialize_specific 196 | import pytest_markdown_docs 197 | assert pytest_markdown_docs.bump == 1 198 | assert initialize_specific == "foobar" 199 | ``` 200 | \"\"\" 201 | """, 202 | ) 203 | result = testdir.runpytest("--markdown-docs") 204 | result.assert_outcomes(passed=1) 205 | 206 | 207 | def test_non_existing_fixture_error(testdir): 208 | testdir.makeconftest( 209 | """ 210 | import pytest 211 | 212 | @pytest.fixture() 213 | def foo(): 214 | pass 215 | """ 216 | ) 217 | 218 | testdir.makefile( 219 | ".md", 220 | """ 221 | \"\"\" 222 | ```python fixture:bar 223 | ``` 224 | \"\"\" 225 | """, 226 | ) 227 | result = testdir.runpytest("--markdown-docs") 228 | assert "fixture 'bar' not found" in result.stdout.str() 229 | result.assert_outcomes(errors=1) 230 | 231 | 232 | def test_fixture_overriding_global(testdir): 233 | testdir.makeconftest( 234 | """ 235 | import pytest 236 | 237 | def pytest_markdown_docs_globals(): 238 | return { 239 | "some_global": "foo" 240 | } 241 | 242 | @pytest.fixture() 243 | def some_global(): 244 | return "bar" 245 | """ 246 | ) 247 | 248 | testdir.makefile( 249 | ".md", 250 | """ 251 | \"\"\" 252 | ```python 253 | assert some_global == "foo" 254 | ``` 255 | 256 | ```python fixture:some_global 257 | assert some_global == "bar" 258 | ``` 259 | \"\"\" 260 | """, 261 | ) 262 | result = testdir.runpytest("--markdown-docs") 263 | result.assert_outcomes(passed=2) 264 | 265 | 266 | def test_continuation_mdx_comment(testdir): 267 | testdir.makefile( 268 | ".mdx", 269 | """ 270 | ```python 271 | b = "hello" 272 | ``` 273 | {/* pmd-metadata: continuation */} 274 | ```python 275 | assert b + " world" == "hello world" 276 | ``` 277 | """, 278 | ) 279 | result = testdir.runpytest("--markdown-docs") 280 | result.assert_outcomes(passed=2) 281 | 282 | 283 | def test_specific_fixture_mdx_comment(testdir): 284 | testdir.makeconftest( 285 | """ 286 | import pytest 287 | @pytest.fixture() 288 | def initialize_specific(): 289 | import pytest_markdown_docs 290 | pytest_markdown_docs.bump = getattr(pytest_markdown_docs, "bump", 0) + 1 291 | yield "foobar" 292 | pytest_markdown_docs.bump -= 1 293 | """ 294 | ) 295 | 296 | testdir.makefile( 297 | ".mdx", 298 | """ 299 | {/* pmd-metadata: fixture:initialize_specific */} 300 | ```python 301 | import pytest_markdown_docs 302 | assert pytest_markdown_docs.bump == 1 303 | assert initialize_specific == "foobar" 304 | ``` 305 | """, 306 | ) 307 | result = testdir.runpytest("--markdown-docs") 308 | result.assert_outcomes(passed=1) 309 | 310 | 311 | def test_multiple_fixtures_mdx_comment(testdir): 312 | testdir.makeconftest( 313 | """ 314 | import pytest 315 | @pytest.fixture() 316 | def initialize_specific(): 317 | import pytest_markdown_docs 318 | pytest_markdown_docs.bump = getattr(pytest_markdown_docs, "bump", 0) + 1 319 | yield "foobar" 320 | pytest_markdown_docs.bump -= 1 321 | 322 | @pytest.fixture 323 | def another_fixture(): 324 | return "hello" 325 | """ 326 | ) 327 | 328 | testdir.makefile( 329 | ".mdx", 330 | """ 331 | {/* pmd-metadata: fixture:initialize_specific fixture:another_fixture */} 332 | ```python 333 | import pytest_markdown_docs 334 | assert pytest_markdown_docs.bump == 1 335 | assert initialize_specific == "foobar" 336 | ``` 337 | """, 338 | ) 339 | result = testdir.runpytest("--markdown-docs") 340 | result.assert_outcomes(passed=1) 341 | 342 | 343 | def test_notest_mdx_comment(testdir): 344 | testdir.makefile( 345 | ".mdx", 346 | """ 347 | {/* pmd-metadata: notest */} 348 | ```python 349 | assert True 350 | ``` 351 | """, 352 | ) 353 | result = testdir.runpytest("--markdown-docs") 354 | result.assert_outcomes(passed=0) 355 | 356 | 357 | def test_superfences_format_markdown(testdir): 358 | testdir.makefile( 359 | ".md", 360 | """ 361 | ```python 362 | b = "hello" 363 | ``` 364 | 365 | ```{.python continuation} 366 | assert b + " world" == "hello world" 367 | ``` 368 | 369 | # the lang may not be the first element 370 | ```{other_option .python .other-class continuation} 371 | assert b + " world" == "hello world" 372 | ``` 373 | """, 374 | ) 375 | result = testdir.runpytest("--markdown-docs", "--markdown-docs-syntax=superfences") 376 | result.assert_outcomes(passed=3) 377 | 378 | 379 | def test_superfences_format_docstring(testdir): 380 | testdir.makepyfile( 381 | """ 382 | def simple(): 383 | \"\"\" 384 | ```python 385 | b = "hello" 386 | ``` 387 | 388 | ```{.python continuation} 389 | assert b + " world" == "hello world" 390 | ``` 391 | \"\"\" 392 | """ 393 | ) 394 | result = testdir.runpytest("--markdown-docs", "--markdown-docs-syntax=superfences") 395 | result.assert_outcomes(passed=2) 396 | 397 | 398 | def test_error_origin_after_docstring_traceback(testdir, support_dir): 399 | sample_file = support_dir / "docstring_error_after.py" 400 | testdir.makepyfile(**{sample_file.stem: sample_file.read_text()}) 401 | result = testdir.runpytest("-v", "--markdown-docs") 402 | 403 | data: LineMatcher = result.stdout 404 | data.re_match_lines( 405 | [ 406 | r"Traceback \(most recent call last\):", 407 | r'\s*File ".*/docstring_error_after.py", line 5, in ', 408 | r"\s*docstring_error_after.error_after\(\)", 409 | r'\s*File ".*/docstring_error_after.py", line 11, in error_after', 410 | r'\s*raise Exception\("bar"\)', 411 | r"\s*Exception: bar", 412 | ], 413 | consecutive=True, 414 | ) 415 | 416 | 417 | def test_error_origin_before_docstring_traceback(testdir, support_dir): 418 | sample_file = support_dir / "docstring_error_before.py" 419 | testdir.makepyfile(**{sample_file.stem: sample_file.read_text()}) 420 | result = testdir.runpytest("-v", "--markdown-docs") 421 | 422 | data: LineMatcher = result.stdout 423 | data.re_match_lines( 424 | [ 425 | r"Traceback \(most recent call last\):", 426 | r'\s*File ".*/docstring_error_before.py", line 9, in ', 427 | r"\s*docstring_error_before.error_before\(\)", 428 | r'\s*File ".*/docstring_error_before.py", line 2, in error_before', 429 | r'\s*raise Exception\("foo"\)', 430 | r"\s*Exception: foo", 431 | ], 432 | consecutive=True, 433 | ) 434 | 435 | 436 | def test_custom_runner(testdir): 437 | testdir.makeconftest( 438 | """ 439 | import pytest_markdown_docs._runners 440 | 441 | @pytest_markdown_docs._runners.register_runner() 442 | class LinesAreAllFoo(pytest_markdown_docs._runners.DefaultRunner): 443 | def runtest(self, test, args): 444 | lines = test.source.strip().split("\\n") 445 | for line in lines: 446 | assert line == "foo" 447 | """ 448 | ) 449 | testdir.makefile( 450 | ".md", 451 | """ 452 | ```python runner:LinesAreAllFoo 453 | foo 454 | foo 455 | ``` 456 | 457 | ```python runner:LinesAreAllFoo 458 | foo 459 | bar 460 | ``` 461 | """, 462 | ) 463 | 464 | result = testdir.runpytest("-v", "--markdown-docs") 465 | result.assert_outcomes(passed=1, failed=1) 466 | result.stdout.re_match_lines( 467 | [ 468 | r".*\[CodeFence#1\]\[line:1\].*PASSED.*", 469 | r".*\[CodeFence#2\]\[line:6\].*FAILED.*", 470 | ], 471 | consecutive=True, 472 | ) 473 | 474 | 475 | def test_admonition_markdown_text_file(testdir): 476 | testdir.makeconftest( 477 | """ 478 | def pytest_markdown_docs_globals(): 479 | return {"a": "hello"} 480 | 481 | def pytest_markdown_docs_markdown_it(): 482 | import markdown_it 483 | from mdit_py_plugins.admon import admon_plugin 484 | 485 | mi = markdown_it.MarkdownIt(config="commonmark") 486 | mi.use(admon_plugin) 487 | return mi 488 | """ 489 | ) 490 | 491 | testdir.makefile( 492 | ".md", 493 | """ 494 | ??? quote 495 | 496 | ```python 497 | assert a + " world" == "hello world" 498 | ``` 499 | 500 | !!! info 501 | ```python 502 | assert False 503 | ``` 504 | 505 | ???+ note 506 | ```python 507 | **@ # this is a syntax error 508 | ``` 509 | """, 510 | ) 511 | result = testdir.runpytest("--markdown-docs") 512 | result.assert_outcomes(passed=1, failed=2) 513 | -------------------------------------------------------------------------------- /src/pytest_markdown_docs/plugin.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import types 3 | import pathlib 4 | 5 | import pytest 6 | import typing 7 | from enum import Enum 8 | 9 | from _pytest._code import ExceptionInfo 10 | from _pytest.config.argparsing import Parser 11 | from _pytest.pathlib import import_path 12 | import logging 13 | 14 | from pytest_markdown_docs import hooks 15 | from pytest_markdown_docs.definitions import FenceTestDefinition, ObjectTestDefinition 16 | from pytest_markdown_docs._runners import get_runner 17 | 18 | if pytest.version_tuple >= (8, 0, 0): 19 | from _pytest.fixtures import TopRequest 20 | else: 21 | # pytest 7 compatible 22 | from _pytest.fixtures import FixtureRequest as TopRequest # type: ignore 23 | 24 | 25 | if typing.TYPE_CHECKING: 26 | from markdown_it.token import Token 27 | from markdown_it import MarkdownIt 28 | 29 | logger = logging.getLogger("pytest-markdown-docs") 30 | 31 | MARKER_NAME = "markdown-docs" 32 | 33 | 34 | class FenceSyntax(Enum): 35 | default = "default" 36 | superfences = "superfences" 37 | 38 | 39 | def get_docstring_start_line(obj) -> typing.Optional[int]: 40 | # Get the source lines and the starting line number of the object 41 | try: 42 | source_lines, start_line = inspect.getsourcelines(obj) 43 | except OSError: 44 | return None 45 | 46 | # Find the line in the source code that starts with triple quotes (""" or ''') 47 | for idx, line in enumerate(source_lines): 48 | line = line.strip() 49 | if line.startswith(('"""', "'''")): 50 | return start_line + idx # Return the starting line number 51 | 52 | return None # Docstring not found in source 53 | 54 | 55 | class MarkdownInlinePythonItem(pytest.Item): 56 | def __init__( 57 | self, 58 | name: str, 59 | parent: typing.Union["MarkdownDocstringCodeModule", "MarkdownTextFile"], 60 | test_definition: FenceTestDefinition, 61 | ) -> None: 62 | super().__init__(name, parent) 63 | self.add_marker(MARKER_NAME) 64 | self.code = test_definition.source 65 | self.obj = None 66 | self.test_definition = test_definition 67 | self.user_properties.append(("code", test_definition.source)) 68 | self.start_line = test_definition.start_line 69 | self.fixturenames = test_definition.fixture_names 70 | self.nofuncargs = True 71 | self.runner_name = test_definition.runner_name 72 | 73 | def setup(self): 74 | def func() -> None: 75 | pass 76 | 77 | self.funcargs = {} 78 | self._fixtureinfo = self.session._fixturemanager.getfixtureinfo( 79 | node=self, func=func, cls=None 80 | ) 81 | self.fixture_request = TopRequest(self, _ispytest=True) 82 | self.fixture_request._fillfixtures() 83 | self.runner = get_runner(self.runner_name) 84 | 85 | def runtest(self): 86 | global_sets = self.parent.config.hook.pytest_markdown_docs_globals() 87 | 88 | mod = types.ModuleType("fence") # dummy module 89 | all_globals = mod.__dict__ 90 | for global_set in global_sets: 91 | all_globals.update(global_set) 92 | 93 | # make sure to evaluate fixtures 94 | # this will insert named fixtures into self.funcargs 95 | for fixture_name in self._fixtureinfo.names_closure: 96 | self.fixture_request.getfixturevalue(fixture_name) 97 | 98 | # Since these are not actual functions with arguments, the only 99 | # arguments that should appear in self.funcargs are the filled fixtures 100 | for argname, value in self.funcargs.items(): 101 | all_globals[argname] = value 102 | 103 | # this ensures that pytest's stdout/stderr capture works during the test: 104 | capman = self.config.pluginmanager.getplugin("capturemanager") 105 | with capman.global_and_fixture_disabled(): 106 | self.runner.runtest(self.test_definition, all_globals) 107 | 108 | def repr_failure( 109 | self, 110 | excinfo: ExceptionInfo[BaseException], 111 | style=None, 112 | ) -> str: 113 | return self.runner.repr_failure(self.test_definition, excinfo, style) 114 | 115 | def reportinfo(self): 116 | return self.path, self.start_line, self.name 117 | 118 | 119 | def get_prefixed_strings( 120 | seq: typing.Collection[str], prefix: str 121 | ) -> typing.Sequence[str]: 122 | # return strings matching a prefix, with the prefix stripped 123 | return tuple(s[len(prefix) :] for s in seq if s.startswith(prefix)) 124 | 125 | 126 | def extract_fence_tests( 127 | markdown_it_parser: "MarkdownIt", 128 | markdown_string: str, 129 | start_line_offset: int, 130 | source_path: pathlib.Path, 131 | markdown_type: str = "md", 132 | fence_syntax: FenceSyntax = FenceSyntax.default, 133 | ) -> typing.Generator[FenceTestDefinition, None, None]: 134 | tokens = markdown_it_parser.parse(markdown_string) 135 | 136 | prev = "" 137 | for i, block in enumerate(tokens): 138 | if block.type != "fence" or not block.map: 139 | continue 140 | 141 | if fence_syntax == FenceSyntax.superfences: 142 | code_info = parse_superfences_block_info(block.info) 143 | else: 144 | code_info = block.info.split() 145 | 146 | lang = code_info[0] if code_info else None 147 | code_options = set(code_info) - {lang} 148 | 149 | if markdown_type == "mdx": 150 | # In MDX, comments are enclosed within a paragraph block and must be 151 | # placed directly above the corresponding code fence. The token 152 | # sequence is as follows: 153 | # i-3: paragraph_open 154 | # i-2: comment 155 | # i-1: paragraph_close 156 | # i: code fence 157 | # 158 | # Therefore, to retrieve the MDX comment associated with the current 159 | # code fence (at index `i`), we need to access the token at `i - 2`. 160 | if i >= 2 and is_mdx_comment(tokens[i - 2]): 161 | code_options |= extract_options_from_mdx_comment(tokens[i - 2].content) 162 | 163 | if lang in ("py", "python", "python3") and "notest" not in code_options: 164 | start_line = ( 165 | start_line_offset + block.map[0] + 1 166 | ) # actual code starts on +1 from the "info" line 167 | if "continuation" not in code_options: 168 | prev = "" 169 | 170 | add_blank_lines = start_line - prev.count("\n") 171 | code_block = prev + ("\n" * add_blank_lines) + block.content 172 | 173 | fixture_names = get_prefixed_strings(code_options, "fixture:") 174 | runner_names = get_prefixed_strings(code_options, "runner:") 175 | if len(runner_names) == 0: 176 | runner_name = None 177 | elif len(runner_names) > 1: 178 | raise Exception( 179 | f"Multiple runners are not supported, use a single one instead: {runner_names}" 180 | ) 181 | else: 182 | runner_name = runner_names[0] 183 | yield FenceTestDefinition( 184 | code_block, 185 | fixture_names, 186 | start_line, 187 | source_path=source_path, 188 | runner_name=runner_name, 189 | ) 190 | prev = code_block 191 | 192 | 193 | def parse_superfences_block_info(block_info: str) -> typing.List[str]: 194 | """Parse PyMdown Superfences block info syntax. 195 | 196 | The default `python continuation` format is not compatible with Material for Mkdocs. 197 | But, PyMdown Superfences has a special brace format to add options to code fence blocks: `{. }`. 198 | 199 | This function also works if the default syntax is used to allow for mixed usage. 200 | """ 201 | block_info = block_info.strip() 202 | 203 | if not block_info.startswith("{"): 204 | # default syntax 205 | return block_info.split() 206 | 207 | block_info = block_info.strip("{}") 208 | code_info = block_info.split() 209 | # Lang may not be the first but is always the first element that starts with a dot. 210 | # (https://facelessuser.github.io/pymdown-extensions/extensions/superfences/#injecting-classes-ids-and-attributes) 211 | dot_lang = next( 212 | (info_part for info_part in code_info if info_part.startswith(".")), None 213 | ) 214 | if dot_lang: 215 | code_info.remove(dot_lang) 216 | lang = dot_lang[1:] 217 | code_info.insert(0, lang) 218 | return code_info 219 | 220 | 221 | def is_mdx_comment(block: "Token") -> bool: 222 | return ( 223 | block.type == "inline" 224 | and block.content.strip().startswith("{/*") 225 | and block.content.strip().endswith("*/}") 226 | and "pmd-metadata:" in block.content 227 | ) 228 | 229 | 230 | def extract_options_from_mdx_comment(comment: str) -> typing.Set[str]: 231 | comment = ( 232 | comment.strip() 233 | .replace("{/*", "") 234 | .replace("*/}", "") 235 | .replace("pmd-metadata:", "") 236 | ) 237 | return set(option.strip() for option in comment.split(" ") if option) 238 | 239 | 240 | class MarkdownDocstringCodeModule(pytest.Module): 241 | def collect(self): 242 | if pytest.version_tuple >= (8, 1, 0): 243 | # consider_namespace_packages is a required keyword argument in pytest 8.1.0 244 | module = import_path( 245 | self.path, root=self.config.rootpath, consider_namespace_packages=True 246 | ) 247 | else: 248 | # but unsupported before pytest 8.1... 249 | module = import_path(self.path, root=self.config.rootpath) 250 | 251 | for object_test in self.find_object_tests_recursive( 252 | module.__name__, module, set(), set() 253 | ): 254 | fence_test = object_test.fence_test 255 | yield MarkdownInlinePythonItem.from_parent( 256 | self, 257 | name=f"{object_test.object_name}[CodeFence#{object_test.intra_object_index + 1}][line:{fence_test.start_line}]", 258 | test_definition=fence_test, 259 | ) 260 | 261 | def find_object_tests_recursive( 262 | self, 263 | module_name: str, 264 | object: typing.Any, 265 | _visited_objects: typing.Set[int], 266 | _found_tests: typing.Set[typing.Tuple[str, int]], 267 | ) -> typing.Generator[ObjectTestDefinition, None, None]: 268 | if id(object) in _visited_objects: 269 | return 270 | _visited_objects.add(id(object)) 271 | docstr = inspect.getdoc(object) 272 | 273 | for member_name, member in inspect.getmembers(object): 274 | if ( 275 | inspect.isclass(member) 276 | or inspect.isfunction(member) 277 | or inspect.ismethod(member) 278 | ) and member.__module__ == module_name: 279 | yield from self.find_object_tests_recursive( 280 | module_name, member, _visited_objects, _found_tests 281 | ) 282 | 283 | if docstr: 284 | docstring_offset = get_docstring_start_line(object) 285 | if docstring_offset is None: 286 | logger.warning( 287 | f"Could not find line number offset for docstring: {docstr}" 288 | ) 289 | else: 290 | obj_name = ( 291 | getattr(object, "__qualname__", None) 292 | or getattr(object, "__name__", None) 293 | or "" 294 | ) 295 | fence_syntax = FenceSyntax(self.config.option.markdowndocs_syntax) 296 | markdown_it_parser = self.config.hook.pytest_markdown_docs_markdown_it() 297 | 298 | for i, fence_test in enumerate( 299 | extract_fence_tests( 300 | markdown_it_parser, 301 | docstr, 302 | docstring_offset, 303 | source_path=self.path, 304 | fence_syntax=fence_syntax, 305 | ) 306 | ): 307 | found_test = ObjectTestDefinition(i, obj_name, fence_test) 308 | found_test_location = ( 309 | module_name, 310 | found_test.fence_test.start_line, 311 | ) 312 | if found_test_location not in _found_tests: 313 | _found_tests.add(found_test_location) 314 | yield found_test 315 | 316 | 317 | class MarkdownTextFile(pytest.File): 318 | def collect(self): 319 | markdown_content = self.path.read_text("utf8") 320 | fence_syntax = FenceSyntax(self.config.option.markdowndocs_syntax) 321 | 322 | markdown_it_parser = self.config.hook.pytest_markdown_docs_markdown_it() 323 | 324 | for i, fence_test in enumerate( 325 | extract_fence_tests( 326 | markdown_it_parser, 327 | markdown_content, 328 | source_path=self.path, 329 | start_line_offset=0, 330 | markdown_type=self.path.suffix.replace(".", ""), 331 | fence_syntax=fence_syntax, 332 | ) 333 | ): 334 | yield MarkdownInlinePythonItem.from_parent( 335 | self, 336 | name=f"[CodeFence#{i + 1}][line:{fence_test.start_line}]", 337 | test_definition=fence_test, 338 | ) 339 | 340 | 341 | def pytest_collect_file( 342 | file_path, 343 | parent, 344 | ): 345 | if parent.config.option.markdowndocs: 346 | pathlib_path = pathlib.Path(str(file_path)) # pytest 7/8 compat 347 | if pathlib_path.suffix == ".py": 348 | return MarkdownDocstringCodeModule.from_parent(parent, path=pathlib_path) 349 | elif pathlib_path.suffix in (".md", ".mdx", ".svx"): 350 | return MarkdownTextFile.from_parent(parent, path=pathlib_path) 351 | 352 | return None 353 | 354 | 355 | def pytest_configure(config): 356 | config.addinivalue_line( 357 | "markers", f"{MARKER_NAME}: filter for pytest-markdown-docs generated tests" 358 | ) 359 | 360 | 361 | def pytest_addoption(parser: Parser) -> None: 362 | group = parser.getgroup("collect") 363 | group.addoption( 364 | "--markdown-docs", 365 | action="store_true", 366 | default=False, 367 | help="run ", 368 | dest="markdowndocs", 369 | ) 370 | group.addoption( 371 | "--markdown-docs-syntax", 372 | action="store", 373 | choices=[choice.value for choice in FenceSyntax], 374 | default="default", 375 | help="Choose an alternative fences syntax", 376 | dest="markdowndocs_syntax", 377 | ) 378 | 379 | 380 | def pytest_addhooks(pluginmanager): 381 | pluginmanager.add_hookspecs(hooks) 382 | 383 | 384 | @pytest.hookimpl 385 | def pytest_markdown_docs_markdown_it() -> "MarkdownIt": 386 | from markdown_it import MarkdownIt 387 | 388 | return MarkdownIt(config="commonmark") 389 | --------------------------------------------------------------------------------