Change headless -> run_key, Add previews, _wrap_literal -> _wrap
This commit is contained in:
		| @@ -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" | ||||
		Reference in New Issue
	
	Block a user