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:
|
for suggestion in suggestions:
|
||||||
if suggestion.startswith(stub):
|
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:
|
except Exception:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -1 +1 @@
|
|||||||
__version__ = "0.1.71"
|
__version__ = "0.1.72"
|
||||||
|
@ -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"
|
||||||
|
Reference in New Issue
Block a user