Add MenuAction, SelectionAction, SignalAction, never_prompt(options_manager propagation), Merged prepare
This commit is contained in:
parent
69b629eb08
commit
91c4d5481f
|
@ -9,10 +9,10 @@ def hello() -> None:
|
||||||
print("Hello, world!")
|
print("Hello, world!")
|
||||||
|
|
||||||
|
|
||||||
hello = Action(name="hello_action", action=hello)
|
hello_action = Action(name="hello_action", action=hello)
|
||||||
|
|
||||||
# Actions can be run by themselves or as part of a command or pipeline
|
# Actions can be run by themselves or as part of a command or pipeline
|
||||||
asyncio.run(hello())
|
asyncio.run(hello_action())
|
||||||
|
|
||||||
|
|
||||||
# Actions are designed to be asynchronous first
|
# Actions are designed to be asynchronous first
|
||||||
|
@ -20,14 +20,14 @@ async def goodbye() -> None:
|
||||||
print("Goodbye!")
|
print("Goodbye!")
|
||||||
|
|
||||||
|
|
||||||
goodbye = Action(name="goodbye_action", action=goodbye)
|
goodbye_action = Action(name="goodbye_action", action=goodbye)
|
||||||
|
|
||||||
asyncio.run(goodbye())
|
asyncio.run(goodbye())
|
||||||
|
|
||||||
# Actions can be run in parallel
|
# Actions can be run in parallel
|
||||||
group = ActionGroup(name="greeting_group", actions=[hello, goodbye])
|
group = ActionGroup(name="greeting_group", actions=[hello_action, goodbye_action])
|
||||||
asyncio.run(group())
|
asyncio.run(group())
|
||||||
|
|
||||||
# Actions can be run in a chain
|
# Actions can be run in a chain
|
||||||
chain = ChainedAction(name="greeting_chain", actions=[hello, goodbye])
|
chain = ChainedAction(name="greeting_chain", actions=[hello_action, goodbye_action])
|
||||||
asyncio.run(chain())
|
asyncio.run(chain())
|
||||||
|
|
|
@ -12,6 +12,7 @@ async def flaky_step():
|
||||||
await asyncio.sleep(0.2)
|
await asyncio.sleep(0.2)
|
||||||
if random.random() < 0.5:
|
if random.random() < 0.5:
|
||||||
raise RuntimeError("Random failure!")
|
raise RuntimeError("Random failure!")
|
||||||
|
print("Flaky step succeeded!")
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -29,8 +29,7 @@ def bootstrap() -> Path | None:
|
||||||
config_path = find_falyx_config()
|
config_path = find_falyx_config()
|
||||||
if config_path and str(config_path.parent) not in sys.path:
|
if config_path and str(config_path.parent) not in sys.path:
|
||||||
sys.path.insert(0, str(config_path.parent))
|
sys.path.insert(0, str(config_path.parent))
|
||||||
return config_path
|
return config_path
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
|
|
@ -43,6 +43,7 @@ from falyx.debug import register_debug_hooks
|
||||||
from falyx.exceptions import EmptyChainError
|
from falyx.exceptions import EmptyChainError
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
from falyx.hook_manager import Hook, HookManager, HookType
|
from falyx.hook_manager import Hook, HookManager, HookType
|
||||||
|
from falyx.options_manager import OptionsManager
|
||||||
from falyx.retry import RetryHandler, RetryPolicy
|
from falyx.retry import RetryHandler, RetryPolicy
|
||||||
from falyx.themes.colors import OneColors
|
from falyx.themes.colors import OneColors
|
||||||
from falyx.utils import ensure_async, logger
|
from falyx.utils import ensure_async, logger
|
||||||
|
@ -66,6 +67,7 @@ class BaseAction(ABC):
|
||||||
hooks: HookManager | None = None,
|
hooks: HookManager | None = None,
|
||||||
inject_last_result: bool = False,
|
inject_last_result: bool = False,
|
||||||
inject_last_result_as: str = "last_result",
|
inject_last_result_as: str = "last_result",
|
||||||
|
never_prompt: bool = False,
|
||||||
logging_hooks: bool = False,
|
logging_hooks: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.name = name
|
self.name = name
|
||||||
|
@ -74,9 +76,11 @@ class BaseAction(ABC):
|
||||||
self.shared_context: SharedContext | None = None
|
self.shared_context: SharedContext | None = None
|
||||||
self.inject_last_result: bool = inject_last_result
|
self.inject_last_result: bool = inject_last_result
|
||||||
self.inject_last_result_as: str = inject_last_result_as
|
self.inject_last_result_as: str = inject_last_result_as
|
||||||
|
self._never_prompt: bool = never_prompt
|
||||||
self._requires_injection: bool = False
|
self._requires_injection: bool = False
|
||||||
self._skip_in_chain: bool = False
|
self._skip_in_chain: bool = False
|
||||||
self.console = Console(color_system="auto")
|
self.console = Console(color_system="auto")
|
||||||
|
self.options_manager: OptionsManager | None = None
|
||||||
|
|
||||||
if logging_hooks:
|
if logging_hooks:
|
||||||
register_debug_hooks(self.hooks)
|
register_debug_hooks(self.hooks)
|
||||||
|
@ -92,23 +96,39 @@ class BaseAction(ABC):
|
||||||
async def preview(self, parent: Tree | None = None):
|
async def preview(self, parent: Tree | None = None):
|
||||||
raise NotImplementedError("preview must be implemented by subclasses")
|
raise NotImplementedError("preview must be implemented by subclasses")
|
||||||
|
|
||||||
def set_shared_context(self, shared_context: SharedContext):
|
def set_options_manager(self, options_manager: OptionsManager) -> None:
|
||||||
|
self.options_manager = options_manager
|
||||||
|
|
||||||
|
def set_shared_context(self, shared_context: SharedContext) -> None:
|
||||||
self.shared_context = shared_context
|
self.shared_context = shared_context
|
||||||
|
|
||||||
def prepare_for_chain(self, shared_context: SharedContext) -> BaseAction:
|
def get_option(self, option_name: str, default: Any = None) -> Any:
|
||||||
|
"""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
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_result(self) -> Any:
|
||||||
|
"""Return the last result from the shared context."""
|
||||||
|
if self.shared_context:
|
||||||
|
return self.shared_context.last_result()
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def never_prompt(self) -> bool:
|
||||||
|
return self.get_option("never_prompt", self._never_prompt)
|
||||||
|
|
||||||
|
def prepare(
|
||||||
|
self, shared_context: SharedContext, options_manager: OptionsManager | None = None
|
||||||
|
) -> BaseAction:
|
||||||
"""
|
"""
|
||||||
Prepare the action specifically for sequential (ChainedAction) execution.
|
Prepare the action specifically for sequential (ChainedAction) execution.
|
||||||
Can be overridden for chain-specific logic.
|
Can be overridden for chain-specific logic.
|
||||||
"""
|
"""
|
||||||
self.set_shared_context(shared_context)
|
self.set_shared_context(shared_context)
|
||||||
return self
|
if options_manager:
|
||||||
|
self.set_options_manager(options_manager)
|
||||||
def prepare_for_group(self, shared_context: SharedContext) -> BaseAction:
|
|
||||||
"""
|
|
||||||
Prepare the action specifically for parallel (ActionGroup) execution.
|
|
||||||
Can be overridden for group-specific logic.
|
|
||||||
"""
|
|
||||||
self.set_shared_context(shared_context)
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def _maybe_inject_last_result(self, kwargs: dict[str, Any]) -> dict[str, Any]:
|
def _maybe_inject_last_result(self, kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
@ -161,8 +181,8 @@ class Action(BaseAction):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
action,
|
action: Callable[..., Any],
|
||||||
rollback=None,
|
rollback: Callable[..., Any] | None = None,
|
||||||
args: tuple[Any, ...] = (),
|
args: tuple[Any, ...] = (),
|
||||||
kwargs: dict[str, Any] | None = None,
|
kwargs: dict[str, Any] | None = None,
|
||||||
hooks: HookManager | None = None,
|
hooks: HookManager | None = None,
|
||||||
|
@ -189,6 +209,17 @@ class Action(BaseAction):
|
||||||
def action(self, value: Callable[..., Any]):
|
def action(self, value: Callable[..., Any]):
|
||||||
self._action = ensure_async(value)
|
self._action = ensure_async(value)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rollback(self) -> Callable[..., Any] | None:
|
||||||
|
return self._rollback
|
||||||
|
|
||||||
|
@rollback.setter
|
||||||
|
def rollback(self, value: Callable[..., Any] | None):
|
||||||
|
if value is None:
|
||||||
|
self._rollback = None
|
||||||
|
else:
|
||||||
|
self._rollback = ensure_async(value)
|
||||||
|
|
||||||
def enable_retry(self):
|
def enable_retry(self):
|
||||||
"""Enable retry with the existing retry policy."""
|
"""Enable retry with the existing retry policy."""
|
||||||
self.retry_policy.enable_policy()
|
self.retry_policy.enable_policy()
|
||||||
|
@ -212,6 +243,7 @@ class Action(BaseAction):
|
||||||
kwargs=combined_kwargs,
|
kwargs=combined_kwargs,
|
||||||
action=self,
|
action=self,
|
||||||
)
|
)
|
||||||
|
|
||||||
context.start_timer()
|
context.start_timer()
|
||||||
try:
|
try:
|
||||||
await self.hooks.trigger(HookType.BEFORE, context)
|
await self.hooks.trigger(HookType.BEFORE, context)
|
||||||
|
@ -425,7 +457,7 @@ class ChainedAction(BaseAction, ActionListMixin):
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
shared_context.current_index = index
|
shared_context.current_index = index
|
||||||
prepared = action.prepare_for_chain(shared_context)
|
prepared = action.prepare(shared_context, self.options_manager)
|
||||||
last_result = shared_context.last_result()
|
last_result = shared_context.last_result()
|
||||||
try:
|
try:
|
||||||
if self.requires_io_injection() and last_result is not None:
|
if self.requires_io_injection() and last_result is not None:
|
||||||
|
@ -446,9 +478,7 @@ class ChainedAction(BaseAction, ActionListMixin):
|
||||||
)
|
)
|
||||||
shared_context.add_result(None)
|
shared_context.add_result(None)
|
||||||
context.extra["results"].append(None)
|
context.extra["results"].append(None)
|
||||||
fallback = self.actions[index + 1].prepare_for_chain(
|
fallback = self.actions[index + 1].prepare(shared_context)
|
||||||
shared_context
|
|
||||||
)
|
|
||||||
result = await fallback()
|
result = await fallback()
|
||||||
fallback._skip_in_chain = True
|
fallback._skip_in_chain = True
|
||||||
else:
|
else:
|
||||||
|
@ -584,7 +614,7 @@ class ActionGroup(BaseAction, ActionListMixin):
|
||||||
|
|
||||||
async def run_one(action: BaseAction):
|
async def run_one(action: BaseAction):
|
||||||
try:
|
try:
|
||||||
prepared = action.prepare_for_group(shared_context)
|
prepared = action.prepare(shared_context, self.options_manager)
|
||||||
result = await prepared(*args, **updated_kwargs)
|
result = await prepared(*args, **updated_kwargs)
|
||||||
shared_context.add_result((action.name, result))
|
shared_context.add_result((action.name, result))
|
||||||
context.extra["results"].append((action.name, result))
|
context.extra["results"].append((action.name, result))
|
||||||
|
|
|
@ -32,6 +32,7 @@ from falyx.debug import register_debug_hooks
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
from falyx.hook_manager import HookManager, HookType
|
from falyx.hook_manager import HookManager, HookType
|
||||||
from falyx.io_action import BaseIOAction
|
from falyx.io_action import BaseIOAction
|
||||||
|
from falyx.options_manager import OptionsManager
|
||||||
from falyx.retry import RetryPolicy
|
from falyx.retry import RetryPolicy
|
||||||
from falyx.retry_utils import enable_retries_recursively
|
from falyx.retry_utils import enable_retries_recursively
|
||||||
from falyx.themes.colors import OneColors
|
from falyx.themes.colors import OneColors
|
||||||
|
@ -116,6 +117,7 @@ class Command(BaseModel):
|
||||||
tags: list[str] = Field(default_factory=list)
|
tags: list[str] = Field(default_factory=list)
|
||||||
logging_hooks: bool = False
|
logging_hooks: bool = False
|
||||||
requires_input: bool | None = None
|
requires_input: bool | None = None
|
||||||
|
options_manager: OptionsManager = Field(default_factory=OptionsManager)
|
||||||
|
|
||||||
_context: ExecutionContext | None = PrivateAttr(default=None)
|
_context: ExecutionContext | None = PrivateAttr(default=None)
|
||||||
|
|
||||||
|
@ -178,8 +180,14 @@ class Command(BaseModel):
|
||||||
f"action='{self.action}')"
|
f"action='{self.action}')"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _inject_options_manager(self):
|
||||||
|
"""Inject the options manager into the action if applicable."""
|
||||||
|
if isinstance(self.action, BaseAction):
|
||||||
|
self.action.set_options_manager(self.options_manager)
|
||||||
|
|
||||||
async def __call__(self, *args, **kwargs):
|
async def __call__(self, *args, **kwargs):
|
||||||
"""Run the action with full hook lifecycle, timing, and error handling."""
|
"""Run the action with full hook lifecycle, timing, and error handling."""
|
||||||
|
self._inject_options_manager()
|
||||||
combined_args = args + self.args
|
combined_args = args + self.args
|
||||||
combined_kwargs = {**self.kwargs, **kwargs}
|
combined_kwargs = {**self.kwargs, **kwargs}
|
||||||
context = ExecutionContext(
|
context = ExecutionContext(
|
||||||
|
@ -200,9 +208,6 @@ class Command(BaseModel):
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
context.exception = error
|
context.exception = error
|
||||||
await self.hooks.trigger(HookType.ON_ERROR, context)
|
await self.hooks.trigger(HookType.ON_ERROR, context)
|
||||||
if context.result is not None:
|
|
||||||
logger.info(f"✅ Recovered: {self.key}")
|
|
||||||
return context.result
|
|
||||||
raise error
|
raise error
|
||||||
finally:
|
finally:
|
||||||
context.stop_timer()
|
context.stop_timer()
|
||||||
|
|
|
@ -1,5 +1,20 @@
|
||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
"""context.py"""
|
"""
|
||||||
|
Execution context management for Falyx CLI actions.
|
||||||
|
|
||||||
|
This module defines `ExecutionContext` and `SharedContext`, which are responsible for
|
||||||
|
capturing per-action and cross-action metadata during CLI workflow execution. These
|
||||||
|
context objects provide structured introspection, result tracking, error recording,
|
||||||
|
and time-based performance metrics.
|
||||||
|
|
||||||
|
- `ExecutionContext`: Captures runtime information for a single action execution,
|
||||||
|
including arguments, results, exceptions, timing, and logging.
|
||||||
|
- `SharedContext`: Maintains shared state and result propagation across
|
||||||
|
`ChainedAction` or `ActionGroup` executions.
|
||||||
|
|
||||||
|
These contexts enable rich introspection, traceability, and workflow coordination,
|
||||||
|
supporting hook lifecycles, retries, and structured output generation.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
@ -11,6 +26,47 @@ from rich.console import Console
|
||||||
|
|
||||||
|
|
||||||
class ExecutionContext(BaseModel):
|
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.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name (str): The name of the action being executed.
|
||||||
|
args (tuple): Positional arguments passed to the action.
|
||||||
|
kwargs (dict): Keyword arguments passed to the action.
|
||||||
|
action (BaseAction | Callable): The action instance being executed.
|
||||||
|
result (Any | None): The result of the action, if successful.
|
||||||
|
exception (Exception | None): The exception raised, if execution failed.
|
||||||
|
start_time (float | None): High-resolution performance start time.
|
||||||
|
end_time (float | None): High-resolution performance end time.
|
||||||
|
start_wall (datetime | None): Wall-clock timestamp when execution began.
|
||||||
|
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.
|
||||||
|
|
||||||
|
Properties:
|
||||||
|
duration (float | None): The execution duration in seconds.
|
||||||
|
success (bool): Whether the action completed without raising an exception.
|
||||||
|
status (str): Returns "OK" if successful, otherwise "ERROR".
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
start_timer(): Starts the timing and timestamp tracking.
|
||||||
|
stop_timer(): Stops timing and stores end timestamps.
|
||||||
|
log_summary(logger=None): Logs a rich or plain summary of execution.
|
||||||
|
to_log_line(): Returns a single-line log entry for metrics or tracing.
|
||||||
|
as_dict(): Serializes core result and diagnostic metadata.
|
||||||
|
get_shared_context(): Returns the shared context or creates a default one.
|
||||||
|
|
||||||
|
This class is used internally by all Falyx actions and hook events. It ensures
|
||||||
|
consistent tracking and reporting across asynchronous workflows, including CLI-driven
|
||||||
|
and automated batch executions.
|
||||||
|
"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
args: tuple = ()
|
args: tuple = ()
|
||||||
kwargs: dict = {}
|
kwargs: dict = {}
|
||||||
|
@ -120,6 +176,37 @@ class ExecutionContext(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class SharedContext(BaseModel):
|
class SharedContext(BaseModel):
|
||||||
|
"""
|
||||||
|
SharedContext maintains transient shared state during the execution
|
||||||
|
of a ChainedAction or ActionGroup.
|
||||||
|
|
||||||
|
This context object is passed to all actions within a chain or group,
|
||||||
|
enabling result propagation, shared data exchange, and coordinated
|
||||||
|
tracking of execution order and failures.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name (str): Identifier for the context (usually the parent action name).
|
||||||
|
results (list[Any]): Captures results from each action, in order of execution.
|
||||||
|
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
|
||||||
|
between actions (e.g., flags, intermediate data, settings).
|
||||||
|
|
||||||
|
Note:
|
||||||
|
SharedContext is only used within grouped or chained workflows. It should not be
|
||||||
|
used for standalone `Action` executions, where state should be scoped to the
|
||||||
|
individual ExecutionContext instead.
|
||||||
|
|
||||||
|
Example usage:
|
||||||
|
- In a ChainedAction: last_result is pulled from `results[-1]`.
|
||||||
|
- In an ActionGroup: all actions can read/write `shared_result` or use `share`.
|
||||||
|
|
||||||
|
This class supports fault-tolerant and modular composition of CLI workflows
|
||||||
|
by enabling flexible intra-action communication without global state.
|
||||||
|
"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
results: list[Any] = Field(default_factory=list)
|
results: list[Any] = Field(default_factory=list)
|
||||||
errors: list[tuple[int, Exception]] = Field(default_factory=list)
|
errors: list[tuple[int, Exception]] = Field(default_factory=list)
|
||||||
|
|
|
@ -53,11 +53,12 @@ from falyx.hook_manager import Hook, HookManager, HookType
|
||||||
from falyx.options_manager import OptionsManager
|
from falyx.options_manager import OptionsManager
|
||||||
from falyx.parsers import get_arg_parsers
|
from falyx.parsers import get_arg_parsers
|
||||||
from falyx.retry import RetryPolicy
|
from falyx.retry import RetryPolicy
|
||||||
|
from falyx.signals import BackSignal, QuitSignal
|
||||||
from falyx.themes.colors import OneColors, get_nord_theme
|
from falyx.themes.colors import OneColors, get_nord_theme
|
||||||
from falyx.utils import (
|
from falyx.utils import (
|
||||||
CaseInsensitiveDict,
|
CaseInsensitiveDict,
|
||||||
async_confirm,
|
|
||||||
chunks,
|
chunks,
|
||||||
|
confirm_async,
|
||||||
get_program_invocation,
|
get_program_invocation,
|
||||||
logger,
|
logger,
|
||||||
)
|
)
|
||||||
|
@ -93,7 +94,7 @@ class Falyx:
|
||||||
include_history_command (bool): Whether to add a built-in history viewer command.
|
include_history_command (bool): Whether to add a built-in history viewer command.
|
||||||
include_help_command (bool): Whether to add a built-in help viewer command.
|
include_help_command (bool): Whether to add a built-in help viewer command.
|
||||||
confirm_on_error (bool): Whether to prompt the user after errors.
|
confirm_on_error (bool): Whether to prompt the user after errors.
|
||||||
never_confirm (bool): Whether to skip confirmation prompts entirely.
|
never_prompt (bool): Whether to skip confirmation prompts entirely.
|
||||||
always_confirm (bool): Whether to force confirmation prompts for all actions.
|
always_confirm (bool): Whether to force confirmation prompts for all actions.
|
||||||
cli_args (Namespace | None): Parsed CLI arguments, usually from argparse.
|
cli_args (Namespace | None): Parsed CLI arguments, usually from argparse.
|
||||||
options (OptionsManager | None): Declarative option mappings.
|
options (OptionsManager | None): Declarative option mappings.
|
||||||
|
@ -123,7 +124,7 @@ class Falyx:
|
||||||
include_history_command: bool = True,
|
include_history_command: bool = True,
|
||||||
include_help_command: bool = True,
|
include_help_command: bool = True,
|
||||||
confirm_on_error: bool = True,
|
confirm_on_error: bool = True,
|
||||||
never_confirm: bool = False,
|
never_prompt: bool = False,
|
||||||
always_confirm: bool = False,
|
always_confirm: bool = False,
|
||||||
cli_args: Namespace | None = None,
|
cli_args: Namespace | None = None,
|
||||||
options: OptionsManager | None = None,
|
options: OptionsManager | None = None,
|
||||||
|
@ -150,7 +151,7 @@ class Falyx:
|
||||||
self.key_bindings: KeyBindings = key_bindings or KeyBindings()
|
self.key_bindings: KeyBindings = key_bindings or KeyBindings()
|
||||||
self.bottom_bar: BottomBar | str | Callable[[], None] = bottom_bar
|
self.bottom_bar: BottomBar | str | Callable[[], None] = bottom_bar
|
||||||
self.confirm_on_error: bool = confirm_on_error
|
self.confirm_on_error: bool = confirm_on_error
|
||||||
self._never_confirm: bool = never_confirm
|
self._never_prompt: bool = never_prompt
|
||||||
self._always_confirm: bool = always_confirm
|
self._always_confirm: bool = always_confirm
|
||||||
self.cli_args: Namespace | None = cli_args
|
self.cli_args: Namespace | None = cli_args
|
||||||
self.render_menu: Callable[["Falyx"], None] | None = render_menu
|
self.render_menu: Callable[["Falyx"], None] | None = render_menu
|
||||||
|
@ -166,7 +167,7 @@ class Falyx:
|
||||||
"""Checks if the options are set correctly."""
|
"""Checks if the options are set correctly."""
|
||||||
self.options: OptionsManager = options or OptionsManager()
|
self.options: OptionsManager = options or OptionsManager()
|
||||||
if not cli_args and not options:
|
if not cli_args and not options:
|
||||||
return
|
return None
|
||||||
|
|
||||||
if options and not cli_args:
|
if options and not cli_args:
|
||||||
raise FalyxError("Options are set, but CLI arguments are not.")
|
raise FalyxError("Options are set, but CLI arguments are not.")
|
||||||
|
@ -521,8 +522,9 @@ class Falyx:
|
||||||
|
|
||||||
def update_exit_command(
|
def update_exit_command(
|
||||||
self,
|
self,
|
||||||
key: str = "0",
|
key: str = "Q",
|
||||||
description: str = "Exit",
|
description: str = "Exit",
|
||||||
|
aliases: list[str] | None = None,
|
||||||
action: Callable[[], Any] = lambda: None,
|
action: Callable[[], Any] = lambda: None,
|
||||||
color: str = OneColors.DARK_RED,
|
color: str = OneColors.DARK_RED,
|
||||||
confirm: bool = False,
|
confirm: bool = False,
|
||||||
|
@ -535,6 +537,7 @@ class Falyx:
|
||||||
self.exit_command = Command(
|
self.exit_command = Command(
|
||||||
key=key,
|
key=key,
|
||||||
description=description,
|
description=description,
|
||||||
|
aliases=aliases if aliases else self.exit_command.aliases,
|
||||||
action=action,
|
action=action,
|
||||||
color=color,
|
color=color,
|
||||||
confirm=confirm,
|
confirm=confirm,
|
||||||
|
@ -549,6 +552,7 @@ class Falyx:
|
||||||
raise NotAFalyxError("submenu must be an instance of Falyx.")
|
raise NotAFalyxError("submenu must be an instance of Falyx.")
|
||||||
self._validate_command_key(key)
|
self._validate_command_key(key)
|
||||||
self.add_command(key, description, submenu.menu, color=color)
|
self.add_command(key, description, submenu.menu, color=color)
|
||||||
|
submenu.update_exit_command(key="B", description="Back", aliases=["BACK"])
|
||||||
|
|
||||||
def add_commands(self, commands: list[dict]) -> None:
|
def add_commands(self, commands: list[dict]) -> None:
|
||||||
"""Adds multiple commands to the menu."""
|
"""Adds multiple commands to the menu."""
|
||||||
|
@ -613,6 +617,7 @@ class Falyx:
|
||||||
retry_all=retry_all,
|
retry_all=retry_all,
|
||||||
retry_policy=retry_policy or RetryPolicy(),
|
retry_policy=retry_policy or RetryPolicy(),
|
||||||
requires_input=requires_input,
|
requires_input=requires_input,
|
||||||
|
options_manager=self.options,
|
||||||
)
|
)
|
||||||
|
|
||||||
if hooks:
|
if hooks:
|
||||||
|
@ -703,7 +708,7 @@ class Falyx:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _should_run_action(self, selected_command: Command) -> bool:
|
async def _should_run_action(self, selected_command: Command) -> bool:
|
||||||
if self._never_confirm:
|
if self._never_prompt:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if self.cli_args and getattr(self.cli_args, "skip_confirm", False):
|
if self.cli_args and getattr(self.cli_args, "skip_confirm", False):
|
||||||
|
@ -717,7 +722,7 @@ class Falyx:
|
||||||
):
|
):
|
||||||
if selected_command.preview_before_confirm:
|
if selected_command.preview_before_confirm:
|
||||||
await selected_command.preview()
|
await selected_command.preview()
|
||||||
confirm_answer = await async_confirm(selected_command.confirmation_prompt)
|
confirm_answer = await confirm_async(selected_command.confirmation_prompt)
|
||||||
|
|
||||||
if confirm_answer:
|
if confirm_answer:
|
||||||
logger.info(f"[{selected_command.description}]🔐 confirmed.")
|
logger.info(f"[{selected_command.description}]🔐 confirmed.")
|
||||||
|
@ -747,18 +752,13 @@ class Falyx:
|
||||||
|
|
||||||
async def _handle_action_error(
|
async def _handle_action_error(
|
||||||
self, selected_command: Command, error: Exception
|
self, selected_command: Command, error: Exception
|
||||||
) -> bool:
|
) -> None:
|
||||||
"""Handles errors that occur during the action of the selected command."""
|
"""Handles errors that occur during the action of the selected command."""
|
||||||
logger.exception(f"Error executing '{selected_command.description}': {error}")
|
logger.exception(f"Error executing '{selected_command.description}': {error}")
|
||||||
self.console.print(
|
self.console.print(
|
||||||
f"[{OneColors.DARK_RED}]An error occurred while executing "
|
f"[{OneColors.DARK_RED}]An error occurred while executing "
|
||||||
f"{selected_command.description}:[/] {error}"
|
f"{selected_command.description}:[/] {error}"
|
||||||
)
|
)
|
||||||
if self.confirm_on_error and not self._never_confirm:
|
|
||||||
return await async_confirm("An error occurred. Do you wish to continue?")
|
|
||||||
if self._never_confirm:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def process_command(self) -> bool:
|
async def process_command(self) -> bool:
|
||||||
"""Processes the action of the selected command."""
|
"""Processes the action of the selected command."""
|
||||||
|
@ -801,13 +801,7 @@ class Falyx:
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
context.exception = error
|
context.exception = error
|
||||||
await self.hooks.trigger(HookType.ON_ERROR, context)
|
await self.hooks.trigger(HookType.ON_ERROR, context)
|
||||||
if not context.exception:
|
await self._handle_action_error(selected_command, error)
|
||||||
logger.info(
|
|
||||||
f"✅ Recovery hook handled error for '{selected_command.description}'"
|
|
||||||
)
|
|
||||||
context.result = result
|
|
||||||
else:
|
|
||||||
return await self._handle_action_error(selected_command, error)
|
|
||||||
finally:
|
finally:
|
||||||
context.stop_timer()
|
context.stop_timer()
|
||||||
await self.hooks.trigger(HookType.AFTER, context)
|
await self.hooks.trigger(HookType.AFTER, context)
|
||||||
|
@ -822,7 +816,7 @@ class Falyx:
|
||||||
|
|
||||||
if not selected_command:
|
if not selected_command:
|
||||||
logger.info("[Headless] Back command selected. Exiting menu.")
|
logger.info("[Headless] Back command selected. Exiting menu.")
|
||||||
return
|
return None
|
||||||
|
|
||||||
logger.info(f"[Headless] 🚀 Running: '{selected_command.description}'")
|
logger.info(f"[Headless] 🚀 Running: '{selected_command.description}'")
|
||||||
|
|
||||||
|
@ -851,11 +845,6 @@ class Falyx:
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
context.exception = error
|
context.exception = error
|
||||||
await self.hooks.trigger(HookType.ON_ERROR, context)
|
await self.hooks.trigger(HookType.ON_ERROR, context)
|
||||||
if not context.exception:
|
|
||||||
logger.info(
|
|
||||||
f"[Headless] ✅ Recovery hook handled error for '{selected_command.description}'"
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
raise FalyxError(
|
raise FalyxError(
|
||||||
f"[Headless] ❌ '{selected_command.description}' failed."
|
f"[Headless] ❌ '{selected_command.description}' failed."
|
||||||
) from error
|
) from error
|
||||||
|
@ -921,6 +910,11 @@ class Falyx:
|
||||||
except (EOFError, KeyboardInterrupt):
|
except (EOFError, KeyboardInterrupt):
|
||||||
logger.info("EOF or KeyboardInterrupt. Exiting menu.")
|
logger.info("EOF or KeyboardInterrupt. Exiting menu.")
|
||||||
break
|
break
|
||||||
|
except QuitSignal:
|
||||||
|
logger.info("QuitSignal received. Exiting menu.")
|
||||||
|
break
|
||||||
|
except BackSignal:
|
||||||
|
logger.info("BackSignal received.")
|
||||||
finally:
|
finally:
|
||||||
logger.info(f"Exiting menu: {self.get_title()}")
|
logger.info(f"Exiting menu: {self.get_title()}")
|
||||||
if self.exit_message:
|
if self.exit_message:
|
||||||
|
@ -938,6 +932,9 @@ class Falyx:
|
||||||
logger.debug("✅ Enabling global debug hooks for all commands")
|
logger.debug("✅ Enabling global debug hooks for all commands")
|
||||||
self.register_all_with_debug_hooks()
|
self.register_all_with_debug_hooks()
|
||||||
|
|
||||||
|
if self.cli_args.never_prompt:
|
||||||
|
self._never_prompt = True
|
||||||
|
|
||||||
if self.cli_args.command == "list":
|
if self.cli_args.command == "list":
|
||||||
await self._show_help()
|
await self._show_help()
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
|
@ -1,30 +0,0 @@
|
||||||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
|
||||||
"""importer.py"""
|
|
||||||
|
|
||||||
import importlib
|
|
||||||
from types import ModuleType
|
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_action(path: str) -> Callable[..., Any]:
|
|
||||||
"""
|
|
||||||
Resolve a dotted path to a Python callable.
|
|
||||||
Example: 'mypackage.mymodule.myfunction'
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ImportError if the module or function does not exist.
|
|
||||||
ValueError if the resolved attribute is not callable.
|
|
||||||
"""
|
|
||||||
if ":" in path:
|
|
||||||
module_path, function_name = path.split(":")
|
|
||||||
else:
|
|
||||||
*module_parts, function_name = path.split(".")
|
|
||||||
module_path = ".".join(module_parts)
|
|
||||||
|
|
||||||
module: ModuleType = importlib.import_module(module_path)
|
|
||||||
function: Any = getattr(module, function_name)
|
|
||||||
|
|
||||||
if not callable(function):
|
|
||||||
raise ValueError(f"Resolved attribute '{function_name}' is not callable.")
|
|
||||||
|
|
||||||
return function
|
|
|
@ -0,0 +1,217 @@
|
||||||
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
|
"""menu_action.py"""
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from prompt_toolkit import PromptSession
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.table import Table
|
||||||
|
from rich.tree import Tree
|
||||||
|
|
||||||
|
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.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
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MenuOption:
|
||||||
|
description: str
|
||||||
|
action: BaseAction
|
||||||
|
color: str = OneColors.WHITE
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if not isinstance(self.description, str):
|
||||||
|
raise TypeError("MenuOption description must be a string.")
|
||||||
|
if not isinstance(self.action, BaseAction):
|
||||||
|
raise TypeError("MenuOption action must be a BaseAction instance.")
|
||||||
|
|
||||||
|
def render(self, key: str) -> str:
|
||||||
|
"""Render the menu option for display."""
|
||||||
|
return f"[{OneColors.WHITE}][{key}][/] [{self.color}]{self.description}[/]"
|
||||||
|
|
||||||
|
|
||||||
|
class MenuOptionMap(CaseInsensitiveDict):
|
||||||
|
"""
|
||||||
|
Manages menu options including validation, reserved key protection,
|
||||||
|
and special signal entries like Quit and Back.
|
||||||
|
"""
|
||||||
|
|
||||||
|
RESERVED_KEYS = {"Q", "B"}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, options: dict[str, MenuOption] | None = None, allow_reserved: bool = False
|
||||||
|
):
|
||||||
|
super().__init__()
|
||||||
|
self.allow_reserved = allow_reserved
|
||||||
|
if options:
|
||||||
|
self.update(options)
|
||||||
|
self._inject_reserved_defaults()
|
||||||
|
|
||||||
|
def _inject_reserved_defaults(self):
|
||||||
|
self._add_reserved(
|
||||||
|
"Q",
|
||||||
|
MenuOption("Exit", SignalAction("Quit", QuitSignal()), OneColors.DARK_RED),
|
||||||
|
)
|
||||||
|
self._add_reserved(
|
||||||
|
"B",
|
||||||
|
MenuOption("Back", SignalAction("Back", BackSignal()), OneColors.DARK_YELLOW),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _add_reserved(self, key: str, option: MenuOption) -> None:
|
||||||
|
"""Add a reserved key, bypassing validation."""
|
||||||
|
norm_key = key.upper()
|
||||||
|
super().__setitem__(norm_key, option)
|
||||||
|
|
||||||
|
def __setitem__(self, key: str, option: MenuOption) -> None:
|
||||||
|
if not isinstance(option, MenuOption):
|
||||||
|
raise TypeError(f"Value for key '{key}' must be a MenuOption.")
|
||||||
|
norm_key = key.upper()
|
||||||
|
if norm_key in self.RESERVED_KEYS and not self.allow_reserved:
|
||||||
|
raise ValueError(
|
||||||
|
f"Key '{key}' is reserved and cannot be used in MenuOptionMap."
|
||||||
|
)
|
||||||
|
super().__setitem__(norm_key, option)
|
||||||
|
|
||||||
|
def __delitem__(self, key: str) -> None:
|
||||||
|
if key.upper() in self.RESERVED_KEYS and not self.allow_reserved:
|
||||||
|
raise ValueError(f"Cannot delete reserved option '{key}'.")
|
||||||
|
super().__delitem__(key)
|
||||||
|
|
||||||
|
def items(self, include_reserved: bool = True):
|
||||||
|
for k, v in super().items():
|
||||||
|
if not include_reserved and k in self.RESERVED_KEYS:
|
||||||
|
continue
|
||||||
|
yield k, v
|
||||||
|
|
||||||
|
|
||||||
|
class MenuAction(BaseAction):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
menu_options: MenuOptionMap,
|
||||||
|
*,
|
||||||
|
title: str = "Select an option",
|
||||||
|
columns: int = 2,
|
||||||
|
prompt_message: str = "Select > ",
|
||||||
|
default_selection: str = "",
|
||||||
|
inject_last_result: bool = False,
|
||||||
|
inject_last_result_as: str = "last_result",
|
||||||
|
console: Console | None = None,
|
||||||
|
prompt_session: PromptSession | None = None,
|
||||||
|
never_prompt: bool = False,
|
||||||
|
include_reserved: bool = True,
|
||||||
|
show_table: bool = True,
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
name,
|
||||||
|
inject_last_result=inject_last_result,
|
||||||
|
inject_last_result_as=inject_last_result_as,
|
||||||
|
never_prompt=never_prompt,
|
||||||
|
)
|
||||||
|
self.menu_options = menu_options
|
||||||
|
self.title = title
|
||||||
|
self.columns = columns
|
||||||
|
self.prompt_message = prompt_message
|
||||||
|
self.default_selection = default_selection
|
||||||
|
self.console = console or Console(color_system="auto")
|
||||||
|
self.prompt_session = prompt_session or PromptSession()
|
||||||
|
self.include_reserved = include_reserved
|
||||||
|
self.show_table = show_table
|
||||||
|
|
||||||
|
def _build_table(self) -> Table:
|
||||||
|
table = render_table_base(
|
||||||
|
title=self.title,
|
||||||
|
columns=self.columns,
|
||||||
|
)
|
||||||
|
for chunk in chunks(
|
||||||
|
self.menu_options.items(include_reserved=self.include_reserved), self.columns
|
||||||
|
):
|
||||||
|
row = []
|
||||||
|
for key, option in chunk:
|
||||||
|
row.append(option.render(key))
|
||||||
|
table.add_row(*row)
|
||||||
|
return table
|
||||||
|
|
||||||
|
async def _run(self, *args, **kwargs) -> Any:
|
||||||
|
kwargs = self._maybe_inject_last_result(kwargs)
|
||||||
|
context = ExecutionContext(
|
||||||
|
name=self.name,
|
||||||
|
args=args,
|
||||||
|
kwargs=kwargs,
|
||||||
|
action=self,
|
||||||
|
)
|
||||||
|
|
||||||
|
effective_default = self.default_selection
|
||||||
|
maybe_result = str(self.last_result)
|
||||||
|
if maybe_result in self.menu_options:
|
||||||
|
effective_default = maybe_result
|
||||||
|
elif self.inject_last_result:
|
||||||
|
logger.warning(
|
||||||
|
"[%s] Injected last result '%s' not found in menu options",
|
||||||
|
self.name,
|
||||||
|
maybe_result,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.never_prompt and not effective_default:
|
||||||
|
raise ValueError(
|
||||||
|
f"[{self.name}] 'never_prompt' is True but no valid default_selection was provided."
|
||||||
|
)
|
||||||
|
|
||||||
|
context.start_timer()
|
||||||
|
try:
|
||||||
|
await self.hooks.trigger(HookType.BEFORE, context)
|
||||||
|
key = effective_default
|
||||||
|
if not self.never_prompt:
|
||||||
|
console = self.console
|
||||||
|
session = self.prompt_session
|
||||||
|
table = self._build_table()
|
||||||
|
key = await prompt_for_selection(
|
||||||
|
self.menu_options.keys(),
|
||||||
|
table,
|
||||||
|
default_selection=self.default_selection,
|
||||||
|
console=console,
|
||||||
|
session=session,
|
||||||
|
prompt_message=self.prompt_message,
|
||||||
|
show_table=self.show_table,
|
||||||
|
)
|
||||||
|
option = self.menu_options[key]
|
||||||
|
result = await option.action(*args, **kwargs)
|
||||||
|
context.result = result
|
||||||
|
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except BackSignal:
|
||||||
|
logger.debug("[%s][BackSignal] ← Returning to previous menu", self.name)
|
||||||
|
return None
|
||||||
|
except QuitSignal:
|
||||||
|
logger.debug("[%s][QuitSignal] ← Exiting application", self.name)
|
||||||
|
raise
|
||||||
|
except Exception as error:
|
||||||
|
context.exception = error
|
||||||
|
await self.hooks.trigger(HookType.ON_ERROR, context)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
context.stop_timer()
|
||||||
|
await self.hooks.trigger(HookType.AFTER, context)
|
||||||
|
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
||||||
|
er.record(context)
|
||||||
|
|
||||||
|
async def preview(self, parent: Tree | None = None):
|
||||||
|
label = f"[{OneColors.DARK_YELLOW_b}]📋 MenuAction[/] '{self.name}'"
|
||||||
|
tree = parent.add(label) if parent else Tree(label)
|
||||||
|
for key, option in self.menu_options.items():
|
||||||
|
tree.add(
|
||||||
|
f"[dim]{key}[/]: {option.description} → [italic]{option.action.name}[/]"
|
||||||
|
)
|
||||||
|
await option.action.preview(parent=tree)
|
||||||
|
if not parent:
|
||||||
|
self.console.print(tree)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"MenuAction(name={self.name}, options={list(self.menu_options.keys())})"
|
|
@ -9,8 +9,8 @@ from falyx.utils import logger
|
||||||
|
|
||||||
|
|
||||||
class OptionsManager:
|
class OptionsManager:
|
||||||
def __init__(self, namespaces: list[tuple[str, Namespace]] = None) -> None:
|
def __init__(self, namespaces: list[tuple[str, Namespace]] | None = None) -> None:
|
||||||
self.options = defaultdict(lambda: Namespace())
|
self.options: defaultdict = defaultdict(lambda: Namespace())
|
||||||
if namespaces:
|
if namespaces:
|
||||||
for namespace_name, namespace in namespaces:
|
for namespace_name, namespace in namespaces:
|
||||||
self.from_namespace(namespace, namespace_name)
|
self.from_namespace(namespace, namespace_name)
|
||||||
|
|
|
@ -37,7 +37,6 @@ def get_arg_parsers(
|
||||||
description: str | None = "Falyx CLI - Run structured async command workflows.",
|
description: str | None = "Falyx CLI - Run structured async command workflows.",
|
||||||
epilog: str | None = None,
|
epilog: str | None = None,
|
||||||
parents: Sequence[ArgumentParser] = [],
|
parents: Sequence[ArgumentParser] = [],
|
||||||
formatter_class: HelpFormatter = HelpFormatter,
|
|
||||||
prefix_chars: str = "-",
|
prefix_chars: str = "-",
|
||||||
fromfile_prefix_chars: str | None = None,
|
fromfile_prefix_chars: str | None = None,
|
||||||
argument_default: Any = None,
|
argument_default: Any = None,
|
||||||
|
@ -53,7 +52,6 @@ def get_arg_parsers(
|
||||||
description=description,
|
description=description,
|
||||||
epilog=epilog,
|
epilog=epilog,
|
||||||
parents=parents,
|
parents=parents,
|
||||||
formatter_class=formatter_class,
|
|
||||||
prefix_chars=prefix_chars,
|
prefix_chars=prefix_chars,
|
||||||
fromfile_prefix_chars=fromfile_prefix_chars,
|
fromfile_prefix_chars=fromfile_prefix_chars,
|
||||||
argument_default=argument_default,
|
argument_default=argument_default,
|
||||||
|
@ -62,6 +60,11 @@ def get_arg_parsers(
|
||||||
allow_abbrev=allow_abbrev,
|
allow_abbrev=allow_abbrev,
|
||||||
exit_on_error=exit_on_error,
|
exit_on_error=exit_on_error,
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--never-prompt",
|
||||||
|
action="store_true",
|
||||||
|
help="Run in non-interactive mode with all prompts bypassed.",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-v", "--verbose", action="store_true", help="Enable debug logging for Falyx."
|
"-v", "--verbose", action="store_true", help="Enable debug logging for Falyx."
|
||||||
)
|
)
|
||||||
|
|
|
@ -43,7 +43,7 @@ class RetryHandler:
|
||||||
delay: float = 1.0,
|
delay: float = 1.0,
|
||||||
backoff: float = 2.0,
|
backoff: float = 2.0,
|
||||||
jitter: float = 0.0,
|
jitter: float = 0.0,
|
||||||
):
|
) -> None:
|
||||||
self.policy.enabled = True
|
self.policy.enabled = True
|
||||||
self.policy.max_retries = max_retries
|
self.policy.max_retries = max_retries
|
||||||
self.policy.delay = delay
|
self.policy.delay = delay
|
||||||
|
@ -51,7 +51,7 @@ class RetryHandler:
|
||||||
self.policy.jitter = jitter
|
self.policy.jitter = jitter
|
||||||
logger.info(f"🔄 Retry policy enabled: {self.policy}")
|
logger.info(f"🔄 Retry policy enabled: {self.policy}")
|
||||||
|
|
||||||
async def retry_on_error(self, context: ExecutionContext):
|
async def retry_on_error(self, context: ExecutionContext) -> None:
|
||||||
from falyx.action import Action
|
from falyx.action import Action
|
||||||
|
|
||||||
name = context.name
|
name = context.name
|
||||||
|
@ -64,21 +64,21 @@ class RetryHandler:
|
||||||
|
|
||||||
if not target:
|
if not target:
|
||||||
logger.warning(f"[{name}] ⚠️ No action target. Cannot retry.")
|
logger.warning(f"[{name}] ⚠️ No action target. Cannot retry.")
|
||||||
return
|
return None
|
||||||
|
|
||||||
if not isinstance(target, Action):
|
if not isinstance(target, Action):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[{name}] ❌ RetryHandler only supports only supports Action objects."
|
f"[{name}] ❌ RetryHandler only supports only supports Action objects."
|
||||||
)
|
)
|
||||||
return
|
return None
|
||||||
|
|
||||||
if not getattr(target, "is_retryable", False):
|
if not getattr(target, "is_retryable", False):
|
||||||
logger.warning(f"[{name}] ❌ Not retryable.")
|
logger.warning(f"[{name}] ❌ Not retryable.")
|
||||||
return
|
return None
|
||||||
|
|
||||||
if not self.policy.enabled:
|
if not self.policy.enabled:
|
||||||
logger.warning(f"[{name}] ❌ Retry policy is disabled.")
|
logger.warning(f"[{name}] ❌ Retry policy is disabled.")
|
||||||
return
|
return None
|
||||||
|
|
||||||
while retries_done < self.policy.max_retries:
|
while retries_done < self.policy.max_retries:
|
||||||
retries_done += 1
|
retries_done += 1
|
||||||
|
@ -97,7 +97,7 @@ class RetryHandler:
|
||||||
context.result = result
|
context.result = result
|
||||||
context.exception = None
|
context.exception = None
|
||||||
logger.info(f"[{name}] ✅ Retry succeeded on attempt {retries_done}.")
|
logger.info(f"[{name}] ✅ Retry succeeded on attempt {retries_done}.")
|
||||||
return
|
return None
|
||||||
except Exception as retry_error:
|
except Exception as retry_error:
|
||||||
last_error = retry_error
|
last_error = retry_error
|
||||||
current_delay *= self.policy.backoff
|
current_delay *= self.policy.backoff
|
||||||
|
@ -108,4 +108,3 @@ class RetryHandler:
|
||||||
|
|
||||||
context.exception = last_error
|
context.exception = last_error
|
||||||
logger.error(f"[{name}] ❌ All {self.policy.max_retries} retries failed.")
|
logger.error(f"[{name}] ❌ All {self.policy.max_retries} retries failed.")
|
||||||
return
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
from falyx.action import Action, BaseAction
|
from falyx.action import Action, BaseAction
|
||||||
from falyx.hook_manager import HookType
|
from falyx.hook_manager import HookType
|
||||||
from falyx.retry import RetryHandler, RetryPolicy
|
from falyx.retry import RetryHandler, RetryPolicy
|
||||||
|
|
|
@ -0,0 +1,354 @@
|
||||||
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
|
"""selection.py"""
|
||||||
|
from typing import Any, Callable, KeysView, Sequence
|
||||||
|
|
||||||
|
from prompt_toolkit import PromptSession
|
||||||
|
from rich import box
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.markup import escape
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
|
from falyx.themes.colors import OneColors
|
||||||
|
from falyx.utils import chunks
|
||||||
|
from falyx.validators import int_range_validator, key_validator
|
||||||
|
|
||||||
|
|
||||||
|
def render_table_base(
|
||||||
|
title: str,
|
||||||
|
caption: str = "",
|
||||||
|
columns: int = 4,
|
||||||
|
box_style: box.Box = box.SIMPLE,
|
||||||
|
show_lines: bool = False,
|
||||||
|
show_header: bool = False,
|
||||||
|
show_footer: bool = False,
|
||||||
|
style: str = "",
|
||||||
|
header_style: str = "",
|
||||||
|
footer_style: str = "",
|
||||||
|
title_style: str = "",
|
||||||
|
caption_style: str = "",
|
||||||
|
highlight: bool = True,
|
||||||
|
column_names: Sequence[str] | None = None,
|
||||||
|
) -> Table:
|
||||||
|
table = Table(
|
||||||
|
title=title,
|
||||||
|
caption=caption,
|
||||||
|
box=box_style,
|
||||||
|
show_lines=show_lines,
|
||||||
|
show_header=show_header,
|
||||||
|
show_footer=show_footer,
|
||||||
|
style=style,
|
||||||
|
header_style=header_style,
|
||||||
|
footer_style=footer_style,
|
||||||
|
title_style=title_style,
|
||||||
|
caption_style=caption_style,
|
||||||
|
highlight=highlight,
|
||||||
|
)
|
||||||
|
if column_names:
|
||||||
|
for column_name in column_names:
|
||||||
|
table.add_column(column_name)
|
||||||
|
else:
|
||||||
|
for _ in range(columns):
|
||||||
|
table.add_column()
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
def render_selection_grid(
|
||||||
|
title: str,
|
||||||
|
selections: Sequence[str],
|
||||||
|
columns: int = 4,
|
||||||
|
caption: str = "",
|
||||||
|
box_style: box.Box = box.SIMPLE,
|
||||||
|
show_lines: bool = False,
|
||||||
|
show_header: bool = False,
|
||||||
|
show_footer: bool = False,
|
||||||
|
style: str = "",
|
||||||
|
header_style: str = "",
|
||||||
|
footer_style: str = "",
|
||||||
|
title_style: str = "",
|
||||||
|
caption_style: str = "",
|
||||||
|
highlight: bool = False,
|
||||||
|
) -> Table:
|
||||||
|
"""Create a selection table with the given parameters."""
|
||||||
|
table = render_table_base(
|
||||||
|
title,
|
||||||
|
caption,
|
||||||
|
columns,
|
||||||
|
box_style,
|
||||||
|
show_lines,
|
||||||
|
show_header,
|
||||||
|
show_footer,
|
||||||
|
style,
|
||||||
|
header_style,
|
||||||
|
footer_style,
|
||||||
|
title_style,
|
||||||
|
caption_style,
|
||||||
|
highlight,
|
||||||
|
)
|
||||||
|
|
||||||
|
for chunk in chunks(selections, columns):
|
||||||
|
table.add_row(*chunk)
|
||||||
|
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
def render_selection_indexed_table(
|
||||||
|
title: str,
|
||||||
|
selections: Sequence[str],
|
||||||
|
columns: int = 4,
|
||||||
|
caption: str = "",
|
||||||
|
box_style: box.Box = box.SIMPLE,
|
||||||
|
show_lines: bool = False,
|
||||||
|
show_header: bool = False,
|
||||||
|
show_footer: bool = False,
|
||||||
|
style: str = "",
|
||||||
|
header_style: str = "",
|
||||||
|
footer_style: str = "",
|
||||||
|
title_style: str = "",
|
||||||
|
caption_style: str = "",
|
||||||
|
highlight: bool = False,
|
||||||
|
formatter: Callable[[int, str], str] | None = None,
|
||||||
|
) -> Table:
|
||||||
|
"""Create a selection table with the given parameters."""
|
||||||
|
table = render_table_base(
|
||||||
|
title,
|
||||||
|
caption,
|
||||||
|
columns,
|
||||||
|
box_style,
|
||||||
|
show_lines,
|
||||||
|
show_header,
|
||||||
|
show_footer,
|
||||||
|
style,
|
||||||
|
header_style,
|
||||||
|
footer_style,
|
||||||
|
title_style,
|
||||||
|
caption_style,
|
||||||
|
highlight,
|
||||||
|
)
|
||||||
|
|
||||||
|
for indexes, chunk in zip(
|
||||||
|
chunks(range(len(selections)), columns), chunks(selections, columns)
|
||||||
|
):
|
||||||
|
row = [
|
||||||
|
formatter(index, selection) if formatter else f"{index}: {selection}"
|
||||||
|
for index, selection in zip(indexes, chunk)
|
||||||
|
]
|
||||||
|
table.add_row(*row)
|
||||||
|
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
def render_selection_dict_table(
|
||||||
|
title: str,
|
||||||
|
selections: dict[str, tuple[str, Any]],
|
||||||
|
columns: int = 2,
|
||||||
|
caption: str = "",
|
||||||
|
box_style: box.Box = box.SIMPLE,
|
||||||
|
show_lines: bool = False,
|
||||||
|
show_header: bool = False,
|
||||||
|
show_footer: bool = False,
|
||||||
|
style: str = "",
|
||||||
|
header_style: str = "",
|
||||||
|
footer_style: str = "",
|
||||||
|
title_style: str = "",
|
||||||
|
caption_style: str = "",
|
||||||
|
highlight: bool = False,
|
||||||
|
) -> Table:
|
||||||
|
"""Create a selection table with the given parameters."""
|
||||||
|
table = render_table_base(
|
||||||
|
title,
|
||||||
|
caption,
|
||||||
|
columns,
|
||||||
|
box_style,
|
||||||
|
show_lines,
|
||||||
|
show_header,
|
||||||
|
show_footer,
|
||||||
|
style,
|
||||||
|
header_style,
|
||||||
|
footer_style,
|
||||||
|
title_style,
|
||||||
|
caption_style,
|
||||||
|
highlight,
|
||||||
|
)
|
||||||
|
|
||||||
|
for chunk in chunks(selections.items(), columns):
|
||||||
|
row = []
|
||||||
|
for key, value in chunk:
|
||||||
|
row.append(f"[{OneColors.WHITE}][{key.upper()}] {value[0]}")
|
||||||
|
table.add_row(*row)
|
||||||
|
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
async def prompt_for_index(
|
||||||
|
max_index: int,
|
||||||
|
table: Table,
|
||||||
|
min_index: int = 0,
|
||||||
|
default_selection: str = "",
|
||||||
|
console: Console | None = None,
|
||||||
|
session: PromptSession | None = None,
|
||||||
|
prompt_message: str = "Select an option > ",
|
||||||
|
show_table: bool = True,
|
||||||
|
):
|
||||||
|
session = session or PromptSession()
|
||||||
|
console = console or Console(color_system="auto")
|
||||||
|
|
||||||
|
if show_table:
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
selection = await session.prompt_async(
|
||||||
|
message=prompt_message,
|
||||||
|
validator=int_range_validator(min_index, max_index),
|
||||||
|
default=default_selection,
|
||||||
|
)
|
||||||
|
return int(selection)
|
||||||
|
|
||||||
|
|
||||||
|
async def prompt_for_selection(
|
||||||
|
keys: Sequence[str] | KeysView[str],
|
||||||
|
table: Table,
|
||||||
|
default_selection: str = "",
|
||||||
|
console: Console | None = None,
|
||||||
|
session: PromptSession | None = None,
|
||||||
|
prompt_message: str = "Select an option > ",
|
||||||
|
show_table: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""Prompt the user to select a key from a set of options. Return the selected key."""
|
||||||
|
session = session or PromptSession()
|
||||||
|
console = console or Console(color_system="auto")
|
||||||
|
|
||||||
|
if show_table:
|
||||||
|
console.print(table, justify="center")
|
||||||
|
|
||||||
|
selected = await session.prompt_async(
|
||||||
|
message=prompt_message,
|
||||||
|
validator=key_validator(keys),
|
||||||
|
default=default_selection,
|
||||||
|
)
|
||||||
|
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
async def select_value_from_list(
|
||||||
|
title: str,
|
||||||
|
selections: Sequence[str],
|
||||||
|
console: Console | None = None,
|
||||||
|
session: PromptSession | None = None,
|
||||||
|
prompt_message: str = "Select an option > ",
|
||||||
|
default_selection: str = "",
|
||||||
|
columns: int = 4,
|
||||||
|
caption: str = "",
|
||||||
|
box_style: box.Box = box.SIMPLE,
|
||||||
|
show_lines: bool = False,
|
||||||
|
show_header: bool = False,
|
||||||
|
show_footer: bool = False,
|
||||||
|
style: str = "",
|
||||||
|
header_style: str = "",
|
||||||
|
footer_style: str = "",
|
||||||
|
title_style: str = "",
|
||||||
|
caption_style: str = "",
|
||||||
|
highlight: bool = False,
|
||||||
|
):
|
||||||
|
"""Prompt for a selection. Return the selected item."""
|
||||||
|
table = render_selection_indexed_table(
|
||||||
|
title,
|
||||||
|
selections,
|
||||||
|
columns,
|
||||||
|
caption,
|
||||||
|
box_style,
|
||||||
|
show_lines,
|
||||||
|
show_header,
|
||||||
|
show_footer,
|
||||||
|
style,
|
||||||
|
header_style,
|
||||||
|
footer_style,
|
||||||
|
title_style,
|
||||||
|
caption_style,
|
||||||
|
highlight,
|
||||||
|
)
|
||||||
|
session = session or PromptSession()
|
||||||
|
console = console or Console(color_system="auto")
|
||||||
|
|
||||||
|
selection_index = await prompt_for_index(
|
||||||
|
len(selections) - 1,
|
||||||
|
table,
|
||||||
|
default_selection=default_selection,
|
||||||
|
console=console,
|
||||||
|
session=session,
|
||||||
|
prompt_message=prompt_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
return selections[selection_index]
|
||||||
|
|
||||||
|
|
||||||
|
async def select_key_from_dict(
|
||||||
|
selections: dict[str, tuple[str, Any]],
|
||||||
|
table: Table,
|
||||||
|
console: Console | None = None,
|
||||||
|
session: PromptSession | None = None,
|
||||||
|
prompt_message: str = "Select an option > ",
|
||||||
|
default_selection: str = "",
|
||||||
|
) -> Any:
|
||||||
|
"""Prompt for a key from a dict, returns the key."""
|
||||||
|
session = session or PromptSession()
|
||||||
|
console = console or Console(color_system="auto")
|
||||||
|
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
return await prompt_for_selection(
|
||||||
|
selections.keys(),
|
||||||
|
table,
|
||||||
|
default_selection=default_selection,
|
||||||
|
console=console,
|
||||||
|
session=session,
|
||||||
|
prompt_message=prompt_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def select_value_from_dict(
|
||||||
|
selections: dict[str, tuple[str, Any]],
|
||||||
|
table: Table,
|
||||||
|
console: Console | None = None,
|
||||||
|
session: PromptSession | None = None,
|
||||||
|
prompt_message: str = "Select an option > ",
|
||||||
|
default_selection: str = "",
|
||||||
|
) -> Any:
|
||||||
|
"""Prompt for a key from a dict, but return the value."""
|
||||||
|
session = session or PromptSession()
|
||||||
|
console = console or Console(color_system="auto")
|
||||||
|
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
selection_key = await prompt_for_selection(
|
||||||
|
selections.keys(),
|
||||||
|
table,
|
||||||
|
default_selection=default_selection,
|
||||||
|
console=console,
|
||||||
|
session=session,
|
||||||
|
prompt_message=prompt_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
return selections[selection_key][1]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_selection_from_dict_menu(
|
||||||
|
title: str,
|
||||||
|
selections: dict[str, tuple[str, Any]],
|
||||||
|
console: Console | None = None,
|
||||||
|
session: PromptSession | None = None,
|
||||||
|
prompt_message: str = "Select an option > ",
|
||||||
|
default_selection: str = "",
|
||||||
|
):
|
||||||
|
"""Prompt for a key from a dict, but return the value."""
|
||||||
|
table = render_selection_dict_table(
|
||||||
|
title,
|
||||||
|
selections,
|
||||||
|
)
|
||||||
|
|
||||||
|
return await select_value_from_dict(
|
||||||
|
selections,
|
||||||
|
table,
|
||||||
|
console,
|
||||||
|
session,
|
||||||
|
prompt_message,
|
||||||
|
default_selection,
|
||||||
|
)
|
|
@ -0,0 +1,169 @@
|
||||||
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
|
"""selection_action.py"""
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from prompt_toolkit import PromptSession
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.tree import Tree
|
||||||
|
|
||||||
|
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.selection import (
|
||||||
|
prompt_for_index,
|
||||||
|
prompt_for_selection,
|
||||||
|
render_selection_dict_table,
|
||||||
|
render_selection_indexed_table,
|
||||||
|
)
|
||||||
|
from falyx.themes.colors import OneColors
|
||||||
|
from falyx.utils import logger
|
||||||
|
|
||||||
|
|
||||||
|
class SelectionAction(BaseAction):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
selections: list[str] | dict[str, tuple[str, Any]],
|
||||||
|
*,
|
||||||
|
title: str = "Select an option",
|
||||||
|
columns: int = 2,
|
||||||
|
prompt_message: str = "Select > ",
|
||||||
|
default_selection: str = "",
|
||||||
|
inject_last_result: bool = False,
|
||||||
|
inject_last_result_as: str = "last_result",
|
||||||
|
return_key: bool = False,
|
||||||
|
console: Console | None = None,
|
||||||
|
session: PromptSession | None = None,
|
||||||
|
never_prompt: bool = False,
|
||||||
|
show_table: bool = True,
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
name,
|
||||||
|
inject_last_result=inject_last_result,
|
||||||
|
inject_last_result_as=inject_last_result_as,
|
||||||
|
never_prompt=never_prompt,
|
||||||
|
)
|
||||||
|
self.selections = selections
|
||||||
|
self.return_key = return_key
|
||||||
|
self.title = title
|
||||||
|
self.columns = columns
|
||||||
|
self.console = console or Console(color_system="auto")
|
||||||
|
self.session = session or PromptSession()
|
||||||
|
self.default_selection = default_selection
|
||||||
|
self.prompt_message = prompt_message
|
||||||
|
self.show_table = show_table
|
||||||
|
|
||||||
|
async def _run(self, *args, **kwargs) -> Any:
|
||||||
|
kwargs = self._maybe_inject_last_result(kwargs)
|
||||||
|
context = ExecutionContext(
|
||||||
|
name=self.name,
|
||||||
|
args=args,
|
||||||
|
kwargs=kwargs,
|
||||||
|
action=self,
|
||||||
|
)
|
||||||
|
|
||||||
|
effective_default = str(self.default_selection)
|
||||||
|
maybe_result = str(self.last_result)
|
||||||
|
if isinstance(self.selections, dict):
|
||||||
|
if maybe_result in self.selections:
|
||||||
|
effective_default = maybe_result
|
||||||
|
elif isinstance(self.selections, list):
|
||||||
|
if maybe_result.isdigit() and int(maybe_result) in range(
|
||||||
|
len(self.selections)
|
||||||
|
):
|
||||||
|
effective_default = maybe_result
|
||||||
|
elif self.inject_last_result:
|
||||||
|
logger.warning(
|
||||||
|
"[%s] Injected last result '%s' not found in selections",
|
||||||
|
self.name,
|
||||||
|
maybe_result,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.never_prompt and not effective_default:
|
||||||
|
raise ValueError(
|
||||||
|
f"[{self.name}] 'never_prompt' is True but no valid default_selection was provided."
|
||||||
|
)
|
||||||
|
|
||||||
|
context.start_timer()
|
||||||
|
try:
|
||||||
|
await self.hooks.trigger(HookType.BEFORE, context)
|
||||||
|
if isinstance(self.selections, list):
|
||||||
|
table = render_selection_indexed_table(
|
||||||
|
self.title, self.selections, self.columns
|
||||||
|
)
|
||||||
|
if not self.never_prompt:
|
||||||
|
index = await prompt_for_index(
|
||||||
|
len(self.selections) - 1,
|
||||||
|
table,
|
||||||
|
default_selection=effective_default,
|
||||||
|
console=self.console,
|
||||||
|
session=self.session,
|
||||||
|
prompt_message=self.prompt_message,
|
||||||
|
show_table=self.show_table,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
index = effective_default
|
||||||
|
result = self.selections[int(index)]
|
||||||
|
elif isinstance(self.selections, dict):
|
||||||
|
table = render_selection_dict_table(
|
||||||
|
self.title, self.selections, self.columns
|
||||||
|
)
|
||||||
|
if not self.never_prompt:
|
||||||
|
key = await prompt_for_selection(
|
||||||
|
self.selections.keys(),
|
||||||
|
table,
|
||||||
|
default_selection=effective_default,
|
||||||
|
console=self.console,
|
||||||
|
session=self.session,
|
||||||
|
prompt_message=self.prompt_message,
|
||||||
|
show_table=self.show_table,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
key = effective_default
|
||||||
|
result = key if self.return_key else self.selections[key][1]
|
||||||
|
else:
|
||||||
|
raise TypeError(
|
||||||
|
f"'selections' must be a list[str] or dict[str, tuple[str, Any]], got {type(self.selections).__name__}"
|
||||||
|
)
|
||||||
|
context.result = result
|
||||||
|
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||||
|
return result
|
||||||
|
except Exception as error:
|
||||||
|
context.exception = error
|
||||||
|
await self.hooks.trigger(HookType.ON_ERROR, context)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
context.stop_timer()
|
||||||
|
await self.hooks.trigger(HookType.AFTER, context)
|
||||||
|
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
||||||
|
er.record(context)
|
||||||
|
|
||||||
|
async def preview(self, parent: Tree | None = None):
|
||||||
|
label = f"[{OneColors.LIGHT_RED}]🧭 SelectionAction[/] '{self.name}'"
|
||||||
|
tree = parent.add(label) if parent else Tree(label)
|
||||||
|
|
||||||
|
if isinstance(self.selections, list):
|
||||||
|
sub = tree.add(f"[dim]Type:[/] List[str] ({len(self.selections)} items)")
|
||||||
|
for i, item in enumerate(self.selections[:10]): # limit to 10
|
||||||
|
sub.add(f"[dim]{i}[/]: {item}")
|
||||||
|
if len(self.selections) > 10:
|
||||||
|
sub.add(f"[dim]... ({len(self.selections) - 10} more)[/]")
|
||||||
|
elif isinstance(self.selections, dict):
|
||||||
|
sub = tree.add(
|
||||||
|
f"[dim]Type:[/] Dict[str, (str, Any)] ({len(self.selections)} items)"
|
||||||
|
)
|
||||||
|
for i, (key, (label, _)) in enumerate(list(self.selections.items())[:10]):
|
||||||
|
sub.add(f"[dim]{key}[/]: {label}")
|
||||||
|
if len(self.selections) > 10:
|
||||||
|
sub.add(f"[dim]... ({len(self.selections) - 10} more)[/]")
|
||||||
|
else:
|
||||||
|
tree.add("[bold red]Invalid selections type[/]")
|
||||||
|
return
|
||||||
|
|
||||||
|
tree.add(f"[dim]Default:[/] '{self.default_selection or self.last_result}'")
|
||||||
|
tree.add(f"[dim]Return:[/] {'Key' if self.return_key else 'Value'}")
|
||||||
|
tree.add(f"[dim]Prompt:[/] {'Disabled' if self.never_prompt else 'Enabled'}")
|
||||||
|
|
||||||
|
if not parent:
|
||||||
|
self.console.print(tree)
|
|
@ -0,0 +1,29 @@
|
||||||
|
from falyx.action import Action
|
||||||
|
from falyx.signals import FlowSignal
|
||||||
|
|
||||||
|
|
||||||
|
class SignalAction(Action):
|
||||||
|
"""
|
||||||
|
An action that raises a control flow signal when executed.
|
||||||
|
|
||||||
|
Useful for exiting a menu, going back, or halting execution gracefully.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, name: str, signal: Exception):
|
||||||
|
if not isinstance(signal, FlowSignal):
|
||||||
|
raise TypeError(
|
||||||
|
f"Signal must be an FlowSignal instance, got {type(signal).__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def raise_signal(*args, **kwargs):
|
||||||
|
raise signal
|
||||||
|
|
||||||
|
super().__init__(name=name, action=raise_signal)
|
||||||
|
self._signal = signal
|
||||||
|
|
||||||
|
@property
|
||||||
|
def signal(self):
|
||||||
|
return self._signal
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"SignalAction(name={self.name}, signal={self._signal.__class__.__name__})"
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
|
class FlowSignal(BaseException):
|
||||||
|
"""Base class for all flow control signals in Falyx.
|
||||||
|
|
||||||
|
These are not errors. They're used to control flow like quitting,
|
||||||
|
going back, or restarting from user input or nested menus.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class QuitSignal(FlowSignal):
|
||||||
|
"""Raised to signal an immediate exit from the CLI framework."""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "Quit signal received."):
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
class BackSignal(FlowSignal):
|
||||||
|
"""Raised to return control to the previous menu or caller."""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "Back signal received."):
|
||||||
|
super().__init__(message)
|
|
@ -1,3 +1,4 @@
|
||||||
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
from rich import box
|
from rich import box
|
||||||
|
|
|
@ -69,7 +69,7 @@ def chunks(iterator, size):
|
||||||
yield chunk
|
yield chunk
|
||||||
|
|
||||||
|
|
||||||
async def async_confirm(message: AnyFormattedText = "Are you sure?") -> bool:
|
async def confirm_async(message: AnyFormattedText = "Are you sure?") -> bool:
|
||||||
session: PromptSession = PromptSession()
|
session: PromptSession = PromptSession()
|
||||||
while True:
|
while True:
|
||||||
merged_message: AnyFormattedText = merge_formatted_text(
|
merged_message: AnyFormattedText = merge_formatted_text(
|
||||||
|
@ -86,26 +86,36 @@ async def async_confirm(message: AnyFormattedText = "Are you sure?") -> bool:
|
||||||
class CaseInsensitiveDict(dict):
|
class CaseInsensitiveDict(dict):
|
||||||
"""A case-insensitive dictionary that treats all keys as uppercase."""
|
"""A case-insensitive dictionary that treats all keys as uppercase."""
|
||||||
|
|
||||||
|
def _normalize_key(self, key):
|
||||||
|
return key.upper() if isinstance(key, str) else key
|
||||||
|
|
||||||
def __setitem__(self, key, value):
|
def __setitem__(self, key, value):
|
||||||
super().__setitem__(key.upper(), value)
|
super().__setitem__(self._normalize_key(key), value)
|
||||||
|
|
||||||
def __getitem__(self, key):
|
def __getitem__(self, key):
|
||||||
return super().__getitem__(key.upper())
|
return super().__getitem__(self._normalize_key(key))
|
||||||
|
|
||||||
def __contains__(self, key):
|
def __contains__(self, key):
|
||||||
return super().__contains__(key.upper())
|
return super().__contains__(self._normalize_key(key))
|
||||||
|
|
||||||
def get(self, key, default=None):
|
def get(self, key, default=None):
|
||||||
return super().get(key.upper(), default)
|
return super().get(self._normalize_key(key), default)
|
||||||
|
|
||||||
def pop(self, key, default=None):
|
def pop(self, key, default=None):
|
||||||
return super().pop(key.upper(), default)
|
return super().pop(self._normalize_key(key), default)
|
||||||
|
|
||||||
def update(self, other=None, **kwargs):
|
def update(self, other=None, **kwargs):
|
||||||
|
items = {}
|
||||||
if other:
|
if other:
|
||||||
other = {k.upper(): v for k, v in other.items()}
|
items.update({self._normalize_key(k): v for k, v in other.items()})
|
||||||
kwargs = {k.upper(): v for k, v in kwargs.items()}
|
items.update({self._normalize_key(k): v for k, v in kwargs.items()})
|
||||||
super().update(other, **kwargs)
|
super().update(items)
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return super().__iter__()
|
||||||
|
|
||||||
|
def keys(self):
|
||||||
|
return super().keys()
|
||||||
|
|
||||||
|
|
||||||
def running_in_container() -> bool:
|
def running_in_container() -> bool:
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
|
from typing import KeysView, Sequence
|
||||||
|
|
||||||
|
from prompt_toolkit.validation import Validator
|
||||||
|
|
||||||
|
|
||||||
|
def int_range_validator(minimum: int, maximum: int) -> Validator:
|
||||||
|
"""Validator for integer ranges."""
|
||||||
|
|
||||||
|
def validate(input: str) -> bool:
|
||||||
|
try:
|
||||||
|
value = int(input)
|
||||||
|
if not (minimum <= value <= maximum):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return Validator.from_callable(validate, error_message="Invalid input.")
|
||||||
|
|
||||||
|
|
||||||
|
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]:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
return Validator.from_callable(validate, error_message="Invalid input.")
|
|
@ -1 +1 @@
|
||||||
__version__ = "0.1.10"
|
__version__ = "0.1.11"
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "falyx"
|
name = "falyx"
|
||||||
version = "0.1.10"
|
version = "0.1.11"
|
||||||
description = "Reliable and introspectable async CLI action framework."
|
description = "Reliable and introspectable async CLI action framework."
|
||||||
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
|
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
@ -23,7 +23,6 @@ black = { version = "^25.0", allow-prereleases = true }
|
||||||
mypy = { version = "^1.0", allow-prereleases = true }
|
mypy = { version = "^1.0", allow-prereleases = true }
|
||||||
isort = { version = "^5.0", allow-prereleases = true }
|
isort = { version = "^5.0", allow-prereleases = true }
|
||||||
pytest-cov = "^4.0"
|
pytest-cov = "^4.0"
|
||||||
pytest-mock = "^3.0"
|
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
falyx = "falyx.__main__:main"
|
falyx = "falyx.__main__:main"
|
||||||
|
|
|
@ -42,6 +42,48 @@ async def test_action_async_callable():
|
||||||
str(action)
|
str(action)
|
||||||
== "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False)"
|
== "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False)"
|
||||||
)
|
)
|
||||||
|
assert (
|
||||||
|
repr(action)
|
||||||
|
== "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chained_action():
|
||||||
|
"""Test if ChainedAction can be created and used."""
|
||||||
|
action1 = Action("one", lambda: 1)
|
||||||
|
action2 = Action("two", lambda: 2)
|
||||||
|
chain = ChainedAction(
|
||||||
|
name="Simple Chain",
|
||||||
|
actions=[action1, action2],
|
||||||
|
return_list=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await chain()
|
||||||
|
assert result == [1, 2]
|
||||||
|
assert (
|
||||||
|
str(chain)
|
||||||
|
== "ChainedAction(name='Simple Chain', actions=['one', 'two'], auto_inject=False, return_list=True)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_action_group():
|
||||||
|
"""Test if ActionGroup can be created and used."""
|
||||||
|
action1 = Action("one", lambda: 1)
|
||||||
|
action2 = Action("two", lambda: 2)
|
||||||
|
group = ChainedAction(
|
||||||
|
name="Simple Group",
|
||||||
|
actions=[action1, action2],
|
||||||
|
return_list=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await group()
|
||||||
|
assert result == [1, 2]
|
||||||
|
assert (
|
||||||
|
str(group)
|
||||||
|
== "ChainedAction(name='Simple Group', actions=['one', 'two'], auto_inject=False, return_list=True)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
@ -120,3 +162,62 @@ async def test_fallback_action():
|
||||||
result = await chain()
|
result = await chain()
|
||||||
assert result == "Fallback value"
|
assert result == "Fallback value"
|
||||||
assert str(action) == "FallbackAction(fallback='Fallback value')"
|
assert str(action) == "FallbackAction(fallback='Fallback value')"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remove_action_from_chain():
|
||||||
|
"""Test if an action can be removed from a chain."""
|
||||||
|
action1 = Action(name="one", action=lambda: 1)
|
||||||
|
action2 = Action(name="two", action=lambda: 2)
|
||||||
|
chain = ChainedAction(
|
||||||
|
name="Simple Chain",
|
||||||
|
actions=[action1, action2],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(chain.actions) == 2
|
||||||
|
|
||||||
|
# Remove the first action
|
||||||
|
chain.remove_action(action1.name)
|
||||||
|
|
||||||
|
assert len(chain.actions) == 1
|
||||||
|
assert chain.actions[0] == action2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_has_action_in_chain():
|
||||||
|
"""Test if an action can be checked for presence in a chain."""
|
||||||
|
action1 = Action(name="one", action=lambda: 1)
|
||||||
|
action2 = Action(name="two", action=lambda: 2)
|
||||||
|
chain = ChainedAction(
|
||||||
|
name="Simple Chain",
|
||||||
|
actions=[action1, action2],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert chain.has_action(action1.name) is True
|
||||||
|
assert chain.has_action(action2.name) is True
|
||||||
|
|
||||||
|
# Remove the first action
|
||||||
|
chain.remove_action(action1.name)
|
||||||
|
|
||||||
|
assert chain.has_action(action1.name) is False
|
||||||
|
assert chain.has_action(action2.name) is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_action_from_chain():
|
||||||
|
"""Test if an action can be retrieved from a chain."""
|
||||||
|
action1 = Action(name="one", action=lambda: 1)
|
||||||
|
action2 = Action(name="two", action=lambda: 2)
|
||||||
|
chain = ChainedAction(
|
||||||
|
name="Simple Chain",
|
||||||
|
actions=[action1, action2],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert chain.get_action(action1.name) == action1
|
||||||
|
assert chain.get_action(action2.name) == action2
|
||||||
|
|
||||||
|
# Remove the first action
|
||||||
|
chain.remove_action(action1.name)
|
||||||
|
|
||||||
|
assert chain.get_action(action1.name) is None
|
||||||
|
assert chain.get_action(action2.name) == action2
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from falyx import Action, Falyx
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_headless():
|
||||||
|
"""Test if Falyx can run in headless mode."""
|
||||||
|
falyx = Falyx("Headless Test")
|
||||||
|
|
||||||
|
# Add a simple command
|
||||||
|
falyx.add_command(
|
||||||
|
key="T",
|
||||||
|
description="Test Command",
|
||||||
|
action=lambda: "Hello, World!",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run the CLI
|
||||||
|
result = await falyx.headless("T")
|
||||||
|
assert result == "Hello, World!"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_headless_recovery():
|
||||||
|
"""Test if Falyx can recover from a failure in headless mode."""
|
||||||
|
falyx = Falyx("Headless Recovery Test")
|
||||||
|
|
||||||
|
state = {"count": 0}
|
||||||
|
|
||||||
|
async def flaky():
|
||||||
|
if not state["count"]:
|
||||||
|
state["count"] += 1
|
||||||
|
raise RuntimeError("Random failure!")
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
# Add a command that raises an exception
|
||||||
|
falyx.add_command(
|
||||||
|
key="E",
|
||||||
|
description="Error Command",
|
||||||
|
action=Action("flaky", flaky),
|
||||||
|
retry=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await falyx.headless("E")
|
||||||
|
assert result == "ok"
|
Loading…
Reference in New Issue