Declarative CLI argument parser
from sphinx_pyproject import SphinxConfig

config = SphinxConfig("../../pyproject.toml", style="poetry")

project = "Cappa"
copyright = "2023, Dan Cardin"
author = config.author
release = config.version

extensions = [
    "autoapi.extension",
    "myst_parser",
    "sphinx.ext.autodoc",
    "sphinx.ext.autosectionlabel",
    "sphinx.ext.intersphinx",
    "sphinx.ext.napoleon",
    "sphinx_copybutton",
]

templates_path = ["_templates"]

exclude_patterns = []

html_theme = "furo"
html_theme_options = {
    "navigation_with_keys": True,
}
html_static_path = ["_static"]

myst_heading_anchors = 3
myst_enable_extensions = [
    "amsmath",
    "colon_fence",
    "deflist",
    "dollarmath",
    "fieldlist",
    "html_admonition",
    "html_image",
    "linkify",
    "replacements",
    "smartquotes",
    "strikethrough",
    "substitution",
    "tasklist",
]

intersphinx_mapping = {
    "python": ("https://docs.python.org/3", None),
}

autoapi_type = "python"
autoapi_dirs = ["../../src/cappa"]
autoapi_generate_api_docs = False

autodoc_typehints = "description"
autodoc_typehints_format = "fully-qualified"
suppress_warnings = ["autoapi.python_import_resolution"]

autosectionlabel_prefix_document = True

docs/source/conf.py
DanCardin/cappa
from cappa.base import command, invoke, parse
from cappa.command import Command
from cappa.completion.types import Completion
from cappa.env import Env
from cappa.invoke import Dep
from cappa.output import Exit, HelpExit, Output
from cappa.subcommand import Subcommand, Subcommands

# isort: split
from cappa.arg import Arg, ArgAction

# isort: split
from cappa import argparse
from cappa.parser import backend

__all__ = [
    "Arg",
    "ArgAction",
    "Command",
    "Completion",
    "Dep",
    "Env",
    "Exit",
    "HelpExit",
    "Output",
    "Subcommand",
    "Subcommands",
    "argparse",
    "backend",
    "command",
    "invoke",
    "parse",
]

src/cappa/__init__.py
DanCardin/cappa
from __future__ import annotations

import enum
import types
import typing

from typing_inspect import is_literal_type

from cappa.typing import T, backend_type, is_none_type, is_subclass, is_union_type

__all__ = [
    "parse_value",
    "parse_literal",
    "parse_list",
    "parse_set",
    "parse_union",
    "parse_tuple",
    "parse_none",
    "detect_choices",
]


type_priority: typing.Final = types.MappingProxyType(
    {
        None: 0,
        float: 1,
        int: 2,
        bool: 3,
        str: 4,
    }
)


def parse_value(annotation: type) -> typing.Callable:
    """Create a value parser for the given annotation.

    Examples:
        >>> from typing import List, Literal, Tuple
        >>> list_parser = parse_value(List[int])
        >>> tuple_parser = parse_value(Tuple[int, ...])
        >>> literal_parser = parse_literal(Literal["foo"])
    """
    origin = typing.get_origin(annotation) or annotation
    type_args = typing.get_args(annotation)

    if is_literal_type(origin):
        return parse_literal(*type_args)

    if is_union_type(origin):
        return parse_union(*type_args)

    if is_subclass(origin, (str, bool, int, float)):
        return origin

    if is_none_type(origin):
        return parse_none()

    if is_subclass(origin, list):
        return parse_list(*type_args)

    if is_subclass(origin, set):
        return parse_set(*type_args)

    if is_subclass(origin, tuple):
        return parse_tuple(*type_args)

    return origin


def parse_literal(type_arg: T) -> typing.Callable[[typing.Any], T]:
    """Create a value parser for a given literal value."""
    mapping_fn: typing.Type = type(type_arg)

    def literal_mapper(value):
        mapped_value = mapping_fn(value)
        if mapped_value == type_arg:
            return mapped_value

        raise ValueError(f"{value} != {type_arg}")

    return literal_mapper


def parse_list(of_type: type[T]) -> typing.Callable[[typing.Any], list[T]]:
    """Create a value parser for a list of given type `of_type`."""
    inner_mapper = parse_value(of_type)

    def list_mapper(value: list):
        return [inner_mapper(v) for v in value]

    return list_mapper


def parse_set(of_type: type[T]) -> typing.Callable[[typing.Any], set[T]]:
    """Create a value parser for a list of given type `of_type`."""
    inner_mapper = parse_value(of_type)

    def set_mapper(value: list):
        return {inner_mapper(v) for v in value}

    return set_mapper


def parse_tuple(*type_args: type) -> typing.Callable[[typing.Any], tuple]:
    """Create a value parser for a tuple with type-args of given `type_args`."""
    if len(type_args) == 2 and type_args[1] == ...:
        inner_type: type = type_args[0]
        list_mapper = parse_value(typing.List[inner_type])  # type: ignore

        def unbounded_tuple_mapper(value: list):
            return tuple(list_mapper(value))

        return unbounded_tuple_mapper

    def tuple_mapper(value: list):
        result = []
        for inner_type, inner_value in zip(type_args, value):
            inner_mapper = parse_value(inner_type)
            inner_value = inner_mapper(inner_value)
            result.append(inner_value)
        return tuple(result)

    return tuple_mapper


def parse_union(*type_args: type) -> typing.Callable[[typing.Any], typing.Any]:
    """Create a value parser for a Union with type-args of given `type_args`."""

    def type_priority_key(type_) -> int:
        return type_priority.get(type_, 0)

    mappers: list[typing.Callable] = [
        parse_value(t) for t in sorted(type_args, key=type_priority_key)
    ]

    def union_mapper(value):
        for mapper in mappers:
            try:
                return mapper(value)
            except ValueError:
                pass

        raise ValueError(
            f"Could not parse '{value}' given options: {', '.join(backend_type(t) for t in type_args)}"
        )

    return union_mapper


def parse_none():
    """Create a value parser for None."""

    def map_none(value):
        if value is None:
            return

        raise ValueError(value)

    return map_none


def detect_choices(origin: type, type_args: tuple[type, ...]) -> list[str] | None:
    if is_subclass(origin, enum.Enum):
        assert issubclass(origin, enum.Enum)
        return [v.value for v in origin]

    if is_subclass(origin, (tuple, list, set)):
        origin = typing.cast(type, type_args[0])
        type_args = typing.get_args(type_args[0])

    if is_union_type(origin):
        if all(is_literal_type(t) for t in type_args):
            return [str(typing.get_args(t)[0]) for t in type_args]

    return None

src/cappa/annotation.py
DanCardin/cappa
"""Define the external facing argument definition types provided by a user."""
from __future__ import annotations

import dataclasses
import enum
import typing
from collections.abc import Callable

from typing_inspect import is_optional_type

from cappa.annotation import detect_choices, parse_value
from cappa.class_inspect import Field, extract_dataclass_metadata
from cappa.completion.completers import complete_choices
from cappa.completion.types import Completion
from cappa.subcommand import Subcommand
from cappa.typing import (
    MISSING,
    NoneType,
    T,
    find_type_annotation,
    is_subclass,
    missing,
)


@enum.unique
class ArgAction(enum.Enum):
    set = "store"
    store_true = "store_true"
    store_false = "store_false"
    append = "append"
    count = "count"

    help = "help"
    version = "version"
    completion = "completion"

    @classmethod
    def value_actions(cls) -> typing.Set[ArgAction]:
        return {cls.help, cls.version, cls.completion}


@dataclasses.dataclass
class Arg(typing.Generic[T]):
    """Describe a CLI argument.

    Arguments:
        name: The name of the argument. Defaults to the name of the corresponding class field.
        short: If `True`, uses first letter of the name to infer a (ex. `-s`) short
            flag. If a string is supplied, that will be used instead. If a string is supplied,
            it is split on '/' (forward slash), to support multiple options. Additionally
            accepts a list of strings.
        long: If `True`, uses first letter of the name to infer a (ex. `--long`) long
            flag. If a string is supplied, that will be used instead. If a string is supplied,
            it is split on '/' (forward slash), to support multiple options. Additionally
            accepts a list of strings.
        count: If `True` the resultant argmuent will count instances and accept zero
            arguments.
        default: An explicit default CLI value. When left unspecified, the default is
            inferred from the class' default or the adapter default/default_factory.
        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.
        parse: An optional function which accepts the raw string argument as input and
            returns a parsed value type's instance. This should only be required for
            complex types that the type system's built-in parsing cannot handle.

        group: Optional group names for the argument. This affects how they're displayed
            in the backended help text.
        hidden: Whether the argument should be hidden in help text. Defaults to False.

        action: Generally automatically inferred from the data type. This allows to
            override the default.
        num_args: Generally automatically inferred from the data type. This allows to
            override the default.
        choices: Generally automatically inferred from the data type. This allows to
            override the default.
        completion: Used to provide custom completions. If specified, should be a function
            which acccepts a partial string value and returns a list of
            [cappa.Completion](cappa.Completion) objects.

        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.
    """

    name: str | MISSING = missing
    short: bool | str | list[str] = False
    long: bool | str | list[str] = False
    count: bool = False
    default: T | None | MISSING = missing
    help: str | None = None
    parse: Callable[[typing.Any], T] | None = None

    group: str | tuple[int, str] | MISSING = missing
    hidden: bool = False

    action: ArgAction | None = None
    num_args: int | None = None
    choices: list[str] | None = None
    completion: Callable[..., list[Completion]] | None = None

    required: bool | None = None

    @classmethod
    def collect(
        cls, field: Field, type_hint: type, fallback_help: str | None = None
    ) -> Arg:
        maybe_arg, annotation = find_type_annotation(type_hint, cls)

        if maybe_arg is None:
            maybe_arg = cls()

        arg = typing.cast(Arg, maybe_arg)

        # Dataclass field metatdata takes precedence if it exists.
        field_metadata = extract_dataclass_metadata(field)
        assert not isinstance(field_metadata, Subcommand)

        if field_metadata:
            arg = field_metadata

        name = infer_name(arg, field)
        default = infer_default(arg, field)

        arg = dataclasses.replace(arg, name=name, default=default)
        return arg.normalize(annotation, fallback_help)

    def normalize(
        self,
        annotation=NoneType,
        fallback_help: str | None = None,
        action: ArgAction | None = None,
        name: str | None = None,
    ) -> Arg:
        origin = typing.get_origin(annotation) or annotation
        type_args = typing.get_args(annotation)
        required = infer_required(self, annotation, self.default)

        name = typing.cast(str, name or self.name)
        short = infer_short(self, name)
        long = infer_long(self, origin, name)
        choices = infer_choices(self, origin, type_args)
        action = action or infer_action(self, origin, type_args, long, self.default)
        num_args = infer_num_args(self, origin, type_args, long)

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

        group = infer_group(self, short, long)

        return dataclasses.replace(
            self,
            name=name,
            required=required,
            short=short,
            long=long,
            choices=choices,
            action=action,
            num_args=num_args,
            parse=parse,
            help=help,
            completion=completion,
            group=group,
        )

    def names(self) -> list[str]:
        short_names = typing.cast(list, self.short or [])
        long_names = typing.cast(list, self.long or [])
        return short_names + long_names

    def names_str(self, delimiter: str = ", ") -> str:
        if self.long or self.short:
            return delimiter.join(self.names())

        return typing.cast(str, self.name)


def infer_name(arg: Arg, field: Field) -> str:
    if not isinstance(arg.name, MISSING):
        raise ValueError("Arg 'name' cannot be set when using automatic inference.")

    return field.name


def infer_default(arg: Arg, field: Field) -> typing.Any:
    if arg.default is not missing:
        return arg.default

    if field.default is not missing:
        return field.default

    if field.default_factory is not missing:
        return field.default_factory()

    return missing


def infer_required(arg: Arg, annotation: type, default: typing.Any | MISSING):
    if arg.required is not None:
        return arg.required

    if default is missing:
        if is_subclass(annotation, bool):
            return False

        return not is_optional_type(annotation)

    return False


def infer_short(arg: Arg, name: str) -> list[str] | typing.Literal[False]:
    if not arg.short:
        return False

    if isinstance(arg.short, bool):
        short_name = name[0]
        return [f"-{short_name}"]

    if isinstance(arg.short, str):
        short = arg.short.split("/")
    else:
        short = arg.short

    return [item[:2] if item.startswith("-") else f"-{item[0]}" for item in short]


def infer_long(arg: Arg, origin: type, name: str) -> list[str] | typing.Literal[False]:
    long = arg.long

    if not long:
        # bools get automatically coerced into flags, otherwise stay off.
        if not is_subclass(origin, bool):
            return False

        long = True

    if isinstance(long, bool):
        long = name.replace("_", "-")
        return [f"--{long}"]

    if isinstance(long, str):
        long = long.split("/")

    return [item if item.startswith("--") else f"--{item}" for item in long]


def infer_choices(
    arg: Arg, origin: type, type_args: tuple[type, ...]
) -> list[str] | None:
    if arg.choices is None:
        choices = detect_choices(origin, type_args)
    else:
        choices = arg.choices

    if not choices:
        return None

    return [str(c) for c in choices]


def infer_action(
    arg: Arg, origin: type, type_args: tuple[type, ...], long, default: typing.Any
) -> ArgAction:
    if arg.count:
        return ArgAction.count

    if arg.action is not None:
        return arg.action

    # Coerce raw `bool` into flags by default
    if is_subclass(origin, bool):
        if default is not missing and bool(default):
            return ArgAction.store_false

        return ArgAction.store_true

    is_positional = not arg.short and not long
    has_specific_num_args = arg.num_args is not None

    if arg.parse or (is_positional and not has_specific_num_args):
        return ArgAction.set

    if is_subclass(origin, (list, set)):
        return ArgAction.append

    if is_subclass(origin, tuple):
        is_unbounded_tuple = len(type_args) == 2 and type_args[1] == ...
        if is_unbounded_tuple:
            return ArgAction.append

    return ArgAction.set


def infer_num_args(
    arg: Arg, origin: type, type_args: tuple[type, ...], long
) -> int | None:
    if arg.num_args is not None or arg.parse:
        return arg.num_args

    is_positional = not arg.short and not long

    if is_subclass(origin, (list, set)) and is_positional:
        return -1

    if is_subclass(origin, tuple):
        is_unbounded_tuple = len(type_args) == 2 and type_args[1] == ...
        if not is_unbounded_tuple:
            return len(type_args)

        if is_positional:
            return -1

    if is_subclass(origin, bool):
        return 1

    return None


def infer_parse(arg: Arg, annotation: type) -> Callable:
    if arg.parse:
        return arg.parse

    return parse_value(annotation)


def infer_help(
    arg: Arg, choices: list[str] | None, 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


def infer_completion(
    arg: Arg, choices: list[str] | None
) -> Callable[[str], list[Completion]] | None:
    if arg.completion:
        return arg.completion

    if choices:
        return complete_choices(choices, help=arg.help)

    return None


def infer_group(
    arg: Arg, short: list[str] | bool, long: list[str] | bool
) -> str | tuple[int, str]:
    group = arg.group
    group_name = None
    if isinstance(group, str):
        group_name = group
        group = missing

    if group is missing:
        if short or long:
            return (0, group_name or "Options")

        return (1, group_name or "Arguments")

    return typing.cast(typing.Tuple[int, str], group)


no_extra_arg_actions = {
    ArgAction.store_true,
    ArgAction.store_false,
    ArgAction.count,
    ArgAction.version,
    ArgAction.help,
}

src/cappa/arg.py
DanCardin/cappa
from __future__ import annotations

import argparse
import sys
import typing

from typing_extensions import assert_never

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.output import Exit, HelpExit
from cappa.typing import assert_not_missing, assert_type, missing

if sys.version_info < (3, 9):  # pragma: no cover
    # Backport https://github.com/python/cpython/pull/3680
    original_get_action_name = argparse._get_action_name

    def _get_action_name(
        argument: argparse.Action | None,
    ) -> str | None:  # pragma: no cover
        name = original_get_action_name(argument)

        assert argument
        if name is None and argument.choices:
            return "{" + ",".join(argument.choices) + "}"

        return name

    argparse._get_action_name = _get_action_name


T = typing.TypeVar("T")


# Work around argparse's lack of `metavar` support on various built-in actions.
class _HelpAction(argparse._HelpAction):
    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):
        self.metavar = metavar
        super().__init__(**kwargs)


class _StoreTrueAction(argparse._StoreTrueAction):
    def __init__(self, metavar=None, **kwargs):
        self.metavar = metavar
        super().__init__(**kwargs)


class _StoreFalseAction(argparse._StoreFalseAction):
    def __init__(self, metavar=None, **kwargs):
        self.metavar = metavar
        super().__init__(**kwargs)


class _CountAction(argparse._CountAction):
    def __init__(self, metavar=None, **kwargs):
        self.metavar = metavar
        super().__init__(**kwargs)


class ArgumentParser(argparse.ArgumentParser):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        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 exit(self, status=0, message=None):
        raise Exit(message, code=status)


class BooleanOptionalAction(argparse.Action):
    """Simplified backport of same-named class from 3.9 onward.

    We know more about the called context here, and thus need much less of the
    logic. Also, we support 3.8, which does not have the original class, so we
    couldn't use it anyway.
    """

    def __init__(self, **kwargs):
        super().__init__(nargs=0, **kwargs)

    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)


class Nestedspace(argparse.Namespace):
    """Write each . separated section as a nested `Nestedspace` instance.

    By default, argparse write everything to a flat namespace so there's no
    obvious way to distinguish between mulitple unrelated subcommands once
    once has been chosen.
    """

    def __setattr__(self, name, value):
        if "." in name:
            group, name = name.split(".", 1)
            ns = getattr(self, group, Nestedspace())
            setattr(ns, name, value)
            self.__dict__[group] = ns
        else:
            self.__dict__[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,
) -> 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, color=color)
    if version:
        parser.version = version.name  # type: ignore

    ns = Nestedspace()

    try:
        result_namespace = parser.parse_args(argv[1:], ns)
    except argparse.ArgumentError as e:
        raise Exit(str(e), code=2)

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

    return parser, command, result


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

    parser = ArgumentParser(
        prog=command.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)

    add_arguments(parser, command)

    return parser


def choose_help_formatter(color: bool = True):
    help_formatter: type[
        argparse.HelpFormatter
    ] = argparse.ArgumentDefaultsHelpFormatter

    if color is True:
        try:
            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:
        group = parser.add_argument_group(title=group_name)

        for arg in args:
            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)
            else:
                assert_never(arg)


def add_argument(
    parser: argparse.ArgumentParser | argparse._ArgumentGroup,
    arg: Arg,
    dest_prefix="",
    **extra_kwargs,
):
    name: str = assert_not_missing(arg.name)

    names: list[str] = []
    if arg.short:
        short = assert_type(arg.short, list)
        names.extend(short)

    if arg.long:
        long = assert_type(arg.long, list)
        names.extend(long)

    is_positional = not names

    num_args = backend_num_args(arg.num_args)

    kwargs: dict[str, typing.Any] = {
        "dest": dest_prefix + name,
        "help": arg.help,
        "metavar": name,
        "action": get_action(arg),
    }

    if not is_positional and arg.required:
        kwargs["required"] = arg.required

    if arg.default is not missing:
        if arg.action and arg.action is arg.action.append:
            kwargs["default"] = list(arg.default)  # type: ignore
        else:
            kwargs["default"] = arg.default

    if num_args and (
        arg.action and arg.action not in {arg.action.store_true, arg.action.store_false}
    ):
        kwargs["nargs"] = num_args
    elif is_positional and not arg.required:
        kwargs["nargs"] = "?"

    if arg.choices:
        kwargs["choices"] = arg.choices

    kwargs.update(extra_kwargs)

    parser.add_argument(*names, **kwargs)


def add_subcommands(
    parser: argparse.ArgumentParser,
    group: str,
    subcommands: Subcommand,
    dest_prefix="",
):
    subcommand_dest = subcommands.name
    subparsers = parser.add_subparsers(
        title=group,
        required=assert_type(subcommands.required, bool),
        description=subcommands.help,
        parser_class=ArgumentParser,
    )

    for name, subcommand in subcommands.options.items():
        nested_dest_prefix = f"{dest_prefix}{subcommand_dest}."
        subparser = subparsers.add_parser(
            name=subcommand.real_name(),
            help=subcommand.help,
            description=subcommand.description,
            formatter_class=parser.formatter_class,
            add_help=False,
        )
        subparser.set_defaults(
            __command__=subcommand, **{nested_dest_prefix + "__name__": name}
        )

        add_arguments(
            subparser,
            subcommand,
            dest_prefix=nested_dest_prefix,
        )


def backend_num_args(num_args: int | None) -> int | str | None:
    if num_args is None:
        return None

    if num_args == -1:
        return "+"

    return num_args


def to_dict(value: argparse.Namespace):
    result = {}
    for k, v in value.__dict__.items():
        if isinstance(v, argparse.Namespace):
            v = to_dict(v)
        result[k] = v

    return result


def join_help(*segments):
    return " ".join([s for s in segments if s])


def get_action(arg: Arg) -> type[argparse.Action] | str:
    action = assert_type(arg.action, ArgAction)
    if action in {ArgAction.store_true, ArgAction.store_false}:
        long = assert_type(arg.long, list)
        has_no_option = any("--no-" in i for i in long)
        if has_no_option:
            return BooleanOptionalAction

    return action.value

src/cappa/argparse.py
DanCardin/cappa
from __future__ import annotations

import dataclasses
import sys
import typing

from rich.theme import Theme
from typing_extensions import dataclass_transform

from cappa.class_inspect import detect
from cappa.command import Command
from cappa.invoke import invoke_callable
from cappa.output import Output

if typing.TYPE_CHECKING:
    from cappa.arg import Arg

T = typing.TypeVar("T")


def parse(
    obj: type[T],
    *,
    argv: list[str] | None = None,
    backend: typing.Callable | None = None,
    color: bool = True,
    version: str | Arg | None = None,
    help: bool | Arg = True,
    completion: bool | Arg = True,
    theme: Theme | None = None,
) -> T:
    """Parse the command, returning an instance of `obj`.

    In the event that a subcommand is selected, only the selected subcommand
    function is invoked.

    Arguments:
        obj: A class which can represent a CLI command chain.
        argv: Defaults to the process argv. This command is generally only
            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, if the `color` extra is installed.
        version: If a string is supplied, adds a -v/--version flag which returns the
            given version string. If an `Arg` is supplied, uses the `name`/`short`/`long`/`help`
            fields to add a corresponding version argument.
        help: If `True` (default to True), adds a -h/--help flag. If an `Arg` is supplied,
            uses the `short`/`long`/`help` fields to add a corresponding help argument.
        completion: Enables completion when using the cappa `backend` option. If `True`
        (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.
    """
    if argv is None:  # pragma: no cover
        argv = sys.argv

    command = Command.get(obj)

    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


def invoke(
    obj: type,
    *,
    deps: typing.Sequence[typing.Callable] | None = None,
    argv: list[str] | None = None,
    backend: typing.Callable | None = None,
    color: bool = True,
    version: str | Arg | None = None,
    help: bool | Arg = True,
    completion: bool | Arg = True,
    theme: Theme | None = None,
):
    """Parse the command, and invoke the selected command or subcommand.

    In the event that a subcommand is selected, only the selected subcommand
    function is invoked.

    Arguments:
        obj: A class which can represent a CLI command chain.
        deps: Optional extra depdnencies to load ahead of invoke processing. These
            deps are evaulated in order and unconditionally.
        argv: Defaults to the process argv. This command is generally only
            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, if the `color` extra is installed.
        version: If a string is supplied, adds a -v/--version flag which returns the
            given version string. If an `Arg` is supplied, uses the `name`/`short`/`long`
            fields to add a corresponding version argument.
        help: If `True` (default to True), adds a -h/--help flag. If an `Arg` is supplied,
            uses the `short`/`long`/`help` fields to add a corresponding help argument.
        completion: Enables completion when using the cappa `backend` option. If `True`
        (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.
    """
    if argv is None:  # pragma: no cover
        argv = sys.argv

    command: Command = Command.get(obj)

    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)


@dataclass_transform()
def command(
    _cls=None,
    *,
    name: str | None = None,
    help: str | None = None,
    description: str | None = None,
    invoke: typing.Callable | str | None = None,
):
    """Register a cappa CLI command/subcomment.

    Args:
        name: The name of the command. If omitted, the name of the command
            will be the name of the `cls`, converted to dash-case.
        help: Optional help text. If omitted, the `cls` docstring will be parsed,
            and the headline section will be used to document the command
            itself, and the arguments section will become the default help text for
            any params/options.
        description: Optional extended help text. If omitted, the `cls` docstring will
            be parsed, and the extended long description section will be used.
        invoke: Optional command to be called in the event parsing is successful.
            In the case of subcommands, it will only call the parsed/selected
            function to invoke.
    """

    def wrapper(_decorated_cls):
        if not detect(_decorated_cls):
            _decorated_cls = dataclasses.dataclass(_decorated_cls)

        instance = Command(
            cmd_cls=_decorated_cls,
            invoke=invoke,
            name=name,
            help=help,
            description=description,
        )
        _decorated_cls.__cappa__ = instance
        return _decorated_cls

    if _cls is not None:
        return wrapper(_cls)
    return wrapper

src/cappa/base.py
DanCardin/cappa
from __future__ import annotations

import dataclasses
import typing
from enum import Enum

from typing_extensions import Self

from cappa.typing import MISSING, missing

if typing.TYPE_CHECKING:
    from cappa import Arg, Subcommand

__all__ = [
    "fields",
    "detect",
]


def detect(cls: type) -> bool:
    try:
        return bool(ClassTypes.from_cls(cls))
    except ValueError:
        return False


class ClassTypes(Enum):
    dataclass = "dataclass"
    pydantic = "pydantic"
    pydantic_dataclass = "pydantic_dataclass"
    attrs = "attrs"

    @classmethod
    def from_cls(cls, obj: type) -> ClassTypes:
        if hasattr(obj, "__pydantic_fields__"):
            return cls.pydantic_dataclass

        if dataclasses.is_dataclass(obj):
            return cls.dataclass

        try:
            from pydantic import BaseModel
        except ImportError:  # pragma: no cover
            pass
        else:
            if issubclass(obj, BaseModel):
                return cls.pydantic

        if hasattr(obj, "__attrs_attrs__"):
            return cls.attrs

        raise ValueError(  # pragma: no cover
            f"'{cls}' is not a currently supported base class. "
            "Must be one of: dataclass, pydantic, or attrs class."
        )


@dataclasses.dataclass
class Field:
    name: str
    annotation: type
    default: typing.Any | MISSING = missing
    default_factory: typing.Any | MISSING = missing
    metadata: dict = dataclasses.field(default_factory=dict)

    @classmethod
    def from_dataclass(cls, typ: type) -> list[Self]:
        fields = []
        for f in typ.__dataclass_fields__.values():  # type: ignore
            field = cls(
                name=f.name,
                annotation=f.type,
                default=f.default if f.default is not dataclasses.MISSING else missing,
                default_factory=f.default_factory
                if f.default_factory is not dataclasses.MISSING
                else missing,
                metadata=f.metadata,
            )
            fields.append(field)
        return fields

    @classmethod
    def from_pydantic(cls, typ: type) -> list[Self]:
        fields = []
        for name, f in typ.model_fields.items():  # type: ignore
            field = cls(
                name=name,
                annotation=f.annotation,
                default=f.default
                if f.default.__repr__() != "PydanticUndefined"
                else missing,
                default_factory=f.default_factory or missing,
            )
            fields.append(field)
        return fields

    @classmethod
    def from_pydantic_dataclass(cls, typ: type) -> list[Self]:
        fields = []
        for name, f in typ.__pydantic_fields__.items():  # type: ignore
            field = cls(
                name=name,
                annotation=f.annotation,
                default=f.default or missing,
                default_factory=f.default_factory or missing,
            )
            fields.append(field)
        return fields

    @classmethod
    def from_attrs(cls, typ: type) -> list[Self]:
        fields = []
        for f in typ.__attrs_attrs__:  # type: ignore
            if hasattr(f.default, "factory"):
                default = None
                default_factory = f.default.factory
            else:
                default = f.default
                default_factory = None
            field = cls(
                name=f.name,
                annotation=f.type,
                default=default or missing,
                default_factory=default_factory or missing,
                metadata=f.metadata,
            )
            fields.append(field)
        return fields


def fields(cls: type):
    class_type = ClassTypes.from_cls(cls)
    if class_type == ClassTypes.dataclass:
        return Field.from_dataclass(cls)

    if class_type == ClassTypes.pydantic:
        return Field.from_pydantic(cls)

    if class_type == ClassTypes.pydantic_dataclass:
        return Field.from_pydantic_dataclass(cls)

    if class_type == ClassTypes.attrs:
        return Field.from_attrs(cls)

    raise NotImplementedError()  # pragma: no cover


def extract_dataclass_metadata(field: Field) -> Arg | Subcommand | None:
    from cappa.arg import Arg
    from cappa.subcommand import Subcommand

    field_metadata = field.metadata.get("cappa")
    if not field_metadata:
        return None

    if not isinstance(field_metadata, (Arg, Subcommand)):
        raise ValueError(
            '`metadata={"cappa": <x>}` must be of type `Arg` or `Subcommand`'
        )

    return field_metadata

src/cappa/class_inspect.py
DanCardin/cappa
from __future__ import annotations

import dataclasses
import inspect
import typing
from collections.abc import Callable

import docstring_parser
from typing_extensions import Self, get_type_hints

from cappa import class_inspect
from cappa.arg import Arg, ArgAction
from cappa.env import Env
from cappa.output import Exit, Output, prompt_types
from cappa.subcommand import Subcommand

T = typing.TypeVar("T")


class CommandArgs(typing.TypedDict, total=False):
    cmd_cls: type
    arguments: list[Arg | Subcommand]
    name: str | None
    help: str | None
    description: str | None
    invoke: Callable | str | None


@dataclasses.dataclass
class Command(typing.Generic[T]):
    """Register a cappa CLI command/subcomment.

    Args:
        cmd_cls: The class representing the command/subcommand
        name: The name of the command. If omitted, the name of the command
            will be the name of the `cls`, converted to dash-case.
        help: Optional help text. If omitted, the `cls` docstring will be parsed,
            and the headline section will be used to document the command
            itself, and the arguments section will become the default help text for
            any params/options.
        description: Optional extended help text. If omitted, the `cls` docstring will
            be parsed, and the extended long description section will be used.
        invoke: Optional command to be called in the event parsing is successful.
            In the case of subcommands, it will only call the parsed/selected
            function to invoke. The value can **either** be a callable object or
            a string. When the value is a string it will be interpreted as
            `module.submodule.function`; the module will be dynamically imported,
            and the referenced function invoked.
    """

    cmd_cls: typing.Type[T]
    arguments: list[Arg | Subcommand] = dataclasses.field(default_factory=list)
    name: str | None = None
    help: str | None = None
    description: str | None = None
    invoke: Callable | str | None = None

    @classmethod
    def get(cls, obj: typing.Type[T]) -> Self:
        if isinstance(obj, cls):
            return obj

        return getattr(obj, "__cappa__", cls(obj))

    def real_name(self) -> str:
        if self.name is not None:
            return self.name

        cls_name = self.cmd_cls.__name__
        import re

        return re.sub(r"(?<!^)(?=[A-Z])", "-", cls_name).lower()

    @classmethod
    def collect(cls, command: Command):
        kwargs: CommandArgs = {}
        arg_help_map = {}

        if not (command.help and command.description):
            doc = get_doc(command.cmd_cls)
            parsed_help = docstring_parser.parse(doc)
            for param in parsed_help.params:
                arg_help_map[param.arg_name] = param.description

            if not command.help:
                kwargs["help"] = parsed_help.short_description

            if not command.description:
                kwargs["description"] = parsed_help.long_description

        if command.arguments:
            arguments: list[Arg | Subcommand] = [
                a.normalize() for a in command.arguments
            ]
        else:
            fields = class_inspect.fields(command.cmd_cls)
            type_hints = get_type_hints(command.cmd_cls, include_extras=True)

            arguments = []

            for field in fields:
                type_hint = type_hints[field.name]

                maybe_subcommand = Subcommand.collect(field, type_hint)
                if maybe_subcommand:
                    arguments.append(maybe_subcommand)
                else:
                    arg_help = arg_help_map.get(field.name)
                    arg_def: Arg = Arg.collect(field, type_hint, fallback_help=arg_help)
                    arguments.append(arg_def)

        kwargs["arguments"] = arguments

        return dataclasses.replace(command, **kwargs)

    @classmethod
    def parse_command(
        cls,
        command: Command[T],
        *,
        argv: list[str],
        output: Output,
        backend: typing.Callable | None = None,
        color: bool = True,
        version: str | Arg | None = None,
        help: bool | Arg = True,
        completion: bool | Arg = True,
    ) -> tuple[Command, Command[T], T]:
        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,
            )
            result = command.map_result(command, 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:
        kwargs = {}
        for arg in self.value_arguments():
            is_subcommand = isinstance(arg, Subcommand)
            if arg.name not in parsed_args:
                # if arg.required:
                #     raise Exit(
                #         f"The following arguments are required: {arg.names_str()}",
                #         code=1,
                #     )

                if is_subcommand:
                    continue

                value = arg.default
            else:
                value = parsed_args[arg.name]

            if isinstance(value, Env):
                value = value.evaluate()
            if isinstance(value, prompt_types):
                value = value()

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

                try:
                    value = arg.parse(value)
                except Exception as e:
                    exception_reason = str(e)
                    raise Exit(
                        f"Invalid value for '{arg.names_str()}' with value '{value}': {exception_reason}",
                        code=2,
                    )

            kwargs[arg.name] = value

        return command.cmd_cls(**kwargs)

    def value_arguments(self):
        for arg in self.arguments:
            if isinstance(arg, Arg):
                if arg.action in ArgAction.value_actions():
                    continue

            yield arg

    def add_meta_actions(
        self,
        help: Arg | None = None,
        version: Arg | None = None,
        completion: Arg | None = None,
    ):
        if help:
            for arg in self.arguments:
                if isinstance(arg, Subcommand):
                    for option in arg.options.values():
                        option.add_meta_actions(help)

            self.arguments.append(help)
        if version:
            self.arguments.append(version)
        if completion:
            self.arguments.append(completion)
        return self


H = typing.TypeVar("H", covariant=True)


class HasCommand(typing.Generic[H], typing.Protocol):
    __cappa__: typing.ClassVar[Command]


def get_doc(cls):
    """Lifted from dataclasses source."""
    doc = cls.__doc__ or ""

    # Dataclasses will set the doc attribute to the below value if there was no
    # explicit docstring. This is just annoying for us, so we treat that as though
    # there wasn't one.
    try:
        # In some cases fetching a signature is not possible.
        # But, we surely should not fail in this case.
        text_sig = str(inspect.signature(cls)).replace(" -> None", "")
    except (TypeError, ValueError):  # pragma: no cover
        text_sig = ""

    dataclasses_docstring = cls.__name__ + text_sig

    if doc == dataclasses_docstring:
        return ""
    return doc

src/cappa/command.py
DanCardin/cappa
from __future__ import annotations

import os
import typing
from pathlib import Path

from cappa.arg import Arg
from cappa.command import Command
from cappa.completion.shells import available_shells
from cappa.output import Exit
from cappa.parser import Completion, FileCompletion, backend


def execute(
    command: Command,
    prog: str,
    action: str,
    help: Arg | None,
    version: Arg | None,
    completion: Arg,
):
    shell_name = Path(os.environ.get("SHELL", "bash")).name
    shell = available_shells.get(shell_name)

    if shell is None:
        raise Exit("Unknown shell", code=1)

    if action == "generate":
        raise Exit(shell.backend_template(prog, completion), code=0)

    command_args = parse_incomplete_command()

    backend(
        command,
        [prog, *command_args],
        version=version,
        help=help,
        provide_completions=True,
    )


def parse_incomplete_command() -> list[str]:
    raw_completion_line = os.environ.get("COMPLETION_LINE", "").replace("\n", " ")
    raw_completion_location = os.environ.get("COMPLETION_LOCATION")
    if raw_completion_line is None or raw_completion_location is None:
        raise Exit(code=0)

    completion_line = list(split_incomplete_command(raw_completion_line))
    completion_index = int(raw_completion_location)

    return completion_line[1:completion_index]


def split_incomplete_command(string: str) -> typing.Iterable[str]:
    import shlex

    lex = shlex.shlex(string, posix=True)
    lex.whitespace_split = True
    lex.commenters = ""

    try:
        yield from lex
    except ValueError:
        yield lex.token


def format_completions(*completions: Completion | FileCompletion) -> str | None:
    if not completions:
        return None

    if isinstance(completions[0], FileCompletion):
        return "file"

    result = []
    for item in completions:
        item = typing.cast(Completion, item)
        result.append(f"{item.value}:{item.help if item.help else ''}")
    return "\n".join(result)

src/cappa/completion/base.py
DanCardin/cappa
from __future__ import annotations

from cappa.completion.types import Completion


def complete_choices(choices: list[str], help: str | None = None):
    def completion(partial: str = ""):
        return [Completion(c, help=help) for c in choices if partial in c]

    return completion

src/cappa/completion/completers.py
DanCardin/cappa
from cappa.completion.types import ShellHandler

shells = [
    ShellHandler(
        "bash",
        template="""\
_%(safe_prog_name)s_completion() {
    local IFS=$'\\n' local response
    response=$(env COMPLETION_LINE="${COMP_WORDS[*]}" COMPLETION_LOCATION=$((COMP_CWORD+1)) $1 --completion=complete)

    for completion in $response; do
        IFS=':' read value help <<< "$completion"

        if [[ $type == 'file' ]]; then
            COMPREPLY=()
            compopt -o default
        else
            COMPREPLY+=($value)
        fi
    done
    return 0
}

_%(safe_prog_name)s_completion_setup() {
    complete -o nosort -F _%(safe_prog_name)s_completion %(prog_name)s
}

_%(safe_prog_name)s_completion_setup;
        """,
    ),
    ShellHandler(
        "zsh",
        template="""\
#compdef %(prog_name)s

_%(safe_prog_name)s_completion() {
    local -a completions
    local -a response
    (( ! $+commands[%(prog_name)s] )) && return 1

    response=("${(@f)$(env COMPLETION_LINE="${words[*]}" COMPLETION_LOCATION=$((CURRENT)) \
%(prog_name)s %(completion_arg)s=complete)}")

    if [[ -n "$completions" ]]; then
        if [[ "${completions[1]}" == "file" ]]; then
            _files
        else
            _describe 'options' completions
        fi
    fi
}

if [[ $zsh_eval_context[-1] == loadautofunc ]]; then
    _%(safe_prog_name)s_completion "$@"
else
    compdef _%(safe_prog_name)s_completion %(prog_name)s
fi
""",
    ),
    ShellHandler(
        "fish",
        template="""\
function _%(safe_prog_name)s_completion
    set -l response (env COMPLETION_LINE=(commandline -cp) COMPLETION_LOCATION=(math (commandline -C) + 1) \
%(prog_name)s %(completion_arg)s=complete)

    for completion in $response
        set -l value (string split --max 1 ":" -- $completion)

        if test $value[1] = "file"
            __fish_complete_path
        else
            echo $value[2]
        end
    end
end

complete --no-files --command %(prog_name)s --arguments "(_%(safe_prog_name)s_completion)"
""",
    ),
]
available_shells = {s.name: s for s in shells}

src/cappa/completion/shells.py
DanCardin/cappa
from __future__ import annotations

import dataclasses
import re
import typing

if typing.TYPE_CHECKING:
    from cappa.arg import Arg


@dataclasses.dataclass
class ShellHandler:
    name: str
    template: str

    def backend_template(self, prog: str, arg: Arg) -> str:
        try:
            prog, _ = prog.rsplit("/", 1)
        except ValueError:
            pass

        safe_name = re.sub(r"\W*", "", prog.replace("-", "_"), flags=re.ASCII)

        assert isinstance(arg.long, list)
        assert len(arg.long) > 0
        return self.template % {
            "prog_name": prog,
            "safe_prog_name": safe_name,
            "completion_arg": arg.long[0],
        }


@dataclasses.dataclass
class Completion:
    value: str | None = None
    help: str | None = None


@dataclasses.dataclass
class FileCompletion:
    text: str


class CompletionError(RuntimeError):
    def __init__(
        self, *completions: Completion | FileCompletion, value="complete", **_
    ) -> None:
        self.completions = completions
        self.value = value

src/cappa/completion/types.py
DanCardin/cappa
from __future__ import annotations

import os
from dataclasses import dataclass


@dataclass
class Env:
    """Lazily evaluates environment variables in the order given.

    Argument value interpolation will handle and `Arg`'s default being an `Env`
    instance, by evaluating the `Env`, in the event the parser-level value
    fell back to the default.

    Examples:
        >>> from cappa import Arg, Env
        >>> arg = Arg(default=Env("HOST", "HOSTNAME", default="localhost"))
    """

    env_vars: tuple[str, ...]
    default: str | None

    def __init__(self, env_var: str, *env_vars: str, default: str | None = None):
        self.env_vars = (env_var, *env_vars)
        self.default = default

    def evaluate(self) -> str | None:
        for env_var in self.env_vars:
            value = os.getenv(env_var)
            if value:
                return value

        return self.default

src/cappa/env.py
DanCardin/cappa
from __future__ import annotations

import typing
from itertools import groupby

from rich.console import NewLine
from rich.table import Table
from rich.text import Text
from typing_extensions import TypeAlias

from cappa.arg import Arg, ArgAction, no_extra_arg_actions
from cappa.command import Command
from cappa.output import Displayable
from cappa.subcommand import Subcommand

ArgGroup: TypeAlias = typing.Tuple[str, typing.List[typing.Union[Arg, Subcommand]]]


def create_version_arg(version: str | Arg | None = None) -> Arg | None:
    if not version:
        return None

    if isinstance(version, str):
        version = Arg(
            version,
            short=["-v"],
            long=["--version"],
            help="Show the version and exit.",
            group=(3, "Help"),
        )

    return version.normalize(action=ArgAction.version)


def create_help_arg(help: bool | Arg | None = True) -> Arg | None:
    if not help:
        return None

    if isinstance(help, bool):
        help = Arg(
            name="help",
            short=["-h"],
            long=["--help"],
            help="Show this message and exit.",
            group=(3, "Help"),
        )

    return help.normalize(action=ArgAction.help, name="help")


def create_completion_arg(completion: bool | Arg = True) -> Arg | None:
    if not completion:
        return None

    if isinstance(completion, bool):
        completion = Arg(
            name="completion",
            long=["--completion"],
            choices=["generate", "complete"],
            group=(3, "Help"),
            help="Use `--completion generate` to print shell-specific completion source",
        ).normalize()

    return completion.normalize(action=ArgAction.completion)


def format_help(command: Command, prog: str) -> list[Displayable]:
    arg_groups = generate_arg_groups(command)

    lines: list[Displayable] = []
    lines.append(add_short_args(prog, arg_groups))

    desc = " ".join([s for s in [command.help, command.description] if s])
    if desc:
        lines.append(NewLine())
        lines.append(Text(desc))

    lines.extend(add_long_args(arg_groups))
    return lines


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)
        return typing.cast(typing.Tuple[int, str], arg.group)

    return [
        (g, [a for a in args if include_hidden or not a.hidden])
        for (_, g), args in groupby(
            sorted(command.arguments, key=by_group), key=by_group
        )
    ]


def add_short_args(prog: str, arg_groups: list[ArgGroup]) -> str:
    segments: list[str] = [f"Usage: {prog}"]
    for _, args in arg_groups:
        for arg in args:
            segments.append(format_arg_name(arg, ", "))

    return " ".join(segments)


def add_long_args(arg_groups: list[ArgGroup]) -> list:
    table = Table(box=None)
    table.add_column(style="cappa.arg", justify="right")
    table.add_column(style="cappa.help")

    for group, args in arg_groups:
        table.add_row(
            Text(group, style="cappa.group", justify="right"),
            Text(style="cappa.group"),
        )
        for arg in args:
            table.add_row(format_arg_name(arg, "/"), arg.help)

        table.add_row()

    return [table]


def format_arg_name(arg: Arg | Subcommand, delimiter) -> str:
    if isinstance(arg, Arg):
        arg_names = arg.names_str(delimiter)
        text = f"[cappa.arg]{arg_names}[/cappa.arg]"

        is_option = arg.short or arg.long
        has_value = arg.action not in no_extra_arg_actions
        if is_option and has_value:
            text = f"{text} [cappa.arg.name]{arg.name}[/cappa.arg.name]"

        if not arg.required:
            return rf"\[{text}]"

        return text

    arg_names = arg.names_str(",")
    return f"[cappa.subcommand]{arg_names}[/cappa.subcommand]"

src/cappa/help.py
DanCardin/cappa
from __future__ import annotations

import importlib
import inspect
import typing
from collections.abc import Callable, Sequence
from dataclasses import dataclass

from typing_extensions import get_type_hints

from cappa.command import Command, HasCommand
from cappa.output import Exit, Output
from cappa.subcommand import Subcommand
from cappa.typing import find_type_annotation

T = typing.TypeVar("T", bound=HasCommand)


class InvokeResolutionError(RuntimeError):
    """Raised for errors encountered during evaluation of invoke depdendencies."""


@dataclass(frozen=True)
class Dep(typing.Generic[T]):
    """Describes the callable required to fullfill a given dependency."""

    callable: Callable


def invoke_callable(
    command: Command,
    parsed_command: Command[T],
    instance: T,
    *,
    output: Output,
    deps: typing.Sequence[Callable]
    | typing.Mapping[Callable, Dep | typing.Any]
    | None = None,
):
    try:
        fn: Callable = resolve_invoke_handler(parsed_command, instance)
        implicit_deps = resolve_implicit_deps(command, instance)
        implicit_deps[Output] = output
        fullfilled_deps = resolve_global_deps(deps, implicit_deps)

        kwargs = fullfill_deps(fn, fullfilled_deps)
    except InvokeResolutionError as e:
        raise InvokeResolutionError(
            f"Failed to invoke {parsed_command.cmd_cls} due to resolution failure."
        ) from e

    try:
        return fn(**kwargs)
    except Exit as e:
        output.exit(e)
        raise e


def resolve_global_deps(
    deps: typing.Sequence[Callable] | typing.Mapping[Callable, Dep | typing.Any] | None,
    implicit_deps: dict,
) -> dict:
    if not deps:
        return implicit_deps

    # Coerce the sequence variant of input into the mapping equivalent.
    if isinstance(deps, Sequence):
        deps = typing.cast(typing.Mapping, {d: Dep(d) for d in deps})

    for source_function, dep in deps.items():
        # Deps need to be fullfilled, whereas raw values are taken directly.
        if isinstance(dep, Dep):
            value = dep.callable(**fullfill_deps(dep.callable, implicit_deps))
        else:
            value = dep

        # We map back to the source dep, i.e. the key in the input mapping. This
        # translates to the raw function in the sequence input, or the source
        # dep being overridden in the dict input.
        source_dep: Dep = Dep(source_function)
        implicit_deps[source_dep] = value

    return implicit_deps


def resolve_invoke_handler(command: Command, instance: T) -> Callable[..., T]:
    fn = command.invoke

    if not fn:
        if callable(instance):
            return instance.__call__

        raise InvokeResolutionError(
            f"Cannot call `invoke` for a command which does not have an invoke handler: {command.cmd_cls}."
        )

    if isinstance(fn, str):
        try:
            module_name, fn_name = fn.rsplit(".", 1)
        except ValueError:
            raise InvokeResolutionError(
                f"Invoke `{fn}` must be a fully qualified reference to a function in a module."
            )

        # Let this bubble upwards if the modules doesn't exist.
        try:
            module = importlib.import_module(module_name)
        except ModuleNotFoundError as e:
            name = getattr(e, "name") or str(e)
            raise InvokeResolutionError(
                f"No module '{name}' when attempting to load '{fn}'."
            )

        if not hasattr(module, fn_name):
            raise InvokeResolutionError(
                f"Module {module} does not have a function `{fn_name}`."
            )

        fn = getattr(module, fn_name)

    if not callable(fn):
        raise InvokeResolutionError(f"`{fn}` does not reference a valid callable.")

    return fn


def resolve_implicit_deps(command: Command, instance: HasCommand) -> dict:
    deps = {instance.__class__: instance}

    for arg in command.arguments:
        if not isinstance(arg, Subcommand):
            # Args do not produce dependencies themselves.
            continue

        option_instance = getattr(instance, arg.name)
        if option_instance is None:
            # None is a valid subcommand instance value, but it wont exist as a dependency
            # where an actual command has been selected.
            continue

        # This **should** always end up producing a value (type). In order to have produced
        # a subcommand instance value of a given type, it would need to exist in the options.
        option = next(  # pragma: no branch
            o for o in arg.options.values() if isinstance(option_instance, o.cmd_cls)
        )
        deps.update(resolve_implicit_deps(option, option_instance))

    return deps


def fullfill_deps(fn: Callable, fullfilled_deps: dict, call: bool = True) -> typing.Any:
    result = {}

    signature = inspect.signature(fn)
    try:
        annotations = get_type_hints(fn, include_extras=True)
    except NameError as e:  # pragma: no cover
        name = getattr(e, "name") or str(e)
        raise InvokeResolutionError(
            f"Could not collect resolve reference to {name} for Dep({fn.__name__})"
        )

    for name, param in signature.parameters.items():
        annotation = annotations.get(name)

        if annotation is None:
            dep = None
        else:
            dep, annotation = find_type_annotation(annotation, Dep)

        if dep is None:
            # Non-annotated args are either implicit dependencies (and thus already fullfilled),
            # or arguments that we cannot fullfill
            if annotation not in fullfilled_deps:
                if param.default is param.empty:
                    annotation_name = annotation.__name__ if annotation else "<empty>"
                    raise InvokeResolutionError(
                        f"`{name}: {annotation_name}` is not a valid dependency for Dep({fn.__name__})."
                    )

                # if there's a default, we can just skip it and let the default fullfill the value.
                continue

            value = fullfilled_deps[annotation]

        else:
            # Whereas everything else should be a resolvable explicit Dep, which might have either
            # already been fullfullfilled, or yet need to be.
            if dep in fullfilled_deps:
                value = fullfilled_deps[dep]
            else:
                value = dep.callable(**fullfill_deps(dep.callable, fullfilled_deps))
                fullfilled_deps[dep] = value

        result[name] = value

    return result

src/cappa/invoke.py
DanCardin/cappa
from __future__ import annotations

import io
import sys
import typing
from dataclasses import dataclass

from rich.console import Console, NewLine
from rich.prompt import Confirm, Prompt
from rich.table import Table
from rich.text import Text
from rich.theme import Theme
from typing_extensions import TypeAlias

prompt_types = (Prompt, Confirm)


Displayable: TypeAlias = typing.Union[str, Text, Table, NewLine]


@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",
        }
    )

    @classmethod
    def from_theme(cls, theme: Theme | None = None):
        output_console = Console(file=sys.stdout, theme=theme or cls.theme)
        error_console = Console(file=sys.stderr, theme=theme or cls.theme)
        return cls(output_console, error_console)

    def exit(self, e: Exit):
        if e.code == 0:
            self.output(e)
        else:
            self.error(e)

    def output(self, message: list[Displayable] | Displayable | Exit | None):
        self.write(self.output_console, message)

    def error(self, message: list[Displayable] | Displayable | Exit | None):
        self.write(self.error_console, message)

    def write(
        self, console: Console, message: list[Displayable] | Displayable | Exit | None
    ):
        if isinstance(message, Exit):
            message = message.message

        if message is None:
            return

        if isinstance(message, list):
            messages = message
        else:
            messages = [message]

        for m in messages:
            console.print(m)


class TestPrompt(Prompt):
    def __init__(self, prompt, *, input, default=..., **kwargs):
        self.file = io.StringIO()
        self.default = default
        self.stream = io.StringIO(input)

        console = Console(file=self.file)
        super().__init__(prompt=prompt, console=console, **kwargs)

    def __call__(self):
        return super().__call__(default=self.default, stream=self.stream)


class Exit(SystemExit):
    def __init__(
        self,
        message: list[Displayable] | Displayable | None = None,
        *,
        code: str | int | None = 0,
    ):
        self.message = message
        super().__init__(code)


class HelpExit(Exit):
    def __init__(
        self,
        message: list[Displayable] | Displayable,
        *,
        code: str | int | None = 0,
    ):
        super().__init__(code=code)
        self.message = message

src/cappa/output.py
DanCardin/cappa
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, CompletionError, FileCompletion
from cappa.help import (
    create_completion_arg,
    create_help_arg,
    create_version_arg,
    format_help,
)
from cappa.output import Exit, HelpExit
from cappa.typing import T, assert_type


class BadArgumentError(RuntimeError):
    def __init__(
        self,
        message: str,
        *,
        value,
        command: Command,
        arg: Arg | Subcommand | None = None,
    ) -> None:
        super().__init__(message)
        self.value = value
        self.command = command
        self.arg = arg


class HelpAction(RuntimeError):
    def __init__(self, *, command, **_):
        self.command = command


class VersionAction(RuntimeError):
    def __init__(self, *, arg, **_):
        self.version = 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"

    try:
        prog, *argv = argv
    except ValueError:
        prog = ""

    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(command, args)
    context.provide_completions = provide_completions

    try:
        try:
            parse(context)
        except HelpAction as e:
            raise HelpExit(format_help(e.command, prog), code=0)
        except VersionAction as e:
            raise Exit(e.version.name, code=0)
        except BadArgumentError as e:
            if context.provide_completions and e.arg:
                completions = e.arg.completion(e.value) if e.arg.completion else []
                raise CompletionError(*completions)

            format_help(e.command, prog)
            raise Exit(str(e), code=2)
    except CompletionError as e:
        from cappa.completion.base import execute, format_completions

        if context.provide_completions:
            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),
        )

    if provide_completions:
        raise Exit(code=0)

    return (context, context.selected_command or command, context.result)


@dataclasses.dataclass
class ParseContext:
    command: Command
    remaining_args: deque[RawArg | RawOption]
    options: dict[str, Arg]
    arguments: deque[Arg | Subcommand]
    missing_options: set[str]

    consumed_args: list[RawArg | RawOption] = dataclasses.field(default_factory=list)

    result: dict[str, typing.Any] = dataclasses.field(default_factory=dict)
    selected_command: Command | None = None

    provide_completions: bool = False

    @classmethod
    def from_command(
        cls,
        command: Command,
        args: deque[RawArg | RawOption],
    ) -> ParseContext:
        options, missing_options = cls.collect_options(command)
        arguments = deque(cls.collect_arguments(command))
        return cls(command, args, options, arguments, missing_options=missing_options)

    @staticmethod
    def collect_options(command: Command) -> tuple[dict[str, Arg], set[str]]:
        result = {}
        unique_names = set()
        for arg in command.arguments:
            if not isinstance(arg, Arg):
                continue

            if arg.short or arg.long:
                if arg.action not in ArgAction.value_actions():
                    unique_names.add(arg.name)
                result[arg.name] = arg

            assert arg.short is not True
            for short in arg.short or []:
                result[short] = arg

            assert arg.long is not True
            for long in arg.long or []:
                result[long] = arg

        return result, unique_names

    @staticmethod
    def collect_arguments(command: Command) -> list[Arg | Subcommand]:
        result = []
        for arg in command.arguments:
            if (
                isinstance(arg, Arg)
                and not arg.short
                and not arg.long
                or isinstance(arg, Subcommand)
            ):
                result.append(arg)
        return result

    def has_values(self) -> bool:
        return bool(self.remaining_args)

    def peek_value(self):
        if not self.remaining_args:
            return None
        return self.remaining_args[0]

    def next_value(self):
        arg = self.remaining_args.popleft()
        self.consumed_args.append(arg)
        return arg

    def next_argument(self):
        return self.arguments.popleft()


@dataclasses.dataclass(frozen=True)
class RawArg:
    raw: str

    @classmethod
    def collect(
        cls, argv: list[str], *, provide_completions: bool = False
    ) -> deque[RawArg | RawOption]:
        result = []

        encountered_double_dash = False
        for arg in argv:
            if encountered_double_dash:
                item: RawArg | RawOption | None = cls(arg)
            else:
                item = RawArg.from_str(arg, provide_completions=provide_completions)

            if item is None:
                encountered_double_dash = True
                continue

            result.append(item)

        return deque(result)

    @classmethod
    def from_str(
        cls, arg: str, *, provide_completions: bool = False
    ) -> RawArg | RawOption | None:
        skip = arg == "--" and not provide_completions
        if skip:
            return None

        is_option = arg and arg[0] == "-" and (provide_completions or len(arg) > 1)

        if is_option:
            return RawOption.from_str(arg)

        return cls(arg)


@dataclasses.dataclass(frozen=True)
class RawOption:
    name: str
    is_long: bool
    value: str | None = None

    @classmethod
    def from_str(cls, arg: str) -> RawOption:
        is_long = arg.startswith("--")

        name = arg
        value = None
        if is_long:
            is_explicit = "=" in arg
            if is_explicit:
                name, value = arg.split("=")
        return cls(name=name, is_long=is_long, value=value)


def parse(context: ParseContext) -> None:
    while True:
        while isinstance(context.peek_value(), RawOption):
            arg = typing.cast(RawOption, context.next_value())

            if arg.is_long:
                parse_option(context, arg)
            else:
                parse_short_option(context, arg)

        parse_args(context)

        if not context.has_values():
            break

    # Options are not explicitly iterated over because they can occur multi times non-contiguouesly.
    # So instead we check afterward, if there are any missing which we haven't yet fulfilled.
    for opt_name in context.missing_options:
        opt = context.options[opt_name]
        if opt.required:
            raise BadArgumentError(
                f"The following arguments are required: {opt.names_str()}",
                value="",
                command=context.command,
                arg=opt,
            )


def parse_option(context: ParseContext, raw: RawOption) -> None:
    if raw.name not in context.options:
        possible_values = [
            name for name in context.options if name.startswith(raw.name)
        ]

        if context.provide_completions:
            options = [
                Completion(option, help=context.options[option].help)
                for option in possible_values
            ]
            raise CompletionError(*options)

        message = f"Unrecognized arguments: {raw.name}"
        if possible_values:
            message += f" (Did you mean: {', '.join(possible_values)})"

        raise BadArgumentError(message, value=raw.name, command=context.command)

    arg = context.options[raw.name]
    consume_arg(context, arg, raw)


def parse_short_option(context: ParseContext, arg: RawOption) -> None:
    if arg.name == "-" and context.provide_completions:
        return parse_option(context, arg)

    for ch in arg.name[1:]:
        opt = RawOption(f"-{ch}", is_long=True)
        parse_option(context, opt)
    return None


def parse_args(context: ParseContext) -> None:
    while context.arguments:
        if isinstance(context.peek_value(), RawOption):
            break

        arg = context.next_argument()

        if isinstance(arg, Subcommand):
            consume_subcommand(context, arg)
        else:
            consume_arg(context, arg)
    else:
        value = context.peek_value()
        if value is None or isinstance(value, RawOption):
            return

        raw_values = []
        while context.peek_value():
            raw = typing.cast(RawArg, context.next_value()).raw
            raw_values.append(raw)

        raise BadArgumentError(
            f"Unrecognized arguments: {', '.join(raw_values)}",
            value=raw_values,
            command=context.command,
        )


def consume_arg(
    context: ParseContext, arg: Arg, option: RawOption | None = None
) -> typing.Any:
    orig_num_args = arg.num_args or 1
    num_args = orig_num_args

    if arg.action in no_extra_arg_actions:
        orig_num_args = 0
        num_args = 0

    result: list[str] | str
    if option and option.value:
        result = [option.value]
    else:
        result = []
        while num_args:
            try:
                value = context.next_value()
            except IndexError:
                break

            if isinstance(value, RawOption):
                raise BadArgumentError(
                    f"Argument requires {orig_num_args} values, "
                    f"only found {len(result)} ('{' '.join(result)}' so far).",
                    value=result,
                    command=context.command,
                    arg=arg,
                )

            result.append(value.raw)

            # If num-args starts at -1, then it will always be truthy when we subtract
            # from it. I.e. it has unbounded length, like we want.
            num_args -= 1

    if orig_num_args == 1:
        if result:
            result = result[0]
            if arg.choices and result not in arg.choices:
                choices = ", ".join(f"'{c}'" for c in arg.choices)
                raise BadArgumentError(
                    f"Invalid choice: '{result}' (choose from {choices})",
                    value=result,
                    command=context.command,
                    arg=arg,
                )

            if context.provide_completions and not context.has_values():
                raise CompletionError(FileCompletion(result))
        else:
            if not option and not arg.required:
                return

            raise BadArgumentError(
                f"Option '{arg.name}' requires an argument.",
                value="",
                command=context.command,
                arg=arg,
            )

    if option and arg.name in context.missing_options:
        context.missing_options.remove(arg.name)

    action = typing.cast(ArgAction, arg.action)
    name = typing.cast(str, arg.name)
    action_handler = process_options[action]
    existing_result = context.result.get(name)
    context.result[name] = action_handler(
        command=context.command,
        arg=arg,
        option_name=option and option.name,
        existing=existing_result,
        value=result,
    )


def consume_subcommand(context: ParseContext, arg: Subcommand) -> typing.Any:
    try:
        value = context.next_value()
    except IndexError:
        if not arg.required:
            return

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

    assert isinstance(value, RawArg), value
    if value.raw not in arg.options:
        raise BadArgumentError(
            "invalid subcommand", value=value.raw, command=context.command, arg=arg
        )

    command = arg.options[value.raw]
    context.selected_command = command

    nested_context = ParseContext.from_command(command, context.remaining_args)
    nested_context.provide_completions = context.provide_completions
    nested_context.result["__name__"] = value.raw

    parse(nested_context)

    name = typing.cast(str, arg.name)
    context.result[name] = nested_context.result
    if nested_context.selected_command:
        context.selected_command = nested_context.selected_command


def raises(exc):
    def store(**value):
        raise exc(**value)

    return store


def store_bool(val: bool):
    def store(arg, option_name: str, **_):
        long = assert_type(arg.long, list)
        has_no_option = any("--no-" in i for i in long)
        if has_no_option:
            return not option_name.startswith("--no-")
        return val

    return store


def store_count(*, existing: int | None, **_):
    return (existing or 0) + 1


def store_set(*, value, **_):
    return value


def store_append(*, existing, value, **_):
    return (existing or []) + [value]


process_options = {
    ArgAction.help: raises(HelpAction),
    ArgAction.version: raises(VersionAction),
    ArgAction.completion: raises(CompletionError),
    ArgAction.set: store_set,
    None: store_set,
    ArgAction.store_true: store_bool(True),
    ArgAction.store_false: store_bool(False),
    ArgAction.count: store_count,
    ArgAction.append: store_append,
}

src/cappa/parser.py
DanCardin/cappa
from __future__ import annotations

import dataclasses
import typing

from typing_extensions import Annotated, Self, TypeAlias
from typing_inspect import is_optional_type, is_union_type

from cappa.class_inspect import Field, extract_dataclass_metadata
from cappa.completion.types import Completion
from cappa.typing import (
    MISSING,
    NoneType,
    T,
    assert_type,
    find_type_annotation,
    missing,
)

if typing.TYPE_CHECKING:
    from cappa.command import Command


@dataclasses.dataclass
class Subcommand:
    """Describe a CLI 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.
        hidden: Whether the argument should be hidden in help text. Defaults to False.
    """

    name: str | MISSING = ...
    help: str = ""
    required: bool | None = None
    group: str | tuple[int, str] = (3, "Subcommands")
    hidden: bool = False

    types: typing.Iterable[type] | MISSING = ...
    options: dict[str, Command] = dataclasses.field(default_factory=dict)

    @classmethod
    def collect(cls, field: Field, type_hint: type) -> Self | None:
        subcommand, annotation = find_type_annotation(type_hint, cls)
        field_metadata = extract_dataclass_metadata(field)
        if field_metadata:
            if not isinstance(field_metadata, Subcommand):
                return None

            subcommand = field_metadata  # type: ignore

        if subcommand is None:
            return None

        return subcommand.normalize(annotation, name=field.name)

    def normalize(self, annotation=NoneType, name: str | None = None) -> Self:
        name = name or assert_type(self.name, str)
        types = infer_types(self, annotation)
        required = infer_required(self, annotation)
        options = infer_options(self, types)
        group = infer_group(self)

        return dataclasses.replace(
            self,
            name=name,
            types=types,
            required=required,
            options=options,
            group=group,
        )

    def map_result(self, parsed_args):
        option_name = parsed_args.pop("__name__")
        option = self.options[option_name]
        return option.map_result(option, parsed_args)

    def names(self) -> list[str]:
        return list(self.options.keys())

    def names_str(self, delimiter: str = ", ") -> str:
        return f"{{{delimiter.join(self.names())}}}"

    def completion(self, partial: str):
        return [Completion(o) for o in self.options if partial in o]


def infer_types(arg: Subcommand, annotation: type) -> typing.Iterable[type]:
    if arg.types is not missing:
        return typing.cast(typing.Iterable[type], arg.types)

    if is_union_type(annotation):
        types = typing.get_args(annotation)
        return tuple([t for t in types if not is_optional_type(t)])

    return (annotation,)


def infer_required(arg: Subcommand, annotation: type) -> bool:
    if arg.required is not None:
        return arg.required

    return not is_optional_type(annotation)


def infer_options(arg: Subcommand, types: typing.Iterable[type]) -> dict[str, Command]:
    from cappa.command import Command

    if arg.options:
        return {
            name: Command.collect(type_command)
            for name, type_command in arg.options.items()
        }

    options = {}
    for type_ in types:
        type_command: Command = Command.get(type_)
        type_name = type_command.real_name()
        options[type_name] = Command.collect(type_command)

    return options


def infer_group(arg: Subcommand) -> str | tuple[int, str]:
    group: str | tuple[int, str] | MISSING = arg.group
    group_name = None
    if isinstance(group, str):
        group_name = group
        group = missing

    if group is missing:
        return (3, group_name or "Subcommands")

    return typing.cast(typing.Tuple[int, str], group)


Subcommands: TypeAlias = Annotated[T, Subcommand]

src/cappa/subcommand.py
DanCardin/cappa
from __future__ import annotations

import typing
from dataclasses import dataclass, field

from typing_extensions import Unpack

import cappa

__all__ = [
    "CommandRunner",
    "RunnerArgs",
]


class RunnerArgs(typing.TypedDict, total=False):
    """Available kwargs for `parse` and `invoke` function, to match `CommandRunner` fields."""

    obj: type
    deps: typing.Sequence[typing.Callable]
    backend: typing.Callable | None
    color: bool
    version: str | cappa.Arg
    help: bool | cappa.Arg


@dataclass
class CommandRunner:
    """Object to hold common parse/invoke invocation state, for testing.

    Accepts almost identical inputs to that of `parse`/`invoke`. The notable
    deviation is `argv`.

    Whereas the original functions accept argv as a single-argument list;
    `CommandRunner` accepts `base_args` at the class-constructor level, which
    is concatenated with the `CommandRunner.parse` or `CommandRunner.invoke`
    `*args`, to arrive a the total set of input args.

    Example:
        Some base CLI object

        >>> from dataclasses import dataclass
        >>>
        >>> import cappa
        >>>
        >>> @dataclass
        ... class Obj:
        ...     first: str
        ...     second: str = '2'

        Create an instance with no arguments means there is no default state

        >>> runner = CommandRunner()
        >>> runner.parse('one', obj=Obj)
        Obj(first='one', second='2')

        Or create a runner that always uses the same base CLI object, and default base command

        >>> runner = CommandRunner(Obj, base_args=['command', 'first'])

        Now each test, can customize the behavior to suit the test in question.

        >>> runner.parse(color=False)
        Obj(first='first', second='2')

        >>> runner.parse('two')
        Obj(first='first', second='two')
    """

    obj: type | None = None
    deps: typing.Sequence[typing.Callable] | None = None
    backend: typing.Callable | None = None
    color: bool = True
    version: str | cappa.Arg | None = None
    help: bool | cappa.Arg = True

    base_args: list[str] = field(default_factory=lambda: ["command"])

    def coalesce_args(self, *args: str, **kwargs: Unpack[RunnerArgs]) -> dict:
        return {
            "argv": self.base_args + list(args),
            "obj": kwargs.get("obj") or self.obj,
            "backend": kwargs.get("backend") or self.backend,
            "color": kwargs.get("color") or self.color,
            "version": kwargs.get("version") or self.version,
            "help": kwargs.get("help") or self.help,
        }

    def parse(self, *args: str, **kwargs: Unpack[RunnerArgs]):
        final_kwargs = self.coalesce_args(*args, **kwargs)
        return cappa.parse(**final_kwargs)

    def invoke(self, *args: str, **kwargs: Unpack[RunnerArgs]):
        final_kwargs = self.coalesce_args(*args, **kwargs)
        deps = kwargs.get("deps") or self.deps
        return cappa.invoke(**final_kwargs, deps=deps)

src/cappa/testing.py
DanCardin/cappa
from __future__ import annotations

import sys
import types
import typing

import typing_inspect
from typing_extensions import Annotated
from typing_inspect import is_literal_type, typing_extensions

if sys.version_info < (3, 10):
    NoneType = type(None)  # pragma: no cover
else:
    NoneType = types.NoneType  # type: ignore

T = typing.TypeVar("T")

missing = ...
MISSING: typing.TypeAlias = type(missing)  # type: ignore


def find_type_annotation(
    type_hint: typing.Type, kind: typing.Type[T]
) -> tuple[T | None, typing.Type]:
    instance = None

    if typing_extensions.get_origin(type_hint) is Annotated:
        annotations = type_hint.__metadata__
        type_hint = type_hint.__origin__

        for annotation in annotations:
            if isinstance(annotation, kind):
                instance = annotation
                break

            if isinstance(annotation, type) and issubclass(annotation, kind):
                instance = annotation()
                break

    return instance, type_hint


def assert_not_missing(value: T | MISSING) -> T:
    assert not isinstance(value, MISSING), value
    return typing.cast(T, value)


def assert_type(value: typing.Any, typ: type[T]) -> T:
    assert isinstance(value, typ), value
    return typing.cast(T, value)


def backend_type(typ) -> str:
    if is_literal_type(typ):
        return typing.get_args(typ)[0]

    return f"<{typ.__name__}>"


def is_union_type(typ):
    if typing_inspect.is_union_type(typ):
        return True

    if hasattr(types, "UnionType") and typ is types.UnionType:
        return True  # pragma: no cover
    return False


def is_none_type(typ):
    return typ is NoneType


def is_subclass(typ, superclass):
    if not isinstance(typ, type):
        return False

    if typ is str:
        return False

    return issubclass(typ, superclass)

src/cappa/typing.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass

import cappa
import pytest
from typing_extensions import Annotated

from tests.utils import backends, parse


@backends
def test_default(backend):
    @dataclass
    class ArgTest:
        default: bool = False

    test = parse(ArgTest, backend=backend)
    assert test.default is False

    test = parse(ArgTest, "--default", backend=backend)
    assert test.default is True


@backends
def test_explicit_short(backend):
    @dataclass
    class ArgTest:
        explicit_short: Annotated[bool, cappa.Arg(short="-e")] = False

    test = parse(ArgTest, backend=backend)
    assert test.explicit_short is False

    test = parse(ArgTest, "-e", backend=backend)
    assert test.explicit_short is True


@backends
def test_explicit_long(backend):
    @dataclass
    class ArgTest:
        explicit_long: Annotated[bool, cappa.Arg(long="--meow")] = False

    test = parse(ArgTest, backend=backend)
    assert test.explicit_long is False

    test = parse(ArgTest, "--meow", backend=backend)
    assert test.explicit_long is True


@backends
def test_store_false(backend):
    @dataclass
    class ArgTest:
        default_true: Annotated[bool, cappa.Arg(long="--false")] = True

    test = parse(ArgTest, backend=backend)
    assert test.default_true is True

    test = parse(ArgTest, "--false", backend=backend)
    assert test.default_true is False


@backends
def test_true_false_option(backend):
    @dataclass
    class ArgTest:
        true_false: Annotated[bool, cappa.Arg(long="--true/--no-true")] = True

    test = parse(ArgTest, backend=backend)
    assert test.true_false is True

    test = parse(ArgTest, "--true", backend=backend)
    assert test.true_false is True

    test = parse(ArgTest, "--no-true", backend=backend)
    assert test.true_false is False

    with pytest.raises(cappa.Exit) as e:
        parse(ArgTest, "--garbage", backend=backend)

    assert e.value.code == 2
    assert "unrecognized arguments: --garbage" in str(e.value.message).lower()

tests/arg/test_bool.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass

import cappa
import pytest
from typing_extensions import Annotated

from tests.utils import backends, parse


@backends
def test_manually_specified_choices(backend):
    @dataclass
    class ArgTest:
        choice: Annotated[str, cappa.Arg(choices=["a", "1"])]

    result = parse(ArgTest, "a")
    assert result.choice == "a"

    result = parse(ArgTest, "1")
    assert result.choice == "1"

    with pytest.raises(cappa.Exit) as e:
        parse(ArgTest, "two", backend=backend)

    message = str(e.value.message).lower()
    assert "invalid choice: 'two' (choose from 'a', '1')" in message

tests/arg/test_choices.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass

import cappa
from typing_extensions import Annotated

from tests.utils import backends, parse


@backends
def test_count_action(backend):
    @dataclass
    class ArgTest:
        arg: Annotated[int, cappa.Arg(short=True, action=cappa.ArgAction.count)]

    result = parse(ArgTest, "-a", backend=backend)
    assert result.arg == 1

    result = parse(ArgTest, "-aaa", backend=backend)
    assert result.arg == 3


@backends
def test_count_option(backend):
    @dataclass
    class ArgTest:
        arg: Annotated[int, cappa.Arg(short=True, count=True)]

    result = parse(ArgTest, "-a", backend=backend)
    assert result.arg == 1

    result = parse(ArgTest, "-aaa", backend=backend)
    assert result.arg == 3

tests/arg/test_count.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass, field

import cappa
import pytest

from tests.utils import backends, parse


@dataclass
class Command:
    name: str = field(metadata={"cappa": cappa.Arg(short=True)})
    cmd: Invalid = field(metadata={"cappa": cappa.Subcommand()})


@dataclass
class Invalid:
    a: int = field(metadata={"cappa": 4})


@backends
def test_valid(backend):
    with pytest.raises(
        ValueError,
        match='`metadata={"cappa": <x>}` must be of type `Arg` or `Subcommand`',
    ):
        parse(Command, "-n", "foo", "subcommand", "4", backend=backend)

tests/arg/test_dataclass_metadata_invalid.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass

import cappa
from typing_extensions import Annotated

from tests.utils import backends, parse


@dataclass
class Command:
    foo: Annotated[int, cappa.Arg(default=4)]
    bar: Annotated[int, cappa.Arg(default=5)]


@backends
def test_valid(backend):
    test = parse(Command, "1", "2", backend=backend)
    assert test == Command(1, 2)

tests/arg/test_default.py
DanCardin/cappa
from __future__ import annotations

import enum
from dataclasses import dataclass

import cappa
import pytest

from tests.utils import backends, parse


class Options(enum.Enum):
    one = "one"
    two = "two"
    three = "three"


@dataclass
class ArgTest:
    options: Options


@backends
def test_valid(backend):
    test = parse(ArgTest, "two", backend=backend)
    assert test.options is Options.two


@backends
def test_invalid(backend):
    with pytest.raises(cappa.Exit) as e:
        parse(ArgTest, "thename", backend=backend)

    message = str(e.value.message).lower()
    assert "invalid choice: 'thename' (choose from 'one', 'two', 'three')" in message

tests/arg/test_enum.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass
from unittest.mock import patch

import cappa
from typing_extensions import Annotated

from tests.utils import backends, parse


@backends
def test_missing_fallback(backend):
    @dataclass
    class ArgTest:
        default: Annotated[str, cappa.Arg(default=cappa.Env("ASDF", default="wat"))]

    test = parse(ArgTest, backend=backend)
    assert test.default == "wat"


@backends
def test_has_env_var(backend):
    @dataclass
    class ArgTest:
        default: Annotated[str, cappa.Arg(default=cappa.Env("ASDF", default="wat"))]

    with patch("os.environ", new={"ASDF": "asdf!"}):
        test = parse(ArgTest, backend=backend)
    assert test.default == "asdf!"


@backends
def test_env_defers_to_real_value(backend):
    @dataclass
    class ArgTest:
        default: Annotated[str, cappa.Arg(default=cappa.Env("ASDF", default="wat"))]

    test = parse(ArgTest, "test", backend=backend)
    assert test.default == "test"


@backends
def test_mapping_is_applied(backend):
    @dataclass
    class ArgTest:
        default: Annotated[bool, cappa.Arg(default=cappa.Env("ASDF", default="wat"))]

    test = parse(ArgTest, backend=backend)
    assert test.default is True

tests/arg/test_env.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass
from typing import Union

import cappa
from typing_extensions import Annotated

from tests.utils import backends, parse


@dataclass
class Sc1:
    ...


@dataclass
class Sc2:
    ...


@dataclass
class Command:
    subcommand: Annotated[Union[Sc1, Sc2], cappa.Subcommand(types=[Sc1, Sc2])]


@backends
def test_required_missing(backend):
    parse(Command, "sc1", backend=backend)
    parse(Command, "sc2", backend=backend)

tests/arg/test_explicit_subcommand_types.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass
from typing import Literal, Union

import cappa
import pytest
from cappa.output import Exit
from typing_extensions import Annotated

from tests.utils import parse


@pytest.mark.help
def test_explicit_parse_function(capsys):
    @dataclass
    class ArgTest:
        numbers: Annotated[int, cappa.Arg(parse=int, help="example")]

    with pytest.raises(Exit):
        parse(ArgTest, "--help")

    stdout = capsys.readouterr().out
    assert "numbers     example" in stdout


@pytest.mark.help
def test_choices_in_help(capsys):
    @dataclass
    class ArgTest:
        numbers: Annotated[
            Union[Literal[1], Literal[2]], cappa.Arg(parse=int, help="example")
        ]

    result = parse(ArgTest, "1")
    assert result == ArgTest(1)

    with pytest.raises(Exit):
        parse(ArgTest, "--help")

    stdout = capsys.readouterr().out
    assert "Valid options: 1, 2" in stdout

tests/arg/test_help.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass, field
from typing import List

import cappa
from typing_extensions import Annotated

from tests.utils import backends, parse


@backends
def test_list_option(backend):
    @dataclass
    class ArgTest:
        variable_number: Annotated[List[str], cappa.Arg(short=True, long=True)] = field(
            default_factory=list
        )

    test = parse(
        ArgTest,
        "-v",
        "one",
        "--variable-number",
        "two",
        backend=backend,
    )
    assert test.variable_number == ["one", "two"]


@backends
def test_list_positional(backend):
    @dataclass
    class ArgTest:
        variable_number: List[str] = field(default_factory=list)

    test = parse(
        ArgTest,
        "one",
        "two",
        "three",
        backend=backend,
    )
    assert test.variable_number == ["one", "two", "three"]

tests/arg/test_list.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass
from typing import Literal, Union

import cappa
import pytest

from tests.utils import backends, parse


@dataclass
class ArgTest:
    name: Union[Literal["one"], Literal["two"], Literal["three"], Literal[4]]


@backends
def test_valid(backend):
    test = parse(ArgTest, "two", backend=backend)
    assert test.name == "two"


@backends
def test_valid_int(backend):
    test = parse(ArgTest, "4", backend=backend)
    assert test.name == 4


@backends
def test_invalid(backend):
    with pytest.raises(cappa.Exit) as e:
        parse(ArgTest, "thename", backend=backend)

    message = str(e.value.message).lower()
    assert (
        "invalid choice: 'thename' (choose from 'one', 'two', 'three', '4')" in message
    )

tests/arg/test_literal.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass
from typing import Literal, Union

import cappa
import pytest

from tests.utils import backends, parse


@dataclass
class ArgTest:
    name: Union[int, Literal["one"]]


@backends
def test_valid_int(backend):
    test = parse(ArgTest, "5", backend=backend)
    assert test.name == 5


@backends
def test_valid_literal(backend):
    test = parse(ArgTest, "one", backend=backend)
    assert test.name == "one"


@backends
def test_invalid_string(backend):
    with pytest.raises(cappa.Exit) as e:
        parse(ArgTest, "thename", backend=backend)

    assert e.value.code == 2

    assert e.value.message == (
        "Invalid value for 'name' with value 'thename': "
        "Could not parse 'thename' given options: <int>, one"
    )

tests/arg/test_literal_intermixed_union.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass
from typing import List, Literal, Set, Tuple, Union

import cappa
import pytest
from typing_extensions import Annotated

from tests.utils import backends, parse


@dataclass
class ArgTest:
    list: Annotated[
        List[Union[Literal["one"], Literal["two"], Literal["three"], Literal[4]]],
        cappa.Arg(short=True, default=[]),
    ]
    tuple: Annotated[
        Tuple[Union[Literal["one"], Literal["two"], Literal["three"], Literal[4]], ...],
        cappa.Arg(short=True, default=()),
    ]
    set: Annotated[
        Set[Union[Literal["one"], Literal["two"], Literal["three"], Literal[4]]],
        cappa.Arg(short=True, default=set()),
    ]


@backends
def test_list(backend):
    test = parse(ArgTest, "-l", "one", "-l", "two", backend=backend)
    assert test.list == ["one", "two"]


@backends
def test_tuple(backend):
    test = parse(ArgTest, "-t", "one", "-t", "two", backend=backend)
    assert test.tuple == ("one", "two")


@backends
def test_set(backend):
    test = parse(ArgTest, "-s", "one", "-s", "two", backend=backend)
    assert test.set == {"one", "two"}


@backends
def test_invalid(backend):
    with pytest.raises(cappa.Exit) as e:
        parse(ArgTest, "-l", "one", "-l", "wat", backend=backend)

    message = str(e.value.message).lower()
    assert "invalid choice: 'wat' (choose from 'one', 'two', 'three', '4')" in message

tests/arg/test_literal_sequence.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass
from typing import Literal

# from tests.utils import parse, backends


@dataclass
class ArgTest:
    name: tuple[Literal["one"], str] | tuple[Literal["two"], int] | tuple[
        Literal["three"],
        float,
    ]


# @backends
# def test_str(backend):
#     test = parse(ArgTest, "one", "string", backend=backend)
#     assert test.name == ("one", "string")
#
#
# @backends
# def test_int(backend):
#     test = parse(ArgTest, "two", "4", backend=backend)
#     assert test.name == ("two", 4)
#
#
# @backends
# def test_float(backend):
#     test = parse(ArgTest, "three", "1.4", backend=backend)
#     assert test.name == ("three", 1.4)

tests/arg/test_literal_tagged_union.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass

import cappa
import pytest

from tests.utils import backends, parse


@backends
def test_default(backend):
    @dataclass
    class ArgTest:
        default: int

    with pytest.raises(cappa.Exit) as e:
        parse(ArgTest, "foo", backend=backend)

    assert e.value.code == 2
    assert (
        e.value.message
        == "Invalid value for 'default' with value 'foo': invalid literal for int() with base 10: 'foo'"
    )

tests/arg/test_mapping_failure.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass

import cappa
import pytest
from typing_extensions import Annotated

from tests.utils import backends, parse


@backends
def test_arg_name_disallowed(backend):
    @dataclass
    class ArgTest:
        bad: Annotated[bool, cappa.Arg(name="oops")] = False

    with pytest.raises(
        ValueError, match="Arg 'name' cannot be set when using automatic inference."
    ):
        parse(ArgTest, backend=backend)

tests/arg/test_name_disallowed.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass

from tests.utils import backends, parse


@backends
def test_positional_with_default_is_optional(backend):
    @dataclass
    class ArgTest:
        arg: int = 0

    result = parse(ArgTest, backend=backend)
    assert result.arg == 0


@backends
def test_multiple_positionals_fill_in_order(backend):
    @dataclass
    class ArgTest:
        arg: int = 0
        arg2: int = 0
        arg3: int = 0

    result = parse(ArgTest, "3", "5", backend=backend)
    assert result.arg == 3
    assert result.arg2 == 5
    assert result.arg3 == 0

tests/arg/test_optional_positional.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass
from typing import Tuple

import cappa
import pytest
from typing_extensions import Annotated

from tests.utils import backends, parse


def split(value: str):
    a, b = value.split(",", 1)
    return int(a), int(b)


def parse_int(val):
    try:
        return int(val)
    except Exception:
        raise cappa.Exit("no go amego", code=2)


@backends
def test_explicit_parse_function(backend):
    @dataclass
    class ArgTest:
        numbers: Annotated[int, cappa.Arg(parse=int)]

    test = parse(ArgTest, "1", backend=backend)
    assert test.numbers == 1


@backends
def test_not_typeable_parse_function(backend):
    @dataclass
    class ArgTest:
        numbers: Annotated[Tuple[int, int], cappa.Arg(parse=split)]

    test = parse(ArgTest, "1,3", backend=backend)
    assert test.numbers == (1, 3)


@backends
def test_parse_failure(backend):
    """An explicit exit takes message precedence, and do not include extra cappa text."""

    @dataclass
    class ArgTest:
        numbers: Annotated[int, cappa.Arg(parse=parse_int)]

    with pytest.raises(cappa.Exit) as e:
        parse(ArgTest, "one", backend=backend)

    assert e.value.code == 2
    assert e.value.message == "no go amego"

tests/arg/test_parse.py
DanCardin/cappa
from __future__ import annotations

import sys
from dataclasses import dataclass
from typing import Literal

import pytest

from tests.utils import backends, parse


@pytest.mark.skipif(sys.version_info < (3, 10), reason="requires 3.10")
@backends
def test_valid(backend):
    @dataclass
    class ArgTest:
        name: Literal["one"] | Literal["two"] | Literal["three"] | Literal[4]

    test = parse(ArgTest, "two", backend=backend)
    assert test.name == "two"

tests/arg/test_pipe_union.py
DanCardin/cappa
from dataclasses import dataclass

import cappa
import pytest
from typing_extensions import Annotated

from tests.utils import backends, parse


@dataclass
class Command:
    a: Annotated[str, cappa.Arg(short=True)]
    b: Annotated[str, cappa.Arg(short=True, required=True)] = "asdf"


@backends
def test_required_implicit(backend):
    with pytest.raises(cappa.Exit) as e:
        parse(Command, "-b", "b", backend=backend)

    assert e.value.code == 2
    assert "are required: -a" in str(e.value.message)

    result = parse(Command, "-a", "a", "-b", "b", backend=backend)
    assert result == Command(a="a", b="b")


@backends
def test_required_explicit(backend):
    with pytest.raises(cappa.Exit) as e:
        parse(Command, "-a", "a", backend=backend)

    assert e.value.code == 2
    assert "are required: -b" in str(e.value.message)

    result = parse(Command, "-a", "a", "-b", "b")
    assert result == Command(a="a", b="b")

tests/arg/test_required.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Set

import cappa
from typing_extensions import Annotated

from tests.utils import backends, parse


@backends
def test_list_option(backend):
    @dataclass
    class ArgTest:
        variable_number: Annotated[Set[str], cappa.Arg(short=True, long=True)] = field(
            default_factory=set
        )

    test = parse(
        ArgTest,
        "-v",
        "one",
        "--variable-number",
        "one",
        "--variable-number",
        "two",
        "-v",
        "two",
        backend=backend,
    )
    assert test.variable_number == {"one", "two"}


@backends
def test_list_positional(backend):
    @dataclass
    class ArgTest:
        variable_number: Set[int] = field(default_factory=set)

    test = parse(
        ArgTest,
        "1",
        "2",
        "3",
        "2",
        "3",
        "1",
        backend=backend,
    )
    assert test.variable_number == {1, 2, 3}

tests/arg/test_set.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass

import cappa
from typing_extensions import Annotated

from tests.utils import backends, parse


@backends
def test_short_missing_dash(backend):
    @dataclass
    class ArgTest:
        number: Annotated[int, cappa.Arg(short="n")]

    result = parse(ArgTest, "-n", "4", backend=backend)
    assert result.number == 4


@backends
def test_multiple_shorts(backend):
    @dataclass
    class ArgTest:
        number: Annotated[int, cappa.Arg(short=["n", "o"])]

    result = parse(ArgTest, "-n", "4", backend=backend)
    assert result.number == 4

    result = parse(ArgTest, "-o", "4", backend=backend)
    assert result.number == 4


@backends
def test_long_missing_dash(backend):
    @dataclass
    class ArgTest:
        number: Annotated[int, cappa.Arg(long="nu")]

    result = parse(ArgTest, "--nu", "4", backend=backend)
    assert result.number == 4


@backends
def test_multiple_longs(backend):
    @dataclass
    class ArgTest:
        number: Annotated[int, cappa.Arg(long=["so", "long"])]

    result = parse(ArgTest, "--so", "4", backend=backend)
    assert result.number == 4

    result = parse(ArgTest, "--long", "4", backend=backend)
    assert result.number == 4

tests/arg/test_short_long.py
DanCardin/cappa
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 backends, parse


@dataclass
class Command:
    subcmd: Annotated[Union[A, None], cappa.Subcommand(required=True)] = None


@dataclass
class A:
    ...


@backends
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)

    result = parse(Command, "a", backend=backend)
    assert result == Command(subcmd=A())

tests/arg/test_subcommand_required.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass
from typing import Tuple

from tests.utils import backends, parse


@dataclass
class ArgTest:
    numbers: Tuple[int, str, float]


@backends
def test_valid(backend):
    test = parse(ArgTest, "1", "2", "3.4", backend=backend)
    assert test.numbers == (1, "2", 3.4)

tests/arg/test_tuple.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass
from typing import Tuple

import cappa
from typing_extensions import Annotated

from tests.utils import backends, parse


@backends
def test_positional_arg(backend):
    @dataclass
    class ArgTest:
        numbers: Tuple[int, ...]

    test = parse(ArgTest, "1", "2", "3", "4", backend=backend)
    assert test.numbers == (1, 2, 3, 4)


@backends
def test_option_flag(backend):
    @dataclass
    class ArgTest:
        numbers: Annotated[Tuple[int, ...], cappa.Arg(short=True)]

    test = parse(ArgTest, "-n", "1", "-n", "2", "-n", "3", "-n", "4", backend=backend)
    assert test.numbers == (1, 2, 3, 4)

tests/arg/test_tuple_unbounded.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass

import cappa
import pytest
from cappa.output import Exit

from tests.utils import parse


@pytest.mark.help
def test_default_help(capsys):
    @dataclass
    class Command:
        """Some help.

        More detail.
        """

        ...

    with pytest.raises(Exit):
        parse(Command, "--help")

    stdout = capsys.readouterr().out
    assert "\nSome help. More detail.\n" in stdout


@pytest.mark.help
def test_default_help_no_long_description(capsys):
    @dataclass
    class Command:
        """Some help."""

        ...

    with pytest.raises(Exit):
        parse(Command, "--help")

    stdout = capsys.readouterr().out
    assert "\nSome help.\n" in stdout


@pytest.mark.help
def test_unannotated_argument(capsys):
    @cappa.command(help="All the help.")
    @dataclass
    class Command:
        ...

    with pytest.raises(Exit):
        parse(Command, "--help")

    stdout = capsys.readouterr().out
    assert "\nAll the help.\n" in stdout


@pytest.mark.help
def test_description_without_help(capsys):
    @cappa.command(description="All the help.")
    @dataclass
    class Command:
        pass

    with pytest.raises(Exit):
        parse(Command, "--help")

    stdout = capsys.readouterr().out
    assert "All the help.\n" in stdout

tests/command/test_help.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass

from tests.utils import parse_completion


def test_arg():
    @dataclass
    class Args:
        value: str

    result = parse_completion(Args, "")
    assert not result


def test_arg_partial():
    @dataclass
    class Args:
        value: str

    result = parse_completion(Args, "m")
    assert result
    assert result == "file"

tests/completion/test_argument.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass
from unittest.mock import patch

import cappa
import pytest
from cappa.parser import backend

from tests.utils import parse


def test_unknown_shell():
    @dataclass
    class Args:
        value: str

    with patch("os.environ", new={"SHELL": "notbash"}):
        with pytest.raises(cappa.Exit) as e:
            parse(Args, "--completion", "complete", backend=backend)

    assert e.value.code == 1
    assert e.value.message == "Unknown shell"


def test_generate_completions():
    @dataclass
    class Args:
        value: str

    with pytest.raises(cappa.Exit) as e:
        parse(Args, "--completion", "generate", backend=backend)

    assert e.value.code == 0
    assert e.value.message
    assert "_testpy_completion" in str(e.value.message)


def test_invalid_setup():
    @dataclass
    class Args:
        value: str

    with pytest.raises(cappa.Exit) as e:
        parse(Args, "--completion", "complete", backend=backend)

    assert e.value.code == 0
    assert not e.value.message


def test_no_completion():
    @dataclass
    class Example:
        ...

    result = cappa.parse(Example, argv=[], backend=backend, completion=False)
    assert result == Example()

    with pytest.raises(cappa.Exit) as e:
        cappa.parse(
            Example,
            argv=["ex", "--completion", "generate"],
            backend=backend,
            completion=False,
        )
    assert e.value.code == 2


def test_arg_completion(capsys):
    @dataclass
    class Example:
        ...

    completion: cappa.Arg = cappa.Arg(short="-p", long="--pompletion")

    result = cappa.parse(Example, argv=[], completion=completion, backend=backend)
    assert result == Example()

    with pytest.raises(SystemExit) as e:
        cappa.parse(
            Example,
            argv=["ex", "-p", "generate"],
            completion=completion,
            backend=backend,
        )
    assert e.value.code == 0

    out = capsys.readouterr().out
    assert "_ex_completion" in out

    with pytest.raises(SystemExit) as e:
        cappa.parse(
            Example,
            argv=["ex", "--help"],
            completion=completion,
            backend=backend,
        )
    assert e.value.code == 0

    out = capsys.readouterr().out
    assert "-p, --pompletion" in out

tests/completion/test_base.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass
from typing import Literal, Union

import cappa
from typing_extensions import Annotated

from tests.utils import parse_completion


def test_choices_without_help():
    @dataclass
    class Args:
        value: Union[Literal["one"], Literal["two"], Literal["three"]]

    result = parse_completion(Args, "")
    assert result
    assert result == "one:\ntwo:\nthree:"


def test_choices_with_help():
    @dataclass
    class Args:
        value: Annotated[
            Union[Literal["one"], Literal["two"], Literal["three"]],
            cappa.Arg(help="helpful"),
        ]

    result = parse_completion(Args, "")
    assert result
    assert result == "one:helpful\ntwo:helpful\nthree:helpful"


def test_half_string():
    @dataclass
    class Args:
        value: Union[Literal["one"], Literal["two"], Literal["three"]]

    result = parse_completion(Args, "'on")
    assert result
    assert result == "one:"

tests/completion/test_choices.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass

import cappa
from typing_extensions import Annotated

from tests.utils import parse_completion


def test_long_option_name():
    @dataclass
    class Args:
        default: bool = False

    result = parse_completion(Args, "--d")
    assert result == "--default:"


def test_long_option_name_with_help():
    @dataclass
    class Args:
        default: Annotated[bool, cappa.Arg(help="Enables default")] = False

    result = parse_completion(Args, "--d")
    assert result == "--default:Enables default"


def test_mulitple_matches():
    @dataclass
    class Args:
        apple: Annotated[str, cappa.Arg(short=True, help="apple")]
        banana: Annotated[str, cappa.Arg(short=True, help="banana")]

    result = parse_completion(Args, "-")
    assert result
    assert "-a:apple\n-b:banana" in result


def test_short_name():
    @dataclass
    class Args:
        apple: Annotated[str, cappa.Arg(short=True)]

    result = parse_completion(Args, "-a")
    assert result is None

tests/completion/test_option.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass
from typing import Union

import cappa
from typing_extensions import Annotated

from tests.utils import parse_completion


@dataclass
class Foo:
    ...


@dataclass
class Bar:
    nested_opt: Annotated[str, cappa.Arg(long=True)]


@dataclass
class Args:
    value: cappa.Subcommands[Union[Foo, Bar]]


def test_subcommand_name_no_partial():
    result = parse_completion(Args, "")
    assert result == "foo:\nbar:"


def test_subcommand_name_with_partial():
    result = parse_completion(Args, "f")
    assert result
    assert result == "foo:"


def test_subcommand_args():
    result = parse_completion(Args, "bar", "--n")
    assert result
    assert result == "--nested-opt:"

tests/completion/test_subcommand.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass

from tests.utils import parse_completion


def test_long_option_name():
    @dataclass
    class Args:
        default: bool = False

    result = parse_completion(Args, "--default")
    assert result is None

tests/completion/test_successful_parse.py
DanCardin/cappa
import example
import pytest


def test_help():
    with pytest.raises(SystemExit):
        example.main(["--help"])

tests/examples/test_kitchen_sink/conftest.py
DanCardin/cappa
#!/usr/bin/env python

from __future__ import annotations

import enum
import logging
import sqlite3
from dataclasses import dataclass, field
from typing import List, Literal, Union

import cappa
from typing_extensions import Annotated

log = logging.getLogger(__name__)


class OtherOptions(enum.Enum):
    a = "a"
    b = "b"
    c = "c"


@dataclass
class Example:
    """Example program.

    Args:
       name: Required name argument
       flag: optional flag to enable
    """

    name: str
    value: Annotated[int, cappa.Arg(short="-V", long="--val", default=0)]

    options: Annotated[
        Union[Literal["one"], Literal["two"], Literal[3]], cappa.Arg(short=True)
    ]
    moptions: Annotated[OtherOptions, cappa.Arg(short=True)]

    subcommand: Annotated[Union[MeowCommand, BarkCommand], cappa.Subcommand]

    flags: Annotated[List[str], cappa.Arg(short=True, long=True)] = field(
        default_factory=list
    )
    flag: bool = False  # --flag


def db() -> sqlite3.Connection:
    return sqlite3.connect("sqlite:///")


def meow(
    command: Example,
    meow: MeowCommand,
    db: Annotated[sqlite3.Connection, cappa.Dep(db)],
):
    result = db.execute(f"select '{command.name}', {meow.times} + 1").fetchone()
    log.info(result)


@cappa.command(name="meow", invoke=meow)
@dataclass
class MeowCommand:
    times: int


def bark(bark: BarkCommand):
    log.info(bark)


@cappa.command(name="bark", invoke=bark)
@dataclass
class BarkCommand:
    ...


# invoke cli parsing
def main(argv=None):
    logging.basicConfig()

    args: Example = cappa.parse(Example, argv=argv, version="1.2.3")
    log.info(args)

    cappa.invoke(Example, argv=argv, version="1.2.3")


if __name__ == "__main__":
    main()

tests/examples/test_kitchen_sink/example.py
DanCardin/cappa
from __future__ import annotations

import re
from dataclasses import dataclass

import cappa
import pytest
from typing_extensions import Annotated

from tests.utils import backends, parse


@backends
def test_string_group(backend, capsys):
    @dataclass
    class Args:
        name: Annotated[str, cappa.Arg(group="Strings")]

    with pytest.raises(cappa.HelpExit) as e:
        parse(Args, "--help", backend=backend)

    assert e.value.code == 0

    out = capsys.readouterr().out
    assert re.match(r".*Strings:?\s*\n\s*name.*", out, re.DOTALL)

tests/help/test_group.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass

import cappa
import pytest

from tests.utils import backends, invoke


@backends
def test_missing_invoke(backend):
    @cappa.command()
    @dataclass
    class Command:
        ...

    with pytest.raises(RuntimeError) as e:
        invoke(Command, backend=backend)

    exc = e.value
    cause = exc.__cause__
    assert "due to resolution failure" in str(exc)
    assert (
        f"Cannot call `invoke` for a command which does not have an invoke handler: {Command}."
        == str(cause)
    )


@backends
def test_string_missing_function(backend):
    @cappa.command(invoke="sys")
    @dataclass
    class Command:
        ...

    with pytest.raises(RuntimeError) as e:
        invoke(Command, backend=backend)

    exc = e.value
    cause = exc.__cause__
    assert "due to resolution failure" in str(exc)
    assert (
        "Invoke `sys` must be a fully qualified reference to a function in a module."
        == str(cause)
    )


@backends
def test_string_references_invalid_module(backend):
    @cappa.command(invoke="completely.wrong")
    @dataclass
    class Command:
        ...

    with pytest.raises(RuntimeError) as e:
        invoke(Command, backend=backend)

    exc = e.value
    cause = exc.__cause__
    assert "due to resolution failure" in str(exc)
    assert "No module 'completely' when attempting to load 'completely.wrong'." == str(
        cause
    )


@backends
def test_string_references_invalid_function(backend):
    @cappa.command(invoke="builtins.meow")
    @dataclass
    class Command:
        ...

    with pytest.raises(RuntimeError) as e:
        invoke(Command, backend=backend)

    exc = e.value
    cause = exc.__cause__
    assert "due to resolution failure" in str(exc)
    assert (
        "Module <module 'builtins' (built-in)> does not have a function `meow`."
        == str(cause)
    )


@backends
def test_string_reference_not_callable(backend):
    @cappa.command(invoke="builtins.__name__")
    @dataclass
    class Command:
        ...

    with pytest.raises(RuntimeError) as e:
        invoke(Command, backend=backend)

    exc = e.value
    cause = exc.__cause__
    assert "due to resolution failure" in str(exc)
    assert "`builtins` does not reference a valid callable." == str(cause)

tests/invoke/test_bad_handler.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass

import cappa
from typing_extensions import Annotated

from tests.utils import backends, invoke


def dependency():
    return 5


@dataclass
class Command:
    foo: int

    def __call__(self, dep: Annotated[int, cappa.Dep(dependency)]):
        return self.foo + dep


@backends
def test_invoke_top_level_command(backend):
    result = invoke(Command, "7", backend=backend)
    assert result == 7 + 5

tests/invoke/test_callable_dataclass.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass

import cappa
from typing_extensions import Annotated

from tests.utils import backends, invoke


def has_default(default: bool = True):
    return {"default": default}


def command(
    default: Annotated[dict, cappa.Dep(has_default)],
):
    return {"one": True, **default}


@cappa.command(invoke=command)
@dataclass
class Command:
    ...


@backends
def test_invoke_top_level_command(backend):
    result = invoke(Command, backend=backend)
    assert result == {"one": True, "default": True}

tests/invoke/test_default_arg_non_dep.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass

import cappa
from typing_extensions import Annotated

from tests.utils import backends, invoke


def level_three():
    return {"level_three": True}


def level_two(level_three: Annotated[dict, cappa.Dep(level_three)]):
    return {"level_two": {**level_three}}


def level_one(level_two: Annotated[dict, cappa.Dep(level_two)]):
    return {"level_one": {**level_two}}


def command(levels: Annotated[dict, cappa.Dep(level_one)]):
    return levels


@cappa.command(invoke=command)
@dataclass
class Command:
    ...


@backends
def test_invoke_top_level_command(backend):
    result = invoke(Command, backend=backend)
    assert result == {"level_one": {"level_two": {"level_three": True}}}

tests/invoke/test_explicit_deps.py
DanCardin/cappa
from __future__ import annotations

import logging
from dataclasses import dataclass

import cappa
from typing_extensions import Annotated

from tests.utils import backends, invoke

log = logging.getLogger("test")


def level_three():
    log.debug("three")
    return {"three": True}


def level_two(three: Annotated[dict, cappa.Dep(level_three)]):
    log.debug("two")
    return {"two": {**three}}


def level_one(
    two: Annotated[dict, cappa.Dep(level_two)],
    three: Annotated[dict, cappa.Dep(level_three)],
):
    log.debug("one")
    return {"one": {**two, **three}}


def command(
    one: Annotated[dict, cappa.Dep(level_one)],
    two: Annotated[dict, cappa.Dep(level_two)],
    three: Annotated[dict, cappa.Dep(level_three)],
):
    return {**one, **two, **three}


@cappa.command(invoke=command)
@dataclass
class Command:
    ...


@backends
def test_invoke_top_level_command(caplog, backend):
    """Assert multiple paths to explicit dependencies are still fullfilled.

    * Ensure they are only called once overall, regardless of the number of downstream dependents.
    """
    caplog.set_level(logging.DEBUG)

    result = invoke(Command, backend=backend)

    assert result == {
        "one": {"two": {"three": True}, "three": True},
        "two": {"three": True},
        "three": True,
    }

    assert len(caplog.records) == 3

tests/invoke/test_explicit_deps_multiple_paths.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass

import cappa

from tests.utils import backends, invoke


def foo(command: Command):
    print("called", command)


def bar():
    print("two")


def command():
    return 1


@cappa.command(invoke=command)
@dataclass
class Command:
    ...


@backends
def test_invoke_top_level_command(capsys, backend):
    result = invoke(Command, deps=[foo, bar], backend=backend)
    assert result == 1

    out = capsys.readouterr().out
    assert "called Command()" in out
    assert "two" in out

tests/invoke/test_global_dep.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass
from typing import Union

import cappa
from typing_extensions import Annotated

from tests.utils import backends, invoke


def top_level_command(tlc: TopLevelCommand):
    return ("tlc", tlc)


def sub_command(sub: Subcommand):
    return ("sub", sub)


@cappa.command(name="sub", invoke=sub_command)
@dataclass
class Subcommand:
    bar: int


@cappa.command(invoke=top_level_command)
@dataclass
class TopLevelCommand:
    foo: Annotated[int, cappa.Arg(long=True)] = 4
    subcommand: Annotated[Union[Subcommand, None], cappa.Subcommand] = None


@backends
def test_invoke_top_level_command(backend):
    result = invoke(TopLevelCommand, "--foo", "4", backend=backend)
    assert result == ("tlc", TopLevelCommand(foo=4))


@backends
def test_invoke_subcommand(backend):
    result = invoke(TopLevelCommand, "sub", "4", backend=backend)
    assert result == ("sub", Subcommand(bar=4))

tests/invoke/test_implicit_deps.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass

import cappa
import pytest

from tests.utils import backends, invoke


@backends
def test_invalid_dependency(backend):
    def command(levels: int):
        return levels

    @cappa.command(invoke=command)
    @dataclass
    class Command:
        ...

    with pytest.raises(RuntimeError) as e:
        invoke(Command, backend=backend)

    exc = e.value
    cause = exc.__cause__
    assert "due to resolution failure" in str(exc)
    assert "`levels: int` is not a valid dependency for Dep(command)." == str(cause)


@backends
def test_unannotated_argument(backend):
    def command(levels):
        return levels

    @cappa.command(invoke=command)
    @dataclass
    class Command:
        ...

    with pytest.raises(RuntimeError) as e:
        invoke(Command, backend=backend)

    exc = e.value
    cause = exc.__cause__
    assert "due to resolution failure" in str(exc)
    assert "`levels: <empty>` is not a valid dependency for Dep(command)." == str(cause)

tests/invoke/test_invalid_depdendency.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass
from typing import Union

import cappa
from typing_extensions import Annotated

from tests.utils import backends, invoke


def foo(tlc: TopLevelCommand, subcommand: Subcommand, foo: Foo):
    return (tlc, subcommand, foo)


@cappa.command(invoke=foo)
@dataclass
class Foo:
    ...


@dataclass
class Subcommand:
    cmd: Annotated[Foo, cappa.Subcommand]


@cappa.command(invoke=lambda: 4)
@dataclass
class TopLevelCommand:
    cmd: Annotated[Union[Subcommand, None], cappa.Subcommand] = None


@backends
def test_every_dependency_level(backend):
    result = invoke(TopLevelCommand, "subcommand", "foo", backend=backend)
    assert result == (
        TopLevelCommand(cmd=Subcommand(cmd=Foo())),
        Subcommand(cmd=Foo()),
        Foo(),
    )


@backends
def test_no_subcmd(backend):
    result = invoke(TopLevelCommand, backend=backend)
    assert result == 4

tests/invoke/test_nested_implicit_dep.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass

import cappa

from tests.utils import backends, invoke


@dataclass
class TopLevelCommand:
    def __call__(self, out: cappa.Output):
        out.output("woah!")
        out.error("woops!")


@backends
def test_outputs_output(backend, capsys):
    invoke(TopLevelCommand, backend=backend)

    outerr = capsys.readouterr()
    assert outerr.out == "woah!\n"
    assert outerr.err == "woops!\n"

tests/invoke/test_output.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass

import cappa
from typing_extensions import Annotated

from tests.utils import backends, invoke


def two():
    return 2


def three(_: Command):
    return 3


def one(two: Annotated[int, cappa.Dep(two)], three: Annotated[int, cappa.Dep(three)]):
    return (two, three)


def command(one: Annotated[int, cappa.Dep(one)]):
    return one


@cappa.command(invoke=command)
@dataclass
class Command:
    ...


@backends
def test_invoke_top_level_command(backend):
    def stub_two():
        return 4

    result = invoke(Command, deps={two: cappa.Dep(stub_two), three: 6}, backend=backend)
    assert result == (4, 6)

tests/invoke/test_stubbing_deps.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass

import cappa
import pytest
from cappa.parser import backend
from typing_extensions import Annotated

from tests.utils import backends, parse


@backends
def test_invalid_choice_help(backend):
    @dataclass
    class Args:
        value: Annotated[str, cappa.Arg(long=True)]

    with pytest.raises(cappa.Exit) as e:
        parse(Args, "--value", "ok", "--meow", "wat", backend=backend)
    assert e.value.code == 2
    assert "unrecognized arguments: --meow" in str(e.value.message).lower()


def test_invalid_choice_help_possible_options():
    @dataclass
    class Args:
        value: Annotated[str, cappa.Arg(long=True)]

    with pytest.raises(cappa.Exit) as e:
        parse(Args, "--val", "on", backend=backend)
    assert e.value.code == 2
    assert "Unrecognized arguments: --val (Did you mean: --value)" in str(
        e.value.message
    )

tests/parser/test_bad_choice_assistance.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass
from typing import List, Union

import cappa
from typing_extensions import Annotated

from tests.utils import backends, parse


@backends
def test_collect_post_dash_args(backend):
    @dataclass
    class Args:
        foo: str
        raw: Annotated[Union[List[str], None], cappa.Arg(num_args=-1)] = None

    test = parse(Args, "foovalue", "--", "--raw", "value", backend=backend)
    assert test.foo == "foovalue"
    assert test.raw == ["--raw", "value"]

tests/parser/test_double_dash.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass

import cappa
from typing_extensions import Annotated

from tests.utils import backends, parse


@backends
def test_unrecognized_post_dash_arg(backend):
    @dataclass
    class Args:
        value: Annotated[str, cappa.Arg(long=True)]

    result = parse(Args, "--value=val", backend=backend)
    assert result == Args(value="val")

tests/parser/test_explicit_equal_option.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass

import cappa
from typing_extensions import Annotated

from tests.utils import backends, parse


@backends
def test_invalid_choice_help(backend):
    @dataclass
    class Args:
        arg: str
        option: Annotated[str, cappa.Arg(long=True)]
        arg2: str
        option2: Annotated[str, cappa.Arg(long=True)]

    result = parse(
        Args, "arg", "--option=opt", "arg2", "--option2=opt2", backend=backend
    )
    assert result == Args(arg="arg", option="opt", arg2="arg2", option2="opt2")

tests/parser/test_interspersed_options.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass
from typing import Tuple

import cappa
import pytest
from typing_extensions import Annotated

from tests.utils import backends, parse


@backends
def test_invalid_choice_help(backend):
    @dataclass
    class Args:
        arg: Tuple[str, str]
        option: Annotated[str, cappa.Arg(long=True)]

    with pytest.raises(cappa.Exit) as e:
        parse(Args, "arg", "--option=opt", backend=backend)
    assert e.value.code == 2

    message = str(e.value.message)
    if backend:
        assert message == "Argument requires 2 values, only found 1 ('arg' so far)."
    else:
        assert "the following arguments are required: arg" in message

tests/parser/test_missing_num_args.py
DanCardin/cappa
from __future__ import annotations

from dataclasses import dataclass
from typing import Union

import cappa
import pytest

from tests.utils import backends, parse


@backends
def test_unrecognized_post_dash_arg(backend):
    @dataclass
    class Args:
        foo: str
        raw: Union[str, None] = None

    with pytest.raises(cappa.Exit) as e:
        parse(Args, "foovalue", "raw", "raw2", backend=backend)

    assert e.value.code == 2
    assert "unrecognized arguments: raw2" in str(e.value.message).lower()

tests/parser/test_unregonized_argument.py
DanCardin/cappa
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 backends, parse


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


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


@backends
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()
    )


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


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


@dataclass
class RequiredProvided:
    subcommand: Annotated[
        Union[RequiredProvidedOne, RequiredProvidedTwo], cappa.Subcommand()
    ]


@backends
def test_required_provided(backend):
    test = parse(
        RequiredProvided, "required-provided-one", "--foo", "foo", backend=backend
    )
    assert isinstance(test.subcommand, RequiredProvidedOne)
    assert test.subcommand.foo == "foo"

    test = parse(RequiredProvided, "required-provided-two", backend=backend)
    assert isinstance(test.subcommand, RequiredProvidedTwo)
    assert test.subcommand.bar is None

    test = parse(
        RequiredProvided, "required-provided-two", "--bar", "bar", backend=backend
    )
    assert isinstance(test.subcommand, RequiredProvidedTwo)
    assert test.subcommand.bar == "bar"


@cappa.command(name="one")
@dataclass
class NamedSubcommandOne:
    pass


@dataclass
class NamedSubcommand:
    subcommand: Annotated[NamedSubcommandOne, cappa.Subcommand()]


@backends
def test_named_subcommand(backend):
    test = parse(NamedSubcommand, "one", backend=backend)
    assert isinstance(test.subcommand, NamedSubcommandOne)

tests/subcommand/test_base.py
DanCardin/cappa
from __future__ import annotations

import re
from dataclasses import dataclass

import cappa
import pytest
from typing_extensions import Annotated

from tests.utils import backends, parse


@dataclass
class Foo:
    ...


@dataclass
class Args:
    subcommand: Annotated[Foo, cappa.Subcommand(group="Yup")]


@backends
def test_required_missing(backend, capsys):
    with pytest.raises(cappa.Exit) as e:
        parse(Args, "--help", backend=backend)
    assert e.value.code == 0

    help = capsys.readouterr().out
    assert re.match(r".*Yup:?[\n\s]+\{foo\}.*", help, re.DOTALL)

tests/subcommand/test_group.py
DanCardin/cappa
from __future__ import annotations

import typing
from dataclasses import dataclass

import cappa

from tests.utils import backends, parse


@dataclass
class Command:
    cmd: cappa.Subcommands[typing.Union[Foo, Bar]]


@dataclass
class Foo:
    f: int


@dataclass
class Bar:
    b: int


@backends
def test_subcommand_type_type_alias(backend):
    result = parse(Command, "foo", "4", backend=backend)
    assert result == Command(cmd=Foo(f=4))

    result = parse(Command, "bar", "4", backend=backend)
    assert result == Command(cmd=Bar(b=4))

tests/subcommand/test_subcommand_type.py
DanCardin/cappa
# type: ignore
from __future__ import annotations

from typing import List

import attr
import cappa
from typing_extensions import Annotated

from tests.utils import backends, parse

factory = attr.Factory(lambda: [4])


@attr.s
class Command:
    name: str = attr.ib()
    default: Annotated[int, cappa.Arg(long=True)] = attr.ib(default=4)
    default_factory: Annotated[List[int], cappa.Arg(long=True)] = attr.ib(
        default=factory
    )


@backends
def test_attrs(backend):
    result = parse(Command, "meow", backend=backend)
    assert result == Command(name="meow", default=4, default_factory=[4])

tests/test_attrs.py
DanCardin/cappa
from dataclasses import dataclass

import cappa
import pytest

from tests.utils import backends


@backends
def test_color_off(backend):
    def no_op():
        pass

    @cappa.command(invoke=no_op)
    @dataclass
    class Example:
        ...

    cappa.invoke(Example, color=False, argv=[], backend=backend)


@backends
def test_no_help(backend):
    @dataclass
    class Example:
        ...

    result = cappa.parse(Example, argv=[], help=False, backend=backend)
    assert result == Example()

    with pytest.raises(SystemExit) as e:
        cappa.parse(Example, argv=["ex", "-h"], help=False, backend=backend)
    assert e.value.code == 2

    with pytest.raises(SystemExit) as e:
        cappa.parse(Example, argv=["ex", "--help"], help=False, backend=backend)
    assert e.value.code == 2


@pytest.mark.help
@backends
def test_arg_help(capsys, backend):
    @dataclass
    class Example:
        ...

    help: cappa.Arg = cappa.Arg(short="-p", long="--pelp")

    result = cappa.parse(Example, argv=[], help=help, backend=backend)
    assert result == Example()

    with pytest.raises(SystemExit) as e:
        cappa.parse(Example, argv=["ex", "-p"], help=help, backend=backend)
    assert e.value.code == 0

    out = capsys.readouterr().out
    assert "-p, --pelp" in out

    with pytest.raises(SystemExit) as e:
        cappa.parse(Example, argv=["ex", "--pelp"], help=help, backend=backend)
    assert e.value.code == 0

    out = capsys.readouterr().out
    assert "-p, --pelp" in out


@backends
def test_version_enabled(capsys, backend):
    @dataclass
    class Example:
        ...

    with pytest.raises(SystemExit) as e:
        cappa.parse(Example, argv=["ex", "--version"], version="1.2.3", backend=backend)
    assert e.value.code == 0

    out = capsys.readouterr().out
    assert "1.2.3" in out


@backends
def test_arg_version(capsys, backend):
    @dataclass
    class Example:
        ...

    version: cappa.Arg = cappa.Arg("1.2.3", short="-p", long="--persion")

    result = cappa.parse(Example, argv=[], version=version, backend=backend)
    assert result == Example()

    with pytest.raises(SystemExit) as e:
        cappa.parse(Example, argv=["ex", "-p"], version=version, backend=backend)
    assert e.value.code == 0

    out = capsys.readouterr().out
    assert "1.2.3" in out

    with pytest.raises(SystemExit) as e:
        cappa.parse(Example, argv=["ex", "--persion"], version=version, backend=backend)
    assert e.value.code == 0

    out = capsys.readouterr().out
    assert "1.2.3" in out


@backends
def test_version_without_help(backend):
    @dataclass
    class Example:
        ...

    result = cappa.parse(Example, argv=[], version="1.2.3", help=False, backend=backend)
    assert result == Example()

tests/test_base.py
DanCardin/cappa
from dataclasses import dataclass

import cappa
import pytest

from tests.utils import parse


@dataclass
class IncludesDocstring:
    """Does a thing.

    and does it really well!

    Args:
        foo (str): the value of foo
        bar (bool): whether to bar
    """

    foo: str
    bar: bool = False


@pytest.mark.help
def test_required_provided(capsys):
    with pytest.raises(cappa.Exit):
        parse(IncludesDocstring, "--help")

    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


@pytest.mark.help
def test_just_a_title(capsys):
    @dataclass
    class IncludesDocstring:
        """Just a title."""

    with pytest.raises(cappa.Exit):
        parse(IncludesDocstring, "--help")

    result = capsys.readouterr().out

    assert "Just a title" in result


@pytest.mark.help
def test_docstring_with_explicit_help(capsys):
    @cappa.command(help="help text")
    @dataclass
    class IncludesDocstring:
        """Just a title."""

    with pytest.raises(cappa.Exit):
        parse(IncludesDocstring, "--help")

    result = capsys.readouterr().out

    assert "Just a title" not in result
    assert "help text" in result


@pytest.mark.help
def test_docstring_with_explicit_description(capsys):
    @cappa.command(description="description")
    @dataclass
    class IncludesDocstring:
        """Just a title."""

    with pytest.raises(cappa.Exit):
        parse(IncludesDocstring, "--help")

    result = capsys.readouterr().out

    assert "Just a title" in result
    assert "description" in result

tests/test_docstring.py
DanCardin/cappa
def successful_test_run(pytester, *, count=None, skipped_count=0):
    pytester.copy_example()
    result = pytester.runpytest_inprocess(
        "-vv",
        "--log-cli-level=WARNING",
        "--log-level=WARNING",
        "conftest.py",
    )
    result.assert_outcomes(passed=count, skipped=skipped_count, failed=0)


def test_kitchen_sink(pytester):
    successful_test_run(pytester, count=1)

tests/test_e2e.py
DanCardin/cappa
from __future__ import annotations

import pathlib
from dataclasses import dataclass, field
from typing import Tuple

import cappa

from tests.utils import backends, parse


@backends
def test_positional_string(backend):
    @dataclass
    class ArgTest:
        name: str

    test = parse(ArgTest, "thename", backend=backend)
    assert test.name == "thename"


@backends
def test_path(backend):
    @dataclass
    class ArgTest:
        name: pathlib.PurePath

    test = parse(ArgTest, "./file.txt", backend=backend)
    assert test.name == pathlib.PurePath("./file.txt")


@backends
def test_bool(backend):
    @dataclass
    class ArgTest:
        flag: bool

    test = parse(ArgTest, "--flag", backend=backend)
    assert test.flag is True


@backends
def test_tuple(backend):
    @dataclass
    class ArgTest:
        exact_num_args: Tuple[str, int] = field(
            default=("a", 0), metadata={"cappa": cappa.Arg(long=True)}
        )

    test = parse(
        ArgTest,
        "--exact-num-args",
        "three",
        "4",
        backend=backend,
    )
    assert test.exact_num_args == ("three", 4)


@backends
def test_missing_value_defaults(backend):
    @dataclass
    class ArgTest:
        exact_num_args: Tuple[str, int] = field(
            default=("a", 0), metadata={"cappa": cappa.Arg(long=True)}
        )

    test = parse(ArgTest, backend=backend)
    assert test.exact_num_args == ("a", 0)

tests/test_example.py
DanCardin/cappa
from __future__ import annotations

import cappa

from tests.utils import backends, parse


@cappa.command(name="sub")
class Example:
    bar: int
    name: str


@backends
def test_invoke_top_level_command(backend):
    result = parse(Example, "4", "foo", backend=backend)
    assert result == Example(bar=4, name="foo")


@backends
def test_no_args_command(backend):
    @cappa.command
    class Example:
        bar: int
        name: str

    result = parse(Example, "4", "foo", backend=backend)
    assert result == Example(bar=4, name="foo")

tests/test_implicit_dataclass.py
DanCardin/cappa
from dataclasses import dataclass
from typing import List

import cappa
import pytest
from cappa.annotation import parse_list, parse_value
from cappa.output import Exit

from tests.utils import backends, parse


@dataclass
class Foo:
    bar: str
    baz: List[int]


command = cappa.Command(
    Foo,
    arguments=[
        cappa.Arg(name="bar", parse=str),
        cappa.Arg(name="baz", parse=parse_list(int), num_args=-1),
    ],
    help="Short help.",
    description="Long description.",
)


@backends
def test_valid(backend):
    result = parse(command, "one", "2", "3", backend=backend)
    assert result == Foo(bar="one", baz=[2, 3])


@pytest.mark.help
@backends
def test_help(capsys, backend):
    with pytest.raises(Exit):
        parse(command, "-h", backend=backend)

    out = capsys.readouterr().out
    assert "Short help." in out
    assert "Long description." in out


@backends
def test_subcommand(backend):
    @dataclass
    class Bar:
        bar: str

    @dataclass
    class Foo:
        sub: Bar

    command = cappa.Command(
        Foo,
        arguments=[
            cappa.Subcommand(
                name="sub",
                options={
                    "bar": cappa.Command(
                        Bar,
                        arguments=[
                            cappa.Arg(name="bar", parse=parse_value),
                        ],
                    )
                },
            ),
        ],
        help="Short help.",
        description="Long description.",
    )

    result = parse(command, "bar", "one", backend=backend)
    assert result == Foo(sub=Bar("one"))

tests/test_manually_built.py
DanCardin/cappa
from dataclasses import dataclass

import cappa
import pytest

from tests.utils import backends


@backends
def test_invoke_exit_success_with_message(capsys, backend):
    def fn():
        raise cappa.Exit("With message")

    @cappa.command(invoke=fn)
    @dataclass
    class Example:
        ...

    with pytest.raises(cappa.Exit):
        cappa.invoke(Example, argv=[""], backend=backend)

    out = capsys.readouterr().out
    assert out == "With message\n"


@backends
def test_invoke_exit_success_without_message(capsys, backend):
    def fn():
        raise cappa.Exit()

    @cappa.command(invoke=fn)
    @dataclass
    class Example:
        ...

    with pytest.raises(cappa.Exit):
        cappa.invoke(Example, argv=[""], backend=backend)

    out = capsys.readouterr().out
    assert out == ""


@backends
def test_invoke_exit_errror(capsys, backend):
    def fn():
        raise cappa.Exit("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 == "With message\n"

tests/test_output.py
DanCardin/cappa
from __future__ import annotations

import cappa
from pydantic import BaseModel, dataclasses
from typing_extensions import Annotated

from tests.utils import backends, parse


class PydanticCommand(BaseModel):
    name: str
    foo: Annotated[int, cappa.Arg(short=True)]


@backends
def test_base_model(backend):
    result = parse(PydanticCommand, "meow", "-f", "4", backend=backend)
    assert result == PydanticCommand(name="meow", foo=4)


@dataclasses.dataclass
class DataclassCommand:
    name: str
    foo: Annotated[int, cappa.Arg(short=True)]


@backends
def test_dataclass(backend):
    result = parse(DataclassCommand, "meow", "-f", "4", backend=backend)
    assert result == DataclassCommand(name="meow", foo=4)

tests/test_pydantic.py
DanCardin/cappa
from dataclasses import dataclass

import cappa
from cappa import output
from typing_extensions import Annotated

from tests.utils import backends, parse


@backends
class TestTestPrompt:
    def test_value(self, backend):
        prompt = output.TestPrompt("gimme text number", default="one", input="two")

        @dataclass
        class Test:
            name: Annotated[str, cappa.Arg(default=prompt)]

        result = parse(Test, backend=backend)

        assert result.name == "two"

        expected = "gimme text number (one): "
        out = prompt.file.getvalue()
        assert out == expected

    def test_default(self, backend):
        prompt = output.TestPrompt("gimme text number", default="one", input="")

        @dataclass
        class Test:
            name: Annotated[str, cappa.Arg(default=prompt)]

        result = parse(Test, backend=backend)

        assert result.name == "one"

        expected = "gimme text number (one): "
        out = prompt.file.getvalue()
        assert out == expected

    def test_mapped(self, backend):
        prompt = output.TestPrompt("gimme text number", default="1", input="5")

        @dataclass
        class Test:
            num: Annotated[int, cappa.Arg(default=prompt)]

        result = parse(Test, backend=backend)

        assert result.num == 5

tests/test_rich.py
DanCardin/cappa
from typing import Union
from unittest.mock import patch

import pytest
from cappa.output import Exit
from cappa.parser import backend
from cappa.testing import CommandRunner

backends = pytest.mark.parametrize("backend", [None, backend])

runner = CommandRunner(base_args=["test.py"])


def parse(cls, *args, backend=None):
    return runner.parse(*args, obj=cls, backend=backend)


def invoke(cls, *args, **kwargs):
    return runner.invoke(*args, obj=cls, **kwargs)


def parse_completion(cls, *args, location=None) -> Union[str, None]:
    env = {
        "COMPLETION_LINE": " ".join(["test.py", *args]),
        "COMPLETION_LOCATION": location if location is not None else len(args) + 1,
    }
    with patch("os.environ", new=env):
        with pytest.raises(Exit) as e:
            parse(cls, "--completion", "complete", backend=backend)

        assert e.value.code == 0
        if e.value.message:
            return str(e.value.message)
        return None

tests/utils.py
DanCardin/cappa