Skip to content

Commit

Permalink
Make RichHelpFormatter itself renderable with rich (#90)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
hamdanal authored Sep 24, 2023
1 parent 03b7a8b commit 5347a6b
Show file tree
Hide file tree
Showing 4 changed files with 137 additions and 22 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Features
- Make `RichHelpFormatter` itself a rich renderable.
* PR #90

## 1.3.0 - 2023-08-19

### Features
Expand Down
94 changes: 73 additions & 21 deletions rich_argparse/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ def __init__(
@property
def console(self) -> r.Console: # deprecate?
if self._console is None:
self._console = r.Console(theme=r.Theme(self.styles))
self._console = r.Console(theme=r.Theme(self.styles), width=self._width)
return self._console

@console.setter
Expand All @@ -115,40 +115,93 @@ def __init__(
if parent is not None:
parent.rich_items.append(self)

def __rich_console__(self, console: r.Console, options: r.ConsoleOptions) -> r.RenderResult:
# empty section
if not self.rich_items and not self.rich_actions:
return
# root section
if self is self.formatter._root_section:
yield from self.rich_items
return
# group section
def _render_items(self, console: r.Console, options: r.ConsoleOptions) -> r.RenderResult:
generated_options = options.update(no_wrap=True, overflow="ignore")
new_line = r.Segment.line()
for item in self.rich_items:
if isinstance(item, r.Padding): # user added rich renderable
item_options = options.update(width=options.max_width - item.left)
lines = r.Segment.split_lines(console.render(item.renderable, item_options))
pad = r.Segment(" " * item.left)
for line_segments in lines:
yield pad
yield from line_segments
yield new_line
else:
yield new_line
else: # argparse generated rich renderable
yield from console.render(item, generated_options)

def _render_actions(self, console: r.Console, options: r.ConsoleOptions) -> r.RenderResult:
options = options.update(no_wrap=True, overflow="ignore")
help_pos = min(self.formatter._action_max_length + 2, self.formatter._max_help_position)
help_width = max(self.formatter._width - help_pos, 11)
if self.heading:
yield r.Text(self.heading, style="argparse.groups")
yield from self.rich_items # (optional) group description
indent = r.Text(" " * help_pos)
for action_header, action_help in self.rich_actions:
if not action_help:
yield action_header # no help, yield the header and finish
# no help, yield the header and finish
yield from console.render(action_header, options)
continue
action_help_lines = self.formatter._rich_split_lines(action_help, help_width)
if len(action_header) > help_pos - 2:
yield action_header # the header is too long, put it on its own line
# the header is too long, put it on its own line
yield from console.render(action_header, options)
action_header = indent
action_header.set_length(help_pos)
action_help_lines[0].rstrip()
yield action_header + action_help_lines[0]
yield from console.render(action_header + action_help_lines[0], options)
for line in action_help_lines[1:]:
line.rstrip()
yield indent + line
yield "\n"
yield from console.render(indent + line, options)
yield ""

def __rich_console__(self, console: r.Console, options: r.ConsoleOptions) -> r.RenderResult:
# empty section
if not self.rich_items and not self.rich_actions:
return
# root section
if self is self.formatter._root_section:
yield from self._render_items(console, options)
return
# group section
if self.heading:
yield r.Text(self.heading, style="argparse.groups")
if self.rich_items:
yield from self._render_items(console, options)
if self.rich_actions:
yield ""
yield from self._render_actions(console, options)

def __rich_console__(self, console: r.Console, options: r.ConsoleOptions) -> r.RenderResult:
root_renderable = console.render(self._root_section, options)
new_line = r.Segment.line()
add_empty_line = False
for line_segments in r.Segment.split_lines(root_renderable):
if len(line_segments) > 1 or (line_segments and line_segments[0]):
if add_empty_line:
yield new_line
add_empty_line = False
for i, segment in enumerate(reversed(line_segments), start=1):
stripped = segment.text.rstrip()
if stripped:
yield from line_segments[:-i]
yield r.Segment(stripped, style=segment.style, control=segment.control)
break
yield new_line
else: # empty line
add_empty_line = True

def add_text(self, text: str | None) -> None:
if text is not argparse.SUPPRESS and text is not None:
if text is argparse.SUPPRESS or text is None:
return
elif isinstance(text, str):
self._current_section.rich_items.append(self._rich_format_text(text))
else:
self.add_renderable(text)

def add_renderable(self, renderable: r.RenderableType) -> None:
padded = r.Padding.indent(renderable, self._current_indent)
self._current_section.rich_items.append(padded)

def add_usage(
self,
Expand Down Expand Up @@ -199,10 +252,9 @@ def add_argument(self, action: argparse.Action) -> None:

def format_help(self) -> str:
with self.console.capture() as capture:
self.console.print(self._root_section, highlight=False, soft_wrap=True)
self.console.print(self, highlight=False, crop=False)
help = capture.get()
if help:
help = self._long_break_matcher.sub("\n\n", help).rstrip() + "\n"
help = _fix_legacy_win_text(self.console, help)
return help

Expand Down
8 changes: 8 additions & 0 deletions rich_argparse/_lazy_rich.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
"Lines",
"strip_control_codes",
"escape",
"Padding",
"Segment",
"StyleType",
"Span",
"Text",
Expand All @@ -27,6 +29,8 @@
from rich.containers import Lines as Lines
from rich.control import strip_control_codes as strip_control_codes
from rich.markup import escape as escape
from rich.padding import Padding as Padding
from rich.segment import Segment as Segment
from rich.style import StyleType as StyleType
from rich.text import Span as Span
from rich.text import Text as Text
Expand All @@ -41,6 +45,8 @@ def __getattr__(name: str) -> Any:
import rich.containers
import rich.control
import rich.markup
import rich.padding
import rich.segment
import rich.style
import rich.text
import rich.theme
Expand All @@ -55,6 +61,8 @@ def __getattr__(name: str) -> Any:
"Lines": rich.containers.Lines,
"strip_control_codes": rich.control.strip_control_codes,
"escape": rich.markup.escape,
"Padding": rich.padding.Padding,
"Segment": rich.segment.Segment,
"StyleType": rich.style.StyleType,
"Span": rich.text.Span,
"Text": rich.text.Text,
Expand Down
53 changes: 52 additions & 1 deletion tests/test_argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@

import pytest
from rich import get_console
from rich.console import Group
from rich.markdown import Markdown
from rich.table import Table
from rich.text import Text

import rich_argparse._lazy_rich as r
Expand Down Expand Up @@ -357,7 +360,7 @@ def test_generated_usage():
(
"PROG "
"\x1b[38;5;244mPROG\x1b[0m "
"\x1b[1m \x1b[0m\x1b[1;38;5;244mPROG\x1b[0m\x1b[1m \x1b[0m"
"\x1b[1m \x1b[0m\x1b[1;38;5;244mPROG\x1b[0m" # "\x1b[1m \x1b[0m"
"\n\x1b[38;5;244m'PROG'\x1b[0m"
),
True,
Expand Down Expand Up @@ -859,3 +862,51 @@ def test_no_win_console_init_on_unix(): # pragma: win32 no cover
out = _fix_legacy_win_text(console, text)
assert out == text
init_win_colors.assert_not_called()


@pytest.mark.usefixtures("force_color")
def test_rich_renderables():
table = Table("foo", "bar")
table.add_row("1", "2")
parser = ArgumentParser(
"PROG",
formatter_class=RichHelpFormatter,
description=Markdown(
textwrap.dedent(
"""\
This is a **description**
_________________________
| foo | bar |
| --- | --- |
| 1 | 2 |
"""
)
),
epilog=Group(Markdown("This is an *epilog*"), table, Text("The end.", style="red")),
)
expected_help = """\
\x1b[38;5;208mUsage:\x1b[0m \x1b[38;5;244mPROG\x1b[0m [\x1b[36m-h\x1b[0m]
This is a \x1b[1mdescription\x1b[0m
\x1b[33m──────────────────────────────────────────────────────────────────────────────────────────────────\x1b[0m
\x1b[1m \x1b[0m\x1b[1mfoo\x1b[0m\x1b[1m \x1b[0m \x1b[1m \x1b[0m\x1b[1mbar\x1b[0m
━━━━━━━━━━━
1 2
\x1b[38;5;208mOptional Arguments:\x1b[0m
\x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m \x1b[39mshow this help message and exit\x1b[0m
This is an \x1b[3mepilog\x1b[0m
┏━━━━━┳━━━━━┓
\x1b[1m \x1b[0m\x1b[1mfoo\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mbar\x1b[0m\x1b[1m \x1b[0m┃
┡━━━━━╇━━━━━┩
│ 1 │ 2 │
└─────┴─────┘
\x1b[31mThe end.\x1b[0m
"""
assert parser.format_help() == clean(expected_help)

0 comments on commit 5347a6b

Please sign in to comment.