diff --git a/CHANGELOG.md b/CHANGELOG.md index a053e5c..7093af3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index a65944a..b03edca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/cappa/argparse.py b/src/cappa/argparse.py index 8c2a153..84ef7b9 100644 --- a/src/cappa/argparse.py +++ b/src/cappa/argparse.py @@ -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 @@ -68,9 +68,10 @@ 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) @@ -78,8 +79,15 @@ def __init__(self, *args, command: Command, **kwargs): 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)) @@ -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, @@ -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( @@ -162,7 +172,7 @@ 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__") @@ -170,13 +180,14 @@ def backend( 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, @@ -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) @@ -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) @@ -256,6 +270,7 @@ def add_subcommands( parser: argparse.ArgumentParser, group: str, subcommands: Subcommand, + output: Output, dest_prefix="", ): subcommand_dest = subcommands.field_name @@ -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( @@ -283,6 +299,7 @@ def add_subcommands( add_arguments( subparser, subcommand, + output=output, dest_prefix=nested_dest_prefix, ) diff --git a/src/cappa/base.py b/src/cappa/base.py index 505ac61..5cea5f3 100644 --- a/src/cappa/base.py +++ b/src/cappa/base.py @@ -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`. @@ -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, @@ -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 @@ -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. @@ -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, @@ -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() @@ -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 @@ -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 diff --git a/src/cappa/command.py b/src/cappa/command.py index 1acf8d8..e5e78e5 100644 --- a/src/cappa/command.py +++ b/src/cappa/command.py @@ -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) @@ -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 @@ -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 diff --git a/src/cappa/completion/base.py b/src/cappa/completion/base.py index 686183d..6e79d18 100644 --- a/src/cappa/completion/base.py +++ b/src/cappa/completion/base.py @@ -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) @@ -26,6 +26,7 @@ def execute(command: Command, prog: str, action: str, arg: Arg): backend( command, command_args, + output=output, provide_completions=True, ) diff --git a/src/cappa/output.py b/src/cappa/output.py index 950377a..cb43b07 100644 --- a/src/cappa/output.py +++ b/src/cappa/output.py @@ -3,7 +3,7 @@ import io import sys import typing -from dataclasses import dataclass +from dataclasses import dataclass, field from rich.console import Console, NewLine from rich.markdown import Markdown @@ -20,57 +20,131 @@ Displayable: TypeAlias = typing.Union[str, Text, Table, NewLine, Markdown, Padding] +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": "default", + } +) + + @dataclass class Output: - output_console: Console - error_console: Console - theme: typing.ClassVar[Theme] = Theme( - { - "cappa.prog": "grey50", - "cappa.group": "dark_orange bold", - "cappa.arg": "cyan", - "cappa.arg.name": "dark_cyan", - "cappa.subcommand": "dark_cyan", - "cappa.help": "default", - } + """Output sink for CLI std out and error streams. + + For simple customization (namely disabling color and overriding the theme), + `invoke` and `parse` accept `color` and `theme` arguments, which internally + configure the `output_console` and `error_console` fields. + + For more involved customization, an Output object can be supplied into either `invoke` + or `parse` functions as well. + + Note, all input arguments to Output are optional. + + Arguments: + output_console: Output sink, defaults to printing to stdout. + error_console: Error sink, defaults to printing to stderr. + output_format: Format string through which output_console output will be + formatted. The following format string named format arguments can be used + 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. + + Examples: + >>> output = Output() + >>> output = Output(error_format="{prog}: error: {message}") + """ + + output_console: Console = field( + default_factory=lambda: Console(file=sys.stdout, theme=theme) + ) + error_console: Console = field( + default_factory=lambda: Console(file=sys.stderr, theme=theme) ) - @classmethod - def from_theme(cls, theme: Theme | None = None, color: bool = True): - no_color = None if color else True - theme = theme or cls.theme - output_console = Console(file=sys.stdout, theme=theme, no_color=no_color) - error_console = Console(file=sys.stderr, theme=theme, no_color=no_color) - return cls(output_console, error_console) + output_format: str = "{message}" + error_format: str = "[red]Error[/red]: {message}" + + def color(self, value: bool = True): + """Override the default `color` setting (None), to an explicit True/False value.""" + self.output_console.no_color = not value + self.error_console.no_color = not value + return self + + def theme(self, t: Theme | None): + """Override the default Theme, or reset the theme back to default (with `None`).""" + self.output_console.push_theme(t or theme) + self.error_console.push_theme(t or theme) def exit(self, e: Exit): + """Print a `cappa.Exit` object to the appropriate console.""" if e.code == 0: self.output(e) else: self.error(e) - def output(self, message: list[Displayable] | Displayable | Exit | None): + def output( + self, message: list[Displayable] | Displayable | Exit | str | None, **context + ): + """Output a message to the `output_console`. + + Additional `**context` can be supplied into the `output_format` string template. + """ + message = self._format_message( + self.output_console, message, self.output_format, **context + ) self.write(self.output_console, message) - def error(self, message: list[Displayable] | Displayable | Exit | None): + def error( + self, message: list[Displayable] | Displayable | Exit | str | None, **context + ): + """Output a message to the `error_console`. + + Additional `**context` can be supplied into the `error_format` string template. + """ + message = self._format_message( + self.error_console, message, self.error_format, **context + ) self.write(self.error_console, message) - def write( - self, console: Console, message: list[Displayable] | Displayable | Exit | None - ): + def _format_message( + self, + console: Console, + message: list[Displayable] | Displayable | Exit | str | None, + format: str, + **context, + ) -> Text | str | None: + code: int | str | None = None + prog = None if isinstance(message, Exit): + code = message.code + prog = message.prog message = message.message if message is None: - return + return None - if isinstance(message, list): - messages = message - else: - messages = [message] + text = rich_to_ansi(console, message) + + inner_context = { + "code": code or 0, + "prog": prog, + "message": text, + } + final_context = {**inner_context, **context} - for m in messages: - console.print(m) + return Text.from_markup(format.format(**final_context)) + + def write(self, console: Console, message: Text | str | None): + if message is None: + return + + console.print(message, overflow="ignore") class TestPrompt(Prompt): @@ -92,8 +166,10 @@ def __init__( message: list[Displayable] | Displayable | None = None, *, code: str | int | None = 0, + prog: str | None = None, ): self.message = message + self.prog = prog super().__init__(code) @@ -103,6 +179,20 @@ def __init__( message: list[Displayable] | Displayable, *, code: str | int | None = 0, + prog: str | None = None, ): - super().__init__(code=code) + super().__init__(code=code, prog=prog) self.message = message + + +def rich_to_ansi( + console: Console, message: list[Displayable] | Displayable | str +) -> str: + with console.capture() as capture: + if isinstance(message, list): + for m in message: + console.print(m) + else: + console.print(message) + + return capture.get().strip() diff --git a/src/cappa/parser.py b/src/cappa/parser.py index 1ef7b63..9f0dee2 100644 --- a/src/cappa/parser.py +++ b/src/cappa/parser.py @@ -9,7 +9,7 @@ from cappa.completion.types import Completion, FileCompletion from cappa.help import format_help from cappa.invoke import fullfill_deps -from cappa.output import Exit, HelpExit +from cappa.output import Exit, HelpExit, Output from cappa.typing import T, assert_type @@ -67,29 +67,31 @@ def from_value(cls, value: Value[str], arg: Arg): def backend( command: Command[T], argv: list[str], + output: Output, 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]) + context = ParseContext.from_command(args, [command], output) context.provide_completions = provide_completions try: try: parse(context) except HelpAction as e: - raise HelpExit(format_help(e.command, e.command_name), code=0) + raise HelpExit( + format_help(e.command, e.command_name), code=0, prog=context.prog + ) except VersionAction as e: - raise Exit(e.version.value_name, code=0) + raise Exit(e.version.value_name, code=0, prog=context.prog) except BadArgumentError as e: if context.provide_completions and e.arg: completions = e.arg.completion(e.value) if e.arg.completion else [] raise CompletionAction(*completions) - format_help(e.command, prog) - raise Exit(str(e), code=2) + raise Exit(str(e), code=2, prog=context.prog) except CompletionAction as e: from cappa.completion.base import execute, format_completions @@ -97,7 +99,7 @@ def backend( completions = format_completions(*e.completions) raise Exit(completions, code=0) - execute(command, prog, e.value, assert_type(e.arg, Arg)) + execute(command, prog, e.value, assert_type(e.arg, Arg), output=output) if provide_completions: raise Exit(code=0) @@ -112,6 +114,8 @@ class ParseContext: arguments: deque[Arg | Subcommand] missing_options: set[str] + output: Output + consumed_args: list[RawArg | RawOption] = dataclasses.field(default_factory=list) result: dict[str, typing.Any] = dataclasses.field(default_factory=dict) @@ -124,6 +128,7 @@ def from_command( cls, args: deque[RawArg | RawOption], command_stack: list[Command], + output: Output, ) -> ParseContext: command = command_stack[-1] options, missing_options = cls.collect_options(command) @@ -132,6 +137,7 @@ def from_command( args, options, arguments, + output=output, missing_options=missing_options, command_stack=command_stack, ) @@ -172,6 +178,10 @@ def collect_arguments(command: Command) -> list[Arg | Subcommand]: result.append(arg) return result + @property + def prog(self): + return " ".join(c.real_name() for c in self.command_stack) + @property def command(self): return self.command_stack[-1] @@ -420,6 +430,7 @@ def consume_subcommand(context: ParseContext, arg: Subcommand) -> typing.Any: nested_context = ParseContext.from_command( context.remaining_args, command_stack=context.command_stack, + output=context.output, ) nested_context.provide_completions = context.provide_completions nested_context.result["__name__"] = value.raw @@ -454,7 +465,7 @@ def consume_arg( if isinstance(value, RawOption): raise BadArgumentError( f"Argument requires {orig_num_args} values, " - f"only found {len(result)} ('{' '.join(result)}' so far).", + f"only found {len(result)} ('{' '.join(result)}' so far)", value=result, command=context.command, arg=arg, @@ -491,7 +502,7 @@ def consume_arg( return raise BadArgumentError( - f"Option '{arg.value_name}' requires an argument.", + f"Option '{arg.value_name}' requires an argument", value="", command=context.command, arg=arg, @@ -510,6 +521,7 @@ def consume_arg( fullfilled_deps: dict = { Command: context.command, + Output: context.output, ParseContext: context, Arg: arg, Value: Value(result), diff --git a/src/cappa/subcommand.py b/src/cappa/subcommand.py index 3634bd6..c459488 100644 --- a/src/cappa/subcommand.py +++ b/src/cappa/subcommand.py @@ -82,10 +82,10 @@ def normalize( group=group, ) - def map_result(self, parsed_args): + def map_result(self, prog: str, parsed_args): option_name = parsed_args.pop("__name__") option = self.options[option_name] - return option.map_result(option, parsed_args) + return option.map_result(option, prog, parsed_args) def names(self) -> list[str]: return list(self.options.keys()) diff --git a/src/cappa/testing.py b/src/cappa/testing.py index 94141fc..15caa0a 100644 --- a/src/cappa/testing.py +++ b/src/cappa/testing.py @@ -19,6 +19,7 @@ class RunnerArgs(typing.TypedDict, total=False): obj: type deps: typing.Sequence[typing.Callable] backend: typing.Callable | None + output: cappa.Output | None color: bool version: str | cappa.Arg help: bool | cappa.Arg @@ -70,6 +71,7 @@ class CommandRunner: obj: type | None = None deps: typing.Sequence[typing.Callable] | None = None backend: typing.Callable | None = None + output: cappa.Output | None = None color: bool = True version: str | cappa.Arg | None = None help: bool | cappa.Arg = True @@ -81,6 +83,7 @@ def coalesce_args(self, *args: str, **kwargs: Unpack[RunnerArgs]) -> dict: "argv": self.base_args + list(args), "obj": kwargs.get("obj") or self.obj, "backend": kwargs.get("backend") or self.backend, + "output": kwargs.get("output") or self.output, "color": kwargs.get("color") or self.color, "version": kwargs.get("version") or self.version, "help": kwargs.get("help") or self.help, diff --git a/tests/help/test_name.py b/tests/help/test_name.py index 841ba91..db264a3 100644 --- a/tests/help/test_name.py +++ b/tests/help/test_name.py @@ -38,6 +38,5 @@ class Args: [--completion COMPLETION] Use `--completion generate` to print shell-specific completion source. Valid options: generate, complete. - """ ) diff --git a/tests/invoke/test_output.py b/tests/invoke/test_output.py index dc9e4f5..6b23bb0 100644 --- a/tests/invoke/test_output.py +++ b/tests/invoke/test_output.py @@ -20,4 +20,4 @@ def test_outputs_output(backend, capsys): outerr = capsys.readouterr() assert outerr.out == "woah!\n" - assert outerr.err == "woops!\n" + assert outerr.err == "Error: woops!\n" diff --git a/tests/parser/test_custom_callable_action.py b/tests/parser/test_custom_callable_action.py index 7dc124c..3a9473b 100644 --- a/tests/parser/test_custom_callable_action.py +++ b/tests/parser/test_custom_callable_action.py @@ -83,3 +83,22 @@ class Args: args = parse(Args, "sub-b", "sub-b-sub", "-v", "one", backend=backend) assert args.cmd.cmd.value == "sub-b-sub" + + +################################ +def custom_out(out: cappa.Output): + out.output("woah") + return 1 + + +@backends +def test_custom_action_output_dep(backend, capsys): + @dataclass + class Args: + value: Annotated[int, cappa.Arg(action=custom_out, short=True)] + + args = parse(Args, "-v", "one", backend=backend) + assert args.value == 1 + + out = capsys.readouterr().out + assert out == "woah\n" diff --git a/tests/parser/test_missing_num_args.py b/tests/parser/test_missing_num_args.py index 6a8b943..eced09e 100644 --- a/tests/parser/test_missing_num_args.py +++ b/tests/parser/test_missing_num_args.py @@ -24,6 +24,6 @@ class Args: message = str(e.value.message) if backend == argparse.backend: - assert "the following arguments are required: arg" in message + assert "The following arguments are required: arg" in message else: - assert message == "Argument requires 2 values, only found 1 ('arg' so far)." + assert message == "Argument requires 2 values, only found 1 ('arg' so far)" diff --git a/tests/test_output.py b/tests/test_output.py index 91defd8..ae734d9 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -1,9 +1,13 @@ +import textwrap from dataclasses import dataclass import cappa import pytest +from rich.table import Table +from rich.text import Text +from typing_extensions import Annotated -from tests.utils import backends +from tests.utils import backends, parse @backends @@ -41,7 +45,7 @@ class Example: @backends -def test_invoke_exit_errror(capsys, backend): +def test_invoke_exit_error(capsys, backend): def fn(): raise cappa.Exit("With message", code=1) @@ -55,4 +59,72 @@ class Example: assert e.value.code == 1 out = capsys.readouterr().err - assert out == "With message\n" + assert out == "Error: With message\n" + + +@backends +def test_error_output_rich_text(capsys, backend): + def fn(): + raise cappa.Exit(Text("With message"), code=1) + + @cappa.command(invoke=fn) + @dataclass + class Example: + ... + + with pytest.raises(cappa.Exit) as e: + cappa.invoke(Example, argv=[], backend=backend) + + assert e.value.code == 1 + out = capsys.readouterr().err + assert out == "Error: With message\n" + + +@backends +def test_explicit_output_prefix(capsys, backend): + @cappa.command(name="asdf") + @dataclass + class Example: + ... + + output = cappa.Output(error_format="{prog}: error({code}): {message}.") + with pytest.raises(cappa.Exit) as e: + parse(Example, "--fooooo", backend=backend, output=output) + + assert e.value.code == 2 + out = capsys.readouterr().err + assert out == "asdf: error(2): Unrecognized arguments: --fooooo.\n" + + +def _debug(output: cappa.Output): + table = Table(style="blue") + table.add_row("one", "two") + output.error(table) + raise cappa.Exit(code=0) + + +@backends +def test_output_formatting_complex_rich_object(capsys, backend): + @dataclass + class Example: + debug_info: Annotated[ + bool, + cappa.Arg(long=True, action=_debug, num_args=0), + ] = False + + output = cappa.Output(error_format="[red]Error[/red]:\n{message}!") + with pytest.raises(cappa.Exit) as e: + parse(Example, "--debug-info", output=output, backend=backend) + + assert e.value.code == 0 + out = capsys.readouterr().err + assert out == textwrap.dedent( + """\ + Error: + ┏━━━━━┳━━━━━┓ + ┃ ┃ ┃ + ┡━━━━━╇━━━━━┩ + │ one │ two │ + └─────┴─────┘! + """ + ) diff --git a/tests/utils.py b/tests/utils.py index 948ee4f..fe6c832 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -11,8 +11,8 @@ runner = CommandRunner(base_args=[]) -def parse(cls, *args, backend=None): - return runner.parse(*args, obj=cls, backend=backend) +def parse(cls, *args, **kwargs): + return runner.parse(*args, obj=cls, **kwargs) def invoke(cls, *args, **kwargs):