diff --git a/falyx/action.py b/falyx/action.py index 923530d..3f44562 100644 --- a/falyx/action.py +++ b/falyx/action.py @@ -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))}, " diff --git a/falyx/command.py b/falyx/command.py index 4c1ac83..fa14fb9 100644 --- a/falyx/command.py +++ b/falyx/command.py @@ -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}')" + ) diff --git a/falyx/context.py b/falyx/context.py index 98f34e0..aefc1e2 100644 --- a/falyx/context.py +++ b/falyx/context.py @@ -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']} | "] diff --git a/falyx/falyx.py b/falyx/falyx.py index 547e19a..0a80fff 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -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() diff --git a/falyx/menu_action.py b/falyx/menu_action.py index a49f43a..caa9c49 100644 --- a/falyx/menu_action.py +++ b/falyx/menu_action.py @@ -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'})" + ) diff --git a/falyx/selection_action.py b/falyx/selection_action.py index 72a5bdd..7e060d9 100644 --- a/falyx/selection_action.py +++ b/falyx/selection_action.py @@ -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'})" + ) diff --git a/tests/test_command.py b/tests/test_command.py index dec97dc..66947b6 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -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" diff --git a/tests/test_headless.py b/tests/test_headless.py deleted file mode 100644 index c8feb72..0000000 --- a/tests/test_headless.py +++ /dev/null @@ -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"