diff --git a/docs/usage.rst b/docs/usage.rst index 3d228db..4256bb4 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -44,17 +44,29 @@ Once enabled, *sphinx-click* enables automatic documentation for ``short`` List sub-commands with short documentation. + ``complete`` + List sub-commands with short documentation, then also list them with + full documentation. + ``none`` Do not list sub-commands. Defaults to ``short`` unless ``show-nested`` (deprecated) is set. + ``:hide-header:`` + Omit the title, description and usage example, thus only showing the + subcommands. + ``:commands:`` Document only listed commands. ``:show-nested:`` This option is deprecated; use ``nested`` instead. + ``:post-process:`` + Add a post-processing hook that will run for each command. + See :ref:`post-processing` for more information. + The generated documentation includes anchors for the generated commands, their options and their environment variables using the `Sphinx standard domain`_. @@ -276,6 +288,43 @@ assuming the group or command in ``git.git`` is named ``cli``. Refer to `issue #2 `__ for more information. +.. _post-processing: + +Post-processing hook +-------------------- +You can add a post-processing hook that will run for each command as its +documentation is generated. To do so, use the ``:post-process:`` option, +e.g.: + +.. code-block:: rst + + .. click:: module:parser + :prog: hello-world + :post-process: my_library.my_module:my_function + +The function will get the command object and a list of +``docutils.nodes.Element`` entries that were generated for it, which you +can then modify to your heart's content. + +Example: + +.. code-block:: python + + def mark_super_user_commands(command: click.Command, nodes: List[Element]) -> None: + """Marks all commands that start with 'su-' as super user commands.""" + if nodes and len(nodes) > 0 and len(nodes[0].children) > 0: + command_node = nodes[0] + if command.name.startswith("su-"): + command_title = command_node.children[0] + text_node: docutils.nodes.Text = command_title.children[0] + command_title.replace(text_node, docutils.nodes.Text(text_node.as_text() + " <-- SUPER!")) + +**Note**: The function should be specified using the module path and function +name with a colon between them, e.g. ``my_library.my_module:my_function``. +Be sure that this is accessible when generating the documentation (e.g. +add it to the ``PYTHONPATH``). + + .. URLs .. _Sphinx directive: http://www.sphinx-doc.org/en/stable/extdev/markupapi.html diff --git a/releasenotes/notes/add-anchors-options-and-post-processing-6f5619e611353d14.yaml b/releasenotes/notes/add-anchors-options-and-post-processing-6f5619e611353d14.yaml new file mode 100644 index 0000000..12ecbaf --- /dev/null +++ b/releasenotes/notes/add-anchors-options-and-post-processing-6f5619e611353d14.yaml @@ -0,0 +1,18 @@ +--- +features: + - | + Additional anchors are now generated for cross-referencing, namely one for each + ``.. program::`` directive, as well as an anchor for each option name and environment + variable. + + Added the ``:hide-header:`` option, which omits the title and description from the output. + + Added a ``complete`` setting to the ``:nested:`` option (i.e. ``:nested: complete``), which + produces a more detailed listing with title, description, usage, short summary and then the + full nested details. + + Added a ``:post-process:`` option, which configures the plugin to invoke the specified + function in the specified module on the list of generated nodes for each command. For more + information, refer to the `documentation`__. + + .. __: https://sphinx-click.readthedocs.io/en/latest/usage/#post-processing diff --git a/sphinx_click/ext.py b/sphinx_click/ext.py index 9876069..abcf0f2 100644 --- a/sphinx_click/ext.py +++ b/sphinx_click/ext.py @@ -1,5 +1,7 @@ -import inspect +import collections.abc import functools +import inspect +import itertools import re import traceback import typing as ty @@ -21,10 +23,11 @@ LOG = logging.getLogger(__name__) +NESTED_COMPLETE = 'complete' NESTED_FULL = 'full' NESTED_SHORT = 'short' NESTED_NONE = 'none' -NestedT = ty.Literal['full', 'short', 'none', None] +NestedT = ty.Literal['complete', 'full', 'short', 'none', None] ANSI_ESC_SEQ_RE = re.compile(r'\x1B\[\d+(;\d+){0,2}m', flags=re.MULTILINE) @@ -171,10 +174,25 @@ def _format_usage(ctx: click.Context) -> ty.Generator[str, None, None]: yield '' +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.core.Option ) -> ty.Generator[str, None, None]: """Format the output for a `click.core.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(ctx, opt) yield '.. option:: {}'.format(opt_help[0]) @@ -209,8 +227,16 @@ def _format_options(ctx: click.Context) -> ty.Generator[str, None, None]: 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( @@ -233,15 +259,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.core.Option, click.Argument], + ctx: click.Context, + param: click.core.Parameter, ) -> 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 '' @@ -270,13 +317,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 '' @@ -329,10 +370,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: NestedT, 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: @@ -340,10 +413,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 @@ -391,24 +463,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 - yield '' + + # usage + for line in _format_usage(ctx): + yield line + + for line in _format_subcommand_summary(ctx, commands): + yield line def nested(argument: ty.Optional[str]) -> NestedT: - 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( @@ -427,9 +509,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: @@ -458,14 +542,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, @@ -475,7 +552,8 @@ def _generate_nodes( nested: NestedT, commands: ty.Optional[ty.List[str]] = None, semantic_group: bool = False, - ) -> ty.List[nodes.section]: + hide_header: bool = False, + ) -> ty.List[nodes.Element]: """Generate the relevant Sphinx nodes. Format a `click.Group` or `click.Command`. @@ -488,6 +566,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) @@ -495,68 +574,128 @@ 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.StringList() + 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 = ty.cast(NestedT, 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: ty.List[nodes.Element] + section: nodes.Element + 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] - def run(self) -> ty.Sequence[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.Sequence[nodes.Element]: 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['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: @@ -576,7 +715,9 @@ def run(self) -> ty.Sequence[nodes.section]: command.strip() for command in self.options['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 4dfd12d..de75f23 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -38,16 +38,18 @@ 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 section[5].astext() == 'hello\n\nGreet a user.' - assert isinstance(section[6], sphinx_nodes.index) - assert isinstance(section[7], sphinx_nodes.desc) - assert section[7].astext() == 'world\n\nGreet the world.' + 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 section[6].astext() == 'hello\n\nGreet a user.' + assert isinstance(section[7], sphinx_nodes.index) + assert isinstance(section[8], sphinx_nodes.desc) + assert section[8].astext() == 'world\n\nGreet the world.' def test_commands(make_app, rootdir): @@ -80,14 +82,16 @@ def test_commands(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) # we should only show a single command, 'world' - 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 section[5].astext() == 'world\n\nGreet the world.' + 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 section[6].astext() == 'world\n\nGreet the world.' def test_nested_full(make_app, rootdir): @@ -124,23 +128,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 e688f2d..2b0b56c 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 @@ -198,6 +218,8 @@ def foobar(bar): """ A sample command. + .. _foobar: + .. program:: foobar .. code-block:: shell @@ -205,12 +227,16 @@ def foobar(bar): .. rubric:: Options + .. _foobar-option: + .. option:: --option