Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/cappa/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from cappa.base import collect, command, invoke, invoke_async, parse, parse_async
from cappa.command import Alias, Command
from cappa.command import Alias, Command, FinalCommand
from cappa.completion.types import Completion
from cappa.default import Confirm, Default, Env, Prompt, ValueFrom
from cappa.file_io import FileMode
Expand All @@ -8,11 +8,11 @@
from cappa.output import Exit, HelpExit, Output
from cappa.parse import default_parse, unpack_arguments
from cappa.state import State
from cappa.subcommand import Subcommand, Subcommands
from cappa.subcommand import FinalSubcommand, Subcommand, Subcommands
from cappa.type_view import Empty, EmptyType

# isort: split
from cappa.arg import Arg, ArgAction, Group, NumArgs
from cappa.arg import Arg, ArgAction, FinalArg, Group, NumArgs
from cappa.destructure import Destructured

# isort: split
Expand All @@ -34,6 +34,9 @@
"Env",
"Exit",
"FileMode",
"FinalArg",
"FinalCommand",
"FinalSubcommand",
"Group",
"HelpExit",
"HelpFormattable",
Expand Down
91 changes: 59 additions & 32 deletions src/cappa/arg.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
Callable,
Generic,
Iterable,
List,
Literal,
Sequence,
Set,
Expand All @@ -32,7 +31,6 @@
Doc,
DocType,
T,
assert_type,
detect_choices,
find_annotations,
)
Expand Down Expand Up @@ -388,7 +386,7 @@ def __hash__(self):
short: bool | str | list[str] | None = False
long: bool | str | list[str] | None = False
count: bool = False
default: T | EmptyType | None = Empty
default: Default | T | EmptyType | None = Empty
help: str | None = None
parse: Callable[..., T] | Sequence[Callable[..., Any]] | None = None
parse_inference: bool = True
Expand Down Expand Up @@ -420,7 +418,7 @@ def collect(
default_short: bool = False,
default_long: bool = False,
state: State[Any] | None = None,
) -> list[Arg[Any]]:
) -> list[FinalArg[Any]]:
args: list[Arg[Any]] = find_annotations(type_view, cls) or [Arg()]

exclusive = field.name if len(args) > 1 else False
Expand All @@ -438,7 +436,7 @@ def collect(
if field_metadata:
args = field_metadata

result: list[Arg[Any]] = []
result: list[FinalArg[Any]] = []
for i, arg in enumerate(args, start=1):
# field_name and default are evaluated outside `normalize` because they can
# potentially depend on the type's dataclass-like field information, whereas
Expand Down Expand Up @@ -480,7 +478,7 @@ def normalize(
state: State[Any] | None = None,
destructure: Destructure | bool | None = None,
show_default: bool | str | DefaultFormatter | None = None,
) -> Arg[Any]:
) -> FinalArg[Any]:
if type_view is None:
type_view = TypeView(Any)

Expand Down Expand Up @@ -517,28 +515,36 @@ def normalize(
)

destructure = destructure or self.destructure
if not destructure:
destructure = None
if destructure is True:
destructure = Destructure()

result = dataclasses.replace(
self,
default=default,
field_name=field_name,
result: FinalArg[Any] = FinalArg(
# preserved from self
count=self.count,
hidden=self.hidden,
deprecated=self.deprecated,
propagate=self.propagate,
parse_inference=self.parse_inference,
# computed/narrowed
value_name=value_name,
required=required,
short=short,
long=long,
choices=choices,
default=default,
help=help,
parse=parse,
group=group,
action=action,
num_args=num_args,
parse=parse,
help=help,
choices=choices,
completion=completion,
group=group,
has_value=has_value,
type_view=type_view,
required=required,
field_name=field_name,
show_default=default_formatter,
destructure=destructure,
has_value=has_value,
type_view=type_view,
)
verify_type_compatibility(
result, field_name, type_view, has_custom_parse=bool(self.parse)
Expand All @@ -550,27 +556,46 @@ def destructured(cls):
"""Mark a an argument as destructured. See also the shorter `Destructured` alias."""
return cls(destructure=Destructure())


@dataclasses.dataclass(frozen=True)
class FinalArg(Arg[T]):
"""Post-normalization form of :class:`Arg` with narrowed field types.

Produced exclusively by :meth:`Arg.normalize`. After normalization all
previously-optional/Empty fields are guaranteed to be concrete values.
"""

value_name: str = ""
short: list[str] | Literal[False] = False
long: list[str] | Literal[False] = False
default: Default = dataclasses.field(default_factory=Default)
group: Group = dataclasses.field(default_factory=Group)
action: ArgActionType = ArgAction.set
num_args: NumArgs = dataclasses.field(default_factory=NumArgs)
required: bool = False
field_name: str = ""
has_value: bool = True
type_view: TypeView[Any] = dataclasses.field(default_factory=lambda: TypeView(Any))
parse: Callable[..., Any] = dataclasses.field(default=parse_value)
show_default: DefaultFormatter = dataclasses.field(default_factory=DefaultFormatter)
destructure: Destructure | None = None

def names(self, *, n: int = 0) -> list[str]:
short_names = cast(List[str], self.short or [])
long_names = cast(List[str], self.long or [])
result = short_names + long_names
if n:
return result[:n]
return result
result = (self.short or []) + (self.long or [])
return result[:n] if n else result

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

return cast(str, self.value_name)
return self.value_name

@cached_property
def is_option(self) -> bool:
return bool(self.short or self.long)


def verify_type_compatibility(
arg: Arg[Any],
arg: FinalArg[Any],
field_name: str,
type_view: TypeView[Any],
*,
Expand All @@ -591,7 +616,7 @@ def verify_type_compatibility(
if has_custom_parse or ArgAction.is_custom(action):
return

num_args = assert_type(arg.num_args, NumArgs)
num_args = arg.num_args
if not num_args.required and num_args.default is None and not type_view.is_optional:
raise ValueError(
f"On field '{field_name}', `NumArgs(required=False, default=None)` requires the "
Expand Down Expand Up @@ -852,7 +877,9 @@ def infer_value_name(arg: Arg[Any], field_name: str, num_args: NumArgs) -> str:
return field_name


def explode_negated_bool_args(args: Sequence[Arg[Any]]) -> Iterable[Arg[Any]]:
def explode_negated_bool_args(
args: Sequence[FinalArg[Any]],
) -> Iterable[FinalArg[Any]]:
"""Expand `--foo/--no-foo` solo arguments into dual-arguments.

Acts as a transform from `Arg(long='--foo/--no-foo')` to
Expand All @@ -861,7 +888,7 @@ def explode_negated_bool_args(args: Sequence[Arg[Any]]) -> Iterable[Arg[Any]]:
for arg in args:
yielded = False
if isinstance(arg.action, ArgAction) and arg.action.is_bool_action and arg.long:
long = cast(List[str], arg.long)
long = arg.long

negatives = [item for item in long if "--no-" in item]
positives = [item for item in long if "--no-" not in item]
Expand All @@ -874,9 +901,9 @@ def explode_negated_bool_args(args: Sequence[Arg[Any]]) -> Iterable[Arg[Any]]:
default_is_false = arg.default.fallback_value is False and not_required
disabled: DefaultFormatter = DefaultFormatter.disabled()
group = dataclasses.replace(
cast(Group, arg.group),
arg.group,
exclusive=True,
id=cast(str, arg.field_name),
id=arg.field_name,
)

positive_arg = dataclasses.replace(
Expand Down
46 changes: 21 additions & 25 deletions src/cappa/argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

import argparse
import sys
from typing import TYPE_CHECKING, Any, Callable, Hashable, List, TypeVar, cast
from typing import TYPE_CHECKING, Any, Callable, Hashable, TypeVar, cast

from cappa.arg import Arg, ArgAction, Group, NumArgs
from cappa.command import Alias, Command, Subcommand
from cappa.arg import Arg, ArgAction, FinalArg, NumArgs
from cappa.command import Alias, Command, FinalCommand
from cappa.help import ArgGroup
from cappa.invoke.base import fulfill_deps
from cappa.output import Exit, HelpExit, Output
from cappa.parser import RawOption, Value
from cappa.typing import assert_type
from cappa.subcommand import FinalSubcommand

if TYPE_CHECKING:
from _typeshed import SupportsWrite
Expand Down Expand Up @@ -69,7 +69,7 @@ def __init__(self, metavar: str | None = None, **kwargs: Any):

class ArgumentParser(argparse.ArgumentParser):
def __init__(
self, *args: Any, command: Command[Any], output: Output, **kwargs: Any
self, *args: Any, command: FinalCommand[Any], output: Output, **kwargs: Any
):
super().__init__(*args, **kwargs)
self.command = command
Expand Down Expand Up @@ -141,7 +141,7 @@ def __setattr__(self, name: str, value: Any):


def backend(
command: Command[T],
command: FinalCommand[T],
argv: list[str],
output: Output,
prog: str,
Expand Down Expand Up @@ -173,7 +173,7 @@ def backend(


def create_parser(
command: Command[Any], output: Output, prog: str
command: FinalCommand[Any], output: Output, prog: str
) -> argparse.ArgumentParser:
kwargs: dict[str, Any] = {}
if sys.version_info >= (3, 9): # pragma: no cover
Expand All @@ -196,7 +196,7 @@ def create_parser(

def add_arguments(
parser: ArgumentParser,
command: Command[Any],
command: FinalCommand[Any],
output: Output,
dest_prefix: str = "",
):
Expand All @@ -209,13 +209,12 @@ def add_arguments(
for field_group in group.field_groups:
if field_group.args:
for arg in field_group.args:
arg_group = cast(Group, arg.group)
target_group: Any = argparse_group

if arg_group.exclusive:
target_group = exclusive_groups.get(arg_group.id)
if arg.group.exclusive:
target_group = exclusive_groups.get(arg.group.id)
if target_group is None:
target_group = exclusive_groups[arg_group.id] = (
target_group = exclusive_groups[arg.group.id] = (
argparse_group.add_mutually_exclusive_group()
)

Expand All @@ -234,7 +233,7 @@ def add_arguments(
def add_argument(
parser: ArgumentParser, # pyright: ignore
subparser: argparse.ArgumentParser | argparse._ArgumentGroup, # pyright: ignore
arg: Arg[Any],
arg: FinalArg[Any],
dest_prefix: str = "",
**extra_kwargs: Any,
):
Expand All @@ -243,36 +242,33 @@ def add_argument(

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

if arg.long:
long = cast(List[str], arg.long)
names.extend(long)
names.extend(arg.long)

is_positional = not names

normalized_num_args = assert_type(arg.num_args, NumArgs)
num_args = backend_num_args(normalized_num_args, assert_type(arg.required, bool))
num_args = backend_num_args(arg.num_args, arg.required)

kwargs: dict[str, Any] = {
"dest": dest_prefix + assert_type(arg.field_name, str),
"dest": dest_prefix + arg.field_name,
"help": arg.help,
"metavar": arg.value_name,
"action": get_action(arg),
"default": argparse.SUPPRESS,
}

if not is_positional and arg.required and normalized_num_args.n >= 0:
if not is_positional and arg.required and arg.num_args.n >= 0:
kwargs["required"] = arg.required

is_optional_value = not is_positional and not normalized_num_args.required
is_optional_value = not is_positional and not arg.num_args.required

if num_args is not None and not ArgAction.is_non_value_consuming(arg.action):
kwargs["nargs"] = num_args
elif is_optional_value:
kwargs["nargs"] = "?"
kwargs["const"] = normalized_num_args.default
kwargs["const"] = arg.num_args.default
elif is_positional and not arg.required:
kwargs["nargs"] = "?"

Expand All @@ -289,15 +285,15 @@ def add_argument(
def add_subcommands(
parser: argparse.ArgumentParser,
group: str,
subcommands: Subcommand,
subcommands: FinalSubcommand,
output: Output,
dest_prefix: str = "",
):
subcommand_dest = subcommands.field_name
visible_metavar = "{" + ",".join(subcommands.names()) + "}"
subparsers = parser.add_subparsers(
title=group,
required=assert_type(subcommands.required, bool),
required=subcommands.required,
parser_class=ArgumentParser,
metavar=visible_metavar,
)
Expand Down
Loading
Loading