1919from interfacy .argparse_backend .argument_parser import (
2020 ArgumentParser ,
2121 NestedSubParsersAction ,
22+ _ArgparseParseError ,
2223 namespace_to_dict ,
2324)
2425from interfacy .argparse_backend .help_formatter import InterfacyHelpFormatter
4142from interfacy .naming import AbbreviationGenerator , FlagStrategy
4243from interfacy .naming .flag_strategy import FlagAllocationState , get_arg_flags_for_parameter
4344from interfacy .pipe import PipeTargets
45+ from interfacy .plugins import InterfacyPlugin
4446from interfacy .schema .schema import Argument , ArgumentKind , Command , ParserSchema , ValueShape
4547from 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."""
0 commit comments