diff --git a/falyx/action/select_file_action.py b/falyx/action/select_file_action.py index d043308..568d026 100644 --- a/falyx/action/select_file_action.py +++ b/falyx/action/select_file_action.py @@ -165,6 +165,11 @@ class SelectFileAction(BaseAction): try: await self.hooks.trigger(HookType.BEFORE, context) + if not self.directory.exists(): + raise FileNotFoundError(f"Directory {self.directory} does not exist.") + elif not self.directory.is_dir(): + raise NotADirectoryError(f"{self.directory} is not a directory.") + files = [ file for file in self.directory.iterdir() diff --git a/falyx/command.py b/falyx/command.py index 58dc848..c59395b 100644 --- a/falyx/command.py +++ b/falyx/command.py @@ -91,6 +91,12 @@ class Command(BaseModel): logging_hooks (bool): Whether to attach logging hooks automatically. options_manager (OptionsManager): Manages global command-line options. arg_parser (CommandArgumentParser): Parses command arguments. + arguments (list[dict[str, Any]]): Argument definitions for the command. + argument_config (Callable[[CommandArgumentParser], None] | None): Function to configure arguments + for the command parser. + arg_metadata (dict[str, str | dict[str, Any]]): Metadata for arguments, + such as help text or choices. + simple_help_signature (bool): Whether to use a simplified help signature. custom_parser (ArgParserProtocol | None): Custom argument parser. custom_help (Callable[[], str | None] | None): Custom help message generator. auto_args (bool): Automatically infer arguments from the action. @@ -227,7 +233,7 @@ class Command(BaseModel): if self.logging_hooks and isinstance(self.action, BaseAction): register_debug_hooks(self.action.hooks) - if self.arg_parser is None: + if self.arg_parser is None and not self.custom_parser: self.arg_parser = CommandArgumentParser( command_key=self.key, command_description=self.description, diff --git a/falyx/completer.py b/falyx/completer.py new file mode 100644 index 0000000..5886cfb --- /dev/null +++ b/falyx/completer.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import shlex +from typing import TYPE_CHECKING, Iterable + +from prompt_toolkit.completion import Completer, Completion +from prompt_toolkit.document import Document + +if TYPE_CHECKING: + from falyx import Falyx + + +class FalyxCompleter(Completer): + """Completer for Falyx commands.""" + + def __init__(self, falyx: "Falyx"): + self.falyx = falyx + + def get_completions(self, document: Document, complete_event) -> Iterable[Completion]: + text = document.text_before_cursor + try: + tokens = shlex.split(text) + cursor_at_end_of_token = document.text_before_cursor.endswith((" ", "\t")) + except ValueError: + return + + if not tokens or (len(tokens) == 1 and not cursor_at_end_of_token): + # Suggest command keys and aliases + yield from self._suggest_commands(tokens[0] if tokens else "") + return + + def _suggest_commands(self, prefix: str) -> Iterable[Completion]: + prefix = prefix.upper() + keys = [self.falyx.exit_command.key] + keys.extend(self.falyx.exit_command.aliases) + if self.falyx.history_command: + keys.append(self.falyx.history_command.key) + keys.extend(self.falyx.history_command.aliases) + if self.falyx.help_command: + keys.append(self.falyx.help_command.key) + keys.extend(self.falyx.help_command.aliases) + for cmd in self.falyx.commands.values(): + keys.append(cmd.key) + keys.extend(cmd.aliases) + for key in keys: + if key.upper().startswith(prefix): + yield Completion(key, start_position=-len(prefix)) diff --git a/falyx/falyx.py b/falyx/falyx.py index 62390d7..c26a108 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -32,7 +32,6 @@ from functools import cached_property from typing import Any, Callable from prompt_toolkit import PromptSession -from prompt_toolkit.completion import WordCompleter from prompt_toolkit.formatted_text import AnyFormattedText from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.patch_stdout import patch_stdout @@ -46,6 +45,7 @@ from falyx.action.action import Action from falyx.action.base_action import BaseAction from falyx.bottom_bar import BottomBar from falyx.command import Command +from falyx.completer import FalyxCompleter from falyx.context import ExecutionContext from falyx.debug import log_after, log_before, log_error, log_success from falyx.exceptions import ( @@ -413,20 +413,9 @@ class Falyx: arg_parser=parser, ) - def _get_completer(self) -> WordCompleter: + def _get_completer(self) -> FalyxCompleter: """Completer to provide auto-completion for the menu commands.""" - keys = [self.exit_command.key] - keys.extend(self.exit_command.aliases) - if self.history_command: - keys.append(self.history_command.key) - keys.extend(self.history_command.aliases) - if self.help_command: - keys.append(self.help_command.key) - keys.extend(self.help_command.aliases) - for cmd in self.commands.values(): - keys.append(cmd.key) - keys.extend(cmd.aliases) - return WordCompleter(keys, ignore_case=True) + return FalyxCompleter(self) def _get_validator_error_message(self) -> str: """Validator to check if the input is a valid command or toggle key.""" diff --git a/falyx/parser/command_argument_parser.py b/falyx/parser/command_argument_parser.py index 4ccd468..86969b8 100644 --- a/falyx/parser/command_argument_parser.py +++ b/falyx/parser/command_argument_parser.py @@ -7,7 +7,6 @@ from typing import Any, Iterable from rich.console import Console from rich.markup import escape -from rich.text import Text from falyx.action.base_action import BaseAction from falyx.exceptions import CommandArgumentError @@ -466,6 +465,10 @@ class CommandArgumentParser: or isinstance(next_spec.nargs, str) and next_spec.nargs in ("+", "*", "?") ), f"Invalid nargs value: {spec.nargs}" + + if next_spec.default: + continue + if next_spec.nargs is None: min_required += 1 elif isinstance(next_spec.nargs, int): @@ -473,9 +476,9 @@ class CommandArgumentParser: elif next_spec.nargs == "+": min_required += 1 elif next_spec.nargs == "?": - min_required += 0 + continue elif next_spec.nargs == "*": - min_required += 0 + continue slice_args = args[i:] if is_last else args[i : i + (remaining - min_required)] values, new_i = self._consume_nargs(slice_args, 0, spec) @@ -484,9 +487,23 @@ class CommandArgumentParser: try: typed = [coerce_value(value, spec.type) for value in values] except Exception as error: - raise CommandArgumentError( - f"Invalid value for '{spec.dest}': {error}" - ) from 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 + else: + raise CommandArgumentError( + f"Invalid value for '{spec.dest}': {error}" + ) from error if spec.action == ArgumentAction.ACTION: assert isinstance( spec.resolver, BaseAction @@ -497,6 +514,8 @@ class CommandArgumentParser: raise CommandArgumentError( f"[{spec.dest}] Action failed: {error}" ) from error + elif not typed and spec.default: + result[spec.dest] = spec.default elif spec.action == ArgumentAction.APPEND: assert result.get(spec.dest) is not None, "dest should not be None" if spec.nargs is None: @@ -515,10 +534,22 @@ class CommandArgumentParser: consumed_positional_indicies.add(j) if i < len(args): - plural = "s" if len(args[i:]) > 1 else "" - raise CommandArgumentError( - f"Unexpected positional argument{plural}: {', '.join(args[i:])}" - ) + 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." + ) + else: + plural = "s" if len(args[i:]) > 1 else "" + raise CommandArgumentError( + f"Unexpected positional argument{plural}: {', '.join(args[i:])}" + ) return i @@ -624,8 +655,22 @@ class CommandArgumentParser: f"Invalid value for '{spec.dest}': {error}" ) from error if not typed_values and spec.nargs not in ("*", "?"): + choices = [] + if spec.default: + choices.append(f"default={spec.default!r}") + if spec.choices: + choices.append(f"choices={spec.choices!r}") + if choices: + choices_text = ", ".join(choices) + raise CommandArgumentError( + f"Argument '{spec.dest}' requires a value. {choices_text}" + ) + if spec.nargs is None: + raise CommandArgumentError( + f"Enter a {spec.type.__name__} value for '{spec.dest}'" + ) raise CommandArgumentError( - f"Expected at least one value for '{spec.dest}'" + f"Argument '{spec.dest}' requires a value. Expected {spec.nargs} values." ) if spec.nargs in (None, 1, "?") and spec.action != ArgumentAction.APPEND: result[spec.dest] = ( @@ -637,7 +682,15 @@ class CommandArgumentParser: i = new_i elif token.startswith("-"): # Handle unrecognized option - raise CommandArgumentError(f"Unrecognized flag: {token}") + 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: # Get the next flagged argument index if it exists next_flagged_index = -1 @@ -692,7 +745,10 @@ class CommandArgumentParser: if spec.dest == "help": continue if spec.required and not result.get(spec.dest): - raise CommandArgumentError(f"Missing required argument: {spec.dest}") + help_text = f" help: {spec.help}" if spec.help else "" + raise CommandArgumentError( + f"Missing required argument {spec.dest}: {spec.get_choice_text()}{help_text}" + ) if spec.choices and result.get(spec.dest) not in spec.choices: raise CommandArgumentError( @@ -807,22 +863,20 @@ class CommandArgumentParser: self.console.print("[bold]positional:[/bold]") for arg in self._positional.values(): flags = arg.get_positional_text() - arg_line = Text(f" {flags:<30} ") + arg_line = f" {flags:<30} " help_text = arg.help or "" if help_text and len(flags) > 30: help_text = f"\n{'':<33}{help_text}" - arg_line.append(help_text) - self.console.print(arg_line) + self.console.print(f"{arg_line}{help_text}") self.console.print("[bold]options:[/bold]") for arg in self._keyword_list: flags = ", ".join(arg.flags) flags_choice = f"{flags} {arg.get_choice_text()}" - arg_line = Text(f" {flags_choice:<30} ") + arg_line = f" {flags_choice:<30} " help_text = arg.help or "" if help_text and len(flags_choice) > 30: help_text = f"\n{'':<33}{help_text}" - arg_line.append(help_text) - self.console.print(arg_line) + self.console.print(f"{arg_line}{help_text}") # Epilog if self.help_epilog: diff --git a/falyx/version.py b/falyx/version.py index 5325e8e..5b3ab24 100644 --- a/falyx/version.py +++ b/falyx/version.py @@ -1 +1 @@ -__version__ = "0.1.55" +__version__ = "0.1.56" diff --git a/pyproject.toml b/pyproject.toml index de2d7bc..edf7800 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "falyx" -version = "0.1.55" +version = "0.1.56" description = "Reliable and introspectable async CLI action framework." authors = ["Roland Thomas Jr "] license = "MIT" diff --git a/tests/test_command_argument_parser.py b/tests/test_command_argument_parser.py index 71b90b8..9882aa7 100644 --- a/tests/test_command_argument_parser.py +++ b/tests/test_command_argument_parser.py @@ -403,8 +403,9 @@ async def test_parse_args_nargs(): parser.add_argument("--action", action="store_true") args = await parser.parse_args(["a", "b", "c", "--action"]) + assert args["files"] == ["a", "b"] + assert args["mode"] == "c" args = await parser.parse_args(["--action", "a", "b", "c"]) - assert args["files"] == ["a", "b"] assert args["mode"] == "c" diff --git a/tests/test_parsers/test_multiple_positional.py b/tests/test_parsers/test_multiple_positional.py new file mode 100644 index 0000000..aac07f9 --- /dev/null +++ b/tests/test_parsers/test_multiple_positional.py @@ -0,0 +1,25 @@ +import pytest + +from falyx.parser import CommandArgumentParser + + +@pytest.mark.asyncio +async def test_multiple_positional(): + parser = CommandArgumentParser() + parser.add_argument("files", nargs="+") + parser.add_argument("mode", choices=["edit", "view"]) + + args = await parser.parse_args(["a", "b", "c", "edit"]) + assert args["files"] == ["a", "b", "c"] + assert args["mode"] == "edit" + + +@pytest.mark.asyncio +async def test_multiple_positional_with_default(): + parser = CommandArgumentParser() + parser.add_argument("files", nargs="+") + parser.add_argument("mode", choices=["edit", "view"], default="edit") + + args = await parser.parse_args(["a", "b", "c"]) + assert args["files"] == ["a", "b", "c"] + assert args["mode"] == "edit"