falyx/falyx/falyx.py

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