Skip to content

Commit e3e9a63

Browse files
committed
🐛 Fix shell completion for Path arguments when using TyperGroup
1 parent 63f0353 commit e3e9a63

File tree

3 files changed

+278
-12
lines changed

3 files changed

+278
-12
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import click
2+
import typer
3+
import typer.core
4+
5+
6+
class DynamicGroup(typer.core.TyperGroup):
7+
def __init__(self, *args, **kwargs):
8+
super().__init__(*args, **kwargs)
9+
self.add_command(process_cmd, "process")
10+
11+
12+
process_cmd = click.Command(
13+
name="process",
14+
callback=lambda **kw: print(kw),
15+
params=[
16+
click.Option(
17+
["--input", "-i"],
18+
type=click.Path(exists=False),
19+
required=True,
20+
help="Input file",
21+
),
22+
click.Option(
23+
["--count", "-n"],
24+
type=int,
25+
default=1,
26+
help="Number of items",
27+
),
28+
click.Option(
29+
["--verbose"],
30+
is_flag=True,
31+
help="Verbose output",
32+
),
33+
],
34+
)
35+
36+
app = typer.Typer()
37+
sub_app = typer.Typer(cls=DynamicGroup)
38+
39+
40+
@sub_app.callback(invoke_without_command=True)
41+
def sub_callback(ctx: typer.Context):
42+
if ctx.invoked_subcommand is None:
43+
typer.echo(ctx.get_help())
44+
raise typer.Exit(0)
45+
46+
47+
app.add_typer(sub_app, name="sub")
48+
49+
if __name__ == "__main__":
50+
app()

tests/test_completion/test_completion_path.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import sys
44

55
from . import path_example as mod
6+
from . import path_typergroup_example as typergroup_mod
67

78

89
def test_script():
@@ -28,3 +29,191 @@ def test_completion_path_bash():
2829
},
2930
)
3031
assert result.returncode == 0
32+
33+
34+
def test_completion_path_zsh_empty():
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+
"_PATH_EXAMPLE.PY_COMPLETE": "complete_zsh",
42+
"_TYPER_COMPLETE_ARGS": "path_example.py ",
43+
},
44+
)
45+
assert result.returncode == 0
46+
assert "_arguments" not in result.stdout
47+
48+
49+
def test_completion_path_zsh_partial():
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+
"_PATH_EXAMPLE.PY_COMPLETE": "complete_zsh",
57+
"_TYPER_COMPLETE_ARGS": "path_example.py /tmp/some_part",
58+
},
59+
)
60+
assert result.returncode == 0
61+
assert "_arguments" not in result.stdout
62+
63+
64+
def test_completion_typergroup_path_zsh_empty():
65+
result = subprocess.run(
66+
[sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "],
67+
capture_output=True,
68+
encoding="utf-8",
69+
env={
70+
**os.environ,
71+
"_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_zsh",
72+
"_TYPER_COMPLETE_ARGS": "path_typergroup_example.py sub process --input ",
73+
},
74+
)
75+
assert result.returncode == 0
76+
assert "_arguments" not in result.stdout
77+
78+
79+
def test_completion_typergroup_path_zsh_partial():
80+
result = subprocess.run(
81+
[sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "],
82+
capture_output=True,
83+
encoding="utf-8",
84+
env={
85+
**os.environ,
86+
"_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_zsh",
87+
"_TYPER_COMPLETE_ARGS": "path_typergroup_example.py sub process --input /tmp/test",
88+
},
89+
)
90+
assert result.returncode == 0
91+
assert "_arguments" not in result.stdout
92+
93+
94+
def test_completion_typergroup_flags_zsh():
95+
result = subprocess.run(
96+
[sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "],
97+
capture_output=True,
98+
encoding="utf-8",
99+
env={
100+
**os.environ,
101+
"_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_zsh",
102+
"_TYPER_COMPLETE_ARGS": "path_typergroup_example.py sub process --",
103+
},
104+
)
105+
assert result.returncode == 0
106+
assert "_arguments" in result.stdout
107+
assert "--input" in result.stdout
108+
assert "--count" in result.stdout
109+
110+
111+
def test_completion_typergroup_path_bash_empty():
112+
result = subprocess.run(
113+
[sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "],
114+
capture_output=True,
115+
encoding="utf-8",
116+
env={
117+
**os.environ,
118+
"_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_bash",
119+
"COMP_WORDS": "path_typergroup_example.py sub process --input ",
120+
"COMP_CWORD": "4",
121+
},
122+
)
123+
assert result.returncode == 0
124+
assert result.stdout.strip() == ""
125+
126+
127+
def test_completion_typergroup_path_bash_partial():
128+
result = subprocess.run(
129+
[sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "],
130+
capture_output=True,
131+
encoding="utf-8",
132+
env={
133+
**os.environ,
134+
"_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_bash",
135+
"COMP_WORDS": "path_typergroup_example.py sub process --input /tmp/test",
136+
"COMP_CWORD": "4",
137+
},
138+
)
139+
assert result.returncode == 0
140+
assert result.stdout.strip() == ""
141+
142+
143+
def test_completion_typergroup_flags_bash():
144+
result = subprocess.run(
145+
[sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "],
146+
capture_output=True,
147+
encoding="utf-8",
148+
env={
149+
**os.environ,
150+
"_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_bash",
151+
"COMP_WORDS": "path_typergroup_example.py sub process --",
152+
"COMP_CWORD": "3",
153+
},
154+
)
155+
assert result.returncode == 0
156+
assert "--input" in result.stdout
157+
assert "--count" in result.stdout
158+
159+
160+
def test_completion_typergroup_path_fish_is_args():
161+
result = subprocess.run(
162+
[sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "],
163+
capture_output=True,
164+
encoding="utf-8",
165+
env={
166+
**os.environ,
167+
"_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_fish",
168+
"_TYPER_COMPLETE_ARGS": "path_typergroup_example.py sub process --input /tmp/test",
169+
"_TYPER_COMPLETE_FISH_ACTION": "is-args",
170+
},
171+
)
172+
assert result.returncode != 0
173+
174+
175+
def test_completion_typergroup_path_fish_get_args():
176+
result = subprocess.run(
177+
[sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "],
178+
capture_output=True,
179+
encoding="utf-8",
180+
env={
181+
**os.environ,
182+
"_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_fish",
183+
"_TYPER_COMPLETE_ARGS": "path_typergroup_example.py sub process --input /tmp/test",
184+
"_TYPER_COMPLETE_FISH_ACTION": "get-args",
185+
},
186+
)
187+
assert result.stdout.strip() == ""
188+
189+
190+
def test_completion_typergroup_path_powershell_empty():
191+
result = subprocess.run(
192+
[sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "],
193+
capture_output=True,
194+
encoding="utf-8",
195+
env={
196+
**os.environ,
197+
"_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_powershell",
198+
"_TYPER_COMPLETE_ARGS": "path_typergroup_example.py sub process --input ",
199+
"_TYPER_COMPLETE_WORD_TO_COMPLETE": "",
200+
},
201+
)
202+
assert result.returncode == 0
203+
assert result.stdout.strip() == ""
204+
205+
206+
def test_completion_typergroup_path_powershell_partial():
207+
result = subprocess.run(
208+
[sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "],
209+
capture_output=True,
210+
encoding="utf-8",
211+
env={
212+
**os.environ,
213+
"_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_powershell",
214+
"_TYPER_COMPLETE_ARGS": "path_typergroup_example.py sub process --input /tmp/test",
215+
"_TYPER_COMPLETE_WORD_TO_COMPLETE": "/tmp/test",
216+
},
217+
)
218+
assert result.returncode == 0
219+
assert result.stdout.strip() == ""

typer/_completion_classes.py

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ def _sanitize_help_text(text: str) -> str:
2727
return rich_utils.rich_render_text(text)
2828

2929

30+
def _is_path_completion(
31+
completions: list[click.shell_completion.CompletionItem],
32+
) -> bool:
33+
"""Check if completions are all file/dir type (Click's Path type)."""
34+
return bool(completions) and all(
35+
item.type in ("file", "dir") for item in completions
36+
)
37+
38+
3039
class BashComplete(click.shell_completion.BashComplete):
3140
name = Shells.bash.value
3241
source_template = COMPLETION_SCRIPT_BASH
@@ -51,14 +60,17 @@ def get_completion_args(self) -> tuple[list[str], str]:
5160
return args, incomplete
5261

5362
def format_completion(self, item: click.shell_completion.CompletionItem) -> str:
54-
# TODO: Explore replicating the new behavior from Click, with item types and
55-
# triggering completion for files and directories
56-
# return f"{item.type},{item.value}"
5763
return f"{item.value}"
5864

5965
def complete(self) -> str:
6066
args, incomplete = self.get_completion_args()
6167
completions = self.get_completions(args, incomplete)
68+
69+
# Return empty so bash falls back to native file completion
70+
# via the "complete -o default" registration.
71+
if _is_path_completion(completions):
72+
return ""
73+
6274
out = [self.format_completion(item) for item in completions]
6375
return "\n".join(out)
6476

@@ -95,9 +107,6 @@ def escape(s: str) -> str:
95107
.replace(":", r"\\:")
96108
)
97109

98-
# TODO: Explore replicating the new behavior from Click, pay attention to
99-
# the difference with and without escape
100-
# return f"{item.type}\n{item.value}\n{item.help if item.help else '_'}"
101110
if item.help:
102111
return f'"{escape(item.value)}":"{_sanitize_help_text(escape(item.help))}"'
103112
else:
@@ -106,6 +115,13 @@ def escape(s: str) -> str:
106115
def complete(self) -> str:
107116
args, incomplete = self.get_completion_args()
108117
completions = self.get_completions(args, incomplete)
118+
119+
# Emit native zsh path completion instead of wrapping in _arguments.
120+
if _is_path_completion(completions):
121+
if any(item.type == "dir" for item in completions):
122+
return "_path_files -/"
123+
return "_path_files -f"
124+
109125
res = [self.format_completion(item) for item in completions]
110126
if res:
111127
args_str = "\n".join(res)
@@ -137,12 +153,6 @@ def get_completion_args(self) -> tuple[list[str], str]:
137153
return args, incomplete
138154

139155
def format_completion(self, item: click.shell_completion.CompletionItem) -> str:
140-
# TODO: Explore replicating the new behavior from Click, pay attention to
141-
# the difference with and without formatted help
142-
# if item.help:
143-
# return f"{item.type},{item.value}\t{item.help}"
144-
145-
# return f"{item.type},{item.value}
146156
if item.help:
147157
formatted_help = re.sub(r"\s", " ", item.help)
148158
return f"{item.value}\t{_sanitize_help_text(formatted_help)}"
@@ -153,6 +163,12 @@ def complete(self) -> str:
153163
complete_action = os.getenv("_TYPER_COMPLETE_FISH_ACTION", "")
154164
args, incomplete = self.get_completion_args()
155165
completions = self.get_completions(args, incomplete)
166+
167+
# Treat path completions as empty so fish falls back to native
168+
# file completion (is-args exits 1, get-args returns nothing).
169+
if _is_path_completion(completions):
170+
completions = []
171+
156172
show_args = [self.format_completion(item) for item in completions]
157173
if complete_action == "get-args":
158174
if show_args:
@@ -188,6 +204,17 @@ def get_completion_args(self) -> tuple[list[str], str]:
188204
def format_completion(self, item: click.shell_completion.CompletionItem) -> str:
189205
return f"{item.value}:::{_sanitize_help_text(item.help) if item.help else ' '}"
190206

207+
def complete(self) -> str:
208+
args, incomplete = self.get_completion_args()
209+
completions = self.get_completions(args, incomplete)
210+
211+
# Return empty so PowerShell falls back to native file completion.
212+
if _is_path_completion(completions):
213+
return ""
214+
215+
out = [self.format_completion(item) for item in completions]
216+
return "\n".join(out)
217+
191218

192219
def completion_init() -> None:
193220
click.shell_completion.add_completion_class(BashComplete, Shells.bash.value)

0 commit comments

Comments
 (0)