Skip to content

Commit

Permalink
Merge pull request #98 from DanCardin/dc/prettier-parse-errors
Browse files Browse the repository at this point in the history
feat: Improve parse error formatting. Include short help by default.
  • Loading branch information
DanCardin authored Feb 22, 2024
2 parents f545da7 + 5af953e commit 97a57d4
Show file tree
Hide file tree
Showing 15 changed files with 172 additions and 38 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ def print_cmd(print: Print):
else:
print("printing!")

@cappa.invoke(invoke=print_cmd)
@cappa.command(invoke=print_cmd)
class Print:
loudly: bool

Expand Down
21 changes: 21 additions & 0 deletions docs/source/exiting.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,24 @@ def function():
@cappa.command(invoke=function)
...
```

## `Output` and Error Messages

By default, `cappa.parse` or `cappa.invoke` will internally construct an
[Output](cappa.Output) object which controls, among other things, how `Exit`
messages are handled. Both functions accept an `output` argument, allowing you
to control an `Output`'s settings.

Of note, there are two message templates:

- output_format (default `{message}`): Exit code == 0
- error_format (default `{short_help}\n\n[red]Error[/red]: {message}`): Exit
code != 0

If, for example, you did not want to include `short_help` by default, and wanted
"Error" to be orange, you could do as follows:

```python
output = cappa.Output(error_format="[orange]Error[/orange]: {message}")
cappa.parse(Command, output=output)
```
3 changes: 1 addition & 2 deletions docs/source/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ vs Click / Typer / Argparse <comparison>
Dataclasses/Pydantic/Attrs <class_compatibility>
Rich Integration <rich>
Completions <completion>
Exiting and Exit Codes <exiting>
Output, Exiting, and Exit Codes <exiting>
Parser Backends (Cappa/Argparse) <backends>
Testing <testing>
```
Expand All @@ -43,7 +43,6 @@ Help Text Inference <help>
Asyncio <asyncio>
Manual Construction <manual_construction>
Sphinx/Docutils Directive <sphinx>
Internals <internals>
```

```{toctree}
Expand Down
9 changes: 0 additions & 9 deletions docs/source/internals.md

This file was deleted.

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.16.1"
version = "0.16.2"
description = "Declarative CLI argument parser."

repository = "https://github.com/dancardin/cappa"
Expand Down
11 changes: 7 additions & 4 deletions src/cappa/argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,9 @@ def backend(
command: Command[T],
argv: list[str],
output: Output,
prog: str,
) -> tuple[typing.Any, Command[T], dict[str, typing.Any]]:
parser = create_parser(command, output=output)
parser = create_parser(command, output=output, prog=prog)

try:
version = next(
Expand All @@ -172,23 +173,25 @@ def backend(
try:
result_namespace = parser.parse_args(argv, ns)
except argparse.ArgumentError as e:
raise Exit(str(e), code=2, prog=command.real_name())
raise Exit(str(e), code=2, prog=prog)

result = to_dict(result_namespace)
command = result.pop("__command__")

return parser, command, result


def create_parser(command: Command, output: Output) -> argparse.ArgumentParser:
def create_parser(
command: Command, output: Output, prog: str
) -> argparse.ArgumentParser:
kwargs: dict[str, typing.Any] = {}
if sys.version_info >= (3, 9): # pragma: no cover
kwargs["exit_on_error"] = False

parser = ArgumentParser(
command=command,
output=output,
prog=command.real_name(),
prog=prog,
description=join_help(command.help, command.description),
allow_abbrev=False,
add_help=False,
Expand Down
13 changes: 11 additions & 2 deletions src/cappa/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,21 @@ def parse_command(
if argv is None: # pragma: no cover
argv = sys.argv[1:]

prog = command.real_name()
try:
parser, parsed_command, parsed_args = backend(command, argv, output=output)
parser, parsed_command, parsed_args = backend(
command, argv, output=output, prog=prog
)
prog = parser.prog
result = command.map_result(command, prog, parsed_args)
except Exit as e:
output.exit(e)
from cappa.help import format_help, format_short_help

output.exit(
e,
help=format_help(command, e.prog or prog),
short_help=format_short_help(command, e.prog or prog),
)
raise

return command, parsed_command, result
Expand Down
1 change: 1 addition & 0 deletions src/cappa/completion/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def execute(command: Command, prog: str, action: str, arg: Arg, output: Output):
command,
command_args,
output=output,
prog=prog,
provide_completions=True,
)

Expand Down
9 changes: 9 additions & 0 deletions src/cappa/help.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ def format_help(command: Command, prog: str) -> list[Displayable]:
return lines


def format_short_help(command: Command, prog: str) -> Displayable:
arg_groups = generate_arg_groups(command)
return add_short_args(prog, arg_groups)


def generate_arg_groups(command: Command, include_hidden=False) -> list[ArgGroup]:
def by_group(arg: Arg | Subcommand) -> tuple[int, str]:
assert isinstance(arg.group, tuple)
Expand Down Expand Up @@ -176,3 +181,7 @@ def format_subcommand(command: Command):
),
command.help,
)


def format_subcommand_names(names: list[str]):
return ", ".join(f"[cappa.subcommand]{a}[/cappa.subcommand]" for a in names)
44 changes: 35 additions & 9 deletions src/cappa/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@
from rich.theme import Theme
from typing_extensions import TypeAlias

__all__ = [
"output_format",
"error_format",
"error_format_without_short_help",
"Displayable",
"theme",
"Output",
"Exit",
"HelpExit",
]

prompt_types = (Prompt, Confirm)


Expand All @@ -33,6 +44,11 @@
)


output_format: str = "{message}"
error_format: str = "{short_help}\n\n[red]Error[/red]: {message}"
error_format_without_short_help: str = "[red]Error[/red]: {message}"


@dataclass
class Output:
"""Output sink for CLI std out and error streams.
Expand All @@ -54,7 +70,7 @@ class Output:
in `output_format`: prog, code, message.
error_format: Format string through which output_console error will be
formatted. The following format string named format arguments can be used
in `output_format`: prog, code, message.
in `error_format`: prog, code, message, help, short_help.
Examples:
>>> output = Output()
Expand All @@ -68,8 +84,8 @@ class Output:
default_factory=lambda: Console(file=sys.stderr, theme=theme)
)

output_format: str = "{message}"
error_format: str = "[red]Error[/red]: {message}"
output_format: str = output_format
error_format: str = error_format

def color(self, value: bool = True):
"""Override the default `color` setting (None), to an explicit True/False value."""
Expand All @@ -82,12 +98,17 @@ def theme(self, t: Theme | None):
self.output_console.push_theme(t or theme)
self.error_console.push_theme(t or theme)

def exit(self, e: Exit):
def exit(
self,
e: Exit,
help: list[Displayable] | None = None,
short_help: Displayable | None = None,
):
"""Print a `cappa.Exit` object to the appropriate console."""
if e.code == 0:
self.output(e)
self.output(e, help=help, short_help=short_help)
else:
self.error(e)
self.error(e, help=help, short_help=short_help)

def output(
self, message: list[Displayable] | Displayable | Exit | str | None, **context
Expand Down Expand Up @@ -118,7 +139,7 @@ def _format_message(
console: Console,
message: list[Displayable] | Displayable | Exit | str | None,
format: str,
**context,
**context: Displayable | list[Displayable] | None,
) -> Text | str | None:
code: int | str | None = None
prog = None
Expand All @@ -137,9 +158,14 @@ def _format_message(
"prog": prog,
"message": text,
}
final_context = {**inner_context, **context}

return Text.from_markup(format.format(**final_context))
context = {"short_help": None, "help": None, **context}
rendered_context = {
k: rich_to_ansi(console, v) if v else "" for k, v in context.items()
}
final_context = {**inner_context, **rendered_context}

return Text.from_markup(format.format(**final_context).strip())

def write(self, console: Console, message: Text | str | None):
if message is None:
Expand Down
17 changes: 12 additions & 5 deletions src/cappa/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from cappa.arg import Arg, ArgAction, no_extra_arg_actions
from cappa.command import Command, Subcommand
from cappa.completion.types import Completion, FileCompletion
from cappa.help import format_help
from cappa.help import format_help, format_subcommand_names
from cappa.invoke import fullfill_deps
from cappa.output import Exit, HelpExit, Output
from cappa.typing import T, assert_type
Expand Down Expand Up @@ -68,10 +68,9 @@ def backend(
command: Command[T],
argv: list[str],
output: Output,
prog: str,
provide_completions: bool = False,
) -> tuple[typing.Any, Command[T], dict[str, typing.Any]]:
prog = command.real_name()

args = RawArg.collect(argv, provide_completions=provide_completions)

context = ParseContext.from_command(args, [command], output)
Expand Down Expand Up @@ -424,16 +423,24 @@ def consume_subcommand(context: ParseContext, arg: Subcommand) -> typing.Any:
return

raise BadArgumentError(
f"The following arguments are required: {{{arg.names_str()}}}",
f"A command is required: {{{format_subcommand_names(arg.names())}}}",
value="",
command=context.command,
arg=arg,
)

assert isinstance(value, RawArg), value
if value.raw not in arg.options:
message = f"Invalid command '{value.raw}'"
possible_values = [name for name in arg.names() if name.startswith(value.raw)]
if possible_values:
message += f" (Did you mean: {format_subcommand_names(possible_values)})"

raise BadArgumentError(
"invalid subcommand", value=value.raw, command=context.command, arg=arg
message,
value=value.raw,
command=context.command,
arg=arg,
)

command = arg.options[value.raw]
Expand Down
12 changes: 12 additions & 0 deletions src/cappa/themes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from rich.theme import Theme

default_theme: Theme = Theme(
{
"cappa.prog": "grey50",
"cappa.group": "dark_orange bold",
"cappa.arg": "cyan",
"cappa.arg.name": "dark_cyan",
"cappa.subcommand": "dark_cyan",
"cappa.help": "",
}
)
4 changes: 3 additions & 1 deletion tests/arg/test_subcommand_required.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ def test_required_implicit(backend):
with pytest.raises(cappa.Exit) as e:
parse(Command)
assert e.value.code == 2
assert "are required: {a}" in str(e.value.message)
assert "A command is required: {[cappa.subcommand]a[/cappa.subcommand]}" in str(
e.value.message
)

result = parse(Command, "a", backend=backend)
assert result == Command(subcmd=A())
13 changes: 9 additions & 4 deletions tests/subcommand/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,15 @@ def test_required_missing(backend):
with pytest.raises(cappa.Exit) as e:
parse(RequiredMissing, backend=backend)
assert isinstance(e.value.message, str)
assert (
"the following arguments are required: {required-missing-one}"
in e.value.message.lower()
)

message = e.value.message.lower()
if backend:
assert "the following arguments are required: {required-missing-one}" in message
else:
assert (
"a command is required: {[cappa.subcommand]required-missing-one[/cappa.subcommand]}"
in message
)


@dataclass
Expand Down
49 changes: 49 additions & 0 deletions tests/subcommand/test_did_you_mean.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import Union

import cappa
import pytest
from typing_extensions import Annotated

from tests.utils import parse


@dataclass
class RequiredMissingOne:
foo: Annotated[Union[str, None], cappa.Arg(long=True)] = None


@dataclass
class RequiredMissingTwo:
foo: Annotated[Union[str, None], cappa.Arg(long=True)] = None


@dataclass
class RequiredMissing:
subcommand: Annotated[
Union[RequiredMissingOne, RequiredMissingTwo], cappa.Subcommand
]


def test_has_possible_values():
with pytest.raises(cappa.Exit) as e:
parse(RequiredMissing, "req", backend=None)
assert isinstance(e.value.message, str)

expected_message = (
"Invalid command 'req' (Did you mean: "
"[cappa.subcommand]required-missing-one[/cappa.subcommand], "
"[cappa.subcommand]required-missing-two[/cappa.subcommand])"
)
assert expected_message == e.value.message


def test_no_possible_values():
with pytest.raises(cappa.Exit) as e:
parse(RequiredMissing, "bad", backend=None)
assert isinstance(e.value.message, str)

expected_message = "Invalid command 'bad'"
assert expected_message == e.value.message

0 comments on commit 97a57d4

Please sign in to comment.