From 7f63e16097a9ec649d4723a593322caff4584c4f Mon Sep 17 00:00:00 2001 From: Roland Thomas Date: Sat, 19 Jul 2025 14:44:43 -0400 Subject: [PATCH] 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+`, 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. --- falyx/action/action.py | 42 ++++- falyx/action/action_factory.py | 47 +++++- falyx/action/action_group.py | 45 +++++- falyx/action/action_mixins.py | 31 +++- falyx/action/action_types.py | 161 +++++++++++++++++--- falyx/action/base_action.py | 19 ++- falyx/action/chained_action.py | 75 ++++++++- falyx/action/confirm_action.py | 52 +++++-- falyx/action/fallback_action.py | 38 ++++- falyx/action/http_action.py | 4 +- falyx/action/io_action.py | 7 +- falyx/action/literal_input_action.py | 33 +++- falyx/action/load_file_action.py | 120 ++++++++++++--- falyx/action/menu_action.py | 91 ++++++++++- falyx/action/process_action.py | 39 ++++- falyx/action/process_pool_action.py | 70 ++++++++- falyx/action/prompt_menu_action.py | 61 +++++++- falyx/action/save_file_action.py | 103 +++++++++---- falyx/action/select_file_action.py | 128 ++++++++++------ falyx/action/selection_action.py | 42 +++-- falyx/action/shell_action.py | 3 +- falyx/action/signal_action.py | 63 +++++++- falyx/action/user_input_action.py | 47 +++++- falyx/bottom_bar.py | 65 ++++++-- falyx/command.py | 14 +- falyx/completer.py | 56 ++++++- falyx/config.py | 39 ++++- falyx/console.py | 2 + falyx/context.py | 15 +- falyx/debug.py | 15 +- falyx/exceptions.py | 25 ++- falyx/execution_registry.py | 150 ++++++++++++------ falyx/falyx.py | 50 ++---- falyx/hook_manager.py | 110 ++++++++++++- falyx/hooks.py | 27 +++- falyx/init.py | 20 ++- falyx/logger.py | 2 +- falyx/menu.py | 59 ++++++- falyx/options_manager.py | 39 ++++- falyx/parser/argument.py | 37 ++++- falyx/parser/argument_action.py | 68 ++++++++- falyx/parser/command_argument_parser.py | 156 +++++++++++++++---- falyx/parser/parser_types.py | 7 +- falyx/parser/parsers.py | 19 ++- falyx/parser/signature.py | 25 ++- falyx/parser/utils.py | 66 ++++++++ falyx/prompt_utils.py | 12 +- falyx/protocols.py | 15 +- falyx/retry.py | 65 +++++++- falyx/retry_utils.py | 11 +- falyx/selection.py | 14 +- falyx/signals.py | 18 ++- falyx/tagged_table.py | 12 +- falyx/utils.py | 18 ++- falyx/validators.py | 19 ++- falyx/version.py | 2 +- pyproject.toml | 2 +- tests/test_action_basic.py | 24 +-- tests/test_actions/test_confirm_action.py | 94 ++++++++++++ tests/test_actions/test_selection_action.py | 2 +- tests/test_command.py | 2 +- 61 files changed, 2324 insertions(+), 373 deletions(-) create mode 100644 tests/test_actions/test_confirm_action.py diff --git a/falyx/action/action.py b/falyx/action/action.py index e0a670d..6ce299f 100644 --- a/falyx/action/action.py +++ b/falyx/action/action.py @@ -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})" ) diff --git a/falyx/action/action_factory.py b/falyx/action/action_factory.py index ce1a5c6..e9aae09 100644 --- a/falyx/action/action_factory.py +++ b/falyx/action/action_factory.py @@ -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})" + ) diff --git a/falyx/action/action_group.py b/falyx/action/action_group.py index 3dfa57e..026a734 100644 --- a/falyx/action/action_group.py +++ b/falyx/action/action_group.py @@ -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})" ) diff --git a/falyx/action/action_mixins.py b/falyx/action/action_mixins.py index 2a9d450..a0e237d 100644 --- a/falyx/action/action_mixins.py +++ b/falyx/action/action_mixins.py @@ -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 diff --git a/falyx/action/action_types.py b/falyx/action/action_types.py index 7e1323d..69e5052 100644 --- a/falyx/action/action_types.py +++ b/falyx/action/action_types.py @@ -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 diff --git a/falyx/action/base_action.py b/falyx/action/base_action.py index 6589d63..ef5b192 100644 --- a/falyx/action/base_action.py +++ b/falyx/action/base_action.py @@ -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) diff --git a/falyx/action/chained_action.py b/falyx/action/chained_action.py index 35a471d..c7231f3 100644 --- a/falyx/action/chained_action.py +++ b/falyx/action/chained_action.py @@ -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})" ) diff --git a/falyx/action/confirm_action.py b/falyx/action/confirm_action.py index 426e453..abb990d 100644 --- a/falyx/action/confirm_action.py +++ b/falyx/action/confirm_action.py @@ -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: diff --git a/falyx/action/fallback_action.py b/falyx/action/fallback_action.py index 2db6c84..d74b291 100644 --- a/falyx/action/fallback_action.py +++ b/falyx/action/fallback_action.py @@ -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 diff --git a/falyx/action/http_action.py b/falyx/action/http_action.py index 7d51c6b..966d90a 100644 --- a/falyx/action/http_action.py +++ b/falyx/action/http_action.py @@ -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. diff --git a/falyx/action/io_action.py b/falyx/action/io_action.py index 820989a..d208715 100644 --- a/falyx/action/io_action.py +++ b/falyx/action/io_action.py @@ -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. """ diff --git a/falyx/action/literal_input_action.py b/falyx/action/literal_input_action.py index 99ec3be..552033f 100644 --- a/falyx/action/literal_input_action.py +++ b/falyx/action/literal_input_action.py @@ -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 diff --git a/falyx/action/load_file_action.py b/falyx/action/load_file_action.py index fba5a30..a4c00fb 100644 --- a/falyx/action/load_file_action.py +++ b/falyx/action/load_file_action.py @@ -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}") diff --git a/falyx/action/menu_action.py b/falyx/action/menu_action.py index 5866f71..395a93d 100644 --- a/falyx/action/menu_action.py +++ b/falyx/action/menu_action.py @@ -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, diff --git a/falyx/action/process_action.py b/falyx/action/process_action.py index 3343506..dc6b178 100644 --- a/falyx/action/process_action.py +++ b/falyx/action/process_action.py @@ -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 diff --git a/falyx/action/process_pool_action.py b/falyx/action/process_pool_action.py index 9b21b45..2ea3bdf 100644 --- a/falyx/action/process_pool_action.py +++ b/falyx/action/process_pool_action.py @@ -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, diff --git a/falyx/action/prompt_menu_action.py b/falyx/action/prompt_menu_action.py index 6a0d0c1..36d3400 100644 --- a/falyx/action/prompt_menu_action.py +++ b/falyx/action/prompt_menu_action.py @@ -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, diff --git a/falyx/action/save_file_action.py b/falyx/action/save_file_action.py index 96bdea4..0132dc2 100644 --- a/falyx/action/save_file_action.py +++ b/falyx/action/save_file_action.py @@ -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}") diff --git a/falyx/action/select_file_action.py b/falyx/action/select_file_action.py index 07284cd..56ce4f8 100644 --- a/falyx/action/select_file_action.py +++ b/falyx/action/select_file_action.py @@ -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: diff --git a/falyx/action/selection_action.py b/falyx/action/selection_action.py index d158acf..032eb08 100644 --- a/falyx/action/selection_action.py +++ b/falyx/action/selection_action.py @@ -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 diff --git a/falyx/action/shell_action.py b/falyx/action/shell_action.py index 4b9a946..6b0f899 100644 --- a/falyx/action/shell_action.py +++ b/falyx/action/shell_action.py @@ -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 diff --git a/falyx/action/signal_action.py b/falyx/action/signal_action.py index 7b22115..f9625ba 100644 --- a/falyx/action/signal_action.py +++ b/falyx/action/signal_action.py @@ -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__}" diff --git a/falyx/action/user_input_action.py b/falyx/action/user_input_action.py index a7c8a14..9bad15c 100644 --- a/falyx/action/user_input_action.py +++ b/falyx/action/user_input_action.py @@ -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__( diff --git a/falyx/bottom_bar.py b/falyx/bottom_bar.py index c1e7cb6..6754a3f 100644 --- a/falyx/bottom_bar.py +++ b/falyx/bottom_bar.py @@ -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+ 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"") 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, diff --git a/falyx/command.py b/falyx/command.py index 7353943..d096aac 100644 --- a/falyx/command.py +++ b/falyx/command.py @@ -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): diff --git a/falyx/completer.py b/falyx/completer.py index caa5d5d..eef6cd8 100644 --- a/falyx/completer.py +++ b/falyx/completer.py @@ -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) diff --git a/falyx/config.py b/falyx/config.py index be26801..3291225 100644 --- a/falyx/config.py +++ b/falyx/config.py @@ -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 diff --git a/falyx/console.py b/falyx/console.py index cbb92ea..ab3f8c4 100644 --- a/falyx/console.py +++ b/falyx/console.py @@ -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 diff --git a/falyx/context.py b/falyx/context.py index 3b8aa48..08704f7 100644 --- a/falyx/context.py +++ b/falyx/context.py @@ -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, } diff --git a/falyx/debug.py b/falyx/debug.py index 2377c38..fbd3096 100644 --- a/falyx/debug.py +++ b/falyx/debug.py @@ -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 diff --git a/falyx/exceptions.py b/falyx/exceptions.py index 7ac8d04..b64e6bd 100644 --- a/falyx/exceptions.py +++ b/falyx/exceptions.py @@ -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): diff --git a/falyx/execution_registry.py b/falyx/execution_registry.py index 05a4f71..ec28da6 100644 --- a/falyx/execution_registry.py +++ b/falyx/execution_registry.py @@ -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 diff --git a/falyx/falyx.py b/falyx/falyx.py index acc3718..ab5e395 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -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: diff --git a/falyx/hook_manager.py b/falyx/hook_manager.py index a8214e0..ca0a506 100644 --- a/falyx/hook_manager.py +++ b/falyx/hook_manager.py @@ -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 diff --git a/falyx/hooks.py b/falyx/hooks.py index 439daff..0dd3cdc 100644 --- a/falyx/hooks.py +++ b/falyx/hooks.py @@ -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 diff --git a/falyx/init.py b/falyx/init.py index 8e92e17..ef5d7fb 100644 --- a/falyx/init.py +++ b/falyx/init.py @@ -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 diff --git a/falyx/logger.py b/falyx/logger.py index 5e7a564..c90f942 100644 --- a/falyx/logger.py +++ b/falyx/logger.py @@ -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") diff --git a/falyx/menu.py b/falyx/menu.py index a641223..cb6a0bd 100644 --- a/falyx/menu.py +++ b/falyx/menu.py @@ -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"} diff --git a/falyx/options_manager.py b/falyx/options_manager.py index 52bdf39..f87d6cb 100644 --- a/falyx/options_manager.py +++ b/falyx/options_manager.py @@ -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) diff --git a/falyx/parser/argument.py b/falyx/parser/argument.py index f029fde..4ecc9f7 100644 --- a/falyx/parser/argument.py +++ b/falyx/parser/argument.py @@ -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, ...] diff --git a/falyx/parser/argument_action.py b/falyx/parser/argument_action.py index a3cd89e..2f80704 100644 --- a/falyx/parser/argument_action.py +++ b/falyx/parser/argument_action.py @@ -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 diff --git a/falyx/parser/command_argument_parser.py b/falyx/parser/command_argument_parser.py index c81c06a..132eecc 100644 --- a/falyx/parser/command_argument_parser.py +++ b/falyx/parser/command_argument_parser.py @@ -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 ( diff --git a/falyx/parser/parser_types.py b/falyx/parser/parser_types.py index 08e7fc7..ef97843 100644 --- a/falyx/parser/parser_types.py +++ b/falyx/parser/parser_types.py @@ -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 diff --git a/falyx/parser/parsers.py b/falyx/parser/parsers.py index 44fd58d..1bf8716 100644 --- a/falyx/parser/parsers.py +++ b/falyx/parser/parsers.py @@ -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, diff --git a/falyx/parser/signature.py b/falyx/parser/signature.py index 720dcd1..1cd6889 100644 --- a/falyx/parser/signature.py +++ b/falyx/parser/signature.py @@ -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) diff --git a/falyx/parser/utils.py b/falyx/parser/utils.py index d3c160f..71396e6 100644 --- a/falyx/parser/utils.py +++ b/falyx/parser/utils.py @@ -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): diff --git a/falyx/prompt_utils.py b/falyx/prompt_utils.py index 9a91cda..8fef397 100644 --- a/falyx/prompt_utils.py +++ b/falyx/prompt_utils.py @@ -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, diff --git a/falyx/protocols.py b/falyx/protocols.py index bc36eea..d308555 100644 --- a/falyx/protocols.py +++ b/falyx/protocols.py @@ -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 diff --git a/falyx/retry.py b/falyx/retry.py index 39e3759..c07b028 100644 --- a/falyx/retry.py +++ b/falyx/retry.py @@ -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 diff --git a/falyx/retry_utils.py b/falyx/retry_utils.py index ee2c1c3..14eefbc 100644 --- a/falyx/retry_utils.py +++ b/falyx/retry_utils.py @@ -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 diff --git a/falyx/selection.py b/falyx/selection.py index e3df731..7185de9 100644 --- a/falyx/selection.py +++ b/falyx/selection.py @@ -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 diff --git a/falyx/signals.py b/falyx/signals.py index 5d06dc9..191c61d 100644 --- a/falyx/signals.py +++ b/falyx/signals.py @@ -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): diff --git a/falyx/tagged_table.py b/falyx/tagged_table.py index d70dbbc..8026cae 100644 --- a/falyx/tagged_table.py +++ b/falyx/tagged_table.py @@ -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 diff --git a/falyx/utils.py b/falyx/utils.py index 1dcf121..19660ef 100644 --- a/falyx/utils.py +++ b/falyx/utils.py @@ -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 diff --git a/falyx/validators.py b/falyx/validators.py index f55e0a3..9020173 100644 --- a/falyx/validators.py +++ b/falyx/validators.py @@ -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 diff --git a/falyx/version.py b/falyx/version.py index 5ddcdfd..54c0948 100644 --- a/falyx/version.py +++ b/falyx/version.py @@ -1 +1 @@ -__version__ = "0.1.64" +__version__ = "0.1.65" diff --git a/pyproject.toml b/pyproject.toml index 8203c59..fb9abee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 "] license = "MIT" diff --git a/tests/test_action_basic.py b/tests/test_action_basic.py index c02f021..8c899cd 100644 --- a/tests/test_action_basic.py +++ b/tests/test_action_basic.py @@ -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)" ) diff --git a/tests/test_actions/test_confirm_action.py b/tests/test_actions/test_confirm_action.py new file mode 100644 index 0000000..f25e1c8 --- /dev/null +++ b/tests/test_actions/test_confirm_action.py @@ -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 diff --git a/tests/test_actions/test_selection_action.py b/tests/test_actions/test_selection_action.py index 507b2c2..3afd89a 100644 --- a/tests/test_actions/test_selection_action.py +++ b/tests/test_actions/test_selection_action.py @@ -1,6 +1,6 @@ import pytest -from falyx.action.selection_action import SelectionAction +from falyx.action import SelectionAction from falyx.selection import SelectionOption diff --git a/tests/test_command.py b/tests/test_command.py index fb6a61e..7d6302c 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -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)')" )