feat(parser): improve choice validation and completion for flagged arguments
- Added `_check_if_in_choices()` to enforce `choices` validation for all nargs modes, including after resolver-based and list-based inputs. - Enhanced `FalyxCompleter` to quote multi-word completions for better UX. - Improved completion filtering logic to suppress stale suggestions when flag values are already consumed. - Moved `ArgumentState` and `TLDRExample` to `parser_types.py` for reuse. - Bumped version to 0.1.72.
This commit is contained in:
		| @@ -90,7 +90,14 @@ class FalyxCompleter(Completer): | ||||
|             ) | ||||
|             for suggestion in suggestions: | ||||
|                 if suggestion.startswith(stub): | ||||
|                     yield Completion(suggestion, start_position=-len(stub)) | ||||
|                     if len(suggestion.split()) > 1: | ||||
|                         yield Completion( | ||||
|                             f'"{suggestion}"', | ||||
|                             start_position=-len(stub), | ||||
|                             display=suggestion, | ||||
|                         ) | ||||
|                     else: | ||||
|                         yield Completion(suggestion, start_position=-len(stub)) | ||||
|         except Exception: | ||||
|             return | ||||
|  | ||||
|   | ||||
| @@ -46,9 +46,8 @@ and complex multi-level conflict handling. Instead, it favors: | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from collections import defaultdict | ||||
| from collections import Counter, defaultdict | ||||
| from copy import deepcopy | ||||
| from dataclasses import dataclass | ||||
| from typing import Any, Iterable, Sequence | ||||
|  | ||||
| from rich.console import Console | ||||
| @@ -63,23 +62,11 @@ from falyx.mode import FalyxMode | ||||
| from falyx.options_manager import OptionsManager | ||||
| from falyx.parser.argument import Argument | ||||
| from falyx.parser.argument_action import ArgumentAction | ||||
| from falyx.parser.parser_types import false_none, true_none | ||||
| from falyx.parser.parser_types import ArgumentState, TLDRExample, false_none, true_none | ||||
| from falyx.parser.utils import coerce_value | ||||
| from falyx.signals import HelpSignal | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| class ArgumentState: | ||||
|     arg: Argument | ||||
|     consumed: bool = False | ||||
|  | ||||
|  | ||||
| @dataclass(frozen=True) | ||||
| class TLDRExample: | ||||
|     usage: str | ||||
|     description: str | ||||
|  | ||||
|  | ||||
| class CommandArgumentParser: | ||||
|     """ | ||||
|     Custom argument parser for Falyx Commands. | ||||
| @@ -620,6 +607,25 @@ class CommandArgumentParser: | ||||
|             ) | ||||
|         return defs | ||||
|  | ||||
|     def _check_if_in_choices( | ||||
|         self, | ||||
|         spec: Argument, | ||||
|         result: dict[str, Any], | ||||
|         arg_states: dict[str, ArgumentState], | ||||
|     ) -> None: | ||||
|         if not spec.choices: | ||||
|             return None | ||||
|         value_check = result.get(spec.dest) | ||||
|         if isinstance(value_check, list): | ||||
|             for value in value_check: | ||||
|                 if value in spec.choices: | ||||
|                     return None | ||||
|         if value_check in spec.choices: | ||||
|             return None | ||||
|         raise CommandArgumentError( | ||||
|             f"Invalid value for '{spec.dest}': must be one of {{{', '.join(spec.choices)}}}" | ||||
|         ) | ||||
|  | ||||
|     def _raise_remaining_args_error( | ||||
|         self, token: str, arg_states: dict[str, ArgumentState] | ||||
|     ) -> None: | ||||
| @@ -748,6 +754,7 @@ class CommandArgumentParser: | ||||
|                         raise CommandArgumentError( | ||||
|                             f"[{spec.dest}] Action failed: {error}" | ||||
|                         ) from error | ||||
|                 self._check_if_in_choices(spec, result, arg_states) | ||||
|                 arg_states[spec.dest].consumed = True | ||||
|             elif not typed and spec.default: | ||||
|                 result[spec.dest] = spec.default | ||||
| @@ -761,9 +768,11 @@ class CommandArgumentParser: | ||||
|                 assert result.get(spec.dest) is not None, "dest should not be None" | ||||
|                 result[spec.dest].extend(typed) | ||||
|             elif spec.nargs in (None, 1, "?"): | ||||
|                 arg_states[spec.dest].consumed = True | ||||
|                 result[spec.dest] = typed[0] if len(typed) == 1 else typed | ||||
|                 self._check_if_in_choices(spec, result, arg_states) | ||||
|                 arg_states[spec.dest].consumed = True | ||||
|             else: | ||||
|                 self._check_if_in_choices(spec, result, arg_states) | ||||
|                 arg_states[spec.dest].consumed = True | ||||
|                 result[spec.dest] = typed | ||||
|  | ||||
| @@ -842,6 +851,7 @@ class CommandArgumentParser: | ||||
|                         raise CommandArgumentError( | ||||
|                             f"[{spec.dest}] Action failed: {error}" | ||||
|                         ) from error | ||||
|                 self._check_if_in_choices(spec, result, arg_states) | ||||
|                 arg_states[spec.dest].consumed = True | ||||
|                 consumed_indices.update(range(i, new_i)) | ||||
|                 i = new_i | ||||
| @@ -927,6 +937,7 @@ class CommandArgumentParser: | ||||
|                     ) | ||||
|                 else: | ||||
|                     result[spec.dest] = typed_values | ||||
|                 self._check_if_in_choices(spec, result, arg_states) | ||||
|                 arg_states[spec.dest].consumed = True | ||||
|                 consumed_indices.update(range(i, new_i)) | ||||
|                 i = new_i | ||||
| @@ -1017,10 +1028,12 @@ class CommandArgumentParser: | ||||
|                     and from_validate | ||||
|                 ): | ||||
|                     if not args: | ||||
|                         arg_states[spec.dest].consumed = False | ||||
|                         raise CommandArgumentError( | ||||
|                             f"Missing required argument '{spec.dest}': {spec.get_choice_text()}{help_text}" | ||||
|                         ) | ||||
|                     continue  # Lazy resolvers are not validated here | ||||
|                 arg_states[spec.dest].consumed = False | ||||
|                 raise CommandArgumentError( | ||||
|                     f"Missing required argument '{spec.dest}': {spec.get_choice_text()}{help_text}" | ||||
|                 ) | ||||
| @@ -1043,15 +1056,18 @@ class CommandArgumentParser: | ||||
|                 if spec.action == ArgumentAction.APPEND: | ||||
|                     for group in result[spec.dest]: | ||||
|                         if len(group) % spec.nargs != 0: | ||||
|                             arg_states[spec.dest].consumed = False | ||||
|                             raise CommandArgumentError( | ||||
|                                 f"Invalid number of values for '{spec.dest}': expected a multiple of {spec.nargs}" | ||||
|                             ) | ||||
|                 elif spec.action == ArgumentAction.EXTEND: | ||||
|                     if len(result[spec.dest]) % spec.nargs != 0: | ||||
|                         arg_states[spec.dest].consumed = False | ||||
|                         raise CommandArgumentError( | ||||
|                             f"Invalid number of values for '{spec.dest}': expected a multiple of {spec.nargs}" | ||||
|                         ) | ||||
|                 elif len(result[spec.dest]) != spec.nargs: | ||||
|                     arg_states[spec.dest].consumed = False | ||||
|                     raise CommandArgumentError( | ||||
|                         f"Invalid number of values for '{spec.dest}': expected {spec.nargs}, got {len(result[spec.dest])}" | ||||
|                     ) | ||||
| @@ -1105,6 +1121,7 @@ class CommandArgumentParser: | ||||
|             if not state.consumed: | ||||
|                 next_non_consumed_positional = state.arg | ||||
|                 break | ||||
|  | ||||
|         if next_non_consumed_positional: | ||||
|             if next_non_consumed_positional.choices: | ||||
|                 return sorted( | ||||
| @@ -1123,13 +1140,23 @@ class CommandArgumentParser: | ||||
|             flag for flag, arg in self._keyword.items() if arg.dest not in consumed_dests | ||||
|         ] | ||||
|  | ||||
|         last_keyword_state_in_args = None | ||||
|         for last_arg in reversed(args): | ||||
|             if last_arg in self._keyword: | ||||
|                 last_keyword_state_in_args = self._last_keyword_states.get( | ||||
|                     self._keyword[last_arg].dest | ||||
|                 ) | ||||
|                 break | ||||
|  | ||||
|         last = args[-1] | ||||
|         next_to_last = args[-2] if len(args) > 1 else "" | ||||
|         suggestions: list[str] = [] | ||||
|  | ||||
|         # Case 2: Mid-flag (e.g., "--ver") | ||||
|         if last.startswith("-") and last not in self._keyword: | ||||
|             if ( | ||||
|             if last_keyword_state_in_args and not last_keyword_state_in_args.consumed: | ||||
|                 pass | ||||
|             elif ( | ||||
|                 len(args) > 1 | ||||
|                 and next_to_last in self._keyword | ||||
|                 and next_to_last in remaining_flags | ||||
| @@ -1150,14 +1177,31 @@ class CommandArgumentParser: | ||||
|         # Case 3: Flag that expects a value (e.g., ["--tag"]) | ||||
|         elif last in self._keyword: | ||||
|             arg = self._keyword[last] | ||||
|             if arg.choices: | ||||
|             if ( | ||||
|                 self._last_keyword_states.get(last.strip("-")) | ||||
|                 and self._last_keyword_states[last.strip("-")].consumed | ||||
|             ): | ||||
|                 pass | ||||
|             elif arg.choices: | ||||
|                 suggestions.extend(arg.choices) | ||||
|             elif arg.suggestions: | ||||
|                 suggestions.extend(arg.suggestions) | ||||
|         # Case 4: Last flag with choices mid-choice (e.g., ["--tag", "v"]) | ||||
|         elif next_to_last in self._keyword: | ||||
|             arg = self._keyword[next_to_last] | ||||
|             if arg.choices and last not in arg.choices: | ||||
|             if ( | ||||
|                 self._last_keyword_states.get(next_to_last.strip("-")) | ||||
|                 and self._last_keyword_states[next_to_last.strip("-")].consumed | ||||
|                 and last_keyword_state_in_args | ||||
|                 and Counter(args)[next_to_last] | ||||
|                 > ( | ||||
|                     last_keyword_state_in_args.arg.nargs | ||||
|                     if isinstance(last_keyword_state_in_args.arg.nargs, int) | ||||
|                     else 1 | ||||
|                 ) | ||||
|             ): | ||||
|                 pass | ||||
|             elif arg.choices and last not in arg.choices and not cursor_at_end_of_token: | ||||
|                 suggestions.extend(arg.choices) | ||||
|             elif ( | ||||
|                 arg.suggestions | ||||
| @@ -1167,8 +1211,12 @@ class CommandArgumentParser: | ||||
|                 and not cursor_at_end_of_token | ||||
|             ): | ||||
|                 suggestions.extend(arg.suggestions) | ||||
|             elif last_keyword_state_in_args and not last_keyword_state_in_args.consumed: | ||||
|                 pass | ||||
|             else: | ||||
|                 suggestions.extend(remaining_flags) | ||||
|         elif last_keyword_state_in_args and not last_keyword_state_in_args.consumed: | ||||
|             pass | ||||
|         # Case 5: Suggest all remaining flags | ||||
|         else: | ||||
|             suggestions.extend(remaining_flags) | ||||
|   | ||||
| @@ -1,20 +1,52 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Utilities for custom type coercion in Falyx argument parsing. | ||||
| Type utilities and argument state models for Falyx's custom CLI argument parser. | ||||
|  | ||||
| Provides special-purpose converters used to support optional boolean flags and | ||||
| other non-standard argument behaviors within the Falyx CLI parser system. | ||||
| This module provides specialized helpers and data structures used by | ||||
| the `CommandArgumentParser` to handle non-standard parsing behavior. | ||||
|  | ||||
| Contents: | ||||
| - `true_none` / `false_none`: Type coercion utilities that allow tri-state boolean | ||||
|   semantics (True, False, None). These are especially useful for supporting | ||||
|   `--flag` / `--no-flag` optional booleans in CLI arguments. | ||||
| - `ArgumentState`: Tracks whether an `Argument` has been consumed during parsing. | ||||
| - `TLDRExample`: A structured example for showing usage snippets and descriptions, | ||||
|    used in TLDR views. | ||||
|  | ||||
| These tools support richer expressiveness and user-friendly ergonomics in | ||||
| Falyx's declarative command-line interfaces. | ||||
| """ | ||||
| from dataclasses import dataclass | ||||
| from typing import Any | ||||
|  | ||||
| from falyx.parser.argument import Argument | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| class ArgumentState: | ||||
|     """Tracks an argument and whether it has been consumed.""" | ||||
|  | ||||
|     arg: Argument | ||||
|     consumed: bool = False | ||||
|  | ||||
|  | ||||
| @dataclass(frozen=True) | ||||
| class TLDRExample: | ||||
|     """Represents a usage example for TLDR output.""" | ||||
|  | ||||
|     usage: str | ||||
|     description: str | ||||
|  | ||||
|  | ||||
| def true_none(value: Any) -> bool | None: | ||||
|     """Return True if value is not None, else None.""" | ||||
|     if value is None: | ||||
|         return None | ||||
|     return True | ||||
|  | ||||
|  | ||||
| def false_none(value: Any) -> bool | None: | ||||
|     """Return False if value is not None, else None.""" | ||||
|     if value is None: | ||||
|         return None | ||||
|     return False | ||||
|   | ||||
| @@ -1 +1 @@ | ||||
| __version__ = "0.1.71" | ||||
| __version__ = "0.1.72" | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| [tool.poetry] | ||||
| name = "falyx" | ||||
| version = "0.1.71" | ||||
| version = "0.1.72" | ||||
| description = "Reliable and introspectable async CLI action framework." | ||||
| authors = ["Roland Thomas Jr <roland@rtj.dev>"] | ||||
| license = "MIT" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user