├── .gitignore ├── .travis.yml ├── README.md ├── mkautodoc ├── __init__.py └── extension.py ├── requirements.txt ├── scripts ├── clean ├── install ├── lint ├── publish └── test ├── setup.py └── tests ├── __init__.py ├── assertions.py ├── mocklib └── mocklib.py └── test_extension.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info 3 | .coverage 4 | .pytest_cache 5 | htmlcov 6 | venv 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: python 3 | 4 | cache: pip 5 | 6 | python: 7 | - "3.6" 8 | - "3.7" 9 | - "3.8-dev" 10 | 11 | install: 12 | - scripts/install 13 | 14 | script: 15 | - scripts/test 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MkAutoDoc 2 | 3 | Python API documentation for MkDocs. 4 | 5 | This markdown extension adds `autodoc` style support, for use with MkDocs. 6 | 7 | ![aIAgAAjQpG](https://user-images.githubusercontent.com/647359/66651320-a276ff80-ec2a-11e9-9cec-9eba425d5304.gif) 8 | 9 | ## Usage 10 | 11 | #### 1. Include the extension in you `mkdocs.yml` config file: 12 | 13 | ```yaml 14 | [...] 15 | markdown_extensions: 16 | - admonition 17 | - codehilite 18 | - mkautodoc 19 | ``` 20 | 21 | #### 2. Ensure the library you want to document is importable. 22 | 23 | This will depend on how your documentation building is setup, but 24 | you may need to use `pip install -e .` or modify `PYTHONPATH` in your docs build script. 25 | 26 | #### 3. Use the `:::` block syntax to add autodoc blocks to your documentation. 27 | 28 | ```markdown 29 | # API documentation 30 | 31 | ::: my_library.some_function 32 | :docstring: 33 | 34 | ::: my_library.SomeClass 35 | :docstring: 36 | :members: 37 | ``` 38 | 39 | #### 4. Optionally, add styling for the API docs 40 | 41 | Update your `mkdocs.yml` to include some custom CSS. 42 | 43 | ```yaml 44 | [...] 45 | extra_css: 46 | - css/custom.css 47 | ``` 48 | 49 | Then add a `css/custom.css` file to your documentation. 50 | 51 | ```css 52 | div.autodoc-docstring { 53 | padding-left: 20px; 54 | margin-bottom: 30px; 55 | border-left: 5px solid rgba(230, 230, 230); 56 | } 57 | 58 | div.autodoc-members { 59 | padding-left: 20px; 60 | margin-bottom: 15px; 61 | } 62 | ``` 63 | 64 | ## Notes 65 | 66 | #### The :docstring: declaration. 67 | 68 | Renders the docstring of the associated function, method, or class. 69 | 70 | #### The `:members:` declaration. 71 | 72 | Renders documentation for member attributes of the associated class. 73 | Currently handles methods and properties. 74 | Instance attributes set during `__init__` are not currently recognised. 75 | 76 | May optionally accept a list of member attributes that should be documented. For example: 77 | 78 | ```markdown 79 | ::: my_library.SomeClass 80 | :docstring: 81 | :members: currency vat_registered calculate_expenses 82 | ``` 83 | -------------------------------------------------------------------------------- /mkautodoc/__init__.py: -------------------------------------------------------------------------------- 1 | from .extension import MKAutoDocExtension, makeExtension 2 | 3 | 4 | __version__ = "0.2.0" 5 | __all__ = ["MKAutoDocExtension", "makeExtension"] 6 | -------------------------------------------------------------------------------- /mkautodoc/extension.py: -------------------------------------------------------------------------------- 1 | from markdown import Markdown 2 | from markdown.extensions import Extension 3 | from markdown.blockprocessors import BlockProcessor 4 | from xml.etree import ElementTree as etree 5 | import importlib 6 | import inspect 7 | import re 8 | import typing 9 | 10 | 11 | # Fuzzy regex for determining source lines in __init__ that look like 12 | # attribute assignments. Eg. `self.counter = 0` 13 | SET_ATTRIBUTE = re.compile("^([ \t]*)self[.]([A-Za-z0-9_]+) *=") 14 | 15 | 16 | def import_from_string(import_str: str) -> typing.Any: 17 | module_str, _, attr_str = import_str.rpartition(".") 18 | 19 | try: 20 | module = importlib.import_module(module_str) 21 | except ImportError as exc: 22 | module_name = module_str.split(".", 1)[0] 23 | if exc.name != module_name: 24 | raise exc from None 25 | raise ValueError(f"Could not import module {module_str!r}.") 26 | 27 | try: 28 | return getattr(module, attr_str) 29 | except AttributeError as exc: 30 | raise ValueError(f"Attribute {attr_str!r} not found in module {module_str!r}.") 31 | 32 | 33 | def get_params(signature: inspect.Signature) -> typing.List[str]: 34 | """ 35 | Given a function signature, return a list of parameter strings 36 | to use in documentation. 37 | 38 | Eg. test(a, b=None, **kwargs) -> ['a', 'b=None', '**kwargs'] 39 | """ 40 | params = [] 41 | render_pos_only_separator = True 42 | render_kw_only_separator = True 43 | 44 | for parameter in signature.parameters.values(): 45 | value = parameter.name 46 | if parameter.default is not parameter.empty: 47 | value = f"{value}={parameter.default!r}" 48 | 49 | if parameter.kind is parameter.VAR_POSITIONAL: 50 | render_kw_only_separator = False 51 | value = f"*{value}" 52 | elif parameter.kind is parameter.VAR_KEYWORD: 53 | value = f"**{value}" 54 | elif parameter.kind is parameter.POSITIONAL_ONLY: 55 | if render_pos_only_separator: 56 | render_pos_only_separator = False 57 | params.append("/") 58 | elif parameter.kind is parameter.KEYWORD_ONLY: 59 | if render_kw_only_separator: 60 | render_kw_only_separator = False 61 | params.append("*") 62 | params.append(value) 63 | 64 | return params 65 | 66 | 67 | def last_iter(seq: typing.Sequence) -> typing.Iterator: 68 | """ 69 | Given a sequence, return a two-tuple (item, is_last) iterable. 70 | 71 | See: https://stackoverflow.com/a/1633483/596689 72 | """ 73 | it = iter(seq) 74 | item = next(it) 75 | is_last = False 76 | 77 | for next_item in it: 78 | yield item, is_last 79 | item = next_item 80 | 81 | is_last = True 82 | yield item, is_last 83 | 84 | 85 | def trim_docstring(docstring: typing.Optional[str]) -> str: 86 | """ 87 | Trim leading indent from a docstring. 88 | 89 | See: https://www.python.org/dev/peps/pep-0257/#handling-docstring-indentation 90 | """ 91 | if not docstring: 92 | return "" 93 | 94 | # Convert tabs to spaces (following the normal Python rules) 95 | # and split into a list of lines: 96 | lines = docstring.expandtabs().splitlines() 97 | # Determine minimum indentation (first line doesn't count): 98 | indent = 1000 99 | for line in lines[1:]: 100 | stripped = line.lstrip() 101 | if stripped: 102 | indent = min(indent, len(line) - len(stripped)) 103 | 104 | # Remove indentation (first line is special): 105 | trimmed = [lines[0].strip()] 106 | if indent < 1000: 107 | for line in lines[1:]: 108 | trimmed.append(line[indent:].rstrip()) 109 | 110 | # Strip off trailing and leading blank lines: 111 | while trimmed and not trimmed[-1]: 112 | trimmed.pop() 113 | while trimmed and not trimmed[0]: 114 | trimmed.pop(0) 115 | 116 | # Return a single string: 117 | return "\n".join(trimmed) 118 | 119 | 120 | class AutoDocProcessor(BlockProcessor): 121 | 122 | CLASSNAME = "autodoc" 123 | RE = re.compile(r"(?:^|\n)::: ?([:a-zA-Z0-9_.]*) *(?:\n|$)") 124 | RE_SPACES = re.compile(" +") 125 | 126 | def __init__(self, parser, md=None): 127 | super().__init__(parser=parser) 128 | self.md = md 129 | 130 | def test(self, parent: etree.Element, block: etree.Element) -> bool: 131 | sibling = self.lastChild(parent) 132 | return bool( 133 | self.RE.search(block) 134 | or ( 135 | block.startswith(" " * self.tab_length) 136 | and sibling is not None 137 | and sibling.get("class", "").find(self.CLASSNAME) != -1 138 | ) 139 | ) 140 | 141 | def run(self, parent: etree.Element, blocks: etree.Element) -> None: 142 | sibling = self.lastChild(parent) 143 | block = blocks.pop(0) 144 | m = self.RE.search(block) 145 | 146 | if m: 147 | block = block[m.end() :] # removes the first line 148 | 149 | block, theRest = self.detab(block) 150 | 151 | if m: 152 | import_string = m.group(1) 153 | item = import_from_string(import_string) 154 | 155 | autodoc_div = etree.SubElement(parent, "div") 156 | autodoc_div.set("class", self.CLASSNAME) 157 | 158 | self.render_signature(autodoc_div, item, import_string) 159 | for line in block.splitlines(): 160 | if line.startswith(":docstring:"): 161 | docstring = trim_docstring(item.__doc__) 162 | self.render_docstring(autodoc_div, item, docstring) 163 | elif line.startswith(":members:"): 164 | members = line.split()[1:] or None 165 | self.render_members(autodoc_div, item, members=members) 166 | 167 | if theRest: 168 | # This block contained unindented line(s) after the first indented 169 | # line. Insert these lines as the first block of the master blocks 170 | # list for future processing. 171 | blocks.insert(0, theRest) 172 | 173 | def render_signature( 174 | self, elem: etree.Element, item: typing.Any, import_string: str 175 | ) -> None: 176 | module_string, _, name_string = import_string.rpartition(".") 177 | 178 | # Eg: `some_module.attribute_name` 179 | signature_elem = etree.SubElement(elem, "div") 180 | signature_elem.set("class", "autodoc-signature") 181 | 182 | if inspect.isclass(item): 183 | qualifier_elem = etree.SubElement(signature_elem, "em") 184 | qualifier_elem.text = "class " 185 | elif inspect.iscoroutinefunction(item): 186 | qualifier_elem = etree.SubElement(signature_elem, "em") 187 | qualifier_elem.text = "async " 188 | 189 | name_elem = etree.SubElement(signature_elem, "code") 190 | if module_string: 191 | name_elem.text = module_string + "." 192 | main_name_elem = etree.SubElement(name_elem, "strong") 193 | main_name_elem.text = name_string 194 | 195 | # If this is a property, then we're done. 196 | if not callable(item): 197 | return 198 | 199 | # Eg: `(a, b='default', **kwargs)`` 200 | signature = inspect.signature(item) 201 | 202 | bracket_elem = etree.SubElement(signature_elem, "span") 203 | bracket_elem.text = "(" 204 | bracket_elem.set("class", "autodoc-punctuation") 205 | 206 | if signature.parameters: 207 | for param, is_last in last_iter(get_params(signature)): 208 | param_elem = etree.SubElement(signature_elem, "em") 209 | param_elem.text = param 210 | param_elem.set("class", "autodoc-param") 211 | 212 | if not is_last: 213 | comma_elem = etree.SubElement(signature_elem, "span") 214 | comma_elem.text = ", " 215 | comma_elem.set("class", "autodoc-punctuation") 216 | 217 | bracket_elem = etree.SubElement(signature_elem, "span") 218 | bracket_elem.text = ")" 219 | bracket_elem.set("class", "autodoc-punctuation") 220 | 221 | def render_docstring( 222 | self, elem: etree.Element, item: typing.Any, docstring: str 223 | ) -> None: 224 | docstring_elem = etree.SubElement(elem, "div") 225 | docstring_elem.set("class", "autodoc-docstring") 226 | 227 | md = Markdown(extensions=self.md.registeredExtensions) 228 | docstring_elem.text = md.convert(docstring) 229 | 230 | def render_members( 231 | self, elem: etree.Element, item: typing.Any, members: typing.List[str] = None 232 | ) -> None: 233 | members_elem = etree.SubElement(elem, "div") 234 | members_elem.set("class", "autodoc-members") 235 | 236 | if members is None: 237 | members = sorted([attr for attr in dir(item) if not attr.startswith("_")]) 238 | 239 | info_items = [] 240 | for attribute_name in members: 241 | attribute = getattr(item, attribute_name) 242 | docs = trim_docstring(getattr(attribute, "__doc__", "")) 243 | info = (attribute_name, docs) 244 | info_items.append(info) 245 | 246 | for attribute_name, docs in info_items: 247 | attribute = getattr(item, attribute_name) 248 | self.render_signature(members_elem, attribute, attribute_name) 249 | self.render_docstring(members_elem, attribute, docs) 250 | 251 | 252 | class MKAutoDocExtension(Extension): 253 | def extendMarkdown(self, md: Markdown) -> None: 254 | md.registerExtension(self) 255 | processor = AutoDocProcessor(md.parser, md=md) 256 | md.parser.blockprocessors.register(processor, "mkautodoc", 110) 257 | 258 | 259 | def makeExtension(): 260 | return MKAutoDocExtension() 261 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | 3 | # Testing 4 | black 5 | pytest 6 | pytest-cov 7 | -------------------------------------------------------------------------------- /scripts/clean: -------------------------------------------------------------------------------- 1 | #!/bin/sh -ex 2 | 3 | PROJECT=mkautodoc 4 | 5 | find ${PROJECT} -type f -name "*.py[co]" -delete 6 | find ${PROJECT} -type d -name __pycache__ -delete 7 | find tests -type d -name __pycache__ -delete 8 | rm -rf dist htmlcov .pytest_cache ${PROJECT}.egg-info 9 | -------------------------------------------------------------------------------- /scripts/install: -------------------------------------------------------------------------------- 1 | #!/bin/sh -ex 2 | 3 | if [ "${CONTINUOUS_INTEGRATION}" = "true" ]; then 4 | BIN_PATH="" 5 | else 6 | rm -rf venv 7 | python -m venv venv 8 | BIN_PATH="venv/bin/" 9 | fi 10 | 11 | ${BIN_PATH}pip install --upgrade pip 12 | ${BIN_PATH}pip install -r requirements.txt 13 | ${BIN_PATH}pip install -e . 14 | -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/bin/sh -ex 2 | 3 | PROJECT="mkautodoc" 4 | 5 | if [ -d "venv" ]; then 6 | BIN_PATH="venv/bin/" 7 | else 8 | BIN_PATH="" 9 | fi 10 | 11 | ${BIN_PATH}black ${PROJECT} tests "${@}" 12 | -------------------------------------------------------------------------------- /scripts/publish: -------------------------------------------------------------------------------- 1 | #!/bin/sh -ex 2 | 3 | PROJECT="mkautodoc" 4 | VERSION=`cat ${PROJECT}/__init__.py | grep __version__ | sed "s/__version__ = //" | sed "s/'//g"` 5 | 6 | if [ -d 'venv' ] ; then 7 | BIN_PATH="venv/bin/" 8 | else 9 | BIN_PATH="" 10 | fi 11 | 12 | if ! command -v "${BIN_PATH}twine" &>/dev/null ; then 13 | echo "Unable to find the 'twine' command." 14 | echo "Install from PyPI, using '${BIN_PATH}pip install twine'." 15 | exit 1 16 | fi 17 | 18 | scripts/clean 19 | 20 | ${BIN_PATH}python setup.py sdist 21 | ${BIN_PATH}twine upload dist/* 22 | # ${BIN_PATH}mkdocs gh-deploy 23 | 24 | scripts/clean 25 | 26 | echo "You probably want to also tag the version now:" 27 | echo "git tag -a ${VERSION} -m 'version ${VERSION}'" 28 | echo "git push --tags" 29 | -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/bin/sh -ex 2 | 3 | PROJECT="mkautodoc" 4 | export PYTHONPATH=tests/mocklib 5 | 6 | if [ -d 'venv' ] ; then 7 | BIN_PATH="venv/bin/" 8 | else 9 | BIN_PATH="" 10 | fi 11 | 12 | scripts/lint --check 13 | ${BIN_PATH}pytest tests --cov=${PROJECT} --cov=tests --cov-report= 14 | ${BIN_PATH}coverage html 15 | ${BIN_PATH}coverage report --show-missing 16 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import re 6 | import sys 7 | 8 | from setuptools import setup 9 | 10 | 11 | def get_version(package): 12 | """ 13 | Return package version as listed in `__version__` in `init.py`. 14 | """ 15 | init_py = open(os.path.join(package, '__init__.py')).read() 16 | return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) 17 | 18 | 19 | def get_packages(package): 20 | """ 21 | Return root package and all sub-packages. 22 | """ 23 | return [dirpath 24 | for dirpath, dirnames, filenames in os.walk(package) 25 | if os.path.exists(os.path.join(dirpath, '__init__.py'))] 26 | 27 | 28 | version = get_version('mkautodoc') 29 | 30 | 31 | setup( 32 | name='mkautodoc', 33 | version=version, 34 | url='https://github.com/tomchristie/mkautodoc', 35 | license='BSD', 36 | description='AutoDoc for MarkDown', 37 | author='Tom Christie', 38 | author_email='tom@tomchristie.com', 39 | packages=get_packages('mkautodoc'), 40 | install_requires=["Markdown"], 41 | python_requires='>=3.6', 42 | classifiers=[ 43 | 'Development Status :: 3 - Alpha', 44 | 'Intended Audience :: Developers', 45 | 'License :: OSI Approved :: BSD License', 46 | 'Operating System :: OS Independent', 47 | 'Programming Language :: Python :: 3', 48 | 'Programming Language :: Python :: 3.6', 49 | 'Programming Language :: Python :: 3.7', 50 | 'Programming Language :: Python :: 3.8', 51 | ], 52 | ) 53 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomchristie/mkautodoc/ea05ffe6f3fc073fe69b52d3a5b226c04675f83e/tests/__init__.py -------------------------------------------------------------------------------- /tests/assertions.py: -------------------------------------------------------------------------------- 1 | from xml import etree 2 | from xml.dom import minidom 3 | import textwrap 4 | 5 | 6 | def assert_xml_equal(xml_string, expected_xml_string): 7 | """ 8 | Assert equality of two xml strings, particularly that the contents of 9 | each string have the same elements, with the same attributes (e.g. class, 10 | text) and the same non-xml string contents 11 | """ 12 | # this prints a human-formatted string of what the test passed in -- useful 13 | # if you need to modify test expectations after you've modified 14 | # a rendering and tested it visually 15 | print(to_readable_error_output(xml_string)) 16 | 17 | assert_elements_equal( 18 | etree.ElementTree.fromstring(tostring(xml_string)), 19 | etree.ElementTree.fromstring(tostring(expected_xml_string)), 20 | ) 21 | 22 | 23 | def assert_elements_equal(element, reference_element): 24 | """ 25 | Assert, recursively, the equality of two etree objects. 26 | """ 27 | assert ( 28 | element.text == reference_element.text 29 | ), f"Text doesn't match: {element.text} =/= {reference_element.text}." 30 | assert ( 31 | element.attrib == reference_element.attrib 32 | ), f"Attrib doesn't match: {element.attrib} =/= {reference_element.attrib}" 33 | assert len(element) == len( 34 | reference_element 35 | ), f"Expected {len(reference_element)} children but got {len(element)}" 36 | for sub_element, reference_sub_element in zip(element, reference_element): 37 | assert_elements_equal(sub_element, reference_sub_element) 38 | 39 | 40 | def tostring(xml_string): 41 | """ 42 | Wraps `xml_string` in a div so it can be rendered, even if it has multiple roots. 43 | """ 44 | return remove_indents(f"
{remove_indents(xml_string)}
").encode("utf-8") 45 | 46 | 47 | def to_readable_error_output(xml_string): 48 | return textwrap.dedent( 49 | "\n".join( 50 | minidom.parseString(tostring(xml_string)) 51 | .toprettyxml(indent=" ") 52 | .split("\n")[2:-2] # remove xml declaration and div added by `tostring` 53 | ) 54 | ) # dent by " " 55 | 56 | 57 | def remove_indents(html): 58 | """ 59 | Remove leading whitespace from a string 60 | 61 | e.g. 62 | input: output: 63 | .
.
64 | .

Some Text

.

Some Text

65 | .
.
66 | . Some more text . Some more text 67 | .
.
68 | .
.
69 | """ 70 | lines = [el.lstrip() for el in html.split("\n")] 71 | return "".join([el for el in lines if el or el != "\n"]) 72 | -------------------------------------------------------------------------------- /tests/mocklib/mocklib.py: -------------------------------------------------------------------------------- 1 | def example_function(a, b=None, *args, **kwargs): 2 | """ 3 | This is a function with a *docstring*. 4 | """ 5 | 6 | 7 | class ExampleClass: 8 | """ 9 | This is a class with a *docstring*. 10 | """ 11 | 12 | def __init__(self): 13 | """ 14 | This is an __init__ with a *docstring*. 15 | """ 16 | 17 | def example_method(self, a, b=None): 18 | """ 19 | This is a method with a *docstring*. 20 | """ 21 | 22 | @property 23 | def example_property(self): 24 | """ 25 | This is a property with a *docstring*. 26 | """ 27 | 28 | 29 | async def example_async_function(): 30 | """ 31 | This is a coroutine function as can be seen by the *async* keyword. 32 | """ 33 | -------------------------------------------------------------------------------- /tests/test_extension.py: -------------------------------------------------------------------------------- 1 | import markdown 2 | from .assertions import assert_xml_equal 3 | 4 | 5 | def test_docstring(): 6 | content = """ 7 | # Example 8 | 9 | ::: mocklib.example_function 10 | :docstring: 11 | """ 12 | output = markdown.markdown(content, extensions=["mkautodoc"]) 13 | assert_xml_equal( 14 | output, 15 | """ 16 |

Example

17 |
18 |
19 | 20 | mocklib. 21 | example_function 22 | 23 | ( 24 | a 25 | , 26 | b=None 27 | , 28 | *args 29 | , 30 | **kwargs 31 | ) 32 |
33 |
34 |

35 | This is a function with a docstring. 36 |

37 |
38 |
""", 39 | ) 40 | 41 | 42 | def test_async_function(): 43 | content = """ 44 | ::: mocklib.example_async_function 45 | """ 46 | output = markdown.markdown(content, extensions=["mkautodoc"]) 47 | assert_xml_equal( 48 | output, 49 | """ 50 |
51 |
52 | async 53 | 54 | mocklib. 55 | example_async_function 56 | 57 | ( 58 | ) 59 |
60 |
""", 61 | ) 62 | 63 | 64 | def test_members(): 65 | content = """ 66 | # Example 67 | 68 | ::: mocklib.ExampleClass 69 | :docstring: 70 | :members: 71 | """ 72 | output = markdown.markdown(content, extensions=["mkautodoc"]) 73 | assert_xml_equal( 74 | output, 75 | """ 76 |

Example

77 |
78 |
79 | class 80 | 81 | mocklib. 82 | ExampleClass 83 | 84 | ( 85 | ) 86 |
87 |
88 |

89 | This is a class with a docstring. 90 |

91 |
92 |
93 |
94 | 95 | example_method 96 | 97 | ( 98 | self 99 | , 100 | a 101 | , 102 | b=None 103 | ) 104 |
105 |
106 |

107 | This is a method with a docstring. 108 |

109 |
110 |
111 | 112 | example_property 113 | 114 |
115 |
116 |

117 | This is a property with a docstring. 118 |

119 |
120 |
121 |
""", 122 | ) 123 | --------------------------------------------------------------------------------