diff --git a/sphinx_click/ext.py b/sphinx_click/ext.py
index a6ec3f5..6da115f 100644
--- a/sphinx_click/ext.py
+++ b/sphinx_click/ext.py
@@ -1,9 +1,11 @@
+import collections.abc
import inspect
import functools
import re
import traceback
import typing as ty
import warnings
+import itertools
try:
import asyncclick as click
@@ -20,6 +22,7 @@
LOG = logging.getLogger(__name__)
+NESTED_COMPLETE = 'complete'
NESTED_FULL = 'full'
NESTED_SHORT = 'short'
NESTED_NONE = 'none'
@@ -164,8 +167,26 @@ def _format_usage(ctx: click.Context) -> ty.Generator[str, None, None]:
yield ''
-def _format_option(opt: click.Option) -> ty.Generator[str, None, None]:
+def _format_command_name(ctx: click.Context) -> str:
+ command_name: str = ctx.command_path.replace(' ', '-')
+ return command_name
+
+
+def _format_option(
+ ctx: click.Context,
+ opt: click.Option,
+) -> ty.Generator[str, None, None]:
"""Format the output for a `click.Option`."""
+
+ # Add an anchor for each form of option name
+ # For click.option('--flag', '-f', ...) it'll create anchors for "flag" and "f"
+ option_names = list(set([option_name.lstrip('-') for option_name in opt.opts]))
+ for option_name in option_names:
+ yield '.. _{command_name}-{param}:'.format(
+ command_name=_format_command_name(ctx), param=option_name
+ )
+ yield ''
+
opt_help = _get_help_record(opt)
yield '.. option:: {}'.format(opt_help[0])
@@ -195,13 +216,21 @@ def _format_options(ctx: click.Context) -> ty.Generator[str, None, None]:
]
for param in params:
- for line in _format_option(param):
+ for line in _format_option(ctx, param):
yield line
yield ''
-def _format_argument(arg: click.Argument) -> ty.Generator[str, None, None]:
+def _format_argument(
+ ctx: click.Context,
+ arg: click.Argument,
+) -> ty.Generator[str, None, None]:
"""Format the output of a `click.Argument`."""
+ yield '.. _{command_name}-{param}:'.format(
+ command_name=_format_command_name(ctx), param=arg.human_readable_name
+ )
+ yield ''
+
yield '.. option:: {}'.format(arg.human_readable_name)
yield ''
yield _indent(
@@ -217,15 +246,36 @@ def _format_arguments(ctx: click.Context) -> ty.Generator[str, None, None]:
params = [x for x in ctx.command.params if isinstance(x, click.Argument)]
for param in params:
- for line in _format_argument(param):
+ for line in _format_argument(ctx, param):
yield line
yield ''
def _format_envvar(
- param: ty.Union[click.Option, click.Argument]
+ ctx: click.Context,
+ param: ty.Union[click.Option, click.Argument],
) -> ty.Generator[str, None, None]:
"""Format the envvars of a `click.Option` or `click.Argument`."""
+ command_name = _format_command_name(ctx)
+
+ # Add an anchor for each form of parameter name
+ # For click.option('--flag', '-f', ...) it'll create anchors for "flag" and "f"
+ param_names = sorted(set(param_name.lstrip('-') for param_name in param.opts))
+
+ # Only add the parameter's own name if it's not already present, in whatever case
+ if param.name.upper() not in (
+ name.upper() for name in param_names
+ ): # Case-insensitive "in" test
+ param_names.append(param.name)
+
+ for param_name in param_names:
+ yield '.. _{command_name}-{param_name}-{envvar}:'.format(
+ command_name=command_name,
+ param_name=param_name,
+ envvar=param.envvar,
+ )
+ yield ''
+
yield '.. envvar:: {}'.format(param.envvar)
yield ' :noindex:'
yield ''
@@ -254,13 +304,7 @@ def _format_envvars(ctx: click.Context) -> ty.Generator[str, None, None]:
params = [x for x in ctx.command.params if x.envvar]
for param in params:
- yield '.. _{command_name}-{param_name}-{envvar}:'.format(
- command_name=ctx.command_path.replace(' ', '-'),
- param_name=param.name,
- envvar=param.envvar,
- )
- yield ''
- for line in _format_envvar(param):
+ for line in _format_envvar(ctx, param):
yield line
yield ''
@@ -313,10 +357,42 @@ def _filter_commands(
return [lookup[command] for command in commands if command in lookup]
+def _format_header(ctx: click.Context) -> ty.Generator[str, None, None]:
+ for line in _format_description(ctx):
+ yield line
+
+ yield '.. _{command_name}:'.format(
+ command_name=_format_command_name(ctx),
+ )
+ yield ''
+ yield '.. program:: {}'.format(ctx.command_path)
+
+
+def _format_subcommand_summary(
+ ctx: click.Context,
+ commands: ty.Optional[ty.List[str]] = None,
+) -> ty.Generator[str, None, None]:
+ command_objs = _filter_commands(ctx, commands)
+
+ if command_objs:
+ yield '.. rubric:: Commands'
+ yield ''
+
+ for command_obj in command_objs:
+ # Don't show hidden subcommands
+ if command_obj.hidden:
+ continue
+
+ for line in _format_subcommand(command_obj):
+ yield line
+ yield ''
+
+
def _format_command(
ctx: click.Context,
nested: str,
commands: ty.Optional[ty.List[str]] = None,
+ hide_header: bool = False,
) -> ty.Generator[str, None, None]:
"""Format the output of `click.Command`."""
if ctx.command.hidden:
@@ -324,10 +400,9 @@ def _format_command(
# description
- for line in _format_description(ctx):
- yield line
-
- yield '.. program:: {}'.format(ctx.command_path)
+ if nested == NESTED_NONE or not hide_header:
+ for line in _format_header(ctx):
+ yield line
# usage
@@ -375,24 +450,34 @@ def _format_command(
if nested in (NESTED_FULL, NESTED_NONE):
return
- command_objs = _filter_commands(ctx, commands)
+ for line in _format_subcommand_summary(ctx, commands):
+ yield line
- if command_objs:
- yield '.. rubric:: Commands'
- yield ''
- for command_obj in command_objs:
- # Don't show hidden subcommands
- if command_obj.hidden:
- continue
+def _format_summary(
+ ctx: click.Context,
+ commands: ty.Optional[ty.List[str]] = None,
+ hide_header: bool = False,
+) -> ty.Generator[str, None, None]:
+ """Format the output of `click.Command`."""
+ if ctx.command.hidden:
+ return
- for line in _format_subcommand(command_obj):
+ if not hide_header:
+ # description
+ for line in _format_header(ctx):
+ yield line
+
+ # usage
+ for line in _format_usage(ctx):
yield line
- yield ''
+
+ for line in _format_subcommand_summary(ctx, commands):
+ yield line
def nested(argument: ty.Optional[str]) -> ty.Optional[str]:
- values = (NESTED_FULL, NESTED_SHORT, NESTED_NONE, None)
+ values = (NESTED_COMPLETE, NESTED_FULL, NESTED_SHORT, NESTED_NONE, None)
if argument not in values:
raise ValueError(
@@ -411,9 +496,11 @@ class ClickDirective(rst.Directive):
'nested': nested,
'commands': directives.unchanged,
'show-nested': directives.flag,
+ 'hide-header': directives.flag,
+ 'post-process': directives.unchanged_required,
}
- def _load_module(self, module_path: str) -> ty.Union[click.Command, click.Group]:
+ def _load_module(self, module_path: str) -> ty.Any:
"""Load the module."""
try:
@@ -442,14 +529,7 @@ def _load_module(self, module_path: str) -> ty.Union[click.Command, click.Group]
'Module "{}" has no attribute "{}"'.format(module_name, attr_name)
)
- parser = getattr(mod, attr_name)
-
- if not isinstance(parser, (click.Command, click.Group)):
- raise self.error(
- '"{}" of type "{}" is not click.Command or click.Group.'
- '"click.BaseCommand"'.format(type(parser), module_path)
- )
- return parser
+ return getattr(mod, attr_name)
def _generate_nodes(
self,
@@ -459,6 +539,7 @@ def _generate_nodes(
nested: str,
commands: ty.Optional[ty.List[str]] = None,
semantic_group: bool = False,
+ hide_header: bool = False,
) -> ty.List[nodes.section]:
"""Generate the relevant Sphinx nodes.
@@ -472,6 +553,7 @@ def _generate_nodes(
empty
:param semantic_group: Display command as title and description for
`click.CommandCollection`.
+ :param hide_header: Hide the title and summary.
:returns: A list of nested docutil nodes
"""
ctx = click.Context(command, info_name=name, parent=parent)
@@ -479,68 +561,127 @@ def _generate_nodes(
if command.hidden:
return []
- # Title
-
- section = nodes.section(
- '',
- nodes.title(text=name),
- ids=[nodes.make_id(ctx.command_path)],
- names=[nodes.fully_normalize_name(ctx.command_path)],
- )
-
# Summary
source_name = ctx.command_path
result = statemachine.ViewList()
+ lines: collections.abc.Iterator[str] = iter(())
+ hide_current_header = hide_header
+ if nested == NESTED_COMPLETE:
+ lines = itertools.chain(lines, _format_summary(ctx, commands, hide_header))
+ nested = NESTED_FULL
+ hide_current_header = True
+
ctx.meta["sphinx-click-env"] = self.env
if semantic_group:
- lines = _format_description(ctx)
+ lines = itertools.chain(lines, _format_description(ctx))
else:
- lines = _format_command(ctx, nested, commands)
+ lines = itertools.chain(
+ lines, _format_command(ctx, nested, commands, hide_current_header)
+ )
for line in lines:
LOG.debug(line)
result.append(line, source_name)
- sphinx_nodes.nested_parse_with_titles(self.state, result, section)
-
# Subcommands
+ subcommand_nodes = []
if nested == NESTED_FULL:
if isinstance(command, click.CommandCollection):
for source in command.sources:
- section.extend(
+ subcommand_nodes.extend(
self._generate_nodes(
source.name,
source,
parent=ctx,
nested=nested,
semantic_group=True,
+ hide_header=False, # Hiding the header should not propagate to children
)
)
else:
- commands = _filter_commands(ctx, commands)
- for command in commands:
+ # We use the term "subcommand" here but these can be main commands as well
+ for subcommand in _filter_commands(ctx, commands):
parent = ctx if not semantic_group else ctx.parent
- section.extend(
+ subcommand_nodes.extend(
self._generate_nodes(
- command.name, command, parent=parent, nested=nested
+ subcommand.name,
+ subcommand,
+ parent=parent,
+ nested=nested,
+ hide_header=False, # Hiding the header should not propagate to children
)
)
- return [section]
+ final_nodes = []
+ if hide_header:
+ final_nodes = subcommand_nodes
+
+ if nested == NESTED_NONE or nested == NESTED_SHORT:
+ section = nodes.paragraph()
+ self.state.nested_parse(result, 0, section)
+ final_nodes.insert(0, section)
+
+ else:
+ # Title
+
+ section = nodes.section(
+ '',
+ nodes.title(text=name),
+ ids=[nodes.make_id(ctx.command_path)],
+ names=[nodes.fully_normalize_name(ctx.command_path)],
+ )
+
+ sphinx_nodes.nested_parse_with_titles(self.state, result, section)
+
+ for node in subcommand_nodes:
+ section.append(node)
+ final_nodes = [section]
+
+ self._post_process(command, final_nodes)
+
+ return final_nodes
+
+ def _post_process(
+ self,
+ command: click.Command,
+ nodes: ty.List[nodes.section],
+ ) -> None:
+ """Runs the post-processor, if any, for the given command and nodes.
+
+ If a post-processor for the created nodes was set via the
+ :post-process: option, every set of nodes generated by the directive is
+ run through the post-processor.
+
+ This allows for per-command customization of the output.
+ """
+ if self.postprocessor:
+ self.postprocessor(command, nodes)
def run(self) -> ty.Iterable[nodes.section]:
self.env = self.state.document.settings.env
command = self._load_module(self.arguments[0])
+ if not isinstance(command, (click.Command, click.Group)):
+ raise self.error(
+ '"{}" of type "{}" is not click.Command or click.Group.'
+ '"click.BaseCommand"'.format(type(command), self.arguments[0])
+ )
+
if 'prog' not in self.options:
raise self.error(':prog: must be specified')
prog_name = self.options.get('prog')
show_nested = 'show-nested' in self.options
nested = self.options.get('nested')
+ hide_header = 'hide-header' in self.options
+
+ self.postprocessor = None
+ if 'post-process' in self.options:
+ postprocessor_module_path = self.options.get('post-process')
+ self.postprocessor = self._load_module(postprocessor_module_path)
if show_nested:
if nested:
@@ -560,7 +701,9 @@ def run(self) -> ty.Iterable[nodes.section]:
command.strip() for command in self.options.get('commands').split(',')
]
- return self._generate_nodes(prog_name, command, None, nested, commands)
+ return self._generate_nodes(
+ prog_name, command, None, nested, commands, False, hide_header
+ )
def setup(app: application.Sphinx) -> ty.Dict[str, ty.Any]:
diff --git a/tests/test_extension.py b/tests/test_extension.py
index 91984d3..3265160 100644
--- a/tests/test_extension.py
+++ b/tests/test_extension.py
@@ -38,14 +38,16 @@ def test_basics(make_app, rootdir):
assert section[0].astext() == 'greet'
assert isinstance(section[1], nodes.paragraph)
assert section[1].astext() == 'A sample command group.'
- assert isinstance(section[2], nodes.literal_block)
+ assert isinstance(section[2], nodes.target)
+ assert section[2].attributes['refid'] == 'greet'
+ assert isinstance(section[3], nodes.literal_block)
- assert isinstance(section[3], nodes.rubric)
- assert section[3].astext() == 'Commands'
- assert isinstance(section[4], sphinx_nodes.index)
- assert isinstance(section[5], sphinx_nodes.desc)
- assert isinstance(section[6], sphinx_nodes.index)
- assert isinstance(section[7], sphinx_nodes.desc)
+ assert isinstance(section[4], nodes.rubric)
+ assert section[4].astext() == 'Commands'
+ assert isinstance(section[5], sphinx_nodes.index)
+ assert isinstance(section[6], sphinx_nodes.desc)
+ assert isinstance(section[7], sphinx_nodes.index)
+ assert isinstance(section[8], sphinx_nodes.desc)
def test_nested_full(make_app, rootdir):
@@ -82,23 +84,29 @@ def test_nested_full(make_app, rootdir):
assert section[0].astext() == 'greet'
assert isinstance(section[1], nodes.paragraph)
assert section[1].astext() == 'A sample command group.'
- assert isinstance(section[2], nodes.literal_block)
+ assert isinstance(section[2], nodes.target)
+ assert section[2].attributes['refid'] == 'greet'
+ assert isinstance(section[3], nodes.literal_block)
- subsection_a = section[3]
+ subsection_a = section[4]
assert isinstance(subsection_a, nodes.section)
assert isinstance(subsection_a[0], nodes.title)
assert subsection_a[0].astext() == 'hello'
assert isinstance(subsection_a[1], nodes.paragraph)
assert subsection_a[1].astext() == 'Greet a user.'
- assert isinstance(subsection_a[2], nodes.literal_block)
+ assert isinstance(subsection_a[2], nodes.target)
+ assert subsection_a[2].attributes['refid'] == 'greet-hello'
+ assert isinstance(subsection_a[3], nodes.literal_block)
# we don't need to verify the rest of this: that's done elsewhere
- subsection_b = section[4]
+ subsection_b = section[5]
assert isinstance(subsection_b, nodes.section)
assert isinstance(subsection_b[0], nodes.title)
assert subsection_b[0].astext() == 'world'
assert isinstance(subsection_b[1], nodes.paragraph)
assert subsection_b[1].astext() == 'Greet the world.'
- assert isinstance(subsection_b[2], nodes.literal_block)
+ assert isinstance(subsection_b[2], nodes.target)
+ assert subsection_b[2].attributes['refid'] == 'greet-world'
+ assert isinstance(subsection_b[3], nodes.literal_block)
diff --git a/tests/test_formatter.py b/tests/test_formatter.py
index 5d9fbe2..72d9fae 100644
--- a/tests/test_formatter.py
+++ b/tests/test_formatter.py
@@ -32,6 +32,8 @@ def foobar():
"""
A sample command.
+ .. _foobar:
+
.. program:: foobar
.. code-block:: shell
@@ -80,6 +82,8 @@ def foobar(bar):
"""
A sample command.
+ .. _foobar:
+
.. program:: foobar
.. code-block:: shell
@@ -87,32 +91,44 @@ def foobar(bar):
.. rubric:: Options
+ .. _foobar-param:
+
.. option:: --param
A sample option
+ .. _foobar-another:
+
.. option:: --another
Another option
+ .. _foobar-choice:
+
.. option:: --choice
A sample option with choices
:options: Option1 | Option2
+ .. _foobar-numeric-choice:
+
.. option:: --numeric-choice
A sample option with numeric choices
:options: 1 | 2 | 3
+ .. _foobar-flag:
+
.. option:: --flag
A boolean flag
.. rubric:: Arguments
+ .. _foobar-ARG:
+
.. option:: ARG
Required argument
@@ -126,7 +142,7 @@ def foobar(bar):
Provide a default for :option:`--param`
- .. _foobar-arg-ARG:
+ .. _foobar-ARG-ARG:
.. envvar:: ARG
:noindex:
@@ -153,6 +169,8 @@ def foobar(bar):
"""
A sample command.
+ .. _foobar:
+
.. program:: foobar
.. code-block:: shell
@@ -160,6 +178,8 @@ def foobar(bar):
.. rubric:: Options
+ .. _foobar-param:
+
.. option:: --param
A sample option
@@ -204,6 +224,8 @@ def foobar(bar):
"""
A sample command.
+ .. _foobar:
+
.. program:: foobar
.. code-block:: shell
@@ -211,18 +233,26 @@ def foobar(bar):
.. rubric:: Options
+ .. _foobar-num-param:
+
.. option:: --num-param
:default: ``42``
+ .. _foobar-param:
+
.. option:: --param
:default: ``Something computed at runtime``
+ .. _foobar-group:
+
.. option:: --group
:default: ``('foo', 'bar')``
+ .. _foobar-only-show-default:
+
.. option:: --only-show-default
:default: ``Some default computed at runtime!``
@@ -275,6 +305,8 @@ def hello(name):
my_cli hello --name "Jack"
+ .. _hello:
+
.. program:: hello
.. code-block:: shell
@@ -282,6 +314,8 @@ def hello(name):
.. rubric:: Options
+ .. _hello-name:
+
.. option:: --name
**Required** Name to say hello to.
@@ -329,6 +363,8 @@ def foobar():
We've got red text, blue backgrounds, a
dash of bold and even some underlined words.
+ .. _foobar:
+
.. program:: foobar
.. code-block:: shell
@@ -336,16 +372,22 @@ def foobar():
.. rubric:: Options
+ .. _foobar-name:
+
.. option:: --name
**Required** Name to say hello to.
+ .. _foobar-choice:
+
.. option:: --choice
A sample option with choices
:options: Option1 | Option2
+ .. _foobar-param:
+
.. option:: --param
:default: ``Something computed at runtime``
@@ -396,6 +438,8 @@ def cli():
:param click.core.Context ctx: Click context.
+ .. _cli:
+
.. program:: cli
.. code-block:: shell
@@ -465,6 +509,8 @@ def cli():
And this is a paragraph
that will be rewrapped again.
+ .. _cli:
+
.. program:: cli
.. code-block:: shell
@@ -472,6 +518,8 @@ def cli():
.. rubric:: Options
+ .. _cli-param:
+
.. option:: --param
An option containing pre-wrapped text.
@@ -520,6 +568,8 @@ def cli():
"""
A sample command group.
+ .. _cli:
+
.. program:: cli
.. code-block:: shell
@@ -551,6 +601,8 @@ def cli():
"""
A sample command group.
+ .. _cli:
+
.. program:: cli
.. code-block:: shell
@@ -558,12 +610,16 @@ def cli():
.. rubric:: Options
+ .. _cli-param:
+
.. option:: --param
A sample option
.. rubric:: Arguments
+ .. _cli-ARG:
+
.. option:: ARG
Required argument
@@ -577,7 +633,7 @@ def cli():
Provide a default for :option:`--param`
- .. _cli-arg-ARG:
+ .. _cli-ARG-ARG:
.. envvar:: ARG
:noindex:
@@ -621,6 +677,8 @@ def test_nested_short(self):
"""
A sample command group.
+ .. _cli:
+
.. program:: cli
.. code-block:: shell
@@ -650,6 +708,8 @@ def test_nested_full(self):
"""
A sample command group.
+ .. _cli:
+
.. program:: cli
.. code-block:: shell
@@ -673,6 +733,8 @@ def test_nested_none(self):
"""
A sample command group.
+ .. _cli:
+
.. program:: cli
.. code-block:: shell
@@ -682,6 +744,37 @@ def test_nested_none(self):
'\n'.join(output),
)
+ def test_nested_complete(self):
+ """Validate a nested command with 'nested' of 'complete'.
+
+ We should include the contents of 'short' and 'full' formats.
+ """
+
+ ctx = self._get_ctx()
+ output = list(ext._format_command(ctx, nested='complete'))
+
+ self.assertEqual(
+ textwrap.dedent(
+ """
+ A sample command group.
+
+ .. _cli:
+
+ .. program:: cli
+ .. code-block:: shell
+
+ cli [OPTIONS] COMMAND [ARGS]...
+
+ .. rubric:: Commands
+
+ .. object:: hello
+
+ A sample command.
+ """
+ ).lstrip(),
+ '\n'.join(output),
+ )
+
class CommandFilterTestCase(unittest.TestCase):
"""Validate filtering of commands."""
@@ -713,6 +806,8 @@ def test_no_commands(self):
"""
A sample command group.
+ .. _cli:
+
.. program:: cli
.. code-block:: shell
@@ -735,6 +830,8 @@ def test_order_of_commands(self):
"""
A sample command group.
+ .. _cli:
+
.. program:: cli
.. code-block:: shell
@@ -794,6 +891,8 @@ def get_command(self, ctx, name):
"""
A sample custom multicommand.
+ .. _cli:
+
.. program:: cli
.. code-block:: shell
@@ -851,6 +950,8 @@ def get_command(self, ctx, name):
"""
A sample custom multicommand.
+ .. _cli:
+
.. program:: cli
.. code-block:: shell
@@ -906,6 +1007,8 @@ def world():
"""
A simple CommandCollection.
+ .. _cli:
+
.. program:: cli
.. code-block:: shell
@@ -922,6 +1025,8 @@ def world():
"""
A simple CommandCollection.
+ .. _cli:
+
.. program:: cli
.. code-block:: shell
@@ -970,6 +1075,8 @@ def cli_with_auto_envvars():
"""
A simple CLI with auto-env vars .
+ .. _cli:
+
.. program:: cli
.. code-block:: shell
@@ -977,14 +1084,20 @@ def cli_with_auto_envvars():
.. rubric:: Options
+ .. _cli-param:
+
.. option:: --param
Help for param
+ .. _cli-other-param:
+
.. option:: --other-param
Help for other-param
+ .. _cli-param-with-explicit-envvar:
+
.. option:: --param-with-explicit-envvar
Help for param-with-explicit-envvar
@@ -998,6 +1111,8 @@ def cli_with_auto_envvars():
Provide a default for :option:`--param`
+ .. _cli-other-param-PREFIX_OTHER_PARAM:
+
.. _cli-other_param-PREFIX_OTHER_PARAM:
.. envvar:: PREFIX_OTHER_PARAM
@@ -1005,6 +1120,8 @@ def cli_with_auto_envvars():
Provide a default for :option:`--other-param`
+ .. _cli-param-with-explicit-envvar-EXPLICIT_ENVVAR:
+
.. _cli-param_with_explicit_envvar-EXPLICIT_ENVVAR:
.. envvar:: EXPLICIT_ENVVAR