diff --git a/tests/test_completion/path_typergroup_example.py b/tests/test_completion/path_typergroup_example.py new file mode 100644 index 0000000000..82f4dc7328 --- /dev/null +++ b/tests/test_completion/path_typergroup_example.py @@ -0,0 +1,41 @@ +import click +import typer +import typer.core + + +class DynamicGroup(typer.core.TyperGroup): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.add_command(process_cmd, "process") + + +process_cmd = click.Command( + name="process", + callback=lambda **kw: print(kw), + params=[ + click.Option( + ["--input", "-i"], + type=click.Path(exists=False), + required=True, + help="Input file", + ), + click.Option( + ["--output-dir", "-o"], + type=click.Path(file_okay=False, dir_okay=True), + help="Output directory", + ), + click.Option( + ["--count", "-n"], + type=int, + default=1, + help="Number of items", + ), + ], +) + +app = typer.Typer() +sub_app = typer.Typer(cls=DynamicGroup) +app.add_typer(sub_app, name="sub") + +if __name__ == "__main__": + app() diff --git a/tests/test_completion/test_completion_path.py b/tests/test_completion/test_completion_path.py index f7bae79615..9e8860db8a 100644 --- a/tests/test_completion/test_completion_path.py +++ b/tests/test_completion/test_completion_path.py @@ -3,6 +3,7 @@ import sys from . import path_example as mod +from . import path_typergroup_example as typergroup_mod def test_script(): @@ -15,6 +16,25 @@ def test_script(): assert "deadpool" in result.stdout +def test_typergroup_script(): + result = subprocess.run( + [ + sys.executable, + "-m", + "coverage", + "run", + typergroup_mod.__file__, + "sub", + "process", + "--input", + "/tmp/test", + ], + capture_output=True, + encoding="utf-8", + ) + assert result.returncode == 0 + + def test_completion_path_bash(): result = subprocess.run( [sys.executable, "-m", "coverage", "run", mod.__file__, " "], @@ -28,3 +48,206 @@ def test_completion_path_bash(): }, ) assert result.returncode == 0 + + +def test_completion_path_zsh_empty(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_PATH_EXAMPLE.PY_COMPLETE": "complete_zsh", + "_TYPER_COMPLETE_ARGS": "path_example.py ", + }, + ) + assert result.returncode == 0 + assert "_arguments" not in result.stdout + + +def test_completion_path_zsh_partial(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_PATH_EXAMPLE.PY_COMPLETE": "complete_zsh", + "_TYPER_COMPLETE_ARGS": "path_example.py /tmp/some_part", + }, + ) + assert result.returncode == 0 + assert "_arguments" not in result.stdout + + +def test_completion_typergroup_path_zsh_empty(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_zsh", + "_TYPER_COMPLETE_ARGS": "path_typergroup_example.py sub process --input ", + }, + ) + assert result.returncode == 0 + assert "_arguments" not in result.stdout + + +def test_completion_typergroup_path_zsh_partial(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_zsh", + "_TYPER_COMPLETE_ARGS": "path_typergroup_example.py sub process --input /tmp/test", + }, + ) + assert result.returncode == 0 + assert "_arguments" not in result.stdout + + +def test_completion_typergroup_dir_zsh(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_zsh", + "_TYPER_COMPLETE_ARGS": "path_typergroup_example.py sub process --output-dir ", + }, + ) + assert result.returncode == 0 + assert "_path_files -/" in result.stdout + + +def test_completion_typergroup_flags_zsh(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_zsh", + "_TYPER_COMPLETE_ARGS": "path_typergroup_example.py sub process --", + }, + ) + assert result.returncode == 0 + assert "_arguments" in result.stdout + assert "--input" in result.stdout + assert "--count" in result.stdout + + +def test_completion_typergroup_path_bash_empty(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_bash", + "COMP_WORDS": "path_typergroup_example.py sub process --input ", + "COMP_CWORD": "4", + }, + ) + assert result.returncode == 0 + assert result.stdout.strip() == "" + + +def test_completion_typergroup_path_bash_partial(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_bash", + "COMP_WORDS": "path_typergroup_example.py sub process --input /tmp/test", + "COMP_CWORD": "4", + }, + ) + assert result.returncode == 0 + assert result.stdout.strip() == "" + + +def test_completion_typergroup_flags_bash(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_bash", + "COMP_WORDS": "path_typergroup_example.py sub process --", + "COMP_CWORD": "3", + }, + ) + assert result.returncode == 0 + assert "--input" in result.stdout + assert "--count" in result.stdout + + +def test_completion_typergroup_path_fish_is_args(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_fish", + "_TYPER_COMPLETE_ARGS": "path_typergroup_example.py sub process --input /tmp/test", + "_TYPER_COMPLETE_FISH_ACTION": "is-args", + }, + ) + assert result.returncode != 0 + + +def test_completion_typergroup_path_fish_get_args(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_fish", + "_TYPER_COMPLETE_ARGS": "path_typergroup_example.py sub process --input /tmp/test", + "_TYPER_COMPLETE_FISH_ACTION": "get-args", + }, + ) + assert result.stdout.strip() == "" + + +def test_completion_typergroup_path_powershell_empty(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_powershell", + "_TYPER_COMPLETE_ARGS": "path_typergroup_example.py sub process --input ", + "_TYPER_COMPLETE_WORD_TO_COMPLETE": "", + }, + ) + assert result.returncode == 0 + assert result.stdout.strip() == "" + + +def test_completion_typergroup_path_powershell_partial(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", typergroup_mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_PATH_TYPERGROUP_EXAMPLE.PY_COMPLETE": "complete_powershell", + "_TYPER_COMPLETE_ARGS": "path_typergroup_example.py sub process --input /tmp/test", + "_TYPER_COMPLETE_WORD_TO_COMPLETE": "/tmp/test", + }, + ) + assert result.returncode == 0 + assert result.stdout.strip() == "" diff --git a/typer/_completion_classes.py b/typer/_completion_classes.py index 8548fb4d6a..aa5dbb6026 100644 --- a/typer/_completion_classes.py +++ b/typer/_completion_classes.py @@ -27,6 +27,15 @@ def _sanitize_help_text(text: str) -> str: return rich_utils.rich_render_text(text) +def _is_path_completion( + completions: list[click.shell_completion.CompletionItem], +) -> bool: + """Check if completions are all file/dir type (Click's Path type).""" + return bool(completions) and all( + item.type in ("file", "dir") for item in completions + ) + + class BashComplete(click.shell_completion.BashComplete): name = Shells.bash.value source_template = COMPLETION_SCRIPT_BASH @@ -59,6 +68,12 @@ def format_completion(self, item: click.shell_completion.CompletionItem) -> str: def complete(self) -> str: args, incomplete = self.get_completion_args() completions = self.get_completions(args, incomplete) + + # Return empty so bash falls back to native file completion + # via the "complete -o default" registration. + if _is_path_completion(completions): + return "" + out = [self.format_completion(item) for item in completions] return "\n".join(out) @@ -106,6 +121,13 @@ def escape(s: str) -> str: def complete(self) -> str: args, incomplete = self.get_completion_args() completions = self.get_completions(args, incomplete) + + # Emit native zsh path completion instead of wrapping in _arguments. + if _is_path_completion(completions): + if any(item.type == "dir" for item in completions): + return "_path_files -/" + return "_path_files -f" + res = [self.format_completion(item) for item in completions] if res: args_str = "\n".join(res) @@ -153,6 +175,12 @@ def complete(self) -> str: complete_action = os.getenv("_TYPER_COMPLETE_FISH_ACTION", "") args, incomplete = self.get_completion_args() completions = self.get_completions(args, incomplete) + + # Treat path completions as empty so fish falls back to native + # file completion (is-args exits 1, get-args returns nothing). + if _is_path_completion(completions): + completions = [] + show_args = [self.format_completion(item) for item in completions] if complete_action == "get-args": if show_args: @@ -188,6 +216,17 @@ def get_completion_args(self) -> tuple[list[str], str]: def format_completion(self, item: click.shell_completion.CompletionItem) -> str: return f"{item.value}:::{_sanitize_help_text(item.help) if item.help else ' '}" + def complete(self) -> str: + args, incomplete = self.get_completion_args() + completions = self.get_completions(args, incomplete) + + # Return empty so PowerShell falls back to native file completion. + if _is_path_completion(completions): + return "" + + out = [self.format_completion(item) for item in completions] + return "\n".join(out) + def completion_init() -> None: click.shell_completion.add_completion_class(BashComplete, Shells.bash.value)