From 5ccd2006d3f3cbbf9847bfae18a43cd60c798bb3 Mon Sep 17 00:00:00 2001 From: Sagi shadur Date: Tue, 14 Jun 2022 16:18:24 +0300 Subject: [PATCH 1/6] Subcommands are now listed as part of the command --- mkdocs_click/_docs.py | 83 +++++++++++++++++++++++++--------- mkdocs_click/_extension.py | 2 + tests/app/expected-enhanced.md | 4 ++ tests/app/expected.md | 4 ++ tests/test_docs.py | 4 ++ 5 files changed, 75 insertions(+), 22 deletions(-) diff --git a/mkdocs_click/_docs.py b/mkdocs_click/_docs.py index 0a5aecb..d9707c6 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 = True, 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 = True, 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,15 @@ 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: cmd.name) + + if list_subcommands: + yield from _make_subcommands_links(subcommands, ctx, has_attr_list=has_attr_list) - 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, @@ -71,11 +81,15 @@ def _recursively_make_command_docs( ) +def _build_command_context(prog_name: str, command: click.BaseCommand, parent: Optional[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 +145,30 @@ 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 +318,24 @@ 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.BaseCommand], + parent: click.Context, + has_attr_list: bool +): + + yield "**Subcommands**" + yield "" + for command in subcommands: + command_name = cast(str, command.name) + ctx = _build_command_context(command_name, command, parent) + 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.splitlines()[0] + 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/tests/app/expected-enhanced.md b/tests/app/expected-enhanced.md index 4b2c490..769e006 100644 --- a/tests/app/expected-enhanced.md +++ b/tests/app/expected-enhanced.md @@ -30,6 +30,10 @@ cli bar [OPTIONS] COMMAND [ARGS]... --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. diff --git a/tests/app/expected.md b/tests/app/expected.md index de309db..0ccc6de 100644 --- a/tests/app/expected.md +++ b/tests/app/expected.md @@ -30,6 +30,10 @@ cli bar [OPTIONS] COMMAND [ARGS]... --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. diff --git a/tests/test_docs.py b/tests/test_docs.py index 70c4da5..f491f40 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -211,6 +211,10 @@ def test_custom_multicommand(multi): --help Show this message and exit. ``` + **Subcommands** + + - *hello*: Hello, world! + ## hello Hello, world! From f7f21698a5dc75cf892684de4eb24b873b3e6d49 Mon Sep 17 00:00:00 2001 From: Sagi shadur Date: Tue, 14 Jun 2022 16:20:22 +0300 Subject: [PATCH 2/6] Document new ability in README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 9df57ee..5a83b91 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: `True`)_ List subcommands of a given command. If _attr_list_ is installed, +add links to subcommands also. \ No newline at end of file From 746e993bf250efa261437d2605b16d1a0171ebea Mon Sep 17 00:00:00 2001 From: Sagi Shadur Date: Tue, 14 Jun 2022 18:19:10 +0300 Subject: [PATCH 3/6] Fix style errors --- mkdocs_click/_docs.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/mkdocs_click/_docs.py b/mkdocs_click/_docs.py index d9707c6..6d54f4b 100644 --- a/mkdocs_click/_docs.py +++ b/mkdocs_click/_docs.py @@ -64,7 +64,7 @@ def _recursively_make_command_docs( if len(subcommands) == 0: return - subcommands.sort(key=lambda cmd: cmd.name) + subcommands.sort(key=lambda cmd: str(cmd.name)) if list_subcommands: yield from _make_subcommands_links(subcommands, ctx, has_attr_list=has_attr_list) @@ -81,7 +81,9 @@ def _recursively_make_command_docs( ) -def _build_command_context(prog_name: str, command: click.BaseCommand, parent: Optional[click.Context]): +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) @@ -170,7 +172,6 @@ def _make_description(ctx: click.Context, remove_ascii_art: bool = False) -> Ite yield "" - def _make_usage(ctx: click.Context) -> Iterator[str]: """Create the Markdown lines from the command usage string.""" @@ -321,21 +322,17 @@ def _make_table_options(ctx: click.Context, show_hidden: bool = False) -> Iterat def _make_subcommands_links( - subcommands: List[click.BaseCommand], - parent: click.Context, - has_attr_list: bool -): + subcommands: List[click.Command], parent: click.Context, has_attr_list: bool +) -> Iterator[str]: yield "**Subcommands**" yield "" for command in subcommands: command_name = cast(str, command.name) ctx = _build_command_context(command_name, command, parent) - 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.splitlines()[0] + 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] yield f"- *{command_bullet}*: {help_string}" yield "" From 1ecf50f4fa09e3b7c3864a8425ffe6153bc49e93 Mon Sep 17 00:00:00 2001 From: Sagi Shadur Date: Wed, 15 Jun 2022 10:27:03 +0300 Subject: [PATCH 4/6] `list_subcommands` is no longer True by default --- mkdocs_click/_docs.py | 21 +++++++++++++++++---- mkdocs_click/_processing.py | 2 ++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/mkdocs_click/_docs.py b/mkdocs_click/_docs.py index 6d54f4b..31853f3 100644 --- a/mkdocs_click/_docs.py +++ b/mkdocs_click/_docs.py @@ -18,7 +18,7 @@ def make_command_docs( style: str = "plain", remove_ascii_art: bool = False, show_hidden: bool = False, - list_subcommands: bool = True, + list_subcommands: bool = False, has_attr_list: bool = False, ) -> Iterator[str]: """Create the Markdown lines for a command and its sub-commands.""" @@ -46,7 +46,7 @@ def _recursively_make_command_docs( style: str = "plain", remove_ascii_art: bool = False, show_hidden: bool = False, - list_subcommands: bool = True, + list_subcommands: bool = False, has_attr_list: bool = False, ) -> Iterator[str]: """Create the raw Markdown lines for a command and its sub-commands.""" @@ -67,7 +67,12 @@ def _recursively_make_command_docs( subcommands.sort(key=lambda cmd: str(cmd.name)) if list_subcommands: - yield from _make_subcommands_links(subcommands, ctx, has_attr_list=has_attr_list) + yield from _make_subcommands_links( + subcommands, + ctx, + has_attr_list=has_attr_list, + show_hidden=show_hidden, + ) for command in subcommands: yield from _recursively_make_command_docs( @@ -77,6 +82,7 @@ def _recursively_make_command_docs( depth=depth + 1, style=style, show_hidden=show_hidden, + list_subcommands=list_subcommands, has_attr_list=has_attr_list, ) @@ -322,7 +328,10 @@ def _make_table_options(ctx: click.Context, show_hidden: bool = False) -> Iterat def _make_subcommands_links( - subcommands: List[click.Command], parent: click.Context, has_attr_list: bool + subcommands: List[click.Command], + parent: click.Context, + has_attr_list: bool, + show_hidden: bool, ) -> Iterator[str]: yield "**Subcommands**" @@ -330,9 +339,13 @@ def _make_subcommands_links( 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/_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 From 33aeefe5222eeb7a2e143a4702edbc2e3319e612 Mon Sep 17 00:00:00 2001 From: Sagi Shadur Date: Wed, 15 Jun 2022 10:27:24 +0300 Subject: [PATCH 5/6] Added more unit tests for `list_commands` --- tests/app/expected-enhanced.md | 4 -- tests/app/expected-sub-enhanced.md | 72 ++++++++++++++++++++++++++++++ tests/app/expected-sub.md | 72 ++++++++++++++++++++++++++++++ tests/app/expected.md | 4 -- tests/test_docs.py | 56 ++++++++++++++++++++++- tests/test_extension.py | 63 ++++++++++++++++++++++++++ 6 files changed, 261 insertions(+), 10 deletions(-) create mode 100644 tests/app/expected-sub-enhanced.md create mode 100644 tests/app/expected-sub.md diff --git a/tests/app/expected-enhanced.md b/tests/app/expected-enhanced.md index 769e006..4b2c490 100644 --- a/tests/app/expected-enhanced.md +++ b/tests/app/expected-enhanced.md @@ -30,10 +30,6 @@ cli bar [OPTIONS] COMMAND [ARGS]... --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. 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/app/expected.md b/tests/app/expected.md index 0ccc6de..de309db 100644 --- a/tests/app/expected.md +++ b/tests/app/expected.md @@ -30,10 +30,6 @@ cli bar [OPTIONS] COMMAND [ARGS]... --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. diff --git a/tests/test_docs.py b/tests/test_docs.py index f491f40..bcb4848 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -190,6 +190,58 @@ def get_command(self, ctx, name): ], ) def test_custom_multicommand(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. + ``` + + ## 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)) + 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). """ @@ -212,7 +264,7 @@ def test_custom_multicommand(multi): ``` **Subcommands** - + - *hello*: Hello, world! ## hello @@ -234,7 +286,7 @@ def test_custom_multicommand(multi): """ ).lstrip() - output = "\n".join(make_command_docs("multi", multi)) + output = "\n".join(make_command_docs("multi", multi, list_subcommands=True)) assert output == expected 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) From 08769291a7cc50d79a1b3abf302728af533de3d6 Mon Sep 17 00:00:00 2001 From: Sagi Shadur Date: Wed, 15 Jun 2022 10:27:50 +0300 Subject: [PATCH 6/6] Updated README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5a83b91..47a9424 100644 --- a/README.md +++ b/README.md @@ -136,5 +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: `True`)_ List subcommands of a given command. If _attr_list_ is installed, +- `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