-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #24 from explosion/feature/document
- Loading branch information
Showing
6 changed files
with
246 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
from typing import TYPE_CHECKING, Optional, List | ||
from collections import defaultdict | ||
from pathlib import Path | ||
import re | ||
|
||
from .util import format_type, DEFAULT_PLACEHOLDER | ||
|
||
if TYPE_CHECKING: | ||
from .cli import Radicli, Command | ||
|
||
DEFAULT_DOCS_COMNENT = "This file is auto-generated" | ||
whitespace_matcher = re.compile(r"\s+", re.ASCII) | ||
|
||
|
||
def document_cli( | ||
cli: "Radicli", | ||
title: Optional[str] = None, | ||
description: Optional[str] = None, | ||
comment: Optional[str] = DEFAULT_DOCS_COMNENT, | ||
path_root: Optional[Path] = None, | ||
) -> str: | ||
"""Generate Markdown-formatted documentation for a CLI.""" | ||
lines = [] | ||
start_heading = 2 if title is not None else 1 | ||
if comment is not None: | ||
lines.append(f"<!-- {comment} -->") | ||
if title is not None: | ||
lines.append(f"# {title}") | ||
if description is not None: | ||
lines.append(_strip(description)) | ||
prefix = f"{cli.prog} " if cli.prog else "" | ||
cli_title = f"`{cli.prog}`" if cli.prog else "CLI" | ||
lines.append(f"{'#' * start_heading} {cli_title}") | ||
if cli.help: | ||
lines.append(cli.help) | ||
for cmd in cli.commands.values(): | ||
lines.extend(_command(cmd, start_heading + 1, prefix, path_root)) | ||
if cmd.name in cli.subcommands: | ||
for sub_cmd in cli.subcommands[cmd.name].values(): | ||
lines.extend(_command(sub_cmd, start_heading + 2, prefix, path_root)) | ||
for name in cli.subcommands: | ||
by_parent = defaultdict(list) | ||
if name not in cli.commands: | ||
sub_cmds = cli.subcommands[name] | ||
by_parent[name].extend(sub_cmds.values()) | ||
for parent, sub_cmds in by_parent.items(): # subcommands without placeholders | ||
lines.append(f"{'#' * (start_heading + 1)} `{prefix + parent}`") | ||
for sub_cmd in sub_cmds: | ||
lines.extend(_command(sub_cmd, start_heading + 2, prefix, path_root)) | ||
return "\n\n".join(lines) | ||
|
||
|
||
def _command( | ||
cmd: "Command", level: int, prefix: str, path_root: Optional[Path] | ||
) -> List[str]: | ||
lines = [] | ||
lines.append(f"{'#' * level} `{prefix + cmd.display_name}`") | ||
if cmd.description: | ||
lines.append(_strip(cmd.description)) | ||
if cmd.args: | ||
table = [] | ||
for ap_arg in cmd.args: | ||
name = f"`{ap_arg.arg.option or ap_arg.id}`" | ||
if ap_arg.arg.short: | ||
name += ", " + f"`{ap_arg.arg.short}`" | ||
default = "" | ||
if ap_arg.default is not DEFAULT_PLACEHOLDER: | ||
if isinstance(ap_arg.default, Path): | ||
default_value = ap_arg.default | ||
if path_root is not None: | ||
default_value = default_value.relative_to(path_root) | ||
else: | ||
default_value = repr(ap_arg.default) | ||
default = f"`{default_value}`" | ||
arg_type = format_type(ap_arg.display_type) | ||
arg_code = f"`{arg_type}`" if arg_type else "" | ||
table.append((name, arg_code, ap_arg.arg.help or "", default)) | ||
header = ["Argument", "Type", "Description", "Default"] | ||
head = f"| {' | '.join(header)} |" | ||
divider = f"| {' | '.join('---' for _ in range(len(header)))} |" | ||
body = "\n".join(f"| {' | '.join(row)} |" for row in table) | ||
lines.append(f"{head}\n{divider}\n{body}") | ||
return lines | ||
|
||
|
||
def _strip(text: str) -> str: | ||
return whitespace_matcher.sub(" ", text).strip() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,110 @@ | ||
from typing import Literal, cast | ||
from pathlib import Path | ||
from dataclasses import dataclass | ||
from radicli import Radicli, Arg, ExistingFilePath | ||
|
||
|
||
def test_document_cli(): | ||
cli = Radicli(prog="rdc", help="This is a CLI") | ||
|
||
# Regular command | ||
@cli.command( | ||
"command1", | ||
arg1=Arg(help="Argument one"), | ||
arg2=Arg("--arg2", "-a2", help="Argument two"), | ||
arg3=Arg("--arg3", "-A3", help="Argument three"), | ||
) | ||
def command1(arg1: str, arg2: int = 2, arg3: bool = False): | ||
"""This is command one.""" | ||
... | ||
|
||
# Placeholder with subcommands | ||
cli.placeholder("command2", description="This is command two") | ||
|
||
@cli.subcommand( | ||
"command2", | ||
"child", | ||
arg1=Arg("--arg1", "-a1", help="Argument one"), | ||
arg2=Arg("--arg2", help="Argument two"), | ||
) | ||
def child1(arg1: Path, arg2: Literal["foo", "bar"] = "bar"): | ||
"""This is command 2 and its child.""" | ||
... | ||
|
||
@dataclass | ||
class MyCustomType: | ||
foo: str | ||
bar: str | ||
|
||
def convert_my_custom_type(v: str) -> MyCustomType: | ||
foo, bar = v.split(",") | ||
return MyCustomType(foo=foo, bar=bar) | ||
|
||
# Subcommand without parent | ||
@cli.subcommand( | ||
"command3", | ||
"child", | ||
arg1=Arg(help="Argument one", converter=convert_my_custom_type), | ||
arg2=Arg(help="Argument two"), | ||
) | ||
def child2( | ||
arg1: MyCustomType, | ||
arg2: ExistingFilePath = cast( | ||
ExistingFilePath, Path(__file__).parent / "__init__.py" | ||
), | ||
): | ||
"""This is command 3 and its child.""" | ||
|
||
docs = cli.document( | ||
title="Documentation", | ||
description="Here are the docs for my CLI", | ||
path_root=Path(__file__).parent, | ||
) | ||
assert docs == EXPECTED.strip() | ||
|
||
|
||
EXPECTED = """ | ||
<!-- This file is auto-generated --> | ||
# Documentation | ||
Here are the docs for my CLI | ||
## `rdc` | ||
This is a CLI | ||
### `rdc command1` | ||
This is command one. | ||
| Argument | Type | Description | Default | | ||
| --- | --- | --- | --- | | ||
| `arg1` | `str` | Argument one | | | ||
| `--arg2`, `-a2` | `int` | Argument two | `2` | | ||
| `--arg3`, `-A3` | `bool` | Argument three | `False` | | ||
### `rdc command2` | ||
This is command two | ||
#### `rdc command2 child` | ||
This is command 2 and its child. | ||
| Argument | Type | Description | Default | | ||
| --- | --- | --- | --- | | ||
| `--arg1`, `-a1` | `Path` | Argument one | | | ||
| `--arg2` | `str` | Argument two | `'bar'` | | ||
### `rdc command3` | ||
#### `rdc command3 child` | ||
This is command 3 and its child. | ||
| Argument | Type | Description | Default | | ||
| --- | --- | --- | --- | | ||
| `arg1` | `MyCustomType` | Argument one | | | ||
| `arg2` | `ExistingFilePath (Path)` | Argument two | `__init__.py` | | ||
""" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters