diff --git a/README.md b/README.md index 9df57ee..47a9424 100644 --- a/README.md +++ b/README.md @@ -136,3 +136,5 @@ Options: - `style`: _(Optional, default: `plain`)_ Style for the options section. The possible choices are `plain` and `table`. - `remove_ascii_art`: _(Optional, default: `False`)_ When docstrings begin with the escape character `\b`, all text will be ignored until the next blank line is encountered. - `show_hidden`: _(Optional, default: `False`)_ Show commands and options that are marked as hidden. +- `list_subcommands`: _(Optional, default: `False`)_ List subcommands of a given command. If _attr_list_ is installed, +add links to subcommands also. \ No newline at end of file diff --git a/mkdocs_click/_docs.py b/mkdocs_click/_docs.py index 0a5aecb..31853f3 100644 --- a/mkdocs_click/_docs.py +++ b/mkdocs_click/_docs.py @@ -3,7 +3,7 @@ # Licensed under the Apache license (see LICENSE) import inspect from contextlib import contextmanager, ExitStack -from typing import Iterator, List, cast +from typing import Iterator, List, cast, Optional import click from markdown.extensions.toc import slugify @@ -18,6 +18,7 @@ def make_command_docs( style: str = "plain", remove_ascii_art: bool = False, show_hidden: bool = False, + list_subcommands: bool = False, has_attr_list: bool = False, ) -> Iterator[str]: """Create the Markdown lines for a command and its sub-commands.""" @@ -28,6 +29,7 @@ def make_command_docs( style=style, remove_ascii_art=remove_ascii_art, show_hidden=show_hidden, + list_subcommands=list_subcommands, has_attr_list=has_attr_list, ): if line.strip() == "\b": @@ -44,10 +46,11 @@ def _recursively_make_command_docs( style: str = "plain", remove_ascii_art: bool = False, show_hidden: bool = False, + list_subcommands: bool = False, has_attr_list: bool = False, ) -> Iterator[str]: """Create the raw Markdown lines for a command and its sub-commands.""" - ctx = click.Context(cast(click.Command, command), info_name=prog_name, parent=parent) + ctx = _build_command_context(prog_name=prog_name, command=command, parent=parent) if ctx.command.hidden and not show_hidden: return @@ -58,8 +61,20 @@ def _recursively_make_command_docs( yield from _make_options(ctx, style, show_hidden=show_hidden) subcommands = _get_sub_commands(ctx.command, ctx) + if len(subcommands) == 0: + return + + subcommands.sort(key=lambda cmd: str(cmd.name)) + + if list_subcommands: + yield from _make_subcommands_links( + subcommands, + ctx, + has_attr_list=has_attr_list, + show_hidden=show_hidden, + ) - for command in sorted(subcommands, key=lambda cmd: cmd.name): # type: ignore + for command in subcommands: yield from _recursively_make_command_docs( cast(str, command.name), command, @@ -67,15 +82,22 @@ def _recursively_make_command_docs( depth=depth + 1, style=style, show_hidden=show_hidden, + list_subcommands=list_subcommands, has_attr_list=has_attr_list, ) +def _build_command_context( + prog_name: str, command: click.BaseCommand, parent: Optional[click.Context] +) -> click.Context: + return click.Context(cast(click.Command, command), info_name=prog_name, parent=parent) + + def _get_sub_commands(command: click.Command, ctx: click.Context) -> List[click.Command]: """Return subcommands of a Click command.""" subcommands = getattr(command, "commands", {}) if subcommands: - return subcommands.values() # type: ignore + return list(subcommands.values()) if not isinstance(command, click.MultiCommand): return [] @@ -131,26 +153,29 @@ def _make_description(ctx: click.Context, remove_ascii_art: bool = False) -> Ite """Create markdown lines based on the command's own description.""" help_string = ctx.command.help or ctx.command.short_help - if help_string: - # https://github.com/pallets/click/pull/2151 - help_string = inspect.cleandoc(help_string) - - if remove_ascii_art: - skipped_ascii_art = True - for i, line in enumerate(help_string.splitlines()): - if skipped_ascii_art is False: - if not line.strip(): - skipped_ascii_art = True - continue - elif i == 0 and line.strip() == "\b": - skipped_ascii_art = False - - if skipped_ascii_art: - yield line - else: - yield from help_string.splitlines() + if not help_string: + return + + # https://github.com/pallets/click/pull/2151 + help_string = inspect.cleandoc(help_string) + if not remove_ascii_art: + yield from help_string.splitlines() yield "" + return + + skipped_ascii_art = True + for i, line in enumerate(help_string.splitlines()): + if skipped_ascii_art is False: + if not line.strip(): + skipped_ascii_art = True + continue + elif i == 0 and line.strip() == "\b": + skipped_ascii_art = False + + if skipped_ascii_art: + yield line + yield "" def _make_usage(ctx: click.Context) -> Iterator[str]: @@ -300,3 +325,27 @@ def _make_table_options(ctx: click.Context, show_hidden: bool = False) -> Iterat yield "| ---- | ---- | ----------- | ------- |" yield from option_rows yield "" + + +def _make_subcommands_links( + subcommands: List[click.Command], + parent: click.Context, + has_attr_list: bool, + show_hidden: bool, +) -> Iterator[str]: + + yield "**Subcommands**" + yield "" + for command in subcommands: + command_name = cast(str, command.name) + ctx = _build_command_context(command_name, command, parent) + if ctx.command.hidden and not show_hidden: + continue + command_bullet = command_name if not has_attr_list else f"[{command_name}](#{slugify(ctx.command_path, '-')})" + help_string = ctx.command.short_help or ctx.command.help + if help_string is not None: + help_string = help_string.splitlines()[0] + else: + help_string = "*No description was provided with this command.*" + yield f"- *{command_bullet}*: {help_string}" + yield "" diff --git a/mkdocs_click/_extension.py b/mkdocs_click/_extension.py index 05fbad5..4fd5989 100644 --- a/mkdocs_click/_extension.py +++ b/mkdocs_click/_extension.py @@ -25,6 +25,7 @@ def replace_command_docs(has_attr_list: bool = False, **options: Any) -> Iterato style = options.get("style", "plain") remove_ascii_art = options.get("remove_ascii_art", False) show_hidden = options.get("show_hidden", False) + list_subcommands = options.get("list_subcommands", False) command_obj = load_command(module, command) @@ -37,6 +38,7 @@ def replace_command_docs(has_attr_list: bool = False, **options: Any) -> Iterato style=style, remove_ascii_art=remove_ascii_art, show_hidden=show_hidden, + list_subcommands=list_subcommands, has_attr_list=has_attr_list, ) diff --git a/mkdocs_click/_processing.py b/mkdocs_click/_processing.py index 7fb7b28..a816eb7 100644 --- a/mkdocs_click/_processing.py +++ b/mkdocs_click/_processing.py @@ -27,6 +27,8 @@ def replace_blocks(lines: Iterable[str], title: str, replace: Callable[..., Iter # New ':key:' or ':key: value' line, ingest it. key = match.group("key") value = match.group("value") or "" + if value.lower() in ["true", "false"]: + value = True if value.lower() == "true" else False options[key] = value continue diff --git a/tests/app/expected-sub-enhanced.md b/tests/app/expected-sub-enhanced.md new file mode 100644 index 0000000..62003ce --- /dev/null +++ b/tests/app/expected-sub-enhanced.md @@ -0,0 +1,72 @@ +# cli { #cli data-toc-label="cli" } + +Main entrypoint for this dummy program + +**Usage:** + +``` +cli [OPTIONS] COMMAND [ARGS]... +``` + +**Options:** + +``` + --help Show this message and exit. +``` + +**Subcommands** + +- *[bar](#cli-bar)*: The bar command +- *[foo](#cli-foo)*: *No description was provided with this command.* + +## cli bar { #cli-bar data-toc-label="bar" } + +The bar command + +**Usage:** + +``` +cli bar [OPTIONS] COMMAND [ARGS]... +``` + +**Options:** + +``` + --help Show this message and exit. +``` + +**Subcommands** + +- *[hello](#cli-bar-hello)*: Simple program that greets NAME for a total of COUNT times. + +### cli bar hello { #cli-bar-hello data-toc-label="hello" } + +Simple program that greets NAME for a total of COUNT times. + +**Usage:** + +``` +cli bar hello [OPTIONS] +``` + +**Options:** + +``` + --count INTEGER Number of greetings. + --name TEXT The person to greet. + --help Show this message and exit. +``` + +## cli foo { #cli-foo data-toc-label="foo" } + +**Usage:** + +``` +cli foo [OPTIONS] +``` + +**Options:** + +``` + --help Show this message and exit. +``` diff --git a/tests/app/expected-sub.md b/tests/app/expected-sub.md new file mode 100644 index 0000000..0df6708 --- /dev/null +++ b/tests/app/expected-sub.md @@ -0,0 +1,72 @@ +# cli + +Main entrypoint for this dummy program + +**Usage:** + +``` +cli [OPTIONS] COMMAND [ARGS]... +``` + +**Options:** + +``` + --help Show this message and exit. +``` + +**Subcommands** + +- *bar*: The bar command +- *foo*: *No description was provided with this command.* + +## bar + +The bar command + +**Usage:** + +``` +cli bar [OPTIONS] COMMAND [ARGS]... +``` + +**Options:** + +``` + --help Show this message and exit. +``` + +**Subcommands** + +- *hello*: Simple program that greets NAME for a total of COUNT times. + +### hello + +Simple program that greets NAME for a total of COUNT times. + +**Usage:** + +``` +cli bar hello [OPTIONS] +``` + +**Options:** + +``` + --count INTEGER Number of greetings. + --name TEXT The person to greet. + --help Show this message and exit. +``` + +## foo + +**Usage:** + +``` +cli foo [OPTIONS] +``` + +**Options:** + +``` + --help Show this message and exit. +``` diff --git a/tests/test_docs.py b/tests/test_docs.py index 70c4da5..bcb4848 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -234,6 +234,62 @@ def test_custom_multicommand(multi): assert output == expected +@pytest.mark.parametrize( + "multi", + [ + pytest.param(MultiCLI("multi", help="Multi help"), id="explicit-name"), + pytest.param(MultiCLI(help="Multi help"), id="no-name"), + ], +) +def test_custom_multicommand_with_list_subcommands(multi): + """ + Custom `MultiCommand` objects are supported (i.e. not just `Group` multi-commands). + """ + expected = dedent( + """ + # multi + + Multi help + + **Usage:** + + ``` + multi [OPTIONS] COMMAND [ARGS]... + ``` + + **Options:** + + ``` + --help Show this message and exit. + ``` + + **Subcommands** + + - *hello*: Hello, world! + + ## hello + + Hello, world! + + **Usage:** + + ``` + multi hello [OPTIONS] + ``` + + **Options:** + + ``` + -d, --debug TEXT Include debug output + --help Show this message and exit. + ``` + """ + ).lstrip() + + output = "\n".join(make_command_docs("multi", multi, list_subcommands=True)) + assert output == expected + + @pytest.mark.parametrize("show_hidden", [True, False]) @pytest.mark.parametrize("style", ["plain", "table"]) def test_show_hidden_option(show_hidden, style): diff --git a/tests/test_extension.py b/tests/test_extension.py index 332db46..7ae2e5a 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -11,6 +11,8 @@ EXPECTED = (Path(__file__).parent / "app" / "expected.md").read_text() EXPECTED_ENHANCED = (Path(__file__).parent / "app" / "expected-enhanced.md").read_text() +EXPECTED_SUB = (Path(__file__).parent / "app" / "expected-sub.md").read_text() +EXPECTED_SUB_ENHANCED = (Path(__file__).parent / "app" / "expected-sub-enhanced.md").read_text() @pytest.mark.parametrize( @@ -124,3 +126,64 @@ def test_enhanced_titles(): ) assert md.convert(source) == md.convert(EXPECTED_ENHANCED) + + +@pytest.mark.parametrize( + "command, expected_name", + [ + pytest.param("cli", "cli", id="cli-simple"), + pytest.param("cli_named", "cli", id="cli-explicit-name"), + pytest.param("multi_named", "multi", id="multi-explicit-name"), + pytest.param("multi", "multi", id="no-name"), + ], +) +def test_extension_with_subcommand(command, expected_name): + """ + Markdown output for a relatively complex Click application is correct. + """ + md = Markdown(extensions=[mkdocs_click.makeExtension()]) + + source = dedent( + f""" + ::: mkdocs-click + :module: tests.app.cli + :command: {command} + :list_subcommands: True + """ + ) + + expected = EXPECTED_SUB.replace("cli", expected_name) + + assert md.convert(source) == md.convert(expected) + + +@pytest.mark.parametrize( + "command, expected_name", + [ + pytest.param("cli", "cli", id="cli-simple"), + pytest.param("cli_named", "cli", id="cli-explicit-name"), + pytest.param("multi_named", "multi", id="multi-explicit-name"), + pytest.param("multi", "multi", id="no-name"), + ], +) +def test_enhanced_titles_with_subcommand(command, expected_name): + """ + Markdown output for a relatively complex Click application is correct. + """ + md = Markdown(extensions=["attr_list"]) + # Register our extension as a second step, so that we see `attr_list`. + # This is what MkDocs does, so there's no hidden usage constraint here. + md.registerExtensions([mkdocs_click.makeExtension()], {}) + + source = dedent( + f""" + ::: mkdocs-click + :module: tests.app.cli + :command: {command} + :list_subcommands: True + """ + ) + + expected = EXPECTED_SUB_ENHANCED.replace("cli", expected_name) + + assert md.convert(source) == md.convert(expected)