feat(help): enhance CLI help rendering with Panels and TLDR validation
- Updated `Command.help_signature` to return a `(Panel, description)` tuple, enabling richer Rich-based help output with formatted panels. - Integrated `program` context into commands to display accurate CLI invocation (`falyx run …`) depending on mode (RUN, PREVIEW, RUN_ALL). - Refactored `_show_help` to print `Panel`-styled usage and descriptions instead of table rows. - Added `program` and `options_manager` propagation to built-in commands (Exit, History, Help) for consistent CLI display. - Improved `CommandArgumentParser.add_tldr_examples()` with stricter validation (`all()` instead of `any()`), and added new TLDR tests for coverage. - Simplified parser epilog text to `Tip: Use 'falyx run ?' to show available commands.` - Added tests for required `Argument` fields and TLDR examples. - Bumped version to 0.1.73.
This commit is contained in:
@ -22,6 +22,8 @@ from typing import Any, Awaitable, Callable
|
||||
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
|
||||
from rich.padding import Padding
|
||||
from rich.panel import Panel
|
||||
from rich.tree import Tree
|
||||
|
||||
from falyx.action.action import Action
|
||||
@ -32,6 +34,7 @@ from falyx.debug import register_debug_hooks
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import HookManager, HookType
|
||||
from falyx.logger import logger
|
||||
from falyx.mode import FalyxMode
|
||||
from falyx.options_manager import OptionsManager
|
||||
from falyx.parser.command_argument_parser import CommandArgumentParser
|
||||
from falyx.parser.signature import infer_args_from_func
|
||||
@ -348,20 +351,37 @@ class Command(BaseModel):
|
||||
return f" {command_keys_text:<20} {options_text} "
|
||||
|
||||
@property
|
||||
def help_signature(self) -> str:
|
||||
def help_signature(self) -> tuple[Panel, str]:
|
||||
"""Generate a help signature for the command."""
|
||||
is_cli_mode = self.options_manager.get("mode") in {
|
||||
FalyxMode.RUN,
|
||||
FalyxMode.PREVIEW,
|
||||
FalyxMode.RUN_ALL,
|
||||
}
|
||||
|
||||
program = f"{self.program} run " if is_cli_mode else ""
|
||||
|
||||
if self.arg_parser and not self.simple_help_signature:
|
||||
signature = [self.arg_parser.get_usage()]
|
||||
signature.append(f" {self.help_text or self.description}")
|
||||
usage = Panel(
|
||||
f"[{self.style}]{program}[/]{self.arg_parser.get_usage()}",
|
||||
expand=False,
|
||||
)
|
||||
description = [f" {self.help_text or self.description}"]
|
||||
if self.tags:
|
||||
signature.append(f" [dim]Tags: {', '.join(self.tags)}[/dim]")
|
||||
return "\n".join(signature).strip()
|
||||
description.append(f" [dim]Tags: {', '.join(self.tags)}[/dim]")
|
||||
return usage, "\n".join(description)
|
||||
|
||||
command_keys = " | ".join(
|
||||
[f"[{self.style}]{self.key}[/{self.style}]"]
|
||||
+ [f"[{self.style}]{alias}[/{self.style}]" for alias in self.aliases]
|
||||
)
|
||||
return f"{command_keys} {self.description}"
|
||||
return (
|
||||
Panel(
|
||||
f"[{self.style}]{program}[/]{command_keys} {self.description}",
|
||||
expand=False,
|
||||
),
|
||||
"",
|
||||
)
|
||||
|
||||
def log_summary(self) -> None:
|
||||
if self._context:
|
||||
|
@ -125,6 +125,7 @@ class Falyx:
|
||||
title: str | Markdown = "Menu",
|
||||
*,
|
||||
program: str | None = "falyx",
|
||||
program_style: str = OneColors.WHITE,
|
||||
usage: str | None = None,
|
||||
description: str | None = "Falyx CLI - Run structured async command workflows.",
|
||||
epilog: str | None = None,
|
||||
@ -158,13 +159,6 @@ class Falyx:
|
||||
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()
|
||||
self.history_command: Command | None = (
|
||||
self._get_history_command() if include_history_command else None
|
||||
)
|
||||
self.help_command: Command | None = (
|
||||
self._get_help_command() if include_help_command else None
|
||||
)
|
||||
self.console: Console = console
|
||||
self.welcome_message: str | Markdown | dict[str, Any] = welcome_message
|
||||
self.exit_message: str | Markdown | dict[str, Any] = exit_message
|
||||
@ -182,6 +176,13 @@ class Falyx:
|
||||
self.validate_options(cli_args, options)
|
||||
self._prompt_session: PromptSession | None = None
|
||||
self.options.set("mode", FalyxMode.MENU)
|
||||
self.exit_command: Command = self._get_exit_command()
|
||||
self.history_command: Command | None = (
|
||||
self._get_history_command() if include_history_command else None
|
||||
)
|
||||
self.help_command: Command | None = (
|
||||
self._get_help_command() if include_help_command else None
|
||||
)
|
||||
|
||||
def validate_options(
|
||||
self,
|
||||
@ -262,6 +263,8 @@ class Falyx:
|
||||
style=OneColors.DARK_RED,
|
||||
simple_help_signature=True,
|
||||
ignore_in_history=True,
|
||||
options_manager=self.options,
|
||||
program=self.program,
|
||||
)
|
||||
|
||||
def _get_history_command(self) -> Command:
|
||||
@ -271,6 +274,7 @@ class Falyx:
|
||||
command_description="History",
|
||||
command_style=OneColors.DARK_YELLOW,
|
||||
aliases=["HISTORY"],
|
||||
program=self.program,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-n",
|
||||
@ -314,17 +318,19 @@ class Falyx:
|
||||
arg_parser=parser,
|
||||
help_text="View the execution history of commands.",
|
||||
ignore_in_history=True,
|
||||
options_manager=self.options,
|
||||
program=self.program,
|
||||
)
|
||||
|
||||
async def _show_help(self, tag: str = "") -> None:
|
||||
is_cli_mode = self.options.get("mode") in {
|
||||
FalyxMode.RUN,
|
||||
FalyxMode.PREVIEW,
|
||||
FalyxMode.RUN_ALL,
|
||||
}
|
||||
|
||||
program = f"{self.program} run " if is_cli_mode else ""
|
||||
if tag:
|
||||
table = Table(
|
||||
title=tag.upper(),
|
||||
title_justify="left",
|
||||
show_header=False,
|
||||
box=box.SIMPLE,
|
||||
show_footer=False,
|
||||
)
|
||||
tag_lower = tag.lower()
|
||||
commands = [
|
||||
command
|
||||
@ -332,27 +338,31 @@ class Falyx:
|
||||
if any(tag_lower == tag.lower() for tag in command.tags)
|
||||
]
|
||||
for command in commands:
|
||||
table.add_row(command.help_signature)
|
||||
self.console.print(table)
|
||||
usage, description = command.help_signature
|
||||
self.console.print(usage)
|
||||
if description:
|
||||
self.console.print(description)
|
||||
return
|
||||
else:
|
||||
table = Table(
|
||||
title="Help",
|
||||
title_justify="left",
|
||||
title_style=OneColors.LIGHT_YELLOW_b,
|
||||
show_header=False,
|
||||
show_footer=False,
|
||||
box=box.SIMPLE,
|
||||
)
|
||||
|
||||
for command in self.commands.values():
|
||||
table.add_row(command.help_signature)
|
||||
usage, description = command.help_signature
|
||||
self.console.print(usage)
|
||||
if description:
|
||||
self.console.print(description)
|
||||
if self.help_command:
|
||||
table.add_row(self.help_command.help_signature)
|
||||
usage, description = self.help_command.help_signature
|
||||
self.console.print(usage)
|
||||
self.console.print(description)
|
||||
if not is_cli_mode:
|
||||
if self.history_command:
|
||||
table.add_row(self.history_command.help_signature)
|
||||
table.add_row(self.exit_command.help_signature)
|
||||
table.add_row(f"Tip: '[{OneColors.LIGHT_YELLOW}]?[KEY][/]' to preview a command ")
|
||||
self.console.print(table)
|
||||
usage, description = self.history_command.help_signature
|
||||
self.console.print(usage)
|
||||
self.console.print(description)
|
||||
usage, _ = self.exit_command.help_signature
|
||||
self.console.print(usage)
|
||||
self.console.print(
|
||||
f"Tip: '{program}[{OneColors.LIGHT_YELLOW}]?[KEY][/]' to preview a command "
|
||||
)
|
||||
|
||||
def _get_help_command(self) -> Command:
|
||||
"""Returns the help command for the menu."""
|
||||
@ -361,6 +371,7 @@ class Falyx:
|
||||
command_description="Help",
|
||||
command_style=OneColors.LIGHT_YELLOW,
|
||||
aliases=["?", "HELP", "LIST"],
|
||||
program=self.program,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-t",
|
||||
@ -378,6 +389,8 @@ class Falyx:
|
||||
style=OneColors.LIGHT_YELLOW,
|
||||
arg_parser=parser,
|
||||
ignore_in_history=True,
|
||||
options_manager=self.options,
|
||||
program=self.program,
|
||||
)
|
||||
|
||||
def _get_completer(self) -> FalyxCompleter:
|
||||
@ -549,6 +562,8 @@ class Falyx:
|
||||
confirm=confirm,
|
||||
confirm_message=confirm_message,
|
||||
ignore_in_history=True,
|
||||
options_manager=self.options,
|
||||
program=self.program,
|
||||
)
|
||||
|
||||
def add_submenu(
|
||||
@ -729,7 +744,7 @@ class Falyx:
|
||||
|
||||
def build_placeholder_menu(self) -> StyleAndTextTuples:
|
||||
"""
|
||||
Builds a menu placeholder for prompt_menu mode.
|
||||
Builds a menu placeholder for show_placeholder_menu.
|
||||
"""
|
||||
visible_commands = [item for item in self.commands.items() if not item[1].hidden]
|
||||
if not visible_commands:
|
||||
|
@ -142,12 +142,13 @@ class CommandArgumentParser:
|
||||
Args:
|
||||
examples (list[tuple[str, str]]): List of (usage, description) tuples.
|
||||
"""
|
||||
if not any(
|
||||
if not all(
|
||||
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))
|
||||
|
||||
@ -1362,7 +1363,7 @@ class CommandArgumentParser:
|
||||
command = self.aliases[0] if self.aliases else self.command_key
|
||||
if is_cli_mode:
|
||||
command = (
|
||||
f"{program} run [{self.command_style}]{command}[/{self.command_style}]"
|
||||
f"[{self.command_style}]{program} run {command}[/{self.command_style}]"
|
||||
)
|
||||
else:
|
||||
command = f"[{self.command_style}]{command}[/{self.command_style}]"
|
||||
|
@ -59,9 +59,7 @@ def get_root_parser(
|
||||
prog: str | None = "falyx",
|
||||
usage: str | None = None,
|
||||
description: str | None = "Falyx CLI - Run structured async command workflows.",
|
||||
epilog: (
|
||||
str | None
|
||||
) = "Tip: Use 'falyx run ?[COMMAND]' to preview any command from the CLI.",
|
||||
epilog: str | None = "Tip: Use 'falyx run ?' to show available commands.",
|
||||
parents: Sequence[ArgumentParser] | None = None,
|
||||
prefix_chars: str = "-",
|
||||
fromfile_prefix_chars: str | None = None,
|
||||
@ -242,7 +240,7 @@ def get_arg_parsers(
|
||||
- Use `falyx run ?[COMMAND]` from the CLI to preview a command.
|
||||
"""
|
||||
if epilog is None:
|
||||
epilog = f"Tip: Use '{prog} run ?[COMMAND]' to preview any command from the CLI."
|
||||
epilog = f"Tip: Use '{prog} run ?' to show available commands."
|
||||
if root_parser is None:
|
||||
parser = get_root_parser(
|
||||
prog=prog,
|
||||
|
@ -1 +1 @@
|
||||
__version__ = "0.1.72"
|
||||
__version__ = "0.1.73"
|
||||
|
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "falyx"
|
||||
version = "0.1.72"
|
||||
version = "0.1.73"
|
||||
description = "Reliable and introspectable async CLI action framework."
|
||||
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
|
||||
license = "MIT"
|
||||
|
@ -88,3 +88,11 @@ def test_argument_equality():
|
||||
assert arg != "not an argument"
|
||||
assert arg is not None
|
||||
assert arg != object()
|
||||
|
||||
|
||||
def test_argument_required():
|
||||
arg = Argument("--foo", dest="foo", required=True)
|
||||
assert arg.required is True
|
||||
|
||||
arg2 = Argument("--bar", dest="bar", required=False)
|
||||
assert arg2.required is False
|
||||
|
47
tests/test_parsers/test_tldr.py
Normal file
47
tests/test_parsers/test_tldr.py
Normal file
@ -0,0 +1,47 @@
|
||||
import pytest
|
||||
|
||||
from falyx.exceptions import CommandArgumentError
|
||||
from falyx.parser.command_argument_parser import CommandArgumentParser
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_tldr_examples():
|
||||
parser = CommandArgumentParser()
|
||||
parser.add_tldr_examples(
|
||||
[
|
||||
("example1", "This is the first example."),
|
||||
("example2", "This is the second example."),
|
||||
]
|
||||
)
|
||||
assert len(parser._tldr_examples) == 2
|
||||
assert parser._tldr_examples[0].usage == "example1"
|
||||
assert parser._tldr_examples[0].description == "This is the first example."
|
||||
assert parser._tldr_examples[1].usage == "example2"
|
||||
assert parser._tldr_examples[1].description == "This is the second example."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bad_tldr_examples():
|
||||
parser = CommandArgumentParser()
|
||||
with pytest.raises(CommandArgumentError):
|
||||
parser.add_tldr_examples(
|
||||
[
|
||||
("example1", "This is the first example.", "extra_arg"),
|
||||
("example2", "This is the second example."),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_tldr_examples_in_init():
|
||||
parser = CommandArgumentParser(
|
||||
tldr_examples=[
|
||||
("example1", "This is the first example."),
|
||||
("example2", "This is the second example."),
|
||||
]
|
||||
)
|
||||
assert len(parser._tldr_examples) == 2
|
||||
assert parser._tldr_examples[0].usage == "example1"
|
||||
assert parser._tldr_examples[0].description == "This is the first example."
|
||||
assert parser._tldr_examples[1].usage == "example2"
|
||||
assert parser._tldr_examples[1].description == "This is the second example."
|
Reference in New Issue
Block a user