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:
|
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()
|
||||||
|
@ -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
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 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."""
|
||||||
|
@ -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:
|
||||||
|
@ -1 +1 @@
|
|||||||
__version__ = "0.1.55"
|
__version__ = "0.1.56"
|
||||||
|
@ -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"
|
||||||
|
@ -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"
|
||||||
|
|
||||||
|
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