feat: Add module docs, Enum coercion, tracebacks, and toggle improvements

- Add comprehensive module docstrings across the codebase for better clarity and documentation.
- Refactor Enum classes (e.g., FileType, ConfirmType) to use `_missing_` for built-in coercion from strings.
- Add `encoding` attribute to `LoadFileAction`, `SaveFileAction`, and `SelectFileAction` for more flexible file handling.
- Enable lazy file loading by default in `SelectFileAction` to improve performance.
- Simplify bottom bar toggle behavior: all toggles now use `ctrl+<key>`, eliminating the need for key conflict checks with Falyx commands.
- Add `ignore_in_history` attribute to `Command` to refine how `ExecutionRegistry` identifies the last valid result.
- Improve History command output: now includes tracebacks when displaying exceptions.
This commit is contained in:
2025-07-19 14:44:43 -04:00
parent 21402bff9a
commit 7f63e16097
61 changed files with 2324 additions and 373 deletions

View File

@ -1,5 +1,38 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 __future__ import annotations
from typing import Any, Awaitable, Callable from typing import Any, Awaitable, Callable
@ -27,11 +60,11 @@ class Action(BaseAction):
- Optional rollback handlers for undo logic. - Optional rollback handlers for undo logic.
Args: 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. action (Callable): The function or coroutine to execute.
rollback (Callable, optional): Rollback function to undo the action. rollback (Callable, optional): Rollback function to undo the action.
args (tuple, optional): Static positional arguments. args (tuple, optional): Positional arguments.
kwargs (dict, optional): Static keyword arguments. kwargs (dict, optional): Keyword arguments.
hooks (HookManager, optional): Hook manager for lifecycle events. hooks (HookManager, optional): Hook manager for lifecycle events.
inject_last_result (bool, optional): Enable last_result injection. inject_last_result (bool, optional): Enable last_result injection.
inject_into (str, optional): Name of injected key. inject_into (str, optional): Name of injected key.
@ -157,6 +190,7 @@ class Action(BaseAction):
return ( return (
f"Action(name={self.name!r}, action=" f"Action(name={self.name!r}, action="
f"{getattr(self._action, '__name__', repr(self._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"retry={self.retry_policy.enabled}, "
f"rollback={self.rollback is not None})" f"rollback={self.rollback is not None})"
) )

View File

@ -1,5 +1,36 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 typing import Any, Callable
from rich.tree import Tree from rich.tree import Tree
@ -22,10 +53,14 @@ class ActionFactory(BaseAction):
where the structure of the next action depends on runtime values. where the structure of the next action depends on runtime values.
Args: 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. factory (Callable): A function that returns a BaseAction given args/kwargs.
inject_last_result (bool): Whether to inject last_result into the factory. 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. 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__( def __init__(
@ -133,3 +168,11 @@ class ActionFactory(BaseAction):
if not parent: if not parent:
self.console.print(tree) 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})"
)

View File

@ -1,5 +1,39 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 asyncio
import random import random
from typing import Any, Awaitable, Callable, Sequence from typing import Any, Awaitable, Callable, Sequence
@ -47,6 +81,8 @@ class ActionGroup(BaseAction, ActionListMixin):
Args: Args:
name (str): Name of the chain. name (str): Name of the chain.
actions (list): List of actions or literals to execute. actions (list): List of actions or literals to execute.
args (tuple, optional): Positional arguments.
kwargs (dict, optional): Keyword arguments.
hooks (HookManager, optional): Hooks for lifecycle events. hooks (HookManager, optional): Hooks for lifecycle events.
inject_last_result (bool, optional): Whether to inject last results into kwargs inject_last_result (bool, optional): Whether to inject last results into kwargs
by default. by default.
@ -191,7 +227,8 @@ class ActionGroup(BaseAction, ActionListMixin):
def __str__(self): def __str__(self):
return ( return (
f"ActionGroup(name={self.name!r}, actions={[a.name for a in self.actions]!r}," f"ActionGroup(name={self.name}, actions={[a.name for a in self.actions]}, "
f" inject_last_result={self.inject_last_result}, " f"args={self.args!r}, kwargs={self.kwargs!r}, "
f"inject_into={self.inject_into!r})" f"inject_last_result={self.inject_last_result}, "
f"inject_into={self.inject_into})"
) )

View File

@ -1,12 +1,35 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 typing import Sequence
from falyx.action.base_action import BaseAction from falyx.action.base_action import BaseAction
class ActionListMixin: 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: def __init__(self) -> None:
self.actions: list[BaseAction] = [] self.actions: list[BaseAction] = []
@ -22,7 +45,7 @@ class ActionListMixin:
self.actions.append(action) self.actions.append(action)
def remove_action(self, name: str) -> None: 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] self.actions = [action for action in self.actions if action.name != name]
def has_action(self, name: str) -> bool: def has_action(self, name: str) -> bool:
@ -30,7 +53,7 @@ class ActionListMixin:
return any(action.name == name for action in self.actions) return any(action.name == name for action in self.actions)
def get_action(self, name: str) -> BaseAction | None: 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: for action in self.actions:
if action.name == name: if action.name == name:
return action return action

View File

@ -1,12 +1,53 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 __future__ import annotations
from enum import Enum from enum import Enum
class FileType(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" TEXT = "text"
PATH = "path" PATH = "path"
@ -17,6 +58,11 @@ class FileType(Enum):
TSV = "tsv" TSV = "tsv"
XML = "xml" XML = "xml"
@classmethod
def choices(cls) -> list[FileType]:
"""Return a list of all hook type choices."""
return list(cls)
@classmethod @classmethod
def _get_alias(cls, value: str) -> str: def _get_alias(cls, value: str) -> str:
aliases = { aliases = {
@ -29,18 +75,38 @@ class FileType(Enum):
@classmethod @classmethod
def _missing_(cls, value: object) -> FileType: def _missing_(cls, value: object) -> FileType:
if isinstance(value, str): if not isinstance(value, str):
normalized = value.lower() raise ValueError(f"Invalid {cls.__name__}: {value!r}")
normalized = value.strip().lower()
alias = cls._get_alias(normalized) alias = cls._get_alias(normalized)
for member in cls: for member in cls:
if member.value == alias: if member.value == alias:
return member return member
valid = ", ".join(member.value for member in cls) 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): 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" KEY = "key"
VALUE = "value" VALUE = "value"
@ -48,14 +114,54 @@ class SelectionReturnType(Enum):
DESCRIPTION_VALUE = "description_value" DESCRIPTION_VALUE = "description_value"
ITEMS = "items" 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 @classmethod
def _missing_(cls, value: object) -> SelectionReturnType: 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) 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): 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_NO = "yes_no"
YES_CANCEL = "yes_cancel" YES_CANCEL = "yes_cancel"
@ -70,15 +176,30 @@ class ConfirmType(Enum):
"""Return a list of all hook type choices.""" """Return a list of all hook type choices."""
return list(cls) return list(cls)
def __str__(self) -> str: @classmethod
"""Return the string representation of the confirm type.""" def _get_alias(cls, value: str) -> str:
return self.value 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 @classmethod
def _missing_(cls, value: object) -> ConfirmType: def _missing_(cls, value: object) -> ConfirmType:
if isinstance(value, str): 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: for member in cls:
if member.value == value.lower(): if member.value == alias:
return member return member
valid = ", ".join(member.value for member in cls) 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

View File

@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""base_action.py """
Core action system for Falyx. Core action system for Falyx.
This module defines the building blocks for executable actions and workflows, This module defines the building blocks for executable actions and workflows,
@ -50,10 +49,16 @@ class BaseAction(ABC):
complex actions like `ChainedAction` or `ActionGroup`. They can also complex actions like `ChainedAction` or `ActionGroup`. They can also
be run independently or as part of Falyx. be run independently or as part of Falyx.
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 inject_last_result (bool): Whether to inject the previous action's result
into kwargs. into kwargs.
inject_into (str): The name of the kwarg key to inject the result as inject_into (str): The name of the kwarg key to inject the result as
(default: 'last_result'). (default: 'last_result').
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__( def __init__(
@ -65,6 +70,7 @@ class BaseAction(ABC):
inject_into: str = "last_result", inject_into: str = "last_result",
never_prompt: bool | None = None, never_prompt: bool | None = None,
logging_hooks: bool = False, logging_hooks: bool = False,
ignore_in_history: bool = False,
) -> None: ) -> None:
self.name = name self.name = name
self.hooks = hooks or HookManager() self.hooks = hooks or HookManager()
@ -76,6 +82,7 @@ class BaseAction(ABC):
self._skip_in_chain: bool = False self._skip_in_chain: bool = False
self.console: Console = console self.console: Console = console
self.options_manager: OptionsManager | None = None self.options_manager: OptionsManager | None = None
self.ignore_in_history: bool = ignore_in_history
if logging_hooks: if logging_hooks:
register_debug_hooks(self.hooks) register_debug_hooks(self.hooks)

View File

@ -1,5 +1,69 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 __future__ import annotations
from typing import Any, Awaitable, Callable, Sequence from typing import Any, Awaitable, Callable, Sequence
@ -35,8 +99,10 @@ class ChainedAction(BaseAction, ActionListMixin):
previous results. previous results.
Args: 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. 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. hooks (HookManager, optional): Hooks for lifecycle events.
inject_last_result (bool, optional): Whether to inject last results into kwargs inject_last_result (bool, optional): Whether to inject last results into kwargs
by default. by default.
@ -235,7 +301,8 @@ class ChainedAction(BaseAction, ActionListMixin):
def __str__(self): def __str__(self):
return ( return (
f"ChainedAction(name={self.name!r}, " f"ChainedAction(name={self.name}, "
f"actions={[a.name for a in self.actions]!r}, " 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})" f"auto_inject={self.auto_inject}, return_list={self.return_list})"
) )

View File

@ -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 __future__ import annotations
from typing import Any from typing import Any
@ -30,7 +70,7 @@ class ConfirmAction(BaseAction):
with an operation. with an operation.
Attributes: 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. message (str): The confirmation message to display.
confirm_type (ConfirmType | str): The type of confirmation to use. confirm_type (ConfirmType | str): The type of confirmation to use.
Options include YES_NO, YES_CANCEL, YES_NO_CANCEL, TYPE_WORD, and OK_CANCEL. 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, never_prompt=never_prompt,
) )
self.message = message 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.prompt_session = prompt_session or PromptSession()
self.word = word self.word = word
self.return_last_result = return_last_result 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: async def _confirm(self) -> bool:
"""Confirm the action with the user.""" """Confirm the action with the user."""
match self.confirm_type: match self.confirm_type:

View File

@ -1,5 +1,41 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 functools import cached_property
from typing import Any from typing import Any

View File

@ -1,5 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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. Defines an Action subclass for making HTTP requests using aiohttp within Falyx workflows.
Features: Features:
@ -47,7 +47,7 @@ class HTTPAction(Action):
- Retry and result injection compatible - Retry and result injection compatible
Args: 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'). method (str): HTTP method (e.g., 'GET', 'POST').
url (str): The request URL. url (str): The request URL.
headers (dict[str, str], optional): Request headers. headers (dict[str, str], optional): Request headers.

View File

@ -1,5 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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. BaseIOAction: A base class for stream- or buffer-based IO-driven Actions.
This module defines `BaseIOAction`, a specialized variant of `BaseAction` 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. - `to_output(data)`: Convert result into output string or bytes.
- `_run(parsed_input, *args, **kwargs)`: Core execution logic. - `_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. 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. inject_last_result (bool): Whether to inject shared context input.
""" """

View File

@ -1,5 +1,36 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 __future__ import annotations
from functools import cached_property from functools import cached_property

View File

@ -1,5 +1,41 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 csv
import json import json
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
@ -21,13 +57,58 @@ from falyx.themes import OneColors
class LoadFileAction(BaseAction): 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__( def __init__(
self, self,
name: str, name: str,
file_path: str | Path | None = None, file_path: str | Path | None = None,
file_type: FileType | str = FileType.TEXT, file_type: FileType | str = FileType.TEXT,
encoding: str = "UTF-8",
inject_last_result: bool = False, inject_last_result: bool = False,
inject_into: str = "file_path", inject_into: str = "file_path",
): ):
@ -35,7 +116,8 @@ class LoadFileAction(BaseAction):
name=name, inject_last_result=inject_last_result, inject_into=inject_into name=name, inject_last_result=inject_last_result, inject_into=inject_into
) )
self._file_path = self._coerce_file_path(file_path) 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 @property
def file_path(self) -> Path | None: def file_path(self) -> Path | None:
@ -63,20 +145,6 @@ class LoadFileAction(BaseAction):
"""Get the file type.""" """Get the file type."""
return self._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]: def get_infer_target(self) -> tuple[None, None]:
return None, None return None, None
@ -91,27 +159,29 @@ class LoadFileAction(BaseAction):
value: Any = None value: Any = None
try: try:
if self.file_type == FileType.TEXT: 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: elif self.file_type == FileType.PATH:
value = self.file_path value = self.file_path
elif self.file_type == FileType.JSON: 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: 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: 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: 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) reader = csv.reader(csvfile)
value = list(reader) value = list(reader)
elif self.file_type == FileType.TSV: 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") reader = csv.reader(tsvfile, delimiter="\t")
value = list(reader) value = list(reader)
elif self.file_type == FileType.XML: 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() root = tree.getroot()
value = ET.tostring(root, encoding="unicode") value = root
else: else:
raise ValueError(f"Unsupported return type: {self.file_type}") raise ValueError(f"Unsupported return type: {self.file_type}")

View File

@ -1,5 +1,42 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 typing import Any
from prompt_toolkit import PromptSession from prompt_toolkit import PromptSession
@ -19,7 +56,57 @@ from falyx.utils import chunks
class MenuAction(BaseAction): 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__( def __init__(
self, self,

View File

@ -1,5 +1,42 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 from __future__ import annotations
import asyncio import asyncio

View File

@ -1,5 +1,19 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 from __future__ import annotations
import asyncio import asyncio
@ -23,6 +37,21 @@ from falyx.themes import OneColors
@dataclass @dataclass
class ProcessTask: 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] task: Callable[..., Any]
args: tuple = () args: tuple = ()
kwargs: dict[str, Any] = field(default_factory=dict) kwargs: dict[str, Any] = field(default_factory=dict)
@ -33,7 +62,44 @@ class ProcessTask:
class ProcessPoolAction(BaseAction): 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__( def __init__(
self, self,

View File

@ -1,5 +1,16 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 typing import Any
from prompt_toolkit import PromptSession from prompt_toolkit import PromptSession
@ -17,7 +28,53 @@ from falyx.themes import OneColors
class PromptMenuAction(BaseAction): 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__( def __init__(
self, self,

View File

@ -1,5 +1,25 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 csv
import json import json
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
@ -22,12 +42,50 @@ from falyx.themes import OneColors
class SaveFileAction(BaseAction): class SaveFileAction(BaseAction):
""" """
SaveFileAction saves data to a file in the specified format (e.g., TEXT, JSON, YAML). Saves data to a file in the specified format.
Supports overwrite control and integrates with chaining workflows via inject_last_result.
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__( def __init__(
@ -36,6 +94,7 @@ class SaveFileAction(BaseAction):
file_path: str, file_path: str,
file_type: FileType | str = FileType.TEXT, file_type: FileType | str = FileType.TEXT,
mode: Literal["w", "a"] = "w", mode: Literal["w", "a"] = "w",
encoding: str = "UTF-8",
data: Any = None, data: Any = None,
overwrite: bool = True, overwrite: bool = True,
create_dirs: 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_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). file_type (FileType | str): Format to write to (e.g. TEXT, JSON, YAML).
mode (Literal["w", "a"]): File mode (default: "w"). 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). data (Any): Data to be saved (if not using inject_last_result).
overwrite (bool): Whether to overwrite the file if it exists. overwrite (bool): Whether to overwrite the file if it exists.
create_dirs (bool): Whether to create parent directories if they do not exist. 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 name=name, inject_last_result=inject_last_result, inject_into=inject_into
) )
self._file_path = self._coerce_file_path(file_path) 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.data = data
self.overwrite = overwrite self.overwrite = overwrite
self.mode = mode self.mode = mode
self.create_dirs = create_dirs self.create_dirs = create_dirs
self.encoding = encoding
@property @property
def file_path(self) -> Path | None: def file_path(self) -> Path | None:
@ -92,20 +153,6 @@ class SaveFileAction(BaseAction):
"""Get the file type.""" """Get the file type."""
return self._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]: def get_infer_target(self) -> tuple[None, None]:
return None, None return None, None
@ -143,13 +190,15 @@ class SaveFileAction(BaseAction):
try: try:
if self.file_type == FileType.TEXT: 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: 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: 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: 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: elif self.file_type == FileType.CSV:
if not isinstance(data, list) or not all( if not isinstance(data, list) or not all(
isinstance(row, list) for row in data 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" f"{self.file_type.name} file type requires a list of lists"
) )
with open( with open(
self.file_path, mode=self.mode, newline="", encoding="UTF-8" self.file_path, mode=self.mode, newline="", encoding=self.encoding
) as csvfile: ) as csvfile:
writer = csv.writer(csvfile) writer = csv.writer(csvfile)
writer.writerows(data) writer.writerows(data)
@ -170,7 +219,7 @@ class SaveFileAction(BaseAction):
f"{self.file_type.name} file type requires a list of lists" f"{self.file_type.name} file type requires a list of lists"
) )
with open( with open(
self.file_path, mode=self.mode, newline="", encoding="UTF-8" self.file_path, mode=self.mode, newline="", encoding=self.encoding
) as tsvfile: ) as tsvfile:
writer = csv.writer(tsvfile, delimiter="\t") writer = csv.writer(tsvfile, delimiter="\t")
writer.writerows(data) writer.writerows(data)
@ -180,7 +229,7 @@ class SaveFileAction(BaseAction):
root = ET.Element("root") root = ET.Element("root")
self._dict_to_xml(data, root) self._dict_to_xml(data, root)
tree = ET.ElementTree(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: else:
raise ValueError(f"Unsupported file type: {self.file_type}") raise ValueError(f"Unsupported file type: {self.file_type}")

View File

@ -1,5 +1,47 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 from __future__ import annotations
import csv import csv
@ -67,6 +109,7 @@ class SelectFileAction(BaseAction):
style: str = OneColors.WHITE, style: str = OneColors.WHITE,
suffix_filter: str | None = None, suffix_filter: str | None = None,
return_type: FileType | str = FileType.PATH, return_type: FileType | str = FileType.PATH,
encoding: str = "UTF-8",
number_selections: int | str = 1, number_selections: int | str = 1,
separator: str = ",", separator: str = ",",
allow_duplicates: bool = False, allow_duplicates: bool = False,
@ -83,7 +126,8 @@ class SelectFileAction(BaseAction):
self.separator = separator self.separator = separator
self.allow_duplicates = allow_duplicates self.allow_duplicates = allow_duplicates
self.prompt_session = prompt_session or PromptSession() 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 @property
def number_selections(self) -> int | str: def number_selections(self) -> int | str:
@ -100,50 +144,45 @@ class SelectFileAction(BaseAction):
else: else:
raise ValueError("number_selections must be a positive integer or one of '*'") 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]: def get_options(self, files: list[Path]) -> dict[str, SelectionOption]:
value: Any
options = {} options = {}
for index, file in enumerate(files): for index, file in enumerate(files):
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: try:
if self.return_type == FileType.TEXT: if self.return_type == FileType.TEXT:
value = file.read_text(encoding="UTF-8") value = file.read_text(encoding=self.encoding)
elif self.return_type == FileType.PATH: elif self.return_type == FileType.PATH:
value = file value = file
elif self.return_type == FileType.JSON: elif self.return_type == FileType.JSON:
value = json.loads(file.read_text(encoding="UTF-8")) value = json.loads(file.read_text(encoding=self.encoding))
elif self.return_type == FileType.TOML: elif self.return_type == FileType.TOML:
value = toml.loads(file.read_text(encoding="UTF-8")) value = toml.loads(file.read_text(encoding=self.encoding))
elif self.return_type == FileType.YAML: elif self.return_type == FileType.YAML:
value = yaml.safe_load(file.read_text(encoding="UTF-8")) value = yaml.safe_load(file.read_text(encoding=self.encoding))
elif self.return_type == FileType.CSV: elif self.return_type == FileType.CSV:
with open(file, newline="", encoding="UTF-8") as csvfile: with open(file, newline="", encoding=self.encoding) as csvfile:
reader = csv.reader(csvfile) reader = csv.reader(csvfile)
value = list(reader) value = list(reader)
elif self.return_type == FileType.TSV: elif self.return_type == FileType.TSV:
with open(file, newline="", encoding="UTF-8") as tsvfile: with open(file, newline="", encoding=self.encoding) as tsvfile:
reader = csv.reader(tsvfile, delimiter="\t") reader = csv.reader(tsvfile, delimiter="\t")
value = list(reader) value = list(reader)
elif self.return_type == FileType.XML: elif self.return_type == FileType.XML:
tree = ET.parse(file, parser=ET.XMLParser(encoding="UTF-8")) tree = ET.parse(file, parser=ET.XMLParser(encoding=self.encoding))
root = tree.getroot() value = tree.getroot()
value = ET.tostring(root, encoding="unicode")
else: else:
raise ValueError(f"Unsupported return type: {self.return_type}") 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: except Exception as error:
logger.error("Failed to parse %s: %s", file.name, error) logger.error("Failed to parse %s: %s", file.name, error)
return options return value
def _find_cancel_key(self, options) -> str: def _find_cancel_key(self, options) -> str:
"""Return first numeric value not already used in the selection dict.""" """Return first numeric value not already used in the selection dict."""
@ -202,9 +241,9 @@ class SelectFileAction(BaseAction):
if isinstance(keys, str): if isinstance(keys, str):
if keys == cancel_key: if keys == cancel_key:
raise CancelSignal("User canceled the selection.") raise CancelSignal("User canceled the selection.")
result = options[keys].value result = self.parse_file(options[keys].value)
elif isinstance(keys, list): 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 context.result = result
await self.hooks.trigger(HookType.ON_SUCCESS, context) 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]Return type:[/] {self.return_type}")
tree.add(f"[dim]Prompt:[/] {self.prompt_message}") tree.add(f"[dim]Prompt:[/] {self.prompt_message}")
tree.add(f"[dim]Columns:[/] {self.columns}") tree.add(f"[dim]Columns:[/] {self.columns}")
tree.add("[dim]Loading:[/] Lazy (parsing occurs after selection)")
try: try:
files = list(self.directory.iterdir()) files = list(self.directory.iterdir())
if self.suffix_filter: if self.suffix_filter:

View File

@ -1,5 +1,36 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 typing import Any
from prompt_toolkit import PromptSession from prompt_toolkit import PromptSession
@ -114,7 +145,7 @@ class SelectionAction(BaseAction):
) )
# Setter normalizes to correct type, mypy can't infer that # Setter normalizes to correct type, mypy can't infer that
self.selections: list[str] | SelectionOptionMap = selections # type: ignore[assignment] 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.title = title
self.columns = columns self.columns = columns
self.prompt_session = prompt_session or PromptSession() self.prompt_session = prompt_session or PromptSession()
@ -140,13 +171,6 @@ class SelectionAction(BaseAction):
else: else:
raise ValueError("number_selections must be a positive integer or '*'") 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 @property
def selections(self) -> list[str] | SelectionOptionMap: def selections(self) -> list[str] | SelectionOptionMap:
return self._selections return self._selections

View File

@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 from __future__ import annotations

View File

@ -1,32 +1,85 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 rich.tree import Tree
from falyx.action.action import Action from falyx.action.action import Action
from falyx.hook_manager import HookManager
from falyx.signals import FlowSignal from falyx.signals import FlowSignal
from falyx.themes import OneColors from falyx.themes import OneColors
class SignalAction(Action): 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 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): 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 raise self.signal
@property @property
def signal(self): def signal(self):
"""Returns the configured `FlowSignal` instance."""
return self._signal return self._signal
@signal.setter @signal.setter
def signal(self, value: FlowSignal): 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): if not isinstance(value, FlowSignal):
raise TypeError( raise TypeError(
f"Signal must be an FlowSignal instance, got {type(value).__name__}" f"Signal must be an FlowSignal instance, got {type(value).__name__}"

View File

@ -1,5 +1,31 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 import PromptSession
from prompt_toolkit.validation import Validator from prompt_toolkit.validation import Validator
from rich.tree import Tree from rich.tree import Tree
@ -13,15 +39,20 @@ from falyx.themes.colors import OneColors
class UserInputAction(BaseAction): 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: Args:
name (str): Action name. name (str): Name of the action (used for introspection and logging).
prompt_text (str): Prompt text (can include '{last_result}' for interpolation). prompt_text (str): The prompt message shown to the user.
validator (Validator, optional): Prompt Toolkit validator. Can include `{last_result}` if `inject_last_result=True`.
prompt_session (PromptSession, optional): Reusable prompt session. default_text (str): Optional default value shown in the prompt.
inject_last_result (bool): Whether to inject last_result into prompt. validator (Validator | None): Prompt Toolkit validator for input constraints.
inject_into (str): Key to use for injection (default: 'last_result'). prompt_session (PromptSession | None): Optional custom prompt session.
inject_last_result (bool): Whether to inject `last_result` into the prompt.
""" """
def __init__( def __init__(

View File

@ -1,10 +1,42 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""bottom_bar.py""" """
Provides the `BottomBar` class for managing a customizable bottom status bar in
Falyx-based CLI applications.
The bottom bar is rendered using `prompt_toolkit` and supports:
- Rich-formatted static content
- Live-updating value trackers and counters
- Toggle switches activated via Ctrl+<key> bindings
- Config-driven visual and behavioral controls
Each item in the bar is registered by name and rendered in columns across the
bottom of the terminal. Toggles are linked to user-defined state accessors and
mutators, and can be automatically bound to `OptionsManager` values for full
integration with Falyx CLI argument parsing.
Key Features:
- Live rendering of structured status items using Rich-style HTML
- Custom or built-in item types: static text, dynamic counters, toggles, value displays
- Ctrl+key toggle handling via `prompt_toolkit.KeyBindings`
- Columnar layout with automatic width scaling
- Optional integration with `OptionsManager` for dynamic state toggling
Usage Example:
bar = BottomBar(columns=3)
bar.add_static("env", "ENV: dev")
bar.add_toggle("d", "Debug", get_debug, toggle_debug)
bar.add_value_tracker("attempts", "Retries", get_retry_count)
bar.render()
Used by Falyx to provide a persistent UI element showing toggles, system state,
and runtime telemetry below the input prompt.
"""
from typing import Any, Callable from typing import Any, Callable
from prompt_toolkit.formatted_text import HTML, merge_formatted_text from prompt_toolkit.formatted_text import HTML, merge_formatted_text
from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
from rich.console import Console from rich.console import Console
from falyx.console import console from falyx.console import console
@ -28,7 +60,6 @@ class BottomBar:
self, self,
columns: int = 3, columns: int = 3,
key_bindings: KeyBindings | None = None, key_bindings: KeyBindings | None = None,
key_validator: Callable[[str], bool] | None = None,
) -> None: ) -> None:
self.columns = columns self.columns = columns
self.console: Console = console self.console: Console = console
@ -36,7 +67,6 @@ class BottomBar:
self._value_getters: dict[str, Callable[[], Any]] = CaseInsensitiveDict() self._value_getters: dict[str, Callable[[], Any]] = CaseInsensitiveDict()
self.toggle_keys: list[str] = [] self.toggle_keys: list[str] = []
self.key_bindings = key_bindings or KeyBindings() self.key_bindings = key_bindings or KeyBindings()
self.key_validator = key_validator
@staticmethod @staticmethod
def default_render(label: str, value: Any, fg: str, bg: str, width: int) -> HTML: 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_on: str = OneColors.GREEN,
bg_off: str = OneColors.DARK_RED, bg_off: str = OneColors.DARK_RED,
) -> None: ) -> 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): if not callable(get_state):
raise ValueError("`get_state` must be a callable returning bool") raise ValueError("`get_state` must be a callable returning bool")
if not callable(toggle_state): if not callable(toggle_state):
raise ValueError("`toggle_state` must be a callable") raise ValueError("`toggle_state` must be a callable")
key = key.upper() key = key.lower()
if key in self.toggle_keys: if key in self.toggle_keys:
raise ValueError(f"Key {key} is already used as a toggle") 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._value_getters[key] = get_state
self.toggle_keys.append(key) self.toggle_keys.append(key)
@ -139,15 +178,13 @@ class BottomBar:
get_state_ = self._value_getters[key] get_state_ = self._value_getters[key]
color = bg_on if get_state_() else bg_off color = bg_on if get_state_() else bg_off
status = "ON" if get_state_() else "OFF" status = "ON" if get_state_() else "OFF"
text = f"({key.upper()}) {label}: {status}" text = f"(^{key.lower()}) {label}: {status}"
return HTML(f"<style bg='{color}' fg='{fg}'>{text:^{self.space}}</style>") return HTML(f"<style bg='{color}' fg='{fg}'>{text:^{self.space}}</style>")
self._add_named(key, render) self._add_named(key, render)
for k in (key.upper(), key.lower()): @self.key_bindings.add(f"c-{key.lower()}", eager=True)
def _(_: KeyPressEvent):
@self.key_bindings.add(k)
def _(_):
toggle_state() toggle_state()
def add_toggle_from_option( def add_toggle_from_option(

View File

@ -1,6 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""command.py """
Defines the Command class for Falyx CLI. Defines the Command class for Falyx CLI.
Commands are callable units representing a menu option or CLI task, 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. arguments (list[dict[str, Any]]): Argument definitions for the command.
argument_config (Callable[[CommandArgumentParser], None] | None): Function to configure arguments argument_config (Callable[[CommandArgumentParser], None] | None): Function to configure arguments
for the command parser. 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_parser (ArgParserProtocol | None): Custom argument parser.
custom_help (Callable[[], str | None] | None): Custom help message generator. custom_help (Callable[[], str | None] | None): Custom help message generator.
auto_args (bool): Automatically infer arguments from the action. 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: Methods:
__call__(): Executes the command, respecting hooks and retries. __call__(): Executes the command, respecting hooks and retries.
@ -140,6 +140,7 @@ class Command(BaseModel):
auto_args: bool = True auto_args: bool = True
arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict) arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict)
simple_help_signature: bool = False simple_help_signature: bool = False
ignore_in_history: bool = False
_context: ExecutionContext | None = PrivateAttr(default=None) _context: ExecutionContext | None = PrivateAttr(default=None)
@ -243,6 +244,9 @@ class Command(BaseModel):
for arg_def in self.get_argument_definitions(): for arg_def in self.get_argument_definitions():
self.arg_parser.add_argument(*arg_def.pop("flags"), **arg_def) 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: def _inject_options_manager(self) -> None:
"""Inject the options manager into the action if applicable.""" """Inject the options manager into the action if applicable."""
if isinstance(self.action, BaseAction): if isinstance(self.action, BaseAction):

View File

@ -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 from __future__ import annotations
import shlex import shlex
@ -11,12 +28,38 @@ if TYPE_CHECKING:
class FalyxCompleter(Completer): 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"): def __init__(self, falyx: "Falyx"):
self.falyx = falyx self.falyx = falyx
def get_completions(self, document: Document, complete_event) -> Iterable[Completion]: 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 text = document.text_before_cursor
try: try:
tokens = shlex.split(text) tokens = shlex.split(text)
@ -40,6 +83,8 @@ class FalyxCompleter(Completer):
stub = "" if cursor_at_end_of_token else tokens[-1] stub = "" if cursor_at_end_of_token else tokens[-1]
try: try:
if not command.arg_parser:
return
suggestions = command.arg_parser.suggest_next( suggestions = command.arg_parser.suggest_next(
parsed_args + ([stub] if stub else []) parsed_args + ([stub] if stub else [])
) )
@ -50,6 +95,15 @@ class FalyxCompleter(Completer):
return return
def _suggest_commands(self, prefix: str) -> Iterable[Completion]: 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() prefix = prefix.upper()
keys = [self.falyx.exit_command.key] keys = [self.falyx.exit_command.key]
keys.extend(self.falyx.exit_command.aliases) keys.extend(self.falyx.exit_command.aliases)

View File

@ -1,6 +1,41 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 from __future__ import annotations
import importlib import importlib

View File

@ -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 rich.console import Console
from falyx.themes import get_nord_theme from falyx.themes import get_nord_theme

View File

@ -19,6 +19,7 @@ from __future__ import annotations
import time import time
from datetime import datetime from datetime import datetime
from traceback import format_exception
from typing import Any from typing import Any
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@ -75,7 +76,8 @@ class ExecutionContext(BaseModel):
kwargs: dict = Field(default_factory=dict) kwargs: dict = Field(default_factory=dict)
action: Any action: Any
result: Any | None = None result: Any | None = None
exception: BaseException | None = None traceback: str | None = None
_exception: BaseException | None = None
start_time: float | None = None start_time: float | None = None
end_time: float | None = None end_time: float | None = None
@ -122,6 +124,16 @@ class ExecutionContext(BaseModel):
def status(self) -> str: def status(self) -> str:
return "OK" if self.success else "ERROR" 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 @property
def signature(self) -> str: def signature(self) -> str:
""" """
@ -138,6 +150,7 @@ class ExecutionContext(BaseModel):
"name": self.name, "name": self.name,
"result": self.result, "result": self.result,
"exception": repr(self.exception) if self.exception else None, "exception": repr(self.exception) if self.exception else None,
"traceback": self.traceback,
"duration": self.duration, "duration": self.duration,
"extra": self.extra, "extra": self.extra,
} }

View File

@ -1,5 +1,18 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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.context import ExecutionContext
from falyx.hook_manager import HookManager, HookType from falyx.hook_manager import HookManager, HookType
from falyx.logger import logger from falyx.logger import logger

View File

@ -1,5 +1,28 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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): class FalyxError(Exception):

View File

@ -1,29 +1,49 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 The registry automatically records every `ExecutionContext` created during action
introspecting the execution history of Falyx actions. 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 Designed for:
easy to debug, audit, and visualize workflow behavior over time. It supports retrieval, - Workflow debugging and CLI diagnostics
filtering, clearing, and formatted summary display. - Interactive history browsing or replaying previous runs
- Providing user-visible `history` or `last-result` commands inside CLI apps
Core Features: Key Features:
- Stores all action execution contexts globally (with access by name). - Global, in-memory store of all `ExecutionContext` objects (by name, index, or full list)
- Provides live execution summaries in a rich table format. - Thread-safe indexing and summary display
- Enables creation of a built-in Falyx Action to print history on demand. - Traceback-aware result inspection and filtering by status (success/error)
- Integrates with Falyx's introspectable and hook-driven execution model. - Used by built-in `History` command in Falyx CLI
Intended for:
- Debugging and diagnostics
- Post-run inspection of CLI workflows
- Interactive tools built with Falyx
Example: Example:
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
# Record a context
er.record(context) er.record(context)
# Display a rich table summary
er.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 from __future__ import annotations
@ -46,30 +66,25 @@ class ExecutionRegistry:
""" """
Global registry for recording and inspecting Falyx action executions. Global registry for recording and inspecting Falyx action executions.
This class captures every `ExecutionContext` generated by a Falyx `Action`, This class captures every `ExecutionContext` created by Falyx Actions,
`ChainedAction`, or `ActionGroup`, maintaining both full history and tracking metadata, results, exceptions, and performance metrics. It enables
name-indexed access for filtered analysis. rich introspection, post-execution inspection, and formatted summaries
suitable for interactive and headless CLI use.
Methods: Data is retained in memory until cleared or process exit.
- record(context): Stores an ExecutionContext, logging a summary line.
- get_all(): Returns the list of all recorded executions.
- get_by_name(name): Returns all executions with the given action name.
- get_latest(): Returns the most recent execution.
- clear(): Wipes the registry for a fresh run.
- summary(): Renders a formatted Rich table of all execution results.
Use Cases: Use Cases:
- Debugging chained or factory-generated workflows - Auditing chained or dynamic workflows
- Viewing results and exceptions from multiple runs - Rendering execution history in a help/debug menu
- Embedding a diagnostic command into your CLI for user support - Accessing previous results or errors for reuse
Note: Attributes:
This registry is in-memory and not persistent. It's reset each time the process _store_by_name (dict): Maps action name → list of ExecutionContext objects.
restarts or `clear()` is called. _store_by_index (dict): Maps numeric index → ExecutionContext.
_store_all (list): Ordered list of all contexts.
Example: _index (int): Global counter for assigning unique execution indices.
ExecutionRegistry.record(context) _lock (Lock): Thread lock for atomic writes to the registry.
ExecutionRegistry.summary() _console (Console): Rich console used for rendering summaries.
""" """
_store_by_name: dict[str, list[ExecutionContext]] = defaultdict(list) _store_by_name: dict[str, list[ExecutionContext]] = defaultdict(list)
@ -81,7 +96,15 @@ class ExecutionRegistry:
@classmethod @classmethod
def record(cls, context: ExecutionContext): 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()) logger.debug(context.to_log_line())
with cls._lock: with cls._lock:
context.index = cls._index context.index = cls._index
@ -92,18 +115,44 @@ class ExecutionRegistry:
@classmethod @classmethod
def get_all(cls) -> list[ExecutionContext]: 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 return cls._store_all
@classmethod @classmethod
def get_by_name(cls, name: str) -> list[ExecutionContext]: 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, []) return cls._store_by_name.get(name, [])
@classmethod @classmethod
def get_latest(cls) -> ExecutionContext: def get_latest(cls) -> ExecutionContext:
"""
Return the most recent execution context.
Returns:
ExecutionContext: The last recorded context.
"""
return cls._store_all[-1] return cls._store_all[-1]
@classmethod @classmethod
def clear(cls): 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_by_name.clear()
cls._store_all.clear() cls._store_all.clear()
cls._store_by_index.clear() cls._store_by_index.clear()
@ -118,6 +167,21 @@ class ExecutionRegistry:
last_result: bool = False, last_result: bool = False,
status: Literal["all", "success", "error"] = "all", 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: if clear:
cls.clear() cls.clear()
cls._console.print(f"[{OneColors.GREEN}]✅ Execution history cleared.") cls._console.print(f"[{OneColors.GREEN}]✅ Execution history cleared.")
@ -125,13 +189,7 @@ class ExecutionRegistry:
if last_result: if last_result:
for ctx in reversed(cls._store_all): for ctx in reversed(cls._store_all):
if ctx.name.upper() not in [ if not ctx.action.ignore_in_history:
"HISTORY",
"HELP",
"EXIT",
"VIEW EXECUTION HISTORY",
"BACK",
]:
cls._console.print(ctx.result) cls._console.print(ctx.result)
return return
cls._console.print( cls._console.print(
@ -148,8 +206,8 @@ class ExecutionRegistry:
) )
return return
cls._console.print(f"{result_context.signature}:") cls._console.print(f"{result_context.signature}:")
if result_context.exception: if result_context.traceback:
cls._console.print(result_context.exception) cls._console.print(result_context.traceback)
else: else:
cls._console.print(result_context.result) cls._console.print(result_context.result)
return return

View File

@ -1,5 +1,6 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 Falyx provides a structured, customizable interactive menu system
for running commands, actions, and workflows. It supports: for running commands, actions, and workflows. It supports:
@ -75,7 +76,7 @@ class FalyxMode(Enum):
class CommandValidator(Validator): 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: def __init__(self, falyx: Falyx, error_message: str) -> None:
super().__init__() super().__init__()
@ -295,6 +296,7 @@ class Falyx:
aliases=["EXIT", "QUIT"], aliases=["EXIT", "QUIT"],
style=OneColors.DARK_RED, style=OneColors.DARK_RED,
simple_help_signature=True, simple_help_signature=True,
ignore_in_history=True,
) )
def _get_history_command(self) -> Command: def _get_history_command(self) -> Command:
@ -347,6 +349,7 @@ class Falyx:
style=OneColors.DARK_YELLOW, style=OneColors.DARK_YELLOW,
arg_parser=parser, arg_parser=parser,
help_text="View the execution history of commands.", help_text="View the execution history of commands.",
ignore_in_history=True,
) )
async def _show_help(self, tag: str = "") -> None: async def _show_help(self, tag: str = "") -> None:
@ -410,6 +413,7 @@ class Falyx:
action=Action("Help", self._show_help), action=Action("Help", self._show_help),
style=OneColors.LIGHT_YELLOW, style=OneColors.LIGHT_YELLOW,
arg_parser=parser, arg_parser=parser,
ignore_in_history=True,
) )
def _get_completer(self) -> FalyxCompleter: def _get_completer(self) -> FalyxCompleter:
@ -417,7 +421,7 @@ class Falyx:
return FalyxCompleter(self) return FalyxCompleter(self)
def _get_validator_error_message(self) -> str: 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 = {self.exit_command.key.upper()}
keys.update({alias.upper() for alias in self.exit_command.aliases}) keys.update({alias.upper() for alias in self.exit_command.aliases})
if self.history_command: if self.history_command:
@ -431,19 +435,12 @@ class Falyx:
keys.add(cmd.key.upper()) keys.add(cmd.key.upper())
keys.update({alias.upper() for alias in cmd.aliases}) 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)) commands_str = ", ".join(sorted(keys))
toggles_str = ", ".join(sorted(toggle_keys))
message_lines = ["Invalid input. Available keys:"] message_lines = ["Invalid input. Available keys:"]
if keys: if keys:
message_lines.append(f" Commands: {commands_str}") message_lines.append(f" Commands: {commands_str}")
if toggle_keys:
message_lines.append(f" Toggles: {toggles_str}")
error_message = " ".join(message_lines) error_message = " ".join(message_lines)
return error_message return error_message
@ -473,10 +470,9 @@ class Falyx:
"""Sets the bottom bar for the menu.""" """Sets the bottom bar for the menu."""
if bottom_bar is None: if bottom_bar is None:
self._bottom_bar: BottomBar | str | Callable[[], Any] = BottomBar( 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): elif isinstance(bottom_bar, BottomBar):
bottom_bar.key_validator = self.is_key_available
bottom_bar.key_bindings = self.key_bindings bottom_bar.key_bindings = self.key_bindings
self._bottom_bar = bottom_bar self._bottom_bar = bottom_bar
elif isinstance(bottom_bar, str) or callable(bottom_bar): elif isinstance(bottom_bar, str) or callable(bottom_bar):
@ -544,32 +540,9 @@ class Falyx:
for key, command in self.commands.items(): for key, command in self.commands.items():
logger.debug("[Command '%s'] hooks:\n%s", key, str(command.hooks)) 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: def _validate_command_key(self, key: str) -> None:
"""Validates the command key to ensure it is unique.""" """Validates the command key to ensure it is unique."""
key = key.upper() key = key.upper()
toggles = (
self._bottom_bar.toggle_keys
if isinstance(self._bottom_bar, BottomBar)
else []
)
collisions = [] collisions = []
if key in self.commands: if key in self.commands:
@ -580,8 +553,6 @@ class Falyx:
collisions.append("history command") collisions.append("history command")
if self.help_command and key == self.help_command.key.upper(): if self.help_command and key == self.help_command.key.upper():
collisions.append("help command") collisions.append("help command")
if key in toggles:
collisions.append("toggle")
if collisions: if collisions:
raise CommandAlreadyExistsError( raise CommandAlreadyExistsError(
@ -611,6 +582,7 @@ class Falyx:
style=style, style=style,
confirm=confirm, confirm=confirm,
confirm_message=confirm_message, confirm_message=confirm_message,
ignore_in_history=True,
) )
def add_submenu( def add_submenu(
@ -685,6 +657,7 @@ class Falyx:
auto_args: bool = True, auto_args: bool = True,
arg_metadata: dict[str, str | dict[str, Any]] | None = None, arg_metadata: dict[str, str | dict[str, Any]] | None = None,
simple_help_signature: bool = False, simple_help_signature: bool = False,
ignore_in_history: bool = False,
) -> Command: ) -> Command:
"""Adds an command to the menu, preventing duplicates.""" """Adds an command to the menu, preventing duplicates."""
self._validate_command_key(key) self._validate_command_key(key)
@ -729,6 +702,7 @@ class Falyx:
auto_args=auto_args, auto_args=auto_args,
arg_metadata=arg_metadata or {}, arg_metadata=arg_metadata or {},
simple_help_signature=simple_help_signature, simple_help_signature=simple_help_signature,
ignore_in_history=ignore_in_history,
) )
if hooks: if hooks:

View File

@ -1,5 +1,21 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 from __future__ import annotations
import inspect import inspect
@ -15,7 +31,27 @@ Hook = Union[
class HookType(Enum): 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" BEFORE = "before"
ON_SUCCESS = "on_success" ON_SUCCESS = "on_success"
@ -28,13 +64,49 @@ class HookType(Enum):
"""Return a list of all hook type choices.""" """Return a list of all hook type choices."""
return list(cls) 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: def __str__(self) -> str:
"""Return the string representation of the hook type.""" """Return the string representation of the hook type."""
return self.value return self.value
class HookManager: 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: def __init__(self) -> None:
self._hooks: dict[HookType, list[Hook]] = { self._hooks: dict[HookType, list[Hook]] = {
@ -42,12 +114,26 @@ class HookManager:
} }
def register(self, hook_type: HookType | str, hook: Hook): def register(self, hook_type: HookType | str, hook: Hook):
"""Raises ValueError if the hook type is not supported.""" """
if not isinstance(hook_type, HookType): 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) hook_type = HookType(hook_type)
self._hooks[hook_type].append(hook) self._hooks[hook_type].append(hook)
def clear(self, hook_type: HookType | None = None): 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: if hook_type:
self._hooks[hook_type] = [] self._hooks[hook_type] = []
else: else:
@ -55,6 +141,17 @@ class HookManager:
self._hooks[ht] = [] self._hooks[ht] = []
async def trigger(self, hook_type: HookType, context: ExecutionContext): 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: if hook_type not in self._hooks:
raise ValueError(f"Unsupported hook type: {hook_type}") raise ValueError(f"Unsupported hook type: {hook_type}")
for hook in self._hooks[hook_type]: for hook in self._hooks[hook_type]:
@ -71,7 +168,6 @@ class HookManager:
context.name, context.name,
hook_error, hook_error,
) )
if hook_type == HookType.ON_ERROR: if hook_type == HookType.ON_ERROR:
assert isinstance( assert isinstance(
context.exception, Exception context.exception, Exception

View File

@ -1,5 +1,30 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 import time
from typing import Any, Callable from typing import Any, Callable

View File

@ -1,5 +1,23 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 pathlib import Path
from falyx.console import console from falyx.console import console

View File

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

View File

@ -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 __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
@ -12,7 +28,25 @@ from falyx.utils import CaseInsensitiveDict
@dataclass @dataclass
class MenuOption: 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 description: str
action: BaseAction action: BaseAction
@ -37,8 +71,27 @@ class MenuOption:
class MenuOptionMap(CaseInsensitiveDict): class MenuOptionMap(CaseInsensitiveDict):
""" """
Manages menu options including validation, reserved key protection, A container for storing and managing `MenuOption` objects by key.
and special signal entries like Quit and Back.
`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"} RESERVED_KEYS = {"B", "X"}

View File

@ -1,5 +1,34 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 argparse import Namespace
from collections import defaultdict from collections import defaultdict
@ -9,7 +38,13 @@ from falyx.logger import logger
class OptionsManager: 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: def __init__(self, namespaces: list[tuple[str, Namespace]] | None = None) -> None:
self.options: defaultdict = defaultdict(Namespace) self.options: defaultdict = defaultdict(Namespace)

View File

@ -1,5 +1,38 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 dataclasses import dataclass
from typing import Any from typing import Any
@ -26,7 +59,7 @@ class Argument:
resolver (BaseAction | None): resolver (BaseAction | None):
An action object that resolves the argument, if applicable. An action object that resolves the argument, if applicable.
lazy_resolver (bool): True if the resolver should be called lazily, False otherwise 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, ...] flags: tuple[str, ...]

View File

@ -1,12 +1,55 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 __future__ import annotations
from enum import Enum from enum import Enum
class ArgumentAction(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" ACTION = "action"
STORE = "store" STORE = "store"
@ -23,6 +66,27 @@ class ArgumentAction(Enum):
"""Return a list of all argument actions.""" """Return a list of all argument actions."""
return list(cls) 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: def __str__(self) -> str:
"""Return the string representation of the argument action.""" """Return the string representation of the argument action."""
return self.value return self.value

View File

@ -1,5 +1,49 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 __future__ import annotations
from collections import defaultdict from collections import defaultdict
@ -407,26 +451,25 @@ class CommandArgumentParser:
lazy_resolver: bool = True, lazy_resolver: bool = True,
suggestions: list[str] | None = None, suggestions: list[str] | None = None,
) -> None: ) -> None:
"""Add an argument to the parser. """
For `ArgumentAction.ACTION`, `nargs` and `type` determine how many and what kind Define a new argument for the parser.
of inputs are passed to the `resolver`.
The return value of the `resolver` is used directly (no type coercion is applied). Supports positional and flagged arguments, type coercion, default values,
Validation, structure, and post-processing should be handled within the `resolver`. validation rules, and optional resolution via `BaseAction`.
Args: Args:
name or flags: Either a name or prefixed flags (e.g. 'faylx', '-f', '--falyx'). *flags (str): The flag(s) or name identifying the argument (e.g., "-v", "--verbose").
action: The action to be taken when the argument is encountered. action (str | ArgumentAction): The argument action type (default: "store").
nargs: The number of arguments expected. nargs (int | str | None): Number of values the argument consumes.
default: The default value if the argument is not provided. default (Any): Default value if the argument is not provided.
type: The type to which the command-line argument should be converted. type (type): Type to coerce argument values to.
choices: A container of the allowable values for the argument. choices (Iterable | None): Optional set of allowed values.
required: Whether or not the argument is required. required (bool): Whether this argument is mandatory.
help: A brief description of the argument. help (str): Help text for rendering in command help.
dest: The name of the attribute to be added to the object returned by parse_args(). dest (str | None): Custom destination key in result dict.
resolver: A BaseAction called with optional nargs specified parsed arguments. resolver (BaseAction | None): If action="action", the BaseAction to call.
lazy_resolver: If True, the resolver is called lazily when the argument is accessed. lazy_resolver (bool): If True, resolver defers until action is triggered.
suggestions: A list of suggestions for the argument. suggestions (list[str] | None): Optional suggestions for interactive completion.
""" """
expected_type = type expected_type = type
self._validate_flags(flags) self._validate_flags(flags)
@ -486,9 +529,24 @@ class CommandArgumentParser:
self._register_argument(argument) self._register_argument(argument)
def get_argument(self, dest: str) -> Argument | None: 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) return next((a for a in self._arguments if a.dest == dest), None)
def to_definition_list(self) -> list[dict[str, Any]]: 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 = [] defs = []
for arg in self._arguments: for arg in self._arguments:
defs.append( defs.append(
@ -507,7 +565,7 @@ class CommandArgumentParser:
) )
return defs return defs
def raise_remaining_args_error( def _raise_remaining_args_error(
self, token: str, arg_states: dict[str, ArgumentState] self, token: str, arg_states: dict[str, ArgumentState]
) -> None: ) -> None:
consumed_dests = [ consumed_dests = [
@ -619,7 +677,7 @@ class CommandArgumentParser:
except Exception as error: except Exception as error:
if len(args[i - new_i :]) == 1 and args[i - new_i].startswith("-"): if len(args[i - new_i :]) == 1 and args[i - new_i].startswith("-"):
token = args[i - new_i] token = args[i - new_i]
self.raise_remaining_args_error(token, arg_states) self._raise_remaining_args_error(token, arg_states)
else: else:
raise CommandArgumentError( raise CommandArgumentError(
f"Invalid value for '{spec.dest}': {error}" f"Invalid value for '{spec.dest}': {error}"
@ -660,7 +718,7 @@ class CommandArgumentParser:
if i < len(args): if i < len(args):
if len(args[i:]) == 1 and args[i].startswith("-"): if len(args[i:]) == 1 and args[i].startswith("-"):
token = args[i] token = args[i]
self.raise_remaining_args_error(token, arg_states) self._raise_remaining_args_error(token, arg_states)
else: else:
plural = "s" if len(args[i:]) > 1 else "" plural = "s" if len(args[i:]) > 1 else ""
raise CommandArgumentError( raise CommandArgumentError(
@ -813,7 +871,7 @@ class CommandArgumentParser:
consumed_indices.update(range(i, new_i)) consumed_indices.update(range(i, new_i))
i = new_i i = new_i
elif token.startswith("-"): elif token.startswith("-"):
self.raise_remaining_args_error(token, arg_states) self._raise_remaining_args_error(token, arg_states)
else: else:
# Get the next flagged argument index if it exists # Get the next flagged argument index if it exists
next_flagged_index = -1 next_flagged_index = -1
@ -837,7 +895,16 @@ class CommandArgumentParser:
async def parse_args( async def parse_args(
self, args: list[str] | None = None, from_validate: bool = False self, args: list[str] | None = None, from_validate: bool = False
) -> dict[str, Any]: ) -> 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: if args is None:
args = [] args = []
@ -932,9 +999,12 @@ class CommandArgumentParser:
self, args: list[str], from_validate: bool = False self, args: list[str], from_validate: bool = False
) -> tuple[tuple[Any, ...], dict[str, Any]]: ) -> tuple[tuple[Any, ...], dict[str, Any]]:
""" """
Parse arguments and return both positional and keyword mappings.
Useful for function-style calling with `*args, **kwargs`.
Returns: Returns:
tuple[args, kwargs] - Positional arguments in defined order, tuple: (args tuple, kwargs dict)
followed by keyword argument mapping.
""" """
parsed = await self.parse_args(args, from_validate) parsed = await self.parse_args(args, from_validate)
args_list = [] args_list = []
@ -950,12 +1020,15 @@ class CommandArgumentParser:
def suggest_next(self, args: list[str]) -> list[str]: 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: Returns:
A list of possible completions based on the current input. list[str]: List of suggested completions.
""" """
# Case 1: Next positional argument # Case 1: Next positional argument
@ -1034,6 +1107,12 @@ class CommandArgumentParser:
return sorted(set(suggestions)) return sorted(set(suggestions))
def get_options_text(self, plain_text=False) -> str: 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 # Options
# Add all keyword arguments to the options list # Add all keyword arguments to the options list
options_list = [] options_list = []
@ -1057,6 +1136,14 @@ class CommandArgumentParser:
return " ".join(options_list) return " ".join(options_list)
def get_command_keys_text(self, plain_text=False) -> str: 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: if plain_text:
command_keys = " | ".join( command_keys = " | ".join(
[f"{self.command_key}"] + [f"{alias}" for alias in self.aliases] [f"{self.command_key}"] + [f"{alias}" for alias in self.aliases]
@ -1072,7 +1159,12 @@ class CommandArgumentParser:
return command_keys return command_keys
def get_usage(self, plain_text=False) -> str: 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) command_keys = self.get_command_keys_text(plain_text)
options_text = self.get_options_text(plain_text) options_text = self.get_options_text(plain_text)
if options_text: if options_text:
@ -1080,6 +1172,11 @@ class CommandArgumentParser:
return command_keys return command_keys
def render_help(self) -> None: 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() usage = self.get_usage()
self.console.print(f"[bold]usage: {usage}[/bold]\n") 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))) return hash(tuple(sorted(self._arguments, key=lambda a: a.dest)))
def __str__(self) -> str: def __str__(self) -> str:
"""Return a human-readable summary of the parser state."""
positional = sum(arg.positional for arg in self._arguments) positional = sum(arg.positional for arg in self._arguments)
required = sum(arg.required for arg in self._arguments) required = sum(arg.required for arg in self._arguments)
return ( return (

View File

@ -1,5 +1,10 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 from typing import Any

View File

@ -1,7 +1,22 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 ( from argparse import (
REMAINDER, REMAINDER,
ArgumentParser, ArgumentParser,

View File

@ -1,4 +1,15 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 import inspect
from typing import Any, Callable from typing import Any, Callable
@ -10,8 +21,18 @@ def infer_args_from_func(
arg_metadata: dict[str, str | dict[str, Any]] | None = None, arg_metadata: dict[str, str | dict[str, Any]] | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
""" """
Infer argument definitions from a callable's signature. Infer CLI-style argument definitions from a function signature.
Returns a list of kwargs suitable for CommandArgumentParser.add_argument.
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): if not callable(func):
logger.debug("Provided argument is not callable: %s", func) logger.debug("Provided argument is not callable: %s", func)

View File

@ -1,4 +1,17 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 import types
from datetime import datetime from datetime import datetime
from enum import EnumMeta from enum import EnumMeta
@ -12,6 +25,17 @@ from falyx.parser.signature import infer_args_from_func
def coerce_bool(value: str) -> bool: 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): if isinstance(value, bool):
return value return value
value = value.strip().lower() value = value.strip().lower()
@ -23,6 +47,21 @@ def coerce_bool(value: str) -> bool:
def coerce_enum(value: Any, enum_type: EnumMeta) -> Any: 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): if isinstance(value, enum_type):
return value return value
@ -42,6 +81,21 @@ def coerce_enum(value: Any, enum_type: EnumMeta) -> Any:
def coerce_value(value: str, target_type: type) -> 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) origin = get_origin(target_type)
args = get_args(target_type) args = get_args(target_type)
@ -79,7 +133,19 @@ def same_argument_definitions(
actions: list[Any], actions: list[Any],
arg_metadata: dict[str, str | dict[str, Any]] | None = None, arg_metadata: dict[str, str | dict[str, Any]] | None = None,
) -> list[dict[str, Any]] | 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 = [] arg_sets = []
for action in actions: for action in actions:
if isinstance(action, BaseAction): if isinstance(action, BaseAction):

View File

@ -1,5 +1,15 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""prompt_utils.py""" """
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 import PromptSession
from prompt_toolkit.formatted_text import ( from prompt_toolkit.formatted_text import (
AnyFormattedText, AnyFormattedText,

View File

@ -1,5 +1,18 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 __future__ import annotations
from typing import Any, Awaitable, Callable, Protocol, runtime_checkable from typing import Any, Awaitable, Callable, Protocol, runtime_checkable

View File

@ -1,5 +1,23 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 from __future__ import annotations
import asyncio import asyncio
@ -12,7 +30,28 @@ from falyx.logger import logger
class RetryPolicy(BaseModel): 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) max_retries: int = Field(default=3, ge=0)
delay: float = Field(default=1.0, ge=0.0) delay: float = Field(default=1.0, ge=0.0)
@ -36,7 +75,27 @@ class RetryPolicy(BaseModel):
class RetryHandler: 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()): def __init__(self, policy: RetryPolicy = RetryPolicy()):
self.policy = policy self.policy = policy

View File

@ -1,5 +1,14 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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.action import Action
from falyx.action.base_action import BaseAction from falyx.action.base_action import BaseAction
from falyx.hook_manager import HookType from falyx.hook_manager import HookType

View File

@ -1,5 +1,17 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 dataclasses import dataclass
from typing import Any, Callable, KeysView, Sequence from typing import Any, Callable, KeysView, Sequence

View File

@ -1,5 +1,21 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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): class FlowSignal(BaseException):

View File

@ -1,5 +1,15 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""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 collections import defaultdict
from rich import box from rich import box

View File

@ -1,5 +1,21 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 from __future__ import annotations
import functools import functools

View File

@ -1,5 +1,22 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # 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 typing import KeysView, Sequence
from prompt_toolkit.validation import ValidationError, Validator from prompt_toolkit.validation import ValidationError, Validator

View File

@ -1 +1 @@
__version__ = "0.1.64" __version__ = "0.1.65"

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "falyx" name = "falyx"
version = "0.1.64" version = "0.1.65"
description = "Reliable and introspectable async CLI action framework." description = "Reliable and introspectable async CLI action framework."
authors = ["Roland Thomas Jr <roland@rtj.dev>"] authors = ["Roland Thomas Jr <roland@rtj.dev>"]
license = "MIT" license = "MIT"

View File

@ -1,6 +1,12 @@
import pytest 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.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
@ -38,14 +44,13 @@ async def test_action_async_callable():
action = Action("test_action", async_callable) action = Action("test_action", async_callable)
result = await action() result = await action()
assert result == "Hello, World!" assert result == "Hello, World!"
print(action)
assert ( assert (
str(action) 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 ( assert (
repr(action) 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, return_list=True,
) )
print(chain)
result = await chain() result = await chain()
assert result == [1, 2] assert result == [1, 2]
assert ( assert (
str(chain) 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.""" """Test if ActionGroup can be created and used."""
action1 = Action("one", lambda: 1) action1 = Action("one", lambda: 1)
action2 = Action("two", lambda: 2) action2 = Action("two", lambda: 2)
group = ChainedAction( group = ActionGroup(
name="Simple Group", name="Simple Group",
actions=[action1, action2], actions=[action1, action2],
return_list=True,
) )
print(group)
result = await group() result = await group()
assert result == [1, 2] assert result == [("one", 1), ("two", 2)]
assert ( assert (
str(group) 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)"
) )

View File

@ -0,0 +1,94 @@
import pytest
from falyx.action import ConfirmAction
@pytest.mark.asyncio
async def test_confirm_action_yes_no():
action = ConfirmAction(
name="test",
message="Are you sure?",
never_prompt=True,
confirm_type="yes_no",
)
result = await action()
assert result is True
@pytest.mark.asyncio
async def test_confirm_action_yes_cancel():
action = ConfirmAction(
name="test",
message="Are you sure?",
never_prompt=True,
confirm_type="yes_cancel",
)
result = await action()
assert result is True
@pytest.mark.asyncio
async def test_confirm_action_yes_no_cancel():
action = ConfirmAction(
name="test",
message="Are you sure?",
never_prompt=True,
confirm_type="yes_no_cancel",
)
result = await action()
assert result is True
@pytest.mark.asyncio
async def test_confirm_action_type_word():
action = ConfirmAction(
name="test",
message="Are you sure?",
never_prompt=True,
confirm_type="type_word",
)
result = await action()
assert result is True
@pytest.mark.asyncio
async def test_confirm_action_type_word_cancel():
action = ConfirmAction(
name="test",
message="Are you sure?",
never_prompt=True,
confirm_type="type_word_cancel",
)
result = await action()
assert result is True
@pytest.mark.asyncio
async def test_confirm_action_ok_cancel():
action = ConfirmAction(
name="test",
message="Are you sure?",
never_prompt=True,
confirm_type="ok_cancel",
)
result = await action()
assert result is True
@pytest.mark.asyncio
async def test_confirm_action_acknowledge():
action = ConfirmAction(
name="test",
message="Are you sure?",
never_prompt=True,
confirm_type="acknowledge",
)
result = await action()
assert result is True

View File

@ -1,6 +1,6 @@
import pytest import pytest
from falyx.action.selection_action import SelectionAction from falyx.action import SelectionAction
from falyx.selection import SelectionOption from falyx.selection import SelectionOption

View File

@ -53,7 +53,7 @@ def test_command_str():
print(cmd) print(cmd)
assert ( assert (
str(cmd) 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)')"
) )