Skip to content

Commit 53e12cd

Browse files
authored
Merge pull request #24 from explosion/feature/document
2 parents 10961dd + ab4f3db commit 53e12cd

File tree

6 files changed

+246
-3
lines changed

6 files changed

+246
-3
lines changed

README.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
# radicli: Radically lightweight command-line interfaces
44

5-
`radicli` is a small, zero-dependency Python package for creating command line interfaces, built on top of Python's [`argparse`](https://docs.python.org/3/library/argparse.html) module. It introduces minimal overhead, preserves your original Python functions and uses **type hints** to parse values provided on the CLI. It supports all common types out-of-the-box, including complex ones like `List[str]`, `Literal` and `Enum`, and allows registering **custom types** with custom converters, as well as custom CLI-only **error handling** and exporting a **static representation** for faster `--help` and errors.
5+
`radicli` is a small, zero-dependency Python package for creating command line interfaces, built on top of Python's [`argparse`](https://docs.python.org/3/library/argparse.html) module. It introduces minimal overhead, preserves your original Python functions and uses **type hints** to parse values provided on the CLI. It supports all common types out-of-the-box, including complex ones like `List[str]`, `Literal` and `Enum`, and allows registering **custom types** with custom converters, as well as custom CLI-only **error handling**, exporting a **static representation** for faster `--help` and errors and auto-generated **Markdown documentation**.
66

77
> **Important note:** This package aims to be a simple option based on the requirements of our libraries. If you're looking for a more full-featured CLI toolkit, check out [`typer`](https://typer.tiangolo.com), [`click`](https://click.palletsprojects.com) or [`plac`](https://plac.readthedocs.io/en/latest/).
88
@@ -331,6 +331,17 @@ If the CLI is part of a Python package, you can generate the static JSON file du
331331

332332
`StaticRadicli` also provides a `disable` argument to disable static parsing during development (or if a certain environment variable is set). Setting `debug=True` will print an additional start and optional end marker (if the static CLI didn't exit before) to indicate that the static CLI ran.
333333

334+
### Auto-documenting the CLI
335+
336+
The `Radicli.document` method lets you generate a simple Markdown-formatted documentation for your CLI with an optional`title` and `description` added to the top. You can also include this call in your CI or build process to ensure the documentation is always up to date.
337+
338+
```python
339+
with Path("README.md").open("w", encoding="utf8") as f:
340+
f.write(cli.document())
341+
```
342+
343+
The `path_root` lets you provide a custom `Path` that's used as the relative root for all paths specified as default arguments. This means that absolute paths won't make it into your README.
344+
334345
## 🎛 API
335346

336347
### <kbd>dataclass</kbd> `Arg`
@@ -554,6 +565,23 @@ command.func(**values)
554565
| `allow_partial` | `bool` | Allow partial parsing and still return the parsed values, even if required arguments are missing. Defaults to `False`. |
555566
| **RETURNS** | `Dict[str, Any]` | The parsed values keyed by argument name that can be passed to the command function. |
556567

568+
#### <kbd>method</kbd> `Radicli.document`
569+
570+
Generate a Markdown-formatted documentation for a CLI.
571+
572+
```python
573+
with Path("README.md").open("w", encodig="utf8") as f:
574+
f.write(cli.document())
575+
```
576+
577+
| Argument | Type | Description |
578+
| ------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
579+
| `title` | `Optional[str]` | Title to add to the top of the file. Defaults to `None`. |
580+
| `description` | `Optional[str]` | Description to add to the top of th file. Defaults to `None`. |
581+
| `comment` | `Optional[str]` | Text of the HTML comment added to the top of the file, usually indicating that it's auto-generated. If `None`, no comment will be added. Defaults to `"This file is auto-generated"`. |
582+
| `path_root` | `Optional[Path]` | Custom path used as relative root for argument defaults of type `Path`, to prevent local absolute paths from ending up in the documentation. Defaults to `None`. |
583+
| **RETURNS** | `str` | The Markdown-formatted docs. |
584+
557585
#### <kbd>method</kbd> `Radicli.to_static`
558586

559587
Export a static JSON representation of the CLI for `StaticRadicli`.

radicli/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from .cli import Radicli, Command
22
from .static import StaticRadicli
33
from .parser import ArgumentParser, HelpFormatter
4+
from .document import document_cli
45
from .util import ArgparseArg, Arg, get_arg, format_type, DEFAULT_PLACEHOLDER
56
from .util import CommandNotFoundError, CliParserError, CommandExistsError
67
from .util import ConverterType, ConvertersType, ErrorHandlersType
@@ -16,6 +17,6 @@
1617
"DEFAULT_PLACEHOLDER", "ExistingPath", "ExistingFilePath", "ExistingDirPath",
1718
"ExistingPathOrDash", "ExistingFilePathOrDash", "PathOrDash",
1819
"ExistingDirPathOrDash", "StrOrUUID", "StaticRadicli", "StaticData",
19-
"get_list_converter",
20+
"get_list_converter", "document_cli",
2021
]
2122
# fmt: on

radicli/cli.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import copy
1010

1111
from .parser import ArgumentParser, HelpFormatter
12+
from .document import document_cli, DEFAULT_DOCS_COMNENT
1213
from .util import Arg, ArgparseArg, get_arg, join_strings, format_type, format_table
1314
from .util import format_arg_help, expand_error_subclasses, SimpleFrozenDict
1415
from .util import CommandNotFoundError, CliParserError, CommandExistsError
@@ -450,3 +451,19 @@ def to_static(self, file_path: Union[str, Path]) -> Path:
450451
with path.open("w", encoding="utf8") as f:
451452
f.write(json.dumps(data))
452453
return path
454+
455+
def document(
456+
self,
457+
title: Optional[str] = None,
458+
description: Optional[str] = None,
459+
comment: Optional[str] = DEFAULT_DOCS_COMNENT,
460+
path_root: Path = Path.cwd(),
461+
) -> str:
462+
"""Generate Markdown-formatted documentation for a CLI."""
463+
return document_cli(
464+
self,
465+
title=title,
466+
description=description,
467+
comment=comment,
468+
path_root=path_root,
469+
)

radicli/document.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
from typing import TYPE_CHECKING, Optional, List
2+
from collections import defaultdict
3+
from pathlib import Path
4+
import re
5+
6+
from .util import format_type, DEFAULT_PLACEHOLDER
7+
8+
if TYPE_CHECKING:
9+
from .cli import Radicli, Command
10+
11+
DEFAULT_DOCS_COMNENT = "This file is auto-generated"
12+
whitespace_matcher = re.compile(r"\s+", re.ASCII)
13+
14+
15+
def document_cli(
16+
cli: "Radicli",
17+
title: Optional[str] = None,
18+
description: Optional[str] = None,
19+
comment: Optional[str] = DEFAULT_DOCS_COMNENT,
20+
path_root: Optional[Path] = None,
21+
) -> str:
22+
"""Generate Markdown-formatted documentation for a CLI."""
23+
lines = []
24+
start_heading = 2 if title is not None else 1
25+
if comment is not None:
26+
lines.append(f"<!-- {comment} -->")
27+
if title is not None:
28+
lines.append(f"# {title}")
29+
if description is not None:
30+
lines.append(_strip(description))
31+
prefix = f"{cli.prog} " if cli.prog else ""
32+
cli_title = f"`{cli.prog}`" if cli.prog else "CLI"
33+
lines.append(f"{'#' * start_heading} {cli_title}")
34+
if cli.help:
35+
lines.append(cli.help)
36+
for cmd in cli.commands.values():
37+
lines.extend(_command(cmd, start_heading + 1, prefix, path_root))
38+
if cmd.name in cli.subcommands:
39+
for sub_cmd in cli.subcommands[cmd.name].values():
40+
lines.extend(_command(sub_cmd, start_heading + 2, prefix, path_root))
41+
for name in cli.subcommands:
42+
by_parent = defaultdict(list)
43+
if name not in cli.commands:
44+
sub_cmds = cli.subcommands[name]
45+
by_parent[name].extend(sub_cmds.values())
46+
for parent, sub_cmds in by_parent.items(): # subcommands without placeholders
47+
lines.append(f"{'#' * (start_heading + 1)} `{prefix + parent}`")
48+
for sub_cmd in sub_cmds:
49+
lines.extend(_command(sub_cmd, start_heading + 2, prefix, path_root))
50+
return "\n\n".join(lines)
51+
52+
53+
def _command(
54+
cmd: "Command", level: int, prefix: str, path_root: Optional[Path]
55+
) -> List[str]:
56+
lines = []
57+
lines.append(f"{'#' * level} `{prefix + cmd.display_name}`")
58+
if cmd.description:
59+
lines.append(_strip(cmd.description))
60+
if cmd.args:
61+
table = []
62+
for ap_arg in cmd.args:
63+
name = f"`{ap_arg.arg.option or ap_arg.id}`"
64+
if ap_arg.arg.short:
65+
name += ", " + f"`{ap_arg.arg.short}`"
66+
default = ""
67+
if ap_arg.default is not DEFAULT_PLACEHOLDER:
68+
if isinstance(ap_arg.default, Path):
69+
default_value = ap_arg.default
70+
if path_root is not None:
71+
default_value = default_value.relative_to(path_root)
72+
else:
73+
default_value = repr(ap_arg.default)
74+
default = f"`{default_value}`"
75+
arg_type = format_type(ap_arg.display_type)
76+
arg_code = f"`{arg_type}`" if arg_type else ""
77+
table.append((name, arg_code, ap_arg.arg.help or "", default))
78+
header = ["Argument", "Type", "Description", "Default"]
79+
head = f"| {' | '.join(header)} |"
80+
divider = f"| {' | '.join('---' for _ in range(len(header)))} |"
81+
body = "\n".join(f"| {' | '.join(row)} |" for row in table)
82+
lines.append(f"{head}\n{divider}\n{body}")
83+
return lines
84+
85+
86+
def _strip(text: str) -> str:
87+
return whitespace_matcher.sub(" ", text).strip()

radicli/tests/test_document.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
from typing import Literal, cast
2+
from pathlib import Path
3+
from dataclasses import dataclass
4+
from radicli import Radicli, Arg, ExistingFilePath
5+
6+
7+
def test_document_cli():
8+
cli = Radicli(prog="rdc", help="This is a CLI")
9+
10+
# Regular command
11+
@cli.command(
12+
"command1",
13+
arg1=Arg(help="Argument one"),
14+
arg2=Arg("--arg2", "-a2", help="Argument two"),
15+
arg3=Arg("--arg3", "-A3", help="Argument three"),
16+
)
17+
def command1(arg1: str, arg2: int = 2, arg3: bool = False):
18+
"""This is command one."""
19+
...
20+
21+
# Placeholder with subcommands
22+
cli.placeholder("command2", description="This is command two")
23+
24+
@cli.subcommand(
25+
"command2",
26+
"child",
27+
arg1=Arg("--arg1", "-a1", help="Argument one"),
28+
arg2=Arg("--arg2", help="Argument two"),
29+
)
30+
def child1(arg1: Path, arg2: Literal["foo", "bar"] = "bar"):
31+
"""This is command 2 and its child."""
32+
...
33+
34+
@dataclass
35+
class MyCustomType:
36+
foo: str
37+
bar: str
38+
39+
def convert_my_custom_type(v: str) -> MyCustomType:
40+
foo, bar = v.split(",")
41+
return MyCustomType(foo=foo, bar=bar)
42+
43+
# Subcommand without parent
44+
@cli.subcommand(
45+
"command3",
46+
"child",
47+
arg1=Arg(help="Argument one", converter=convert_my_custom_type),
48+
arg2=Arg(help="Argument two"),
49+
)
50+
def child2(
51+
arg1: MyCustomType,
52+
arg2: ExistingFilePath = cast(
53+
ExistingFilePath, Path(__file__).parent / "__init__.py"
54+
),
55+
):
56+
"""This is command 3 and its child."""
57+
58+
docs = cli.document(
59+
title="Documentation",
60+
description="Here are the docs for my CLI",
61+
path_root=Path(__file__).parent,
62+
)
63+
assert docs == EXPECTED.strip()
64+
65+
66+
EXPECTED = """
67+
<!-- This file is auto-generated -->
68+
69+
# Documentation
70+
71+
Here are the docs for my CLI
72+
73+
## `rdc`
74+
75+
This is a CLI
76+
77+
### `rdc command1`
78+
79+
This is command one.
80+
81+
| Argument | Type | Description | Default |
82+
| --- | --- | --- | --- |
83+
| `arg1` | `str` | Argument one | |
84+
| `--arg2`, `-a2` | `int` | Argument two | `2` |
85+
| `--arg3`, `-A3` | `bool` | Argument three | `False` |
86+
87+
### `rdc command2`
88+
89+
This is command two
90+
91+
#### `rdc command2 child`
92+
93+
This is command 2 and its child.
94+
95+
| Argument | Type | Description | Default |
96+
| --- | --- | --- | --- |
97+
| `--arg1`, `-a1` | `Path` | Argument one | |
98+
| `--arg2` | `str` | Argument two | `'bar'` |
99+
100+
### `rdc command3`
101+
102+
#### `rdc command3 child`
103+
104+
This is command 3 and its child.
105+
106+
| Argument | Type | Description | Default |
107+
| --- | --- | --- | --- |
108+
| `arg1` | `MyCustomType` | Argument one | |
109+
| `arg2` | `ExistingFilePath (Path)` | Argument two | `__init__.py` |
110+
"""

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[metadata]
2-
version = 0.0.20
2+
version = 0.0.21
33
description = Radically lightweight command-line interfaces
44
url = https://github.com/explosion/radicli
55
author = Explosion

0 commit comments

Comments
 (0)