Skip to content

Commit 27d69c9

Browse files
committed
refactor: Destructured implementation to be more like Arg handling to fix default and helptext behavior.
1 parent 07154bb commit 27d69c9

11 files changed

Lines changed: 387 additions & 174 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
- fix: Allow negative number arguments and option values.
1313
- fix: Precalculate implicit deps during class construction rather than traversing the output shape.
1414
- fix: Thread `output` into `parse_result.instance` resolution in `parse`/`parse_async`/`invoke`/`invoke_async`, so `cappa.Exit` raised from `Arg(parse=...)` callbacks renders an error message instead of silently exiting non-zero.
15+
- refactor: Destructured implementation to be more like Arg handling to fix default and helptext behavior.
1516

1617
## 0.31
1718

src/cappa/arg.py

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
Literal,
1515
Sequence,
1616
Set,
17+
TextIO,
1718
Union,
1819
cast,
1920
)
@@ -24,7 +25,14 @@
2425
from cappa.completion.completers import complete_choices
2526
from cappa.completion.types import Completion
2627
from cappa.default import Default, DefaultFormatter, ValueFrom
27-
from cappa.parse import Parser, evaluate_parse, parse_literal, parse_value
28+
from cappa.invoke.types import Resolved
29+
from cappa.parse import (
30+
Parser,
31+
evaluate_parse,
32+
parse_handler,
33+
parse_literal,
34+
parse_value,
35+
)
2836
from cappa.state import State
2937
from cappa.type_view import Empty, EmptyType, TypeView
3038
from cappa.typing import (
@@ -36,7 +44,7 @@
3644
)
3745

3846
if TYPE_CHECKING:
39-
from cappa.destructure import Destructure, destructure
47+
from cappa.destructure import Destructure
4048

4149

4250
@enum.unique
@@ -418,7 +426,7 @@ def collect(
418426
default_short: bool = False,
419427
default_long: bool = False,
420428
state: State[Any] | None = None,
421-
) -> list[FinalArg[Any]]:
429+
) -> list[FinalArg[Any] | FinalDestructure[Any]]:
422430
args: list[Arg[Any]] = find_annotations(type_view, cls) or [Arg()]
423431

424432
exclusive = field.name if len(args) > 1 else False
@@ -436,7 +444,7 @@ def collect(
436444
if field_metadata:
437445
args = field_metadata
438446

439-
result: list[FinalArg[Any]] = []
447+
result: list[FinalArg[Any] | FinalDestructure[Any]] = []
440448
for i, arg in enumerate(args, start=1):
441449
# field_name and default are evaluated outside `normalize` because they can
442450
# potentially depend on the type's dataclass-like field information, whereas
@@ -458,8 +466,7 @@ def collect(
458466
)
459467

460468
if normalized_arg.destructure:
461-
destructured_args = destructure(normalized_arg, type_view)
462-
result.extend(destructured_args)
469+
result.extend(normalized_arg.destructure.explode_args())
463470
else:
464471
result.append(normalized_arg)
465472

@@ -514,12 +521,9 @@ def normalize(
514521
show_default if show_default is not None else self.show_default
515522
)
516523

517-
destructure = destructure or self.destructure
518-
if not destructure:
519-
destructure = None
520-
if destructure is True:
521-
destructure = Destructure()
522-
524+
destructure = Destructure.collect(
525+
field_name, default, destructure or self.destructure, type_view
526+
)
523527
result: FinalArg[Any] = FinalArg(
524528
# preserved from self
525529
count=self.count,
@@ -578,7 +582,22 @@ class FinalArg(Arg[T]):
578582
type_view: TypeView[Any] = dataclasses.field(default_factory=lambda: TypeView(Any))
579583
parse: Callable[..., Any] = dataclasses.field(default=parse_value)
580584
show_default: DefaultFormatter = dataclasses.field(default_factory=DefaultFormatter)
581-
destructure: Destructure | None = None
585+
destructure: FinalDestructure[Any] | None = None
586+
587+
def map_result(
588+
self,
589+
prog: str,
590+
parsed_args: dict[str, Any],
591+
state: State[Any] | None = None,
592+
input: TextIO | None = None,
593+
) -> Resolved[T]:
594+
field_name = self.field_name
595+
if field_name in parsed_args:
596+
is_parsed, value = False, parsed_args[field_name]
597+
else:
598+
is_parsed, value = self.default(state=state, input=input)
599+
handler = parse_handler(self.parse, prog, value, self.names_str())
600+
return Resolved(handler, args=(value, is_parsed))
582601

583602
def names(self, *, n: int = 0) -> list[str]:
584603
result = (self.short or []) + (self.long or [])
@@ -878,14 +897,19 @@ def infer_value_name(arg: Arg[Any], field_name: str, num_args: NumArgs) -> str:
878897

879898

880899
def explode_negated_bool_args(
881-
args: Sequence[FinalArg[Any]],
882-
) -> Iterable[FinalArg[Any]]:
900+
args: Sequence[FinalArg[Any] | FinalDestructure[Any]],
901+
) -> Iterable[FinalArg[Any] | FinalDestructure[Any]]:
883902
"""Expand `--foo/--no-foo` solo arguments into dual-arguments.
884903
885904
Acts as a transform from `Arg(long='--foo/--no-foo')` to
886905
`Annotated[Arg(long='--foo', action=ArgAction.store_true), Arg(long='--no-foo', action=ArgAction.store_false)]`
887906
"""
888907
for arg in args:
908+
# Only args can *be* bool negatable args.
909+
if not isinstance(arg, FinalArg):
910+
yield arg
911+
continue
912+
889913
yielded = False
890914
if isinstance(arg.action, ArgAction) and arg.action.is_bool_action and arg.long:
891915
long = arg.long
@@ -940,4 +964,4 @@ def infer_has_value(arg: Arg[Any], action: ArgActionType):
940964
return True
941965

942966

943-
from cappa.destructure import Destructure, destructure # noqa: E402
967+
from cappa.destructure import Destructure, FinalDestructure # noqa: E402

src/cappa/command.py

Lines changed: 35 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import contextlib
44
import dataclasses
5-
import inspect
65
import sys
76
from collections.abc import Callable
87
from typing import (
@@ -26,7 +25,6 @@
2625
from cappa.arg import Arg, FinalArg, Group
2726
from cappa.class_inspect import fields as get_fields
2827
from cappa.class_inspect import get_command, get_command_capable_object
29-
from cappa.default import Default
3028
from cappa.docstring import ClassHelpText
3129
from cappa.help import HelpFormattable, HelpFormatter
3230
from cappa.invoke.types import Resolved
@@ -115,8 +113,8 @@ class Command(Generic[T]):
115113
"""
116114

117115
cmd_cls: type[T]
118-
arguments: Sequence[Arg[Any] | Subcommand] = dataclasses.field(
119-
default_factory=lambda: []
116+
arguments: Sequence[Arg[Any] | Subcommand | FinalDestructure[Any]] = (
117+
dataclasses.field(default_factory=lambda: [])
120118
)
121119
propagated_arguments: list[FinalArg[Any]] = dataclasses.field(
122120
default_factory=lambda: []
@@ -192,7 +190,7 @@ def collect(
192190

193191
propagated_arguments = propagated_arguments or []
194192

195-
arguments: list[FinalArg[Any]] = []
193+
arguments: list[FinalArg[Any] | FinalDestructure[Any]] = []
196194
raw_subcommands: list[tuple[Subcommand, TypeView[Any] | None, str | None]] = []
197195
if self.arguments:
198196
param_by_name = {p.name: p for p in function_view.parameters}
@@ -213,6 +211,8 @@ def collect(
213211
state=state,
214212
)
215213
)
214+
elif isinstance(arg, FinalDestructure):
215+
arguments.append(arg)
216216
else:
217217
raw_subcommands.append((arg, None, None))
218218

@@ -233,7 +233,7 @@ def collect(
233233
)
234234
)
235235
else:
236-
arg_defs: list[FinalArg[Any]] = Arg.collect(
236+
arg_defs = Arg.collect(
237237
field,
238238
param_view.type_view,
239239
fallback_help=arg_help,
@@ -245,7 +245,7 @@ def collect(
245245

246246
propagating_arguments = [
247247
*propagated_arguments,
248-
*(arg for arg in arguments if arg.propagate),
248+
*(arg for arg in arguments if isinstance(arg, FinalArg) and arg.propagate),
249249
]
250250
subcommands = [
251251
subcommand.normalize(
@@ -258,8 +258,10 @@ def collect(
258258
for subcommand, type_view, field_name in raw_subcommands
259259
]
260260

261-
check_group_identity(arguments)
262-
final_arguments: list[FinalArg[Any] | FinalSubcommand] = [
261+
check_group_identity([a for a in arguments if isinstance(a, FinalArg)])
262+
final_arguments: list[
263+
FinalArg[Any] | FinalSubcommand | FinalDestructure[Any]
264+
] = [
263265
*arguments,
264266
*subcommands,
265267
]
@@ -290,8 +292,8 @@ class FinalCommand(Command[T]):
290292
Produced exclusively by :meth:`Command.collect`.
291293
"""
292294

293-
arguments: Sequence[FinalArg[Any] | FinalSubcommand] = dataclasses.field( # pyright: ignore
294-
default_factory=list
295+
arguments: Sequence[FinalArg[Any] | FinalSubcommand | FinalDestructure[Any]] = ( # pyright: ignore
296+
dataclasses.field(default_factory=list)
295297
)
296298
propagated_arguments: list[FinalArg[Any]] = dataclasses.field( # pyright: ignore
297299
default_factory=list
@@ -310,10 +312,17 @@ def value_arguments(self) -> Iterable[FinalArg[Any]]:
310312
if isinstance(arg, FinalArg) and arg.has_value:
311313
yield arg
312314

315+
@property
316+
def destructured_arguments(self) -> Iterable[FinalDestructure[Any]]:
317+
for arg in self.arguments:
318+
if isinstance(arg, FinalDestructure):
319+
yield arg
320+
313321
@property
314322
def all_arguments(self) -> Iterable[FinalArg[Any] | FinalSubcommand]:
315323
for arg in self.arguments:
316-
yield arg
324+
if not isinstance(arg, FinalDestructure):
325+
yield arg
317326
for arg in self.propagated_arguments:
318327
yield arg
319328

@@ -329,10 +338,7 @@ def positional_arguments(
329338
) -> Iterable[FinalArg[Any] | FinalSubcommand]:
330339
for arg in self.arguments:
331340
if (
332-
isinstance(arg, FinalArg)
333-
and not arg.short
334-
and not arg.long
335-
and not arg.destructure
341+
isinstance(arg, FinalArg) and not arg.short and not arg.long
336342
) or isinstance(arg, FinalSubcommand):
337343
yield arg
338344

@@ -379,18 +385,18 @@ def map_result(
379385

380386
kwargs: dict[str, Any] = {}
381387
for arg in self.value_arguments:
382-
field_name = arg.field_name
383-
384-
if field_name in parsed_args:
385-
is_parsed, value = (False, parsed_args[field_name])
386-
else:
387-
assert isinstance(arg.default, Default), arg
388-
is_parsed, value = arg.default(state=state, input=input)
389-
390-
handler = parse_handler(arg, prog, value)
391-
kwargs[field_name] = Resolved(handler, args=(value, is_parsed))
388+
kwargs[arg.field_name] = arg.map_result(
389+
prog, parsed_args, state=state, input=input
390+
)
392391

393392
subcommand_deps: dict[Hashable, Any] = {}
393+
for destructure in self.destructured_arguments:
394+
fd_parsed = parsed_args.get(destructure.field_name, {})
395+
fd_resolved = destructure.map_result(
396+
prog, fd_parsed, output, state=state, input=input
397+
)
398+
kwargs[destructure.field_name] = fd_resolved
399+
394400
subcommand = self.subcommand
395401
if subcommand:
396402
field_name = subcommand.field_name
@@ -464,62 +470,6 @@ def check_group_identity(args: list[FinalArg[Any]]):
464470
group_identity[arg.group.id] = arg.group
465471

466472

467-
@contextlib.contextmanager
468-
def parse_value(
469-
arg: FinalArg[T], prog: str, value: Any, is_parsed: bool
470-
) -> Generator[T, None, None]:
471-
if not is_parsed:
472-
try:
473-
value = arg.parse(value)
474-
except Exception as e:
475-
exception_reason = str(e)
476-
raise Exit(
477-
f"Invalid value for '{arg.names_str()}': {exception_reason}",
478-
code=2,
479-
prog=prog,
480-
)
481-
482-
yield value
483-
484-
485-
def parse_handler(
486-
arg: FinalArg[T], prog: str, value: Any
487-
) -> Callable[[Any, bool], Any]:
488-
is_async_value = inspect.iscoroutine(value)
489-
is_async_parse = inspect.iscoroutinefunction(
490-
arg.parse
491-
) or inspect.isasyncgenfunction(arg.parse)
492-
493-
if is_async_value or is_async_parse:
494-
495-
async def async_parse(raw_value: Any, is_parsed: bool) -> T:
496-
# Await the value if it's a coroutine
497-
if inspect.iscoroutine(raw_value):
498-
raw_value = await raw_value
499-
500-
with parse_value(arg, prog, raw_value, is_parsed) as parsed:
501-
# Check if the parse result is a coroutine and await it
502-
if inspect.iscoroutine(parsed):
503-
try:
504-
return await parsed
505-
except Exception as e:
506-
exception_reason = str(e)
507-
raise Exit(
508-
f"Invalid value for '{arg.names_str()}': {exception_reason}",
509-
code=2,
510-
prog=prog,
511-
)
512-
return parsed
513-
514-
return async_parse
515-
516-
def sync_parse(raw_value: Any, is_parsed: bool) -> T:
517-
with parse_value(arg, prog, raw_value, is_parsed) as parsed:
518-
return parsed
519-
520-
return sync_parse
521-
522-
523473
@contextlib.contextmanager
524474
def graceful_exit(
525475
command: FinalCommand[T], prog: str, output: Output
@@ -544,3 +494,6 @@ def graceful_exit(
544494
raise
545495

546496
raise
497+
498+
499+
from cappa.destructure import FinalDestructure # noqa: E402

src/cappa/default.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ def __or__(self, other: Any) -> Default:
115115

116116
def __call__(
117117
self, state: State[Any] | None = None, input: TextIO | None = None
118-
) -> tuple[bool, Any | None]:
118+
) -> tuple[bool, Any]:
119119
"""Evaluate the default retrieval sequence, returning the first non-Empty value."""
120120
for default in self.sequence:
121121
if isinstance(default, ValueFrom):

0 commit comments

Comments
 (0)