Skip to content

Commit

Permalink
✨ Render Rich markup as HTML in Markdown docs (#847)
Browse files Browse the repository at this point in the history
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
  • Loading branch information
svlandeg and pre-commit-ci[bot] authored Nov 18, 2024
1 parent a6167b3 commit 5996fc1
Show file tree
Hide file tree
Showing 6 changed files with 122 additions and 7 deletions.
22 changes: 22 additions & 0 deletions tests/assets/cli/rich_formatted_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import typer
from typing_extensions import Annotated

app = typer.Typer(rich_markup_mode="rich")


@app.command(help="Say [bold red]hello[/bold red] to the user.")
def hello(
user_1: Annotated[
str,
typer.Argument(help="The [bold]cool[/bold] name of the [green]user[/green]"),
],
user_2: Annotated[str, typer.Argument(help="The world")] = "The World",
force: Annotated[
bool, typer.Option(help="Force the welcome [red]message[/red]")
] = False,
):
print(f"Hello {user_1} and {user_2}") # pragma: no cover


if __name__ == "__main__":
app()
21 changes: 21 additions & 0 deletions tests/assets/cli/richformattedapp-docs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Awesome CLI

Say <span style="color: #800000; text-decoration-color: #800000; font-weight: bold">hello</span> to the user.

**Usage**:

```console
$ hello [OPTIONS] USER_1 [USER_2]
```

**Arguments**:

* `USER_1`: The <span style="font-weight: bold">cool</span> name of the <span style="color: #008000; text-decoration-color: #008000">user</span> [required]
* `[USER_2]`: The world [default: The World]

**Options**:

* `--force / --no-force`: Force the welcome <span style="color: #800000; text-decoration-color: #800000">message</span> [default: no-force]
* `--install-completion`: Install completion for the current shell.
* `--show-completion`: Show completion for the current shell, to copy it or customize the installation.
* `--help`: Show this message and exit.
32 changes: 32 additions & 0 deletions tests/test_cli/test_doc.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
import subprocess
import sys
from pathlib import Path
Expand Down Expand Up @@ -140,3 +141,34 @@ def test_doc_file_not_existing():
encoding="utf-8",
)
assert "Not a valid file or Python module:" in result.stderr


def test_doc_html_output(tmp_path: Path):
out_file: Path = tmp_path / "out.md"
result = subprocess.run(
[
sys.executable,
"-m",
"coverage",
"run",
"-m",
"typer",
"tests.assets.cli.rich_formatted_app",
"utils",
"docs",
"--title",
"Awesome CLI",
"--output",
str(out_file),
],
capture_output=True,
encoding="utf-8",
env={**os.environ, "PYTHONIOENCODING": "utf-8"},
)
docs_path: Path = (
Path(__file__).parent.parent / "assets" / "cli" / "richformattedapp-docs.md"
)
docs = docs_path.read_text(encoding="utf-8")
written_docs = out_file.read_text(encoding="utf-8")
assert docs in written_docs
assert "Docs saved to:" in result.stdout
24 changes: 20 additions & 4 deletions typer/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@

from . import __version__

try:
import rich

has_rich = True
from . import rich_utils

except ImportError: # pragma: no cover
has_rich = False
rich = None # type: ignore

default_app_names = ("app", "cli", "main")
default_func_names = ("main", "cli", "app")

Expand Down Expand Up @@ -199,7 +209,7 @@ def get_docs_for_click(
title = f"`{command_name}`" if command_name else "CLI"
docs += f" {title}\n\n"
if obj.help:
docs += f"{obj.help}\n\n"
docs += f"{_parse_html(obj.help)}\n\n"
usage_pieces = obj.collect_usage_pieces(ctx)
if usage_pieces:
docs += "**Usage**:\n\n"
Expand All @@ -223,15 +233,15 @@ def get_docs_for_click(
for arg_name, arg_help in args:
docs += f"* `{arg_name}`"
if arg_help:
docs += f": {arg_help}"
docs += f": {_parse_html(arg_help)}"
docs += "\n"
docs += "\n"
if opts:
docs += "**Options**:\n\n"
for opt_name, opt_help in opts:
docs += f"* `{opt_name}`"
if opt_help:
docs += f": {opt_help}"
docs += f": {_parse_html(opt_help)}"
docs += "\n"
docs += "\n"
if obj.epilog:
Expand All @@ -247,7 +257,7 @@ def get_docs_for_click(
docs += f"* `{command_obj.name}`"
command_help = command_obj.get_short_help_str()
if command_help:
docs += f": {command_help}"
docs += f": {_parse_html(command_help)}"
docs += "\n"
docs += "\n"
for command in commands:
Expand All @@ -262,6 +272,12 @@ def get_docs_for_click(
return docs


def _parse_html(input_text: str) -> str:
if not has_rich: # pragma: no cover
return input_text
return rich_utils.rich_to_html(input_text)


@utils_app.command()
def docs(
ctx: typer.Context,
Expand Down
16 changes: 13 additions & 3 deletions typer/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,8 +366,13 @@ def get_help_record(self, ctx: click.Context) -> Optional[Tuple[str, str]]:
if self.required:
extra.append(_("required"))
if extra:
extra_str = ";".join(extra)
help = f"{help} [{extra_str}]" if help else f"[{extra_str}]"
extra_str = "; ".join(extra)
extra_str = f"[{extra_str}]"
if rich is not None:
# This is needed for when we want to export to HTML
extra_str = rich.markup.escape(extra_str).strip()

help = f"{help} {extra_str}" if help else f"{extra_str}"
return name, help

def make_metavar(self) -> str:
Expand Down Expand Up @@ -554,7 +559,12 @@ def _write_opts(opts: Sequence[str]) -> str:

if extra:
extra_str = "; ".join(extra)
help = f"{help} [{extra_str}]" if help else f"[{extra_str}]"
extra_str = f"[{extra_str}]"
if rich is not None:
# This is needed for when we want to export to HTML
extra_str = rich.markup.escape(extra_str).strip()

help = f"{help} {extra_str}" if help else f"{extra_str}"

return ("; " if any_prefix_is_slash else " / ").join(rv), help

Expand Down
14 changes: 14 additions & 0 deletions typer/rich_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -709,3 +709,17 @@ def rich_abort_error() -> None:
"""Print richly formatted abort error."""
console = _get_rich_console(stderr=True)
console.print(ABORTED_TEXT, style=STYLE_ABORTED)


def rich_to_html(input_text: str) -> str:
"""Print the HTML version of a rich-formatted input string.
This function does not provide a full HTML page, but can be used to insert
HTML-formatted text spans into a markdown file.
"""
console = Console(record=True, highlight=False)

with console.capture():
console.print(input_text, overflow="ignore", crop=False)

return console.export_html(inline_styles=True, code_format="{code}").strip()

0 comments on commit 5996fc1

Please sign in to comment.