diff --git a/docs/source/backends.md b/docs/source/backends.md index 28b704e..861ddb6 100644 --- a/docs/source/backends.md +++ b/docs/source/backends.md @@ -53,13 +53,13 @@ and redefining argparse's objects with slight, necessary modifications. ## Cappa backend -Some "headline" features you get when using the cappa backend: +Ihe main "headline" feature you get when using the cappa backend is: Automatic +[completion](./completion.md) support. -- Automatic [completion](./completion.md) support -- Automatic rich-powered, colored help-text formatting +Is roughly 1/3 the size in LOC, given that much of the featureset and +flexibility of argparse are unused and a source of contention with cappa's +design. It's going to be easier to support arbitrary features with the native +parser than with an external backend. -But generally, it's going to be easier to support arbitrary features with a -custom parser than with an external backend. - -This backend is not currently the default due to its relative infancy. It will, -however, **become** the default before 1.0 of the library. +This backend is not currently the default, however it **will** become the +default before 1.0 of the library. diff --git a/docs/source/rich.md b/docs/source/rich.md index 3d2dc4c..e1ab9e1 100644 --- a/docs/source/rich.md +++ b/docs/source/rich.md @@ -1,10 +1,5 @@ # Rich -```{note} -In order to get rich "support" (essentially colored help text) when using the -argparse backend, you should separately depend upon the "rich-argparse" dependency. -``` - ## Color Colored output, including help-text generation, is automatically enabled. diff --git a/src/cappa/argparse.py b/src/cappa/argparse.py index 3bff5ec..12ba564 100644 --- a/src/cappa/argparse.py +++ b/src/cappa/argparse.py @@ -9,7 +9,7 @@ from cappa.arg import Arg, ArgAction from cappa.command import Command, Subcommand -from cappa.help import create_help_arg, create_version_arg, generate_arg_groups +from cappa.help import format_help, generate_arg_groups from cappa.invoke import fullfill_deps from cappa.output import Exit, HelpExit from cappa.parser import RawOption, Value @@ -42,10 +42,6 @@ def __init__(self, metavar=None, **kwargs): self.metavar = metavar super().__init__(**kwargs) - def __call__(self, parser, namespace, values, option_string=None): - parser.print_help() - raise HelpExit("", code=0) - class _VersionAction(argparse._VersionAction): def __init__(self, metavar=None, **kwargs): @@ -72,8 +68,9 @@ def __init__(self, metavar=None, **kwargs): class ArgumentParser(argparse.ArgumentParser): - def __init__(self, *args, **kwargs): + def __init__(self, *args, command: Command, **kwargs): super().__init__(*args, **kwargs) + self.command = command self.register("action", "store_true", _StoreTrueAction) self.register("action", "store_false", _StoreFalseAction) @@ -84,6 +81,9 @@ def __init__(self, *args, **kwargs): def exit(self, status=0, message=None): raise Exit(message, code=status) + def print_help(self): + raise HelpExit(format_help(self.command, self.prog)) + class BooleanOptionalAction(argparse.Action): """Simplified backport of same-named class from 3.9 onward. @@ -143,19 +143,19 @@ def __setattr__(self, name, value): def backend( - command: Command[T], - argv: list[str], - color: bool = True, - version: str | Arg | None = None, - help: bool | Arg = True, - completion: bool | Arg = True, + command: Command[T], argv: list[str] ) -> tuple[typing.Any, Command[T], dict[str, typing.Any]]: - version = create_version_arg(version) - command.add_meta_actions(help=create_help_arg(help), version=version) + parser = create_parser(command) - parser = create_parser(command, color=color) - if version: + try: + version = next( + a + for a in command.arguments + if isinstance(a, Arg) and a.action is ArgAction.version + ) parser.version = version.name # type: ignore + except StopIteration: + pass ns = Nestedspace() @@ -170,20 +170,17 @@ def backend( return parser, command, result -def create_parser( - command: Command, - color: bool = True, -) -> argparse.ArgumentParser: +def create_parser(command: Command) -> 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, prog=command.real_name(), description=join_help(command.help, command.description), allow_abbrev=False, add_help=False, - formatter_class=choose_help_formatter(color=color), **kwargs, ) parser.set_defaults(__command__=command) @@ -193,22 +190,6 @@ def create_parser( return parser -def choose_help_formatter(color: bool = True): - help_formatter: type[ - argparse.HelpFormatter - ] = argparse.ArgumentDefaultsHelpFormatter - - if color is True: - try: # pragma: no cover - from rich_argparse import ArgumentDefaultsRichHelpFormatter - - help_formatter = ArgumentDefaultsRichHelpFormatter - except ImportError: # pragma: no cover - pass - - return help_formatter - - def add_arguments(parser: argparse.ArgumentParser, command: Command, dest_prefix=""): arg_groups = generate_arg_groups(command, include_hidden=True) for group_name, args in arg_groups: @@ -242,7 +223,7 @@ def add_argument( is_positional = not names - num_args = backend_num_args(arg.num_args, is_positional) + num_args = backend_num_args(arg.num_args) kwargs: dict[str, typing.Any] = { "dest": dest_prefix + name, @@ -285,7 +266,6 @@ def add_subcommands( subparsers = parser.add_subparsers( title=group, required=assert_type(subcommands.required, bool), - description=subcommands.help, parser_class=ArgumentParser, ) @@ -297,6 +277,8 @@ def add_subcommands( description=subcommand.description, formatter_class=parser.formatter_class, add_help=False, + command=subcommand, # type: ignore + prog=f"{parser.prog} {subcommand.real_name()}", ) subparser.set_defaults( __command__=subcommand, **{nested_dest_prefix + "__name__": name} @@ -309,7 +291,7 @@ def add_subcommands( ) -def backend_num_args(num_args: int | None, is_positional) -> int | str | None: +def backend_num_args(num_args: int | None) -> int | str | None: if num_args is None or num_args == 1: return None diff --git a/src/cappa/base.py b/src/cappa/base.py index 92f1443..4c8cbba 100644 --- a/src/cappa/base.py +++ b/src/cappa/base.py @@ -1,13 +1,20 @@ from __future__ import annotations import dataclasses +import os import typing from rich.theme import Theme from typing_extensions import dataclass_transform +from cappa import argparse from cappa.class_inspect import detect from cappa.command import Command +from cappa.help import ( + create_completion_arg, + create_help_arg, + create_version_arg, +) from cappa.invoke import invoke_callable from cappa.output import Output @@ -39,9 +46,7 @@ def parse( necessary when testing. backend: A function used to perform the underlying parsing and return a raw parsed state. This defaults to constructing built-in function using argparse. - color: Whether to output in color. Note by default this will only affect the native - `cappa.backend` backend. If using the argparse backend, `rich-argparse` must be - separately installed. + color: Whether to output in color. version: If a string is supplied, adds a -v/--version flag which returns the given string as the version. If an `Arg` is supplied, uses the `name`/`short`/`long`/`help` fields to add a corresponding version argument. Note the `name` is assumed to **be** @@ -53,20 +58,26 @@ def parse( the argument's behavior. theme: Optional rich theme to customized output formatting. """ - command = Command.get(obj) + if backend is None: # pragma: no cover + from cappa import argparse + backend = argparse.backend + + command: Command = collect( + obj, + help=help, + version=version, + completion=completion, + color=color, + backend=backend, + ) output = Output.from_theme(theme) _, _, instance = Command.parse_command( command, argv=argv, backend=backend, - color=color, - version=version, - help=help, output=output, - completion=completion, ) - return instance @@ -95,9 +106,7 @@ def invoke( necessary when testing. backend: A function used to perform the underlying parsing and return a raw parsed state. This defaults to constructing built-in function using argparse. - color: Whether to output in color. Note by default this will only affect the native - `cappa.backend` backend. If using the argparse backend, `rich-argparse` must be - separately installed. + color: Whether to output in color. version: If a string is supplied, adds a -v/--version flag which returns the given string as the version. If an `Arg` is supplied, uses the `name`/`short`/`long`/`help` fields to add a corresponding version argument. Note the `name` is assumed to **be** @@ -109,20 +118,26 @@ def invoke( the argument's behavior. theme: Optional rich theme to customized output formatting. """ - command: Command = Command.get(obj) + if backend is None: # pragma: no cover + from cappa import argparse + + backend = argparse.backend + command: Command = collect( + obj, + help=help, + version=version, + completion=completion, + color=color, + backend=backend, + ) output = Output.from_theme(theme) command, parsed_command, instance = Command.parse_command( command, argv=argv, backend=backend, - color=color, - version=version, - help=help, output=output, - completion=completion, ) - return invoke_callable(command, parsed_command, instance, output=output, deps=deps) @@ -168,3 +183,36 @@ def wrapper(_decorated_cls): if _cls is not None: return wrapper(_cls) return wrapper + + +def collect( + obj: type, + *, + backend: typing.Callable | None = None, + version: str | Arg | None = None, + help: bool | Arg = True, + completion: bool | Arg = True, + color: bool = True, +): + if not color: + # XXX: This should probably be doing something with the Output rather than + # mutating global state. + os.environ["NO_COLOR"] = "1" + + if backend is None: # pragma: no cover + backend = argparse.backend + + command: Command = Command.get(obj) + command = Command.collect(command) + + if backend is argparse.backend: + completion = False + + help_arg = create_help_arg(help) + version_arg = create_version_arg(version) + completion_arg = create_completion_arg(completion) + + command.add_meta_actions( + help=help_arg, version=version_arg, completion=completion_arg + ) + return command diff --git a/src/cappa/command.py b/src/cappa/command.py index 25c6757..32ef9fc 100644 --- a/src/cappa/command.py +++ b/src/cappa/command.py @@ -121,33 +121,15 @@ def parse_command( cls, command: Command[T], *, - argv: list[str] | None = None, output: Output, - backend: typing.Callable | None = None, - color: bool = True, - version: str | Arg | None = None, - help: bool | Arg = True, - completion: bool | Arg = True, + backend: typing.Callable, + argv: list[str] | None = None, ) -> tuple[Command, Command[T], T]: if argv is None: # pragma: no cover argv = sys.argv[1:] - command = cls.collect(command) - - if backend is None: # pragma: no cover - from cappa import argparse - - backend = argparse.backend - try: - _, parsed_command, parsed_args = backend( - command, - argv, - color=color, - version=version, - help=help, - completion=completion, - ) + _, parsed_command, parsed_args = backend(command, argv) result = command.map_result(command, parsed_args) except Exit as e: output.exit(e) diff --git a/src/cappa/completion/base.py b/src/cappa/completion/base.py index 733d697..686183d 100644 --- a/src/cappa/completion/base.py +++ b/src/cappa/completion/base.py @@ -11,14 +11,7 @@ from cappa.parser import Completion, FileCompletion, backend -def execute( - command: Command, - prog: str, - action: str, - help: Arg | None, - version: Arg | None, - completion: Arg, -): +def execute(command: Command, prog: str, action: str, arg: Arg): shell_name = Path(os.environ.get("SHELL", "bash")).name shell = available_shells.get(shell_name) @@ -26,15 +19,13 @@ def execute( raise Exit("Unknown shell", code=1) if action == "generate": - raise Exit(shell.backend_template(prog, completion), code=0) + raise Exit(shell.backend_template(prog, arg), code=0) command_args = parse_incomplete_command() backend( command, command_args, - version=version, - help=help, provide_completions=True, ) diff --git a/src/cappa/parser.py b/src/cappa/parser.py index 7019c59..56eb703 100644 --- a/src/cappa/parser.py +++ b/src/cappa/parser.py @@ -1,19 +1,13 @@ from __future__ import annotations import dataclasses -import os import typing from collections import deque 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 ( - create_completion_arg, - create_help_arg, - create_version_arg, - format_help, -) +from cappa.help import format_help from cappa.invoke import fullfill_deps from cappa.output import Exit, HelpExit from cappa.typing import T, assert_type @@ -56,40 +50,29 @@ def from_arg(cls, arg: Arg): class CompletionAction(RuntimeError): def __init__( - self, *completions: Completion | FileCompletion, value="complete", **_ + self, + *completions: Completion | FileCompletion, + value="complete", + arg: Arg | None = None, ) -> None: self.completions = completions self.value = value + self.arg = arg @classmethod - def from_value(cls, value: Value[str]): - raise cls(value=value.value) + def from_value(cls, value: Value[str], arg: Arg): + raise cls(value=value.value, arg=arg) def backend( command: Command[T], argv: list[str], - color: bool = True, - version: str | Arg | None = None, - help: bool | Arg | None = True, - completion: bool | Arg = True, provide_completions: bool = False, ) -> tuple[typing.Any, Command[T], dict[str, typing.Any]]: - if not color: - os.environ["NO_COLOR"] = "1" - prog = command.real_name() args = RawArg.collect(argv, provide_completions=provide_completions) - help_arg = create_help_arg(help) - version_arg = create_version_arg(version) - completion_arg = create_completion_arg(completion) - - command.add_meta_actions( - help=help_arg, version=version_arg, completion=completion_arg - ) - context = ParseContext.from_command(args, [command]) context.provide_completions = provide_completions @@ -114,14 +97,7 @@ def backend( completions = format_completions(*e.completions) raise Exit(completions, code=0) - execute( - command, - prog, - e.value, - help=help_arg, - version=version_arg, - completion=assert_type(completion_arg, Arg), - ) + execute(command, prog, e.value, assert_type(e.arg, Arg)) if provide_completions: raise Exit(code=0) diff --git a/src/cappa/subcommand.py b/src/cappa/subcommand.py index ec37b03..4619e09 100644 --- a/src/cappa/subcommand.py +++ b/src/cappa/subcommand.py @@ -28,9 +28,6 @@ class Subcommand: Arguments: name: Defaults to the name of the class, converted to dash case, but can be overridden here. - help: By default, the help text will be inferred from the containing class' - arguments' section, if it exists. Alternatively, you can directly supply - the help text here. types: Defaults to the class's annotated types, but can be overridden here. required: Defaults to automatically inferring requiredness, based on whether the class's value has a default. By setting this, you can force a particular value. @@ -38,7 +35,6 @@ class Subcommand: """ name: str | MISSING = ... - help: str | None = None required: bool | None = None group: str | tuple[int, str] = (3, "Subcommands") hidden: bool = False @@ -80,7 +76,6 @@ def normalize( required = infer_required(self, annotation) options = infer_options(self, types) group = infer_group(self) - help = infer_help(self, fallback_help) return dataclasses.replace( self, @@ -89,7 +84,6 @@ def normalize( required=required, options=options, group=group, - help=help, ) def map_result(self, parsed_args): @@ -156,11 +150,4 @@ def infer_group(arg: Subcommand) -> str | tuple[int, str]: return typing.cast(typing.Tuple[int, str], group) -def infer_help(arg: Subcommand, fallback_help: str | None) -> str | None: - help = arg.help - if help is None: - help = fallback_help - return help - - Subcommands: TypeAlias = Annotated[T, Subcommand] diff --git a/tests/arg/test_help.py b/tests/arg/test_help.py index 7741351..bcd39f8 100644 --- a/tests/arg/test_help.py +++ b/tests/arg/test_help.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from dataclasses import dataclass from typing import Literal, Union @@ -8,42 +9,45 @@ from cappa.output import Exit from typing_extensions import Annotated, Doc # type: ignore -from tests.utils import parse +from tests.utils import backends, parse @pytest.mark.help -def test_explicit_parse_function(capsys): +@backends +def test_explicit_parse_function(backend, capsys): @dataclass class ArgTest: numbers: Annotated[int, cappa.Arg(parse=int, help="example")] with pytest.raises(Exit): - parse(ArgTest, "--help") + parse(ArgTest, "--help", backend=backend) stdout = capsys.readouterr().out - assert "numbers example" in stdout + assert re.match(r".*numbers\s+example.*", stdout, re.DOTALL) @pytest.mark.help -def test_choices_in_help(capsys): +@backends +def test_choices_in_help(backend, capsys): @dataclass class ArgTest: numbers: Annotated[ Union[Literal[1], Literal[2]], cappa.Arg(parse=int, help="example") ] - result = parse(ArgTest, "1") + result = parse(ArgTest, "1", backend=backend) assert result == ArgTest(1) with pytest.raises(Exit): - parse(ArgTest, "--help") + parse(ArgTest, "--help", backend=backend) stdout = capsys.readouterr().out assert "Valid options: 1, 2" in stdout @pytest.mark.help -def test_pep_727_doc_annotated(capsys): +@backends +def test_pep_727_doc_annotated(backend, capsys): @dataclass class ArgTest: """Test. @@ -61,7 +65,7 @@ class ArgTest: ] with pytest.raises(Exit): - parse(ArgTest, "--help") + parse(ArgTest, "--help", backend=backend) stdout = capsys.readouterr().out assert "Use Doc if exists" in stdout diff --git a/tests/command/test_help.py b/tests/command/test_help.py index 99069bf..4e8c805 100644 --- a/tests/command/test_help.py +++ b/tests/command/test_help.py @@ -1,16 +1,18 @@ from __future__ import annotations +import re from dataclasses import dataclass import cappa import pytest from cappa.output import Exit -from tests.utils import parse +from tests.utils import backends, parse @pytest.mark.help -def test_default_help(capsys): +@backends +def test_default_help(backend, capsys): @dataclass class Command: """Some help. @@ -21,14 +23,15 @@ class Command: ... with pytest.raises(Exit): - parse(Command, "--help") + parse(Command, "--help", backend=backend) stdout = capsys.readouterr().out - assert "\nSome help. More detail.\n" in stdout + assert re.match(r".*Some help\.\s+More detail\..*", stdout, re.DOTALL) @pytest.mark.help -def test_default_help_no_long_description(capsys): +@backends +def test_default_help_no_long_description(backend, capsys): @dataclass class Command: """Some help.""" @@ -36,35 +39,37 @@ class Command: ... with pytest.raises(Exit): - parse(Command, "--help") + parse(Command, "--help", backend=backend) stdout = capsys.readouterr().out - assert "\nSome help.\n" in stdout + assert "Some help." in stdout @pytest.mark.help -def test_unannotated_argument(capsys): +@backends +def test_unannotated_argument(backend, capsys): @cappa.command(help="All the help.") @dataclass class Command: ... with pytest.raises(Exit): - parse(Command, "--help") + parse(Command, "--help", backend=backend) stdout = capsys.readouterr().out - assert "\nAll the help.\n" in stdout + assert "All the help." in stdout @pytest.mark.help -def test_description_without_help(capsys): +@backends +def test_description_without_help(backend, capsys): @cappa.command(description="All the help.") @dataclass class Command: pass with pytest.raises(Exit): - parse(Command, "--help") + parse(Command, "--help", backend=backend) stdout = capsys.readouterr().out - assert "All the help.\n" in stdout + assert "All the help." in stdout diff --git a/tests/subcommand/test_help.py b/tests/subcommand/test_help.py deleted file mode 100644 index 40301cf..0000000 --- a/tests/subcommand/test_help.py +++ /dev/null @@ -1,49 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -import cappa -import pytest -from cappa.output import Exit -from typing_extensions import Annotated, Doc # type: ignore - -from tests.utils import parse - - -@dataclass -class Cmd: - foo: int - - -@pytest.mark.help -def test_pep_727_doc_annotated_arg_wins(capsys): - @dataclass - class ArgTest: - subcommand: Annotated[Cmd, cappa.Subcommand(help="Arg wins"), Doc("Doc loses")] - - with pytest.raises(Exit): - parse(ArgTest, "--help") - - stdout = capsys.readouterr().out - assert "Arg wins" in stdout - assert "Doc loses" not in stdout - - -@pytest.mark.help -def test_pep_727_doc_annotated_doc_beats_docstring(capsys): - @dataclass - class ArgTest: - """Test. - - Arguments: - subcommand: docstring loses - """ - - subcommand: Annotated[Cmd, cappa.Subcommand, Doc("Doc wins")] - - with pytest.raises(Exit): - parse(ArgTest, "--help") - - stdout = capsys.readouterr().out - assert "Doc wins" in stdout - assert "docstring loses" not in stdout diff --git a/tests/test_docstring.py b/tests/test_docstring.py index 0502812..7aa414d 100644 --- a/tests/test_docstring.py +++ b/tests/test_docstring.py @@ -1,9 +1,10 @@ +import re from dataclasses import dataclass import cappa import pytest -from tests.utils import parse +from tests.utils import backends, parse @dataclass @@ -22,26 +23,28 @@ class IncludesDocstring: @pytest.mark.help -def test_required_provided(capsys): +@backends +def test_required_provided(backend, capsys): with pytest.raises(cappa.Exit): - parse(IncludesDocstring, "--help") + parse(IncludesDocstring, "--help", backend=backend) result = capsys.readouterr().out - assert "[--bar] [-h] foo" in result - assert "Does a thing. and does it really well!" in result - assert "foo the value of foo" in result - assert "--bar whether to bar (default: False)" in result + assert "[--bar] foo [-h]" in result + assert re.match(r".*Does a thing\.\s+and does it really well!.*", result, re.DOTALL) + assert re.match(r".*foo\s+the value of foo.*", result, re.DOTALL) + assert re.match(r".*\[--bar\]\s+whether to bar.*", result, re.DOTALL) @pytest.mark.help -def test_just_a_title(capsys): +@backends +def test_just_a_title(backend, capsys): @dataclass class IncludesDocstring: """Just a title.""" with pytest.raises(cappa.Exit): - parse(IncludesDocstring, "--help") + parse(IncludesDocstring, "--help", backend=backend) result = capsys.readouterr().out @@ -49,14 +52,15 @@ class IncludesDocstring: @pytest.mark.help -def test_docstring_with_explicit_help(capsys): +@backends +def test_docstring_with_explicit_help(backend, capsys): @cappa.command(help="help text") @dataclass class IncludesDocstring: """Just a title.""" with pytest.raises(cappa.Exit): - parse(IncludesDocstring, "--help") + parse(IncludesDocstring, "--help", backend=backend) result = capsys.readouterr().out @@ -65,14 +69,15 @@ class IncludesDocstring: @pytest.mark.help -def test_docstring_with_explicit_description(capsys): +@backends +def test_docstring_with_explicit_description(backend, capsys): @cappa.command(description="description") @dataclass class IncludesDocstring: """Just a title.""" with pytest.raises(cappa.Exit): - parse(IncludesDocstring, "--help") + parse(IncludesDocstring, "--help", backend=backend) result = capsys.readouterr().out