Change headless -> run_key, Add previews, _wrap_literal -> _wrap
This commit is contained in:
parent
b5da6b9647
commit
5d96d6d3d9
|
@ -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))}, "
|
||||
|
|
|
@ -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}')"
|
||||
)
|
||||
|
|
|
@ -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']} | "]
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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'})"
|
||||
)
|
||||
|
|
|
@ -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'})"
|
||||
)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
Loading…
Reference in New Issue