diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bca8644..6f90b51 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,6 +1,12 @@ name: Python package -on: [push] +on: + push: + branches: + - main + pull_request: + branches: + - main jobs: build: @@ -8,7 +14,7 @@ jobs: 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 diff --git a/Makefile b/Makefile index c70e1c7..1c3b45b 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ black: - black memo tests setup.py + black mktestdocs tests setup.py test: pytest diff --git a/README.md b/README.md index 7901f09..3ebffcd 100644 --- a/README.md +++ b/README.md @@ -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 ``` @@ -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) ``` @@ -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 @@ -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}") + ``` + +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") +``` diff --git a/mktestdocs/__init__.py b/mktestdocs/__init__.py index 4ea4731..89fde50 100644 --- a/mktestdocs/__init__.py +++ b/mktestdocs/__init__.py @@ -1,4 +1,5 @@ from mktestdocs.__main__ import ( + register_executor, check_codeblock, grab_code_blocks, check_docstring, @@ -10,6 +11,7 @@ __all__ = [ "__version__", + "register_executor", "check_codeblock", "grab_code_blocks", "check_docstring", diff --git a/mktestdocs/__main__.py b/mktestdocs/__main__.py index a1073eb..85a73e4 100644 --- a/mktestdocs/__main__.py +++ b/mktestdocs/__main__.py @@ -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. @@ -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) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4adefae --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/data/bad/a.md b/tests/data/bad/a.md index 66f46d3..a8545ee 100644 --- a/tests/data/bad/a.md +++ b/tests/data/bad/a.md @@ -7,7 +7,7 @@ Talk talk talk. Some more talk. ```python -import random +import random random.random() ``` diff --git a/tests/data/bad/b.md b/tests/data/bad/b.md new file mode 100644 index 0000000..db38f8e --- /dev/null +++ b/tests/data/bad/b.md @@ -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 +``` diff --git a/tests/data/bad/big-bad.md b/tests/data/bad/big-bad.md new file mode 100644 index 0000000..133a6a6 --- /dev/null +++ b/tests/data/bad/big-bad.md @@ -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 +``` diff --git a/tests/data/big-bad.md b/tests/data/big-bad.md deleted file mode 100644 index 0d9022e..0000000 --- a/tests/data/big-bad.md +++ /dev/null @@ -1,12 +0,0 @@ -This is another test. - -```python -a = 1 -b = 2 -``` - -This shouldn't work. - -```python -assert add(1, 2) == 3 -``` diff --git a/tests/data/good/a.md b/tests/data/good/a.md index 53b027b..e9d75b7 100644 --- a/tests/data/good/a.md +++ b/tests/data/good/a.md @@ -7,7 +7,7 @@ Talk talk talk. Some more talk. ```python -import random +import random random.random() ``` diff --git a/tests/data/good/b.md b/tests/data/good/b.md new file mode 100644 index 0000000..0ab6eaa --- /dev/null +++ b/tests/data/good/b.md @@ -0,0 +1,13 @@ +Talk talk talk. + +```bash +GREETING="hello" +``` + +Some more talk. + +```bash +for i in {1..4}; do + echo $i +done +``` diff --git a/tests/data/good/big-good.md b/tests/data/good/big-good.md new file mode 100644 index 0000000..43ab33a --- /dev/null +++ b/tests/data/good/big-good.md @@ -0,0 +1,27 @@ +This is another test. + +```python +from operator import add +a = 1 +b = 2 +``` + +Using multiple languages. + +```bash +let MKTEST_VAR=2**4 +``` + +This should work. + +```python +assert add(1, 2) == 3 +``` + +This should also work. + +```bash +for i in {1..$MKTEST_VAR}; do + echo $i +done +``` diff --git a/tests/data/good/c.md b/tests/data/good/c.md new file mode 100644 index 0000000..287bf5a --- /dev/null +++ b/tests/data/good/c.md @@ -0,0 +1,10 @@ +Talk talk talk. + +``` +foo="Just a string. But in what language?" +``` + +Empty code block + +``` +``` diff --git a/tests/data/good/d.md b/tests/data/good/d.md new file mode 100644 index 0000000..c461041 --- /dev/null +++ b/tests/data/good/d.md @@ -0,0 +1,5 @@ +Talk talk talk. + +```python +1 + 1 == 2 +``` diff --git a/tests/docs/additional-language-support.md b/tests/docs/additional-language-support.md new file mode 100644 index 0000000..8f41baa --- /dev/null +++ b/tests/docs/additional-language-support.md @@ -0,0 +1,5 @@ +This is an example REST response + +```json +{"body": {"results": ["spam", "eggs"]}, "errors": []} +``` diff --git a/tests/docs/bash-support.md b/tests/docs/bash-support.md new file mode 100644 index 0000000..a7621c6 --- /dev/null +++ b/tests/docs/bash-support.md @@ -0,0 +1,13 @@ +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}") +``` diff --git a/tests/data/big-good.md b/tests/docs/multiple-code-blocks.md similarity index 58% rename from tests/data/big-good.md rename to tests/docs/multiple-code-blocks.md index 5f36a4b..10985d4 100644 --- a/tests/data/big-good.md +++ b/tests/docs/multiple-code-blocks.md @@ -1,12 +1,12 @@ -This is another test. +This is a code block ```python from operator import add -a = 1 +a = 1 b = 2 ``` -This should work. +This code-block should run fine. ```python assert add(1, 2) == 3 diff --git a/tests/docs/test.md b/tests/docs/test.md new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_markdown.py b/tests/test_markdown.py index 21cea03..2612ba5 100644 --- a/tests/test_markdown.py +++ b/tests/test_markdown.py @@ -1,27 +1,77 @@ import pathlib import pytest +from shutil import which +from unittest.mock import Mock -from mktestdocs import check_md_file +from mktestdocs import check_md_file, register_executor +from mktestdocs.__main__ import exec_bash -@pytest.mark.parametrize("fpath", pathlib.Path("tests/data/good").glob("*.md"), ids=str) +@pytest.mark.parametrize("fpath", pathlib.Path("tests/data/good").glob("?.md"), ids=str) def test_files_good(fpath): check_md_file(fpath=fpath) -@pytest.mark.parametrize("fpath", pathlib.Path("tests/data/bad").glob("*.md"), ids=str) -def test_files_bad(fpath): +def test_files_bad(): + fpath = pathlib.Path("tests") / "data" / "bad" / "a.md" with pytest.raises(Exception): check_md_file(fpath=fpath) def test_big_files_good(): """Confirm that we can deal with multi-cell markdown cells.""" - check_md_file(fpath="tests/data/big-good.md", memory=True) + check_md_file(fpath="tests/data/good/big-good.md", memory=True) def test_big_file_independant(): - """Confirm that different files don't influence eachother.""" - check_md_file(fpath="tests/data/big-good.md", memory=True) + """Confirm that different files don't influence each other.""" + check_md_file(fpath="tests/data/good/big-good.md", memory=True) with pytest.raises(Exception): - check_md_file(fpath="tests/data/big-bad.md", memory=True) \ No newline at end of file + check_md_file(fpath="tests/data/bad/big-bad.md", memory=True) + + +@pytest.mark.skipif(which("bash") is None, reason="No bash shell available") +@pytest.mark.parametrize("fpath", pathlib.Path("tests/data/good").glob("?.md"), ids=str) +def test_files_good_bash(fpath): + check_md_file(fpath=fpath, lang="bash") + + +@pytest.mark.skipif(which("bash") is None, reason="No bash shell available") +def test_files_bad_bash(): + fpath = pathlib.Path("tests") / "data" / "bad" / "b.md" + with pytest.raises(Exception): + check_md_file(fpath=fpath, lang="bash") + + +@pytest.mark.skipif(which("bash") is None, reason="No bash shell available") +def test_big_files_good_bash(): + fpath = pathlib.Path("tests") / "data" / "good" / "big-good.md" + check_md_file(fpath=fpath, memory=True, lang="bash") + + +@pytest.mark.skipif(which("bash") is None, reason="No bash shell available") +def test_big_file_independant_bash(): + fdir = pathlib.Path("tests") / "data" + check_md_file(fpath=fdir / "good" / "big-good.md", memory=True, lang="bash") + with pytest.raises(Exception): + check_md_file(fpath=fdir / "bad" / "big-bad.md", memory=True, lang="bash") + + +def test_files_unmarked_language_default(): + fpath = pathlib.Path("tests") / "data" / "good" / "c.md" + check_md_file(fpath, lang="") + + +@pytest.mark.skipif(which("bash") is None, reason="No bash shell available") +def test_files_unmarked_language_bash(temp_executors): + fpath = pathlib.Path("tests") / "data" / "good" / "c.md" + register_executor("", exec_bash) + check_md_file(fpath, lang="") + + +def test_override_executor(temp_executors): + fpath = pathlib.Path("tests") / "data" / "good" / "a.md" + hijack = Mock() + register_executor("python", hijack) + check_md_file(fpath, lang="python") + hijack.assert_called() diff --git a/tests/test_mktestdocs.py b/tests/test_mktestdocs.py new file mode 100644 index 0000000..e841f53 --- /dev/null +++ b/tests/test_mktestdocs.py @@ -0,0 +1,10 @@ +import pathlib + +from mktestdocs import check_md_file + +def test_readme(monkeypatch): + test_dir = pathlib.Path(__file__).parent + fpath = test_dir.parent / "README.md" + monkeypatch.chdir(test_dir) + + check_md_file(fpath=fpath)