Skip to content

Instantly share code, notes, and snippets.

@earonesty
Last active March 4, 2022 16:55
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save earonesty/9592c694ea670b66b070a1a72d7d9397 to your computer and use it in GitHub Desktop.
Save earonesty/9592c694ea670b66b070a1a72d7d9397 to your computer and use it in GitHub Desktop.
"""
Generate api-style github markdown-files from python docstrings.
Example:
qpydoc my_module > API.md
"""
import re
import sys
import importlib
import inspect
import os
import argparse
import pathlib
import logging as log
import textwrap
from typing import IO, Generic
log.basicConfig()
class MdGen:
def __init__(self, output_dir=None, source_url=None):
self.source_url = source_url
self.source_path = None
if output_dir:
self.dir = pathlib.Path(output_dir)
self.dir.mkdir(exist_ok=True)
self.module_links = True
else:
self.dir = None
self.module_links = False
self.seen = set()
@staticmethod
def __module_name_to_md(module_name):
return module_name.replace(".", "_") + ".md"
def __get_output_file(self, module_name):
if self.dir:
path = self.dir / self.__module_name_to_md(module_name)
return path.open("w")
else:
return sys.stdout
@staticmethod
def import_module(name):
return importlib.import_module(name)
@staticmethod
def __dedent(doc):
first_dent = re.match("[^ ][^\n]+\r?\n+( {2,})", doc)
if first_dent:
# we assume you mean for the first line to be "dedented" along with the next
doc = first_dent[1] + doc
doc = textwrap.dedent(doc)
return doc
def section(self, file: IO, level, name, text, link):
text = text and text.strip()
if not text:
return
hash_level = "#" * level
if "." in name and link:
parent_name, child_name = name.rsplit(".", 1)
if self.module_links and link:
parent_link = self.__module_name_to_md(parent_name)
print(f"{hash_level} [{parent_name}]({parent_link}).{child_name}", file=file)
else:
parent_link = "#" + parent_name.replace(".", "_")
print(f"{hash_level} [{parent_name}]({parent_link}).{child_name}", file=file)
else:
print(f"{hash_level} {name}", file=file)
print(self.__dedent(text), file=file)
print("\n", file=file)
def get_kids(self, ent, base_name):
pub = getattr(ent, "__all__", None)
if not pub:
pub = []
for name in ent.__dict__:
if name.startswith("_"):
continue
pub.append(name)
pub = sorted(pub)
res = []
for name in pub:
obj = getattr(ent, name, None)
if obj is not None:
path = name
if base_name:
path = base_name + "." + name
if path in self.seen:
log.debug("filter seen: %s", path)
continue
self.seen.add(path)
res.append((name, obj))
return res
def func_gen(self, file: IO, func, path):
doc = getattr(func, "__doc__")
if not doc:
return
sig = inspect.signature(func)
print("####", path + str(sig), file=file)
print(self.__dedent(doc), file=file)
print(file=file)
def class_gen(self, file: IO, cls, name):
params = getattr(cls, "__parameters__", None)
show_name = name
bs = []
for b in cls.__bases__:
if b != Generic:
bs.append(b.__name__)
if bs:
show_name += '(' + ",".join(bs) + ")"
if params:
pnames = []
for p in params:
pname = p.__name__
bound = p.__bound__
if bound:
bound = getattr(bound, "__name__", getattr(bound, "__forward_arg__", ""))
pname = pname + "=" + bound
pnames += [pname]
show_name = show_name + " [" + ",".join(pnames) + "]"
self.section(file, 2, show_name, getattr(cls, "__doc__"), False)
for path, ent in self.get_kids(cls, name):
if inspect.isfunction(ent):
self.func_gen(file, ent, "." + path)
def module_gen(self, mod) -> str:
name = mod.__name__
control = getattr(mod, "__autodoc__", True)
if not control:
return ""
file = self.__get_output_file(name)
parentpath = pathlib.Path(os.path.dirname(mod.__file__))
if not self.source_path:
log.debug("set source path: %s", parentpath)
self.source_path = parentpath
mod_doc = getattr(mod, "__doc__")
self.section(file, 1, name, mod_doc, True)
if self.source_url:
source_link = self.source_url.strip("/") + "/" + os.path.relpath(mod.__file__, self.source_path)
print(f"[(view source)]({source_link})", file=file)
funcs = []
for path, ent in self.get_kids(mod, name):
log.debug("mod: %s, kid: %s", name, path)
if inspect.isclass(ent):
if ent.__module__ == mod.__name__:
self.class_gen(file, ent, path)
if inspect.isfunction(ent):
if ent.__module__ == mod.__name__:
funcs.append((path, ent))
if inspect.ismodule(ent):
filepath = getattr(ent, "__file__", "")
childpath = pathlib.Path(filepath)
if parentpath in childpath.parents:
sub_name = self.module_gen(ent)
if sub_name and self.module_links:
sub_file = self.__module_name_to_md(sub_name)
print(f" - [{sub_name}]({sub_file})", file=file)
if funcs:
print("## Functions:\n", file=file)
for path, ent in funcs:
self.func_gen(file, ent, path)
if file != sys.stdout:
file.close()
return name
def parse_args():
parser = argparse.ArgumentParser(description="Generate markdown from a python module")
parser.add_argument(
"module",
help="Python import path for a module that contains code to document.",
)
parser.add_argument("--debug", help="Debug output", action="store_true", default=False)
parser.add_argument("--out", "-o", help="Output folder", action="store", default=None)
parser.add_argument("--src", "-u", help="Source url, for generating source links", action="store", default=None)
return parser.parse_args()
def main():
args = parse_args()
if args.debug:
log.getLogger().setLevel(log.DEBUG)
d = MdGen(output_dir=args.out, source_url=args.src)
mod = d.import_module(args.module)
d.module_gen(mod)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment