# This file is part of Pimlico
# Copyright (C) 2020 Mark Granroth-Wilding
# Licensed under the GNU LGPL v3.0 - https://www.gnu.org/licenses/lgpl-3.0.en.html
"""
Tool to generate Pimlico command docs. Based on Sphinx's apidoc tool.
"""
from __future__ import print_function
from builtins import str
from builtins import zip
from builtins import range
import argparse
import os
import re
from argparse import SUPPRESS, OPTIONAL, ZERO_OR_MORE, ONE_OR_MORE, REMAINDER, PARSER
from operator import itemgetter
from sphinx import __version__
from .rest import format_heading
from pimlico import install_core_dependencies
from pimlico.cli.main import SUBCOMMANDS
from pimlico.cli.subcommands import PimlicoCLISubcommand
from pimlico.utils.docs.rest import make_table
[docs]def generate_docs(output_dir):
"""
Generate RST docs for Pimlico commands and output to a directory.
"""
command_names, command_descs = list(zip(*(
generate_docs_for_command(command, output_dir) for command in SUBCOMMANDS
)))
# Generate an index for all commands
generate_contents_page(command_names, command_descs, output_dir)
[docs]def generate_docs_for_command(command_cls, output_dir):
command_name = command_cls.command_name
print("Building docs for %s" % command_name)
# Instantiate the subcommand, so we can manipulate arguments
command = command_cls()
command_doc = command_cls.__doc__
base_command_doc = PimlicoCLISubcommand.__doc__
command_help = command_cls.command_help
# Work out what text to use
# If the command hasn't overridden the base command's doc, don't use that
if command_doc is None or command_doc == base_command_doc:
# Fall back to using the help text, if that's available
if command_help is not None:
doc_text = command_help
else:
# No documentation available
doc_text = ""
else:
doc_text = strip_common_indent(command_doc)
if doc_text:
doc_text = "%s." % doc_text.rstrip("\n. ")
if command.command_desc is not None:
# Description supplied -- best practice
command_short_desc = command.command_desc
elif command_help is not None:
# No description, but help text: use that instead
command_short_desc = command_help.strip()
if len(command_short_desc) > 500:
command_short_desc = command_short_desc[:497] + "..."
else:
command_short_desc = None
# We have to create an argument parser and get the command to add its args to it, so we can see what they are
arg_parser = argparse.ArgumentParser()
command.add_arguments(arg_parser)
# Now we have to do some sneaky peaking into the arg parser
# Pull information out of the argparser actions, following the procedure argparse uses for printing help
optionals = []
positionals = []
for action in arg_parser._actions:
if action.option_strings:
optionals.append(action)
else:
positionals.append(action)
# Collect all actions' format strings
parts = []
for i, action in enumerate(positionals + optionals):
# Produce all arg strings
if not action.option_strings:
# Positional arg
# Add the action string to the list
parts.append(_format_args(action, action.dest))
else:
# Produce the first way to invoke the option in brackets
option_string = action.option_strings[0]
if action.nargs == 0:
# If the Optional doesn't take a value, format is:
# -s or --long
part = '%s' % option_string
else:
# If the Optional takes a value, format is:
# -s ARGS or --long ARGS
default = action.dest.upper()
args_string = _format_args(action, default)
part = '%s %s' % (option_string, args_string)
# Make it look optional if it's not required or in a group
if not action.required:
part = '[%s]' % part
parts.append(part)
usage = "pimlico.sh [...] %s %s" % (command_name, " ".join(parts))
args_table = [
[
"``%s``" % _format_args(action, action.dest),
cap_first(action.help) if action.help is not SUPPRESS else "",
] for action in positionals
]
# Put together the options table
options_table = [
[
", ".join("``%s``" % s for s in action.option_strings),
cap_first(action.help) if action.help is not SUPPRESS else "",
]
for action in optionals
# Skip the --help option always, since it's on every command and doesn't look good in the docs
if "--help" not in action.option_strings
]
filename = os.path.join(output_dir, "%s.rst" % command_name)
with open(filename, "w") as output_file:
# Add a label that we can use to link to this doc elsewhere in the docs
output_file.write(".. _command_%s:\n\n" % command_name)
# Make a page heading
output_file.write(format_heading(0, "%s" % command_name))
output_file.write("\n*Command-line tool subcommand*\n\n")
# Insert text from docstrings
output_file.write(doc_text + "\n\n\n")
# Include usage
output_file.write("Usage:\n\n::\n\n %s\n\n\n" % usage)
# Output a table of inputs
if args_table:
output_file.write(format_heading(1, "Positional arguments"))
output_file.write("%s\n" % make_table(args_table, header=["Arg", "Description"]))
# Table of outputs
if options_table:
output_file.write(format_heading(1, "Options"))
output_file.write("%s\n" % make_table(options_table, header=["Option", "Description"]))
return command_name, command_short_desc
def _format_args(action, default_metavar):
if action.metavar is not None:
metavar_result = action.metavar
elif action.choices is not None:
choice_strs = [str(choice) for choice in action.choices]
metavar_result = '{%s}' % ','.join(choice_strs)
else:
metavar_result = default_metavar
if isinstance(metavar_result, tuple):
get_metavar = lambda x: metavar_result
else:
get_metavar = lambda tuple_size: (metavar_result, ) * tuple_size
if action.nargs is None:
part = '%s' % get_metavar(1)
elif action.nargs == OPTIONAL:
part = '[%s]' % get_metavar(1)
elif action.nargs == ZERO_OR_MORE:
part = '[%s [%s ...]]' % get_metavar(2)
elif action.nargs == ONE_OR_MORE:
part = '%s [%s ...]' % get_metavar(2)
elif action.nargs == REMAINDER:
part = '...'
elif action.nargs == PARSER:
part = '%s ...' % get_metavar(1)
else:
formats = ['%s' for _ in range(action.nargs)]
part = ' '.join(formats) % get_metavar(action.nargs)
return part
[docs]def generate_contents_page(commands, command_descs, output_dir):
print("Building contents page (index.rst)")
command_table = [
[":doc:`%s`" % name, desc] for (name, desc) in sorted(zip(commands, command_descs), key=itemgetter(0))
]
with open(os.path.join(output_dir, "index.rst"), "w") as index_file:
index_file.write("""\
{title}
The main Pimlico command-line interface (usually accessed via `pimlico.sh` in your project root)
provides subcommands to perform different operations. Call it like so, using one of the subcommands
documented below to access particular functionality:
.. code-block:: bash
./pimlico.sh <config-file> [general options...] <subcommand> [subcommand args/options]
The commands you are likely to use most often are: :doc:`status`, :doc:`run`, :doc:`reset` and maybe :doc:`browse`.
For a reference for each command's options, see the command-line documentation: ``./pimlico.sh --help``, for
a general reference and ``./pimlico.sh <config_file> <command> --help`` for a specific subcommand's
reference.
Below is a more detailed guide for each subcommand, including all of the documentation available via the
command line.
{table}
.. toctree::
:maxdepth: 1
:titlesonly:
:hidden:
{list}
""".format(
title=format_heading(0, "Command-line interface"),
table=make_table(command_table),
list="\n ".join(commands),
))
[docs]def cap_first(txt):
if len(txt) > 0:
return txt[0].upper() + txt[1:]
else:
return txt
_find_non_space = re.compile('[^ ]').search
[docs]def strip_common_indent(code):
# Taken from h5py source
min_indent = None
lines = code.splitlines()
for line in lines:
match = _find_non_space(line)
if not match:
continue # blank
indent = match.start()
if line[indent] == '#':
continue # comment
if min_indent is None or min_indent > indent:
min_indent = indent
for ix, line in enumerate(lines):
match = _find_non_space(line)
if not match or not line or line[indent:indent+1] == '#':
continue
lines[ix] = line[min_indent:]
return '\n'.join(lines)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Generate command documentation RST files from core Pimlico "
"command-line commands")
parser.add_argument("output_dir", help="Where to put the .rst files")
opts = parser.parse_args()
output_dir = os.path.abspath(opts.output_dir)
# Install basic Pimlico requirements
install_core_dependencies()
print("Sphinx %s" % __version__)
print("Pimlico command doc generator")
print("Outputting module docs to %s" % output_dir)
if not os.path.isdir(output_dir):
os.makedirs(output_dir)
generate_docs(output_dir)