Skip to content

Commit

Permalink
Merge pull request #58 from DanCardin/dc/error_format
Browse files Browse the repository at this point in the history
  • Loading branch information
DanCardin authored Oct 25, 2023
2 parents 0e455b0 + 54908ce commit 44f5e52
Show file tree
Hide file tree
Showing 16 changed files with 327 additions and 83 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## 0.11.0

- Add option for explicit Output object, and add `error_format` option to allow
customizing output formatting.

## 0.10.2

- Disallow explicit `required=False` in combination with the lack of a field
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.10.2"
version = "0.11.0"
description = "Declarative CLI argument parser."

repository = "https://github.com/dancardin/cappa"
Expand Down
47 changes: 32 additions & 15 deletions src/cappa/argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from cappa.command import Command, Subcommand
from cappa.help import format_help, generate_arg_groups
from cappa.invoke import fullfill_deps
from cappa.output import Exit, HelpExit
from cappa.output import Exit, HelpExit, Output
from cappa.parser import RawOption, Value
from cappa.typing import assert_type, missing

Expand Down Expand Up @@ -68,18 +68,26 @@ def __init__(self, metavar=None, **kwargs):


class ArgumentParser(argparse.ArgumentParser):
def __init__(self, *args, command: Command, **kwargs):
def __init__(self, *args, command: Command, output: Output, **kwargs):
super().__init__(*args, **kwargs)
self.command = command
self.output = output

self.register("action", "store_true", _StoreTrueAction)
self.register("action", "store_false", _StoreFalseAction)
self.register("action", "help", _HelpAction)
self.register("action", "version", _VersionAction)
self.register("action", "count", _CountAction)

def error(self, message):
# Avoids argparse's error prefixing code, deferring it to Output
self.exit(2, message)

def exit(self, status=0, message=None):
raise Exit(message, code=status)
if message:
message = message.capitalize()

raise Exit(message, code=status, prog=self.prog)

def print_help(self):
raise HelpExit(format_help(self.command, self.prog))
Expand All @@ -100,16 +108,16 @@ def __call__(self, parser, namespace, values, option_string=None):
assert isinstance(option_string, str)
setattr(namespace, self.dest, not option_string.startswith("--no-"))

def format_usage(self):
return " | ".join(self.option_strings)


def custom_action(arg: Arg, action: Callable):
class CustomAction(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
def __call__(
self, parser: ArgumentParser, namespace, values, option_string=None # type: ignore
):
# XXX: This should ideally be able to inject parser state, but here, we dont
# have access to the same state as the native parser.
fullfilled_deps: dict = {
Output: parser.output,
Value: Value(values),
Command: namespace.__command__,
Arg: arg,
Expand Down Expand Up @@ -143,9 +151,11 @@ def __setattr__(self, name, value):


def backend(
command: Command[T], argv: list[str]
command: Command[T],
argv: list[str],
output: Output,
) -> tuple[typing.Any, Command[T], dict[str, typing.Any]]:
parser = create_parser(command)
parser = create_parser(command, output=output)

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

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

return parser, command, result


def create_parser(command: Command) -> argparse.ArgumentParser:
def create_parser(command: Command, output: Output) -> 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(),
description=join_help(command.help, command.description),
allow_abbrev=False,
Expand All @@ -185,12 +196,13 @@ def create_parser(command: Command) -> argparse.ArgumentParser:
)
parser.set_defaults(__command__=command)

add_arguments(parser, command)

add_arguments(parser, command, output=output)
return parser


def add_arguments(parser: argparse.ArgumentParser, command: Command, dest_prefix=""):
def add_arguments(
parser: argparse.ArgumentParser, command: Command, output: Output, dest_prefix=""
):
arg_groups = generate_arg_groups(command, include_hidden=True)
for group_name, args in arg_groups:
group = parser.add_argument_group(title=group_name)
Expand All @@ -199,7 +211,9 @@ def add_arguments(parser: argparse.ArgumentParser, command: Command, dest_prefix
if isinstance(arg, Arg):
add_argument(group, arg, dest_prefix=dest_prefix)
elif isinstance(arg, Subcommand):
add_subcommands(parser, group_name, arg, dest_prefix=dest_prefix)
add_subcommands(
parser, group_name, arg, output=output, dest_prefix=dest_prefix
)
else:
assert_never(arg)

Expand Down Expand Up @@ -256,6 +270,7 @@ def add_subcommands(
parser: argparse.ArgumentParser,
group: str,
subcommands: Subcommand,
output: Output,
dest_prefix="",
):
subcommand_dest = subcommands.field_name
Expand All @@ -274,6 +289,7 @@ def add_subcommands(
formatter_class=parser.formatter_class,
add_help=False,
command=subcommand, # type: ignore
output=output,
prog=f"{parser.prog} {subcommand.real_name()}",
)
subparser.set_defaults(
Expand All @@ -283,6 +299,7 @@ def add_subcommands(
add_arguments(
subparser,
subcommand,
output=output,
dest_prefix=nested_dest_prefix,
)

Expand Down
42 changes: 33 additions & 9 deletions src/cappa/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def parse(
help: bool | Arg = True,
completion: bool | Arg = True,
theme: Theme | None = None,
output: Output | None = None,
) -> T:
"""Parse the command, returning an instance of `obj`.
Expand All @@ -57,8 +58,12 @@ def parse(
(default to True), adds a --completion flag. An `Arg` can be supplied to customize
the argument's behavior.
theme: Optional rich theme to customized output formatting.
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.
"""
concrete_backend = _default_backend(backend)
concrete_backend = _coalesce_backend(backend)
concrete_output = _coalesce_output(output, theme, color)

command: Command[T] = collect(
obj,
Expand All @@ -67,12 +72,11 @@ def parse(
completion=completion,
backend=concrete_backend,
)
output = Output.from_theme(theme, color=color)
_, _, instance = Command.parse_command(
command,
argv=argv,
backend=concrete_backend,
output=output,
output=concrete_output,
)
return instance

Expand All @@ -88,6 +92,7 @@ def invoke(
help: bool | Arg = True,
completion: bool | Arg = True,
theme: Theme | None = None,
output: Output | None = None,
) -> T:
"""Parse the command, and invoke the selected command or subcommand.
Expand All @@ -114,8 +119,12 @@ def invoke(
(default to True), adds a --completion flag. An `Arg` can be supplied to customize
the argument's behavior.
theme: Optional rich theme to customized output formatting.
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.
"""
concrete_backend = _default_backend(backend)
concrete_backend = _coalesce_backend(backend)
concrete_output = _coalesce_output(output, theme, color)

command: Command = collect(
obj,
Expand All @@ -124,14 +133,15 @@ def invoke(
completion=completion,
backend=concrete_backend,
)
output = Output.from_theme(theme, color=color)
command, parsed_command, instance = Command.parse_command(
command,
argv=argv,
backend=concrete_backend,
output=output,
output=concrete_output,
)
return invoke_callable(
command, parsed_command, instance, output=concrete_output, deps=deps
)
return invoke_callable(command, parsed_command, instance, output=output, deps=deps)


@dataclass_transform()
Expand Down Expand Up @@ -206,7 +216,7 @@ def collect(
command: Command[T] = Command.get(obj)
command = Command.collect(command)

concrete_backend = _default_backend(backend)
concrete_backend = _coalesce_backend(backend)
if concrete_backend is argparse.backend:
completion = False

Expand All @@ -219,7 +229,21 @@ def collect(
)


def _default_backend(backend: typing.Callable | None = None):
def _coalesce_backend(backend: typing.Callable | None = None):
if backend is None: # pragma: no cover
return parser.backend
return backend


def _coalesce_output(
output: Output | None = None, theme: Theme | None = None, color: bool = True
):
if output is None:
output = Output()

output.theme(theme)

if not color:
output.color(False)

return output
10 changes: 6 additions & 4 deletions src/cappa/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,15 +130,16 @@ def parse_command(
argv = sys.argv[1:]

try:
_, parsed_command, parsed_args = backend(command, argv)
result = command.map_result(command, parsed_args)
parser, parsed_command, parsed_args = backend(command, argv, output=output)
prog = parser.prog
result = command.map_result(command, prog, parsed_args)
except Exit as e:
output.exit(e)
raise

return command, parsed_command, result

def map_result(self, command: Command[T], parsed_args) -> T:
def map_result(self, command: Command[T], prog: str, parsed_args) -> T:
kwargs = {}
for arg in self.value_arguments():
is_subcommand = isinstance(arg, Subcommand)
Expand All @@ -157,7 +158,7 @@ def map_result(self, command: Command[T], parsed_args) -> T:
value = value()

if isinstance(arg, Subcommand):
value = arg.map_result(value)
value = arg.map_result(prog, value)
else:
assert arg.parse

Expand All @@ -168,6 +169,7 @@ def map_result(self, command: Command[T], parsed_args) -> T:
raise Exit(
f"Invalid value for '{arg.names_str()}' with value '{value}': {exception_reason}",
code=2,
prog=prog,
)

kwargs[arg.field_name] = value
Expand Down
5 changes: 3 additions & 2 deletions src/cappa/completion/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@
from cappa.arg import Arg
from cappa.command import Command
from cappa.completion.shells import available_shells
from cappa.output import Exit
from cappa.output import Exit, Output
from cappa.parser import Completion, FileCompletion, backend


def execute(command: Command, prog: str, action: str, arg: Arg):
def execute(command: Command, prog: str, action: str, arg: Arg, output: Output):
shell_name = Path(os.environ.get("SHELL", "bash")).name
shell = available_shells.get(shell_name)

Expand All @@ -26,6 +26,7 @@ def execute(command: Command, prog: str, action: str, arg: Arg):
backend(
command,
command_args,
output=output,
provide_completions=True,
)

Expand Down
Loading

0 comments on commit 44f5e52

Please sign in to comment.