Linting
This commit is contained in:
parent
e999ad5e1c
commit
87a56ac40b
|
@ -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)
|
||||
|
@ -721,8 +738,8 @@ class ActionGroup(BaseAction, ActionListMixin):
|
|||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"ActionGroup(name={self.name!r}, actions={[a.name for a in self.actions]!r}, "
|
||||
f"inject_last_result={self.inject_last_result})"
|
||||
f"ActionGroup(name={self.name!r}, actions={[a.name for a in self.actions]!r},"
|
||||
f" inject_last_result={self.inject_last_result})"
|
||||
)
|
||||
|
||||
|
||||
|
@ -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})"
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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]])
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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."""
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
140
falyx/falyx.py
140
falyx/falyx.py
|
@ -51,12 +51,13 @@ from falyx.exceptions import (
|
|||
)
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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})"
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
@ -246,6 +251,6 @@ class ShellAction(BaseIOAction):
|
|||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"ShellAction(name={self.name!r}, command_template={self.command_template!r}, "
|
||||
f"safe_mode={self.safe_mode})"
|
||||
f"ShellAction(name={self.name!r}, command_template={self.command_template!r},"
|
||||
f" safe_mode={self.safe_mode})"
|
||||
)
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""logger.py"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("falyx")
|
|
@ -12,15 +12,18 @@ from falyx.action import BaseAction
|
|||
from falyx.context import ExecutionContext
|
||||
from falyx.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()
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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'})"
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -1 +1 @@
|
|||
__version__ = "0.1.23"
|
||||
__version__ = "0.1.24"
|
||||
|
|
7
pylintrc
7
pylintrc
|
@ -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.
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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():
|
||||
|
|
|
@ -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 ---
|
||||
|
||||
|
|
Loading…
Reference in New Issue