Skip to content

Commit

Permalink
Merge pull request #24 from explosion/feature/document
Browse files Browse the repository at this point in the history
  • Loading branch information
ines committed Mar 22, 2023
2 parents 10961dd + ab4f3db commit 53e12cd
Show file tree
Hide file tree
Showing 6 changed files with 246 additions and 3 deletions.
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# radicli: Radically lightweight command-line interfaces

`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.
`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**.

> **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/).
Expand Down Expand Up @@ -331,6 +331,17 @@ If the CLI is part of a Python package, you can generate the static JSON file du

`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.

### Auto-documenting the CLI

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.

```python
with Path("README.md").open("w", encoding="utf8") as f:
f.write(cli.document())
```

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.

## 🎛 API

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

#### <kbd>method</kbd> `Radicli.document`

Generate a Markdown-formatted documentation for a CLI.

```python
with Path("README.md").open("w", encodig="utf8") as f:
f.write(cli.document())
```

| Argument | Type | Description |
| ------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `title` | `Optional[str]` | Title to add to the top of the file. Defaults to `None`. |
| `description` | `Optional[str]` | Description to add to the top of th file. Defaults to `None`. |
| `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"`. |
| `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`. |
| **RETURNS** | `str` | The Markdown-formatted docs. |

#### <kbd>method</kbd> `Radicli.to_static`

Export a static JSON representation of the CLI for `StaticRadicli`.
Expand Down
3 changes: 2 additions & 1 deletion radicli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from .cli import Radicli, Command
from .static import StaticRadicli
from .parser import ArgumentParser, HelpFormatter
from .document import document_cli
from .util import ArgparseArg, Arg, get_arg, format_type, DEFAULT_PLACEHOLDER
from .util import CommandNotFoundError, CliParserError, CommandExistsError
from .util import ConverterType, ConvertersType, ErrorHandlersType
Expand All @@ -16,6 +17,6 @@
"DEFAULT_PLACEHOLDER", "ExistingPath", "ExistingFilePath", "ExistingDirPath",
"ExistingPathOrDash", "ExistingFilePathOrDash", "PathOrDash",
"ExistingDirPathOrDash", "StrOrUUID", "StaticRadicli", "StaticData",
"get_list_converter",
"get_list_converter", "document_cli",
]
# fmt: on
17 changes: 17 additions & 0 deletions radicli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import copy

from .parser import ArgumentParser, HelpFormatter
from .document import document_cli, DEFAULT_DOCS_COMNENT
from .util import Arg, ArgparseArg, get_arg, join_strings, format_type, format_table
from .util import format_arg_help, expand_error_subclasses, SimpleFrozenDict
from .util import CommandNotFoundError, CliParserError, CommandExistsError
Expand Down Expand Up @@ -450,3 +451,19 @@ def to_static(self, file_path: Union[str, Path]) -> Path:
with path.open("w", encoding="utf8") as f:
f.write(json.dumps(data))
return path

def document(
self,
title: Optional[str] = None,
description: Optional[str] = None,
comment: Optional[str] = DEFAULT_DOCS_COMNENT,
path_root: Path = Path.cwd(),
) -> str:
"""Generate Markdown-formatted documentation for a CLI."""
return document_cli(
self,
title=title,
description=description,
comment=comment,
path_root=path_root,
)
87 changes: 87 additions & 0 deletions radicli/document.py
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()
110 changes: 110 additions & 0 deletions radicli/tests/test_document.py
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` |
"""
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[metadata]
version = 0.0.20
version = 0.0.21
description = Radically lightweight command-line interfaces
url = https://github.com/explosion/radicli
author = Explosion
Expand Down

0 comments on commit 53e12cd

Please sign in to comment.