dycco.py |
|
---|---|
Dycco is another Python port of Docco, the quick-and-dirty, hundred-line-long, literate-programming-style documentation generator. This particular version has been updated to work with Python 3 (as of 2022). This version allows output to a markdown file or to an asciidoc3 file, as well as adding a option to sanitize internal HTML (which is handy if your code includes html fragments). Dycco reads Python source files and produces annotated source documentation in HTML format. Comments and docstrings are formatted with Markdown or with AsciiDoc3 and presented as annotations alongside the source code, which is syntax-highlighted by Pygments. This page is the result of running Dycco against its own source file. Dycco differs from Nick Fitzgerald's Pycco (new version), the first Python port of Docco, in that it only knows how to generate documenation on Python source code and it uses that specialization to more accurately parse documentation. It does so using a two-pass parsing stage, first walking the Abstract Syntax Tree of the code to gather up docstrings, then examining the code line-by-line to extract comments. Dycco's HTML and CSS are taken straight from Docco, but, like Pycco, Dycco uses Mustache templates rendered by Pystache. The first version of Dycco's templates and CSS were taken straight from Pycco, then updated to match the latest changes to Docco's. |
|
import ast
import datetime
import os
import re
import shutil
import io
from collections import defaultdict
import html
import markdown
import pystache
from pygments import highlight
from pygments.lexers import get_lexer_by_name
from pygments.formatters import HtmlFormatter
|
|
We have to muck about a bit because of asciidoc3's strange behaviour See: AttributeError: module 'asciidoc3' has no attribute 'messages' for the explanation |
import importlib.util
ascii_location = None
ascii_module = importlib.util.find_spec('asciidoc3')
if ascii_module:
|
We found a version of asciidoc3, so record where it is for later use by |
ascii_location = ascii_module.submodule_search_locations[0] + '/asciidoc3.py'
import asciidoc3.asciidoc3api as AsciiDoc3API
COMMENT_PATTERN = r'^\s*#'
DYCCO_ROOT = os.path.dirname(__file__)
DYCCO_RESOURCES = os.path.join(DYCCO_ROOT, 'resources')
DYCCO_TEMPLATE = os.path.join(DYCCO_RESOURCES, 'template.html')
DYCCO_CSS = os.path.join(DYCCO_RESOURCES, 'dycco.css')
|
Documentation Generation |
|
Generates documentation for the Python files at the given The Usually, markdown() is called, but if If escape_html is True, we use Python's
|
def document(input_paths, output_dir, use_ascii:bool = False, escape_html:bool = False,
single_file:bool = False):
|
If we get a single path, stick it in a list so we can still pretend we're operating on multiple paths. |
if isinstance(input_paths, str):
input_paths = [input_paths]
|
Make sure the directory exists |
if not os.path.exists(output_dir) or not os.path.isdir(output_dir):
os.makedirs(output_dir)
|
Parse each input file into sections, render the sections as HTML into a
string, and create or overwrite the documentation at the appropriate
output path. We have a default extension of |
extension = 'html'
if single_file:
|
...but if we are wanting a single file, use Markdown's or Asciidoc3's extensions |
extension = 'adoc' if use_ascii else 'md'
for input_path in input_paths:
filename = os.path.basename(input_path)
output_path = make_output_path(filename, output_dir, extension)
with open(input_path) as f_input:
src = f_input.read()
sections = parse(src)
output_body = render(filename, sections, use_ascii, escape_html, single_file)
with open(output_path, 'w') as f_output:
f_output.write(output_body)
|
Copy the CSS file into the output directory |
shutil.copy(DYCCO_CSS, output_dir)
|
Parsing the Source |
|
Parse the given source code in two passes. The first pass walks the Abstract Syntax Tree of the code, gathering up and noting the location of any docstrings. The second pass processes the code line by line, grouping the code into sections based on docstrings and comments. The data structure returned is a special It will look a little like this:
The docs for each section can come from docstrings (the first pass) or from comments (the second pass). The line numbers start at zero, for simplicity's sake. |
def parse(src:str) -> defaultdict:
|
Create the basic |
sections = make_sections()
|
First, parse all of the docstrings and get a list (set) of lines we should
skip when parsing the rest of the code. Modifies |
skip_lines = parse_docstrings(src, sections)
|
Second, parse the rest of the code, adding code and comments to the
appropriate sections. Modifies |
parse_code(src, sections, skip_lines)
return sections
|
First Pass |
|
Parse the given |
def parse_docstrings(src:str, sections:defaultdict) -> set:
|
Find any docstrings in the source code by walking its AST. |
visitor = DocStringVisitor()
visitor.visit(ast.parse(src))
|
Add all of the docstrings we've found to the appropriate places in the
|
for target_line, doc in visitor.docstrings.items():
sections[target_line]['docs'].append(doc)
return visitor.docstring_lines
|
Second Pass |
|
Parse the given |
def parse_code(src:str, sections:defaultdict, skip_lines:set):
|
Iterate through each line of source code to gather up comments and code listings and add them to the sections structure. |
current_comment = None
current_section = None
for i, line in enumerate(src.splitlines()):
|
Skip any lines that were in docstrings |
if i in skip_lines or should_filter(line, i):
continue
|
Are we looking at a comment? If so, and we do not have a current comment block, we're starting a new section. If we do have a current comment block, we just add this comment to it (e.g. multi-line comments). |
if re.match(COMMENT_PATTERN, line):
comment = re.sub(COMMENT_PATTERN, '', line)
if current_comment is None:
current_comment = comment
else:
current_comment += '\n' + comment
|
Otherwise, we're looking at a line of code and we need to add it to the appropriate section, along with any preceding comments. |
else:
|
If we have a current comment, that means we're starting a new section with this line of code. |
if current_comment:
current_comment = current_comment.strip()
docs = sections[i]['docs']
|
If we've already got docs for this section, that (hopefully) means we're looking at a function/class def that has a docstring, but that the current comments precede the def. In this case, we prepend the comments, so they come before the docstring. |
if docs:
docs.insert(0, current_comment)
else:
docs.append(current_comment)
|
The next comment we encounter will start a new section, but any lines of code that follow this one belong to this section. |
current_comment = None
current_section = i
|
We don't have a current section, so we should be at our first bit of code (aside from any module-level docstrings), and should start a new section. But we want to skip any empty leading blank lines. |
elif current_section is None and line:
current_section = i
|
If the current line is already in the |
if i in sections:
current_section = i
|
Finally, append the current line of code to the current section's code block. Skips any empty leading lines of code, which will not have a current section. |
if current_section is not None:
sections[current_section]['code'].append(line)
|
DecoratorsNow we need to jiggle any decorators about - they should not be at the end of sections, but at the start of the next code section (however, sometimes they get placed at the end). |
|
Get ourselves an ordered list of the possible section numbers so that we can easily find the 'next' greater section-number |
section_numbers = sorted(sections.keys())
|
We'll now have an ordered list of just the section numbers like |
for this_section_number in section_numbers[:-1]:
|
Get what would be the next section number: find where we are and add one to it
so, if we were |
next_index = section_numbers.index(this_section_number) + 1
|
And then we want the value that's there ( |
next_section_number = section_numbers[next_index]
|
We move back 'up' the code content, moving any trailing decorators to the next section. |
content = sections[this_section_number]['code']
for line in reversed(content):
|
Bail out if there are none, or we've used them all up... |
if not line.strip().startswith('@'):
break
|
...Otherwise, remove the last entry from our section's code... |
s = sections[this_section_number]['code'].pop()
|
...and add it to the start of the next section's code - doing it this way also preserves the order of the decorators. |
sections[next_section_number]['code'].insert(0, s)
|
Rendering |
|
Renders the given sections, which should be the result of calling
If |
def render(title:str, sections:defaultdict, use_ascii:bool = False,
escape_html:bool = False, single_file:bool = False) -> str:
|
Transform the |
sections = [{
'num': key,
'docs_html': preprocess_docs(value['docs'], use_ascii, escape_html, single_file),
'code_html': preprocess_code(value['code'], use_ascii, single_file)
} for key, value in sorted(sections.items())]
|
We include a timestamp in the footer. |
date = datetime.datetime.utcnow().strftime('%d %b %Y')
if single_file:
|
For a |
out_lines = []
for section in sections:
out_lines.extend([section['docs_html'], '\n', section['code_html']])
out_text = '\n'.join(out_lines)
return out_text
else:
|
...otherwise, we carry on as before, rendering via pystache and the template |
context = {
'title': title,
'sections': sections,
'date': date,
}
with open(DYCCO_TEMPLATE) as f:
return pystache.render(f.read(), context)
|
Preprocessors |
|
Preprocess the given |
def preprocess_docs(docs:list, use_ascii:bool, escape_html:bool, raw:bool = False) -> str:
assert isinstance(docs, list)
|
Join the documentation sections together.
Sometimes we have |
collated_docs = '\n\n'.join(filter(None, docs))
|
Sanitize it if required |
sanitized_docs = html.escape(collated_docs) if escape_html else collated_docs
if raw:
|
Don't do any actual processing, just return the strings. |
return sanitized_docs
if use_ascii:
|
Documentation - Asciidoc3If we couldn't find asciidoc3, bail out with an error |
if ascii_location is None:
raise ImportError('asciidoc3 was not found')
|
Asciidoc3 likes file-like entities, so give it them |
dummy_infile = io.StringIO(sanitized_docs)
dummy_outfile = io.StringIO()
|
We have to force-feed asciidoc3 with its location ( |
asciidoc = AsciiDoc3API.AsciiDoc3API(ascii_location)
asciidoc.options('--no-header-footer')
|
Call asciidoc - the output will be in |
asciidoc.execute(dummy_infile, dummy_outfile, backend='html5')
|
...so return its content |
return dummy_outfile.getvalue()
else:
|
Documentation - Markdown |
|
Otherwise, just pass the joined-up (possibly sanitized) document sections to markdown() |
return markdown.markdown(sanitized_docs)
|
Code - Pygments |
|
Preprocess the given code, which should be a Although we are strictly python, it's possible that this might get called by other
routines as it's quite handy, so allow the language to be specified
in |
def preprocess_code(code:list, use_ascii:bool = False, raw:bool = False, language_name:str = 'python') -> str:
assert isinstance(code, list)
|
Sometimes code is empty, so just return nothing |
if not code or not ''.join(code).strip():
return ''
if raw:
|
We don't highlight for |
delimiter = '---------------------------------------------------------------------'
if use_ascii:
|
...either asciidoc3's markers... |
code_block = '\n[source,{2}]\n{0}\n{1}\n{0}\n'.format(delimiter, '\n'.join(code), language_name)
else:
|
...or Markdown's markers |
delimiter = "```"
code_block = '{0}{2}\n{1}\n{0}\n'.format(delimiter, '\n'.join(code), language_name)
return code_block
else:
|
Do what we always used to - pass througn Pygments |
lexer = get_lexer_by_name("python")
formatter = HtmlFormatter()
result = highlight('\n'.join(code), lexer, formatter)
return result
|
Support Functions |
|
Creates the special |
def make_sections() -> defaultdict:
|
A callable for use as the default object in the |
def section() -> dict:
return {'docs': [], 'code': [],}
return defaultdict(section)
|
Test the given line to see if it should be included. Excludes shebang lines, for now. |
def should_filter(line:str, num:int) -> bool:
|
Filter shebang comments. |
if num == 0 and line.startswith('#!'):
return True
|
Filter encoding specification comments. |
if num < 2 and line.startswith('#') and re.search('coding[:=]', line):
return True
return False
|
Creates an appropriate output path for the given source file and output
directory. The output file name will be the name of the source file
without its original extension but with |
def make_output_path(filename, output_dir, extension:str = 'html') -> str:
name, ext = os.path.splitext(filename)
return os.path.join(output_dir, '%s.%s' % (name, extension))
|
AST Parsing |
|
A |
class DocStringVisitor(ast.NodeVisitor):
|
def __init__(self):
|
|
Docstrings will be tracked as a dict mapping 0-based target line numbers to cleaned up docstrings. |
self.docstrings = {}
|
Track the line numbers where docstrings are found, so they can be skipped when processing the source code line-by-line. |
self.docstring_lines = set()
|
Keep track of the current module, class, or function node we're looking at, if any. |
self.current_node = None
self.current_doc = None
|
A method to be called when visiting any node that might have an
associated docstring (ie, module, function and class nodes). This uses
|
def _visit_docstring_node(self, node):
self.current_node = node
self.current_doc = ast.get_docstring(node) or ''
|
Mark the place of any function or class definitions without docstrings, to ensure that a new section will be started for every def when rendering. |
if isinstance(node, (ast.FunctionDef, ast.ClassDef, ast.AsyncFunctionDef)) and not self.current_doc:
self.docstrings[node.lineno - 1 - len(node.decorator_list)] = None
super(DocStringVisitor, self).generic_visit(node)
|
Use the |
visit_Module = _visit_docstring_node
visit_FunctionDef = _visit_docstring_node
visit_ClassDef = _visit_docstring_node
visit_AsyncFunctionDef = _visit_docstring_node
|
We need to actually visit the nodes representing the docstrings to
record their positions. Docstring nodes show up as
The Python docs state: A constant value. The value attribute of the Constant literal contains the Python object it represents. The values represented can be simple types such as a number, string or None, but also immutable container types (tuples and frozensets) if all of their elements are constant. ...or in English, the |
def visit_Expr(self, node):
if isinstance(node.value, ast.Constant):
if isinstance(node.value.value, str) and self.current_node and self.current_doc:
|
Figure out where the docstring ends, accounting for 0-based line numbers. Note that modules have got an end-line despite what the older code says. |
end_line = node.end_lineno - 1
|
We need to know how many lines are in the docstring to figure out where it actually starts. We might have triple-strings of various combinations:- > """...""" or > """... > """ or > """ > ...""" or... well, you get the idea! |
|
|
line_count = len(node.value.s.splitlines())
if isinstance(self.current_node, ast.Module):
start_line = end_line - (line_count if line_count > 1 else 0)
target_line = start_line
|
The current node's |
else:
start_line = end_line - (line_count - 1)
target_line = self.current_node.lineno - 1
|
Mark the positions of this node and its documentation. |
assert target_line not in self.docstrings
self.docstrings[target_line] = self.current_doc.strip()
self.docstring_lines.update(range(start_line, end_line + 1))
|
Reset the accounting variables even if we didn't find a docstring, so that we don't accidentally add "unattached" docstrings to whatever class/def/module happened to come before them. |
if self.current_node:
self.current_node = None
self.current_doc = None
super(DocStringVisitor, self).generic_visit(node)
|