pythonpytestdoctestmkdocsmkdocstrings

How do I get doctest to run with examples in markdown codeblocks for mkdocs?


I'm using mkdocs & mkdocstring to build my documentation and including code examples in the docstrings. I'm also using doctest (via pytest --doctest-modules) to test all those examples.

Option 1 - format for documentation

If I format my docstring like this:

    """
    Recursively flattens a nested iterable (including strings!) and returns all elements in order left to right.

    Examples:
    --------
    ```
    >>> [x for x in flatten([1,2,[3,4,[5],6],7,[8,9]])]
    [1, 2, 3, 4, 5, 6, 7, 8, 9]
    ```
    """

Then it renders nicely in the documentation but doctest fails with the error:

Expected:
    [1, 2, 3, 4, 5, 6, 7, 8, 9]
    ```
Got:
    [1, 2, 3, 4, 5, 6, 7, 8, 9]

That makes sense as doctest treats everything until a blank line as expected output and aims to match is exactly

Option 2 - format for doctest

If I format the docstring for doctest without code blocks:

    """
    Recursively flattens a nested iterable (including strings!) and returns all elements in order left to right.

    Examples:
    --------
    >>> [x for x in flatten([1,2,[3,4,[5],6],7,[8,9]])]
    [1, 2, 3, 4, 5, 6, 7, 8, 9]
    """

then doctest passes but the documentation renders

[x for x in flatten([1,2,[3,4,[5],6],7,[8,9]])][1, 2, 3, 4, 5, 6, 7, 8, 9]

Workaround? - add a blank line for doctest

If I format it with an extra blank line before the end of the codeblock:

    """
    Recursively flattens a nested iterable (including strings!) and returns all elements in order left to right.

    Examples:
    --------
    ```
    >>> [x for x in flatten([1,2,[3,4,[5],6],7,[8,9]])]
    [1, 2, 3, 4, 5, 6, 7, 8, 9]

    ```
    """

Then doctest passes but

  1. there is a blank line at the bottom of the example in the documentation (ugly)
  2. I need to remember to add a blank line at the end of each example (error prone and annoying)

Does anyone know of a better solution?


Solution

  • Patching the regex that doctest uses to identify codeblocks solved this problem. Documenting it here for those who stumble across this in the future ...

    As this is not something I want to do regularly in projects(!), I created pytest-doctest-mkdocstrings as a pytest plugin to do this for me and included some additional sanity-checking, configuration options etc.

    pip install pytest-doctest-mkdocstrings
    pytest --doctest-mdcodeblocks --doctest-modules --doctest-glob="*.md"
    

    For those who are looking here for the answer in code to use yourself, the required change is:

    
        _MD_EXAMPLE_RE = re.compile(
            r"""
                # Source consists of a PS1 line followed by zero or more PS2 lines.
                (?P<source>
                    (?:^(?P<indent> [ ]*) >>>    .*)    # PS1 line
                    (?:\n           [ ]*  \.\.\. .*)*)  # PS2 lines
                \n?
                # Want consists of any non-blank lines that do not start with PS1.
                (?P<want> (?:(?![ ]*$)    # Not a blank line
                            (?![ ]*```)  # Not end of a code block
                            (?![ ]*>>>)  # Not a line starting with PS1
                            .+$\n?       # But any other line
                        )*)
                """,
            re.MULTILINE | re.VERBOSE,
        )
    
        doctest.DocTestParser._EXAMPLE_RE = _MD_EXAMPLE_RE
    

    Specifically I have included (?![ ]*```) # Not end of a code block in the identification of the "want"