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

Conversation

svlandeg
Copy link
Member

@svlandeg svlandeg commented Dec 12, 2024

Background

With PR #847, we implemented functionality to ensure that help descriptions with Rich tags (e.g. [bold]) would get rendered properly in Markdown (instead of just showing the "raw" brackets). This uses Rich's functionality from console.export_html.

Unfortunately, a problem occurred at the time because meta information like [required] looks like Rich tags, but with no known formatting they would just get stripped off by console.export_html(). That then meant we had to escape those tags at the point in the code where they're being added to the rest of the help string - this happens in get_help_record for both TyperOption and TyperArgument :

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}"

Current issue

Now, as pointed out in #1058, there's now an issue when the escaping happens if rich is installed (triggering the if clause in the code above) BUT the Typer app actually has rich_markup_mode=None. The help text will in this case show the backslash from the escaping, which is clearly unwanted behaviour.

Fix (proposal)

To remedy, we need to know at the time of escaping what the value is of rich_markup_mode. This is not trivial, as TyperOption and TyperArgument are unaware of this information. And we can't just pass it through from the Typer app either, because get_click_param() creates empty/default OptionInfo/ArgumentInfo objects which will lead to default values downstream. I don't see a way to make things work via this route.

Instead, this PR proposes to store a custom value in ctx.obj. This object is being passed around anyway, and as far as I understood, ctx.obj may be used for these kind of application-specific settings. The code I've written tries to be super careful not to mess up any current usages of this object:

    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):
            ctx.obj['TYPER_RICH_MARKUP_MODE'] = typer_obj.rich_markup_mode

Then, right before the escaping we can check this value and refrain from escaping if Rich is not enabled.

Additional complication

When utils docs is called to create a markdown version of a file, we need to make sure that we only call rich_to_html on text that has been properly escaped, i.e. when rich is installed AND rich_markup_mode == "rich". This requires us to inspect the value of ctx.obj also in the function _parse_html. I've added another unit test to double check this behaviour when rich_markup_mode=None - cf test_doc_no_typer()

@svlandeg svlandeg self-assigned this Dec 12, 2024
@@ -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)

@svlandeg svlandeg added the bug Something isn't working label Dec 12, 2024
"""


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)

@svlandeg svlandeg marked this pull request as ready for review December 12, 2024 15:25
@svlandeg svlandeg removed their assignment Dec 12, 2024
Comment on lines +276 to +282
def _parse_html(ctx: click.Context, input_text: str) -> str:
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 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
Copy link
Contributor

Choose a reason for hiding this comment

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

🤔 I think we should move the computation of rich_markup_mode in get_docs_for_click so we don't do it more than necessary (and we keep this function simple)

Copy link
Member Author

Choose a reason for hiding this comment

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

Yea fair enough, I had it there first 😁 Somehow that felt like cluttering the get_docs_for_click function, and conceptually it's nicer to pass around ctx everywhere. But I agree that it means repeating the same computation (though they should be simple, fast checks/retrievals)

def _parse_html(ctx: click.Context, input_text: str) -> str:
rich_markup_mode = None
if hasattr(ctx, "obj") and isinstance(ctx.obj, dict):
rich_markup_mode = ctx.obj.get("TYPER_RICH_MARKUP_MODE", None)
Copy link
Member Author

@svlandeg svlandeg Dec 13, 2024

Choose a reason for hiding this comment

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

We should also consider pulling this "TYPER_RICH_MARKUP_MODE" value out as a variable and putting it somewhere central.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants