"""
Code for converting epytext docstrings so they can be used in Sphinx. This
module may be used as either a Sphinx plugin to allow sphinx-build to parse
epytext, or as a command line program to convert files from epytext to
reST.
"""
import argparse
import glob
import os
import re
# A list of Epytext fields to convert to Sphinx fields. Taken from
# (and listed in the same order as) http://epydoc.sourceforge.net/fields.html
FIELDS = [
"param", "type", "return", "rtype", "keyword", "raise", "ivar", "cvar",
"var", "see", "note", "attention", "bug", "warning", "deprecated"
]
ANY_FIELD = "|".join(FIELDS)
[docs]def setup(app):
"""
Hook up `process_docstring` so it can convert all docstrings. This
function is called automatically by Sphinx.
:param app: The currently running Sphinx instance.
:type app: sphinx.Sphinx
"""
app.connect('autodoc-process-docstring', process_docstring)
return {"parallel_read_safe": True}
[docs]def process_docstring(app, what, name, obj, options, lines):
"""
Convert the given docstring from Epytext to Sphinx reST format.
:param lines: A list of all lines in the docstring to convert. This list
will be modified in place.
:type lines: list(str)
All other arguments are ignored and are present only for compatibility
with the autodoc-process-docstring connection.
"""
_process_docstring(lines)
def _process_docstring(lines):
"""
Convert the given docstring from Epytext to Sphinx reST format.
:param lines: A list of all lines in the docstring to convert. This list
will be modified in place.
:type lines: list(str)
"""
_indent_field_continuations(lines)
docstring = "\n".join(lines)
# Convert L{...} and C{...} to backticks
docstring = re.sub(r"(?:L|C){(.*?)}", r"`\1`", docstring, flags=re.DOTALL)
# Remove any U{...} markup for URLs, since sphinx recognizes URLs
# automatically without any special markup. We require that URLs start with
# "protocol://".
docstring = re.sub(r"U{(\w+://.*?)}", r"\1", docstring, flags=re.DOTALL)
# Convert I{...} to asterisks (italics)
docstring = re.sub(r"I{(.*?)}", r"*\1*", docstring, flags=re.DOTALL)
# Convert B{...} to double asterisks (bold)
docstring = re.sub(r"B{(.*?)}", r"**\1**", docstring, flags=re.DOTALL)
# Convert M{...} to math role backticks (mathematical expressions)
docstring = re.sub(r"M{(.*?)}", r":math:`\1`", docstring, flags=re.DOTALL)
# convert all fields from @ to colons
docstring = re.sub(r"@(%s)" % ANY_FIELD, r":\1", docstring)
lines[:] = docstring.split("\n")
def _indent_field_continuations(lines):
"""
Indent any lines that continue a field started on a prior line.
:param lines: A list of all lines in the docstring to convert. This list
will be modified in place.
:type lines: list
In the following docstring::
'''
@param arg: This is a very long docstring. It's so long that it
continues on a second line.
'''
Epydoc recognizes that both sentences are part of the description of
C{arg}. However, in this docstring::
'''
:param arg: This is a very long docstring. It's so long that it
continues on a second line.
'''
Sphinx doesn't consider "continues on a second line" to be part of the
description of `arg`. In order for Sphinx to recognize multi-line
fields, any continuation lines need to be indented::
'''
:param arg: This is a very long docstring. It's so long that it
continues on a second line.
'''
This function adds this indentation so that Sphinx will properly
recognize multi-line fields. This function adds indentation only where
it's required, so it will add indentation to::
'''
@param arg: This is a very long docstring. It's so long that it
continues on a second line.
'''
and::
'''
@param arg: This docstring has a bulleted list in it:
- List item 1
- List item 2
'''
but not::
'''
@param arg: This is a very long docstring. It's so long that it
continues on a second line, but the second line has already
been indented. So has the third line.
'''
"""
in_field = False
indentation = ""
for line_num, curline in enumerate(lines):
if in_field:
if re.match(indentation + r"(?:\t|(?:\ {0,4}))[^\s@]", curline):
# this line continues a field started on a previous line
# and needs additional indentation
lines[line_num] = " " + curline
else:
in_field = False
if not in_field:
match = re.match(r"([\ \t]*)@(?:%s)" % ANY_FIELD, curline)
if match:
# this line starts a new field (i.e. it starts with an @ sign
# and a member of FIELDS
in_field = True
# store the leading whitespace from this line so we can find out
# if the following lines are indented at least this much
indentation = match.group(1)
[docs]def convert_file(filename, all_triple_quoted_strings=False):
"""
Convert the specified file from epytext to reST.
:param filename: The name of the file to convert.
:type filename: str
:param all_triple_quoted_strings: Whether to convert all comments enclosed within
triple quotes or only proper doc strings
:type all_triple_quoted_strings: bool
"""
with open(filename) as handle:
file_text = handle.read()
file_text = _convert_file_contents(file_text, all_triple_quoted_strings)
with open(filename, "wt") as handle:
handle.write(file_text)
def _convert_file_contents(file_text, all_triple_quoted_strings=False):
"""Convert the specified Python source code from epytext to reST.
:param file_text: The source code to convert.
:type file_text: str
:param all_triple_quoted_strings: Whether to convert all comments enclosed within
triple quotes or only proper doc strings
:type all_triple_quoted_strings: bool
:return: The converted source code.
:rtype: str
"""
if all_triple_quoted_strings:
# simply convert all comments enclosed within triple quotes
file_text = re.sub(r"""
(?P<pre> # capture everything before the docstring
(?P<quote>"{3}|'{3})\n?) # opening triple quote
(?P<docstring>.+?) # the docstring itself
(?P<post> # capture everything after the docstring
[\ \t]*(?P=quote)) # closing triple quote
""",
_replace_docstring,
file_text,
flags=re.DOTALL | re.VERBOSE)
return file_text
# convert the module docstring
file_text = re.sub(r"""
(?P<pre> # capture everything before the docstring
^(?:[\ \t]*(?:\#[^\n]*)?\n)* # blank or comment lines (we intentionally
# match entire lines at a time to avoid
# catastrophic backtracking)
(?P<quote>"{3}|'{3})\n?) # opening triple quote
(?P<docstring>.+?) # the docstring itself
(?P<post> # capture everything after the docstring
[\ \t]*(?P=quote)) # closing triple quote
""",
_replace_docstring,
file_text,
flags=re.DOTALL | re.VERBOSE)
# convert class or function docstrings
file_text = re.sub(r"""
(?P<pre> # capture everything before the docstring
(?:def|class)\ +\w*\ * # The function or class name
(?:\(.*?\))?\ *: # The arguments or inheritance list
# (optional because of old-style classes)
(?:[\ \t]*(?:\#[^\n]*)?\n)* # blank or comment lines (matching
# entire lines at a time to avoid
# catastrophic backtracking)
\s*(?P<quote>"{3}|'{3})\n?) # opening triple quote
(?P<docstring>.+?) # the docstring itself
(?P<post> # capture everything after the docstring
[\ \t]*(?P=quote)) # closing triple quote
""",
_replace_docstring,
file_text,
flags=re.DOTALL | re.VERBOSE)
# convert docstrings that are explicitly specified using __doc__ or _doc
# assignment. (We assume that the docstring uses triple quotes.)
file_text = re.sub(r"""
(?P<pre> # capture everything before the docstring
\b(__doc__|_doc)\s*=\s* # __doc__ assignment
(?P<quote>"{3}|'{3})\n?) # opening triple quote
(?P<docstring>.+?) # the docstring itself
(?P<post> # capture everything after the docstring
[\ \t]*(?P=quote)) # closing triple quote
""",
_replace_docstring,
file_text,
flags=re.DOTALL | re.VERBOSE)
return file_text
def _replace_docstring(match):
"""
Convert the matched docstring from epytext to reST.
:param match: A regular expression match. This match must contain three
named groups:
pre
All matching text before the docsting including the opening triple
quotes.
docstring
The docstring to be converted.
post
All matching text after the docstring including the closing triple
quotes.
:type match: re.MatchObject
:return: A string to replace the match with.
:rtype: str
"""
docstring_lines = match.group("docstring").split("\n")
_process_docstring(docstring_lines)
new_docstring = "\n".join(docstring_lines)
return match.group("pre") + new_docstring + match.group("post")
def _walk_paths(paths, exclusions):
"""
Generate all filenames referenced by the command-line arguments.
:param paths: A list of paths to yield. For any directories, will
yield all .py or wscript (waf Python files) files under that directory.
:type paths: list
:param exclusions: A list of files to exclude. Any file name (excluding
path) that exactly matches an element of this list will be skipped.
:type exclusions: list
"""
for cur_path in paths:
for filename in glob.iglob(cur_path):
if os.path.isdir(filename):
for dirpath, dirnames, dirfiles in os.walk(filename):
for cur_filename in dirfiles:
# wscript files are waf Python files
if ((cur_filename == "wscript" or
cur_filename.endswith(".py")) and
cur_filename not in exclusions):
yield os.path.join(dirpath, cur_filename)
elif os.path.isfile(filename):
yield filename
else:
raise RuntimeError("Invalid path: %s" % filename)
[docs]def main():
parser = argparse.ArgumentParser(prog="epydoc_to_sphinx")
parser.add_argument("--exclude",
"-x",
action="append",
default=[],
help="Filenames to exclude from conversion")
parser.add_argument("--verbose",
"-v",
action="store_true",
help="Print out filenames as files are converted.")
parser.add_argument("paths",
nargs="*",
default=["."],
help="Files or directories to convert")
parser.add_argument("--all-triple-quoted-strings",
"-s",
action='store_true',
help="Run on all triple quoted blocks of text")
args = parser.parse_args()
for filename in _walk_paths(args.paths, args.exclude):
if args.verbose:
print(filename)
convert_file(filename,
all_triple_quoted_strings=args.all_triple_quoted_strings)
if __name__ == "__main__":
main()