This commit is contained in:
Roland Thomas Jr 2025-05-13 00:18:04 -04:00
parent e999ad5e1c
commit 87a56ac40b
Signed by: roland
GPG Key ID: 7C3C2B085A4C2872
37 changed files with 428 additions and 253 deletions

View File

@ -4,7 +4,8 @@
Core action system for Falyx. Core action system for Falyx.
This module defines the building blocks for executable actions and workflows, This module defines the building blocks for executable actions and workflows,
providing a structured way to compose, execute, recover, and manage sequences of operations. providing a structured way to compose, execute, recover, and manage sequences of
operations.
All actions are callable and follow a unified signature: All actions are callable and follow a unified signature:
result = action(*args, **kwargs) result = action(*args, **kwargs)
@ -14,7 +15,8 @@ Core guarantees:
- Consistent timing and execution context tracking for each run. - Consistent timing and execution context tracking for each run.
- Unified, predictable result handling and error propagation. - Unified, predictable result handling and error propagation.
- Optional last_result injection to enable flexible, data-driven workflows. - Optional last_result injection to enable flexible, data-driven workflows.
- Built-in support for retries, rollbacks, parallel groups, chaining, and fallback recovery. - Built-in support for retries, rollbacks, parallel groups, chaining, and fallback
recovery.
Key components: Key components:
- Action: wraps a function or coroutine into a standard executable unit. - Action: wraps a function or coroutine into a standard executable unit.
@ -43,10 +45,11 @@ 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.logger import logger
from falyx.options_manager import OptionsManager 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
class BaseAction(ABC): class BaseAction(ABC):
@ -55,7 +58,8 @@ class BaseAction(ABC):
complex actions like `ChainedAction` or `ActionGroup`. They can also complex actions like `ChainedAction` or `ActionGroup`. They can also
be run independently or as part of Falyx. be run independently or as part of Falyx.
inject_last_result (bool): Whether to inject the previous action's result into kwargs. inject_last_result (bool): Whether to inject the previous action's result
into kwargs.
inject_into (str): The name of the kwarg key to inject the result as inject_into (str): The name of the kwarg key to inject the result as
(default: 'last_result'). (default: 'last_result').
_requires_injection (bool): Whether the action requires input injection. _requires_injection (bool): Whether the action requires input injection.
@ -104,7 +108,9 @@ class BaseAction(ABC):
self.shared_context = shared_context self.shared_context = shared_context
def get_option(self, option_name: str, default: Any = None) -> Any: def get_option(self, option_name: str, default: Any = None) -> Any:
"""Resolve an option from the OptionsManager if present, otherwise use the fallback.""" """
Resolve an option from the OptionsManager if present, otherwise use the fallback.
"""
if self.options_manager: if self.options_manager:
return self.options_manager.get(option_name, default) return self.options_manager.get(option_name, default)
return default return default
@ -288,8 +294,10 @@ class Action(BaseAction):
def __str__(self): def __str__(self):
return ( return (
f"Action(name={self.name!r}, action={getattr(self._action, '__name__', repr(self._action))}, " f"Action(name={self.name!r}, action="
f"args={self.args!r}, kwargs={self.kwargs!r}, retry={self.retry_policy.enabled})" f"{getattr(self._action, '__name__', repr(self._action))}, "
f"args={self.args!r}, kwargs={self.kwargs!r}, "
f"retry={self.retry_policy.enabled})"
) )
@ -309,7 +317,7 @@ class LiteralInputAction(Action):
def __init__(self, value: Any): def __init__(self, value: Any):
self._value = value self._value = value
async def literal(*args, **kwargs): async def literal(*_, **__):
return value return value
super().__init__("Input", literal) super().__init__("Input", literal)
@ -333,14 +341,16 @@ class LiteralInputAction(Action):
class FallbackAction(Action): class FallbackAction(Action):
""" """
FallbackAction provides a default value if the previous action failed or returned None. FallbackAction provides a default value if the previous action failed or
returned None.
It injects the last result and checks: It injects the last result and checks:
- If last_result is not None, it passes it through unchanged. - If last_result is not None, it passes it through unchanged.
- If last_result is None (e.g., due to failure), it replaces it with a fallback value. - If last_result is None (e.g., due to failure), it replaces it with a fallback value.
Used in ChainedAction pipelines to gracefully recover from errors or missing data. Used in ChainedAction pipelines to gracefully recover from errors or missing data.
When activated, it consumes the preceding error and allows the chain to continue normally. When activated, it consumes the preceding error and allows the chain to continue
normally.
Args: Args:
fallback (Any): The fallback value to use if last_result is None. fallback (Any): The fallback value to use if last_result is None.
@ -413,16 +423,19 @@ class ChainedAction(BaseAction, ActionListMixin):
- Rolls back all previously executed actions if a failure occurs. - Rolls back all previously executed actions if a failure occurs.
- Handles literal values with LiteralInputAction. - Handles literal values with LiteralInputAction.
Best used for defining robust, ordered workflows where each step can depend on previous results. Best used for defining robust, ordered workflows where each step can depend on
previous results.
Args: Args:
name (str): Name of the chain. name (str): Name of the chain.
actions (list): List of actions or literals to execute. actions (list): List of actions or literals to execute.
hooks (HookManager, optional): Hooks for lifecycle events. hooks (HookManager, optional): Hooks for lifecycle events.
inject_last_result (bool, optional): Whether to inject last results into kwargs by default. inject_last_result (bool, optional): Whether to inject last results into kwargs
by default.
inject_into (str, optional): Key name for injection. inject_into (str, optional): Key name for injection.
auto_inject (bool, optional): Auto-enable injection for subsequent actions. auto_inject (bool, optional): Auto-enable injection for subsequent actions.
return_list (bool, optional): Whether to return a list of all results. False returns the last result. return_list (bool, optional): Whether to return a list of all results. False
returns the last result.
""" """
def __init__( def __init__(
@ -468,7 +481,7 @@ class ChainedAction(BaseAction, ActionListMixin):
if not self.actions: if not self.actions:
raise EmptyChainError(f"[{self.name}] No actions to execute.") raise EmptyChainError(f"[{self.name}] No actions to execute.")
shared_context = SharedContext(name=self.name) shared_context = SharedContext(name=self.name, action=self)
if self.shared_context: if self.shared_context:
shared_context.add_result(self.shared_context.last_result()) shared_context.add_result(self.shared_context.last_result())
updated_kwargs = self._maybe_inject_last_result(kwargs) updated_kwargs = self._maybe_inject_last_result(kwargs)
@ -503,7 +516,8 @@ class ChainedAction(BaseAction, ActionListMixin):
self.actions[index + 1], FallbackAction self.actions[index + 1], FallbackAction
): ):
logger.warning( logger.warning(
"[%s] ⚠️ Fallback triggered: %s, recovering with fallback '%s'.", "[%s] ⚠️ Fallback triggered: %s, recovering with fallback "
"'%s'.",
self.name, self.name,
error, error,
self.actions[index + 1].name, self.actions[index + 1].name,
@ -579,7 +593,8 @@ class ChainedAction(BaseAction, ActionListMixin):
def __str__(self): def __str__(self):
return ( return (
f"ChainedAction(name={self.name!r}, actions={[a.name for a in self.actions]!r}, " f"ChainedAction(name={self.name!r}, "
f"actions={[a.name for a in self.actions]!r}, "
f"auto_inject={self.auto_inject}, return_list={self.return_list})" f"auto_inject={self.auto_inject}, return_list={self.return_list})"
) )
@ -613,7 +628,8 @@ class ActionGroup(BaseAction, ActionListMixin):
name (str): Name of the chain. name (str): Name of the chain.
actions (list): List of actions or literals to execute. actions (list): List of actions or literals to execute.
hooks (HookManager, optional): Hooks for lifecycle events. hooks (HookManager, optional): Hooks for lifecycle events.
inject_last_result (bool, optional): Whether to inject last results into kwargs by default. inject_last_result (bool, optional): Whether to inject last results into kwargs
by default.
inject_into (str, optional): Key name for injection. inject_into (str, optional): Key name for injection.
""" """
@ -643,7 +659,8 @@ class ActionGroup(BaseAction, ActionListMixin):
return Action(name=action.__name__, action=action) return Action(name=action.__name__, action=action)
else: else:
raise TypeError( raise TypeError(
f"ActionGroup only accepts BaseAction or callable, got {type(action).__name__}" "ActionGroup only accepts BaseAction or callable, got "
f"{type(action).__name__}"
) )
def add_action(self, action: BaseAction | Any) -> None: def add_action(self, action: BaseAction | Any) -> None:
@ -653,7 +670,7 @@ class ActionGroup(BaseAction, ActionListMixin):
action.register_teardown(self.hooks) action.register_teardown(self.hooks)
async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]: async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]:
shared_context = SharedContext(name=self.name, is_parallel=True) shared_context = SharedContext(name=self.name, action=self, is_parallel=True)
if self.shared_context: if self.shared_context:
shared_context.set_shared_result(self.shared_context.last_result()) shared_context.set_shared_result(self.shared_context.last_result())
updated_kwargs = self._maybe_inject_last_result(kwargs) updated_kwargs = self._maybe_inject_last_result(kwargs)
@ -721,8 +738,8 @@ class ActionGroup(BaseAction, ActionListMixin):
def __str__(self): def __str__(self):
return ( return (
f"ActionGroup(name={self.name!r}, actions={[a.name for a in self.actions]!r}, " f"ActionGroup(name={self.name!r}, actions={[a.name for a in self.actions]!r},"
f"inject_last_result={self.inject_last_result})" f" inject_last_result={self.inject_last_result})"
) )
@ -831,6 +848,7 @@ class ProcessAction(BaseAction):
def __str__(self) -> str: def __str__(self) -> str:
return ( return (
f"ProcessAction(name={self.name!r}, action={getattr(self.action, '__name__', repr(self.action))}, " f"ProcessAction(name={self.name!r}, "
f"action={getattr(self.action, '__name__', repr(self.action))}, "
f"args={self.args!r}, kwargs={self.kwargs!r})" f"args={self.args!r}, kwargs={self.kwargs!r})"
) )

View File

@ -1,4 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""action_factory.py"""
from typing import Any from typing import Any
from rich.tree import Tree from rich.tree import Tree
@ -7,6 +8,7 @@ from falyx.action import BaseAction
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType from falyx.hook_manager import HookType
from falyx.logger import logger
from falyx.protocols import ActionFactoryProtocol from falyx.protocols import ActionFactoryProtocol
from falyx.themes.colors import OneColors from falyx.themes.colors import OneColors
@ -33,7 +35,7 @@ class ActionFactoryAction(BaseAction):
inject_last_result: bool = False, inject_last_result: bool = False,
inject_into: str = "last_result", inject_into: str = "last_result",
preview_args: tuple[Any, ...] = (), preview_args: tuple[Any, ...] = (),
preview_kwargs: dict[str, Any] = {}, preview_kwargs: dict[str, Any] | None = None,
): ):
super().__init__( super().__init__(
name=name, name=name,
@ -42,7 +44,7 @@ class ActionFactoryAction(BaseAction):
) )
self.factory = factory self.factory = factory
self.preview_args = preview_args self.preview_args = preview_args
self.preview_kwargs = preview_kwargs self.preview_kwargs = preview_kwargs or {}
async def _run(self, *args, **kwargs) -> Any: async def _run(self, *args, **kwargs) -> Any:
updated_kwargs = self._maybe_inject_last_result(kwargs) updated_kwargs = self._maybe_inject_last_result(kwargs)
@ -58,10 +60,20 @@ class ActionFactoryAction(BaseAction):
generated_action = self.factory(*args, **updated_kwargs) generated_action = self.factory(*args, **updated_kwargs)
if not isinstance(generated_action, BaseAction): if not isinstance(generated_action, BaseAction):
raise TypeError( raise TypeError(
f"[{self.name}] Factory must return a BaseAction, got {type(generated_action).__name__}" f"[{self.name}] Factory must return a BaseAction, got "
f"{type(generated_action).__name__}"
) )
if self.shared_context: if self.shared_context:
generated_action.set_shared_context(self.shared_context) generated_action.set_shared_context(self.shared_context)
if hasattr(generated_action, "register_teardown") and callable(
generated_action.register_teardown
):
generated_action.register_teardown(self.shared_context.action.hooks)
logger.debug(
"[%s] Registered teardown for %s",
self.name,
generated_action.name,
)
if self.options_manager: if self.options_manager:
generated_action.set_options_manager(self.options_manager) generated_action.set_options_manager(self.options_manager)
context.result = await generated_action(*args, **kwargs) context.result = await generated_action(*args, **kwargs)

View File

@ -146,7 +146,7 @@ class BottomBar:
for k in (key.upper(), key.lower()): for k in (key.upper(), key.lower()):
@self.key_bindings.add(k) @self.key_bindings.add(k)
def _(event): def _(_):
toggle_state() toggle_state()
def add_toggle_from_option( def add_toggle_from_option(
@ -204,6 +204,6 @@ class BottomBar:
"""Render the bottom bar.""" """Render the bottom bar."""
lines = [] lines = []
for chunk in chunks(self._named_items.values(), self.columns): for chunk in chunks(self._named_items.values(), self.columns):
lines.extend([fn for fn in chunk]) lines.extend(list(chunk))
lines.append(lambda: HTML("\n")) lines.append(lambda: HTML("\n"))
return merge_formatted_text([fn() for fn in lines[:-1]]) return merge_formatted_text([fn() for fn in lines[:-1]])

View File

@ -33,12 +33,13 @@ from falyx.exceptions import FalyxError
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType from falyx.hook_manager import HookManager, HookType
from falyx.io_action import BaseIOAction from falyx.io_action import BaseIOAction
from falyx.logger import logger
from falyx.options_manager import OptionsManager from falyx.options_manager import OptionsManager
from falyx.prompt_utils import should_prompt_user from falyx.prompt_utils import confirm_async, should_prompt_user
from falyx.retry import RetryPolicy from falyx.retry import RetryPolicy
from falyx.retry_utils import enable_retries_recursively from falyx.retry_utils import enable_retries_recursively
from falyx.themes.colors import OneColors from falyx.themes.colors import OneColors
from falyx.utils import _noop, confirm_async, ensure_async, logger from falyx.utils import _noop, ensure_async
console = Console(color_system="auto") console = Console(color_system="auto")
@ -134,7 +135,7 @@ class Command(BaseModel):
return ensure_async(action) return ensure_async(action)
raise TypeError("Action must be a callable or an instance of BaseAction") raise TypeError("Action must be a callable or an instance of BaseAction")
def model_post_init(self, __context: Any) -> None: def model_post_init(self, _: Any) -> None:
"""Post-initialization to set up the action and hooks.""" """Post-initialization to set up the action and hooks."""
if self.retry and isinstance(self.action, Action): if self.retry and isinstance(self.action, Action):
self.action.enable_retry() self.action.enable_retry()
@ -142,14 +143,16 @@ class Command(BaseModel):
self.action.set_retry_policy(self.retry_policy) self.action.set_retry_policy(self.retry_policy)
elif self.retry: elif self.retry:
logger.warning( logger.warning(
f"[Command:{self.key}] Retry requested, but action is not an Action instance." "[Command:%s] Retry requested, but action is not an Action instance.",
self.key,
) )
if self.retry_all and isinstance(self.action, BaseAction): if self.retry_all and isinstance(self.action, BaseAction):
self.retry_policy.enabled = True self.retry_policy.enabled = True
enable_retries_recursively(self.action, self.retry_policy) enable_retries_recursively(self.action, self.retry_policy)
elif self.retry_all: elif self.retry_all:
logger.warning( logger.warning(
f"[Command:{self.key}] Retry all requested, but action is not a BaseAction instance." "[Command:%s] Retry all requested, but action is not a BaseAction.",
self.key,
) )
if self.logging_hooks and isinstance(self.action, BaseAction): if self.logging_hooks and isinstance(self.action, BaseAction):
@ -201,7 +204,7 @@ class Command(BaseModel):
if self.preview_before_confirm: if self.preview_before_confirm:
await self.preview() await self.preview()
if not await confirm_async(self.confirmation_prompt): if not await confirm_async(self.confirmation_prompt):
logger.info(f"[Command:{self.key}] ❌ Cancelled by user.") logger.info("[Command:%s] ❌ Cancelled by user.", self.key)
raise FalyxError(f"[Command:{self.key}] Cancelled by confirmation.") raise FalyxError(f"[Command:{self.key}] Cancelled by confirmation.")
context.start_timer() context.start_timer()
@ -288,7 +291,7 @@ class Command(BaseModel):
if self.help_text: if self.help_text:
console.print(f"[dim]💡 {self.help_text}[/dim]") console.print(f"[dim]💡 {self.help_text}[/dim]")
console.print( console.print(
f"[{OneColors.DARK_RED}]⚠️ Action is not callable or lacks a preview method.[/]" f"[{OneColors.DARK_RED}]⚠️ No preview available for this action.[/]"
) )
def __str__(self) -> str: def __str__(self) -> str:

View File

@ -16,9 +16,9 @@ from rich.console import Console
from falyx.action import Action, BaseAction from falyx.action import Action, BaseAction
from falyx.command import Command from falyx.command import Command
from falyx.falyx import Falyx from falyx.falyx import Falyx
from falyx.logger import logger
from falyx.retry import RetryPolicy from falyx.retry import RetryPolicy
from falyx.themes.colors import OneColors from falyx.themes.colors import OneColors
from falyx.utils import logger
console = Console(color_system="auto") console = Console(color_system="auto")
@ -47,7 +47,8 @@ def import_action(dotted_path: str) -> Any:
logger.error("Failed to import module '%s': %s", module_path, error) logger.error("Failed to import module '%s': %s", module_path, error)
console.print( console.print(
f"[{OneColors.DARK_RED}]❌ Could not import '{dotted_path}': {error}[/]\n" f"[{OneColors.DARK_RED}]❌ Could not import '{dotted_path}': {error}[/]\n"
f"[{OneColors.COMMENT_GREY}]Ensure the module is installed and discoverable via PYTHONPATH." f"[{OneColors.COMMENT_GREY}]Ensure the module is installed and discoverable "
"via PYTHONPATH."
) )
sys.exit(1) sys.exit(1)
try: try:
@ -57,13 +58,16 @@ def import_action(dotted_path: str) -> Any:
"Module '%s' does not have attribute '%s': %s", module_path, attr, error "Module '%s' does not have attribute '%s': %s", module_path, attr, error
) )
console.print( console.print(
f"[{OneColors.DARK_RED}]❌ Module '{module_path}' has no attribute '{attr}': {error}[/]" f"[{OneColors.DARK_RED}]❌ Module '{module_path}' has no attribute "
f"'{attr}': {error}[/]"
) )
sys.exit(1) sys.exit(1)
return action return action
class RawCommand(BaseModel): class RawCommand(BaseModel):
"""Raw command model for Falyx CLI configuration."""
key: str key: str
description: str description: str
action: str action: str
@ -72,7 +76,7 @@ class RawCommand(BaseModel):
kwargs: dict[str, Any] = {} kwargs: dict[str, Any] = {}
aliases: list[str] = [] aliases: list[str] = []
tags: list[str] = [] tags: list[str] = []
style: str = "white" style: str = OneColors.WHITE
confirm: bool = False confirm: bool = False
confirm_message: str = "Are you sure?" confirm_message: str = "Are you sure?"
@ -81,7 +85,7 @@ class RawCommand(BaseModel):
spinner: bool = False spinner: bool = False
spinner_message: str = "Processing..." spinner_message: str = "Processing..."
spinner_type: str = "dots" spinner_type: str = "dots"
spinner_style: str = "cyan" spinner_style: str = OneColors.CYAN
spinner_kwargs: dict[str, Any] = {} spinner_kwargs: dict[str, Any] = {}
before_hooks: list[Callable] = [] before_hooks: list[Callable] = []
@ -126,6 +130,8 @@ def convert_commands(raw_commands: list[dict[str, Any]]) -> list[Command]:
class FalyxConfig(BaseModel): class FalyxConfig(BaseModel):
"""Falyx CLI configuration model."""
title: str = "Falyx CLI" title: str = "Falyx CLI"
prompt: str | list[tuple[str, str]] | list[list[str]] = [ prompt: str | list[tuple[str, str]] | list[list[str]] = [
(OneColors.BLUE_b, "FALYX > ") (OneColors.BLUE_b, "FALYX > ")
@ -148,7 +154,7 @@ class FalyxConfig(BaseModel):
def to_falyx(self) -> Falyx: def to_falyx(self) -> Falyx:
flx = Falyx( flx = Falyx(
title=self.title, title=self.title,
prompt=self.prompt, prompt=self.prompt, # type: ignore[arg-type]
columns=self.columns, columns=self.columns,
welcome_message=self.welcome_message, welcome_message=self.welcome_message,
exit_message=self.exit_message, exit_message=self.exit_message,
@ -159,7 +165,9 @@ class FalyxConfig(BaseModel):
def loader(file_path: Path | str) -> Falyx: def loader(file_path: Path | str) -> Falyx:
""" """
Load command definitions from a YAML or TOML file. Load Falyx CLI configuration from a YAML or TOML file.
The file should contain a dictionary with a list of commands.
Each command should be defined as a dictionary with at least: Each command should be defined as a dictionary with at least:
- key: a unique single-character key - key: a unique single-character key

View File

@ -29,10 +29,10 @@ class ExecutionContext(BaseModel):
""" """
Represents the runtime metadata and state for a single action execution. Represents the runtime metadata and state for a single action execution.
The `ExecutionContext` tracks arguments, results, exceptions, timing, and additional The `ExecutionContext` tracks arguments, results, exceptions, timing, and
metadata for each invocation of a Falyx `BaseAction`. It provides integration with the additional metadata for each invocation of a Falyx `BaseAction`. It provides
Falyx hook system and execution registry, enabling lifecycle management, diagnostics, integration with the Falyx hook system and execution registry, enabling lifecycle
and structured logging. management, diagnostics, and structured logging.
Attributes: Attributes:
name (str): The name of the action being executed. name (str): The name of the action being executed.
@ -47,7 +47,8 @@ class ExecutionContext(BaseModel):
end_wall (datetime | None): Wall-clock timestamp when execution ended. end_wall (datetime | None): Wall-clock timestamp when execution ended.
extra (dict): Metadata for custom introspection or special use by Actions. extra (dict): Metadata for custom introspection or special use by Actions.
console (Console): Rich console instance for logging or UI output. console (Console): Rich console instance for logging or UI output.
shared_context (SharedContext | None): Optional shared context when running in a chain or group. shared_context (SharedContext | None): Optional shared context when running in
a chain or group.
Properties: Properties:
duration (float | None): The execution duration in seconds. duration (float | None): The execution duration in seconds.
@ -95,7 +96,11 @@ class ExecutionContext(BaseModel):
self.end_wall = datetime.now() self.end_wall = datetime.now()
def get_shared_context(self) -> SharedContext: def get_shared_context(self) -> SharedContext:
return self.shared_context or SharedContext(name="default") if not self.shared_context:
raise ValueError(
"SharedContext is not set. This context is not part of a chain or group."
)
return self.shared_context
@property @property
def duration(self) -> float | None: def duration(self) -> float | None:
@ -190,8 +195,10 @@ class SharedContext(BaseModel):
errors (list[tuple[int, Exception]]): Indexed list of errors from failed actions. errors (list[tuple[int, Exception]]): Indexed list of errors from failed actions.
current_index (int): Index of the currently executing action (used in chains). current_index (int): Index of the currently executing action (used in chains).
is_parallel (bool): Whether the context is used in parallel mode (ActionGroup). 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. shared_result (Any | None): Optional shared value available to all actions in
share (dict[str, Any]): Custom shared key-value store for user-defined communication parallel mode.
share (dict[str, Any]): Custom shared key-value store for user-defined
communication
between actions (e.g., flags, intermediate data, settings). between actions (e.g., flags, intermediate data, settings).
Note: Note:
@ -208,6 +215,7 @@ class SharedContext(BaseModel):
""" """
name: str name: str
action: Any
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)
current_index: int = -1 current_index: int = -1

View File

@ -1,7 +1,8 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""debug.py"""
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.hook_manager import HookManager, HookType from falyx.hook_manager import HookManager, HookType
from falyx.utils import logger from falyx.logger import logger
def log_before(context: ExecutionContext): def log_before(context: ExecutionContext):

View File

@ -1,4 +1,7 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""exceptions.py"""
class FalyxError(Exception): class FalyxError(Exception):
"""Custom exception for the Menu class.""" """Custom exception for the Menu class."""

View File

@ -1,5 +1,32 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""execution_registry.py""" """
execution_registry.py
This module provides the `ExecutionRegistry`, a global class for tracking and
introspecting the execution history of Falyx actions.
The registry captures `ExecutionContext` instances from all executed actions, making it
easy to debug, audit, and visualize workflow behavior over time. It supports retrieval,
filtering, clearing, and formatted summary display.
Core Features:
- Stores all action execution contexts globally (with access by name).
- Provides live execution summaries in a rich table format.
- Enables creation of a built-in Falyx Action to print history on demand.
- Integrates with Falyx's introspectable and hook-driven execution model.
Intended for:
- Debugging and diagnostics
- Post-run inspection of CLI workflows
- Interactive tools built with Falyx
Example:
from falyx.execution_registry import ExecutionRegistry as er
er.record(context)
er.summary()
"""
from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from datetime import datetime from datetime import datetime
from typing import Dict, List from typing import Dict, List
@ -9,11 +36,40 @@ from rich.console import Console
from rich.table import Table from rich.table import Table
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.logger import logger
from falyx.themes.colors import OneColors from falyx.themes.colors import OneColors
from falyx.utils import logger
class ExecutionRegistry: class ExecutionRegistry:
"""
Global registry for recording and inspecting Falyx action executions.
This class captures every `ExecutionContext` generated by a Falyx `Action`,
`ChainedAction`, or `ActionGroup`, maintaining both full history and
name-indexed access for filtered analysis.
Methods:
- record(context): Stores an ExecutionContext, logging a summary line.
- get_all(): Returns the list of all recorded executions.
- get_by_name(name): Returns all executions with the given action name.
- get_latest(): Returns the most recent execution.
- clear(): Wipes the registry for a fresh run.
- summary(): Renders a formatted Rich table of all execution results.
Use Cases:
- Debugging chained or factory-generated workflows
- Viewing results and exceptions from multiple runs
- Embedding a diagnostic command into your CLI for user support
Note:
This registry is in-memory and not persistent. It's reset each time the process
restarts or `clear()` is called.
Example:
ExecutionRegistry.record(context)
ExecutionRegistry.summary()
"""
_store_by_name: Dict[str, List[ExecutionContext]] = defaultdict(list) _store_by_name: Dict[str, List[ExecutionContext]] = defaultdict(list)
_store_all: List[ExecutionContext] = [] _store_all: List[ExecutionContext] = []
_console = Console(color_system="auto") _console = Console(color_system="auto")
@ -78,13 +134,3 @@ class ExecutionRegistry:
table.add_row(ctx.name, start, end, duration, status, result) table.add_row(ctx.name, start, end, duration, status, result)
cls._console.print(table) cls._console.print(table)
@classmethod
def get_history_action(cls) -> "Action":
"""Return an Action that prints the execution summary."""
from falyx.action import Action
async def show_history():
cls.summary()
return Action(name="View Execution History", action=show_history)

View File

@ -51,12 +51,13 @@ from falyx.exceptions import (
) )
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.logger import logger
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.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 CaseInsensitiveDict, chunks, get_program_invocation, logger from falyx.utils import CaseInsensitiveDict, chunks, get_program_invocation
from falyx.version import __version__ from falyx.version import __version__
@ -78,7 +79,8 @@ class Falyx:
Key Features: Key Features:
- Interactive menu with Rich rendering and Prompt Toolkit input handling - Interactive menu with Rich rendering and Prompt Toolkit input handling
- Dynamic command management with alias and abbreviation matching - Dynamic command management with alias and abbreviation matching
- Full lifecycle hooks (before, success, error, after, teardown) at both menu and command levels - Full lifecycle hooks (before, success, error, after, teardown) at both menu and
command levels
- Built-in retry support, spinner visuals, and confirmation prompts - Built-in retry support, spinner visuals, and confirmation prompts
- Submenu nesting and action chaining - Submenu nesting and action chaining
- History tracking, help generation, and run key execution modes - History tracking, help generation, and run key execution modes
@ -99,12 +101,14 @@ class Falyx:
force_confirm (bool): Seed default for `OptionsManager["force_confirm"]` force_confirm (bool): Seed default for `OptionsManager["force_confirm"]`
cli_args (Namespace | None): Parsed CLI arguments, usually from argparse. cli_args (Namespace | None): Parsed CLI arguments, usually from argparse.
options (OptionsManager | None): Declarative option mappings. options (OptionsManager | None): Declarative option mappings.
custom_table (Callable[[Falyx], Table] | Table | None): Custom menu table generator. custom_table (Callable[[Falyx], Table] | Table | None): Custom menu table
generator.
Methods: Methods:
run(): Main entry point for CLI argument-based workflows. Most users will use this. run(): Main entry point for CLI argument-based workflows. Suggested for
most use cases.
menu(): Run the interactive menu loop. menu(): Run the interactive menu loop.
run_key(command_key, return_context): Run a command directly without showing the menu. run_key(command_key, return_context): Run a command directly without the menu.
add_command(): Add a single command to the menu. add_command(): Add a single command to the menu.
add_commands(): Add multiple commands at once. add_commands(): Add multiple commands at once.
register_all_hooks(): Register hooks across all commands and submenus. register_all_hooks(): Register hooks across all commands and submenus.
@ -184,8 +188,10 @@ class Falyx:
@property @property
def _name_map(self) -> dict[str, Command]: def _name_map(self) -> dict[str, Command]:
"""Builds a mapping of all valid input names (keys, aliases, normalized names) to Command objects. """
If a collision occurs, logs a warning and keeps the first registered command. Builds a mapping of all valid input names (keys, aliases, normalized names) to
Command objects. If a collision occurs, logs a warning and keeps the first
registered command.
""" """
mapping: dict[str, Command] = {} mapping: dict[str, Command] = {}
@ -195,8 +201,11 @@ class Falyx:
existing = mapping[norm] existing = mapping[norm]
if existing is not cmd: if existing is not cmd:
logger.warning( logger.warning(
f"[alias conflict] '{name}' already assigned to '{existing.description}'." "[alias conflict] '%s' already assigned to '%s'. "
f" Skipping for '{cmd.description}'." "Skipping for '%s'.",
name,
existing.description,
cmd.description,
) )
else: else:
mapping[norm] = cmd mapping[norm] = cmd
@ -238,7 +247,7 @@ class Falyx:
key="Y", key="Y",
description="History", description="History",
aliases=["HISTORY"], aliases=["HISTORY"],
action=er.get_history_action(), action=Action(name="View Execution History", action=er.summary),
style=OneColors.DARK_YELLOW, style=OneColors.DARK_YELLOW,
) )
@ -283,7 +292,8 @@ class Falyx:
self.console.print(table, justify="center") self.console.print(table, justify="center")
if self.mode == FalyxMode.MENU: if self.mode == FalyxMode.MENU:
self.console.print( self.console.print(
f"📦 Tip: '[{OneColors.LIGHT_YELLOW}]?[KEY][/]' to preview a command before running it.\n", f"📦 Tip: '[{OneColors.LIGHT_YELLOW}]?[KEY][/]' to preview a command "
"before running it.\n",
justify="center", justify="center",
) )
@ -346,7 +356,7 @@ class Falyx:
is_preview, choice = self.get_command(text, from_validate=True) is_preview, choice = self.get_command(text, from_validate=True)
if is_preview and choice is None: if is_preview and choice is None:
return True return True
return True if choice else False return bool(choice)
return Validator.from_callable( return Validator.from_callable(
validator, validator,
@ -444,43 +454,10 @@ class Falyx:
def debug_hooks(self) -> None: def debug_hooks(self) -> None:
"""Logs the names of all hooks registered for the menu and its commands.""" """Logs the names of all hooks registered for the menu and its commands."""
logger.debug("Menu-level hooks:\n%s", str(self.hooks))
def hook_names(hook_list):
return [hook.__name__ for hook in hook_list]
logger.debug(
"Menu-level before hooks: "
f"{hook_names(self.hooks._hooks[HookType.BEFORE])}"
)
logger.debug(
f"Menu-level success hooks: {hook_names(self.hooks._hooks[HookType.ON_SUCCESS])}"
)
logger.debug(
f"Menu-level error hooks: {hook_names(self.hooks._hooks[HookType.ON_ERROR])}"
)
logger.debug(
f"Menu-level after hooks: {hook_names(self.hooks._hooks[HookType.AFTER])}"
)
logger.debug(
f"Menu-level on_teardown hooks: {hook_names(self.hooks._hooks[HookType.ON_TEARDOWN])}"
)
for key, command in self.commands.items(): for key, command in self.commands.items():
logger.debug( logger.debug("[Command '%s'] hooks:\n%s", key, str(command.hooks))
f"[Command '{key}'] before: {hook_names(command.hooks._hooks[HookType.BEFORE])}"
)
logger.debug(
f"[Command '{key}'] success: {hook_names(command.hooks._hooks[HookType.ON_SUCCESS])}"
)
logger.debug(
f"[Command '{key}'] error: {hook_names(command.hooks._hooks[HookType.ON_ERROR])}"
)
logger.debug(
f"[Command '{key}'] after: {hook_names(command.hooks._hooks[HookType.AFTER])}"
)
logger.debug(
f"[Command '{key}'] on_teardown: {hook_names(command.hooks._hooks[HookType.ON_TEARDOWN])}"
)
def is_key_available(self, key: str) -> bool: def is_key_available(self, key: str) -> bool:
key = key.upper() key = key.upper()
@ -586,7 +563,7 @@ class Falyx:
action: BaseAction | Callable[[], Any], action: BaseAction | Callable[[], Any],
*, *,
args: tuple = (), args: tuple = (),
kwargs: dict[str, Any] = {}, kwargs: dict[str, Any] | None = None,
hidden: bool = False, hidden: bool = False,
aliases: list[str] | None = None, aliases: list[str] | None = None,
help_text: str = "", help_text: str = "",
@ -619,7 +596,7 @@ class Falyx:
description=description, description=description,
action=action, action=action,
args=args, args=args,
kwargs=kwargs, kwargs=kwargs if kwargs else {},
hidden=hidden, hidden=hidden,
aliases=aliases if aliases else [], aliases=aliases if aliases else [],
help_text=help_text, help_text=help_text,
@ -665,20 +642,26 @@ class Falyx:
bottom_row = [] bottom_row = []
if self.history_command: if self.history_command:
bottom_row.append( bottom_row.append(
f"[{self.history_command.key}] [{self.history_command.style}]{self.history_command.description}" f"[{self.history_command.key}] [{self.history_command.style}]"
f"{self.history_command.description}"
) )
if self.help_command: if self.help_command:
bottom_row.append( bottom_row.append(
f"[{self.help_command.key}] [{self.help_command.style}]{self.help_command.description}" f"[{self.help_command.key}] [{self.help_command.style}]"
f"{self.help_command.description}"
) )
bottom_row.append( bottom_row.append(
f"[{self.exit_command.key}] [{self.exit_command.style}]{self.exit_command.description}" f"[{self.exit_command.key}] [{self.exit_command.style}]"
f"{self.exit_command.description}"
) )
return bottom_row return bottom_row
def build_default_table(self) -> Table: def build_default_table(self) -> Table:
"""Build the standard table layout. Developers can subclass or call this in custom tables.""" """
table = Table(title=self.title, show_header=False, box=box.SIMPLE, expand=True) Build the standard table layout. Developers can subclass or call this
in custom tables.
"""
table = Table(title=self.title, show_header=False, box=box.SIMPLE, expand=True) # type: ignore[arg-type]
visible_commands = [item for item in self.commands.items() if not item[1].hidden] visible_commands = [item for item in self.commands.items() if not item[1].hidden]
for chunk in chunks(visible_commands, self.columns): for chunk in chunks(visible_commands, self.columns):
row = [] row = []
@ -708,7 +691,10 @@ class Falyx:
def get_command( def get_command(
self, choice: str, from_validate=False self, choice: str, from_validate=False
) -> tuple[bool, Command | None]: ) -> tuple[bool, Command | None]:
"""Returns the selected command based on user input. Supports keys, aliases, and abbreviations.""" """
Returns the selected command based on user input.
Supports keys, aliases, and abbreviations.
"""
is_preview, choice = self.parse_preview_command(choice) is_preview, choice = self.parse_preview_command(choice)
if is_preview and not choice and self.help_command: if is_preview and not choice and self.help_command:
is_preview = False is_preview = False
@ -716,7 +702,7 @@ class Falyx:
elif is_preview and not choice: elif is_preview and not choice:
if not from_validate: if not from_validate:
self.console.print( self.console.print(
f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode.[/]" f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode."
) )
return is_preview, None return is_preview, None
@ -734,7 +720,8 @@ class Falyx:
if fuzzy_matches: if fuzzy_matches:
if not from_validate: if not from_validate:
self.console.print( self.console.print(
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'. Did you mean:[/] " f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'. "
"Did you mean:"
) )
for match in fuzzy_matches: for match in fuzzy_matches:
cmd = name_map[match] cmd = name_map[match]
@ -759,7 +746,7 @@ class Falyx:
self, selected_command: Command, error: Exception self, selected_command: Command, error: Exception
) -> None: ) -> 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("Error executing '%s': %s", 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}"
@ -770,27 +757,27 @@ class Falyx:
choice = await self.prompt_session.prompt_async() choice = await self.prompt_session.prompt_async()
is_preview, selected_command = self.get_command(choice) is_preview, selected_command = self.get_command(choice)
if not selected_command: if not selected_command:
logger.info(f"Invalid command '{choice}'.") logger.info("Invalid command '%s'.", choice)
return True return True
if is_preview: if is_preview:
logger.info(f"Preview command '{selected_command.key}' selected.") logger.info("Preview command '%s' selected.", selected_command.key)
await selected_command.preview() await selected_command.preview()
return True return True
if selected_command.requires_input: if selected_command.requires_input:
program = get_program_invocation() program = get_program_invocation()
self.console.print( self.console.print(
f"[{OneColors.LIGHT_YELLOW}]⚠️ Command '{selected_command.key}' requires input " f"[{OneColors.LIGHT_YELLOW}]⚠️ Command '{selected_command.key}' requires"
f"and must be run via [{OneColors.MAGENTA}]'{program} run'[{OneColors.LIGHT_YELLOW}] " f" input and must be run via [{OneColors.MAGENTA}]'{program} run"
"with proper piping or arguments.[/]" f"'[{OneColors.LIGHT_YELLOW}] with proper piping or arguments.[/]"
) )
return True return True
self.last_run_command = selected_command self.last_run_command = selected_command
if selected_command == self.exit_command: if selected_command == self.exit_command:
logger.info(f"🔙 Back selected: exiting {self.get_title()}") logger.info("🔙 Back selected: exiting %s", self.get_title())
return False return False
context = self._create_context(selected_command) context = self._create_context(selected_command)
@ -821,7 +808,7 @@ class Falyx:
return None return None
if is_preview: if is_preview:
logger.info(f"Preview command '{selected_command.key}' selected.") logger.info("Preview command '%s' selected.", selected_command.key)
await selected_command.preview() await selected_command.preview()
return None return None
@ -840,13 +827,13 @@ class Falyx:
await self.hooks.trigger(HookType.ON_SUCCESS, context) await self.hooks.trigger(HookType.ON_SUCCESS, context)
logger.info("[run_key] ✅ '%s' complete.", selected_command.description) logger.info("[run_key] ✅ '%s' complete.", selected_command.description)
except (KeyboardInterrupt, EOFError): except (KeyboardInterrupt, EOFError) as error:
logger.warning( logger.warning(
"[run_key] ⚠️ Interrupted by user: ", selected_command.description "[run_key] ⚠️ Interrupted by user: %s", selected_command.description
) )
raise FalyxError( raise FalyxError(
f"[run_key] ⚠️ '{selected_command.description}' interrupted by user." f"[run_key] ⚠️ '{selected_command.description}' interrupted by user."
) ) from error
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)
@ -885,7 +872,8 @@ class Falyx:
selected_command.action.set_retry_policy(selected_command.retry_policy) selected_command.action.set_retry_policy(selected_command.retry_policy)
else: else:
logger.warning( logger.warning(
f"[Command:{selected_command.key}] Retry requested, but action is not an Action instance." "[Command:%s] Retry requested, but action is not an Action instance.",
selected_command.key,
) )
def print_message(self, message: str | Markdown | dict[str, Any]) -> None: def print_message(self, message: str | Markdown | dict[str, Any]) -> None:
@ -904,7 +892,7 @@ class Falyx:
async def menu(self) -> None: async def menu(self) -> None:
"""Runs the menu and handles user input.""" """Runs the menu and handles user input."""
logger.info(f"Running menu: {self.get_title()}") logger.info("Running menu: %s", self.get_title())
self.debug_hooks() self.debug_hooks()
if self.welcome_message: if self.welcome_message:
self.print_message(self.welcome_message) self.print_message(self.welcome_message)
@ -928,7 +916,7 @@ class Falyx:
except BackSignal: except BackSignal:
logger.info("BackSignal received.") logger.info("BackSignal received.")
finally: finally:
logger.info(f"Exiting menu: {self.get_title()}") logger.info("Exiting menu: %s", self.get_title())
if self.exit_message: if self.exit_message:
self.print_message(self.exit_message) self.print_message(self.exit_message)
@ -964,7 +952,7 @@ class Falyx:
_, command = self.get_command(self.cli_args.name) _, command = self.get_command(self.cli_args.name)
if not command: if not command:
self.console.print( self.console.print(
f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found.[/]" f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found."
) )
sys.exit(1) sys.exit(1)
self.console.print( self.console.print(
@ -979,7 +967,7 @@ class Falyx:
if is_preview: if is_preview:
if command is None: if command is None:
sys.exit(1) sys.exit(1)
logger.info(f"Preview command '{command.key}' selected.") logger.info("Preview command '%s' selected.", command.key)
await command.preview() await command.preview()
sys.exit(0) sys.exit(0)
if not command: if not command:
@ -1004,12 +992,14 @@ class Falyx:
] ]
if not matching: if not matching:
self.console.print( self.console.print(
f"[{OneColors.LIGHT_YELLOW}]⚠️ No commands found with tag: '{self.cli_args.tag}'[/]" f"[{OneColors.LIGHT_YELLOW}]⚠️ No commands found with tag: "
f"'{self.cli_args.tag}'"
) )
sys.exit(1) sys.exit(1)
self.console.print( self.console.print(
f"[{OneColors.CYAN_b}]🚀 Running all commands with tag:[/] {self.cli_args.tag}" f"[{OneColors.CYAN_b}]🚀 Running all commands with tag:[/] "
f"{self.cli_args.tag}"
) )
for cmd in matching: for cmd in matching:
self._set_retry_policy(cmd) self._set_retry_policy(cmd)

View File

@ -7,7 +7,7 @@ from enum import Enum
from typing import Awaitable, Callable, Dict, List, Optional, Union from typing import Awaitable, Callable, Dict, List, Optional, Union
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.utils import logger from falyx.logger import logger
Hook = Union[ Hook = Union[
Callable[[ExecutionContext], None], Callable[[ExecutionContext], Awaitable[None]] Callable[[ExecutionContext], None], Callable[[ExecutionContext], Awaitable[None]]
@ -34,6 +34,8 @@ class HookType(Enum):
class HookManager: class HookManager:
"""HookManager"""
def __init__(self) -> None: def __init__(self) -> None:
self._hooks: Dict[HookType, List[Hook]] = { self._hooks: Dict[HookType, List[Hook]] = {
hook_type: [] for hook_type in HookType hook_type: [] for hook_type in HookType
@ -62,8 +64,11 @@ class HookManager:
hook(context) hook(context)
except Exception as hook_error: except Exception as hook_error:
logger.warning( logger.warning(
f"⚠️ Hook '{hook.__name__}' raised an exception during '{hook_type}'" "⚠️ Hook '%s' raised an exception during '%s' for '%s': %s",
f" for '{context.name}': {hook_error}" hook.__name__,
hook_type,
context.name,
hook_error,
) )
if hook_type == HookType.ON_ERROR: if hook_type == HookType.ON_ERROR:
@ -71,3 +76,15 @@ class HookManager:
context.exception, Exception context.exception, Exception
), "Context exception should be set for ON_ERROR hook" ), "Context exception should be set for ON_ERROR hook"
raise context.exception from hook_error raise context.exception from hook_error
def __str__(self) -> str:
"""Return a formatted string of registered hooks grouped by hook type."""
def format_hook_list(hooks: list[Hook]) -> str:
return ", ".join(h.__name__ for h in hooks) if hooks else ""
lines = ["<HookManager>"]
for hook_type in HookType:
hook_list = self._hooks.get(hook_type, [])
lines.append(f" {hook_type.value}: {format_hook_list(hook_list)}")
return "\n".join(lines)

View File

@ -5,11 +5,13 @@ from typing import Any, Callable
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.exceptions import CircuitBreakerOpen from falyx.exceptions import CircuitBreakerOpen
from falyx.logger import logger
from falyx.themes.colors import OneColors from falyx.themes.colors import OneColors
from falyx.utils import logger
class ResultReporter: class ResultReporter:
"""Reports the success of an action."""
def __init__(self, formatter: Callable[[Any], str] | None = None): def __init__(self, formatter: Callable[[Any], str] | None = None):
""" """
Optional result formatter. If not provided, uses repr(result). Optional result formatter. If not provided, uses repr(result).
@ -41,6 +43,8 @@ class ResultReporter:
class CircuitBreaker: class CircuitBreaker:
"""Circuit Breaker pattern to prevent repeated failures."""
def __init__(self, max_failures=3, reset_timeout=10): def __init__(self, max_failures=3, reset_timeout=10):
self.max_failures = max_failures self.max_failures = max_failures
self.reset_timeout = reset_timeout self.reset_timeout = reset_timeout
@ -55,7 +59,7 @@ class CircuitBreaker:
f"🔴 Circuit open for '{name}' until {time.ctime(self.open_until)}." f"🔴 Circuit open for '{name}' until {time.ctime(self.open_until)}."
) )
else: else:
logger.info(f"🟢 Circuit closed again for '{name}'.") logger.info("🟢 Circuit closed again for '%s'.")
self.failures = 0 self.failures = 0
self.open_until = None self.open_until = None
@ -63,15 +67,18 @@ class CircuitBreaker:
name = context.name name = context.name
self.failures += 1 self.failures += 1
logger.warning( logger.warning(
f"⚠️ CircuitBreaker: '{name}' failure {self.failures}/{self.max_failures}." "⚠️ CircuitBreaker: '%s' failure %s/%s.",
name,
self.failures,
self.max_failures,
) )
if self.failures >= self.max_failures: if self.failures >= self.max_failures:
self.open_until = time.time() + self.reset_timeout self.open_until = time.time() + self.reset_timeout
logger.error( logger.error(
f"🔴 Circuit opened for '{name}' until {time.ctime(self.open_until)}." "🔴 Circuit opened for '%s' until %s.", name, time.ctime(self.open_until)
) )
def after_hook(self, context: ExecutionContext): def after_hook(self, _: ExecutionContext):
self.failures = 0 self.failures = 0
def is_open(self): def is_open(self):

View File

@ -16,8 +16,8 @@ from rich.tree import Tree
from falyx.action import Action from falyx.action import Action
from falyx.context import ExecutionContext, SharedContext from falyx.context import ExecutionContext, SharedContext
from falyx.hook_manager import HookManager, HookType from falyx.hook_manager import HookManager, HookType
from falyx.logger import logger
from falyx.themes.colors import OneColors from falyx.themes.colors import OneColors
from falyx.utils import logger
async def close_shared_http_session(context: ExecutionContext) -> None: async def close_shared_http_session(context: ExecutionContext) -> None:
@ -35,9 +35,9 @@ class HTTPAction(Action):
""" """
An Action for executing HTTP requests using aiohttp with shared session reuse. An Action for executing HTTP requests using aiohttp with shared session reuse.
This action integrates seamlessly into Falyx pipelines, with automatic session management, This action integrates seamlessly into Falyx pipelines, with automatic session
result injection, and lifecycle hook support. It is ideal for CLI-driven API workflows management, result injection, and lifecycle hook support. It is ideal for CLI-driven
where you need to call remote services and process their responses. API workflows where you need to call remote services and process their responses.
Features: Features:
- Uses aiohttp for asynchronous HTTP requests - Uses aiohttp for asynchronous HTTP requests
@ -97,7 +97,7 @@ class HTTPAction(Action):
retry_policy=retry_policy, retry_policy=retry_policy,
) )
async def _request(self, *args, **kwargs) -> dict[str, Any]: async def _request(self, *_, **__) -> dict[str, Any]:
if self.shared_context: if self.shared_context:
context: SharedContext = self.shared_context context: SharedContext = self.shared_context
session = context.get("http_session") session = context.get("http_session")
@ -153,6 +153,7 @@ class HTTPAction(Action):
def __str__(self): def __str__(self):
return ( return (
f"HTTPAction(name={self.name!r}, method={self.method!r}, url={self.url!r}, " f"HTTPAction(name={self.name!r}, method={self.method!r}, url={self.url!r}, "
f"headers={self.headers!r}, params={self.params!r}, json={self.json!r}, data={self.data!r}, " f"headers={self.headers!r}, params={self.params!r}, json={self.json!r}, "
f"retry={self.retry_policy.enabled}, inject_last_result={self.inject_last_result})" f"data={self.data!r}, retry={self.retry_policy.enabled}, "
f"inject_last_result={self.inject_last_result})"
) )

View File

@ -1,4 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""init.py"""
from pathlib import Path from pathlib import Path
from rich.console import Console from rich.console import Console

View File

@ -28,8 +28,8 @@ from falyx.context import ExecutionContext
from falyx.exceptions import FalyxError from falyx.exceptions import FalyxError
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType from falyx.hook_manager import HookManager, HookType
from falyx.logger import logger
from falyx.themes.colors import OneColors from falyx.themes.colors import OneColors
from falyx.utils import logger
class BaseIOAction(BaseAction): class BaseIOAction(BaseAction):
@ -78,7 +78,7 @@ class BaseIOAction(BaseAction):
def from_input(self, raw: str | bytes) -> Any: def from_input(self, raw: str | bytes) -> Any:
raise NotImplementedError raise NotImplementedError
def to_output(self, data: Any) -> str | bytes: def to_output(self, result: Any) -> str | bytes:
raise NotImplementedError raise NotImplementedError
async def _resolve_input(self, kwargs: dict[str, Any]) -> str | bytes: async def _resolve_input(self, kwargs: dict[str, Any]) -> str | bytes:
@ -113,7 +113,7 @@ class BaseIOAction(BaseAction):
try: try:
if self.mode == "stream": if self.mode == "stream":
line_gen = await self._read_stdin_stream() line_gen = await self._read_stdin_stream()
async for line in self._stream_lines(line_gen, args, kwargs): async for _ in self._stream_lines(line_gen, args, kwargs):
pass pass
result = getattr(self, "_last_result", None) result = getattr(self, "_last_result", None)
else: else:
@ -185,8 +185,9 @@ class ShellAction(BaseIOAction):
Designed for quick integration with shell tools like `grep`, `ping`, `jq`, etc. Designed for quick integration with shell tools like `grep`, `ping`, `jq`, etc.
Security Warning: Security Warning:
By default, ShellAction uses `shell=True`, which can be dangerous with unsanitized input. By default, ShellAction uses `shell=True`, which can be dangerous with
To mitigate this, set `safe_mode=True` to use `shell=False` with `shlex.split()`. unsanitized input. To mitigate this, set `safe_mode=True` to use `shell=False`
with `shlex.split()`.
Features: Features:
- Automatically handles input parsing (str/bytes) - Automatically handles input parsing (str/bytes)
@ -198,9 +199,11 @@ class ShellAction(BaseIOAction):
Args: Args:
name (str): Name of the action. name (str): Name of the action.
command_template (str): Shell command to execute. Must include `{}` to include input. command_template (str): Shell command to execute. Must include `{}` to include
If no placeholder is present, the input is not included. input. If no placeholder is present, the input is not
safe_mode (bool): If True, runs with `shell=False` using shlex parsing (default: False). included.
safe_mode (bool): If True, runs with `shell=False` using shlex parsing
(default: False).
""" """
def __init__( def __init__(
@ -222,9 +225,11 @@ class ShellAction(BaseIOAction):
command = self.command_template.format(parsed_input) command = self.command_template.format(parsed_input)
if self.safe_mode: if self.safe_mode:
args = shlex.split(command) args = shlex.split(command)
result = subprocess.run(args, capture_output=True, text=True) result = subprocess.run(args, capture_output=True, text=True, check=True)
else: else:
result = subprocess.run(command, shell=True, text=True, capture_output=True) result = subprocess.run(
command, shell=True, text=True, capture_output=True, check=True
)
if result.returncode != 0: if result.returncode != 0:
raise RuntimeError(result.stderr.strip()) raise RuntimeError(result.stderr.strip())
return result.stdout.strip() return result.stdout.strip()
@ -246,6 +251,6 @@ class ShellAction(BaseIOAction):
def __str__(self): def __str__(self):
return ( return (
f"ShellAction(name={self.name!r}, command_template={self.command_template!r}, " f"ShellAction(name={self.name!r}, command_template={self.command_template!r},"
f"safe_mode={self.safe_mode})" f" safe_mode={self.safe_mode})"
) )

5
falyx/logger.py Normal file
View File

@ -0,0 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""logger.py"""
import logging
logger = logging.getLogger("falyx")

View File

@ -12,15 +12,18 @@ from falyx.action import BaseAction
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType from falyx.hook_manager import HookType
from falyx.logger import logger
from falyx.selection import prompt_for_selection, render_table_base from falyx.selection import prompt_for_selection, render_table_base
from falyx.signal_action import SignalAction from falyx.signal_action import SignalAction
from falyx.signals import BackSignal, QuitSignal from falyx.signals import BackSignal, QuitSignal
from falyx.themes.colors import OneColors from falyx.themes.colors import OneColors
from falyx.utils import CaseInsensitiveDict, chunks, logger from falyx.utils import CaseInsensitiveDict, chunks
@dataclass @dataclass
class MenuOption: class MenuOption:
"""Represents a single menu option with a description and an action to execute."""
description: str description: str
action: BaseAction action: BaseAction
style: str = OneColors.WHITE style: str = OneColors.WHITE
@ -93,6 +96,8 @@ class MenuOptionMap(CaseInsensitiveDict):
class MenuAction(BaseAction): class MenuAction(BaseAction):
"""MenuAction class for creating single use menu actions."""
def __init__( def __init__(
self, self,
name: str, name: str,
@ -162,7 +167,8 @@ class MenuAction(BaseAction):
if self.never_prompt and not effective_default: if self.never_prompt and not effective_default:
raise ValueError( raise ValueError(
f"[{self.name}] 'never_prompt' is True but no valid default_selection was provided." f"[{self.name}] 'never_prompt' is True but no valid default_selection"
" was provided."
) )
context.start_timer() context.start_timer()

View File

@ -5,12 +5,14 @@ from argparse import Namespace
from collections import defaultdict from collections import defaultdict
from typing import Any, Callable from typing import Any, Callable
from falyx.utils import logger from falyx.logger import logger
class OptionsManager: class OptionsManager:
"""OptionsManager"""
def __init__(self, namespaces: list[tuple[str, Namespace]] | None = None) -> None: def __init__(self, namespaces: list[tuple[str, Namespace]] | None = None) -> None:
self.options: defaultdict = defaultdict(lambda: Namespace()) self.options: defaultdict = defaultdict(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)
@ -42,7 +44,9 @@ class OptionsManager:
f"Cannot toggle non-boolean option: '{option_name}' in '{namespace_name}'" f"Cannot toggle non-boolean option: '{option_name}' in '{namespace_name}'"
) )
self.set(option_name, not current, namespace_name=namespace_name) self.set(option_name, not current, namespace_name=namespace_name)
logger.debug(f"Toggled '{option_name}' in '{namespace_name}' to {not current}") logger.debug(
"Toggled '%s' in '%s' to %s", option_name, namespace_name, not current
)
def get_value_getter( def get_value_getter(
self, option_name: str, namespace_name: str = "cli_args" self, option_name: str, namespace_name: str = "cli_args"

View File

@ -39,7 +39,7 @@ def get_arg_parsers(
epilog: ( epilog: (
str | None str | None
) = "Tip: Use 'falyx run ?[COMMAND]' to preview any command from the CLI.", ) = "Tip: Use 'falyx run ?[COMMAND]' to preview any command from the CLI.",
parents: Sequence[ArgumentParser] = [], parents: Sequence[ArgumentParser] | None = None,
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,
@ -54,7 +54,7 @@ def get_arg_parsers(
usage=usage, usage=usage,
description=description, description=description,
epilog=epilog, epilog=epilog,
parents=parents, parents=parents if parents else [],
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,

View File

@ -1,5 +1,15 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""prompt_utils.py"""
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import (
AnyFormattedText,
FormattedText,
merge_formatted_text,
)
from falyx.options_manager import OptionsManager from falyx.options_manager import OptionsManager
from falyx.themes.colors import OneColors
from falyx.validators import yes_no_validator
def should_prompt_user( def should_prompt_user(
@ -8,7 +18,10 @@ def should_prompt_user(
options: OptionsManager, options: OptionsManager,
namespace: str = "cli_args", namespace: str = "cli_args",
): ):
"""Determine whether to prompt the user for confirmation based on command and global options.""" """
Determine whether to prompt the user for confirmation based on command
and global options.
"""
never_prompt = options.get("never_prompt", False, namespace) never_prompt = options.get("never_prompt", False, namespace)
force_confirm = options.get("force_confirm", False, namespace) force_confirm = options.get("force_confirm", False, namespace)
skip_confirm = options.get("skip_confirm", False, namespace) skip_confirm = options.get("skip_confirm", False, namespace)
@ -17,3 +30,19 @@ def should_prompt_user(
return False return False
return confirm or force_confirm return confirm or force_confirm
async def confirm_async(
message: AnyFormattedText = "Are you sure?",
prefix: AnyFormattedText = FormattedText([(OneColors.CYAN, "")]),
suffix: AnyFormattedText = FormattedText([(OneColors.LIGHT_YELLOW_b, " [Y/n] > ")]),
session: PromptSession | None = None,
) -> bool:
"""Prompt the user with a yes/no async confirmation and return True for 'Y'."""
session = session or PromptSession()
merged_message: AnyFormattedText = merge_formatted_text([prefix, message, suffix])
answer = await session.prompt_async(
merged_message,
validator=yes_no_validator(),
)
return answer.upper() == "Y"

View File

@ -1,4 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""protocols.py"""
from __future__ import annotations from __future__ import annotations
from typing import Any, Protocol from typing import Any, Protocol

View File

@ -8,10 +8,12 @@ import random
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.utils import logger from falyx.logger import logger
class RetryPolicy(BaseModel): class RetryPolicy(BaseModel):
"""RetryPolicy"""
max_retries: int = Field(default=3, ge=0) max_retries: int = Field(default=3, ge=0)
delay: float = Field(default=1.0, ge=0.0) delay: float = Field(default=1.0, ge=0.0)
backoff: float = Field(default=2.0, ge=1.0) backoff: float = Field(default=2.0, ge=1.0)
@ -34,6 +36,8 @@ class RetryPolicy(BaseModel):
class RetryHandler: class RetryHandler:
"""RetryHandler class to manage retry policies for actions."""
def __init__(self, policy: RetryPolicy = RetryPolicy()): def __init__(self, policy: RetryPolicy = RetryPolicy()):
self.policy = policy self.policy = policy
@ -49,7 +53,7 @@ class RetryHandler:
self.policy.delay = delay self.policy.delay = delay
self.policy.backoff = backoff self.policy.backoff = backoff
self.policy.jitter = jitter self.policy.jitter = jitter
logger.info(f"🔄 Retry policy enabled: {self.policy}") logger.info("🔄 Retry policy enabled: %s", self.policy)
async def retry_on_error(self, context: ExecutionContext) -> None: async def retry_on_error(self, context: ExecutionContext) -> None:
from falyx.action import Action from falyx.action import Action
@ -63,21 +67,21 @@ class RetryHandler:
last_error = error last_error = error
if not target: if not target:
logger.warning(f"[{name}] ⚠️ No action target. Cannot retry.") logger.warning("[%s] ⚠️ No action target. Cannot retry.", name)
return None 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." "[%s] ❌ RetryHandler only supports only supports Action objects.", name
) )
return None return None
if not getattr(target, "is_retryable", False): if not getattr(target, "is_retryable", False):
logger.warning(f"[{name}] ❌ Not retryable.") logger.warning("[%s] ❌ Not retryable.", name)
return None return None
if not self.policy.enabled: if not self.policy.enabled:
logger.warning(f"[{name}] ❌ Retry policy is disabled.") logger.warning("[%s] ❌ Retry policy is disabled.", name)
return None return None
while retries_done < self.policy.max_retries: while retries_done < self.policy.max_retries:
@ -88,23 +92,30 @@ class RetryHandler:
sleep_delay += random.uniform(-self.policy.jitter, self.policy.jitter) sleep_delay += random.uniform(-self.policy.jitter, self.policy.jitter)
logger.info( logger.info(
f"[{name}] 🔄 Retrying ({retries_done}/{self.policy.max_retries}) " "[%s] 🔄 Retrying (%s/%s) in %ss due to '%s'...",
f"in {current_delay}s due to '{last_error}'..." name,
retries_done,
self.policy.max_retries,
current_delay,
last_error,
) )
await asyncio.sleep(current_delay) await asyncio.sleep(current_delay)
try: try:
result = await target.action(*context.args, **context.kwargs) result = await target.action(*context.args, **context.kwargs)
context.result = result context.result = result
context.exception = None context.exception = None
logger.info(f"[{name}] ✅ Retry succeeded on attempt {retries_done}.") logger.info("[%s] ✅ Retry succeeded on attempt %s.", name, retries_done)
return None 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
logger.warning( logger.warning(
f"[{name}] ⚠️ Retry attempt {retries_done}/{self.policy.max_retries} " "[%s] ⚠️ Retry attempt %s/%s failed due to '%s'.",
f"failed due to '{retry_error}'." name,
retries_done,
self.policy.max_retries,
retry_error,
) )
context.exception = last_error context.exception = last_error
logger.error(f"[{name}] ❌ All {self.policy.max_retries} retries failed.") logger.error("[%s] ❌ All %s retries failed.", name, self.policy.max_retries)

View File

@ -1,4 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""retry_utils.py"""
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

View File

@ -1,4 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""select_file_action.py"""
from __future__ import annotations from __future__ import annotations
import csv import csv
@ -18,16 +19,18 @@ from falyx.action import BaseAction
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType from falyx.hook_manager import HookType
from falyx.logger import logger
from falyx.selection import ( from falyx.selection import (
SelectionOption, SelectionOption,
prompt_for_selection, prompt_for_selection,
render_selection_dict_table, render_selection_dict_table,
) )
from falyx.themes.colors import OneColors from falyx.themes.colors import OneColors
from falyx.utils import logger
class FileReturnType(Enum): class FileReturnType(Enum):
"""Enum for file return types."""
TEXT = "text" TEXT = "text"
PATH = "path" PATH = "path"
JSON = "json" JSON = "json"

View File

@ -16,6 +16,8 @@ from falyx.validators import int_range_validator, key_validator
@dataclass @dataclass
class SelectionOption: class SelectionOption:
"""Represents a single selection option with a description and a value."""
description: str description: str
value: Any value: Any
style: str = OneColors.WHITE style: str = OneColors.WHITE
@ -26,7 +28,8 @@ class SelectionOption:
def render(self, key: str) -> str: def render(self, key: str) -> str:
"""Render the selection option for display.""" """Render the selection option for display."""
return f"[{OneColors.WHITE}][{key}][/] [{self.style}]{self.description}[/]" key = escape(f"[{key}]")
return f"[{OneColors.WHITE}]{key}[/] [{self.style}]{self.description}[/]"
def render_table_base( def render_table_base(
@ -194,7 +197,8 @@ def render_selection_dict_table(
row = [] row = []
for key, option in chunk: for key, option in chunk:
row.append( row.append(
f"[{OneColors.WHITE}][{key.upper()}] [{option.style}]{option.description}[/]" f"[{OneColors.WHITE}][{key.upper()}] "
f"[{option.style}]{option.description}[/]"
) )
table.add_row(*row) table.add_row(*row)

View File

@ -10,6 +10,7 @@ from falyx.action import BaseAction
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType from falyx.hook_manager import HookType
from falyx.logger import logger
from falyx.selection import ( from falyx.selection import (
SelectionOption, SelectionOption,
prompt_for_index, prompt_for_index,
@ -18,10 +19,18 @@ from falyx.selection import (
render_selection_indexed_table, render_selection_indexed_table,
) )
from falyx.themes.colors import OneColors from falyx.themes.colors import OneColors
from falyx.utils import CaseInsensitiveDict, logger from falyx.utils import CaseInsensitiveDict
class SelectionAction(BaseAction): class SelectionAction(BaseAction):
"""
A selection action that prompts the user to select an option from a list or
dictionary. The selected option is then returned as the result of the action.
If return_key is True, the key of the selected option is returned instead of
the value.
"""
def __init__( def __init__(
self, self,
name: str, name: str,
@ -45,7 +54,8 @@ class SelectionAction(BaseAction):
inject_into=inject_into, inject_into=inject_into,
never_prompt=never_prompt, never_prompt=never_prompt,
) )
self.selections: list[str] | CaseInsensitiveDict = selections # Setter normalizes to correct type, mypy can't infer that
self.selections: list[str] | CaseInsensitiveDict = selections # type: ignore[assignment]
self.return_key = return_key self.return_key = return_key
self.title = title self.title = title
self.columns = columns self.columns = columns
@ -71,7 +81,8 @@ class SelectionAction(BaseAction):
self._selections = cid self._selections = cid
else: else:
raise TypeError( raise TypeError(
f"'selections' must be a list[str] or dict[str, SelectionOption], got {type(value).__name__}" "'selections' must be a list[str] or dict[str, SelectionOption], "
f"got {type(value).__name__}"
) )
async def _run(self, *args, **kwargs) -> Any: async def _run(self, *args, **kwargs) -> Any:
@ -108,7 +119,8 @@ class SelectionAction(BaseAction):
if self.never_prompt and not effective_default: if self.never_prompt and not effective_default:
raise ValueError( raise ValueError(
f"[{self.name}] 'never_prompt' is True but no valid default_selection was provided." f"[{self.name}] 'never_prompt' is True but no valid default_selection "
"was provided."
) )
context.start_timer() context.start_timer()
@ -152,7 +164,8 @@ class SelectionAction(BaseAction):
result = key if self.return_key else self.selections[key].value result = key if self.return_key else self.selections[key].value
else: else:
raise TypeError( raise TypeError(
f"'selections' must be a list[str] or dict[str, tuple[str, Any]], got {type(self.selections).__name__}" "'selections' must be a list[str] or dict[str, tuple[str, Any]], "
f"got {type(self.selections).__name__}"
) )
context.result = result context.result = result
await self.hooks.trigger(HookType.ON_SUCCESS, context) await self.hooks.trigger(HookType.ON_SUCCESS, context)
@ -205,5 +218,6 @@ class SelectionAction(BaseAction):
return ( return (
f"SelectionAction(name={self.name!r}, type={selection_type}, " f"SelectionAction(name={self.name!r}, type={selection_type}, "
f"default_selection={self.default_selection!r}, " f"default_selection={self.default_selection!r}, "
f"return_key={self.return_key}, prompt={'off' if self.never_prompt else 'on'})" f"return_key={self.return_key}, "
f"prompt={'off' if self.never_prompt else 'on'})"
) )

View File

@ -1,4 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""signal_action.py"""
from falyx.action import Action from falyx.action import Action
from falyx.signals import FlowSignal from falyx.signals import FlowSignal

View File

@ -1,4 +1,7 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""signals.py"""
class FlowSignal(BaseException): class FlowSignal(BaseException):
"""Base class for all flow control signals in Falyx. """Base class for all flow control signals in Falyx.

View File

@ -1,4 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""tagged_table.py"""
from collections import defaultdict from collections import defaultdict
from rich import box from rich import box
@ -10,7 +11,7 @@ from falyx.falyx import Falyx
def build_tagged_table(flx: Falyx) -> Table: def build_tagged_table(flx: Falyx) -> Table:
"""Custom table builder that groups commands by tags.""" """Custom table builder that groups commands by tags."""
table = Table(title=flx.title, show_header=False, box=box.SIMPLE) table = Table(title=flx.title, show_header=False, box=box.SIMPLE) # type: ignore[arg-type]
# Group commands by first tag # Group commands by first tag
grouped: dict[str, list[Command]] = defaultdict(list) grouped: dict[str, list[Command]] = defaultdict(list)

View File

@ -1,5 +1,7 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""utils.py""" """utils.py"""
from __future__ import annotations
import functools import functools
import inspect import inspect
import logging import logging
@ -10,23 +12,12 @@ from itertools import islice
from typing import Any, Awaitable, Callable, TypeVar from typing import Any, Awaitable, Callable, TypeVar
import pythonjsonlogger.json import pythonjsonlogger.json
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import (
AnyFormattedText,
FormattedText,
merge_formatted_text,
)
from rich.logging import RichHandler from rich.logging import RichHandler
from falyx.themes.colors import OneColors
from falyx.validators import yes_no_validator
logger = logging.getLogger("falyx")
T = TypeVar("T") T = TypeVar("T")
async def _noop(*args, **kwargs): async def _noop(*_, **__):
pass pass
@ -70,22 +61,6 @@ def chunks(iterator, size):
yield chunk yield chunk
async def confirm_async(
message: AnyFormattedText = "Are you sure?",
prefix: AnyFormattedText = FormattedText([(OneColors.CYAN, "")]),
suffix: AnyFormattedText = FormattedText([(OneColors.LIGHT_YELLOW_b, " [Y/n] > ")]),
session: PromptSession | None = None,
) -> bool:
"""Prompt the user with a yes/no async confirmation and return True for 'Y'."""
session = session or PromptSession()
merged_message: AnyFormattedText = merge_formatted_text([prefix, message, suffix])
answer = await session.prompt_async(
merged_message,
validator=yes_no_validator(),
)
return True if answer.upper() == "Y" else False
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."""
@ -114,12 +89,6 @@ class CaseInsensitiveDict(dict):
items.update({self._normalize_key(k): v for k, v in kwargs.items()}) items.update({self._normalize_key(k): v for k, v in kwargs.items()})
super().update(items) 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:
try: try:
@ -143,11 +112,13 @@ def setup_logging(
console_log_level: int = logging.WARNING, console_log_level: int = logging.WARNING,
): ):
""" """
Configure logging for Falyx with support for both CLI-friendly and structured JSON output. Configure logging for Falyx with support for both CLI-friendly and structured
JSON output.
This function sets up separate logging handlers for console and file output, with optional This function sets up separate logging handlers for console and file output,
support for JSON formatting. It also auto-detects whether the application is running inside with optional support for JSON formatting. It also auto-detects whether the
a container to default to machine-readable logs when appropriate. application is running inside a container to default to machine-readable logs
when appropriate.
Args: Args:
mode (str | None): mode (str | None):
@ -170,7 +141,8 @@ def setup_logging(
- Clears existing root handlers before setup. - Clears existing root handlers before setup.
- Configures console logging using either Rich (for CLI) or JSON formatting. - Configures console logging using either Rich (for CLI) or JSON formatting.
- Configures file logging in plain text or JSON based on `json_log_to_file`. - Configures file logging in plain text or JSON based on `json_log_to_file`.
- Automatically sets logging levels for noisy third-party modules (`urllib3`, `asyncio`). - Automatically sets logging levels for noisy third-party modules
(`urllib3`, `asyncio`, `markdown_it`).
- Propagates logs from the "falyx" logger to ensure centralized output. - Propagates logs from the "falyx" logger to ensure centralized output.
Raises: Raises:

View File

@ -1,4 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""validators.py"""
from typing import KeysView, Sequence from typing import KeysView, Sequence
from prompt_toolkit.validation import Validator from prompt_toolkit.validation import Validator
@ -7,10 +8,10 @@ from prompt_toolkit.validation import Validator
def int_range_validator(minimum: int, maximum: int) -> Validator: def int_range_validator(minimum: int, maximum: int) -> Validator:
"""Validator for integer ranges.""" """Validator for integer ranges."""
def validate(input: str) -> bool: def validate(text: str) -> bool:
try: try:
value = int(input) value = int(text)
if not (minimum <= value <= maximum): if not minimum <= value <= maximum:
return False return False
return True return True
except ValueError: except ValueError:
@ -25,8 +26,8 @@ def int_range_validator(minimum: int, maximum: int) -> Validator:
def key_validator(keys: Sequence[str] | KeysView[str]) -> Validator: def key_validator(keys: Sequence[str] | KeysView[str]) -> Validator:
"""Validator for key inputs.""" """Validator for key inputs."""
def validate(input: str) -> bool: def validate(text: str) -> bool:
if input.upper() not in [key.upper() for key in keys]: if text.upper() not in [key.upper() for key in keys]:
return False return False
return True return True
@ -38,8 +39,8 @@ def key_validator(keys: Sequence[str] | KeysView[str]) -> Validator:
def yes_no_validator() -> Validator: def yes_no_validator() -> Validator:
"""Validator for yes/no inputs.""" """Validator for yes/no inputs."""
def validate(input: str) -> bool: def validate(text: str) -> bool:
if input.upper() not in ["Y", "N"]: if text.upper() not in ["Y", "N"]:
return False return False
return True return True

View File

@ -1 +1 @@
__version__ = "0.1.23" __version__ = "0.1.24"

View File

@ -146,7 +146,10 @@ disable=abstract-method,
wrong-import-order, wrong-import-order,
xrange-builtin, xrange-builtin,
zip-builtin-not-iterating, zip-builtin-not-iterating,
broad-exception-caught broad-exception-caught,
too-many-positional-arguments,
inconsistent-quotes,
import-outside-toplevel
[REPORTS] [REPORTS]
@ -260,7 +263,7 @@ generated-members=
[FORMAT] [FORMAT]
# Maximum number of characters on a single line. # Maximum number of characters on a single line.
max-line-length=80 max-line-length=90
# TODO(https://github.com/PyCQA/pylint/issues/3352): Direct pylint to exempt # TODO(https://github.com/PyCQA/pylint/issues/3352): Direct pylint to exempt
# lines made too long by directives to pytype. # lines made too long by directives to pytype.

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "falyx" name = "falyx"
version = "0.1.23" version = "0.1.24"
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"
@ -17,7 +17,7 @@ toml = "^0.10"
pyyaml = "^6.0" pyyaml = "^6.0"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
pytest = "^7.0" pytest = "^8.3.5"
pytest-asyncio = "^0.20" pytest-asyncio = "^0.20"
ruff = "^0.3" ruff = "^0.3"
toml = "^0.10" toml = "^0.10"
@ -36,7 +36,7 @@ build-backend = "poetry.core.masonry.api"
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
asyncio_mode = "auto" asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function" #asyncio_default_fixture_loop_scope = "function"
[tool.pylint."MESSAGES CONTROL"] [tool.pylint."MESSAGES CONTROL"]
disable = ["broad-exception-caught"] disable = ["broad-exception-caught"]

View File

@ -33,7 +33,7 @@ async def test_process_action_executes_correctly():
assert result == 5 assert result == 5
unpickleable = lambda x: x + 1 unpickleable = lambda x: x + 1 # noqa: E731
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@ -35,8 +35,8 @@ def test_bootstrap_no_config():
sys_path_before = list(sys.path) sys_path_before = list(sys.path)
bootstrap_path = bootstrap() bootstrap_path = bootstrap()
assert bootstrap_path is None assert bootstrap_path is None
sys.path = sys_path_before assert sys.path == sys_path_before
assert str(Path.cwd()) not in sys.path # assert str(Path.cwd()) not in sys.path
def test_bootstrap_with_global_config(): def test_bootstrap_with_global_config():

View File

@ -1,11 +1,7 @@
import asyncio
import pytest import pytest
from falyx.action import Action, ActionGroup, ChainedAction, FallbackAction from falyx.action import Action, ActionGroup, ChainedAction, FallbackAction
from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType
# --- Fixtures --- # --- Fixtures ---