Linting
This commit is contained in:
		| @@ -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})" | ||||||
|         ) |         ) | ||||||
|   | |||||||
| @@ -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) | ||||||
|   | |||||||
| @@ -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]]) | ||||||
|   | |||||||
| @@ -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: | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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): | ||||||
|   | |||||||
| @@ -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.""" | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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) |  | ||||||
|   | |||||||
							
								
								
									
										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.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) | ||||||
|   | |||||||
| @@ -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) | ||||||
|   | |||||||
| @@ -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): | ||||||
|   | |||||||
| @@ -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})" | ||||||
|         ) |         ) | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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
									
								
							
							
						
						
									
										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.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() | ||||||
|   | |||||||
| @@ -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" | ||||||
|   | |||||||
| @@ -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, | ||||||
|   | |||||||
| @@ -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" | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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) | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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" | ||||||
|   | |||||||
| @@ -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) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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'})" | ||||||
|         ) |         ) | ||||||
|   | |||||||
| @@ -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 | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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. | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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) | ||||||
|   | |||||||
| @@ -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: | ||||||
|   | |||||||
| @@ -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 | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1 +1 @@ | |||||||
| __version__ = "0.1.23" | __version__ = "0.1.24" | ||||||
|   | |||||||
							
								
								
									
										7
									
								
								pylintrc
									
									
									
									
									
								
							
							
						
						
									
										7
									
								
								pylintrc
									
									
									
									
									
								
							| @@ -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. | ||||||
|   | |||||||
| @@ -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"] | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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(): | ||||||
|   | |||||||
| @@ -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 --- | ||||||
|  |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user