Skip to content

Commit 353cf14

Browse files
committed
Integrated more of rich-argparse into cmd2.
Upgraded several parsers for built-in commands to use rich.
1 parent ee2c9bb commit 353cf14

File tree

14 files changed

+353
-243
lines changed

14 files changed

+353
-243
lines changed

cmd2/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
with contextlib.suppress(importlib_metadata.PackageNotFoundError):
77
__version__ = importlib_metadata.version(__name__)
88

9-
from . import plugin
9+
from . import (
10+
plugin,
11+
rich_utils,
12+
)
1013
from .ansi import (
1114
Bg,
1215
Cursor,
@@ -96,6 +99,7 @@
9699
'SkipPostcommandHooks',
97100
# modules
98101
'plugin',
102+
'rich_utils',
99103
# Utilities
100104
'categorize',
101105
'CompletionMode',

cmd2/ansi.py

Lines changed: 6 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
wcswidth,
2020
)
2121

22+
from . import rich_utils
23+
2224
#######################################################
2325
# Common ANSI escape sequence constants
2426
#######################################################
@@ -28,38 +30,6 @@
2830
BEL = '\a'
2931

3032

31-
class AllowStyle(Enum):
32-
"""Values for ``cmd2.ansi.allow_style``."""
33-
34-
ALWAYS = 'Always' # Always output ANSI style sequences
35-
NEVER = 'Never' # Remove ANSI style sequences from all output
36-
TERMINAL = 'Terminal' # Remove ANSI style sequences if the output is not going to the terminal
37-
38-
def __str__(self) -> str:
39-
"""Return value instead of enum name for printing in cmd2's set command."""
40-
return str(self.value)
41-
42-
def __repr__(self) -> str:
43-
"""Return quoted value instead of enum description for printing in cmd2's set command."""
44-
return repr(self.value)
45-
46-
47-
# Controls when ANSI style sequences are allowed in output
48-
allow_style = AllowStyle.TERMINAL
49-
"""When using outside of a cmd2 app, set this variable to one of:
50-
51-
- ``AllowStyle.ALWAYS`` - always output ANSI style sequences
52-
- ``AllowStyle.NEVER`` - remove ANSI style sequences from all output
53-
- ``AllowStyle.TERMINAL`` - remove ANSI style sequences if the output is not going to the terminal
54-
55-
to control how ANSI style sequences are handled by ``style_aware_write()``.
56-
57-
``style_aware_write()`` is called by cmd2 methods like ``poutput()``, ``perror()``,
58-
``pwarning()``, etc.
59-
60-
The default is ``AllowStyle.TERMINAL``.
61-
"""
62-
6333
# Regular expression to match ANSI style sequence
6434
ANSI_STYLE_RE = re.compile(rf'{ESC}\[[^m]*m')
6535

@@ -133,8 +103,11 @@ def style_aware_write(fileobj: IO[str], msg: str) -> None:
133103
:param fileobj: the file object being written to
134104
:param msg: the string being written
135105
"""
136-
if allow_style == AllowStyle.NEVER or (allow_style == AllowStyle.TERMINAL and not fileobj.isatty()):
106+
if rich_utils.allow_style == rich_utils.AllowStyle.NEVER or (
107+
rich_utils.allow_style == rich_utils.AllowStyle.TERMINAL and not fileobj.isatty()
108+
):
137109
msg = strip_style(msg)
110+
138111
fileobj.write(msg)
139112

140113

cmd2/argparse_custom.py

Lines changed: 136 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,6 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens)
236236
)
237237
from gettext import gettext
238238
from typing import (
239-
IO,
240239
TYPE_CHECKING,
241240
Any,
242241
ClassVar,
@@ -248,11 +247,23 @@ def my_completer(self, text, line, begidx, endidx, arg_tokens)
248247
runtime_checkable,
249248
)
250249

251-
from rich_argparse import RawTextRichHelpFormatter
250+
from rich.console import (
251+
Group,
252+
RenderableType,
253+
)
254+
from rich.table import Column, Table
255+
from rich.text import Text
256+
from rich_argparse import (
257+
ArgumentDefaultsRichHelpFormatter,
258+
MetavarTypeRichHelpFormatter,
259+
RawDescriptionRichHelpFormatter,
260+
RawTextRichHelpFormatter,
261+
RichHelpFormatter,
262+
)
252263

253264
from . import (
254-
ansi,
255265
constants,
266+
rich_utils,
256267
)
257268

258269
if TYPE_CHECKING: # pragma: no cover
@@ -759,19 +770,19 @@ def _add_argument_wrapper(
759770
# Validate nargs tuple
760771
if (
761772
len(nargs) != 2
762-
or not isinstance(nargs[0], int) # type: ignore[unreachable]
763-
or not (isinstance(nargs[1], int) or nargs[1] == constants.INFINITY) # type: ignore[misc]
773+
or not isinstance(nargs[0], int)
774+
or not (isinstance(nargs[1], int) or nargs[1] == constants.INFINITY)
764775
):
765776
raise ValueError('Ranged values for nargs must be a tuple of 1 or 2 integers')
766-
if nargs[0] >= nargs[1]: # type: ignore[misc]
777+
if nargs[0] >= nargs[1]:
767778
raise ValueError('Invalid nargs range. The first value must be less than the second')
768779
if nargs[0] < 0:
769780
raise ValueError('Negative numbers are invalid for nargs range')
770781

771782
# Save the nargs tuple as our range setting
772783
nargs_range = nargs
773784
range_min = nargs_range[0]
774-
range_max = nargs_range[1] # type: ignore[misc]
785+
range_max = nargs_range[1]
775786

776787
# Convert nargs into a format argparse recognizes
777788
if range_min == 0:
@@ -807,7 +818,7 @@ def _add_argument_wrapper(
807818
new_arg = orig_actions_container_add_argument(self, *args, **kwargs)
808819

809820
# Set the custom attributes
810-
new_arg.set_nargs_range(nargs_range) # type: ignore[arg-type, attr-defined]
821+
new_arg.set_nargs_range(nargs_range) # type: ignore[attr-defined]
811822

812823
if choices_provider:
813824
new_arg.set_choices_provider(choices_provider) # type: ignore[attr-defined]
@@ -996,13 +1007,9 @@ def _SubParsersAction_remove_parser(self: argparse._SubParsersAction, name: str)
9961007
############################################################################################################
9971008

9981009

999-
class Cmd2HelpFormatter(RawTextRichHelpFormatter):
1010+
class Cmd2HelpFormatter(RichHelpFormatter):
10001011
"""Custom help formatter to configure ordering of help text."""
10011012

1002-
# rich-argparse formats all group names with str.title().
1003-
# Override their formatter to do nothing.
1004-
group_name_formatter: ClassVar[Callable[[str], str]] = str
1005-
10061013
# Disable automatic highlighting in the help text.
10071014
highlights: ClassVar[list[str]] = []
10081015

@@ -1015,6 +1022,22 @@ class Cmd2HelpFormatter(RawTextRichHelpFormatter):
10151022
help_markup: ClassVar[bool] = False
10161023
text_markup: ClassVar[bool] = False
10171024

1025+
def __init__(
1026+
self,
1027+
prog: str,
1028+
indent_increment: int = 2,
1029+
max_help_position: int = 24,
1030+
width: Optional[int] = None,
1031+
*,
1032+
console: Optional[rich_utils.Cmd2Console] = None,
1033+
**kwargs: Any,
1034+
) -> None:
1035+
"""Initialize Cmd2HelpFormatter."""
1036+
if console is None:
1037+
console = rich_utils.Cmd2Console(sys.stdout)
1038+
1039+
super().__init__(prog, indent_increment, max_help_position, width, console=console, **kwargs)
1040+
10181041
def _format_usage(
10191042
self,
10201043
usage: Optional[str],
@@ -1207,17 +1230,93 @@ def _format_args(self, action: argparse.Action, default_metavar: Union[str, tupl
12071230
return super()._format_args(action, default_metavar) # type: ignore[arg-type]
12081231

12091232

1233+
class RawDescriptionCmd2HelpFormatter(
1234+
RawDescriptionRichHelpFormatter,
1235+
Cmd2HelpFormatter,
1236+
):
1237+
"""Cmd2 help message formatter which retains any formatting in descriptions and epilogs."""
1238+
1239+
1240+
class RawTextCmd2HelpFormatter(
1241+
RawTextRichHelpFormatter,
1242+
Cmd2HelpFormatter,
1243+
):
1244+
"""Cmd2 help message formatter which retains formatting of all help text."""
1245+
1246+
1247+
class ArgumentDefaultsCmd2HelpFormatter(
1248+
ArgumentDefaultsRichHelpFormatter,
1249+
Cmd2HelpFormatter,
1250+
):
1251+
"""Cmd2 help message formatter which adds default values to argument help."""
1252+
1253+
1254+
class MetavarTypeCmd2HelpFormatter(
1255+
MetavarTypeRichHelpFormatter,
1256+
Cmd2HelpFormatter,
1257+
):
1258+
"""Cmd2 help message formatter which uses the argument 'type' as the default
1259+
metavar value (instead of the argument 'dest').
1260+
""" # noqa: D205
1261+
1262+
1263+
class TextGroup:
1264+
"""A block of text which is formatted like an argparse argument group, including a title.
1265+
1266+
Title:
1267+
Here is the first row of text.
1268+
Here is yet another row of text.
1269+
"""
1270+
1271+
def __init__(
1272+
self,
1273+
title: str,
1274+
text: RenderableType,
1275+
formatter_creator: Callable[[], Cmd2HelpFormatter],
1276+
) -> None:
1277+
"""TextGroup initializer.
1278+
1279+
:param title: the group's title
1280+
:param text: the group's text (string or object that may be rendered by Rich)
1281+
:param formatter_creator: callable which returns a Cmd2HelpFormatter instance
1282+
"""
1283+
self.title = title
1284+
self.text = text
1285+
self.formatter_creator = formatter_creator
1286+
1287+
def __rich__(self) -> Group:
1288+
"""Perform custom rendering."""
1289+
formatter = self.formatter_creator()
1290+
1291+
styled_title = Text(
1292+
type(formatter).group_name_formatter(f"{self.title}:"),
1293+
style=formatter.styles["argparse.groups"],
1294+
)
1295+
1296+
# Left pad the text like an argparse argument group does
1297+
left_padding = formatter._indent_increment
1298+
text_table = Table(
1299+
Column(overflow="fold"),
1300+
box=None,
1301+
show_header=False,
1302+
padding=(0, 0, 0, left_padding),
1303+
)
1304+
text_table.add_row(self.text)
1305+
1306+
return Group(styled_title, text_table)
1307+
1308+
12101309
class Cmd2ArgumentParser(argparse.ArgumentParser):
12111310
"""Custom ArgumentParser class that improves error and help output."""
12121311

12131312
def __init__(
12141313
self,
12151314
prog: Optional[str] = None,
12161315
usage: Optional[str] = None,
1217-
description: Optional[str] = None,
1218-
epilog: Optional[str] = None,
1316+
description: Optional[RenderableType] = None,
1317+
epilog: Optional[RenderableType] = None,
12191318
parents: Sequence[argparse.ArgumentParser] = (),
1220-
formatter_class: type[argparse.HelpFormatter] = Cmd2HelpFormatter,
1319+
formatter_class: type[Cmd2HelpFormatter] = Cmd2HelpFormatter,
12211320
prefix_chars: str = '-',
12221321
fromfile_prefix_chars: Optional[str] = None,
12231322
argument_default: Optional[str] = None,
@@ -1247,8 +1346,8 @@ def __init__(
12471346
super().__init__(
12481347
prog=prog,
12491348
usage=usage,
1250-
description=description,
1251-
epilog=epilog,
1349+
description=description, # type: ignore[arg-type]
1350+
epilog=epilog, # type: ignore[arg-type]
12521351
parents=parents if parents else [],
12531352
formatter_class=formatter_class, # type: ignore[arg-type]
12541353
prefix_chars=prefix_chars,
@@ -1261,6 +1360,10 @@ def __init__(
12611360
**kwargs, # added in Python 3.14
12621361
)
12631362

1363+
# Recast to assist type checkers since these can be Rich renderables in a Cmd2HelpFormatter.
1364+
self.description: Optional[RenderableType] = self.description # type: ignore[assignment]
1365+
self.epilog: Optional[RenderableType] = self.epilog # type: ignore[assignment]
1366+
12641367
self.set_ap_completer_type(ap_completer_type) # type: ignore[attr-defined]
12651368

12661369
def add_subparsers(self, **kwargs: Any) -> argparse._SubParsersAction: # type: ignore[type-arg]
@@ -1290,8 +1393,18 @@ def error(self, message: str) -> NoReturn:
12901393
formatted_message += '\n ' + line
12911394

12921395
self.print_usage(sys.stderr)
1293-
formatted_message = ansi.style_error(formatted_message)
1294-
self.exit(2, f'{formatted_message}\n\n')
1396+
1397+
# Add error style to message
1398+
console = self._get_formatter().console
1399+
with console.capture() as capture:
1400+
console.print(formatted_message, style="cmd2.error", crop=False)
1401+
formatted_message = f"{capture.get()}"
1402+
1403+
self.exit(2, f'{formatted_message}\n')
1404+
1405+
def _get_formatter(self) -> Cmd2HelpFormatter:
1406+
"""Override _get_formatter with customizations for Cmd2HelpFormatter."""
1407+
return cast(Cmd2HelpFormatter, super()._get_formatter())
12951408

12961409
def format_help(self) -> str:
12971410
"""Return a string containing a help message, including the program usage and information about the arguments.
@@ -1350,12 +1463,9 @@ def format_help(self) -> str:
13501463
# determine help from format above
13511464
return formatter.format_help() + '\n'
13521465

1353-
def _print_message(self, message: str, file: Optional[IO[str]] = None) -> None: # type: ignore[override]
1354-
# Override _print_message to use style_aware_write() since we use ANSI escape characters to support color
1355-
if message:
1356-
if file is None:
1357-
file = sys.stderr
1358-
ansi.style_aware_write(file, message)
1466+
def create_text_group(self, title: str, text: RenderableType) -> TextGroup:
1467+
"""Create a TextGroup using this parser's formatter creator."""
1468+
return TextGroup(title, text, self._get_formatter)
13591469

13601470

13611471
class Cmd2AttributeWrapper:

0 commit comments

Comments
 (0)