Skip to content

Commit

Permalink
feat: Add help_formatter system wherein help text rendering can be …
Browse files Browse the repository at this point in the history
…customized.

This enables one to include or exclude things like default values, or choices.
Notably changes to include default values in help text by default.
  • Loading branch information
DanCardin committed Jun 19, 2024
1 parent 49c7776 commit cbb87bb
Show file tree
Hide file tree
Showing 17 changed files with 486 additions and 100 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 0.21

### 0.21.0

- feat: Add `help_formatter` system wherein help text rendering can be customized
to include or exclude things like default values, or choices. Notably changes
to include default values in help text by default.

## 0.20

### 0.20.1
Expand Down
82 changes: 78 additions & 4 deletions docs/source/help.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# Help Text Inference
# Help

## Help Inference

Cappa tries to infer help text from a variety of sources, preferring them in
descending order of priority:
Expand All @@ -9,7 +11,7 @@ descending order of priority:

If none of the above sources produce help text, no description will be rendered.

## Explicit `help=`
### Explicit `help=`

All of `Command`, `Subcommand`, and `Arg` accept a `help` argument, which will
take priority over any other existent form of help text inference.
Expand All @@ -24,7 +26,7 @@ class Command:
arg: Annotated[Sub, cappa.Subcommand(help='Subcommand help')]
```

## PEP-727 `Doc` annotation
### PEP-727 `Doc` annotation

[PEP-727](https://peps.python.org/pep-0727/) proposes adding a `typing.Doc`
object, in an attempt to standardize the location tooling must handle in order
Expand All @@ -51,7 +53,7 @@ class Command:
arg: Annotated[str, Doc('Arg help')]
```

## Class Docstring Parsing
### Class Docstring Parsing

```{note}
Docstring parsing is provided by the `docstring-parser` dependency. You can
Expand Down Expand Up @@ -98,3 +100,75 @@ Example CLI. With some long description.
Positional Arguments:
foo The number of foos
```

## Argument Help Formatting

By default, help text is composed from a few sources:

* The actual help text (as described above)
* The default argument value (if exists)
* The set of available "choices" (if exists) (for `Enum`, `Literal`, and `choices=[...]`)

This can be controlled through the use of the `help_formatter` argument to the root
`cappa.parse`, `cappa.invoke`, etc functions. Additionally it can be set on a
per-command/subcommand level by making use of the `@cappa.command(help_formatter=...)`
kwarg.

```{eval-rst}
.. autoapimodule:: cappa
:members: HelpFormatter
```

### Customize "default" help representation
By default, the default value will be rendered into the help text as `(Default: {default})`.

You can customize this, by altering the default `help_formatter`:

```python
from cappa import parse, HelpFormatter

class Command:
...

parse(Command, help_formatter=HelpFormatter(default_format="(Default `{default}`)"))
```

### Customizing help formatter "sources"
The default `arg_format` is `("{help}", "{choices}", "{default}")`. That means, given
some argument: `foo: Annotated[Literal["one", "two"], Arg(help="Foo.")] = "two"`, the
help text will be rendered as `Foo. Valid options: one, two. (Default: two)`

`arg_format` can be any of: A string, a callable, or a sequence of strings or callables.
Sequences of values will be joined together with an empty space, and **empty strings or `None`
will be ignored**.

The **purpose** of allowing sequences of individual segments is to ensure consistent
formatting when individual format options are not used. For example `"{help} {default}"`
would otherwise yield `Foo. ` or ` (Default: foo)` (i.e. trailing or leading spaces).
As such, where formatting may be variable (like with `default`), they should be split
into different segments.

Further, if simple format strings are not enough to ensure correct formatting, a segment
can alternatively be a callable (`Callable[[Arg], str | None]` or stricter). Again, if that
segment returns `None` or `""`, it will be omitted.

The following format string identifiers are included in the format context for each
segment: `help`, `default` (which in turn is rendered with `default_format`), `choices`, and `arg`.

An example of this might look like:

```python
from cappa import parse, HelpFormatter, Arg

class Command:
foo: Annotated[str, Arg(help="Help text.", deprecated=True)] = "foo"

def deprecated(arg: Arg) -> str | None:
if arg.deprecated:
return "Deprecrated"
return None

parse(Command, help_formatter=HelpFormatter(arg_format=("{default}", "{help}", deprecated))
```

Resulting in something like `(Default: foo) Help text. Deprecrated`.
2 changes: 1 addition & 1 deletion docs/source/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Testing <testing>
:hidden:
Examples (vs click/typer/argparse) <examples>
Help Text Inference <help>
Help/Help Inference <help>
Asyncio <asyncio>
Manual Construction <manual_construction>
Sphinx/Docutils Directive <sphinx>
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "cappa"
version = "0.20.1"
version = "0.21.0"
description = "Declarative CLI argument parser."

repository = "https://github.com/dancardin/cappa"
Expand Down
3 changes: 3 additions & 0 deletions src/cappa/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from cappa.completion.types import Completion
from cappa.env import Env
from cappa.file_io import FileMode
from cappa.help import HelpFormatable, HelpFormatter
from cappa.invoke import Dep
from cappa.output import Exit, HelpExit, Output
from cappa.subcommand import Subcommand, Subcommands
Expand All @@ -25,6 +26,8 @@
"FileMode",
"Group",
"HelpExit",
"HelpFormatable",
"HelpFormatter",
"Output",
"Subcommand",
"Subcommands",
Expand Down
16 changes: 3 additions & 13 deletions src/cappa/arg.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ def normalize(
required = infer_required(self, annotation, default)

parse = infer_parse(self, annotation)
help = infer_help(self, choices, fallback_help)
help = infer_help(self, fallback_help)
completion = infer_completion(self, choices)

group = infer_group(self, short, long, exclusive)
Expand Down Expand Up @@ -546,22 +546,12 @@ def infer_parse(arg: Arg, annotation: type) -> Callable:
return parse_value(annotation, extra_annotations=arg.annotations)


def infer_help(
arg: Arg, choices: list[str] | None, fallback_help: str | None
) -> str | None:
def infer_help(arg: Arg, fallback_help: str | None) -> str | None:
help = arg.help
if help is None:
help = fallback_help

if not choices:
return help

choices_str = "Valid options: " + ", ".join(choices) + "."

if help:
return f"{help} {choices_str}"

return choices_str
return help


def infer_completion(
Expand Down
4 changes: 2 additions & 2 deletions src/cappa/argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from cappa.arg import Arg, ArgAction, no_extra_arg_actions
from cappa.command import Command, Subcommand
from cappa.help import format_help, generate_arg_groups
from cappa.help import generate_arg_groups
from cappa.invoke import fullfill_deps
from cappa.output import Exit, HelpExit, Output
from cappa.parser import RawOption, Value
Expand Down Expand Up @@ -90,7 +90,7 @@ def exit(self, status=0, message=None):
raise Exit(message, code=status, prog=self.prog)

def print_help(self):
raise HelpExit(format_help(self.command, self.prog))
raise HelpExit(self.command.help_formatter(self.command, self.prog))


class BooleanOptionalAction(argparse.Action):
Expand Down
21 changes: 20 additions & 1 deletion src/cappa/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from cappa.class_inspect import detect
from cappa.command import Command
from cappa.help import (
HelpFormatable,
HelpFormatter,
create_completion_arg,
create_help_arg,
create_version_arg,
Expand All @@ -34,6 +36,7 @@ def parse(
completion: bool | Arg = True,
theme: Theme | None = None,
output: Output | None = None,
help_formatter: HelpFormatable | None = None,
) -> T:
"""Parse the command, returning an instance of `obj`.
Expand Down Expand Up @@ -61,6 +64,7 @@ def parse(
output: Optional `Output` instance. A default `Output` will constructed if one is not provided.
Note the `color` and `theme` arguments take precedence over manually constructed `Output`
attributes.
help_formatter: Override the default help formatter.
"""
_, _, instance, _ = parse_command(
obj=obj,
Expand All @@ -72,6 +76,7 @@ def parse(
completion=completion,
theme=theme,
output=output,
help_formatter=help_formatter,
)
return instance

Expand All @@ -90,6 +95,7 @@ def invoke(
completion: bool | Arg = True,
theme: Theme | None = None,
output: Output | None = None,
help_formatter: HelpFormatable | None = None,
):
"""Parse the command, and invoke the selected async command or subcommand.
Expand Down Expand Up @@ -119,6 +125,7 @@ def invoke(
output: Optional `Output` instance. A default `Output` will constructed if one is not provided.
Note the `color` and `theme` arguments take precedence over manually constructed `Output`
attributes.
help_formatter: Override the default help formatter.
"""
command, parsed_command, instance, concrete_output = parse_command(
obj=obj,
Expand All @@ -130,6 +137,7 @@ def invoke(
completion=completion,
theme=theme,
output=output,
help_formatter=help_formatter,
)
resolved, global_deps = resolve_callable(
command, parsed_command, instance, output=concrete_output, deps=deps
Expand All @@ -156,6 +164,7 @@ async def invoke_async(
completion: bool | Arg = True,
theme: Theme | None = None,
output: Output | None = None,
help_formatter: HelpFormatable | None = None,
):
"""Parse the command, and invoke the selected command or subcommand.
Expand Down Expand Up @@ -185,6 +194,7 @@ async def invoke_async(
output: Optional `Output` instance. A default `Output` will constructed if one is not provided.
Note the `color` and `theme` arguments take precedence over manually constructed `Output`
attributes.
help_formatter: Override the default help formatter.
"""
command, parsed_command, instance, concrete_output = parse_command(
obj=obj,
Expand All @@ -196,6 +206,7 @@ async def invoke_async(
completion=completion,
theme=theme,
output=output,
help_formatter=help_formatter,
)
resolved, global_deps = resolve_callable(
command, parsed_command, instance, output=concrete_output, deps=deps
Expand All @@ -219,6 +230,7 @@ def parse_command(
completion: bool | Arg = True,
theme: Theme | None = None,
output: Output | None = None,
help_formatter: HelpFormatable | None = None,
) -> tuple[Command, Command[T], T, Output]:
concrete_backend = _coalesce_backend(backend)
concrete_output = _coalesce_output(output, theme, color)
Expand All @@ -229,6 +241,7 @@ def parse_command(
version=version,
completion=completion,
backend=concrete_backend,
help_formatter=help_formatter,
)
command, parsed_command, instance = Command.parse_command(
command,
Expand All @@ -251,6 +264,7 @@ def command(
default_short: bool = False,
default_long: bool = False,
deprecated: bool = False,
help_formatter: HelpFormatable = HelpFormatter.default,
):
"""Register a cappa CLI command/subcomment.
Expand All @@ -275,6 +289,7 @@ def command(
deprecated: If supplied, the argument will be marked as deprecated. If given `True`,
a default message will be generated, otherwise a supplied string will be
used as the deprecation message.
help_formatter: Override the default help formatter.
"""

def wrapper(_decorated_cls):
Expand All @@ -291,8 +306,10 @@ def wrapper(_decorated_cls):
default_short=default_short,
default_long=default_long,
deprecated=deprecated,
help_formatter=help_formatter,
)
_decorated_cls.__cappa__ = instance

return _decorated_cls

if _cls is not None:
Expand All @@ -307,6 +324,7 @@ def collect(
version: str | Arg | None = None,
help: bool | Arg = True,
completion: bool | Arg = True,
help_formatter: HelpFormatable | None = None,
) -> Command[T]:
"""Retrieve the `Command` object from a cappa-capable source class.
Expand All @@ -324,8 +342,9 @@ def collect(
(default to True), adds a --completion flag. An `Arg` can be supplied to customize
the argument's behavior.
color: Whether to output in color.
help_formatter: Override the default help formatter.
"""
command: Command[T] = Command.get(obj)
command: Command[T] = Command.get(obj, help_formatter=help_formatter)
command = Command.collect(command)

concrete_backend = _coalesce_backend(backend)
Expand Down
Loading

0 comments on commit cbb87bb

Please sign in to comment.