Linting
This commit is contained in:
		| @@ -4,7 +4,8 @@ | ||||
| Core action system for Falyx. | ||||
|  | ||||
| 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: | ||||
|     result = action(*args, **kwargs) | ||||
| @@ -14,7 +15,8 @@ Core guarantees: | ||||
| - Consistent timing and execution context tracking for each run. | ||||
| - Unified, predictable result handling and error propagation. | ||||
| - 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: | ||||
| - 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.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import Hook, HookManager, HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.options_manager import OptionsManager | ||||
| from falyx.retry import RetryHandler, RetryPolicy | ||||
| from falyx.themes.colors import OneColors | ||||
| from falyx.utils import ensure_async, logger | ||||
| from falyx.utils import ensure_async | ||||
|  | ||||
|  | ||||
| class BaseAction(ABC): | ||||
| @@ -55,7 +58,8 @@ class BaseAction(ABC): | ||||
|     complex actions like `ChainedAction` or `ActionGroup`. They can also | ||||
|     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 | ||||
|                                  (default: 'last_result'). | ||||
|     _requires_injection (bool): Whether the action requires input injection. | ||||
| @@ -104,7 +108,9 @@ class BaseAction(ABC): | ||||
|         self.shared_context = shared_context | ||||
|  | ||||
|     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: | ||||
|             return self.options_manager.get(option_name, default) | ||||
|         return default | ||||
| @@ -288,8 +294,10 @@ class Action(BaseAction): | ||||
|  | ||||
|     def __str__(self): | ||||
|         return ( | ||||
|             f"Action(name={self.name!r}, action={getattr(self._action, '__name__', repr(self._action))}, " | ||||
|             f"args={self.args!r}, kwargs={self.kwargs!r}, retry={self.retry_policy.enabled})" | ||||
|             f"Action(name={self.name!r}, action=" | ||||
|             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): | ||||
|         self._value = value | ||||
|  | ||||
|         async def literal(*args, **kwargs): | ||||
|         async def literal(*_, **__): | ||||
|             return value | ||||
|  | ||||
|         super().__init__("Input", literal) | ||||
| @@ -333,14 +341,16 @@ class LiteralInputAction(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: | ||||
|     - 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. | ||||
|  | ||||
|     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: | ||||
|         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. | ||||
|     - 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: | ||||
|         name (str): Name of the chain. | ||||
|         actions (list): List of actions or literals to execute. | ||||
|         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. | ||||
|         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__( | ||||
| @@ -468,7 +481,7 @@ class ChainedAction(BaseAction, ActionListMixin): | ||||
|         if not self.actions: | ||||
|             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: | ||||
|             shared_context.add_result(self.shared_context.last_result()) | ||||
|         updated_kwargs = self._maybe_inject_last_result(kwargs) | ||||
| @@ -503,7 +516,8 @@ class ChainedAction(BaseAction, ActionListMixin): | ||||
|                         self.actions[index + 1], FallbackAction | ||||
|                     ): | ||||
|                         logger.warning( | ||||
|                             "[%s] ⚠️ Fallback triggered: %s, recovering with fallback '%s'.", | ||||
|                             "[%s] ⚠️ Fallback triggered: %s, recovering with fallback " | ||||
|                             "'%s'.", | ||||
|                             self.name, | ||||
|                             error, | ||||
|                             self.actions[index + 1].name, | ||||
| @@ -579,7 +593,8 @@ class ChainedAction(BaseAction, ActionListMixin): | ||||
|  | ||||
|     def __str__(self): | ||||
|         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})" | ||||
|         ) | ||||
|  | ||||
| @@ -613,7 +628,8 @@ class ActionGroup(BaseAction, ActionListMixin): | ||||
|         name (str): Name of the chain. | ||||
|         actions (list): List of actions or literals to execute. | ||||
|         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. | ||||
|     """ | ||||
|  | ||||
| @@ -643,7 +659,8 @@ class ActionGroup(BaseAction, ActionListMixin): | ||||
|             return Action(name=action.__name__, action=action) | ||||
|         else: | ||||
|             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: | ||||
| @@ -653,7 +670,7 @@ class ActionGroup(BaseAction, ActionListMixin): | ||||
|             action.register_teardown(self.hooks) | ||||
|  | ||||
|     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: | ||||
|             shared_context.set_shared_result(self.shared_context.last_result()) | ||||
|         updated_kwargs = self._maybe_inject_last_result(kwargs) | ||||
| @@ -831,6 +848,7 @@ class ProcessAction(BaseAction): | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         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})" | ||||
|         ) | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """action_factory.py""" | ||||
| from typing import Any | ||||
|  | ||||
| from rich.tree import Tree | ||||
| @@ -7,6 +8,7 @@ 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.logger import logger | ||||
| from falyx.protocols import ActionFactoryProtocol | ||||
| from falyx.themes.colors import OneColors | ||||
|  | ||||
| @@ -33,7 +35,7 @@ class ActionFactoryAction(BaseAction): | ||||
|         inject_last_result: bool = False, | ||||
|         inject_into: str = "last_result", | ||||
|         preview_args: tuple[Any, ...] = (), | ||||
|         preview_kwargs: dict[str, Any] = {}, | ||||
|         preview_kwargs: dict[str, Any] | None = None, | ||||
|     ): | ||||
|         super().__init__( | ||||
|             name=name, | ||||
| @@ -42,7 +44,7 @@ class ActionFactoryAction(BaseAction): | ||||
|         ) | ||||
|         self.factory = factory | ||||
|         self.preview_args = preview_args | ||||
|         self.preview_kwargs = preview_kwargs | ||||
|         self.preview_kwargs = preview_kwargs or {} | ||||
|  | ||||
|     async def _run(self, *args, **kwargs) -> Any: | ||||
|         updated_kwargs = self._maybe_inject_last_result(kwargs) | ||||
| @@ -58,10 +60,20 @@ class ActionFactoryAction(BaseAction): | ||||
|             generated_action = self.factory(*args, **updated_kwargs) | ||||
|             if not isinstance(generated_action, BaseAction): | ||||
|                 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: | ||||
|                 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: | ||||
|                 generated_action.set_options_manager(self.options_manager) | ||||
|             context.result = await generated_action(*args, **kwargs) | ||||
|   | ||||
| @@ -146,7 +146,7 @@ class BottomBar: | ||||
|         for k in (key.upper(), key.lower()): | ||||
|  | ||||
|             @self.key_bindings.add(k) | ||||
|             def _(event): | ||||
|             def _(_): | ||||
|                 toggle_state() | ||||
|  | ||||
|     def add_toggle_from_option( | ||||
| @@ -204,6 +204,6 @@ class BottomBar: | ||||
|         """Render the bottom bar.""" | ||||
|         lines = [] | ||||
|         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")) | ||||
|         return merge_formatted_text([fn() for fn in lines[:-1]]) | ||||
|   | ||||
| @@ -33,12 +33,13 @@ from falyx.exceptions import FalyxError | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import HookManager, HookType | ||||
| from falyx.io_action import BaseIOAction | ||||
| from falyx.logger import logger | ||||
| 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_utils import enable_retries_recursively | ||||
| 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") | ||||
|  | ||||
| @@ -134,7 +135,7 @@ class Command(BaseModel): | ||||
|             return ensure_async(action) | ||||
|         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.""" | ||||
|         if self.retry and isinstance(self.action, Action): | ||||
|             self.action.enable_retry() | ||||
| @@ -142,14 +143,16 @@ class Command(BaseModel): | ||||
|             self.action.set_retry_policy(self.retry_policy) | ||||
|         elif self.retry: | ||||
|             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): | ||||
|             self.retry_policy.enabled = True | ||||
|             enable_retries_recursively(self.action, self.retry_policy) | ||||
|         elif self.retry_all: | ||||
|             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): | ||||
| @@ -201,7 +204,7 @@ class Command(BaseModel): | ||||
|             if self.preview_before_confirm: | ||||
|                 await self.preview() | ||||
|             if not await confirm_async(self.confirmation_prompt): | ||||
|                 logger.info(f"[Command:{self.key}] ❌ Cancelled by user.") | ||||
|                 logger.info("[Command:%s] ❌ Cancelled by user.", self.key) | ||||
|                 raise FalyxError(f"[Command:{self.key}] Cancelled by confirmation.") | ||||
|  | ||||
|         context.start_timer() | ||||
| @@ -288,7 +291,7 @@ class Command(BaseModel): | ||||
|             if self.help_text: | ||||
|                 console.print(f"[dim]💡 {self.help_text}[/dim]") | ||||
|             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: | ||||
|   | ||||
| @@ -16,9 +16,9 @@ from rich.console import Console | ||||
| from falyx.action import Action, BaseAction | ||||
| from falyx.command import Command | ||||
| from falyx.falyx import Falyx | ||||
| from falyx.logger import logger | ||||
| from falyx.retry import RetryPolicy | ||||
| from falyx.themes.colors import OneColors | ||||
| from falyx.utils import logger | ||||
|  | ||||
| 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) | ||||
|         console.print( | ||||
|             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) | ||||
|     try: | ||||
| @@ -57,13 +58,16 @@ def import_action(dotted_path: str) -> Any: | ||||
|             "Module '%s' does not have attribute '%s': %s", module_path, attr, error | ||||
|         ) | ||||
|         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) | ||||
|     return action | ||||
|  | ||||
|  | ||||
| class RawCommand(BaseModel): | ||||
|     """Raw command model for Falyx CLI configuration.""" | ||||
|  | ||||
|     key: str | ||||
|     description: str | ||||
|     action: str | ||||
| @@ -72,7 +76,7 @@ class RawCommand(BaseModel): | ||||
|     kwargs: dict[str, Any] = {} | ||||
|     aliases: list[str] = [] | ||||
|     tags: list[str] = [] | ||||
|     style: str = "white" | ||||
|     style: str = OneColors.WHITE | ||||
|  | ||||
|     confirm: bool = False | ||||
|     confirm_message: str = "Are you sure?" | ||||
| @@ -81,7 +85,7 @@ class RawCommand(BaseModel): | ||||
|     spinner: bool = False | ||||
|     spinner_message: str = "Processing..." | ||||
|     spinner_type: str = "dots" | ||||
|     spinner_style: str = "cyan" | ||||
|     spinner_style: str = OneColors.CYAN | ||||
|     spinner_kwargs: dict[str, Any] = {} | ||||
|  | ||||
|     before_hooks: list[Callable] = [] | ||||
| @@ -126,6 +130,8 @@ def convert_commands(raw_commands: list[dict[str, Any]]) -> list[Command]: | ||||
|  | ||||
|  | ||||
| class FalyxConfig(BaseModel): | ||||
|     """Falyx CLI configuration model.""" | ||||
|  | ||||
|     title: str = "Falyx CLI" | ||||
|     prompt: str | list[tuple[str, str]] | list[list[str]] = [ | ||||
|         (OneColors.BLUE_b, "FALYX > ") | ||||
| @@ -148,7 +154,7 @@ class FalyxConfig(BaseModel): | ||||
|     def to_falyx(self) -> Falyx: | ||||
|         flx = Falyx( | ||||
|             title=self.title, | ||||
|             prompt=self.prompt, | ||||
|             prompt=self.prompt,  # type: ignore[arg-type] | ||||
|             columns=self.columns, | ||||
|             welcome_message=self.welcome_message, | ||||
|             exit_message=self.exit_message, | ||||
| @@ -159,7 +165,9 @@ class FalyxConfig(BaseModel): | ||||
|  | ||||
| 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: | ||||
|     - key: a unique single-character key | ||||
|   | ||||
| @@ -29,10 +29,10 @@ 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. | ||||
|     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. | ||||
| @@ -47,7 +47,8 @@ class ExecutionContext(BaseModel): | ||||
|         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. | ||||
|         shared_context (SharedContext | None): Optional shared context when running in | ||||
|                                                a chain or group. | ||||
|  | ||||
|     Properties: | ||||
|         duration (float | None): The execution duration in seconds. | ||||
| @@ -95,7 +96,11 @@ class ExecutionContext(BaseModel): | ||||
|         self.end_wall = datetime.now() | ||||
|  | ||||
|     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 | ||||
|     def duration(self) -> float | None: | ||||
| @@ -190,8 +195,10 @@ class SharedContext(BaseModel): | ||||
|         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 | ||||
|         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: | ||||
| @@ -208,6 +215,7 @@ class SharedContext(BaseModel): | ||||
|     """ | ||||
|  | ||||
|     name: str | ||||
|     action: Any | ||||
|     results: list[Any] = Field(default_factory=list) | ||||
|     errors: list[tuple[int, Exception]] = Field(default_factory=list) | ||||
|     current_index: int = -1 | ||||
|   | ||||
| @@ -1,7 +1,8 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """debug.py""" | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.hook_manager import HookManager, HookType | ||||
| from falyx.utils import logger | ||||
| from falyx.logger import logger | ||||
|  | ||||
|  | ||||
| def log_before(context: ExecutionContext): | ||||
|   | ||||
| @@ -1,4 +1,7 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """exceptions.py""" | ||||
|  | ||||
|  | ||||
| class FalyxError(Exception): | ||||
|     """Custom exception for the Menu class.""" | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,32 @@ | ||||
| # 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 datetime import datetime | ||||
| from typing import Dict, List | ||||
| @@ -9,11 +36,40 @@ from rich.console import Console | ||||
| from rich.table import Table | ||||
|  | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.logger import logger | ||||
| from falyx.themes.colors import OneColors | ||||
| from falyx.utils import logger | ||||
|  | ||||
|  | ||||
| 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_all: List[ExecutionContext] = [] | ||||
|     _console = Console(color_system="auto") | ||||
| @@ -78,13 +134,3 @@ class ExecutionRegistry: | ||||
|             table.add_row(ctx.name, start, end, duration, status, result) | ||||
|  | ||||
|         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) | ||||
|   | ||||
							
								
								
									
										140
									
								
								falyx/falyx.py
									
									
									
									
									
								
							
							
						
						
									
										140
									
								
								falyx/falyx.py
									
									
									
									
									
								
							| @@ -51,12 +51,13 @@ from falyx.exceptions import ( | ||||
| ) | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import Hook, HookManager, HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.options_manager import OptionsManager | ||||
| from falyx.parsers import get_arg_parsers | ||||
| from falyx.retry import RetryPolicy | ||||
| from falyx.signals import BackSignal, QuitSignal | ||||
| from falyx.themes.colors import OneColors, get_nord_theme | ||||
| from falyx.utils import CaseInsensitiveDict, chunks, get_program_invocation, logger | ||||
| from falyx.utils import CaseInsensitiveDict, chunks, get_program_invocation | ||||
| from falyx.version import __version__ | ||||
|  | ||||
|  | ||||
| @@ -78,7 +79,8 @@ class Falyx: | ||||
|     Key Features: | ||||
|     - Interactive menu with Rich rendering and Prompt Toolkit input handling | ||||
|     - 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 | ||||
|     - Submenu nesting and action chaining | ||||
|     - History tracking, help generation, and run key execution modes | ||||
| @@ -99,12 +101,14 @@ class Falyx: | ||||
|         force_confirm (bool): Seed default for `OptionsManager["force_confirm"]` | ||||
|         cli_args (Namespace | None): Parsed CLI arguments, usually from argparse. | ||||
|         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: | ||||
|         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. | ||||
|         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_commands(): Add multiple commands at once. | ||||
|         register_all_hooks(): Register hooks across all commands and submenus. | ||||
| @@ -184,8 +188,10 @@ class Falyx: | ||||
|  | ||||
|     @property | ||||
|     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] = {} | ||||
|  | ||||
| @@ -195,8 +201,11 @@ class Falyx: | ||||
|                 existing = mapping[norm] | ||||
|                 if existing is not cmd: | ||||
|                     logger.warning( | ||||
|                         f"[alias conflict] '{name}' already assigned to '{existing.description}'." | ||||
|                         f" Skipping for '{cmd.description}'." | ||||
|                         "[alias conflict] '%s' already assigned to '%s'. " | ||||
|                         "Skipping for '%s'.", | ||||
|                         name, | ||||
|                         existing.description, | ||||
|                         cmd.description, | ||||
|                     ) | ||||
|             else: | ||||
|                 mapping[norm] = cmd | ||||
| @@ -238,7 +247,7 @@ class Falyx: | ||||
|             key="Y", | ||||
|             description="History", | ||||
|             aliases=["HISTORY"], | ||||
|             action=er.get_history_action(), | ||||
|             action=Action(name="View Execution History", action=er.summary), | ||||
|             style=OneColors.DARK_YELLOW, | ||||
|         ) | ||||
|  | ||||
| @@ -283,7 +292,8 @@ class Falyx: | ||||
|         self.console.print(table, justify="center") | ||||
|         if self.mode == FalyxMode.MENU: | ||||
|             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", | ||||
|             ) | ||||
|  | ||||
| @@ -346,7 +356,7 @@ class Falyx: | ||||
|             is_preview, choice = self.get_command(text, from_validate=True) | ||||
|             if is_preview and choice is None: | ||||
|                 return True | ||||
|             return True if choice else False | ||||
|             return bool(choice) | ||||
|  | ||||
|         return Validator.from_callable( | ||||
|             validator, | ||||
| @@ -444,43 +454,10 @@ class Falyx: | ||||
|  | ||||
|     def debug_hooks(self) -> None: | ||||
|         """Logs the names of all hooks registered for the menu and its commands.""" | ||||
|  | ||||
|         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])}" | ||||
|         ) | ||||
|         logger.debug("Menu-level hooks:\n%s", str(self.hooks)) | ||||
|  | ||||
|         for key, command in self.commands.items(): | ||||
|             logger.debug( | ||||
|                 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])}" | ||||
|             ) | ||||
|             logger.debug("[Command '%s'] hooks:\n%s", key, str(command.hooks)) | ||||
|  | ||||
|     def is_key_available(self, key: str) -> bool: | ||||
|         key = key.upper() | ||||
| @@ -586,7 +563,7 @@ class Falyx: | ||||
|         action: BaseAction | Callable[[], Any], | ||||
|         *, | ||||
|         args: tuple = (), | ||||
|         kwargs: dict[str, Any] = {}, | ||||
|         kwargs: dict[str, Any] | None = None, | ||||
|         hidden: bool = False, | ||||
|         aliases: list[str] | None = None, | ||||
|         help_text: str = "", | ||||
| @@ -619,7 +596,7 @@ class Falyx: | ||||
|             description=description, | ||||
|             action=action, | ||||
|             args=args, | ||||
|             kwargs=kwargs, | ||||
|             kwargs=kwargs if kwargs else {}, | ||||
|             hidden=hidden, | ||||
|             aliases=aliases if aliases else [], | ||||
|             help_text=help_text, | ||||
| @@ -665,20 +642,26 @@ class Falyx: | ||||
|         bottom_row = [] | ||||
|         if self.history_command: | ||||
|             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: | ||||
|             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( | ||||
|             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 | ||||
|  | ||||
|     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] | ||||
|         for chunk in chunks(visible_commands, self.columns): | ||||
|             row = [] | ||||
| @@ -708,7 +691,10 @@ class Falyx: | ||||
|     def get_command( | ||||
|         self, choice: str, from_validate=False | ||||
|     ) -> 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) | ||||
|         if is_preview and not choice and self.help_command: | ||||
|             is_preview = False | ||||
| @@ -716,7 +702,7 @@ class Falyx: | ||||
|         elif is_preview and not choice: | ||||
|             if not from_validate: | ||||
|                 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 | ||||
|  | ||||
| @@ -734,7 +720,8 @@ class Falyx: | ||||
|         if fuzzy_matches: | ||||
|             if not from_validate: | ||||
|                 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: | ||||
|                 cmd = name_map[match] | ||||
| @@ -759,7 +746,7 @@ class Falyx: | ||||
|         self, selected_command: Command, error: Exception | ||||
|     ) -> None: | ||||
|         """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( | ||||
|             f"[{OneColors.DARK_RED}]An error occurred while executing " | ||||
|             f"{selected_command.description}:[/] {error}" | ||||
| @@ -770,27 +757,27 @@ class Falyx: | ||||
|         choice = await self.prompt_session.prompt_async() | ||||
|         is_preview, selected_command = self.get_command(choice) | ||||
|         if not selected_command: | ||||
|             logger.info(f"Invalid command '{choice}'.") | ||||
|             logger.info("Invalid command '%s'.", choice) | ||||
|             return True | ||||
|  | ||||
|         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() | ||||
|             return True | ||||
|  | ||||
|         if selected_command.requires_input: | ||||
|             program = get_program_invocation() | ||||
|             self.console.print( | ||||
|                 f"[{OneColors.LIGHT_YELLOW}]⚠️ Command '{selected_command.key}' requires input " | ||||
|                 f"and must be run via [{OneColors.MAGENTA}]'{program} run'[{OneColors.LIGHT_YELLOW}] " | ||||
|                 "with proper piping or arguments.[/]" | ||||
|                 f"[{OneColors.LIGHT_YELLOW}]⚠️ Command '{selected_command.key}' requires" | ||||
|                 f" input and must be run via [{OneColors.MAGENTA}]'{program} run" | ||||
|                 f"'[{OneColors.LIGHT_YELLOW}] with proper piping or arguments.[/]" | ||||
|             ) | ||||
|             return True | ||||
|  | ||||
|         self.last_run_command = selected_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 | ||||
|  | ||||
|         context = self._create_context(selected_command) | ||||
| @@ -821,7 +808,7 @@ class Falyx: | ||||
|             return None | ||||
|  | ||||
|         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() | ||||
|             return None | ||||
|  | ||||
| @@ -840,13 +827,13 @@ class Falyx: | ||||
|  | ||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||
|             logger.info("[run_key] ✅ '%s' complete.", selected_command.description) | ||||
|         except (KeyboardInterrupt, EOFError): | ||||
|         except (KeyboardInterrupt, EOFError) as error: | ||||
|             logger.warning( | ||||
|                 "[run_key] ⚠️ Interrupted by user: ", selected_command.description | ||||
|                 "[run_key] ⚠️ Interrupted by user: %s", selected_command.description | ||||
|             ) | ||||
|             raise FalyxError( | ||||
|                 f"[run_key] ⚠️ '{selected_command.description}' interrupted by user." | ||||
|             ) | ||||
|             ) from error | ||||
|         except Exception as error: | ||||
|             context.exception = error | ||||
|             await self.hooks.trigger(HookType.ON_ERROR, context) | ||||
| @@ -885,7 +872,8 @@ class Falyx: | ||||
|                 selected_command.action.set_retry_policy(selected_command.retry_policy) | ||||
|             else: | ||||
|                 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: | ||||
| @@ -904,7 +892,7 @@ class Falyx: | ||||
|  | ||||
|     async def menu(self) -> None: | ||||
|         """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() | ||||
|         if self.welcome_message: | ||||
|             self.print_message(self.welcome_message) | ||||
| @@ -928,7 +916,7 @@ class Falyx: | ||||
|                 except BackSignal: | ||||
|                     logger.info("BackSignal received.") | ||||
|         finally: | ||||
|             logger.info(f"Exiting menu: {self.get_title()}") | ||||
|             logger.info("Exiting menu: %s", self.get_title()) | ||||
|             if self.exit_message: | ||||
|                 self.print_message(self.exit_message) | ||||
|  | ||||
| @@ -964,7 +952,7 @@ class Falyx: | ||||
|             _, command = self.get_command(self.cli_args.name) | ||||
|             if not command: | ||||
|                 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) | ||||
|             self.console.print( | ||||
| @@ -979,7 +967,7 @@ class Falyx: | ||||
|             if is_preview: | ||||
|                 if command is None: | ||||
|                     sys.exit(1) | ||||
|                 logger.info(f"Preview command '{command.key}' selected.") | ||||
|                 logger.info("Preview command '%s' selected.", command.key) | ||||
|                 await command.preview() | ||||
|                 sys.exit(0) | ||||
|             if not command: | ||||
| @@ -1004,12 +992,14 @@ class Falyx: | ||||
|             ] | ||||
|             if not matching: | ||||
|                 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) | ||||
|  | ||||
|             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: | ||||
|                 self._set_retry_policy(cmd) | ||||
|   | ||||
| @@ -7,7 +7,7 @@ from enum import Enum | ||||
| from typing import Awaitable, Callable, Dict, List, Optional, Union | ||||
|  | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.utils import logger | ||||
| from falyx.logger import logger | ||||
|  | ||||
| Hook = Union[ | ||||
|     Callable[[ExecutionContext], None], Callable[[ExecutionContext], Awaitable[None]] | ||||
| @@ -34,6 +34,8 @@ class HookType(Enum): | ||||
|  | ||||
|  | ||||
| class HookManager: | ||||
|     """HookManager""" | ||||
|  | ||||
|     def __init__(self) -> None: | ||||
|         self._hooks: Dict[HookType, List[Hook]] = { | ||||
|             hook_type: [] for hook_type in HookType | ||||
| @@ -62,8 +64,11 @@ class HookManager: | ||||
|                     hook(context) | ||||
|             except Exception as hook_error: | ||||
|                 logger.warning( | ||||
|                     f"⚠️ Hook '{hook.__name__}' raised an exception during '{hook_type}'" | ||||
|                     f" for '{context.name}': {hook_error}" | ||||
|                     "⚠️ Hook '%s' raised an exception during '%s' for '%s': %s", | ||||
|                     hook.__name__, | ||||
|                     hook_type, | ||||
|                     context.name, | ||||
|                     hook_error, | ||||
|                 ) | ||||
|  | ||||
|                 if hook_type == HookType.ON_ERROR: | ||||
| @@ -71,3 +76,15 @@ class HookManager: | ||||
|                         context.exception, Exception | ||||
|                     ), "Context exception should be set for ON_ERROR hook" | ||||
|                     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) | ||||
|   | ||||
| @@ -5,11 +5,13 @@ from typing import Any, Callable | ||||
|  | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.exceptions import CircuitBreakerOpen | ||||
| from falyx.logger import logger | ||||
| from falyx.themes.colors import OneColors | ||||
| from falyx.utils import logger | ||||
|  | ||||
|  | ||||
| class ResultReporter: | ||||
|     """Reports the success of an action.""" | ||||
|  | ||||
|     def __init__(self, formatter: Callable[[Any], str] | None = None): | ||||
|         """ | ||||
|         Optional result formatter. If not provided, uses repr(result). | ||||
| @@ -41,6 +43,8 @@ class ResultReporter: | ||||
|  | ||||
|  | ||||
| class CircuitBreaker: | ||||
|     """Circuit Breaker pattern to prevent repeated failures.""" | ||||
|  | ||||
|     def __init__(self, max_failures=3, reset_timeout=10): | ||||
|         self.max_failures = max_failures | ||||
|         self.reset_timeout = reset_timeout | ||||
| @@ -55,7 +59,7 @@ class CircuitBreaker: | ||||
|                     f"🔴 Circuit open for '{name}' until {time.ctime(self.open_until)}." | ||||
|                 ) | ||||
|             else: | ||||
|                 logger.info(f"🟢 Circuit closed again for '{name}'.") | ||||
|                 logger.info("🟢 Circuit closed again for '%s'.") | ||||
|                 self.failures = 0 | ||||
|                 self.open_until = None | ||||
|  | ||||
| @@ -63,15 +67,18 @@ class CircuitBreaker: | ||||
|         name = context.name | ||||
|         self.failures += 1 | ||||
|         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: | ||||
|             self.open_until = time.time() + self.reset_timeout | ||||
|             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 | ||||
|  | ||||
|     def is_open(self): | ||||
|   | ||||
| @@ -16,8 +16,8 @@ from rich.tree import Tree | ||||
| from falyx.action import Action | ||||
| from falyx.context import ExecutionContext, SharedContext | ||||
| from falyx.hook_manager import HookManager, HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.themes.colors import OneColors | ||||
| from falyx.utils import logger | ||||
|  | ||||
|  | ||||
| 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. | ||||
|  | ||||
|     This action integrates seamlessly into Falyx pipelines, with automatic session management, | ||||
|     result injection, and lifecycle hook support. It is ideal for CLI-driven API workflows | ||||
|     where you need to call remote services and process their responses. | ||||
|     This action integrates seamlessly into Falyx pipelines, with automatic session | ||||
|     management, result injection, and lifecycle hook support. It is ideal for CLI-driven | ||||
|     API workflows where you need to call remote services and process their responses. | ||||
|  | ||||
|     Features: | ||||
|     - Uses aiohttp for asynchronous HTTP requests | ||||
| @@ -97,7 +97,7 @@ class HTTPAction(Action): | ||||
|             retry_policy=retry_policy, | ||||
|         ) | ||||
|  | ||||
|     async def _request(self, *args, **kwargs) -> dict[str, Any]: | ||||
|     async def _request(self, *_, **__) -> dict[str, Any]: | ||||
|         if self.shared_context: | ||||
|             context: SharedContext = self.shared_context | ||||
|             session = context.get("http_session") | ||||
| @@ -153,6 +153,7 @@ class HTTPAction(Action): | ||||
|     def __str__(self): | ||||
|         return ( | ||||
|             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"retry={self.retry_policy.enabled}, inject_last_result={self.inject_last_result})" | ||||
|             f"headers={self.headers!r}, params={self.params!r}, json={self.json!r}, " | ||||
|             f"data={self.data!r}, retry={self.retry_policy.enabled}, " | ||||
|             f"inject_last_result={self.inject_last_result})" | ||||
|         ) | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """init.py""" | ||||
| from pathlib import Path | ||||
|  | ||||
| from rich.console import Console | ||||
|   | ||||
| @@ -28,8 +28,8 @@ from falyx.context import ExecutionContext | ||||
| from falyx.exceptions import FalyxError | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import HookManager, HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.themes.colors import OneColors | ||||
| from falyx.utils import logger | ||||
|  | ||||
|  | ||||
| class BaseIOAction(BaseAction): | ||||
| @@ -78,7 +78,7 @@ class BaseIOAction(BaseAction): | ||||
|     def from_input(self, raw: str | bytes) -> Any: | ||||
|         raise NotImplementedError | ||||
|  | ||||
|     def to_output(self, data: Any) -> str | bytes: | ||||
|     def to_output(self, result: Any) -> str | bytes: | ||||
|         raise NotImplementedError | ||||
|  | ||||
|     async def _resolve_input(self, kwargs: dict[str, Any]) -> str | bytes: | ||||
| @@ -113,7 +113,7 @@ class BaseIOAction(BaseAction): | ||||
|         try: | ||||
|             if self.mode == "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 | ||||
|                 result = getattr(self, "_last_result", None) | ||||
|             else: | ||||
| @@ -185,8 +185,9 @@ class ShellAction(BaseIOAction): | ||||
|     Designed for quick integration with shell tools like `grep`, `ping`, `jq`, etc. | ||||
|  | ||||
|     ⚠️ Security Warning: | ||||
|     By default, ShellAction uses `shell=True`, which can be dangerous with unsanitized input. | ||||
|     To mitigate this, set `safe_mode=True` to use `shell=False` with `shlex.split()`. | ||||
|     By default, ShellAction uses `shell=True`, which can be dangerous with | ||||
|     unsanitized input. To mitigate this, set `safe_mode=True` to use `shell=False` | ||||
|     with `shlex.split()`. | ||||
|  | ||||
|     Features: | ||||
|     - Automatically handles input parsing (str/bytes) | ||||
| @@ -198,9 +199,11 @@ class ShellAction(BaseIOAction): | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action. | ||||
|         command_template (str): Shell command to execute. Must include `{}` to include input. | ||||
|                                 If no placeholder is present, the input is not included. | ||||
|         safe_mode (bool): If True, runs with `shell=False` using shlex parsing (default: False). | ||||
|         command_template (str): Shell command to execute. Must include `{}` to include | ||||
|                                 input. If no placeholder is present, the input is not | ||||
|                                 included. | ||||
|         safe_mode (bool): If True, runs with `shell=False` using shlex parsing | ||||
|                           (default: False). | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
| @@ -222,9 +225,11 @@ class ShellAction(BaseIOAction): | ||||
|         command = self.command_template.format(parsed_input) | ||||
|         if self.safe_mode: | ||||
|             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: | ||||
|             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: | ||||
|             raise RuntimeError(result.stderr.strip()) | ||||
|         return result.stdout.strip() | ||||
|   | ||||
							
								
								
									
										5
									
								
								falyx/logger.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								falyx/logger.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """logger.py""" | ||||
| import logging | ||||
|  | ||||
| logger = logging.getLogger("falyx") | ||||
| @@ -12,15 +12,18 @@ 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.logger import logger | ||||
| 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 | ||||
| from falyx.utils import CaseInsensitiveDict, chunks | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| class MenuOption: | ||||
|     """Represents a single menu option with a description and an action to execute.""" | ||||
|  | ||||
|     description: str | ||||
|     action: BaseAction | ||||
|     style: str = OneColors.WHITE | ||||
| @@ -93,6 +96,8 @@ class MenuOptionMap(CaseInsensitiveDict): | ||||
|  | ||||
|  | ||||
| class MenuAction(BaseAction): | ||||
|     """MenuAction class for creating single use menu actions.""" | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
| @@ -162,7 +167,8 @@ class MenuAction(BaseAction): | ||||
|  | ||||
|         if self.never_prompt and not effective_default: | ||||
|             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() | ||||
|   | ||||
| @@ -5,12 +5,14 @@ from argparse import Namespace | ||||
| from collections import defaultdict | ||||
| from typing import Any, Callable | ||||
|  | ||||
| from falyx.utils import logger | ||||
| from falyx.logger import logger | ||||
|  | ||||
|  | ||||
| class OptionsManager: | ||||
|     """OptionsManager""" | ||||
|  | ||||
|     def __init__(self, namespaces: list[tuple[str, Namespace]] | None = None) -> None: | ||||
|         self.options: defaultdict = defaultdict(lambda: Namespace()) | ||||
|         self.options: defaultdict = defaultdict(Namespace) | ||||
|         if namespaces: | ||||
|             for namespace_name, namespace in namespaces: | ||||
|                 self.from_namespace(namespace, namespace_name) | ||||
| @@ -42,7 +44,9 @@ class OptionsManager: | ||||
|                 f"Cannot toggle non-boolean option: '{option_name}' in '{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( | ||||
|         self, option_name: str, namespace_name: str = "cli_args" | ||||
|   | ||||
| @@ -39,7 +39,7 @@ def get_arg_parsers( | ||||
|     epilog: ( | ||||
|         str | None | ||||
|     ) = "Tip: Use 'falyx run ?[COMMAND]' to preview any command from the CLI.", | ||||
|     parents: Sequence[ArgumentParser] = [], | ||||
|     parents: Sequence[ArgumentParser] | None = None, | ||||
|     prefix_chars: str = "-", | ||||
|     fromfile_prefix_chars: str | None = None, | ||||
|     argument_default: Any = None, | ||||
| @@ -54,7 +54,7 @@ def get_arg_parsers( | ||||
|         usage=usage, | ||||
|         description=description, | ||||
|         epilog=epilog, | ||||
|         parents=parents, | ||||
|         parents=parents if parents else [], | ||||
|         prefix_chars=prefix_chars, | ||||
|         fromfile_prefix_chars=fromfile_prefix_chars, | ||||
|         argument_default=argument_default, | ||||
|   | ||||
| @@ -1,5 +1,15 @@ | ||||
| # 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.themes.colors import OneColors | ||||
| from falyx.validators import yes_no_validator | ||||
|  | ||||
|  | ||||
| def should_prompt_user( | ||||
| @@ -8,7 +18,10 @@ def should_prompt_user( | ||||
|     options: OptionsManager, | ||||
|     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) | ||||
|     force_confirm = options.get("force_confirm", False, namespace) | ||||
|     skip_confirm = options.get("skip_confirm", False, namespace) | ||||
| @@ -17,3 +30,19 @@ def should_prompt_user( | ||||
|         return False | ||||
|  | ||||
|     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" | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """protocols.py""" | ||||
| from __future__ import annotations | ||||
|  | ||||
| from typing import Any, Protocol | ||||
|   | ||||
| @@ -8,10 +8,12 @@ import random | ||||
| from pydantic import BaseModel, Field | ||||
|  | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.utils import logger | ||||
| from falyx.logger import logger | ||||
|  | ||||
|  | ||||
| class RetryPolicy(BaseModel): | ||||
|     """RetryPolicy""" | ||||
|  | ||||
|     max_retries: int = Field(default=3, ge=0) | ||||
|     delay: float = Field(default=1.0, ge=0.0) | ||||
|     backoff: float = Field(default=2.0, ge=1.0) | ||||
| @@ -34,6 +36,8 @@ class RetryPolicy(BaseModel): | ||||
|  | ||||
|  | ||||
| class RetryHandler: | ||||
|     """RetryHandler class to manage retry policies for actions.""" | ||||
|  | ||||
|     def __init__(self, policy: RetryPolicy = RetryPolicy()): | ||||
|         self.policy = policy | ||||
|  | ||||
| @@ -49,7 +53,7 @@ class RetryHandler: | ||||
|         self.policy.delay = delay | ||||
|         self.policy.backoff = backoff | ||||
|         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: | ||||
|         from falyx.action import Action | ||||
| @@ -63,21 +67,21 @@ class RetryHandler: | ||||
|         last_error = error | ||||
|  | ||||
|         if not target: | ||||
|             logger.warning(f"[{name}] ⚠️ No action target. Cannot retry.") | ||||
|             logger.warning("[%s] ⚠️ No action target. Cannot retry.", name) | ||||
|             return None | ||||
|  | ||||
|         if not isinstance(target, Action): | ||||
|             logger.warning( | ||||
|                 f"[{name}] ❌ RetryHandler only supports only supports Action objects." | ||||
|                 "[%s] ❌ RetryHandler only supports only supports Action objects.", name | ||||
|             ) | ||||
|             return None | ||||
|  | ||||
|         if not getattr(target, "is_retryable", False): | ||||
|             logger.warning(f"[{name}] ❌ Not retryable.") | ||||
|             logger.warning("[%s] ❌ Not retryable.", name) | ||||
|             return None | ||||
|  | ||||
|         if not self.policy.enabled: | ||||
|             logger.warning(f"[{name}] ❌ Retry policy is disabled.") | ||||
|             logger.warning("[%s] ❌ Retry policy is disabled.", name) | ||||
|             return None | ||||
|  | ||||
|         while retries_done < self.policy.max_retries: | ||||
| @@ -88,23 +92,30 @@ class RetryHandler: | ||||
|                 sleep_delay += random.uniform(-self.policy.jitter, self.policy.jitter) | ||||
|  | ||||
|             logger.info( | ||||
|                 f"[{name}] 🔄 Retrying ({retries_done}/{self.policy.max_retries}) " | ||||
|                 f"in {current_delay}s due to '{last_error}'..." | ||||
|                 "[%s] 🔄 Retrying (%s/%s) in %ss due to '%s'...", | ||||
|                 name, | ||||
|                 retries_done, | ||||
|                 self.policy.max_retries, | ||||
|                 current_delay, | ||||
|                 last_error, | ||||
|             ) | ||||
|             await asyncio.sleep(current_delay) | ||||
|             try: | ||||
|                 result = await target.action(*context.args, **context.kwargs) | ||||
|                 context.result = result | ||||
|                 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 | ||||
|             except Exception as retry_error: | ||||
|                 last_error = retry_error | ||||
|                 current_delay *= self.policy.backoff | ||||
|                 logger.warning( | ||||
|                     f"[{name}] ⚠️ Retry attempt {retries_done}/{self.policy.max_retries} " | ||||
|                     f"failed due to '{retry_error}'." | ||||
|                     "[%s] ⚠️ Retry attempt %s/%s failed due to '%s'.", | ||||
|                     name, | ||||
|                     retries_done, | ||||
|                     self.policy.max_retries, | ||||
|                     retry_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) | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """retry_utils.py""" | ||||
| from falyx.action import Action, BaseAction | ||||
| from falyx.hook_manager import HookType | ||||
| from falyx.retry import RetryHandler, RetryPolicy | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """select_file_action.py""" | ||||
| from __future__ import annotations | ||||
|  | ||||
| import csv | ||||
| @@ -18,16 +19,18 @@ 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.logger import logger | ||||
| from falyx.selection import ( | ||||
|     SelectionOption, | ||||
|     prompt_for_selection, | ||||
|     render_selection_dict_table, | ||||
| ) | ||||
| from falyx.themes.colors import OneColors | ||||
| from falyx.utils import logger | ||||
|  | ||||
|  | ||||
| class FileReturnType(Enum): | ||||
|     """Enum for file return types.""" | ||||
|  | ||||
|     TEXT = "text" | ||||
|     PATH = "path" | ||||
|     JSON = "json" | ||||
|   | ||||
| @@ -16,6 +16,8 @@ from falyx.validators import int_range_validator, key_validator | ||||
|  | ||||
| @dataclass | ||||
| class SelectionOption: | ||||
|     """Represents a single selection option with a description and a value.""" | ||||
|  | ||||
|     description: str | ||||
|     value: Any | ||||
|     style: str = OneColors.WHITE | ||||
| @@ -26,7 +28,8 @@ class SelectionOption: | ||||
|  | ||||
|     def render(self, key: str) -> str: | ||||
|         """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( | ||||
| @@ -194,7 +197,8 @@ def render_selection_dict_table( | ||||
|         row = [] | ||||
|         for key, option in chunk: | ||||
|             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) | ||||
|  | ||||
|   | ||||
| @@ -10,6 +10,7 @@ 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.logger import logger | ||||
| from falyx.selection import ( | ||||
|     SelectionOption, | ||||
|     prompt_for_index, | ||||
| @@ -18,10 +19,18 @@ from falyx.selection import ( | ||||
|     render_selection_indexed_table, | ||||
| ) | ||||
| from falyx.themes.colors import OneColors | ||||
| from falyx.utils import CaseInsensitiveDict, logger | ||||
| from falyx.utils import CaseInsensitiveDict | ||||
|  | ||||
|  | ||||
| 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__( | ||||
|         self, | ||||
|         name: str, | ||||
| @@ -45,7 +54,8 @@ class SelectionAction(BaseAction): | ||||
|             inject_into=inject_into, | ||||
|             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.title = title | ||||
|         self.columns = columns | ||||
| @@ -71,7 +81,8 @@ class SelectionAction(BaseAction): | ||||
|             self._selections = cid | ||||
|         else: | ||||
|             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: | ||||
| @@ -108,7 +119,8 @@ class SelectionAction(BaseAction): | ||||
|  | ||||
|         if self.never_prompt and not effective_default: | ||||
|             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() | ||||
| @@ -152,7 +164,8 @@ class SelectionAction(BaseAction): | ||||
|                 result = key if self.return_key else self.selections[key].value | ||||
|             else: | ||||
|                 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 | ||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||
| @@ -205,5 +218,6 @@ class SelectionAction(BaseAction): | ||||
|         return ( | ||||
|             f"SelectionAction(name={self.name!r}, type={selection_type}, " | ||||
|             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'})" | ||||
|         ) | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """signal_action.py""" | ||||
| from falyx.action import Action | ||||
| from falyx.signals import FlowSignal | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,7 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """signals.py""" | ||||
|  | ||||
|  | ||||
| class FlowSignal(BaseException): | ||||
|     """Base class for all flow control signals in Falyx. | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """tagged_table.py""" | ||||
| from collections import defaultdict | ||||
|  | ||||
| from rich import box | ||||
| @@ -10,7 +11,7 @@ from falyx.falyx import Falyx | ||||
|  | ||||
| def build_tagged_table(flx: Falyx) -> Table: | ||||
|     """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 | ||||
|     grouped: dict[str, list[Command]] = defaultdict(list) | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """utils.py""" | ||||
| from __future__ import annotations | ||||
|  | ||||
| import functools | ||||
| import inspect | ||||
| import logging | ||||
| @@ -10,23 +12,12 @@ from itertools import islice | ||||
| from typing import Any, Awaitable, Callable, TypeVar | ||||
|  | ||||
| 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 falyx.themes.colors import OneColors | ||||
| from falyx.validators import yes_no_validator | ||||
|  | ||||
| logger = logging.getLogger("falyx") | ||||
|  | ||||
| T = TypeVar("T") | ||||
|  | ||||
|  | ||||
| async def _noop(*args, **kwargs): | ||||
| async def _noop(*_, **__): | ||||
|     pass | ||||
|  | ||||
|  | ||||
| @@ -70,22 +61,6 @@ def chunks(iterator, size): | ||||
|         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): | ||||
|     """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()}) | ||||
|         super().update(items) | ||||
|  | ||||
|     def __iter__(self): | ||||
|         return super().__iter__() | ||||
|  | ||||
|     def keys(self): | ||||
|         return super().keys() | ||||
|  | ||||
|  | ||||
| def running_in_container() -> bool: | ||||
|     try: | ||||
| @@ -143,11 +112,13 @@ def setup_logging( | ||||
|     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 | ||||
|     support for JSON formatting. It also auto-detects whether the application is running inside | ||||
|     a container to default to machine-readable logs when appropriate. | ||||
|     This function sets up separate logging handlers for console and file output, | ||||
|     with optional support for JSON formatting. It also auto-detects whether the | ||||
|     application is running inside a container to default to machine-readable logs | ||||
|     when appropriate. | ||||
|  | ||||
|     Args: | ||||
|         mode (str | None): | ||||
| @@ -170,7 +141,8 @@ def setup_logging( | ||||
|         - Clears existing root handlers before setup. | ||||
|         - 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`. | ||||
|         - 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. | ||||
|  | ||||
|     Raises: | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """validators.py""" | ||||
| from typing import KeysView, Sequence | ||||
|  | ||||
| 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: | ||||
|     """Validator for integer ranges.""" | ||||
|  | ||||
|     def validate(input: str) -> bool: | ||||
|     def validate(text: str) -> bool: | ||||
|         try: | ||||
|             value = int(input) | ||||
|             if not (minimum <= value <= maximum): | ||||
|             value = int(text) | ||||
|             if not minimum <= value <= maximum: | ||||
|                 return False | ||||
|             return True | ||||
|         except ValueError: | ||||
| @@ -25,8 +26,8 @@ def int_range_validator(minimum: int, maximum: int) -> Validator: | ||||
| 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]: | ||||
|     def validate(text: str) -> bool: | ||||
|         if text.upper() not in [key.upper() for key in keys]: | ||||
|             return False | ||||
|         return True | ||||
|  | ||||
| @@ -38,8 +39,8 @@ def key_validator(keys: Sequence[str] | KeysView[str]) -> Validator: | ||||
| def yes_no_validator() -> Validator: | ||||
|     """Validator for yes/no inputs.""" | ||||
|  | ||||
|     def validate(input: str) -> bool: | ||||
|         if input.upper() not in ["Y", "N"]: | ||||
|     def validate(text: str) -> bool: | ||||
|         if text.upper() not in ["Y", "N"]: | ||||
|             return False | ||||
|         return True | ||||
|  | ||||
|   | ||||
| @@ -1 +1 @@ | ||||
| __version__ = "0.1.23" | ||||
| __version__ = "0.1.24" | ||||
|   | ||||
							
								
								
									
										7
									
								
								pylintrc
									
									
									
									
									
								
							
							
						
						
									
										7
									
								
								pylintrc
									
									
									
									
									
								
							| @@ -146,7 +146,10 @@ disable=abstract-method, | ||||
|         wrong-import-order, | ||||
|         xrange-builtin, | ||||
|         zip-builtin-not-iterating, | ||||
|         broad-exception-caught | ||||
|         broad-exception-caught, | ||||
|         too-many-positional-arguments, | ||||
|         inconsistent-quotes, | ||||
|         import-outside-toplevel | ||||
|  | ||||
|  | ||||
| [REPORTS] | ||||
| @@ -260,7 +263,7 @@ generated-members= | ||||
| [FORMAT] | ||||
|  | ||||
| # 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 | ||||
| # lines made too long by directives to pytype. | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| [tool.poetry] | ||||
| name = "falyx" | ||||
| version = "0.1.23" | ||||
| version = "0.1.24" | ||||
| description = "Reliable and introspectable async CLI action framework." | ||||
| authors = ["Roland Thomas Jr <roland@rtj.dev>"] | ||||
| license = "MIT" | ||||
| @@ -17,7 +17,7 @@ toml = "^0.10" | ||||
| pyyaml = "^6.0" | ||||
|  | ||||
| [tool.poetry.group.dev.dependencies] | ||||
| pytest = "^7.0" | ||||
| pytest = "^8.3.5" | ||||
| pytest-asyncio = "^0.20" | ||||
| ruff = "^0.3" | ||||
| toml = "^0.10" | ||||
| @@ -36,7 +36,7 @@ build-backend = "poetry.core.masonry.api" | ||||
| [tool.pytest.ini_options] | ||||
| testpaths = ["tests"] | ||||
| asyncio_mode = "auto" | ||||
| asyncio_default_fixture_loop_scope = "function" | ||||
| #asyncio_default_fixture_loop_scope = "function" | ||||
|  | ||||
| [tool.pylint."MESSAGES CONTROL"] | ||||
| disable = ["broad-exception-caught"] | ||||
|   | ||||
| @@ -33,7 +33,7 @@ async def test_process_action_executes_correctly(): | ||||
|         assert result == 5 | ||||
|  | ||||
|  | ||||
| unpickleable = lambda x: x + 1 | ||||
| unpickleable = lambda x: x + 1  # noqa: E731 | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
|   | ||||
| @@ -35,8 +35,8 @@ def test_bootstrap_no_config(): | ||||
|     sys_path_before = list(sys.path) | ||||
|     bootstrap_path = bootstrap() | ||||
|     assert bootstrap_path is None | ||||
|     sys.path = sys_path_before | ||||
|     assert str(Path.cwd()) not in sys.path | ||||
|     assert sys.path == sys_path_before | ||||
|     # assert str(Path.cwd()) not in sys.path | ||||
|  | ||||
|  | ||||
| def test_bootstrap_with_global_config(): | ||||
|   | ||||
| @@ -1,11 +1,7 @@ | ||||
| import asyncio | ||||
|  | ||||
| import pytest | ||||
|  | ||||
| from falyx.action import Action, ActionGroup, ChainedAction, FallbackAction | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import HookType | ||||
|  | ||||
| # --- Fixtures --- | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user