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
|
||||
classes
|
||||
install
|
||||
sphinxext
|
||||
developers
|
||||
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