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