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

View File

@ -272,15 +272,21 @@ class Command(BaseModel):
if hasattr(self.action, "preview") and callable(self.action.preview): if hasattr(self.action, "preview") and callable(self.action.preview):
tree = Tree(label) tree = Tree(label)
await self.action.preview(parent=tree) await self.action.preview(parent=tree)
if self.help_text:
tree.add(f"[dim]💡 {self.help_text}[/dim]")
console.print(tree) console.print(tree)
elif callable(self.action) and not isinstance(self.action, BaseAction): elif callable(self.action) and not isinstance(self.action, BaseAction):
console.print(f"{label}") console.print(f"{label}")
if self.help_text:
console.print(f"[dim]💡 {self.help_text}[/dim]")
console.print( console.print(
f"[{OneColors.LIGHT_RED_b}]→ Would call:[/] {self.action.__name__}" f"[{OneColors.LIGHT_RED_b}]→ Would call:[/] {self.action.__name__}"
f"[dim](args={self.args}, kwargs={self.kwargs})[/dim]" f"[dim](args={self.args}, kwargs={self.kwargs})[/dim]"
) )
else: else:
console.print(f"{label}") console.print(f"{label}")
if self.help_text:
console.print(f"[dim]💡 {self.help_text}[/dim]")
console.print( console.print(
f"[{OneColors.DARK_RED}]⚠️ Action is not callable or lacks a preview method.[/]" 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.""" Configuration loader for Falyx CLI commands."""
import importlib import importlib
import sys
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import toml import toml
import yaml import yaml
from rich.console import Console
from falyx.action import Action, BaseAction from falyx.action import Action, BaseAction
from falyx.command import Command from falyx.command import Command
from falyx.retry import RetryPolicy 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: 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'.""" """Dynamically imports a callable from a dotted path like 'my.module.func'."""
module_path, _, attr = dotted_path.rpartition(".") module_path, _, attr = dotted_path.rpartition(".")
if not module_path: if not module_path:
raise ValueError(f"Invalid action path: {dotted_path}") console.print(f"[{OneColors.DARK_RED}]❌ Invalid action path:[/] {dotted_path}")
module = importlib.import_module(module_path) sys.exit(1)
return getattr(module, attr) 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]]: def loader(file_path: Path | str) -> list[dict[str, Any]]:

View File

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

View File

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

View File

@ -36,7 +36,9 @@ def get_arg_parsers(
prog: str | None = "falyx", prog: str | None = "falyx",
usage: str | None = None, usage: str | None = None,
description: str | None = "Falyx CLI - Run structured async command workflows.", 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] = [], parents: Sequence[ArgumentParser] = [],
prefix_chars: str = "-", prefix_chars: str = "-",
fromfile_prefix_chars: str | None = None, 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 = 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("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( run_parser.add_argument(
"--retries", type=int, help="Number of retries on failure", default=0 "--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", 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("-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( run_all_parser.add_argument(
"--retries", type=int, help="Number of retries on failure", default=0 "--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) 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( key = await prompt_for_selection(
options.keys(), options.keys(),

View File

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

View File

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