Add help_text to preview, preview to menu, error handling for imports, summary printing for run and run-all, mandatory kwargs for most arguments

This commit is contained in:
Roland Thomas Jr 2025-05-10 09:56:48 -04:00
parent 9351ae658c
commit 5c09f86b9b
Signed by: roland
GPG Key ID: 7C3C2B085A4C2872
11 changed files with 159 additions and 76 deletions

View File

@ -14,6 +14,7 @@ from typing import Any
from falyx.config import loader
from falyx.falyx import Falyx
from falyx.parsers import FalyxParsers, get_arg_parsers
from falyx.themes.colors import OneColors
def find_falyx_config() -> Path | None:
@ -71,6 +72,7 @@ def run(args: Namespace) -> Any:
title="🛠️ Config-Driven CLI",
cli_args=args,
columns=4,
prompt=[(OneColors.BLUE_b, "FALYX > ")],
)
flx.add_commands(loader(bootstrap_path))
return asyncio.run(flx.run())

View File

@ -272,15 +272,21 @@ class Command(BaseModel):
if hasattr(self.action, "preview") and callable(self.action.preview):
tree = Tree(label)
await self.action.preview(parent=tree)
if self.help_text:
tree.add(f"[dim]💡 {self.help_text}[/dim]")
console.print(tree)
elif callable(self.action) and not isinstance(self.action, BaseAction):
console.print(f"{label}")
if self.help_text:
console.print(f"[dim]💡 {self.help_text}[/dim]")
console.print(
f"[{OneColors.LIGHT_RED_b}]→ Would call:[/] {self.action.__name__}"
f"[dim](args={self.args}, kwargs={self.kwargs})[/dim]"
)
else:
console.print(f"{label}")
if self.help_text:
console.print(f"[dim]💡 {self.help_text}[/dim]")
console.print(
f"[{OneColors.DARK_RED}]⚠️ Action is not callable or lacks a preview method.[/]"
)

View File

@ -3,15 +3,21 @@
Configuration loader for Falyx CLI commands."""
import importlib
import sys
from pathlib import Path
from typing import Any
import toml
import yaml
from rich.console import Console
from falyx.action import Action, BaseAction
from falyx.command import Command
from falyx.retry import RetryPolicy
from falyx.themes.colors import OneColors
from falyx.utils import logger
console = Console(color_system="auto")
def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command:
@ -30,9 +36,28 @@ def import_action(dotted_path: str) -> Any:
"""Dynamically imports a callable from a dotted path like 'my.module.func'."""
module_path, _, attr = dotted_path.rpartition(".")
if not module_path:
raise ValueError(f"Invalid action path: {dotted_path}")
module = importlib.import_module(module_path)
return getattr(module, attr)
console.print(f"[{OneColors.DARK_RED}]❌ Invalid action path:[/] {dotted_path}")
sys.exit(1)
try:
module = importlib.import_module(module_path)
except ModuleNotFoundError as error:
logger.error("Failed to import module '%s': %s", module_path, error)
console.print(
f"[{OneColors.DARK_RED}]❌ Could not import '{dotted_path}': {error}[/]\n"
f"[{OneColors.COMMENT_GREY}]Ensure the module is installed and discoverable via PYTHONPATH."
)
sys.exit(1)
try:
action = getattr(module, attr)
except AttributeError as error:
logger.error(
"Module '%s' does not have attribute '%s': %s", module_path, attr, error
)
console.print(
f"[{OneColors.DARK_RED}]❌ Module '{module_path}' has no attribute '{attr}': {error}[/]"
)
sys.exit(1)
return action
def loader(file_path: Path | str) -> list[dict[str, Any]]:

View File

@ -283,7 +283,7 @@ class Falyx:
self.console.print(table, justify="center")
if self.mode == FalyxMode.MENU:
self.console.print(
f"📦 Tip: Type '[{OneColors.LIGHT_YELLOW}]?[KEY][/]' to preview a command before running it.\n",
f"📦 Tip: '[{OneColors.LIGHT_YELLOW}]?[KEY][/]' to preview a command before running it.\n",
justify="center",
)
@ -343,7 +343,9 @@ class Falyx:
error_message = " ".join(message_lines)
def validator(text):
_, choice = self.get_command(text, from_validate=True)
is_preview, choice = self.get_command(text, from_validate=True)
if is_preview and choice is None:
return True
return True if choice else False
return Validator.from_callable(
@ -694,6 +696,13 @@ class Falyx:
) -> tuple[bool, Command | None]:
"""Returns the selected command based on user input. Supports keys, aliases, and abbreviations."""
is_preview, choice = self.parse_preview_command(choice)
if is_preview and not choice:
if not from_validate:
self.console.print(
f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode.[/]"
)
return is_preview, None
choice = choice.upper()
name_map = self._name_map
@ -788,12 +797,17 @@ class Falyx:
async def run_key(self, command_key: str, return_context: bool = False) -> Any:
"""Run a command by key without displaying the menu (non-interactive mode)."""
self.debug_hooks()
_, selected_command = self.get_command(command_key)
is_preview, selected_command = self.get_command(command_key)
self.last_run_command = selected_command
if not selected_command:
return None
if is_preview:
logger.info(f"Preview command '{selected_command.key}' selected.")
await selected_command.preview()
return None
logger.info(
"[run_key] 🚀 Executing: %s%s",
selected_command.key,
@ -943,11 +957,14 @@ class Falyx:
if self.cli_args.command == "run":
self.mode = FalyxMode.RUN
_, command = self.get_command(self.cli_args.name)
is_preview, command = self.get_command(self.cli_args.name)
if is_preview:
if command is None:
sys.exit(1)
logger.info(f"Preview command '{command.key}' selected.")
await command.preview()
sys.exit(0)
if not command:
self.console.print(
f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found.[/]"
)
sys.exit(1)
self._set_retry_policy(command)
try:
@ -955,6 +972,9 @@ class Falyx:
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":
@ -976,6 +996,10 @@ class Falyx:
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()

View File

@ -59,6 +59,7 @@ class BaseIOAction(BaseAction):
def __init__(
self,
name: str,
*,
hooks: HookManager | None = None,
mode: str = "buffered",
logging_hooks: bool = True,

View File

@ -36,7 +36,9 @@ def get_arg_parsers(
prog: str | None = "falyx",
usage: str | None = None,
description: str | None = "Falyx CLI - Run structured async command workflows.",
epilog: str | None = None,
epilog: (
str | None
) = "Tip: Use 'falyx run ?[COMMAND]' to preview any command from the CLI.",
parents: Sequence[ArgumentParser] = [],
prefix_chars: str = "-",
fromfile_prefix_chars: str | None = None,
@ -79,6 +81,11 @@ def get_arg_parsers(
run_parser = subparsers.add_parser("run", help="Run a specific command")
run_parser.add_argument("name", help="Key, alias, or description of the command")
run_parser.add_argument(
"--summary",
action="store_true",
help="Print an execution summary after command completes",
)
run_parser.add_argument(
"--retries", type=int, help="Number of retries on failure", default=0
)
@ -111,6 +118,11 @@ def get_arg_parsers(
"run-all", help="Run all commands with a given tag"
)
run_all_parser.add_argument("-t", "--tag", required=True, help="Tag to match")
run_all_parser.add_argument(
"--summary",
action="store_true",
help="Print a summary after all tagged commands run",
)
run_all_parser.add_argument(
"--retries", type=int, help="Number of retries on failure", default=0
)

View File

@ -137,7 +137,9 @@ class SelectFileAction(BaseAction):
options = self.get_options(files)
table = render_selection_dict_table(self.title, options, self.columns)
table = render_selection_dict_table(
title=self.title, selections=options, columns=self.columns
)
key = await prompt_for_selection(
options.keys(),

View File

@ -31,6 +31,7 @@ class SelectionOption:
def render_table_base(
title: str,
*,
caption: str = "",
columns: int = 4,
box_style: box.Box = box.SIMPLE,
@ -71,6 +72,7 @@ def render_table_base(
def render_selection_grid(
title: str,
selections: Sequence[str],
*,
columns: int = 4,
caption: str = "",
box_style: box.Box = box.SIMPLE,
@ -86,19 +88,19 @@ def render_selection_grid(
) -> Table:
"""Create a selection table with the given parameters."""
table = render_table_base(
title,
caption,
columns,
box_style,
show_lines,
show_header,
show_footer,
style,
header_style,
footer_style,
title_style,
caption_style,
highlight,
title=title,
caption=caption,
columns=columns,
box_style=box_style,
show_lines=show_lines,
show_header=show_header,
show_footer=show_footer,
style=style,
header_style=header_style,
footer_style=footer_style,
title_style=title_style,
caption_style=caption_style,
highlight=highlight,
)
for chunk in chunks(selections, columns):
@ -110,6 +112,7 @@ def render_selection_grid(
def render_selection_indexed_table(
title: str,
selections: Sequence[str],
*,
columns: int = 4,
caption: str = "",
box_style: box.Box = box.SIMPLE,
@ -126,19 +129,19 @@ def render_selection_indexed_table(
) -> Table:
"""Create a selection table with the given parameters."""
table = render_table_base(
title,
caption,
columns,
box_style,
show_lines,
show_header,
show_footer,
style,
header_style,
footer_style,
title_style,
caption_style,
highlight,
title=title,
caption=caption,
columns=columns,
box_style=box_style,
show_lines=show_lines,
show_header=show_header,
show_footer=show_footer,
style=style,
header_style=header_style,
footer_style=footer_style,
title_style=title_style,
caption_style=caption_style,
highlight=highlight,
)
for indexes, chunk in zip(
@ -156,6 +159,7 @@ def render_selection_indexed_table(
def render_selection_dict_table(
title: str,
selections: dict[str, SelectionOption],
*,
columns: int = 2,
caption: str = "",
box_style: box.Box = box.SIMPLE,
@ -171,19 +175,19 @@ def render_selection_dict_table(
) -> Table:
"""Create a selection table with the given parameters."""
table = render_table_base(
title,
caption,
columns,
box_style,
show_lines,
show_header,
show_footer,
style,
header_style,
footer_style,
title_style,
caption_style,
highlight,
title=title,
caption=caption,
columns=columns,
box_style=box_style,
show_lines=show_lines,
show_header=show_header,
show_footer=show_footer,
style=style,
header_style=header_style,
footer_style=footer_style,
title_style=title_style,
caption_style=caption_style,
highlight=highlight,
)
for chunk in chunks(selections.items(), columns):
@ -200,6 +204,7 @@ def render_selection_dict_table(
async def prompt_for_index(
max_index: int,
table: Table,
*,
min_index: int = 0,
default_selection: str = "",
console: Console | None = None,
@ -224,6 +229,7 @@ async def prompt_for_index(
async def prompt_for_selection(
keys: Sequence[str] | KeysView[str],
table: Table,
*,
default_selection: str = "",
console: Console | None = None,
prompt_session: PromptSession | None = None,
@ -249,6 +255,7 @@ async def prompt_for_selection(
async def select_value_from_list(
title: str,
selections: Sequence[str],
*,
console: Console | None = None,
prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ",
@ -268,20 +275,20 @@ async def select_value_from_list(
):
"""Prompt for a selection. Return the selected item."""
table = render_selection_indexed_table(
title,
selections,
columns,
caption,
box_style,
show_lines,
show_header,
show_footer,
style,
header_style,
footer_style,
title_style,
caption_style,
highlight,
title=title,
selections=selections,
columns=columns,
caption=caption,
box_style=box_style,
show_lines=show_lines,
show_header=show_header,
show_footer=show_footer,
style=style,
header_style=header_style,
footer_style=footer_style,
title_style=title_style,
caption_style=caption_style,
highlight=highlight,
)
prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="auto")
@ -301,6 +308,7 @@ async def select_value_from_list(
async def select_key_from_dict(
selections: dict[str, SelectionOption],
table: Table,
*,
console: Console | None = None,
prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ",
@ -325,6 +333,7 @@ async def select_key_from_dict(
async def select_value_from_dict(
selections: dict[str, SelectionOption],
table: Table,
*,
console: Console | None = None,
prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ",
@ -351,6 +360,7 @@ async def select_value_from_dict(
async def get_selection_from_dict_menu(
title: str,
selections: dict[str, SelectionOption],
*,
console: Console | None = None,
prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ",
@ -363,10 +373,10 @@ async def get_selection_from_dict_menu(
)
return await select_value_from_dict(
selections,
table,
console,
prompt_session,
prompt_message,
default_selection,
selections=selections,
table=table,
console=console,
prompt_session=prompt_session,
prompt_message=prompt_message,
default_selection=default_selection,
)

View File

@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""selection_action.py"""
from pathlib import Path
from typing import Any
from prompt_toolkit import PromptSession
@ -117,7 +116,9 @@ class SelectionAction(BaseAction):
await self.hooks.trigger(HookType.BEFORE, context)
if isinstance(self.selections, list):
table = render_selection_indexed_table(
self.title, self.selections, self.columns
title=self.title,
selections=self.selections,
columns=self.columns,
)
if not self.never_prompt:
index = await prompt_for_index(
@ -134,7 +135,7 @@ class SelectionAction(BaseAction):
result = self.selections[int(index)]
elif isinstance(self.selections, dict):
table = render_selection_dict_table(
self.title, self.selections, self.columns
title=self.title, selections=self.selections, columns=self.columns
)
if not self.never_prompt:
key = await prompt_for_selection(

View File

@ -1 +1 @@
__version__ = "0.1.21"
__version__ = "0.1.22"

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "falyx"
version = "0.1.21"
version = "0.1.22"
description = "Reliable and introspectable async CLI action framework."
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
license = "MIT"