Move spinner and confirmation logic from Falyx to Command

This commit is contained in:
Roland Thomas Jr 2025-05-08 00:45:24 -04:00
parent 05a7f982f2
commit 880d86d47d
Signed by: roland
GPG Key ID: 7C3C2B085A4C2872
9 changed files with 89 additions and 95 deletions

View File

@ -29,14 +29,16 @@ from rich.tree import Tree
from falyx.action import Action, ActionGroup, BaseAction, ChainedAction from falyx.action import Action, ActionGroup, BaseAction, ChainedAction
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.debug import register_debug_hooks from falyx.debug import register_debug_hooks
from falyx.exceptions import FalyxError
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType from falyx.hook_manager import HookManager, HookType
from falyx.io_action import BaseIOAction from falyx.io_action import BaseIOAction
from falyx.options_manager import OptionsManager from falyx.options_manager import OptionsManager
from falyx.prompt_utils import should_prompt_user
from falyx.retry import RetryPolicy from falyx.retry import RetryPolicy
from falyx.retry_utils import enable_retries_recursively from falyx.retry_utils import enable_retries_recursively
from falyx.themes.colors import OneColors from falyx.themes.colors import OneColors
from falyx.utils import _noop, ensure_async, logger from falyx.utils import _noop, confirm_async, ensure_async, logger
console = Console() console = Console()
@ -180,7 +182,10 @@ class Command(BaseModel):
self.action.set_options_manager(self.options_manager) self.action.set_options_manager(self.options_manager)
async def __call__(self, *args, **kwargs) -> Any: async def __call__(self, *args, **kwargs) -> Any:
"""Run the action with full hook lifecycle, timing, and error handling.""" """
Run the action with full hook lifecycle, timing, error handling,
confirmation prompts, preview, and spinner integration.
"""
self._inject_options_manager() self._inject_options_manager()
combined_args = args + self.args combined_args = args + self.args
combined_kwargs = {**self.kwargs, **kwargs} combined_kwargs = {**self.kwargs, **kwargs}
@ -191,11 +196,29 @@ class Command(BaseModel):
action=self, action=self,
) )
self._context = context self._context = context
if should_prompt_user(confirm=self.confirm, options=self.options_manager):
if self.preview_before_confirm:
await self.preview()
if not await confirm_async(self.confirmation_prompt):
logger.info(f"[Command:{self.key}] ❌ Cancelled by user.")
raise FalyxError(f"[Command:{self.key}] Cancelled by confirmation.")
context.start_timer() context.start_timer()
try: try:
await self.hooks.trigger(HookType.BEFORE, context) await self.hooks.trigger(HookType.BEFORE, context)
result = await self.action(*combined_args, **combined_kwargs) if self.spinner:
with console.status(
self.spinner_message,
spinner=self.spinner_type,
spinner_style=self.spinner_style,
**self.spinner_kwargs,
):
result = await self.action(*combined_args, **combined_kwargs)
else:
result = await self.action(*combined_args, **combined_kwargs)
context.result = result context.result = result
await self.hooks.trigger(HookType.ON_SUCCESS, context) await self.hooks.trigger(HookType.ON_SUCCESS, context)
return context.result return context.result

View File

@ -129,7 +129,7 @@ class ExecutionContext(BaseModel):
if self.start_wall: if self.start_wall:
message.append(f"Start: {self.start_wall.strftime('%H:%M:%S')} | ") message.append(f"Start: {self.start_wall.strftime('%H:%M:%S')} | ")
if self.end_time: if self.end_wall:
message.append(f"End: {self.end_wall.strftime('%H:%M:%S')} | ") message.append(f"End: {self.end_wall.strftime('%H:%M:%S')} | ")
message.append(f"Duration: {summary['duration']:.3f}s | ") message.append(f"Duration: {summary['duration']:.3f}s | ")

View File

@ -55,13 +55,7 @@ from falyx.parsers import get_arg_parsers
from falyx.retry import RetryPolicy from falyx.retry import RetryPolicy
from falyx.signals import BackSignal, QuitSignal from falyx.signals import BackSignal, QuitSignal
from falyx.themes.colors import OneColors, get_nord_theme from falyx.themes.colors import OneColors, get_nord_theme
from falyx.utils import ( from falyx.utils import CaseInsensitiveDict, chunks, get_program_invocation, logger
CaseInsensitiveDict,
chunks,
confirm_async,
get_program_invocation,
logger,
)
from falyx.version import __version__ from falyx.version import __version__
@ -93,9 +87,8 @@ class Falyx:
key_bindings (KeyBindings | None): Custom Prompt Toolkit key bindings. key_bindings (KeyBindings | None): Custom Prompt Toolkit key bindings.
include_history_command (bool): Whether to add a built-in history viewer command. 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. include_help_command (bool): Whether to add a built-in help viewer command.
confirm_on_error (bool): Whether to prompt the user after errors. never_prompt (bool): Seed default for `OptionsManager["never_prompt"]`
never_prompt (bool): Whether to skip confirmation prompts entirely. force_confirm (bool): Seed default for `OptionsManager["force_confirm"]`
always_confirm (bool): Whether to force confirmation prompts for all actions.
cli_args (Namespace | None): Parsed CLI arguments, usually from argparse. cli_args (Namespace | None): Parsed CLI arguments, usually from argparse.
options (OptionsManager | None): Declarative option mappings. options (OptionsManager | None): Declarative option mappings.
custom_table (Callable[[Falyx], Table] | Table | None): Custom menu table generator. custom_table (Callable[[Falyx], Table] | Table | None): Custom menu table generator.
@ -123,9 +116,8 @@ class Falyx:
key_bindings: KeyBindings | None = None, key_bindings: KeyBindings | None = None,
include_history_command: bool = True, include_history_command: bool = True,
include_help_command: bool = True, include_help_command: bool = True,
confirm_on_error: bool = True,
never_prompt: bool = False, never_prompt: bool = False,
always_confirm: bool = False, force_confirm: bool = False,
cli_args: Namespace | None = None, cli_args: Namespace | None = None,
options: OptionsManager | None = None, options: OptionsManager | None = None,
render_menu: Callable[["Falyx"], None] | None = None, render_menu: Callable[["Falyx"], None] | None = None,
@ -150,16 +142,15 @@ class Falyx:
self.last_run_command: Command | None = None self.last_run_command: Command | None = None
self.key_bindings: KeyBindings = key_bindings or KeyBindings() self.key_bindings: KeyBindings = key_bindings or KeyBindings()
self.bottom_bar: BottomBar | str | Callable[[], None] = bottom_bar self.bottom_bar: BottomBar | str | Callable[[], None] = bottom_bar
self.confirm_on_error: bool = confirm_on_error
self._never_prompt: bool = never_prompt self._never_prompt: bool = never_prompt
self._always_confirm: bool = always_confirm self._force_confirm: bool = force_confirm
self.cli_args: Namespace | None = cli_args self.cli_args: Namespace | None = cli_args
self.render_menu: Callable[["Falyx"], None] | None = render_menu self.render_menu: Callable[["Falyx"], None] | None = render_menu
self.custom_table: Callable[["Falyx"], Table] | Table | None = custom_table self.custom_table: Callable[["Falyx"], Table] | Table | None = custom_table
self.set_options(cli_args, options) self.validate_options(cli_args, options)
self._session: PromptSession | None = None self._session: PromptSession | None = None
def set_options( def validate_options(
self, self,
cli_args: Namespace | None, cli_args: Namespace | None,
options: OptionsManager | None = None, options: OptionsManager | None = None,
@ -175,8 +166,6 @@ class Falyx:
assert isinstance( assert isinstance(
cli_args, Namespace cli_args, Namespace
), "CLI arguments must be a Namespace object." ), "CLI arguments must be a Namespace object."
if options is None:
self.options.from_namespace(cli_args, "cli_args")
if not isinstance(self.options, OptionsManager): if not isinstance(self.options, OptionsManager):
raise FalyxError("Options must be an instance of OptionsManager.") raise FalyxError("Options must be an instance of OptionsManager.")
@ -705,33 +694,8 @@ class Falyx:
self.console.print( self.console.print(
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'[/]" f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'[/]"
) )
logger.warning(f"⚠️ Command '{choice}' not found.")
return None return None
async def _should_run_action(self, selected_command: Command) -> bool:
if self._never_prompt:
return True
if self.cli_args and getattr(self.cli_args, "skip_confirm", False):
return True
if (
self._always_confirm
or selected_command.confirm
or self.cli_args
and getattr(self.cli_args, "force_confirm", False)
):
if selected_command.preview_before_confirm:
await selected_command.preview()
confirm_answer = await confirm_async(selected_command.confirmation_prompt)
if confirm_answer:
logger.info(f"[{selected_command.description}]🔐 confirmed.")
else:
logger.info(f"[{selected_command.description}]❌ cancelled.")
return confirm_answer
return True
def _create_context(self, selected_command: Command) -> ExecutionContext: def _create_context(self, selected_command: Command) -> ExecutionContext:
"""Creates a context dictionary for the selected command.""" """Creates a context dictionary for the selected command."""
return ExecutionContext( return ExecutionContext(
@ -741,16 +705,6 @@ class Falyx:
action=selected_command, action=selected_command,
) )
async def _run_action_with_spinner(self, command: Command) -> Any:
"""Runs the action of the selected command with a spinner."""
with self.console.status(
command.spinner_message,
spinner=command.spinner_type,
spinner_style=command.spinner_style,
**command.spinner_kwargs,
):
return await command()
async def _handle_action_error( async def _handle_action_error(
self, selected_command: Command, error: Exception self, selected_command: Command, error: Exception
) -> None: ) -> None:
@ -784,19 +738,12 @@ class Falyx:
logger.info(f"🔙 Back selected: exiting {self.get_title()}") logger.info(f"🔙 Back selected: exiting {self.get_title()}")
return False return False
if not await self._should_run_action(selected_command):
logger.info(f"{selected_command.description} cancelled.")
return True
context = self._create_context(selected_command) context = self._create_context(selected_command)
context.start_timer() context.start_timer()
try: try:
await self.hooks.trigger(HookType.BEFORE, context) await self.hooks.trigger(HookType.BEFORE, context)
if selected_command.spinner: result = await selected_command()
result = await self._run_action_with_spinner(selected_command)
else:
result = await selected_command()
context.result = result context.result = result
await self.hooks.trigger(HookType.ON_SUCCESS, context) await self.hooks.trigger(HookType.ON_SUCCESS, context)
except Exception as error: except Exception as error:
@ -824,22 +771,11 @@ class Falyx:
selected_command.description, selected_command.description,
) )
if not await self._should_run_action(selected_command):
logger.info("[run_key] ❌ Cancelled: %s", selected_command.description)
raise FalyxError(
f"[run_key] '{selected_command.description}' "
"cancelled by confirmation."
)
context = self._create_context(selected_command) context = self._create_context(selected_command)
context.start_timer() context.start_timer()
try: try:
await self.hooks.trigger(HookType.BEFORE, context) await self.hooks.trigger(HookType.BEFORE, context)
result = await selected_command()
if selected_command.spinner:
result = await self._run_action_with_spinner(selected_command)
else:
result = await selected_command()
context.result = result context.result = result
await self.hooks.trigger(HookType.ON_SUCCESS, context) await self.hooks.trigger(HookType.ON_SUCCESS, context)
@ -939,6 +875,13 @@ class Falyx:
"""Run Falyx CLI with structured subcommands.""" """Run Falyx CLI with structured subcommands."""
if not self.cli_args: if not self.cli_args:
self.cli_args = get_arg_parsers().root.parse_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: if self.cli_args.verbose:
logging.getLogger("falyx").setLevel(logging.DEBUG) logging.getLogger("falyx").setLevel(logging.DEBUG)
@ -947,9 +890,6 @@ class Falyx:
logger.debug("✅ Enabling global debug hooks for all commands") logger.debug("✅ Enabling global debug hooks for all commands")
self.register_all_with_debug_hooks() self.register_all_with_debug_hooks()
if self.cli_args.never_prompt:
self._never_prompt = True
if self.cli_args.command == "list": if self.cli_args.command == "list":
await self._show_help() await self._show_help()
sys.exit(0) sys.exit(0)

View File

@ -97,14 +97,17 @@ class HTTPAction(Action):
) )
async def _request(self, *args, **kwargs) -> dict[str, Any]: async def _request(self, *args, **kwargs) -> dict[str, Any]:
assert self.shared_context is not None, "SharedContext is not set" # TODO: Add check for HOOK registration
context: SharedContext = self.shared_context if self.shared_context:
context: SharedContext = self.shared_context
session = context.get("http_session")
if session is None:
session = aiohttp.ClientSession()
context.set("http_session", session)
context.set("_session_should_close", True)
session = context.get("http_session") else:
if session is None:
session = aiohttp.ClientSession() session = aiohttp.ClientSession()
context.set("http_session", session)
context.set("_session_should_close", True)
async with session.request( async with session.request(
self.method, self.method,
@ -122,6 +125,9 @@ class HTTPAction(Action):
"body": body, "body": body,
} }
if not self.shared_context:
await session.close()
async def preview(self, parent: Tree | None = None): async def preview(self, parent: Tree | None = None):
label = [ label = [
f"[{OneColors.CYAN_b}]🌐 HTTPAction[/] '{self.name}'", f"[{OneColors.CYAN_b}]🌐 HTTPAction[/] '{self.name}'",

19
falyx/prompt_utils.py Normal file
View File

@ -0,0 +1,19 @@
from falyx.options_manager import OptionsManager
def should_prompt_user(
*,
confirm: bool,
options: OptionsManager,
namespace: str = "cli_args",
):
"""Determine whether to prompt the user for confirmation based on command and global options."""
never_prompt = options.get("never_prompt", False, namespace)
always_confirm = options.get("always_confirm", False, namespace)
force_confirm = options.get("force_confirm", False, namespace)
skip_confirm = options.get("skip_confirm", False, namespace)
if never_prompt or skip_confirm:
return False
return confirm or always_confirm or force_confirm

View File

@ -145,7 +145,7 @@ def render_selection_indexed_table(
chunks(range(len(selections)), columns), chunks(selections, columns) chunks(range(len(selections)), columns), chunks(selections, columns)
): ):
row = [ row = [
formatter(index, selection) if formatter else f"{index}: {selection}" formatter(index, selection) if formatter else f"[{index}] {selection}"
for index, selection in zip(indexes, chunk) for index, selection in zip(indexes, chunk)
] ]
table.add_row(*row) table.add_row(*row)

View File

@ -87,17 +87,23 @@ class SelectionAction(BaseAction):
if isinstance(self.selections, dict): if isinstance(self.selections, dict):
if maybe_result in self.selections: if maybe_result in self.selections:
effective_default = maybe_result effective_default = maybe_result
elif self.inject_last_result:
logger.warning(
"[%s] Injected last result '%s' not found in selections",
self.name,
maybe_result,
)
elif isinstance(self.selections, list): elif isinstance(self.selections, list):
if maybe_result.isdigit() and int(maybe_result) in range( if maybe_result.isdigit() and int(maybe_result) in range(
len(self.selections) len(self.selections)
): ):
effective_default = maybe_result effective_default = maybe_result
elif self.inject_last_result: elif self.inject_last_result:
logger.warning( logger.warning(
"[%s] Injected last result '%s' not found in selections", "[%s] Injected last result '%s' not found in selections",
self.name, self.name,
maybe_result, maybe_result,
) )
if self.never_prompt and not effective_default: if self.never_prompt and not effective_default:
raise ValueError( raise ValueError(

View File

@ -1 +1 @@
__version__ = "0.1.16" __version__ = "0.1.17"

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "falyx" name = "falyx"
version = "0.1.16" version = "0.1.17"
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"