From e579e68e97060670e320d30c7dfae5bb652f8325 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Mon, 27 Mar 2017 21:38:51 +0100 Subject: [PATCH] Initial release Signed-off-by: Stephen Finucane --- README.rst | 43 ++++++ docs/changelog.rst | 5 + docs/contributing.rst | 57 ++++++++ docs/index.rst | 27 ++-- docs/installation.rst | 20 +++ docs/usage.rst | 71 ++++++++++ setup.cfg | 3 +- sphinx_click/ext.py | 288 ++++++++++++++++++++++++++++++++++++++++ tests/test_formatter.py | 133 +++++++++++++++++++ 9 files changed, 638 insertions(+), 9 deletions(-) create mode 100644 docs/changelog.rst create mode 100644 docs/contributing.rst create mode 100644 docs/installation.rst create mode 100644 docs/usage.rst create mode 100644 sphinx_click/ext.py create mode 100644 tests/test_formatter.py diff --git a/README.rst b/README.rst index c134fac..0393505 100644 --- a/README.rst +++ b/README.rst @@ -7,3 +7,46 @@ documentation from a `click-based`__ application and include it in your docs. __ http://www.sphinx-doc.org/ __ http://click.pocoo.org/ + +Installation +------------ + +Install the plugin using `pip`: + +.. code-block:: shell + + $ pip install sphinx-click + +Alternatively, install from source by cloning this repo then running +`setup.py`: + +.. code-block:: shell + + $ python setup.py install + +Usage +----- + +.. important:: + + To document a click-based application, both the application itself and any + additional dependencies required by that application **must be installed**. + +Enable the plugin in your Sphinx `conf.py` file: + +.. code-block:: python + + extensions = ['sphinx_click.ext'] + +Once enabled, you can now use the plugin wherever necessary in the +documentation. + +.. code-block:: + + .. click:: + :module: my_path.to.my_module + :func: my_func + :prog: hello-world + +Detailed information on the various options available is provided in the +`documentation `_. diff --git a/docs/changelog.rst b/docs/changelog.rst new file mode 100644 index 0000000..a424b3c --- /dev/null +++ b/docs/changelog.rst @@ -0,0 +1,5 @@ +Changes +======= + +.. include:: ../ChangeLog + :start-line: 2 diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 100644 index 0000000..8bc411b --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1,57 @@ +Contribution +============ + +We welcome all contributions to `sphinx-click`. + +Support +------- + +Open and issue in the `issue tracker`_ for all support requests. +`StackOverflow`_ is also worth considering. + +Reporting Issues +---------------- + +Report all issues in the `issue tracker`_. When doing so, please include +version information for: + +- Python +- `click` +- `sphinx-click` + +Submitting Patches +------------------ + +All patches should be submitted as pull requests on the `GitHub project`_. + +- Include tests if fixing a bug + +- Clearly explain what you're trying to accomplish + +- Follow :pep:`8`. You can use the `pep8` tox target for this + +Testing +------- + +`sphinx-click` uses `tox` and `unittest` for testing. To run all tests, run: + +.. code-block:: shell + + $ tox + +We support a number of Python versions. To list available environments, run: + +.. code-block:: shell + + $ tox --list + +To run one of these environments, such as `py27` which runs tests under Python +2.7, run: + +.. code-block:: shell + + $ tox -e py27 + +.. _issue tracker: https://github.com/click-contrib/sphinx-click/issues +.. _StackOverflow: https://stackoverflow.com +.. _GitHub project: https://github.com/click-contrib/sphinx-click diff --git a/docs/index.rst b/docs/index.rst index 0f14f2d..992cf89 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,15 +1,26 @@ -Welcome to sphinx-click's documentation! -======================================== +sphinx-click +============ + +:mod:`sphinx-click ` is a `Sphinx`__ plugin that allows you to +automatically extract documentation from a `click-based`__ application and +include it in your docs. + +__ http://www.sphinx-doc.org/ +__ http://click.pocoo.org/ .. toctree:: :maxdepth: 2 - :caption: Contents: + installation + usage + contributing + changelog +.. seealso:: -Indices and tables -================== + Module :mod:`click` + This extension assumes you are using :mod:`click` to create your command + line application. -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` + Module :mod:`sphinxcontrib.autoprogram` + An equivalent library for use with :mod:`argparse`. diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 0000000..3746563 --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,20 @@ +Installation +============ + +Install the plugin using `pip`: + +.. code-block:: shell + + $ pip install sphinx-click + +Alternatively, install from source by cloning this repo then running +`setup.py`: + +.. code-block:: shell + + $ python setup.py install + +.. important:: + + Both the package you're referencing and any dependencies **must be + installed**. diff --git a/docs/usage.rst b/docs/usage.rst new file mode 100644 index 0000000..f8d1b69 --- /dev/null +++ b/docs/usage.rst @@ -0,0 +1,71 @@ +Usage +===== + +`sphinx-click` enables automatic documentation by way of a `Sphinx +directive`__. + +.. rst:directive:: .. click:: module:parser + + Automatically extract documentation from a `click-based`__ application and + include it in your docs. + + .. code-block:: rst + + .. click:: module:parser + :prog: hello-world + :show-nested: + + The directive takes the import name of the parser object as its sole + argument. + + In addition, the following options are required: + + `:prog:` + The name of your tool (or how it should appear in your documentation). For + example, if you run your script as ``./boo --opts args`` then ``:prog:`` + will be ``boo``. If this is not given, the module name is used. + + The following options are optional: + + `:show-nested:` + Enable full documentation for sub-commands. + +__ http://www.sphinx-doc.org/en/stable/extdev/markupapi.html +__ http://click.pocoo.org/ + +Example +------- + +Take the below `click` application, which is defined in the `example_app` +module: + +.. code-block:: python + + import click + + @click.group() + def greet(): + """A sample command group.""" + pass + + @greet.command() + @click.argument('user') + def hello(user): + """Greet a user.""" + click.echo('Hello %s' % user) + +To document this, use the following: + +.. code-block:: rst + + .. click:: hello_world:greet + :prog: hello-world + +If you wish to include full documentation for the subcommand, ``hello``, in the +output, add the ``show-nested`` flag. + +.. code-block:: rst + + .. click:: hello_world:greet + :prog: hello-world + :show-nested: diff --git a/setup.cfg b/setup.cfg index 50fdeb2..5729d7d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -7,7 +7,7 @@ classifiers = Programming Language :: Python :: 2 Programming Language :: Python :: 3 Programming Language :: Python - Development Status :: 1 - Planning + Development Status :: 5 - Production/Stable Environment :: Console Intended Audience :: Developers Intended Audience :: Information Technology @@ -28,6 +28,7 @@ all-files = 1 warning-is-error = 1 build-dir = docs/_build source-dir = docs +builders = html [wheel] universal = 1 diff --git a/sphinx_click/ext.py b/sphinx_click/ext.py new file mode 100644 index 0000000..f7717de --- /dev/null +++ b/sphinx_click/ext.py @@ -0,0 +1,288 @@ +from docutils import nodes +from docutils.parsers.rst import directives +from docutils import statemachine + +import click +from sphinx.util.compat import Directive + + +def _indent(text, level=1): + prefix = ' ' * (4 * level) + + def prefixed_lines(): + for line in text.splitlines(True): + yield (prefix + line if line.strip() else line) + + return ''.join(prefixed_lines()) + + +def _get_usage(ctx): + """Alternative, non-prefixed version of 'get_usage'.""" + formatter = ctx.make_formatter() + pieces = ctx.command.collect_usage_pieces(ctx) + formatter.write_usage(ctx.command_path, ' '.join(pieces), prefix='') + return formatter.getvalue().rstrip('\n') + + +def _get_help_record(opt): + """Re-implementation of click.Opt.get_help_record. + + The variant of 'get_help_record' found in Click makes uses of slashes to + separate multiple opts, and formats option arguments using upper case. This + is not compatible with Sphinx's 'option' directive, which expects + comma-separated opts and option arguments surrounded by angle brackets [1]. + + [1] http://www.sphinx-doc.org/en/stable/domains.html#directive-option + """ + def _write_opts(opts): + rv, _ = click.formatting.join_options(opts) + if not opt.is_flag and not opt.count: + rv += ' <{}>'.format(opt.name) + return rv + + rv = [_write_opts(opt.opts)] + if opt.secondary_opts: + rv.append(_write_opts(opt.secondary_opts)) + + help = opt.help or '' + extra = [] + if opt.default is not None and opt.show_default: + extra.append('default: %s' % ( + ', '.join('%s' % d for d in opt.default) + if isinstance(opt.default, (list, tuple)) + else opt.default, )) + if opt.required: + extra.append('required') + if extra: + help = '%s[%s]' % (help and help + ' ' or '', '; '.join(extra)) + + return ', '.join(rv), help + + +def _format_option(opt): + """Format the output a `click.Option`.""" + opt = _get_help_record(opt) + + yield '.. option:: {}'.format(opt[0]) + if opt[1]: + yield '' + for line in statemachine.string2lines( + opt[1], tab_width=4, convert_whitespace=True): + yield _indent(line) + + +def _format_argument(arg): + """Format the output of a `click.Argument`.""" + yield '.. option:: {}'.format(arg.human_readable_name) + yield '' + yield _indent('{} argument{}'.format( + 'Required' if arg.required else 'Optional', + '(s)' if arg.nargs != 1 else '')) + + +def _format_envvar(param): + """Format the envvars of a `click.Option` or `click.Argument`.""" + yield '.. envvar:: {}'.format(param.envvar) + yield '' + if isinstance(param, click.Argument): + param_ref = param.human_readable_name + else: + # if a user has defined an opt with multiple "aliases", always use the + # first. For example, if '--foo' or '-f' are possible, use '--foo'. + param_ref = param.opts[0] + + yield _indent('Provide a default for :option:`{}`'.format(param_ref)) + + +def _format_subcommand(command): + """Format a sub-command of a `click.Command` or `click.Group`.""" + yield '.. object:: {}'.format(command[0]) + + if command[1].short_help: + yield '' + for line in statemachine.string2lines( + command[1].short_help, tab_width=4, convert_whitespace=True): + yield _indent(line) + + +def _format_command(ctx, show_nested): + """Format the output of `click.Command`.""" + yield '.. program:: {}'.format(ctx.command_path) + + # usage + + yield '.. code-block:: shell' + yield '' + for line in _get_usage(ctx).splitlines(): + yield _indent(line) + yield '' + + # options + + # the hidden attribute is part of click 7.x only hence use of getattr + params = [x for x in ctx.command.params if isinstance(x, click.Option) + and not getattr(x, 'hidden', False)] + + if params: + # we use rubric to provide some separation without exploding the table + # of contents + yield '.. rubric:: Options' + yield '' + + for param in params: + for line in _format_option(param): + yield line + yield '' + + # arguments + + params = [x for x in ctx.command.params if isinstance(x, click.Argument)] + + if params: + yield '.. rubric:: Arguments' + yield '' + + for param in params: + for line in _format_argument(param): + yield line + yield '' + + # environment variables + + params = [x for x in ctx.command.params if getattr(x, 'envvar')] + + if params: + yield '.. rubric:: Environment variables' + yield '' + + for param in params: + for line in _format_envvar(param): + yield line + yield '' + + # if we're nesting commands, we need to do this slightly differently + if show_nested: + return + + commands = getattr(ctx.command, 'commands', {}).items() + + if commands: + yield '.. rubric:: Commands' + yield '' + + for command in commands: + for line in _format_subcommand(command): + yield line + yield '' + + +class ClickDirective(Directive): + + has_content = False + required_arguments = 1 + option_spec = { + 'prog': directives.unchanged_required, + 'show-nested': directives.flag, + } + + def _load_module(self, module_path): + """Load the module.""" + try: + module_name, attr_name = module_path.split(':', 1) + except ValueError: # noqa + raise self.error('"{}" is not of format "module.parser"'.format( + module_path)) + + try: + mod = __import__(module_name, globals(), locals(), [attr_name]) + except: # noqa + raise self.error('Failed to import "{}" from "{}"'.format( + attr_name, module_name)) + + if not hasattr(mod, attr_name): + raise self.error('Module "{}" has no attribute "{}"'.format( + module_name, attr_name)) + + return getattr(mod, attr_name) + + def _generate_nodes(self, name, command, parent=None, show_nested=False): + """Generate the relevant Sphinx nodes. + + Format a `click.Group` or `click.Command`. + + :param name: Name of command, as used on the command line + :param command: Instance of `click.Group` or `click.Command` + :param parent: Instance of `click.Context`, or None + :param show_nested: Whether subcommands should be included in output + :returns: A list of nested docutil nodes + """ + ctx = click.Context(command, info_name=name, parent=parent) + + # Title + + # We build this with plain old docutils nodes + + section = nodes.section( + '', + nodes.title(text=name), + ids=[nodes.make_id(ctx.command_path)], + names=[nodes.fully_normalize_name(ctx.command_path)]) + + source_name = ctx.command_path + result = statemachine.ViewList() + + # Description + + # We parse this as reStructuredText, allowing users to embed rich + # information in their help messages if they so choose. + + for line in statemachine.string2lines( + ctx.command.help, tab_width=4, convert_whitespace=True): + result.append(line, source_name) + + result.append('', source_name) + + # Summary + + if isinstance(command, click.Command): + summary = _format_command(ctx, show_nested) + else: + # TODO(stephenfin): Do we care to differentiate? Perhaps we + # shouldn't show usage for groups? + summary = _format_command(ctx, show_nested) + + for line in summary: + result.append(line, source_name) + + self.state.nested_parse(result, 0, section) + + # Commands + + if show_nested: + commands = getattr(ctx.command, 'commands', {}) + for command_name, command_obj in commands.items(): + section.extend(self._generate_nodes( + command_name, + command_obj, + ctx, + show_nested)) + + return [section] + + def run(self): + self.env = self.state.document.settings.env + + command = self._load_module(self.arguments[0]) + + if 'prog' in self.options: + prog_name = self.options.get('prog') + else: + raise self.error(':prog: must be specified') + + show_nested = 'show-nested' in self.options + + return self._generate_nodes(prog_name, command, None, show_nested) + + +def setup(app): + app.add_directive('click', ClickDirective) diff --git a/tests/test_formatter.py b/tests/test_formatter.py new file mode 100644 index 0000000..9288e01 --- /dev/null +++ b/tests/test_formatter.py @@ -0,0 +1,133 @@ +import textwrap +import unittest + +import click + +from sphinx_click import ext + + +class GroupTestCase(unittest.TestCase): + + def test_no_parameters(self): + """Validate a `click.Group` with no parameters. + + This exercises the code paths for a group with *no* arguments, *no* + options and *no* environment variables. + """ + + @click.group() + def cli(): + """A sample command group.""" + pass + + ctx = click.Context(cli, info_name='cli') + output = list(ext._format_command(ctx, show_nested=False)) + + self.assertEqual(textwrap.dedent(""" + .. program:: cli + .. code-block:: shell + + cli [OPTIONS] COMMAND [ARGS]... + """).lstrip(), '\n'.join(output)) + + def test_basic_parameters(self): + """Validate a combination of parameters. + + This exercises the code paths for a group with arguments, options and + environment variables. + """ + + @click.group() + @click.option('--param', envvar='PARAM', help='A sample option') + @click.argument('ARG', envvar='ARG') + def cli(): + """A sample command group.""" + pass + + ctx = click.Context(cli, info_name='cli') + output = list(ext._format_command(ctx, show_nested=False)) + + self.assertEqual(textwrap.dedent(""" + .. program:: cli + .. code-block:: shell + + cli [OPTIONS] ARG COMMAND [ARGS]... + + .. rubric:: Options + + .. option:: --param + + A sample option + + .. rubric:: Arguments + + .. option:: ARG + + Required argument + + .. rubric:: Environment variables + + .. envvar:: PARAM + + Provide a default for :option:`--param` + + .. envvar:: ARG + + Provide a default for :option:`ARG` + """).lstrip(), '\n'.join(output)) + + +class NestedCommandsTestCase(unittest.TestCase): + + @staticmethod + def _get_ctx(): + + @click.group() + def cli(): + """A sample command group.""" + pass + + @cli.command() + def hello(): + """A sample command.""" + pass + + return click.Context(cli, info_name='cli') + + def test_hide_nested(self): + """Validate a nested command without show_nested. + + If we're not showing sub-commands separately, we should list them. + """ + + ctx = self._get_ctx() + output = list(ext._format_command(ctx, show_nested=False)) + + self.assertEqual(textwrap.dedent(""" + .. program:: cli + .. code-block:: shell + + cli [OPTIONS] COMMAND [ARGS]... + + .. rubric:: Commands + + .. object:: hello + + A sample command. + """).lstrip(), '\n'.join(output)) + + def test_show_nested(self): + """Validate a nested command without show_nested. + + If we're not showing sub-commands separately, we should not list them. + """ + + ctx = self._get_ctx() + output = list(ext._format_command(ctx, show_nested=True)) + + self.assertEqual(textwrap.dedent(""" + .. program:: cli + .. code-block:: shell + + cli [OPTIONS] COMMAND [ARGS]... + """).lstrip(), '\n'.join(output))