├── .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 |  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"
Some Text
.Some Text
65 | .
20 | mocklib.
21 | example_function
22 |
23 | (
24 | a
25 | ,
26 | b=None
27 | ,
28 | *args
29 | ,
30 | **kwargs
31 | )
32 | 35 | This is a function with a docstring. 36 |
37 |
54 | mocklib.
55 | example_async_function
56 |
57 | (
58 | )
59 |
81 | mocklib.
82 | ExampleClass
83 |
84 | (
85 | )
86 | 89 | This is a class with a docstring. 90 |
91 |
95 | example_method
96 |
97 | (
98 | self
99 | ,
100 | a
101 | ,
102 | b=None
103 | )
104 | 107 | This is a method with a docstring. 108 |
109 |
112 | example_property
113 |
114 | 117 | This is a property with a docstring. 118 |
119 |