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