From fa5e2a4c2cabf694f761bdab4a41c58f0650e708 Mon Sep 17 00:00:00 2001 From: Roland Thomas Date: Tue, 22 Jul 2025 21:56:44 -0400 Subject: [PATCH] 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 --- examples/argument_examples.py | 9 ++- examples/falyx_demo.py | 2 +- falyx/action/confirm_action.py | 34 ++++---- falyx/action/menu_action.py | 9 ++- falyx/action/prompt_menu_action.py | 9 ++- falyx/action/select_file_action.py | 7 +- falyx/action/selection_action.py | 7 +- falyx/action/user_input_action.py | 30 ++++---- falyx/bottom_bar.py | 2 +- falyx/command.py | 4 + falyx/falyx.py | 31 ++++---- falyx/mode.py | 12 +++ falyx/parser/argument_action.py | 2 + falyx/parser/command_argument_parser.py | 90 +++++++++++++++++++++- falyx/prompt_utils.py | 31 ++++++++ falyx/selection.py | 5 +- falyx/version.py | 2 +- pyproject.toml | 2 +- tests/test_actions/test_confirm_action.py | 14 ++-- tests/test_parsers/test_argument_action.py | 2 +- 20 files changed, 235 insertions(+), 69 deletions(-) create mode 100644 falyx/mode.py diff --git a/examples/argument_examples.py b/examples/argument_examples.py index 6793c27..d2b4724 100644 --- a/examples/argument_examples.py +++ b/examples/argument_examples.py @@ -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", diff --git a/examples/falyx_demo.py b/examples/falyx_demo.py index 4ba791b..45b1195 100644 --- a/examples/falyx_demo.py +++ b/examples/falyx_demo.py @@ -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__}") diff --git a/falyx/action/confirm_action.py b/falyx/action/confirm_action.py index abb990d..a33e5a6 100644 --- a/falyx/action/confirm_action.py +++ b/falyx/action/confirm_action.py @@ -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})" ) diff --git a/falyx/action/menu_action.py b/falyx/action/menu_action.py index 395a93d..5174966 100644 --- a/falyx/action/menu_action.py +++ b/falyx/action/menu_action.py @@ -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 diff --git a/falyx/action/prompt_menu_action.py b/falyx/action/prompt_menu_action.py index 36d3400..0d0b211 100644 --- a/falyx/action/prompt_menu_action.py +++ b/falyx/action/prompt_menu_action.py @@ -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]: diff --git a/falyx/action/select_file_action.py b/falyx/action/select_file_action.py index 56ce4f8..f59e05d 100644 --- a/falyx/action/select_file_action.py +++ b/falyx/action/select_file_action.py @@ -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 diff --git a/falyx/action/selection_action.py b/falyx/action/selection_action.py index 032eb08..1ba41d0 100644 --- a/falyx/action/selection_action.py +++ b/falyx/action/selection_action.py @@ -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 diff --git a/falyx/action/user_input_action.py b/falyx/action/user_input_action.py index 9bad15c..7923f18 100644 --- a/falyx/action/user_input_action.py +++ b/falyx/action/user_input_action.py @@ -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}", "") - if "{last_result}" in self.prompt_text - else self.prompt_text + prompt_message = ( + self.prompt_message.replace("{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: diff --git a/falyx/bottom_bar.py b/falyx/bottom_bar.py index 55ca438..7c3ea0b 100644 --- a/falyx/bottom_bar.py +++ b/falyx/bottom_bar.py @@ -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, diff --git a/falyx/command.py b/falyx/command.py index d096aac..7894a18 100644 --- a/falyx/command.py +++ b/falyx/command.py @@ -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) diff --git a/falyx/falyx.py b/falyx/falyx.py index 11e1de5..9424e7e 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -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() diff --git a/falyx/mode.py b/falyx/mode.py new file mode 100644 index 0000000..c6782ce --- /dev/null +++ b/falyx/mode.py @@ -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" diff --git a/falyx/parser/argument_action.py b/falyx/parser/argument_action.py index 2f80704..b4db0a2 100644 --- a/falyx/parser/argument_action.py +++ b/falyx/parser/argument_action.py @@ -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]: diff --git a/falyx/parser/command_argument_parser.py b/falyx/parser/command_argument_parser.py index 88d5e0a..d78480f 100644 --- a/falyx/parser/command_argument_parser.py +++ b/falyx/parser/command_argument_parser.py @@ -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 diff --git a/falyx/prompt_utils.py b/falyx/prompt_utils.py index 8fef397..1138d47 100644 --- a/falyx/prompt_utils.py +++ b/falyx/prompt_utils.py @@ -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 diff --git a/falyx/selection.py b/falyx/selection.py index 7185de9..49a39d3 100644 --- a/falyx/selection.py +++ b/falyx/selection.py @@ -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 ), diff --git a/falyx/version.py b/falyx/version.py index 613d040..a9221c5 100644 --- a/falyx/version.py +++ b/falyx/version.py @@ -1 +1 @@ -__version__ = "0.1.68" +__version__ = "0.1.69" diff --git a/pyproject.toml b/pyproject.toml index 45e0559..927c5d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 "] license = "MIT" diff --git a/tests/test_actions/test_confirm_action.py b/tests/test_actions/test_confirm_action.py index f25e1c8..1c7a6fb 100644 --- a/tests/test_actions/test_confirm_action.py +++ b/tests/test_actions/test_confirm_action.py @@ -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", ) diff --git a/tests/test_parsers/test_argument_action.py b/tests/test_parsers/test_argument_action.py index 005a05e..0ebf654 100644 --- a/tests/test_parsers/test_argument_action.py +++ b/tests/test_parsers/test_argument_action.py @@ -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