Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ prompt is displayed.
- `cmd2` no longer sets a default title for a subparsers group. If you desire a title, you will
need to pass one in like this `parser.add_subparsers(title="subcommands")`. This is standard
`argparse` behavior.
- `TextGroup` is now a standalone Rich renderable.
- Removed `formatter_creator` parameter from `TextGroup.__init__()`.
- Removed `Cmd2ArgumentParser.create_text_group()` method.
- Enhancements
- New `cmd2.Cmd` parameters
- **auto_suggest**: (boolean) if `True`, provide fish shell style auto-suggestions. These
Expand Down
4 changes: 3 additions & 1 deletion cmd2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from .argparse_completer import set_default_ap_completer_type
from .argparse_custom import (
Cmd2ArgumentParser,
TextGroup,
register_argparse_argument_parameter,
set_default_argument_parser_type,
)
Expand Down Expand Up @@ -59,6 +60,7 @@
'DEFAULT_SHORTCUTS',
# Argparse Exports
'Cmd2ArgumentParser',
'TextGroup',
'register_argparse_argument_parameter',
'set_default_ap_completer_type',
'set_default_argument_parser_type',
Expand All @@ -68,7 +70,7 @@
'CommandSet',
'Statement',
# Colors
"Color",
'Color',
# Completion
'Choices',
'CompletionItem',
Expand Down
45 changes: 33 additions & 12 deletions cmd2/argparse_custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,11 @@ def get_choices(self) -> Choices:
)

from rich.console import (
Console,
ConsoleOptions,
Group,
RenderableType,
RenderResult,
)
from rich.table import Column
from rich.text import Text
Expand Down Expand Up @@ -506,7 +509,7 @@ def _ActionsContainer_add_argument( # noqa: N802


# Overwrite _ActionsContainer.add_argument with our patch
setattr(argparse._ActionsContainer, 'add_argument', _ActionsContainer_add_argument)
argparse._ActionsContainer.add_argument = _ActionsContainer_add_argument # type: ignore[method-assign]


############################################################################################################
Expand Down Expand Up @@ -560,6 +563,20 @@ def console(self, console: Cmd2RichArgparseConsole) -> None:
"""Set our console instance."""
self._console = console

def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
"""Provide this help formatter to renderables via the console."""
if isinstance(console, Cmd2RichArgparseConsole):
old_formatter = console.help_formatter
console.help_formatter = self
try:
yield from super().__rich_console__(console, options)
finally:
console.help_formatter = old_formatter
else:
# Handle rendering on a console type other than Cmd2RichArgparseConsole.
# In this case, we don't set the help_formatter on the console.
yield from super().__rich_console__(console, options)

def _set_color(self, color: bool, **kwargs: Any) -> None:
"""Set the color for the help output.

Expand Down Expand Up @@ -680,25 +697,33 @@ def __init__(
self,
title: str,
text: RenderableType,
formatter_creator: Callable[..., Cmd2HelpFormatter],
) -> None:
"""TextGroup initializer.

:param title: the group's title
:param text: the group's text (string or object that may be rendered by Rich)
:param formatter_creator: callable which returns a Cmd2HelpFormatter instance
"""
self.title = title
self.text = text
self.formatter_creator = formatter_creator

def __rich__(self) -> Group:
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
"""Return a renderable Rich Group object for the class instance.

This method formats the title and indents the text to match argparse
group styling, making the object displayable by a Rich console.
"""
formatter = self.formatter_creator()
formatter: Cmd2HelpFormatter | None = None
if isinstance(console, Cmd2RichArgparseConsole):
formatter = console.help_formatter

# This occurs if the console is not a Cmd2RichArgparseConsole or if the
# TextGroup is printed directly instead of as part of an argparse help message.
if formatter is None:
# If console is the wrong type, then have Cmd2HelpFormatter create its own.
formatter = Cmd2HelpFormatter(
prog="",
console=console if isinstance(console, Cmd2RichArgparseConsole) else None,
)

styled_title = Text(
type(formatter).group_name_formatter(f"{self.title}:"),
Expand All @@ -708,7 +733,7 @@ def __rich__(self) -> Group:
# Indent text like an argparse argument group does
indented_text = ru.indent(self.text, formatter._indent_increment)

return Group(styled_title, indented_text)
yield Group(styled_title, indented_text)


class Cmd2ArgumentParser(argparse.ArgumentParser):
Expand Down Expand Up @@ -762,7 +787,7 @@ def __init__(
add_help=add_help,
allow_abbrev=allow_abbrev,
exit_on_error=exit_on_error,
**kwargs, # added in Python 3.14
**kwargs,
)

self.ap_completer_type = ap_completer_type
Expand Down Expand Up @@ -995,10 +1020,6 @@ def format_help(self) -> str:
"""Override to add a newline."""
return super().format_help() + '\n'

def create_text_group(self, title: str, text: RenderableType) -> TextGroup:
"""Create a TextGroup using this parser's formatter creator."""
return TextGroup(title, text, self._get_formatter)

def _get_nargs_pattern(self, action: argparse.Action) -> str:
"""Override to support nargs ranges."""
nargs_range = action.get_nargs_range() # type: ignore[attr-defined]
Expand Down
19 changes: 11 additions & 8 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,10 @@
)
from . import rich_utils as ru
from . import string_utils as su
from .argparse_custom import Cmd2ArgumentParser
from .argparse_custom import (
Cmd2ArgumentParser,
TextGroup,
)
from .clipboard import (
get_paste_buffer,
write_to_paste_buffer,
Expand Down Expand Up @@ -3725,7 +3728,7 @@ def _build_alias_parser() -> Cmd2ArgumentParser:
"An alias is a command that enables replacement of a word by another string.",
)
alias_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description)
alias_parser.epilog = alias_parser.create_text_group(
alias_parser.epilog = TextGroup(
"See Also",
"macro",
)
Expand Down Expand Up @@ -3757,7 +3760,7 @@ def _build_alias_create_parser(cls) -> Cmd2ArgumentParser:
"for the actual command the alias resolves to."
),
)
alias_create_parser.epilog = alias_create_parser.create_text_group("Notes", alias_create_notes)
alias_create_parser.epilog = TextGroup("Notes", alias_create_notes)

# Add arguments
alias_create_parser.add_argument('name', help='name of this alias')
Expand Down Expand Up @@ -3941,7 +3944,7 @@ def _build_macro_parser() -> Cmd2ArgumentParser:
"A macro is similar to an alias, but it can contain argument placeholders.",
)
macro_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_description)
macro_parser.epilog = macro_parser.create_text_group(
macro_parser.epilog = TextGroup(
"See Also",
"alias",
)
Expand Down Expand Up @@ -4004,7 +4007,7 @@ def _build_macro_create_parser(cls) -> Cmd2ArgumentParser:
"This default behavior changes if custom completion for macro arguments has been implemented."
),
)
macro_create_parser.epilog = macro_create_parser.create_text_group("Notes", macro_create_notes)
macro_create_parser.epilog = TextGroup("Notes", macro_create_notes)

# Add arguments
macro_create_parser.add_argument('name', help='name of this macro')
Expand Down Expand Up @@ -4511,7 +4514,7 @@ def do_shortcuts(self, _: argparse.Namespace) -> None:
@staticmethod
def _build__eof_parser() -> Cmd2ArgumentParser:
_eof_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Called when Ctrl-D is pressed.")
_eof_parser.epilog = _eof_parser.create_text_group(
_eof_parser.epilog = TextGroup(
"Note",
"This command is for internal use and is not intended to be called from the command line.",
)
Expand Down Expand Up @@ -5388,7 +5391,7 @@ def _persist_history(self) -> None:
def _build_edit_parser(cls) -> Cmd2ArgumentParser:
edit_description = "Run a text editor and optionally open a file with it."
edit_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=edit_description)
edit_parser.epilog = edit_parser.create_text_group(
edit_parser.epilog = TextGroup(
"Note",
Text.assemble(
"To set a new editor, run: ",
Expand Down Expand Up @@ -5519,7 +5522,7 @@ def _build__relative_run_script_parser(cls) -> Cmd2ArgumentParser:
),
)

_relative_run_script_parser.epilog = _relative_run_script_parser.create_text_group(
_relative_run_script_parser.epilog = TextGroup(
"Note",
"This command is intended to be used from within a text script.",
)
Expand Down
19 changes: 19 additions & 0 deletions cmd2/rich_utils.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
"""Provides common utilities to support Rich in cmd2-based applications."""

import re
import threading
from collections.abc import Mapping
from enum import Enum
from typing import (
IO,
TYPE_CHECKING,
Any,
TypedDict,
)

if TYPE_CHECKING:
from .argparse_custom import Cmd2HelpFormatter

from rich.box import SIMPLE_HEAD
from rich.console import (
Console,
Expand Down Expand Up @@ -345,6 +350,9 @@ class Cmd2RichArgparseConsole(Cmd2BaseConsole):
and highlighting. Because rich-argparse does markup and highlighting without
involving the console, disabling these settings does not affect the library's
internal functionality.

Additionally, this console serves as a context carrier for the active help formatter,
allowing renderables to access formatting settings during help generation.
"""

def __init__(self, *, file: IO[str] | None = None) -> None:
Expand All @@ -360,6 +368,17 @@ def __init__(self, *, file: IO[str] | None = None) -> None:
emoji=False,
highlight=False,
)
self._thread_local = threading.local()

@property
def help_formatter(self) -> 'Cmd2HelpFormatter | None':
"""Return the active help formatter for this thread."""
return getattr(self._thread_local, 'help_formatter', None)

@help_formatter.setter
def help_formatter(self, value: 'Cmd2HelpFormatter | None') -> None:
"""Set the active help formatter for this thread."""
self._thread_local.help_formatter = value


class Cmd2ExceptionConsole(Cmd2BaseConsole):
Expand Down
3 changes: 0 additions & 3 deletions ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,6 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
mccabe.max-complexity = 49

[lint.per-file-ignores]
# Do not call setattr with constant attribute value
"cmd2/argparse_custom.py" = ["B010"]

# Ignore various warnings in examples/ directory
"examples/*.py" = [
"ANN", # Ignore all type annotation rules in examples folder
Expand Down
63 changes: 63 additions & 0 deletions tests/test_argparse_custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import sys

import pytest
from rich.console import Console

import cmd2
from cmd2 import (
Expand All @@ -22,6 +23,68 @@
from .conftest import run_cmd


def test_text_group_direct_cmd2() -> None:
"""Print a TextGroup directly using a Cmd2RichArgparseConsole."""
title = "Notes"
content = "Some text"
text_group = argparse_custom.TextGroup(title, content)
console = Cmd2RichArgparseConsole()
with console.capture() as capture:
console.print(text_group)
output = capture.get()
assert "Notes:" in output
assert " Some text" in output


def test_text_group_direct_plain() -> None:
"""Print a TextGroup directly not using a Cmd2RichArgparseConsole."""
title = "Notes"
content = "Some text"
text_group = argparse_custom.TextGroup(title, content)
console = Console()
with console.capture() as capture:
console.print(text_group)
output = capture.get()
assert "Notes:" in output
assert " Some text" in output


def test_text_group_in_parser_cmd2(capsys) -> None:
"""Print a TextGroup with argparse using a Cmd2RichArgparseConsole."""
parser = Cmd2ArgumentParser(prog="test")
parser.epilog = argparse_custom.TextGroup("Notes", "Some text")

# Render help
parser.print_help()
out, _ = capsys.readouterr()

assert "Notes:" in out
assert " Some text" in out


def test_text_group_in_parser_plain(capsys) -> None:
"""Print a TextGroup with argparse not using a Cmd2RichArgparseConsole."""

class CustomParser(Cmd2ArgumentParser):
from typing import Any

def _get_formatter(self, **kwargs: Any) -> Cmd2HelpFormatter:
"""Overwrite the formatter's console with a plain one."""
formatter = super()._get_formatter(**kwargs)
formatter.console = Console()
return formatter

parser = CustomParser(prog="test")
parser.epilog = argparse_custom.TextGroup("Notes", "Some text")

# Render help
parser.print_help()
out, _ = capsys.readouterr()

assert "Notes:" in out
assert " Some text" in out


class ApCustomTestApp(cmd2.Cmd):
"""Test app for cmd2's argparse customization"""

Expand Down
Loading