Skip to content

Commit d5da802

Browse files
authored
Removed TextGroup's dependency on a specific parser instance. (#1637)
1 parent 41eef55 commit d5da802

7 files changed

Lines changed: 132 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ prompt is displayed.
8181
- `cmd2` no longer sets a default title for a subparsers group. If you desire a title, you will
8282
need to pass one in like this `parser.add_subparsers(title="subcommands")`. This is standard
8383
`argparse` behavior.
84+
- `TextGroup` is now a standalone Rich renderable.
85+
- Removed `formatter_creator` parameter from `TextGroup.__init__()`.
86+
- Removed `Cmd2ArgumentParser.create_text_group()` method.
8487
- Enhancements
8588
- New `cmd2.Cmd` parameters
8689
- **auto_suggest**: (boolean) if `True`, provide fish shell style auto-suggestions. These

cmd2/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from .argparse_completer import set_default_ap_completer_type
1515
from .argparse_custom import (
1616
Cmd2ArgumentParser,
17+
TextGroup,
1718
register_argparse_argument_parameter,
1819
set_default_argument_parser_type,
1920
)
@@ -59,6 +60,7 @@
5960
'DEFAULT_SHORTCUTS',
6061
# Argparse Exports
6162
'Cmd2ArgumentParser',
63+
'TextGroup',
6264
'register_argparse_argument_parameter',
6365
'set_default_ap_completer_type',
6466
'set_default_argument_parser_type',
@@ -68,7 +70,7 @@
6870
'CommandSet',
6971
'Statement',
7072
# Colors
71-
"Color",
73+
'Color',
7274
# Completion
7375
'Choices',
7476
'CompletionItem',

cmd2/argparse_custom.py

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -243,8 +243,11 @@ def get_choices(self) -> Choices:
243243
)
244244

245245
from rich.console import (
246+
Console,
247+
ConsoleOptions,
246248
Group,
247249
RenderableType,
250+
RenderResult,
248251
)
249252
from rich.table import Column
250253
from rich.text import Text
@@ -506,7 +509,7 @@ def _ActionsContainer_add_argument( # noqa: N802
506509

507510

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

511514

512515
############################################################################################################
@@ -560,6 +563,20 @@ def console(self, console: Cmd2RichArgparseConsole) -> None:
560563
"""Set our console instance."""
561564
self._console = console
562565

566+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
567+
"""Provide this help formatter to renderables via the console."""
568+
if isinstance(console, Cmd2RichArgparseConsole):
569+
old_formatter = console.help_formatter
570+
console.help_formatter = self
571+
try:
572+
yield from super().__rich_console__(console, options)
573+
finally:
574+
console.help_formatter = old_formatter
575+
else:
576+
# Handle rendering on a console type other than Cmd2RichArgparseConsole.
577+
# In this case, we don't set the help_formatter on the console.
578+
yield from super().__rich_console__(console, options)
579+
563580
def _set_color(self, color: bool, **kwargs: Any) -> None:
564581
"""Set the color for the help output.
565582
@@ -680,25 +697,33 @@ def __init__(
680697
self,
681698
title: str,
682699
text: RenderableType,
683-
formatter_creator: Callable[..., Cmd2HelpFormatter],
684700
) -> None:
685701
"""TextGroup initializer.
686702
687703
:param title: the group's title
688704
:param text: the group's text (string or object that may be rendered by Rich)
689-
:param formatter_creator: callable which returns a Cmd2HelpFormatter instance
690705
"""
691706
self.title = title
692707
self.text = text
693-
self.formatter_creator = formatter_creator
694708

695-
def __rich__(self) -> Group:
709+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
696710
"""Return a renderable Rich Group object for the class instance.
697711
698712
This method formats the title and indents the text to match argparse
699713
group styling, making the object displayable by a Rich console.
700714
"""
701-
formatter = self.formatter_creator()
715+
formatter: Cmd2HelpFormatter | None = None
716+
if isinstance(console, Cmd2RichArgparseConsole):
717+
formatter = console.help_formatter
718+
719+
# This occurs if the console is not a Cmd2RichArgparseConsole or if the
720+
# TextGroup is printed directly instead of as part of an argparse help message.
721+
if formatter is None:
722+
# If console is the wrong type, then have Cmd2HelpFormatter create its own.
723+
formatter = Cmd2HelpFormatter(
724+
prog="",
725+
console=console if isinstance(console, Cmd2RichArgparseConsole) else None,
726+
)
702727

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

711-
return Group(styled_title, indented_text)
736+
yield Group(styled_title, indented_text)
712737

713738

714739
class Cmd2ArgumentParser(argparse.ArgumentParser):
@@ -762,7 +787,7 @@ def __init__(
762787
add_help=add_help,
763788
allow_abbrev=allow_abbrev,
764789
exit_on_error=exit_on_error,
765-
**kwargs, # added in Python 3.14
790+
**kwargs,
766791
)
767792

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

998-
def create_text_group(self, title: str, text: RenderableType) -> TextGroup:
999-
"""Create a TextGroup using this parser's formatter creator."""
1000-
return TextGroup(title, text, self._get_formatter)
1001-
10021023
def _get_nargs_pattern(self, action: argparse.Action) -> str:
10031024
"""Override to support nargs ranges."""
10041025
nargs_range = action.get_nargs_range() # type: ignore[attr-defined]

cmd2/cmd2.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,10 @@
108108
)
109109
from . import rich_utils as ru
110110
from . import string_utils as su
111-
from .argparse_custom import Cmd2ArgumentParser
111+
from .argparse_custom import (
112+
Cmd2ArgumentParser,
113+
TextGroup,
114+
)
112115
from .clipboard import (
113116
get_paste_buffer,
114117
write_to_paste_buffer,
@@ -3725,7 +3728,7 @@ def _build_alias_parser() -> Cmd2ArgumentParser:
37253728
"An alias is a command that enables replacement of a word by another string.",
37263729
)
37273730
alias_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description)
3728-
alias_parser.epilog = alias_parser.create_text_group(
3731+
alias_parser.epilog = TextGroup(
37293732
"See Also",
37303733
"macro",
37313734
)
@@ -3757,7 +3760,7 @@ def _build_alias_create_parser(cls) -> Cmd2ArgumentParser:
37573760
"for the actual command the alias resolves to."
37583761
),
37593762
)
3760-
alias_create_parser.epilog = alias_create_parser.create_text_group("Notes", alias_create_notes)
3763+
alias_create_parser.epilog = TextGroup("Notes", alias_create_notes)
37613764

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

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

5522-
_relative_run_script_parser.epilog = _relative_run_script_parser.create_text_group(
5525+
_relative_run_script_parser.epilog = TextGroup(
55235526
"Note",
55245527
"This command is intended to be used from within a text script.",
55255528
)

cmd2/rich_utils.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
"""Provides common utilities to support Rich in cmd2-based applications."""
22

33
import re
4+
import threading
45
from collections.abc import Mapping
56
from enum import Enum
67
from typing import (
78
IO,
9+
TYPE_CHECKING,
810
Any,
911
TypedDict,
1012
)
1113

14+
if TYPE_CHECKING:
15+
from .argparse_custom import Cmd2HelpFormatter
16+
1217
from rich.box import SIMPLE_HEAD
1318
from rich.console import (
1419
Console,
@@ -345,6 +350,9 @@ class Cmd2RichArgparseConsole(Cmd2BaseConsole):
345350
and highlighting. Because rich-argparse does markup and highlighting without
346351
involving the console, disabling these settings does not affect the library's
347352
internal functionality.
353+
354+
Additionally, this console serves as a context carrier for the active help formatter,
355+
allowing renderables to access formatting settings during help generation.
348356
"""
349357

350358
def __init__(self, *, file: IO[str] | None = None) -> None:
@@ -360,6 +368,17 @@ def __init__(self, *, file: IO[str] | None = None) -> None:
360368
emoji=False,
361369
highlight=False,
362370
)
371+
self._thread_local = threading.local()
372+
373+
@property
374+
def help_formatter(self) -> 'Cmd2HelpFormatter | None':
375+
"""Return the active help formatter for this thread."""
376+
return getattr(self._thread_local, 'help_formatter', None)
377+
378+
@help_formatter.setter
379+
def help_formatter(self, value: 'Cmd2HelpFormatter | None') -> None:
380+
"""Set the active help formatter for this thread."""
381+
self._thread_local.help_formatter = value
363382

364383

365384
class Cmd2ExceptionConsole(Cmd2BaseConsole):

ruff.toml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,6 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
138138
mccabe.max-complexity = 49
139139

140140
[lint.per-file-ignores]
141-
# Do not call setattr with constant attribute value
142-
"cmd2/argparse_custom.py" = ["B010"]
143-
144141
# Ignore various warnings in examples/ directory
145142
"examples/*.py" = [
146143
"ANN", # Ignore all type annotation rules in examples folder

tests/test_argparse_custom.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import sys
55

66
import pytest
7+
from rich.console import Console
78

89
import cmd2
910
from cmd2 import (
@@ -22,6 +23,68 @@
2223
from .conftest import run_cmd
2324

2425

26+
def test_text_group_direct_cmd2() -> None:
27+
"""Print a TextGroup directly using a Cmd2RichArgparseConsole."""
28+
title = "Notes"
29+
content = "Some text"
30+
text_group = argparse_custom.TextGroup(title, content)
31+
console = Cmd2RichArgparseConsole()
32+
with console.capture() as capture:
33+
console.print(text_group)
34+
output = capture.get()
35+
assert "Notes:" in output
36+
assert " Some text" in output
37+
38+
39+
def test_text_group_direct_plain() -> None:
40+
"""Print a TextGroup directly not using a Cmd2RichArgparseConsole."""
41+
title = "Notes"
42+
content = "Some text"
43+
text_group = argparse_custom.TextGroup(title, content)
44+
console = Console()
45+
with console.capture() as capture:
46+
console.print(text_group)
47+
output = capture.get()
48+
assert "Notes:" in output
49+
assert " Some text" in output
50+
51+
52+
def test_text_group_in_parser_cmd2(capsys) -> None:
53+
"""Print a TextGroup with argparse using a Cmd2RichArgparseConsole."""
54+
parser = Cmd2ArgumentParser(prog="test")
55+
parser.epilog = argparse_custom.TextGroup("Notes", "Some text")
56+
57+
# Render help
58+
parser.print_help()
59+
out, _ = capsys.readouterr()
60+
61+
assert "Notes:" in out
62+
assert " Some text" in out
63+
64+
65+
def test_text_group_in_parser_plain(capsys) -> None:
66+
"""Print a TextGroup with argparse not using a Cmd2RichArgparseConsole."""
67+
68+
class CustomParser(Cmd2ArgumentParser):
69+
from typing import Any
70+
71+
def _get_formatter(self, **kwargs: Any) -> Cmd2HelpFormatter:
72+
"""Overwrite the formatter's console with a plain one."""
73+
formatter = super()._get_formatter(**kwargs)
74+
formatter.console = Console()
75+
return formatter
76+
77+
parser = CustomParser(prog="test")
78+
parser.epilog = argparse_custom.TextGroup("Notes", "Some text")
79+
80+
# Render help
81+
parser.print_help()
82+
out, _ = capsys.readouterr()
83+
84+
assert "Notes:" in out
85+
assert " Some text" in out
86+
87+
2588
class ApCustomTestApp(cmd2.Cmd):
2689
"""Test app for cmd2's argparse customization"""
2790

0 commit comments

Comments
 (0)