pax_global_header00006660000000000000000000000064142724353450014523gustar00rootroot0000000000000052 comment=ad41abc4a94c4d63347e808ce6ca1dc0fb800bfb mkautodoc-0.2.0/000077500000000000000000000000001427243534500135105ustar00rootroot00000000000000mkautodoc-0.2.0/.gitignore000066400000000000000000000000661427243534500155020ustar00rootroot00000000000000*.pyc *.egg-info .coverage .pytest_cache htmlcov venv mkautodoc-0.2.0/.travis.yml000066400000000000000000000002271427243534500156220ustar00rootroot00000000000000dist: xenial language: python cache: pip python: - "3.6" - "3.7" - "3.8-dev" install: - scripts/install script: - scripts/test mkautodoc-0.2.0/README.md000066400000000000000000000033731427243534500147750ustar00rootroot00000000000000# MkAutoDoc Python API documention for MkDocs. This markdown extension adds `autodoc` style support, for use with MkDocs. ![aIAgAAjQpG](https://user-images.githubusercontent.com/647359/66651320-a276ff80-ec2a-11e9-9cec-9eba425d5304.gif) ## Usage #### 1. Include the extension in you `mkdocs.yml` config file: ```yaml [...] markdown_extensions: - admonition - codehilite - mkautodoc ``` #### 2. Ensure the library you want to document is importable. This will depend on how your documentation building is setup, but you may need to use `pip install -e .` or modify `PYTHONPATH` in your docs build script. #### 3. Use the `:::` block syntax to add autodoc blocks to your documentation. ```markdown # API documentation ::: my_library.some_function :docstring: ::: my_library.SomeClass :docstring: :members: ``` #### 4. Optionally, add styling for the API docs Update your `mkdocs.yml` to include some custom CSS. ```yaml [...] extra_css: - css/custom.css ``` Then add a `css/custom.css` file to your documentation. ```css div.autodoc-docstring { padding-left: 20px; margin-bottom: 30px; border-left: 5px solid rgba(230, 230, 230); } div.autodoc-members { padding-left: 20px; margin-bottom: 15px; } ``` ## Notes #### The :docstring: declaration. Renders the docstring of the associated function, method, or class. #### The `:members:` declaration. Renders documentation for member attributes of the associated class. Currently handles methods and properties. Instance attributes set during `__init__` are not currently recognised. May optionally accept a list of member attributes that should be documented. For example: ```markdown ::: my_library.SomeClass :docstring: :members: currency vat_registered calculate_expenses ``` mkautodoc-0.2.0/mkautodoc/000077500000000000000000000000001427243534500154765ustar00rootroot00000000000000mkautodoc-0.2.0/mkautodoc/__init__.py000066400000000000000000000002031427243534500176020ustar00rootroot00000000000000from .extension import MKAutoDocExtension, makeExtension __version__ = "0.2.0" __all__ = ["MKAutoDocExtension", "makeExtension"] mkautodoc-0.2.0/mkautodoc/extension.py000066400000000000000000000211761427243534500200730ustar00rootroot00000000000000from markdown import Markdown from markdown.extensions import Extension from markdown.blockprocessors import BlockProcessor from xml.etree import ElementTree as etree import importlib import inspect import re import typing # Fuzzy regex for determining source lines in __init__ that look like # attribute assignments. Eg. `self.counter = 0` SET_ATTRIBUTE = re.compile("^([ \t]*)self[.]([A-Za-z0-9_]+) *=") def import_from_string(import_str: str) -> typing.Any: module_str, _, attr_str = import_str.rpartition(".") try: module = importlib.import_module(module_str) except ImportError as exc: module_name = module_str.split(".", 1)[0] if exc.name != module_name: raise exc from None raise ValueError(f"Could not import module {module_str!r}.") try: return getattr(module, attr_str) except AttributeError as exc: raise ValueError(f"Attribute {attr_str!r} not found in module {module_str!r}.") def get_params(signature: inspect.Signature) -> typing.List[str]: """ Given a function signature, return a list of parameter strings to use in documentation. Eg. test(a, b=None, **kwargs) -> ['a', 'b=None', '**kwargs'] """ params = [] render_pos_only_separator = True render_kw_only_separator = True for parameter in signature.parameters.values(): value = parameter.name if parameter.default is not parameter.empty: value = f"{value}={parameter.default!r}" if parameter.kind is parameter.VAR_POSITIONAL: render_kw_only_separator = False value = f"*{value}" elif parameter.kind is parameter.VAR_KEYWORD: value = f"**{value}" elif parameter.kind is parameter.POSITIONAL_ONLY: if render_pos_only_separator: render_pos_only_separator = False params.append("/") elif parameter.kind is parameter.KEYWORD_ONLY: if render_kw_only_separator: render_kw_only_separator = False params.append("*") params.append(value) return params def last_iter(seq: typing.Sequence) -> typing.Iterator: """ Given a sequence, return a two-tuple (item, is_last) iterable. See: https://stackoverflow.com/a/1633483/596689 """ it = iter(seq) item = next(it) is_last = False for next_item in it: yield item, is_last item = next_item is_last = True yield item, is_last def trim_docstring(docstring: typing.Optional[str]) -> str: """ Trim leading indent from a docstring. See: https://www.python.org/dev/peps/pep-0257/#handling-docstring-indentation """ if not docstring: return "" # Convert tabs to spaces (following the normal Python rules) # and split into a list of lines: lines = docstring.expandtabs().splitlines() # Determine minimum indentation (first line doesn't count): indent = 1000 for line in lines[1:]: stripped = line.lstrip() if stripped: indent = min(indent, len(line) - len(stripped)) # Remove indentation (first line is special): trimmed = [lines[0].strip()] if indent < 1000: for line in lines[1:]: trimmed.append(line[indent:].rstrip()) # Strip off trailing and leading blank lines: while trimmed and not trimmed[-1]: trimmed.pop() while trimmed and not trimmed[0]: trimmed.pop(0) # Return a single string: return "\n".join(trimmed) class AutoDocProcessor(BlockProcessor): CLASSNAME = "autodoc" RE = re.compile(r"(?:^|\n)::: ?([:a-zA-Z0-9_.]*) *(?:\n|$)") RE_SPACES = re.compile(" +") def __init__(self, parser, md=None): super().__init__(parser=parser) self.md = md def test(self, parent: etree.Element, block: etree.Element) -> bool: sibling = self.lastChild(parent) return bool( self.RE.search(block) or ( block.startswith(" " * self.tab_length) and sibling is not None and sibling.get("class", "").find(self.CLASSNAME) != -1 ) ) def run(self, parent: etree.Element, blocks: etree.Element) -> None: sibling = self.lastChild(parent) block = blocks.pop(0) m = self.RE.search(block) if m: block = block[m.end() :] # removes the first line block, theRest = self.detab(block) if m: import_string = m.group(1) item = import_from_string(import_string) autodoc_div = etree.SubElement(parent, "div") autodoc_div.set("class", self.CLASSNAME) self.render_signature(autodoc_div, item, import_string) for line in block.splitlines(): if line.startswith(":docstring:"): docstring = trim_docstring(item.__doc__) self.render_docstring(autodoc_div, item, docstring) elif line.startswith(":members:"): members = line.split()[1:] or None self.render_members(autodoc_div, item, members=members) if theRest: # This block contained unindented line(s) after the first indented # line. Insert these lines as the first block of the master blocks # list for future processing. blocks.insert(0, theRest) def render_signature( self, elem: etree.Element, item: typing.Any, import_string: str ) -> None: module_string, _, name_string = import_string.rpartition(".") # Eg: `some_module.attribute_name` signature_elem = etree.SubElement(elem, "div") signature_elem.set("class", "autodoc-signature") if inspect.isclass(item): qualifier_elem = etree.SubElement(signature_elem, "em") qualifier_elem.text = "class " elif inspect.iscoroutinefunction(item): qualifier_elem = etree.SubElement(signature_elem, "em") qualifier_elem.text = "async " name_elem = etree.SubElement(signature_elem, "code") if module_string: name_elem.text = module_string + "." main_name_elem = etree.SubElement(name_elem, "strong") main_name_elem.text = name_string # If this is a property, then we're done. if not callable(item): return # Eg: `(a, b='default', **kwargs)`` signature = inspect.signature(item) bracket_elem = etree.SubElement(signature_elem, "span") bracket_elem.text = "(" bracket_elem.set("class", "autodoc-punctuation") if signature.parameters: for param, is_last in last_iter(get_params(signature)): param_elem = etree.SubElement(signature_elem, "em") param_elem.text = param param_elem.set("class", "autodoc-param") if not is_last: comma_elem = etree.SubElement(signature_elem, "span") comma_elem.text = ", " comma_elem.set("class", "autodoc-punctuation") bracket_elem = etree.SubElement(signature_elem, "span") bracket_elem.text = ")" bracket_elem.set("class", "autodoc-punctuation") def render_docstring( self, elem: etree.Element, item: typing.Any, docstring: str ) -> None: docstring_elem = etree.SubElement(elem, "div") docstring_elem.set("class", "autodoc-docstring") md = Markdown(extensions=self.md.registeredExtensions) docstring_elem.text = md.convert(docstring) def render_members( self, elem: etree.Element, item: typing.Any, members: typing.List[str] = None ) -> None: members_elem = etree.SubElement(elem, "div") members_elem.set("class", "autodoc-members") if members is None: members = sorted([attr for attr in dir(item) if not attr.startswith("_")]) info_items = [] for attribute_name in members: attribute = getattr(item, attribute_name) docs = trim_docstring(getattr(attribute, "__doc__", "")) info = (attribute_name, docs) info_items.append(info) for attribute_name, docs in info_items: attribute = getattr(item, attribute_name) self.render_signature(members_elem, attribute, attribute_name) self.render_docstring(members_elem, attribute, docs) class MKAutoDocExtension(Extension): def extendMarkdown(self, md: Markdown) -> None: md.registerExtension(self) processor = AutoDocProcessor(md.parser, md=md) md.parser.blockprocessors.register(processor, "mkautodoc", 110) def makeExtension(): return MKAutoDocExtension() mkautodoc-0.2.0/requirements.txt000066400000000000000000000000501427243534500167670ustar00rootroot00000000000000-e . # Testing black pytest pytest-cov mkautodoc-0.2.0/scripts/000077500000000000000000000000001427243534500151775ustar00rootroot00000000000000mkautodoc-0.2.0/scripts/clean000077500000000000000000000003501427243534500162050ustar00rootroot00000000000000#!/bin/sh -ex PROJECT=mkautodoc find ${PROJECT} -type f -name "*.py[co]" -delete find ${PROJECT} -type d -name __pycache__ -delete find tests -type d -name __pycache__ -delete rm -rf dist htmlcov .pytest_cache ${PROJECT}.egg-info mkautodoc-0.2.0/scripts/install000077500000000000000000000004071427243534500165740ustar00rootroot00000000000000#!/bin/sh -ex if [ "${CONTINUOUS_INTEGRATION}" = "true" ]; then BIN_PATH="" else rm -rf venv python -m venv venv BIN_PATH="venv/bin/" fi ${BIN_PATH}pip install --upgrade pip ${BIN_PATH}pip install -r requirements.txt ${BIN_PATH}pip install -e . mkautodoc-0.2.0/scripts/lint000077500000000000000000000002261427243534500160730ustar00rootroot00000000000000#!/bin/sh -ex PROJECT="mkautodoc" if [ -d "venv" ]; then BIN_PATH="venv/bin/" else BIN_PATH="" fi ${BIN_PATH}black ${PROJECT} tests "${@}" mkautodoc-0.2.0/scripts/publish000077500000000000000000000012071427243534500165730ustar00rootroot00000000000000#!/bin/sh -ex PROJECT="mkautodoc" VERSION=`cat ${PROJECT}/__init__.py | grep __version__ | sed "s/__version__ = //" | sed "s/'//g"` if [ -d 'venv' ] ; then BIN_PATH="venv/bin/" else BIN_PATH="" fi if ! command -v "${BIN_PATH}twine" &>/dev/null ; then echo "Unable to find the 'twine' command." echo "Install from PyPI, using '${BIN_PATH}pip install twine'." exit 1 fi scripts/clean ${BIN_PATH}python setup.py sdist ${BIN_PATH}twine upload dist/* # ${BIN_PATH}mkdocs gh-deploy scripts/clean echo "You probably want to also tag the version now:" echo "git tag -a ${VERSION} -m 'version ${VERSION}'" echo "git push --tags" mkautodoc-0.2.0/scripts/test000077500000000000000000000004511427243534500161040ustar00rootroot00000000000000#!/bin/sh -ex PROJECT="mkautodoc" export PYTHONPATH=tests/mocklib if [ -d 'venv' ] ; then BIN_PATH="venv/bin/" else BIN_PATH="" fi scripts/lint --check ${BIN_PATH}pytest tests --cov=${PROJECT} --cov=tests --cov-report= ${BIN_PATH}coverage html ${BIN_PATH}coverage report --show-missing mkautodoc-0.2.0/setup.py000066400000000000000000000025431427243534500152260ustar00rootroot00000000000000#!/usr/bin/env python # -*- coding: utf-8 -*- import os import re import sys from setuptools import setup def get_version(package): """ Return package version as listed in `__version__` in `init.py`. """ init_py = open(os.path.join(package, '__init__.py')).read() return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) def get_packages(package): """ Return root package and all sub-packages. """ return [dirpath for dirpath, dirnames, filenames in os.walk(package) if os.path.exists(os.path.join(dirpath, '__init__.py'))] version = get_version('mkautodoc') setup( name='mkautodoc', version=version, url='https://github.com/tomchristie/mkautodoc', license='BSD', description='AutoDoc for MarkDown', author='Tom Christie', author_email='tom@tomchristie.com', packages=get_packages('mkautodoc'), install_requires=["Markdown"], python_requires='>=3.6', classifiers=[ 'Development Status :: 3 - Alpha', 'Intended Audience :: Developers', 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', ], ) mkautodoc-0.2.0/tests/000077500000000000000000000000001427243534500146525ustar00rootroot00000000000000mkautodoc-0.2.0/tests/__init__.py000066400000000000000000000000001427243534500167510ustar00rootroot00000000000000mkautodoc-0.2.0/tests/assertions.py000066400000000000000000000046511427243534500174240ustar00rootroot00000000000000from xml import etree from xml.dom import minidom import textwrap def assert_xml_equal(xml_string, expected_xml_string): """ Assert equality of two xml strings, particularly that the contents of each string have the same elements, with the same attributes (e.g. class, text) and the same non-xml string contents """ # this prints a human-formatted string of what the test passed in -- useful # if you need to modify test expectations after you've modified # a rendering and tested it visually print(to_readable_error_output(xml_string)) assert_elements_equal( etree.ElementTree.fromstring(tostring(xml_string)), etree.ElementTree.fromstring(tostring(expected_xml_string)), ) def assert_elements_equal(element, reference_element): """ Assert, recursively, the equality of two etree objects. """ assert ( element.text == reference_element.text ), f"Text doesn't match: {element.text} =/= {reference_element.text}." assert ( element.attrib == reference_element.attrib ), f"Attrib doesn't match: {element.attrib} =/= {reference_element.attrib}" assert len(element) == len( reference_element ), f"Expected {len(reference_element)} children but got {len(element)}" for sub_element, reference_sub_element in zip(element, reference_element): assert_elements_equal(sub_element, reference_sub_element) def tostring(xml_string): """ Wraps `xml_string` in a div so it can be rendered, even if it has multiple roots. """ return remove_indents(f"
{remove_indents(xml_string)}
").encode("utf-8") def to_readable_error_output(xml_string): return textwrap.dedent( "\n".join( minidom.parseString(tostring(xml_string)) .toprettyxml(indent=" ") .split("\n")[2:-2] # remove xml declaration and div added by `tostring` ) ) # dent by " " def remove_indents(html): """ Remove leading whitespace from a string e.g. input: output: .
.
.

Some Text

.

Some Text

.
.
. Some more text . Some more text .
.
.
.
""" lines = [el.lstrip() for el in html.split("\n")] return "".join([el for el in lines if el or el != "\n"]) mkautodoc-0.2.0/tests/mocklib/000077500000000000000000000000001427243534500162725ustar00rootroot00000000000000mkautodoc-0.2.0/tests/mocklib/mocklib.py000066400000000000000000000011771427243534500202720ustar00rootroot00000000000000def example_function(a, b=None, *args, **kwargs): """ This is a function with a *docstring*. """ class ExampleClass: """ This is a class with a *docstring*. """ def __init__(self): """ This is an __init__ with a *docstring*. """ def example_method(self, a, b=None): """ This is a method with a *docstring*. """ @property def example_property(self): """ This is a property with a *docstring*. """ async def example_async_function(): """ This is a coroutine function as can be seen by the *async* keyword. """ mkautodoc-0.2.0/tests/test_extension.py000066400000000000000000000055321427243534500203040ustar00rootroot00000000000000import markdown from .assertions import assert_xml_equal def test_docstring(): content = """ # Example ::: mocklib.example_function :docstring: """ output = markdown.markdown(content, extensions=["mkautodoc"]) assert_xml_equal( output, """

Example

mocklib. example_function ( a , b=None , *args , **kwargs )

This is a function with a docstring.

""", ) def test_async_function(): content = """ ::: mocklib.example_async_function """ output = markdown.markdown(content, extensions=["mkautodoc"]) assert_xml_equal( output, """
async mocklib. example_async_function ( )
""", ) def test_members(): content = """ # Example ::: mocklib.ExampleClass :docstring: :members: """ output = markdown.markdown(content, extensions=["mkautodoc"]) assert_xml_equal( output, """

Example

class mocklib. ExampleClass ( )

This is a class with a docstring.

example_method ( self , a , b=None )

This is a method with a docstring.

example_property

This is a property with a docstring.

""", )