Add MenuAction, SelectionAction, SignalAction, never_prompt(options_manager propagation), Merged prepare

This commit is contained in:
Roland Thomas Jr 2025-05-04 14:11:03 -04:00
parent 69b629eb08
commit 91c4d5481f
Signed by: roland
GPG Key ID: 7C3C2B085A4C2872
24 changed files with 1177 additions and 109 deletions

View File

@ -9,10 +9,10 @@ def hello() -> None:
print("Hello, world!") 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 # 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 # Actions are designed to be asynchronous first
@ -20,14 +20,14 @@ async def goodbye() -> None:
print("Goodbye!") print("Goodbye!")
goodbye = Action(name="goodbye_action", action=goodbye) goodbye_action = Action(name="goodbye_action", action=goodbye)
asyncio.run(goodbye()) asyncio.run(goodbye())
# Actions can be run in parallel # 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()) asyncio.run(group())
# Actions can be run in a chain # 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()) asyncio.run(chain())

View File

@ -12,6 +12,7 @@ async def flaky_step():
await asyncio.sleep(0.2) await asyncio.sleep(0.2)
if random.random() < 0.5: if random.random() < 0.5:
raise RuntimeError("Random failure!") raise RuntimeError("Random failure!")
print("Flaky step succeeded!")
return "ok" return "ok"

View File

@ -29,8 +29,7 @@ def bootstrap() -> Path | None:
config_path = find_falyx_config() config_path = find_falyx_config()
if config_path and str(config_path.parent) not in sys.path: if config_path and str(config_path.parent) not in sys.path:
sys.path.insert(0, str(config_path.parent)) sys.path.insert(0, str(config_path.parent))
return config_path return config_path
return None
def main() -> None: def main() -> None:

View File

@ -43,6 +43,7 @@ from falyx.debug import register_debug_hooks
from falyx.exceptions import EmptyChainError from falyx.exceptions import EmptyChainError
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import Hook, HookManager, HookType from falyx.hook_manager import Hook, HookManager, HookType
from falyx.options_manager import OptionsManager
from falyx.retry import RetryHandler, RetryPolicy from falyx.retry import RetryHandler, RetryPolicy
from falyx.themes.colors import OneColors from falyx.themes.colors import OneColors
from falyx.utils import ensure_async, logger from falyx.utils import ensure_async, logger
@ -66,6 +67,7 @@ class BaseAction(ABC):
hooks: HookManager | None = None, hooks: HookManager | None = None,
inject_last_result: bool = False, inject_last_result: bool = False,
inject_last_result_as: str = "last_result", inject_last_result_as: str = "last_result",
never_prompt: bool = False,
logging_hooks: bool = False, logging_hooks: bool = False,
) -> None: ) -> None:
self.name = name self.name = name
@ -74,9 +76,11 @@ class BaseAction(ABC):
self.shared_context: SharedContext | None = None self.shared_context: SharedContext | None = None
self.inject_last_result: bool = inject_last_result self.inject_last_result: bool = inject_last_result
self.inject_last_result_as: str = inject_last_result_as self.inject_last_result_as: str = inject_last_result_as
self._never_prompt: bool = never_prompt
self._requires_injection: bool = False self._requires_injection: bool = False
self._skip_in_chain: bool = False self._skip_in_chain: bool = False
self.console = Console(color_system="auto") self.console = Console(color_system="auto")
self.options_manager: OptionsManager | None = None
if logging_hooks: if logging_hooks:
register_debug_hooks(self.hooks) register_debug_hooks(self.hooks)
@ -92,23 +96,39 @@ class BaseAction(ABC):
async def preview(self, parent: Tree | None = None): async def preview(self, parent: Tree | None = None):
raise NotImplementedError("preview must be implemented by subclasses") 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 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. Prepare the action specifically for sequential (ChainedAction) execution.
Can be overridden for chain-specific logic. Can be overridden for chain-specific logic.
""" """
self.set_shared_context(shared_context) self.set_shared_context(shared_context)
return self if options_manager:
self.set_options_manager(options_manager)
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)
return self return self
def _maybe_inject_last_result(self, kwargs: dict[str, Any]) -> dict[str, Any]: def _maybe_inject_last_result(self, kwargs: dict[str, Any]) -> dict[str, Any]:
@ -161,8 +181,8 @@ class Action(BaseAction):
def __init__( def __init__(
self, self,
name: str, name: str,
action, action: Callable[..., Any],
rollback=None, rollback: Callable[..., Any] | None = None,
args: tuple[Any, ...] = (), args: tuple[Any, ...] = (),
kwargs: dict[str, Any] | None = None, kwargs: dict[str, Any] | None = None,
hooks: HookManager | None = None, hooks: HookManager | None = None,
@ -189,6 +209,17 @@ class Action(BaseAction):
def action(self, value: Callable[..., Any]): def action(self, value: Callable[..., Any]):
self._action = ensure_async(value) 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): def enable_retry(self):
"""Enable retry with the existing retry policy.""" """Enable retry with the existing retry policy."""
self.retry_policy.enable_policy() self.retry_policy.enable_policy()
@ -212,6 +243,7 @@ class Action(BaseAction):
kwargs=combined_kwargs, kwargs=combined_kwargs,
action=self, action=self,
) )
context.start_timer() context.start_timer()
try: try:
await self.hooks.trigger(HookType.BEFORE, context) await self.hooks.trigger(HookType.BEFORE, context)
@ -425,7 +457,7 @@ class ChainedAction(BaseAction, ActionListMixin):
) )
continue continue
shared_context.current_index = index 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() last_result = shared_context.last_result()
try: try:
if self.requires_io_injection() and last_result is not None: if self.requires_io_injection() and last_result is not None:
@ -446,9 +478,7 @@ class ChainedAction(BaseAction, ActionListMixin):
) )
shared_context.add_result(None) shared_context.add_result(None)
context.extra["results"].append(None) context.extra["results"].append(None)
fallback = self.actions[index + 1].prepare_for_chain( fallback = self.actions[index + 1].prepare(shared_context)
shared_context
)
result = await fallback() result = await fallback()
fallback._skip_in_chain = True fallback._skip_in_chain = True
else: else:
@ -584,7 +614,7 @@ class ActionGroup(BaseAction, ActionListMixin):
async def run_one(action: BaseAction): async def run_one(action: BaseAction):
try: try:
prepared = action.prepare_for_group(shared_context) prepared = action.prepare(shared_context, self.options_manager)
result = await prepared(*args, **updated_kwargs) result = await prepared(*args, **updated_kwargs)
shared_context.add_result((action.name, result)) shared_context.add_result((action.name, result))
context.extra["results"].append((action.name, result)) context.extra["results"].append((action.name, result))

View File

@ -32,6 +32,7 @@ from falyx.debug import register_debug_hooks
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.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
@ -116,6 +117,7 @@ class Command(BaseModel):
tags: list[str] = Field(default_factory=list) tags: list[str] = Field(default_factory=list)
logging_hooks: bool = False logging_hooks: bool = False
requires_input: bool | None = None requires_input: bool | None = None
options_manager: OptionsManager = Field(default_factory=OptionsManager)
_context: ExecutionContext | None = PrivateAttr(default=None) _context: ExecutionContext | None = PrivateAttr(default=None)
@ -178,8 +180,14 @@ class Command(BaseModel):
f"action='{self.action}')" 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): async def __call__(self, *args, **kwargs):
"""Run the action with full hook lifecycle, timing, and error handling.""" """Run the action with full hook lifecycle, timing, and error handling."""
self._inject_options_manager()
combined_args = args + self.args combined_args = args + self.args
combined_kwargs = {**self.kwargs, **kwargs} combined_kwargs = {**self.kwargs, **kwargs}
context = ExecutionContext( context = ExecutionContext(
@ -200,9 +208,6 @@ class Command(BaseModel):
except Exception as error: except Exception as error:
context.exception = error context.exception = error
await self.hooks.trigger(HookType.ON_ERROR, context) 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 raise error
finally: finally:
context.stop_timer() context.stop_timer()

View File

@ -1,5 +1,20 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 from __future__ import annotations
import time import time
@ -11,6 +26,47 @@ from rich.console import Console
class ExecutionContext(BaseModel): 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 name: str
args: tuple = () args: tuple = ()
kwargs: dict = {} kwargs: dict = {}
@ -120,6 +176,37 @@ class ExecutionContext(BaseModel):
class SharedContext(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 name: str
results: list[Any] = Field(default_factory=list) results: list[Any] = Field(default_factory=list)
errors: list[tuple[int, Exception]] = Field(default_factory=list) errors: list[tuple[int, Exception]] = Field(default_factory=list)

View File

@ -53,11 +53,12 @@ from falyx.hook_manager import Hook, HookManager, HookType
from falyx.options_manager import OptionsManager from falyx.options_manager import OptionsManager
from falyx.parsers import get_arg_parsers 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.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, CaseInsensitiveDict,
async_confirm,
chunks, chunks,
confirm_async,
get_program_invocation, get_program_invocation,
logger, logger,
) )
@ -93,7 +94,7 @@ class Falyx:
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. 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. 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.
@ -123,7 +124,7 @@ class Falyx:
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, confirm_on_error: bool = True,
never_confirm: bool = False, never_prompt: bool = False,
always_confirm: bool = False, always_confirm: bool = False,
cli_args: Namespace | None = None, cli_args: Namespace | None = None,
options: OptionsManager | None = None, options: OptionsManager | None = None,
@ -150,7 +151,7 @@ class Falyx:
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.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._always_confirm: bool = always_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
@ -166,7 +167,7 @@ class Falyx:
"""Checks if the options are set correctly.""" """Checks if the options are set correctly."""
self.options: OptionsManager = options or OptionsManager() self.options: OptionsManager = options or OptionsManager()
if not cli_args and not options: if not cli_args and not options:
return return None
if options and not cli_args: if options and not cli_args:
raise FalyxError("Options are set, but CLI arguments are not.") raise FalyxError("Options are set, but CLI arguments are not.")
@ -521,8 +522,9 @@ class Falyx:
def update_exit_command( def update_exit_command(
self, self,
key: str = "0", key: str = "Q",
description: str = "Exit", description: str = "Exit",
aliases: list[str] | None = None,
action: Callable[[], Any] = lambda: None, action: Callable[[], Any] = lambda: None,
color: str = OneColors.DARK_RED, color: str = OneColors.DARK_RED,
confirm: bool = False, confirm: bool = False,
@ -535,6 +537,7 @@ class Falyx:
self.exit_command = Command( self.exit_command = Command(
key=key, key=key,
description=description, description=description,
aliases=aliases if aliases else self.exit_command.aliases,
action=action, action=action,
color=color, color=color,
confirm=confirm, confirm=confirm,
@ -549,6 +552,7 @@ class Falyx:
raise NotAFalyxError("submenu must be an instance of Falyx.") raise NotAFalyxError("submenu must be an instance of Falyx.")
self._validate_command_key(key) self._validate_command_key(key)
self.add_command(key, description, submenu.menu, color=color) 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: def add_commands(self, commands: list[dict]) -> None:
"""Adds multiple commands to the menu.""" """Adds multiple commands to the menu."""
@ -613,6 +617,7 @@ class Falyx:
retry_all=retry_all, retry_all=retry_all,
retry_policy=retry_policy or RetryPolicy(), retry_policy=retry_policy or RetryPolicy(),
requires_input=requires_input, requires_input=requires_input,
options_manager=self.options,
) )
if hooks: if hooks:
@ -703,7 +708,7 @@ class Falyx:
return None return None
async def _should_run_action(self, selected_command: Command) -> bool: async def _should_run_action(self, selected_command: Command) -> bool:
if self._never_confirm: if self._never_prompt:
return True return True
if self.cli_args and getattr(self.cli_args, "skip_confirm", False): if self.cli_args and getattr(self.cli_args, "skip_confirm", False):
@ -717,7 +722,7 @@ class Falyx:
): ):
if selected_command.preview_before_confirm: if selected_command.preview_before_confirm:
await selected_command.preview() 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: if confirm_answer:
logger.info(f"[{selected_command.description}]🔐 confirmed.") logger.info(f"[{selected_command.description}]🔐 confirmed.")
@ -747,18 +752,13 @@ class Falyx:
async def _handle_action_error( async def _handle_action_error(
self, selected_command: Command, error: Exception self, selected_command: Command, error: Exception
) -> bool: ) -> None:
"""Handles errors that occur during the action of the selected command.""" """Handles errors that occur during the action of the selected command."""
logger.exception(f"Error executing '{selected_command.description}': {error}") logger.exception(f"Error executing '{selected_command.description}': {error}")
self.console.print( self.console.print(
f"[{OneColors.DARK_RED}]An error occurred while executing " f"[{OneColors.DARK_RED}]An error occurred while executing "
f"{selected_command.description}:[/] {error}" 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: async def process_command(self) -> bool:
"""Processes the action of the selected command.""" """Processes the action of the selected command."""
@ -801,13 +801,7 @@ class Falyx:
except Exception as error: except Exception as error:
context.exception = error context.exception = error
await self.hooks.trigger(HookType.ON_ERROR, context) await self.hooks.trigger(HookType.ON_ERROR, context)
if not context.exception: await self._handle_action_error(selected_command, error)
logger.info(
f"✅ Recovery hook handled error for '{selected_command.description}'"
)
context.result = result
else:
return await self._handle_action_error(selected_command, error)
finally: finally:
context.stop_timer() context.stop_timer()
await self.hooks.trigger(HookType.AFTER, context) await self.hooks.trigger(HookType.AFTER, context)
@ -822,7 +816,7 @@ class Falyx:
if not selected_command: if not selected_command:
logger.info("[Headless] Back command selected. Exiting menu.") logger.info("[Headless] Back command selected. Exiting menu.")
return return None
logger.info(f"[Headless] 🚀 Running: '{selected_command.description}'") logger.info(f"[Headless] 🚀 Running: '{selected_command.description}'")
@ -851,11 +845,6 @@ class Falyx:
except Exception as error: except Exception as error:
context.exception = error context.exception = error
await self.hooks.trigger(HookType.ON_ERROR, context) 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( raise FalyxError(
f"[Headless] ❌ '{selected_command.description}' failed." f"[Headless] ❌ '{selected_command.description}' failed."
) from error ) from error
@ -921,6 +910,11 @@ class Falyx:
except (EOFError, KeyboardInterrupt): except (EOFError, KeyboardInterrupt):
logger.info("EOF or KeyboardInterrupt. Exiting menu.") logger.info("EOF or KeyboardInterrupt. Exiting menu.")
break break
except QuitSignal:
logger.info("QuitSignal received. Exiting menu.")
break
except BackSignal:
logger.info("BackSignal received.")
finally: finally:
logger.info(f"Exiting menu: {self.get_title()}") logger.info(f"Exiting menu: {self.get_title()}")
if self.exit_message: if self.exit_message:
@ -938,6 +932,9 @@ 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

@ -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

217
falyx/menu_action.py Normal file
View File

@ -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())})"

View File

@ -9,8 +9,8 @@ from falyx.utils import logger
class OptionsManager: class OptionsManager:
def __init__(self, namespaces: list[tuple[str, Namespace]] = None) -> None: def __init__(self, namespaces: list[tuple[str, Namespace]] | None = None) -> None:
self.options = defaultdict(lambda: Namespace()) self.options: defaultdict = defaultdict(lambda: Namespace())
if namespaces: if namespaces:
for namespace_name, namespace in namespaces: for namespace_name, namespace in namespaces:
self.from_namespace(namespace, namespace_name) self.from_namespace(namespace, namespace_name)

View File

@ -37,7 +37,6 @@ def get_arg_parsers(
description: str | None = "Falyx CLI - Run structured async command workflows.", description: str | None = "Falyx CLI - Run structured async command workflows.",
epilog: str | None = None, epilog: str | None = None,
parents: Sequence[ArgumentParser] = [], parents: Sequence[ArgumentParser] = [],
formatter_class: HelpFormatter = HelpFormatter,
prefix_chars: str = "-", prefix_chars: str = "-",
fromfile_prefix_chars: str | None = None, fromfile_prefix_chars: str | None = None,
argument_default: Any = None, argument_default: Any = None,
@ -53,7 +52,6 @@ def get_arg_parsers(
description=description, description=description,
epilog=epilog, epilog=epilog,
parents=parents, parents=parents,
formatter_class=formatter_class,
prefix_chars=prefix_chars, prefix_chars=prefix_chars,
fromfile_prefix_chars=fromfile_prefix_chars, fromfile_prefix_chars=fromfile_prefix_chars,
argument_default=argument_default, argument_default=argument_default,
@ -62,6 +60,11 @@ def get_arg_parsers(
allow_abbrev=allow_abbrev, allow_abbrev=allow_abbrev,
exit_on_error=exit_on_error, 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( parser.add_argument(
"-v", "--verbose", action="store_true", help="Enable debug logging for Falyx." "-v", "--verbose", action="store_true", help="Enable debug logging for Falyx."
) )

View File

@ -43,7 +43,7 @@ class RetryHandler:
delay: float = 1.0, delay: float = 1.0,
backoff: float = 2.0, backoff: float = 2.0,
jitter: float = 0.0, jitter: float = 0.0,
): ) -> None:
self.policy.enabled = True self.policy.enabled = True
self.policy.max_retries = max_retries self.policy.max_retries = max_retries
self.policy.delay = delay self.policy.delay = delay
@ -51,7 +51,7 @@ class RetryHandler:
self.policy.jitter = jitter self.policy.jitter = jitter
logger.info(f"🔄 Retry policy enabled: {self.policy}") 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 from falyx.action import Action
name = context.name name = context.name
@ -64,21 +64,21 @@ class RetryHandler:
if not target: if not target:
logger.warning(f"[{name}] ⚠️ No action target. Cannot retry.") logger.warning(f"[{name}] ⚠️ No action target. Cannot retry.")
return return None
if not isinstance(target, Action): if not isinstance(target, Action):
logger.warning( logger.warning(
f"[{name}] ❌ RetryHandler only supports only supports Action objects." f"[{name}] ❌ RetryHandler only supports only supports Action objects."
) )
return return None
if not getattr(target, "is_retryable", False): if not getattr(target, "is_retryable", False):
logger.warning(f"[{name}] ❌ Not retryable.") logger.warning(f"[{name}] ❌ Not retryable.")
return return None
if not self.policy.enabled: if not self.policy.enabled:
logger.warning(f"[{name}] ❌ Retry policy is disabled.") logger.warning(f"[{name}] ❌ Retry policy is disabled.")
return return None
while retries_done < self.policy.max_retries: while retries_done < self.policy.max_retries:
retries_done += 1 retries_done += 1
@ -97,7 +97,7 @@ class RetryHandler:
context.result = result context.result = result
context.exception = None context.exception = None
logger.info(f"[{name}] ✅ Retry succeeded on attempt {retries_done}.") logger.info(f"[{name}] ✅ Retry succeeded on attempt {retries_done}.")
return return None
except Exception as retry_error: except Exception as retry_error:
last_error = retry_error last_error = retry_error
current_delay *= self.policy.backoff current_delay *= self.policy.backoff
@ -108,4 +108,3 @@ class RetryHandler:
context.exception = last_error context.exception = last_error
logger.error(f"[{name}] ❌ All {self.policy.max_retries} retries failed.") logger.error(f"[{name}] ❌ All {self.policy.max_retries} retries failed.")
return

View File

@ -1,3 +1,4 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
from falyx.action import Action, BaseAction from falyx.action import Action, BaseAction
from falyx.hook_manager import HookType from falyx.hook_manager import HookType
from falyx.retry import RetryHandler, RetryPolicy from falyx.retry import RetryHandler, RetryPolicy

354
falyx/selection.py Normal file
View File

@ -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,
)

169
falyx/selection_action.py Normal file
View File

@ -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)

29
falyx/signal_action.py Normal file
View File

@ -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__})"

21
falyx/signals.py Normal file
View File

@ -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)

View File

@ -1,3 +1,4 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
from collections import defaultdict from collections import defaultdict
from rich import box from rich import box

View File

@ -69,7 +69,7 @@ def chunks(iterator, size):
yield chunk 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() session: PromptSession = PromptSession()
while True: while True:
merged_message: AnyFormattedText = merge_formatted_text( merged_message: AnyFormattedText = merge_formatted_text(
@ -86,26 +86,36 @@ async def async_confirm(message: AnyFormattedText = "Are you sure?") -> bool:
class CaseInsensitiveDict(dict): class CaseInsensitiveDict(dict):
"""A case-insensitive dictionary that treats all keys as uppercase.""" """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): def __setitem__(self, key, value):
super().__setitem__(key.upper(), value) super().__setitem__(self._normalize_key(key), value)
def __getitem__(self, key): def __getitem__(self, key):
return super().__getitem__(key.upper()) return super().__getitem__(self._normalize_key(key))
def __contains__(self, key): def __contains__(self, key):
return super().__contains__(key.upper()) return super().__contains__(self._normalize_key(key))
def get(self, key, default=None): 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): 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): def update(self, other=None, **kwargs):
items = {}
if other: if other:
other = {k.upper(): v for k, v in other.items()} items.update({self._normalize_key(k): v for k, v in other.items()})
kwargs = {k.upper(): v for k, v in kwargs.items()} items.update({self._normalize_key(k): v for k, v in kwargs.items()})
super().update(other, **kwargs) super().update(items)
def __iter__(self):
return super().__iter__()
def keys(self):
return super().keys()
def running_in_container() -> bool: def running_in_container() -> bool:

30
falyx/validators.py Normal file
View File

@ -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.")

View File

@ -1 +1 @@
__version__ = "0.1.10" __version__ = "0.1.11"

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "falyx" name = "falyx"
version = "0.1.10" version = "0.1.11"
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"
@ -23,7 +23,6 @@ black = { version = "^25.0", allow-prereleases = true }
mypy = { version = "^1.0", allow-prereleases = true } mypy = { version = "^1.0", allow-prereleases = true }
isort = { version = "^5.0", allow-prereleases = true } isort = { version = "^5.0", allow-prereleases = true }
pytest-cov = "^4.0" pytest-cov = "^4.0"
pytest-mock = "^3.0"
[tool.poetry.scripts] [tool.poetry.scripts]
falyx = "falyx.__main__:main" falyx = "falyx.__main__:main"

View File

@ -42,6 +42,48 @@ async def test_action_async_callable():
str(action) str(action)
== "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False)" == "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 @pytest.mark.asyncio
@ -120,3 +162,62 @@ async def test_fallback_action():
result = await chain() result = await chain()
assert result == "Fallback value" assert result == "Fallback value"
assert str(action) == "FallbackAction(fallback='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

45
tests/test_headless.py Normal file
View File

@ -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"