diff --git a/CHANGELOG.md b/CHANGELOG.md index 15cce1d..9d3c4c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 37a2d63..15548fd 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 @@ -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 diff --git a/rich_argparse/__main__.py b/rich_argparse/__main__.py index ad71cf9..dc10d0f 100644 --- a/rich_argparse/__main__.py +++ b/rich_argparse/__main__.py @@ -1,3 +1,5 @@ +# Source code: https://github.com/hamdanal/rich-argparse +# MIT license: Copyright (c) Ali Hamdan from __future__ import annotations if __name__ == "__main__": diff --git a/rich_argparse/_argparse.py b/rich_argparse/_argparse.py index a59c4c4..a303173 100644 --- a/rich_argparse/_argparse.py +++ b/rich_argparse/_argparse.py @@ -1,5 +1,7 @@ # Source code: https://github.com/hamdanal/rich-argparse # MIT license: Copyright (c) Ali Hamdan + +# for internal use only from __future__ import annotations import argparse @@ -7,7 +9,13 @@ 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: @@ -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 diff --git a/rich_argparse/_common.py b/rich_argparse/_common.py index 0d3a152..09f9244 100644 --- a/rich_argparse/_common.py +++ b/rich_argparse/_common.py @@ -1,3 +1,6 @@ +# Source code: https://github.com/hamdanal/rich-argparse +# MIT license: Copyright (c) Ali Hamdan + # for internal use only from __future__ import annotations @@ -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() diff --git a/rich_argparse/_contrib.py b/rich_argparse/_contrib.py new file mode 100644 index 0000000..351c44a --- /dev/null +++ b/rich_argparse/_contrib.py @@ -0,0 +1,33 @@ +# Source code: https://github.com/hamdanal/rich-argparse +# MIT license: Copyright (c) Ali Hamdan + +# 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" diff --git a/rich_argparse/_lazy_rich.py b/rich_argparse/_lazy_rich.py index f6d39a5..59294bb 100644 --- a/rich_argparse/_lazy_rich.py +++ b/rich_argparse/_lazy_rich.py @@ -1,3 +1,6 @@ +# Source code: https://github.com/hamdanal/rich-argparse +# MIT license: Copyright (c) Ali Hamdan + # for internal use only from __future__ import annotations diff --git a/rich_argparse/_optparse.py b/rich_argparse/_optparse.py index 68a2b0b..9d24121 100644 --- a/rich_argparse/_optparse.py +++ b/rich_argparse/_optparse.py @@ -1,3 +1,7 @@ +# Source code: https://github.com/hamdanal/rich-argparse +# MIT license: Copyright (c) Ali Hamdan + +# for internal use only from __future__ import annotations import optparse diff --git a/rich_argparse/contrib.py b/rich_argparse/contrib.py new file mode 100644 index 0000000..9d15680 --- /dev/null +++ b/rich_argparse/contrib.py @@ -0,0 +1,16 @@ +# Source code: https://github.com/hamdanal/rich-argparse +# MIT license: Copyright (c) Ali Hamdan +"""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", +] diff --git a/rich_argparse/optparse.py b/rich_argparse/optparse.py index 2700f7c..1c64da9 100644 --- a/rich_argparse/optparse.py +++ b/rich_argparse/optparse.py @@ -1,3 +1,5 @@ +# Source code: https://github.com/hamdanal/rich-argparse +# MIT license: Copyright (c) Ali Hamdan from __future__ import annotations from rich_argparse._optparse import ( diff --git a/tests/conftest.py b/tests/conftest.py index de270d8..167eced 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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"}): @@ -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 diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..bfaeb19 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import argparse as ap +import functools +import io +import optparse as op +import sys +import textwrap +from collections.abc import Callable +from typing import Any, Generic, TypeVar +from unittest.mock import patch + +import pytest + +if sys.version_info >= (3, 10): # pragma: >=3.10 cover + from typing import Concatenate, ParamSpec +else: # pragma: <3.10 cover + from typing_extensions import Concatenate, ParamSpec + +R = TypeVar("R") # return type +S = TypeVar("S") # self type +P = ParamSpec("P") # other parameters type +PT = TypeVar("PT", bound="ap.ArgumentParser | op.OptionParser") # parser type +GT = TypeVar("GT") # group type + + +def get_cmd_output(parser: ap.ArgumentParser | op.OptionParser, 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() + + +def copy_signature( + func: Callable[Concatenate[Any, P], object], +) -> Callable[[Callable[Concatenate[S, ...], R]], Callable[Concatenate[S, P], R]]: + """Copy the signature of the given method except self and return types.""" + return functools.wraps(func)(lambda f: f) + + +class BaseGroups(Generic[GT]): + """Base class for argument groups and option groups.""" + + def __init__(self) -> None: + self.groups: list[GT] = [] + + def append(self, group: GT) -> None: + self.groups.append(group) + + +class BaseParsers(Generic[PT]): + """Base class for argument parsers and option parsers.""" + + parsers: list[PT] + + 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 + + +# argparse +# ======== +class ArgumentGroups(BaseGroups[ap._ArgumentGroup]): + @copy_signature(ap._ArgumentGroup.add_argument) # type: ignore[arg-type] + def add_argument(self, /, *args, **kwds) -> None: + for group in self.groups: + group.add_argument(*args, **kwds) + + +class ArgumentParsers(BaseParsers[ap.ArgumentParser]): + def __init__( + self, + *formatter_classes: type[ap.HelpFormatter], + prog: str | None = None, + usage: str | None = None, + description: str | None = None, + epilog: str | None = None, + ) -> None: + assert len(set(formatter_classes)) == len(formatter_classes), "Duplicate formatter_class" + self.parsers = [ + ap.ArgumentParser( + prog=prog, + usage=usage, + description=description, + epilog=epilog, + formatter_class=formatter_class, + ) + for formatter_class in formatter_classes + ] + + class SubParsers: + def __init__(self) -> None: + self.parents: list[ap.ArgumentParser] = [] + self.subparsers: list[ap._SubParsersAction[ap.ArgumentParser]] = [] + + def append(self, p: ap.ArgumentParser, sp: ap._SubParsersAction[ap.ArgumentParser]) -> None: + self.parents.append(p) + self.subparsers.append(sp) + + @copy_signature(ap._SubParsersAction.add_parser) # type: ignore[arg-type] + def add_parser(self, /, *args, **kwds) -> ArgumentParsers: + parsers = ArgumentParsers() + for parent, subparser in zip(self.parents, self.subparsers): + sp = subparser.add_parser(*args, **kwds, formatter_class=parent.formatter_class) + parsers.parsers.append(sp) + return parsers + + @copy_signature(ap.ArgumentParser.add_argument) # type: ignore[arg-type] + def add_argument(self, /, *args, **kwds) -> None: + for parser in self.parsers: + parser.add_argument(*args, **kwds) + + @copy_signature(ap.ArgumentParser.add_argument_group) + def add_argument_group(self, /, *args, **kwds) -> ArgumentGroups: + groups = ArgumentGroups() + for parser in self.parsers: + groups.append(parser.add_argument_group(*args, **kwds)) + return groups + + @copy_signature(ap.ArgumentParser.add_subparsers) + def add_subparsers(self, /, *args, **kwds) -> SubParsers: + subparsers = self.SubParsers() + for parser in self.parsers: + sp = parser.add_subparsers(*args, **kwds) + subparsers.append(parser, sp) + return subparsers + + +def clean_argparse(text: str, dedent: bool = True) -> str: + """Clean argparse help text.""" + # Can be replaced with textwrap.dedent(text) when Python 3.10 is the minimum version + if sys.version_info >= (3, 10): # pragma: >=3.10 cover + # replace "optional arguments:" with "options:" + pos = text.lower().index("optional arguments:") + text = text[: pos + 6] + text[pos + 17 :] + if dedent: + text = textwrap.dedent(text) + return text + + +# optparse +# ======== +class OptionGroups(BaseGroups[op.OptionGroup]): + @copy_signature(op.OptionGroup.add_option) + def add_option(self, /, *args, **kwds) -> None: + for group in self.groups: + group.add_option(*args, **kwds) + + +class OptionParsers(BaseParsers[op.OptionParser]): + def __init__( + self, + *formatters: op.HelpFormatter, + prog: str | None = None, + usage: str | None = None, + description: str | None = None, + epilog: str | None = None, + ) -> None: + assert len(set(formatters)) == len(formatters), "Duplicate formatter" + self.parsers = [ + op.OptionParser( + prog=prog, usage=usage, description=description, epilog=epilog, formatter=formatter + ) + for formatter in formatters + ] + + @copy_signature(op.OptionParser.add_option) + def add_option(self, /, *args, **kwds) -> None: + for parser in self.parsers: + parser.add_option(*args, **kwds) + + @copy_signature(op.OptionParser.add_option_group) + def add_option_group(self, /, *args, **kwds) -> OptionGroups: + groups = OptionGroups() + for parser in self.parsers: + groups.append(parser.add_option_group(*args, **kwds)) + return groups diff --git a/tests/requirements.txt b/tests/requirements.txt index c0b0405..a3f7968 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -2,3 +2,4 @@ pytest coverage[toml] covdefaults pytest-cov +typing-extensions; python_version < "3.10" diff --git a/tests/test_argparse.py b/tests/test_argparse.py index 8da829a..78a8ef6 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -13,11 +13,8 @@ MetavarTypeHelpFormatter, RawDescriptionHelpFormatter, RawTextHelpFormatter, - _ArgumentGroup, - _SubParsersAction, ) from contextlib import nullcontext -from typing import Type from unittest.mock import Mock, patch import pytest @@ -37,59 +34,9 @@ RichHelpFormatter, ) from rich_argparse._common import _fix_legacy_win_text -from tests.conftest import Parsers, get_cmd_output - - -# helpers -# ======= -def clean(text: str, dedent: bool = True) -> str: - if sys.version_info >= (3, 10): # pragma: >=3.10 cover - # replace "optional arguments:" with "options:" - pos = text.lower().index("optional arguments:") - text = text[: pos + 6] + text[pos + 17 :] - if dedent: - text = textwrap.dedent(text) - return text - - -class ArgumentParsers(Parsers[ArgumentParser, _ArgumentGroup, Type[HelpFormatter]]): - parser_class = ArgumentParser - formatter_param_name = "formatter_class" - - class SubParsers: - def __init__(self) -> None: - self.parents: list[ArgumentParser] = [] - self.subparsers: list[_SubParsersAction[ArgumentParser]] = [] - - def append(self, p: ArgumentParser, sp: _SubParsersAction[ArgumentParser]) -> None: - self.parents.append(p) - self.subparsers.append(sp) - - def add_parser(self, *args, **kwds) -> ArgumentParsers: - parsers = ArgumentParsers() - for parent, subparser in zip(self.parents, self.subparsers): - sp = subparser.add_parser(*args, **kwds, formatter_class=parent.formatter_class) - parsers.parsers.append(sp) - return parsers - - def add_subparsers(self, *args, **kwds) -> SubParsers: - subparsers = self.SubParsers() - for parser in self.parsers: - sp = parser.add_subparsers(*args, **kwds) - subparsers.append(parser, sp) - return subparsers - - -# fixtures -# ======== -@pytest.fixture() -def disable_group_name_formatter(): - with patch.object(RichHelpFormatter, "group_name_formatter", str): - yield - - -# tests -# ===== +from tests.helpers import ArgumentParsers, clean_argparse, get_cmd_output + + def test_params_substitution(): # in text (description, epilog, group description) and version: substitute %(prog)s # in help message: substitute %(param)s for all param in vars(action) @@ -114,7 +61,7 @@ def test_params_substitution(): The epilog of awesome_program. """ - assert parser.format_help() == clean(expected_help_output) + assert parser.format_help() == clean_argparse(expected_help_output) assert get_cmd_output(parser, cmd=["--version"]) == "awesome_program 1.0.0\n" @@ -211,7 +158,7 @@ def test_padding_and_wrapping(): %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%%%%%%% """ - parsers.assert_format_help_equal(expected=clean(expected_help_output)) + parsers.assert_format_help_equal(expected=clean_argparse(expected_help_output)) @pytest.mark.xfail(reason="rich wraps differently") @@ -303,7 +250,7 @@ class SpecialType(str): ... [underline] epilog. """ - parsers.assert_format_help_equal(expected=clean(expected_help_output)) + parsers.assert_format_help_equal(expected=clean_argparse(expected_help_output)) parsers.assert_cmd_output_equal(cmd=["--version"], expected="[underline] %1.0.0\n") @@ -350,7 +297,7 @@ def test_generated_usage(): \x1b[36m-y\x1b[0m \x1b[38;5;36mY\x1b[0m \x1b[39mYes.\x1b[0m \x1b[36m-n\x1b[0m \x1b[38;5;36mN\x1b[0m \x1b[39mNo.\x1b[0m """ - assert parser.format_help() == clean(expected_help_output) + assert parser.format_help() == clean_argparse(expected_help_output) @pytest.mark.parametrize( @@ -444,7 +391,7 @@ def test_actions_spans_in_usage(): \x1b[36m--opt\x1b[0m [\x1b[38;5;36mOPT\x1b[0m] \x1b[36m--opts\x1b[0m \x1b[38;5;36mOPTS\x1b[0m [\x1b[38;5;36mOPTS\x1b[0m \x1b[38;5;36m...\x1b[0m] """ - assert parser.format_help() == clean(expected_help_output) + assert parser.format_help() == clean_argparse(expected_help_output) @pytest.mark.skipif(sys.version_info < (3, 9), reason="not available in 3.8") @@ -459,7 +406,7 @@ def test_boolean_optional_action_spans(): # pragma: >=3.9 cover \x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m \x1b[39mshow this help message and exit\x1b[0m \x1b[36m--bool\x1b[0m, \x1b[36m--no-bool\x1b[0m """ - assert parser.format_help() == clean(expected_help_output) + assert parser.format_help() == clean_argparse(expected_help_output) def test_usage_spans_errors(): @@ -520,7 +467,7 @@ def test_raw_description_rich_help_formatter(): The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. """ - parsers.assert_format_help_equal(expected=clean(expected_help_output)) + parsers.assert_format_help_equal(expected=clean_argparse(expected_help_output)) @pytest.mark.usefixtures("disable_group_name_formatter") @@ -551,7 +498,7 @@ def test_raw_text_rich_help_formatter(): The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. """ - parsers.assert_format_help_equal(expected=clean(expected_help_output)) + parsers.assert_format_help_equal(expected=clean_argparse(expected_help_output)) @pytest.mark.usefixtures("disable_group_name_formatter") @@ -568,7 +515,7 @@ def test_argument_default_rich_help_formatter(): -h, --help show this help message and exit --option OPTION help of option (default: def) """ - parsers.assert_format_help_equal(expected=clean(expected_help_output)) + parsers.assert_format_help_equal(expected=clean_argparse(expected_help_output)) @pytest.mark.usefixtures("disable_group_name_formatter") @@ -583,7 +530,7 @@ def test_metavar_type_help_formatter(): -h, --help show this help message and exit --count int how many? """ - parsers.assert_format_help_equal(expected=clean(expected_help_output)) + parsers.assert_format_help_equal(expected=clean_argparse(expected_help_output)) def test_django_rich_help_formatter(): @@ -639,7 +586,7 @@ class DjangoRichHelpFormatter(DjangoHelpFormatter, RichHelpFormatter): --traceback show traceback --verbosity verbosity level """ - assert parser.format_help() == clean(expected_help_output) + assert parser.format_help() == clean_argparse(expected_help_output) @pytest.mark.parametrize("indent_increment", (1, 3)) @@ -681,7 +628,7 @@ def test_text_highlighter(): # Make sure we can use a style multiple times in regexes pattern_with_duplicate_style = r"'(?P[^']*)'" RichHelpFormatter.highlights.append(pattern_with_duplicate_style) - assert parser.format_help() == clean(expected_help_output) + assert parser.format_help() == clean_argparse(expected_help_output) RichHelpFormatter.highlights.remove(pattern_with_duplicate_style) @@ -730,7 +677,7 @@ def test_default_highlights(): \x1b[39mEpilog with `\x1b[0m\x1b[1;39msyntax\x1b[0m\x1b[39m` and \x1b[0m\x1b[36m--options\x1b[0m\x1b[39m.\x1b[0m """ - assert parser.format_help().endswith(clean(expected_help_output)) + assert parser.format_help().endswith(clean_argparse(expected_help_output)) @pytest.mark.usefixtures("force_color") @@ -830,7 +777,7 @@ def test_help_with_control_codes(): with patch("rich.console.Console.is_terminal", return_value=True): colored_help_text = rich_parser.format_help() # cannot use textwrap.dedent because of the control codes - assert colored_help_text == clean(expected_help_text, dedent=False) + assert colored_help_text == clean_argparse(expected_help_text, dedent=False) @pytest.mark.skipif(sys.platform != "win32", reason="windows-only test") @@ -854,7 +801,7 @@ def test_legacy_windows(): # pragma: win32 cover parser = ArgumentParser("PROG", formatter_class=RichHelpFormatter) with patch("rich_argparse._common._initialize_win_colors", init_win_colors): help = parser.format_help() - assert help == clean(expected_colored_output) + assert help == clean_argparse(expected_colored_output) init_win_colors.assert_not_called() # Legacy windows console on new windows => colors: YES, initialization: YES @@ -864,7 +811,7 @@ def test_legacy_windows(): # pragma: win32 cover "rich_argparse._common._initialize_win_colors", init_win_colors ): help = parser.format_help() - assert help == clean(expected_colored_output) + assert help == clean_argparse(expected_colored_output) init_win_colors.assert_called_once_with() # Legacy windows console on old windows => colors: NO, initialization: YES @@ -874,7 +821,7 @@ def test_legacy_windows(): # pragma: win32 cover "rich_argparse._common._initialize_win_colors", init_win_colors ): help = parser.format_help() - assert help == clean(expected_output) + assert help == clean_argparse(expected_output) init_win_colors.assert_called_once_with() # Legacy windows, but colors disabled in formatter => colors: NO, initialization: NO @@ -889,7 +836,7 @@ def fmt_no_color(prog): "rich_argparse._common._initialize_win_colors", init_win_colors ): help = no_colors_parser.format_help() - assert help == clean(expected_output) + assert help == clean_argparse(expected_output) init_win_colors.assert_not_called() @@ -947,7 +894,7 @@ def test_rich_renderables(): └─────┴─────┘ \x1b[31mThe end.\x1b[0m """ - assert parser.format_help() == clean(expected_help) + assert parser.format_help() == clean_argparse(expected_help) def test_help_preview_generation(tmp_path): @@ -1032,7 +979,7 @@ def test_disable_help_markup(): -h, --help show this help message and exit --foo FOO [red]Help text (default: def).[/] """ - assert help_text == clean(expected_help_text) + assert help_text == clean_argparse(expected_help_text) def test_disable_text_markup(): @@ -1051,7 +998,7 @@ def test_disable_text_markup(): -h, --help show this help message and exit --foo FOO Help text. """ - assert help_text == clean(expected_help_text) + assert help_text == clean_argparse(expected_help_text) @pytest.mark.usefixtures("force_color") @@ -1070,7 +1017,7 @@ def test_arg_default_spans(): \x1b[36m--foo\x1b[0m \x1b[38;5;36mFOO\x1b[0m \x1b[39m(default: \x1b[0m\x1b[3;39m'def'\x1b[0m\x1b[39m) \x1b[0m\x1b[31m(default: \x1b[0m\x1b[3;39mdef\x1b[0m\x1b[31m)\x1b[0m\x1b[39m (default: \x1b[0m\x1b[3;39mdef\x1b[0m\x1b[39m)\x1b[0m """ help_text = parser.format_help() - assert help_text == clean(expected_help_text) + assert help_text == clean_argparse(expected_help_text) @pytest.mark.usefixtures("force_color") @@ -1137,4 +1084,4 @@ def test_metavar_spans(): \x1b[36m--op6\x1b[0m \x1b[38;5;36mOP6\x1b[0m \x1b[38;5;36mOP6\x1b[0m \x1b[38;5;36mOP6\x1b[0m \x1b[36m--op7\x1b[0m \x1b[38;5;36mMET1\x1b[0m \x1b[38;5;36mMET2\x1b[0m \x1b[38;5;36mMET3\x1b[0m """ - assert help_text == clean(expected_help_text) + assert help_text == clean_argparse(expected_help_text) diff --git a/tests/test_contrib.py b/tests/test_contrib.py new file mode 100644 index 0000000..8e99e8a --- /dev/null +++ b/tests/test_contrib.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from argparse import ArgumentParser + +from rich_argparse.contrib import ParagraphRichHelpFormatter +from tests.helpers import clean_argparse + + +def test_paragraph_rich_help_formatter(): + long_sentence = "The quick brown fox jumps over the lazy dog. " * 3 + long_paragraphs = [long_sentence] * 2 + long_text = "\n\n\r\n\t " + "\n\n".join(long_paragraphs) + "\n\n\r\n\t " + parser = ArgumentParser( + prog="PROG", + description=long_text, + epilog=long_text, + formatter_class=ParagraphRichHelpFormatter, + ) + group = parser.add_argument_group("group", description=long_text) + group.add_argument("--long", help=long_text) + + expected_help_output = """\ + Usage: PROG [-h] [--long LONG] + + The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The + quick brown fox jumps over the lazy dog. + + The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The + quick brown fox jumps over the lazy dog. + + Optional Arguments: + -h, --help show this help message and exit + + Group: + The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The + quick brown fox jumps over the lazy dog. + + The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The + quick brown fox jumps over the lazy dog. + + --long LONG The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the + lazy dog. The quick brown fox jumps over the lazy dog. + + The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the + lazy dog. The quick brown fox jumps over the lazy dog. + + The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The + quick brown fox jumps over the lazy dog. + + The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The + quick brown fox jumps over the lazy dog. + """ + assert parser.format_help() == clean_argparse(expected_help_output) diff --git a/tests/test_optparse.py b/tests/test_optparse.py index ded23ad..a251898 100644 --- a/tests/test_optparse.py +++ b/tests/test_optparse.py @@ -1,14 +1,7 @@ from __future__ import annotations import sys -from optparse import ( - SUPPRESS_HELP, - HelpFormatter, - IndentedHelpFormatter, - OptionGroup, - OptionParser, - TitledHelpFormatter, -) +from optparse import SUPPRESS_HELP, IndentedHelpFormatter, OptionParser, TitledHelpFormatter from textwrap import dedent from unittest.mock import Mock, patch @@ -22,18 +15,9 @@ RichHelpFormatter, TitledRichHelpFormatter, ) -from tests.conftest import Parsers - - -# helpers -# ======= -class OptionParsers(Parsers[OptionParser, OptionGroup, HelpFormatter]): - parser_class = OptionParser - formatter_param_name = "formatter" +from tests.helpers import OptionParsers -# tests -# ===== def test_default_substitution(): parser = OptionParser(prog="PROG", formatter=IndentedRichHelpFormatter()) parser.add_option("--option", default="[bold]", help="help of option (default: %default)")