feat: Add module docs, Enum coercion, tracebacks, and toggle improvements
- Add comprehensive module docstrings across the codebase for better clarity and documentation. - Refactor Enum classes (e.g., FileType, ConfirmType) to use `_missing_` for built-in coercion from strings. - Add `encoding` attribute to `LoadFileAction`, `SaveFileAction`, and `SelectFileAction` for more flexible file handling. - Enable lazy file loading by default in `SelectFileAction` to improve performance. - Simplify bottom bar toggle behavior: all toggles now use `ctrl+<key>`, eliminating the need for key conflict checks with Falyx commands. - Add `ignore_in_history` attribute to `Command` to refine how `ExecutionRegistry` identifies the last valid result. - Improve History command output: now includes tracebacks when displaying exceptions.
This commit is contained in:
		| @@ -1,5 +1,38 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """action.py""" | ||||
| """ | ||||
| Defines `Action`, the core atomic unit in the Falyx CLI framework, used to wrap and | ||||
| execute a single callable or coroutine with structured lifecycle support. | ||||
|  | ||||
| An `Action` is the simplest building block in Falyx's execution model, enabling | ||||
| developers to turn ordinary Python functions into hookable, retryable, introspectable | ||||
| workflow steps. It supports synchronous or asynchronous callables, argument injection, | ||||
| rollback handlers, and retry policies. | ||||
|  | ||||
| Key Features: | ||||
| - Lifecycle hooks: `before`, `on_success`, `on_error`, `after`, `on_teardown` | ||||
| - Optional `last_result` injection for chained workflows | ||||
| - Retry logic via configurable `RetryPolicy` and `RetryHandler` | ||||
| - Rollback function support for recovery and undo behavior | ||||
| - Rich preview output for introspection and dry-run diagnostics | ||||
|  | ||||
| Usage Scenarios: | ||||
| - Wrapping business logic, utility functions, or external API calls | ||||
| - Converting lightweight callables into structured CLI actions | ||||
| - Composing workflows using `Action`, `ChainedAction`, or `ActionGroup` | ||||
|  | ||||
| Example: | ||||
|     def compute(x, y): | ||||
|         return x + y | ||||
|  | ||||
|     Action( | ||||
|         name="AddNumbers", | ||||
|         action=compute, | ||||
|         args=(2, 3), | ||||
|     ) | ||||
|  | ||||
| This module serves as the foundation for building robust, observable, | ||||
| and composable CLI automation flows in Falyx. | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from typing import Any, Awaitable, Callable | ||||
| @@ -27,11 +60,11 @@ class Action(BaseAction): | ||||
|     - Optional rollback handlers for undo logic. | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action. | ||||
|         name (str): Name of the action. Used for logging and debugging. | ||||
|         action (Callable): The function or coroutine to execute. | ||||
|         rollback (Callable, optional): Rollback function to undo the action. | ||||
|         args (tuple, optional): Static positional arguments. | ||||
|         kwargs (dict, optional): Static keyword arguments. | ||||
|         args (tuple, optional): Positional arguments. | ||||
|         kwargs (dict, optional): Keyword arguments. | ||||
|         hooks (HookManager, optional): Hook manager for lifecycle events. | ||||
|         inject_last_result (bool, optional): Enable last_result injection. | ||||
|         inject_into (str, optional): Name of injected key. | ||||
| @@ -157,6 +190,7 @@ class Action(BaseAction): | ||||
|         return ( | ||||
|             f"Action(name={self.name!r}, action=" | ||||
|             f"{getattr(self._action, '__name__', repr(self._action))}, " | ||||
|             f"args={self.args!r}, kwargs={self.kwargs!r}, " | ||||
|             f"retry={self.retry_policy.enabled}, " | ||||
|             f"rollback={self.rollback is not None})" | ||||
|         ) | ||||
|   | ||||
| @@ -1,5 +1,36 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """action_factory_action.py""" | ||||
| """ | ||||
| Defines `ActionFactory`, a dynamic Falyx Action that defers the construction of its | ||||
| underlying logic to runtime using a user-defined factory function. | ||||
|  | ||||
| This pattern is useful when the specific Action to execute cannot be determined until | ||||
| execution time—such as when branching on data, generating parameterized HTTP requests, | ||||
| or selecting configuration-aware flows. `ActionFactory` integrates seamlessly with the | ||||
| Falyx lifecycle system and supports hook propagation, teardown registration, and | ||||
| contextual previewing. | ||||
|  | ||||
| Key Features: | ||||
| - Accepts a factory function that returns a `BaseAction` instance | ||||
| - Supports injection of `last_result` and arbitrary args/kwargs | ||||
| - Integrates into chained or standalone workflows | ||||
| - Automatically previews generated action tree | ||||
| - Propagates shared context and teardown hooks to the returned action | ||||
|  | ||||
| Common Use Cases: | ||||
| - Conditional or data-driven action generation | ||||
| - Configurable workflows with dynamic behavior | ||||
| - Adapter for factory-style dependency injection in CLI flows | ||||
|  | ||||
| Example: | ||||
|     def generate_request_action(env): | ||||
|         return HTTPAction(f"GET /status/{env}", url=f"https://api/{env}/status") | ||||
|  | ||||
|     ActionFactory( | ||||
|         name="GetEnvStatus", | ||||
|         factory=generate_request_action, | ||||
|         inject_last_result=True, | ||||
|     ) | ||||
| """ | ||||
| from typing import Any, Callable | ||||
|  | ||||
| from rich.tree import Tree | ||||
| @@ -22,10 +53,14 @@ class ActionFactory(BaseAction): | ||||
|     where the structure of the next action depends on runtime values. | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action. | ||||
|         name (str): Name of the action. Used for logging and debugging. | ||||
|         factory (Callable): A function that returns a BaseAction given args/kwargs. | ||||
|         inject_last_result (bool): Whether to inject last_result into the factory. | ||||
|         inject_into (str): The name of the kwarg to inject last_result as. | ||||
|         args (tuple, optional): Positional arguments for the factory. | ||||
|         kwargs (dict, optional): Keyword arguments for the factory. | ||||
|         preview_args (tuple, optional): Positional arguments for the preview. | ||||
|         preview_kwargs (dict, optional): Keyword arguments for the preview. | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
| @@ -133,3 +168,11 @@ class ActionFactory(BaseAction): | ||||
|  | ||||
|         if not parent: | ||||
|             self.console.print(tree) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return ( | ||||
|             f"ActionFactory(name={self.name!r}, " | ||||
|             f"inject_last_result={self.inject_last_result}, " | ||||
|             f"factory={self._factory.__name__ if hasattr(self._factory, '__name__') else type(self._factory).__name__}, " | ||||
|             f"args={self.args!r}, kwargs={self.kwargs!r})" | ||||
|         ) | ||||
|   | ||||
| @@ -1,5 +1,39 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """action_group.py""" | ||||
| """ | ||||
| Defines `ActionGroup`, a Falyx Action that executes multiple sub-actions concurrently | ||||
| using asynchronous parallelism. | ||||
|  | ||||
| `ActionGroup` is designed for workflows where several independent actions can run | ||||
| simultaneously to improve responsiveness and reduce latency. It ensures robust error | ||||
| isolation, shared result tracking, and full lifecycle hook integration while preserving | ||||
| Falyx's introspectability and chaining capabilities. | ||||
|  | ||||
| Key Features: | ||||
| - Executes all actions in parallel via `asyncio.gather` | ||||
| - Aggregates results as a list of `(name, result)` tuples | ||||
| - Collects and reports multiple errors without interrupting execution | ||||
| - Compatible with `SharedContext`, `OptionsManager`, and `last_result` injection | ||||
| - Teardown-aware: propagates teardown registration across all child actions | ||||
| - Fully previewable via Rich tree rendering | ||||
|  | ||||
| Use Cases: | ||||
| - Batch execution of independent tasks (e.g., multiple file operations, API calls) | ||||
| - Concurrent report generation or validations | ||||
| - High-throughput CLI pipelines where latency is critical | ||||
|  | ||||
| Raises: | ||||
| - `EmptyGroupError`: If no actions are added to the group | ||||
| - `Exception`: Summarizes all failed actions after execution | ||||
|  | ||||
| Example: | ||||
|     ActionGroup( | ||||
|         name="ParallelChecks", | ||||
|         actions=[Action(...), Action(...), ChainedAction(...)], | ||||
|     ) | ||||
|  | ||||
| This module complements `ChainedAction` by offering breadth-wise (parallel) execution | ||||
| as opposed to depth-wise (sequential) execution. | ||||
| """ | ||||
| import asyncio | ||||
| import random | ||||
| from typing import Any, Awaitable, Callable, Sequence | ||||
| @@ -47,6 +81,8 @@ class ActionGroup(BaseAction, ActionListMixin): | ||||
|     Args: | ||||
|         name (str): Name of the chain. | ||||
|         actions (list): List of actions or literals to execute. | ||||
|         args (tuple, optional): Positional arguments. | ||||
|         kwargs (dict, optional): Keyword arguments. | ||||
|         hooks (HookManager, optional): Hooks for lifecycle events. | ||||
|         inject_last_result (bool, optional): Whether to inject last results into kwargs | ||||
|                                              by default. | ||||
| @@ -191,7 +227,8 @@ class ActionGroup(BaseAction, ActionListMixin): | ||||
|  | ||||
|     def __str__(self): | ||||
|         return ( | ||||
|             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_into={self.inject_into!r})" | ||||
|             f"ActionGroup(name={self.name}, actions={[a.name for a in self.actions]}, " | ||||
|             f"args={self.args!r}, kwargs={self.kwargs!r}, " | ||||
|             f"inject_last_result={self.inject_last_result}, " | ||||
|             f"inject_into={self.inject_into})" | ||||
|         ) | ||||
|   | ||||
| @@ -1,12 +1,35 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """action_mixins.py""" | ||||
| """ | ||||
| Provides reusable mixins for managing collections of `BaseAction` instances | ||||
| within composite Falyx actions such as `ActionGroup` or `ChainedAction`. | ||||
|  | ||||
| The primary export, `ActionListMixin`, encapsulates common functionality for | ||||
| maintaining a mutable list of named actions—such as adding, removing, or retrieving | ||||
| actions by name—without duplicating logic across composite action types. | ||||
| """ | ||||
|  | ||||
| from typing import Sequence | ||||
|  | ||||
| from falyx.action.base_action import BaseAction | ||||
|  | ||||
|  | ||||
| class ActionListMixin: | ||||
|     """Mixin for managing a list of actions.""" | ||||
|     """ | ||||
|     Mixin for managing a list of named `BaseAction` objects. | ||||
|  | ||||
|     Provides helper methods for setting, adding, removing, checking, and | ||||
|     retrieving actions in composite Falyx constructs like `ActionGroup`. | ||||
|  | ||||
|     Attributes: | ||||
|         actions (list[BaseAction]): The internal list of managed actions. | ||||
|  | ||||
|     Methods: | ||||
|         set_actions(actions): Replaces all current actions with the given list. | ||||
|         add_action(action): Adds a new action to the list. | ||||
|         remove_action(name): Removes an action by its name. | ||||
|         has_action(name): Returns True if an action with the given name exists. | ||||
|         get_action(name): Returns the action matching the name, or None. | ||||
|     """ | ||||
|  | ||||
|     def __init__(self) -> None: | ||||
|         self.actions: list[BaseAction] = [] | ||||
| @@ -22,7 +45,7 @@ class ActionListMixin: | ||||
|         self.actions.append(action) | ||||
|  | ||||
|     def remove_action(self, name: str) -> None: | ||||
|         """Removes an action by name.""" | ||||
|         """Removes all actions with the given name.""" | ||||
|         self.actions = [action for action in self.actions if action.name != name] | ||||
|  | ||||
|     def has_action(self, name: str) -> bool: | ||||
| @@ -30,7 +53,7 @@ class ActionListMixin: | ||||
|         return any(action.name == name for action in self.actions) | ||||
|  | ||||
|     def get_action(self, name: str) -> BaseAction | None: | ||||
|         """Retrieves an action by name.""" | ||||
|         """Retrieves a single action with the given name.""" | ||||
|         for action in self.actions: | ||||
|             if action.name == name: | ||||
|                 return action | ||||
|   | ||||
| @@ -1,12 +1,53 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """action_types.py""" | ||||
| """ | ||||
| Defines strongly-typed enums used throughout the Falyx CLI framework for | ||||
| representing common structured values like file formats, selection return types, | ||||
| and confirmation modes. | ||||
|  | ||||
| These enums support alias resolution, graceful coercion from string inputs, | ||||
| and are used for input validation, serialization, and CLI configuration parsing. | ||||
|  | ||||
| Exports: | ||||
| - FileType: Defines supported file formats for `LoadFileAction` and `SaveFileAction` | ||||
| - SelectionReturnType: Defines structured return modes for `SelectionAction` | ||||
| - ConfirmType: Defines selectable confirmation types for prompts and guards | ||||
|  | ||||
| Key Features: | ||||
| - Custom `_missing_()` methods for forgiving string coercion and error reporting | ||||
| - Aliases and normalization support for user-friendly config-driven workflows | ||||
| - Useful in CLI flag parsing, YAML configs, and dynamic schema validation | ||||
|  | ||||
| Example: | ||||
|     FileType("yml") → FileType.YAML | ||||
|     SelectionReturnType("value") → SelectionReturnType.VALUE | ||||
|     ConfirmType("YES_NO") → ConfirmType.YES_NO | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from enum import Enum | ||||
|  | ||||
|  | ||||
| class FileType(Enum): | ||||
|     """Enum for file return types.""" | ||||
|     """ | ||||
|     Represents supported file types for reading and writing in Falyx Actions. | ||||
|  | ||||
|     Used by `LoadFileAction` and `SaveFileAction` to determine how to parse or | ||||
|     serialize file content. Includes alias resolution for common extensions like | ||||
|     `.yml`, `.txt`, and `filepath`. | ||||
|  | ||||
|     Members: | ||||
|         TEXT: Raw encoded text as a string. | ||||
|         PATH: Returns the file path (as a Path object). | ||||
|         JSON: JSON-formatted object. | ||||
|         TOML: TOML-formatted object. | ||||
|         YAML: YAML-formatted object. | ||||
|         CSV: List of rows (as lists) from a CSV file. | ||||
|         TSV: Same as CSV, but tab-delimited. | ||||
|         XML: Raw XML as a ElementTree. | ||||
|  | ||||
|     Example: | ||||
|         FileType("yml")  → FileType.YAML | ||||
|     """ | ||||
|  | ||||
|     TEXT = "text" | ||||
|     PATH = "path" | ||||
| @@ -17,6 +58,11 @@ class FileType(Enum): | ||||
|     TSV = "tsv" | ||||
|     XML = "xml" | ||||
|  | ||||
|     @classmethod | ||||
|     def choices(cls) -> list[FileType]: | ||||
|         """Return a list of all hook type choices.""" | ||||
|         return list(cls) | ||||
|  | ||||
|     @classmethod | ||||
|     def _get_alias(cls, value: str) -> str: | ||||
|         aliases = { | ||||
| @@ -29,18 +75,38 @@ class FileType(Enum): | ||||
|  | ||||
|     @classmethod | ||||
|     def _missing_(cls, value: object) -> FileType: | ||||
|         if isinstance(value, str): | ||||
|             normalized = value.lower() | ||||
|             alias = cls._get_alias(normalized) | ||||
|             for member in cls: | ||||
|                 if member.value == alias: | ||||
|                     return member | ||||
|         if not isinstance(value, str): | ||||
|             raise ValueError(f"Invalid {cls.__name__}: {value!r}") | ||||
|         normalized = value.strip().lower() | ||||
|         alias = cls._get_alias(normalized) | ||||
|         for member in cls: | ||||
|             if member.value == alias: | ||||
|                 return member | ||||
|         valid = ", ".join(member.value for member in cls) | ||||
|         raise ValueError(f"Invalid FileType: '{value}'. Must be one of: {valid}") | ||||
|         raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}") | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         """Return the string representation of the confirm type.""" | ||||
|         return self.value | ||||
|  | ||||
|  | ||||
| class SelectionReturnType(Enum): | ||||
|     """Enum for dictionary return types.""" | ||||
|     """ | ||||
|     Controls what is returned from a `SelectionAction` when using a selection map. | ||||
|  | ||||
|     Determines how the user's choice(s) from a `dict[str, SelectionOption]` are | ||||
|     transformed and returned by the action. | ||||
|  | ||||
|     Members: | ||||
|         KEY: Return the selected key(s) only. | ||||
|         VALUE: Return the value(s) associated with the selected key(s). | ||||
|         DESCRIPTION: Return the description(s) of the selected item(s). | ||||
|         DESCRIPTION_VALUE: Return a dict of {description: value} pairs. | ||||
|         ITEMS: Return full `SelectionOption` objects as a dict {key: SelectionOption}. | ||||
|  | ||||
|     Example: | ||||
|         return_type=SelectionReturnType.VALUE  → returns raw values like 'prod' | ||||
|     """ | ||||
|  | ||||
|     KEY = "key" | ||||
|     VALUE = "value" | ||||
| @@ -48,14 +114,54 @@ class SelectionReturnType(Enum): | ||||
|     DESCRIPTION_VALUE = "description_value" | ||||
|     ITEMS = "items" | ||||
|  | ||||
|     @classmethod | ||||
|     def choices(cls) -> list[SelectionReturnType]: | ||||
|         """Return a list of all hook type choices.""" | ||||
|         return list(cls) | ||||
|  | ||||
|     @classmethod | ||||
|     def _get_alias(cls, value: str) -> str: | ||||
|         aliases = { | ||||
|             "desc": "description", | ||||
|             "desc_value": "description_value", | ||||
|         } | ||||
|         return aliases.get(value, value) | ||||
|  | ||||
|     @classmethod | ||||
|     def _missing_(cls, value: object) -> SelectionReturnType: | ||||
|         if not isinstance(value, str): | ||||
|             raise ValueError(f"Invalid {cls.__name__}: {value!r}") | ||||
|         normalized = value.strip().lower() | ||||
|         alias = cls._get_alias(normalized) | ||||
|         for member in cls: | ||||
|             if member.value == alias: | ||||
|                 return member | ||||
|         valid = ", ".join(member.value for member in cls) | ||||
|         raise ValueError(f"Invalid DictReturnType: '{value}'. Must be one of: {valid}") | ||||
|         raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}") | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         """Return the string representation of the confirm type.""" | ||||
|         return self.value | ||||
|  | ||||
|  | ||||
| class ConfirmType(Enum): | ||||
|     """Enum for different confirmation types.""" | ||||
|     """ | ||||
|     Enum for defining prompt styles in confirmation dialogs. | ||||
|  | ||||
|     Used by confirmation actions to control user input behavior and available choices. | ||||
|  | ||||
|     Members: | ||||
|         YES_NO: Prompt with Yes / No options. | ||||
|         YES_CANCEL: Prompt with Yes / Cancel options. | ||||
|         YES_NO_CANCEL: Prompt with Yes / No / Cancel options. | ||||
|         TYPE_WORD: Require user to type a specific confirmation word (e.g., "delete"). | ||||
|         TYPE_WORD_CANCEL: Same as TYPE_WORD, but allows cancellation. | ||||
|         OK_CANCEL: Prompt with OK / Cancel options. | ||||
|         ACKNOWLEDGE: Single confirmation button (e.g., "Acknowledge"). | ||||
|  | ||||
|     Example: | ||||
|         ConfirmType("yes_no_cancel") → ConfirmType.YES_NO_CANCEL | ||||
|     """ | ||||
|  | ||||
|     YES_NO = "yes_no" | ||||
|     YES_CANCEL = "yes_cancel" | ||||
| @@ -70,15 +176,30 @@ class ConfirmType(Enum): | ||||
|         """Return a list of all hook type choices.""" | ||||
|         return list(cls) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         """Return the string representation of the confirm type.""" | ||||
|         return self.value | ||||
|     @classmethod | ||||
|     def _get_alias(cls, value: str) -> str: | ||||
|         aliases = { | ||||
|             "yes": "yes_no", | ||||
|             "ok": "ok_cancel", | ||||
|             "type": "type_word", | ||||
|             "word": "type_word", | ||||
|             "word_cancel": "type_word_cancel", | ||||
|             "ack": "acknowledge", | ||||
|         } | ||||
|         return aliases.get(value, value) | ||||
|  | ||||
|     @classmethod | ||||
|     def _missing_(cls, value: object) -> ConfirmType: | ||||
|         if isinstance(value, str): | ||||
|             for member in cls: | ||||
|                 if member.value == value.lower(): | ||||
|                     return member | ||||
|         if not isinstance(value, str): | ||||
|             raise ValueError(f"Invalid {cls.__name__}: {value!r}") | ||||
|         normalized = value.strip().lower() | ||||
|         alias = cls._get_alias(normalized) | ||||
|         for member in cls: | ||||
|             if member.value == alias: | ||||
|                 return member | ||||
|         valid = ", ".join(member.value for member in cls) | ||||
|         raise ValueError(f"Invalid ConfirmType: '{value}'. Must be one of: {valid}") | ||||
|         raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}") | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         """Return the string representation of the confirm type.""" | ||||
|         return self.value | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """base_action.py | ||||
|  | ||||
| """ | ||||
| Core action system for Falyx. | ||||
|  | ||||
| This module defines the building blocks for executable actions and workflows, | ||||
| @@ -50,10 +49,16 @@ class BaseAction(ABC): | ||||
|     complex actions like `ChainedAction` or `ActionGroup`. They can also | ||||
|     be run independently or as part of Falyx. | ||||
|  | ||||
|     inject_last_result (bool): Whether to inject the previous action's result | ||||
|                                into kwargs. | ||||
|     inject_into (str): The name of the kwarg key to inject the result as | ||||
|                        (default: 'last_result'). | ||||
|     Args: | ||||
|         name (str): Name of the action. Used for logging and debugging. | ||||
|         hooks (HookManager | None): Hook manager for lifecycle events. | ||||
|         inject_last_result (bool): Whether to inject the previous action's result | ||||
|                                    into kwargs. | ||||
|         inject_into (str): The name of the kwarg key to inject the result as | ||||
|                            (default: 'last_result'). | ||||
|         never_prompt (bool | None): Whether to never prompt for input. | ||||
|         logging_hooks (bool): Whether to register debug hooks for logging. | ||||
|         ignore_in_history (bool): Whether to ignore this action in execution history last result. | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
| @@ -65,6 +70,7 @@ class BaseAction(ABC): | ||||
|         inject_into: str = "last_result", | ||||
|         never_prompt: bool | None = None, | ||||
|         logging_hooks: bool = False, | ||||
|         ignore_in_history: bool = False, | ||||
|     ) -> None: | ||||
|         self.name = name | ||||
|         self.hooks = hooks or HookManager() | ||||
| @@ -76,6 +82,7 @@ class BaseAction(ABC): | ||||
|         self._skip_in_chain: bool = False | ||||
|         self.console: Console = console | ||||
|         self.options_manager: OptionsManager | None = None | ||||
|         self.ignore_in_history: bool = ignore_in_history | ||||
|  | ||||
|         if logging_hooks: | ||||
|             register_debug_hooks(self.hooks) | ||||
|   | ||||
| @@ -1,5 +1,69 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """chained_action.py""" | ||||
| """ | ||||
| Defines `ChainedAction`, a core Falyx construct for executing a sequence of actions | ||||
| in strict order, optionally injecting results from previous steps into subsequent ones. | ||||
|  | ||||
| `ChainedAction` is designed for linear workflows where each step may depend on | ||||
| the output of the previous one. It supports rollback semantics, fallback recovery, | ||||
| and advanced error handling using `SharedContext`. Literal values are supported via | ||||
| automatic wrapping with `LiteralInputAction`. | ||||
|  | ||||
| Key Features: | ||||
| - Executes a list of actions sequentially | ||||
| - Optional `auto_inject` to forward `last_result` into each step | ||||
| - Supports fallback recovery using `FallbackAction` when an error occurs | ||||
| - Rollback stack to undo already-completed actions on failure | ||||
| - Integrates with the full Falyx hook lifecycle | ||||
| - Previews and introspects workflow structure via `Rich` | ||||
|  | ||||
| Use Cases: | ||||
| - Ordered pipelines (e.g., build → test → deploy) | ||||
| - Data transformations or ETL workflows | ||||
| - Linear decision trees or interactive wizards | ||||
|  | ||||
| Special Behaviors: | ||||
| - Literal inputs (e.g., strings, numbers) are converted to `LiteralInputAction` | ||||
| - If an action raises and is followed by a `FallbackAction`, it will be skipped and recovered | ||||
| - If a `BreakChainSignal` is raised, the chain stops early and rollbacks are triggered | ||||
|  | ||||
| Raises: | ||||
| - `EmptyChainError`: If no actions are present | ||||
| - `BreakChainSignal`: When explicitly triggered by a child action | ||||
| - `Exception`: For all unhandled failures during chained execution | ||||
|  | ||||
| Example: | ||||
|     ChainedAction( | ||||
|         name="DeployFlow", | ||||
|         actions=[ | ||||
|             ActionGroup( | ||||
|                 name="PreDeploymentChecks", | ||||
|                 actions=[ | ||||
|                     Action( | ||||
|                         name="ValidateInputs", | ||||
|                         action=validate_inputs, | ||||
|                     ), | ||||
|                     Action( | ||||
|                         name="CheckDependencies", | ||||
|                         action=check_dependencies, | ||||
|                     ), | ||||
|                 ], | ||||
|             ), | ||||
|             Action( | ||||
|                 name="BuildArtifact", | ||||
|                 action=build_artifact, | ||||
|             ), | ||||
|             Action( | ||||
|                 name="Upload", | ||||
|                 action=upload, | ||||
|             ), | ||||
|             Action( | ||||
|                 name="NotifySuccess", | ||||
|                 action=notify_success, | ||||
|             ), | ||||
|         ], | ||||
|         auto_inject=True, | ||||
|     ) | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from typing import Any, Awaitable, Callable, Sequence | ||||
| @@ -35,8 +99,10 @@ class ChainedAction(BaseAction, ActionListMixin): | ||||
|     previous results. | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the chain. | ||||
|         name (str): Name of the chain. Used for logging and debugging. | ||||
|         actions (list): List of actions or literals to execute. | ||||
|         args (tuple, optional): Positional arguments. | ||||
|         kwargs (dict, optional): Keyword arguments. | ||||
|         hooks (HookManager, optional): Hooks for lifecycle events. | ||||
|         inject_last_result (bool, optional): Whether to inject last results into kwargs | ||||
|                                              by default. | ||||
| @@ -235,7 +301,8 @@ class ChainedAction(BaseAction, ActionListMixin): | ||||
|  | ||||
|     def __str__(self): | ||||
|         return ( | ||||
|             f"ChainedAction(name={self.name!r}, " | ||||
|             f"actions={[a.name for a in self.actions]!r}, " | ||||
|             f"ChainedAction(name={self.name}, " | ||||
|             f"actions={[a.name for a in self.actions]}, " | ||||
|             f"args={self.args!r}, kwargs={self.kwargs!r}, " | ||||
|             f"auto_inject={self.auto_inject}, return_list={self.return_list})" | ||||
|         ) | ||||
|   | ||||
| @@ -1,3 +1,43 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Defines `ConfirmAction`, a Falyx Action that prompts the user for confirmation | ||||
| before continuing execution. | ||||
|  | ||||
| `ConfirmAction` supports a wide range of confirmation strategies, including: | ||||
| - Yes/No-style prompts | ||||
| - OK/Cancel dialogs | ||||
| - Typed confirmation (e.g., "CONFIRM" or "DELETE") | ||||
| - Acknowledge-only flows | ||||
|  | ||||
| It is useful for adding safety gates, user-driven approval steps, or destructive | ||||
| operation guards in CLI workflows. This Action supports both interactive use and | ||||
| non-interactive (headless) behavior via `never_prompt`, as well as full hook lifecycle | ||||
| integration and optional result passthrough. | ||||
|  | ||||
| Key Features: | ||||
| - Supports all common confirmation types (see `ConfirmType`) | ||||
| - Integrates with `PromptSession` for prompt_toolkit-based UX | ||||
| - Configurable fallback word validation and behavior on cancel | ||||
| - Can return the injected `last_result` instead of a boolean | ||||
| - Fully compatible with Falyx hooks, preview, and result injection | ||||
|  | ||||
| Use Cases: | ||||
| - Safety checks before deleting, pushing, or overwriting resources | ||||
| - Gatekeeping interactive workflows | ||||
| - Validating irreversible or sensitive operations | ||||
|  | ||||
| Example: | ||||
|     ConfirmAction( | ||||
|         name="ConfirmDeploy", | ||||
|         message="Are you sure you want to deploy to production?", | ||||
|         confirm_type="yes_no_cancel", | ||||
|     ) | ||||
|  | ||||
| Raises: | ||||
| - `CancelSignal`: When the user chooses to abort the action | ||||
| - `ValueError`: If an invalid `confirm_type` is provided | ||||
| """ | ||||
|  | ||||
| from __future__ import annotations | ||||
|  | ||||
| from typing import Any | ||||
| @@ -30,7 +70,7 @@ class ConfirmAction(BaseAction): | ||||
|     with an operation. | ||||
|  | ||||
|     Attributes: | ||||
|         name (str): Name of the action. | ||||
|         name (str): Name of the action. Used for logging and debugging. | ||||
|         message (str): The confirmation message to display. | ||||
|         confirm_type (ConfirmType | str): The type of confirmation to use. | ||||
|             Options include YES_NO, YES_CANCEL, YES_NO_CANCEL, TYPE_WORD, and OK_CANCEL. | ||||
| @@ -72,19 +112,11 @@ class ConfirmAction(BaseAction): | ||||
|             never_prompt=never_prompt, | ||||
|         ) | ||||
|         self.message = message | ||||
|         self.confirm_type = self._coerce_confirm_type(confirm_type) | ||||
|         self.confirm_type = ConfirmType(confirm_type) | ||||
|         self.prompt_session = prompt_session or PromptSession() | ||||
|         self.word = word | ||||
|         self.return_last_result = return_last_result | ||||
|  | ||||
|     def _coerce_confirm_type(self, confirm_type: ConfirmType | str) -> ConfirmType: | ||||
|         """Coerce the confirm_type to a ConfirmType enum.""" | ||||
|         if isinstance(confirm_type, ConfirmType): | ||||
|             return confirm_type | ||||
|         elif isinstance(confirm_type, str): | ||||
|             return ConfirmType(confirm_type) | ||||
|         return ConfirmType(confirm_type) | ||||
|  | ||||
|     async def _confirm(self) -> bool: | ||||
|         """Confirm the action with the user.""" | ||||
|         match self.confirm_type: | ||||
|   | ||||
| @@ -1,5 +1,41 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """fallback_action.py""" | ||||
| """ | ||||
| Defines `FallbackAction`, a lightweight recovery Action used within `ChainedAction` | ||||
| pipelines to gracefully handle errors or missing results from a preceding step. | ||||
|  | ||||
| When placed immediately after a failing or null-returning Action, `FallbackAction` | ||||
| injects the `last_result` and checks whether it is `None`. If so, it substitutes a | ||||
| predefined fallback value and allows the chain to continue. If `last_result` is valid, | ||||
| it is passed through unchanged. | ||||
|  | ||||
| This mechanism allows workflows to recover from failure or gaps in data | ||||
| without prematurely terminating the entire chain. | ||||
|  | ||||
| Key Features: | ||||
| - Injects and inspects `last_result` | ||||
| - Replaces `None` with a fallback value | ||||
| - Consumes upstream errors when used with `ChainedAction` | ||||
| - Fully compatible with Falyx's preview and hook systems | ||||
|  | ||||
| Typical Use Cases: | ||||
| - Graceful degradation in chained workflows | ||||
| - Providing default values when earlier steps are optional | ||||
| - Replacing missing data with static or precomputed values | ||||
|  | ||||
| Example: | ||||
|     ChainedAction( | ||||
|         name="FetchWithFallback", | ||||
|         actions=[ | ||||
|             Action("MaybeFetchRemoteAction", action=fetch_data), | ||||
|             FallbackAction(fallback={"data": "default"}), | ||||
|             Action("ProcessDataAction", action=process_data), | ||||
|         ], | ||||
|         auto_inject=True, | ||||
|     ) | ||||
|  | ||||
| The `FallbackAction` ensures that even if `MaybeFetchRemoteAction` fails or returns | ||||
| None, `ProcessDataAction` still receives a usable input. | ||||
| """ | ||||
| from functools import cached_property | ||||
| from typing import Any | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """http_action.py | ||||
| """ | ||||
| Defines an Action subclass for making HTTP requests using aiohttp within Falyx workflows. | ||||
|  | ||||
| Features: | ||||
| @@ -47,7 +47,7 @@ class HTTPAction(Action): | ||||
|     - Retry and result injection compatible | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action. | ||||
|         name (str): Name of the action. Used for logging and debugging. | ||||
|         method (str): HTTP method (e.g., 'GET', 'POST'). | ||||
|         url (str): The request URL. | ||||
|         headers (dict[str, str], optional): Request headers. | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """io_action.py | ||||
| """ | ||||
| BaseIOAction: A base class for stream- or buffer-based IO-driven Actions. | ||||
|  | ||||
| This module defines `BaseIOAction`, a specialized variant of `BaseAction` | ||||
| @@ -48,8 +48,11 @@ class BaseIOAction(BaseAction): | ||||
|     - `to_output(data)`: Convert result into output string or bytes. | ||||
|     - `_run(parsed_input, *args, **kwargs)`: Core execution logic. | ||||
|  | ||||
|     Attributes: | ||||
|     Args: | ||||
|         name (str): Name of the action. Used for logging and debugging. | ||||
|         hooks (HookManager | None): Hook manager for lifecycle events. | ||||
|         mode (str): Either "buffered" or "stream". Controls input behavior. | ||||
|         logging_hooks (bool): Whether to register debug hooks for logging. | ||||
|         inject_last_result (bool): Whether to inject shared context input. | ||||
|     """ | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,36 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """literal_input_action.py""" | ||||
| """ | ||||
| Defines `LiteralInputAction`, a lightweight Falyx Action that injects a static, | ||||
| predefined value into a `ChainedAction` workflow. | ||||
|  | ||||
| This Action is useful for embedding literal values (e.g., strings, numbers, | ||||
| dicts) as part of a CLI pipeline without writing custom callables. It behaves | ||||
| like a constant-returning function that can serve as the starting point, | ||||
| fallback, or manual override within a sequence of actions. | ||||
|  | ||||
| Key Features: | ||||
| - Wraps any static value as a Falyx-compatible Action | ||||
| - Fully hookable and previewable like any other Action | ||||
| - Enables declarative workflows with no required user input | ||||
| - Compatible with auto-injection and shared context in `ChainedAction` | ||||
|  | ||||
| Common Use Cases: | ||||
| - Supplying default parameters or configuration values mid-pipeline | ||||
| - Starting a chain with a fixed value (e.g., base URL, credentials) | ||||
| - Bridging gaps between conditional or dynamically generated Actions | ||||
|  | ||||
| Example: | ||||
|     ChainedAction( | ||||
|         name="SendStaticMessage", | ||||
|         actions=[ | ||||
|             LiteralInputAction("hello world"), | ||||
|             SendMessageAction(), | ||||
|         ] | ||||
|     ) | ||||
|  | ||||
| The `LiteralInputAction` is a foundational building block for pipelines that | ||||
| require predictable, declarative value injection at any stage. | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from functools import cached_property | ||||
|   | ||||
| @@ -1,5 +1,41 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """load_file_action.py""" | ||||
| """ | ||||
| Defines `LoadFileAction`, a Falyx Action for reading and parsing the contents of a file | ||||
| at runtime in a structured, introspectable, and lifecycle-aware manner. | ||||
|  | ||||
| This action supports multiple common file types—including plain text, structured data | ||||
| formats (JSON, YAML, TOML), tabular formats (CSV, TSV), XML, and raw Path objects— | ||||
| making it ideal for configuration loading, data ingestion, and file-driven workflows. | ||||
|  | ||||
| It integrates seamlessly with Falyx pipelines and supports `last_result` injection, | ||||
| Rich-powered previews, and lifecycle hook execution. | ||||
|  | ||||
| Key Features: | ||||
| - Format-aware parsing for structured and unstructured files | ||||
| - Supports injection of `last_result` as the target file path | ||||
| - Headless-compatible via `never_prompt` and argument overrides | ||||
| - Lifecycle hooks: before, success, error, after, teardown | ||||
| - Preview renders file metadata, size, modified timestamp, and parsed content | ||||
| - Fully typed and alias-compatible via `FileType` | ||||
|  | ||||
| Supported File Types: | ||||
| - `TEXT`: Raw text string (UTF-8) | ||||
| - `PATH`: The file path itself as a `Path` object | ||||
| - `JSON`, `YAML`, `TOML`: Parsed into `dict` or `list` | ||||
| - `CSV`, `TSV`: Parsed into `list[list[str]]` | ||||
| - `XML`: Returns the root `ElementTree.Element` | ||||
|  | ||||
| Example: | ||||
|     LoadFileAction( | ||||
|         name="LoadSettings", | ||||
|         file_path="config/settings.yaml", | ||||
|         file_type="yaml" | ||||
|     ) | ||||
|  | ||||
| This module is a foundational building block for file-driven CLI workflows in Falyx. | ||||
| It is often paired with `SaveFileAction`, `SelectionAction`, or `ConfirmAction` for | ||||
| robust and interactive pipelines. | ||||
| """ | ||||
| import csv | ||||
| import json | ||||
| import xml.etree.ElementTree as ET | ||||
| @@ -21,13 +57,58 @@ from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| class LoadFileAction(BaseAction): | ||||
|     """LoadFileAction allows loading and parsing files of various types.""" | ||||
|     """ | ||||
|     LoadFileAction loads and parses the contents of a file at runtime. | ||||
|  | ||||
|     This action supports multiple common file formats—including plain text, JSON, | ||||
|     YAML, TOML, XML, CSV, and TSV—and returns a parsed representation of the file. | ||||
|     It can be used to inject external data into a CLI workflow, load configuration files, | ||||
|     or process structured datasets interactively or in headless mode. | ||||
|  | ||||
|     Key Features: | ||||
|     - Supports rich previewing of file metadata and contents | ||||
|     - Auto-injects `last_result` as `file_path` if configured | ||||
|     - Hookable at every lifecycle stage (before, success, error, after, teardown) | ||||
|     - Supports both static and dynamic file targets (via args or injected values) | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action for tracking and logging. | ||||
|         file_path (str | Path | None): Path to the file to be loaded. Can be passed | ||||
|             directly or injected via `last_result`. | ||||
|         file_type (FileType | str): Type of file to parse. Options include: | ||||
|             TEXT, JSON, YAML, TOML, CSV, TSV, XML, PATH. | ||||
|         encoding (str): Encoding to use when reading files (default: 'UTF-8'). | ||||
|         inject_last_result (bool): Whether to use the last result as the file path. | ||||
|         inject_into (str): Name of the kwarg to inject `last_result` into (default: 'file_path'). | ||||
|  | ||||
|     Returns: | ||||
|         Any: The parsed file content. Format depends on `file_type`: | ||||
|             - TEXT: str | ||||
|             - JSON/YAML/TOML: dict or list | ||||
|             - CSV/TSV: list[list[str]] | ||||
|             - XML: xml.etree.ElementTree | ||||
|             - PATH: Path object | ||||
|  | ||||
|     Raises: | ||||
|         ValueError: If `file_path` is missing or invalid. | ||||
|         FileNotFoundError: If the file does not exist. | ||||
|         TypeError: If `file_type` is unsupported or the factory does not return a BaseAction. | ||||
|         Any parsing errors will be logged but not raised unless fatal. | ||||
|  | ||||
|     Example: | ||||
|         LoadFileAction( | ||||
|             name="LoadConfig", | ||||
|             file_path="config/settings.yaml", | ||||
|             file_type="yaml" | ||||
|         ) | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         file_path: str | Path | None = None, | ||||
|         file_type: FileType | str = FileType.TEXT, | ||||
|         encoding: str = "UTF-8", | ||||
|         inject_last_result: bool = False, | ||||
|         inject_into: str = "file_path", | ||||
|     ): | ||||
| @@ -35,7 +116,8 @@ class LoadFileAction(BaseAction): | ||||
|             name=name, inject_last_result=inject_last_result, inject_into=inject_into | ||||
|         ) | ||||
|         self._file_path = self._coerce_file_path(file_path) | ||||
|         self._file_type = self._coerce_file_type(file_type) | ||||
|         self._file_type = FileType(file_type) | ||||
|         self.encoding = encoding | ||||
|  | ||||
|     @property | ||||
|     def file_path(self) -> Path | None: | ||||
| @@ -63,20 +145,6 @@ class LoadFileAction(BaseAction): | ||||
|         """Get the file type.""" | ||||
|         return self._file_type | ||||
|  | ||||
|     @file_type.setter | ||||
|     def file_type(self, value: FileType | str): | ||||
|         """Set the file type, converting to FileType if necessary.""" | ||||
|         self._file_type = self._coerce_file_type(value) | ||||
|  | ||||
|     def _coerce_file_type(self, file_type: FileType | str) -> FileType: | ||||
|         """Coerce the file type to a FileType enum.""" | ||||
|         if isinstance(file_type, FileType): | ||||
|             return file_type | ||||
|         elif isinstance(file_type, str): | ||||
|             return FileType(file_type) | ||||
|         else: | ||||
|             raise TypeError("file_type must be a FileType enum or string") | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[None, None]: | ||||
|         return None, None | ||||
|  | ||||
| @@ -91,27 +159,29 @@ class LoadFileAction(BaseAction): | ||||
|         value: Any = None | ||||
|         try: | ||||
|             if self.file_type == FileType.TEXT: | ||||
|                 value = self.file_path.read_text(encoding="UTF-8") | ||||
|                 value = self.file_path.read_text(encoding=self.encoding) | ||||
|             elif self.file_type == FileType.PATH: | ||||
|                 value = self.file_path | ||||
|             elif self.file_type == FileType.JSON: | ||||
|                 value = json.loads(self.file_path.read_text(encoding="UTF-8")) | ||||
|                 value = json.loads(self.file_path.read_text(encoding=self.encoding)) | ||||
|             elif self.file_type == FileType.TOML: | ||||
|                 value = toml.loads(self.file_path.read_text(encoding="UTF-8")) | ||||
|                 value = toml.loads(self.file_path.read_text(encoding=self.encoding)) | ||||
|             elif self.file_type == FileType.YAML: | ||||
|                 value = yaml.safe_load(self.file_path.read_text(encoding="UTF-8")) | ||||
|                 value = yaml.safe_load(self.file_path.read_text(encoding=self.encoding)) | ||||
|             elif self.file_type == FileType.CSV: | ||||
|                 with open(self.file_path, newline="", encoding="UTF-8") as csvfile: | ||||
|                 with open(self.file_path, newline="", encoding=self.encoding) as csvfile: | ||||
|                     reader = csv.reader(csvfile) | ||||
|                     value = list(reader) | ||||
|             elif self.file_type == FileType.TSV: | ||||
|                 with open(self.file_path, newline="", encoding="UTF-8") as tsvfile: | ||||
|                 with open(self.file_path, newline="", encoding=self.encoding) as tsvfile: | ||||
|                     reader = csv.reader(tsvfile, delimiter="\t") | ||||
|                     value = list(reader) | ||||
|             elif self.file_type == FileType.XML: | ||||
|                 tree = ET.parse(self.file_path, parser=ET.XMLParser(encoding="UTF-8")) | ||||
|                 tree = ET.parse( | ||||
|                     self.file_path, parser=ET.XMLParser(encoding=self.encoding) | ||||
|                 ) | ||||
|                 root = tree.getroot() | ||||
|                 value = ET.tostring(root, encoding="unicode") | ||||
|                 value = root | ||||
|             else: | ||||
|                 raise ValueError(f"Unsupported return type: {self.file_type}") | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,42 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """menu_action.py""" | ||||
| """ | ||||
| Defines `MenuAction`, a one-shot, interactive menu-style Falyx Action that presents | ||||
| a set of labeled options to the user and executes the corresponding action based on | ||||
| their selection. | ||||
|  | ||||
| Unlike the persistent top-level Falyx menu, `MenuAction` is intended for embedded, | ||||
| self-contained decision points within a workflow. It supports both interactive and | ||||
| non-interactive (headless) usage, integrates fully with the Falyx hook lifecycle, | ||||
| and allows optional defaulting or input injection from previous actions. | ||||
|  | ||||
| Each selectable item is defined in a `MenuOptionMap`, mapping a single-character or | ||||
| keyword to a `MenuOption`, which includes a description and a corresponding `BaseAction`. | ||||
|  | ||||
| Key Features: | ||||
| - Renders a Rich-powered multi-column menu table | ||||
| - Accepts custom prompt sessions or tables | ||||
| - Supports `last_result` injection for context-aware defaults | ||||
| - Gracefully handles `BackSignal` and `QuitSignal` for flow control | ||||
| - Compatible with preview trees and introspection tools | ||||
|  | ||||
| Use Cases: | ||||
| - In-workflow submenus or branches | ||||
| - Interactive control points in chained or grouped workflows | ||||
| - Configurable menus for multi-step user-driven automation | ||||
|  | ||||
| Example: | ||||
|     MenuAction( | ||||
|         name="SelectEnv", | ||||
|         menu_options=MenuOptionMap(options={ | ||||
|             "D": MenuOption("Deploy to Dev", DeployDevAction()), | ||||
|             "P": MenuOption("Deploy to Prod", DeployProdAction()), | ||||
|         }), | ||||
|         default_selection="D", | ||||
|     ) | ||||
|  | ||||
| This module is ideal for enabling structured, discoverable, and declarative | ||||
| menus in both interactive and programmatic CLI automation. | ||||
| """ | ||||
| from typing import Any | ||||
|  | ||||
| from prompt_toolkit import PromptSession | ||||
| @@ -19,7 +56,57 @@ from falyx.utils import chunks | ||||
|  | ||||
|  | ||||
| class MenuAction(BaseAction): | ||||
|     """MenuAction class for creating single use menu actions.""" | ||||
|     """ | ||||
|     MenuAction displays a one-time interactive menu of predefined options, | ||||
|     each mapped to a corresponding Action. | ||||
|  | ||||
|     Unlike the main Falyx menu system, `MenuAction` is intended for scoped, | ||||
|     self-contained selection logic—ideal for small in-flow menus, decision branches, | ||||
|     or embedded control points in larger workflows. | ||||
|  | ||||
|     Each selectable item is defined in a `MenuOptionMap`, which maps a string key | ||||
|     to a `MenuOption`, bundling a description and a callable Action. | ||||
|  | ||||
|     Key Features: | ||||
|     - One-shot selection from labeled actions | ||||
|     - Optional default or last_result-based selection | ||||
|     - Full hook lifecycle (before, success, error, after, teardown) | ||||
|     - Works with or without rendering a table (for headless use) | ||||
|     - Compatible with `BackSignal` and `QuitSignal` for graceful control flow exits | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action. Used for logging and debugging. | ||||
|         menu_options (MenuOptionMap): Mapping of keys to `MenuOption` objects. | ||||
|         title (str): Table title displayed when prompting the user. | ||||
|         columns (int): Number of columns in the rendered table. | ||||
|         prompt_message (str): Prompt text displayed before selection. | ||||
|         default_selection (str): Key to use if no user input is provided. | ||||
|         inject_last_result (bool): Whether to inject `last_result` into args/kwargs. | ||||
|         inject_into (str): Key under which to inject `last_result`. | ||||
|         prompt_session (PromptSession | None): Custom session for Prompt Toolkit input. | ||||
|         never_prompt (bool): If True, skips interaction and uses default or last_result. | ||||
|         include_reserved (bool): Whether to include reserved keys (like 'X' for Exit). | ||||
|         show_table (bool): Whether to render the Rich menu table. | ||||
|         custom_table (Table | None): Pre-rendered Rich Table (bypasses auto-building). | ||||
|  | ||||
|     Returns: | ||||
|         Any: The result of the selected option's Action. | ||||
|  | ||||
|     Raises: | ||||
|         BackSignal: When the user chooses to return to a previous menu. | ||||
|         QuitSignal: When the user chooses to exit the program. | ||||
|         ValueError: If `never_prompt=True` but no default selection is resolvable. | ||||
|         Exception: Any error raised during the execution of the selected Action. | ||||
|  | ||||
|     Example: | ||||
|         MenuAction( | ||||
|             name="ChooseBranch", | ||||
|             menu_options=MenuOptionMap(options={ | ||||
|                 "A": MenuOption("Run analysis", ActionGroup(...)), | ||||
|                 "B": MenuOption("Run report", Action(...)), | ||||
|             }), | ||||
|         ) | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|   | ||||
| @@ -1,5 +1,42 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """process_action.py""" | ||||
| """ | ||||
| Defines `ProcessAction`, a Falyx Action that executes a blocking or CPU-bound function | ||||
| in a separate process using `concurrent.futures.ProcessPoolExecutor`. | ||||
|  | ||||
| This is useful for offloading expensive computations or subprocess-compatible operations | ||||
| from the main event loop, while maintaining Falyx's composable, hookable, and injectable | ||||
| execution model. | ||||
|  | ||||
| `ProcessAction` mirrors the behavior of a normal `Action`, but ensures isolation from | ||||
| the asyncio event loop and handles serialization (pickling) of arguments and injected | ||||
| state. | ||||
|  | ||||
| Key Features: | ||||
| - Runs a callable in a separate Python process | ||||
| - Compatible with `last_result` injection for chained workflows | ||||
| - Validates that injected values are pickleable before dispatch | ||||
| - Supports hook lifecycle (`before`, `on_success`, `on_error`, etc.) | ||||
| - Custom executor support for reuse or configuration | ||||
|  | ||||
| Use Cases: | ||||
| - CPU-intensive operations (e.g., image processing, simulations, data transformations) | ||||
| - Blocking third-party libraries that don't cooperate with asyncio | ||||
| - CLI workflows that require subprocess-level parallelism or safety | ||||
|  | ||||
| Example: | ||||
|     ProcessAction( | ||||
|         name="ComputeChecksum", | ||||
|         action=calculate_sha256, | ||||
|         args=("large_file.bin",), | ||||
|     ) | ||||
|  | ||||
| Raises: | ||||
| - `ValueError`: If an injected value is not pickleable | ||||
| - `Exception`: Propagated from the subprocess on failure | ||||
|  | ||||
| This module enables structured offloading of workload in CLI pipelines while maintaining | ||||
| full introspection and lifecycle management. | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import asyncio | ||||
|   | ||||
| @@ -1,5 +1,19 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """process_pool_action.py""" | ||||
| """ | ||||
| Defines `ProcessPoolAction`, a parallelized action executor that distributes | ||||
| tasks across multiple processes using Python's `concurrent.futures.ProcessPoolExecutor`. | ||||
|  | ||||
| This module enables structured execution of CPU-bound tasks in parallel while | ||||
| retaining Falyx's core guarantees: lifecycle hooks, error isolation, execution context | ||||
| tracking, and introspectable previews. | ||||
|  | ||||
| Key Components: | ||||
| - ProcessTask: Lightweight wrapper for a task + args/kwargs | ||||
| - ProcessPoolAction: Parallel action that runs tasks concurrently in separate processes | ||||
|  | ||||
| Use this module to accelerate workflows involving expensive computation or | ||||
| external resources that benefit from true parallelism. | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import asyncio | ||||
| @@ -23,6 +37,21 @@ from falyx.themes import OneColors | ||||
|  | ||||
| @dataclass | ||||
| class ProcessTask: | ||||
|     """ | ||||
|     Represents a callable task with its arguments for parallel execution. | ||||
|  | ||||
|     This lightweight container is used to queue individual tasks for execution | ||||
|     inside a `ProcessPoolAction`. | ||||
|  | ||||
|     Attributes: | ||||
|         task (Callable): A function to execute. | ||||
|         args (tuple): Positional arguments to pass to the function. | ||||
|         kwargs (dict): Keyword arguments to pass to the function. | ||||
|  | ||||
|     Raises: | ||||
|         TypeError: If `task` is not callable. | ||||
|     """ | ||||
|  | ||||
|     task: Callable[..., Any] | ||||
|     args: tuple = () | ||||
|     kwargs: dict[str, Any] = field(default_factory=dict) | ||||
| @@ -33,7 +62,44 @@ class ProcessTask: | ||||
|  | ||||
|  | ||||
| class ProcessPoolAction(BaseAction): | ||||
|     """ """ | ||||
|     """ | ||||
|     Executes a set of independent tasks in parallel using a process pool. | ||||
|  | ||||
|     `ProcessPoolAction` is ideal for CPU-bound tasks that benefit from | ||||
|     concurrent execution in separate processes. Each task is wrapped in a | ||||
|     `ProcessTask` instance and executed in a `concurrent.futures.ProcessPoolExecutor`. | ||||
|  | ||||
|     Key Features: | ||||
|     - Parallel, process-based execution | ||||
|     - Hook lifecycle support across all stages | ||||
|     - Supports argument injection (e.g., `last_result`) | ||||
|     - Compatible with retry behavior and shared context propagation | ||||
|     - Captures all task results (including exceptions) and records execution context | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action. Used for logging and debugging. | ||||
|         actions (Sequence[ProcessTask] | None): A list of tasks to run. | ||||
|         hooks (HookManager | None): Optional hook manager for lifecycle events. | ||||
|         executor (ProcessPoolExecutor | None): Custom executor instance (optional). | ||||
|         inject_last_result (bool): Whether to inject the last result into task kwargs. | ||||
|         inject_into (str): Name of the kwarg to use for injected result. | ||||
|  | ||||
|     Returns: | ||||
|         list[Any]: A list of task results in submission order. Exceptions are preserved. | ||||
|  | ||||
|     Raises: | ||||
|         EmptyPoolError: If no actions are registered. | ||||
|         ValueError: If injected `last_result` is not pickleable. | ||||
|  | ||||
|     Example: | ||||
|         ProcessPoolAction( | ||||
|             name="ParallelTransforms", | ||||
|             actions=[ | ||||
|                 ProcessTask(func_a, args=(1,)), | ||||
|                 ProcessTask(func_b, kwargs={"x": 2}), | ||||
|             ] | ||||
|         ) | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|   | ||||
| @@ -1,5 +1,16 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """prompt_menu_action.py""" | ||||
| """ | ||||
| Defines `PromptMenuAction`, a Falyx Action that prompts the user to choose from | ||||
| a list of labeled options using a single-line prompt input. Each option corresponds | ||||
| to a `MenuOption` that wraps a description and an executable action. | ||||
|  | ||||
| Unlike `MenuAction`, this action renders a flat, inline prompt (e.g., `Option1 | Option2`) | ||||
| without using a rich table. It is ideal for compact decision points, hotkey-style menus, | ||||
| or contextual user input flows. | ||||
|  | ||||
| Key Components: | ||||
| - PromptMenuAction: Inline prompt-driven menu runner | ||||
| """ | ||||
| from typing import Any | ||||
|  | ||||
| from prompt_toolkit import PromptSession | ||||
| @@ -17,7 +28,53 @@ from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| class PromptMenuAction(BaseAction): | ||||
|     """PromptMenuAction class for creating prompt -> actions.""" | ||||
|     """ | ||||
|     Displays a single-line interactive prompt for selecting an option from a menu. | ||||
|  | ||||
|     `PromptMenuAction` is a lightweight alternative to `MenuAction`, offering a more | ||||
|     compact selection interface. Instead of rendering a full table, it displays | ||||
|     available keys inline as a placeholder (e.g., `A | B | C`) and accepts the user's | ||||
|     input to execute the associated action. | ||||
|  | ||||
|     Each key is defined in a `MenuOptionMap`, which maps to a `MenuOption` containing | ||||
|     a description and an executable action. | ||||
|  | ||||
|     Key Features: | ||||
|     - Minimal UI: rendered as a single prompt line with placeholder | ||||
|     - Optional fallback to `default_selection` or injected `last_result` | ||||
|     - Fully hookable lifecycle (before, success, error, after, teardown) | ||||
|     - Supports reserved keys and structured error recovery | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action. Used for logging and debugging. | ||||
|         menu_options (MenuOptionMap): A mapping of keys to `MenuOption` objects. | ||||
|         prompt_message (str): Text displayed before user input (default: "Select > "). | ||||
|         default_selection (str): Fallback key if no input is provided. | ||||
|         inject_last_result (bool): Whether to use `last_result` as a fallback input key. | ||||
|         inject_into (str): Kwarg name under which to inject the last result. | ||||
|         prompt_session (PromptSession | None): Custom Prompt Toolkit session. | ||||
|         never_prompt (bool): If True, skips user input and uses `default_selection`. | ||||
|         include_reserved (bool): Whether to include reserved keys in logic and preview. | ||||
|  | ||||
|     Returns: | ||||
|         Any: The result of the selected option's action. | ||||
|  | ||||
|     Raises: | ||||
|         BackSignal: If the user signals to return to the previous menu. | ||||
|         QuitSignal: If the user signals to exit the CLI entirely. | ||||
|         ValueError: If `never_prompt` is enabled but no fallback is available. | ||||
|         Exception: If an error occurs during the action's execution. | ||||
|  | ||||
|     Example: | ||||
|         PromptMenuAction( | ||||
|             name="HotkeyPrompt", | ||||
|             menu_options=MenuOptionMap(options={ | ||||
|                 "R": MenuOption("Run", ChainedAction(...)), | ||||
|                 "S": MenuOption("Skip", Action(...)), | ||||
|             }), | ||||
|             prompt_message="Choose action > ", | ||||
|         ) | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|   | ||||
| @@ -1,5 +1,25 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """save_file_action.py""" | ||||
| """ | ||||
| Defines `SaveFileAction`, a Falyx Action for writing structured or unstructured data | ||||
| to a file in a variety of supported formats. | ||||
|  | ||||
| Supports overwrite control, automatic directory creation, and full lifecycle hook | ||||
| integration. Compatible with chaining and injection of upstream results via | ||||
| `inject_last_result`. | ||||
|  | ||||
| Supported formats: TEXT, JSON, YAML, TOML, CSV, TSV, XML | ||||
|  | ||||
| Key Features: | ||||
| - Auto-serialization of Python data to structured formats | ||||
| - Flexible path control with directory creation and overwrite handling | ||||
| - Injection of data via chaining (`last_result`) | ||||
| - Preview mode with file metadata visualization | ||||
|  | ||||
| Common use cases: | ||||
| - Writing processed results to disk | ||||
| - Logging artifacts from batch pipelines | ||||
| - Exporting config or user input to JSON/YAML for reuse | ||||
| """ | ||||
| import csv | ||||
| import json | ||||
| import xml.etree.ElementTree as ET | ||||
| @@ -22,12 +42,50 @@ from falyx.themes import OneColors | ||||
|  | ||||
| class SaveFileAction(BaseAction): | ||||
|     """ | ||||
|     SaveFileAction saves data to a file in the specified format (e.g., TEXT, JSON, YAML). | ||||
|     Supports overwrite control and integrates with chaining workflows via inject_last_result. | ||||
|     Saves data to a file in the specified format. | ||||
|  | ||||
|     Supported types: TEXT, JSON, YAML, TOML, CSV, TSV, XML | ||||
|     `SaveFileAction` serializes and writes input data to disk using the format | ||||
|     defined by `file_type`. It supports plain text and structured formats like | ||||
|     JSON, YAML, TOML, CSV, TSV, and XML. Files may be overwritten or appended | ||||
|     based on settings, and parent directories are created if missing. | ||||
|  | ||||
|     If the file exists and overwrite is False, the action will raise a FileExistsError. | ||||
|     Data can be provided directly via the `data` argument or dynamically injected | ||||
|     from the previous Action using `inject_last_result`. | ||||
|  | ||||
|     Key Features: | ||||
|     - Format-aware saving with validation | ||||
|     - Lifecycle hook support (before, success, error, after, teardown) | ||||
|     - Chain-compatible via last_result injection | ||||
|     - Supports safe overwrite behavior and preview diagnostics | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action. Used for logging and debugging. | ||||
|         file_path (str | Path): Destination file path. | ||||
|         file_type (FileType | str): Output format (e.g., "json", "yaml", "text"). | ||||
|         mode (Literal["w", "a"]): File mode—write or append. Default is "w". | ||||
|         encoding (str): Encoding to use when writing files (default: "UTF-8"). | ||||
|         data (Any): Data to save. If omitted, uses last_result injection. | ||||
|         overwrite (bool): Whether to overwrite existing files. Default is True. | ||||
|         create_dirs (bool): Whether to auto-create parent directories. | ||||
|         inject_last_result (bool): Inject previous result as input if enabled. | ||||
|         inject_into (str): Name of kwarg to inject last_result into (default: "data"). | ||||
|  | ||||
|     Returns: | ||||
|         str: The full path to the saved file. | ||||
|  | ||||
|     Raises: | ||||
|         FileExistsError: If the file exists and `overwrite` is False. | ||||
|         FileNotFoundError: If parent directory is missing and `create_dirs` is False. | ||||
|         ValueError: If data format is invalid for the target file type. | ||||
|         Exception: Any errors encountered during file writing. | ||||
|  | ||||
|     Example: | ||||
|         SaveFileAction( | ||||
|             name="SaveOutput", | ||||
|             file_path="output/data.json", | ||||
|             file_type="json", | ||||
|             inject_last_result=True | ||||
|         ) | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
| @@ -36,6 +94,7 @@ class SaveFileAction(BaseAction): | ||||
|         file_path: str, | ||||
|         file_type: FileType | str = FileType.TEXT, | ||||
|         mode: Literal["w", "a"] = "w", | ||||
|         encoding: str = "UTF-8", | ||||
|         data: Any = None, | ||||
|         overwrite: bool = True, | ||||
|         create_dirs: bool = True, | ||||
| @@ -50,6 +109,7 @@ class SaveFileAction(BaseAction): | ||||
|             file_path (str | Path): Path to the file where data will be saved. | ||||
|             file_type (FileType | str): Format to write to (e.g. TEXT, JSON, YAML). | ||||
|             mode (Literal["w", "a"]): File mode (default: "w"). | ||||
|             encoding (str): Encoding to use when writing files (default: "UTF-8"). | ||||
|             data (Any): Data to be saved (if not using inject_last_result). | ||||
|             overwrite (bool): Whether to overwrite the file if it exists. | ||||
|             create_dirs (bool): Whether to create parent directories if they do not exist. | ||||
| @@ -60,11 +120,12 @@ class SaveFileAction(BaseAction): | ||||
|             name=name, inject_last_result=inject_last_result, inject_into=inject_into | ||||
|         ) | ||||
|         self._file_path = self._coerce_file_path(file_path) | ||||
|         self._file_type = self._coerce_file_type(file_type) | ||||
|         self._file_type = FileType(file_type) | ||||
|         self.data = data | ||||
|         self.overwrite = overwrite | ||||
|         self.mode = mode | ||||
|         self.create_dirs = create_dirs | ||||
|         self.encoding = encoding | ||||
|  | ||||
|     @property | ||||
|     def file_path(self) -> Path | None: | ||||
| @@ -92,20 +153,6 @@ class SaveFileAction(BaseAction): | ||||
|         """Get the file type.""" | ||||
|         return self._file_type | ||||
|  | ||||
|     @file_type.setter | ||||
|     def file_type(self, value: FileType | str): | ||||
|         """Set the file type, converting to FileType if necessary.""" | ||||
|         self._file_type = self._coerce_file_type(value) | ||||
|  | ||||
|     def _coerce_file_type(self, file_type: FileType | str) -> FileType: | ||||
|         """Coerce the file type to a FileType enum.""" | ||||
|         if isinstance(file_type, FileType): | ||||
|             return file_type | ||||
|         elif isinstance(file_type, str): | ||||
|             return FileType(file_type) | ||||
|         else: | ||||
|             raise TypeError("file_type must be a FileType enum or string") | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[None, None]: | ||||
|         return None, None | ||||
|  | ||||
| @@ -143,13 +190,15 @@ class SaveFileAction(BaseAction): | ||||
|  | ||||
|         try: | ||||
|             if self.file_type == FileType.TEXT: | ||||
|                 self.file_path.write_text(data, encoding="UTF-8") | ||||
|                 self.file_path.write_text(data, encoding=self.encoding) | ||||
|             elif self.file_type == FileType.JSON: | ||||
|                 self.file_path.write_text(json.dumps(data, indent=4), encoding="UTF-8") | ||||
|                 self.file_path.write_text( | ||||
|                     json.dumps(data, indent=4), encoding=self.encoding | ||||
|                 ) | ||||
|             elif self.file_type == FileType.TOML: | ||||
|                 self.file_path.write_text(toml.dumps(data), encoding="UTF-8") | ||||
|                 self.file_path.write_text(toml.dumps(data), encoding=self.encoding) | ||||
|             elif self.file_type == FileType.YAML: | ||||
|                 self.file_path.write_text(yaml.dump(data), encoding="UTF-8") | ||||
|                 self.file_path.write_text(yaml.dump(data), encoding=self.encoding) | ||||
|             elif self.file_type == FileType.CSV: | ||||
|                 if not isinstance(data, list) or not all( | ||||
|                     isinstance(row, list) for row in data | ||||
| @@ -158,7 +207,7 @@ class SaveFileAction(BaseAction): | ||||
|                         f"{self.file_type.name} file type requires a list of lists" | ||||
|                     ) | ||||
|                 with open( | ||||
|                     self.file_path, mode=self.mode, newline="", encoding="UTF-8" | ||||
|                     self.file_path, mode=self.mode, newline="", encoding=self.encoding | ||||
|                 ) as csvfile: | ||||
|                     writer = csv.writer(csvfile) | ||||
|                     writer.writerows(data) | ||||
| @@ -170,7 +219,7 @@ class SaveFileAction(BaseAction): | ||||
|                         f"{self.file_type.name} file type requires a list of lists" | ||||
|                     ) | ||||
|                 with open( | ||||
|                     self.file_path, mode=self.mode, newline="", encoding="UTF-8" | ||||
|                     self.file_path, mode=self.mode, newline="", encoding=self.encoding | ||||
|                 ) as tsvfile: | ||||
|                     writer = csv.writer(tsvfile, delimiter="\t") | ||||
|                     writer.writerows(data) | ||||
| @@ -180,7 +229,7 @@ class SaveFileAction(BaseAction): | ||||
|                 root = ET.Element("root") | ||||
|                 self._dict_to_xml(data, root) | ||||
|                 tree = ET.ElementTree(root) | ||||
|                 tree.write(self.file_path, encoding="UTF-8", xml_declaration=True) | ||||
|                 tree.write(self.file_path, encoding=self.encoding, xml_declaration=True) | ||||
|             else: | ||||
|                 raise ValueError(f"Unsupported file type: {self.file_type}") | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,47 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """select_file_action.py""" | ||||
| """ | ||||
| Defines `SelectFileAction`, a Falyx Action that allows users to select one or more | ||||
| files from a target directory and optionally return either their content or path, | ||||
| parsed based on a selected `FileType`. | ||||
|  | ||||
| This action combines rich interactive selection (via `SelectionOption`) with | ||||
| format-aware parsing, making it ideal for loading external resources, injecting | ||||
| config files, or dynamically selecting inputs mid-pipeline. | ||||
|  | ||||
| Supports filtering by file suffix, customizable prompt layout, multi-select mode, | ||||
| and automatic content parsing for common formats. | ||||
|  | ||||
| Key Features: | ||||
| - Lists files from a directory and renders them in a Rich-powered menu | ||||
| - Supports suffix filtering (e.g., only `.yaml` or `.json` files) | ||||
| - Returns content parsed as `str`, `dict`, `list`, or raw `Path` depending on `FileType` | ||||
| - Works in single or multi-selection mode | ||||
| - Fully compatible with Falyx hooks and context system | ||||
| - Graceful cancellation via `CancelSignal` | ||||
|  | ||||
| Supported Return Types (`FileType`): | ||||
| - `TEXT`: UTF-8 string content | ||||
| - `PATH`: File path object (`Path`) | ||||
| - `JSON`, `YAML`, `TOML`: Parsed dictionaries or lists | ||||
| - `CSV`, `TSV`: `list[list[str]]` from structured rows | ||||
| - `XML`: `ElementTree.Element` root object | ||||
|  | ||||
| Use Cases: | ||||
| - Prompting users to select a config file during setup | ||||
| - Dynamically loading data into chained workflows | ||||
| - CLI interfaces that require structured file ingestion | ||||
|  | ||||
| Example: | ||||
|     SelectFileAction( | ||||
|         name="ChooseConfigFile", | ||||
|         directory="configs/", | ||||
|         suffix_filter=".yaml", | ||||
|         return_type="yaml", | ||||
|     ) | ||||
|  | ||||
| This module is ideal for use cases where file choice is deferred to runtime | ||||
| and needs to feed into structured automation pipelines. | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import csv | ||||
| @@ -67,6 +109,7 @@ class SelectFileAction(BaseAction): | ||||
|         style: str = OneColors.WHITE, | ||||
|         suffix_filter: str | None = None, | ||||
|         return_type: FileType | str = FileType.PATH, | ||||
|         encoding: str = "UTF-8", | ||||
|         number_selections: int | str = 1, | ||||
|         separator: str = ",", | ||||
|         allow_duplicates: bool = False, | ||||
| @@ -83,7 +126,8 @@ class SelectFileAction(BaseAction): | ||||
|         self.separator = separator | ||||
|         self.allow_duplicates = allow_duplicates | ||||
|         self.prompt_session = prompt_session or PromptSession() | ||||
|         self.return_type = self._coerce_return_type(return_type) | ||||
|         self.return_type = FileType(return_type) | ||||
|         self.encoding = encoding | ||||
|  | ||||
|     @property | ||||
|     def number_selections(self) -> int | str: | ||||
| @@ -100,51 +144,46 @@ class SelectFileAction(BaseAction): | ||||
|         else: | ||||
|             raise ValueError("number_selections must be a positive integer or one of '*'") | ||||
|  | ||||
|     def _coerce_return_type(self, return_type: FileType | str) -> FileType: | ||||
|         if isinstance(return_type, FileType): | ||||
|             return return_type | ||||
|         elif isinstance(return_type, str): | ||||
|             return FileType(return_type) | ||||
|         else: | ||||
|             raise TypeError("return_type must be a FileType enum or string") | ||||
|  | ||||
|     def get_options(self, files: list[Path]) -> dict[str, SelectionOption]: | ||||
|         value: Any | ||||
|         options = {} | ||||
|         for index, file in enumerate(files): | ||||
|             try: | ||||
|                 if self.return_type == FileType.TEXT: | ||||
|                     value = file.read_text(encoding="UTF-8") | ||||
|                 elif self.return_type == FileType.PATH: | ||||
|                     value = file | ||||
|                 elif self.return_type == FileType.JSON: | ||||
|                     value = json.loads(file.read_text(encoding="UTF-8")) | ||||
|                 elif self.return_type == FileType.TOML: | ||||
|                     value = toml.loads(file.read_text(encoding="UTF-8")) | ||||
|                 elif self.return_type == FileType.YAML: | ||||
|                     value = yaml.safe_load(file.read_text(encoding="UTF-8")) | ||||
|                 elif self.return_type == FileType.CSV: | ||||
|                     with open(file, newline="", encoding="UTF-8") as csvfile: | ||||
|                         reader = csv.reader(csvfile) | ||||
|                         value = list(reader) | ||||
|                 elif self.return_type == FileType.TSV: | ||||
|                     with open(file, newline="", encoding="UTF-8") as tsvfile: | ||||
|                         reader = csv.reader(tsvfile, delimiter="\t") | ||||
|                         value = list(reader) | ||||
|                 elif self.return_type == FileType.XML: | ||||
|                     tree = ET.parse(file, parser=ET.XMLParser(encoding="UTF-8")) | ||||
|                     root = tree.getroot() | ||||
|                     value = ET.tostring(root, encoding="unicode") | ||||
|                 else: | ||||
|                     raise ValueError(f"Unsupported return type: {self.return_type}") | ||||
|  | ||||
|                 options[str(index)] = SelectionOption( | ||||
|                     description=file.name, value=value, style=self.style | ||||
|                 ) | ||||
|             except Exception as error: | ||||
|                 logger.error("Failed to parse %s: %s", file.name, error) | ||||
|             options[str(index)] = SelectionOption( | ||||
|                 description=file.name, | ||||
|                 value=file,  # Store the Path only — parsing will happen later | ||||
|                 style=self.style, | ||||
|             ) | ||||
|         return options | ||||
|  | ||||
|     def parse_file(self, file: Path) -> Any: | ||||
|         value: Any | ||||
|         try: | ||||
|             if self.return_type == FileType.TEXT: | ||||
|                 value = file.read_text(encoding=self.encoding) | ||||
|             elif self.return_type == FileType.PATH: | ||||
|                 value = file | ||||
|             elif self.return_type == FileType.JSON: | ||||
|                 value = json.loads(file.read_text(encoding=self.encoding)) | ||||
|             elif self.return_type == FileType.TOML: | ||||
|                 value = toml.loads(file.read_text(encoding=self.encoding)) | ||||
|             elif self.return_type == FileType.YAML: | ||||
|                 value = yaml.safe_load(file.read_text(encoding=self.encoding)) | ||||
|             elif self.return_type == FileType.CSV: | ||||
|                 with open(file, newline="", encoding=self.encoding) as csvfile: | ||||
|                     reader = csv.reader(csvfile) | ||||
|                     value = list(reader) | ||||
|             elif self.return_type == FileType.TSV: | ||||
|                 with open(file, newline="", encoding=self.encoding) as tsvfile: | ||||
|                     reader = csv.reader(tsvfile, delimiter="\t") | ||||
|                     value = list(reader) | ||||
|             elif self.return_type == FileType.XML: | ||||
|                 tree = ET.parse(file, parser=ET.XMLParser(encoding=self.encoding)) | ||||
|                 value = tree.getroot() | ||||
|             else: | ||||
|                 raise ValueError(f"Unsupported return type: {self.return_type}") | ||||
|         except Exception as error: | ||||
|             logger.error("Failed to parse %s: %s", file.name, error) | ||||
|         return value | ||||
|  | ||||
|     def _find_cancel_key(self, options) -> str: | ||||
|         """Return first numeric value not already used in the selection dict.""" | ||||
|         for index in range(len(options)): | ||||
| @@ -202,9 +241,9 @@ class SelectFileAction(BaseAction): | ||||
|             if isinstance(keys, str): | ||||
|                 if keys == cancel_key: | ||||
|                     raise CancelSignal("User canceled the selection.") | ||||
|                 result = options[keys].value | ||||
|                 result = self.parse_file(options[keys].value) | ||||
|             elif isinstance(keys, list): | ||||
|                 result = [options[key].value for key in keys] | ||||
|                 result = [self.parse_file(options[key].value) for key in keys] | ||||
|  | ||||
|             context.result = result | ||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||
| @@ -228,6 +267,7 @@ class SelectFileAction(BaseAction): | ||||
|         tree.add(f"[dim]Return type:[/] {self.return_type}") | ||||
|         tree.add(f"[dim]Prompt:[/] {self.prompt_message}") | ||||
|         tree.add(f"[dim]Columns:[/] {self.columns}") | ||||
|         tree.add("[dim]Loading:[/] Lazy (parsing occurs after selection)") | ||||
|         try: | ||||
|             files = list(self.directory.iterdir()) | ||||
|             if self.suffix_filter: | ||||
|   | ||||
| @@ -1,5 +1,36 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """selection_action.py""" | ||||
| """ | ||||
| Defines `SelectionAction`, a highly flexible Falyx Action for interactive or headless | ||||
| selection from a list or dictionary of user-defined options. | ||||
|  | ||||
| This module powers workflows that require prompting the user for input, selecting | ||||
| configuration presets, branching execution paths, or collecting multiple values | ||||
| in a type-safe, hook-compatible, and composable way. | ||||
|  | ||||
| Key Features: | ||||
| - Supports both flat lists and structured dictionaries (`SelectionOptionMap`) | ||||
| - Handles single or multi-selection with configurable separators | ||||
| - Returns results in various formats (key, value, description, item, or mapping) | ||||
| - Integrates fully with Falyx lifecycle hooks and `last_result` injection | ||||
| - Works in interactive (`prompt_toolkit`) and non-interactive (headless) modes | ||||
| - Renders a Rich-based table preview for diagnostics or dry runs | ||||
|  | ||||
| Usage Scenarios: | ||||
| - Guided CLI wizards or configuration menus | ||||
| - Dynamic branching or conditional step logic | ||||
| - User-driven parameterization in chained workflows | ||||
| - Reusable pickers for environments, files, datasets, etc. | ||||
|  | ||||
| Example: | ||||
|     SelectionAction( | ||||
|         name="ChooseMode", | ||||
|         selections={"dev": "Development", "prod": "Production"}, | ||||
|         return_type="key" | ||||
|     ) | ||||
|  | ||||
| This module is foundational to creating expressive, user-centered CLI experiences | ||||
| within Falyx while preserving reproducibility and automation friendliness. | ||||
| """ | ||||
| from typing import Any | ||||
|  | ||||
| from prompt_toolkit import PromptSession | ||||
| @@ -114,7 +145,7 @@ class SelectionAction(BaseAction): | ||||
|         ) | ||||
|         # Setter normalizes to correct type, mypy can't infer that | ||||
|         self.selections: list[str] | SelectionOptionMap = selections  # type: ignore[assignment] | ||||
|         self.return_type: SelectionReturnType = self._coerce_return_type(return_type) | ||||
|         self.return_type: SelectionReturnType = SelectionReturnType(return_type) | ||||
|         self.title = title | ||||
|         self.columns = columns | ||||
|         self.prompt_session = prompt_session or PromptSession() | ||||
| @@ -140,13 +171,6 @@ class SelectionAction(BaseAction): | ||||
|         else: | ||||
|             raise ValueError("number_selections must be a positive integer or '*'") | ||||
|  | ||||
|     def _coerce_return_type( | ||||
|         self, return_type: SelectionReturnType | str | ||||
|     ) -> SelectionReturnType: | ||||
|         if isinstance(return_type, SelectionReturnType): | ||||
|             return return_type | ||||
|         return SelectionReturnType(return_type) | ||||
|  | ||||
|     @property | ||||
|     def selections(self) -> list[str] | SelectionOptionMap: | ||||
|         return self._selections | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """shell_action.py | ||||
| Execute shell commands with input substitution.""" | ||||
| """Execute shell commands with input substitution.""" | ||||
|  | ||||
| from __future__ import annotations | ||||
|  | ||||
|   | ||||
| @@ -1,32 +1,85 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """signal_action.py""" | ||||
| """ | ||||
| Defines `SignalAction`, a lightweight Falyx Action that raises a `FlowSignal` | ||||
| (such as `BackSignal`, `QuitSignal`, or `BreakChainSignal`) during execution to | ||||
| alter or exit the CLI flow. | ||||
|  | ||||
| Unlike traditional actions, `SignalAction` does not return a result—instead, it raises | ||||
| a signal to break, back out, or exit gracefully. Despite its minimal behavior, | ||||
| it fully supports Falyx's hook lifecycle, including `before`, `on_error`, `after`, | ||||
| and `on_teardown`—allowing it to trigger logging, audit events, UI updates, or custom | ||||
| telemetry before halting flow. | ||||
|  | ||||
| Key Features: | ||||
| - Declaratively raises a `FlowSignal` from within any Falyx workflow | ||||
| - Works in menus, chained actions, or conditionals | ||||
| - Hook-compatible: can run pre- and post-signal lifecycle hooks | ||||
| - Supports previewing and structured introspection | ||||
|  | ||||
| Use Cases: | ||||
| - Implementing "Back", "Cancel", or "Quit" options in `MenuAction` or `PromptMenuAction` | ||||
| - Triggering an intentional early exit from a `ChainedAction` | ||||
| - Running cleanup hooks before stopping execution | ||||
|  | ||||
| Example: | ||||
|     SignalAction("ExitApp", QuitSignal(), hooks=my_hook_manager) | ||||
| """ | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.action.action import Action | ||||
| from falyx.hook_manager import HookManager | ||||
| from falyx.signals import FlowSignal | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| class SignalAction(Action): | ||||
|     """ | ||||
|     An action that raises a control flow signal when executed. | ||||
|     A hook-compatible action that raises a control flow signal when invoked. | ||||
|  | ||||
|     Useful for exiting a menu, going back, or halting execution gracefully. | ||||
|     `SignalAction` raises a `FlowSignal` (e.g., `BackSignal`, `QuitSignal`, | ||||
|     `BreakChainSignal`) during execution. It is commonly used to exit menus, | ||||
|     break from chained actions, or halt workflows intentionally. | ||||
|  | ||||
|     Even though the signal interrupts normal flow, all registered lifecycle hooks | ||||
|     (`before`, `on_error`, `after`, `on_teardown`) are triggered as expected— | ||||
|     allowing structured behavior such as logging, analytics, or UI changes | ||||
|     before the signal is raised. | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action (used for logging and debugging). | ||||
|         signal (FlowSignal): A subclass of `FlowSignal` to raise (e.g., QuitSignal). | ||||
|         hooks (HookManager | None): Optional hook manager to attach lifecycle hooks. | ||||
|  | ||||
|     Raises: | ||||
|         FlowSignal: Always raises the provided signal when the action is run. | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, name: str, signal: FlowSignal): | ||||
|     def __init__(self, name: str, signal: FlowSignal, hooks: HookManager | None = None): | ||||
|         self.signal = signal | ||||
|         super().__init__(name, action=self.raise_signal) | ||||
|         super().__init__(name, action=self.raise_signal, hooks=hooks) | ||||
|  | ||||
|     async def raise_signal(self, *args, **kwargs): | ||||
|         """ | ||||
|         Raises the configured `FlowSignal`. | ||||
|  | ||||
|         This method is called internally by the Falyx runtime and is the core | ||||
|         behavior of the action. All hooks surrounding execution are still triggered. | ||||
|         """ | ||||
|         raise self.signal | ||||
|  | ||||
|     @property | ||||
|     def signal(self): | ||||
|         """Returns the configured `FlowSignal` instance.""" | ||||
|         return self._signal | ||||
|  | ||||
|     @signal.setter | ||||
|     def signal(self, value: FlowSignal): | ||||
|         """ | ||||
|         Validates that the provided value is a `FlowSignal`. | ||||
|  | ||||
|         Raises: | ||||
|             TypeError: If `value` is not an instance of `FlowSignal`. | ||||
|         """ | ||||
|         if not isinstance(value, FlowSignal): | ||||
|             raise TypeError( | ||||
|                 f"Signal must be an FlowSignal instance, got {type(value).__name__}" | ||||
|   | ||||
| @@ -1,5 +1,31 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """user_input_action.py""" | ||||
| """ | ||||
| Defines `UserInputAction`, a Falyx Action that prompts the user for input using | ||||
| Prompt Toolkit and returns the result as a string. | ||||
|  | ||||
| This action is ideal for interactive CLI workflows that require user input mid-pipeline. | ||||
| It supports dynamic prompt interpolation, prompt validation, default text fallback, | ||||
| and full lifecycle hook execution. | ||||
|  | ||||
| Key Features: | ||||
| - Rich Prompt Toolkit integration for input and validation | ||||
| - Dynamic prompt formatting using `last_result` injection | ||||
| - Optional `Validator` support for structured input (e.g., emails, numbers) | ||||
| - Hook lifecycle compatibility (before, on_success, on_error, after, teardown) | ||||
| - Preview support for introspection or dry-run flows | ||||
|  | ||||
| Use Cases: | ||||
| - Asking for confirmation text or field input mid-chain | ||||
| - Injecting user-provided variables into automated pipelines | ||||
| - Interactive menu or wizard experiences | ||||
|  | ||||
| Example: | ||||
|     UserInputAction( | ||||
|         name="GetUsername", | ||||
|         prompt_text="Enter your username > ", | ||||
|         validator=Validator.from_callable(lambda s: len(s) > 0), | ||||
|     ) | ||||
| """ | ||||
| from prompt_toolkit import PromptSession | ||||
| from prompt_toolkit.validation import Validator | ||||
| from rich.tree import Tree | ||||
| @@ -13,15 +39,20 @@ from falyx.themes.colors import OneColors | ||||
|  | ||||
| class UserInputAction(BaseAction): | ||||
|     """ | ||||
|     Prompts the user for input via PromptSession and returns the result. | ||||
|     Prompts the user for textual input and returns their response. | ||||
|  | ||||
|     `UserInputAction` uses Prompt Toolkit to gather input with optional validation, | ||||
|     lifecycle hook compatibility, and support for default text. If `inject_last_result` | ||||
|     is enabled, the prompt message can interpolate `{last_result}` dynamically. | ||||
|  | ||||
|     Args: | ||||
|         name (str): Action name. | ||||
|         prompt_text (str): Prompt text (can include '{last_result}' for interpolation). | ||||
|         validator (Validator, optional): Prompt Toolkit validator. | ||||
|         prompt_session (PromptSession, optional): Reusable prompt session. | ||||
|         inject_last_result (bool): Whether to inject last_result into prompt. | ||||
|         inject_into (str): Key to use for injection (default: 'last_result'). | ||||
|         name (str): Name of the action (used for introspection and logging). | ||||
|         prompt_text (str): The prompt message shown to the user. | ||||
|             Can include `{last_result}` if `inject_last_result=True`. | ||||
|         default_text (str): Optional default value shown in the prompt. | ||||
|         validator (Validator | None): Prompt Toolkit validator for input constraints. | ||||
|         prompt_session (PromptSession | None): Optional custom prompt session. | ||||
|         inject_last_result (bool): Whether to inject `last_result` into the prompt. | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|   | ||||
| @@ -1,10 +1,42 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """bottom_bar.py""" | ||||
| """ | ||||
| Provides the `BottomBar` class for managing a customizable bottom status bar in | ||||
| Falyx-based CLI applications. | ||||
|  | ||||
| The bottom bar is rendered using `prompt_toolkit` and supports: | ||||
| - Rich-formatted static content | ||||
| - Live-updating value trackers and counters | ||||
| - Toggle switches activated via Ctrl+<key> bindings | ||||
| - Config-driven visual and behavioral controls | ||||
|  | ||||
| Each item in the bar is registered by name and rendered in columns across the | ||||
| bottom of the terminal. Toggles are linked to user-defined state accessors and | ||||
| mutators, and can be automatically bound to `OptionsManager` values for full | ||||
| integration with Falyx CLI argument parsing. | ||||
|  | ||||
| Key Features: | ||||
| - Live rendering of structured status items using Rich-style HTML | ||||
| - Custom or built-in item types: static text, dynamic counters, toggles, value displays | ||||
| - Ctrl+key toggle handling via `prompt_toolkit.KeyBindings` | ||||
| - Columnar layout with automatic width scaling | ||||
| - Optional integration with `OptionsManager` for dynamic state toggling | ||||
|  | ||||
| Usage Example: | ||||
|     bar = BottomBar(columns=3) | ||||
|     bar.add_static("env", "ENV: dev") | ||||
|     bar.add_toggle("d", "Debug", get_debug, toggle_debug) | ||||
|     bar.add_value_tracker("attempts", "Retries", get_retry_count) | ||||
|     bar.render() | ||||
|  | ||||
| Used by Falyx to provide a persistent UI element showing toggles, system state, | ||||
| and runtime telemetry below the input prompt. | ||||
| """ | ||||
|  | ||||
| from typing import Any, Callable | ||||
|  | ||||
| from prompt_toolkit.formatted_text import HTML, merge_formatted_text | ||||
| from prompt_toolkit.key_binding import KeyBindings | ||||
| from prompt_toolkit.key_binding.key_processor import KeyPressEvent | ||||
| from rich.console import Console | ||||
|  | ||||
| from falyx.console import console | ||||
| @@ -28,7 +60,6 @@ class BottomBar: | ||||
|         self, | ||||
|         columns: int = 3, | ||||
|         key_bindings: KeyBindings | None = None, | ||||
|         key_validator: Callable[[str], bool] | None = None, | ||||
|     ) -> None: | ||||
|         self.columns = columns | ||||
|         self.console: Console = console | ||||
| @@ -36,7 +67,6 @@ class BottomBar: | ||||
|         self._value_getters: dict[str, Callable[[], Any]] = CaseInsensitiveDict() | ||||
|         self.toggle_keys: list[str] = [] | ||||
|         self.key_bindings = key_bindings or KeyBindings() | ||||
|         self.key_validator = key_validator | ||||
|  | ||||
|     @staticmethod | ||||
|     def default_render(label: str, value: Any, fg: str, bg: str, width: int) -> HTML: | ||||
| @@ -121,17 +151,26 @@ class BottomBar: | ||||
|         bg_on: str = OneColors.GREEN, | ||||
|         bg_off: str = OneColors.DARK_RED, | ||||
|     ) -> None: | ||||
|         """ | ||||
|         Add a toggle to the bottom bar. | ||||
|         Always uses the ctrl + key combination for toggling. | ||||
|  | ||||
|         Args: | ||||
|             key (str): The key to toggle the state. | ||||
|             label (str): The label for the toggle. | ||||
|             get_state (Callable[[], bool]): Function to get the current state. | ||||
|             toggle_state (Callable[[], None]): Function to toggle the state. | ||||
|             fg (str): Foreground color for the label. | ||||
|             bg_on (str): Background color when the toggle is ON. | ||||
|             bg_off (str): Background color when the toggle is OFF. | ||||
|         """ | ||||
|         if not callable(get_state): | ||||
|             raise ValueError("`get_state` must be a callable returning bool") | ||||
|         if not callable(toggle_state): | ||||
|             raise ValueError("`toggle_state` must be a callable") | ||||
|         key = key.upper() | ||||
|         key = key.lower() | ||||
|         if key in self.toggle_keys: | ||||
|             raise ValueError(f"Key {key} is already used as a toggle") | ||||
|         if self.key_validator and not self.key_validator(key): | ||||
|             raise ValueError( | ||||
|                 f"Key '{key}' conflicts with existing command, toggle, or reserved key." | ||||
|             ) | ||||
|         self._value_getters[key] = get_state | ||||
|         self.toggle_keys.append(key) | ||||
|  | ||||
| @@ -139,16 +178,14 @@ class BottomBar: | ||||
|             get_state_ = self._value_getters[key] | ||||
|             color = bg_on if get_state_() else bg_off | ||||
|             status = "ON" if get_state_() else "OFF" | ||||
|             text = f"({key.upper()}) {label}: {status}" | ||||
|             text = f"(^{key.lower()}) {label}: {status}" | ||||
|             return HTML(f"<style bg='{color}' fg='{fg}'>{text:^{self.space}}</style>") | ||||
|  | ||||
|         self._add_named(key, render) | ||||
|  | ||||
|         for k in (key.upper(), key.lower()): | ||||
|  | ||||
|             @self.key_bindings.add(k) | ||||
|             def _(_): | ||||
|                 toggle_state() | ||||
|         @self.key_bindings.add(f"c-{key.lower()}", eager=True) | ||||
|         def _(_: KeyPressEvent): | ||||
|             toggle_state() | ||||
|  | ||||
|     def add_toggle_from_option( | ||||
|         self, | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """command.py | ||||
|  | ||||
| """ | ||||
| Defines the Command class for Falyx CLI. | ||||
|  | ||||
| Commands are callable units representing a menu option or CLI task, | ||||
| @@ -92,12 +91,13 @@ class Command(BaseModel): | ||||
|         arguments (list[dict[str, Any]]): Argument definitions for the command. | ||||
|         argument_config (Callable[[CommandArgumentParser], None] | None): Function to configure arguments | ||||
|             for the command parser. | ||||
|         arg_metadata (dict[str, str | dict[str, Any]]): Metadata for arguments, | ||||
|             such as help text or choices. | ||||
|         simple_help_signature (bool): Whether to use a simplified help signature. | ||||
|         custom_parser (ArgParserProtocol | None): Custom argument parser. | ||||
|         custom_help (Callable[[], str | None] | None): Custom help message generator. | ||||
|         auto_args (bool): Automatically infer arguments from the action. | ||||
|         arg_metadata (dict[str, str | dict[str, Any]]): Metadata for arguments, | ||||
|             such as help text or choices. | ||||
|         simple_help_signature (bool): Whether to use a simplified help signature. | ||||
|         ignore_in_history (bool): Whether to ignore this command in execution history last result. | ||||
|  | ||||
|     Methods: | ||||
|         __call__(): Executes the command, respecting hooks and retries. | ||||
| @@ -140,6 +140,7 @@ class Command(BaseModel): | ||||
|     auto_args: bool = True | ||||
|     arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict) | ||||
|     simple_help_signature: bool = False | ||||
|     ignore_in_history: bool = False | ||||
|  | ||||
|     _context: ExecutionContext | None = PrivateAttr(default=None) | ||||
|  | ||||
| @@ -243,6 +244,9 @@ class Command(BaseModel): | ||||
|             for arg_def in self.get_argument_definitions(): | ||||
|                 self.arg_parser.add_argument(*arg_def.pop("flags"), **arg_def) | ||||
|  | ||||
|         if self.ignore_in_history and isinstance(self.action, BaseAction): | ||||
|             self.action.ignore_in_history = True | ||||
|  | ||||
|     def _inject_options_manager(self) -> None: | ||||
|         """Inject the options manager into the action if applicable.""" | ||||
|         if isinstance(self.action, BaseAction): | ||||
|   | ||||
| @@ -1,3 +1,20 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Provides `FalyxCompleter`, an intelligent autocompletion engine for Falyx CLI | ||||
| menus using Prompt Toolkit. | ||||
|  | ||||
| This completer supports: | ||||
| - Command key and alias completion (e.g. `R`, `HELP`, `X`) | ||||
| - Argument flag completion for registered commands (e.g. `--tag`, `--name`) | ||||
| - Context-aware suggestions based on cursor position and argument structure | ||||
| - Interactive value completions (e.g. choices and suggestions defined per argument) | ||||
|  | ||||
| Completions are sourced from `CommandArgumentParser.suggest_next`, which analyzes | ||||
| parsed tokens to determine appropriate next arguments, flags, or values. | ||||
|  | ||||
| Integrated with the `Falyx.prompt_session` to enhance the interactive experience. | ||||
| """ | ||||
|  | ||||
| from __future__ import annotations | ||||
|  | ||||
| import shlex | ||||
| @@ -11,12 +28,38 @@ if TYPE_CHECKING: | ||||
|  | ||||
|  | ||||
| class FalyxCompleter(Completer): | ||||
|     """Completer for Falyx commands.""" | ||||
|     """ | ||||
|     Prompt Toolkit completer for Falyx CLI command input. | ||||
|  | ||||
|     This completer provides real-time, context-aware suggestions for: | ||||
|     - Command keys and aliases (resolved via Falyx._name_map) | ||||
|     - CLI argument flags and values for each command | ||||
|     - Suggestions and choices defined in the associated CommandArgumentParser | ||||
|  | ||||
|     It leverages `CommandArgumentParser.suggest_next()` to compute valid completions | ||||
|     based on current argument state, including: | ||||
|         - Remaining required or optional flags | ||||
|         - Flag value suggestions (choices or custom completions) | ||||
|         - Next positional argument hints | ||||
|  | ||||
|     Args: | ||||
|         falyx (Falyx): The Falyx menu instance containing all command mappings and parsers. | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, falyx: "Falyx"): | ||||
|         self.falyx = falyx | ||||
|  | ||||
|     def get_completions(self, document: Document, complete_event) -> Iterable[Completion]: | ||||
|         """ | ||||
|         Yield completions based on the current document input. | ||||
|  | ||||
|         Args: | ||||
|             document (Document): The prompt_toolkit document containing the input buffer. | ||||
|             complete_event: The completion trigger event (unused). | ||||
|  | ||||
|         Yields: | ||||
|             Completion objects matching command keys or argument suggestions. | ||||
|         """ | ||||
|         text = document.text_before_cursor | ||||
|         try: | ||||
|             tokens = shlex.split(text) | ||||
| @@ -40,6 +83,8 @@ class FalyxCompleter(Completer): | ||||
|         stub = "" if cursor_at_end_of_token else tokens[-1] | ||||
|  | ||||
|         try: | ||||
|             if not command.arg_parser: | ||||
|                 return | ||||
|             suggestions = command.arg_parser.suggest_next( | ||||
|                 parsed_args + ([stub] if stub else []) | ||||
|             ) | ||||
| @@ -50,6 +95,15 @@ class FalyxCompleter(Completer): | ||||
|             return | ||||
|  | ||||
|     def _suggest_commands(self, prefix: str) -> Iterable[Completion]: | ||||
|         """ | ||||
|         Suggest top-level command keys and aliases based on the given prefix. | ||||
|  | ||||
|         Args: | ||||
|             prefix (str): The user input to match against available commands. | ||||
|  | ||||
|         Yields: | ||||
|             Completion: Matching keys or aliases from all registered commands. | ||||
|         """ | ||||
|         prefix = prefix.upper() | ||||
|         keys = [self.falyx.exit_command.key] | ||||
|         keys.extend(self.falyx.exit_command.aliases) | ||||
|   | ||||
| @@ -1,6 +1,41 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """config.py | ||||
| Configuration loader for Falyx CLI commands.""" | ||||
| """ | ||||
| Configuration loader and schema definitions for the Falyx CLI framework. | ||||
|  | ||||
| This module supports config-driven initialization of CLI commands and submenus | ||||
| from YAML or TOML files. It enables declarative command definitions, auto-imports | ||||
| Python callables from dotted paths, and wraps them in `Action` or `Command` objects | ||||
| as needed. | ||||
|  | ||||
| Features: | ||||
| - Parses Falyx command and submenu definitions from YAML or TOML. | ||||
| - Supports hooks, retry policies, confirm prompts, spinners, aliases, and tags. | ||||
| - Dynamically imports Python functions/classes from `action:` strings. | ||||
| - Wraps user callables into Falyx `Command` or `Action` instances. | ||||
| - Validates prompt and retry configuration using `pydantic` models. | ||||
|  | ||||
| Main Components: | ||||
| - `FalyxConfig`: Pydantic model for top-level config structure. | ||||
| - `RawCommand`: Intermediate command definition model from raw config. | ||||
| - `Submenu`: Schema for nested CLI menus. | ||||
| - `loader(path)`: Loads and returns a fully constructed `Falyx` instance. | ||||
|  | ||||
| Typical Config (YAML): | ||||
| ```yaml | ||||
| title: My CLI | ||||
| commands: | ||||
|   - key: A | ||||
|     description: Say hello | ||||
|     action: my_package.tasks.hello | ||||
|     aliases: [hi] | ||||
|     tags: [example] | ||||
| ``` | ||||
|  | ||||
| Example: | ||||
|     from falyx.config import loader | ||||
|     cli = loader("falyx.yaml") | ||||
|     cli.run() | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import importlib | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """Global console instance for Falyx CLI applications.""" | ||||
| from rich.console import Console | ||||
|  | ||||
| from falyx.themes import get_nord_theme | ||||
|   | ||||
| @@ -19,6 +19,7 @@ from __future__ import annotations | ||||
|  | ||||
| import time | ||||
| from datetime import datetime | ||||
| from traceback import format_exception | ||||
| from typing import Any | ||||
|  | ||||
| from pydantic import BaseModel, ConfigDict, Field | ||||
| @@ -75,7 +76,8 @@ class ExecutionContext(BaseModel): | ||||
|     kwargs: dict = Field(default_factory=dict) | ||||
|     action: Any | ||||
|     result: Any | None = None | ||||
|     exception: BaseException | None = None | ||||
|     traceback: str | None = None | ||||
|     _exception: BaseException | None = None | ||||
|  | ||||
|     start_time: float | None = None | ||||
|     end_time: float | None = None | ||||
| @@ -122,6 +124,16 @@ class ExecutionContext(BaseModel): | ||||
|     def status(self) -> str: | ||||
|         return "OK" if self.success else "ERROR" | ||||
|  | ||||
|     @property | ||||
|     def exception(self) -> BaseException | None: | ||||
|         return self._exception | ||||
|  | ||||
|     @exception.setter | ||||
|     def exception(self, exc: BaseException | None): | ||||
|         self._exception = exc | ||||
|         if exc is not None: | ||||
|             self.traceback = "".join(format_exception(exc)).strip() | ||||
|  | ||||
|     @property | ||||
|     def signature(self) -> str: | ||||
|         """ | ||||
| @@ -138,6 +150,7 @@ class ExecutionContext(BaseModel): | ||||
|             "name": self.name, | ||||
|             "result": self.result, | ||||
|             "exception": repr(self.exception) if self.exception else None, | ||||
|             "traceback": self.traceback, | ||||
|             "duration": self.duration, | ||||
|             "extra": self.extra, | ||||
|         } | ||||
|   | ||||
| @@ -1,5 +1,18 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """debug.py""" | ||||
| """ | ||||
| Provides debug logging hooks for Falyx action execution. | ||||
|  | ||||
| This module defines lifecycle hook functions (`log_before`, `log_success`, `log_after`, `log_error`) | ||||
| that can be registered with a `HookManager` to trace command execution. | ||||
|  | ||||
| Logs include: | ||||
| - Action invocation with argument signature | ||||
| - Success result (with truncation for large outputs) | ||||
| - Errors with full exception info | ||||
| - Total runtime duration after execution | ||||
|  | ||||
| Also exports `register_debug_hooks()` to register all log hooks in bulk. | ||||
| """ | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.hook_manager import HookManager, HookType | ||||
| from falyx.logger import logger | ||||
|   | ||||
| @@ -1,5 +1,28 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """exceptions.py""" | ||||
| """ | ||||
| Defines all custom exception classes used in the Falyx CLI framework. | ||||
|  | ||||
| These exceptions provide structured error handling for common failure cases, | ||||
| including command conflicts, invalid actions or hooks, parser errors, and execution guards | ||||
| like circuit breakers or empty workflows. | ||||
|  | ||||
| All exceptions inherit from `FalyxError`, the base exception for the framework. | ||||
|  | ||||
| Exception Hierarchy: | ||||
| - FalyxError | ||||
|     ├── CommandAlreadyExistsError | ||||
|     ├── InvalidHookError | ||||
|     ├── InvalidActionError | ||||
|     ├── NotAFalyxError | ||||
|     ├── CircuitBreakerOpen | ||||
|     ├── EmptyChainError | ||||
|     ├── EmptyGroupError | ||||
|     ├── EmptyPoolError | ||||
|     └── CommandArgumentError | ||||
|  | ||||
| These are raised internally throughout the Falyx system to signal user-facing or | ||||
| developer-facing problems that should be caught and reported. | ||||
| """ | ||||
|  | ||||
|  | ||||
| class FalyxError(Exception): | ||||
|   | ||||
| @@ -1,29 +1,49 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| execution_registry.py | ||||
| Provides the `ExecutionRegistry`, a centralized runtime store for capturing and inspecting | ||||
| the execution history of Falyx actions. | ||||
|  | ||||
| This module provides the `ExecutionRegistry`, a global class for tracking and | ||||
| introspecting the execution history of Falyx actions. | ||||
| The registry automatically records every `ExecutionContext` created during action | ||||
| execution—including context metadata, results, exceptions, duration, and tracebacks. | ||||
| It supports filtering, summarization, and visual inspection via a Rich-rendered table. | ||||
|  | ||||
| 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. | ||||
| Designed for: | ||||
| - Workflow debugging and CLI diagnostics | ||||
| - Interactive history browsing or replaying previous runs | ||||
| - Providing user-visible `history` or `last-result` commands inside CLI apps | ||||
|  | ||||
| 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 | ||||
| Key Features: | ||||
| - Global, in-memory store of all `ExecutionContext` objects (by name, index, or full list) | ||||
| - Thread-safe indexing and summary display | ||||
| - Traceback-aware result inspection and filtering by status (success/error) | ||||
| - Used by built-in `History` command in Falyx CLI | ||||
|  | ||||
| Example: | ||||
|     from falyx.execution_registry import ExecutionRegistry as er | ||||
|  | ||||
|     # Record a context | ||||
|     er.record(context) | ||||
|  | ||||
|     # Display a rich table summary | ||||
|     er.summary() | ||||
|  | ||||
|     # Print the last non-ignored result | ||||
|     er.summary(last_result=True) | ||||
|  | ||||
|     # Clear execution history | ||||
|     er.summary(clear=True) | ||||
|  | ||||
| Note: | ||||
|     The registry is volatile and cleared on each process restart or when `clear()` is called. | ||||
|     All data is retained in memory only. | ||||
|  | ||||
| Public Interface: | ||||
| - record(context): Log an ExecutionContext and assign index. | ||||
| - get_all(): List all stored contexts. | ||||
| - get_by_name(name): Retrieve all contexts by action name. | ||||
| - get_latest(): Retrieve the most recent context. | ||||
| - clear(): Reset the registry. | ||||
| - summary(...): Rich console summary of stored execution results. | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| @@ -46,30 +66,25 @@ 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. | ||||
|     This class captures every `ExecutionContext` created by Falyx Actions, | ||||
|     tracking metadata, results, exceptions, and performance metrics. It enables | ||||
|     rich introspection, post-execution inspection, and formatted summaries | ||||
|     suitable for interactive and headless CLI use. | ||||
|  | ||||
|     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. | ||||
|     Data is retained in memory until cleared or process exit. | ||||
|  | ||||
|     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 | ||||
|         - Auditing chained or dynamic workflows | ||||
|         - Rendering execution history in a help/debug menu | ||||
|         - Accessing previous results or errors for reuse | ||||
|  | ||||
|     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() | ||||
|     Attributes: | ||||
|         _store_by_name (dict): Maps action name → list of ExecutionContext objects. | ||||
|         _store_by_index (dict): Maps numeric index → ExecutionContext. | ||||
|         _store_all (list): Ordered list of all contexts. | ||||
|         _index (int): Global counter for assigning unique execution indices. | ||||
|         _lock (Lock): Thread lock for atomic writes to the registry. | ||||
|         _console (Console): Rich console used for rendering summaries. | ||||
|     """ | ||||
|  | ||||
|     _store_by_name: dict[str, list[ExecutionContext]] = defaultdict(list) | ||||
| @@ -81,7 +96,15 @@ class ExecutionRegistry: | ||||
|  | ||||
|     @classmethod | ||||
|     def record(cls, context: ExecutionContext): | ||||
|         """Record an execution context.""" | ||||
|         """ | ||||
|         Record an execution context and assign a unique index. | ||||
|  | ||||
|         This method logs the context, appends it to the registry, | ||||
|         and makes it available for future summary or filtering. | ||||
|  | ||||
|         Args: | ||||
|             context (ExecutionContext): The context to be tracked. | ||||
|         """ | ||||
|         logger.debug(context.to_log_line()) | ||||
|         with cls._lock: | ||||
|             context.index = cls._index | ||||
| @@ -92,18 +115,44 @@ class ExecutionRegistry: | ||||
|  | ||||
|     @classmethod | ||||
|     def get_all(cls) -> list[ExecutionContext]: | ||||
|         """ | ||||
|         Return all recorded execution contexts in order of execution. | ||||
|  | ||||
|         Returns: | ||||
|             list[ExecutionContext]: All stored action contexts. | ||||
|         """ | ||||
|         return cls._store_all | ||||
|  | ||||
|     @classmethod | ||||
|     def get_by_name(cls, name: str) -> list[ExecutionContext]: | ||||
|         """ | ||||
|         Retrieve all executions recorded under a given action name. | ||||
|  | ||||
|         Args: | ||||
|             name (str): The name of the action. | ||||
|  | ||||
|         Returns: | ||||
|             list[ExecutionContext]: Matching contexts, or empty if none found. | ||||
|         """ | ||||
|         return cls._store_by_name.get(name, []) | ||||
|  | ||||
|     @classmethod | ||||
|     def get_latest(cls) -> ExecutionContext: | ||||
|         """ | ||||
|         Return the most recent execution context. | ||||
|  | ||||
|         Returns: | ||||
|             ExecutionContext: The last recorded context. | ||||
|         """ | ||||
|         return cls._store_all[-1] | ||||
|  | ||||
|     @classmethod | ||||
|     def clear(cls): | ||||
|         """ | ||||
|         Clear all stored execution data and reset internal indices. | ||||
|  | ||||
|         This operation is destructive and cannot be undone. | ||||
|         """ | ||||
|         cls._store_by_name.clear() | ||||
|         cls._store_all.clear() | ||||
|         cls._store_by_index.clear() | ||||
| @@ -118,6 +167,21 @@ class ExecutionRegistry: | ||||
|         last_result: bool = False, | ||||
|         status: Literal["all", "success", "error"] = "all", | ||||
|     ): | ||||
|         """ | ||||
|         Display a formatted Rich table of recorded executions. | ||||
|  | ||||
|         Supports filtering by action name, index, or execution status. | ||||
|         Can optionally show only the last result or a specific indexed result. | ||||
|         Also supports clearing the registry interactively. | ||||
|  | ||||
|         Args: | ||||
|             name (str): Filter by action name. | ||||
|             index (int | None): Filter by specific execution index. | ||||
|             result_index (int | None): Print result (or traceback) of a specific index. | ||||
|             clear (bool): If True, clears the registry and exits. | ||||
|             last_result (bool): If True, prints only the most recent result. | ||||
|             status (Literal): One of "all", "success", or "error" to filter displayed rows. | ||||
|         """ | ||||
|         if clear: | ||||
|             cls.clear() | ||||
|             cls._console.print(f"[{OneColors.GREEN}]✅ Execution history cleared.") | ||||
| @@ -125,13 +189,7 @@ class ExecutionRegistry: | ||||
|  | ||||
|         if last_result: | ||||
|             for ctx in reversed(cls._store_all): | ||||
|                 if ctx.name.upper() not in [ | ||||
|                     "HISTORY", | ||||
|                     "HELP", | ||||
|                     "EXIT", | ||||
|                     "VIEW EXECUTION HISTORY", | ||||
|                     "BACK", | ||||
|                 ]: | ||||
|                 if not ctx.action.ignore_in_history: | ||||
|                     cls._console.print(ctx.result) | ||||
|                     return | ||||
|             cls._console.print( | ||||
| @@ -148,8 +206,8 @@ class ExecutionRegistry: | ||||
|                 ) | ||||
|                 return | ||||
|             cls._console.print(f"{result_context.signature}:") | ||||
|             if result_context.exception: | ||||
|                 cls._console.print(result_context.exception) | ||||
|             if result_context.traceback: | ||||
|                 cls._console.print(result_context.traceback) | ||||
|             else: | ||||
|                 cls._console.print(result_context.result) | ||||
|             return | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """Main class for constructing and running Falyx CLI menus. | ||||
| """ | ||||
| Main class for constructing and running Falyx CLI menus. | ||||
|  | ||||
| Falyx provides a structured, customizable interactive menu system | ||||
| for running commands, actions, and workflows. It supports: | ||||
| @@ -75,7 +76,7 @@ class FalyxMode(Enum): | ||||
|  | ||||
|  | ||||
| class CommandValidator(Validator): | ||||
|     """Validator to check if the input is a valid command or toggle key.""" | ||||
|     """Validator to check if the input is a valid command.""" | ||||
|  | ||||
|     def __init__(self, falyx: Falyx, error_message: str) -> None: | ||||
|         super().__init__() | ||||
| @@ -295,6 +296,7 @@ class Falyx: | ||||
|             aliases=["EXIT", "QUIT"], | ||||
|             style=OneColors.DARK_RED, | ||||
|             simple_help_signature=True, | ||||
|             ignore_in_history=True, | ||||
|         ) | ||||
|  | ||||
|     def _get_history_command(self) -> Command: | ||||
| @@ -347,6 +349,7 @@ class Falyx: | ||||
|             style=OneColors.DARK_YELLOW, | ||||
|             arg_parser=parser, | ||||
|             help_text="View the execution history of commands.", | ||||
|             ignore_in_history=True, | ||||
|         ) | ||||
|  | ||||
|     async def _show_help(self, tag: str = "") -> None: | ||||
| @@ -410,6 +413,7 @@ class Falyx: | ||||
|             action=Action("Help", self._show_help), | ||||
|             style=OneColors.LIGHT_YELLOW, | ||||
|             arg_parser=parser, | ||||
|             ignore_in_history=True, | ||||
|         ) | ||||
|  | ||||
|     def _get_completer(self) -> FalyxCompleter: | ||||
| @@ -417,7 +421,7 @@ class Falyx: | ||||
|         return FalyxCompleter(self) | ||||
|  | ||||
|     def _get_validator_error_message(self) -> str: | ||||
|         """Validator to check if the input is a valid command or toggle key.""" | ||||
|         """Validator to check if the input is a valid command.""" | ||||
|         keys = {self.exit_command.key.upper()} | ||||
|         keys.update({alias.upper() for alias in self.exit_command.aliases}) | ||||
|         if self.history_command: | ||||
| @@ -431,19 +435,12 @@ class Falyx: | ||||
|             keys.add(cmd.key.upper()) | ||||
|             keys.update({alias.upper() for alias in cmd.aliases}) | ||||
|  | ||||
|         if isinstance(self._bottom_bar, BottomBar): | ||||
|             toggle_keys = {key.upper() for key in self._bottom_bar.toggle_keys} | ||||
|         else: | ||||
|             toggle_keys = set() | ||||
|  | ||||
|         commands_str = ", ".join(sorted(keys)) | ||||
|         toggles_str = ", ".join(sorted(toggle_keys)) | ||||
|  | ||||
|         message_lines = ["Invalid input. Available keys:"] | ||||
|         if keys: | ||||
|             message_lines.append(f"  Commands: {commands_str}") | ||||
|         if toggle_keys: | ||||
|             message_lines.append(f"  Toggles: {toggles_str}") | ||||
|  | ||||
|         error_message = " ".join(message_lines) | ||||
|         return error_message | ||||
|  | ||||
| @@ -473,10 +470,9 @@ class Falyx: | ||||
|         """Sets the bottom bar for the menu.""" | ||||
|         if bottom_bar is None: | ||||
|             self._bottom_bar: BottomBar | str | Callable[[], Any] = BottomBar( | ||||
|                 self.columns, self.key_bindings, key_validator=self.is_key_available | ||||
|                 self.columns, self.key_bindings | ||||
|             ) | ||||
|         elif isinstance(bottom_bar, BottomBar): | ||||
|             bottom_bar.key_validator = self.is_key_available | ||||
|             bottom_bar.key_bindings = self.key_bindings | ||||
|             self._bottom_bar = bottom_bar | ||||
|         elif isinstance(bottom_bar, str) or callable(bottom_bar): | ||||
| @@ -544,32 +540,9 @@ class Falyx: | ||||
|         for key, command in self.commands.items(): | ||||
|             logger.debug("[Command '%s'] hooks:\n%s", key, str(command.hooks)) | ||||
|  | ||||
|     def is_key_available(self, key: str) -> bool: | ||||
|         key = key.upper() | ||||
|         toggles = ( | ||||
|             self._bottom_bar.toggle_keys | ||||
|             if isinstance(self._bottom_bar, BottomBar) | ||||
|             else [] | ||||
|         ) | ||||
|  | ||||
|         conflicts = ( | ||||
|             key in self.commands, | ||||
|             key == self.exit_command.key.upper(), | ||||
|             self.history_command and key == self.history_command.key.upper(), | ||||
|             self.help_command and key == self.help_command.key.upper(), | ||||
|             key in toggles, | ||||
|         ) | ||||
|  | ||||
|         return not any(conflicts) | ||||
|  | ||||
|     def _validate_command_key(self, key: str) -> None: | ||||
|         """Validates the command key to ensure it is unique.""" | ||||
|         key = key.upper() | ||||
|         toggles = ( | ||||
|             self._bottom_bar.toggle_keys | ||||
|             if isinstance(self._bottom_bar, BottomBar) | ||||
|             else [] | ||||
|         ) | ||||
|         collisions = [] | ||||
|  | ||||
|         if key in self.commands: | ||||
| @@ -580,8 +553,6 @@ class Falyx: | ||||
|             collisions.append("history command") | ||||
|         if self.help_command and key == self.help_command.key.upper(): | ||||
|             collisions.append("help command") | ||||
|         if key in toggles: | ||||
|             collisions.append("toggle") | ||||
|  | ||||
|         if collisions: | ||||
|             raise CommandAlreadyExistsError( | ||||
| @@ -611,6 +582,7 @@ class Falyx: | ||||
|             style=style, | ||||
|             confirm=confirm, | ||||
|             confirm_message=confirm_message, | ||||
|             ignore_in_history=True, | ||||
|         ) | ||||
|  | ||||
|     def add_submenu( | ||||
| @@ -685,6 +657,7 @@ class Falyx: | ||||
|         auto_args: bool = True, | ||||
|         arg_metadata: dict[str, str | dict[str, Any]] | None = None, | ||||
|         simple_help_signature: bool = False, | ||||
|         ignore_in_history: bool = False, | ||||
|     ) -> Command: | ||||
|         """Adds an command to the menu, preventing duplicates.""" | ||||
|         self._validate_command_key(key) | ||||
| @@ -729,6 +702,7 @@ class Falyx: | ||||
|             auto_args=auto_args, | ||||
|             arg_metadata=arg_metadata or {}, | ||||
|             simple_help_signature=simple_help_signature, | ||||
|             ignore_in_history=ignore_in_history, | ||||
|         ) | ||||
|  | ||||
|         if hooks: | ||||
|   | ||||
| @@ -1,5 +1,21 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """hook_manager.py""" | ||||
| """ | ||||
| Defines the `HookManager` and `HookType` used in the Falyx CLI framework to manage | ||||
| execution lifecycle hooks around actions and commands. | ||||
|  | ||||
| The hook system enables structured callbacks for important stages in a Falyx action's | ||||
| execution, such as before execution, after success, upon error, and teardown. These | ||||
| can be used for logging, side effects, diagnostics, metrics, and rollback logic. | ||||
|  | ||||
| Key Components: | ||||
| - HookType: Enum categorizing supported hook lifecycle stages | ||||
| - HookManager: Core class for registering and invoking hooks during action execution | ||||
| - Hook: Union of sync and async callables accepting an `ExecutionContext` | ||||
|  | ||||
| Usage: | ||||
|     hooks = HookManager() | ||||
|     hooks.register(HookType.BEFORE, log_before) | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import inspect | ||||
| @@ -15,7 +31,27 @@ Hook = Union[ | ||||
|  | ||||
|  | ||||
| class HookType(Enum): | ||||
|     """Enum for hook types to categorize the hooks.""" | ||||
|     """ | ||||
|     Enum for supported hook lifecycle phases in Falyx. | ||||
|  | ||||
|     HookType is used to classify lifecycle events that can be intercepted | ||||
|     with user-defined callbacks. | ||||
|  | ||||
|     Members: | ||||
|         BEFORE: Run before the action is invoked. | ||||
|         ON_SUCCESS: Run after successful completion. | ||||
|         ON_ERROR: Run when an exception occurs. | ||||
|         AFTER: Run after success or failure (always runs). | ||||
|         ON_TEARDOWN: Run at the very end, for resource cleanup. | ||||
|  | ||||
|     Aliases: | ||||
|         "success" → "on_success" | ||||
|         "error" → "on_error" | ||||
|         "teardown" → "on_teardown" | ||||
|  | ||||
|     Example: | ||||
|         HookType("error") → HookType.ON_ERROR | ||||
|     """ | ||||
|  | ||||
|     BEFORE = "before" | ||||
|     ON_SUCCESS = "on_success" | ||||
| @@ -28,13 +64,49 @@ class HookType(Enum): | ||||
|         """Return a list of all hook type choices.""" | ||||
|         return list(cls) | ||||
|  | ||||
|     @classmethod | ||||
|     def _get_alias(cls, value: str) -> str: | ||||
|         aliases = { | ||||
|             "success": "on_success", | ||||
|             "error": "on_error", | ||||
|             "teardown": "on_teardown", | ||||
|         } | ||||
|         return aliases.get(value, value) | ||||
|  | ||||
|     @classmethod | ||||
|     def _missing_(cls, value: object) -> HookType: | ||||
|         if not isinstance(value, str): | ||||
|             raise ValueError(f"Invalid {cls.__name__}: {value!r}") | ||||
|         normalized = value.strip().lower() | ||||
|         alias = cls._get_alias(normalized) | ||||
|         for member in cls: | ||||
|             if member.value == alias: | ||||
|                 return member | ||||
|         valid = ", ".join(member.value for member in cls) | ||||
|         raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}") | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         """Return the string representation of the hook type.""" | ||||
|         return self.value | ||||
|  | ||||
|  | ||||
| class HookManager: | ||||
|     """HookManager""" | ||||
|     """ | ||||
|     Manages lifecycle hooks for a command or action. | ||||
|  | ||||
|     `HookManager` tracks user-defined callbacks to be run at key points in a command's | ||||
|     lifecycle: before execution, on success, on error, after completion, and during | ||||
|     teardown. Both sync and async hooks are supported. | ||||
|  | ||||
|     Methods: | ||||
|         register(hook_type, hook): Register a callable for a given HookType. | ||||
|         clear(hook_type): Remove hooks for one or all lifecycle stages. | ||||
|         trigger(hook_type, context): Execute all hooks of a given type. | ||||
|  | ||||
|     Example: | ||||
|         hooks = HookManager() | ||||
|         hooks.register(HookType.BEFORE, my_logger) | ||||
|     """ | ||||
|  | ||||
|     def __init__(self) -> None: | ||||
|         self._hooks: dict[HookType, list[Hook]] = { | ||||
| @@ -42,12 +114,26 @@ class HookManager: | ||||
|         } | ||||
|  | ||||
|     def register(self, hook_type: HookType | str, hook: Hook): | ||||
|         """Raises ValueError if the hook type is not supported.""" | ||||
|         if not isinstance(hook_type, HookType): | ||||
|             hook_type = HookType(hook_type) | ||||
|         """ | ||||
|         Register a new hook for a given lifecycle phase. | ||||
|  | ||||
|         Args: | ||||
|             hook_type (HookType | str): The hook category (e.g. "before", "on_success"). | ||||
|             hook (Callable): The hook function to register. | ||||
|  | ||||
|         Raises: | ||||
|             ValueError: If the hook type is invalid. | ||||
|         """ | ||||
|         hook_type = HookType(hook_type) | ||||
|         self._hooks[hook_type].append(hook) | ||||
|  | ||||
|     def clear(self, hook_type: HookType | None = None): | ||||
|         """ | ||||
|         Clear registered hooks for one or all hook types. | ||||
|  | ||||
|         Args: | ||||
|             hook_type (HookType | None): If None, clears all hooks. | ||||
|         """ | ||||
|         if hook_type: | ||||
|             self._hooks[hook_type] = [] | ||||
|         else: | ||||
| @@ -55,6 +141,17 @@ class HookManager: | ||||
|                 self._hooks[ht] = [] | ||||
|  | ||||
|     async def trigger(self, hook_type: HookType, context: ExecutionContext): | ||||
|         """ | ||||
|         Invoke all hooks registered for a given lifecycle phase. | ||||
|  | ||||
|         Args: | ||||
|             hook_type (HookType): The lifecycle phase to trigger. | ||||
|             context (ExecutionContext): The execution context passed to each hook. | ||||
|  | ||||
|         Raises: | ||||
|             Exception: Re-raises the original context.exception if a hook fails during | ||||
|                        ON_ERROR. Other hook exceptions are logged and skipped. | ||||
|         """ | ||||
|         if hook_type not in self._hooks: | ||||
|             raise ValueError(f"Unsupported hook type: {hook_type}") | ||||
|         for hook in self._hooks[hook_type]: | ||||
| @@ -71,7 +168,6 @@ class HookManager: | ||||
|                     context.name, | ||||
|                     hook_error, | ||||
|                 ) | ||||
|  | ||||
|                 if hook_type == HookType.ON_ERROR: | ||||
|                     assert isinstance( | ||||
|                         context.exception, Exception | ||||
|   | ||||
| @@ -1,5 +1,30 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """hooks.py""" | ||||
| """ | ||||
| Defines reusable lifecycle hooks for Falyx Actions and Commands. | ||||
|  | ||||
| This module includes: | ||||
| - `ResultReporter`: A success hook that displays a formatted result with duration. | ||||
| - `CircuitBreaker`: A failure-aware hook manager that prevents repeated execution | ||||
|   after a configurable number of failures. | ||||
|  | ||||
| These hooks can be registered on `HookManager` instances via lifecycle stages | ||||
| (`before`, `on_error`, `after`, etc.) to enhance resiliency and observability. | ||||
|  | ||||
| Intended for use with: | ||||
| - Retryable or unstable actions | ||||
| - Interactive CLI feedback | ||||
| - Safety checks prior to execution | ||||
|  | ||||
| Example usage: | ||||
|     breaker = CircuitBreaker(max_failures=3) | ||||
|     hooks.register(HookType.BEFORE, breaker.before_hook) | ||||
|     hooks.register(HookType.ON_ERROR, breaker.error_hook) | ||||
|     hooks.register(HookType.AFTER, breaker.after_hook) | ||||
|  | ||||
|     reporter = ResultReporter() | ||||
|     hooks.register(HookType.ON_SUCCESS, reporter.report) | ||||
| """ | ||||
|  | ||||
| import time | ||||
| from typing import Any, Callable | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,23 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """init.py""" | ||||
| """ | ||||
| Project and global initializer for Falyx CLI environments. | ||||
|  | ||||
| This module defines functions to bootstrap a new Falyx-based CLI project or | ||||
| create a global user-level configuration in `~/.config/falyx`. | ||||
|  | ||||
| Functions: | ||||
| - `init_project(name: str)`: Creates a new CLI project folder with `tasks.py` | ||||
|   and `falyx.yaml` using example actions and config structure. | ||||
| - `init_global()`: Creates a shared config in the user's home directory for | ||||
|   defining reusable or always-available CLI commands. | ||||
|  | ||||
| Generated files include: | ||||
| - `tasks.py`: Python module with `Action`, `ChainedAction`, and async examples | ||||
| - `falyx.yaml`: YAML config with command definitions for CLI entry points | ||||
|  | ||||
| Used by: | ||||
| - The `falyx init` and `falyx init --global` commands | ||||
| """ | ||||
| from pathlib import Path | ||||
|  | ||||
| from falyx.console import console | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """logger.py""" | ||||
| """Global logger instance for Falyx CLI applications.""" | ||||
| import logging | ||||
|  | ||||
| logger: logging.Logger = logging.getLogger("falyx") | ||||
|   | ||||
| @@ -1,3 +1,19 @@ | ||||
| """ | ||||
| Defines `MenuOption` and `MenuOptionMap`, core components used to construct | ||||
| interactive menus within Falyx Actions such as `MenuAction` and `PromptMenuAction`. | ||||
|  | ||||
| Each `MenuOption` represents a single actionable choice with a description, | ||||
| styling, and a bound `BaseAction`. `MenuOptionMap` manages collections of these | ||||
| options, including support for reserved keys like `B` (Back) and `X` (Exit), which | ||||
| can trigger navigation signals when selected. | ||||
|  | ||||
| These constructs enable declarative and reusable menu definitions in both code and config. | ||||
|  | ||||
| Key Components: | ||||
| - MenuOption: A user-facing label and action binding | ||||
| - MenuOptionMap: A key-aware container for menu options, with reserved entry support | ||||
| """ | ||||
|  | ||||
| from __future__ import annotations | ||||
|  | ||||
| from dataclasses import dataclass | ||||
| @@ -12,7 +28,25 @@ from falyx.utils import CaseInsensitiveDict | ||||
|  | ||||
| @dataclass | ||||
| class MenuOption: | ||||
|     """Represents a single menu option with a description and an action to execute.""" | ||||
|     """ | ||||
|     Represents a single menu entry, including its label and associated action. | ||||
|  | ||||
|     Used in conjunction with `MenuOptionMap` to define interactive command menus. | ||||
|     Each `MenuOption` contains a description (shown to the user), a `BaseAction` | ||||
|     to execute when selected, and an optional Rich-compatible style. | ||||
|  | ||||
|     Attributes: | ||||
|         description (str): The label shown next to the menu key. | ||||
|         action (BaseAction): The action to invoke when selected. | ||||
|         style (str): A Rich-compatible color/style string for UI display. | ||||
|  | ||||
|     Methods: | ||||
|         render(key): Returns a Rich-formatted string for menu display. | ||||
|         render_prompt(key): Returns a `FormattedText` object for use in prompt placeholders. | ||||
|  | ||||
|     Raises: | ||||
|         TypeError: If `description` is not a string or `action` is not a `BaseAction`. | ||||
|     """ | ||||
|  | ||||
|     description: str | ||||
|     action: BaseAction | ||||
| @@ -37,8 +71,27 @@ class MenuOption: | ||||
|  | ||||
| class MenuOptionMap(CaseInsensitiveDict): | ||||
|     """ | ||||
|     Manages menu options including validation, reserved key protection, | ||||
|     and special signal entries like Quit and Back. | ||||
|     A container for storing and managing `MenuOption` objects by key. | ||||
|  | ||||
|     `MenuOptionMap` is used to define the set of available choices in a | ||||
|     Falyx menu. Keys are case-insensitive and mapped to `MenuOption` instances. | ||||
|     The map supports special reserved keys—`B` for Back and `X` for Exit—unless | ||||
|     explicitly disabled via `allow_reserved=False`. | ||||
|  | ||||
|     This class enforces strict typing of menu options and prevents accidental | ||||
|     overwrites of reserved keys. | ||||
|  | ||||
|     Args: | ||||
|         options (dict[str, MenuOption] | None): Initial options to populate the menu. | ||||
|         allow_reserved (bool): If True, allows overriding reserved keys. | ||||
|  | ||||
|     Methods: | ||||
|         items(include_reserved): Returns an iterable of menu options, | ||||
|                                  optionally filtering out reserved keys. | ||||
|  | ||||
|     Raises: | ||||
|         TypeError: If non-`MenuOption` values are assigned. | ||||
|         ValueError: If attempting to use or delete a reserved key without permission. | ||||
|     """ | ||||
|  | ||||
|     RESERVED_KEYS = {"B", "X"} | ||||
|   | ||||
| @@ -1,5 +1,34 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """options_manager.py""" | ||||
| """ | ||||
| Manages global or scoped CLI options across namespaces for Falyx commands. | ||||
|  | ||||
| The `OptionsManager` provides a centralized interface for retrieving, setting, toggling, | ||||
| and introspecting options defined in `argparse.Namespace` objects. It is used internally | ||||
| by Falyx to pass and resolve runtime flags like `--verbose`, `--force-confirm`, etc. | ||||
|  | ||||
| Each option is stored under a namespace key (e.g., "cli_args", "user_config") to | ||||
| support multiple sources of configuration. | ||||
|  | ||||
| Key Features: | ||||
| - Safe getter/setter for typed option resolution | ||||
| - Toggle support for boolean options (used by bottom bar toggles, etc.) | ||||
| - Callable getter/toggler wrappers for dynamic UI bindings | ||||
| - Namespace merging via `from_namespace` | ||||
|  | ||||
| Typical Usage: | ||||
|     options = OptionsManager() | ||||
|     options.from_namespace(args, namespace_name="cli_args") | ||||
|     if options.get("verbose"): | ||||
|         ... | ||||
|     options.toggle("force_confirm") | ||||
|     value_fn = options.get_value_getter("dry_run") | ||||
|     toggle_fn = options.get_toggle_function("debug") | ||||
|  | ||||
| Used by: | ||||
| - Falyx CLI runtime configuration | ||||
| - Bottom bar toggles | ||||
| - Dynamic flag injection into commands and actions | ||||
| """ | ||||
|  | ||||
| from argparse import Namespace | ||||
| from collections import defaultdict | ||||
| @@ -9,7 +38,13 @@ from falyx.logger import logger | ||||
|  | ||||
|  | ||||
| class OptionsManager: | ||||
|     """OptionsManager""" | ||||
|     """ | ||||
|     Manages CLI option state across multiple argparse namespaces. | ||||
|  | ||||
|     Allows dynamic retrieval, setting, toggling, and introspection of command-line | ||||
|     options. Supports named namespaces (e.g., "cli_args") and is used throughout | ||||
|     Falyx for runtime configuration and bottom bar toggle integration. | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, namespaces: list[tuple[str, Namespace]] | None = None) -> None: | ||||
|         self.options: defaultdict = defaultdict(Namespace) | ||||
|   | ||||
| @@ -1,5 +1,38 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """argument.py""" | ||||
| """ | ||||
| Defines the `Argument` dataclass used by `CommandArgumentParser` to represent | ||||
| individual command-line parameters in a structured, introspectable format. | ||||
|  | ||||
| Each `Argument` instance describes one CLI input, including its flags, type, | ||||
| default behavior, action semantics, help text, and optional resolver integration | ||||
| for dynamic evaluation. | ||||
|  | ||||
| Falyx uses this structure to support a declarative CLI design, providing flexible | ||||
| argument parsing with full support for positional and keyword arguments, coercion, | ||||
| completion, and help rendering. | ||||
|  | ||||
| Arguments should be created using `CommandArgumentParser.add_argument()` | ||||
| or defined in YAML configurations, allowing for rich introspection and validation. | ||||
|  | ||||
| Key Attributes: | ||||
| - `flags`: One or more short/long flags (e.g. `-v`, `--verbose`) | ||||
| - `dest`: Internal name used as the key in parsed results | ||||
| - `action`: `ArgumentAction` enum describing behavior (store, count, resolve, etc.) | ||||
| - `type`: Type coercion or callable converter | ||||
| - `default`: Optional fallback value | ||||
| - `choices`: Allowed values, if restricted | ||||
| - `nargs`: Number of expected values (`int`, `'?'`, `'*'`, `'+'`) | ||||
| - `positional`: Whether this argument is positional (no flag) | ||||
| - `resolver`: Optional `BaseAction` to resolve argument value dynamically | ||||
| - `lazy_resolver`: Whether to defer resolution until needed | ||||
| - `suggestions`: Optional completions for interactive shells | ||||
|  | ||||
| Used By: | ||||
| - `CommandArgumentParser` | ||||
| - `Falyx` runtime parsing | ||||
| - Rich-based CLI help generation | ||||
| - Completion and preview suggestions | ||||
| """ | ||||
| from dataclasses import dataclass | ||||
| from typing import Any | ||||
|  | ||||
| @@ -26,7 +59,7 @@ class Argument: | ||||
|         resolver (BaseAction | None): | ||||
|             An action object that resolves the argument, if applicable. | ||||
|         lazy_resolver (bool): True if the resolver should be called lazily, False otherwise | ||||
|         suggestions (list[str] | None): A list of suggestions for the argument. | ||||
|         suggestions (list[str] | None): Optional completions for interactive shells | ||||
|     """ | ||||
|  | ||||
|     flags: tuple[str, ...] | ||||
|   | ||||
| @@ -1,12 +1,55 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """argument_action.py""" | ||||
| """ | ||||
| Defines `ArgumentAction`, an enum used to standardize the behavior of CLI arguments | ||||
| defined within Falyx command configurations. | ||||
|  | ||||
| Each member of this enum maps to a valid `argparse` like actions or Falyx-specific | ||||
| behavior used during command argument parsing. This allows declarative configuration | ||||
| of argument behavior when building CLI commands via `CommandArgumentParser`. | ||||
|  | ||||
| Supports alias coercion for shorthand or config-friendly values, and provides | ||||
| a consistent interface for downstream argument handling logic. | ||||
|  | ||||
| Exports: | ||||
|     - ArgumentAction: Enum of allowed actions for command arguments. | ||||
|  | ||||
| Example: | ||||
|     ArgumentAction("store_true") → ArgumentAction.STORE_TRUE | ||||
|     ArgumentAction("true")       → ArgumentAction.STORE_TRUE (via alias) | ||||
|     ArgumentAction("optional")   → ArgumentAction.STORE_BOOL_OPTIONAL | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from enum import Enum | ||||
|  | ||||
|  | ||||
| class ArgumentAction(Enum): | ||||
|     """Defines the action to be taken when the argument is encountered.""" | ||||
|     """ | ||||
|     Defines the action to be taken when the argument is encountered. | ||||
|  | ||||
|     This enum mirrors the core behavior of Python's `argparse` actions, with a few | ||||
|     Falyx-specific extensions. It is used when defining command-line arguments for | ||||
|     `CommandArgumentParser` or YAML-based argument definitions. | ||||
|  | ||||
|     Members: | ||||
|         ACTION: Invoke a callable as the argument handler (Falyx extension). | ||||
|         STORE: Store the provided value (default). | ||||
|         STORE_TRUE: Store `True` if the flag is present. | ||||
|         STORE_FALSE: Store `False` if the flag is present. | ||||
|         STORE_BOOL_OPTIONAL: Accept an optional bool (e.g., `--debug` or `--no-debug`). | ||||
|         APPEND: Append the value to a list. | ||||
|         EXTEND: Extend a list with multiple values. | ||||
|         COUNT: Count the number of occurrences. | ||||
|         HELP: Display help and exit. | ||||
|  | ||||
|     Aliases: | ||||
|         - "true" → "store_true" | ||||
|         - "false" → "store_false" | ||||
|         - "optional" → "store_bool_optional" | ||||
|  | ||||
|     Example: | ||||
|         ArgumentAction("true") → ArgumentAction.STORE_TRUE | ||||
|     """ | ||||
|  | ||||
|     ACTION = "action" | ||||
|     STORE = "store" | ||||
| @@ -23,6 +66,27 @@ class ArgumentAction(Enum): | ||||
|         """Return a list of all argument actions.""" | ||||
|         return list(cls) | ||||
|  | ||||
|     @classmethod | ||||
|     def _get_alias(cls, value: str) -> str: | ||||
|         aliases = { | ||||
|             "optional": "store_bool_optional", | ||||
|             "true": "store_true", | ||||
|             "false": "store_false", | ||||
|         } | ||||
|         return aliases.get(value, value) | ||||
|  | ||||
|     @classmethod | ||||
|     def _missing_(cls, value: object) -> ArgumentAction: | ||||
|         if not isinstance(value, str): | ||||
|             raise ValueError(f"Invalid {cls.__name__}: {value!r}") | ||||
|         normalized = value.strip().lower() | ||||
|         alias = cls._get_alias(normalized) | ||||
|         for member in cls: | ||||
|             if member.value == alias: | ||||
|                 return member | ||||
|         valid = ", ".join(member.value for member in cls) | ||||
|         raise ValueError(f"Invalid {cls.__name__}: '{value}'. Must be one of: {valid}") | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         """Return the string representation of the argument action.""" | ||||
|         return self.value | ||||
|   | ||||
| @@ -1,5 +1,49 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """command_argument_parser.py""" | ||||
| """ | ||||
| This module implements `CommandArgumentParser`, a flexible, rich-aware alternative to | ||||
| argparse tailored specifically for Falyx CLI workflows. It provides structured parsing, | ||||
| type coercion, flag support, and usage/help rendering for CLI-defined commands. | ||||
|  | ||||
| Unlike argparse, this parser is lightweight, introspectable, and designed to integrate | ||||
| deeply with Falyx's Action system, including support for lazy execution and resolver | ||||
| binding via `BaseAction`. | ||||
|  | ||||
| Key Features: | ||||
| - Declarative argument registration via `add_argument()` | ||||
| - Support for positional and keyword flags, type coercion, default values | ||||
| - Enum- and action-driven argument semantics via `ArgumentAction` | ||||
| - Lazy evaluation of arguments using Falyx `Action` resolvers | ||||
| - Optional value completion via suggestions and choices | ||||
| - Rich-powered help rendering with grouped display | ||||
| - Optional boolean flags via `--flag` / `--no-flag` | ||||
| - POSIX-style bundling for single-character flags (`-abc`) | ||||
| - Partial parsing for completions and validation via `suggest_next()` | ||||
|  | ||||
| Public Interface: | ||||
| - `add_argument(...)`: Register a new argument with type, flags, and behavior. | ||||
| - `parse_args(...)`: Parse CLI-style argument list into a `dict[str, Any]`. | ||||
| - `parse_args_split(...)`: Return `(*args, **kwargs)` for Action invocation. | ||||
| - `render_help()`: Render a rich-styled help panel. | ||||
| - `suggest_next(...)`: Return suggested flags or values for completion. | ||||
|  | ||||
| Example Usage: | ||||
|     parser = CommandArgumentParser(command_key="D") | ||||
|     parser.add_argument("--env", choices=["prod", "dev"], required=True) | ||||
|     parser.add_argument("path", type=Path) | ||||
|  | ||||
|     args = await parser.parse_args(["--env", "prod", "./config.yml"]) | ||||
|  | ||||
|     # args == {'env': 'prod', 'path': Path('./config.yml')} | ||||
|  | ||||
|     parser.render_help()  # Pretty Rich output | ||||
|  | ||||
| Design Notes: | ||||
| This parser intentionally omits argparse-style groups, metavar support, | ||||
| and complex multi-level conflict handling. Instead, it favors: | ||||
| - Simplicity | ||||
| - Completeness | ||||
| - Falyx-specific integration (hooks, lifecycle, and error surfaces) | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from collections import defaultdict | ||||
| @@ -407,26 +451,25 @@ class CommandArgumentParser: | ||||
|         lazy_resolver: bool = True, | ||||
|         suggestions: list[str] | None = None, | ||||
|     ) -> None: | ||||
|         """Add an argument to the parser. | ||||
|         For `ArgumentAction.ACTION`, `nargs` and `type` determine how many and what kind | ||||
|         of inputs are passed to the `resolver`. | ||||
|         """ | ||||
|         Define a new argument for the parser. | ||||
|  | ||||
|         The return value of the `resolver` is used directly (no type coercion is applied). | ||||
|         Validation, structure, and post-processing should be handled within the `resolver`. | ||||
|         Supports positional and flagged arguments, type coercion, default values, | ||||
|         validation rules, and optional resolution via `BaseAction`. | ||||
|  | ||||
|         Args: | ||||
|             name or flags: Either a name or prefixed flags (e.g. 'faylx', '-f', '--falyx'). | ||||
|             action: The action to be taken when the argument is encountered. | ||||
|             nargs: The number of arguments expected. | ||||
|             default: The default value if the argument is not provided. | ||||
|             type: The type to which the command-line argument should be converted. | ||||
|             choices: A container of the allowable values for the argument. | ||||
|             required: Whether or not the argument is required. | ||||
|             help: A brief description of the argument. | ||||
|             dest: The name of the attribute to be added to the object returned by parse_args(). | ||||
|             resolver: A BaseAction called with optional nargs specified parsed arguments. | ||||
|             lazy_resolver: If True, the resolver is called lazily when the argument is accessed. | ||||
|             suggestions: A list of suggestions for the argument. | ||||
|             *flags (str): The flag(s) or name identifying the argument (e.g., "-v", "--verbose"). | ||||
|             action (str | ArgumentAction): The argument action type (default: "store"). | ||||
|             nargs (int | str | None): Number of values the argument consumes. | ||||
|             default (Any): Default value if the argument is not provided. | ||||
|             type (type): Type to coerce argument values to. | ||||
|             choices (Iterable | None): Optional set of allowed values. | ||||
|             required (bool): Whether this argument is mandatory. | ||||
|             help (str): Help text for rendering in command help. | ||||
|             dest (str | None): Custom destination key in result dict. | ||||
|             resolver (BaseAction | None): If action="action", the BaseAction to call. | ||||
|             lazy_resolver (bool): If True, resolver defers until action is triggered. | ||||
|             suggestions (list[str] | None): Optional suggestions for interactive completion. | ||||
|         """ | ||||
|         expected_type = type | ||||
|         self._validate_flags(flags) | ||||
| @@ -486,9 +529,24 @@ class CommandArgumentParser: | ||||
|             self._register_argument(argument) | ||||
|  | ||||
|     def get_argument(self, dest: str) -> Argument | None: | ||||
|         """ | ||||
|         Return the Argument object for a given destination name. | ||||
|  | ||||
|         Args: | ||||
|             dest (str): Destination key of the argument. | ||||
|  | ||||
|         Returns: | ||||
|             Argument or None: Matching Argument instance, if defined. | ||||
|         """ | ||||
|         return next((a for a in self._arguments if a.dest == dest), None) | ||||
|  | ||||
|     def to_definition_list(self) -> list[dict[str, Any]]: | ||||
|         """ | ||||
|         Convert argument metadata into a serializable list of dicts. | ||||
|  | ||||
|         Returns: | ||||
|             List of definitions for use in config introspection, documentation, or export. | ||||
|         """ | ||||
|         defs = [] | ||||
|         for arg in self._arguments: | ||||
|             defs.append( | ||||
| @@ -507,7 +565,7 @@ class CommandArgumentParser: | ||||
|             ) | ||||
|         return defs | ||||
|  | ||||
|     def raise_remaining_args_error( | ||||
|     def _raise_remaining_args_error( | ||||
|         self, token: str, arg_states: dict[str, ArgumentState] | ||||
|     ) -> None: | ||||
|         consumed_dests = [ | ||||
| @@ -619,7 +677,7 @@ class CommandArgumentParser: | ||||
|             except Exception as error: | ||||
|                 if len(args[i - new_i :]) == 1 and args[i - new_i].startswith("-"): | ||||
|                     token = args[i - new_i] | ||||
|                     self.raise_remaining_args_error(token, arg_states) | ||||
|                     self._raise_remaining_args_error(token, arg_states) | ||||
|                 else: | ||||
|                     raise CommandArgumentError( | ||||
|                         f"Invalid value for '{spec.dest}': {error}" | ||||
| @@ -660,7 +718,7 @@ class CommandArgumentParser: | ||||
|         if i < len(args): | ||||
|             if len(args[i:]) == 1 and args[i].startswith("-"): | ||||
|                 token = args[i] | ||||
|                 self.raise_remaining_args_error(token, arg_states) | ||||
|                 self._raise_remaining_args_error(token, arg_states) | ||||
|             else: | ||||
|                 plural = "s" if len(args[i:]) > 1 else "" | ||||
|                 raise CommandArgumentError( | ||||
| @@ -813,7 +871,7 @@ class CommandArgumentParser: | ||||
|                 consumed_indices.update(range(i, new_i)) | ||||
|                 i = new_i | ||||
|         elif token.startswith("-"): | ||||
|             self.raise_remaining_args_error(token, arg_states) | ||||
|             self._raise_remaining_args_error(token, arg_states) | ||||
|         else: | ||||
|             # Get the next flagged argument index if it exists | ||||
|             next_flagged_index = -1 | ||||
| @@ -837,7 +895,16 @@ class CommandArgumentParser: | ||||
|     async def parse_args( | ||||
|         self, args: list[str] | None = None, from_validate: bool = False | ||||
|     ) -> dict[str, Any]: | ||||
|         """Parse Falyx Command arguments.""" | ||||
|         """ | ||||
|         Parse arguments into a dictionary of resolved values. | ||||
|  | ||||
|         Args: | ||||
|             args (list[str]): The CLI-style argument list. | ||||
|             from_validate (bool): If True, enables relaxed resolution for validation mode. | ||||
|  | ||||
|         Returns: | ||||
|             dict[str, Any]: Parsed argument result mapping. | ||||
|         """ | ||||
|         if args is None: | ||||
|             args = [] | ||||
|  | ||||
| @@ -932,9 +999,12 @@ class CommandArgumentParser: | ||||
|         self, args: list[str], from_validate: bool = False | ||||
|     ) -> tuple[tuple[Any, ...], dict[str, Any]]: | ||||
|         """ | ||||
|         Parse arguments and return both positional and keyword mappings. | ||||
|  | ||||
|         Useful for function-style calling with `*args, **kwargs`. | ||||
|  | ||||
|         Returns: | ||||
|             tuple[args, kwargs] - Positional arguments in defined order, | ||||
|             followed by keyword argument mapping. | ||||
|             tuple: (args tuple, kwargs dict) | ||||
|         """ | ||||
|         parsed = await self.parse_args(args, from_validate) | ||||
|         args_list = [] | ||||
| @@ -950,12 +1020,15 @@ class CommandArgumentParser: | ||||
|  | ||||
|     def suggest_next(self, args: list[str]) -> list[str]: | ||||
|         """ | ||||
|         Suggest the next possible flags or values given partially typed arguments. | ||||
|         Suggest completions for the next argument based on current input. | ||||
|  | ||||
|         This does NOT raise errors. It is intended for completions, not validation. | ||||
|         This is used for interactive shell completion or prompt_toolkit integration. | ||||
|  | ||||
|         Args: | ||||
|             args (list[str]): Current partial argument tokens. | ||||
|  | ||||
|         Returns: | ||||
|             A list of possible completions based on the current input. | ||||
|             list[str]: List of suggested completions. | ||||
|         """ | ||||
|  | ||||
|         # Case 1: Next positional argument | ||||
| @@ -1034,6 +1107,12 @@ class CommandArgumentParser: | ||||
|         return sorted(set(suggestions)) | ||||
|  | ||||
|     def get_options_text(self, plain_text=False) -> str: | ||||
|         """ | ||||
|         Render all defined arguments as a help-style string. | ||||
|  | ||||
|         Returns: | ||||
|             str: A visual description of argument flags and structure. | ||||
|         """ | ||||
|         # Options | ||||
|         # Add all keyword arguments to the options list | ||||
|         options_list = [] | ||||
| @@ -1057,6 +1136,14 @@ class CommandArgumentParser: | ||||
|         return " ".join(options_list) | ||||
|  | ||||
|     def get_command_keys_text(self, plain_text=False) -> str: | ||||
|         """ | ||||
|         Return formatted string showing the command key and aliases. | ||||
|  | ||||
|         Used in help rendering and introspection. | ||||
|  | ||||
|         Returns: | ||||
|             str: The visual command selector line. | ||||
|         """ | ||||
|         if plain_text: | ||||
|             command_keys = " | ".join( | ||||
|                 [f"{self.command_key}"] + [f"{alias}" for alias in self.aliases] | ||||
| @@ -1072,7 +1159,12 @@ class CommandArgumentParser: | ||||
|         return command_keys | ||||
|  | ||||
|     def get_usage(self, plain_text=False) -> str: | ||||
|         """Get the usage text for the command.""" | ||||
|         """ | ||||
|         Render the usage string for this parser. | ||||
|  | ||||
|         Returns: | ||||
|             str: A formatted usage line showing syntax and argument structure. | ||||
|         """ | ||||
|         command_keys = self.get_command_keys_text(plain_text) | ||||
|         options_text = self.get_options_text(plain_text) | ||||
|         if options_text: | ||||
| @@ -1080,6 +1172,11 @@ class CommandArgumentParser: | ||||
|         return command_keys | ||||
|  | ||||
|     def render_help(self) -> None: | ||||
|         """ | ||||
|         Print formatted help text for this command using Rich output. | ||||
|  | ||||
|         Includes usage, description, argument groups, and optional epilog. | ||||
|         """ | ||||
|         usage = self.get_usage() | ||||
|         self.console.print(f"[bold]usage: {usage}[/bold]\n") | ||||
|  | ||||
| @@ -1142,6 +1239,7 @@ class CommandArgumentParser: | ||||
|         return hash(tuple(sorted(self._arguments, key=lambda a: a.dest))) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         """Return a human-readable summary of the parser state.""" | ||||
|         positional = sum(arg.positional for arg in self._arguments) | ||||
|         required = sum(arg.required for arg in self._arguments) | ||||
|         return ( | ||||
|   | ||||
| @@ -1,5 +1,10 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """parser_types.py""" | ||||
| """ | ||||
| Utilities for custom type coercion in Falyx argument parsing. | ||||
|  | ||||
| Provides special-purpose converters used to support optional boolean flags and | ||||
| other non-standard argument behaviors within the Falyx CLI parser system. | ||||
| """ | ||||
| from typing import Any | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,22 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """parsers.py | ||||
| This module contains the argument parsers used for the Falyx CLI. | ||||
| """ | ||||
| Provides the argument parser infrastructure for the Falyx CLI. | ||||
|  | ||||
| This module defines the `FalyxParsers` dataclass and related utilities for building | ||||
| structured CLI interfaces with argparse. It supports top-level CLI commands like | ||||
| `run`, `run-all`, `preview`, `list`, and `version`, and integrates seamlessly with | ||||
| registered `Command` objects for dynamic help, usage generation, and argument handling. | ||||
|  | ||||
| Key Components: | ||||
| - `FalyxParsers`: Container for all CLI subparsers. | ||||
| - `get_arg_parsers()`: Factory for generating full parser suite. | ||||
| - `get_root_parser()`: Creates the root-level CLI parser with global options. | ||||
| - `get_subparsers()`: Helper to attach subcommand parsers to the root parser. | ||||
|  | ||||
| Used internally by the Falyx CLI `run()` entry point to parse arguments and route | ||||
| execution across commands and workflows. | ||||
| """ | ||||
|  | ||||
| from argparse import ( | ||||
|     REMAINDER, | ||||
|     ArgumentParser, | ||||
|   | ||||
| @@ -1,4 +1,15 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Provides utilities for introspecting Python callables and extracting argument | ||||
| metadata compatible with Falyx's `CommandArgumentParser`. | ||||
|  | ||||
| This module is primarily used to auto-generate command argument definitions from | ||||
| function signatures, enabling seamless integration of plain functions into the | ||||
| Falyx CLI with minimal boilerplate. | ||||
|  | ||||
| Functions: | ||||
| - infer_args_from_func: Generate a list of argument definitions based on a function's signature. | ||||
| """ | ||||
| import inspect | ||||
| from typing import Any, Callable | ||||
|  | ||||
| @@ -10,8 +21,18 @@ def infer_args_from_func( | ||||
|     arg_metadata: dict[str, str | dict[str, Any]] | None = None, | ||||
| ) -> list[dict[str, Any]]: | ||||
|     """ | ||||
|     Infer argument definitions from a callable's signature. | ||||
|     Returns a list of kwargs suitable for CommandArgumentParser.add_argument. | ||||
|     Infer CLI-style argument definitions from a function signature. | ||||
|  | ||||
|     This utility inspects the parameters of a function and returns a list of dictionaries, | ||||
|     each of which can be passed to `CommandArgumentParser.add_argument()`. | ||||
|  | ||||
|     Args: | ||||
|         func (Callable | None): The function to inspect. | ||||
|         arg_metadata (dict | None): Optional metadata overrides for help text, type hints, | ||||
|                                     choices, and suggestions for each parameter. | ||||
|  | ||||
|     Returns: | ||||
|         list[dict[str, Any]]: A list of argument definitions inferred from the function. | ||||
|     """ | ||||
|     if not callable(func): | ||||
|         logger.debug("Provided argument is not callable: %s", func) | ||||
|   | ||||
| @@ -1,4 +1,17 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Contains value coercion and signature comparison utilities for Falyx argument parsing. | ||||
|  | ||||
| This module provides type coercion functions for converting string input into expected | ||||
| Python types, including `Enum`, `bool`, `datetime`, and `Literal`. It also supports | ||||
| checking whether multiple actions share identical inferred argument definitions. | ||||
|  | ||||
| Functions: | ||||
| - coerce_bool: Convert a string to a boolean. | ||||
| - coerce_enum: Convert a string or raw value to an Enum instance. | ||||
| - coerce_value: General-purpose coercion to a target type (including nested unions, enums, etc.). | ||||
| - same_argument_definitions: Check if multiple callables share the same argument structure. | ||||
| """ | ||||
| import types | ||||
| from datetime import datetime | ||||
| from enum import EnumMeta | ||||
| @@ -12,6 +25,17 @@ from falyx.parser.signature import infer_args_from_func | ||||
|  | ||||
|  | ||||
| def coerce_bool(value: str) -> bool: | ||||
|     """ | ||||
|     Convert a string to a boolean. | ||||
|  | ||||
|     Accepts various truthy and falsy representations such as 'true', 'yes', '0', 'off', etc. | ||||
|  | ||||
|     Args: | ||||
|         value (str): The input string or boolean. | ||||
|  | ||||
|     Returns: | ||||
|         bool: Parsed boolean result. | ||||
|     """ | ||||
|     if isinstance(value, bool): | ||||
|         return value | ||||
|     value = value.strip().lower() | ||||
| @@ -23,6 +47,21 @@ def coerce_bool(value: str) -> bool: | ||||
|  | ||||
|  | ||||
| def coerce_enum(value: Any, enum_type: EnumMeta) -> Any: | ||||
|     """ | ||||
|     Convert a raw value or string to an Enum instance. | ||||
|  | ||||
|     Tries to resolve by name, value, or coerced base type. | ||||
|  | ||||
|     Args: | ||||
|         value (Any): The input value to convert. | ||||
|         enum_type (EnumMeta): The target Enum class. | ||||
|  | ||||
|     Returns: | ||||
|         Enum: The corresponding Enum instance. | ||||
|  | ||||
|     Raises: | ||||
|         ValueError: If the value cannot be resolved to a valid Enum member. | ||||
|     """ | ||||
|     if isinstance(value, enum_type): | ||||
|         return value | ||||
|  | ||||
| @@ -42,6 +81,21 @@ def coerce_enum(value: Any, enum_type: EnumMeta) -> Any: | ||||
|  | ||||
|  | ||||
| def coerce_value(value: str, target_type: type) -> Any: | ||||
|     """ | ||||
|     Attempt to convert a string to the given target type. | ||||
|  | ||||
|     Handles complex typing constructs such as Union, Literal, Enum, and datetime. | ||||
|  | ||||
|     Args: | ||||
|         value (str): The input string to convert. | ||||
|         target_type (type): The desired type. | ||||
|  | ||||
|     Returns: | ||||
|         Any: The coerced value. | ||||
|  | ||||
|     Raises: | ||||
|         ValueError: If conversion fails or the value is invalid. | ||||
|     """ | ||||
|     origin = get_origin(target_type) | ||||
|     args = get_args(target_type) | ||||
|  | ||||
| @@ -79,7 +133,19 @@ def same_argument_definitions( | ||||
|     actions: list[Any], | ||||
|     arg_metadata: dict[str, str | dict[str, Any]] | None = None, | ||||
| ) -> list[dict[str, Any]] | None: | ||||
|     """ | ||||
|     Determine if multiple callables resolve to the same argument definitions. | ||||
|  | ||||
|     This is used to infer whether actions in an ActionGroup or ProcessPool can share | ||||
|     a unified argument parser. | ||||
|  | ||||
|     Args: | ||||
|         actions (list[Any]): A list of BaseAction instances or callables. | ||||
|         arg_metadata (dict | None): Optional overrides for argument help or type info. | ||||
|  | ||||
|     Returns: | ||||
|         list[dict[str, Any]] | None: The shared argument definitions if consistent, else None. | ||||
|     """ | ||||
|     arg_sets = [] | ||||
|     for action in actions: | ||||
|         if isinstance(action, BaseAction): | ||||
|   | ||||
| @@ -1,5 +1,15 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """prompt_utils.py""" | ||||
| """ | ||||
| Utilities for user interaction prompts in the Falyx CLI framework. | ||||
|  | ||||
| Provides asynchronous confirmation dialogs and helper logic to determine | ||||
| whether a user should be prompted based on command-line options. | ||||
|  | ||||
| Includes: | ||||
| - `should_prompt_user()` for conditional prompt logic. | ||||
| - `confirm_async()` for interactive yes/no confirmation. | ||||
| """ | ||||
|  | ||||
| from prompt_toolkit import PromptSession | ||||
| from prompt_toolkit.formatted_text import ( | ||||
|     AnyFormattedText, | ||||
|   | ||||
| @@ -1,5 +1,18 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """protocols.py""" | ||||
| """ | ||||
| Defines structural protocols for advanced Falyx features. | ||||
|  | ||||
| These runtime-checkable `Protocol` classes specify the expected interfaces for: | ||||
| - Factories that asynchronously return actions | ||||
| - Argument parsers used in dynamic command execution | ||||
|  | ||||
| Used to support type-safe extensibility and plugin-like behavior without requiring | ||||
| explicit base classes. | ||||
|  | ||||
| Protocols: | ||||
| - ActionFactoryProtocol: Async callable that returns a coroutine yielding a BaseAction. | ||||
| - ArgParserProtocol: Callable that accepts CLI-style args and returns (args, kwargs) tuple. | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from typing import Any, Awaitable, Callable, Protocol, runtime_checkable | ||||
|   | ||||
| @@ -1,5 +1,23 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """retry.py""" | ||||
| """ | ||||
| Implements retry logic for Falyx Actions using configurable retry policies. | ||||
|  | ||||
| This module defines: | ||||
| - `RetryPolicy`: A configurable model controlling retry behavior (delay, backoff, jitter). | ||||
| - `RetryHandler`: A hook-compatible class that manages retry attempts for failed actions. | ||||
|  | ||||
| Used to automatically retry transient failures in leaf-level `Action` objects | ||||
| when marked as retryable. Integrates with the Falyx hook lifecycle via `on_error`. | ||||
|  | ||||
| Supports: | ||||
| - Exponential backoff with optional jitter | ||||
| - Manual or declarative policy control | ||||
| - Per-action retry logging and recovery | ||||
|  | ||||
| Example: | ||||
|     handler = RetryHandler(RetryPolicy(max_retries=5, delay=1.0)) | ||||
|     action.hooks.register(HookType.ON_ERROR, handler.retry_on_error) | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import asyncio | ||||
| @@ -12,7 +30,28 @@ from falyx.logger import logger | ||||
|  | ||||
|  | ||||
| class RetryPolicy(BaseModel): | ||||
|     """RetryPolicy""" | ||||
|     """ | ||||
|     Defines a retry strategy for Falyx `Action` objects. | ||||
|  | ||||
|     This model controls whether an action should be retried on failure, and how: | ||||
|     - `max_retries`: Maximum number of retry attempts. | ||||
|     - `delay`: Initial wait time before the first retry (in seconds). | ||||
|     - `backoff`: Multiplier applied to the delay after each failure (≥ 1.0). | ||||
|     - `jitter`: Optional random noise added/subtracted from delay to reduce thundering herd issues. | ||||
|     - `enabled`: Whether this policy is currently active. | ||||
|  | ||||
|     Retry is only triggered for leaf-level `Action` instances marked with `is_retryable=True` | ||||
|     and registered with an appropriate `RetryHandler`. | ||||
|  | ||||
|     Example: | ||||
|         RetryPolicy(max_retries=3, delay=1.0, backoff=2.0, jitter=0.2, enabled=True) | ||||
|  | ||||
|     Use `enable_policy()` to activate the policy after construction. | ||||
|  | ||||
|     See Also: | ||||
|         - `RetryHandler`: Executes retry logic based on this configuration. | ||||
|         - `HookType.ON_ERROR`: The hook type used to trigger retries. | ||||
|     """ | ||||
|  | ||||
|     max_retries: int = Field(default=3, ge=0) | ||||
|     delay: float = Field(default=1.0, ge=0.0) | ||||
| @@ -36,7 +75,27 @@ class RetryPolicy(BaseModel): | ||||
|  | ||||
|  | ||||
| class RetryHandler: | ||||
|     """RetryHandler class to manage retry policies for actions.""" | ||||
|     """ | ||||
|     Executes retry logic for Falyx actions using a provided `RetryPolicy`. | ||||
|  | ||||
|     This class is intended to be registered as an `on_error` hook. It will | ||||
|     re-attempt the failed `Action`'s `action` method using the args/kwargs from | ||||
|     the failed context, following exponential backoff and optional jitter. | ||||
|  | ||||
|     Only supports retrying leaf `Action` instances (not ChainedAction or ActionGroup) | ||||
|     where `is_retryable=True`. | ||||
|  | ||||
|     Attributes: | ||||
|         policy (RetryPolicy): The retry configuration controlling timing and limits. | ||||
|  | ||||
|     Example: | ||||
|         handler = RetryHandler(RetryPolicy(max_retries=3, delay=1.0, enabled=True)) | ||||
|         action.hooks.register(HookType.ON_ERROR, handler.retry_on_error) | ||||
|  | ||||
|     Notes: | ||||
|         - Retries are not triggered if the policy is disabled or `max_retries=0`. | ||||
|         - All retry attempts and final failure are logged automatically. | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, policy: RetryPolicy = RetryPolicy()): | ||||
|         self.policy = policy | ||||
|   | ||||
| @@ -1,5 +1,14 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """retry_utils.py""" | ||||
| """ | ||||
| Utilities for enabling retry behavior across Falyx actions. | ||||
|  | ||||
| This module provides a helper to recursively apply a `RetryPolicy` to an action and its | ||||
| nested children (e.g. `ChainedAction`, `ActionGroup`), and register the appropriate | ||||
| `RetryHandler` to hook into error handling. | ||||
|  | ||||
| Includes: | ||||
| - `enable_retries_recursively`: Attaches a retry policy and error hook to all eligible actions. | ||||
| """ | ||||
| from falyx.action.action import Action | ||||
| from falyx.action.base_action import BaseAction | ||||
| from falyx.hook_manager import HookType | ||||
|   | ||||
| @@ -1,5 +1,17 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """selection.py""" | ||||
| """ | ||||
| Provides interactive selection utilities for Falyx CLI actions. | ||||
|  | ||||
| This module defines `SelectionOption` objects, selection maps, and rich-powered | ||||
| rendering functions to build interactive selection prompts using `prompt_toolkit`. | ||||
| It supports: | ||||
| - Grid-based and dictionary-based selection menus | ||||
| - Index- or key-driven multi-select prompts | ||||
| - Formatted Rich tables for CLI visual menus | ||||
| - Cancel keys, defaults, and duplication control | ||||
|  | ||||
| Used by `SelectionAction` and other prompt-driven workflows within Falyx. | ||||
| """ | ||||
| from dataclasses import dataclass | ||||
| from typing import Any, Callable, KeysView, Sequence | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,21 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """signals.py""" | ||||
| """ | ||||
| Defines flow control signals used internally by the Falyx CLI framework. | ||||
|  | ||||
| These signals are raised to interrupt or redirect CLI execution flow | ||||
| (e.g., returning to a menu, quitting, or displaying help) without | ||||
| being treated as traditional exceptions. | ||||
|  | ||||
| All signals inherit from `FlowSignal`, which is a subclass of `BaseException` | ||||
| to ensure they bypass standard `except Exception` blocks. | ||||
|  | ||||
| Signals: | ||||
| - BreakChainSignal: Exit a chained action early. | ||||
| - QuitSignal: Terminate the CLI session. | ||||
| - BackSignal: Return to the previous menu or caller. | ||||
| - CancelSignal: Cancel the current operation. | ||||
| - HelpSignal: Trigger help output in interactive flows. | ||||
| """ | ||||
|  | ||||
|  | ||||
| class FlowSignal(BaseException): | ||||
|   | ||||
| @@ -1,5 +1,15 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """tagged_table.py""" | ||||
| """ | ||||
| Generates a Rich table view of Falyx commands grouped by their tags. | ||||
|  | ||||
| This module defines a utility function for rendering a custom CLI command | ||||
| table that organizes commands into groups based on their first tag. It is | ||||
| used to visually separate commands in interactive menus for better clarity | ||||
| and discoverability. | ||||
|  | ||||
| Functions: | ||||
| - build_tagged_table(flx): Returns a `rich.Table` of commands grouped by tag. | ||||
| """ | ||||
| from collections import defaultdict | ||||
|  | ||||
| from rich import box | ||||
|   | ||||
| @@ -1,5 +1,21 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """utils.py""" | ||||
| """ | ||||
| General-purpose utilities and helpers for the Falyx CLI framework. | ||||
|  | ||||
| This module includes asynchronous wrappers, logging setup, formatting utilities, | ||||
| and small type-safe enhancements such as `CaseInsensitiveDict` and coroutine enforcement. | ||||
|  | ||||
| Features: | ||||
| - `ensure_async`: Wraps sync functions as async coroutines. | ||||
| - `chunks`: Splits an iterable into fixed-size chunks. | ||||
| - `CaseInsensitiveDict`: Dict subclass with case-insensitive string keys. | ||||
| - `setup_logging`: Configures Rich or JSON logging based on environment or container detection. | ||||
| - `get_program_invocation`: Returns the recommended CLI command to invoke the program. | ||||
| - `running_in_container`: Detects if the process is running inside a container. | ||||
|  | ||||
| These utilities support consistent behavior across CLI rendering, logging, | ||||
| command parsing, and compatibility layers. | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import functools | ||||
|   | ||||
| @@ -1,5 +1,22 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """validators.py""" | ||||
| """ | ||||
| Input validators for use with Prompt Toolkit and interactive Falyx CLI workflows. | ||||
|  | ||||
| This module defines reusable `Validator` instances and subclasses that enforce valid | ||||
| user input during prompts—especially for selection actions, confirmations, and | ||||
| argument parsing. | ||||
|  | ||||
| Included Validators: | ||||
| - int_range_validator: Enforces numeric input within a range. | ||||
| - key_validator: Ensures the entered value matches a valid selection key. | ||||
| - yes_no_validator: Restricts input to 'Y' or 'N'. | ||||
| - word_validator / words_validator: Accepts specific valid words (case-insensitive). | ||||
| - MultiIndexValidator: Validates numeric list input (e.g. "1,2,3"). | ||||
| - MultiKeyValidator: Validates string key list input (e.g. "A,B,C"). | ||||
|  | ||||
| These validators integrate directly into `PromptSession.prompt_async()` to | ||||
| enforce correctness and provide helpful error messages. | ||||
| """ | ||||
| from typing import KeysView, Sequence | ||||
|  | ||||
| from prompt_toolkit.validation import ValidationError, Validator | ||||
|   | ||||
| @@ -1 +1 @@ | ||||
| __version__ = "0.1.64" | ||||
| __version__ = "0.1.65" | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| [tool.poetry] | ||||
| name = "falyx" | ||||
| version = "0.1.64" | ||||
| version = "0.1.65" | ||||
| description = "Reliable and introspectable async CLI action framework." | ||||
| authors = ["Roland Thomas Jr <roland@rtj.dev>"] | ||||
| license = "MIT" | ||||
|   | ||||
| @@ -1,6 +1,12 @@ | ||||
| import pytest | ||||
|  | ||||
| from falyx.action import Action, ChainedAction, FallbackAction, LiteralInputAction | ||||
| from falyx.action import ( | ||||
|     Action, | ||||
|     ActionGroup, | ||||
|     ChainedAction, | ||||
|     FallbackAction, | ||||
|     LiteralInputAction, | ||||
| ) | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
|  | ||||
| @@ -38,14 +44,13 @@ async def test_action_async_callable(): | ||||
|     action = Action("test_action", async_callable) | ||||
|     result = await action() | ||||
|     assert result == "Hello, World!" | ||||
|     print(action) | ||||
|     assert ( | ||||
|         str(action) | ||||
|         == "Action(name='test_action', action=async_callable, retry=False, rollback=False)" | ||||
|         == "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False, rollback=False)" | ||||
|     ) | ||||
|     assert ( | ||||
|         repr(action) | ||||
|         == "Action(name='test_action', action=async_callable, retry=False, rollback=False)" | ||||
|         == "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False, rollback=False)" | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @@ -60,11 +65,12 @@ async def test_chained_action(): | ||||
|         return_list=True, | ||||
|     ) | ||||
|  | ||||
|     print(chain) | ||||
|     result = await chain() | ||||
|     assert result == [1, 2] | ||||
|     assert ( | ||||
|         str(chain) | ||||
|         == "ChainedAction(name='Simple Chain', actions=['one', 'two'], auto_inject=False, return_list=True)" | ||||
|         == "ChainedAction(name=Simple Chain, actions=['one', 'two'], args=(), kwargs={}, auto_inject=False, return_list=True)" | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @@ -73,17 +79,17 @@ async def test_action_group(): | ||||
|     """Test if ActionGroup can be created and used.""" | ||||
|     action1 = Action("one", lambda: 1) | ||||
|     action2 = Action("two", lambda: 2) | ||||
|     group = ChainedAction( | ||||
|     group = ActionGroup( | ||||
|         name="Simple Group", | ||||
|         actions=[action1, action2], | ||||
|         return_list=True, | ||||
|     ) | ||||
|  | ||||
|     print(group) | ||||
|     result = await group() | ||||
|     assert result == [1, 2] | ||||
|     assert result == [("one", 1), ("two", 2)] | ||||
|     assert ( | ||||
|         str(group) | ||||
|         == "ChainedAction(name='Simple Group', actions=['one', 'two'], auto_inject=False, return_list=True)" | ||||
|         == "ActionGroup(name=Simple Group, actions=['one', 'two'], args=(), kwargs={}, inject_last_result=False, inject_into=last_result)" | ||||
|     ) | ||||
|  | ||||
|  | ||||
|   | ||||
							
								
								
									
										94
									
								
								tests/test_actions/test_confirm_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								tests/test_actions/test_confirm_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,94 @@ | ||||
| import pytest | ||||
|  | ||||
| from falyx.action import ConfirmAction | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_confirm_action_yes_no(): | ||||
|     action = ConfirmAction( | ||||
|         name="test", | ||||
|         message="Are you sure?", | ||||
|         never_prompt=True, | ||||
|         confirm_type="yes_no", | ||||
|     ) | ||||
|  | ||||
|     result = await action() | ||||
|     assert result is True | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_confirm_action_yes_cancel(): | ||||
|     action = ConfirmAction( | ||||
|         name="test", | ||||
|         message="Are you sure?", | ||||
|         never_prompt=True, | ||||
|         confirm_type="yes_cancel", | ||||
|     ) | ||||
|  | ||||
|     result = await action() | ||||
|     assert result is True | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_confirm_action_yes_no_cancel(): | ||||
|     action = ConfirmAction( | ||||
|         name="test", | ||||
|         message="Are you sure?", | ||||
|         never_prompt=True, | ||||
|         confirm_type="yes_no_cancel", | ||||
|     ) | ||||
|  | ||||
|     result = await action() | ||||
|     assert result is True | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_confirm_action_type_word(): | ||||
|     action = ConfirmAction( | ||||
|         name="test", | ||||
|         message="Are you sure?", | ||||
|         never_prompt=True, | ||||
|         confirm_type="type_word", | ||||
|     ) | ||||
|  | ||||
|     result = await action() | ||||
|     assert result is True | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_confirm_action_type_word_cancel(): | ||||
|     action = ConfirmAction( | ||||
|         name="test", | ||||
|         message="Are you sure?", | ||||
|         never_prompt=True, | ||||
|         confirm_type="type_word_cancel", | ||||
|     ) | ||||
|  | ||||
|     result = await action() | ||||
|     assert result is True | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_confirm_action_ok_cancel(): | ||||
|     action = ConfirmAction( | ||||
|         name="test", | ||||
|         message="Are you sure?", | ||||
|         never_prompt=True, | ||||
|         confirm_type="ok_cancel", | ||||
|     ) | ||||
|  | ||||
|     result = await action() | ||||
|     assert result is True | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_confirm_action_acknowledge(): | ||||
|     action = ConfirmAction( | ||||
|         name="test", | ||||
|         message="Are you sure?", | ||||
|         never_prompt=True, | ||||
|         confirm_type="acknowledge", | ||||
|     ) | ||||
|  | ||||
|     result = await action() | ||||
|     assert result is True | ||||
| @@ -1,6 +1,6 @@ | ||||
| import pytest | ||||
|  | ||||
| from falyx.action.selection_action import SelectionAction | ||||
| from falyx.action import SelectionAction | ||||
| from falyx.selection import SelectionOption | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -53,7 +53,7 @@ def test_command_str(): | ||||
|     print(cmd) | ||||
|     assert ( | ||||
|         str(cmd) | ||||
|         == "Command(key='TEST', description='Test Command' action='Action(name='test_action', action=dummy_action, retry=False, rollback=False)')" | ||||
|         == "Command(key='TEST', description='Test Command' action='Action(name='test_action', action=dummy_action, args=(), kwargs={}, retry=False, rollback=False)')" | ||||
|     ) | ||||
|  | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user