feat(parser): add docstrings, centralized suggestion errors, and improved flag handling
-Added descriptive docstrings across `Falyx` and `CommandArgumentParser` internals: - `is_cli_mode`, `get_tip`, and `_render_help` in `falyx.py` - Validation and parsing helpers in `command_argument_parser.py` (`_validate_nargs`, `_normalize_choices`, `_validate_default_list_type`, `_validate_action`, `_register_store_bool_optional`, `_register_argument`, `_check_if_in_choices`, `_raise_remaining_args_error`, `_consume_nargs`, `_consume_all_positional_args`, `_handle_token`, `_find_last_flag_argument`, `_is_mid_value`, `_is_invalid_choices_state`, `_value_suggestions_for_arg`) - Introduced `_raise_suggestion_error()` utility to standardize error messages when required values are missing, including defaults and choices. - Replaced duplicated inline suggestion/error logic in `APPEND`, `EXTEND`, and generic STORE handlers with this helper. - Improved error chaining with `from error` for clarity in `_normalize_choices` and `_validate_action`. - Consolidated `HELP`, `TLDR`, and `COUNT` default-value validation into a single check. - Enhanced completions: - Extended suggestion logic to show remaining flags for `APPEND`, `EXTEND`, and `COUNT` arguments when last tokens are not keywords. - Added `.config.json` to `.gitignore`. - Bumped version to 0.1.85.
This commit is contained in:
		
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -15,3 +15,4 @@ build/ | |||||||
| .vscode/ | .vscode/ | ||||||
| coverage.xml | coverage.xml | ||||||
| .coverage | .coverage | ||||||
|  | .config.json | ||||||
|   | |||||||
| @@ -203,6 +203,7 @@ class Falyx: | |||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def is_cli_mode(self) -> bool: |     def is_cli_mode(self) -> bool: | ||||||
|  |         """Checks if the current mode is a CLI mode.""" | ||||||
|         return self.options.get("mode") in { |         return self.options.get("mode") in { | ||||||
|             FalyxMode.RUN, |             FalyxMode.RUN, | ||||||
|             FalyxMode.PREVIEW, |             FalyxMode.PREVIEW, | ||||||
| @@ -367,6 +368,7 @@ class Falyx: | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def get_tip(self) -> str: |     def get_tip(self) -> str: | ||||||
|  |         """Returns a random tip for the user about using Falyx.""" | ||||||
|         program = f"{self.program} run " if self.is_cli_mode else "" |         program = f"{self.program} run " if self.is_cli_mode else "" | ||||||
|         tips = [ |         tips = [ | ||||||
|             f"Use '{program}?[COMMAND]' to preview a command.", |             f"Use '{program}?[COMMAND]' to preview a command.", | ||||||
| @@ -405,6 +407,7 @@ class Falyx: | |||||||
|     async def _render_help( |     async def _render_help( | ||||||
|         self, tag: str = "", key: str | None = None, tldr: bool = False |         self, tag: str = "", key: str | None = None, tldr: bool = False | ||||||
|     ) -> None: |     ) -> None: | ||||||
|  |         """Renders the help menu with command details, usage examples, and tips.""" | ||||||
|         if tldr and not key: |         if tldr and not key: | ||||||
|             if self.help_command and self.help_command.arg_parser: |             if self.help_command and self.help_command.arg_parser: | ||||||
|                 self.help_command.arg_parser.render_tldr() |                 self.help_command.arg_parser.render_tldr() | ||||||
|   | |||||||
| @@ -245,6 +245,7 @@ class CommandArgumentParser: | |||||||
|     def _validate_nargs( |     def _validate_nargs( | ||||||
|         self, nargs: int | str | None, action: ArgumentAction |         self, nargs: int | str | None, action: ArgumentAction | ||||||
|     ) -> int | str | None: |     ) -> int | str | None: | ||||||
|  |         """Validate the nargs value for the argument.""" | ||||||
|         if action in ( |         if action in ( | ||||||
|             ArgumentAction.STORE_FALSE, |             ArgumentAction.STORE_FALSE, | ||||||
|             ArgumentAction.STORE_TRUE, |             ArgumentAction.STORE_TRUE, | ||||||
| @@ -274,6 +275,7 @@ class CommandArgumentParser: | |||||||
|     def _normalize_choices( |     def _normalize_choices( | ||||||
|         self, choices: Iterable | None, expected_type: Any, action: ArgumentAction |         self, choices: Iterable | None, expected_type: Any, action: ArgumentAction | ||||||
|     ) -> list[Any]: |     ) -> list[Any]: | ||||||
|  |         """Normalize and validate choices for the argument.""" | ||||||
|         if choices is not None: |         if choices is not None: | ||||||
|             if action in ( |             if action in ( | ||||||
|                 ArgumentAction.STORE_TRUE, |                 ArgumentAction.STORE_TRUE, | ||||||
| @@ -287,10 +289,10 @@ class CommandArgumentParser: | |||||||
|                 raise CommandArgumentError("choices cannot be a dict") |                 raise CommandArgumentError("choices cannot be a dict") | ||||||
|             try: |             try: | ||||||
|                 choices = list(choices) |                 choices = list(choices) | ||||||
|             except TypeError: |             except TypeError as error: | ||||||
|                 raise CommandArgumentError( |                 raise CommandArgumentError( | ||||||
|                     "choices must be iterable (like list, tuple, or set)" |                     "choices must be iterable (like list, tuple, or set)" | ||||||
|                 ) |                 ) from error | ||||||
|         else: |         else: | ||||||
|             choices = [] |             choices = [] | ||||||
|         for choice in choices: |         for choice in choices: | ||||||
| @@ -317,6 +319,7 @@ class CommandArgumentParser: | |||||||
|     def _validate_default_list_type( |     def _validate_default_list_type( | ||||||
|         self, default: list[Any], expected_type: type, dest: str |         self, default: list[Any], expected_type: type, dest: str | ||||||
|     ) -> None: |     ) -> None: | ||||||
|  |         """Validate the default value type for a list.""" | ||||||
|         if isinstance(default, list): |         if isinstance(default, list): | ||||||
|             for item in default: |             for item in default: | ||||||
|                 try: |                 try: | ||||||
| @@ -346,13 +349,14 @@ class CommandArgumentParser: | |||||||
|     def _validate_action( |     def _validate_action( | ||||||
|         self, action: ArgumentAction | str, positional: bool |         self, action: ArgumentAction | str, positional: bool | ||||||
|     ) -> ArgumentAction: |     ) -> ArgumentAction: | ||||||
|  |         """Validate the action type.""" | ||||||
|         if not isinstance(action, ArgumentAction): |         if not isinstance(action, ArgumentAction): | ||||||
|             try: |             try: | ||||||
|                 action = ArgumentAction(action) |                 action = ArgumentAction(action) | ||||||
|             except ValueError: |             except ValueError as error: | ||||||
|                 raise CommandArgumentError( |                 raise CommandArgumentError( | ||||||
|                     f"Invalid action '{action}' is not a valid ArgumentAction" |                     f"Invalid action '{action}' is not a valid ArgumentAction" | ||||||
|                 ) |                 ) from error | ||||||
|         if action in ( |         if action in ( | ||||||
|             ArgumentAction.STORE_TRUE, |             ArgumentAction.STORE_TRUE, | ||||||
|             ArgumentAction.STORE_FALSE, |             ArgumentAction.STORE_FALSE, | ||||||
| @@ -398,18 +402,11 @@ class CommandArgumentParser: | |||||||
|             raise CommandArgumentError( |             raise CommandArgumentError( | ||||||
|                 f"Default value cannot be set for action {action}. It is a boolean flag." |                 f"Default value cannot be set for action {action}. It is a boolean flag." | ||||||
|             ) |             ) | ||||||
|         elif action == ArgumentAction.HELP: |         elif action in (ArgumentAction.HELP, ArgumentAction.TLDR, ArgumentAction.COUNT): | ||||||
|             raise CommandArgumentError( |             raise CommandArgumentError( | ||||||
|                 "Default value cannot be set for action HELP. It is a help flag." |                 f"Default value cannot be set for action {action}." | ||||||
|             ) |  | ||||||
|         elif action == ArgumentAction.TLDR: |  | ||||||
|             raise CommandArgumentError( |  | ||||||
|                 "Default value cannot be set for action TLDR. It is a tldr flag." |  | ||||||
|             ) |  | ||||||
|         elif action == ArgumentAction.COUNT: |  | ||||||
|             raise CommandArgumentError( |  | ||||||
|                 "Default value cannot be set for action COUNT. It is a count flag." |  | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|         if action in (ArgumentAction.APPEND, ArgumentAction.EXTEND) and not isinstance( |         if action in (ArgumentAction.APPEND, ArgumentAction.EXTEND) and not isinstance( | ||||||
|             default, list |             default, list | ||||||
|         ): |         ): | ||||||
| @@ -448,6 +445,7 @@ class CommandArgumentParser: | |||||||
|         dest: str, |         dest: str, | ||||||
|         help: str, |         help: str, | ||||||
|     ) -> None: |     ) -> None: | ||||||
|  |         """Register a store_bool_optional action with the parser.""" | ||||||
|         if len(flags) != 1: |         if len(flags) != 1: | ||||||
|             raise CommandArgumentError( |             raise CommandArgumentError( | ||||||
|                 "store_bool_optional action can only have a single flag" |                 "store_bool_optional action can only have a single flag" | ||||||
| @@ -483,7 +481,7 @@ class CommandArgumentParser: | |||||||
|     def _register_argument( |     def _register_argument( | ||||||
|         self, argument: Argument, bypass_validation: bool = False |         self, argument: Argument, bypass_validation: bool = False | ||||||
|     ) -> None: |     ) -> None: | ||||||
|  |         """Register a new argument with the parser.""" | ||||||
|         for flag in argument.flags: |         for flag in argument.flags: | ||||||
|             if flag in self._flag_map and not bypass_validation: |             if flag in self._flag_map and not bypass_validation: | ||||||
|                 existing = self._flag_map[flag] |                 existing = self._flag_map[flag] | ||||||
| @@ -653,6 +651,7 @@ class CommandArgumentParser: | |||||||
|         result: dict[str, Any], |         result: dict[str, Any], | ||||||
|         arg_states: dict[str, ArgumentState], |         arg_states: dict[str, ArgumentState], | ||||||
|     ) -> None: |     ) -> None: | ||||||
|  |         """Check if the value is in the choices for the argument.""" | ||||||
|         if not spec.choices: |         if not spec.choices: | ||||||
|             return None |             return None | ||||||
|         value_check = result.get(spec.dest) |         value_check = result.get(spec.dest) | ||||||
| @@ -670,6 +669,7 @@ class CommandArgumentParser: | |||||||
|     def _raise_remaining_args_error( |     def _raise_remaining_args_error( | ||||||
|         self, token: str, arg_states: dict[str, ArgumentState] |         self, token: str, arg_states: dict[str, ArgumentState] | ||||||
|     ) -> None: |     ) -> None: | ||||||
|  |         """Raise an error for unrecognized options with suggestions.""" | ||||||
|         consumed_dests = [ |         consumed_dests = [ | ||||||
|             state.arg.dest for state in arg_states.values() if state.consumed |             state.arg.dest for state in arg_states.values() if state.consumed | ||||||
|         ] |         ] | ||||||
| @@ -691,6 +691,7 @@ class CommandArgumentParser: | |||||||
|     def _consume_nargs( |     def _consume_nargs( | ||||||
|         self, args: list[str], index: int, spec: Argument |         self, args: list[str], index: int, spec: Argument | ||||||
|     ) -> tuple[list[str], int]: |     ) -> tuple[list[str], int]: | ||||||
|  |         """Consume the specified number of arguments based on nargs.""" | ||||||
|         assert ( |         assert ( | ||||||
|             spec.nargs is None |             spec.nargs is None | ||||||
|             or isinstance(spec.nargs, int) |             or isinstance(spec.nargs, int) | ||||||
| @@ -736,6 +737,7 @@ class CommandArgumentParser: | |||||||
|         from_validate: bool = False, |         from_validate: bool = False, | ||||||
|         base_index: int = 0, |         base_index: int = 0, | ||||||
|     ) -> int: |     ) -> int: | ||||||
|  |         """Consume all positional arguments from the provided args list.""" | ||||||
|         remaining_positional_args = [ |         remaining_positional_args = [ | ||||||
|             (spec_index, spec) |             (spec_index, spec) | ||||||
|             for spec_index, spec in enumerate(positional_args) |             for spec_index, spec in enumerate(positional_args) | ||||||
| @@ -809,6 +811,8 @@ class CommandArgumentParser: | |||||||
|                 result[spec.dest] = spec.default |                 result[spec.dest] = spec.default | ||||||
|             elif spec.action == ArgumentAction.APPEND: |             elif spec.action == ArgumentAction.APPEND: | ||||||
|                 assert result.get(spec.dest) is not None, "dest should not be None" |                 assert result.get(spec.dest) is not None, "dest should not be None" | ||||||
|  |                 if not typed: | ||||||
|  |                     self._raise_suggestion_error(spec) | ||||||
|                 if spec.nargs is None: |                 if spec.nargs is None: | ||||||
|                     result[spec.dest].append(typed[0]) |                     result[spec.dest].append(typed[0]) | ||||||
|                 else: |                 else: | ||||||
| @@ -891,6 +895,34 @@ class CommandArgumentParser: | |||||||
|             valid = False |             valid = False | ||||||
|         return valid |         return valid | ||||||
|  |  | ||||||
|  |     def _raise_suggestion_error(self, spec: Argument) -> None: | ||||||
|  |         """Raise an error with suggestions for the argument.""" | ||||||
|  |         help_text = f"help: {spec.help}" if spec.help else "" | ||||||
|  |         choices = [] | ||||||
|  |         if spec.default: | ||||||
|  |             choices.append(f"default={spec.default}") | ||||||
|  |         if spec.choices: | ||||||
|  |             choices.append(f"choices={spec.choices}") | ||||||
|  |         if choices: | ||||||
|  |             choices.append(help_text) | ||||||
|  |             choices_text = ", ".join(choices) | ||||||
|  |             raise CommandArgumentError( | ||||||
|  |                 f"Argument '{spec.dest}' requires a value. {choices_text}" | ||||||
|  |             ) | ||||||
|  |         elif spec.nargs is None: | ||||||
|  |             try: | ||||||
|  |                 raise CommandArgumentError( | ||||||
|  |                     f"Enter a {spec.type.__name__} value for '{spec.dest}'. {help_text}" | ||||||
|  |                 ) | ||||||
|  |             except AttributeError as error: | ||||||
|  |                 raise CommandArgumentError( | ||||||
|  |                     f"Enter a value for '{spec.dest}'. {help_text}" | ||||||
|  |                 ) from error | ||||||
|  |         else: | ||||||
|  |             raise CommandArgumentError( | ||||||
|  |                 f"Argument '{spec.dest}' requires a value. Expected {spec.nargs} values. {help_text}" | ||||||
|  |             ) | ||||||
|  |  | ||||||
|     async def _handle_token( |     async def _handle_token( | ||||||
|         self, |         self, | ||||||
|         token: str, |         token: str, | ||||||
| @@ -903,6 +935,7 @@ class CommandArgumentParser: | |||||||
|         arg_states: dict[str, ArgumentState], |         arg_states: dict[str, ArgumentState], | ||||||
|         from_validate: bool = False, |         from_validate: bool = False, | ||||||
|     ) -> int: |     ) -> int: | ||||||
|  |         """Handle a single token in the command line arguments.""" | ||||||
|         if token in self._keyword: |         if token in self._keyword: | ||||||
|             spec = self._keyword[token] |             spec = self._keyword[token] | ||||||
|             action = spec.action |             action = spec.action | ||||||
| @@ -979,8 +1012,10 @@ class CommandArgumentParser: | |||||||
|                     raise CommandArgumentError( |                     raise CommandArgumentError( | ||||||
|                         f"Invalid value for '{spec.dest}': {error}" |                         f"Invalid value for '{spec.dest}': {error}" | ||||||
|                     ) from error |                     ) from error | ||||||
|  |                 if not typed_values: | ||||||
|  |                     self._raise_suggestion_error(spec) | ||||||
|                 if spec.nargs is None: |                 if spec.nargs is None: | ||||||
|                     result[spec.dest].append(spec.type(values[0])) |                     result[spec.dest].append(spec.type(typed_values[0])) | ||||||
|                 else: |                 else: | ||||||
|                     result[spec.dest].append(typed_values) |                     result[spec.dest].append(typed_values) | ||||||
|                 consumed_indices.update(range(index, new_index)) |                 consumed_indices.update(range(index, new_index)) | ||||||
| @@ -1010,27 +1045,7 @@ class CommandArgumentParser: | |||||||
|                         f"Invalid value for '{spec.dest}': {error}" |                         f"Invalid value for '{spec.dest}': {error}" | ||||||
|                     ) from error |                     ) from error | ||||||
|                 if not typed_values and spec.nargs not in ("*", "?"): |                 if not typed_values and spec.nargs not in ("*", "?"): | ||||||
|                     choices = [] |                     self._raise_suggestion_error(spec) | ||||||
|                     if spec.default: |  | ||||||
|                         choices.append(f"default={spec.default}") |  | ||||||
|                     if spec.choices: |  | ||||||
|                         choices.append(f"choices={spec.choices}") |  | ||||||
|                     if choices: |  | ||||||
|                         choices_text = ", ".join(choices) |  | ||||||
|                         raise CommandArgumentError( |  | ||||||
|                             f"Argument '{spec.dest}' requires a value. {choices_text}" |  | ||||||
|                         ) |  | ||||||
|                     elif spec.nargs is None: |  | ||||||
|                         try: |  | ||||||
|                             raise CommandArgumentError( |  | ||||||
|                                 f"Enter a {spec.type.__name__} value for '{spec.dest}'" |  | ||||||
|                             ) |  | ||||||
|                         except AttributeError: |  | ||||||
|                             raise CommandArgumentError(f"Enter a value for '{spec.dest}'") |  | ||||||
|                     else: |  | ||||||
|                         raise CommandArgumentError( |  | ||||||
|                             f"Argument '{spec.dest}' requires a value. Expected {spec.nargs} values." |  | ||||||
|                         ) |  | ||||||
|                 if spec.nargs in (None, 1, "?"): |                 if spec.nargs in (None, 1, "?"): | ||||||
|                     result[spec.dest] = ( |                     result[spec.dest] = ( | ||||||
|                         typed_values[0] if len(typed_values) == 1 else typed_values |                         typed_values[0] if len(typed_values) == 1 else typed_values | ||||||
| @@ -1067,6 +1082,7 @@ class CommandArgumentParser: | |||||||
|         return index |         return index | ||||||
|  |  | ||||||
|     def _find_last_flag_argument(self, args: list[str]) -> Argument | None: |     def _find_last_flag_argument(self, args: list[str]) -> Argument | None: | ||||||
|  |         """Find the last flag argument in the provided args.""" | ||||||
|         last_flag_argument = None |         last_flag_argument = None | ||||||
|         for arg in reversed(args): |         for arg in reversed(args): | ||||||
|             if arg in self._keyword: |             if arg in self._keyword: | ||||||
| @@ -1238,6 +1254,7 @@ class CommandArgumentParser: | |||||||
|     def _is_mid_value( |     def _is_mid_value( | ||||||
|         self, state: ArgumentState | None, args: list[str], cursor_at_end_of_token: bool |         self, state: ArgumentState | None, args: list[str], cursor_at_end_of_token: bool | ||||||
|     ) -> bool: |     ) -> bool: | ||||||
|  |         """Check if the current state is in the middle of consuming a value.""" | ||||||
|         if state is None: |         if state is None: | ||||||
|             return False |             return False | ||||||
|         if cursor_at_end_of_token: |         if cursor_at_end_of_token: | ||||||
| @@ -1252,6 +1269,7 @@ class CommandArgumentParser: | |||||||
|         cursor_at_end_of_token: bool, |         cursor_at_end_of_token: bool, | ||||||
|         num_args_since_last_keyword: int, |         num_args_since_last_keyword: int, | ||||||
|     ) -> bool: |     ) -> bool: | ||||||
|  |         """Check if the state indicates an invalid choice condition.""" | ||||||
|         if isinstance(state.arg.nargs, int): |         if isinstance(state.arg.nargs, int): | ||||||
|             return ( |             return ( | ||||||
|                 state.has_invalid_choice |                 state.has_invalid_choice | ||||||
| @@ -1279,6 +1297,7 @@ class CommandArgumentParser: | |||||||
|         cursor_at_end_of_token: bool, |         cursor_at_end_of_token: bool, | ||||||
|         num_args_since_last_keyword: int, |         num_args_since_last_keyword: int, | ||||||
|     ) -> list[str]: |     ) -> list[str]: | ||||||
|  |         """Return a list of value suggestions for the given argument state.""" | ||||||
|         if self._is_invalid_choices_state( |         if self._is_invalid_choices_state( | ||||||
|             state, cursor_at_end_of_token, num_args_since_last_keyword |             state, cursor_at_end_of_token, num_args_since_last_keyword | ||||||
|         ): |         ): | ||||||
| @@ -1420,6 +1439,18 @@ class CommandArgumentParser: | |||||||
|                         num_args_since_last_keyword, |                         num_args_since_last_keyword, | ||||||
|                     ) |                     ) | ||||||
|                 ) |                 ) | ||||||
|  |                 if ( | ||||||
|  |                     last_keyword_state_in_args.arg.action | ||||||
|  |                     in ( | ||||||
|  |                         ArgumentAction.APPEND, | ||||||
|  |                         ArgumentAction.EXTEND, | ||||||
|  |                         ArgumentAction.COUNT, | ||||||
|  |                     ) | ||||||
|  |                     and next_to_last not in self._keyword | ||||||
|  |                 ): | ||||||
|  |                     suggestions.extend( | ||||||
|  |                         flag for flag in remaining_flags if flag.startswith(last) | ||||||
|  |                     ) | ||||||
|             elif not cursor_at_end_of_token: |             elif not cursor_at_end_of_token: | ||||||
|                 # Suggest all flags that start with the last token |                 # Suggest all flags that start with the last token | ||||||
|                 suggestions.extend( |                 suggestions.extend( | ||||||
| @@ -1516,6 +1547,16 @@ class CommandArgumentParser: | |||||||
|                         num_args_since_last_keyword, |                         num_args_since_last_keyword, | ||||||
|                     ) |                     ) | ||||||
|                 ) |                 ) | ||||||
|  |                 if ( | ||||||
|  |                     last_keyword_state_in_args.arg.action | ||||||
|  |                     in ( | ||||||
|  |                         ArgumentAction.APPEND, | ||||||
|  |                         ArgumentAction.EXTEND, | ||||||
|  |                         ArgumentAction.COUNT, | ||||||
|  |                     ) | ||||||
|  |                     and last not in self._keyword | ||||||
|  |                 ): | ||||||
|  |                     suggestions.extend(flag for flag in remaining_flags) | ||||||
|             else: |             else: | ||||||
|                 suggestions.extend(remaining_flags) |                 suggestions.extend(remaining_flags) | ||||||
|         # Case 5: Last flag is incomplete and expects a value (e.g., ["--tag", "value1", "va"]) |         # Case 5: Last flag is incomplete and expects a value (e.g., ["--tag", "value1", "va"]) | ||||||
|   | |||||||
| @@ -1 +1 @@ | |||||||
| __version__ = "0.1.84" | __version__ = "0.1.85" | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| [tool.poetry] | [tool.poetry] | ||||||
| name = "falyx" | name = "falyx" | ||||||
| version = "0.1.84" | version = "0.1.85" | ||||||
| description = "Reliable and introspectable async CLI action framework." | description = "Reliable and introspectable async CLI action framework." | ||||||
| authors = ["Roland Thomas Jr <roland@rtj.dev>"] | authors = ["Roland Thomas Jr <roland@rtj.dev>"] | ||||||
| license = "MIT" | license = "MIT" | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user