Skip to content

Commit 6822ffa

Browse files
authored
Merge pull request #8 from ucodery/main
Extend mktestdocs beyond python blocks
2 parents b88a48b + 867bd00 commit 6822ffa

File tree

20 files changed

+357
-53
lines changed

20 files changed

+357
-53
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
black:
2-
black memo tests setup.py
2+
black mktestdocs tests setup.py
33

44
test:
55
pytest

README.md

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ Let's suppose that you have the following markdown file:
4141

4242
```python
4343
from operator import add
44-
a = 1
44+
a = 1
4545
b = 2
4646
```
4747

@@ -54,10 +54,20 @@ Let's suppose that you have the following markdown file:
5454
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`.
5555

5656
```python
57-
# Assume that cell-blocks are independent.
58-
check_md_file(fpath=fpath)
57+
import pathlib
58+
59+
from mktestdocs import check_md_file
60+
61+
fpath = pathlib.Path("docs") / "multiple-code-blocks.md"
62+
63+
try:
64+
# Assume that cell-blocks are independent.
65+
check_md_file(fpath=fpath)
66+
except NameError:
67+
# But they weren't
68+
pass
5969

60-
# Assumes that cell-blocks depend on eachother.
70+
# Assumes that cell-blocks depend on each other.
6171
check_md_file(fpath=fpath, memory=True)
6272
```
6373

@@ -88,7 +98,7 @@ from dinosaur import Dinosaur
8898
import pytest
8999
from mktestdocs import check_docstring, get_codeblock_members
90100

91-
# This retreives all methods/properties with a docstring.
101+
# This retrieves all methods/properties with a docstring.
92102
members = get_codeblock_members(Dinosaur)
93103

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

102112
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).
113+
114+
## Bash Support
115+
116+
Be default, bash code blocks are also supported. A markdown file that contains
117+
both python and bash code blocks can have each executed separately.
118+
119+
This will print the python version to the terminal
120+
121+
```bash
122+
python --version
123+
```
124+
125+
This will print the exact same version string
126+
127+
```python
128+
import sys
129+
130+
print(f"Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")
131+
```
132+
133+
This markdown could be fully tested like this
134+
135+
```python
136+
import pathlib
137+
138+
from mktestdocs import check_md_file
139+
140+
fpath = pathlib.Path("docs") / "bash-support.md"
141+
142+
check_md_file(fpath=fpath, lang="python")
143+
check_md_file(fpath=fpath, lang="bash")
144+
```
145+
146+
## Additional Language Support
147+
148+
You can add support for languages other than python and bash by first
149+
registering a new executor for that language. The `register_executor` function
150+
takes a tag to specify the code block type supported, and a function that will
151+
be passed any code blocks found in markdown files.
152+
153+
For example if you have a markdown file like this
154+
155+
This is an example REST response
156+
157+
```json
158+
{"body": {"results": ["spam", "eggs"]}, "errors": []}
159+
```
160+
161+
You could create a json validator that tested the example was always valid json like this
162+
163+
```python
164+
import json
165+
import pathlib
166+
167+
from mktestdocs import check_md_file, register_executor
168+
169+
def parse_json(json_text):
170+
json.loads(json_text)
171+
172+
register_executor("json", parse_json)
173+
174+
check_md_file(fpath=pathlib.Path("docs") / "additional-language-support.md", lang="json")
175+
```

mktestdocs/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from mktestdocs.__main__ import (
2+
register_executor,
23
check_codeblock,
34
grab_code_blocks,
45
check_docstring,
@@ -10,6 +11,7 @@
1011

1112
__all__ = [
1213
"__version__",
14+
"register_executor",
1315
"check_codeblock",
1416
"grab_code_blocks",
1517
"check_docstring",

mktestdocs/__main__.py

Lines changed: 75 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,56 @@
11
import inspect
22
import pathlib
3+
import subprocess
34
import textwrap
45

56

7+
_executors = {}
8+
9+
10+
def register_executor(lang, executor):
11+
"""Add a new executor for markdown code blocks
12+
13+
lang should be the tag used after the opening ```
14+
executor should be a callable that takes one argument:
15+
the code block found
16+
"""
17+
_executors[lang] = executor
18+
19+
20+
def exec_bash(source):
21+
"""Exec the bash source given in a new subshell
22+
23+
Does not return anything, but if any command returns not-0 an error
24+
will be raised
25+
"""
26+
command = ["bash", "-e", "-u", "-c", source]
27+
try:
28+
subprocess.run(command, check=True)
29+
except Exception:
30+
print(source)
31+
raise
32+
33+
34+
register_executor("bash", exec_bash)
35+
36+
37+
def exec_python(source):
38+
"""Exec the python source given in a new module namespace
39+
40+
Does not return anything, but exceptions raised by the source
41+
will propagate out unmodified
42+
"""
43+
try:
44+
exec(source, {"__MODULE__": "__main__"})
45+
except Exception:
46+
print(source)
47+
raise
48+
49+
50+
register_executor("", exec_python)
51+
register_executor("python", exec_python)
52+
53+
654
def get_codeblock_members(*classes):
755
"""
856
Grabs the docstrings of any methods of any classes that are passed in.
@@ -61,49 +109,54 @@ def check_docstring(obj, lang=""):
61109
"""
62110
Given a function, test the contents of the docstring.
63111
"""
112+
if lang not in _executors:
113+
raise LookupError(
114+
f"{lang} is not a supported language to check\n"
115+
"\tHint: you can add support for any language by using register_executor"
116+
)
117+
executor = _executors[lang]
64118
for b in grab_code_blocks(obj.__doc__, lang=lang):
65-
try:
66-
exec(b, {"__MODULE__": "__main__"})
67-
except Exception:
68-
print(f"Error Encountered in `{obj.__name__}`. Caused by:\n")
69-
print(b)
70-
raise
119+
executor(b)
71120

72121

73122
def check_raw_string(raw, lang="python"):
74123
"""
75124
Given a raw string, test the contents.
76125
"""
126+
if lang not in _executors:
127+
raise LookupError(
128+
f"{lang} is not a supported language to check\n"
129+
"\tHint: you can add support for any language by using register_executor"
130+
)
131+
executor = _executors[lang]
77132
for b in grab_code_blocks(raw, lang=lang):
78-
try:
79-
exec(b, {"__MODULE__": "__main__"})
80-
except Exception:
81-
print(b)
82-
raise
133+
executor(b)
83134

84135

85136
def check_raw_file_full(raw, lang="python"):
137+
if lang not in _executors:
138+
raise LookupError(
139+
f"{lang} is not a supported language to check\n"
140+
"\tHint: you can add support for any language by using register_executor"
141+
)
142+
executor = _executors[lang]
86143
all_code = ""
87144
for b in grab_code_blocks(raw, lang=lang):
88145
all_code = f"{all_code}\n{b}"
89-
try:
90-
exec(all_code, {"__MODULE__": "__main__"})
91-
except Exception:
92-
print(all_code)
93-
raise
94-
146+
executor(all_code)
147+
95148

96-
def check_md_file(fpath, memory=False):
149+
def check_md_file(fpath, memory=False, lang="python"):
97150
"""
98151
Given a markdown file, parse the contents for python code blocks
99-
and check that each independant block does not cause an error.
152+
and check that each independent block does not cause an error.
100153
101154
Arguments:
102155
fpath: path to markdown file
103-
memory: wheather or not previous code-blocks should be remembered
156+
memory: whether or not previous code-blocks should be remembered
104157
"""
105158
text = pathlib.Path(fpath).read_text()
106159
if not memory:
107-
check_raw_string(text, lang="python")
160+
check_raw_string(text, lang=lang)
108161
else:
109-
check_raw_file_full(text, lang="python")
162+
check_raw_file_full(text, lang=lang)

tests/conftest.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from copy import copy
2+
3+
import pytest
4+
5+
import mktestdocs
6+
7+
8+
@pytest.fixture
9+
def temp_executors():
10+
old_executors = copy(mktestdocs.__main__._executors)
11+
yield
12+
mktestdocs.__main__._executors = old_executors

tests/data/bad/a.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Talk talk talk.
77
Some more talk.
88

99
```python
10-
import random
10+
import random
1111

1212
random.random()
1313
```

tests/data/bad/b.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
Talk talk talk.
2+
3+
```bash
4+
GREETING="hello"
5+
```
6+
7+
Some more talk.
8+
9+
```bash
10+
for i in {1..4}; do
11+
echo $i
12+
done
13+
```
14+
15+
This is not allowed.
16+
17+
```bash
18+
echo $GREETING
19+
```

tests/data/bad/big-bad.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
This is another test.
2+
3+
```python
4+
a = 1
5+
b = 2
6+
```
7+
8+
This shouldn't work.
9+
10+
```python
11+
assert add(1, 2) == 3
12+
```
13+
14+
It uses multiple languages.
15+
16+
```bash
17+
GREETING="hello"
18+
```
19+
20+
This also shouldn't work.
21+
22+
```bash
23+
import math
24+
```

tests/data/big-bad.md

Lines changed: 0 additions & 12 deletions
This file was deleted.

tests/data/good/a.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Talk talk talk.
77
Some more talk.
88

99
```python
10-
import random
10+
import random
1111

1212
random.random()
1313
```

0 commit comments

Comments
 (0)