feat(parser): add docstrings, centralized suggestion errors, and improved flag handling
-Added descriptive docstrings across `Falyx` and `CommandArgumentParser` internals: - `is_cli_mode`, `get_tip`, and `_render_help` in `falyx.py` - Validation and parsing helpers in `command_argument_parser.py` (`_validate_nargs`, `_normalize_choices`, `_validate_default_list_type`, `_validate_action`, `_register_store_bool_optional`, `_register_argument`, `_check_if_in_choices`, `_raise_remaining_args_error`, `_consume_nargs`, `_consume_all_positional_args`, `_handle_token`, `_find_last_flag_argument`, `_is_mid_value`, `_is_invalid_choices_state`, `_value_suggestions_for_arg`) - Introduced `_raise_suggestion_error()` utility to standardize error messages when required values are missing, including defaults and choices. - Replaced duplicated inline suggestion/error logic in `APPEND`, `EXTEND`, and generic STORE handlers with this helper. - Improved error chaining with `from error` for clarity in `_normalize_choices` and `_validate_action`. - Consolidated `HELP`, `TLDR`, and `COUNT` default-value validation into a single check. - Enhanced completions: - Extended suggestion logic to show remaining flags for `APPEND`, `EXTEND`, and `COUNT` arguments when last tokens are not keywords. - Added `.config.json` to `.gitignore`. - Bumped version to 0.1.85.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -15,3 +15,4 @@ build/
|
|||||||
.vscode/
|
.vscode/
|
||||||
coverage.xml
|
coverage.xml
|
||||||
.coverage
|
.coverage
|
||||||
|
.config.json
|
||||||
|
@ -203,6 +203,7 @@ class Falyx:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def is_cli_mode(self) -> bool:
|
def is_cli_mode(self) -> bool:
|
||||||
|
"""Checks if the current mode is a CLI mode."""
|
||||||
return self.options.get("mode") in {
|
return self.options.get("mode") in {
|
||||||
FalyxMode.RUN,
|
FalyxMode.RUN,
|
||||||
FalyxMode.PREVIEW,
|
FalyxMode.PREVIEW,
|
||||||
@ -367,6 +368,7 @@ class Falyx:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_tip(self) -> str:
|
def get_tip(self) -> str:
|
||||||
|
"""Returns a random tip for the user about using Falyx."""
|
||||||
program = f"{self.program} run " if self.is_cli_mode else ""
|
program = f"{self.program} run " if self.is_cli_mode else ""
|
||||||
tips = [
|
tips = [
|
||||||
f"Use '{program}?[COMMAND]' to preview a command.",
|
f"Use '{program}?[COMMAND]' to preview a command.",
|
||||||
@ -405,6 +407,7 @@ class Falyx:
|
|||||||
async def _render_help(
|
async def _render_help(
|
||||||
self, tag: str = "", key: str | None = None, tldr: bool = False
|
self, tag: str = "", key: str | None = None, tldr: bool = False
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Renders the help menu with command details, usage examples, and tips."""
|
||||||
if tldr and not key:
|
if tldr and not key:
|
||||||
if self.help_command and self.help_command.arg_parser:
|
if self.help_command and self.help_command.arg_parser:
|
||||||
self.help_command.arg_parser.render_tldr()
|
self.help_command.arg_parser.render_tldr()
|
||||||
|
@ -245,6 +245,7 @@ class CommandArgumentParser:
|
|||||||
def _validate_nargs(
|
def _validate_nargs(
|
||||||
self, nargs: int | str | None, action: ArgumentAction
|
self, nargs: int | str | None, action: ArgumentAction
|
||||||
) -> int | str | None:
|
) -> int | str | None:
|
||||||
|
"""Validate the nargs value for the argument."""
|
||||||
if action in (
|
if action in (
|
||||||
ArgumentAction.STORE_FALSE,
|
ArgumentAction.STORE_FALSE,
|
||||||
ArgumentAction.STORE_TRUE,
|
ArgumentAction.STORE_TRUE,
|
||||||
@ -274,6 +275,7 @@ class CommandArgumentParser:
|
|||||||
def _normalize_choices(
|
def _normalize_choices(
|
||||||
self, choices: Iterable | None, expected_type: Any, action: ArgumentAction
|
self, choices: Iterable | None, expected_type: Any, action: ArgumentAction
|
||||||
) -> list[Any]:
|
) -> list[Any]:
|
||||||
|
"""Normalize and validate choices for the argument."""
|
||||||
if choices is not None:
|
if choices is not None:
|
||||||
if action in (
|
if action in (
|
||||||
ArgumentAction.STORE_TRUE,
|
ArgumentAction.STORE_TRUE,
|
||||||
@ -287,10 +289,10 @@ class CommandArgumentParser:
|
|||||||
raise CommandArgumentError("choices cannot be a dict")
|
raise CommandArgumentError("choices cannot be a dict")
|
||||||
try:
|
try:
|
||||||
choices = list(choices)
|
choices = list(choices)
|
||||||
except TypeError:
|
except TypeError as error:
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
"choices must be iterable (like list, tuple, or set)"
|
"choices must be iterable (like list, tuple, or set)"
|
||||||
)
|
) from error
|
||||||
else:
|
else:
|
||||||
choices = []
|
choices = []
|
||||||
for choice in choices:
|
for choice in choices:
|
||||||
@ -317,6 +319,7 @@ class CommandArgumentParser:
|
|||||||
def _validate_default_list_type(
|
def _validate_default_list_type(
|
||||||
self, default: list[Any], expected_type: type, dest: str
|
self, default: list[Any], expected_type: type, dest: str
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Validate the default value type for a list."""
|
||||||
if isinstance(default, list):
|
if isinstance(default, list):
|
||||||
for item in default:
|
for item in default:
|
||||||
try:
|
try:
|
||||||
@ -346,13 +349,14 @@ class CommandArgumentParser:
|
|||||||
def _validate_action(
|
def _validate_action(
|
||||||
self, action: ArgumentAction | str, positional: bool
|
self, action: ArgumentAction | str, positional: bool
|
||||||
) -> ArgumentAction:
|
) -> ArgumentAction:
|
||||||
|
"""Validate the action type."""
|
||||||
if not isinstance(action, ArgumentAction):
|
if not isinstance(action, ArgumentAction):
|
||||||
try:
|
try:
|
||||||
action = ArgumentAction(action)
|
action = ArgumentAction(action)
|
||||||
except ValueError:
|
except ValueError as error:
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
f"Invalid action '{action}' is not a valid ArgumentAction"
|
f"Invalid action '{action}' is not a valid ArgumentAction"
|
||||||
)
|
) from error
|
||||||
if action in (
|
if action in (
|
||||||
ArgumentAction.STORE_TRUE,
|
ArgumentAction.STORE_TRUE,
|
||||||
ArgumentAction.STORE_FALSE,
|
ArgumentAction.STORE_FALSE,
|
||||||
@ -398,18 +402,11 @@ class CommandArgumentParser:
|
|||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
f"Default value cannot be set for action {action}. It is a boolean flag."
|
f"Default value cannot be set for action {action}. It is a boolean flag."
|
||||||
)
|
)
|
||||||
elif action == ArgumentAction.HELP:
|
elif action in (ArgumentAction.HELP, ArgumentAction.TLDR, ArgumentAction.COUNT):
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
"Default value cannot be set for action HELP. It is a help flag."
|
f"Default value cannot be set for action {action}."
|
||||||
)
|
|
||||||
elif action == ArgumentAction.TLDR:
|
|
||||||
raise CommandArgumentError(
|
|
||||||
"Default value cannot be set for action TLDR. It is a tldr flag."
|
|
||||||
)
|
|
||||||
elif action == ArgumentAction.COUNT:
|
|
||||||
raise CommandArgumentError(
|
|
||||||
"Default value cannot be set for action COUNT. It is a count flag."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if action in (ArgumentAction.APPEND, ArgumentAction.EXTEND) and not isinstance(
|
if action in (ArgumentAction.APPEND, ArgumentAction.EXTEND) and not isinstance(
|
||||||
default, list
|
default, list
|
||||||
):
|
):
|
||||||
@ -448,6 +445,7 @@ class CommandArgumentParser:
|
|||||||
dest: str,
|
dest: str,
|
||||||
help: str,
|
help: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Register a store_bool_optional action with the parser."""
|
||||||
if len(flags) != 1:
|
if len(flags) != 1:
|
||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
"store_bool_optional action can only have a single flag"
|
"store_bool_optional action can only have a single flag"
|
||||||
@ -483,7 +481,7 @@ class CommandArgumentParser:
|
|||||||
def _register_argument(
|
def _register_argument(
|
||||||
self, argument: Argument, bypass_validation: bool = False
|
self, argument: Argument, bypass_validation: bool = False
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Register a new argument with the parser."""
|
||||||
for flag in argument.flags:
|
for flag in argument.flags:
|
||||||
if flag in self._flag_map and not bypass_validation:
|
if flag in self._flag_map and not bypass_validation:
|
||||||
existing = self._flag_map[flag]
|
existing = self._flag_map[flag]
|
||||||
@ -653,6 +651,7 @@ class CommandArgumentParser:
|
|||||||
result: dict[str, Any],
|
result: dict[str, Any],
|
||||||
arg_states: dict[str, ArgumentState],
|
arg_states: dict[str, ArgumentState],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Check if the value is in the choices for the argument."""
|
||||||
if not spec.choices:
|
if not spec.choices:
|
||||||
return None
|
return None
|
||||||
value_check = result.get(spec.dest)
|
value_check = result.get(spec.dest)
|
||||||
@ -670,6 +669,7 @@ class CommandArgumentParser:
|
|||||||
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:
|
||||||
|
"""Raise an error for unrecognized options with suggestions."""
|
||||||
consumed_dests = [
|
consumed_dests = [
|
||||||
state.arg.dest for state in arg_states.values() if state.consumed
|
state.arg.dest for state in arg_states.values() if state.consumed
|
||||||
]
|
]
|
||||||
@ -691,6 +691,7 @@ class CommandArgumentParser:
|
|||||||
def _consume_nargs(
|
def _consume_nargs(
|
||||||
self, args: list[str], index: int, spec: Argument
|
self, args: list[str], index: int, spec: Argument
|
||||||
) -> tuple[list[str], int]:
|
) -> tuple[list[str], int]:
|
||||||
|
"""Consume the specified number of arguments based on nargs."""
|
||||||
assert (
|
assert (
|
||||||
spec.nargs is None
|
spec.nargs is None
|
||||||
or isinstance(spec.nargs, int)
|
or isinstance(spec.nargs, int)
|
||||||
@ -736,6 +737,7 @@ class CommandArgumentParser:
|
|||||||
from_validate: bool = False,
|
from_validate: bool = False,
|
||||||
base_index: int = 0,
|
base_index: int = 0,
|
||||||
) -> int:
|
) -> int:
|
||||||
|
"""Consume all positional arguments from the provided args list."""
|
||||||
remaining_positional_args = [
|
remaining_positional_args = [
|
||||||
(spec_index, spec)
|
(spec_index, spec)
|
||||||
for spec_index, spec in enumerate(positional_args)
|
for spec_index, spec in enumerate(positional_args)
|
||||||
@ -809,6 +811,8 @@ class CommandArgumentParser:
|
|||||||
result[spec.dest] = spec.default
|
result[spec.dest] = spec.default
|
||||||
elif spec.action == ArgumentAction.APPEND:
|
elif spec.action == ArgumentAction.APPEND:
|
||||||
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"
|
||||||
|
if not typed:
|
||||||
|
self._raise_suggestion_error(spec)
|
||||||
if spec.nargs is None:
|
if spec.nargs is None:
|
||||||
result[spec.dest].append(typed[0])
|
result[spec.dest].append(typed[0])
|
||||||
else:
|
else:
|
||||||
@ -891,6 +895,34 @@ class CommandArgumentParser:
|
|||||||
valid = False
|
valid = False
|
||||||
return valid
|
return valid
|
||||||
|
|
||||||
|
def _raise_suggestion_error(self, spec: Argument) -> None:
|
||||||
|
"""Raise an error with suggestions for the argument."""
|
||||||
|
help_text = f"help: {spec.help}" if spec.help else ""
|
||||||
|
choices = []
|
||||||
|
if spec.default:
|
||||||
|
choices.append(f"default={spec.default}")
|
||||||
|
if spec.choices:
|
||||||
|
choices.append(f"choices={spec.choices}")
|
||||||
|
if choices:
|
||||||
|
choices.append(help_text)
|
||||||
|
choices_text = ", ".join(choices)
|
||||||
|
raise CommandArgumentError(
|
||||||
|
f"Argument '{spec.dest}' requires a value. {choices_text}"
|
||||||
|
)
|
||||||
|
elif spec.nargs is None:
|
||||||
|
try:
|
||||||
|
raise CommandArgumentError(
|
||||||
|
f"Enter a {spec.type.__name__} value for '{spec.dest}'. {help_text}"
|
||||||
|
)
|
||||||
|
except AttributeError as error:
|
||||||
|
raise CommandArgumentError(
|
||||||
|
f"Enter a value for '{spec.dest}'. {help_text}"
|
||||||
|
) from error
|
||||||
|
else:
|
||||||
|
raise CommandArgumentError(
|
||||||
|
f"Argument '{spec.dest}' requires a value. Expected {spec.nargs} values. {help_text}"
|
||||||
|
)
|
||||||
|
|
||||||
async def _handle_token(
|
async def _handle_token(
|
||||||
self,
|
self,
|
||||||
token: str,
|
token: str,
|
||||||
@ -903,6 +935,7 @@ class CommandArgumentParser:
|
|||||||
arg_states: dict[str, ArgumentState],
|
arg_states: dict[str, ArgumentState],
|
||||||
from_validate: bool = False,
|
from_validate: bool = False,
|
||||||
) -> int:
|
) -> int:
|
||||||
|
"""Handle a single token in the command line arguments."""
|
||||||
if token in self._keyword:
|
if token in self._keyword:
|
||||||
spec = self._keyword[token]
|
spec = self._keyword[token]
|
||||||
action = spec.action
|
action = spec.action
|
||||||
@ -979,8 +1012,10 @@ class CommandArgumentParser:
|
|||||||
raise CommandArgumentError(
|
raise CommandArgumentError(
|
||||||
f"Invalid value for '{spec.dest}': {error}"
|
f"Invalid value for '{spec.dest}': {error}"
|
||||||
) from error
|
) from error
|
||||||
|
if not typed_values:
|
||||||
|
self._raise_suggestion_error(spec)
|
||||||
if spec.nargs is None:
|
if spec.nargs is None:
|
||||||
result[spec.dest].append(spec.type(values[0]))
|
result[spec.dest].append(spec.type(typed_values[0]))
|
||||||
else:
|
else:
|
||||||
result[spec.dest].append(typed_values)
|
result[spec.dest].append(typed_values)
|
||||||
consumed_indices.update(range(index, new_index))
|
consumed_indices.update(range(index, new_index))
|
||||||
@ -1010,27 +1045,7 @@ class CommandArgumentParser:
|
|||||||
f"Invalid value for '{spec.dest}': {error}"
|
f"Invalid value for '{spec.dest}': {error}"
|
||||||
) from error
|
) from error
|
||||||
if not typed_values and spec.nargs not in ("*", "?"):
|
if not typed_values and spec.nargs not in ("*", "?"):
|
||||||
choices = []
|
self._raise_suggestion_error(spec)
|
||||||
if spec.default:
|
|
||||||
choices.append(f"default={spec.default}")
|
|
||||||
if spec.choices:
|
|
||||||
choices.append(f"choices={spec.choices}")
|
|
||||||
if choices:
|
|
||||||
choices_text = ", ".join(choices)
|
|
||||||
raise CommandArgumentError(
|
|
||||||
f"Argument '{spec.dest}' requires a value. {choices_text}"
|
|
||||||
)
|
|
||||||
elif spec.nargs is None:
|
|
||||||
try:
|
|
||||||
raise CommandArgumentError(
|
|
||||||
f"Enter a {spec.type.__name__} value for '{spec.dest}'"
|
|
||||||
)
|
|
||||||
except AttributeError:
|
|
||||||
raise CommandArgumentError(f"Enter a value for '{spec.dest}'")
|
|
||||||
else:
|
|
||||||
raise CommandArgumentError(
|
|
||||||
f"Argument '{spec.dest}' requires a value. Expected {spec.nargs} values."
|
|
||||||
)
|
|
||||||
if spec.nargs in (None, 1, "?"):
|
if spec.nargs in (None, 1, "?"):
|
||||||
result[spec.dest] = (
|
result[spec.dest] = (
|
||||||
typed_values[0] if len(typed_values) == 1 else typed_values
|
typed_values[0] if len(typed_values) == 1 else typed_values
|
||||||
@ -1067,6 +1082,7 @@ class CommandArgumentParser:
|
|||||||
return index
|
return index
|
||||||
|
|
||||||
def _find_last_flag_argument(self, args: list[str]) -> Argument | None:
|
def _find_last_flag_argument(self, args: list[str]) -> Argument | None:
|
||||||
|
"""Find the last flag argument in the provided args."""
|
||||||
last_flag_argument = None
|
last_flag_argument = None
|
||||||
for arg in reversed(args):
|
for arg in reversed(args):
|
||||||
if arg in self._keyword:
|
if arg in self._keyword:
|
||||||
@ -1238,6 +1254,7 @@ class CommandArgumentParser:
|
|||||||
def _is_mid_value(
|
def _is_mid_value(
|
||||||
self, state: ArgumentState | None, args: list[str], cursor_at_end_of_token: bool
|
self, state: ArgumentState | None, args: list[str], cursor_at_end_of_token: bool
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
"""Check if the current state is in the middle of consuming a value."""
|
||||||
if state is None:
|
if state is None:
|
||||||
return False
|
return False
|
||||||
if cursor_at_end_of_token:
|
if cursor_at_end_of_token:
|
||||||
@ -1252,6 +1269,7 @@ class CommandArgumentParser:
|
|||||||
cursor_at_end_of_token: bool,
|
cursor_at_end_of_token: bool,
|
||||||
num_args_since_last_keyword: int,
|
num_args_since_last_keyword: int,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
"""Check if the state indicates an invalid choice condition."""
|
||||||
if isinstance(state.arg.nargs, int):
|
if isinstance(state.arg.nargs, int):
|
||||||
return (
|
return (
|
||||||
state.has_invalid_choice
|
state.has_invalid_choice
|
||||||
@ -1279,6 +1297,7 @@ class CommandArgumentParser:
|
|||||||
cursor_at_end_of_token: bool,
|
cursor_at_end_of_token: bool,
|
||||||
num_args_since_last_keyword: int,
|
num_args_since_last_keyword: int,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
|
"""Return a list of value suggestions for the given argument state."""
|
||||||
if self._is_invalid_choices_state(
|
if self._is_invalid_choices_state(
|
||||||
state, cursor_at_end_of_token, num_args_since_last_keyword
|
state, cursor_at_end_of_token, num_args_since_last_keyword
|
||||||
):
|
):
|
||||||
@ -1420,6 +1439,18 @@ class CommandArgumentParser:
|
|||||||
num_args_since_last_keyword,
|
num_args_since_last_keyword,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
last_keyword_state_in_args.arg.action
|
||||||
|
in (
|
||||||
|
ArgumentAction.APPEND,
|
||||||
|
ArgumentAction.EXTEND,
|
||||||
|
ArgumentAction.COUNT,
|
||||||
|
)
|
||||||
|
and next_to_last not in self._keyword
|
||||||
|
):
|
||||||
|
suggestions.extend(
|
||||||
|
flag for flag in remaining_flags if flag.startswith(last)
|
||||||
|
)
|
||||||
elif not cursor_at_end_of_token:
|
elif not cursor_at_end_of_token:
|
||||||
# Suggest all flags that start with the last token
|
# Suggest all flags that start with the last token
|
||||||
suggestions.extend(
|
suggestions.extend(
|
||||||
@ -1516,6 +1547,16 @@ class CommandArgumentParser:
|
|||||||
num_args_since_last_keyword,
|
num_args_since_last_keyword,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
last_keyword_state_in_args.arg.action
|
||||||
|
in (
|
||||||
|
ArgumentAction.APPEND,
|
||||||
|
ArgumentAction.EXTEND,
|
||||||
|
ArgumentAction.COUNT,
|
||||||
|
)
|
||||||
|
and last not in self._keyword
|
||||||
|
):
|
||||||
|
suggestions.extend(flag for flag in remaining_flags)
|
||||||
else:
|
else:
|
||||||
suggestions.extend(remaining_flags)
|
suggestions.extend(remaining_flags)
|
||||||
# Case 5: Last flag is incomplete and expects a value (e.g., ["--tag", "value1", "va"])
|
# Case 5: Last flag is incomplete and expects a value (e.g., ["--tag", "value1", "va"])
|
||||||
|
@ -1 +1 @@
|
|||||||
__version__ = "0.1.84"
|
__version__ = "0.1.85"
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "falyx"
|
name = "falyx"
|
||||||
version = "0.1.84"
|
version = "0.1.85"
|
||||||
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