Add FalyxCompleter, Add check for valid directory for SelectFileAction, Add more detail to error messages in CommandArgumentParser, Don't initialize CAP if a custom parser is used

This commit is contained in:
2025-07-03 00:58:57 -04:00
parent e2f0bf5903
commit ed42f6488e
9 changed files with 164 additions and 37 deletions

View File

@ -165,6 +165,11 @@ class SelectFileAction(BaseAction):
try: try:
await self.hooks.trigger(HookType.BEFORE, context) 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 = [ files = [
file file
for file in self.directory.iterdir() for file in self.directory.iterdir()

View File

@ -91,6 +91,12 @@ class Command(BaseModel):
logging_hooks (bool): Whether to attach logging hooks automatically. logging_hooks (bool): Whether to attach logging hooks automatically.
options_manager (OptionsManager): Manages global command-line options. options_manager (OptionsManager): Manages global command-line options.
arg_parser (CommandArgumentParser): Parses command arguments. 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_parser (ArgParserProtocol | None): Custom argument parser.
custom_help (Callable[[], str | None] | None): Custom help message generator. custom_help (Callable[[], str | None] | None): Custom help message generator.
auto_args (bool): Automatically infer arguments from the action. 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): if self.logging_hooks and isinstance(self.action, BaseAction):
register_debug_hooks(self.action.hooks) 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( self.arg_parser = CommandArgumentParser(
command_key=self.key, command_key=self.key,
command_description=self.description, command_description=self.description,

47
falyx/completer.py Normal file
View File

@ -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))

View File

@ -32,7 +32,6 @@ from functools import cached_property
from typing import Any, Callable from typing import Any, Callable
from prompt_toolkit import PromptSession from prompt_toolkit import PromptSession
from prompt_toolkit.completion import WordCompleter
from prompt_toolkit.formatted_text import AnyFormattedText from prompt_toolkit.formatted_text import AnyFormattedText
from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.patch_stdout import patch_stdout 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.action.base_action import BaseAction
from falyx.bottom_bar import BottomBar from falyx.bottom_bar import BottomBar
from falyx.command import Command from falyx.command import Command
from falyx.completer import FalyxCompleter
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.debug import log_after, log_before, log_error, log_success from falyx.debug import log_after, log_before, log_error, log_success
from falyx.exceptions import ( from falyx.exceptions import (
@ -413,20 +413,9 @@ class Falyx:
arg_parser=parser, arg_parser=parser,
) )
def _get_completer(self) -> WordCompleter: def _get_completer(self) -> FalyxCompleter:
"""Completer to provide auto-completion for the menu commands.""" """Completer to provide auto-completion for the menu commands."""
keys = [self.exit_command.key] return FalyxCompleter(self)
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)
def _get_validator_error_message(self) -> str: def _get_validator_error_message(self) -> str:
"""Validator to check if the input is a valid command or toggle key.""" """Validator to check if the input is a valid command or toggle key."""

View File

@ -7,7 +7,6 @@ from typing import Any, Iterable
from rich.console import Console from rich.console import Console
from rich.markup import escape from rich.markup import escape
from rich.text import Text
from falyx.action.base_action import BaseAction from falyx.action.base_action import BaseAction
from falyx.exceptions import CommandArgumentError from falyx.exceptions import CommandArgumentError
@ -466,6 +465,10 @@ class CommandArgumentParser:
or isinstance(next_spec.nargs, str) or isinstance(next_spec.nargs, str)
and next_spec.nargs in ("+", "*", "?") and next_spec.nargs in ("+", "*", "?")
), f"Invalid nargs value: {spec.nargs}" ), f"Invalid nargs value: {spec.nargs}"
if next_spec.default:
continue
if next_spec.nargs is None: if next_spec.nargs is None:
min_required += 1 min_required += 1
elif isinstance(next_spec.nargs, int): elif isinstance(next_spec.nargs, int):
@ -473,9 +476,9 @@ class CommandArgumentParser:
elif next_spec.nargs == "+": elif next_spec.nargs == "+":
min_required += 1 min_required += 1
elif next_spec.nargs == "?": elif next_spec.nargs == "?":
min_required += 0 continue
elif next_spec.nargs == "*": elif next_spec.nargs == "*":
min_required += 0 continue
slice_args = args[i:] if is_last else args[i : i + (remaining - min_required)] slice_args = args[i:] if is_last else args[i : i + (remaining - min_required)]
values, new_i = self._consume_nargs(slice_args, 0, spec) values, new_i = self._consume_nargs(slice_args, 0, spec)
@ -484,9 +487,23 @@ class CommandArgumentParser:
try: try:
typed = [coerce_value(value, spec.type) for value in values] typed = [coerce_value(value, spec.type) for value in values]
except Exception as error: except Exception as error:
raise CommandArgumentError( if len(args[i - new_i :]) == 1 and args[i - new_i].startswith("-"):
f"Invalid value for '{spec.dest}': {error}" token = args[i - new_i]
) from error 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: if spec.action == ArgumentAction.ACTION:
assert isinstance( assert isinstance(
spec.resolver, BaseAction spec.resolver, BaseAction
@ -497,6 +514,8 @@ class CommandArgumentParser:
raise CommandArgumentError( raise CommandArgumentError(
f"[{spec.dest}] Action failed: {error}" f"[{spec.dest}] Action failed: {error}"
) from error ) from error
elif not typed and 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 spec.nargs is None: if spec.nargs is None:
@ -515,10 +534,22 @@ class CommandArgumentParser:
consumed_positional_indicies.add(j) consumed_positional_indicies.add(j)
if i < len(args): if i < len(args):
plural = "s" if len(args[i:]) > 1 else "" if len(args[i:]) == 1 and args[i].startswith("-"):
raise CommandArgumentError( token = args[i]
f"Unexpected positional argument{plural}: {', '.join(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 return i
@ -624,8 +655,22 @@ 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 = []
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( 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: if spec.nargs in (None, 1, "?") and spec.action != ArgumentAction.APPEND:
result[spec.dest] = ( result[spec.dest] = (
@ -637,7 +682,15 @@ class CommandArgumentParser:
i = new_i i = new_i
elif token.startswith("-"): elif token.startswith("-"):
# Handle unrecognized option # 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: 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
@ -692,7 +745,10 @@ class CommandArgumentParser:
if spec.dest == "help": if spec.dest == "help":
continue continue
if spec.required and not result.get(spec.dest): 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: if spec.choices and result.get(spec.dest) not in spec.choices:
raise CommandArgumentError( raise CommandArgumentError(
@ -807,22 +863,20 @@ class CommandArgumentParser:
self.console.print("[bold]positional:[/bold]") self.console.print("[bold]positional:[/bold]")
for arg in self._positional.values(): for arg in self._positional.values():
flags = arg.get_positional_text() flags = arg.get_positional_text()
arg_line = Text(f" {flags:<30} ") arg_line = f" {flags:<30} "
help_text = arg.help or "" help_text = arg.help or ""
if help_text and len(flags) > 30: if help_text and len(flags) > 30:
help_text = f"\n{'':<33}{help_text}" help_text = f"\n{'':<33}{help_text}"
arg_line.append(help_text) self.console.print(f"{arg_line}{help_text}")
self.console.print(arg_line)
self.console.print("[bold]options:[/bold]") self.console.print("[bold]options:[/bold]")
for arg in self._keyword_list: for arg in self._keyword_list:
flags = ", ".join(arg.flags) flags = ", ".join(arg.flags)
flags_choice = f"{flags} {arg.get_choice_text()}" 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 "" help_text = arg.help or ""
if help_text and len(flags_choice) > 30: if help_text and len(flags_choice) > 30:
help_text = f"\n{'':<33}{help_text}" help_text = f"\n{'':<33}{help_text}"
arg_line.append(help_text) self.console.print(f"{arg_line}{help_text}")
self.console.print(arg_line)
# Epilog # Epilog
if self.help_epilog: if self.help_epilog:

View File

@ -1 +1 @@
__version__ = "0.1.55" __version__ = "0.1.56"

View File

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

@ -403,8 +403,9 @@ async def test_parse_args_nargs():
parser.add_argument("--action", action="store_true") parser.add_argument("--action", action="store_true")
args = await parser.parse_args(["a", "b", "c", "--action"]) 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"]) args = await parser.parse_args(["--action", "a", "b", "c"])
assert args["files"] == ["a", "b"] assert args["files"] == ["a", "b"]
assert args["mode"] == "c" assert args["mode"] == "c"

View File

@ -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"