Skip to content

Add action to generate SVG, HTML, or TXT help preview #93

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Oct 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* PR #90
- Allow passing custom console to `RichHelpFormatter`.
* Issue #91, PR #92
- Add `HelpPreviewAction` to generate a preview of the help output in SVG, HTML, or TXT formats.
* Issue #91, PR #93

## 1.3.0 - 2023-08-19

Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ changes to the code.
* ["usage"](#colors-in-the-usage)
* [--version](#colors-in---version)
* [Subparsers](#working-with-subparsers)
* [Documenting your CLI](#generate-help-preview)
* [Third party formatters](#working-with-third-party-formatters) (ft. django)
* [Optparse](#optparse-support) (experimental)
* [Legacy Windows](#legacy-windows-support)
Expand Down Expand Up @@ -155,6 +156,39 @@ p1 = subparsers.add_parser(..., formatter_class=parser.formatter_class)
p2 = subparsers.add_parser(..., formatter_class=parser.formatter_class)
```

## Generate help preview

You can generate a preview of the help message for your CLI in SVG, HTML, or TXT formats using the
`HelpPreviewAction` action. This is useful for including the help message in the documentation of
your app. The action uses the
[rich exporting API](https://rich.readthedocs.io/en/stable/console.html#exporting) internally.

```python
import argparse
from rich.terminal_theme import DIMMED_MONOKAI
from rich_argparse import HelpPreviewAction, RichHelpFormatter

parser = argparse.ArgumentParser(..., formatter_class=RichHelpFormatter)
...
parser.add_argument(
"--generate-help-preview",
action=HelpPreviewAction,
path="help-preview.svg", # (optional) or "help-preview.html" or "help-preview.txt"
export_kwds={"theme": DIMMED_MONOKAI}, # (optional) keywords passed to console.save_... methods
)
```
This action is hidden, it won't show up in the help message or in the parsed arguments namespace.

Use it like this:

```sh
python my_cli.py --generate-help-preview # generates help-preview.svg (default path specified above)
# or
python my_cli.py --generate-help-preview my-help.svg # generates my-help.svg
# or
COLUMNS=120 python my_cli.py --generate-help-preview # force the width of the output to 120 columns
```

## Working with third party formatters

*rich-argparse* can be used with other formatters that **do not rely on the private internals**
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ select = ["E", "F", "C", "B", "UP", "RUF100", "TID"]
unfixable = ["B"]
ignore = ["E501"]
isort.required-imports = ["from __future__ import annotations"]
isort.extra-standard-library = ["typing_extensions"]
flake8-tidy-imports.ban-relative-imports = "all"

[tool.mypy]
Expand Down
73 changes: 61 additions & 12 deletions rich_argparse/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
import argparse
import re
import sys
from typing import TYPE_CHECKING, Callable, ClassVar, Iterable, Iterator
from typing import TYPE_CHECKING, Any, ClassVar

import rich_argparse._lazy_rich as r
from rich_argparse._common import _HIGHLIGHTS, _fix_legacy_win_text, _rich_fill, _rich_wrap

if TYPE_CHECKING:
from argparse import Action, ArgumentParser, Namespace, _MutuallyExclusiveGroup
from collections.abc import Callable, Iterable, Iterator, MutableMapping, Sequence
from typing_extensions import Self

__all__ = [
Expand All @@ -19,6 +21,7 @@
"RawTextRichHelpFormatter",
"ArgumentDefaultsRichHelpFormatter",
"MetavarTypeRichHelpFormatter",
"HelpPreviewAction",
]


Expand Down Expand Up @@ -208,8 +211,8 @@ def add_renderable(self, renderable: r.RenderableType) -> None:
def add_usage(
self,
usage: str | None,
actions: Iterable[argparse.Action],
groups: Iterable[argparse._MutuallyExclusiveGroup],
actions: Iterable[Action],
groups: Iterable[_MutuallyExclusiveGroup],
prefix: str | None = None,
) -> None:
if usage is argparse.SUPPRESS:
Expand Down Expand Up @@ -247,7 +250,7 @@ def add_usage(
rich_usage.spans.extend(usage_spans)
self._root_section.rich_items.append(rich_usage)

def add_argument(self, action: argparse.Action) -> None:
def add_argument(self, action: Action) -> None:
super().add_argument(action)
if action.help is not argparse.SUPPRESS:
self._current_section.rich_actions.extend(self._rich_format_action(action))
Expand Down Expand Up @@ -277,10 +280,10 @@ def _rich_prog_spans(self, usage: str) -> Iterator[r.Span]:
yield r.Span(prog_start, prog_end, "argparse.prog")

def _rich_usage_spans(
self, text: str, start: int, actions: Iterable[argparse.Action]
self, text: str, start: int, actions: Iterable[Action]
) -> Iterator[r.Span]:
options: list[argparse.Action] = []
positionals: list[argparse.Action] = []
options: list[Action] = []
positionals: list[Action] = []
for action in actions:
if action.help is not argparse.SUPPRESS:
options.append(action) if action.option_strings else positionals.append(action)
Expand Down Expand Up @@ -336,7 +339,7 @@ def _rich_whitespace_sub(self, text: r.Text) -> r.Text:
# =====================================
# Rich version of HelpFormatter methods
# =====================================
def _rich_expand_help(self, action: argparse.Action) -> r.Text:
def _rich_expand_help(self, action: Action) -> r.Text:
params = dict(vars(action), prog=self._prog)
for name in list(params):
if params[name] is argparse.SUPPRESS:
Expand Down Expand Up @@ -372,17 +375,15 @@ def _rich_format_text(self, text: str) -> r.Text:
indent = r.Text(" " * self._current_indent)
return self._rich_fill_text(rich_text, text_width, indent)

def _rich_format_action(
self, action: argparse.Action
) -> Iterator[tuple[r.Text, r.Text | None]]:
def _rich_format_action(self, action: Action) -> Iterator[tuple[r.Text, r.Text | None]]:
header = self._rich_format_action_invocation(action)
header.pad_left(self._current_indent)
help = self._rich_expand_help(action) if action.help and action.help.strip() else None
yield header, help
for subaction in self._iter_indented_subactions(action):
yield from self._rich_format_action(subaction)

def _rich_format_action_invocation(self, action: argparse.Action) -> r.Text:
def _rich_format_action_invocation(self, action: Action) -> r.Text:
if not action.option_strings:
return r.Text().append(self._format_action_invocation(action), style="argparse.args")
else:
Expand Down Expand Up @@ -424,3 +425,51 @@ class MetavarTypeRichHelpFormatter(argparse.MetavarTypeHelpFormatter, RichHelpFo
"""Rich help message formatter which uses the argument 'type' as the default
metavar value (instead of the argument 'dest').
"""


class HelpPreviewAction(argparse.Action):
"""Action that renders the help to SVG, HTML, or text file and exits."""

def __init__(
self,
option_strings: Sequence[str],
dest: str = argparse.SUPPRESS,
default: str = argparse.SUPPRESS,
help: str = argparse.SUPPRESS,
*,
path: str | None = None,
export_kwds: MutableMapping[str, Any] | None = None,
) -> None:
super().__init__(option_strings, dest, nargs="?", const=path, default=default, help=help)
self.export_kwds = export_kwds or {}

def __call__(
self,
parser: ArgumentParser,
namespace: Namespace,
values: str | Sequence[Any] | None,
option_string: str | None = None,
) -> None:
path = values
if path is None:
parser.exit(1, "error: help preview path is not provided\n")
if not isinstance(path, str):
parser.exit(1, "error: help preview path must be a string\n")
if not path.endswith((".svg", ".html", ".txt")):
parser.exit(1, "error: help preview path must end with .svg, .html, or .txt\n")

text = r.Text.from_ansi(parser.format_help())
console = r.Console(record=True)
with console.capture():
console.print(text, crop=False)

if path.endswith(".svg"):
self.export_kwds.setdefault("title", "")
console.save_svg(path, **self.export_kwds)
elif path.endswith(".html"):
console.save_html(path, **self.export_kwds)
elif path.endswith(".txt"):
console.save_text(path, **self.export_kwds)
else:
raise AssertionError("unreachable")
parser.exit(0, f"Help preview saved to {path}\n")
26 changes: 13 additions & 13 deletions rich_argparse/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import argparse
import sys

from rich_argparse import RichHelpFormatter
from rich.terminal_theme import DIMMED_MONOKAI

from rich_argparse import HelpPreviewAction, RichHelpFormatter

if __name__ == "__main__":
RichHelpFormatter.highlights.append(r"(?:^|\s)-{1,2}[\w]+[\w-]* (?P<metavar>METAVAR)\b")
Expand Down Expand Up @@ -84,17 +86,15 @@
"--poor", action="store_false", dest="rich", help="Does poor mean --not-rich 😉?"
)
mutex.add_argument("--not-rich", action="store_false", dest="rich", help=argparse.SUPPRESS)

if "--generate-rich-argparse-preview" in sys.argv: # for internal use only
from rich.console import Console
from rich.terminal_theme import DIMMED_MONOKAI
from rich.text import Text

width = 128
parser.formatter_class = lambda prog: RichHelpFormatter(prog, width=width)
text = Text.from_ansi(parser.format_help())
console = Console(record=True, width=width)
console.print(text)
console.save_svg("rich-argparse.svg", title="", theme=DIMMED_MONOKAI)
parser.add_argument(
"--generate-rich-argparse-preview",
action=HelpPreviewAction,
path="rich-argparse.svg",
export_kwds={"theme": DIMMED_MONOKAI},
)
# There is no program to run, always print help (except for the hidden --generate option)
# You probably don't want to do this in your own code.
if any(arg.startswith("--generate") for arg in sys.argv):
parser.parse_args()
else:
parser.print_help()
63 changes: 63 additions & 0 deletions tests/test_argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import rich_argparse._lazy_rich as r
from rich_argparse import (
ArgumentDefaultsRichHelpFormatter,
HelpPreviewAction,
MetavarTypeRichHelpFormatter,
RawDescriptionRichHelpFormatter,
RawTextRichHelpFormatter,
Expand Down Expand Up @@ -910,3 +911,65 @@ def test_rich_renderables():
\x1b[31mThe end.\x1b[0m
"""
assert parser.format_help() == clean(expected_help)


def test_help_preview_generation(tmp_path):
parser = ArgumentParser("PROG", formatter_class=RichHelpFormatter)
parser.add_argument("--foo", help="foo help")
preview_action = parser.add_argument("--generate", action=HelpPreviewAction)
default_path = tmp_path / "default-preview.svg"
parser.add_argument("--generate-with-default", action=HelpPreviewAction, path=str(default_path))

# No namespace pollution
args = parser.parse_args(["--foo", "FOO"])
assert vars(args) == {"foo": "FOO"}

# No help pollution
assert "--generate" not in parser.format_help()

# No file, error
with pytest.raises(SystemExit) as exc_info:
parser.parse_args(["--generate"])
assert exc_info.value.code == 1

# Default file, ok
with pytest.raises(SystemExit) as exc_info:
parser.parse_args(["--generate-with-default"])
assert exc_info.value.code == 0
assert default_path.exists()

# SVG file
svg_file = tmp_path / "preview.svg"
with pytest.raises(SystemExit) as exc_info:
parser.parse_args(["--generate", str(svg_file)])
assert exc_info.value.code == 0
assert svg_file.exists()
assert svg_file.read_text().startswith("<svg")

# HTML file
preview_action.export_kwds = {}
html_file = tmp_path / "preview.html"
with pytest.raises(SystemExit) as exc_info:
parser.parse_args(["--generate", str(html_file)])
assert exc_info.value.code == 0
assert html_file.exists()
assert html_file.read_text().startswith("<!DOCTYPE html>")

# TXT file
preview_action.export_kwds = {}
txt_file = tmp_path / "preview.txt"
with pytest.raises(SystemExit) as exc_info:
parser.parse_args(["--generate", str(txt_file)])
assert exc_info.value.code == 0
assert txt_file.exists()
assert txt_file.read_text().startswith("Usage:")

# Wrong file extension
with pytest.raises(SystemExit) as exc_info:
parser.parse_args(["--generate", str(tmp_path / "preview.png")])
assert exc_info.value.code == 1

# Wrong type
with pytest.raises(SystemExit) as exc_info:
parser.parse_args(["--generate", ("",)])
assert exc_info.value.code == 1