Skip to content

Commit 228b533

Browse files
committed
🐛 Fix shell completion for Path arguments when using TyperGroup
1 parent 94484c4 commit 228b533

File tree

3 files changed

+303
-0
lines changed

3 files changed

+303
-0
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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+
["--output-dir", "-o"],
24+
type=click.Path(file_okay=False, dir_okay=True),
25+
help="Output directory",
26+
),
27+
click.Option(
28+
["--count", "-n"],
29+
type=int,
30+
default=1,
31+
help="Number of items",
32+
),
33+
],
34+
)
35+
36+
app = typer.Typer()
37+
sub_app = typer.Typer(cls=DynamicGroup)
38+
app.add_typer(sub_app, name="sub")
39+
40+
if __name__ == "__main__":
41+
app()

tests/test_completion/test_completion_path.py

Lines changed: 223 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():
@@ -15,6 +16,25 @@ def test_script():
1516
assert "deadpool" in result.stdout
1617

1718

19+
def test_typergroup_script():
20+
result = subprocess.run(
21+
[
22+
sys.executable,
23+
"-m",
24+
"coverage",
25+
"run",
26+
typergroup_mod.__file__,
27+
"sub",
28+
"process",
29+
"--input",
30+
"/tmp/test",
31+
],
32+
capture_output=True,
33+
encoding="utf-8",
34+
)
35+
assert result.returncode == 0
36+
37+
1838
def test_completion_path_bash():
1939
result = subprocess.run(
2040
[sys.executable, "-m", "coverage", "run", mod.__file__, " "],
@@ -28,3 +48,206 @@ def test_completion_path_bash():
2848
},
2949
)
3050
assert result.returncode == 0
51+
52+
53+
def test_completion_path_zsh_empty():
54+
result = subprocess.run(
55+
[sys.executable, "-m", "coverage", "run", mod.__file__, " "],
56+
capture_output=True,
57+
encoding="utf-8",
58+
env={
59+
**os.environ,
60+
"_PATH_EXAMPLE.PY_COMPLETE": "complete_zsh",
61+
"_TYPER_COMPLETE_ARGS": "path_example.py ",
62+
},
63+
)
64+
assert result.returncode == 0
65+
assert "_arguments" not in result.stdout
66+
67+
68+
def test_completion_path_zsh_partial():
69+
result = subprocess.run(
70+
[sys.executable, "-m", "coverage", "run", mod.__file__, " "],
71+
capture_output=True,
72+
encoding="utf-8",
73+
env={
74+
**os.environ,
75+
"_PATH_EXAMPLE.PY_COMPLETE": "complete_zsh",
76+
"_TYPER_COMPLETE_ARGS": "path_example.py /tmp/some_part",
77+
},
78+
)
79+
assert result.returncode == 0
80+
assert "_arguments" not in result.stdout
81+
82+
83+
def test_completion_typergroup_path_zsh_empty():
84+
result = subprocess.run(
85+
[sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "],
86+
capture_output=True,
87+
encoding="utf-8",
88+
env={
89+
**os.environ,
90+
"_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_zsh",
91+
"_TYPER_COMPLETE_ARGS": "path_typergroup_example.py sub process --input ",
92+
},
93+
)
94+
assert result.returncode == 0
95+
assert "_arguments" not in result.stdout
96+
97+
98+
def test_completion_typergroup_path_zsh_partial():
99+
result = subprocess.run(
100+
[sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "],
101+
capture_output=True,
102+
encoding="utf-8",
103+
env={
104+
**os.environ,
105+
"_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_zsh",
106+
"_TYPER_COMPLETE_ARGS": "path_typergroup_example.py sub process --input /tmp/test",
107+
},
108+
)
109+
assert result.returncode == 0
110+
assert "_arguments" not in result.stdout
111+
112+
113+
def test_completion_typergroup_dir_zsh():
114+
result = subprocess.run(
115+
[sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "],
116+
capture_output=True,
117+
encoding="utf-8",
118+
env={
119+
**os.environ,
120+
"_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_zsh",
121+
"_TYPER_COMPLETE_ARGS": "path_typergroup_example.py sub process --output-dir ",
122+
},
123+
)
124+
assert result.returncode == 0
125+
assert "_path_files -/" in result.stdout
126+
127+
128+
def test_completion_typergroup_flags_zsh():
129+
result = subprocess.run(
130+
[sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "],
131+
capture_output=True,
132+
encoding="utf-8",
133+
env={
134+
**os.environ,
135+
"_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_zsh",
136+
"_TYPER_COMPLETE_ARGS": "path_typergroup_example.py sub process --",
137+
},
138+
)
139+
assert result.returncode == 0
140+
assert "_arguments" in result.stdout
141+
assert "--input" in result.stdout
142+
assert "--count" in result.stdout
143+
144+
145+
def test_completion_typergroup_path_bash_empty():
146+
result = subprocess.run(
147+
[sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "],
148+
capture_output=True,
149+
encoding="utf-8",
150+
env={
151+
**os.environ,
152+
"_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_bash",
153+
"COMP_WORDS": "path_typergroup_example.py sub process --input ",
154+
"COMP_CWORD": "4",
155+
},
156+
)
157+
assert result.returncode == 0
158+
assert result.stdout.strip() == ""
159+
160+
161+
def test_completion_typergroup_path_bash_partial():
162+
result = subprocess.run(
163+
[sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "],
164+
capture_output=True,
165+
encoding="utf-8",
166+
env={
167+
**os.environ,
168+
"_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_bash",
169+
"COMP_WORDS": "path_typergroup_example.py sub process --input /tmp/test",
170+
"COMP_CWORD": "4",
171+
},
172+
)
173+
assert result.returncode == 0
174+
assert result.stdout.strip() == ""
175+
176+
177+
def test_completion_typergroup_flags_bash():
178+
result = subprocess.run(
179+
[sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "],
180+
capture_output=True,
181+
encoding="utf-8",
182+
env={
183+
**os.environ,
184+
"_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_bash",
185+
"COMP_WORDS": "path_typergroup_example.py sub process --",
186+
"COMP_CWORD": "3",
187+
},
188+
)
189+
assert result.returncode == 0
190+
assert "--input" in result.stdout
191+
assert "--count" in result.stdout
192+
193+
194+
def test_completion_typergroup_path_fish_is_args():
195+
result = subprocess.run(
196+
[sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "],
197+
capture_output=True,
198+
encoding="utf-8",
199+
env={
200+
**os.environ,
201+
"_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_fish",
202+
"_TYPER_COMPLETE_ARGS": "path_typergroup_example.py sub process --input /tmp/test",
203+
"_TYPER_COMPLETE_FISH_ACTION": "is-args",
204+
},
205+
)
206+
assert result.returncode != 0
207+
208+
209+
def test_completion_typergroup_path_fish_get_args():
210+
result = subprocess.run(
211+
[sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "],
212+
capture_output=True,
213+
encoding="utf-8",
214+
env={
215+
**os.environ,
216+
"_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_fish",
217+
"_TYPER_COMPLETE_ARGS": "path_typergroup_example.py sub process --input /tmp/test",
218+
"_TYPER_COMPLETE_FISH_ACTION": "get-args",
219+
},
220+
)
221+
assert result.stdout.strip() == ""
222+
223+
224+
def test_completion_typergroup_path_powershell_empty():
225+
result = subprocess.run(
226+
[sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "],
227+
capture_output=True,
228+
encoding="utf-8",
229+
env={
230+
**os.environ,
231+
"_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_powershell",
232+
"_TYPER_COMPLETE_ARGS": "path_typergroup_example.py sub process --input ",
233+
"_TYPER_COMPLETE_WORD_TO_COMPLETE": "",
234+
},
235+
)
236+
assert result.returncode == 0
237+
assert result.stdout.strip() == ""
238+
239+
240+
def test_completion_typergroup_path_powershell_partial():
241+
result = subprocess.run(
242+
[sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "],
243+
capture_output=True,
244+
encoding="utf-8",
245+
env={
246+
**os.environ,
247+
"_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_powershell",
248+
"_TYPER_COMPLETE_ARGS": "path_typergroup_example.py sub process --input /tmp/test",
249+
"_TYPER_COMPLETE_WORD_TO_COMPLETE": "/tmp/test",
250+
},
251+
)
252+
assert result.returncode == 0
253+
assert result.stdout.strip() == ""

typer/_completion_classes.py

Lines changed: 39 additions & 0 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
@@ -59,6 +68,12 @@ def format_completion(self, item: click.shell_completion.CompletionItem) -> str:
5968
def complete(self) -> str:
6069
args, incomplete = self.get_completion_args()
6170
completions = self.get_completions(args, incomplete)
71+
72+
# Return empty so bash falls back to native file completion
73+
# via the "complete -o default" registration.
74+
if _is_path_completion(completions):
75+
return ""
76+
6277
out = [self.format_completion(item) for item in completions]
6378
return "\n".join(out)
6479

@@ -106,6 +121,13 @@ def escape(s: str) -> str:
106121
def complete(self) -> str:
107122
args, incomplete = self.get_completion_args()
108123
completions = self.get_completions(args, incomplete)
124+
125+
# Emit native zsh path completion instead of wrapping in _arguments.
126+
if _is_path_completion(completions):
127+
if any(item.type == "dir" for item in completions):
128+
return "_path_files -/"
129+
return "_path_files -f"
130+
109131
res = [self.format_completion(item) for item in completions]
110132
if res:
111133
args_str = "\n".join(res)
@@ -153,6 +175,12 @@ def complete(self) -> str:
153175
complete_action = os.getenv("_TYPER_COMPLETE_FISH_ACTION", "")
154176
args, incomplete = self.get_completion_args()
155177
completions = self.get_completions(args, incomplete)
178+
179+
# Treat path completions as empty so fish falls back to native
180+
# file completion (is-args exits 1, get-args returns nothing).
181+
if _is_path_completion(completions):
182+
completions = []
183+
156184
show_args = [self.format_completion(item) for item in completions]
157185
if complete_action == "get-args":
158186
if show_args:
@@ -188,6 +216,17 @@ def get_completion_args(self) -> tuple[list[str], str]:
188216
def format_completion(self, item: click.shell_completion.CompletionItem) -> str:
189217
return f"{item.value}:::{_sanitize_help_text(item.help) if item.help else ' '}"
190218

219+
def complete(self) -> str:
220+
args, incomplete = self.get_completion_args()
221+
completions = self.get_completions(args, incomplete)
222+
223+
# Return empty so PowerShell falls back to native file completion.
224+
if _is_path_completion(completions):
225+
return ""
226+
227+
out = [self.format_completion(item) for item in completions]
228+
return "\n".join(out)
229+
191230

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

0 commit comments

Comments
 (0)