diff --git a/tests/test_completion/example_rich_tags.py b/tests/test_completion/example_rich_tags.py new file mode 100644 index 0000000000..4fc85c6d92 --- /dev/null +++ b/tests/test_completion/example_rich_tags.py @@ -0,0 +1,31 @@ +import typer + +app = typer.Typer() + + +@app.command() +def create(username: str): + """ + Create a [green]new[green/] user with USERNAME. + """ + print(f"Creating user: {username}") + + +@app.command() +def delete(username: str): + """ + Delete a user with [bold]USERNAME[/]. + """ + print(f"Deleting user: {username}") + + +@app.command() +def delete_all(): + """ + [red]Delete ALL users[/red] in the database. + """ + print("Deleting all users") + + +if __name__ == "__main__": + app() diff --git a/tests/test_completion/test_completion_complete.py b/tests/test_completion/test_completion_complete.py index e6d18b58a3..ea37f68546 100644 --- a/tests/test_completion/test_completion_complete.py +++ b/tests/test_completion/test_completion_complete.py @@ -79,7 +79,7 @@ def test_completion_complete_subcommand_fish(): }, ) assert ( - "delete Delete a user with USERNAME.\ndelete-all Delete ALL users in the database." + "delete\tDelete a user with USERNAME.\ndelete-all\tDelete ALL users in the database." in result.stdout ) diff --git a/tests/test_completion/test_completion_complete_rich.py b/tests/test_completion/test_completion_complete_rich.py new file mode 100644 index 0000000000..9c53bb1032 --- /dev/null +++ b/tests/test_completion/test_completion_complete_rich.py @@ -0,0 +1,113 @@ +import os +import subprocess +import sys + +from . import example_rich_tags as mod + + +def test_script(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, "create", "DeadPool"], + capture_output=True, + encoding="utf-8", + ) + assert result.returncode == 0 + assert "Creating user: DeadPool" in result.stdout + + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, "delete", "DeadPool"], + capture_output=True, + encoding="utf-8", + ) + assert result.returncode == 0 + assert "Deleting user: DeadPool" in result.stdout + + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, "delete-all"], + capture_output=True, + encoding="utf-8", + ) + assert result.returncode == 0 + assert "Deleting all users" in result.stdout + + +def test_completion_complete_subcommand_bash(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_EXAMPLE_RICH_TAGS.PY_COMPLETE": "complete_bash", + "COMP_WORDS": "example_rich_tags.py del", + "COMP_CWORD": "1", + }, + ) + assert "delete\ndelete-all" in result.stdout + + +def test_completion_complete_subcommand_zsh(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_EXAMPLE_RICH_TAGS.PY_COMPLETE": "complete_zsh", + "_TYPER_COMPLETE_ARGS": "example_rich_tags.py del", + }, + ) + assert ( + """_arguments '*: :(("delete":"Delete a user with USERNAME."\n""" + """\"delete-all":"Delete ALL users in the database."))'""" + ) in result.stdout + + +def test_completion_complete_subcommand_fish(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_EXAMPLE_RICH_TAGS.PY_COMPLETE": "complete_fish", + "_TYPER_COMPLETE_ARGS": "example_rich_tags.py del", + "_TYPER_COMPLETE_FISH_ACTION": "get-args", + }, + ) + assert ( + "delete\tDelete a user with USERNAME.\ndelete-all\tDelete ALL users in the database." + in result.stdout + ) + + +def test_completion_complete_subcommand_powershell(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_EXAMPLE_RICH_TAGS.PY_COMPLETE": "complete_powershell", + "_TYPER_COMPLETE_ARGS": "example_rich_tags.py del", + }, + ) + assert ( + "delete:::Delete a user with USERNAME.\ndelete-all:::Delete ALL users in the database." + ) in result.stdout + + +def test_completion_complete_subcommand_pwsh(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_EXAMPLE_RICH_TAGS.PY_COMPLETE": "complete_pwsh", + "_TYPER_COMPLETE_ARGS": "example_rich_tags.py del", + }, + ) + assert ( + "delete:::Delete a user with USERNAME.\ndelete-all:::Delete ALL users in the database." + ) in result.stdout diff --git a/tests/test_completion/test_sanitization.py b/tests/test_completion/test_sanitization.py new file mode 100644 index 0000000000..a28dcca7d0 --- /dev/null +++ b/tests/test_completion/test_sanitization.py @@ -0,0 +1,39 @@ +from importlib.machinery import ModuleSpec +from typing import Union +from unittest.mock import patch + +import pytest +from typer._completion_classes import _sanitize_help_text + + +@pytest.mark.parametrize( + "find_spec, help_text, expected", + [ + ( + ModuleSpec("rich", loader=None), + "help text without rich tags", + "help text without rich tags", + ), + ( + None, + "help text without rich tags", + "help text without rich tags", + ), + ( + ModuleSpec("rich", loader=None), + "help [bold]with[/] rich tags", + "help with rich tags", + ), + ( + None, + "help [bold]with[/] rich tags", + "help [bold]with[/] rich tags", + ), + ], +) +def test_sanitize_help_text( + find_spec: Union[ModuleSpec, None], help_text: str, expected: str +): + with patch("importlib.util.find_spec", return_value=find_spec) as mock_find_spec: + assert _sanitize_help_text(help_text) == expected + mock_find_spec.assert_called_once_with("rich") diff --git a/typer/_completion_classes.py b/typer/_completion_classes.py index f0bb89c3cc..070bbaa214 100644 --- a/typer/_completion_classes.py +++ b/typer/_completion_classes.py @@ -1,3 +1,4 @@ +import importlib.util import os import re import sys @@ -21,6 +22,15 @@ shellingham = None +def _sanitize_help_text(text: str) -> str: + """Sanitizes the help text by removing rich tags""" + if not importlib.util.find_spec("rich"): + return text + from . import rich_utils + + return rich_utils.rich_render_text(text) + + class BashComplete(click.shell_completion.BashComplete): name = Shells.bash.value source_template = COMPLETION_SCRIPT_BASH @@ -93,7 +103,7 @@ def escape(s: str) -> str: # the difference with and without escape # return f"{item.type}\n{item.value}\n{item.help if item.help else '_'}" if item.help: - return f'"{escape(item.value)}":"{escape(item.help)}"' + return f'"{escape(item.value)}":"{_sanitize_help_text(escape(item.help))}"' else: return f'"{escape(item.value)}"' @@ -139,7 +149,7 @@ def format_completion(self, item: click.shell_completion.CompletionItem) -> str: # return f"{item.type},{item.value} if item.help: formatted_help = re.sub(r"\s", " ", item.help) - return f"{item.value}\t{formatted_help}" + return f"{item.value}\t{_sanitize_help_text(formatted_help)}" else: return f"{item.value}" @@ -180,7 +190,7 @@ def get_completion_args(self) -> Tuple[List[str], str]: return args, incomplete def format_completion(self, item: click.shell_completion.CompletionItem) -> str: - return f"{item.value}:::{item.help or ' '}" + return f"{item.value}:::{_sanitize_help_text(item.help) if item.help else ' '}" def completion_init() -> None: diff --git a/typer/completion.py b/typer/completion.py index 07ce83190c..c355baa781 100644 --- a/typer/completion.py +++ b/typer/completion.py @@ -15,12 +15,6 @@ except ImportError: # pragma: no cover shellingham = None -try: - import rich - -except ImportError: # pragma: no cover - rich = None # type: ignore - _click_patched = False @@ -147,13 +141,7 @@ def shell_complete( # Typer override to print the completion help msg with Rich if instruction == "complete": - if not rich: # pragma: no cover - click.echo(comp.complete()) - else: - from . import rich_utils - - rich_utils.print_with_rich(comp.complete()) - + click.echo(comp.complete()) return 0 # Typer override end diff --git a/typer/rich_utils.py b/typer/rich_utils.py index 7d603da2d7..23b0b595b4 100644 --- a/typer/rich_utils.py +++ b/typer/rich_utils.py @@ -712,12 +712,6 @@ def rich_abort_error() -> None: console.print(ABORTED_TEXT, style=STYLE_ABORTED) -def print_with_rich(text: str) -> None: - """Print richly formatted message.""" - console = _get_rich_console() - console.print(text) - - def rich_to_html(input_text: str) -> str: """Print the HTML version of a rich-formatted input string. @@ -729,3 +723,9 @@ def rich_to_html(input_text: str) -> str: console.print(input_text, overflow="ignore", crop=False) return console.export_html(inline_styles=True, code_format="{code}").strip() + + +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")