From 119c476321ca8ee26bfe8fbd20138910116a48a7 Mon Sep 17 00:00:00 2001 From: Gabriel Arjones Date: Wed, 3 Sep 2025 21:19:36 -0300 Subject: [PATCH 1/9] Make sure 'rich.markup' is imported when rendering help text rich.markup is used to escape strings when rendering help texts. This fixes a regression introduced by https://github.com/fastapi/typer/pull/1128 --- typer/core.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/typer/core.py b/typer/core.py index 54e295d639..ce8caedc66 100644 --- a/typer/core.py +++ b/typer/core.py @@ -372,6 +372,7 @@ def get_help_record(self, ctx: click.Context) -> Optional[Tuple[str, str]]: extra_str = f"[{extra_str}]" if rich is not None: # This is needed for when we want to export to HTML + import rich.markup extra_str = rich.markup.escape(extra_str).strip() help = f"{help} {extra_str}" if help else f"{extra_str}" @@ -583,6 +584,7 @@ def _write_opts(opts: Sequence[str]) -> str: extra_str = f"[{extra_str}]" if rich is not None: # This is needed for when we want to export to HTML + import rich.markup extra_str = rich.markup.escape(extra_str).strip() help = f"{help} {extra_str}" if help else f"{extra_str}" From 7a78f9a7c1071ff744eaffce4a78b8d18dfe2b7d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 00:20:55 +0000 Subject: [PATCH 2/9] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20for?= =?UTF-8?q?mat=20from=20pre-commit.com=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- typer/core.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/typer/core.py b/typer/core.py index ce8caedc66..cf14ce4031 100644 --- a/typer/core.py +++ b/typer/core.py @@ -373,6 +373,7 @@ def get_help_record(self, ctx: click.Context) -> Optional[Tuple[str, str]]: if rich is not None: # This is needed for when we want to export to HTML import rich.markup + extra_str = rich.markup.escape(extra_str).strip() help = f"{help} {extra_str}" if help else f"{extra_str}" @@ -585,6 +586,7 @@ def _write_opts(opts: Sequence[str]) -> str: if rich is not None: # This is needed for when we want to export to HTML import rich.markup + extra_str = rich.markup.escape(extra_str).strip() help = f"{help} {extra_str}" if help else f"{extra_str}" From 2ec85241969aa93631dc1702455a4c93534a7d1a Mon Sep 17 00:00:00 2001 From: svlandeg Date: Fri, 5 Sep 2025 11:32:08 +0200 Subject: [PATCH 3/9] move escaping functionality to rich_utils --- typer/core.py | 8 ++++---- typer/rich_utils.py | 6 ++++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/typer/core.py b/typer/core.py index cf14ce4031..b197382029 100644 --- a/typer/core.py +++ b/typer/core.py @@ -372,9 +372,9 @@ def get_help_record(self, ctx: click.Context) -> Optional[Tuple[str, str]]: extra_str = f"[{extra_str}]" if rich is not None: # This is needed for when we want to export to HTML - import rich.markup + from . import rich_utils - extra_str = rich.markup.escape(extra_str).strip() + extra_str = rich_utils.escape_before_html_export(extra_str) help = f"{help} {extra_str}" if help else f"{extra_str}" return name, help @@ -585,9 +585,9 @@ def _write_opts(opts: Sequence[str]) -> str: extra_str = f"[{extra_str}]" if rich is not None: # This is needed for when we want to export to HTML - import rich.markup + from . import rich_utils - extra_str = rich.markup.escape(extra_str).strip() + extra_str = rich_utils.escape_before_html_export(extra_str) help = f"{help} {extra_str}" if help else f"{extra_str}" diff --git a/typer/rich_utils.py b/typer/rich_utils.py index 404e97503b..0a01d4d787 100644 --- a/typer/rich_utils.py +++ b/typer/rich_utils.py @@ -16,6 +16,7 @@ from rich.emoji import Emoji from rich.highlighter import RegexHighlighter from rich.markdown import Markdown +from rich.markup import escape from rich.padding import Padding from rich.panel import Panel from rich.table import Table @@ -727,6 +728,11 @@ def rich_abort_error() -> None: console.print(ABORTED_TEXT, style=STYLE_ABORTED) +def escape_before_html_export(input_text: str) -> str: + """Ensure that the input string can be used for HTML export.""" + return escape(input_text).strip() + + def rich_to_html(input_text: str) -> str: """Print the HTML version of a rich-formatted input string. From 8cf68ee9513972a1cc2d7d8cb5bcc249f9fc2253 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Fri, 5 Sep 2025 16:18:46 +0200 Subject: [PATCH 4/9] add test that fails on master --- tests/test_rich_utils.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py index fbcfdde4b8..f7574db9aa 100644 --- a/tests/test_rich_utils.py +++ b/tests/test_rich_utils.py @@ -1,3 +1,6 @@ +import sys +from typing import Annotated + import typer import typer.completion from typer.testing import CliRunner @@ -79,3 +82,21 @@ def main( assert "Hello Rick" in result.stdout assert "First: option_1_default" in result.stdout assert "Second: Morty" in result.stdout + + +def test_rich_ness(): + # Remove rich.markup if it was imported by other tests + if "rich" in sys.modules: + rich_module = sys.modules["rich"] + if hasattr(rich_module, "markup"): + delattr(rich_module, "markup") + + app = typer.Typer(rich_markup_mode=None) + + @app.command() + def main(bar: Annotated[str, typer.Argument(help="foobar")]): + pass + + result = runner.invoke(app, ["--help"]) + assert "Usage" in result.stdout + assert "bar" in result.stdout From d2ce044d96db855836db209a5642e4f3ebc052f4 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Fri, 5 Sep 2025 16:23:37 +0200 Subject: [PATCH 5/9] simplify test --- tests/test_rich_utils.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py index f7574db9aa..e664ba3f37 100644 --- a/tests/test_rich_utils.py +++ b/tests/test_rich_utils.py @@ -1,5 +1,4 @@ import sys -from typing import Annotated import typer import typer.completion @@ -94,9 +93,9 @@ def test_rich_ness(): app = typer.Typer(rich_markup_mode=None) @app.command() - def main(bar: Annotated[str, typer.Argument(help="foobar")]): + def main(bar: str): pass result = runner.invoke(app, ["--help"]) assert "Usage" in result.stdout - assert "bar" in result.stdout + assert "BAR" in result.stdout From c441984e9b97558d3b7f57558cb24c5ab12e79cc Mon Sep 17 00:00:00 2001 From: svlandeg Date: Fri, 5 Sep 2025 16:31:02 +0200 Subject: [PATCH 6/9] fix coverage --- tests/test_rich_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py index e664ba3f37..d31dbafb5c 100644 --- a/tests/test_rich_utils.py +++ b/tests/test_rich_utils.py @@ -83,7 +83,7 @@ def main( assert "Second: Morty" in result.stdout -def test_rich_ness(): +def test_rich_markup_import_regression(): # Remove rich.markup if it was imported by other tests if "rich" in sys.modules: rich_module = sys.modules["rich"] @@ -94,7 +94,7 @@ def test_rich_ness(): @app.command() def main(bar: str): - pass + pass # pragma: no cover result = runner.invoke(app, ["--help"]) assert "Usage" in result.stdout From 5cb453495d43ad67427123045030cbc228ed9cbb Mon Sep 17 00:00:00 2001 From: svlandeg Date: Fri, 5 Sep 2025 16:37:25 +0200 Subject: [PATCH 7/9] move traceback stuff to rich_utils as well --- typer/main.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/typer/main.py b/typer/main.py index 44a9725590..7ec9b8a0db 100644 --- a/typer/main.py +++ b/typer/main.py @@ -75,28 +75,19 @@ def except_hook( return typer_path = os.path.dirname(__file__) click_path = os.path.dirname(click.__file__) - supress_internal_dir_names = [typer_path, click_path] + internal_dir_names = [typer_path, click_path] exc = exc_value if rich: - from rich.traceback import Traceback - from . import rich_utils - rich_tb = Traceback.from_exception( - type(exc), - exc, - exc.__traceback__, - show_locals=exception_config.pretty_exceptions_show_locals, - suppress=supress_internal_dir_names, - width=rich_utils.MAX_WIDTH, - ) + rich_tb = rich_utils.get_traceback(exc, exception_config, internal_dir_names) console_stderr = rich_utils._get_rich_console(stderr=True) console_stderr.print(rich_tb) return tb_exc = traceback.TracebackException.from_exception(exc) stack: List[FrameSummary] = [] for frame in tb_exc.stack: - if any(frame.filename.startswith(path) for path in supress_internal_dir_names): + if any(frame.filename.startswith(path) for path in internal_dir_names): if not exception_config.pretty_exceptions_short: # Hide the line for internal libraries, Typer and Click stack.append( From 387f186dea453b0c3370fe4c9f5e853e45d6bf21 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Fri, 5 Sep 2025 16:39:02 +0200 Subject: [PATCH 8/9] get_traceback method --- typer/rich_utils.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/typer/rich_utils.py b/typer/rich_utils.py index 0a01d4d787..85901ab459 100644 --- a/typer/rich_utils.py +++ b/typer/rich_utils.py @@ -22,6 +22,8 @@ from rich.table import Table from rich.text import Text from rich.theme import Theme +from rich.traceback import Traceback +from typer.models import DeveloperExceptionConfig if sys.version_info >= (3, 9): from typing import Literal @@ -750,3 +752,19 @@ def rich_render_text(text: str) -> str: """Remove rich tags and render a pure text representation""" console = _get_rich_console() return "".join(segment.text for segment in console.render(text)).rstrip("\n") + + +def get_traceback( + exc: BaseException, + exception_config: DeveloperExceptionConfig, + internal_dir_names: List[str], +): + rich_tb = Traceback.from_exception( + type(exc), + exc, + exc.__traceback__, + show_locals=exception_config.pretty_exceptions_show_locals, + suppress=internal_dir_names, + width=MAX_WIDTH, + ) + return rich_tb From 3d8be3203dde7f73d41f528a8f939d704fe08902 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Fri, 5 Sep 2025 16:48:39 +0200 Subject: [PATCH 9/9] add return type --- typer/rich_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typer/rich_utils.py b/typer/rich_utils.py index 85901ab459..d4c3676aea 100644 --- a/typer/rich_utils.py +++ b/typer/rich_utils.py @@ -758,7 +758,7 @@ def get_traceback( exc: BaseException, exception_config: DeveloperExceptionConfig, internal_dir_names: List[str], -): +) -> Traceback: rich_tb = Traceback.from_exception( type(exc), exc,