diff --git a/README.md b/README.md index 33e9d15..7a2b084 100644 --- a/README.md +++ b/README.md @@ -114,3 +114,4 @@ Options: - `command`: name of the command object. - `prog_name`: _(Optional, default: same as `command`)_ the name to display for the command. - `depth`: _(Optional, default: `0`)_ Offset to add when generating headers. +- `style`: _(Optional, default: `plain`)_ style for the options section. The possible choices are `plain` and `table`. diff --git a/mkdocs_click/_docs.py b/mkdocs_click/_docs.py index 86cc278..816cbf5 100644 --- a/mkdocs_click/_docs.py +++ b/mkdocs_click/_docs.py @@ -1,21 +1,23 @@ # (C) Datadog, Inc. 2020-present # All rights reserved # Licensed under the Apache license (see LICENSE) -from typing import Iterator, List, Optional, cast +from typing import Iterable, Iterator, List, Optional, cast import click from ._exceptions import MkDocsClickException -def make_command_docs(prog_name: str, command: click.BaseCommand, level: int = 0) -> Iterator[str]: +def make_command_docs( + prog_name: str, command: click.BaseCommand, level: int = 0, style: str = "plain" +) -> Iterator[str]: """Create the Markdown lines for a command and its sub-commands.""" - for line in _recursively_make_command_docs(prog_name, command, level=level): + for line in _recursively_make_command_docs(prog_name, command, level=level, style=style): yield line.replace("\b", "") def _recursively_make_command_docs( - prog_name: str, command: click.BaseCommand, parent: click.Context = None, level: int = 0 + prog_name: str, command: click.BaseCommand, parent: click.Context = None, level: int = 0, style: str = "plain" ) -> Iterator[str]: """Create the raw Markdown lines for a command and its sub-commands.""" ctx = click.Context(cast(click.Command, command), parent=parent) @@ -23,7 +25,7 @@ def _recursively_make_command_docs( yield from _make_title(prog_name, level) yield from _make_description(ctx) yield from _make_usage(ctx) - yield from _make_options(ctx) + yield from _make_options(ctx, style) subcommands = _get_sub_commands(ctx.command, ctx) @@ -100,8 +102,19 @@ def _make_usage(ctx: click.Context) -> Iterator[str]: yield "" -def _make_options(ctx: click.Context) -> Iterator[str]: +def _make_options(ctx: click.Context, style: str = "plain") -> Iterator[str]: """Create the Markdown lines describing the options for the command.""" + + if style == "plain": + return _make_plain_options(ctx) + elif style == "table": + return _make_table_options(ctx) + else: + raise MkDocsClickException(f"{style} is not a valid option style, which must be either `plain` or `table`.") + + +def _make_plain_options(ctx: click.Context) -> Iterator[str]: + """Create the plain style options description.""" formatter = ctx.make_formatter() click.Command.format_options(ctx.command, ctx, formatter) @@ -120,3 +133,44 @@ def _make_options(ctx: click.Context) -> Iterator[str]: yield from option_lines yield "```" yield "" + + +def _make_table_options(ctx: click.Context) -> Iterator[str]: + """Create the table style options description.""" + + def backquote(opts: Iterable[str]) -> List[str]: + return [f"`{opt}`" for opt in opts] + + def format_possible_value(opt: click.Option) -> str: + param_type = opt.type + display_name = param_type.name + + # TODO: remove type-ignore comments once python/typeshed#4813 gets merged. + if isinstance(param_type, click.Choice): + return f"{display_name} ({' | '.join(backquote(param_type.choices))})" + elif isinstance(param_type, click.DateTime): + return f"{display_name} ({' | '.join(backquote(param_type.formats))})" # type: ignore[attr-defined] + elif isinstance(param_type, (click.IntRange, click.FloatRange)): + if param_type.min is not None and param_type.max is not None: # type: ignore[union-attr] + return f"{display_name} (between `{param_type.min}` and `{param_type.max}`)" # type: ignore[union-attr] + elif param_type.min is not None: # type: ignore[union-attr] + return f"{display_name} (`{param_type.min}` and above)" # type: ignore[union-attr] + else: + return f"{display_name} (`{param_type.max}` and below)" # type: ignore[union-attr] + else: + return display_name + + params = [param for param in ctx.command.get_params(ctx) if isinstance(param, click.Option)] + + yield "Options:" + yield "" + yield "| Name | Type | Description | Default |" + yield "| ------ | ---- | ----------- | ------- |" + for param in params: + names = ", ".join(backquote(param.opts)) + names_negation = f" / {', '.join(backquote(param.secondary_opts))}" if param.secondary_opts != [] else "" + value_type = format_possible_value(param) + description = param.help if param.help is not None else "N/A" + default = f"`{param.default}`" if param.default is not None else "_required_" + yield f"| {names}{names_negation} | {value_type} | {description} | {default} |" + yield "" diff --git a/mkdocs_click/_extension.py b/mkdocs_click/_extension.py index 2d5655f..875b696 100644 --- a/mkdocs_click/_extension.py +++ b/mkdocs_click/_extension.py @@ -21,10 +21,11 @@ def replace_command_docs(**options: Any) -> Iterator[str]: command = options["command"] prog_name = options.get("prog_name", command) depth = int(options.get("depth", 0)) + style = options.get("option-style", "plain") command_obj = load_command(module, command) - return make_command_docs(prog_name=prog_name, command=command_obj, level=depth) + return make_command_docs(prog_name=prog_name, command=command_obj, level=depth, style=style) class ClickProcessor(Preprocessor): diff --git a/tests/unit/test_docs.py b/tests/unit/test_docs.py index 1b043f5..5d2ac07 100644 --- a/tests/unit/test_docs.py +++ b/tests/unit/test_docs.py @@ -54,6 +54,89 @@ def test_prog_name(): assert output == HELLO_EXPECTED.replace("# hello", "# hello-world") +def test_make_command_docs_invalid(): + with pytest.raises( + MkDocsClickException, match="invalid is not a valid option style, which must be either `plain` or `table`." + ): + "\n".join(make_command_docs("hello", hello, style="invalid")).strip() + + +@click.command() +@click.option("-d", "--debug", help="Include debug output") +@click.option("--choice", type=click.Choice(["foo", "bar"]), default="foo") +@click.option("--date", type=click.DateTime(["%Y-%m-%d"])) +@click.option("--range-a", type=click.FloatRange(0, 1), default=0) +@click.option("--range-b", type=click.FloatRange(0)) +@click.option("--range-c", type=click.FloatRange(None, 1), default=0) +@click.option("--flag/--no-flag") +def hello_table(): + """Hello, world!""" + + +HELLO_TABLE_EXPECTED = dedent( + """ + # hello + + Hello, world! + + Usage: + + ``` + hello-table [OPTIONS] + ``` + + Options: + + | Name | Type | Description | Default | + | ------ | ---- | ----------- | ------- | + | `-d`, `--debug` | text | Include debug output | _required_ | + | `--choice` | choice (`foo` | `bar`) | N/A | `foo` | + | `--date` | datetime (`%Y-%m-%d`) | N/A | _required_ | + | `--range-a` | float range (between `0` and `1`) | N/A | `0` | + | `--range-b` | float range (`0` and above) | N/A | _required_ | + | `--range-c` | float range (`1` and below) | N/A | `0` | + | `--flag` / `--no-flag` | boolean | N/A | `False` | + | `--help` | boolean | Show this message and exit. | `False` | + """ +).strip() + + +def test_make_command_docs_table(): + output = "\n".join(make_command_docs("hello", hello_table, style="table")).strip() + assert output == HELLO_TABLE_EXPECTED + + +@click.command() +def hello_only_help(): + """Hello, world!""" + + +HELLO_ONLY_HELP_EXPECTED = dedent( + """ + # hello + + Hello, world! + + Usage: + + ``` + hello-only-help [OPTIONS] + ``` + + Options: + + | Name | Type | Description | Default | + | ------ | ---- | ----------- | ------- | + | `--help` | boolean | Show this message and exit. | `False` | + """ +).strip() + + +def test_make_command_docs_only_help(): + output = "\n".join(make_command_docs("hello", hello_only_help, style="table")).strip() + assert output == HELLO_ONLY_HELP_EXPECTED + + class MultiCLI(click.MultiCommand): def list_commands(self, ctx): return ["single-command"]