Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add table style for option formatting #25

Merged
merged 10 commits into from
Feb 17, 2021
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
66 changes: 60 additions & 6 deletions mkdocs_click/_docs.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,31 @@
# (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)

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)

Expand Down Expand Up @@ -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]:
florimondmanca marked this conversation as resolved.
Show resolved Hide resolved
"""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)

Expand All @@ -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
florimondmanca marked this conversation as resolved.
Show resolved Hide resolved
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 ""
3 changes: 2 additions & 1 deletion mkdocs_click/_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
83 changes: 83 additions & 0 deletions tests/unit/test_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down