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 the literal value."""
|
||||||
return self._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:
|
def __str__(self) -> str:
|
||||||
return f"LiteralInputAction(value={self.value!r})"
|
return f"LiteralInputAction(value={self.value!r})"
|
||||||
|
|
||||||
|
@ -344,6 +352,14 @@ class FallbackAction(Action):
|
||||||
"""Return the fallback value."""
|
"""Return the fallback value."""
|
||||||
return self._fallback
|
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:
|
def __str__(self) -> str:
|
||||||
return f"FallbackAction(fallback={self.fallback!r})"
|
return f"FallbackAction(fallback={self.fallback!r})"
|
||||||
|
|
||||||
|
@ -419,13 +435,16 @@ class ChainedAction(BaseAction, ActionListMixin):
|
||||||
if actions:
|
if actions:
|
||||||
self.set_actions(actions)
|
self.set_actions(actions)
|
||||||
|
|
||||||
def _wrap_literal_if_needed(self, action: BaseAction | Any) -> BaseAction:
|
def _wrap_if_needed(self, action: BaseAction | Any) -> BaseAction:
|
||||||
return (
|
if isinstance(action, BaseAction):
|
||||||
LiteralInputAction(action) if not isinstance(action, BaseAction) else action
|
return action
|
||||||
)
|
elif callable(action):
|
||||||
|
return Action(name=action.__name__, action=action)
|
||||||
|
else:
|
||||||
|
return LiteralInputAction(action)
|
||||||
|
|
||||||
def add_action(self, action: BaseAction | Any) -> None:
|
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:
|
if self.actions and self.auto_inject and not action.inject_last_result:
|
||||||
action.inject_last_result = True
|
action.inject_last_result = True
|
||||||
super().add_action(action)
|
super().add_action(action)
|
||||||
|
@ -529,6 +548,12 @@ class ChainedAction(BaseAction, ActionListMixin):
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.error("[%s] ⚠️ Rollback failed: %s", action.name, 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):
|
async def preview(self, parent: Tree | None = None):
|
||||||
label = [f"[{OneColors.CYAN_b}]⛓ ChainedAction[/] '{self.name}'"]
|
label = [f"[{OneColors.CYAN_b}]⛓ ChainedAction[/] '{self.name}'"]
|
||||||
if self.inject_last_result:
|
if self.inject_last_result:
|
||||||
|
@ -539,12 +564,6 @@ class ChainedAction(BaseAction, ActionListMixin):
|
||||||
if not parent:
|
if not parent:
|
||||||
self.console.print(tree)
|
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):
|
def __str__(self):
|
||||||
return (
|
return (
|
||||||
f"ChainedAction(name={self.name!r}, actions={[a.name for a in self.actions]!r}, "
|
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)
|
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
||||||
er.record(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):
|
async def preview(self, parent: Tree | None = None):
|
||||||
label = [f"[{OneColors.MAGENTA_b}]⏩ ActionGroup (parallel)[/] '{self.name}'"]
|
label = [f"[{OneColors.MAGENTA_b}]⏩ ActionGroup (parallel)[/] '{self.name}'"]
|
||||||
if self.inject_last_result:
|
if self.inject_last_result:
|
||||||
|
@ -659,12 +684,6 @@ class ActionGroup(BaseAction, ActionListMixin):
|
||||||
if not parent:
|
if not parent:
|
||||||
self.console.print(tree)
|
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):
|
def __str__(self):
|
||||||
return (
|
return (
|
||||||
f"ActionGroup(name={self.name!r}, actions={[a.name for a in self.actions]!r}, "
|
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)
|
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
||||||
er.record(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):
|
async def preview(self, parent: Tree | None = None):
|
||||||
label = [
|
label = [
|
||||||
f"[{OneColors.DARK_YELLOW_b}]🧠 ProcessAction (new process)[/] '{self.name}'"
|
f"[{OneColors.DARK_YELLOW_b}]🧠 ProcessAction (new process)[/] '{self.name}'"
|
||||||
|
@ -760,15 +788,6 @@ class ProcessAction(BaseAction):
|
||||||
else:
|
else:
|
||||||
self.console.print(Tree("".join(label)))
|
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:
|
def __str__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f"ProcessAction(name={self.name!r}, func={getattr(self.func, '__name__', repr(self.func))}, "
|
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)
|
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:
|
def model_post_init(self, __context: Any) -> None:
|
||||||
"""Post-initialization to set up the action and hooks."""
|
"""Post-initialization to set up the action and hooks."""
|
||||||
if self.retry and isinstance(self.action, Action):
|
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 any(isinstance(action, BaseIOAction) for action in self.action.actions)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@field_validator("action", mode="before")
|
def _inject_options_manager(self) -> None:
|
||||||
@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):
|
|
||||||
"""Inject the options manager into the action if applicable."""
|
"""Inject the options manager into the action if applicable."""
|
||||||
if isinstance(self.action, BaseAction):
|
if isinstance(self.action, BaseAction):
|
||||||
self.action.set_options_manager(self.options_manager)
|
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."""
|
"""Run the action with full hook lifecycle, timing, and error handling."""
|
||||||
self._inject_options_manager()
|
self._inject_options_manager()
|
||||||
combined_args = args + self.args
|
combined_args = args + self.args
|
||||||
|
@ -245,18 +239,18 @@ class Command(BaseModel):
|
||||||
|
|
||||||
return FormattedText(prompt)
|
return FormattedText(prompt)
|
||||||
|
|
||||||
def log_summary(self):
|
def log_summary(self) -> None:
|
||||||
if self._context:
|
if self._context:
|
||||||
self._context.log_summary()
|
self._context.log_summary()
|
||||||
|
|
||||||
async def preview(self):
|
async def preview(self) -> None:
|
||||||
label = f"[{OneColors.GREEN_b}]Command:[/] '{self.key}' — {self.description}"
|
label = f"[{OneColors.GREEN_b}]Command:[/] '{self.key}' — {self.description}"
|
||||||
|
|
||||||
if hasattr(self.action, "preview") and callable(self.action.preview):
|
if hasattr(self.action, "preview") and callable(self.action.preview):
|
||||||
tree = Tree(label)
|
tree = Tree(label)
|
||||||
await self.action.preview(parent=tree)
|
await self.action.preview(parent=tree)
|
||||||
console.print(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"{label}")
|
||||||
console.print(
|
console.print(
|
||||||
f"[{OneColors.LIGHT_RED_b}]→ Would call:[/] {self.action.__name__}"
|
f"[{OneColors.LIGHT_RED_b}]→ Would call:[/] {self.action.__name__}"
|
||||||
|
@ -267,3 +261,9 @@ class Command(BaseModel):
|
||||||
console.print(
|
console.print(
|
||||||
f"[{OneColors.DARK_RED}]⚠️ Action is not callable or lacks a preview method.[/]"
|
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,
|
"extra": self.extra,
|
||||||
}
|
}
|
||||||
|
|
||||||
def log_summary(self, logger=None):
|
def log_summary(self, logger=None) -> None:
|
||||||
summary = self.as_dict()
|
summary = self.as_dict()
|
||||||
message = [f"[SUMMARY] {summary['name']} | "]
|
message = [f"[SUMMARY] {summary['name']} | "]
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,7 @@ for running commands, actions, and workflows. It supports:
|
||||||
- Interactive input validation and auto-completion
|
- Interactive input validation and auto-completion
|
||||||
- History tracking and help menu generation
|
- History tracking and help menu generation
|
||||||
- Confirmation prompts and spinners
|
- Confirmation prompts and spinners
|
||||||
- Headless mode for automated script execution
|
- Run key for automated script execution
|
||||||
- CLI argument parsing with argparse integration
|
- CLI argument parsing with argparse integration
|
||||||
- Retry policy configuration for actions
|
- 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
|
- Full lifecycle hooks (before, success, error, after, teardown) at both menu and command levels
|
||||||
- Built-in retry support, spinner visuals, and confirmation prompts
|
- Built-in retry support, spinner visuals, and confirmation prompts
|
||||||
- Submenu nesting and action chaining
|
- 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
|
- Seamless CLI argument parsing and integration via argparse
|
||||||
- Extensible with user-defined hooks, bottom bars, and custom layouts
|
- Extensible with user-defined hooks, bottom bars, and custom layouts
|
||||||
|
|
||||||
|
@ -103,7 +103,7 @@ class Falyx:
|
||||||
Methods:
|
Methods:
|
||||||
run(): Main entry point for CLI argument-based workflows. Most users will use this.
|
run(): Main entry point for CLI argument-based workflows. Most users will use this.
|
||||||
menu(): Run the interactive menu loop.
|
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_command(): Add a single command to the menu.
|
||||||
add_commands(): Add multiple commands at once.
|
add_commands(): Add multiple commands at once.
|
||||||
register_all_hooks(): Register hooks across all commands and submenus.
|
register_all_hooks(): Register hooks across all commands and submenus.
|
||||||
|
@ -705,6 +705,7 @@ class Falyx:
|
||||||
self.console.print(
|
self.console.print(
|
||||||
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'[/]"
|
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'[/]"
|
||||||
)
|
)
|
||||||
|
logger.warning(f"⚠️ Command '{choice}' not found.")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _should_run_action(self, selected_command: Command) -> bool:
|
async def _should_run_action(self, selected_command: Command) -> bool:
|
||||||
|
@ -808,21 +809,26 @@ class Falyx:
|
||||||
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def headless(self, command_key: str, return_context: bool = False) -> Any:
|
async def run_key(self, command_key: str, return_context: bool = False) -> Any:
|
||||||
"""Runs the action of the selected command without displaying the menu."""
|
"""Run a command by key without displaying the menu (non-interactive mode)."""
|
||||||
self.debug_hooks()
|
self.debug_hooks()
|
||||||
selected_command = self.get_command(command_key)
|
selected_command = self.get_command(command_key)
|
||||||
self.last_run_command = selected_command
|
self.last_run_command = selected_command
|
||||||
|
|
||||||
if not selected_command:
|
if not selected_command:
|
||||||
logger.info("[Headless] Back command selected. Exiting menu.")
|
|
||||||
return None
|
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):
|
if not await self._should_run_action(selected_command):
|
||||||
|
logger.info("[run_key] ❌ Cancelled: %s", selected_command.description)
|
||||||
raise FalyxError(
|
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)
|
context = self._create_context(selected_command)
|
||||||
|
@ -837,16 +843,25 @@ class Falyx:
|
||||||
context.result = result
|
context.result = result
|
||||||
|
|
||||||
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
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):
|
except (KeyboardInterrupt, EOFError):
|
||||||
|
logger.warning(
|
||||||
|
"[run_key] ⚠️ Interrupted by user: ", selected_command.description
|
||||||
|
)
|
||||||
raise FalyxError(
|
raise FalyxError(
|
||||||
f"[Headless] ⚠️ '{selected_command.description}' interrupted by user."
|
f"[run_key] ⚠️ '{selected_command.description}' interrupted by user."
|
||||||
)
|
)
|
||||||
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)
|
||||||
|
logger.error(
|
||||||
|
"[run_key] ❌ Failed: %s — %s: %s",
|
||||||
|
selected_command.description,
|
||||||
|
type(error).__name__,
|
||||||
|
error,
|
||||||
|
)
|
||||||
raise FalyxError(
|
raise FalyxError(
|
||||||
f"[Headless] ❌ '{selected_command.description}' failed."
|
f"[run_key] ❌ '{selected_command.description}' failed."
|
||||||
) from error
|
) from error
|
||||||
finally:
|
finally:
|
||||||
context.stop_timer()
|
context.stop_timer()
|
||||||
|
@ -965,7 +980,7 @@ class Falyx:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
self._set_retry_policy(command)
|
self._set_retry_policy(command)
|
||||||
try:
|
try:
|
||||||
await self.headless(self.cli_args.name)
|
await self.run_key(self.cli_args.name)
|
||||||
except FalyxError as error:
|
except FalyxError as error:
|
||||||
self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]")
|
self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
@ -988,7 +1003,7 @@ class Falyx:
|
||||||
)
|
)
|
||||||
for cmd in matching:
|
for cmd in matching:
|
||||||
self._set_retry_policy(cmd)
|
self._set_retry_policy(cmd)
|
||||||
await self.headless(cmd.key)
|
await self.run_key(cmd.key)
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
await self.menu()
|
await self.menu()
|
||||||
|
|
|
@ -214,4 +214,9 @@ class MenuAction(BaseAction):
|
||||||
self.console.print(tree)
|
self.console.print(tree)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
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
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
"""selection_action.py"""
|
"""selection_action.py"""
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from prompt_toolkit import PromptSession
|
from prompt_toolkit import PromptSession
|
||||||
|
@ -18,7 +19,7 @@ from falyx.selection import (
|
||||||
render_selection_indexed_table,
|
render_selection_indexed_table,
|
||||||
)
|
)
|
||||||
from falyx.themes.colors import OneColors
|
from falyx.themes.colors import OneColors
|
||||||
from falyx.utils import logger
|
from falyx.utils import CaseInsensitiveDict, logger
|
||||||
|
|
||||||
|
|
||||||
class SelectionAction(BaseAction):
|
class SelectionAction(BaseAction):
|
||||||
|
@ -45,7 +46,7 @@ class SelectionAction(BaseAction):
|
||||||
inject_last_result_as=inject_last_result_as,
|
inject_last_result_as=inject_last_result_as,
|
||||||
never_prompt=never_prompt,
|
never_prompt=never_prompt,
|
||||||
)
|
)
|
||||||
self.selections = selections
|
self.selections: list[str] | CaseInsensitiveDict = selections
|
||||||
self.return_key = return_key
|
self.return_key = return_key
|
||||||
self.title = title
|
self.title = title
|
||||||
self.columns = columns
|
self.columns = columns
|
||||||
|
@ -55,6 +56,23 @@ class SelectionAction(BaseAction):
|
||||||
self.prompt_message = prompt_message
|
self.prompt_message = prompt_message
|
||||||
self.show_table = show_table
|
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:
|
async def _run(self, *args, **kwargs) -> Any:
|
||||||
kwargs = self._maybe_inject_last_result(kwargs)
|
kwargs = self._maybe_inject_last_result(kwargs)
|
||||||
context = ExecutionContext(
|
context = ExecutionContext(
|
||||||
|
@ -168,3 +186,15 @@ class SelectionAction(BaseAction):
|
||||||
|
|
||||||
if not parent:
|
if not parent:
|
||||||
self.console.print(tree)
|
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 ---
|
# --- Tests ---
|
||||||
def test_command_creation():
|
@pytest.mark.asyncio
|
||||||
|
async def test_command_creation():
|
||||||
"""Test if Command can be created with a callable."""
|
"""Test if Command can be created with a callable."""
|
||||||
action = Action("test_action", dummy_action)
|
action = Action("test_action", dummy_action)
|
||||||
cmd = Command(key="TEST", description="Test Command", action=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.description == "Test Command"
|
||||||
assert cmd.action == action
|
assert cmd.action == action
|
||||||
|
|
||||||
|
result = await cmd()
|
||||||
|
assert result == "ok"
|
||||||
|
assert cmd.result == "ok"
|
||||||
|
|
||||||
|
|
||||||
def test_command_str():
|
def test_command_str():
|
||||||
"""Test if Command string representation is correct."""
|
"""Test if Command string representation is correct."""
|
||||||
action = Action("test_action", dummy_action)
|
action = Action("test_action", dummy_action)
|
||||||
cmd = Command(key="TEST", description="Test Command", action=action)
|
cmd = Command(key="TEST", description="Test Command", action=action)
|
||||||
print(cmd)
|
|
||||||
assert (
|
assert (
|
||||||
str(cmd)
|
str(cmd)
|
||||||
== "Command(key='TEST', description='Test Command' action='Action(name='test_action', action=dummy_action, args=(), kwargs={}, retry=False)')"
|
== "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:
|
with pytest.raises(Exception) as exc_info:
|
||||||
assert cmd.action.retry_policy.enabled is False
|
assert cmd.action.retry_policy.enabled is False
|
||||||
assert "'function' object has no attribute 'retry_policy'" in str(exc_info.value)
|
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