feat: add TLDR ArgumentAction and Rich-compatible prompt styling
- Introduce `ArgumentAction.TLDR` for showing concise usage examples - Add `rich_text_to_prompt_text()` to support Rich-style markup in all prompt_toolkit inputs - Migrate all prompt-based Actions to use `prompt_message` with Rich styling support - Standardize `CancelSignal` as the default interrupt behavior for prompt-driven Actions
This commit is contained in:
		| @@ -68,9 +68,16 @@ def default_config(parser: CommandArgumentParser) -> None: | ||||
|         type=int, | ||||
|         help="Optional number argument.", | ||||
|     ) | ||||
|     parser.add_tldr_examples( | ||||
|         [ | ||||
|             ("web", "Deploy 'web' to the default location (New York)"), | ||||
|             ("cache London --tag beta", "Deploy 'cache' to London with tag"), | ||||
|             ("database --region us-west-2 --verbose", "Verbose deploy to west region"), | ||||
|         ] | ||||
|     ) | ||||
|  | ||||
|  | ||||
| flx = Falyx("Argument Examples") | ||||
| flx = Falyx("Argument Examples", program="argument_examples.py") | ||||
|  | ||||
| flx.add_command( | ||||
|     key="T", | ||||
|   | ||||
| @@ -84,7 +84,7 @@ async def main() -> None: | ||||
|  | ||||
|     # --- Bottom bar info --- | ||||
|     flx.bottom_bar.columns = 3 | ||||
|     flx.bottom_bar.add_toggle_from_option("V", "Verbose", flx.options, "verbose") | ||||
|     flx.bottom_bar.add_toggle_from_option("B", "Verbose", flx.options, "verbose") | ||||
|     flx.bottom_bar.add_toggle_from_option("U", "Debug Hooks", flx.options, "debug_hooks") | ||||
|     flx.bottom_bar.add_static("Version", f"Falyx v{__version__}") | ||||
|  | ||||
|   | ||||
| @@ -51,7 +51,11 @@ from falyx.context import ExecutionContext | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.prompt_utils import confirm_async, should_prompt_user | ||||
| from falyx.prompt_utils import ( | ||||
|     confirm_async, | ||||
|     rich_text_to_prompt_text, | ||||
|     should_prompt_user, | ||||
| ) | ||||
| from falyx.signals import CancelSignal | ||||
| from falyx.themes import OneColors | ||||
| from falyx.validators import word_validator, words_validator | ||||
| @@ -71,7 +75,7 @@ class ConfirmAction(BaseAction): | ||||
|  | ||||
|     Attributes: | ||||
|         name (str): Name of the action. Used for logging and debugging. | ||||
|         message (str): The confirmation message to display. | ||||
|         prompt_message (str): The confirmation message to display. | ||||
|         confirm_type (ConfirmType | str): The type of confirmation to use. | ||||
|             Options include YES_NO, YES_CANCEL, YES_NO_CANCEL, TYPE_WORD, and OK_CANCEL. | ||||
|         prompt_session (PromptSession | None): The session to use for input. | ||||
| @@ -84,7 +88,7 @@ class ConfirmAction(BaseAction): | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         message: str = "Confirm?", | ||||
|         prompt_message: str = "Confirm?", | ||||
|         confirm_type: ConfirmType | str = ConfirmType.YES_NO, | ||||
|         prompt_session: PromptSession | None = None, | ||||
|         never_prompt: bool = False, | ||||
| @@ -111,9 +115,11 @@ class ConfirmAction(BaseAction): | ||||
|             inject_into=inject_into, | ||||
|             never_prompt=never_prompt, | ||||
|         ) | ||||
|         self.message = message | ||||
|         self.prompt_message = rich_text_to_prompt_text(prompt_message) | ||||
|         self.confirm_type = ConfirmType(confirm_type) | ||||
|         self.prompt_session = prompt_session or PromptSession() | ||||
|         self.prompt_session = prompt_session or PromptSession( | ||||
|             interrupt_exception=CancelSignal | ||||
|         ) | ||||
|         self.word = word | ||||
|         self.return_last_result = return_last_result | ||||
|  | ||||
| @@ -122,7 +128,7 @@ class ConfirmAction(BaseAction): | ||||
|         match self.confirm_type: | ||||
|             case ConfirmType.YES_NO: | ||||
|                 return await confirm_async( | ||||
|                     self.message, | ||||
|                     self.prompt_message, | ||||
|                     prefix="❓ ", | ||||
|                     suffix=" [Y/n] > ", | ||||
|                     session=self.prompt_session, | ||||
| @@ -130,7 +136,7 @@ class ConfirmAction(BaseAction): | ||||
|             case ConfirmType.YES_NO_CANCEL: | ||||
|                 error_message = "Enter 'Y', 'y' to confirm, 'N', 'n' to decline, or 'C', 'c' to abort." | ||||
|                 answer = await self.prompt_session.prompt_async( | ||||
|                     f"❓ {self.message} [Y]es, [N]o, or [C]ancel to abort > ", | ||||
|                     f"❓ {self.prompt_message} [Y]es, [N]o, or [C]ancel to abort > ", | ||||
|                     validator=words_validator( | ||||
|                         ["Y", "N", "C"], error_message=error_message | ||||
|                     ), | ||||
| @@ -140,13 +146,13 @@ class ConfirmAction(BaseAction): | ||||
|                 return answer.upper() == "Y" | ||||
|             case ConfirmType.TYPE_WORD: | ||||
|                 answer = await self.prompt_session.prompt_async( | ||||
|                     f"❓ {self.message} [{self.word}] to confirm or [N/n] > ", | ||||
|                     f"❓ {self.prompt_message} [{self.word}] to confirm or [N/n] > ", | ||||
|                     validator=word_validator(self.word), | ||||
|                 ) | ||||
|                 return answer.upper().strip() != "N" | ||||
|             case ConfirmType.TYPE_WORD_CANCEL: | ||||
|                 answer = await self.prompt_session.prompt_async( | ||||
|                     f"❓ {self.message} [{self.word}] to confirm or [N/n] > ", | ||||
|                     f"❓ {self.prompt_message} [{self.word}] to confirm or [N/n] > ", | ||||
|                     validator=word_validator(self.word), | ||||
|                 ) | ||||
|                 if answer.upper().strip() == "N": | ||||
| @@ -154,7 +160,7 @@ class ConfirmAction(BaseAction): | ||||
|                 return answer.upper().strip() == self.word.upper().strip() | ||||
|             case ConfirmType.YES_CANCEL: | ||||
|                 answer = await confirm_async( | ||||
|                     self.message, | ||||
|                     self.prompt_message, | ||||
|                     prefix="❓ ", | ||||
|                     suffix=" [Y/n] > ", | ||||
|                     session=self.prompt_session, | ||||
| @@ -165,7 +171,7 @@ class ConfirmAction(BaseAction): | ||||
|             case ConfirmType.OK_CANCEL: | ||||
|                 error_message = "Enter 'O', 'o' to confirm or 'C', 'c' to abort." | ||||
|                 answer = await self.prompt_session.prompt_async( | ||||
|                     f"❓ {self.message} [O]k to confirm, [C]ancel to abort > ", | ||||
|                     f"❓ {self.prompt_message} [O]k to confirm, [C]ancel to abort > ", | ||||
|                     validator=words_validator(["O", "C"], error_message=error_message), | ||||
|                 ) | ||||
|                 if answer.upper() == "C": | ||||
| @@ -173,7 +179,7 @@ class ConfirmAction(BaseAction): | ||||
|                 return answer.upper() == "O" | ||||
|             case ConfirmType.ACKNOWLEDGE: | ||||
|                 answer = await self.prompt_session.prompt_async( | ||||
|                     f"❓ {self.message} [A]cknowledge > ", | ||||
|                     f"❓ {self.prompt_message} [A]cknowledge > ", | ||||
|                     validator=word_validator("A"), | ||||
|                 ) | ||||
|                 return answer.upper().strip() == "A" | ||||
| @@ -232,7 +238,7 @@ class ConfirmAction(BaseAction): | ||||
|             if not parent | ||||
|             else parent.add(f"[{OneColors.CYAN_b}]ConfirmAction[/]: {self.name}") | ||||
|         ) | ||||
|         tree.add(f"[bold]Message:[/] {self.message}") | ||||
|         tree.add(f"[bold]Message:[/] {self.prompt_message}") | ||||
|         tree.add(f"[bold]Type:[/] {self.confirm_type.value}") | ||||
|         tree.add(f"[bold]Prompt Required:[/] {'No' if self.never_prompt else 'Yes'}") | ||||
|         if self.confirm_type in (ConfirmType.TYPE_WORD, ConfirmType.TYPE_WORD_CANCEL): | ||||
| @@ -242,6 +248,6 @@ class ConfirmAction(BaseAction): | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return ( | ||||
|             f"ConfirmAction(name={self.name}, message={self.message}, " | ||||
|             f"ConfirmAction(name={self.name}, message={self.prompt_message}, " | ||||
|             f"confirm_type={self.confirm_type}, return_last_result={self.return_last_result})" | ||||
|         ) | ||||
|   | ||||
| @@ -49,8 +49,9 @@ from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.menu import MenuOptionMap | ||||
| from falyx.prompt_utils import rich_text_to_prompt_text | ||||
| from falyx.selection import prompt_for_selection, render_table_base | ||||
| from falyx.signals import BackSignal, QuitSignal | ||||
| from falyx.signals import BackSignal, CancelSignal, QuitSignal | ||||
| from falyx.themes import OneColors | ||||
| from falyx.utils import chunks | ||||
|  | ||||
| @@ -134,9 +135,11 @@ class MenuAction(BaseAction): | ||||
|         self.menu_options = menu_options | ||||
|         self.title = title | ||||
|         self.columns = columns | ||||
|         self.prompt_message = prompt_message | ||||
|         self.prompt_message = rich_text_to_prompt_text(prompt_message) | ||||
|         self.default_selection = default_selection | ||||
|         self.prompt_session = prompt_session or PromptSession() | ||||
|         self.prompt_session = prompt_session or PromptSession( | ||||
|             interrupt_exception=CancelSignal | ||||
|         ) | ||||
|         self.include_reserved = include_reserved | ||||
|         self.show_table = show_table | ||||
|         self.custom_table = custom_table | ||||
|   | ||||
| @@ -23,7 +23,8 @@ from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.menu import MenuOptionMap | ||||
| from falyx.signals import BackSignal, QuitSignal | ||||
| from falyx.prompt_utils import rich_text_to_prompt_text | ||||
| from falyx.signals import BackSignal, CancelSignal, QuitSignal | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| @@ -96,9 +97,11 @@ class PromptMenuAction(BaseAction): | ||||
|             never_prompt=never_prompt, | ||||
|         ) | ||||
|         self.menu_options = menu_options | ||||
|         self.prompt_message = prompt_message | ||||
|         self.prompt_message = rich_text_to_prompt_text(prompt_message) | ||||
|         self.default_selection = default_selection | ||||
|         self.prompt_session = prompt_session or PromptSession() | ||||
|         self.prompt_session = prompt_session or PromptSession( | ||||
|             interrupt_exception=CancelSignal | ||||
|         ) | ||||
|         self.include_reserved = include_reserved | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[None, None]: | ||||
|   | ||||
| @@ -61,6 +61,7 @@ from falyx.context import ExecutionContext | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.prompt_utils import rich_text_to_prompt_text | ||||
| from falyx.selection import ( | ||||
|     SelectionOption, | ||||
|     prompt_for_selection, | ||||
| @@ -119,13 +120,15 @@ class SelectFileAction(BaseAction): | ||||
|         self.directory = Path(directory).resolve() | ||||
|         self.title = title | ||||
|         self.columns = columns | ||||
|         self.prompt_message = prompt_message | ||||
|         self.prompt_message = rich_text_to_prompt_text(prompt_message) | ||||
|         self.suffix_filter = suffix_filter | ||||
|         self.style = style | ||||
|         self.number_selections = number_selections | ||||
|         self.separator = separator | ||||
|         self.allow_duplicates = allow_duplicates | ||||
|         self.prompt_session = prompt_session or PromptSession() | ||||
|         self.prompt_session = prompt_session or PromptSession( | ||||
|             interrupt_exception=CancelSignal | ||||
|         ) | ||||
|         self.return_type = FileType(return_type) | ||||
|         self.encoding = encoding | ||||
|  | ||||
|   | ||||
| @@ -42,6 +42,7 @@ from falyx.context import ExecutionContext | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.prompt_utils import rich_text_to_prompt_text | ||||
| from falyx.selection import ( | ||||
|     SelectionOption, | ||||
|     SelectionOptionMap, | ||||
| @@ -148,12 +149,14 @@ class SelectionAction(BaseAction): | ||||
|         self.return_type: SelectionReturnType = SelectionReturnType(return_type) | ||||
|         self.title = title | ||||
|         self.columns = columns | ||||
|         self.prompt_session = prompt_session or PromptSession() | ||||
|         self.prompt_session = prompt_session or PromptSession( | ||||
|             interrupt_exception=CancelSignal | ||||
|         ) | ||||
|         self.default_selection = default_selection | ||||
|         self.number_selections = number_selections | ||||
|         self.separator = separator | ||||
|         self.allow_duplicates = allow_duplicates | ||||
|         self.prompt_message = prompt_message | ||||
|         self.prompt_message = rich_text_to_prompt_text(prompt_message) | ||||
|         self.show_table = show_table | ||||
|  | ||||
|     @property | ||||
|   | ||||
| @@ -22,7 +22,7 @@ Use Cases: | ||||
| Example: | ||||
|     UserInputAction( | ||||
|         name="GetUsername", | ||||
|         prompt_text="Enter your username > ", | ||||
|         prompt_message="Enter your username > ", | ||||
|         validator=Validator.from_callable(lambda s: len(s) > 0), | ||||
|     ) | ||||
| """ | ||||
| @@ -34,6 +34,8 @@ from falyx.action.base_action import BaseAction | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import HookType | ||||
| from falyx.prompt_utils import rich_text_to_prompt_text | ||||
| from falyx.signals import CancelSignal | ||||
| from falyx.themes.colors import OneColors | ||||
|  | ||||
|  | ||||
| @@ -47,7 +49,7 @@ class UserInputAction(BaseAction): | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action (used for introspection and logging). | ||||
|         prompt_text (str): The prompt message shown to the user. | ||||
|         prompt_message (str): The prompt message shown to the user. | ||||
|             Can include `{last_result}` if `inject_last_result=True`. | ||||
|         default_text (str): Optional default value shown in the prompt. | ||||
|         validator (Validator | None): Prompt Toolkit validator for input constraints. | ||||
| @@ -59,7 +61,7 @@ class UserInputAction(BaseAction): | ||||
|         self, | ||||
|         name: str, | ||||
|         *, | ||||
|         prompt_text: str = "Input > ", | ||||
|         prompt_message: str = "Input > ", | ||||
|         default_text: str = "", | ||||
|         validator: Validator | None = None, | ||||
|         prompt_session: PromptSession | None = None, | ||||
| @@ -69,9 +71,11 @@ class UserInputAction(BaseAction): | ||||
|             name=name, | ||||
|             inject_last_result=inject_last_result, | ||||
|         ) | ||||
|         self.prompt_text = prompt_text | ||||
|         self.prompt_message = rich_text_to_prompt_text(prompt_message) | ||||
|         self.validator = validator | ||||
|         self.prompt_session = prompt_session or PromptSession() | ||||
|         self.prompt_session = prompt_session or PromptSession( | ||||
|             interrupt_exception=CancelSignal | ||||
|         ) | ||||
|         self.default_text = default_text | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[None, None]: | ||||
| @@ -88,12 +92,12 @@ class UserInputAction(BaseAction): | ||||
|         try: | ||||
|             await self.hooks.trigger(HookType.BEFORE, context) | ||||
|  | ||||
|             prompt_text = self.prompt_text | ||||
|             prompt_message = self.prompt_message | ||||
|             if self.inject_last_result and self.last_result: | ||||
|                 prompt_text = prompt_text.format(last_result=self.last_result) | ||||
|                 prompt_message = prompt_message.format(last_result=self.last_result) | ||||
|  | ||||
|             answer = await self.prompt_session.prompt_async( | ||||
|                 prompt_text, | ||||
|                 prompt_message, | ||||
|                 validator=self.validator, | ||||
|                 default=kwargs.get("default_text", self.default_text), | ||||
|             ) | ||||
| @@ -114,12 +118,12 @@ class UserInputAction(BaseAction): | ||||
|         label = f"[{OneColors.MAGENTA}]⌨ UserInputAction[/] '{self.name}'" | ||||
|         tree = parent.add(label) if parent else Tree(label) | ||||
|  | ||||
|         prompt_text = ( | ||||
|             self.prompt_text.replace("{last_result}", "<last_result>") | ||||
|             if "{last_result}" in self.prompt_text | ||||
|             else self.prompt_text | ||||
|         prompt_message = ( | ||||
|             self.prompt_message.replace("{last_result}", "<last_result>") | ||||
|             if "{last_result}" in self.prompt_message | ||||
|             else self.prompt_message | ||||
|         ) | ||||
|         tree.add(f"[dim]Prompt:[/] {prompt_text}") | ||||
|         tree.add(f"[dim]Prompt:[/] {prompt_message}") | ||||
|         if self.validator: | ||||
|             tree.add("[dim]Validator:[/] Yes") | ||||
|         if not parent: | ||||
|   | ||||
| @@ -56,7 +56,7 @@ class BottomBar: | ||||
|             Must return True if key is available, otherwise False. | ||||
|     """ | ||||
|  | ||||
|     RESERVED_CTRL_KEYS = {"c", "d", "z"} | ||||
|     RESERVED_CTRL_KEYS = {"c", "d", "z", "v"} | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|   | ||||
| @@ -98,6 +98,7 @@ class Command(BaseModel): | ||||
|             such as help text or choices. | ||||
|         simple_help_signature (bool): Whether to use a simplified help signature. | ||||
|         ignore_in_history (bool): Whether to ignore this command in execution history last result. | ||||
|         program: (str | None): The parent program name. | ||||
|  | ||||
|     Methods: | ||||
|         __call__(): Executes the command, respecting hooks and retries. | ||||
| @@ -141,6 +142,7 @@ class Command(BaseModel): | ||||
|     arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict) | ||||
|     simple_help_signature: bool = False | ||||
|     ignore_in_history: bool = False | ||||
|     program: str | None = None | ||||
|  | ||||
|     _context: ExecutionContext | None = PrivateAttr(default=None) | ||||
|  | ||||
| @@ -240,6 +242,8 @@ class Command(BaseModel): | ||||
|                 help_text=self.help_text, | ||||
|                 help_epilog=self.help_epilog, | ||||
|                 aliases=self.aliases, | ||||
|                 program=self.program, | ||||
|                 options_manager=self.options_manager, | ||||
|             ) | ||||
|             for arg_def in self.get_argument_definitions(): | ||||
|                 self.arg_parser.add_argument(*arg_def.pop("flags"), **arg_def) | ||||
|   | ||||
| @@ -26,12 +26,11 @@ import shlex | ||||
| import sys | ||||
| from argparse import ArgumentParser, Namespace, _SubParsersAction | ||||
| from difflib import get_close_matches | ||||
| from enum import Enum | ||||
| from functools import cached_property | ||||
| from typing import Any, Callable | ||||
|  | ||||
| from prompt_toolkit import PromptSession | ||||
| from prompt_toolkit.formatted_text import AnyFormattedText | ||||
| from prompt_toolkit.formatted_text import StyleAndTextTuples | ||||
| from prompt_toolkit.key_binding import KeyBindings | ||||
| from prompt_toolkit.patch_stdout import patch_stdout | ||||
| from prompt_toolkit.validation import ValidationError, Validator | ||||
| @@ -58,8 +57,10 @@ from falyx.exceptions import ( | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import Hook, HookManager, HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.mode import FalyxMode | ||||
| from falyx.options_manager import OptionsManager | ||||
| from falyx.parser import CommandArgumentParser, FalyxParsers, get_arg_parsers | ||||
| from falyx.prompt_utils import rich_text_to_prompt_text | ||||
| from falyx.protocols import ArgParserProtocol | ||||
| from falyx.retry import RetryPolicy | ||||
| from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal | ||||
| @@ -68,13 +69,6 @@ from falyx.utils import CaseInsensitiveDict, _noop, chunks, ensure_async | ||||
| from falyx.version import __version__ | ||||
|  | ||||
|  | ||||
| class FalyxMode(Enum): | ||||
|     MENU = "menu" | ||||
|     RUN = "run" | ||||
|     PREVIEW = "preview" | ||||
|     RUN_ALL = "run-all" | ||||
|  | ||||
|  | ||||
| class CommandValidator(Validator): | ||||
|     """Validator to check if the input is a valid command.""" | ||||
|  | ||||
| @@ -167,7 +161,7 @@ class Falyx: | ||||
|         epilog: str | None = None, | ||||
|         version: str = __version__, | ||||
|         version_style: str = OneColors.BLUE_b, | ||||
|         prompt: str | AnyFormattedText = "> ", | ||||
|         prompt: str | StyleAndTextTuples = "> ", | ||||
|         columns: int = 3, | ||||
|         bottom_bar: BottomBar | str | Callable[[], Any] | None = None, | ||||
|         welcome_message: str | Markdown | dict[str, Any] = "", | ||||
| @@ -191,7 +185,7 @@ class Falyx: | ||||
|         self.epilog: str | None = epilog | ||||
|         self.version: str = version | ||||
|         self.version_style: str = version_style | ||||
|         self.prompt: str | AnyFormattedText = prompt | ||||
|         self.prompt: str | StyleAndTextTuples = rich_text_to_prompt_text(prompt) | ||||
|         self.columns: int = columns | ||||
|         self.commands: dict[str, Command] = CaseInsensitiveDict() | ||||
|         self.exit_command: Command = self._get_exit_command() | ||||
| @@ -216,7 +210,7 @@ class Falyx: | ||||
|         self._hide_menu_table: bool = hide_menu_table | ||||
|         self.validate_options(cli_args, options) | ||||
|         self._prompt_session: PromptSession | None = None | ||||
|         self.mode = FalyxMode.MENU | ||||
|         self.options.set("mode", FalyxMode.MENU) | ||||
|  | ||||
|     def validate_options( | ||||
|         self, | ||||
| @@ -702,6 +696,7 @@ class Falyx: | ||||
|             arg_metadata=arg_metadata or {}, | ||||
|             simple_help_signature=simple_help_signature, | ||||
|             ignore_in_history=ignore_in_history, | ||||
|             program=self.program, | ||||
|         ) | ||||
|  | ||||
|         if hooks: | ||||
| @@ -821,7 +816,11 @@ class Falyx: | ||||
|                 logger.info("Command '%s' selected.", run_command.key) | ||||
|             if is_preview: | ||||
|                 return True, run_command, args, kwargs | ||||
|             elif self.mode in {FalyxMode.RUN, FalyxMode.RUN_ALL, FalyxMode.PREVIEW}: | ||||
|             elif self.options.get("mode") in { | ||||
|                 FalyxMode.RUN, | ||||
|                 FalyxMode.RUN_ALL, | ||||
|                 FalyxMode.PREVIEW, | ||||
|             }: | ||||
|                 return False, run_command, args, kwargs | ||||
|             try: | ||||
|                 args, kwargs = await run_command.parse_args(input_args, from_validate) | ||||
| @@ -1119,7 +1118,7 @@ class Falyx: | ||||
|             sys.exit(0) | ||||
|  | ||||
|         if self.cli_args.command == "preview": | ||||
|             self.mode = FalyxMode.PREVIEW | ||||
|             self.options.set("mode", FalyxMode.PREVIEW) | ||||
|             _, command, args, kwargs = await self.get_command(self.cli_args.name) | ||||
|             if not command: | ||||
|                 self.console.print( | ||||
| @@ -1133,7 +1132,7 @@ class Falyx: | ||||
|             sys.exit(0) | ||||
|  | ||||
|         if self.cli_args.command == "run": | ||||
|             self.mode = FalyxMode.RUN | ||||
|             self.options.set("mode", FalyxMode.RUN) | ||||
|             is_preview, command, _, __ = await self.get_command(self.cli_args.name) | ||||
|             if is_preview: | ||||
|                 if command is None: | ||||
| @@ -1172,7 +1171,7 @@ class Falyx: | ||||
|             sys.exit(0) | ||||
|  | ||||
|         if self.cli_args.command == "run-all": | ||||
|             self.mode = FalyxMode.RUN_ALL | ||||
|             self.options.set("mode", FalyxMode.RUN_ALL) | ||||
|             matching = [ | ||||
|                 cmd | ||||
|                 for cmd in self.commands.values() | ||||
|   | ||||
							
								
								
									
										12
									
								
								falyx/mode.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								falyx/mode.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Defines `FalyxMode`, an enum representing the different modes of operation for Falyx. | ||||
| """ | ||||
| from enum import Enum | ||||
|  | ||||
|  | ||||
| class FalyxMode(Enum): | ||||
|     MENU = "menu" | ||||
|     RUN = "run" | ||||
|     PREVIEW = "preview" | ||||
|     RUN_ALL = "run-all" | ||||
| @@ -41,6 +41,7 @@ class ArgumentAction(Enum): | ||||
|         EXTEND: Extend a list with multiple values. | ||||
|         COUNT: Count the number of occurrences. | ||||
|         HELP: Display help and exit. | ||||
|         TLDR: Display brief examples and exit. | ||||
|  | ||||
|     Aliases: | ||||
|         - "true" → "store_true" | ||||
| @@ -60,6 +61,7 @@ class ArgumentAction(Enum): | ||||
|     EXTEND = "extend" | ||||
|     COUNT = "count" | ||||
|     HELP = "help" | ||||
|     TLDR = "tldr" | ||||
|  | ||||
|     @classmethod | ||||
|     def choices(cls) -> list[ArgumentAction]: | ||||
|   | ||||
| @@ -53,10 +53,14 @@ from typing import Any, Iterable, Sequence | ||||
|  | ||||
| from rich.console import Console | ||||
| from rich.markup import escape | ||||
| from rich.padding import Padding | ||||
| from rich.panel import Panel | ||||
|  | ||||
| from falyx.action.base_action import BaseAction | ||||
| from falyx.console import console | ||||
| from falyx.exceptions import CommandArgumentError | ||||
| from falyx.mode import FalyxMode | ||||
| from falyx.options_manager import OptionsManager | ||||
| from falyx.parser.argument import Argument | ||||
| from falyx.parser.argument_action import ArgumentAction | ||||
| from falyx.parser.parser_types import false_none, true_none | ||||
| @@ -70,6 +74,12 @@ class ArgumentState: | ||||
|     consumed: bool = False | ||||
|  | ||||
|  | ||||
| @dataclass(frozen=True) | ||||
| class TLDRExample: | ||||
|     usage: str | ||||
|     description: str | ||||
|  | ||||
|  | ||||
| class CommandArgumentParser: | ||||
|     """ | ||||
|     Custom argument parser for Falyx Commands. | ||||
| @@ -99,6 +109,9 @@ class CommandArgumentParser: | ||||
|         help_text: str = "", | ||||
|         help_epilog: str = "", | ||||
|         aliases: list[str] | None = None, | ||||
|         tldr_examples: list[tuple[str, str]] | None = None, | ||||
|         program: str | None = None, | ||||
|         options_manager: OptionsManager | None = None, | ||||
|     ) -> None: | ||||
|         """Initialize the CommandArgumentParser.""" | ||||
|         self.console: Console = console | ||||
| @@ -108,6 +121,7 @@ class CommandArgumentParser: | ||||
|         self.help_text: str = help_text | ||||
|         self.help_epilog: str = help_epilog | ||||
|         self.aliases: list[str] = aliases or [] | ||||
|         self.program: str | None = program | ||||
|         self._arguments: list[Argument] = [] | ||||
|         self._positional: dict[str, Argument] = {} | ||||
|         self._keyword: dict[str, Argument] = {} | ||||
| @@ -117,6 +131,10 @@ class CommandArgumentParser: | ||||
|         self._add_help() | ||||
|         self._last_positional_states: dict[str, ArgumentState] = {} | ||||
|         self._last_keyword_states: dict[str, ArgumentState] = {} | ||||
|         self._tldr_examples: list[TLDRExample] = [] | ||||
|         if tldr_examples: | ||||
|             self.add_tldr_examples(tldr_examples) | ||||
|         self.options_manager: OptionsManager = options_manager or OptionsManager() | ||||
|  | ||||
|     def _add_help(self): | ||||
|         """Add help argument to the parser.""" | ||||
| @@ -128,6 +146,30 @@ class CommandArgumentParser: | ||||
|             dest="help", | ||||
|         ) | ||||
|  | ||||
|     def add_tldr_examples(self, examples: list[tuple[str, str]]) -> None: | ||||
|         """ | ||||
|         Add TLDR examples to the parser. | ||||
|  | ||||
|         Args: | ||||
|             examples (list[tuple[str, str]]): List of (usage, description) tuples. | ||||
|         """ | ||||
|         if not any( | ||||
|             isinstance(example, tuple) and len(example) == 2 for example in examples | ||||
|         ): | ||||
|             raise CommandArgumentError( | ||||
|                 "TLDR examples must be a list of (usage, description) tuples" | ||||
|             ) | ||||
|         for usage, description in examples: | ||||
|             self._tldr_examples.append(TLDRExample(usage=usage, description=description)) | ||||
|  | ||||
|         if "tldr" not in self._dest_set: | ||||
|             self.add_argument( | ||||
|                 "--tldr", | ||||
|                 action=ArgumentAction.TLDR, | ||||
|                 help="Show quick usage examples and exit.", | ||||
|                 dest="tldr", | ||||
|             ) | ||||
|  | ||||
|     def _is_positional(self, flags: tuple[str, ...]) -> bool: | ||||
|         """Check if the flags are positional.""" | ||||
|         positional = False | ||||
| @@ -180,6 +222,7 @@ class CommandArgumentParser: | ||||
|                 ArgumentAction.STORE_FALSE, | ||||
|                 ArgumentAction.STORE_BOOL_OPTIONAL, | ||||
|                 ArgumentAction.HELP, | ||||
|                 ArgumentAction.TLDR, | ||||
|             ): | ||||
|                 raise CommandArgumentError( | ||||
|                     f"Argument with action {action} cannot be required" | ||||
| @@ -212,6 +255,7 @@ class CommandArgumentParser: | ||||
|             ArgumentAction.STORE_TRUE, | ||||
|             ArgumentAction.COUNT, | ||||
|             ArgumentAction.HELP, | ||||
|             ArgumentAction.TLDR, | ||||
|             ArgumentAction.STORE_BOOL_OPTIONAL, | ||||
|         ): | ||||
|             if nargs is not None: | ||||
| @@ -320,6 +364,7 @@ class CommandArgumentParser: | ||||
|             ArgumentAction.STORE_BOOL_OPTIONAL, | ||||
|             ArgumentAction.COUNT, | ||||
|             ArgumentAction.HELP, | ||||
|             ArgumentAction.TLDR, | ||||
|         ): | ||||
|             if positional: | ||||
|                 raise CommandArgumentError( | ||||
| @@ -764,6 +809,11 @@ class CommandArgumentParser: | ||||
|                     self.render_help() | ||||
|                 arg_states[spec.dest].consumed = True | ||||
|                 raise HelpSignal() | ||||
|             elif action == ArgumentAction.TLDR: | ||||
|                 if not from_validate: | ||||
|                     self.render_tldr() | ||||
|                 arg_states[spec.dest].consumed = True | ||||
|                 raise HelpSignal() | ||||
|             elif action == ArgumentAction.ACTION: | ||||
|                 assert isinstance( | ||||
|                     spec.resolver, BaseAction | ||||
| @@ -943,7 +993,11 @@ class CommandArgumentParser: | ||||
|  | ||||
|         # Required validation | ||||
|         for spec in self._arguments: | ||||
|             if spec.dest == "help": | ||||
|             if ( | ||||
|                 spec.dest == "help" | ||||
|                 or spec.dest == "tldr" | ||||
|                 and spec.action == ArgumentAction.TLDR | ||||
|             ): | ||||
|                 continue | ||||
|             if spec.required and not result.get(spec.dest): | ||||
|                 help_text = f" help: {spec.help}" if spec.help else "" | ||||
| @@ -1230,6 +1284,40 @@ class CommandArgumentParser: | ||||
|         if self.help_epilog: | ||||
|             self.console.print("\n" + self.help_epilog, style="dim") | ||||
|  | ||||
|     def render_tldr(self) -> None: | ||||
|         """ | ||||
|         Print TLDR examples for this command using Rich output. | ||||
|  | ||||
|         Displays brief usage examples with descriptions. | ||||
|         """ | ||||
|         if not self._tldr_examples: | ||||
|             self.console.print("[bold]No TLDR examples available.[/bold]") | ||||
|             return | ||||
|         is_cli_mode = self.options_manager.get("mode") in { | ||||
|             FalyxMode.RUN, | ||||
|             FalyxMode.PREVIEW, | ||||
|             FalyxMode.RUN_ALL, | ||||
|         } | ||||
|  | ||||
|         program = self.program or "falyx" | ||||
|         command = self.aliases[0] if self.aliases else self.command_key | ||||
|         if is_cli_mode: | ||||
|             command = f"{program} run {command}" | ||||
|         command = f"[{self.command_style}]{command}[/{self.command_style}]" | ||||
|  | ||||
|         usage = self.get_usage() | ||||
|         self.console.print(f"[bold]usage:[/] {usage}\n") | ||||
|  | ||||
|         if self.help_text: | ||||
|             self.console.print(f"{self.help_text}\n") | ||||
|  | ||||
|         self.console.print("[bold]examples:[/bold]") | ||||
|         for example in self._tldr_examples: | ||||
|             usage = f"{command} {example.usage.strip()}" | ||||
|             description = example.description.strip() | ||||
|             block = f"[bold]{usage}[/bold]\n{description}" | ||||
|             self.console.print(Padding(Panel(block, expand=False), (0, 2))) | ||||
|  | ||||
|     def __eq__(self, other: object) -> bool: | ||||
|         if not isinstance(other, CommandArgumentParser): | ||||
|             return False | ||||
|   | ||||
| @@ -14,8 +14,11 @@ from prompt_toolkit import PromptSession | ||||
| from prompt_toolkit.formatted_text import ( | ||||
|     AnyFormattedText, | ||||
|     FormattedText, | ||||
|     StyleAndTextTuples, | ||||
|     merge_formatted_text, | ||||
| ) | ||||
| from rich.console import Console | ||||
| from rich.text import Text | ||||
|  | ||||
| from falyx.options_manager import OptionsManager | ||||
| from falyx.themes import OneColors | ||||
| @@ -56,3 +59,31 @@ async def confirm_async( | ||||
|         validator=yes_no_validator(), | ||||
|     ) | ||||
|     return answer.upper() == "Y" | ||||
|  | ||||
|  | ||||
| def rich_text_to_prompt_text(text: Text | str | StyleAndTextTuples) -> StyleAndTextTuples: | ||||
|     """ | ||||
|     Convert a Rich Text object to a list of (style, text) tuples | ||||
|     compatible with prompt_toolkit. | ||||
|     """ | ||||
|     if isinstance(text, list): | ||||
|         if all(isinstance(pair, tuple) and len(pair) == 2 for pair in text): | ||||
|             return text | ||||
|         raise TypeError("Expected list of (style, text) tuples") | ||||
|  | ||||
|     if isinstance(text, str): | ||||
|         text = Text.from_markup(text) | ||||
|  | ||||
|     if not isinstance(text, Text): | ||||
|         raise TypeError("Expected str, rich.text.Text, or list of (style, text) tuples") | ||||
|  | ||||
|     console = Console(color_system=None, file=None, width=999, legacy_windows=False) | ||||
|     segments = text.render(console) | ||||
|  | ||||
|     prompt_fragments: StyleAndTextTuples = [] | ||||
|     for segment in segments: | ||||
|         style = segment.style or "" | ||||
|         string = segment.text | ||||
|         if string: | ||||
|             prompt_fragments.append((str(style), string)) | ||||
|     return prompt_fragments | ||||
|   | ||||
| @@ -21,6 +21,7 @@ from rich.markup import escape | ||||
| from rich.table import Table | ||||
|  | ||||
| from falyx.console import console | ||||
| from falyx.prompt_utils import rich_text_to_prompt_text | ||||
| from falyx.themes import OneColors | ||||
| from falyx.utils import CaseInsensitiveDict, chunks | ||||
| from falyx.validators import MultiIndexValidator, MultiKeyValidator | ||||
| @@ -293,7 +294,7 @@ async def prompt_for_index( | ||||
|         console.print(table, justify="center") | ||||
|  | ||||
|     selection = await prompt_session.prompt_async( | ||||
|         message=prompt_message, | ||||
|         message=rich_text_to_prompt_text(prompt_message), | ||||
|         validator=MultiIndexValidator( | ||||
|             min_index, | ||||
|             max_index, | ||||
| @@ -332,7 +333,7 @@ async def prompt_for_selection( | ||||
|         console.print(table, justify="center") | ||||
|  | ||||
|     selected = await prompt_session.prompt_async( | ||||
|         message=prompt_message, | ||||
|         message=rich_text_to_prompt_text(prompt_message), | ||||
|         validator=MultiKeyValidator( | ||||
|             keys, number_selections, separator, allow_duplicates, cancel_key | ||||
|         ), | ||||
|   | ||||
| @@ -1 +1 @@ | ||||
| __version__ = "0.1.68" | ||||
| __version__ = "0.1.69" | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| [tool.poetry] | ||||
| name = "falyx" | ||||
| version = "0.1.68" | ||||
| version = "0.1.69" | ||||
| description = "Reliable and introspectable async CLI action framework." | ||||
| authors = ["Roland Thomas Jr <roland@rtj.dev>"] | ||||
| license = "MIT" | ||||
|   | ||||
| @@ -7,7 +7,7 @@ from falyx.action import ConfirmAction | ||||
| async def test_confirm_action_yes_no(): | ||||
|     action = ConfirmAction( | ||||
|         name="test", | ||||
|         message="Are you sure?", | ||||
|         prompt_message="Are you sure?", | ||||
|         never_prompt=True, | ||||
|         confirm_type="yes_no", | ||||
|     ) | ||||
| @@ -20,7 +20,7 @@ async def test_confirm_action_yes_no(): | ||||
| async def test_confirm_action_yes_cancel(): | ||||
|     action = ConfirmAction( | ||||
|         name="test", | ||||
|         message="Are you sure?", | ||||
|         prompt_message="Are you sure?", | ||||
|         never_prompt=True, | ||||
|         confirm_type="yes_cancel", | ||||
|     ) | ||||
| @@ -33,7 +33,7 @@ async def test_confirm_action_yes_cancel(): | ||||
| async def test_confirm_action_yes_no_cancel(): | ||||
|     action = ConfirmAction( | ||||
|         name="test", | ||||
|         message="Are you sure?", | ||||
|         prompt_message="Are you sure?", | ||||
|         never_prompt=True, | ||||
|         confirm_type="yes_no_cancel", | ||||
|     ) | ||||
| @@ -46,7 +46,7 @@ async def test_confirm_action_yes_no_cancel(): | ||||
| async def test_confirm_action_type_word(): | ||||
|     action = ConfirmAction( | ||||
|         name="test", | ||||
|         message="Are you sure?", | ||||
|         prompt_message="Are you sure?", | ||||
|         never_prompt=True, | ||||
|         confirm_type="type_word", | ||||
|     ) | ||||
| @@ -59,7 +59,7 @@ async def test_confirm_action_type_word(): | ||||
| async def test_confirm_action_type_word_cancel(): | ||||
|     action = ConfirmAction( | ||||
|         name="test", | ||||
|         message="Are you sure?", | ||||
|         prompt_message="Are you sure?", | ||||
|         never_prompt=True, | ||||
|         confirm_type="type_word_cancel", | ||||
|     ) | ||||
| @@ -72,7 +72,7 @@ async def test_confirm_action_type_word_cancel(): | ||||
| async def test_confirm_action_ok_cancel(): | ||||
|     action = ConfirmAction( | ||||
|         name="test", | ||||
|         message="Are you sure?", | ||||
|         prompt_message="Are you sure?", | ||||
|         never_prompt=True, | ||||
|         confirm_type="ok_cancel", | ||||
|     ) | ||||
| @@ -85,7 +85,7 @@ async def test_confirm_action_ok_cancel(): | ||||
| async def test_confirm_action_acknowledge(): | ||||
|     action = ConfirmAction( | ||||
|         name="test", | ||||
|         message="Are you sure?", | ||||
|         prompt_message="Are you sure?", | ||||
|         never_prompt=True, | ||||
|         confirm_type="acknowledge", | ||||
|     ) | ||||
|   | ||||
| @@ -8,4 +8,4 @@ def test_argument_action(): | ||||
|     assert action != "invalid_action" | ||||
|     assert action.value == "append" | ||||
|     assert str(action) == "append" | ||||
|     assert len(ArgumentAction.choices()) == 9 | ||||
|     assert len(ArgumentAction.choices()) == 10 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user