From c15e3afa5eeb9e8b295a6b6df607ef9d5a0f8564 Mon Sep 17 00:00:00 2001 From: Roland Thomas Date: Wed, 16 Jul 2025 18:55:22 -0400 Subject: [PATCH 1/2] Working on completions --- falyx/completer.py | 20 +++++++ falyx/falyx.py | 2 +- falyx/parser/command_argument_parser.py | 79 +++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/falyx/completer.py b/falyx/completer.py index 5886cfb..caa5d5d 100644 --- a/falyx/completer.py +++ b/falyx/completer.py @@ -29,6 +29,26 @@ class FalyxCompleter(Completer): yield from self._suggest_commands(tokens[0] if tokens else "") return + # Identify command + command_key = tokens[0].upper() + command = self.falyx._name_map.get(command_key) + if not command or not command.arg_parser: + return + + # If at end of token, e.g., "--t" vs "--tag ", add a stub so suggest_next sees it + parsed_args = tokens[1:] if cursor_at_end_of_token else tokens[1:-1] + stub = "" if cursor_at_end_of_token else tokens[-1] + + try: + suggestions = command.arg_parser.suggest_next( + parsed_args + ([stub] if stub else []) + ) + for suggestion in suggestions: + if suggestion.startswith(stub): + yield Completion(suggestion, start_position=-len(stub)) + except Exception: + return + def _suggest_commands(self, prefix: str) -> Iterable[Completion]: prefix = prefix.upper() keys = [self.falyx.exit_command.key] diff --git a/falyx/falyx.py b/falyx/falyx.py index fe39635..c3aa88d 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -507,7 +507,7 @@ class Falyx: message=self.prompt, multiline=False, completer=self._get_completer(), - reserve_space_for_menu=1, + reserve_space_for_menu=5, validator=CommandValidator(self, self._get_validator_error_message()), bottom_toolbar=self._get_bottom_bar_render(), key_bindings=self.key_bindings, diff --git a/falyx/parser/command_argument_parser.py b/falyx/parser/command_argument_parser.py index dce3be7..51e5166 100644 --- a/falyx/parser/command_argument_parser.py +++ b/falyx/parser/command_argument_parser.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections import defaultdict from copy import deepcopy +from difflib import get_close_matches from typing import Any, Iterable from rich.console import Console @@ -914,6 +915,84 @@ class CommandArgumentParser: kwargs_dict[arg.dest] = parsed[arg.dest] return tuple(args_list), kwargs_dict + def suggest_next(self, args: list[str]) -> list[str]: + """ + Suggest the next possible flags or values given partially typed arguments. + + This does NOT raise errors. It is intended for completions, not validation. + + Returns: + A list of possible completions based on the current input. + """ + consumed_positionals = [] + positional_choices = [ + str(choice) + for arg in self._positional.values() + for choice in arg.choices + if arg.choices + ] + if not args: + # Nothing entered yet: suggest all top-level flags and positionals + if positional_choices: + return sorted(set(positional_choices)) + return sorted( + set( + flag + for arg in self._arguments + for flag in arg.flags + if not arg.positional + ) + ) + + last = args[-1] + suggestions: list[str] = [] + + # Case 1: Mid-flag (e.g., "--ver") + if last.startswith("-") and not last in self._flag_map: + possible_flags = [flag for flag in self._flag_map if flag.startswith(last)] + suggestions.extend(possible_flags) + + # Case 2: Flag that expects a value (e.g., ["--tag"]) + elif last in self._flag_map: + arg = self._flag_map[last] + if arg.choices: + suggestions.extend(arg.choices) + + # Case 3: Just completed a flag, suggest next + elif len(args) >= 2 and args[-2] in self._flag_map: + # Just gave value for a flag, now suggest next possible + used_dests = { + self._flag_map[arg].dest for arg in args if arg in self._flag_map + } + 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) + else: + suggestions.append(f"<{arg.dest}>") # generic placeholder + + return sorted(set(suggestions)) + def get_options_text(self, plain_text=False) -> str: # Options # Add all keyword arguments to the options list From 9b9f6434a4c046dfb16a8c0f16c8347e5baad6a1 Mon Sep 17 00:00:00 2001 From: Roland Thomas Date: Thu, 17 Jul 2025 20:09:29 -0400 Subject: [PATCH 2/2] Add completions, Add suggestions list to Argument --- examples/argument_examples.py | 17 +- falyx/falyx.py | 1 - falyx/parser/argument.py | 2 + falyx/parser/command_argument_parser.py | 246 ++++++++++++++---------- falyx/parser/signature.py | 5 +- falyx/version.py | 2 +- pyproject.toml | 2 +- tests/test_parsers/test_completions.py | 18 ++ 8 files changed, 184 insertions(+), 109 deletions(-) create mode 100644 tests/test_parsers/test_completions.py diff --git a/examples/argument_examples.py b/examples/argument_examples.py index f138522..6793c27 100644 --- a/examples/argument_examples.py +++ b/examples/argument_examples.py @@ -21,11 +21,13 @@ async def test_args( service: str, place: Place = Place.NEW_YORK, region: str = "us-east-1", + tag: str | None = None, verbose: bool | None = None, + number: int | None = None, ) -> str: if verbose: - print(f"Deploying {service} to {region} at {place}...") - return f"{service} deployed to {region} at {place}" + print(f"Deploying {service}:{tag}:{number} to {region} at {place}...") + return f"{service}:{tag}:{number} deployed to {region} at {place}" def default_config(parser: CommandArgumentParser) -> None: @@ -55,6 +57,17 @@ def default_config(parser: CommandArgumentParser) -> None: action="store_bool_optional", 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") diff --git a/falyx/falyx.py b/falyx/falyx.py index c3aa88d..acc3718 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -507,7 +507,6 @@ class Falyx: message=self.prompt, multiline=False, completer=self._get_completer(), - reserve_space_for_menu=5, validator=CommandValidator(self, self._get_validator_error_message()), bottom_toolbar=self._get_bottom_bar_render(), key_bindings=self.key_bindings, diff --git a/falyx/parser/argument.py b/falyx/parser/argument.py index 18850ad..f029fde 100644 --- a/falyx/parser/argument.py +++ b/falyx/parser/argument.py @@ -26,6 +26,7 @@ class Argument: resolver (BaseAction | None): An action object that resolves the argument, if applicable. 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, ...] @@ -40,6 +41,7 @@ class Argument: positional: bool = False resolver: BaseAction | None = None lazy_resolver: bool = False + suggestions: list[str] | None = None def get_positional_text(self) -> str: """Get the positional text for the argument.""" diff --git a/falyx/parser/command_argument_parser.py b/falyx/parser/command_argument_parser.py index 51e5166..c81c06a 100644 --- a/falyx/parser/command_argument_parser.py +++ b/falyx/parser/command_argument_parser.py @@ -4,8 +4,8 @@ from __future__ import annotations from collections import defaultdict from copy import deepcopy -from difflib import get_close_matches -from typing import Any, Iterable +from dataclasses import dataclass +from typing import Any, Iterable, Sequence from rich.console import Console from rich.markup import escape @@ -20,6 +20,12 @@ from falyx.parser.utils import coerce_value from falyx.signals import HelpSignal +@dataclass +class ArgumentState: + arg: Argument + consumed: bool = False + + class CommandArgumentParser: """ Custom argument parser for Falyx Commands. @@ -65,6 +71,8 @@ class CommandArgumentParser: self._flag_map: dict[str, Argument] = {} self._dest_set: set[str] = set() self._add_help() + self._last_positional_states: dict[str, ArgumentState] = {} + self._last_keyword_states: dict[str, ArgumentState] = {} def _add_help(self): """Add help argument to the parser.""" @@ -360,19 +368,19 @@ class CommandArgumentParser: ) 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: - if ( - flag in self._flag_map - and not argument.action == ArgumentAction.STORE_BOOL_OPTIONAL - ): + if flag in self._flag_map and not bypass_validation: existing = self._flag_map[flag] raise CommandArgumentError( f"Flag '{flag}' is already used by argument '{existing.dest}'" ) + for flag in argument.flags: self._flag_map[flag] = argument if not argument.positional: @@ -397,6 +405,7 @@ class CommandArgumentParser: dest: str | None = None, resolver: BaseAction | None = None, lazy_resolver: bool = True, + suggestions: list[str] | None = None, ) -> None: """Add an argument to the parser. 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. 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. + 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 self._validate_flags(flags) @@ -446,6 +457,10 @@ class CommandArgumentParser: f"Default value '{default}' not in allowed choices: {choices}" ) 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): raise CommandArgumentError( f"lazy_resolver must be a boolean, got {type(lazy_resolver)}" @@ -466,6 +481,7 @@ class CommandArgumentParser: positional=positional, resolver=resolver, lazy_resolver=lazy_resolver, + suggestions=suggestions, ) self._register_argument(argument) @@ -491,6 +507,27 @@ class CommandArgumentParser: ) 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( self, args: list[str], start: int, spec: Argument ) -> tuple[list[str], int]: @@ -536,6 +573,7 @@ class CommandArgumentParser: result: dict[str, Any], positional_args: list[Argument], consumed_positional_indicies: set[int], + arg_states: dict[str, ArgumentState], from_validate: bool = False, ) -> int: remaining_positional_args = [ @@ -581,17 +619,7 @@ class CommandArgumentParser: except Exception as error: if len(args[i - new_i :]) == 1 and args[i - new_i].startswith("-"): token = args[i - new_i] - 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)}?" - ) from error - else: - raise CommandArgumentError( - f"Unrecognized option '{token}'. Use --help to see available options." - ) from error + self.raise_remaining_args_error(token, arg_states) else: raise CommandArgumentError( f"Invalid value for '{spec.dest}': {error}" @@ -607,6 +635,7 @@ class CommandArgumentParser: raise CommandArgumentError( f"[{spec.dest}] Action failed: {error}" ) from error + arg_states[spec.dest].consumed = True elif not typed and spec.default: result[spec.dest] = spec.default elif spec.action == ArgumentAction.APPEND: @@ -619,8 +648,10 @@ 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 else: + arg_states[spec.dest].consumed = True result[spec.dest] = typed if spec.nargs not in ("*", "+"): @@ -629,15 +660,7 @@ class CommandArgumentParser: if i < len(args): if len(args[i:]) == 1 and args[i].startswith("-"): token = args[i] - 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." - ) + self.raise_remaining_args_error(token, arg_states) else: plural = "s" if len(args[i:]) > 1 else "" raise CommandArgumentError( @@ -671,6 +694,7 @@ class CommandArgumentParser: positional_args: list[Argument], consumed_positional_indices: set[int], consumed_indices: set[int], + arg_states: dict[str, ArgumentState], from_validate: bool = False, ) -> int: if token in self._keyword: @@ -680,6 +704,7 @@ class CommandArgumentParser: if action == ArgumentAction.HELP: if not from_validate: self.render_help() + arg_states[spec.dest].consumed = True raise HelpSignal() elif action == ArgumentAction.ACTION: assert isinstance( @@ -692,24 +717,29 @@ class CommandArgumentParser: raise CommandArgumentError( f"Invalid value for '{spec.dest}': {error}" ) from error - try: - result[spec.dest] = await spec.resolver(*typed_values) - except Exception as error: - raise CommandArgumentError( - f"[{spec.dest}] Action failed: {error}" - ) from error + if not spec.lazy_resolver or not from_validate: + try: + result[spec.dest] = await spec.resolver(*typed_values) + except Exception as error: + raise CommandArgumentError( + f"[{spec.dest}] Action failed: {error}" + ) from error + arg_states[spec.dest].consumed = True consumed_indices.update(range(i, new_i)) i = new_i elif action == ArgumentAction.STORE_TRUE: result[spec.dest] = True + arg_states[spec.dest].consumed = True consumed_indices.add(i) i += 1 elif action == ArgumentAction.STORE_FALSE: result[spec.dest] = False + arg_states[spec.dest].consumed = True consumed_indices.add(i) i += 1 elif action == ArgumentAction.STORE_BOOL_OPTIONAL: result[spec.dest] = spec.type(True) + arg_states[spec.dest].consumed = True consumed_indices.add(i) i += 1 elif action == ArgumentAction.COUNT: @@ -779,19 +809,11 @@ class CommandArgumentParser: ) else: result[spec.dest] = typed_values + arg_states[spec.dest].consumed = True consumed_indices.update(range(i, new_i)) i = new_i elif token.startswith("-"): - # Handle unrecognized option - 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." - ) + self.raise_remaining_args_error(token, arg_states) else: # Get the next flagged argument index if it exists next_flagged_index = -1 @@ -806,6 +828,7 @@ class CommandArgumentParser: result, positional_args, consumed_positional_indices, + arg_states=arg_states, from_validate=from_validate, ) i += args_consumed @@ -818,6 +841,14 @@ class CommandArgumentParser: if args is None: 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} positional_args: list[Argument] = [ arg for arg in self._arguments if arg.positional @@ -839,6 +870,7 @@ class CommandArgumentParser: positional_args, consumed_positional_indices, consumed_indices, + arg_states=arg_states, from_validate=from_validate, ) @@ -863,6 +895,7 @@ class CommandArgumentParser: ) if spec.choices and result.get(spec.dest) not in spec.choices: + arg_states[spec.dest].consumed = False raise CommandArgumentError( f"Invalid value for '{spec.dest}': must be one of {{{', '.join(spec.choices)}}}" ) @@ -924,72 +957,79 @@ class CommandArgumentParser: Returns: A list of possible completions based on the current input. """ - consumed_positionals = [] - positional_choices = [ - str(choice) - for arg in self._positional.values() - for choice in arg.choices - if arg.choices - ] - if not args: - # Nothing entered yet: suggest all top-level flags and positionals - if positional_choices: - return sorted(set(positional_choices)) - return sorted( - set( - flag - for arg in self._arguments - for flag in arg.flags - if not arg.positional + + # Case 1: Next positional argument + next_non_consumed_positional: Argument | None = None + for state in self._last_positional_states.values(): + if not state.consumed: + next_non_consumed_positional = state.arg + break + if next_non_consumed_positional: + if next_non_consumed_positional.choices: + return sorted( + (str(choice) for choice in next_non_consumed_positional.choices) ) - ) + 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] + next_to_last = args[-2] if len(args) > 1 else "" suggestions: list[str] = [] - # Case 1: Mid-flag (e.g., "--ver") - if last.startswith("-") and not last in self._flag_map: - possible_flags = [flag for flag in self._flag_map if flag.startswith(last)] - suggestions.extend(possible_flags) - - # Case 2: Flag that expects a value (e.g., ["--tag"]) - elif last in self._flag_map: - arg = self._flag_map[last] - if arg.choices: - suggestions.extend(arg.choices) - - # Case 3: Just completed a flag, suggest next - elif len(args) >= 2 and args[-2] in self._flag_map: - # Just gave value for a flag, now suggest next possible - used_dests = { - self._flag_map[arg].dest for arg in args if arg in self._flag_map - } - 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] + # Case 2: Mid-flag (e.g., "--ver") + if last.startswith("-") and last not in self._keyword: + 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) - else: - suggestions.append(f"<{arg.dest}>") # generic placeholder + 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) + # Case 3: Flag that expects a value (e.g., ["--tag"]) + elif last in self._keyword: + arg = self._keyword[last] + if 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: + 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: + suggestions.extend(remaining_flags) + # Case 5: Suggest all remaining flags + else: + suggestions.extend(remaining_flags) return sorted(set(suggestions)) diff --git a/falyx/parser/signature.py b/falyx/parser/signature.py index 4224fbe..720dcd1 100644 --- a/falyx/parser/signature.py +++ b/falyx/parser/signature.py @@ -54,8 +54,10 @@ def infer_args_from_func( if arg_type is bool: if param.default is False: action = "store_true" - else: + default = None + elif param.default is True: action = "store_false" + default = None if arg_type is list: action = "append" @@ -75,6 +77,7 @@ def infer_args_from_func( "action": action, "help": metadata.get("help", ""), "choices": metadata.get("choices"), + "suggestions": metadata.get("suggestions"), } ) diff --git a/falyx/version.py b/falyx/version.py index 5c2098c..2d5664e 100644 --- a/falyx/version.py +++ b/falyx/version.py @@ -1 +1 @@ -__version__ = "0.1.62" +__version__ = "0.1.63" diff --git a/pyproject.toml b/pyproject.toml index 39a60e6..98b019a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "falyx" -version = "0.1.62" +version = "0.1.63" description = "Reliable and introspectable async CLI action framework." authors = ["Roland Thomas Jr "] license = "MIT" diff --git a/tests/test_parsers/test_completions.py b/tests/test_parsers/test_completions.py new file mode 100644 index 0000000..672cd37 --- /dev/null +++ b/tests/test_parsers/test_completions.py @@ -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)