Skip to content

Commit

Permalink
Add contrib module with ParagraphRichHelpFormatter (#147)
Browse files Browse the repository at this point in the history
Closes #140
  • Loading branch information
hamdanal authored Jan 4, 2025
1 parent 7ff5067 commit 3707030
Show file tree
Hide file tree
Showing 16 changed files with 399 additions and 217 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## Unreleased

### Features
- [GH-140](https://github.com/hamdanal/rich-argparse/issues/140),
[PR-147](https://github.com/hamdanal/rich-argparse/pull/147)
Add `rich_argparse.contrib` module to provide additional non-standard formatters. The new module
currently contains a single formatter `ParagraphRichHelpFormatter` that preserves paragraph breaks
(`\n\n`) in the descriptions, epilog, and help text.

### Fixes
- [GH-141](https://github.com/hamdanal/rich-argparse/issues/141),
[PR-142](https://github.com/hamdanal/rich-argparse/pull/142)
Expand Down
34 changes: 25 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ changes to the code.
* [Rich renderables](#rich-descriptions-and-epilog)
* [Subparsers](#working-with-subparsers)
* [Documenting your CLI](#generate-help-preview)
* [Third party formatters](#working-with-third-party-formatters) (ft. django)
* [Additional formatters](#additional-formatters)
* [Third party formatters](#third-party-formatters) (ft. django)
* [Optparse](#optparse-support) (experimental)
* [Legacy Windows](#legacy-windows-support)

Expand All @@ -54,16 +55,18 @@ parser = argparse.ArgumentParser(..., formatter_class=RichHelpFormatter)
...
```

*rich-argparse* defines equivalents to all argparse's [built-in formatters](
*rich-argparse* defines equivalents to all [argparse's standard formatters](
https://docs.python.org/3/library/argparse.html#formatter-class):

| `rich_argparse` formatter | equivalent in `argparse` |
|---------------------------|--------------------------|
| `RichHelpFormatter` | `HelpFormatter` |
| `RawDescriptionRichHelpFormatter` | `RawDescriptionHelpFormatter` |
| `RawTextRichHelpFormatter` | `RawTextHelpFormatter` |
| `rich_argparse` formatter | equivalent in `argparse` |
|-------------------------------------|---------------------------------|
| `RichHelpFormatter` | `HelpFormatter` |
| `RawDescriptionRichHelpFormatter` | `RawDescriptionHelpFormatter` |
| `RawTextRichHelpFormatter` | `RawTextHelpFormatter` |
| `ArgumentDefaultsRichHelpFormatter` | `ArgumentDefaultsHelpFormatter` |
| `MetavarTypeRichHelpFormatter` | `MetavarTypeHelpFormatter` |
| `MetavarTypeRichHelpFormatter` | `MetavarTypeHelpFormatter` |

Additional formatters are available in the `rich_argparse.contrib` [module](#additional-formatters).

## Output styles

Expand Down Expand Up @@ -239,7 +242,20 @@ python my_cli.py --generate-help-preview my-help.svg # generates my-help.svg
COLUMNS=120 python my_cli.py --generate-help-preview # force the width of the output to 120 columns
```

## Working with third party formatters
## Additional formatters

*rich-argparse* ships with additional non-standard argparse formatters for some common use cases in
the `rich_argparse.contrib` module. They can be imported with the `from rich_argparse.contrib import`
syntax.

* `ParagraphRichHelpFormatter`: A formatter similar to `RichHelpFormatter` that preserves paragraph
breaks. A paragraph break is defined as two consecutive newlines (`\n\n`) in the help or
description text. Leading and trailing trailing whitespace are stripped similar to
`RichHelpFormatter`.

_More formatters will be added in the future._

## Third party formatters

*rich-argparse* can be used with other custom formatters through multiple inheritance. For example,
[django](https://pypi.org/project/django) defines a custom help formatter for its built in commands
Expand Down
2 changes: 2 additions & 0 deletions rich_argparse/__main__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# Source code: https://github.com/hamdanal/rich-argparse
# MIT license: Copyright (c) Ali Hamdan <ali.hamdan.dev@gmail.com>
from __future__ import annotations

if __name__ == "__main__":
Expand Down
17 changes: 10 additions & 7 deletions rich_argparse/_argparse.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
# Source code: https://github.com/hamdanal/rich-argparse
# MIT license: Copyright (c) Ali Hamdan <ali.hamdan.dev@gmail.com>

# for internal use only
from __future__ import annotations

import argparse
import re
import sys

import rich_argparse._lazy_rich as r
from rich_argparse._common import _HIGHLIGHTS, _fix_legacy_win_text, rich_fill, rich_wrap
from rich_argparse._common import (
_HIGHLIGHTS,
_fix_legacy_win_text,
rich_fill,
rich_strip,
rich_wrap,
)

TYPE_CHECKING = False
if TYPE_CHECKING:
Expand Down Expand Up @@ -395,12 +403,7 @@ def _rich_whitespace_sub(self, text: r.Text) -> r.Text:
text = text[:start] + space + text[end:]
else: # performance shortcut
text.plain = text.plain[:start] + " " + text.plain[end:]
# Text has no strip method yet
lstrip_at = len(text.plain) - len(text.plain.lstrip())
if lstrip_at:
text = text[lstrip_at:]
text.rstrip()
return text
return rich_strip(text)

# =====================================
# Rich version of HelpFormatter methods
Expand Down
12 changes: 12 additions & 0 deletions rich_argparse/_common.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Source code: https://github.com/hamdanal/rich-argparse
# MIT license: Copyright (c) Ali Hamdan <ali.hamdan.dev@gmail.com>

# for internal use only
from __future__ import annotations

Expand All @@ -15,6 +18,15 @@
_windows_console_fixed = None


def rich_strip(text: r.Text) -> r.Text:
"""Strip leading and trailing whitespace from `rich.text.Text`."""
lstrip_at = len(text.plain) - len(text.plain.lstrip())
if lstrip_at: # rich.Text.lstrip() is not available yet!!
text = text[lstrip_at:]
text.rstrip()
return text


def rich_wrap(console: r.Console, text: r.Text, width: int) -> r.Lines:
"""`textwrap.wrap()` equivalent for `rich.text.Text`."""
text = text.copy()
Expand Down
33 changes: 33 additions & 0 deletions rich_argparse/_contrib.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Source code: https://github.com/hamdanal/rich-argparse
# MIT license: Copyright (c) Ali Hamdan <ali.hamdan.dev@gmail.com>

# for internal use only
from __future__ import annotations

import rich_argparse._lazy_rich as r
from rich_argparse._argparse import RichHelpFormatter
from rich_argparse._common import rich_strip, rich_wrap


class ParagraphRichHelpFormatter(RichHelpFormatter):
"""Rich help message formatter which retains paragraph separation."""

def _rich_split_lines(self, text: r.Text, width: int) -> r.Lines:
text = rich_strip(text)
lines = r.Lines()
for paragraph in text.split("\n\n"):
# Normalize whitespace in the paragraph
paragraph = self._rich_whitespace_sub(paragraph)
# Wrap the paragraph to the specified width
paragraph_lines = rich_wrap(self.console, paragraph, width)
# Add the wrapped lines to the output
lines.extend(paragraph_lines)
# Add a blank line between paragraphs
lines.append(r.Text("\n"))
if lines: # pragma: no cover
lines.pop() # Remove trailing newline
return lines

def _rich_fill_text(self, text: r.Text, width: int, indent: r.Text) -> r.Text:
lines = self._rich_split_lines(text, width)
return r.Text("\n").join(indent + line for line in lines) + "\n"
3 changes: 3 additions & 0 deletions rich_argparse/_lazy_rich.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Source code: https://github.com/hamdanal/rich-argparse
# MIT license: Copyright (c) Ali Hamdan <ali.hamdan.dev@gmail.com>

# for internal use only
from __future__ import annotations

Expand Down
4 changes: 4 additions & 0 deletions rich_argparse/_optparse.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# Source code: https://github.com/hamdanal/rich-argparse
# MIT license: Copyright (c) Ali Hamdan <ali.hamdan.dev@gmail.com>

# for internal use only
from __future__ import annotations

import optparse
Expand Down
16 changes: 16 additions & 0 deletions rich_argparse/contrib.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Source code: https://github.com/hamdanal/rich-argparse
# MIT license: Copyright (c) Ali Hamdan <ali.hamdan.dev@gmail.com>
"""Extra formatters for rich help messages.
The rich_argparse.contrib module contains optional, standard implementations of common patterns of
rich help message formatting. These formatters are not included in the main rich_argparse module
because they do not translate directly to argparse formatters.
"""

from __future__ import annotations

from rich_argparse._contrib import ParagraphRichHelpFormatter

__all__ = [
"ParagraphRichHelpFormatter",
]
2 changes: 2 additions & 0 deletions rich_argparse/optparse.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# Source code: https://github.com/hamdanal/rich-argparse
# MIT license: Copyright (c) Ali Hamdan <ali.hamdan.dev@gmail.com>
from __future__ import annotations

from rich_argparse._optparse import (
Expand Down
115 changes: 11 additions & 104 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,116 +1,15 @@
from __future__ import annotations

import io
import os
import sys
from typing import Any, Generic, Protocol, TypeVar
from unittest.mock import patch

import pytest

from rich_argparse import RichHelpFormatter

class Parser(Protocol):
def format_help(self) -> str: ...

def parse_args(self, *args, **kwds) -> Any: ...


PT = TypeVar("PT", bound=Parser) # parser type
GT = TypeVar("GT") # group type
FT = TypeVar("FT") # formatter type


# helpers
# =======
class Groups(Generic[GT]):
def __init__(self) -> None:
self.groups: list[GT] = []

def append(self, group: GT) -> None:
self.groups.append(group)

def add_argument(self, *args, **kwds) -> None:
for group in self.groups:
assert hasattr(group, "add_argument"), "Group has no add_argument method"
group.add_argument(*args, **kwds)

def add_option(self, *args, **kwds) -> None:
for group in self.groups:
assert hasattr(group, "add_option"), "Group has no add_option method"
group.add_option(*args, **kwds)


class Parsers(Generic[PT, GT, FT]):
parser_class: type[PT]
formatter_param_name: str

def __init__(self, *formatters: FT, **kwds) -> None:
self.parsers: list[PT] = []
assert len(set(formatters)) == len(formatters), "Duplicate formatters"
for fmt in formatters:
kwds[self.formatter_param_name] = fmt
parser = self.parser_class(**kwds)
assert hasattr(parser, "format_help"), "Parser has no format_help method"
self.parsers.append(parser)

def __init_subclass__(cls) -> None:
for name in ("parser_class", "formatter_param_name"):
assert hasattr(cls, name), f"Parsers subclass must define {name} attribute"
return super().__init_subclass__()

def add_argument(self, *args, **kwds) -> None:
for parser in self.parsers:
assert hasattr(parser, "add_argument"), "Parser has no add_argument method"
parser.add_argument(*args, **kwds)

def add_argument_group(self, *args, **kwds) -> Groups[GT]:
groups = Groups[GT]()
for parser in self.parsers:
assert hasattr(parser, "add_argument_group"), "Parser has no add_argument_group method"
groups.append(parser.add_argument_group(*args, **kwds))
return groups

def add_option(self, *args, **kwds) -> None:
for parser in self.parsers:
assert hasattr(parser, "add_option"), "Parser has no add_option method"
parser.add_option(*args, **kwds)

def add_option_group(self, *args, **kwds) -> Groups[GT]:
groups = Groups[GT]()
for parser in self.parsers:
assert hasattr(parser, "add_option_group"), "Parser has no add_option_group method"
groups.append(parser.add_option_group(*args, **kwds))
return groups

def assert_format_help_equal(self, expected: str | None = None) -> None:
assert self.parsers, "No parsers to compare."
outputs = [parser.format_help() for parser in self.parsers]
if expected is None: # pragma: no cover
expected = outputs.pop()
assert outputs, "No outputs to compare."
for output in outputs:
assert output == expected

def assert_cmd_output_equal(self, cmd: list[str], expected: str | None = None) -> None:
assert self.parsers, "No parsers to compare."
outputs = [get_cmd_output(parser, cmd) for parser in self.parsers]
if expected is None: # pragma: no cover
expected = outputs.pop()
assert outputs, "No outputs to compare."
for output in outputs:
assert output == expected


def get_cmd_output(parser: Parser, cmd: list[str]) -> str:
__tracebackhide__ = True
stdout = io.StringIO()
with pytest.raises(SystemExit), patch.object(sys, "stdout", stdout):
parser.parse_args(cmd)
return stdout.getvalue()


# fixtures
# ========
# Common fixtures
# ===============
@pytest.fixture(scope="session", autouse=True)
def set_terminal_properties():
with patch.dict(os.environ, {"COLUMNS": "100", "TERM": "xterm-256color"}):
Expand All @@ -127,3 +26,11 @@ def turnoff_legacy_windows():
def force_color():
with patch("rich.console.Console.is_terminal", return_value=True):
yield


# argparse fixtures
# =================
@pytest.fixture()
def disable_group_name_formatter():
with patch.object(RichHelpFormatter, "group_name_formatter", str):
yield
Loading

0 comments on commit 3707030

Please sign in to comment.