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:
2025-07-24 21:00:11 -04:00
parent 489d730755
commit 734f7b5962
5 changed files with 112 additions and 25 deletions

View File

@ -90,6 +90,13 @@ class FalyxCompleter(Completer):
) )
for suggestion in suggestions: for suggestion in suggestions:
if suggestion.startswith(stub): if suggestion.startswith(stub):
if len(suggestion.split()) > 1:
yield Completion(
f'"{suggestion}"',
start_position=-len(stub),
display=suggestion,
)
else:
yield Completion(suggestion, start_position=-len(stub)) yield Completion(suggestion, start_position=-len(stub))
except Exception: except Exception:
return return

View File

@ -46,9 +46,8 @@ and complex multi-level conflict handling. Instead, it favors:
""" """
from __future__ import annotations from __future__ import annotations
from collections import defaultdict from collections import Counter, defaultdict
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass
from typing import Any, Iterable, Sequence from typing import Any, Iterable, Sequence
from rich.console import Console from rich.console import Console
@ -63,23 +62,11 @@ from falyx.mode import FalyxMode
from falyx.options_manager import OptionsManager from falyx.options_manager import OptionsManager
from falyx.parser.argument import Argument from falyx.parser.argument import Argument
from falyx.parser.argument_action import ArgumentAction 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.parser.utils import coerce_value
from falyx.signals import HelpSignal from falyx.signals import HelpSignal
@dataclass
class ArgumentState:
arg: Argument
consumed: bool = False
@dataclass(frozen=True)
class TLDRExample:
usage: str
description: str
class CommandArgumentParser: class CommandArgumentParser:
""" """
Custom argument parser for Falyx Commands. Custom argument parser for Falyx Commands.
@ -620,6 +607,25 @@ class CommandArgumentParser:
) )
return defs 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( def _raise_remaining_args_error(
self, token: str, arg_states: dict[str, ArgumentState] self, token: str, arg_states: dict[str, ArgumentState]
) -> None: ) -> None:
@ -748,6 +754,7 @@ class CommandArgumentParser:
raise CommandArgumentError( raise CommandArgumentError(
f"[{spec.dest}] Action failed: {error}" f"[{spec.dest}] Action failed: {error}"
) from error ) from error
self._check_if_in_choices(spec, result, arg_states)
arg_states[spec.dest].consumed = True arg_states[spec.dest].consumed = True
elif not typed and spec.default: elif not typed and spec.default:
result[spec.dest] = 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" assert result.get(spec.dest) is not None, "dest should not be None"
result[spec.dest].extend(typed) result[spec.dest].extend(typed)
elif spec.nargs in (None, 1, "?"): elif spec.nargs in (None, 1, "?"):
arg_states[spec.dest].consumed = True
result[spec.dest] = typed[0] if len(typed) == 1 else typed 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: else:
self._check_if_in_choices(spec, result, arg_states)
arg_states[spec.dest].consumed = True arg_states[spec.dest].consumed = True
result[spec.dest] = typed result[spec.dest] = typed
@ -842,6 +851,7 @@ class CommandArgumentParser:
raise CommandArgumentError( raise CommandArgumentError(
f"[{spec.dest}] Action failed: {error}" f"[{spec.dest}] Action failed: {error}"
) from error ) from error
self._check_if_in_choices(spec, result, arg_states)
arg_states[spec.dest].consumed = True arg_states[spec.dest].consumed = True
consumed_indices.update(range(i, new_i)) consumed_indices.update(range(i, new_i))
i = new_i i = new_i
@ -927,6 +937,7 @@ class CommandArgumentParser:
) )
else: else:
result[spec.dest] = typed_values result[spec.dest] = typed_values
self._check_if_in_choices(spec, result, arg_states)
arg_states[spec.dest].consumed = True arg_states[spec.dest].consumed = True
consumed_indices.update(range(i, new_i)) consumed_indices.update(range(i, new_i))
i = new_i i = new_i
@ -1017,10 +1028,12 @@ class CommandArgumentParser:
and from_validate and from_validate
): ):
if not args: if not args:
arg_states[spec.dest].consumed = False
raise CommandArgumentError( raise CommandArgumentError(
f"Missing required argument '{spec.dest}': {spec.get_choice_text()}{help_text}" f"Missing required argument '{spec.dest}': {spec.get_choice_text()}{help_text}"
) )
continue # Lazy resolvers are not validated here continue # Lazy resolvers are not validated here
arg_states[spec.dest].consumed = False
raise CommandArgumentError( raise CommandArgumentError(
f"Missing required argument '{spec.dest}': {spec.get_choice_text()}{help_text}" f"Missing required argument '{spec.dest}': {spec.get_choice_text()}{help_text}"
) )
@ -1043,15 +1056,18 @@ class CommandArgumentParser:
if spec.action == ArgumentAction.APPEND: if spec.action == ArgumentAction.APPEND:
for group in result[spec.dest]: for group in result[spec.dest]:
if len(group) % spec.nargs != 0: if len(group) % spec.nargs != 0:
arg_states[spec.dest].consumed = False
raise CommandArgumentError( raise CommandArgumentError(
f"Invalid number of values for '{spec.dest}': expected a multiple of {spec.nargs}" f"Invalid number of values for '{spec.dest}': expected a multiple of {spec.nargs}"
) )
elif spec.action == ArgumentAction.EXTEND: elif spec.action == ArgumentAction.EXTEND:
if len(result[spec.dest]) % spec.nargs != 0: if len(result[spec.dest]) % spec.nargs != 0:
arg_states[spec.dest].consumed = False
raise CommandArgumentError( raise CommandArgumentError(
f"Invalid number of values for '{spec.dest}': expected a multiple of {spec.nargs}" f"Invalid number of values for '{spec.dest}': expected a multiple of {spec.nargs}"
) )
elif len(result[spec.dest]) != spec.nargs: elif len(result[spec.dest]) != spec.nargs:
arg_states[spec.dest].consumed = False
raise CommandArgumentError( raise CommandArgumentError(
f"Invalid number of values for '{spec.dest}': expected {spec.nargs}, got {len(result[spec.dest])}" 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: if not state.consumed:
next_non_consumed_positional = state.arg next_non_consumed_positional = state.arg
break break
if next_non_consumed_positional: if next_non_consumed_positional:
if next_non_consumed_positional.choices: if next_non_consumed_positional.choices:
return sorted( return sorted(
@ -1123,13 +1140,23 @@ class CommandArgumentParser:
flag for flag, arg in self._keyword.items() if arg.dest not in consumed_dests 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] last = args[-1]
next_to_last = args[-2] if len(args) > 1 else "" next_to_last = args[-2] if len(args) > 1 else ""
suggestions: list[str] = [] suggestions: list[str] = []
# Case 2: Mid-flag (e.g., "--ver") # Case 2: Mid-flag (e.g., "--ver")
if last.startswith("-") and last not in self._keyword: 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 len(args) > 1
and next_to_last in self._keyword and next_to_last in self._keyword
and next_to_last in remaining_flags and next_to_last in remaining_flags
@ -1150,14 +1177,31 @@ class CommandArgumentParser:
# Case 3: Flag that expects a value (e.g., ["--tag"]) # Case 3: Flag that expects a value (e.g., ["--tag"])
elif last in self._keyword: elif last in self._keyword:
arg = self._keyword[last] 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) suggestions.extend(arg.choices)
elif arg.suggestions: elif arg.suggestions:
suggestions.extend(arg.suggestions) suggestions.extend(arg.suggestions)
# Case 4: Last flag with choices mid-choice (e.g., ["--tag", "v"]) # Case 4: Last flag with choices mid-choice (e.g., ["--tag", "v"])
elif next_to_last in self._keyword: elif next_to_last in self._keyword:
arg = self._keyword[next_to_last] 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) suggestions.extend(arg.choices)
elif ( elif (
arg.suggestions arg.suggestions
@ -1167,8 +1211,12 @@ class CommandArgumentParser:
and not cursor_at_end_of_token and not cursor_at_end_of_token
): ):
suggestions.extend(arg.suggestions) suggestions.extend(arg.suggestions)
elif last_keyword_state_in_args and not last_keyword_state_in_args.consumed:
pass
else: else:
suggestions.extend(remaining_flags) 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 # Case 5: Suggest all remaining flags
else: else:
suggestions.extend(remaining_flags) suggestions.extend(remaining_flags)

View File

@ -1,20 +1,52 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 This module provides specialized helpers and data structures used by
other non-standard argument behaviors within the Falyx CLI parser system. 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 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: def true_none(value: Any) -> bool | None:
"""Return True if value is not None, else None."""
if value is None: if value is None:
return None return None
return True return True
def false_none(value: Any) -> bool | None: def false_none(value: Any) -> bool | None:
"""Return False if value is not None, else None."""
if value is None: if value is None:
return None return None
return False return False

View File

@ -1 +1 @@
__version__ = "0.1.71" __version__ = "0.1.72"

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "falyx" name = "falyx"
version = "0.1.71" version = "0.1.72"
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"