Skip to content
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

🐛 Fix escaping in help text when rich is installed but not used #1089

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
37 changes: 37 additions & 0 deletions tests/assets/cli/multi_app_norich.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import typer

sub_app = typer.Typer()

variable = "Some text"


@sub_app.command()
def hello(name: str = "World", age: int = typer.Option(0, help="The age of the user")):
"""
Say Hello
"""


@sub_app.command()
def hi(user: str = typer.Argument("World", help="The name of the user to greet")):
"""
Say Hi
"""


@sub_app.command()
def bye():
"""
Say bye
"""


app = typer.Typer(help="Demo App", epilog="The end", rich_markup_mode=None)
Copy link
Member Author

@svlandeg svlandeg Dec 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The point of this new test is this: checking that rich_markup_mode=None, with rich installed, still produces correct results (showing [required], default values, etc and not accidentally 'removing' them because of incorrect escaping behaviour)

app.add_typer(sub_app, name="sub")


@app.command()
def top():
"""
Top command
"""
24 changes: 24 additions & 0 deletions tests/test_cli/test_doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,30 @@ def test_doc_title_output(tmp_path: Path):
assert "Docs saved to:" in result.stdout


def test_doc_no_rich():
result = subprocess.run(
[
sys.executable,
"-m",
"coverage",
"run",
"-m",
"typer",
"tests.assets.cli.multi_app_norich",
"utils",
"docs",
"--name",
"multiapp",
],
capture_output=True,
encoding="utf-8",
)
docs_path: Path = Path(__file__).parent.parent / "assets/cli/multiapp-docs.md"
docs = docs_path.read_text()
assert docs in result.stdout
assert "**Arguments**" in result.stdout


def test_doc_not_existing():
result = subprocess.run(
[
Expand Down
1 change: 1 addition & 0 deletions tests/test_rich_markup_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def main(arg: str):
assert "Hello World" in result.stdout

result = runner.invoke(app, ["--help"])
assert "ARG [required]" in result.stdout
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fails with master, as pointed out in #1058 (it'll have a slash)

assert all(c not in result.stdout for c in rounded)


Expand Down
28 changes: 20 additions & 8 deletions typer/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from click import Command, Group, Option

from . import __version__
from .models import DefaultPlaceholder

try:
import rich
Expand Down Expand Up @@ -202,14 +203,17 @@ def get_docs_for_click(
title: Optional[str] = None,
) -> str:
docs = "#" * (1 + indent)
rich_markup_mode = None
if hasattr(ctx, "obj") and isinstance(ctx.obj, dict):
rich_markup_mode = ctx.obj.get("TYPER_RICH_MARKUP_MODE", None)
command_name = name or obj.name
if call_prefix:
command_name = f"{call_prefix} {command_name}"
if not title:
title = f"`{command_name}`" if command_name else "CLI"
docs += f" {title}\n\n"
if obj.help:
docs += f"{_parse_html(obj.help)}\n\n"
docs += f"{_parse_html(obj.help, rich_markup_mode)}\n\n"
usage_pieces = obj.collect_usage_pieces(ctx)
if usage_pieces:
docs += "**Usage**:\n\n"
Expand All @@ -233,15 +237,15 @@ def get_docs_for_click(
for arg_name, arg_help in args:
docs += f"* `{arg_name}`"
if arg_help:
docs += f": {_parse_html(arg_help)}"
docs += f": {_parse_html(arg_help, rich_markup_mode)}"
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": {_parse_html(opt_help)}"
docs += f": {_parse_html(opt_help, rich_markup_mode)}"
docs += "\n"
docs += "\n"
if obj.epilog:
Expand All @@ -257,7 +261,7 @@ def get_docs_for_click(
docs += f"* `{command_obj.name}`"
command_help = command_obj.get_short_help_str()
if command_help:
docs += f": {_parse_html(command_help)}"
docs += f": {_parse_html(command_help, rich_markup_mode)}"
docs += "\n"
docs += "\n"
for command in commands:
Expand All @@ -272,10 +276,10 @@ 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)
def _parse_html(input_text: str, rich_markup_mode: Optional[str]) -> str:
if has_rich and rich_markup_mode and rich_markup_mode == "rich": # pragma: no cover
return rich_utils.rich_to_html(input_text)
return input_text


@utils_app.command()
Expand All @@ -301,6 +305,14 @@ def docs(
if not typer_obj:
typer.echo("No Typer app found", err=True)
raise typer.Abort()
if hasattr(typer_obj, "rich_markup_mode"):
if not hasattr(ctx, "obj") or ctx.obj is None:
ctx.ensure_object(dict)
if isinstance(ctx.obj, dict):
if isinstance(typer_obj.rich_markup_mode, DefaultPlaceholder):
ctx.obj["TYPER_RICH_MARKUP_MODE"] = typer_obj.rich_markup_mode.value
else:
ctx.obj["TYPER_RICH_MARKUP_MODE"] = typer_obj.rich_markup_mode
click_obj = typer.main.get_command(typer_obj)
docs = get_docs_for_click(obj=click_obj, ctx=ctx, name=name, title=title)
clean_docs = f"{docs.strip()}\n"
Expand Down
20 changes: 18 additions & 2 deletions typer/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
import click.types
import click.utils

from .models import DefaultPlaceholder

if sys.version_info >= (3, 8):
from typing import Literal
else:
Expand Down Expand Up @@ -371,7 +373,10 @@ def get_help_record(self, ctx: click.Context) -> Optional[Tuple[str, str]]:
if extra:
extra_str = "; ".join(extra)
extra_str = f"[{extra_str}]"
if rich is not None:
rich_markup_mode = None
if hasattr(ctx, "obj") and isinstance(ctx.obj, dict):
rich_markup_mode = ctx.obj.get("TYPER_RICH_MARKUP_MODE", None)
if rich is not None and rich_markup_mode == "rich":
# This is needed for when we want to export to HTML
extra_str = rich.markup.escape(extra_str).strip()

Expand Down Expand Up @@ -565,7 +570,11 @@ def _write_opts(opts: Sequence[str]) -> str:
if extra:
extra_str = "; ".join(extra)
extra_str = f"[{extra_str}]"
if rich is not None:

rich_markup_mode = None
if hasattr(ctx, "obj") and isinstance(ctx.obj, dict):
rich_markup_mode = ctx.obj.get("TYPER_RICH_MARKUP_MODE", None)
if rich is not None and rich_markup_mode == "rich":
# This is needed for when we want to export to HTML
extra_str = rich.markup.escape(extra_str).strip()

Expand Down Expand Up @@ -690,6 +699,13 @@ def main(

def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
if not rich or self.rich_markup_mode is None:
if not hasattr(ctx, "obj") or ctx.obj is None:
ctx.ensure_object(dict)
if isinstance(ctx.obj, dict):
if isinstance(self.rich_markup_mode, DefaultPlaceholder):
ctx.obj["TYPER_RICH_MARKUP_MODE"] = self.rich_markup_mode.value
else:
ctx.obj["TYPER_RICH_MARKUP_MODE"] = self.rich_markup_mode
return super().format_help(ctx, formatter)
return rich_utils.rich_format_help(
obj=self,
Expand Down
Loading