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,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

View File

@ -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)

View File

@ -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

View File

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

View File

@ -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"