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:
@ -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()
|
||||
|
@ -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,
|
||||
|
47
falyx/completer.py
Normal file
47
falyx/completer.py
Normal 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))
|
@ -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."""
|
||||
|
@ -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,6 +487,20 @@ class CommandArgumentParser:
|
||||
try:
|
||||
typed = [coerce_value(value, spec.type) for value in values]
|
||||
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
|
||||
else:
|
||||
raise CommandArgumentError(
|
||||
f"Invalid value for '{spec.dest}': {error}"
|
||||
) from error
|
||||
@ -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,6 +534,18 @@ class CommandArgumentParser:
|
||||
consumed_positional_indicies.add(j)
|
||||
|
||||
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."
|
||||
)
|
||||
else:
|
||||
plural = "s" if len(args[i:]) > 1 else ""
|
||||
raise CommandArgumentError(
|
||||
f"Unexpected positional argument{plural}: {', '.join(args[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"Expected at least one value for '{spec.dest}'"
|
||||
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"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:
|
||||
|
@ -1 +1 @@
|
||||
__version__ = "0.1.55"
|
||||
__version__ = "0.1.56"
|
||||
|
@ -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 <roland@rtj.dev>"]
|
||||
license = "MIT"
|
||||
|
@ -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"
|
||||
|
||||
|
25
tests/test_parsers/test_multiple_positional.py
Normal file
25
tests/test_parsers/test_multiple_positional.py
Normal 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"
|
Reference in New Issue
Block a user