From 734f7b5962f813c9370c228328d4fc6826d27d53 Mon Sep 17 00:00:00 2001 From: Roland Thomas Date: Thu, 24 Jul 2025 21:00:11 -0400 Subject: [PATCH] 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. --- falyx/completer.py | 9 ++- falyx/parser/command_argument_parser.py | 86 +++++++++++++++++++------ falyx/parser/parser_types.py | 38 ++++++++++- falyx/version.py | 2 +- pyproject.toml | 2 +- 5 files changed, 112 insertions(+), 25 deletions(-) diff --git a/falyx/completer.py b/falyx/completer.py index 9b6d289..aac2369 100644 --- a/falyx/completer.py +++ b/falyx/completer.py @@ -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 diff --git a/falyx/parser/command_argument_parser.py b/falyx/parser/command_argument_parser.py index 4bf8da5..b37b07a 100644 --- a/falyx/parser/command_argument_parser.py +++ b/falyx/parser/command_argument_parser.py @@ -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) diff --git a/falyx/parser/parser_types.py b/falyx/parser/parser_types.py index ef97843..b4644fc 100644 --- a/falyx/parser/parser_types.py +++ b/falyx/parser/parser_types.py @@ -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 diff --git a/falyx/version.py b/falyx/version.py index fe4c664..4fef18a 100644 --- a/falyx/version.py +++ b/falyx/version.py @@ -1 +1 @@ -__version__ = "0.1.71" +__version__ = "0.1.72" diff --git a/pyproject.toml b/pyproject.toml index a817b9d..c913b16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 "] license = "MIT"