Add completions, Add suggestions list to Argument

This commit is contained in:
2025-07-17 20:09:29 -04:00
parent c15e3afa5e
commit 9b9f6434a4
8 changed files with 184 additions and 109 deletions

View File

@ -21,11 +21,13 @@ async def test_args(
service: str, service: str,
place: Place = Place.NEW_YORK, place: Place = Place.NEW_YORK,
region: str = "us-east-1", region: str = "us-east-1",
tag: str | None = None,
verbose: bool | None = None, verbose: bool | None = None,
number: int | None = None,
) -> str: ) -> str:
if verbose: if verbose:
print(f"Deploying {service} to {region} at {place}...") print(f"Deploying {service}:{tag}:{number} to {region} at {place}...")
return f"{service} deployed to {region} at {place}" return f"{service}:{tag}:{number} deployed to {region} at {place}"
def default_config(parser: CommandArgumentParser) -> None: def default_config(parser: CommandArgumentParser) -> None:
@ -55,6 +57,17 @@ def default_config(parser: CommandArgumentParser) -> None:
action="store_bool_optional", action="store_bool_optional",
help="Enable verbose output.", help="Enable verbose output.",
) )
parser.add_argument(
"--tag",
type=str,
help="Optional tag for the deployment.",
suggestions=["latest", "stable", "beta"],
)
parser.add_argument(
"--number",
type=int,
help="Optional number argument.",
)
flx = Falyx("Argument Examples") flx = Falyx("Argument Examples")

View File

@ -507,7 +507,6 @@ class Falyx:
message=self.prompt, message=self.prompt,
multiline=False, multiline=False,
completer=self._get_completer(), completer=self._get_completer(),
reserve_space_for_menu=5,
validator=CommandValidator(self, self._get_validator_error_message()), validator=CommandValidator(self, self._get_validator_error_message()),
bottom_toolbar=self._get_bottom_bar_render(), bottom_toolbar=self._get_bottom_bar_render(),
key_bindings=self.key_bindings, key_bindings=self.key_bindings,

View File

@ -26,6 +26,7 @@ class Argument:
resolver (BaseAction | None): resolver (BaseAction | None):
An action object that resolves the argument, if applicable. An action object that resolves the argument, if applicable.
lazy_resolver (bool): True if the resolver should be called lazily, False otherwise lazy_resolver (bool): True if the resolver should be called lazily, False otherwise
suggestions (list[str] | None): A list of suggestions for the argument.
""" """
flags: tuple[str, ...] flags: tuple[str, ...]
@ -40,6 +41,7 @@ class Argument:
positional: bool = False positional: bool = False
resolver: BaseAction | None = None resolver: BaseAction | None = None
lazy_resolver: bool = False lazy_resolver: bool = False
suggestions: list[str] | None = None
def get_positional_text(self) -> str: def get_positional_text(self) -> str:
"""Get the positional text for the argument.""" """Get the positional text for the argument."""

View File

@ -4,8 +4,8 @@ from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from copy import deepcopy from copy import deepcopy
from difflib import get_close_matches from dataclasses import dataclass
from typing import Any, Iterable from typing import Any, Iterable, Sequence
from rich.console import Console from rich.console import Console
from rich.markup import escape from rich.markup import escape
@ -20,6 +20,12 @@ 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
class CommandArgumentParser: class CommandArgumentParser:
""" """
Custom argument parser for Falyx Commands. Custom argument parser for Falyx Commands.
@ -65,6 +71,8 @@ class CommandArgumentParser:
self._flag_map: dict[str, Argument] = {} self._flag_map: dict[str, Argument] = {}
self._dest_set: set[str] = set() self._dest_set: set[str] = set()
self._add_help() self._add_help()
self._last_positional_states: dict[str, ArgumentState] = {}
self._last_keyword_states: dict[str, ArgumentState] = {}
def _add_help(self): def _add_help(self):
"""Add help argument to the parser.""" """Add help argument to the parser."""
@ -360,19 +368,19 @@ class CommandArgumentParser:
) )
self._register_argument(argument) self._register_argument(argument)
self._register_argument(negated_argument) self._register_argument(negated_argument, bypass_validation=True)
def _register_argument(self, argument: Argument): def _register_argument(
self, argument: Argument, bypass_validation: bool = False
) -> None:
for flag in argument.flags: for flag in argument.flags:
if ( if flag in self._flag_map and not bypass_validation:
flag in self._flag_map
and not argument.action == ArgumentAction.STORE_BOOL_OPTIONAL
):
existing = self._flag_map[flag] existing = self._flag_map[flag]
raise CommandArgumentError( raise CommandArgumentError(
f"Flag '{flag}' is already used by argument '{existing.dest}'" f"Flag '{flag}' is already used by argument '{existing.dest}'"
) )
for flag in argument.flags: for flag in argument.flags:
self._flag_map[flag] = argument self._flag_map[flag] = argument
if not argument.positional: if not argument.positional:
@ -397,6 +405,7 @@ class CommandArgumentParser:
dest: str | None = None, dest: str | None = None,
resolver: BaseAction | None = None, resolver: BaseAction | None = None,
lazy_resolver: bool = True, lazy_resolver: bool = True,
suggestions: list[str] | None = None,
) -> None: ) -> None:
"""Add an argument to the parser. """Add an argument to the parser.
For `ArgumentAction.ACTION`, `nargs` and `type` determine how many and what kind For `ArgumentAction.ACTION`, `nargs` and `type` determine how many and what kind
@ -416,6 +425,8 @@ class CommandArgumentParser:
help: A brief description of the argument. help: A brief description of the argument.
dest: The name of the attribute to be added to the object returned by parse_args(). dest: The name of the attribute to be added to the object returned by parse_args().
resolver: A BaseAction called with optional nargs specified parsed arguments. resolver: A BaseAction called with optional nargs specified parsed arguments.
lazy_resolver: If True, the resolver is called lazily when the argument is accessed.
suggestions: A list of suggestions for the argument.
""" """
expected_type = type expected_type = type
self._validate_flags(flags) self._validate_flags(flags)
@ -446,6 +457,10 @@ class CommandArgumentParser:
f"Default value '{default}' not in allowed choices: {choices}" f"Default value '{default}' not in allowed choices: {choices}"
) )
required = self._determine_required(required, positional, nargs, action) required = self._determine_required(required, positional, nargs, action)
if not isinstance(suggestions, Sequence) and suggestions is not None:
raise CommandArgumentError(
f"suggestions must be a list or None, got {type(suggestions)}"
)
if not isinstance(lazy_resolver, bool): if not isinstance(lazy_resolver, bool):
raise CommandArgumentError( raise CommandArgumentError(
f"lazy_resolver must be a boolean, got {type(lazy_resolver)}" f"lazy_resolver must be a boolean, got {type(lazy_resolver)}"
@ -466,6 +481,7 @@ class CommandArgumentParser:
positional=positional, positional=positional,
resolver=resolver, resolver=resolver,
lazy_resolver=lazy_resolver, lazy_resolver=lazy_resolver,
suggestions=suggestions,
) )
self._register_argument(argument) self._register_argument(argument)
@ -491,6 +507,27 @@ class CommandArgumentParser:
) )
return defs return defs
def raise_remaining_args_error(
self, token: str, arg_states: dict[str, ArgumentState]
) -> None:
consumed_dests = [
state.arg.dest for state in arg_states.values() if state.consumed
]
remaining_flags = [
flag
for flag, arg in self._keyword.items()
if arg.dest not in consumed_dests and flag.startswith(token)
]
if remaining_flags:
raise CommandArgumentError(
f"Unrecognized option '{token}'. Did you mean one of: {', '.join(remaining_flags)}?"
)
else:
raise CommandArgumentError(
f"Unrecognized option '{token}'. Use --help to see available options."
)
def _consume_nargs( def _consume_nargs(
self, args: list[str], start: int, spec: Argument self, args: list[str], start: int, spec: Argument
) -> tuple[list[str], int]: ) -> tuple[list[str], int]:
@ -536,6 +573,7 @@ class CommandArgumentParser:
result: dict[str, Any], result: dict[str, Any],
positional_args: list[Argument], positional_args: list[Argument],
consumed_positional_indicies: set[int], consumed_positional_indicies: set[int],
arg_states: dict[str, ArgumentState],
from_validate: bool = False, from_validate: bool = False,
) -> int: ) -> int:
remaining_positional_args = [ remaining_positional_args = [
@ -581,17 +619,7 @@ class CommandArgumentParser:
except Exception as error: except Exception as error:
if len(args[i - new_i :]) == 1 and args[i - new_i].startswith("-"): if len(args[i - new_i :]) == 1 and args[i - new_i].startswith("-"):
token = args[i - new_i] token = args[i - new_i]
valid_flags = [ self.raise_remaining_args_error(token, arg_states)
flag for flag in self._flag_map if flag.startswith(token)
]
if valid_flags:
raise CommandArgumentError(
f"Unrecognized option '{token}'. Did you mean one of: {', '.join(valid_flags)}?"
) from error
else:
raise CommandArgumentError(
f"Unrecognized option '{token}'. Use --help to see available options."
) from error
else: else:
raise CommandArgumentError( raise CommandArgumentError(
f"Invalid value for '{spec.dest}': {error}" f"Invalid value for '{spec.dest}': {error}"
@ -607,6 +635,7 @@ class CommandArgumentParser:
raise CommandArgumentError( raise CommandArgumentError(
f"[{spec.dest}] Action failed: {error}" f"[{spec.dest}] Action failed: {error}"
) from error ) from error
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
elif spec.action == ArgumentAction.APPEND: elif spec.action == ArgumentAction.APPEND:
@ -619,8 +648,10 @@ 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
else: else:
arg_states[spec.dest].consumed = True
result[spec.dest] = typed result[spec.dest] = typed
if spec.nargs not in ("*", "+"): if spec.nargs not in ("*", "+"):
@ -629,15 +660,7 @@ class CommandArgumentParser:
if i < len(args): if i < len(args):
if len(args[i:]) == 1 and args[i].startswith("-"): if len(args[i:]) == 1 and args[i].startswith("-"):
token = args[i] token = args[i]
valid_flags = [flag for flag in self._flag_map if flag.startswith(token)] self.raise_remaining_args_error(token, arg_states)
if valid_flags:
raise CommandArgumentError(
f"Unrecognized option '{token}'. Did you mean one of: {', '.join(valid_flags)}?"
)
else:
raise CommandArgumentError(
f"Unrecognized option '{token}'. Use --help to see available options."
)
else: else:
plural = "s" if len(args[i:]) > 1 else "" plural = "s" if len(args[i:]) > 1 else ""
raise CommandArgumentError( raise CommandArgumentError(
@ -671,6 +694,7 @@ class CommandArgumentParser:
positional_args: list[Argument], positional_args: list[Argument],
consumed_positional_indices: set[int], consumed_positional_indices: set[int],
consumed_indices: set[int], consumed_indices: set[int],
arg_states: dict[str, ArgumentState],
from_validate: bool = False, from_validate: bool = False,
) -> int: ) -> int:
if token in self._keyword: if token in self._keyword:
@ -680,6 +704,7 @@ class CommandArgumentParser:
if action == ArgumentAction.HELP: if action == ArgumentAction.HELP:
if not from_validate: if not from_validate:
self.render_help() self.render_help()
arg_states[spec.dest].consumed = True
raise HelpSignal() raise HelpSignal()
elif action == ArgumentAction.ACTION: elif action == ArgumentAction.ACTION:
assert isinstance( assert isinstance(
@ -692,24 +717,29 @@ 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 spec.lazy_resolver or not from_validate:
try: try:
result[spec.dest] = await spec.resolver(*typed_values) result[spec.dest] = await spec.resolver(*typed_values)
except Exception as error: except Exception as error:
raise CommandArgumentError( raise CommandArgumentError(
f"[{spec.dest}] Action failed: {error}" f"[{spec.dest}] Action failed: {error}"
) from error ) from error
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
elif action == ArgumentAction.STORE_TRUE: elif action == ArgumentAction.STORE_TRUE:
result[spec.dest] = True result[spec.dest] = True
arg_states[spec.dest].consumed = True
consumed_indices.add(i) consumed_indices.add(i)
i += 1 i += 1
elif action == ArgumentAction.STORE_FALSE: elif action == ArgumentAction.STORE_FALSE:
result[spec.dest] = False result[spec.dest] = False
arg_states[spec.dest].consumed = True
consumed_indices.add(i) consumed_indices.add(i)
i += 1 i += 1
elif action == ArgumentAction.STORE_BOOL_OPTIONAL: elif action == ArgumentAction.STORE_BOOL_OPTIONAL:
result[spec.dest] = spec.type(True) result[spec.dest] = spec.type(True)
arg_states[spec.dest].consumed = True
consumed_indices.add(i) consumed_indices.add(i)
i += 1 i += 1
elif action == ArgumentAction.COUNT: elif action == ArgumentAction.COUNT:
@ -779,19 +809,11 @@ class CommandArgumentParser:
) )
else: else:
result[spec.dest] = typed_values result[spec.dest] = typed_values
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
elif token.startswith("-"): elif token.startswith("-"):
# Handle unrecognized option self.raise_remaining_args_error(token, arg_states)
valid_flags = [flag for flag in self._flag_map if flag.startswith(token)]
if valid_flags:
raise CommandArgumentError(
f"Unrecognized option '{token}'. Did you mean one of: {', '.join(valid_flags)}?"
)
else:
raise CommandArgumentError(
f"Unrecognized option '{token}'. Use --help to see available options."
)
else: else:
# Get the next flagged argument index if it exists # Get the next flagged argument index if it exists
next_flagged_index = -1 next_flagged_index = -1
@ -806,6 +828,7 @@ class CommandArgumentParser:
result, result,
positional_args, positional_args,
consumed_positional_indices, consumed_positional_indices,
arg_states=arg_states,
from_validate=from_validate, from_validate=from_validate,
) )
i += args_consumed i += args_consumed
@ -818,6 +841,14 @@ class CommandArgumentParser:
if args is None: if args is None:
args = [] args = []
arg_states = {arg.dest: ArgumentState(arg) for arg in self._arguments}
self._last_positional_states = {
arg.dest: arg_states[arg.dest] for arg in self._positional.values()
}
self._last_keyword_states = {
arg.dest: arg_states[arg.dest] for arg in self._keyword_list
}
result = {arg.dest: deepcopy(arg.default) for arg in self._arguments} result = {arg.dest: deepcopy(arg.default) for arg in self._arguments}
positional_args: list[Argument] = [ positional_args: list[Argument] = [
arg for arg in self._arguments if arg.positional arg for arg in self._arguments if arg.positional
@ -839,6 +870,7 @@ class CommandArgumentParser:
positional_args, positional_args,
consumed_positional_indices, consumed_positional_indices,
consumed_indices, consumed_indices,
arg_states=arg_states,
from_validate=from_validate, from_validate=from_validate,
) )
@ -863,6 +895,7 @@ class CommandArgumentParser:
) )
if spec.choices and result.get(spec.dest) not in spec.choices: if spec.choices and result.get(spec.dest) not in spec.choices:
arg_states[spec.dest].consumed = False
raise CommandArgumentError( raise CommandArgumentError(
f"Invalid value for '{spec.dest}': must be one of {{{', '.join(spec.choices)}}}" f"Invalid value for '{spec.dest}': must be one of {{{', '.join(spec.choices)}}}"
) )
@ -924,72 +957,79 @@ class CommandArgumentParser:
Returns: Returns:
A list of possible completions based on the current input. A list of possible completions based on the current input.
""" """
consumed_positionals = []
positional_choices = [ # Case 1: Next positional argument
str(choice) next_non_consumed_positional: Argument | None = None
for arg in self._positional.values() for state in self._last_positional_states.values():
for choice in arg.choices if not state.consumed:
if arg.choices next_non_consumed_positional = state.arg
] break
if not args: if next_non_consumed_positional:
# Nothing entered yet: suggest all top-level flags and positionals if next_non_consumed_positional.choices:
if positional_choices:
return sorted(set(positional_choices))
return sorted( return sorted(
set( (str(choice) for choice in next_non_consumed_positional.choices)
flag
for arg in self._arguments
for flag in arg.flags
if not arg.positional
)
) )
if next_non_consumed_positional.suggestions:
return sorted(next_non_consumed_positional.suggestions)
consumed_dests = [
state.arg.dest
for state in self._last_keyword_states.values()
if state.consumed
]
remaining_flags = [
flag for flag, arg in self._keyword.items() if arg.dest not in consumed_dests
]
last = args[-1] last = args[-1]
next_to_last = args[-2] if len(args) > 1 else ""
suggestions: list[str] = [] suggestions: list[str] = []
# Case 1: Mid-flag (e.g., "--ver") # Case 2: Mid-flag (e.g., "--ver")
if last.startswith("-") and not last in self._flag_map: if last.startswith("-") and last not in self._keyword:
possible_flags = [flag for flag in self._flag_map if flag.startswith(last)] if (
len(args) > 1
and next_to_last in self._keyword
and next_to_last in remaining_flags
):
# If the last token is a mid-flag, suggest based on the previous flag
arg = self._keyword[next_to_last]
if arg.choices:
suggestions.extend(arg.choices)
elif arg.suggestions:
suggestions.extend(arg.suggestions)
else:
possible_flags = [
flag
for flag, arg in self._keyword.items()
if flag.startswith(last) and arg.dest not in consumed_dests
]
suggestions.extend(possible_flags) suggestions.extend(possible_flags)
# Case 3: Flag that expects a value (e.g., ["--tag"])
# Case 2: Flag that expects a value (e.g., ["--tag"]) elif last in self._keyword:
elif last in self._flag_map: arg = self._keyword[last]
arg = self._flag_map[last]
if arg.choices: if arg.choices:
suggestions.extend(arg.choices) suggestions.extend(arg.choices)
elif arg.suggestions:
# Case 3: Just completed a flag, suggest next suggestions.extend(arg.suggestions)
elif len(args) >= 2 and args[-2] in self._flag_map: # Case 4: Last flag with choices mid-choice (e.g., ["--tag", "v"])
# Just gave value for a flag, now suggest next possible elif next_to_last in self._keyword:
used_dests = { arg = self._keyword[next_to_last]
self._flag_map[arg].dest for arg in args if arg in self._flag_map if arg.choices and last not in arg.choices:
}
remaining_args = [
a
for a in self._arguments
if not a.positional
and a.dest not in used_dests
and a.action != ArgumentAction.HELP
]
for arg in remaining_args:
suggestions.extend(arg.flags)
# Case 4: Positional values not yet filled
else:
consumed_positionals = [arg for arg in self._arguments if arg.positional][
: len(args)
]
remaining_positionals = [
arg
for arg in self._arguments
if arg.positional and arg not in consumed_positionals
]
if remaining_positionals:
arg = remaining_positionals[0]
if arg.choices:
suggestions.extend(arg.choices) suggestions.extend(arg.choices)
elif (
arg.suggestions
and last not in arg.suggestions
and not any(last.startswith(suggestion) for suggestion in arg.suggestions)
and any(suggestion.startswith(last) for suggestion in arg.suggestions)
):
suggestions.extend(arg.suggestions)
else: else:
suggestions.append(f"<{arg.dest}>") # generic placeholder suggestions.extend(remaining_flags)
# Case 5: Suggest all remaining flags
else:
suggestions.extend(remaining_flags)
return sorted(set(suggestions)) return sorted(set(suggestions))

View File

@ -54,8 +54,10 @@ def infer_args_from_func(
if arg_type is bool: if arg_type is bool:
if param.default is False: if param.default is False:
action = "store_true" action = "store_true"
else: default = None
elif param.default is True:
action = "store_false" action = "store_false"
default = None
if arg_type is list: if arg_type is list:
action = "append" action = "append"
@ -75,6 +77,7 @@ def infer_args_from_func(
"action": action, "action": action,
"help": metadata.get("help", ""), "help": metadata.get("help", ""),
"choices": metadata.get("choices"), "choices": metadata.get("choices"),
"suggestions": metadata.get("suggestions"),
} }
) )

View File

@ -1 +1 @@
__version__ = "0.1.62" __version__ = "0.1.63"

View File

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

View File

@ -0,0 +1,18 @@
import pytest
from falyx.parser.command_argument_parser import CommandArgumentParser
@pytest.mark.asyncio
@pytest.mark.parametrize(
"input_tokens, expected",
[
([""], ["--help", "--tag", "-h"]),
(["--ta"], ["--tag"]),
(["--tag"], ["analytics", "build"]),
],
)
async def test_suggest_next(input_tokens, expected):
parser = CommandArgumentParser(...)
parser.add_argument("--tag", choices=["analytics", "build"])
assert sorted(parser.suggest_next(input_tokens)) == sorted(expected)