Add MenuAction, SelectionAction, SignalAction, never_prompt(options_manager propagation), Merged prepare
This commit is contained in:
parent
69b629eb08
commit
91c4d5481f
|
@ -9,10 +9,10 @@ def hello() -> None:
|
|||
print("Hello, world!")
|
||||
|
||||
|
||||
hello = Action(name="hello_action", action=hello)
|
||||
hello_action = Action(name="hello_action", action=hello)
|
||||
|
||||
# Actions can be run by themselves or as part of a command or pipeline
|
||||
asyncio.run(hello())
|
||||
asyncio.run(hello_action())
|
||||
|
||||
|
||||
# Actions are designed to be asynchronous first
|
||||
|
@ -20,14 +20,14 @@ async def goodbye() -> None:
|
|||
print("Goodbye!")
|
||||
|
||||
|
||||
goodbye = Action(name="goodbye_action", action=goodbye)
|
||||
goodbye_action = Action(name="goodbye_action", action=goodbye)
|
||||
|
||||
asyncio.run(goodbye())
|
||||
|
||||
# Actions can be run in parallel
|
||||
group = ActionGroup(name="greeting_group", actions=[hello, goodbye])
|
||||
group = ActionGroup(name="greeting_group", actions=[hello_action, goodbye_action])
|
||||
asyncio.run(group())
|
||||
|
||||
# Actions can be run in a chain
|
||||
chain = ChainedAction(name="greeting_chain", actions=[hello, goodbye])
|
||||
chain = ChainedAction(name="greeting_chain", actions=[hello_action, goodbye_action])
|
||||
asyncio.run(chain())
|
||||
|
|
|
@ -12,6 +12,7 @@ async def flaky_step():
|
|||
await asyncio.sleep(0.2)
|
||||
if random.random() < 0.5:
|
||||
raise RuntimeError("Random failure!")
|
||||
print("Flaky step succeeded!")
|
||||
return "ok"
|
||||
|
||||
|
||||
|
|
|
@ -30,7 +30,6 @@ def bootstrap() -> Path | None:
|
|||
if config_path and str(config_path.parent) not in sys.path:
|
||||
sys.path.insert(0, str(config_path.parent))
|
||||
return config_path
|
||||
return None
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
|
|
@ -43,6 +43,7 @@ from falyx.debug import register_debug_hooks
|
|||
from falyx.exceptions import EmptyChainError
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import Hook, HookManager, HookType
|
||||
from falyx.options_manager import OptionsManager
|
||||
from falyx.retry import RetryHandler, RetryPolicy
|
||||
from falyx.themes.colors import OneColors
|
||||
from falyx.utils import ensure_async, logger
|
||||
|
@ -66,6 +67,7 @@ class BaseAction(ABC):
|
|||
hooks: HookManager | None = None,
|
||||
inject_last_result: bool = False,
|
||||
inject_last_result_as: str = "last_result",
|
||||
never_prompt: bool = False,
|
||||
logging_hooks: bool = False,
|
||||
) -> None:
|
||||
self.name = name
|
||||
|
@ -74,9 +76,11 @@ class BaseAction(ABC):
|
|||
self.shared_context: SharedContext | None = None
|
||||
self.inject_last_result: bool = inject_last_result
|
||||
self.inject_last_result_as: str = inject_last_result_as
|
||||
self._never_prompt: bool = never_prompt
|
||||
self._requires_injection: bool = False
|
||||
self._skip_in_chain: bool = False
|
||||
self.console = Console(color_system="auto")
|
||||
self.options_manager: OptionsManager | None = None
|
||||
|
||||
if logging_hooks:
|
||||
register_debug_hooks(self.hooks)
|
||||
|
@ -92,23 +96,39 @@ class BaseAction(ABC):
|
|||
async def preview(self, parent: Tree | None = None):
|
||||
raise NotImplementedError("preview must be implemented by subclasses")
|
||||
|
||||
def set_shared_context(self, shared_context: SharedContext):
|
||||
def set_options_manager(self, options_manager: OptionsManager) -> None:
|
||||
self.options_manager = options_manager
|
||||
|
||||
def set_shared_context(self, shared_context: SharedContext) -> None:
|
||||
self.shared_context = shared_context
|
||||
|
||||
def prepare_for_chain(self, shared_context: SharedContext) -> BaseAction:
|
||||
def get_option(self, option_name: str, default: Any = None) -> Any:
|
||||
"""Resolve an option from the OptionsManager if present, otherwise use the fallback."""
|
||||
if self.options_manager:
|
||||
return self.options_manager.get(option_name, default)
|
||||
return default
|
||||
|
||||
@property
|
||||
def last_result(self) -> Any:
|
||||
"""Return the last result from the shared context."""
|
||||
if self.shared_context:
|
||||
return self.shared_context.last_result()
|
||||
return None
|
||||
|
||||
@property
|
||||
def never_prompt(self) -> bool:
|
||||
return self.get_option("never_prompt", self._never_prompt)
|
||||
|
||||
def prepare(
|
||||
self, shared_context: SharedContext, options_manager: OptionsManager | None = None
|
||||
) -> BaseAction:
|
||||
"""
|
||||
Prepare the action specifically for sequential (ChainedAction) execution.
|
||||
Can be overridden for chain-specific logic.
|
||||
"""
|
||||
self.set_shared_context(shared_context)
|
||||
return self
|
||||
|
||||
def prepare_for_group(self, shared_context: SharedContext) -> BaseAction:
|
||||
"""
|
||||
Prepare the action specifically for parallel (ActionGroup) execution.
|
||||
Can be overridden for group-specific logic.
|
||||
"""
|
||||
self.set_shared_context(shared_context)
|
||||
if options_manager:
|
||||
self.set_options_manager(options_manager)
|
||||
return self
|
||||
|
||||
def _maybe_inject_last_result(self, kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
|
@ -161,8 +181,8 @@ class Action(BaseAction):
|
|||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
action,
|
||||
rollback=None,
|
||||
action: Callable[..., Any],
|
||||
rollback: Callable[..., Any] | None = None,
|
||||
args: tuple[Any, ...] = (),
|
||||
kwargs: dict[str, Any] | None = None,
|
||||
hooks: HookManager | None = None,
|
||||
|
@ -189,6 +209,17 @@ class Action(BaseAction):
|
|||
def action(self, value: Callable[..., Any]):
|
||||
self._action = ensure_async(value)
|
||||
|
||||
@property
|
||||
def rollback(self) -> Callable[..., Any] | None:
|
||||
return self._rollback
|
||||
|
||||
@rollback.setter
|
||||
def rollback(self, value: Callable[..., Any] | None):
|
||||
if value is None:
|
||||
self._rollback = None
|
||||
else:
|
||||
self._rollback = ensure_async(value)
|
||||
|
||||
def enable_retry(self):
|
||||
"""Enable retry with the existing retry policy."""
|
||||
self.retry_policy.enable_policy()
|
||||
|
@ -212,6 +243,7 @@ class Action(BaseAction):
|
|||
kwargs=combined_kwargs,
|
||||
action=self,
|
||||
)
|
||||
|
||||
context.start_timer()
|
||||
try:
|
||||
await self.hooks.trigger(HookType.BEFORE, context)
|
||||
|
@ -425,7 +457,7 @@ class ChainedAction(BaseAction, ActionListMixin):
|
|||
)
|
||||
continue
|
||||
shared_context.current_index = index
|
||||
prepared = action.prepare_for_chain(shared_context)
|
||||
prepared = action.prepare(shared_context, self.options_manager)
|
||||
last_result = shared_context.last_result()
|
||||
try:
|
||||
if self.requires_io_injection() and last_result is not None:
|
||||
|
@ -446,9 +478,7 @@ class ChainedAction(BaseAction, ActionListMixin):
|
|||
)
|
||||
shared_context.add_result(None)
|
||||
context.extra["results"].append(None)
|
||||
fallback = self.actions[index + 1].prepare_for_chain(
|
||||
shared_context
|
||||
)
|
||||
fallback = self.actions[index + 1].prepare(shared_context)
|
||||
result = await fallback()
|
||||
fallback._skip_in_chain = True
|
||||
else:
|
||||
|
@ -584,7 +614,7 @@ class ActionGroup(BaseAction, ActionListMixin):
|
|||
|
||||
async def run_one(action: BaseAction):
|
||||
try:
|
||||
prepared = action.prepare_for_group(shared_context)
|
||||
prepared = action.prepare(shared_context, self.options_manager)
|
||||
result = await prepared(*args, **updated_kwargs)
|
||||
shared_context.add_result((action.name, result))
|
||||
context.extra["results"].append((action.name, result))
|
||||
|
|
|
@ -32,6 +32,7 @@ from falyx.debug import register_debug_hooks
|
|||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import HookManager, HookType
|
||||
from falyx.io_action import BaseIOAction
|
||||
from falyx.options_manager import OptionsManager
|
||||
from falyx.retry import RetryPolicy
|
||||
from falyx.retry_utils import enable_retries_recursively
|
||||
from falyx.themes.colors import OneColors
|
||||
|
@ -116,6 +117,7 @@ class Command(BaseModel):
|
|||
tags: list[str] = Field(default_factory=list)
|
||||
logging_hooks: bool = False
|
||||
requires_input: bool | None = None
|
||||
options_manager: OptionsManager = Field(default_factory=OptionsManager)
|
||||
|
||||
_context: ExecutionContext | None = PrivateAttr(default=None)
|
||||
|
||||
|
@ -178,8 +180,14 @@ class Command(BaseModel):
|
|||
f"action='{self.action}')"
|
||||
)
|
||||
|
||||
def _inject_options_manager(self):
|
||||
"""Inject the options manager into the action if applicable."""
|
||||
if isinstance(self.action, BaseAction):
|
||||
self.action.set_options_manager(self.options_manager)
|
||||
|
||||
async def __call__(self, *args, **kwargs):
|
||||
"""Run the action with full hook lifecycle, timing, and error handling."""
|
||||
self._inject_options_manager()
|
||||
combined_args = args + self.args
|
||||
combined_kwargs = {**self.kwargs, **kwargs}
|
||||
context = ExecutionContext(
|
||||
|
@ -200,9 +208,6 @@ class Command(BaseModel):
|
|||
except Exception as error:
|
||||
context.exception = error
|
||||
await self.hooks.trigger(HookType.ON_ERROR, context)
|
||||
if context.result is not None:
|
||||
logger.info(f"✅ Recovered: {self.key}")
|
||||
return context.result
|
||||
raise error
|
||||
finally:
|
||||
context.stop_timer()
|
||||
|
|
|
@ -1,5 +1,20 @@
|
|||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""context.py"""
|
||||
"""
|
||||
Execution context management for Falyx CLI actions.
|
||||
|
||||
This module defines `ExecutionContext` and `SharedContext`, which are responsible for
|
||||
capturing per-action and cross-action metadata during CLI workflow execution. These
|
||||
context objects provide structured introspection, result tracking, error recording,
|
||||
and time-based performance metrics.
|
||||
|
||||
- `ExecutionContext`: Captures runtime information for a single action execution,
|
||||
including arguments, results, exceptions, timing, and logging.
|
||||
- `SharedContext`: Maintains shared state and result propagation across
|
||||
`ChainedAction` or `ActionGroup` executions.
|
||||
|
||||
These contexts enable rich introspection, traceability, and workflow coordination,
|
||||
supporting hook lifecycles, retries, and structured output generation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
@ -11,6 +26,47 @@ from rich.console import Console
|
|||
|
||||
|
||||
class ExecutionContext(BaseModel):
|
||||
"""
|
||||
Represents the runtime metadata and state for a single action execution.
|
||||
|
||||
The `ExecutionContext` tracks arguments, results, exceptions, timing, and additional
|
||||
metadata for each invocation of a Falyx `BaseAction`. It provides integration with the
|
||||
Falyx hook system and execution registry, enabling lifecycle management, diagnostics,
|
||||
and structured logging.
|
||||
|
||||
Attributes:
|
||||
name (str): The name of the action being executed.
|
||||
args (tuple): Positional arguments passed to the action.
|
||||
kwargs (dict): Keyword arguments passed to the action.
|
||||
action (BaseAction | Callable): The action instance being executed.
|
||||
result (Any | None): The result of the action, if successful.
|
||||
exception (Exception | None): The exception raised, if execution failed.
|
||||
start_time (float | None): High-resolution performance start time.
|
||||
end_time (float | None): High-resolution performance end time.
|
||||
start_wall (datetime | None): Wall-clock timestamp when execution began.
|
||||
end_wall (datetime | None): Wall-clock timestamp when execution ended.
|
||||
extra (dict): Metadata for custom introspection or special use by Actions.
|
||||
console (Console): Rich console instance for logging or UI output.
|
||||
shared_context (SharedContext | None): Optional shared context when running in a chain or group.
|
||||
|
||||
Properties:
|
||||
duration (float | None): The execution duration in seconds.
|
||||
success (bool): Whether the action completed without raising an exception.
|
||||
status (str): Returns "OK" if successful, otherwise "ERROR".
|
||||
|
||||
Methods:
|
||||
start_timer(): Starts the timing and timestamp tracking.
|
||||
stop_timer(): Stops timing and stores end timestamps.
|
||||
log_summary(logger=None): Logs a rich or plain summary of execution.
|
||||
to_log_line(): Returns a single-line log entry for metrics or tracing.
|
||||
as_dict(): Serializes core result and diagnostic metadata.
|
||||
get_shared_context(): Returns the shared context or creates a default one.
|
||||
|
||||
This class is used internally by all Falyx actions and hook events. It ensures
|
||||
consistent tracking and reporting across asynchronous workflows, including CLI-driven
|
||||
and automated batch executions.
|
||||
"""
|
||||
|
||||
name: str
|
||||
args: tuple = ()
|
||||
kwargs: dict = {}
|
||||
|
@ -120,6 +176,37 @@ class ExecutionContext(BaseModel):
|
|||
|
||||
|
||||
class SharedContext(BaseModel):
|
||||
"""
|
||||
SharedContext maintains transient shared state during the execution
|
||||
of a ChainedAction or ActionGroup.
|
||||
|
||||
This context object is passed to all actions within a chain or group,
|
||||
enabling result propagation, shared data exchange, and coordinated
|
||||
tracking of execution order and failures.
|
||||
|
||||
Attributes:
|
||||
name (str): Identifier for the context (usually the parent action name).
|
||||
results (list[Any]): Captures results from each action, in order of execution.
|
||||
errors (list[tuple[int, Exception]]): Indexed list of errors from failed actions.
|
||||
current_index (int): Index of the currently executing action (used in chains).
|
||||
is_parallel (bool): Whether the context is used in parallel mode (ActionGroup).
|
||||
shared_result (Any | None): Optional shared value available to all actions in parallel mode.
|
||||
share (dict[str, Any]): Custom shared key-value store for user-defined communication
|
||||
between actions (e.g., flags, intermediate data, settings).
|
||||
|
||||
Note:
|
||||
SharedContext is only used within grouped or chained workflows. It should not be
|
||||
used for standalone `Action` executions, where state should be scoped to the
|
||||
individual ExecutionContext instead.
|
||||
|
||||
Example usage:
|
||||
- In a ChainedAction: last_result is pulled from `results[-1]`.
|
||||
- In an ActionGroup: all actions can read/write `shared_result` or use `share`.
|
||||
|
||||
This class supports fault-tolerant and modular composition of CLI workflows
|
||||
by enabling flexible intra-action communication without global state.
|
||||
"""
|
||||
|
||||
name: str
|
||||
results: list[Any] = Field(default_factory=list)
|
||||
errors: list[tuple[int, Exception]] = Field(default_factory=list)
|
||||
|
|
|
@ -53,11 +53,12 @@ from falyx.hook_manager import Hook, HookManager, HookType
|
|||
from falyx.options_manager import OptionsManager
|
||||
from falyx.parsers import get_arg_parsers
|
||||
from falyx.retry import RetryPolicy
|
||||
from falyx.signals import BackSignal, QuitSignal
|
||||
from falyx.themes.colors import OneColors, get_nord_theme
|
||||
from falyx.utils import (
|
||||
CaseInsensitiveDict,
|
||||
async_confirm,
|
||||
chunks,
|
||||
confirm_async,
|
||||
get_program_invocation,
|
||||
logger,
|
||||
)
|
||||
|
@ -93,7 +94,7 @@ class Falyx:
|
|||
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.
|
||||
confirm_on_error (bool): Whether to prompt the user after errors.
|
||||
never_confirm (bool): Whether to skip confirmation prompts entirely.
|
||||
never_prompt (bool): Whether to skip confirmation prompts entirely.
|
||||
always_confirm (bool): Whether to force confirmation prompts for all actions.
|
||||
cli_args (Namespace | None): Parsed CLI arguments, usually from argparse.
|
||||
options (OptionsManager | None): Declarative option mappings.
|
||||
|
@ -123,7 +124,7 @@ class Falyx:
|
|||
include_history_command: bool = True,
|
||||
include_help_command: bool = True,
|
||||
confirm_on_error: bool = True,
|
||||
never_confirm: bool = False,
|
||||
never_prompt: bool = False,
|
||||
always_confirm: bool = False,
|
||||
cli_args: Namespace | None = None,
|
||||
options: OptionsManager | None = None,
|
||||
|
@ -150,7 +151,7 @@ class Falyx:
|
|||
self.key_bindings: KeyBindings = key_bindings or KeyBindings()
|
||||
self.bottom_bar: BottomBar | str | Callable[[], None] = bottom_bar
|
||||
self.confirm_on_error: bool = confirm_on_error
|
||||
self._never_confirm: bool = never_confirm
|
||||
self._never_prompt: bool = never_prompt
|
||||
self._always_confirm: bool = always_confirm
|
||||
self.cli_args: Namespace | None = cli_args
|
||||
self.render_menu: Callable[["Falyx"], None] | None = render_menu
|
||||
|
@ -166,7 +167,7 @@ class Falyx:
|
|||
"""Checks if the options are set correctly."""
|
||||
self.options: OptionsManager = options or OptionsManager()
|
||||
if not cli_args and not options:
|
||||
return
|
||||
return None
|
||||
|
||||
if options and not cli_args:
|
||||
raise FalyxError("Options are set, but CLI arguments are not.")
|
||||
|
@ -521,8 +522,9 @@ class Falyx:
|
|||
|
||||
def update_exit_command(
|
||||
self,
|
||||
key: str = "0",
|
||||
key: str = "Q",
|
||||
description: str = "Exit",
|
||||
aliases: list[str] | None = None,
|
||||
action: Callable[[], Any] = lambda: None,
|
||||
color: str = OneColors.DARK_RED,
|
||||
confirm: bool = False,
|
||||
|
@ -535,6 +537,7 @@ class Falyx:
|
|||
self.exit_command = Command(
|
||||
key=key,
|
||||
description=description,
|
||||
aliases=aliases if aliases else self.exit_command.aliases,
|
||||
action=action,
|
||||
color=color,
|
||||
confirm=confirm,
|
||||
|
@ -549,6 +552,7 @@ class Falyx:
|
|||
raise NotAFalyxError("submenu must be an instance of Falyx.")
|
||||
self._validate_command_key(key)
|
||||
self.add_command(key, description, submenu.menu, color=color)
|
||||
submenu.update_exit_command(key="B", description="Back", aliases=["BACK"])
|
||||
|
||||
def add_commands(self, commands: list[dict]) -> None:
|
||||
"""Adds multiple commands to the menu."""
|
||||
|
@ -613,6 +617,7 @@ class Falyx:
|
|||
retry_all=retry_all,
|
||||
retry_policy=retry_policy or RetryPolicy(),
|
||||
requires_input=requires_input,
|
||||
options_manager=self.options,
|
||||
)
|
||||
|
||||
if hooks:
|
||||
|
@ -703,7 +708,7 @@ class Falyx:
|
|||
return None
|
||||
|
||||
async def _should_run_action(self, selected_command: Command) -> bool:
|
||||
if self._never_confirm:
|
||||
if self._never_prompt:
|
||||
return True
|
||||
|
||||
if self.cli_args and getattr(self.cli_args, "skip_confirm", False):
|
||||
|
@ -717,7 +722,7 @@ class Falyx:
|
|||
):
|
||||
if selected_command.preview_before_confirm:
|
||||
await selected_command.preview()
|
||||
confirm_answer = await async_confirm(selected_command.confirmation_prompt)
|
||||
confirm_answer = await confirm_async(selected_command.confirmation_prompt)
|
||||
|
||||
if confirm_answer:
|
||||
logger.info(f"[{selected_command.description}]🔐 confirmed.")
|
||||
|
@ -747,18 +752,13 @@ class Falyx:
|
|||
|
||||
async def _handle_action_error(
|
||||
self, selected_command: Command, error: Exception
|
||||
) -> bool:
|
||||
) -> None:
|
||||
"""Handles errors that occur during the action of the selected command."""
|
||||
logger.exception(f"Error executing '{selected_command.description}': {error}")
|
||||
self.console.print(
|
||||
f"[{OneColors.DARK_RED}]An error occurred while executing "
|
||||
f"{selected_command.description}:[/] {error}"
|
||||
)
|
||||
if self.confirm_on_error and not self._never_confirm:
|
||||
return await async_confirm("An error occurred. Do you wish to continue?")
|
||||
if self._never_confirm:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def process_command(self) -> bool:
|
||||
"""Processes the action of the selected command."""
|
||||
|
@ -801,13 +801,7 @@ class Falyx:
|
|||
except Exception as error:
|
||||
context.exception = error
|
||||
await self.hooks.trigger(HookType.ON_ERROR, context)
|
||||
if not context.exception:
|
||||
logger.info(
|
||||
f"✅ Recovery hook handled error for '{selected_command.description}'"
|
||||
)
|
||||
context.result = result
|
||||
else:
|
||||
return await self._handle_action_error(selected_command, error)
|
||||
await self._handle_action_error(selected_command, error)
|
||||
finally:
|
||||
context.stop_timer()
|
||||
await self.hooks.trigger(HookType.AFTER, context)
|
||||
|
@ -822,7 +816,7 @@ class Falyx:
|
|||
|
||||
if not selected_command:
|
||||
logger.info("[Headless] Back command selected. Exiting menu.")
|
||||
return
|
||||
return None
|
||||
|
||||
logger.info(f"[Headless] 🚀 Running: '{selected_command.description}'")
|
||||
|
||||
|
@ -851,11 +845,6 @@ class Falyx:
|
|||
except Exception as error:
|
||||
context.exception = error
|
||||
await self.hooks.trigger(HookType.ON_ERROR, context)
|
||||
if not context.exception:
|
||||
logger.info(
|
||||
f"[Headless] ✅ Recovery hook handled error for '{selected_command.description}'"
|
||||
)
|
||||
return True
|
||||
raise FalyxError(
|
||||
f"[Headless] ❌ '{selected_command.description}' failed."
|
||||
) from error
|
||||
|
@ -921,6 +910,11 @@ class Falyx:
|
|||
except (EOFError, KeyboardInterrupt):
|
||||
logger.info("EOF or KeyboardInterrupt. Exiting menu.")
|
||||
break
|
||||
except QuitSignal:
|
||||
logger.info("QuitSignal received. Exiting menu.")
|
||||
break
|
||||
except BackSignal:
|
||||
logger.info("BackSignal received.")
|
||||
finally:
|
||||
logger.info(f"Exiting menu: {self.get_title()}")
|
||||
if self.exit_message:
|
||||
|
@ -938,6 +932,9 @@ class Falyx:
|
|||
logger.debug("✅ Enabling global debug hooks for all commands")
|
||||
self.register_all_with_debug_hooks()
|
||||
|
||||
if self.cli_args.never_prompt:
|
||||
self._never_prompt = True
|
||||
|
||||
if self.cli_args.command == "list":
|
||||
await self._show_help()
|
||||
sys.exit(0)
|
||||
|
|
|
@ -1,30 +0,0 @@
|
|||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""importer.py"""
|
||||
|
||||
import importlib
|
||||
from types import ModuleType
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
def resolve_action(path: str) -> Callable[..., Any]:
|
||||
"""
|
||||
Resolve a dotted path to a Python callable.
|
||||
Example: 'mypackage.mymodule.myfunction'
|
||||
|
||||
Raises:
|
||||
ImportError if the module or function does not exist.
|
||||
ValueError if the resolved attribute is not callable.
|
||||
"""
|
||||
if ":" in path:
|
||||
module_path, function_name = path.split(":")
|
||||
else:
|
||||
*module_parts, function_name = path.split(".")
|
||||
module_path = ".".join(module_parts)
|
||||
|
||||
module: ModuleType = importlib.import_module(module_path)
|
||||
function: Any = getattr(module, function_name)
|
||||
|
||||
if not callable(function):
|
||||
raise ValueError(f"Resolved attribute '{function_name}' is not callable.")
|
||||
|
||||
return function
|
|
@ -0,0 +1,217 @@
|
|||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""menu_action.py"""
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from prompt_toolkit import PromptSession
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.tree import Tree
|
||||
|
||||
from falyx.action import BaseAction
|
||||
from falyx.context import ExecutionContext
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import HookType
|
||||
from falyx.selection import prompt_for_selection, render_table_base
|
||||
from falyx.signal_action import SignalAction
|
||||
from falyx.signals import BackSignal, QuitSignal
|
||||
from falyx.themes.colors import OneColors
|
||||
from falyx.utils import CaseInsensitiveDict, chunks, logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class MenuOption:
|
||||
description: str
|
||||
action: BaseAction
|
||||
color: str = OneColors.WHITE
|
||||
|
||||
def __post_init__(self):
|
||||
if not isinstance(self.description, str):
|
||||
raise TypeError("MenuOption description must be a string.")
|
||||
if not isinstance(self.action, BaseAction):
|
||||
raise TypeError("MenuOption action must be a BaseAction instance.")
|
||||
|
||||
def render(self, key: str) -> str:
|
||||
"""Render the menu option for display."""
|
||||
return f"[{OneColors.WHITE}][{key}][/] [{self.color}]{self.description}[/]"
|
||||
|
||||
|
||||
class MenuOptionMap(CaseInsensitiveDict):
|
||||
"""
|
||||
Manages menu options including validation, reserved key protection,
|
||||
and special signal entries like Quit and Back.
|
||||
"""
|
||||
|
||||
RESERVED_KEYS = {"Q", "B"}
|
||||
|
||||
def __init__(
|
||||
self, options: dict[str, MenuOption] | None = None, allow_reserved: bool = False
|
||||
):
|
||||
super().__init__()
|
||||
self.allow_reserved = allow_reserved
|
||||
if options:
|
||||
self.update(options)
|
||||
self._inject_reserved_defaults()
|
||||
|
||||
def _inject_reserved_defaults(self):
|
||||
self._add_reserved(
|
||||
"Q",
|
||||
MenuOption("Exit", SignalAction("Quit", QuitSignal()), OneColors.DARK_RED),
|
||||
)
|
||||
self._add_reserved(
|
||||
"B",
|
||||
MenuOption("Back", SignalAction("Back", BackSignal()), OneColors.DARK_YELLOW),
|
||||
)
|
||||
|
||||
def _add_reserved(self, key: str, option: MenuOption) -> None:
|
||||
"""Add a reserved key, bypassing validation."""
|
||||
norm_key = key.upper()
|
||||
super().__setitem__(norm_key, option)
|
||||
|
||||
def __setitem__(self, key: str, option: MenuOption) -> None:
|
||||
if not isinstance(option, MenuOption):
|
||||
raise TypeError(f"Value for key '{key}' must be a MenuOption.")
|
||||
norm_key = key.upper()
|
||||
if norm_key in self.RESERVED_KEYS and not self.allow_reserved:
|
||||
raise ValueError(
|
||||
f"Key '{key}' is reserved and cannot be used in MenuOptionMap."
|
||||
)
|
||||
super().__setitem__(norm_key, option)
|
||||
|
||||
def __delitem__(self, key: str) -> None:
|
||||
if key.upper() in self.RESERVED_KEYS and not self.allow_reserved:
|
||||
raise ValueError(f"Cannot delete reserved option '{key}'.")
|
||||
super().__delitem__(key)
|
||||
|
||||
def items(self, include_reserved: bool = True):
|
||||
for k, v in super().items():
|
||||
if not include_reserved and k in self.RESERVED_KEYS:
|
||||
continue
|
||||
yield k, v
|
||||
|
||||
|
||||
class MenuAction(BaseAction):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
menu_options: MenuOptionMap,
|
||||
*,
|
||||
title: str = "Select an option",
|
||||
columns: int = 2,
|
||||
prompt_message: str = "Select > ",
|
||||
default_selection: str = "",
|
||||
inject_last_result: bool = False,
|
||||
inject_last_result_as: str = "last_result",
|
||||
console: Console | None = None,
|
||||
prompt_session: PromptSession | None = None,
|
||||
never_prompt: bool = False,
|
||||
include_reserved: bool = True,
|
||||
show_table: bool = True,
|
||||
):
|
||||
super().__init__(
|
||||
name,
|
||||
inject_last_result=inject_last_result,
|
||||
inject_last_result_as=inject_last_result_as,
|
||||
never_prompt=never_prompt,
|
||||
)
|
||||
self.menu_options = menu_options
|
||||
self.title = title
|
||||
self.columns = columns
|
||||
self.prompt_message = prompt_message
|
||||
self.default_selection = default_selection
|
||||
self.console = console or Console(color_system="auto")
|
||||
self.prompt_session = prompt_session or PromptSession()
|
||||
self.include_reserved = include_reserved
|
||||
self.show_table = show_table
|
||||
|
||||
def _build_table(self) -> Table:
|
||||
table = render_table_base(
|
||||
title=self.title,
|
||||
columns=self.columns,
|
||||
)
|
||||
for chunk in chunks(
|
||||
self.menu_options.items(include_reserved=self.include_reserved), self.columns
|
||||
):
|
||||
row = []
|
||||
for key, option in chunk:
|
||||
row.append(option.render(key))
|
||||
table.add_row(*row)
|
||||
return table
|
||||
|
||||
async def _run(self, *args, **kwargs) -> Any:
|
||||
kwargs = self._maybe_inject_last_result(kwargs)
|
||||
context = ExecutionContext(
|
||||
name=self.name,
|
||||
args=args,
|
||||
kwargs=kwargs,
|
||||
action=self,
|
||||
)
|
||||
|
||||
effective_default = self.default_selection
|
||||
maybe_result = str(self.last_result)
|
||||
if maybe_result in self.menu_options:
|
||||
effective_default = maybe_result
|
||||
elif self.inject_last_result:
|
||||
logger.warning(
|
||||
"[%s] Injected last result '%s' not found in menu options",
|
||||
self.name,
|
||||
maybe_result,
|
||||
)
|
||||
|
||||
if self.never_prompt and not effective_default:
|
||||
raise ValueError(
|
||||
f"[{self.name}] 'never_prompt' is True but no valid default_selection was provided."
|
||||
)
|
||||
|
||||
context.start_timer()
|
||||
try:
|
||||
await self.hooks.trigger(HookType.BEFORE, context)
|
||||
key = effective_default
|
||||
if not self.never_prompt:
|
||||
console = self.console
|
||||
session = self.prompt_session
|
||||
table = self._build_table()
|
||||
key = await prompt_for_selection(
|
||||
self.menu_options.keys(),
|
||||
table,
|
||||
default_selection=self.default_selection,
|
||||
console=console,
|
||||
session=session,
|
||||
prompt_message=self.prompt_message,
|
||||
show_table=self.show_table,
|
||||
)
|
||||
option = self.menu_options[key]
|
||||
result = await option.action(*args, **kwargs)
|
||||
context.result = result
|
||||
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||
return result
|
||||
|
||||
except BackSignal:
|
||||
logger.debug("[%s][BackSignal] ← Returning to previous menu", self.name)
|
||||
return None
|
||||
except QuitSignal:
|
||||
logger.debug("[%s][QuitSignal] ← Exiting application", self.name)
|
||||
raise
|
||||
except Exception as error:
|
||||
context.exception = error
|
||||
await self.hooks.trigger(HookType.ON_ERROR, context)
|
||||
raise
|
||||
finally:
|
||||
context.stop_timer()
|
||||
await self.hooks.trigger(HookType.AFTER, context)
|
||||
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
||||
er.record(context)
|
||||
|
||||
async def preview(self, parent: Tree | None = None):
|
||||
label = f"[{OneColors.DARK_YELLOW_b}]📋 MenuAction[/] '{self.name}'"
|
||||
tree = parent.add(label) if parent else Tree(label)
|
||||
for key, option in self.menu_options.items():
|
||||
tree.add(
|
||||
f"[dim]{key}[/]: {option.description} → [italic]{option.action.name}[/]"
|
||||
)
|
||||
await option.action.preview(parent=tree)
|
||||
if not parent:
|
||||
self.console.print(tree)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"MenuAction(name={self.name}, options={list(self.menu_options.keys())})"
|
|
@ -9,8 +9,8 @@ from falyx.utils import logger
|
|||
|
||||
|
||||
class OptionsManager:
|
||||
def __init__(self, namespaces: list[tuple[str, Namespace]] = None) -> None:
|
||||
self.options = defaultdict(lambda: Namespace())
|
||||
def __init__(self, namespaces: list[tuple[str, Namespace]] | None = None) -> None:
|
||||
self.options: defaultdict = defaultdict(lambda: Namespace())
|
||||
if namespaces:
|
||||
for namespace_name, namespace in namespaces:
|
||||
self.from_namespace(namespace, namespace_name)
|
||||
|
|
|
@ -37,7 +37,6 @@ def get_arg_parsers(
|
|||
description: str | None = "Falyx CLI - Run structured async command workflows.",
|
||||
epilog: str | None = None,
|
||||
parents: Sequence[ArgumentParser] = [],
|
||||
formatter_class: HelpFormatter = HelpFormatter,
|
||||
prefix_chars: str = "-",
|
||||
fromfile_prefix_chars: str | None = None,
|
||||
argument_default: Any = None,
|
||||
|
@ -53,7 +52,6 @@ def get_arg_parsers(
|
|||
description=description,
|
||||
epilog=epilog,
|
||||
parents=parents,
|
||||
formatter_class=formatter_class,
|
||||
prefix_chars=prefix_chars,
|
||||
fromfile_prefix_chars=fromfile_prefix_chars,
|
||||
argument_default=argument_default,
|
||||
|
@ -62,6 +60,11 @@ def get_arg_parsers(
|
|||
allow_abbrev=allow_abbrev,
|
||||
exit_on_error=exit_on_error,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--never-prompt",
|
||||
action="store_true",
|
||||
help="Run in non-interactive mode with all prompts bypassed.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v", "--verbose", action="store_true", help="Enable debug logging for Falyx."
|
||||
)
|
||||
|
|
|
@ -43,7 +43,7 @@ class RetryHandler:
|
|||
delay: float = 1.0,
|
||||
backoff: float = 2.0,
|
||||
jitter: float = 0.0,
|
||||
):
|
||||
) -> None:
|
||||
self.policy.enabled = True
|
||||
self.policy.max_retries = max_retries
|
||||
self.policy.delay = delay
|
||||
|
@ -51,7 +51,7 @@ class RetryHandler:
|
|||
self.policy.jitter = jitter
|
||||
logger.info(f"🔄 Retry policy enabled: {self.policy}")
|
||||
|
||||
async def retry_on_error(self, context: ExecutionContext):
|
||||
async def retry_on_error(self, context: ExecutionContext) -> None:
|
||||
from falyx.action import Action
|
||||
|
||||
name = context.name
|
||||
|
@ -64,21 +64,21 @@ class RetryHandler:
|
|||
|
||||
if not target:
|
||||
logger.warning(f"[{name}] ⚠️ No action target. Cannot retry.")
|
||||
return
|
||||
return None
|
||||
|
||||
if not isinstance(target, Action):
|
||||
logger.warning(
|
||||
f"[{name}] ❌ RetryHandler only supports only supports Action objects."
|
||||
)
|
||||
return
|
||||
return None
|
||||
|
||||
if not getattr(target, "is_retryable", False):
|
||||
logger.warning(f"[{name}] ❌ Not retryable.")
|
||||
return
|
||||
return None
|
||||
|
||||
if not self.policy.enabled:
|
||||
logger.warning(f"[{name}] ❌ Retry policy is disabled.")
|
||||
return
|
||||
return None
|
||||
|
||||
while retries_done < self.policy.max_retries:
|
||||
retries_done += 1
|
||||
|
@ -97,7 +97,7 @@ class RetryHandler:
|
|||
context.result = result
|
||||
context.exception = None
|
||||
logger.info(f"[{name}] ✅ Retry succeeded on attempt {retries_done}.")
|
||||
return
|
||||
return None
|
||||
except Exception as retry_error:
|
||||
last_error = retry_error
|
||||
current_delay *= self.policy.backoff
|
||||
|
@ -108,4 +108,3 @@ class RetryHandler:
|
|||
|
||||
context.exception = last_error
|
||||
logger.error(f"[{name}] ❌ All {self.policy.max_retries} retries failed.")
|
||||
return
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
from falyx.action import Action, BaseAction
|
||||
from falyx.hook_manager import HookType
|
||||
from falyx.retry import RetryHandler, RetryPolicy
|
||||
|
|
|
@ -0,0 +1,354 @@
|
|||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""selection.py"""
|
||||
from typing import Any, Callable, KeysView, Sequence
|
||||
|
||||
from prompt_toolkit import PromptSession
|
||||
from rich import box
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
from rich.table import Table
|
||||
|
||||
from falyx.themes.colors import OneColors
|
||||
from falyx.utils import chunks
|
||||
from falyx.validators import int_range_validator, key_validator
|
||||
|
||||
|
||||
def render_table_base(
|
||||
title: str,
|
||||
caption: str = "",
|
||||
columns: int = 4,
|
||||
box_style: box.Box = box.SIMPLE,
|
||||
show_lines: bool = False,
|
||||
show_header: bool = False,
|
||||
show_footer: bool = False,
|
||||
style: str = "",
|
||||
header_style: str = "",
|
||||
footer_style: str = "",
|
||||
title_style: str = "",
|
||||
caption_style: str = "",
|
||||
highlight: bool = True,
|
||||
column_names: Sequence[str] | None = None,
|
||||
) -> Table:
|
||||
table = Table(
|
||||
title=title,
|
||||
caption=caption,
|
||||
box=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,
|
||||
)
|
||||
if column_names:
|
||||
for column_name in column_names:
|
||||
table.add_column(column_name)
|
||||
else:
|
||||
for _ in range(columns):
|
||||
table.add_column()
|
||||
return table
|
||||
|
||||
|
||||
def render_selection_grid(
|
||||
title: str,
|
||||
selections: Sequence[str],
|
||||
columns: int = 4,
|
||||
caption: str = "",
|
||||
box_style: box.Box = box.SIMPLE,
|
||||
show_lines: bool = False,
|
||||
show_header: bool = False,
|
||||
show_footer: bool = False,
|
||||
style: str = "",
|
||||
header_style: str = "",
|
||||
footer_style: str = "",
|
||||
title_style: str = "",
|
||||
caption_style: str = "",
|
||||
highlight: bool = False,
|
||||
) -> 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,
|
||||
)
|
||||
|
||||
for chunk in chunks(selections, columns):
|
||||
table.add_row(*chunk)
|
||||
|
||||
return table
|
||||
|
||||
|
||||
def render_selection_indexed_table(
|
||||
title: str,
|
||||
selections: Sequence[str],
|
||||
columns: int = 4,
|
||||
caption: str = "",
|
||||
box_style: box.Box = box.SIMPLE,
|
||||
show_lines: bool = False,
|
||||
show_header: bool = False,
|
||||
show_footer: bool = False,
|
||||
style: str = "",
|
||||
header_style: str = "",
|
||||
footer_style: str = "",
|
||||
title_style: str = "",
|
||||
caption_style: str = "",
|
||||
highlight: bool = False,
|
||||
formatter: Callable[[int, str], str] | None = None,
|
||||
) -> 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,
|
||||
)
|
||||
|
||||
for indexes, chunk in zip(
|
||||
chunks(range(len(selections)), columns), chunks(selections, columns)
|
||||
):
|
||||
row = [
|
||||
formatter(index, selection) if formatter else f"{index}: {selection}"
|
||||
for index, selection in zip(indexes, chunk)
|
||||
]
|
||||
table.add_row(*row)
|
||||
|
||||
return table
|
||||
|
||||
|
||||
def render_selection_dict_table(
|
||||
title: str,
|
||||
selections: dict[str, tuple[str, Any]],
|
||||
columns: int = 2,
|
||||
caption: str = "",
|
||||
box_style: box.Box = box.SIMPLE,
|
||||
show_lines: bool = False,
|
||||
show_header: bool = False,
|
||||
show_footer: bool = False,
|
||||
style: str = "",
|
||||
header_style: str = "",
|
||||
footer_style: str = "",
|
||||
title_style: str = "",
|
||||
caption_style: str = "",
|
||||
highlight: bool = False,
|
||||
) -> 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,
|
||||
)
|
||||
|
||||
for chunk in chunks(selections.items(), columns):
|
||||
row = []
|
||||
for key, value in chunk:
|
||||
row.append(f"[{OneColors.WHITE}][{key.upper()}] {value[0]}")
|
||||
table.add_row(*row)
|
||||
|
||||
return table
|
||||
|
||||
|
||||
async def prompt_for_index(
|
||||
max_index: int,
|
||||
table: Table,
|
||||
min_index: int = 0,
|
||||
default_selection: str = "",
|
||||
console: Console | None = None,
|
||||
session: PromptSession | None = None,
|
||||
prompt_message: str = "Select an option > ",
|
||||
show_table: bool = True,
|
||||
):
|
||||
session = session or PromptSession()
|
||||
console = console or Console(color_system="auto")
|
||||
|
||||
if show_table:
|
||||
console.print(table)
|
||||
|
||||
selection = await session.prompt_async(
|
||||
message=prompt_message,
|
||||
validator=int_range_validator(min_index, max_index),
|
||||
default=default_selection,
|
||||
)
|
||||
return int(selection)
|
||||
|
||||
|
||||
async def prompt_for_selection(
|
||||
keys: Sequence[str] | KeysView[str],
|
||||
table: Table,
|
||||
default_selection: str = "",
|
||||
console: Console | None = None,
|
||||
session: PromptSession | None = None,
|
||||
prompt_message: str = "Select an option > ",
|
||||
show_table: bool = True,
|
||||
) -> str:
|
||||
"""Prompt the user to select a key from a set of options. Return the selected key."""
|
||||
session = session or PromptSession()
|
||||
console = console or Console(color_system="auto")
|
||||
|
||||
if show_table:
|
||||
console.print(table, justify="center")
|
||||
|
||||
selected = await session.prompt_async(
|
||||
message=prompt_message,
|
||||
validator=key_validator(keys),
|
||||
default=default_selection,
|
||||
)
|
||||
|
||||
return selected
|
||||
|
||||
|
||||
async def select_value_from_list(
|
||||
title: str,
|
||||
selections: Sequence[str],
|
||||
console: Console | None = None,
|
||||
session: PromptSession | None = None,
|
||||
prompt_message: str = "Select an option > ",
|
||||
default_selection: str = "",
|
||||
columns: int = 4,
|
||||
caption: str = "",
|
||||
box_style: box.Box = box.SIMPLE,
|
||||
show_lines: bool = False,
|
||||
show_header: bool = False,
|
||||
show_footer: bool = False,
|
||||
style: str = "",
|
||||
header_style: str = "",
|
||||
footer_style: str = "",
|
||||
title_style: str = "",
|
||||
caption_style: str = "",
|
||||
highlight: bool = False,
|
||||
):
|
||||
"""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,
|
||||
)
|
||||
session = session or PromptSession()
|
||||
console = console or Console(color_system="auto")
|
||||
|
||||
selection_index = await prompt_for_index(
|
||||
len(selections) - 1,
|
||||
table,
|
||||
default_selection=default_selection,
|
||||
console=console,
|
||||
session=session,
|
||||
prompt_message=prompt_message,
|
||||
)
|
||||
|
||||
return selections[selection_index]
|
||||
|
||||
|
||||
async def select_key_from_dict(
|
||||
selections: dict[str, tuple[str, Any]],
|
||||
table: Table,
|
||||
console: Console | None = None,
|
||||
session: PromptSession | None = None,
|
||||
prompt_message: str = "Select an option > ",
|
||||
default_selection: str = "",
|
||||
) -> Any:
|
||||
"""Prompt for a key from a dict, returns the key."""
|
||||
session = session or PromptSession()
|
||||
console = console or Console(color_system="auto")
|
||||
|
||||
console.print(table)
|
||||
|
||||
return await prompt_for_selection(
|
||||
selections.keys(),
|
||||
table,
|
||||
default_selection=default_selection,
|
||||
console=console,
|
||||
session=session,
|
||||
prompt_message=prompt_message,
|
||||
)
|
||||
|
||||
|
||||
async def select_value_from_dict(
|
||||
selections: dict[str, tuple[str, Any]],
|
||||
table: Table,
|
||||
console: Console | None = None,
|
||||
session: PromptSession | None = None,
|
||||
prompt_message: str = "Select an option > ",
|
||||
default_selection: str = "",
|
||||
) -> Any:
|
||||
"""Prompt for a key from a dict, but return the value."""
|
||||
session = session or PromptSession()
|
||||
console = console or Console(color_system="auto")
|
||||
|
||||
console.print(table)
|
||||
|
||||
selection_key = await prompt_for_selection(
|
||||
selections.keys(),
|
||||
table,
|
||||
default_selection=default_selection,
|
||||
console=console,
|
||||
session=session,
|
||||
prompt_message=prompt_message,
|
||||
)
|
||||
|
||||
return selections[selection_key][1]
|
||||
|
||||
|
||||
async def get_selection_from_dict_menu(
|
||||
title: str,
|
||||
selections: dict[str, tuple[str, Any]],
|
||||
console: Console | None = None,
|
||||
session: PromptSession | None = None,
|
||||
prompt_message: str = "Select an option > ",
|
||||
default_selection: str = "",
|
||||
):
|
||||
"""Prompt for a key from a dict, but return the value."""
|
||||
table = render_selection_dict_table(
|
||||
title,
|
||||
selections,
|
||||
)
|
||||
|
||||
return await select_value_from_dict(
|
||||
selections,
|
||||
table,
|
||||
console,
|
||||
session,
|
||||
prompt_message,
|
||||
default_selection,
|
||||
)
|
|
@ -0,0 +1,169 @@
|
|||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""selection_action.py"""
|
||||
from typing import Any
|
||||
|
||||
from prompt_toolkit import PromptSession
|
||||
from rich.console import Console
|
||||
from rich.tree import Tree
|
||||
|
||||
from falyx.action import BaseAction
|
||||
from falyx.context import ExecutionContext
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import HookType
|
||||
from falyx.selection import (
|
||||
prompt_for_index,
|
||||
prompt_for_selection,
|
||||
render_selection_dict_table,
|
||||
render_selection_indexed_table,
|
||||
)
|
||||
from falyx.themes.colors import OneColors
|
||||
from falyx.utils import logger
|
||||
|
||||
|
||||
class SelectionAction(BaseAction):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
selections: list[str] | dict[str, tuple[str, Any]],
|
||||
*,
|
||||
title: str = "Select an option",
|
||||
columns: int = 2,
|
||||
prompt_message: str = "Select > ",
|
||||
default_selection: str = "",
|
||||
inject_last_result: bool = False,
|
||||
inject_last_result_as: str = "last_result",
|
||||
return_key: bool = False,
|
||||
console: Console | None = None,
|
||||
session: PromptSession | None = None,
|
||||
never_prompt: bool = False,
|
||||
show_table: bool = True,
|
||||
):
|
||||
super().__init__(
|
||||
name,
|
||||
inject_last_result=inject_last_result,
|
||||
inject_last_result_as=inject_last_result_as,
|
||||
never_prompt=never_prompt,
|
||||
)
|
||||
self.selections = selections
|
||||
self.return_key = return_key
|
||||
self.title = title
|
||||
self.columns = columns
|
||||
self.console = console or Console(color_system="auto")
|
||||
self.session = session or PromptSession()
|
||||
self.default_selection = default_selection
|
||||
self.prompt_message = prompt_message
|
||||
self.show_table = show_table
|
||||
|
||||
async def _run(self, *args, **kwargs) -> Any:
|
||||
kwargs = self._maybe_inject_last_result(kwargs)
|
||||
context = ExecutionContext(
|
||||
name=self.name,
|
||||
args=args,
|
||||
kwargs=kwargs,
|
||||
action=self,
|
||||
)
|
||||
|
||||
effective_default = str(self.default_selection)
|
||||
maybe_result = str(self.last_result)
|
||||
if isinstance(self.selections, dict):
|
||||
if maybe_result in self.selections:
|
||||
effective_default = maybe_result
|
||||
elif isinstance(self.selections, list):
|
||||
if maybe_result.isdigit() and int(maybe_result) in range(
|
||||
len(self.selections)
|
||||
):
|
||||
effective_default = maybe_result
|
||||
elif self.inject_last_result:
|
||||
logger.warning(
|
||||
"[%s] Injected last result '%s' not found in selections",
|
||||
self.name,
|
||||
maybe_result,
|
||||
)
|
||||
|
||||
if self.never_prompt and not effective_default:
|
||||
raise ValueError(
|
||||
f"[{self.name}] 'never_prompt' is True but no valid default_selection was provided."
|
||||
)
|
||||
|
||||
context.start_timer()
|
||||
try:
|
||||
await self.hooks.trigger(HookType.BEFORE, context)
|
||||
if isinstance(self.selections, list):
|
||||
table = render_selection_indexed_table(
|
||||
self.title, self.selections, self.columns
|
||||
)
|
||||
if not self.never_prompt:
|
||||
index = await prompt_for_index(
|
||||
len(self.selections) - 1,
|
||||
table,
|
||||
default_selection=effective_default,
|
||||
console=self.console,
|
||||
session=self.session,
|
||||
prompt_message=self.prompt_message,
|
||||
show_table=self.show_table,
|
||||
)
|
||||
else:
|
||||
index = effective_default
|
||||
result = self.selections[int(index)]
|
||||
elif isinstance(self.selections, dict):
|
||||
table = render_selection_dict_table(
|
||||
self.title, self.selections, self.columns
|
||||
)
|
||||
if not self.never_prompt:
|
||||
key = await prompt_for_selection(
|
||||
self.selections.keys(),
|
||||
table,
|
||||
default_selection=effective_default,
|
||||
console=self.console,
|
||||
session=self.session,
|
||||
prompt_message=self.prompt_message,
|
||||
show_table=self.show_table,
|
||||
)
|
||||
else:
|
||||
key = effective_default
|
||||
result = key if self.return_key else self.selections[key][1]
|
||||
else:
|
||||
raise TypeError(
|
||||
f"'selections' must be a list[str] or dict[str, tuple[str, Any]], got {type(self.selections).__name__}"
|
||||
)
|
||||
context.result = result
|
||||
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||
return result
|
||||
except Exception as error:
|
||||
context.exception = error
|
||||
await self.hooks.trigger(HookType.ON_ERROR, context)
|
||||
raise
|
||||
finally:
|
||||
context.stop_timer()
|
||||
await self.hooks.trigger(HookType.AFTER, context)
|
||||
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
||||
er.record(context)
|
||||
|
||||
async def preview(self, parent: Tree | None = None):
|
||||
label = f"[{OneColors.LIGHT_RED}]🧭 SelectionAction[/] '{self.name}'"
|
||||
tree = parent.add(label) if parent else Tree(label)
|
||||
|
||||
if isinstance(self.selections, list):
|
||||
sub = tree.add(f"[dim]Type:[/] List[str] ({len(self.selections)} items)")
|
||||
for i, item in enumerate(self.selections[:10]): # limit to 10
|
||||
sub.add(f"[dim]{i}[/]: {item}")
|
||||
if len(self.selections) > 10:
|
||||
sub.add(f"[dim]... ({len(self.selections) - 10} more)[/]")
|
||||
elif isinstance(self.selections, dict):
|
||||
sub = tree.add(
|
||||
f"[dim]Type:[/] Dict[str, (str, Any)] ({len(self.selections)} items)"
|
||||
)
|
||||
for i, (key, (label, _)) in enumerate(list(self.selections.items())[:10]):
|
||||
sub.add(f"[dim]{key}[/]: {label}")
|
||||
if len(self.selections) > 10:
|
||||
sub.add(f"[dim]... ({len(self.selections) - 10} more)[/]")
|
||||
else:
|
||||
tree.add("[bold red]Invalid selections type[/]")
|
||||
return
|
||||
|
||||
tree.add(f"[dim]Default:[/] '{self.default_selection or self.last_result}'")
|
||||
tree.add(f"[dim]Return:[/] {'Key' if self.return_key else 'Value'}")
|
||||
tree.add(f"[dim]Prompt:[/] {'Disabled' if self.never_prompt else 'Enabled'}")
|
||||
|
||||
if not parent:
|
||||
self.console.print(tree)
|
|
@ -0,0 +1,29 @@
|
|||
from falyx.action import Action
|
||||
from falyx.signals import FlowSignal
|
||||
|
||||
|
||||
class SignalAction(Action):
|
||||
"""
|
||||
An action that raises a control flow signal when executed.
|
||||
|
||||
Useful for exiting a menu, going back, or halting execution gracefully.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, signal: Exception):
|
||||
if not isinstance(signal, FlowSignal):
|
||||
raise TypeError(
|
||||
f"Signal must be an FlowSignal instance, got {type(signal).__name__}"
|
||||
)
|
||||
|
||||
async def raise_signal(*args, **kwargs):
|
||||
raise signal
|
||||
|
||||
super().__init__(name=name, action=raise_signal)
|
||||
self._signal = signal
|
||||
|
||||
@property
|
||||
def signal(self):
|
||||
return self._signal
|
||||
|
||||
def __str__(self):
|
||||
return f"SignalAction(name={self.name}, signal={self._signal.__class__.__name__})"
|
|
@ -0,0 +1,21 @@
|
|||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
class FlowSignal(BaseException):
|
||||
"""Base class for all flow control signals in Falyx.
|
||||
|
||||
These are not errors. They're used to control flow like quitting,
|
||||
going back, or restarting from user input or nested menus.
|
||||
"""
|
||||
|
||||
|
||||
class QuitSignal(FlowSignal):
|
||||
"""Raised to signal an immediate exit from the CLI framework."""
|
||||
|
||||
def __init__(self, message: str = "Quit signal received."):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class BackSignal(FlowSignal):
|
||||
"""Raised to return control to the previous menu or caller."""
|
||||
|
||||
def __init__(self, message: str = "Back signal received."):
|
||||
super().__init__(message)
|
|
@ -1,3 +1,4 @@
|
|||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
from collections import defaultdict
|
||||
|
||||
from rich import box
|
||||
|
|
|
@ -69,7 +69,7 @@ def chunks(iterator, size):
|
|||
yield chunk
|
||||
|
||||
|
||||
async def async_confirm(message: AnyFormattedText = "Are you sure?") -> bool:
|
||||
async def confirm_async(message: AnyFormattedText = "Are you sure?") -> bool:
|
||||
session: PromptSession = PromptSession()
|
||||
while True:
|
||||
merged_message: AnyFormattedText = merge_formatted_text(
|
||||
|
@ -86,26 +86,36 @@ async def async_confirm(message: AnyFormattedText = "Are you sure?") -> bool:
|
|||
class CaseInsensitiveDict(dict):
|
||||
"""A case-insensitive dictionary that treats all keys as uppercase."""
|
||||
|
||||
def _normalize_key(self, key):
|
||||
return key.upper() if isinstance(key, str) else key
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
super().__setitem__(key.upper(), value)
|
||||
super().__setitem__(self._normalize_key(key), value)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return super().__getitem__(key.upper())
|
||||
return super().__getitem__(self._normalize_key(key))
|
||||
|
||||
def __contains__(self, key):
|
||||
return super().__contains__(key.upper())
|
||||
return super().__contains__(self._normalize_key(key))
|
||||
|
||||
def get(self, key, default=None):
|
||||
return super().get(key.upper(), default)
|
||||
return super().get(self._normalize_key(key), default)
|
||||
|
||||
def pop(self, key, default=None):
|
||||
return super().pop(key.upper(), default)
|
||||
return super().pop(self._normalize_key(key), default)
|
||||
|
||||
def update(self, other=None, **kwargs):
|
||||
items = {}
|
||||
if other:
|
||||
other = {k.upper(): v for k, v in other.items()}
|
||||
kwargs = {k.upper(): v for k, v in kwargs.items()}
|
||||
super().update(other, **kwargs)
|
||||
items.update({self._normalize_key(k): v for k, v in other.items()})
|
||||
items.update({self._normalize_key(k): v for k, v in kwargs.items()})
|
||||
super().update(items)
|
||||
|
||||
def __iter__(self):
|
||||
return super().__iter__()
|
||||
|
||||
def keys(self):
|
||||
return super().keys()
|
||||
|
||||
|
||||
def running_in_container() -> bool:
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
from typing import KeysView, Sequence
|
||||
|
||||
from prompt_toolkit.validation import Validator
|
||||
|
||||
|
||||
def int_range_validator(minimum: int, maximum: int) -> Validator:
|
||||
"""Validator for integer ranges."""
|
||||
|
||||
def validate(input: str) -> bool:
|
||||
try:
|
||||
value = int(input)
|
||||
if not (minimum <= value <= maximum):
|
||||
return False
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
return Validator.from_callable(validate, error_message="Invalid input.")
|
||||
|
||||
|
||||
def key_validator(keys: Sequence[str] | KeysView[str]) -> Validator:
|
||||
"""Validator for key inputs."""
|
||||
|
||||
def validate(input: str) -> bool:
|
||||
if input.upper() not in [key.upper() for key in keys]:
|
||||
return False
|
||||
return True
|
||||
|
||||
return Validator.from_callable(validate, error_message="Invalid input.")
|
|
@ -1 +1 @@
|
|||
__version__ = "0.1.10"
|
||||
__version__ = "0.1.11"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[tool.poetry]
|
||||
name = "falyx"
|
||||
version = "0.1.10"
|
||||
version = "0.1.11"
|
||||
description = "Reliable and introspectable async CLI action framework."
|
||||
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
|
||||
license = "MIT"
|
||||
|
@ -23,7 +23,6 @@ black = { version = "^25.0", allow-prereleases = true }
|
|||
mypy = { version = "^1.0", allow-prereleases = true }
|
||||
isort = { version = "^5.0", allow-prereleases = true }
|
||||
pytest-cov = "^4.0"
|
||||
pytest-mock = "^3.0"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
falyx = "falyx.__main__:main"
|
||||
|
|
|
@ -42,6 +42,48 @@ async def test_action_async_callable():
|
|||
str(action)
|
||||
== "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False)"
|
||||
)
|
||||
assert (
|
||||
repr(action)
|
||||
== "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chained_action():
|
||||
"""Test if ChainedAction can be created and used."""
|
||||
action1 = Action("one", lambda: 1)
|
||||
action2 = Action("two", lambda: 2)
|
||||
chain = ChainedAction(
|
||||
name="Simple Chain",
|
||||
actions=[action1, action2],
|
||||
return_list=True,
|
||||
)
|
||||
|
||||
result = await chain()
|
||||
assert result == [1, 2]
|
||||
assert (
|
||||
str(chain)
|
||||
== "ChainedAction(name='Simple Chain', actions=['one', 'two'], auto_inject=False, return_list=True)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_action_group():
|
||||
"""Test if ActionGroup can be created and used."""
|
||||
action1 = Action("one", lambda: 1)
|
||||
action2 = Action("two", lambda: 2)
|
||||
group = ChainedAction(
|
||||
name="Simple Group",
|
||||
actions=[action1, action2],
|
||||
return_list=True,
|
||||
)
|
||||
|
||||
result = await group()
|
||||
assert result == [1, 2]
|
||||
assert (
|
||||
str(group)
|
||||
== "ChainedAction(name='Simple Group', actions=['one', 'two'], auto_inject=False, return_list=True)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
@ -120,3 +162,62 @@ async def test_fallback_action():
|
|||
result = await chain()
|
||||
assert result == "Fallback value"
|
||||
assert str(action) == "FallbackAction(fallback='Fallback value')"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_action_from_chain():
|
||||
"""Test if an action can be removed from a chain."""
|
||||
action1 = Action(name="one", action=lambda: 1)
|
||||
action2 = Action(name="two", action=lambda: 2)
|
||||
chain = ChainedAction(
|
||||
name="Simple Chain",
|
||||
actions=[action1, action2],
|
||||
)
|
||||
|
||||
assert len(chain.actions) == 2
|
||||
|
||||
# Remove the first action
|
||||
chain.remove_action(action1.name)
|
||||
|
||||
assert len(chain.actions) == 1
|
||||
assert chain.actions[0] == action2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_has_action_in_chain():
|
||||
"""Test if an action can be checked for presence in a chain."""
|
||||
action1 = Action(name="one", action=lambda: 1)
|
||||
action2 = Action(name="two", action=lambda: 2)
|
||||
chain = ChainedAction(
|
||||
name="Simple Chain",
|
||||
actions=[action1, action2],
|
||||
)
|
||||
|
||||
assert chain.has_action(action1.name) is True
|
||||
assert chain.has_action(action2.name) is True
|
||||
|
||||
# Remove the first action
|
||||
chain.remove_action(action1.name)
|
||||
|
||||
assert chain.has_action(action1.name) is False
|
||||
assert chain.has_action(action2.name) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_action_from_chain():
|
||||
"""Test if an action can be retrieved from a chain."""
|
||||
action1 = Action(name="one", action=lambda: 1)
|
||||
action2 = Action(name="two", action=lambda: 2)
|
||||
chain = ChainedAction(
|
||||
name="Simple Chain",
|
||||
actions=[action1, action2],
|
||||
)
|
||||
|
||||
assert chain.get_action(action1.name) == action1
|
||||
assert chain.get_action(action2.name) == action2
|
||||
|
||||
# Remove the first action
|
||||
chain.remove_action(action1.name)
|
||||
|
||||
assert chain.get_action(action1.name) is None
|
||||
assert chain.get_action(action2.name) == action2
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
import pytest
|
||||
|
||||
from falyx import Action, Falyx
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_headless():
|
||||
"""Test if Falyx can run in headless mode."""
|
||||
falyx = Falyx("Headless Test")
|
||||
|
||||
# Add a simple command
|
||||
falyx.add_command(
|
||||
key="T",
|
||||
description="Test Command",
|
||||
action=lambda: "Hello, World!",
|
||||
)
|
||||
|
||||
# Run the CLI
|
||||
result = await falyx.headless("T")
|
||||
assert result == "Hello, World!"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_headless_recovery():
|
||||
"""Test if Falyx can recover from a failure in headless mode."""
|
||||
falyx = Falyx("Headless Recovery Test")
|
||||
|
||||
state = {"count": 0}
|
||||
|
||||
async def flaky():
|
||||
if not state["count"]:
|
||||
state["count"] += 1
|
||||
raise RuntimeError("Random failure!")
|
||||
return "ok"
|
||||
|
||||
# Add a command that raises an exception
|
||||
falyx.add_command(
|
||||
key="E",
|
||||
description="Error Command",
|
||||
action=Action("flaky", flaky),
|
||||
retry=True,
|
||||
)
|
||||
|
||||
result = await falyx.headless("E")
|
||||
assert result == "ok"
|
Loading…
Reference in New Issue