Add 'autoprogram-cliff' Sphinx directive
Many projects, such as 'python-openstackclient', manually write documentation for their cliff-based command line tools. In many cases, this documentation is a 1:1 reflection of what one could build from the command line. This is unnecessary overhead that could and should be avoided. Add an 'autoprogram-cliff' directive that will allow folks to automatically document their command line tools. Change-Id: I497e62382768ffc9668a103706001735a7d851ff
This commit is contained in:
parent
6a39ba568c
commit
e7a6a596c5
|
@ -0,0 +1,253 @@
|
||||||
|
# Copyright (C) 2017, Red Hat, Inc.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import fnmatch
|
||||||
|
|
||||||
|
from docutils import nodes
|
||||||
|
from docutils.parsers import rst
|
||||||
|
from docutils.parsers.rst import directives
|
||||||
|
from docutils import statemachine
|
||||||
|
|
||||||
|
from cliff import commandmanager
|
||||||
|
|
||||||
|
|
||||||
|
def _indent(text):
|
||||||
|
"""Indent by four spaces."""
|
||||||
|
prefix = ' ' * 4
|
||||||
|
|
||||||
|
def prefixed_lines():
|
||||||
|
for line in text.splitlines(True):
|
||||||
|
yield (prefix + line if line.strip() else line)
|
||||||
|
|
||||||
|
return ''.join(prefixed_lines())
|
||||||
|
|
||||||
|
|
||||||
|
def _format_usage(parser):
|
||||||
|
"""Get usage without a prefix."""
|
||||||
|
fmt = argparse.HelpFormatter(parser.prog)
|
||||||
|
fmt.add_usage(parser.usage, parser._actions,
|
||||||
|
parser._mutually_exclusive_groups, prefix='')
|
||||||
|
|
||||||
|
return fmt.format_help().strip().splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
def _format_positional_action(action):
|
||||||
|
"""Format a positional action."""
|
||||||
|
if action.help == argparse.SUPPRESS:
|
||||||
|
return
|
||||||
|
|
||||||
|
# NOTE(stephenfin): We use 'dest' - not 'metavar' - because the 'option'
|
||||||
|
# directive dictates that only option argument names should be surrounded
|
||||||
|
# by angle brackets
|
||||||
|
yield '.. option:: {}'.format(action.dest)
|
||||||
|
if action.help:
|
||||||
|
yield ''
|
||||||
|
for line in statemachine.string2lines(
|
||||||
|
action.help, tab_width=4, convert_whitespace=True):
|
||||||
|
yield _indent(line)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_optional_action(action):
|
||||||
|
"""Format an optional action."""
|
||||||
|
if action.help == argparse.SUPPRESS:
|
||||||
|
return
|
||||||
|
|
||||||
|
if action.nargs == 0:
|
||||||
|
yield '.. option:: {}'.format(', '.join(action.option_strings))
|
||||||
|
else:
|
||||||
|
# TODO(stephenfin): At some point, we may wish to provide more
|
||||||
|
# information about the options themselves, for example, if nargs is
|
||||||
|
# specified
|
||||||
|
option_strings = [' '.join(
|
||||||
|
[x, action.metavar or '<{}>'.format(action.dest.upper())])
|
||||||
|
for x in action.option_strings]
|
||||||
|
yield '.. option:: {}'.format(', '.join(option_strings))
|
||||||
|
|
||||||
|
if action.help:
|
||||||
|
yield ''
|
||||||
|
for line in statemachine.string2lines(
|
||||||
|
action.help, tab_width=4, convert_whitespace=True):
|
||||||
|
yield _indent(line)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_parser(parser):
|
||||||
|
"""Format the output of an argparse 'ArgumentParser' object.
|
||||||
|
|
||||||
|
Given the following parser::
|
||||||
|
|
||||||
|
>>> import argparse
|
||||||
|
>>> parser = argparse.ArgumentParser(prog='hello-world')
|
||||||
|
>>> parser.add_argument('name', help='User name', metavar='<name>')
|
||||||
|
>>> parser.add_argument('--language', action='store', dest='lang', \
|
||||||
|
help='Greeting language')
|
||||||
|
|
||||||
|
Returns the following::
|
||||||
|
|
||||||
|
.. program:: hello-world
|
||||||
|
.. code:: shell
|
||||||
|
|
||||||
|
hello-world [-h] [--language LANG] <name>
|
||||||
|
|
||||||
|
.. option:: name
|
||||||
|
|
||||||
|
User name
|
||||||
|
|
||||||
|
.. option:: --language LANG
|
||||||
|
|
||||||
|
Greeting language
|
||||||
|
|
||||||
|
.. option:: -h, --help
|
||||||
|
|
||||||
|
Show this help message and exit
|
||||||
|
"""
|
||||||
|
yield '.. program:: {}'.format(parser.prog)
|
||||||
|
|
||||||
|
yield '.. code-block:: shell'
|
||||||
|
yield ''
|
||||||
|
for line in _format_usage(parser):
|
||||||
|
yield _indent(line)
|
||||||
|
yield ''
|
||||||
|
|
||||||
|
# In argparse, all arguments and parameters are known as "actions".
|
||||||
|
# Optional actions are what would be known as flags or options in other
|
||||||
|
# libraries, while positional actions would generally be known as
|
||||||
|
# arguments. We present these slightly differently.
|
||||||
|
|
||||||
|
for action in parser._get_optional_actions():
|
||||||
|
for line in _format_optional_action(action):
|
||||||
|
yield line
|
||||||
|
yield ''
|
||||||
|
|
||||||
|
for action in parser._get_positional_actions():
|
||||||
|
for line in _format_positional_action(action):
|
||||||
|
yield line
|
||||||
|
yield ''
|
||||||
|
|
||||||
|
|
||||||
|
class AutoprogramCliffDirective(rst.Directive):
|
||||||
|
"""Auto-document a subclass of `cliff.command.Command`."""
|
||||||
|
|
||||||
|
has_content = False
|
||||||
|
required_arguments = 1
|
||||||
|
option_spec = {
|
||||||
|
'command': directives.unchanged,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _load_command(self, manager, command_name):
|
||||||
|
"""Load a command using an instance of a `CommandManager`."""
|
||||||
|
try:
|
||||||
|
# find_command expects the value of argv so split to emulate that
|
||||||
|
return manager.find_command(command_name.split())[0]
|
||||||
|
except ValueError:
|
||||||
|
raise self.error('"{}" is not a valid command in the "{}" '
|
||||||
|
'namespace'.format(
|
||||||
|
command_name, manager.namespace))
|
||||||
|
|
||||||
|
def _generate_nodes(self, title, command_name, command_class):
|
||||||
|
"""Generate the relevant Sphinx nodes.
|
||||||
|
|
||||||
|
This is a little funky. Parts of this use raw docutils nodes while
|
||||||
|
other parts use reStructuredText and nested parsing. The reason for
|
||||||
|
this is simple: it avoids us having to reinvent the wheel. While raw
|
||||||
|
docutils nodes are helpful for the simpler elements of the output,
|
||||||
|
they don't provide an easy way to use Sphinx's own directives, such as
|
||||||
|
the 'option' directive. Refer to [1] for more information.
|
||||||
|
|
||||||
|
[1] http://www.sphinx-doc.org/en/stable/extdev/markupapi.html
|
||||||
|
|
||||||
|
:param title: Title of command
|
||||||
|
:param command_name: Name of command, as used on the command line
|
||||||
|
:param command_class: Subclass of :py:class:`cliff.command.Command`
|
||||||
|
:param prefix: Prefix to apply before command, if any
|
||||||
|
:returns: A list of nested docutil nodes
|
||||||
|
"""
|
||||||
|
command = command_class(None, None)
|
||||||
|
parser = command.get_parser(command_name)
|
||||||
|
description = command.get_description()
|
||||||
|
|
||||||
|
# Drop the automatically-added help action
|
||||||
|
for action in parser._actions:
|
||||||
|
if isinstance(action, argparse._HelpAction):
|
||||||
|
del parser._actions[parser._actions.index(action)]
|
||||||
|
|
||||||
|
# Title
|
||||||
|
|
||||||
|
# We build this with plain old docutils nodes
|
||||||
|
|
||||||
|
section = nodes.section(
|
||||||
|
'',
|
||||||
|
nodes.title(text=title),
|
||||||
|
ids=[nodes.make_id(title)],
|
||||||
|
names=[nodes.fully_normalize_name(title)])
|
||||||
|
|
||||||
|
source_name = '<{}>'.format(command.__class__.__name__)
|
||||||
|
result = statemachine.ViewList()
|
||||||
|
|
||||||
|
# Description
|
||||||
|
|
||||||
|
# We parse this as reStructuredText, allowing users to embed rich
|
||||||
|
# information in their help messages if they so choose.
|
||||||
|
|
||||||
|
if description:
|
||||||
|
for line in statemachine.string2lines(
|
||||||
|
description, tab_width=4, convert_whitespace=True):
|
||||||
|
result.append(line, source_name)
|
||||||
|
|
||||||
|
result.append('', source_name)
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
|
||||||
|
# We both build and parse this as reStructuredText
|
||||||
|
|
||||||
|
for line in _format_parser(parser):
|
||||||
|
result.append(line, source_name)
|
||||||
|
|
||||||
|
self.state.nested_parse(result, 0, section)
|
||||||
|
|
||||||
|
return [section]
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
self.env = self.state.document.settings.env
|
||||||
|
|
||||||
|
command_pattern = self.options.get('command')
|
||||||
|
application_name = self.env.config.autoprogram_cliff_application or ''
|
||||||
|
|
||||||
|
# TODO(sfinucan): We should probably add this wildcarding functionality
|
||||||
|
# to the CommandManager itself to allow things like "show me the
|
||||||
|
# commands like 'foo *'"
|
||||||
|
manager = commandmanager.CommandManager(self.arguments[0])
|
||||||
|
if command_pattern:
|
||||||
|
commands = [x for x in manager.commands
|
||||||
|
if fnmatch.fnmatch(x, command_pattern)]
|
||||||
|
else:
|
||||||
|
commands = manager.commands.keys()
|
||||||
|
|
||||||
|
output = []
|
||||||
|
for command_name in sorted(commands):
|
||||||
|
command_class = self._load_command(manager, command_name)
|
||||||
|
|
||||||
|
title = command_name
|
||||||
|
if application_name:
|
||||||
|
command_name = ' '.join([application_name, command_name])
|
||||||
|
|
||||||
|
output.extend(self._generate_nodes(
|
||||||
|
title, command_name, command_class))
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def setup(app):
|
||||||
|
app.add_directive('autoprogram-cliff', AutoprogramCliffDirective)
|
||||||
|
app.add_config_value('autoprogram_cliff_application', '', True)
|
|
@ -0,0 +1,106 @@
|
||||||
|
# Copyright (C) 2017, Red Hat, Inc.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
from nose.tools import assert_equals
|
||||||
|
|
||||||
|
from cliff import sphinxext
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_help():
|
||||||
|
"""Handle positional and optional actions without help messages."""
|
||||||
|
parser = argparse.ArgumentParser(prog='hello-world', add_help=False)
|
||||||
|
parser.add_argument('name', action='store')
|
||||||
|
parser.add_argument('--language', dest='lang')
|
||||||
|
|
||||||
|
output = '\n'.join(sphinxext._format_parser(parser))
|
||||||
|
assert_equals(textwrap.dedent("""
|
||||||
|
.. program:: hello-world
|
||||||
|
.. code-block:: shell
|
||||||
|
|
||||||
|
hello-world [--language LANG] name
|
||||||
|
|
||||||
|
.. option:: --language <LANG>
|
||||||
|
|
||||||
|
.. option:: name
|
||||||
|
""").lstrip(), output)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonempty_help():
|
||||||
|
"""Handle positional and optional actions with help messages."""
|
||||||
|
parser = argparse.ArgumentParser(prog='hello-world', add_help=False)
|
||||||
|
parser.add_argument('name', help='user name')
|
||||||
|
parser.add_argument('--language', dest='lang', help='greeting language')
|
||||||
|
|
||||||
|
output = '\n'.join(sphinxext._format_parser(parser))
|
||||||
|
assert_equals(textwrap.dedent("""
|
||||||
|
.. program:: hello-world
|
||||||
|
.. code-block:: shell
|
||||||
|
|
||||||
|
hello-world [--language LANG] name
|
||||||
|
|
||||||
|
.. option:: --language <LANG>
|
||||||
|
|
||||||
|
greeting language
|
||||||
|
|
||||||
|
.. option:: name
|
||||||
|
|
||||||
|
user name
|
||||||
|
""").lstrip(), output)
|
||||||
|
|
||||||
|
|
||||||
|
def test_flag():
|
||||||
|
"""Handle a boolean argparse action."""
|
||||||
|
parser = argparse.ArgumentParser(prog='hello-world', add_help=False)
|
||||||
|
parser.add_argument('name', help='user name')
|
||||||
|
parser.add_argument('--translate', action='store_true',
|
||||||
|
help='translate to local language')
|
||||||
|
|
||||||
|
output = '\n'.join(sphinxext._format_parser(parser))
|
||||||
|
assert_equals(textwrap.dedent("""
|
||||||
|
.. program:: hello-world
|
||||||
|
.. code-block:: shell
|
||||||
|
|
||||||
|
hello-world [--translate] name
|
||||||
|
|
||||||
|
.. option:: --translate
|
||||||
|
|
||||||
|
translate to local language
|
||||||
|
|
||||||
|
.. option:: name
|
||||||
|
|
||||||
|
user name
|
||||||
|
""").lstrip(), output)
|
||||||
|
|
||||||
|
|
||||||
|
def test_supressed():
|
||||||
|
"""Handle a supressed action."""
|
||||||
|
parser = argparse.ArgumentParser(prog='hello-world', add_help=False)
|
||||||
|
parser.add_argument('name', help='user name')
|
||||||
|
parser.add_argument('--variable', help=argparse.SUPPRESS)
|
||||||
|
|
||||||
|
output = '\n'.join(sphinxext._format_parser(parser))
|
||||||
|
assert_equals(textwrap.dedent("""
|
||||||
|
.. program:: hello-world
|
||||||
|
.. code-block:: shell
|
||||||
|
|
||||||
|
hello-world name
|
||||||
|
|
||||||
|
|
||||||
|
.. option:: name
|
||||||
|
|
||||||
|
user name
|
||||||
|
""").lstrip(), output)
|
|
@ -19,6 +19,7 @@ Contents:
|
||||||
interactive_mode
|
interactive_mode
|
||||||
classes
|
classes
|
||||||
install
|
install
|
||||||
|
sphinxext
|
||||||
developers
|
developers
|
||||||
history
|
history
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,109 @@
|
||||||
|
==================
|
||||||
|
Sphinx Integration
|
||||||
|
==================
|
||||||
|
|
||||||
|
cliff supports integration with Sphinx by way of a `Sphinx directives`__.
|
||||||
|
|
||||||
|
.. rst:directive:: .. autoprogram-cliff:: namespace
|
||||||
|
|
||||||
|
Automatically document an instance of :py:class:`cliff.command.Command`,
|
||||||
|
including a description, usage summary, and overview of all options.
|
||||||
|
|
||||||
|
.. code-block:: rst
|
||||||
|
|
||||||
|
.. autoprogram-cliff:: openstack.compute.v2
|
||||||
|
:command: server add fixed ip
|
||||||
|
|
||||||
|
One argument is required, corresponding to the namespace that the command(s)
|
||||||
|
can be found in. This is generally defined in the `entry_points` section of
|
||||||
|
either `setup.cfg` or `setup.py`. Refer to the example_ below for more
|
||||||
|
information.
|
||||||
|
|
||||||
|
In addition, the following directive options can be supplied:
|
||||||
|
|
||||||
|
`:command:`
|
||||||
|
|
||||||
|
The name of the command, as it would appear if called from the command
|
||||||
|
line without the executable name. This will be defined in `setup.cfg` or
|
||||||
|
`setup.py` albeit with underscores. This is optional and `fnmatch-style`__
|
||||||
|
wildcarding is supported. Refer to the example_ below for more
|
||||||
|
information.
|
||||||
|
|
||||||
|
.. seealso:: The ``autoprogram_cliff_application`` configuration option.
|
||||||
|
|
||||||
|
The following global configuration values are supported. These should be
|
||||||
|
placed in `conf.py`:
|
||||||
|
|
||||||
|
`autoprogram_cliff_application`
|
||||||
|
|
||||||
|
The top-level application name, which will be prefixed before all
|
||||||
|
commands. This is generally defined in the `console_scripts` attribute of
|
||||||
|
the `entry_points` section of either `setup.cfg` or `setup.py`. Refer to
|
||||||
|
the example_ below for more information.
|
||||||
|
|
||||||
|
.. seealso:: The ``:command:`` directive option.
|
||||||
|
|
||||||
|
.. seealso::
|
||||||
|
|
||||||
|
Module `sphinxcontrib.autoprogram`
|
||||||
|
An equivalent library for use with plain-old `argparse` applications.
|
||||||
|
|
||||||
|
Module `sphinx-click`
|
||||||
|
An equivalent library for use with `click` applications.
|
||||||
|
|
||||||
|
.. important::
|
||||||
|
|
||||||
|
The :rst:dir:`autoprogram-cliff` directive emits :rst:dir:`code-block`
|
||||||
|
snippets marked up as `shell` code. This requires `pygments` >= 0.6.
|
||||||
|
|
||||||
|
.. _example:
|
||||||
|
|
||||||
|
Example
|
||||||
|
=======
|
||||||
|
|
||||||
|
Take a sample `setup.cfg` file, which is based on the `setup.cfg` for the
|
||||||
|
`python-openstackclient` project:
|
||||||
|
|
||||||
|
.. code-block:: ini
|
||||||
|
|
||||||
|
[entry_points]
|
||||||
|
console_scripts =
|
||||||
|
openstack = openstackclient.shell:main
|
||||||
|
|
||||||
|
openstack.compute.v2 =
|
||||||
|
host_list = openstackclient.compute.v2.host:ListHost
|
||||||
|
host_set = openstackclient.compute.v2.host:SetHost
|
||||||
|
host_show = openstackclient.compute.v2.host:ShowHost
|
||||||
|
|
||||||
|
This will register three commands - ``host list``, ``host set`` and ``host
|
||||||
|
show`` - for a top-level executable called ``openstack``. To document the first
|
||||||
|
of these, add the following:
|
||||||
|
|
||||||
|
.. code-block:: rst
|
||||||
|
|
||||||
|
.. autoprogram-cliff:: openstack.compute.v2
|
||||||
|
:command: host list
|
||||||
|
|
||||||
|
You could also register all of these at once like so:
|
||||||
|
|
||||||
|
.. code-block:: rst
|
||||||
|
|
||||||
|
.. autoprogram-cliff:: openstack.compute.v2
|
||||||
|
:command: host *
|
||||||
|
|
||||||
|
Finally, if these are the only commands available in that namespace, you can
|
||||||
|
omit the `:command:` parameter entirely:
|
||||||
|
|
||||||
|
.. code-block:: rst
|
||||||
|
|
||||||
|
.. autoprogram-cliff:: openstack.compute.v2
|
||||||
|
|
||||||
|
In all cases, you should add the following to your `conf.py` to ensure all
|
||||||
|
usage examples show the full command name:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
autoprogram_cliff_application = 'openstack'
|
||||||
|
|
||||||
|
__ http://www.sphinx-doc.org/en/stable/extdev/markupapi.html
|
||||||
|
__ https://docs.python.org/3/library/fnmatch.html
|
Loading…
Reference in New Issue