Skip to content

Commit 5347a6b

Browse files
authored
Make RichHelpFormatter itself renderable with rich (#90)
This is a new approach that allows the formatter itself to be rendered with rich. The handling of whitespace manipulation and wrapping is now baked into the renderable itself. This also removes the need to use `soft_wrap=True` when printing the formatted help. The downside is that the code is now very low-level in terms of rich rendering, it needs to do line-by-line handling of `Segment` objects. This could help unblock more niche use-cases like #81 and #54.
1 parent 03b7a8b commit 5347a6b

File tree

4 files changed

+137
-22
lines changed

4 files changed

+137
-22
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### Features
6+
- Make `RichHelpFormatter` itself a rich renderable.
7+
* PR #90
8+
59
## 1.3.0 - 2023-08-19
610

711
### Features

rich_argparse/__init__.py

Lines changed: 73 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ def __init__(
9595
@property
9696
def console(self) -> r.Console: # deprecate?
9797
if self._console is None:
98-
self._console = r.Console(theme=r.Theme(self.styles))
98+
self._console = r.Console(theme=r.Theme(self.styles), width=self._width)
9999
return self._console
100100

101101
@console.setter
@@ -115,40 +115,93 @@ def __init__(
115115
if parent is not None:
116116
parent.rich_items.append(self)
117117

118-
def __rich_console__(self, console: r.Console, options: r.ConsoleOptions) -> r.RenderResult:
119-
# empty section
120-
if not self.rich_items and not self.rich_actions:
121-
return
122-
# root section
123-
if self is self.formatter._root_section:
124-
yield from self.rich_items
125-
return
126-
# group section
118+
def _render_items(self, console: r.Console, options: r.ConsoleOptions) -> r.RenderResult:
119+
generated_options = options.update(no_wrap=True, overflow="ignore")
120+
new_line = r.Segment.line()
121+
for item in self.rich_items:
122+
if isinstance(item, r.Padding): # user added rich renderable
123+
item_options = options.update(width=options.max_width - item.left)
124+
lines = r.Segment.split_lines(console.render(item.renderable, item_options))
125+
pad = r.Segment(" " * item.left)
126+
for line_segments in lines:
127+
yield pad
128+
yield from line_segments
129+
yield new_line
130+
else:
131+
yield new_line
132+
else: # argparse generated rich renderable
133+
yield from console.render(item, generated_options)
134+
135+
def _render_actions(self, console: r.Console, options: r.ConsoleOptions) -> r.RenderResult:
136+
options = options.update(no_wrap=True, overflow="ignore")
127137
help_pos = min(self.formatter._action_max_length + 2, self.formatter._max_help_position)
128138
help_width = max(self.formatter._width - help_pos, 11)
129-
if self.heading:
130-
yield r.Text(self.heading, style="argparse.groups")
131-
yield from self.rich_items # (optional) group description
132139
indent = r.Text(" " * help_pos)
133140
for action_header, action_help in self.rich_actions:
134141
if not action_help:
135-
yield action_header # no help, yield the header and finish
142+
# no help, yield the header and finish
143+
yield from console.render(action_header, options)
136144
continue
137145
action_help_lines = self.formatter._rich_split_lines(action_help, help_width)
138146
if len(action_header) > help_pos - 2:
139-
yield action_header # the header is too long, put it on its own line
147+
# the header is too long, put it on its own line
148+
yield from console.render(action_header, options)
140149
action_header = indent
141150
action_header.set_length(help_pos)
142151
action_help_lines[0].rstrip()
143-
yield action_header + action_help_lines[0]
152+
yield from console.render(action_header + action_help_lines[0], options)
144153
for line in action_help_lines[1:]:
145154
line.rstrip()
146-
yield indent + line
147-
yield "\n"
155+
yield from console.render(indent + line, options)
156+
yield ""
157+
158+
def __rich_console__(self, console: r.Console, options: r.ConsoleOptions) -> r.RenderResult:
159+
# empty section
160+
if not self.rich_items and not self.rich_actions:
161+
return
162+
# root section
163+
if self is self.formatter._root_section:
164+
yield from self._render_items(console, options)
165+
return
166+
# group section
167+
if self.heading:
168+
yield r.Text(self.heading, style="argparse.groups")
169+
if self.rich_items:
170+
yield from self._render_items(console, options)
171+
if self.rich_actions:
172+
yield ""
173+
yield from self._render_actions(console, options)
174+
175+
def __rich_console__(self, console: r.Console, options: r.ConsoleOptions) -> r.RenderResult:
176+
root_renderable = console.render(self._root_section, options)
177+
new_line = r.Segment.line()
178+
add_empty_line = False
179+
for line_segments in r.Segment.split_lines(root_renderable):
180+
if len(line_segments) > 1 or (line_segments and line_segments[0]):
181+
if add_empty_line:
182+
yield new_line
183+
add_empty_line = False
184+
for i, segment in enumerate(reversed(line_segments), start=1):
185+
stripped = segment.text.rstrip()
186+
if stripped:
187+
yield from line_segments[:-i]
188+
yield r.Segment(stripped, style=segment.style, control=segment.control)
189+
break
190+
yield new_line
191+
else: # empty line
192+
add_empty_line = True
148193

149194
def add_text(self, text: str | None) -> None:
150-
if text is not argparse.SUPPRESS and text is not None:
195+
if text is argparse.SUPPRESS or text is None:
196+
return
197+
elif isinstance(text, str):
151198
self._current_section.rich_items.append(self._rich_format_text(text))
199+
else:
200+
self.add_renderable(text)
201+
202+
def add_renderable(self, renderable: r.RenderableType) -> None:
203+
padded = r.Padding.indent(renderable, self._current_indent)
204+
self._current_section.rich_items.append(padded)
152205

153206
def add_usage(
154207
self,
@@ -199,10 +252,9 @@ def add_argument(self, action: argparse.Action) -> None:
199252

200253
def format_help(self) -> str:
201254
with self.console.capture() as capture:
202-
self.console.print(self._root_section, highlight=False, soft_wrap=True)
255+
self.console.print(self, highlight=False, crop=False)
203256
help = capture.get()
204257
if help:
205-
help = self._long_break_matcher.sub("\n\n", help).rstrip() + "\n"
206258
help = _fix_legacy_win_text(self.console, help)
207259
return help
208260

rich_argparse/_lazy_rich.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
"Lines",
1313
"strip_control_codes",
1414
"escape",
15+
"Padding",
16+
"Segment",
1517
"StyleType",
1618
"Span",
1719
"Text",
@@ -27,6 +29,8 @@
2729
from rich.containers import Lines as Lines
2830
from rich.control import strip_control_codes as strip_control_codes
2931
from rich.markup import escape as escape
32+
from rich.padding import Padding as Padding
33+
from rich.segment import Segment as Segment
3034
from rich.style import StyleType as StyleType
3135
from rich.text import Span as Span
3236
from rich.text import Text as Text
@@ -41,6 +45,8 @@ def __getattr__(name: str) -> Any:
4145
import rich.containers
4246
import rich.control
4347
import rich.markup
48+
import rich.padding
49+
import rich.segment
4450
import rich.style
4551
import rich.text
4652
import rich.theme
@@ -55,6 +61,8 @@ def __getattr__(name: str) -> Any:
5561
"Lines": rich.containers.Lines,
5662
"strip_control_codes": rich.control.strip_control_codes,
5763
"escape": rich.markup.escape,
64+
"Padding": rich.padding.Padding,
65+
"Segment": rich.segment.Segment,
5866
"StyleType": rich.style.StyleType,
5967
"Span": rich.text.Span,
6068
"Text": rich.text.Text,

tests/test_argparse.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222

2323
import pytest
2424
from rich import get_console
25+
from rich.console import Group
26+
from rich.markdown import Markdown
27+
from rich.table import Table
2528
from rich.text import Text
2629

2730
import rich_argparse._lazy_rich as r
@@ -357,7 +360,7 @@ def test_generated_usage():
357360
(
358361
"PROG "
359362
"\x1b[38;5;244mPROG\x1b[0m "
360-
"\x1b[1m \x1b[0m\x1b[1;38;5;244mPROG\x1b[0m\x1b[1m \x1b[0m"
363+
"\x1b[1m \x1b[0m\x1b[1;38;5;244mPROG\x1b[0m" # "\x1b[1m \x1b[0m"
361364
"\n\x1b[38;5;244m'PROG'\x1b[0m"
362365
),
363366
True,
@@ -859,3 +862,51 @@ def test_no_win_console_init_on_unix(): # pragma: win32 no cover
859862
out = _fix_legacy_win_text(console, text)
860863
assert out == text
861864
init_win_colors.assert_not_called()
865+
866+
867+
@pytest.mark.usefixtures("force_color")
868+
def test_rich_renderables():
869+
table = Table("foo", "bar")
870+
table.add_row("1", "2")
871+
parser = ArgumentParser(
872+
"PROG",
873+
formatter_class=RichHelpFormatter,
874+
description=Markdown(
875+
textwrap.dedent(
876+
"""\
877+
This is a **description**
878+
_________________________
879+
880+
| foo | bar |
881+
| --- | --- |
882+
| 1 | 2 |
883+
"""
884+
)
885+
),
886+
epilog=Group(Markdown("This is an *epilog*"), table, Text("The end.", style="red")),
887+
)
888+
expected_help = """\
889+
\x1b[38;5;208mUsage:\x1b[0m \x1b[38;5;244mPROG\x1b[0m [\x1b[36m-h\x1b[0m]
890+
891+
This is a \x1b[1mdescription\x1b[0m
892+
893+
\x1b[33m──────────────────────────────────────────────────────────────────────────────────────────────────\x1b[0m
894+
895+
896+
\x1b[1m \x1b[0m\x1b[1mfoo\x1b[0m\x1b[1m \x1b[0m \x1b[1m \x1b[0m\x1b[1mbar\x1b[0m
897+
━━━━━━━━━━━
898+
1 2
899+
900+
901+
\x1b[38;5;208mOptional Arguments:\x1b[0m
902+
\x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m \x1b[39mshow this help message and exit\x1b[0m
903+
904+
This is an \x1b[3mepilog\x1b[0m
905+
┏━━━━━┳━━━━━┓
906+
\x1b[1m \x1b[0m\x1b[1mfoo\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mbar\x1b[0m\x1b[1m \x1b[0m┃
907+
┡━━━━━╇━━━━━┩
908+
│ 1 │ 2 │
909+
└─────┴─────┘
910+
\x1b[31mThe end.\x1b[0m
911+
"""
912+
assert parser.format_help() == clean(expected_help)

0 commit comments

Comments
 (0)