This commit is contained in:
Roland Thomas Jr 2025-05-13 00:18:04 -04:00
parent e999ad5e1c
commit 87a56ac40b
Signed by: roland
GPG Key ID: 7C3C2B085A4C2872
37 changed files with 428 additions and 253 deletions

View File

@ -4,7 +4,8 @@
Core action system for Falyx.
This module defines the building blocks for executable actions and workflows,
providing a structured way to compose, execute, recover, and manage sequences of operations.
providing a structured way to compose, execute, recover, and manage sequences of
operations.
All actions are callable and follow a unified signature:
result = action(*args, **kwargs)
@ -14,7 +15,8 @@ Core guarantees:
- Consistent timing and execution context tracking for each run.
- Unified, predictable result handling and error propagation.
- Optional last_result injection to enable flexible, data-driven workflows.
- Built-in support for retries, rollbacks, parallel groups, chaining, and fallback recovery.
- Built-in support for retries, rollbacks, parallel groups, chaining, and fallback
recovery.
Key components:
- Action: wraps a function or coroutine into a standard executable unit.
@ -43,10 +45,11 @@ from falyx.debug import register_debug_hooks
from falyx.exceptions import EmptyChainError
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import Hook, HookManager, HookType
from falyx.logger import logger
from falyx.options_manager import OptionsManager
from falyx.retry import RetryHandler, RetryPolicy
from falyx.themes.colors import OneColors
from falyx.utils import ensure_async, logger
from falyx.utils import ensure_async
class BaseAction(ABC):
@ -55,7 +58,8 @@ class BaseAction(ABC):
complex actions like `ChainedAction` or `ActionGroup`. They can also
be run independently or as part of Falyx.
inject_last_result (bool): Whether to inject the previous action's result into kwargs.
inject_last_result (bool): Whether to inject the previous action's result
into kwargs.
inject_into (str): The name of the kwarg key to inject the result as
(default: 'last_result').
_requires_injection (bool): Whether the action requires input injection.
@ -104,7 +108,9 @@ class BaseAction(ABC):
self.shared_context = shared_context
def get_option(self, option_name: str, default: Any = None) -> Any:
"""Resolve an option from the OptionsManager if present, otherwise use the fallback."""
"""
Resolve an option from the OptionsManager if present, otherwise use the fallback.
"""
if self.options_manager:
return self.options_manager.get(option_name, default)
return default
@ -288,8 +294,10 @@ class Action(BaseAction):
def __str__(self):
return (
f"Action(name={self.name!r}, action={getattr(self._action, '__name__', repr(self._action))}, "
f"args={self.args!r}, kwargs={self.kwargs!r}, retry={self.retry_policy.enabled})"
f"Action(name={self.name!r}, action="
f"{getattr(self._action, '__name__', repr(self._action))}, "
f"args={self.args!r}, kwargs={self.kwargs!r}, "
f"retry={self.retry_policy.enabled})"
)
@ -309,7 +317,7 @@ class LiteralInputAction(Action):
def __init__(self, value: Any):
self._value = value
async def literal(*args, **kwargs):
async def literal(*_, **__):
return value
super().__init__("Input", literal)
@ -333,14 +341,16 @@ class LiteralInputAction(Action):
class FallbackAction(Action):
"""
FallbackAction provides a default value if the previous action failed or returned None.
FallbackAction provides a default value if the previous action failed or
returned None.
It injects the last result and checks:
- If last_result is not None, it passes it through unchanged.
- If last_result is None (e.g., due to failure), it replaces it with a fallback value.
Used in ChainedAction pipelines to gracefully recover from errors or missing data.
When activated, it consumes the preceding error and allows the chain to continue normally.
When activated, it consumes the preceding error and allows the chain to continue
normally.
Args:
fallback (Any): The fallback value to use if last_result is None.
@ -413,16 +423,19 @@ class ChainedAction(BaseAction, ActionListMixin):
- Rolls back all previously executed actions if a failure occurs.
- Handles literal values with LiteralInputAction.
Best used for defining robust, ordered workflows where each step can depend on previous results.
Best used for defining robust, ordered workflows where each step can depend on
previous results.
Args:
name (str): Name of the chain.
actions (list): List of actions or literals to execute.
hooks (HookManager, optional): Hooks for lifecycle events.
inject_last_result (bool, optional): Whether to inject last results into kwargs by default.
inject_last_result (bool, optional): Whether to inject last results into kwargs
by default.
inject_into (str, optional): Key name for injection.
auto_inject (bool, optional): Auto-enable injection for subsequent actions.
return_list (bool, optional): Whether to return a list of all results. False returns the last result.
return_list (bool, optional): Whether to return a list of all results. False
returns the last result.
"""
def __init__(
@ -468,7 +481,7 @@ class ChainedAction(BaseAction, ActionListMixin):
if not self.actions:
raise EmptyChainError(f"[{self.name}] No actions to execute.")
shared_context = SharedContext(name=self.name)
shared_context = SharedContext(name=self.name, action=self)
if self.shared_context:
shared_context.add_result(self.shared_context.last_result())
updated_kwargs = self._maybe_inject_last_result(kwargs)
@ -503,7 +516,8 @@ class ChainedAction(BaseAction, ActionListMixin):
self.actions[index + 1], FallbackAction
):
logger.warning(
"[%s] ⚠️ Fallback triggered: %s, recovering with fallback '%s'.",
"[%s] ⚠️ Fallback triggered: %s, recovering with fallback "
"'%s'.",
self.name,
error,
self.actions[index + 1].name,
@ -579,7 +593,8 @@ class ChainedAction(BaseAction, ActionListMixin):
def __str__(self):
return (
f"ChainedAction(name={self.name!r}, actions={[a.name for a in self.actions]!r}, "
f"ChainedAction(name={self.name!r}, "
f"actions={[a.name for a in self.actions]!r}, "
f"auto_inject={self.auto_inject}, return_list={self.return_list})"
)
@ -613,7 +628,8 @@ class ActionGroup(BaseAction, ActionListMixin):
name (str): Name of the chain.
actions (list): List of actions or literals to execute.
hooks (HookManager, optional): Hooks for lifecycle events.
inject_last_result (bool, optional): Whether to inject last results into kwargs by default.
inject_last_result (bool, optional): Whether to inject last results into kwargs
by default.
inject_into (str, optional): Key name for injection.
"""
@ -643,7 +659,8 @@ class ActionGroup(BaseAction, ActionListMixin):
return Action(name=action.__name__, action=action)
else:
raise TypeError(
f"ActionGroup only accepts BaseAction or callable, got {type(action).__name__}"
"ActionGroup only accepts BaseAction or callable, got "
f"{type(action).__name__}"
)
def add_action(self, action: BaseAction | Any) -> None:
@ -653,7 +670,7 @@ class ActionGroup(BaseAction, ActionListMixin):
action.register_teardown(self.hooks)
async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]:
shared_context = SharedContext(name=self.name, is_parallel=True)
shared_context = SharedContext(name=self.name, action=self, is_parallel=True)
if self.shared_context:
shared_context.set_shared_result(self.shared_context.last_result())
updated_kwargs = self._maybe_inject_last_result(kwargs)
@ -831,6 +848,7 @@ class ProcessAction(BaseAction):
def __str__(self) -> str:
return (
f"ProcessAction(name={self.name!r}, action={getattr(self.action, '__name__', repr(self.action))}, "
f"ProcessAction(name={self.name!r}, "
f"action={getattr(self.action, '__name__', repr(self.action))}, "
f"args={self.args!r}, kwargs={self.kwargs!r})"
)

View File

@ -1,4 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""action_factory.py"""
from typing import Any
from rich.tree import Tree
@ -7,6 +8,7 @@ from falyx.action import BaseAction
from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType
from falyx.logger import logger
from falyx.protocols import ActionFactoryProtocol
from falyx.themes.colors import OneColors
@ -33,7 +35,7 @@ class ActionFactoryAction(BaseAction):
inject_last_result: bool = False,
inject_into: str = "last_result",
preview_args: tuple[Any, ...] = (),
preview_kwargs: dict[str, Any] = {},
preview_kwargs: dict[str, Any] | None = None,
):
super().__init__(
name=name,
@ -42,7 +44,7 @@ class ActionFactoryAction(BaseAction):
)
self.factory = factory
self.preview_args = preview_args
self.preview_kwargs = preview_kwargs
self.preview_kwargs = preview_kwargs or {}
async def _run(self, *args, **kwargs) -> Any:
updated_kwargs = self._maybe_inject_last_result(kwargs)
@ -58,10 +60,20 @@ class ActionFactoryAction(BaseAction):
generated_action = self.factory(*args, **updated_kwargs)
if not isinstance(generated_action, BaseAction):
raise TypeError(
f"[{self.name}] Factory must return a BaseAction, got {type(generated_action).__name__}"
f"[{self.name}] Factory must return a BaseAction, got "
f"{type(generated_action).__name__}"
)
if self.shared_context:
generated_action.set_shared_context(self.shared_context)
if hasattr(generated_action, "register_teardown") and callable(
generated_action.register_teardown
):
generated_action.register_teardown(self.shared_context.action.hooks)
logger.debug(
"[%s] Registered teardown for %s",
self.name,
generated_action.name,
)
if self.options_manager:
generated_action.set_options_manager(self.options_manager)
context.result = await generated_action(*args, **kwargs)

View File

@ -146,7 +146,7 @@ class BottomBar:
for k in (key.upper(), key.lower()):
@self.key_bindings.add(k)
def _(event):
def _(_):
toggle_state()
def add_toggle_from_option(
@ -204,6 +204,6 @@ class BottomBar:
"""Render the bottom bar."""
lines = []
for chunk in chunks(self._named_items.values(), self.columns):
lines.extend([fn for fn in chunk])
lines.extend(list(chunk))
lines.append(lambda: HTML("\n"))
return merge_formatted_text([fn() for fn in lines[:-1]])

View File

@ -33,12 +33,13 @@ from falyx.exceptions import FalyxError
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType
from falyx.io_action import BaseIOAction
from falyx.logger import logger
from falyx.options_manager import OptionsManager
from falyx.prompt_utils import should_prompt_user
from falyx.prompt_utils import confirm_async, should_prompt_user
from falyx.retry import RetryPolicy
from falyx.retry_utils import enable_retries_recursively
from falyx.themes.colors import OneColors
from falyx.utils import _noop, confirm_async, ensure_async, logger
from falyx.utils import _noop, ensure_async
console = Console(color_system="auto")
@ -134,7 +135,7 @@ class Command(BaseModel):
return ensure_async(action)
raise TypeError("Action must be a callable or an instance of BaseAction")
def model_post_init(self, __context: Any) -> None:
def model_post_init(self, _: Any) -> None:
"""Post-initialization to set up the action and hooks."""
if self.retry and isinstance(self.action, Action):
self.action.enable_retry()
@ -142,14 +143,16 @@ class Command(BaseModel):
self.action.set_retry_policy(self.retry_policy)
elif self.retry:
logger.warning(
f"[Command:{self.key}] Retry requested, but action is not an Action instance."
"[Command:%s] Retry requested, but action is not an Action instance.",
self.key,
)
if self.retry_all and isinstance(self.action, BaseAction):
self.retry_policy.enabled = True
enable_retries_recursively(self.action, self.retry_policy)
elif self.retry_all:
logger.warning(
f"[Command:{self.key}] Retry all requested, but action is not a BaseAction instance."
"[Command:%s] Retry all requested, but action is not a BaseAction.",
self.key,
)
if self.logging_hooks and isinstance(self.action, BaseAction):
@ -201,7 +204,7 @@ class Command(BaseModel):
if self.preview_before_confirm:
await self.preview()
if not await confirm_async(self.confirmation_prompt):
logger.info(f"[Command:{self.key}] ❌ Cancelled by user.")
logger.info("[Command:%s] ❌ Cancelled by user.", self.key)
raise FalyxError(f"[Command:{self.key}] Cancelled by confirmation.")
context.start_timer()
@ -288,7 +291,7 @@ class Command(BaseModel):
if self.help_text:
console.print(f"[dim]💡 {self.help_text}[/dim]")
console.print(
f"[{OneColors.DARK_RED}]⚠️ Action is not callable or lacks a preview method.[/]"
f"[{OneColors.DARK_RED}]⚠️ No preview available for this action.[/]"
)
def __str__(self) -> str:

View File

@ -16,9 +16,9 @@ from rich.console import Console
from falyx.action import Action, BaseAction
from falyx.command import Command
from falyx.falyx import Falyx
from falyx.logger import logger
from falyx.retry import RetryPolicy
from falyx.themes.colors import OneColors
from falyx.utils import logger
console = Console(color_system="auto")
@ -47,7 +47,8 @@ def import_action(dotted_path: str) -> Any:
logger.error("Failed to import module '%s': %s", module_path, error)
console.print(
f"[{OneColors.DARK_RED}]❌ Could not import '{dotted_path}': {error}[/]\n"
f"[{OneColors.COMMENT_GREY}]Ensure the module is installed and discoverable via PYTHONPATH."
f"[{OneColors.COMMENT_GREY}]Ensure the module is installed and discoverable "
"via PYTHONPATH."
)
sys.exit(1)
try:
@ -57,13 +58,16 @@ def import_action(dotted_path: str) -> Any:
"Module '%s' does not have attribute '%s': %s", module_path, attr, error
)
console.print(
f"[{OneColors.DARK_RED}]❌ Module '{module_path}' has no attribute '{attr}': {error}[/]"
f"[{OneColors.DARK_RED}]❌ Module '{module_path}' has no attribute "
f"'{attr}': {error}[/]"
)
sys.exit(1)
return action
class RawCommand(BaseModel):
"""Raw command model for Falyx CLI configuration."""
key: str
description: str
action: str
@ -72,7 +76,7 @@ class RawCommand(BaseModel):
kwargs: dict[str, Any] = {}
aliases: list[str] = []
tags: list[str] = []
style: str = "white"
style: str = OneColors.WHITE
confirm: bool = False
confirm_message: str = "Are you sure?"
@ -81,7 +85,7 @@ class RawCommand(BaseModel):
spinner: bool = False
spinner_message: str = "Processing..."
spinner_type: str = "dots"
spinner_style: str = "cyan"
spinner_style: str = OneColors.CYAN
spinner_kwargs: dict[str, Any] = {}
before_hooks: list[Callable] = []
@ -126,6 +130,8 @@ def convert_commands(raw_commands: list[dict[str, Any]]) -> list[Command]:
class FalyxConfig(BaseModel):
"""Falyx CLI configuration model."""
title: str = "Falyx CLI"
prompt: str | list[tuple[str, str]] | list[list[str]] = [
(OneColors.BLUE_b, "FALYX > ")
@ -148,7 +154,7 @@ class FalyxConfig(BaseModel):
def to_falyx(self) -> Falyx:
flx = Falyx(
title=self.title,
prompt=self.prompt,
prompt=self.prompt, # type: ignore[arg-type]
columns=self.columns,
welcome_message=self.welcome_message,
exit_message=self.exit_message,
@ -159,7 +165,9 @@ class FalyxConfig(BaseModel):
def loader(file_path: Path | str) -> Falyx:
"""
Load command definitions from a YAML or TOML file.
Load Falyx CLI configuration from a YAML or TOML file.
The file should contain a dictionary with a list of commands.
Each command should be defined as a dictionary with at least:
- key: a unique single-character key

View File

@ -29,10 +29,10 @@ class ExecutionContext(BaseModel):
"""
Represents the runtime metadata and state for a single action execution.
The `ExecutionContext` tracks arguments, results, exceptions, timing, and additional
metadata for each invocation of a Falyx `BaseAction`. It provides integration with the
Falyx hook system and execution registry, enabling lifecycle management, diagnostics,
and structured logging.
The `ExecutionContext` tracks arguments, results, exceptions, timing, and
additional metadata for each invocation of a Falyx `BaseAction`. It provides
integration with the Falyx hook system and execution registry, enabling lifecycle
management, diagnostics, and structured logging.
Attributes:
name (str): The name of the action being executed.
@ -47,7 +47,8 @@ class ExecutionContext(BaseModel):
end_wall (datetime | None): Wall-clock timestamp when execution ended.
extra (dict): Metadata for custom introspection or special use by Actions.
console (Console): Rich console instance for logging or UI output.
shared_context (SharedContext | None): Optional shared context when running in a chain or group.
shared_context (SharedContext | None): Optional shared context when running in
a chain or group.
Properties:
duration (float | None): The execution duration in seconds.
@ -95,7 +96,11 @@ class ExecutionContext(BaseModel):
self.end_wall = datetime.now()
def get_shared_context(self) -> SharedContext:
return self.shared_context or SharedContext(name="default")
if not self.shared_context:
raise ValueError(
"SharedContext is not set. This context is not part of a chain or group."
)
return self.shared_context
@property
def duration(self) -> float | None:
@ -190,8 +195,10 @@ class SharedContext(BaseModel):
errors (list[tuple[int, Exception]]): Indexed list of errors from failed actions.
current_index (int): Index of the currently executing action (used in chains).
is_parallel (bool): Whether the context is used in parallel mode (ActionGroup).
shared_result (Any | None): Optional shared value available to all actions in parallel mode.
share (dict[str, Any]): Custom shared key-value store for user-defined communication
shared_result (Any | None): Optional shared value available to all actions in
parallel mode.
share (dict[str, Any]): Custom shared key-value store for user-defined
communication
between actions (e.g., flags, intermediate data, settings).
Note:
@ -208,6 +215,7 @@ class SharedContext(BaseModel):
"""
name: str
action: Any
results: list[Any] = Field(default_factory=list)
errors: list[tuple[int, Exception]] = Field(default_factory=list)
current_index: int = -1

View File

@ -1,7 +1,8 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""debug.py"""
from falyx.context import ExecutionContext
from falyx.hook_manager import HookManager, HookType
from falyx.utils import logger
from falyx.logger import logger
def log_before(context: ExecutionContext):

View File

@ -1,4 +1,7 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""exceptions.py"""
class FalyxError(Exception):
"""Custom exception for the Menu class."""

View File

@ -1,5 +1,32 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""execution_registry.py"""
"""
execution_registry.py
This module provides the `ExecutionRegistry`, a global class for tracking and
introspecting the execution history of Falyx actions.
The registry captures `ExecutionContext` instances from all executed actions, making it
easy to debug, audit, and visualize workflow behavior over time. It supports retrieval,
filtering, clearing, and formatted summary display.
Core Features:
- Stores all action execution contexts globally (with access by name).
- Provides live execution summaries in a rich table format.
- Enables creation of a built-in Falyx Action to print history on demand.
- Integrates with Falyx's introspectable and hook-driven execution model.
Intended for:
- Debugging and diagnostics
- Post-run inspection of CLI workflows
- Interactive tools built with Falyx
Example:
from falyx.execution_registry import ExecutionRegistry as er
er.record(context)
er.summary()
"""
from __future__ import annotations
from collections import defaultdict
from datetime import datetime
from typing import Dict, List
@ -9,11 +36,40 @@ from rich.console import Console
from rich.table import Table
from falyx.context import ExecutionContext
from falyx.logger import logger
from falyx.themes.colors import OneColors
from falyx.utils import logger
class ExecutionRegistry:
"""
Global registry for recording and inspecting Falyx action executions.
This class captures every `ExecutionContext` generated by a Falyx `Action`,
`ChainedAction`, or `ActionGroup`, maintaining both full history and
name-indexed access for filtered analysis.
Methods:
- record(context): Stores an ExecutionContext, logging a summary line.
- get_all(): Returns the list of all recorded executions.
- get_by_name(name): Returns all executions with the given action name.
- get_latest(): Returns the most recent execution.
- clear(): Wipes the registry for a fresh run.
- summary(): Renders a formatted Rich table of all execution results.
Use Cases:
- Debugging chained or factory-generated workflows
- Viewing results and exceptions from multiple runs
- Embedding a diagnostic command into your CLI for user support
Note:
This registry is in-memory and not persistent. It's reset each time the process
restarts or `clear()` is called.
Example:
ExecutionRegistry.record(context)
ExecutionRegistry.summary()
"""
_store_by_name: Dict[str, List[ExecutionContext]] = defaultdict(list)
_store_all: List[ExecutionContext] = []
_console = Console(color_system="auto")
@ -78,13 +134,3 @@ class ExecutionRegistry:
table.add_row(ctx.name, start, end, duration, status, result)
cls._console.print(table)
@classmethod
def get_history_action(cls) -> "Action":
"""Return an Action that prints the execution summary."""
from falyx.action import Action
async def show_history():
cls.summary()
return Action(name="View Execution History", action=show_history)

View File

@ -51,12 +51,13 @@ from falyx.exceptions import (
)
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import Hook, HookManager, HookType
from falyx.logger import logger
from falyx.options_manager import OptionsManager
from falyx.parsers import get_arg_parsers
from falyx.retry import RetryPolicy
from falyx.signals import BackSignal, QuitSignal
from falyx.themes.colors import OneColors, get_nord_theme
from falyx.utils import CaseInsensitiveDict, chunks, get_program_invocation, logger
from falyx.utils import CaseInsensitiveDict, chunks, get_program_invocation
from falyx.version import __version__
@ -78,7 +79,8 @@ class Falyx:
Key Features:
- Interactive menu with Rich rendering and Prompt Toolkit input handling
- Dynamic command management with alias and abbreviation matching
- Full lifecycle hooks (before, success, error, after, teardown) at both menu and command levels
- Full lifecycle hooks (before, success, error, after, teardown) at both menu and
command levels
- Built-in retry support, spinner visuals, and confirmation prompts
- Submenu nesting and action chaining
- History tracking, help generation, and run key execution modes
@ -99,12 +101,14 @@ class Falyx:
force_confirm (bool): Seed default for `OptionsManager["force_confirm"]`
cli_args (Namespace | None): Parsed CLI arguments, usually from argparse.
options (OptionsManager | None): Declarative option mappings.
custom_table (Callable[[Falyx], Table] | Table | None): Custom menu table generator.
custom_table (Callable[[Falyx], Table] | Table | None): Custom menu table
generator.
Methods:
run(): Main entry point for CLI argument-based workflows. Most users will use this.
run(): Main entry point for CLI argument-based workflows. Suggested for
most use cases.
menu(): Run the interactive menu loop.
run_key(command_key, return_context): Run a command directly without showing the menu.
run_key(command_key, return_context): Run a command directly without the menu.
add_command(): Add a single command to the menu.
add_commands(): Add multiple commands at once.
register_all_hooks(): Register hooks across all commands and submenus.
@ -184,8 +188,10 @@ class Falyx:
@property
def _name_map(self) -> dict[str, Command]:
"""Builds a mapping of all valid input names (keys, aliases, normalized names) to Command objects.
If a collision occurs, logs a warning and keeps the first registered command.
"""
Builds a mapping of all valid input names (keys, aliases, normalized names) to
Command objects. If a collision occurs, logs a warning and keeps the first
registered command.
"""
mapping: dict[str, Command] = {}
@ -195,8 +201,11 @@ class Falyx:
existing = mapping[norm]
if existing is not cmd:
logger.warning(
f"[alias conflict] '{name}' already assigned to '{existing.description}'."
f" Skipping for '{cmd.description}'."
"[alias conflict] '%s' already assigned to '%s'. "
"Skipping for '%s'.",
name,
existing.description,
cmd.description,
)
else:
mapping[norm] = cmd
@ -238,7 +247,7 @@ class Falyx:
key="Y",
description="History",
aliases=["HISTORY"],
action=er.get_history_action(),
action=Action(name="View Execution History", action=er.summary),
style=OneColors.DARK_YELLOW,
)
@ -283,7 +292,8 @@ class Falyx:
self.console.print(table, justify="center")
if self.mode == FalyxMode.MENU:
self.console.print(
f"📦 Tip: '[{OneColors.LIGHT_YELLOW}]?[KEY][/]' to preview a command before running it.\n",
f"📦 Tip: '[{OneColors.LIGHT_YELLOW}]?[KEY][/]' to preview a command "
"before running it.\n",
justify="center",
)
@ -346,7 +356,7 @@ class Falyx:
is_preview, choice = self.get_command(text, from_validate=True)
if is_preview and choice is None:
return True
return True if choice else False
return bool(choice)
return Validator.from_callable(
validator,
@ -444,43 +454,10 @@ class Falyx:
def debug_hooks(self) -> None:
"""Logs the names of all hooks registered for the menu and its commands."""
def hook_names(hook_list):
return [hook.__name__ for hook in hook_list]
logger.debug(
"Menu-level before hooks: "
f"{hook_names(self.hooks._hooks[HookType.BEFORE])}"
)
logger.debug(
f"Menu-level success hooks: {hook_names(self.hooks._hooks[HookType.ON_SUCCESS])}"
)
logger.debug(
f"Menu-level error hooks: {hook_names(self.hooks._hooks[HookType.ON_ERROR])}"
)
logger.debug(
f"Menu-level after hooks: {hook_names(self.hooks._hooks[HookType.AFTER])}"
)
logger.debug(
f"Menu-level on_teardown hooks: {hook_names(self.hooks._hooks[HookType.ON_TEARDOWN])}"
)
logger.debug("Menu-level hooks:\n%s", str(self.hooks))
for key, command in self.commands.items():
logger.debug(
f"[Command '{key}'] before: {hook_names(command.hooks._hooks[HookType.BEFORE])}"
)
logger.debug(
f"[Command '{key}'] success: {hook_names(command.hooks._hooks[HookType.ON_SUCCESS])}"
)
logger.debug(
f"[Command '{key}'] error: {hook_names(command.hooks._hooks[HookType.ON_ERROR])}"
)
logger.debug(
f"[Command '{key}'] after: {hook_names(command.hooks._hooks[HookType.AFTER])}"
)
logger.debug(
f"[Command '{key}'] on_teardown: {hook_names(command.hooks._hooks[HookType.ON_TEARDOWN])}"
)
logger.debug("[Command '%s'] hooks:\n%s", key, str(command.hooks))
def is_key_available(self, key: str) -> bool:
key = key.upper()
@ -586,7 +563,7 @@ class Falyx:
action: BaseAction | Callable[[], Any],
*,
args: tuple = (),
kwargs: dict[str, Any] = {},
kwargs: dict[str, Any] | None = None,
hidden: bool = False,
aliases: list[str] | None = None,
help_text: str = "",
@ -619,7 +596,7 @@ class Falyx:
description=description,
action=action,
args=args,
kwargs=kwargs,
kwargs=kwargs if kwargs else {},
hidden=hidden,
aliases=aliases if aliases else [],
help_text=help_text,
@ -665,20 +642,26 @@ class Falyx:
bottom_row = []
if self.history_command:
bottom_row.append(
f"[{self.history_command.key}] [{self.history_command.style}]{self.history_command.description}"
f"[{self.history_command.key}] [{self.history_command.style}]"
f"{self.history_command.description}"
)
if self.help_command:
bottom_row.append(
f"[{self.help_command.key}] [{self.help_command.style}]{self.help_command.description}"
f"[{self.help_command.key}] [{self.help_command.style}]"
f"{self.help_command.description}"
)
bottom_row.append(
f"[{self.exit_command.key}] [{self.exit_command.style}]{self.exit_command.description}"
f"[{self.exit_command.key}] [{self.exit_command.style}]"
f"{self.exit_command.description}"
)
return bottom_row
def build_default_table(self) -> Table:
"""Build the standard table layout. Developers can subclass or call this in custom tables."""
table = Table(title=self.title, show_header=False, box=box.SIMPLE, expand=True)
"""
Build the standard table layout. Developers can subclass or call this
in custom tables.
"""
table = Table(title=self.title, show_header=False, box=box.SIMPLE, expand=True) # type: ignore[arg-type]
visible_commands = [item for item in self.commands.items() if not item[1].hidden]
for chunk in chunks(visible_commands, self.columns):
row = []
@ -708,7 +691,10 @@ class Falyx:
def get_command(
self, choice: str, from_validate=False
) -> tuple[bool, Command | None]:
"""Returns the selected command based on user input. Supports keys, aliases, and abbreviations."""
"""
Returns the selected command based on user input.
Supports keys, aliases, and abbreviations.
"""
is_preview, choice = self.parse_preview_command(choice)
if is_preview and not choice and self.help_command:
is_preview = False
@ -716,7 +702,7 @@ class Falyx:
elif is_preview and not choice:
if not from_validate:
self.console.print(
f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode.[/]"
f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode."
)
return is_preview, None
@ -734,7 +720,8 @@ class Falyx:
if fuzzy_matches:
if not from_validate:
self.console.print(
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'. Did you mean:[/] "
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'. "
"Did you mean:"
)
for match in fuzzy_matches:
cmd = name_map[match]
@ -759,7 +746,7 @@ class Falyx:
self, selected_command: Command, error: Exception
) -> None:
"""Handles errors that occur during the action of the selected command."""
logger.exception(f"Error executing '{selected_command.description}': {error}")
logger.exception("Error executing '%s': %s", selected_command.description, error)
self.console.print(
f"[{OneColors.DARK_RED}]An error occurred while executing "
f"{selected_command.description}:[/] {error}"
@ -770,27 +757,27 @@ class Falyx:
choice = await self.prompt_session.prompt_async()
is_preview, selected_command = self.get_command(choice)
if not selected_command:
logger.info(f"Invalid command '{choice}'.")
logger.info("Invalid command '%s'.", choice)
return True
if is_preview:
logger.info(f"Preview command '{selected_command.key}' selected.")
logger.info("Preview command '%s' selected.", selected_command.key)
await selected_command.preview()
return True
if selected_command.requires_input:
program = get_program_invocation()
self.console.print(
f"[{OneColors.LIGHT_YELLOW}]⚠️ Command '{selected_command.key}' requires input "
f"and must be run via [{OneColors.MAGENTA}]'{program} run'[{OneColors.LIGHT_YELLOW}] "
"with proper piping or arguments.[/]"
f"[{OneColors.LIGHT_YELLOW}]⚠️ Command '{selected_command.key}' requires"
f" input and must be run via [{OneColors.MAGENTA}]'{program} run"
f"'[{OneColors.LIGHT_YELLOW}] with proper piping or arguments.[/]"
)
return True
self.last_run_command = selected_command
if selected_command == self.exit_command:
logger.info(f"🔙 Back selected: exiting {self.get_title()}")
logger.info("🔙 Back selected: exiting %s", self.get_title())
return False
context = self._create_context(selected_command)
@ -821,7 +808,7 @@ class Falyx:
return None
if is_preview:
logger.info(f"Preview command '{selected_command.key}' selected.")
logger.info("Preview command '%s' selected.", selected_command.key)
await selected_command.preview()
return None
@ -840,13 +827,13 @@ class Falyx:
await self.hooks.trigger(HookType.ON_SUCCESS, context)
logger.info("[run_key] ✅ '%s' complete.", selected_command.description)
except (KeyboardInterrupt, EOFError):
except (KeyboardInterrupt, EOFError) as error:
logger.warning(
"[run_key] ⚠️ Interrupted by user: ", selected_command.description
"[run_key] ⚠️ Interrupted by user: %s", selected_command.description
)
raise FalyxError(
f"[run_key] ⚠️ '{selected_command.description}' interrupted by user."
)
) from error
except Exception as error:
context.exception = error
await self.hooks.trigger(HookType.ON_ERROR, context)
@ -885,7 +872,8 @@ class Falyx:
selected_command.action.set_retry_policy(selected_command.retry_policy)
else:
logger.warning(
f"[Command:{selected_command.key}] Retry requested, but action is not an Action instance."
"[Command:%s] Retry requested, but action is not an Action instance.",
selected_command.key,
)
def print_message(self, message: str | Markdown | dict[str, Any]) -> None:
@ -904,7 +892,7 @@ class Falyx:
async def menu(self) -> None:
"""Runs the menu and handles user input."""
logger.info(f"Running menu: {self.get_title()}")
logger.info("Running menu: %s", self.get_title())
self.debug_hooks()
if self.welcome_message:
self.print_message(self.welcome_message)
@ -928,7 +916,7 @@ class Falyx:
except BackSignal:
logger.info("BackSignal received.")
finally:
logger.info(f"Exiting menu: {self.get_title()}")
logger.info("Exiting menu: %s", self.get_title())
if self.exit_message:
self.print_message(self.exit_message)
@ -964,7 +952,7 @@ class Falyx:
_, command = self.get_command(self.cli_args.name)
if not command:
self.console.print(
f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found.[/]"
f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found."
)
sys.exit(1)
self.console.print(
@ -979,7 +967,7 @@ class Falyx:
if is_preview:
if command is None:
sys.exit(1)
logger.info(f"Preview command '{command.key}' selected.")
logger.info("Preview command '%s' selected.", command.key)
await command.preview()
sys.exit(0)
if not command:
@ -1004,12 +992,14 @@ class Falyx:
]
if not matching:
self.console.print(
f"[{OneColors.LIGHT_YELLOW}]⚠️ No commands found with tag: '{self.cli_args.tag}'[/]"
f"[{OneColors.LIGHT_YELLOW}]⚠️ No commands found with tag: "
f"'{self.cli_args.tag}'"
)
sys.exit(1)
self.console.print(
f"[{OneColors.CYAN_b}]🚀 Running all commands with tag:[/] {self.cli_args.tag}"
f"[{OneColors.CYAN_b}]🚀 Running all commands with tag:[/] "
f"{self.cli_args.tag}"
)
for cmd in matching:
self._set_retry_policy(cmd)

View File

@ -7,7 +7,7 @@ from enum import Enum
from typing import Awaitable, Callable, Dict, List, Optional, Union
from falyx.context import ExecutionContext
from falyx.utils import logger
from falyx.logger import logger
Hook = Union[
Callable[[ExecutionContext], None], Callable[[ExecutionContext], Awaitable[None]]
@ -34,6 +34,8 @@ class HookType(Enum):
class HookManager:
"""HookManager"""
def __init__(self) -> None:
self._hooks: Dict[HookType, List[Hook]] = {
hook_type: [] for hook_type in HookType
@ -62,8 +64,11 @@ class HookManager:
hook(context)
except Exception as hook_error:
logger.warning(
f"⚠️ Hook '{hook.__name__}' raised an exception during '{hook_type}'"
f" for '{context.name}': {hook_error}"
"⚠️ Hook '%s' raised an exception during '%s' for '%s': %s",
hook.__name__,
hook_type,
context.name,
hook_error,
)
if hook_type == HookType.ON_ERROR:
@ -71,3 +76,15 @@ class HookManager:
context.exception, Exception
), "Context exception should be set for ON_ERROR hook"
raise context.exception from hook_error
def __str__(self) -> str:
"""Return a formatted string of registered hooks grouped by hook type."""
def format_hook_list(hooks: list[Hook]) -> str:
return ", ".join(h.__name__ for h in hooks) if hooks else ""
lines = ["<HookManager>"]
for hook_type in HookType:
hook_list = self._hooks.get(hook_type, [])
lines.append(f" {hook_type.value}: {format_hook_list(hook_list)}")
return "\n".join(lines)

View File

@ -5,11 +5,13 @@ from typing import Any, Callable
from falyx.context import ExecutionContext
from falyx.exceptions import CircuitBreakerOpen
from falyx.logger import logger
from falyx.themes.colors import OneColors
from falyx.utils import logger
class ResultReporter:
"""Reports the success of an action."""
def __init__(self, formatter: Callable[[Any], str] | None = None):
"""
Optional result formatter. If not provided, uses repr(result).
@ -41,6 +43,8 @@ class ResultReporter:
class CircuitBreaker:
"""Circuit Breaker pattern to prevent repeated failures."""
def __init__(self, max_failures=3, reset_timeout=10):
self.max_failures = max_failures
self.reset_timeout = reset_timeout
@ -55,7 +59,7 @@ class CircuitBreaker:
f"🔴 Circuit open for '{name}' until {time.ctime(self.open_until)}."
)
else:
logger.info(f"🟢 Circuit closed again for '{name}'.")
logger.info("🟢 Circuit closed again for '%s'.")
self.failures = 0
self.open_until = None
@ -63,15 +67,18 @@ class CircuitBreaker:
name = context.name
self.failures += 1
logger.warning(
f"⚠️ CircuitBreaker: '{name}' failure {self.failures}/{self.max_failures}."
"⚠️ CircuitBreaker: '%s' failure %s/%s.",
name,
self.failures,
self.max_failures,
)
if self.failures >= self.max_failures:
self.open_until = time.time() + self.reset_timeout
logger.error(
f"🔴 Circuit opened for '{name}' until {time.ctime(self.open_until)}."
"🔴 Circuit opened for '%s' until %s.", name, time.ctime(self.open_until)
)
def after_hook(self, context: ExecutionContext):
def after_hook(self, _: ExecutionContext):
self.failures = 0
def is_open(self):

View File

@ -16,8 +16,8 @@ from rich.tree import Tree
from falyx.action import Action
from falyx.context import ExecutionContext, SharedContext
from falyx.hook_manager import HookManager, HookType
from falyx.logger import logger
from falyx.themes.colors import OneColors
from falyx.utils import logger
async def close_shared_http_session(context: ExecutionContext) -> None:
@ -35,9 +35,9 @@ class HTTPAction(Action):
"""
An Action for executing HTTP requests using aiohttp with shared session reuse.
This action integrates seamlessly into Falyx pipelines, with automatic session management,
result injection, and lifecycle hook support. It is ideal for CLI-driven API workflows
where you need to call remote services and process their responses.
This action integrates seamlessly into Falyx pipelines, with automatic session
management, result injection, and lifecycle hook support. It is ideal for CLI-driven
API workflows where you need to call remote services and process their responses.
Features:
- Uses aiohttp for asynchronous HTTP requests
@ -97,7 +97,7 @@ class HTTPAction(Action):
retry_policy=retry_policy,
)
async def _request(self, *args, **kwargs) -> dict[str, Any]:
async def _request(self, *_, **__) -> dict[str, Any]:
if self.shared_context:
context: SharedContext = self.shared_context
session = context.get("http_session")
@ -153,6 +153,7 @@ class HTTPAction(Action):
def __str__(self):
return (
f"HTTPAction(name={self.name!r}, method={self.method!r}, url={self.url!r}, "
f"headers={self.headers!r}, params={self.params!r}, json={self.json!r}, data={self.data!r}, "
f"retry={self.retry_policy.enabled}, inject_last_result={self.inject_last_result})"
f"headers={self.headers!r}, params={self.params!r}, json={self.json!r}, "
f"data={self.data!r}, retry={self.retry_policy.enabled}, "
f"inject_last_result={self.inject_last_result})"
)

View File

@ -1,4 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""init.py"""
from pathlib import Path
from rich.console import Console

View File

@ -28,8 +28,8 @@ from falyx.context import ExecutionContext
from falyx.exceptions import FalyxError
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType
from falyx.logger import logger
from falyx.themes.colors import OneColors
from falyx.utils import logger
class BaseIOAction(BaseAction):
@ -78,7 +78,7 @@ class BaseIOAction(BaseAction):
def from_input(self, raw: str | bytes) -> Any:
raise NotImplementedError
def to_output(self, data: Any) -> str | bytes:
def to_output(self, result: Any) -> str | bytes:
raise NotImplementedError
async def _resolve_input(self, kwargs: dict[str, Any]) -> str | bytes:
@ -113,7 +113,7 @@ class BaseIOAction(BaseAction):
try:
if self.mode == "stream":
line_gen = await self._read_stdin_stream()
async for line in self._stream_lines(line_gen, args, kwargs):
async for _ in self._stream_lines(line_gen, args, kwargs):
pass
result = getattr(self, "_last_result", None)
else:
@ -185,8 +185,9 @@ class ShellAction(BaseIOAction):
Designed for quick integration with shell tools like `grep`, `ping`, `jq`, etc.
Security Warning:
By default, ShellAction uses `shell=True`, which can be dangerous with unsanitized input.
To mitigate this, set `safe_mode=True` to use `shell=False` with `shlex.split()`.
By default, ShellAction uses `shell=True`, which can be dangerous with
unsanitized input. To mitigate this, set `safe_mode=True` to use `shell=False`
with `shlex.split()`.
Features:
- Automatically handles input parsing (str/bytes)
@ -198,9 +199,11 @@ class ShellAction(BaseIOAction):
Args:
name (str): Name of the action.
command_template (str): Shell command to execute. Must include `{}` to include input.
If no placeholder is present, the input is not included.
safe_mode (bool): If True, runs with `shell=False` using shlex parsing (default: False).
command_template (str): Shell command to execute. Must include `{}` to include
input. If no placeholder is present, the input is not
included.
safe_mode (bool): If True, runs with `shell=False` using shlex parsing
(default: False).
"""
def __init__(
@ -222,9 +225,11 @@ class ShellAction(BaseIOAction):
command = self.command_template.format(parsed_input)
if self.safe_mode:
args = shlex.split(command)
result = subprocess.run(args, capture_output=True, text=True)
result = subprocess.run(args, capture_output=True, text=True, check=True)
else:
result = subprocess.run(command, shell=True, text=True, capture_output=True)
result = subprocess.run(
command, shell=True, text=True, capture_output=True, check=True
)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip())
return result.stdout.strip()

5
falyx/logger.py Normal file
View File

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

View File

@ -12,15 +12,18 @@ from falyx.action import BaseAction
from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType
from falyx.logger import logger
from falyx.selection import prompt_for_selection, render_table_base
from falyx.signal_action import SignalAction
from falyx.signals import BackSignal, QuitSignal
from falyx.themes.colors import OneColors
from falyx.utils import CaseInsensitiveDict, chunks, logger
from falyx.utils import CaseInsensitiveDict, chunks
@dataclass
class MenuOption:
"""Represents a single menu option with a description and an action to execute."""
description: str
action: BaseAction
style: str = OneColors.WHITE
@ -93,6 +96,8 @@ class MenuOptionMap(CaseInsensitiveDict):
class MenuAction(BaseAction):
"""MenuAction class for creating single use menu actions."""
def __init__(
self,
name: str,
@ -162,7 +167,8 @@ class MenuAction(BaseAction):
if self.never_prompt and not effective_default:
raise ValueError(
f"[{self.name}] 'never_prompt' is True but no valid default_selection was provided."
f"[{self.name}] 'never_prompt' is True but no valid default_selection"
" was provided."
)
context.start_timer()

View File

@ -5,12 +5,14 @@ from argparse import Namespace
from collections import defaultdict
from typing import Any, Callable
from falyx.utils import logger
from falyx.logger import logger
class OptionsManager:
"""OptionsManager"""
def __init__(self, namespaces: list[tuple[str, Namespace]] | None = None) -> None:
self.options: defaultdict = defaultdict(lambda: Namespace())
self.options: defaultdict = defaultdict(Namespace)
if namespaces:
for namespace_name, namespace in namespaces:
self.from_namespace(namespace, namespace_name)
@ -42,7 +44,9 @@ class OptionsManager:
f"Cannot toggle non-boolean option: '{option_name}' in '{namespace_name}'"
)
self.set(option_name, not current, namespace_name=namespace_name)
logger.debug(f"Toggled '{option_name}' in '{namespace_name}' to {not current}")
logger.debug(
"Toggled '%s' in '%s' to %s", option_name, namespace_name, not current
)
def get_value_getter(
self, option_name: str, namespace_name: str = "cli_args"

View File

@ -39,7 +39,7 @@ def get_arg_parsers(
epilog: (
str | None
) = "Tip: Use 'falyx run ?[COMMAND]' to preview any command from the CLI.",
parents: Sequence[ArgumentParser] = [],
parents: Sequence[ArgumentParser] | None = None,
prefix_chars: str = "-",
fromfile_prefix_chars: str | None = None,
argument_default: Any = None,
@ -54,7 +54,7 @@ def get_arg_parsers(
usage=usage,
description=description,
epilog=epilog,
parents=parents,
parents=parents if parents else [],
prefix_chars=prefix_chars,
fromfile_prefix_chars=fromfile_prefix_chars,
argument_default=argument_default,

View File

@ -1,5 +1,15 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""prompt_utils.py"""
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import (
AnyFormattedText,
FormattedText,
merge_formatted_text,
)
from falyx.options_manager import OptionsManager
from falyx.themes.colors import OneColors
from falyx.validators import yes_no_validator
def should_prompt_user(
@ -8,7 +18,10 @@ def should_prompt_user(
options: OptionsManager,
namespace: str = "cli_args",
):
"""Determine whether to prompt the user for confirmation based on command and global options."""
"""
Determine whether to prompt the user for confirmation based on command
and global options.
"""
never_prompt = options.get("never_prompt", False, namespace)
force_confirm = options.get("force_confirm", False, namespace)
skip_confirm = options.get("skip_confirm", False, namespace)
@ -17,3 +30,19 @@ def should_prompt_user(
return False
return confirm or force_confirm
async def confirm_async(
message: AnyFormattedText = "Are you sure?",
prefix: AnyFormattedText = FormattedText([(OneColors.CYAN, "")]),
suffix: AnyFormattedText = FormattedText([(OneColors.LIGHT_YELLOW_b, " [Y/n] > ")]),
session: PromptSession | None = None,
) -> bool:
"""Prompt the user with a yes/no async confirmation and return True for 'Y'."""
session = session or PromptSession()
merged_message: AnyFormattedText = merge_formatted_text([prefix, message, suffix])
answer = await session.prompt_async(
merged_message,
validator=yes_no_validator(),
)
return answer.upper() == "Y"

View File

@ -1,4 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""protocols.py"""
from __future__ import annotations
from typing import Any, Protocol

View File

@ -8,10 +8,12 @@ import random
from pydantic import BaseModel, Field
from falyx.context import ExecutionContext
from falyx.utils import logger
from falyx.logger import logger
class RetryPolicy(BaseModel):
"""RetryPolicy"""
max_retries: int = Field(default=3, ge=0)
delay: float = Field(default=1.0, ge=0.0)
backoff: float = Field(default=2.0, ge=1.0)
@ -34,6 +36,8 @@ class RetryPolicy(BaseModel):
class RetryHandler:
"""RetryHandler class to manage retry policies for actions."""
def __init__(self, policy: RetryPolicy = RetryPolicy()):
self.policy = policy
@ -49,7 +53,7 @@ class RetryHandler:
self.policy.delay = delay
self.policy.backoff = backoff
self.policy.jitter = jitter
logger.info(f"🔄 Retry policy enabled: {self.policy}")
logger.info("🔄 Retry policy enabled: %s", self.policy)
async def retry_on_error(self, context: ExecutionContext) -> None:
from falyx.action import Action
@ -63,21 +67,21 @@ class RetryHandler:
last_error = error
if not target:
logger.warning(f"[{name}] ⚠️ No action target. Cannot retry.")
logger.warning("[%s] ⚠️ No action target. Cannot retry.", name)
return None
if not isinstance(target, Action):
logger.warning(
f"[{name}] ❌ RetryHandler only supports only supports Action objects."
"[%s] ❌ RetryHandler only supports only supports Action objects.", name
)
return None
if not getattr(target, "is_retryable", False):
logger.warning(f"[{name}] ❌ Not retryable.")
logger.warning("[%s] ❌ Not retryable.", name)
return None
if not self.policy.enabled:
logger.warning(f"[{name}] ❌ Retry policy is disabled.")
logger.warning("[%s] ❌ Retry policy is disabled.", name)
return None
while retries_done < self.policy.max_retries:
@ -88,23 +92,30 @@ class RetryHandler:
sleep_delay += random.uniform(-self.policy.jitter, self.policy.jitter)
logger.info(
f"[{name}] 🔄 Retrying ({retries_done}/{self.policy.max_retries}) "
f"in {current_delay}s due to '{last_error}'..."
"[%s] 🔄 Retrying (%s/%s) in %ss due to '%s'...",
name,
retries_done,
self.policy.max_retries,
current_delay,
last_error,
)
await asyncio.sleep(current_delay)
try:
result = await target.action(*context.args, **context.kwargs)
context.result = result
context.exception = None
logger.info(f"[{name}] ✅ Retry succeeded on attempt {retries_done}.")
logger.info("[%s] ✅ Retry succeeded on attempt %s.", name, retries_done)
return None
except Exception as retry_error:
last_error = retry_error
current_delay *= self.policy.backoff
logger.warning(
f"[{name}] ⚠️ Retry attempt {retries_done}/{self.policy.max_retries} "
f"failed due to '{retry_error}'."
"[%s] ⚠️ Retry attempt %s/%s failed due to '%s'.",
name,
retries_done,
self.policy.max_retries,
retry_error,
)
context.exception = last_error
logger.error(f"[{name}] ❌ All {self.policy.max_retries} retries failed.")
logger.error("[%s] ❌ All %s retries failed.", name, self.policy.max_retries)

View File

@ -1,4 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""retry_utils.py"""
from falyx.action import Action, BaseAction
from falyx.hook_manager import HookType
from falyx.retry import RetryHandler, RetryPolicy

View File

@ -1,4 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""select_file_action.py"""
from __future__ import annotations
import csv
@ -18,16 +19,18 @@ from falyx.action import BaseAction
from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType
from falyx.logger import logger
from falyx.selection import (
SelectionOption,
prompt_for_selection,
render_selection_dict_table,
)
from falyx.themes.colors import OneColors
from falyx.utils import logger
class FileReturnType(Enum):
"""Enum for file return types."""
TEXT = "text"
PATH = "path"
JSON = "json"

View File

@ -16,6 +16,8 @@ from falyx.validators import int_range_validator, key_validator
@dataclass
class SelectionOption:
"""Represents a single selection option with a description and a value."""
description: str
value: Any
style: str = OneColors.WHITE
@ -26,7 +28,8 @@ class SelectionOption:
def render(self, key: str) -> str:
"""Render the selection option for display."""
return f"[{OneColors.WHITE}][{key}][/] [{self.style}]{self.description}[/]"
key = escape(f"[{key}]")
return f"[{OneColors.WHITE}]{key}[/] [{self.style}]{self.description}[/]"
def render_table_base(
@ -194,7 +197,8 @@ def render_selection_dict_table(
row = []
for key, option in chunk:
row.append(
f"[{OneColors.WHITE}][{key.upper()}] [{option.style}]{option.description}[/]"
f"[{OneColors.WHITE}][{key.upper()}] "
f"[{option.style}]{option.description}[/]"
)
table.add_row(*row)

View File

@ -10,6 +10,7 @@ from falyx.action import BaseAction
from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType
from falyx.logger import logger
from falyx.selection import (
SelectionOption,
prompt_for_index,
@ -18,10 +19,18 @@ from falyx.selection import (
render_selection_indexed_table,
)
from falyx.themes.colors import OneColors
from falyx.utils import CaseInsensitiveDict, logger
from falyx.utils import CaseInsensitiveDict
class SelectionAction(BaseAction):
"""
A selection action that prompts the user to select an option from a list or
dictionary. The selected option is then returned as the result of the action.
If return_key is True, the key of the selected option is returned instead of
the value.
"""
def __init__(
self,
name: str,
@ -45,7 +54,8 @@ class SelectionAction(BaseAction):
inject_into=inject_into,
never_prompt=never_prompt,
)
self.selections: list[str] | CaseInsensitiveDict = selections
# Setter normalizes to correct type, mypy can't infer that
self.selections: list[str] | CaseInsensitiveDict = selections # type: ignore[assignment]
self.return_key = return_key
self.title = title
self.columns = columns
@ -71,7 +81,8 @@ class SelectionAction(BaseAction):
self._selections = cid
else:
raise TypeError(
f"'selections' must be a list[str] or dict[str, SelectionOption], got {type(value).__name__}"
"'selections' must be a list[str] or dict[str, SelectionOption], "
f"got {type(value).__name__}"
)
async def _run(self, *args, **kwargs) -> Any:
@ -108,7 +119,8 @@ class SelectionAction(BaseAction):
if self.never_prompt and not effective_default:
raise ValueError(
f"[{self.name}] 'never_prompt' is True but no valid default_selection was provided."
f"[{self.name}] 'never_prompt' is True but no valid default_selection "
"was provided."
)
context.start_timer()
@ -152,7 +164,8 @@ class SelectionAction(BaseAction):
result = key if self.return_key else self.selections[key].value
else:
raise TypeError(
f"'selections' must be a list[str] or dict[str, tuple[str, Any]], got {type(self.selections).__name__}"
"'selections' must be a list[str] or dict[str, tuple[str, Any]], "
f"got {type(self.selections).__name__}"
)
context.result = result
await self.hooks.trigger(HookType.ON_SUCCESS, context)
@ -205,5 +218,6 @@ class SelectionAction(BaseAction):
return (
f"SelectionAction(name={self.name!r}, type={selection_type}, "
f"default_selection={self.default_selection!r}, "
f"return_key={self.return_key}, prompt={'off' if self.never_prompt else 'on'})"
f"return_key={self.return_key}, "
f"prompt={'off' if self.never_prompt else 'on'})"
)

View File

@ -1,4 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""signal_action.py"""
from falyx.action import Action
from falyx.signals import FlowSignal

View File

@ -1,4 +1,7 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""signals.py"""
class FlowSignal(BaseException):
"""Base class for all flow control signals in Falyx.

View File

@ -1,4 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""tagged_table.py"""
from collections import defaultdict
from rich import box
@ -10,7 +11,7 @@ from falyx.falyx import Falyx
def build_tagged_table(flx: Falyx) -> Table:
"""Custom table builder that groups commands by tags."""
table = Table(title=flx.title, show_header=False, box=box.SIMPLE)
table = Table(title=flx.title, show_header=False, box=box.SIMPLE) # type: ignore[arg-type]
# Group commands by first tag
grouped: dict[str, list[Command]] = defaultdict(list)

View File

@ -1,5 +1,7 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""utils.py"""
from __future__ import annotations
import functools
import inspect
import logging
@ -10,23 +12,12 @@ from itertools import islice
from typing import Any, Awaitable, Callable, TypeVar
import pythonjsonlogger.json
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import (
AnyFormattedText,
FormattedText,
merge_formatted_text,
)
from rich.logging import RichHandler
from falyx.themes.colors import OneColors
from falyx.validators import yes_no_validator
logger = logging.getLogger("falyx")
T = TypeVar("T")
async def _noop(*args, **kwargs):
async def _noop(*_, **__):
pass
@ -70,22 +61,6 @@ def chunks(iterator, size):
yield chunk
async def confirm_async(
message: AnyFormattedText = "Are you sure?",
prefix: AnyFormattedText = FormattedText([(OneColors.CYAN, "")]),
suffix: AnyFormattedText = FormattedText([(OneColors.LIGHT_YELLOW_b, " [Y/n] > ")]),
session: PromptSession | None = None,
) -> bool:
"""Prompt the user with a yes/no async confirmation and return True for 'Y'."""
session = session or PromptSession()
merged_message: AnyFormattedText = merge_formatted_text([prefix, message, suffix])
answer = await session.prompt_async(
merged_message,
validator=yes_no_validator(),
)
return True if answer.upper() == "Y" else False
class CaseInsensitiveDict(dict):
"""A case-insensitive dictionary that treats all keys as uppercase."""
@ -114,12 +89,6 @@ class CaseInsensitiveDict(dict):
items.update({self._normalize_key(k): v for k, v in kwargs.items()})
super().update(items)
def __iter__(self):
return super().__iter__()
def keys(self):
return super().keys()
def running_in_container() -> bool:
try:
@ -143,11 +112,13 @@ def setup_logging(
console_log_level: int = logging.WARNING,
):
"""
Configure logging for Falyx with support for both CLI-friendly and structured JSON output.
Configure logging for Falyx with support for both CLI-friendly and structured
JSON output.
This function sets up separate logging handlers for console and file output, with optional
support for JSON formatting. It also auto-detects whether the application is running inside
a container to default to machine-readable logs when appropriate.
This function sets up separate logging handlers for console and file output,
with optional support for JSON formatting. It also auto-detects whether the
application is running inside a container to default to machine-readable logs
when appropriate.
Args:
mode (str | None):
@ -170,7 +141,8 @@ def setup_logging(
- Clears existing root handlers before setup.
- Configures console logging using either Rich (for CLI) or JSON formatting.
- Configures file logging in plain text or JSON based on `json_log_to_file`.
- Automatically sets logging levels for noisy third-party modules (`urllib3`, `asyncio`).
- Automatically sets logging levels for noisy third-party modules
(`urllib3`, `asyncio`, `markdown_it`).
- Propagates logs from the "falyx" logger to ensure centralized output.
Raises:

View File

@ -1,4 +1,5 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""validators.py"""
from typing import KeysView, Sequence
from prompt_toolkit.validation import Validator
@ -7,10 +8,10 @@ from prompt_toolkit.validation import Validator
def int_range_validator(minimum: int, maximum: int) -> Validator:
"""Validator for integer ranges."""
def validate(input: str) -> bool:
def validate(text: str) -> bool:
try:
value = int(input)
if not (minimum <= value <= maximum):
value = int(text)
if not minimum <= value <= maximum:
return False
return True
except ValueError:
@ -25,8 +26,8 @@ def int_range_validator(minimum: int, maximum: int) -> Validator:
def key_validator(keys: Sequence[str] | KeysView[str]) -> Validator:
"""Validator for key inputs."""
def validate(input: str) -> bool:
if input.upper() not in [key.upper() for key in keys]:
def validate(text: str) -> bool:
if text.upper() not in [key.upper() for key in keys]:
return False
return True
@ -38,8 +39,8 @@ def key_validator(keys: Sequence[str] | KeysView[str]) -> Validator:
def yes_no_validator() -> Validator:
"""Validator for yes/no inputs."""
def validate(input: str) -> bool:
if input.upper() not in ["Y", "N"]:
def validate(text: str) -> bool:
if text.upper() not in ["Y", "N"]:
return False
return True

View File

@ -1 +1 @@
__version__ = "0.1.23"
__version__ = "0.1.24"

View File

@ -146,7 +146,10 @@ disable=abstract-method,
wrong-import-order,
xrange-builtin,
zip-builtin-not-iterating,
broad-exception-caught
broad-exception-caught,
too-many-positional-arguments,
inconsistent-quotes,
import-outside-toplevel
[REPORTS]
@ -260,7 +263,7 @@ generated-members=
[FORMAT]
# Maximum number of characters on a single line.
max-line-length=80
max-line-length=90
# TODO(https://github.com/PyCQA/pylint/issues/3352): Direct pylint to exempt
# lines made too long by directives to pytype.

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "falyx"
version = "0.1.23"
version = "0.1.24"
description = "Reliable and introspectable async CLI action framework."
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
license = "MIT"
@ -17,7 +17,7 @@ toml = "^0.10"
pyyaml = "^6.0"
[tool.poetry.group.dev.dependencies]
pytest = "^7.0"
pytest = "^8.3.5"
pytest-asyncio = "^0.20"
ruff = "^0.3"
toml = "^0.10"
@ -36,7 +36,7 @@ build-backend = "poetry.core.masonry.api"
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
#asyncio_default_fixture_loop_scope = "function"
[tool.pylint."MESSAGES CONTROL"]
disable = ["broad-exception-caught"]

View File

@ -33,7 +33,7 @@ async def test_process_action_executes_correctly():
assert result == 5
unpickleable = lambda x: x + 1
unpickleable = lambda x: x + 1 # noqa: E731
@pytest.mark.asyncio

View File

@ -35,8 +35,8 @@ def test_bootstrap_no_config():
sys_path_before = list(sys.path)
bootstrap_path = bootstrap()
assert bootstrap_path is None
sys.path = sys_path_before
assert str(Path.cwd()) not in sys.path
assert sys.path == sys_path_before
# assert str(Path.cwd()) not in sys.path
def test_bootstrap_with_global_config():

View File

@ -1,11 +1,7 @@
import asyncio
import pytest
from falyx.action import Action, ActionGroup, ChainedAction, FallbackAction
from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType
# --- Fixtures ---