Move spinner and confirmation logic from Falyx to Command
This commit is contained in:
parent
05a7f982f2
commit
880d86d47d
|
@ -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)
|
||||||
|
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)
|
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
|
||||||
|
|
|
@ -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 | ")
|
||||||
|
|
|
@ -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,18 +738,11 @@ 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 self._run_action_with_spinner(selected_command)
|
|
||||||
else:
|
|
||||||
result = await selected_command()
|
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)
|
||||||
|
@ -824,21 +771,10 @@ 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)
|
||||||
|
|
||||||
if selected_command.spinner:
|
|
||||||
result = await self._run_action_with_spinner(selected_command)
|
|
||||||
else:
|
|
||||||
result = await selected_command()
|
result = await selected_command()
|
||||||
context.result = result
|
context.result = result
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
|
|
@ -97,15 +97,18 @@ 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
|
||||||
|
if self.shared_context:
|
||||||
context: SharedContext = self.shared_context
|
context: SharedContext = self.shared_context
|
||||||
|
|
||||||
session = context.get("http_session")
|
session = context.get("http_session")
|
||||||
if session is None:
|
if session is None:
|
||||||
session = aiohttp.ClientSession()
|
session = aiohttp.ClientSession()
|
||||||
context.set("http_session", session)
|
context.set("http_session", session)
|
||||||
context.set("_session_should_close", True)
|
context.set("_session_should_close", True)
|
||||||
|
|
||||||
|
else:
|
||||||
|
session = aiohttp.ClientSession()
|
||||||
|
|
||||||
async with session.request(
|
async with session.request(
|
||||||
self.method,
|
self.method,
|
||||||
self.url,
|
self.url,
|
||||||
|
@ -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}'",
|
||||||
|
|
|
@ -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
|
|
@ -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)
|
||||||
|
|
|
@ -87,6 +87,12 @@ 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)
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
__version__ = "0.1.16"
|
__version__ = "0.1.17"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in New Issue