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:
parent
9351ae658c
commit
5c09f86b9b
|
@ -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())
|
||||
|
|
|
@ -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.[/]"
|
||||
)
|
||||
|
|
|
@ -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]]:
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -59,6 +59,7 @@ class BaseIOAction(BaseAction):
|
|||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
hooks: HookManager | None = None,
|
||||
mode: str = "buffered",
|
||||
logging_hooks: bool = True,
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -1 +1 @@
|
|||
__version__ = "0.1.21"
|
||||
__version__ = "0.1.22"
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue