Skip to content

Commit 4f04666

Browse files
gorajesvlandeg
andauthored
🐛 Fix shell completions for the fish shell (#1069)
Co-authored-by: svlandeg <[email protected]>
1 parent 85ca5b5 commit 4f04666

File tree

7 files changed

+204
-23
lines changed

7 files changed

+204
-23
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import typer
2+
3+
app = typer.Typer()
4+
5+
6+
@app.command()
7+
def create(username: str):
8+
"""
9+
Create a [green]new[green/] user with USERNAME.
10+
"""
11+
print(f"Creating user: {username}")
12+
13+
14+
@app.command()
15+
def delete(username: str):
16+
"""
17+
Delete a user with [bold]USERNAME[/].
18+
"""
19+
print(f"Deleting user: {username}")
20+
21+
22+
@app.command()
23+
def delete_all():
24+
"""
25+
[red]Delete ALL users[/red] in the database.
26+
"""
27+
print("Deleting all users")
28+
29+
30+
if __name__ == "__main__":
31+
app()

tests/test_completion/test_completion_complete.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ def test_completion_complete_subcommand_fish():
7979
},
8080
)
8181
assert (
82-
"delete Delete a user with USERNAME.\ndelete-all Delete ALL users in the database."
82+
"delete\tDelete a user with USERNAME.\ndelete-all\tDelete ALL users in the database."
8383
in result.stdout
8484
)
8585

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import os
2+
import subprocess
3+
import sys
4+
5+
from . import example_rich_tags as mod
6+
7+
8+
def test_script():
9+
result = subprocess.run(
10+
[sys.executable, "-m", "coverage", "run", mod.__file__, "create", "DeadPool"],
11+
capture_output=True,
12+
encoding="utf-8",
13+
)
14+
assert result.returncode == 0
15+
assert "Creating user: DeadPool" in result.stdout
16+
17+
result = subprocess.run(
18+
[sys.executable, "-m", "coverage", "run", mod.__file__, "delete", "DeadPool"],
19+
capture_output=True,
20+
encoding="utf-8",
21+
)
22+
assert result.returncode == 0
23+
assert "Deleting user: DeadPool" in result.stdout
24+
25+
result = subprocess.run(
26+
[sys.executable, "-m", "coverage", "run", mod.__file__, "delete-all"],
27+
capture_output=True,
28+
encoding="utf-8",
29+
)
30+
assert result.returncode == 0
31+
assert "Deleting all users" in result.stdout
32+
33+
34+
def test_completion_complete_subcommand_bash():
35+
result = subprocess.run(
36+
[sys.executable, "-m", "coverage", "run", mod.__file__, " "],
37+
capture_output=True,
38+
encoding="utf-8",
39+
env={
40+
**os.environ,
41+
"_EXAMPLE_RICH_TAGS.PY_COMPLETE": "complete_bash",
42+
"COMP_WORDS": "example_rich_tags.py del",
43+
"COMP_CWORD": "1",
44+
},
45+
)
46+
assert "delete\ndelete-all" in result.stdout
47+
48+
49+
def test_completion_complete_subcommand_zsh():
50+
result = subprocess.run(
51+
[sys.executable, "-m", "coverage", "run", mod.__file__, " "],
52+
capture_output=True,
53+
encoding="utf-8",
54+
env={
55+
**os.environ,
56+
"_EXAMPLE_RICH_TAGS.PY_COMPLETE": "complete_zsh",
57+
"_TYPER_COMPLETE_ARGS": "example_rich_tags.py del",
58+
},
59+
)
60+
assert (
61+
"""_arguments '*: :(("delete":"Delete a user with USERNAME."\n"""
62+
"""\"delete-all":"Delete ALL users in the database."))'"""
63+
) in result.stdout
64+
65+
66+
def test_completion_complete_subcommand_fish():
67+
result = subprocess.run(
68+
[sys.executable, "-m", "coverage", "run", mod.__file__, " "],
69+
capture_output=True,
70+
encoding="utf-8",
71+
env={
72+
**os.environ,
73+
"_EXAMPLE_RICH_TAGS.PY_COMPLETE": "complete_fish",
74+
"_TYPER_COMPLETE_ARGS": "example_rich_tags.py del",
75+
"_TYPER_COMPLETE_FISH_ACTION": "get-args",
76+
},
77+
)
78+
assert (
79+
"delete\tDelete a user with USERNAME.\ndelete-all\tDelete ALL users in the database."
80+
in result.stdout
81+
)
82+
83+
84+
def test_completion_complete_subcommand_powershell():
85+
result = subprocess.run(
86+
[sys.executable, "-m", "coverage", "run", mod.__file__, " "],
87+
capture_output=True,
88+
encoding="utf-8",
89+
env={
90+
**os.environ,
91+
"_EXAMPLE_RICH_TAGS.PY_COMPLETE": "complete_powershell",
92+
"_TYPER_COMPLETE_ARGS": "example_rich_tags.py del",
93+
},
94+
)
95+
assert (
96+
"delete:::Delete a user with USERNAME.\ndelete-all:::Delete ALL users in the database."
97+
) in result.stdout
98+
99+
100+
def test_completion_complete_subcommand_pwsh():
101+
result = subprocess.run(
102+
[sys.executable, "-m", "coverage", "run", mod.__file__, " "],
103+
capture_output=True,
104+
encoding="utf-8",
105+
env={
106+
**os.environ,
107+
"_EXAMPLE_RICH_TAGS.PY_COMPLETE": "complete_pwsh",
108+
"_TYPER_COMPLETE_ARGS": "example_rich_tags.py del",
109+
},
110+
)
111+
assert (
112+
"delete:::Delete a user with USERNAME.\ndelete-all:::Delete ALL users in the database."
113+
) in result.stdout
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from importlib.machinery import ModuleSpec
2+
from typing import Union
3+
from unittest.mock import patch
4+
5+
import pytest
6+
from typer._completion_classes import _sanitize_help_text
7+
8+
9+
@pytest.mark.parametrize(
10+
"find_spec, help_text, expected",
11+
[
12+
(
13+
ModuleSpec("rich", loader=None),
14+
"help text without rich tags",
15+
"help text without rich tags",
16+
),
17+
(
18+
None,
19+
"help text without rich tags",
20+
"help text without rich tags",
21+
),
22+
(
23+
ModuleSpec("rich", loader=None),
24+
"help [bold]with[/] rich tags",
25+
"help with rich tags",
26+
),
27+
(
28+
None,
29+
"help [bold]with[/] rich tags",
30+
"help [bold]with[/] rich tags",
31+
),
32+
],
33+
)
34+
def test_sanitize_help_text(
35+
find_spec: Union[ModuleSpec, None], help_text: str, expected: str
36+
):
37+
with patch("importlib.util.find_spec", return_value=find_spec) as mock_find_spec:
38+
assert _sanitize_help_text(help_text) == expected
39+
mock_find_spec.assert_called_once_with("rich")

typer/_completion_classes.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import importlib.util
12
import os
23
import re
34
import sys
@@ -21,6 +22,15 @@
2122
shellingham = None
2223

2324

25+
def _sanitize_help_text(text: str) -> str:
26+
"""Sanitizes the help text by removing rich tags"""
27+
if not importlib.util.find_spec("rich"):
28+
return text
29+
from . import rich_utils
30+
31+
return rich_utils.rich_render_text(text)
32+
33+
2434
class BashComplete(click.shell_completion.BashComplete):
2535
name = Shells.bash.value
2636
source_template = COMPLETION_SCRIPT_BASH
@@ -93,7 +103,7 @@ def escape(s: str) -> str:
93103
# the difference with and without escape
94104
# return f"{item.type}\n{item.value}\n{item.help if item.help else '_'}"
95105
if item.help:
96-
return f'"{escape(item.value)}":"{escape(item.help)}"'
106+
return f'"{escape(item.value)}":"{_sanitize_help_text(escape(item.help))}"'
97107
else:
98108
return f'"{escape(item.value)}"'
99109

@@ -139,7 +149,7 @@ def format_completion(self, item: click.shell_completion.CompletionItem) -> str:
139149
# return f"{item.type},{item.value}
140150
if item.help:
141151
formatted_help = re.sub(r"\s", " ", item.help)
142-
return f"{item.value}\t{formatted_help}"
152+
return f"{item.value}\t{_sanitize_help_text(formatted_help)}"
143153
else:
144154
return f"{item.value}"
145155

@@ -180,7 +190,7 @@ def get_completion_args(self) -> Tuple[List[str], str]:
180190
return args, incomplete
181191

182192
def format_completion(self, item: click.shell_completion.CompletionItem) -> str:
183-
return f"{item.value}:::{item.help or ' '}"
193+
return f"{item.value}:::{_sanitize_help_text(item.help) if item.help else ' '}"
184194

185195

186196
def completion_init() -> None:

typer/completion.py

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,6 @@
1515
except ImportError: # pragma: no cover
1616
shellingham = None
1717

18-
try:
19-
import rich
20-
21-
except ImportError: # pragma: no cover
22-
rich = None # type: ignore
23-
2418

2519
_click_patched = False
2620

@@ -147,13 +141,7 @@ def shell_complete(
147141

148142
# Typer override to print the completion help msg with Rich
149143
if instruction == "complete":
150-
if not rich: # pragma: no cover
151-
click.echo(comp.complete())
152-
else:
153-
from . import rich_utils
154-
155-
rich_utils.print_with_rich(comp.complete())
156-
144+
click.echo(comp.complete())
157145
return 0
158146
# Typer override end
159147

typer/rich_utils.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -712,12 +712,6 @@ def rich_abort_error() -> None:
712712
console.print(ABORTED_TEXT, style=STYLE_ABORTED)
713713

714714

715-
def print_with_rich(text: str) -> None:
716-
"""Print richly formatted message."""
717-
console = _get_rich_console()
718-
console.print(text)
719-
720-
721715
def rich_to_html(input_text: str) -> str:
722716
"""Print the HTML version of a rich-formatted input string.
723717
@@ -729,3 +723,9 @@ def rich_to_html(input_text: str) -> str:
729723
console.print(input_text, overflow="ignore", crop=False)
730724

731725
return console.export_html(inline_styles=True, code_format="{code}").strip()
726+
727+
728+
def rich_render_text(text: str) -> str:
729+
"""Remove rich tags and render a pure text representation"""
730+
console = _get_rich_console()
731+
return "".join(segment.text for segment in console.render(text)).rstrip("\n")

0 commit comments

Comments
 (0)