Change headless -> run_key, Add previews, _wrap_literal -> _wrap

This commit is contained in:
Roland Thomas Jr 2025-05-06 22:56:45 -04:00
parent b5da6b9647
commit 5d96d6d3d9
Signed by: roland
GPG Key ID: 7C3C2B085A4C2872
8 changed files with 161 additions and 110 deletions

View File

@ -312,6 +312,14 @@ class LiteralInputAction(Action):
"""Return the literal value."""
return self._value
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.LIGHT_YELLOW}]📥 LiteralInput[/] '{self.name}'"]
label.append(f" [dim](value = {repr(self.value)})[/dim]")
if parent:
parent.add("".join(label))
else:
self.console.print(Tree("".join(label)))
def __str__(self) -> str:
return f"LiteralInputAction(value={self.value!r})"
@ -344,6 +352,14 @@ class FallbackAction(Action):
"""Return the fallback value."""
return self._fallback
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.LIGHT_RED}]🛟 Fallback[/] '{self.name}'"]
label.append(f" [dim](uses fallback = {repr(self.fallback)})[/dim]")
if parent:
parent.add("".join(label))
else:
self.console.print(Tree("".join(label)))
def __str__(self) -> str:
return f"FallbackAction(fallback={self.fallback!r})"
@ -419,13 +435,16 @@ class ChainedAction(BaseAction, ActionListMixin):
if actions:
self.set_actions(actions)
def _wrap_literal_if_needed(self, action: BaseAction | Any) -> BaseAction:
return (
LiteralInputAction(action) if not isinstance(action, BaseAction) else action
)
def _wrap_if_needed(self, action: BaseAction | Any) -> BaseAction:
if isinstance(action, BaseAction):
return action
elif callable(action):
return Action(name=action.__name__, action=action)
else:
return LiteralInputAction(action)
def add_action(self, action: BaseAction | Any) -> None:
action = self._wrap_literal_if_needed(action)
action = self._wrap_if_needed(action)
if self.actions and self.auto_inject and not action.inject_last_result:
action.inject_last_result = True
super().add_action(action)
@ -529,6 +548,12 @@ class ChainedAction(BaseAction, ActionListMixin):
except Exception as error:
logger.error("[%s] ⚠️ Rollback failed: %s", action.name, error)
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
"""Register a hook for all actions and sub-actions."""
self.hooks.register(hook_type, hook)
for action in self.actions:
action.register_hooks_recursively(hook_type, hook)
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.CYAN_b}]⛓ ChainedAction[/] '{self.name}'"]
if self.inject_last_result:
@ -539,12 +564,6 @@ class ChainedAction(BaseAction, ActionListMixin):
if not parent:
self.console.print(tree)
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
"""Register a hook for all actions and sub-actions."""
self.hooks.register(hook_type, hook)
for action in self.actions:
action.register_hooks_recursively(hook_type, hook)
def __str__(self):
return (
f"ChainedAction(name={self.name!r}, actions={[a.name for a in self.actions]!r}, "
@ -648,6 +667,12 @@ class ActionGroup(BaseAction, ActionListMixin):
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
er.record(context)
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
"""Register a hook for all actions and sub-actions."""
super().register_hooks_recursively(hook_type, hook)
for action in self.actions:
action.register_hooks_recursively(hook_type, hook)
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.MAGENTA_b}]⏩ ActionGroup (parallel)[/] '{self.name}'"]
if self.inject_last_result:
@ -659,12 +684,6 @@ class ActionGroup(BaseAction, ActionListMixin):
if not parent:
self.console.print(tree)
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
"""Register a hook for all actions and sub-actions."""
super().register_hooks_recursively(hook_type, hook)
for action in self.actions:
action.register_hooks_recursively(hook_type, hook)
def __str__(self):
return (
f"ActionGroup(name={self.name!r}, actions={[a.name for a in self.actions]!r}, "
@ -749,6 +768,15 @@ class ProcessAction(BaseAction):
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
er.record(context)
def _validate_pickleable(self, obj: Any) -> bool:
try:
import pickle
pickle.dumps(obj)
return True
except (pickle.PicklingError, TypeError):
return False
async def preview(self, parent: Tree | None = None):
label = [
f"[{OneColors.DARK_YELLOW_b}]🧠 ProcessAction (new process)[/] '{self.name}'"
@ -760,15 +788,6 @@ class ProcessAction(BaseAction):
else:
self.console.print(Tree("".join(label)))
def _validate_pickleable(self, obj: Any) -> bool:
try:
import pickle
pickle.dumps(obj)
return True
except (pickle.PicklingError, TypeError):
return False
def __str__(self) -> str:
return (
f"ProcessAction(name={self.name!r}, func={getattr(self.func, '__name__', repr(self.func))}, "

View File

@ -123,6 +123,15 @@ class Command(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
@field_validator("action", mode="before")
@classmethod
def wrap_callable_as_async(cls, action: Any) -> Any:
if isinstance(action, BaseAction):
return action
elif callable(action):
return ensure_async(action)
raise TypeError("Action must be a callable or an instance of BaseAction")
def model_post_init(self, __context: Any) -> None:
"""Post-initialization to set up the action and hooks."""
if self.retry and isinstance(self.action, Action):
@ -165,27 +174,12 @@ class Command(BaseModel):
return any(isinstance(action, BaseIOAction) for action in self.action.actions)
return False
@field_validator("action", mode="before")
@classmethod
def wrap_callable_as_async(cls, action: Any) -> Any:
if isinstance(action, BaseAction):
return action
elif callable(action):
return ensure_async(action)
raise TypeError("Action must be a callable or an instance of BaseAction")
def __str__(self):
return (
f"Command(key='{self.key}', description='{self.description}' "
f"action='{self.action}')"
)
def _inject_options_manager(self):
def _inject_options_manager(self) -> None:
"""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) -> Any:
"""Run the action with full hook lifecycle, timing, and error handling."""
self._inject_options_manager()
combined_args = args + self.args
@ -245,18 +239,18 @@ class Command(BaseModel):
return FormattedText(prompt)
def log_summary(self):
def log_summary(self) -> None:
if self._context:
self._context.log_summary()
async def preview(self):
async def preview(self) -> None:
label = f"[{OneColors.GREEN_b}]Command:[/] '{self.key}'{self.description}"
if hasattr(self.action, "preview") and callable(self.action.preview):
tree = Tree(label)
await self.action.preview(parent=tree)
console.print(tree)
elif callable(self.action):
elif callable(self.action) and not isinstance(self.action, BaseAction):
console.print(f"{label}")
console.print(
f"[{OneColors.LIGHT_RED_b}]→ Would call:[/] {self.action.__name__}"
@ -267,3 +261,9 @@ class Command(BaseModel):
console.print(
f"[{OneColors.DARK_RED}]⚠️ Action is not callable or lacks a preview method.[/]"
)
def __str__(self) -> str:
return (
f"Command(key='{self.key}', description='{self.description}' "
f"action='{self.action}')"
)

View File

@ -122,7 +122,7 @@ class ExecutionContext(BaseModel):
"extra": self.extra,
}
def log_summary(self, logger=None):
def log_summary(self, logger=None) -> None:
summary = self.as_dict()
message = [f"[SUMMARY] {summary['name']} | "]

View File

@ -12,7 +12,7 @@ for running commands, actions, and workflows. It supports:
- Interactive input validation and auto-completion
- History tracking and help menu generation
- Confirmation prompts and spinners
- Headless mode for automated script execution
- Run key for automated script execution
- CLI argument parsing with argparse integration
- Retry policy configuration for actions
@ -79,7 +79,7 @@ class Falyx:
- 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 headless execution modes
- History tracking, help generation, and run key execution modes
- Seamless CLI argument parsing and integration via argparse
- Extensible with user-defined hooks, bottom bars, and custom layouts
@ -103,7 +103,7 @@ class Falyx:
Methods:
run(): Main entry point for CLI argument-based workflows. Most users will use this.
menu(): Run the interactive menu loop.
headless(command_key, return_context): Run a command directly without showing the menu.
run_key(command_key, return_context): Run a command directly without showing 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.
@ -705,6 +705,7 @@ class Falyx:
self.console.print(
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'[/]"
)
logger.warning(f"⚠️ Command '{choice}' not found.")
return None
async def _should_run_action(self, selected_command: Command) -> bool:
@ -808,21 +809,26 @@ class Falyx:
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
return True
async def headless(self, command_key: str, return_context: bool = False) -> Any:
"""Runs the action of the selected command without displaying the menu."""
async def run_key(self, command_key: str, return_context: bool = False) -> Any:
"""Run a command by key without displaying the menu (non-interactive mode)."""
self.debug_hooks()
selected_command = self.get_command(command_key)
self.last_run_command = selected_command
if not selected_command:
logger.info("[Headless] Back command selected. Exiting menu.")
return None
logger.info(f"[Headless] 🚀 Running: '{selected_command.description}'")
logger.info(
"[run_key] 🚀 Executing: %s%s",
selected_command.key,
selected_command.description,
)
if not await self._should_run_action(selected_command):
logger.info("[run_key] ❌ Cancelled: %s", selected_command.description)
raise FalyxError(
f"[Headless] '{selected_command.description}' cancelled by confirmation."
f"[run_key] '{selected_command.description}' "
"cancelled by confirmation."
)
context = self._create_context(selected_command)
@ -837,16 +843,25 @@ class Falyx:
context.result = result
await self.hooks.trigger(HookType.ON_SUCCESS, context)
logger.info(f"[Headless] ✅ '{selected_command.description}' complete.")
logger.info("[run_key] ✅ '%s' complete.", selected_command.description)
except (KeyboardInterrupt, EOFError):
logger.warning(
"[run_key] ⚠️ Interrupted by user: ", selected_command.description
)
raise FalyxError(
f"[Headless] ⚠️ '{selected_command.description}' interrupted by user."
f"[run_key] ⚠️ '{selected_command.description}' interrupted by user."
)
except Exception as error:
context.exception = error
await self.hooks.trigger(HookType.ON_ERROR, context)
logger.error(
"[run_key] ❌ Failed: %s%s: %s",
selected_command.description,
type(error).__name__,
error,
)
raise FalyxError(
f"[Headless] ❌ '{selected_command.description}' failed."
f"[run_key] ❌ '{selected_command.description}' failed."
) from error
finally:
context.stop_timer()
@ -965,7 +980,7 @@ class Falyx:
sys.exit(1)
self._set_retry_policy(command)
try:
await self.headless(self.cli_args.name)
await self.run_key(self.cli_args.name)
except FalyxError as error:
self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]")
sys.exit(1)
@ -988,7 +1003,7 @@ class Falyx:
)
for cmd in matching:
self._set_retry_policy(cmd)
await self.headless(cmd.key)
await self.run_key(cmd.key)
sys.exit(0)
await self.menu()

View File

@ -214,4 +214,9 @@ class MenuAction(BaseAction):
self.console.print(tree)
def __str__(self) -> str:
return f"MenuAction(name={self.name}, options={list(self.menu_options.keys())})"
return (
f"MenuAction(name={self.name!r}, options={list(self.menu_options.keys())!r}, "
f"default_selection={self.default_selection!r}, "
f"include_reserved={self.include_reserved}, "
f"prompt={'off' if self.never_prompt else 'on'})"
)

View File

@ -1,5 +1,6 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""selection_action.py"""
from pathlib import Path
from typing import Any
from prompt_toolkit import PromptSession
@ -18,7 +19,7 @@ from falyx.selection import (
render_selection_indexed_table,
)
from falyx.themes.colors import OneColors
from falyx.utils import logger
from falyx.utils import CaseInsensitiveDict, logger
class SelectionAction(BaseAction):
@ -45,7 +46,7 @@ class SelectionAction(BaseAction):
inject_last_result_as=inject_last_result_as,
never_prompt=never_prompt,
)
self.selections = selections
self.selections: list[str] | CaseInsensitiveDict = selections
self.return_key = return_key
self.title = title
self.columns = columns
@ -55,6 +56,23 @@ class SelectionAction(BaseAction):
self.prompt_message = prompt_message
self.show_table = show_table
@property
def selections(self) -> list[str] | CaseInsensitiveDict:
return self._selections
@selections.setter
def selections(self, value: list[str] | dict[str, SelectionOption]):
if isinstance(value, list):
self._selections: list[str] | CaseInsensitiveDict = value
elif isinstance(value, dict):
cid = CaseInsensitiveDict()
cid.update(value)
self._selections = cid
else:
raise TypeError(
f"'selections' must be a list[str] or dict[str, SelectionOption], got {type(value).__name__}"
)
async def _run(self, *args, **kwargs) -> Any:
kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext(
@ -168,3 +186,15 @@ class SelectionAction(BaseAction):
if not parent:
self.console.print(tree)
def __str__(self) -> str:
selection_type = (
"List"
if isinstance(self.selections, list)
else "Dict" if isinstance(self.selections, dict) else "Unknown"
)
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'})"
)

View File

@ -33,7 +33,8 @@ class DummyInputAction(BaseIOAction):
# --- Tests ---
def test_command_creation():
@pytest.mark.asyncio
async def test_command_creation():
"""Test if Command can be created with a callable."""
action = Action("test_action", dummy_action)
cmd = Command(key="TEST", description="Test Command", action=action)
@ -41,12 +42,15 @@ def test_command_creation():
assert cmd.description == "Test Command"
assert cmd.action == action
result = await cmd()
assert result == "ok"
assert cmd.result == "ok"
def test_command_str():
"""Test if Command string representation is correct."""
action = Action("test_action", dummy_action)
cmd = Command(key="TEST", description="Test Command", action=action)
print(cmd)
assert (
str(cmd)
== "Command(key='TEST', description='Test Command' action='Action(name='test_action', action=dummy_action, args=(), kwargs={}, retry=False)')"
@ -233,3 +237,26 @@ def test_chain_retry_all_not_base_action():
with pytest.raises(Exception) as exc_info:
assert cmd.action.retry_policy.enabled is False
assert "'function' object has no attribute 'retry_policy'" in str(exc_info.value)
@pytest.mark.asyncio
async def test_command_exception_handling():
"""Test if Command handles exceptions correctly."""
async def bad_action():
raise ZeroDivisionError("This is a test exception")
cmd = Command(key="TEST", description="Test Command", action=bad_action)
with pytest.raises(ZeroDivisionError):
await cmd()
assert cmd.result is None
assert isinstance(cmd._context.exception, ZeroDivisionError)
def test_command_bad_action():
"""Test if Command raises an exception when action is not callable."""
with pytest.raises(TypeError) as exc_info:
Command(key="TEST", description="Test Command", action="not_callable")
assert str(exc_info.value) == "Action must be a callable or an instance of BaseAction"

View File

@ -1,45 +0,0 @@
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"