Skip to content

Commit c99f51b

Browse files
authored
Merge pull request #263 from DanCardin/dc/final
refactor: Add "final" variants of Arg/Command/Subcommand.
2 parents 7d40324 + 3de18c4 commit c99f51b

15 files changed

Lines changed: 403 additions & 342 deletions

File tree

src/cappa/__init__.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from cappa.base import collect, command, invoke, invoke_async, parse, parse_async
2-
from cappa.command import Alias, Command
2+
from cappa.command import Alias, Command, FinalCommand
33
from cappa.completion.types import Completion
44
from cappa.default import Confirm, Default, Env, Prompt, ValueFrom
55
from cappa.file_io import FileMode
@@ -8,11 +8,11 @@
88
from cappa.output import Exit, HelpExit, Output
99
from cappa.parse import default_parse, unpack_arguments
1010
from cappa.state import State
11-
from cappa.subcommand import Subcommand, Subcommands
11+
from cappa.subcommand import FinalSubcommand, Subcommand, Subcommands
1212
from cappa.type_view import Empty, EmptyType
1313

1414
# isort: split
15-
from cappa.arg import Arg, ArgAction, Group, NumArgs
15+
from cappa.arg import Arg, ArgAction, FinalArg, Group, NumArgs
1616
from cappa.destructure import Destructured
1717

1818
# isort: split
@@ -34,6 +34,9 @@
3434
"Env",
3535
"Exit",
3636
"FileMode",
37+
"FinalArg",
38+
"FinalCommand",
39+
"FinalSubcommand",
3740
"Group",
3841
"HelpExit",
3942
"HelpFormattable",

src/cappa/arg.py

Lines changed: 59 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
Callable,
1212
Generic,
1313
Iterable,
14-
List,
1514
Literal,
1615
Sequence,
1716
Set,
@@ -32,7 +31,6 @@
3231
Doc,
3332
DocType,
3433
T,
35-
assert_type,
3634
detect_choices,
3735
find_annotations,
3836
)
@@ -388,7 +386,7 @@ def __hash__(self):
388386
short: bool | str | list[str] | None = False
389387
long: bool | str | list[str] | None = False
390388
count: bool = False
391-
default: T | EmptyType | None = Empty
389+
default: Default | T | EmptyType | None = Empty
392390
help: str | None = None
393391
parse: Callable[..., T] | Sequence[Callable[..., Any]] | None = None
394392
parse_inference: bool = True
@@ -420,7 +418,7 @@ def collect(
420418
default_short: bool = False,
421419
default_long: bool = False,
422420
state: State[Any] | None = None,
423-
) -> list[Arg[Any]]:
421+
) -> list[FinalArg[Any]]:
424422
args: list[Arg[Any]] = find_annotations(type_view, cls) or [Arg()]
425423

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

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

@@ -517,28 +515,36 @@ def normalize(
517515
)
518516

519517
destructure = destructure or self.destructure
518+
if not destructure:
519+
destructure = None
520520
if destructure is True:
521521
destructure = Destructure()
522522

523-
result = dataclasses.replace(
524-
self,
525-
default=default,
526-
field_name=field_name,
523+
result: FinalArg[Any] = FinalArg(
524+
# preserved from self
525+
count=self.count,
526+
hidden=self.hidden,
527+
deprecated=self.deprecated,
528+
propagate=self.propagate,
529+
parse_inference=self.parse_inference,
530+
# computed/narrowed
527531
value_name=value_name,
528-
required=required,
529532
short=short,
530533
long=long,
531-
choices=choices,
534+
default=default,
535+
help=help,
536+
parse=parse,
537+
group=group,
532538
action=action,
533539
num_args=num_args,
534-
parse=parse,
535-
help=help,
540+
choices=choices,
536541
completion=completion,
537-
group=group,
538-
has_value=has_value,
539-
type_view=type_view,
542+
required=required,
543+
field_name=field_name,
540544
show_default=default_formatter,
541545
destructure=destructure,
546+
has_value=has_value,
547+
type_view=type_view,
542548
)
543549
verify_type_compatibility(
544550
result, field_name, type_view, has_custom_parse=bool(self.parse)
@@ -550,27 +556,46 @@ def destructured(cls):
550556
"""Mark a an argument as destructured. See also the shorter `Destructured` alias."""
551557
return cls(destructure=Destructure())
552558

559+
560+
@dataclasses.dataclass(frozen=True)
561+
class FinalArg(Arg[T]):
562+
"""Post-normalization form of :class:`Arg` with narrowed field types.
563+
564+
Produced exclusively by :meth:`Arg.normalize`. After normalization all
565+
previously-optional/Empty fields are guaranteed to be concrete values.
566+
"""
567+
568+
value_name: str = ""
569+
short: list[str] | Literal[False] = False
570+
long: list[str] | Literal[False] = False
571+
default: Default = dataclasses.field(default_factory=Default)
572+
group: Group = dataclasses.field(default_factory=Group)
573+
action: ArgActionType = ArgAction.set
574+
num_args: NumArgs = dataclasses.field(default_factory=NumArgs)
575+
required: bool = False
576+
field_name: str = ""
577+
has_value: bool = True
578+
type_view: TypeView[Any] = dataclasses.field(default_factory=lambda: TypeView(Any))
579+
parse: Callable[..., Any] = dataclasses.field(default=parse_value)
580+
show_default: DefaultFormatter = dataclasses.field(default_factory=DefaultFormatter)
581+
destructure: Destructure | None = None
582+
553583
def names(self, *, n: int = 0) -> list[str]:
554-
short_names = cast(List[str], self.short or [])
555-
long_names = cast(List[str], self.long or [])
556-
result = short_names + long_names
557-
if n:
558-
return result[:n]
559-
return result
584+
result = (self.short or []) + (self.long or [])
585+
return result[:n] if n else result
560586

561587
def names_str(self, delimiter: str = ", ", *, n: int = 0) -> str:
562-
if self.long or self.short:
588+
if self.short or self.long:
563589
return delimiter.join(self.names(n=n))
564-
565-
return cast(str, self.value_name)
590+
return self.value_name
566591

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

571596

572597
def verify_type_compatibility(
573-
arg: Arg[Any],
598+
arg: FinalArg[Any],
574599
field_name: str,
575600
type_view: TypeView[Any],
576601
*,
@@ -591,7 +616,7 @@ def verify_type_compatibility(
591616
if has_custom_parse or ArgAction.is_custom(action):
592617
return
593618

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

854879

855-
def explode_negated_bool_args(args: Sequence[Arg[Any]]) -> Iterable[Arg[Any]]:
880+
def explode_negated_bool_args(
881+
args: Sequence[FinalArg[Any]],
882+
) -> Iterable[FinalArg[Any]]:
856883
"""Expand `--foo/--no-foo` solo arguments into dual-arguments.
857884
858885
Acts as a transform from `Arg(long='--foo/--no-foo')` to
@@ -861,7 +888,7 @@ def explode_negated_bool_args(args: Sequence[Arg[Any]]) -> Iterable[Arg[Any]]:
861888
for arg in args:
862889
yielded = False
863890
if isinstance(arg.action, ArgAction) and arg.action.is_bool_action and arg.long:
864-
long = cast(List[str], arg.long)
891+
long = arg.long
865892

866893
negatives = [item for item in long if "--no-" in item]
867894
positives = [item for item in long if "--no-" not in item]
@@ -874,9 +901,9 @@ def explode_negated_bool_args(args: Sequence[Arg[Any]]) -> Iterable[Arg[Any]]:
874901
default_is_false = arg.default.fallback_value is False and not_required
875902
disabled: DefaultFormatter = DefaultFormatter.disabled()
876903
group = dataclasses.replace(
877-
cast(Group, arg.group),
904+
arg.group,
878905
exclusive=True,
879-
id=cast(str, arg.field_name),
906+
id=arg.field_name,
880907
)
881908

882909
positive_arg = dataclasses.replace(

src/cappa/argparse.py

Lines changed: 21 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22

33
import argparse
44
import sys
5-
from typing import TYPE_CHECKING, Any, Callable, Hashable, List, TypeVar, cast
5+
from typing import TYPE_CHECKING, Any, Callable, Hashable, TypeVar, cast
66

7-
from cappa.arg import Arg, ArgAction, Group, NumArgs
8-
from cappa.command import Alias, Command, Subcommand
7+
from cappa.arg import Arg, ArgAction, FinalArg, NumArgs
8+
from cappa.command import Alias, Command, FinalCommand
99
from cappa.help import ArgGroup
1010
from cappa.invoke.base import fulfill_deps
1111
from cappa.output import Exit, HelpExit, Output
1212
from cappa.parser import RawOption, Value
13-
from cappa.typing import assert_type
13+
from cappa.subcommand import FinalSubcommand
1414

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

7070
class ArgumentParser(argparse.ArgumentParser):
7171
def __init__(
72-
self, *args: Any, command: Command[Any], output: Output, **kwargs: Any
72+
self, *args: Any, command: FinalCommand[Any], output: Output, **kwargs: Any
7373
):
7474
super().__init__(*args, **kwargs)
7575
self.command = command
@@ -141,7 +141,7 @@ def __setattr__(self, name: str, value: Any):
141141

142142

143143
def backend(
144-
command: Command[T],
144+
command: FinalCommand[T],
145145
argv: list[str],
146146
output: Output,
147147
prog: str,
@@ -173,7 +173,7 @@ def backend(
173173

174174

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

197197
def add_arguments(
198198
parser: ArgumentParser,
199-
command: Command[Any],
199+
command: FinalCommand[Any],
200200
output: Output,
201201
dest_prefix: str = "",
202202
):
@@ -209,13 +209,12 @@ def add_arguments(
209209
for field_group in group.field_groups:
210210
if field_group.args:
211211
for arg in field_group.args:
212-
arg_group = cast(Group, arg.group)
213212
target_group: Any = argparse_group
214213

215-
if arg_group.exclusive:
216-
target_group = exclusive_groups.get(arg_group.id)
214+
if arg.group.exclusive:
215+
target_group = exclusive_groups.get(arg.group.id)
217216
if target_group is None:
218-
target_group = exclusive_groups[arg_group.id] = (
217+
target_group = exclusive_groups[arg.group.id] = (
219218
argparse_group.add_mutually_exclusive_group()
220219
)
221220

@@ -234,7 +233,7 @@ def add_arguments(
234233
def add_argument(
235234
parser: ArgumentParser, # pyright: ignore
236235
subparser: argparse.ArgumentParser | argparse._ArgumentGroup, # pyright: ignore
237-
arg: Arg[Any],
236+
arg: FinalArg[Any],
238237
dest_prefix: str = "",
239238
**extra_kwargs: Any,
240239
):
@@ -243,36 +242,33 @@ def add_argument(
243242

244243
names: list[str] = []
245244
if arg.short:
246-
short = cast(List[str], arg.short)
247-
names.extend(short)
245+
names.extend(arg.short)
248246

249247
if arg.long:
250-
long = cast(List[str], arg.long)
251-
names.extend(long)
248+
names.extend(arg.long)
252249

253250
is_positional = not names
254251

255-
normalized_num_args = assert_type(arg.num_args, NumArgs)
256-
num_args = backend_num_args(normalized_num_args, assert_type(arg.required, bool))
252+
num_args = backend_num_args(arg.num_args, arg.required)
257253

258254
kwargs: dict[str, Any] = {
259-
"dest": dest_prefix + assert_type(arg.field_name, str),
255+
"dest": dest_prefix + arg.field_name,
260256
"help": arg.help,
261257
"metavar": arg.value_name,
262258
"action": get_action(arg),
263259
"default": argparse.SUPPRESS,
264260
}
265261

266-
if not is_positional and arg.required and normalized_num_args.n >= 0:
262+
if not is_positional and arg.required and arg.num_args.n >= 0:
267263
kwargs["required"] = arg.required
268264

269-
is_optional_value = not is_positional and not normalized_num_args.required
265+
is_optional_value = not is_positional and not arg.num_args.required
270266

271267
if num_args is not None and not ArgAction.is_non_value_consuming(arg.action):
272268
kwargs["nargs"] = num_args
273269
elif is_optional_value:
274270
kwargs["nargs"] = "?"
275-
kwargs["const"] = normalized_num_args.default
271+
kwargs["const"] = arg.num_args.default
276272
elif is_positional and not arg.required:
277273
kwargs["nargs"] = "?"
278274

@@ -289,15 +285,15 @@ def add_argument(
289285
def add_subcommands(
290286
parser: argparse.ArgumentParser,
291287
group: str,
292-
subcommands: Subcommand,
288+
subcommands: FinalSubcommand,
293289
output: Output,
294290
dest_prefix: str = "",
295291
):
296292
subcommand_dest = subcommands.field_name
297293
visible_metavar = "{" + ",".join(subcommands.names()) + "}"
298294
subparsers = parser.add_subparsers(
299295
title=group,
300-
required=assert_type(subcommands.required, bool),
296+
required=subcommands.required,
301297
parser_class=ArgumentParser,
302298
metavar=visible_metavar,
303299
)

0 commit comments

Comments
 (0)