1069 lines
42 KiB
Python
1069 lines
42 KiB
Python
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
|
"""falyx.py
|
|
|
|
Main class for constructing and running Falyx CLI menus.
|
|
|
|
Falyx provides a structured, customizable interactive menu system
|
|
for running commands, actions, and workflows. It supports:
|
|
|
|
- Hook lifecycle management (before/on_success/on_error/after/on_teardown)
|
|
- Dynamic command addition and alias resolution
|
|
- Rich-based menu display with multi-column layouts
|
|
- Interactive input validation and auto-completion
|
|
- History tracking and help menu generation
|
|
- Confirmation prompts and spinners
|
|
- Run key for automated script execution
|
|
- CLI argument parsing with argparse integration
|
|
- Retry policy configuration for actions
|
|
|
|
Falyx enables building flexible, robust, and user-friendly
|
|
terminal applications with minimal boilerplate.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import shlex
|
|
import sys
|
|
from argparse import Namespace
|
|
from difflib import get_close_matches
|
|
from enum import Enum
|
|
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
|
|
from prompt_toolkit.validation import ValidationError, Validator
|
|
from rich import box
|
|
from rich.console import Console
|
|
from rich.markdown import Markdown
|
|
from rich.table import Table
|
|
|
|
from falyx.action.action import Action, BaseAction
|
|
from falyx.bottom_bar import BottomBar
|
|
from falyx.command import Command
|
|
from falyx.context import ExecutionContext
|
|
from falyx.debug import log_after, log_before, log_error, log_success
|
|
from falyx.exceptions import (
|
|
CommandAlreadyExistsError,
|
|
CommandArgumentError,
|
|
FalyxError,
|
|
InvalidActionError,
|
|
NotAFalyxError,
|
|
)
|
|
from falyx.execution_registry import ExecutionRegistry as er
|
|
from falyx.hook_manager import Hook, HookManager, HookType
|
|
from falyx.logger import logger
|
|
from falyx.options_manager import OptionsManager
|
|
from falyx.parsers import get_arg_parsers
|
|
from falyx.retry import RetryPolicy
|
|
from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
|
|
from falyx.themes import OneColors, get_nord_theme
|
|
from falyx.utils import CaseInsensitiveDict, _noop, chunks, get_program_invocation
|
|
from falyx.version import __version__
|
|
|
|
|
|
class FalyxMode(Enum):
|
|
MENU = "menu"
|
|
RUN = "run"
|
|
PREVIEW = "preview"
|
|
RUN_ALL = "run-all"
|
|
|
|
|
|
class CommandValidator(Validator):
|
|
"""Validator to check if the input is a valid command or toggle key."""
|
|
|
|
def __init__(self, falyx: Falyx, error_message: str) -> None:
|
|
super().__init__()
|
|
self.falyx = falyx
|
|
self.error_message = error_message
|
|
|
|
def validate(self, document) -> None:
|
|
text = document.text
|
|
is_preview, choice, _, __ = self.falyx.get_command(text, from_validate=True)
|
|
if is_preview:
|
|
return None
|
|
if not choice:
|
|
raise ValidationError(
|
|
message=self.error_message,
|
|
cursor_position=document.get_end_of_document_position(),
|
|
)
|
|
|
|
|
|
class Falyx:
|
|
"""
|
|
Main menu controller for Falyx CLI applications.
|
|
|
|
Falyx orchestrates the full lifecycle of an interactive menu system,
|
|
handling user input, command execution, error recovery, and structured
|
|
CLI workflows.
|
|
|
|
Key Features:
|
|
- Interactive menu with Rich rendering and Prompt Toolkit input handling
|
|
- Dynamic command management with alias and abbreviation matching
|
|
- Full lifecycle hooks (before, success, error, after, teardown) at both menu and
|
|
command levels
|
|
- Built-in retry support, spinner visuals, and confirmation prompts
|
|
- Submenu nesting and action chaining
|
|
- History tracking, help generation, and run key execution modes
|
|
- Seamless CLI argument parsing and integration via argparse
|
|
- Extensible with user-defined hooks, bottom bars, and custom layouts
|
|
|
|
Args:
|
|
title (str | Markdown): Title displayed for the menu.
|
|
prompt (AnyFormattedText): Prompt displayed when requesting user input.
|
|
columns (int): Number of columns to use when rendering menu commands.
|
|
bottom_bar (BottomBar | str | Callable | None): Bottom toolbar content or logic.
|
|
welcome_message (str | Markdown | dict): Welcome message shown at startup.
|
|
exit_message (str | Markdown | dict): Exit message shown on shutdown.
|
|
key_bindings (KeyBindings | None): Custom Prompt Toolkit key bindings.
|
|
include_history_command (bool): Whether to add a built-in history viewer command.
|
|
include_help_command (bool): Whether to add a built-in help viewer command.
|
|
never_prompt (bool): Seed default for `OptionsManager["never_prompt"]`
|
|
force_confirm (bool): Seed default for `OptionsManager["force_confirm"]`
|
|
cli_args (Namespace | None): Parsed CLI arguments, usually from argparse.
|
|
options (OptionsManager | None): Declarative option mappings.
|
|
custom_table (Callable[[Falyx], Table] | Table | None): Custom menu table
|
|
generator.
|
|
|
|
Methods:
|
|
run(): Main entry point for CLI argument-based workflows. Suggested for
|
|
most use cases.
|
|
menu(): Run the interactive menu loop.
|
|
run_key(command_key, return_context): Run a command directly without the menu.
|
|
add_command(): Add a single command to the menu.
|
|
add_commands(): Add multiple commands at once.
|
|
register_all_hooks(): Register hooks across all commands and submenus.
|
|
debug_hooks(): Log hook registration for debugging.
|
|
build_default_table(): Construct the standard Rich table layout.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
title: str | Markdown = "Menu",
|
|
*,
|
|
prompt: str | AnyFormattedText = "> ",
|
|
columns: int = 3,
|
|
bottom_bar: BottomBar | str | Callable[[], Any] | None = None,
|
|
welcome_message: str | Markdown | dict[str, Any] = "",
|
|
exit_message: str | Markdown | dict[str, Any] = "",
|
|
key_bindings: KeyBindings | None = None,
|
|
include_history_command: bool = True,
|
|
include_help_command: bool = True,
|
|
never_prompt: bool = False,
|
|
force_confirm: bool = False,
|
|
cli_args: Namespace | None = None,
|
|
options: OptionsManager | None = None,
|
|
render_menu: Callable[["Falyx"], None] | None = None,
|
|
custom_table: Callable[["Falyx"], Table] | Table | None = None,
|
|
) -> None:
|
|
"""Initializes the Falyx object."""
|
|
self.title: str | Markdown = title
|
|
self.prompt: str | AnyFormattedText = prompt
|
|
self.columns: int = columns
|
|
self.commands: dict[str, Command] = CaseInsensitiveDict()
|
|
self.exit_command: Command = self._get_exit_command()
|
|
self.history_command: Command | None = (
|
|
self._get_history_command() if include_history_command else None
|
|
)
|
|
self.help_command: Command | None = (
|
|
self._get_help_command() if include_help_command else None
|
|
)
|
|
self.console: Console = Console(color_system="auto", theme=get_nord_theme())
|
|
self.welcome_message: str | Markdown | dict[str, Any] = welcome_message
|
|
self.exit_message: str | Markdown | dict[str, Any] = exit_message
|
|
self.hooks: HookManager = HookManager()
|
|
self.last_run_command: Command | None = None
|
|
self.key_bindings: KeyBindings = key_bindings or KeyBindings()
|
|
self.bottom_bar: BottomBar | str | Callable[[], None] = bottom_bar
|
|
self._never_prompt: bool = never_prompt
|
|
self._force_confirm: bool = force_confirm
|
|
self.cli_args: Namespace | None = cli_args
|
|
self.render_menu: Callable[["Falyx"], None] | None = render_menu
|
|
self.custom_table: Callable[["Falyx"], Table] | Table | None = custom_table
|
|
self.validate_options(cli_args, options)
|
|
self._prompt_session: PromptSession | None = None
|
|
self.mode = FalyxMode.MENU
|
|
|
|
def validate_options(
|
|
self,
|
|
cli_args: Namespace | None,
|
|
options: OptionsManager | None = None,
|
|
) -> None:
|
|
"""Checks if the options are set correctly."""
|
|
self.options: OptionsManager = options or OptionsManager()
|
|
if not cli_args and not options:
|
|
return None
|
|
|
|
if options and not cli_args:
|
|
raise FalyxError("Options are set, but CLI arguments are not.")
|
|
|
|
assert isinstance(
|
|
cli_args, Namespace
|
|
), "CLI arguments must be a Namespace object."
|
|
|
|
if not isinstance(self.options, OptionsManager):
|
|
raise FalyxError("Options must be an instance of OptionsManager.")
|
|
|
|
if not isinstance(self.cli_args, Namespace):
|
|
raise FalyxError("CLI arguments must be a Namespace object.")
|
|
|
|
@property
|
|
def _name_map(self) -> dict[str, Command]:
|
|
"""
|
|
Builds a mapping of all valid input names (keys, aliases, normalized names) to
|
|
Command objects. If a collision occurs, logs a warning and keeps the first
|
|
registered command.
|
|
"""
|
|
mapping: dict[str, Command] = {}
|
|
|
|
def register(name: str, cmd: Command):
|
|
norm = name.upper().strip()
|
|
if norm in mapping:
|
|
existing = mapping[norm]
|
|
if existing is not cmd:
|
|
logger.warning(
|
|
"[alias conflict] '%s' already assigned to '%s'. "
|
|
"Skipping for '%s'.",
|
|
name,
|
|
existing.description,
|
|
cmd.description,
|
|
)
|
|
else:
|
|
mapping[norm] = cmd
|
|
|
|
for special in [self.exit_command, self.history_command, self.help_command]:
|
|
if special:
|
|
register(special.key, special)
|
|
for alias in special.aliases:
|
|
register(alias, special)
|
|
register(special.description, special)
|
|
|
|
for cmd in self.commands.values():
|
|
register(cmd.key, cmd)
|
|
for alias in cmd.aliases:
|
|
register(alias, cmd)
|
|
register(cmd.description, cmd)
|
|
return mapping
|
|
|
|
def get_title(self) -> str:
|
|
"""Returns the string title of the menu."""
|
|
if isinstance(self.title, str):
|
|
return self.title
|
|
elif isinstance(self.title, Markdown):
|
|
return self.title.markup
|
|
return self.title
|
|
|
|
def _get_exit_command(self) -> Command:
|
|
"""Returns the back command for the menu."""
|
|
return Command(
|
|
key="X",
|
|
description="Exit",
|
|
action=Action("Exit", action=_noop),
|
|
aliases=["EXIT", "QUIT"],
|
|
style=OneColors.DARK_RED,
|
|
)
|
|
|
|
def _get_history_command(self) -> Command:
|
|
"""Returns the history command for the menu."""
|
|
return Command(
|
|
key="Y",
|
|
description="History",
|
|
aliases=["HISTORY"],
|
|
action=Action(name="View Execution History", action=er.summary),
|
|
style=OneColors.DARK_YELLOW,
|
|
)
|
|
|
|
async def _show_help(self):
|
|
table = Table(title="[bold cyan]Help Menu[/]", box=box.SIMPLE)
|
|
table.add_column("Key", style="bold", no_wrap=True)
|
|
table.add_column("Aliases", style="dim", no_wrap=True)
|
|
table.add_column("Description", style="dim", overflow="fold")
|
|
table.add_column("Tags", style="dim", no_wrap=True)
|
|
|
|
for command in self.commands.values():
|
|
help_text = command.help_text or command.description
|
|
if command.requires_input:
|
|
help_text += " [dim](requires input)[/dim]"
|
|
table.add_row(
|
|
f"[{command.style}]{command.key}[/]",
|
|
", ".join(command.aliases) if command.aliases else "",
|
|
help_text,
|
|
", ".join(command.tags) if command.tags else "",
|
|
)
|
|
|
|
table.add_row(
|
|
f"[{self.exit_command.style}]{self.exit_command.key}[/]",
|
|
", ".join(self.exit_command.aliases),
|
|
"Exit this menu or program",
|
|
)
|
|
|
|
if self.history_command:
|
|
table.add_row(
|
|
f"[{self.history_command.style}]{self.history_command.key}[/]",
|
|
", ".join(self.history_command.aliases),
|
|
"History of executed actions",
|
|
)
|
|
|
|
if self.help_command:
|
|
table.add_row(
|
|
f"[{self.help_command.style}]{self.help_command.key}[/]",
|
|
", ".join(self.help_command.aliases),
|
|
"Show this help menu",
|
|
)
|
|
|
|
self.console.print(table, justify="center")
|
|
if self.mode == FalyxMode.MENU:
|
|
self.console.print(
|
|
f"📦 Tip: '[{OneColors.LIGHT_YELLOW}]?[KEY][/]' to preview a command "
|
|
"before running it.\n",
|
|
justify="center",
|
|
)
|
|
|
|
def _get_help_command(self) -> Command:
|
|
"""Returns the help command for the menu."""
|
|
return Command(
|
|
key="H",
|
|
aliases=["HELP", "?"],
|
|
description="Help",
|
|
action=Action("Help", self._show_help),
|
|
style=OneColors.LIGHT_YELLOW,
|
|
)
|
|
|
|
def _get_completer(self) -> WordCompleter:
|
|
"""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)
|
|
|
|
def _get_validator_error_message(self) -> str:
|
|
"""Validator to check if the input is a valid command or toggle key."""
|
|
keys = {self.exit_command.key.upper()}
|
|
keys.update({alias.upper() for alias in self.exit_command.aliases})
|
|
if self.history_command:
|
|
keys.add(self.history_command.key.upper())
|
|
keys.update({alias.upper() for alias in self.history_command.aliases})
|
|
if self.help_command:
|
|
keys.add(self.help_command.key.upper())
|
|
keys.update({alias.upper() for alias in self.help_command.aliases})
|
|
|
|
for cmd in self.commands.values():
|
|
keys.add(cmd.key.upper())
|
|
keys.update({alias.upper() for alias in cmd.aliases})
|
|
|
|
if isinstance(self._bottom_bar, BottomBar):
|
|
toggle_keys = {key.upper() for key in self._bottom_bar.toggle_keys}
|
|
else:
|
|
toggle_keys = set()
|
|
|
|
commands_str = ", ".join(sorted(keys))
|
|
toggles_str = ", ".join(sorted(toggle_keys))
|
|
|
|
message_lines = ["Invalid input. Available keys:"]
|
|
if keys:
|
|
message_lines.append(f" Commands: {commands_str}")
|
|
if toggle_keys:
|
|
message_lines.append(f" Toggles: {toggles_str}")
|
|
error_message = " ".join(message_lines)
|
|
return error_message
|
|
|
|
def _invalidate_prompt_session_cache(self):
|
|
"""Forces the prompt session to be recreated on the next access."""
|
|
if hasattr(self, "prompt_session"):
|
|
del self.prompt_session
|
|
self._prompt_session = None
|
|
|
|
def add_help_command(self):
|
|
"""Adds a help command to the menu if it doesn't already exist."""
|
|
if not self.help_command:
|
|
self.help_command = self._get_help_command()
|
|
|
|
def add_history_command(self):
|
|
"""Adds a history command to the menu if it doesn't already exist."""
|
|
if not self.history_command:
|
|
self.history_command = self._get_history_command()
|
|
|
|
@property
|
|
def bottom_bar(self) -> BottomBar | str | Callable[[], Any] | None:
|
|
"""Returns the bottom bar for the menu."""
|
|
return self._bottom_bar
|
|
|
|
@bottom_bar.setter
|
|
def bottom_bar(self, bottom_bar: BottomBar | str | Callable[[], Any] | None) -> None:
|
|
"""Sets the bottom bar for the menu."""
|
|
if bottom_bar is None:
|
|
self._bottom_bar: BottomBar | str | Callable[[], Any] = BottomBar(
|
|
self.columns, self.key_bindings, key_validator=self.is_key_available
|
|
)
|
|
elif isinstance(bottom_bar, BottomBar):
|
|
bottom_bar.key_validator = self.is_key_available
|
|
bottom_bar.key_bindings = self.key_bindings
|
|
self._bottom_bar = bottom_bar
|
|
elif isinstance(bottom_bar, str) or callable(bottom_bar):
|
|
self._bottom_bar = bottom_bar
|
|
else:
|
|
raise FalyxError(
|
|
"Bottom bar must be a string, callable, or BottomBar instance."
|
|
)
|
|
self._invalidate_prompt_session_cache()
|
|
|
|
def _get_bottom_bar_render(self) -> Callable[[], Any] | str | None:
|
|
"""Returns the bottom bar for the menu."""
|
|
if isinstance(self.bottom_bar, BottomBar) and self.bottom_bar._named_items:
|
|
return self.bottom_bar.render
|
|
elif callable(self.bottom_bar):
|
|
return self.bottom_bar
|
|
elif isinstance(self.bottom_bar, str):
|
|
return self.bottom_bar
|
|
elif self.bottom_bar is None:
|
|
return None
|
|
return None
|
|
|
|
@cached_property
|
|
def prompt_session(self) -> PromptSession:
|
|
"""Returns the prompt session for the menu."""
|
|
if self._prompt_session is None:
|
|
self._prompt_session = PromptSession(
|
|
message=self.prompt,
|
|
multiline=False,
|
|
completer=self._get_completer(),
|
|
reserve_space_for_menu=1,
|
|
validator=CommandValidator(self, self._get_validator_error_message()),
|
|
bottom_toolbar=self._get_bottom_bar_render(),
|
|
key_bindings=self.key_bindings,
|
|
validate_while_typing=False,
|
|
)
|
|
return self._prompt_session
|
|
|
|
def register_all_hooks(self, hook_type: HookType, hooks: Hook | list[Hook]) -> None:
|
|
"""Registers hooks for all commands in the menu and actions recursively."""
|
|
hook_list = hooks if isinstance(hooks, list) else [hooks]
|
|
for hook in hook_list:
|
|
if not callable(hook):
|
|
raise InvalidActionError("Hook must be a callable.")
|
|
self.hooks.register(hook_type, hook)
|
|
for command in self.commands.values():
|
|
command.hooks.register(hook_type, hook)
|
|
if isinstance(command.action, Falyx):
|
|
command.action.register_all_hooks(hook_type, hook)
|
|
if isinstance(command.action, BaseAction):
|
|
command.action.register_hooks_recursively(hook_type, hook)
|
|
|
|
def register_all_with_debug_hooks(self) -> None:
|
|
"""Registers debug hooks for all commands in the menu and actions recursively."""
|
|
self.register_all_hooks(HookType.BEFORE, log_before)
|
|
self.register_all_hooks(HookType.ON_SUCCESS, log_success)
|
|
self.register_all_hooks(HookType.ON_ERROR, log_error)
|
|
self.register_all_hooks(HookType.AFTER, log_after)
|
|
|
|
def debug_hooks(self) -> None:
|
|
"""Logs the names of all hooks registered for the menu and its commands."""
|
|
logger.debug("Menu-level hooks:\n%s", str(self.hooks))
|
|
|
|
for key, command in self.commands.items():
|
|
logger.debug("[Command '%s'] hooks:\n%s", key, str(command.hooks))
|
|
|
|
def is_key_available(self, key: str) -> bool:
|
|
key = key.upper()
|
|
toggles = (
|
|
self._bottom_bar.toggle_keys
|
|
if isinstance(self._bottom_bar, BottomBar)
|
|
else []
|
|
)
|
|
|
|
conflicts = (
|
|
key in self.commands,
|
|
key == self.exit_command.key.upper(),
|
|
self.history_command and key == self.history_command.key.upper(),
|
|
self.help_command and key == self.help_command.key.upper(),
|
|
key in toggles,
|
|
)
|
|
|
|
return not any(conflicts)
|
|
|
|
def _validate_command_key(self, key: str) -> None:
|
|
"""Validates the command key to ensure it is unique."""
|
|
key = key.upper()
|
|
toggles = (
|
|
self._bottom_bar.toggle_keys
|
|
if isinstance(self._bottom_bar, BottomBar)
|
|
else []
|
|
)
|
|
collisions = []
|
|
|
|
if key in self.commands:
|
|
collisions.append("command")
|
|
if key == self.exit_command.key.upper():
|
|
collisions.append("back command")
|
|
if self.history_command and key == self.history_command.key.upper():
|
|
collisions.append("history command")
|
|
if self.help_command and key == self.help_command.key.upper():
|
|
collisions.append("help command")
|
|
if key in toggles:
|
|
collisions.append("toggle")
|
|
|
|
if collisions:
|
|
raise CommandAlreadyExistsError(
|
|
f"Command key '{key}' conflicts with existing {', '.join(collisions)}."
|
|
)
|
|
|
|
def update_exit_command(
|
|
self,
|
|
key: str = "X",
|
|
description: str = "Exit",
|
|
aliases: list[str] | None = None,
|
|
action: Callable[[], Any] | None = None,
|
|
style: str = OneColors.DARK_RED,
|
|
confirm: bool = False,
|
|
confirm_message: str = "Are you sure?",
|
|
) -> None:
|
|
"""Updates the back command of the menu."""
|
|
self._validate_command_key(key)
|
|
action = action or Action(description, action=_noop)
|
|
if not callable(action):
|
|
raise InvalidActionError("Action must be a callable.")
|
|
self.exit_command = Command(
|
|
key=key,
|
|
description=description,
|
|
aliases=aliases if aliases else self.exit_command.aliases,
|
|
action=action,
|
|
style=style,
|
|
confirm=confirm,
|
|
confirm_message=confirm_message,
|
|
)
|
|
|
|
def add_submenu(
|
|
self, key: str, description: str, submenu: Falyx, *, style: str = OneColors.CYAN
|
|
) -> None:
|
|
"""Adds a submenu to the menu."""
|
|
if not isinstance(submenu, Falyx):
|
|
raise NotAFalyxError("submenu must be an instance of Falyx.")
|
|
self._validate_command_key(key)
|
|
self.add_command(key, description, submenu.menu, style=style)
|
|
if submenu.exit_command.key == "X":
|
|
submenu.update_exit_command(key="B", description="Back", aliases=["BACK"])
|
|
|
|
def add_commands(self, commands: list[Command] | list[dict]) -> None:
|
|
"""Adds a list of Command instances or config dicts."""
|
|
for command in commands:
|
|
if isinstance(command, dict):
|
|
self.add_command(**command)
|
|
elif isinstance(command, Command):
|
|
self.add_command_from_command(command)
|
|
else:
|
|
raise FalyxError(
|
|
"Command must be a dictionary or an instance of Command."
|
|
)
|
|
|
|
def add_command_from_command(self, command: Command) -> None:
|
|
"""Adds a command to the menu from an existing Command object."""
|
|
if not isinstance(command, Command):
|
|
raise FalyxError("command must be an instance of Command.")
|
|
self._validate_command_key(command.key)
|
|
self.commands[command.key] = command
|
|
|
|
def add_command(
|
|
self,
|
|
key: str,
|
|
description: str,
|
|
action: BaseAction | Callable[[], Any],
|
|
*,
|
|
args: tuple = (),
|
|
kwargs: dict[str, Any] | None = None,
|
|
hidden: bool = False,
|
|
aliases: list[str] | None = None,
|
|
help_text: str = "",
|
|
style: str = OneColors.WHITE,
|
|
confirm: bool = False,
|
|
confirm_message: str = "Are you sure?",
|
|
preview_before_confirm: bool = True,
|
|
spinner: bool = False,
|
|
spinner_message: str = "Processing...",
|
|
spinner_type: str = "dots",
|
|
spinner_style: str = OneColors.CYAN,
|
|
spinner_kwargs: dict[str, Any] | None = None,
|
|
hooks: HookManager | None = None,
|
|
before_hooks: list[Callable] | None = None,
|
|
success_hooks: list[Callable] | None = None,
|
|
error_hooks: list[Callable] | None = None,
|
|
after_hooks: list[Callable] | None = None,
|
|
teardown_hooks: list[Callable] | None = None,
|
|
tags: list[str] | None = None,
|
|
logging_hooks: bool = False,
|
|
retry: bool = False,
|
|
retry_all: bool = False,
|
|
retry_policy: RetryPolicy | None = None,
|
|
requires_input: bool | None = None,
|
|
) -> Command:
|
|
"""Adds an command to the menu, preventing duplicates."""
|
|
self._validate_command_key(key)
|
|
command = Command(
|
|
key=key,
|
|
description=description,
|
|
action=action,
|
|
args=args,
|
|
kwargs=kwargs if kwargs else {},
|
|
hidden=hidden,
|
|
aliases=aliases if aliases else [],
|
|
help_text=help_text,
|
|
style=style,
|
|
confirm=confirm,
|
|
confirm_message=confirm_message,
|
|
preview_before_confirm=preview_before_confirm,
|
|
spinner=spinner,
|
|
spinner_message=spinner_message,
|
|
spinner_type=spinner_type,
|
|
spinner_style=spinner_style,
|
|
spinner_kwargs=spinner_kwargs or {},
|
|
tags=tags if tags else [],
|
|
logging_hooks=logging_hooks,
|
|
retry=retry,
|
|
retry_all=retry_all,
|
|
retry_policy=retry_policy or RetryPolicy(),
|
|
requires_input=requires_input,
|
|
options_manager=self.options,
|
|
)
|
|
|
|
if hooks:
|
|
if not isinstance(hooks, HookManager):
|
|
raise NotAFalyxError("hooks must be an instance of HookManager.")
|
|
command.hooks = hooks
|
|
|
|
for hook in before_hooks or []:
|
|
command.hooks.register(HookType.BEFORE, hook)
|
|
for hook in success_hooks or []:
|
|
command.hooks.register(HookType.ON_SUCCESS, hook)
|
|
for hook in error_hooks or []:
|
|
command.hooks.register(HookType.ON_ERROR, hook)
|
|
for hook in after_hooks or []:
|
|
command.hooks.register(HookType.AFTER, hook)
|
|
for hook in teardown_hooks or []:
|
|
command.hooks.register(HookType.ON_TEARDOWN, hook)
|
|
|
|
self.commands[key] = command
|
|
return command
|
|
|
|
def get_bottom_row(self) -> list[str]:
|
|
"""Returns the bottom row of the table for displaying additional commands."""
|
|
bottom_row = []
|
|
if self.history_command:
|
|
bottom_row.append(
|
|
f"[{self.history_command.key}] [{self.history_command.style}]"
|
|
f"{self.history_command.description}"
|
|
)
|
|
if self.help_command:
|
|
bottom_row.append(
|
|
f"[{self.help_command.key}] [{self.help_command.style}]"
|
|
f"{self.help_command.description}"
|
|
)
|
|
bottom_row.append(
|
|
f"[{self.exit_command.key}] [{self.exit_command.style}]"
|
|
f"{self.exit_command.description}"
|
|
)
|
|
return bottom_row
|
|
|
|
def build_default_table(self) -> Table:
|
|
"""
|
|
Build the standard table layout. Developers can subclass or call this
|
|
in custom tables.
|
|
"""
|
|
table = Table(title=self.title, show_header=False, box=box.SIMPLE, expand=True) # type: ignore[arg-type]
|
|
visible_commands = [item for item in self.commands.items() if not item[1].hidden]
|
|
for chunk in chunks(visible_commands, self.columns):
|
|
row = []
|
|
for key, command in chunk:
|
|
row.append(f"[{key}] [{command.style}]{command.description}")
|
|
table.add_row(*row)
|
|
bottom_row = self.get_bottom_row()
|
|
for row in chunks(bottom_row, self.columns):
|
|
table.add_row(*row)
|
|
return table
|
|
|
|
@property
|
|
def table(self) -> Table:
|
|
"""Creates or returns a custom table to display the menu commands."""
|
|
if callable(self.custom_table):
|
|
return self.custom_table(self)
|
|
elif isinstance(self.custom_table, Table):
|
|
return self.custom_table
|
|
else:
|
|
return self.build_default_table()
|
|
|
|
def parse_preview_command(self, input_str: str) -> tuple[bool, str]:
|
|
if input_str.startswith("?"):
|
|
return True, input_str[1:].strip()
|
|
return False, input_str.strip()
|
|
|
|
def get_command(
|
|
self, raw_choices: str, from_validate=False
|
|
) -> tuple[bool, Command | None, tuple, dict[str, Any]]:
|
|
"""
|
|
Returns the selected command based on user input.
|
|
Supports keys, aliases, and abbreviations.
|
|
"""
|
|
args = ()
|
|
kwargs: dict[str, Any] = {}
|
|
choice, *input_args = shlex.split(raw_choices)
|
|
is_preview, choice = self.parse_preview_command(choice)
|
|
if is_preview and not choice and self.help_command:
|
|
is_preview = False
|
|
choice = "?"
|
|
elif is_preview and not choice:
|
|
# No help command enabled
|
|
if not from_validate:
|
|
self.console.print(
|
|
f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode."
|
|
)
|
|
return is_preview, None, args, kwargs
|
|
|
|
choice = choice.upper()
|
|
name_map = self._name_map
|
|
if choice in name_map:
|
|
if not from_validate:
|
|
logger.info("Command '%s' selected.", choice)
|
|
if input_args and name_map[choice].arg_parser:
|
|
try:
|
|
args, kwargs = name_map[choice].parse_args(input_args)
|
|
except CommandArgumentError as error:
|
|
if not from_validate:
|
|
if not name_map[choice].show_help():
|
|
self.console.print(
|
|
f"[{OneColors.DARK_RED}]❌ Invalid arguments for '{choice}': {error}"
|
|
)
|
|
else:
|
|
name_map[choice].show_help()
|
|
raise ValidationError(
|
|
message=str(error), cursor_position=len(raw_choices)
|
|
)
|
|
return is_preview, None, args, kwargs
|
|
return is_preview, name_map[choice], args, kwargs
|
|
|
|
prefix_matches = [cmd for key, cmd in name_map.items() if key.startswith(choice)]
|
|
if len(prefix_matches) == 1:
|
|
return is_preview, prefix_matches[0], args, kwargs
|
|
|
|
fuzzy_matches = get_close_matches(choice, list(name_map.keys()), n=3, cutoff=0.7)
|
|
if fuzzy_matches:
|
|
if not from_validate:
|
|
self.console.print(
|
|
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'. "
|
|
"Did you mean:"
|
|
)
|
|
for match in fuzzy_matches:
|
|
cmd = name_map[match]
|
|
self.console.print(f" • [bold]{match}[/] → {cmd.description}")
|
|
else:
|
|
if not from_validate:
|
|
self.console.print(
|
|
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'[/]"
|
|
)
|
|
return is_preview, None, args, kwargs
|
|
|
|
def _create_context(self, selected_command: Command) -> ExecutionContext:
|
|
"""Creates a context dictionary for the selected command."""
|
|
return ExecutionContext(
|
|
name=selected_command.description,
|
|
args=tuple(),
|
|
kwargs={},
|
|
action=selected_command,
|
|
)
|
|
|
|
async def _handle_action_error(
|
|
self, selected_command: Command, error: Exception
|
|
) -> None:
|
|
"""Handles errors that occur during the action of the selected command."""
|
|
logger.exception("Error executing '%s': %s", selected_command.description, error)
|
|
self.console.print(
|
|
f"[{OneColors.DARK_RED}]An error occurred while executing "
|
|
f"{selected_command.description}:[/] {error}"
|
|
)
|
|
|
|
async def process_command(self) -> bool:
|
|
"""Processes the action of the selected command."""
|
|
with patch_stdout(raw=True):
|
|
choice = await self.prompt_session.prompt_async()
|
|
is_preview, selected_command, args, kwargs = self.get_command(choice)
|
|
if not selected_command:
|
|
logger.info("Invalid command '%s'.", choice)
|
|
return True
|
|
|
|
if is_preview:
|
|
logger.info("Preview command '%s' selected.", selected_command.key)
|
|
await selected_command.preview()
|
|
return True
|
|
|
|
if selected_command.requires_input:
|
|
program = get_program_invocation()
|
|
self.console.print(
|
|
f"[{OneColors.LIGHT_YELLOW}]⚠️ Command '{selected_command.key}' requires"
|
|
f" input and must be run via [{OneColors.MAGENTA}]'{program} run"
|
|
f"'[{OneColors.LIGHT_YELLOW}] with proper piping or arguments.[/]"
|
|
)
|
|
return True
|
|
|
|
self.last_run_command = selected_command
|
|
|
|
if selected_command == self.exit_command:
|
|
logger.info("🔙 Back selected: exiting %s", self.get_title())
|
|
return False
|
|
|
|
context = self._create_context(selected_command)
|
|
context.start_timer()
|
|
try:
|
|
await self.hooks.trigger(HookType.BEFORE, context)
|
|
print(args, kwargs)
|
|
result = await selected_command(*args, **kwargs)
|
|
context.result = result
|
|
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
|
except Exception as error:
|
|
context.exception = error
|
|
await self.hooks.trigger(HookType.ON_ERROR, context)
|
|
await self._handle_action_error(selected_command, error)
|
|
finally:
|
|
context.stop_timer()
|
|
await self.hooks.trigger(HookType.AFTER, context)
|
|
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
|
return True
|
|
|
|
async def run_key(
|
|
self,
|
|
command_key: str,
|
|
return_context: bool = False,
|
|
args: tuple = (),
|
|
kwargs: dict[str, Any] | None = None,
|
|
) -> Any:
|
|
"""Run a command by key without displaying the menu (non-interactive mode)."""
|
|
self.debug_hooks()
|
|
is_preview, selected_command, _, __ = self.get_command(command_key)
|
|
kwargs = kwargs or {}
|
|
|
|
self.last_run_command = selected_command
|
|
|
|
if not selected_command:
|
|
return None
|
|
|
|
if is_preview:
|
|
logger.info("Preview command '%s' selected.", selected_command.key)
|
|
await selected_command.preview()
|
|
return None
|
|
|
|
logger.info(
|
|
"[run_key] 🚀 Executing: %s — %s",
|
|
selected_command.key,
|
|
selected_command.description,
|
|
)
|
|
|
|
context = self._create_context(selected_command)
|
|
context.start_timer()
|
|
try:
|
|
await self.hooks.trigger(HookType.BEFORE, context)
|
|
result = await selected_command(*args, **kwargs)
|
|
context.result = result
|
|
|
|
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
|
logger.info("[run_key] ✅ '%s' complete.", selected_command.description)
|
|
except (KeyboardInterrupt, EOFError) as error:
|
|
logger.warning(
|
|
"[run_key] ⚠️ Interrupted by user: %s", selected_command.description
|
|
)
|
|
raise FalyxError(
|
|
f"[run_key] ⚠️ '{selected_command.description}' interrupted by user."
|
|
) from error
|
|
except Exception as error:
|
|
context.exception = error
|
|
await self.hooks.trigger(HookType.ON_ERROR, context)
|
|
logger.error(
|
|
"[run_key] ❌ Failed: %s — %s: %s",
|
|
selected_command.description,
|
|
type(error).__name__,
|
|
error,
|
|
)
|
|
raise FalyxError(
|
|
f"[run_key] ❌ '{selected_command.description}' failed."
|
|
) from error
|
|
finally:
|
|
context.stop_timer()
|
|
await self.hooks.trigger(HookType.AFTER, context)
|
|
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
|
|
|
return context if return_context else context.result
|
|
|
|
def _set_retry_policy(self, selected_command: Command) -> None:
|
|
"""Sets the retry policy for the command based on CLI arguments."""
|
|
assert isinstance(self.cli_args, Namespace), "CLI arguments must be provided."
|
|
if (
|
|
self.cli_args.retries
|
|
or self.cli_args.retry_delay
|
|
or self.cli_args.retry_backoff
|
|
):
|
|
selected_command.retry_policy.enabled = True
|
|
if self.cli_args.retries:
|
|
selected_command.retry_policy.max_retries = self.cli_args.retries
|
|
if self.cli_args.retry_delay:
|
|
selected_command.retry_policy.delay = self.cli_args.retry_delay
|
|
if self.cli_args.retry_backoff:
|
|
selected_command.retry_policy.backoff = self.cli_args.retry_backoff
|
|
if isinstance(selected_command.action, Action):
|
|
selected_command.action.set_retry_policy(selected_command.retry_policy)
|
|
else:
|
|
logger.warning(
|
|
"[Command:%s] Retry requested, but action is not an Action instance.",
|
|
selected_command.key,
|
|
)
|
|
|
|
def print_message(self, message: str | Markdown | dict[str, Any]) -> None:
|
|
"""Prints a message to the console."""
|
|
if isinstance(message, (str, Markdown)):
|
|
self.console.print(message)
|
|
elif isinstance(message, dict):
|
|
self.console.print(
|
|
*message.get("args", tuple()),
|
|
**message.get("kwargs", {}),
|
|
)
|
|
else:
|
|
raise TypeError(
|
|
"Message must be a string, Markdown, or dictionary with args and kwargs."
|
|
)
|
|
|
|
async def menu(self) -> None:
|
|
"""Runs the menu and handles user input."""
|
|
logger.info("Running menu: %s", self.get_title())
|
|
self.debug_hooks()
|
|
if self.welcome_message:
|
|
self.print_message(self.welcome_message)
|
|
try:
|
|
while True:
|
|
if callable(self.render_menu):
|
|
self.render_menu(self)
|
|
else:
|
|
self.console.print(self.table, justify="center")
|
|
try:
|
|
task = asyncio.create_task(self.process_command())
|
|
should_continue = await task
|
|
if not should_continue:
|
|
break
|
|
except (EOFError, KeyboardInterrupt):
|
|
logger.info("EOF or KeyboardInterrupt. Exiting menu.")
|
|
break
|
|
except QuitSignal:
|
|
logger.info("QuitSignal received. Exiting menu.")
|
|
break
|
|
except BackSignal:
|
|
logger.info("BackSignal received.")
|
|
except CancelSignal:
|
|
logger.info("CancelSignal received.")
|
|
except HelpSignal:
|
|
logger.info("HelpSignal received.")
|
|
finally:
|
|
logger.info("Exiting menu: %s", self.get_title())
|
|
if self.exit_message:
|
|
self.print_message(self.exit_message)
|
|
|
|
async def run(self) -> None:
|
|
"""Run Falyx CLI with structured subcommands."""
|
|
if not self.cli_args:
|
|
self.cli_args = get_arg_parsers().root.parse_args()
|
|
self.options.from_namespace(self.cli_args, "cli_args")
|
|
|
|
if not self.options.get("never_prompt"):
|
|
self.options.set("never_prompt", self._never_prompt)
|
|
|
|
if not self.options.get("force_confirm"):
|
|
self.options.set("force_confirm", self._force_confirm)
|
|
|
|
if self.cli_args.verbose:
|
|
logging.getLogger("falyx").setLevel(logging.DEBUG)
|
|
|
|
if self.cli_args.debug_hooks:
|
|
logger.debug("✅ Enabling global debug hooks for all commands")
|
|
self.register_all_with_debug_hooks()
|
|
|
|
if self.cli_args.command == "list":
|
|
await self._show_help()
|
|
sys.exit(0)
|
|
|
|
if self.cli_args.command == "version" or self.cli_args.version:
|
|
self.console.print(f"[{OneColors.GREEN_b}]Falyx CLI v{__version__}[/]")
|
|
sys.exit(0)
|
|
|
|
if self.cli_args.command == "preview":
|
|
self.mode = FalyxMode.PREVIEW
|
|
_, command, args, kwargs = self.get_command(self.cli_args.name)
|
|
if not command:
|
|
self.console.print(
|
|
f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found."
|
|
)
|
|
sys.exit(1)
|
|
self.console.print(
|
|
f"Preview of command '{command.key}': {command.description}"
|
|
)
|
|
await command.preview()
|
|
sys.exit(0)
|
|
|
|
if self.cli_args.command == "run":
|
|
self.mode = FalyxMode.RUN
|
|
is_preview, command, _, __ = self.get_command(self.cli_args.name)
|
|
if is_preview:
|
|
if command is None:
|
|
sys.exit(1)
|
|
logger.info("Preview command '%s' selected.", command.key)
|
|
await command.preview()
|
|
sys.exit(0)
|
|
if not command:
|
|
sys.exit(1)
|
|
self._set_retry_policy(command)
|
|
try:
|
|
args, kwargs = command.parse_args(self.cli_args.command_args)
|
|
except HelpSignal:
|
|
sys.exit(0)
|
|
try:
|
|
await self.run_key(self.cli_args.name, args=args, kwargs=kwargs)
|
|
except FalyxError as error:
|
|
self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]")
|
|
sys.exit(1)
|
|
|
|
if self.cli_args.summary:
|
|
er.summary()
|
|
sys.exit(0)
|
|
|
|
if self.cli_args.command == "run-all":
|
|
self.mode = FalyxMode.RUN_ALL
|
|
matching = [
|
|
cmd
|
|
for cmd in self.commands.values()
|
|
if self.cli_args.tag.lower() in (tag.lower() for tag in cmd.tags)
|
|
]
|
|
if not matching:
|
|
self.console.print(
|
|
f"[{OneColors.LIGHT_YELLOW}]⚠️ No commands found with tag: "
|
|
f"'{self.cli_args.tag}'"
|
|
)
|
|
sys.exit(1)
|
|
|
|
self.console.print(
|
|
f"[{OneColors.CYAN_b}]🚀 Running all commands with tag:[/] "
|
|
f"{self.cli_args.tag}"
|
|
)
|
|
for cmd in matching:
|
|
self._set_retry_policy(cmd)
|
|
await self.run_key(cmd.key)
|
|
|
|
if self.cli_args.summary:
|
|
er.summary()
|
|
|
|
sys.exit(0)
|
|
|
|
await self.menu()
|