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
10 changes: 8 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
name: Python package

on: [push]
on:
push:
branches:
- main
pull_request:
branches:
- main

jobs:
build:

runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.7, 3.8]
python-version: [3.7, 3.8, 3.9]

steps:
- uses: actions/checkout@v2
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
black:
black memo tests setup.py
black mktestdocs tests setup.py

test:
pytest
Expand Down
83 changes: 78 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Let's suppose that you have the following markdown file:

```python
from operator import add
a = 1
a = 1
b = 2
```

Expand All @@ -54,10 +54,20 @@ Let's suppose that you have the following markdown file:
Then in this case the second code-block depends on the first code-block. The standard settings of `check_md_file` assume that each code-block needs to run independently. If you'd like to test markdown files with these sequential code-blocks be sure to set `memory=True`.

```python
# Assume that cell-blocks are independent.
check_md_file(fpath=fpath)
import pathlib

from mktestdocs import check_md_file

fpath = pathlib.Path("docs") / "multiple-code-blocks.md"

try:
# Assume that cell-blocks are independent.
check_md_file(fpath=fpath)
except NameError:
# But they weren't
pass

# Assumes that cell-blocks depend on eachother.
# Assumes that cell-blocks depend on each other.
check_md_file(fpath=fpath, memory=True)
```

Expand Down Expand Up @@ -88,7 +98,7 @@ from dinosaur import Dinosaur
import pytest
from mktestdocs import check_docstring, get_codeblock_members

# This retreives all methods/properties with a docstring.
# This retrieves all methods/properties with a docstring.
members = get_codeblock_members(Dinosaur)

# Note the use of `__qualname__`, makes for pretty output
Expand All @@ -100,3 +110,66 @@ def test_member(obj):
When you run these commands via `pytest --verbose` you should see informative test info being run.

If you're wondering why you'd want to write markdown in a docstring feel free to check out [mkdocstrings](https://github.com/mkdocstrings/mkdocstrings).

## Bash Support

Be default, bash code blocks are also supported. A markdown file that contains
both python and bash code blocks can have each executed separately.

This will print the python version to the terminal

```bash
python --version
```

This will print the exact same version string

```python
import sys

print(f"Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")
```
Comment on lines +119 to +131
Copy link
Owner

@koaning koaning May 26, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't help but notice you using an extra tab here, presumably to display the ticks. I figured I'd share a trick, because there is an alternative syntax for moments like this. No need to change anything here, but I typically share this trick because many folks seem unaware.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was actually just copying the md examples from elsewhere in the README. But no, I did not know that other way of including backtics; it will certainly be useful in the future.

I do find it interesting that I am finding out about this markdown formatting while working on mktestdocs. I believe the current mktestdocs would not execute these valid quadruple or pentuple, etc, code blocks. Perhaps that would be a valuable addition in the future?

Copy link
Owner

@koaning koaning May 26, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whoa. This is indeed getting meta 😅 I'm the maintainer of mktestdocs and I did not even realise that the extra-ticks-markdown-formatting is related to the project.

I think I prefer to see it as an advanced use case that might be best to skip for now. I'd be interested in understanding the use-case a bit better before actually thinking about the feature. It should be easy for folks to build their own implementation in the meantime.


This markdown could be fully tested like this

```python
import pathlib

from mktestdocs import check_md_file

fpath = pathlib.Path("docs") / "bash-support.md"

check_md_file(fpath=fpath, lang="python")
check_md_file(fpath=fpath, lang="bash")
```

## Additional Language Support

You can add support for languages other than python and bash by first
registering a new executor for that language. The `register_executor` function
takes a tag to specify the code block type supported, and a function that will
be passed any code blocks found in markdown files.

For example if you have a markdown file like this

This is an example REST response

```json
{"body": {"results": ["spam", "eggs"]}, "errors": []}
```

You could create a json validator that tested the example was always valid json like this

```python
import json
import pathlib

from mktestdocs import check_md_file, register_executor

def parse_json(json_text):
json.loads(json_text)

register_executor("json", parse_json)

check_md_file(fpath=pathlib.Path("docs") / "additional-language-support.md", lang="json")
```
2 changes: 2 additions & 0 deletions mktestdocs/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from mktestdocs.__main__ import (
register_executor,
check_codeblock,
grab_code_blocks,
check_docstring,
Expand All @@ -10,6 +11,7 @@

__all__ = [
"__version__",
"register_executor",
"check_codeblock",
"grab_code_blocks",
"check_docstring",
Expand Down
97 changes: 75 additions & 22 deletions mktestdocs/__main__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,56 @@
import inspect
import pathlib
import subprocess
import textwrap


_executors = {}


def register_executor(lang, executor):
"""Add a new executor for markdown code blocks

lang should be the tag used after the opening ```
executor should be a callable that takes one argument:
the code block found
"""
_executors[lang] = executor


def exec_bash(source):
"""Exec the bash source given in a new subshell

Does not return anything, but if any command returns not-0 an error
will be raised
"""
command = ["bash", "-e", "-u", "-c", source]
try:
subprocess.run(command, check=True)
except Exception:
print(source)
raise


register_executor("bash", exec_bash)


def exec_python(source):
"""Exec the python source given in a new module namespace

Does not return anything, but exceptions raised by the source
will propagate out unmodified
"""
try:
exec(source, {"__MODULE__": "__main__"})
except Exception:
print(source)
raise


register_executor("", exec_python)
register_executor("python", exec_python)


def get_codeblock_members(*classes):
"""
Grabs the docstrings of any methods of any classes that are passed in.
Expand Down Expand Up @@ -61,49 +109,54 @@ def check_docstring(obj, lang=""):
"""
Given a function, test the contents of the docstring.
"""
if lang not in _executors:
raise LookupError(
f"{lang} is not a supported language to check\n"
"\tHint: you can add support for any language by using register_executor"
)
executor = _executors[lang]
for b in grab_code_blocks(obj.__doc__, lang=lang):
try:
exec(b, {"__MODULE__": "__main__"})
except Exception:
print(f"Error Encountered in `{obj.__name__}`. Caused by:\n")
print(b)
raise
executor(b)


def check_raw_string(raw, lang="python"):
"""
Given a raw string, test the contents.
"""
if lang not in _executors:
raise LookupError(
f"{lang} is not a supported language to check\n"
"\tHint: you can add support for any language by using register_executor"
)
executor = _executors[lang]
for b in grab_code_blocks(raw, lang=lang):
try:
exec(b, {"__MODULE__": "__main__"})
except Exception:
print(b)
raise
executor(b)


def check_raw_file_full(raw, lang="python"):
if lang not in _executors:
raise LookupError(
f"{lang} is not a supported language to check\n"
"\tHint: you can add support for any language by using register_executor"
)
executor = _executors[lang]
all_code = ""
for b in grab_code_blocks(raw, lang=lang):
all_code = f"{all_code}\n{b}"
try:
exec(all_code, {"__MODULE__": "__main__"})
except Exception:
print(all_code)
raise

executor(all_code)


def check_md_file(fpath, memory=False):
def check_md_file(fpath, memory=False, lang="python"):
"""
Given a markdown file, parse the contents for python code blocks
and check that each independant block does not cause an error.
and check that each independent block does not cause an error.

Arguments:
fpath: path to markdown file
memory: wheather or not previous code-blocks should be remembered
memory: whether or not previous code-blocks should be remembered
"""
text = pathlib.Path(fpath).read_text()
if not memory:
check_raw_string(text, lang="python")
check_raw_string(text, lang=lang)
else:
check_raw_file_full(text, lang="python")
check_raw_file_full(text, lang=lang)
12 changes: 12 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from copy import copy

import pytest

import mktestdocs


@pytest.fixture
def temp_executors():
old_executors = copy(mktestdocs.__main__._executors)
yield
mktestdocs.__main__._executors = old_executors
2 changes: 1 addition & 1 deletion tests/data/bad/a.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Talk talk talk.
Some more talk.

```python
import random
import random

random.random()
```
Expand Down
19 changes: 19 additions & 0 deletions tests/data/bad/b.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
Talk talk talk.

```bash
GREETING="hello"
```

Some more talk.

```bash
for i in {1..4}; do
echo $i
done
```

This is not allowed.

```bash
echo $GREETING
```
24 changes: 24 additions & 0 deletions tests/data/bad/big-bad.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
This is another test.

```python
a = 1
b = 2
```

This shouldn't work.

```python
assert add(1, 2) == 3
```

It uses multiple languages.

```bash
GREETING="hello"
```

This also shouldn't work.

```bash
import math
```
12 changes: 0 additions & 12 deletions tests/data/big-bad.md

This file was deleted.

2 changes: 1 addition & 1 deletion tests/data/good/a.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Talk talk talk.
Some more talk.

```python
import random
import random

random.random()
```
13 changes: 13 additions & 0 deletions tests/data/good/b.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Talk talk talk.

```bash
GREETING="hello"
```

Some more talk.

```bash
for i in {1..4}; do
echo $i
done
```
Loading