Skip to content

Commit 9dab17c

Browse files
committed
feat: add code-registered parser plugins
1 parent dbcf10d commit 9dab17c

12 files changed

Lines changed: 1334 additions & 62 deletions

File tree

interfacy/argparse_backend/argparser.py

Lines changed: 141 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from interfacy.argparse_backend.argument_parser import (
2020
ArgumentParser,
2121
NestedSubParsersAction,
22+
_ArgparseParseError,
2223
namespace_to_dict,
2324
)
2425
from interfacy.argparse_backend.help_formatter import InterfacyHelpFormatter
@@ -41,6 +42,7 @@
4142
from interfacy.naming import AbbreviationGenerator, FlagStrategy
4243
from interfacy.naming.flag_strategy import FlagAllocationState, get_arg_flags_for_parameter
4344
from interfacy.pipe import PipeTargets
45+
from interfacy.plugins import InterfacyPlugin
4446
from interfacy.schema.schema import Argument, ArgumentKind, Command, ParserSchema, ValueShape
4547
from interfacy.util import (
4648
extract_optional_union_list,
@@ -152,6 +154,7 @@ def __init__(
152154
reraise_interrupt: bool = False,
153155
expand_model_params: bool = True,
154156
model_expansion_max_depth: int = 3,
157+
plugins: Sequence[InterfacyPlugin] | None = None,
155158
formatter_class: type[argparse.HelpFormatter] = InterfacyHelpFormatter,
156159
) -> None:
157160
super().__init__(
@@ -183,6 +186,7 @@ def __init__(
183186
reraise_interrupt=reraise_interrupt,
184187
expand_model_params=expand_model_params,
185188
model_expansion_max_depth=model_expansion_max_depth,
189+
plugins=plugins,
186190
)
187191
self.formatter_class = formatter_class
188192
self._parser: ArgumentParser | None = None
@@ -477,8 +481,36 @@ def _argument_kwargs(self, arg: Argument) -> dict[str, Any]:
477481

478482
return kwargs
479483

480-
def _add_argument_from_schema(self, parser: ArgumentParser, argument: Argument) -> None:
481-
kwargs = self._argument_kwargs(argument)
484+
def _argument_kwargs_for_relaxed_parse(self, arg: Argument) -> dict[str, Any]:
485+
kwargs = self._argument_kwargs(arg)
486+
if arg.kind == ArgumentKind.OPTION:
487+
kwargs["required"] = False
488+
return kwargs
489+
490+
if arg.value_shape == ValueShape.LIST:
491+
kwargs["nargs"] = "*"
492+
return kwargs
493+
494+
if arg.value_shape == ValueShape.TUPLE and isinstance(arg.nargs, int):
495+
kwargs["nargs"] = "*"
496+
return kwargs
497+
498+
if arg.required:
499+
kwargs["nargs"] = "?"
500+
return kwargs
501+
502+
def _add_argument_from_schema(
503+
self,
504+
parser: ArgumentParser,
505+
argument: Argument,
506+
*,
507+
relaxed_parse: bool = False,
508+
) -> None:
509+
kwargs = (
510+
self._argument_kwargs_for_relaxed_parse(argument)
511+
if relaxed_parse
512+
else self._argument_kwargs(argument)
513+
)
482514
add_flags = argument.flags
483515
if kwargs.get("action") is argparse.BooleanOptionalAction:
484516
add_flags = self._normalize_boolean_optional_flags(argument.flags)
@@ -603,10 +635,11 @@ def _add_command_arguments(
603635
command: Command,
604636
*,
605637
extra_executable_flags: Sequence[ExecutableFlag] = (),
638+
relaxed_parse: bool = False,
606639
) -> None:
607640
all_arguments = [*command.initializer, *command.parameters]
608641
for argument in [arg for arg in all_arguments if arg.kind == ArgumentKind.POSITIONAL]:
609-
self._add_argument_from_schema(parser, argument)
642+
self._add_argument_from_schema(parser, argument, relaxed_parse=relaxed_parse)
610643
for entry in self._ordered_option_entries_for_help(
611644
all_arguments,
612645
[*extra_executable_flags, *command.executable_flags],
@@ -615,18 +648,19 @@ def _add_command_arguments(
615648
if isinstance(entry, ExecutableFlag):
616649
self._add_executable_flag(parser, entry)
617650
continue
618-
self._add_argument_from_schema(parser, entry)
651+
self._add_argument_from_schema(parser, entry, relaxed_parse=relaxed_parse)
619652

620653
def _create_command_subparsers(
621654
self,
622655
parser: ArgumentParser,
623656
*,
624657
depth: int,
625658
has_custom_epilog: bool,
659+
relaxed_parse: bool = False,
626660
) -> NestedSubParsersAction:
627661
return parser.add_subparsers(
628662
dest=self._command_dest(depth),
629-
required=True,
663+
required=not relaxed_parse,
630664
title=self._subparsers_title(),
631665
help=(
632666
argparse.SUPPRESS
@@ -642,6 +676,7 @@ def _add_subcommands(
642676
*,
643677
depth: int,
644678
include_aliases: bool,
679+
relaxed_parse: bool = False,
645680
) -> None:
646681
if not command.subcommands:
647682
return
@@ -656,7 +691,12 @@ def _add_subcommands(
656691
if include_aliases:
657692
parser_kwargs["aliases"] = list(sub_cmd.aliases) if sub_cmd.aliases else []
658693
subparser = subparsers.add_parser(sub_cmd.cli_name, **parser_kwargs)
659-
self._apply_command_schema(subparser, sub_cmd, depth=depth + 1)
694+
self._apply_command_schema(
695+
subparser,
696+
sub_cmd,
697+
depth=depth + 1,
698+
relaxed_parse=relaxed_parse,
699+
)
660700

661701
def _apply_schema_for_subcommands(
662702
self,
@@ -666,24 +706,28 @@ def _apply_schema_for_subcommands(
666706
depth: int,
667707
include_aliases: bool,
668708
executable_flags: Sequence[ExecutableFlag] = (),
709+
relaxed_parse: bool = False,
669710
) -> None:
670711
self._add_command_arguments(
671712
parser,
672713
command,
673714
extra_executable_flags=executable_flags,
715+
relaxed_parse=relaxed_parse,
674716
)
675717
if self._should_set_epilog(command.epilog):
676718
parser.epilog = command.epilog
677719
subparsers = self._create_command_subparsers(
678720
parser,
679721
depth=depth,
680722
has_custom_epilog=bool(command.epilog),
723+
relaxed_parse=relaxed_parse,
681724
)
682725
self._add_subcommands(
683726
subparsers,
684727
command,
685728
depth=depth,
686729
include_aliases=include_aliases,
730+
relaxed_parse=relaxed_parse,
687731
)
688732
self._set_subparsers_metavar(subparsers)
689733

@@ -694,6 +738,7 @@ def _apply_command_schema(
694738
*,
695739
depth: int = 0,
696740
extra_executable_flags: Sequence[ExecutableFlag] = (),
741+
relaxed_parse: bool = False,
697742
) -> None:
698743
parser.set_schema_command(command)
699744

@@ -707,6 +752,7 @@ def _apply_command_schema(
707752
depth=depth,
708753
include_aliases=True,
709754
executable_flags=extra_executable_flags,
755+
relaxed_parse=relaxed_parse,
710756
)
711757
return
712758

@@ -717,28 +763,39 @@ def _apply_command_schema(
717763
depth=depth,
718764
include_aliases=False,
719765
executable_flags=extra_executable_flags,
766+
relaxed_parse=relaxed_parse,
720767
)
721768
return
722769

723770
self._add_command_arguments(
724771
parser,
725772
command,
726773
extra_executable_flags=extra_executable_flags,
774+
relaxed_parse=relaxed_parse,
727775
)
728776

729-
def _build_from_schema(self, schema: ParserSchema) -> ArgumentParser:
777+
def _build_from_schema(
778+
self,
779+
schema: ParserSchema,
780+
*,
781+
relaxed_parse: bool = False,
782+
) -> ArgumentParser:
730783
parser = self._new_parser()
731784
parser.set_schema(schema)
732785

733786
single_cmd = next(iter(schema.commands.values())) if len(schema.commands) == 1 else None
734-
single_group = single_cmd if single_cmd and not single_cmd.is_leaf else None
787+
single_group = (
788+
single_cmd
789+
if single_cmd and not single_cmd.is_leaf and single_cmd.command_type == "group"
790+
else None
791+
)
735792

736793
if schema.is_multi_command or single_group:
737794
for executable_flag in schema.executable_flags:
738795
self._add_executable_flag(parser, executable_flag)
739796
subparsers = parser.add_subparsers(
740797
dest=self.COMMAND_KEY,
741-
required=True,
798+
required=not relaxed_parse,
742799
title=self._subparsers_title(),
743800
help=(
744801
argparse.SUPPRESS
@@ -753,7 +810,7 @@ def _build_from_schema(self, schema: ParserSchema) -> ArgumentParser:
753810
help=self._escape_argparse_help_text(cmd.description),
754811
aliases=list(cmd.aliases),
755812
)
756-
self._apply_command_schema(subparser, cmd)
813+
self._apply_command_schema(subparser, cmd, relaxed_parse=relaxed_parse)
757814
self._set_subparsers_metavar(subparsers)
758815

759816
if schema.commands_help and not (
@@ -763,10 +820,12 @@ def _build_from_schema(self, schema: ParserSchema) -> ArgumentParser:
763820
parser.epilog = schema.commands_help
764821
else:
765822
cmd = next(iter(schema.commands.values()))
823+
parser.set_schema(None)
766824
self._apply_command_schema(
767825
parser,
768826
cmd,
769827
extra_executable_flags=schema.executable_flags,
828+
relaxed_parse=relaxed_parse,
770829
)
771830

772831
if cmd.epilog and not parser.epilog:
@@ -825,10 +884,57 @@ def _restore_backend_registration_state(self, snapshot: object | None) -> None:
825884
self._parser = snapshot.get("parser")
826885
self._last_schema = snapshot.get("last_schema")
827886

887+
def _invalidate_backend_build_cache(self) -> None:
888+
self._parser = None
889+
self._last_schema = None
890+
828891
def get_last_schema(self) -> ParserSchema | None:
829892
"""Return the most recently built parser schema for this backend."""
830893
return self._last_schema
831894

895+
def _normalize_parsed_namespace(self, namespace: dict[str, Any]) -> dict[str, Any]:
896+
if self.COMMAND_KEY in namespace:
897+
cli_name = namespace[self.COMMAND_KEY]
898+
canonical = self.name_registry.canonical_for(cli_name) or cli_name
899+
namespace[self.COMMAND_KEY] = canonical
900+
901+
if canonical in namespace:
902+
pass
903+
elif cli_name in namespace:
904+
namespace[canonical] = namespace.pop(cli_name)
905+
else:
906+
namespace[canonical] = {}
907+
908+
self._normalize_subcommand_buckets(namespace)
909+
self._prune_none_buckets(namespace)
910+
return self._convert_tuple_args(namespace)
911+
912+
def _prune_none_buckets(self, namespace: dict[str, Any]) -> None:
913+
namespace.pop(None, None)
914+
for value in namespace.values():
915+
if isinstance(value, dict):
916+
self._prune_none_buckets(value)
917+
918+
def _parse_partial_namespace_for_recovery(
919+
self,
920+
schema: ParserSchema,
921+
args: list[str],
922+
) -> dict[str, Any] | None:
923+
parser = self._build_from_schema(schema, relaxed_parse=True)
924+
parser._interfacy_raise_parse_errors = True
925+
try:
926+
parsed, unknown = parser.parse_known_args(args)
927+
except (_ArgparseParseError, argparse.ArgumentError, ValueError):
928+
return None
929+
except _ExecutableFlagTriggeredError:
930+
return None
931+
932+
if unknown:
933+
return None
934+
935+
namespace = namespace_to_dict(parsed)
936+
return self._normalize_parsed_namespace(namespace)
937+
832938
def _normalize_subcommand_buckets(self, namespace: dict[str, Any]) -> bool:
833939
removable = True
834940
for value in namespace.values():
@@ -861,28 +967,37 @@ def parse_args(self, args: list[str] | None = None) -> dict[str, Any]:
861967
args = args if args is not None else self.get_args()
862968
parser = self.build_parser()
863969
self._parser = parser
970+
parser._interfacy_raise_parse_errors = bool(self.plugins)
864971
try:
865972
parsed = parser.parse_args(args)
973+
except _ArgparseParseError as exc:
974+
if not self.plugins or self._last_schema is None:
975+
parser._interfacy_raise_parse_errors = False
976+
parser.error(str(exc))
977+
raise AssertionError("unreachable") from exc
978+
979+
namespace = self._parse_partial_namespace_for_recovery(self._last_schema, args)
980+
if namespace is not None:
981+
recovered = self._recover_namespace_from_partial_parse(
982+
self._last_schema,
983+
namespace,
984+
backend="argparse",
985+
message=str(exc),
986+
raw_exception=exc,
987+
)
988+
if recovered is not None:
989+
return recovered
990+
991+
parser._interfacy_raise_parse_errors = False
992+
parser.error(str(exc))
993+
raise AssertionError("unreachable") from exc
866994
except _ExecutableFlagTriggeredError as exc:
867995
exit_code = execute_executable_flag(exc.flag, display_result_fn=self.result_display_fn)
868996
raise SystemExit(exit_code) from None
869-
namespace = namespace_to_dict(parsed)
870-
871-
if self.COMMAND_KEY in namespace:
872-
cli_name = namespace[self.COMMAND_KEY]
873-
canonical = self.name_registry.canonical_for(cli_name) or cli_name
874-
namespace[self.COMMAND_KEY] = canonical
875-
876-
if canonical in namespace:
877-
pass
878-
elif cli_name in namespace:
879-
namespace[canonical] = namespace.pop(cli_name)
880-
else:
881-
namespace[canonical] = {}
997+
finally:
998+
parser._interfacy_raise_parse_errors = False
882999

883-
self._normalize_subcommand_buckets(namespace)
884-
namespace = self._convert_tuple_args(namespace)
885-
return namespace
1000+
return self._normalize_parsed_namespace(namespace_to_dict(parsed))
8861001

8871002
def _convert_tuple_args(self, namespace: dict[str, Any]) -> dict[str, Any]:
8881003
"""Convert arguments with ValueShape.TUPLE from list to tuple, applying per-element parsers."""

interfacy/argparse_backend/argument_parser.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@
3232
SUBCOMMANDS_KEY = "_subcommands"
3333

3434

35+
class _ArgparseParseError(Exception):
36+
"""Internal recoverable argparse parse error."""
37+
38+
3539
def _uses_template_layout(layout: "HelpLayout | None") -> bool:
3640
if layout is None:
3741
return False
@@ -309,6 +313,7 @@ def __init__(
309313
self._interfacy_help_layout.help_position = help_position
310314
self._schema_command: Command | None = None
311315
self._schema: ParserSchema | None = None
316+
self._interfacy_raise_parse_errors = False
312317
self.add_help = add_help
313318
if add_help:
314319
if "-" in self.prefix_chars:
@@ -773,6 +778,9 @@ def error(self, message: str) -> Never:
773778
By default, argparse prints only a short usage line on errors. For CLIs built
774779
around subcommands, a missing subcommand is much more useful when the full help is displayed.
775780
"""
781+
if self._interfacy_raise_parse_errors:
782+
raise _ArgparseParseError(message)
783+
776784
marker = "the following arguments are required:"
777785
if marker in message:
778786
subparser_actions = [
@@ -813,5 +821,6 @@ def error(self, message: str) -> Never:
813821
"ArgumentParser",
814822
"NargsPattern",
815823
"NestedSubParsersAction",
824+
"_ArgparseParseError",
816825
"namespace_to_dict",
817826
]

0 commit comments

Comments
 (0)