diff --git a/docs/tutorial/options-autocompletion.md b/docs/tutorial/options-autocompletion.md index c0dcddb9fb..26fcdea8a7 100644 --- a/docs/tutorial/options-autocompletion.md +++ b/docs/tutorial/options-autocompletion.md @@ -214,7 +214,7 @@ Hello Sebastian And the same way as before, we want to provide **completion** for those names. But we don't want to provide the **same names** for completion if they were already given in previous parameters. -For that, we will access and use the "Context". When you create a **Typer** application it uses Click underneath. And every Click application has a special object called a "Context" that is normally hidden. +For that, we will access and use the "Context". When you create a **Typer** application it uses Click underneath. And every Click application has a special object called a "Context" that is normally hidden. But you can access the context by declaring a function parameter of type `typer.Context`. @@ -264,6 +264,36 @@ It's quite possible that if there's only one option left, your shell will comple /// +## Reusing generic completer functions + +You may want to reuse completer functions across CLI applications or within the same CLI application. If you need to filter out previously supplied parameters the completer function will first have to determine which parameter it is being asked to complete. + +We can declare a parameter of type click.Parameter along with the `click.Context` in our completer function to determine this. For example, lets revisit our above context example where we filter out duplicates but add a second greeter argument that reuses the same completer function: + +{* docs_src/options_autocompletion/tutorial010_an.py hl[15:16] *} + +/// tip + +You may also return click.shell_completion.CompletionItem objects from completer functions instead of 2-tuples. + +/// + + +Check it: + +
+ +```console +$ typer ./main.py run --name Sebastian --greeter Camila --greeter [TAB][TAB] + +// Our function returns Sebastian too because it is completing greeter +Carlos -- The writer of scripts. +Sebastian -- The type hints guy. +``` + +
+ + ## Getting the raw *CLI parameters* You can also get the raw *CLI parameters*, just a `list` of `str` with everything passed in the command line before the incomplete value. @@ -342,7 +372,7 @@ But it's probably useful only in very advanced use cases. ## Getting the Context and the raw *CLI parameters* -Of course, you can declare everything if you need it, the context, the raw *CLI parameters*, and the incomplete `str`: +Of course, you can declare everything if you need it, the context, the raw *CLI parameters*, the Parameter and the incomplete `str`: {* docs_src/options_autocompletion/tutorial009_an.py hl[16] *} @@ -381,6 +411,7 @@ You can declare function parameters of these types: * `str`: for the incomplete value. * `typer.Context`: for the current context. +* `click.Parameter`: for the CLI parameter being completed. * `List[str]`: for the raw *CLI parameters*. It doesn't matter how you name them, in which order, or which ones of the 3 options you declare. It will all "**just work**" ✨ diff --git a/docs_src/options_autocompletion/tutorial009.py b/docs_src/options_autocompletion/tutorial009.py index 7e82c7ff07..9a74cf65d3 100644 --- a/docs_src/options_autocompletion/tutorial009.py +++ b/docs_src/options_autocompletion/tutorial009.py @@ -1,6 +1,7 @@ from typing import List import typer +from click.core import Parameter from rich.console import Console valid_completion_items = [ @@ -12,9 +13,11 @@ err_console = Console(stderr=True) -def complete_name(ctx: typer.Context, args: List[str], incomplete: str): +def complete_name( + ctx: typer.Context, args: List[str], param: Parameter, incomplete: str +): err_console.print(f"{args}") - names = ctx.params.get("name") or [] + names = ctx.params.get(param.name) or [] for name, help_text in valid_completion_items: if name.startswith(incomplete) and name not in names: yield (name, help_text) diff --git a/docs_src/options_autocompletion/tutorial009_an.py b/docs_src/options_autocompletion/tutorial009_an.py index c5b825eaf0..a7b4601ff4 100644 --- a/docs_src/options_autocompletion/tutorial009_an.py +++ b/docs_src/options_autocompletion/tutorial009_an.py @@ -1,6 +1,7 @@ from typing import List import typer +from click.core import Parameter from rich.console import Console from typing_extensions import Annotated @@ -13,9 +14,11 @@ err_console = Console(stderr=True) -def complete_name(ctx: typer.Context, args: List[str], incomplete: str): +def complete_name( + ctx: typer.Context, args: List[str], param: Parameter, incomplete: str +): err_console.print(f"{args}") - names = ctx.params.get("name") or [] + names = ctx.params.get(param.name) or [] for name, help_text in valid_completion_items: if name.startswith(incomplete) and name not in names: yield (name, help_text) diff --git a/docs_src/options_autocompletion/tutorial010.py b/docs_src/options_autocompletion/tutorial010.py new file mode 100644 index 0000000000..450679a549 --- /dev/null +++ b/docs_src/options_autocompletion/tutorial010.py @@ -0,0 +1,38 @@ +from typing import List + +import click +import typer +from click.shell_completion import CompletionItem + +valid_completion_items = [ + ("Camila", "The reader of books."), + ("Carlos", "The writer of scripts."), + ("Sebastian", "The type hints guy."), +] + + +def complete_name(ctx: typer.Context, param: click.Parameter, incomplete: str): + names = (ctx.params.get(param.name) if param.name else []) or [] + for name, help_text in valid_completion_items: + if name.startswith(incomplete) and name not in names: + yield CompletionItem(name, help=help_text) + + +app = typer.Typer() + + +@app.command() +def main( + name: List[str] = typer.Option( + ["World"], help="The name to say hi to.", autocompletion=complete_name + ), + greeter: List[str] = typer.Option( + None, help="Who are the greeters?.", autocompletion=complete_name + ), +): + for n in name: + print(f"Hello {n}, from {' and '.join(greeter or [])}") + + +if __name__ == "__main__": + app() diff --git a/docs_src/options_autocompletion/tutorial010_an.py b/docs_src/options_autocompletion/tutorial010_an.py new file mode 100644 index 0000000000..74b69abf07 --- /dev/null +++ b/docs_src/options_autocompletion/tutorial010_an.py @@ -0,0 +1,41 @@ +from typing import List + +import click +import typer +from click.shell_completion import CompletionItem +from typing_extensions import Annotated + +valid_completion_items = [ + ("Camila", "The reader of books."), + ("Carlos", "The writer of scripts."), + ("Sebastian", "The type hints guy."), +] + + +def complete_name(ctx: typer.Context, param: click.Parameter, incomplete: str): + names = (ctx.params.get(param.name) if param.name else []) or [] + for name, help_text in valid_completion_items: + if name.startswith(incomplete) and name not in names: + yield CompletionItem(name, help=help_text) + + +app = typer.Typer() + + +@app.command() +def main( + name: Annotated[ + List[str], + typer.Option(help="The name to say hi to.", autocompletion=complete_name), + ] = ["World"], + greeter: Annotated[ + List[str], + typer.Option(help="Who are the greeters?.", autocompletion=complete_name), + ] = [], +): + for n in name: + print(f"Hello {n}, from {' and '.join(greeter)}") + + +if __name__ == "__main__": + app() diff --git a/pyproject.toml b/pyproject.toml index c0ca3aa418..af62109c9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -182,6 +182,7 @@ ignore = [ "docs_src/options_autocompletion/tutorial007_an.py" = ["B006"] "docs_src/options_autocompletion/tutorial008_an.py" = ["B006"] "docs_src/options_autocompletion/tutorial009_an.py" = ["B006"] +"docs_src/options_autocompletion/tutorial010_an.py" = ["B006"] "docs_src/parameter_types/enum/tutorial003_an.py" = ["B006"] # Loop control variable `value` not used within loop body "docs_src/progressbar/tutorial001.py" = ["B007"] diff --git a/tests/assets/completion_no_types.py b/tests/assets/completion_no_types.py index 8dc610a1b2..3ee3f3820b 100644 --- a/tests/assets/completion_no_types.py +++ b/tests/assets/completion_no_types.py @@ -3,9 +3,10 @@ app = typer.Typer() -def complete(ctx, args, incomplete): +def complete(ctx, args, param, incomplete): typer.echo(f"info name is: {ctx.info_name}", err=True) typer.echo(f"args is: {args}", err=True) + typer.echo(f"param is: {param.name}", err=True) typer.echo(f"incomplete is: {incomplete}", err=True) return [ ("Camila", "The reader of books."), diff --git a/tests/assets/completion_no_types_order.py b/tests/assets/completion_no_types_order.py index dbbbc77f19..11e8f5a599 100644 --- a/tests/assets/completion_no_types_order.py +++ b/tests/assets/completion_no_types_order.py @@ -3,9 +3,10 @@ app = typer.Typer() -def complete(args, incomplete, ctx): +def complete(args, incomplete, ctx, param): typer.echo(f"info name is: {ctx.info_name}", err=True) typer.echo(f"args is: {args}", err=True) + typer.echo(f"param is: {param.name}", err=True) typer.echo(f"incomplete is: {incomplete}", err=True) return [ ("Camila", "The reader of books."), diff --git a/tests/test_others.py b/tests/test_others.py index 1078e63d1f..50848999d6 100644 --- a/tests/test_others.py +++ b/tests/test_others.py @@ -177,6 +177,7 @@ def test_completion_untyped_parameters(): ) assert "info name is: completion_no_types.py" in result.stderr assert "args is: []" in result.stderr + assert "param is: name" in result.stderr assert "incomplete is: Ca" in result.stderr assert '"Camila":"The reader of books."' in result.stdout assert '"Carlos":"The writer of scripts."' in result.stdout @@ -203,6 +204,7 @@ def test_completion_untyped_parameters_different_order_correct_names(): ) assert "info name is: completion_no_types_order.py" in result.stderr assert "args is: []" in result.stderr + assert "param is: name" in result.stderr assert "incomplete is: Ca" in result.stderr assert '"Camila":"The reader of books."' in result.stdout assert '"Carlos":"The writer of scripts."' in result.stdout diff --git a/tests/test_tutorial/test_options_autocompletion/test_tutorial010.py b/tests/test_tutorial/test_options_autocompletion/test_tutorial010.py new file mode 100644 index 0000000000..658dac4de8 --- /dev/null +++ b/tests/test_tutorial/test_options_autocompletion/test_tutorial010.py @@ -0,0 +1,90 @@ +import os +import subprocess +import sys + +from typer.testing import CliRunner + +from docs_src.options_autocompletion import tutorial010 as mod + +runner = CliRunner() + + +def test_completion(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_TUTORIAL010.PY_COMPLETE": "complete_zsh", + "_TYPER_COMPLETE_ARGS": "tutorial010.py --name Sebastian --name ", + }, + ) + assert '"Camila":"The reader of books."' in result.stdout + assert '"Carlos":"The writer of scripts."' in result.stdout + assert '"Sebastian":"The type hints guy."' not in result.stdout + + +def test_completion_greeter1(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_TUTORIAL010.PY_COMPLETE": "complete_zsh", + "_TYPER_COMPLETE_ARGS": "tutorial010.py --name Sebastian --greeter Ca", + }, + ) + assert '"Camila":"The reader of books."' in result.stdout + assert '"Carlos":"The writer of scripts."' in result.stdout + assert '"Sebastian":"The type hints guy."' not in result.stdout + + +def test_completion_greeter2(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_TUTORIAL010.PY_COMPLETE": "complete_zsh", + "_TYPER_COMPLETE_ARGS": "tutorial010.py --name Sebastian --greeter Carlos --greeter ", + }, + ) + assert '"Camila":"The reader of books."' in result.stdout + assert '"Carlos":"The writer of scripts."' not in result.stdout + assert '"Sebastian":"The type hints guy."' in result.stdout + + +def test_1(): + result = runner.invoke(mod.app, ["--name", "Camila", "--name", "Sebastian"]) + assert result.exit_code == 0 + assert "Hello Camila" in result.output + assert "Hello Sebastian" in result.output + + +def test_2(): + result = runner.invoke( + mod.app, ["--name", "Camila", "--name", "Sebastian", "--greeter", "Carlos"] + ) + assert result.exit_code == 0 + assert "Hello Camila, from Carlos" in result.output + assert "Hello Sebastian, from Carlos" in result.output + + +def test_3(): + result = runner.invoke( + mod.app, ["--name", "Camila", "--greeter", "Carlos", "--greeter", "Sebastian"] + ) + assert result.exit_code == 0 + assert "Hello Camila, from Carlos and Sebastian" in result.output + + +def test_script(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, "--help"], + capture_output=True, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_tutorial/test_options_autocompletion/test_tutorial010_an.py b/tests/test_tutorial/test_options_autocompletion/test_tutorial010_an.py new file mode 100644 index 0000000000..64e0cc81ee --- /dev/null +++ b/tests/test_tutorial/test_options_autocompletion/test_tutorial010_an.py @@ -0,0 +1,90 @@ +import os +import subprocess +import sys + +from typer.testing import CliRunner + +from docs_src.options_autocompletion import tutorial010_an as mod + +runner = CliRunner() + + +def test_completion(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_TUTORIAL010_AN.PY_COMPLETE": "complete_zsh", + "_TYPER_COMPLETE_ARGS": "tutorial010_an.py --name Sebastian --name ", + }, + ) + assert '"Camila":"The reader of books."' in result.stdout + assert '"Carlos":"The writer of scripts."' in result.stdout + assert '"Sebastian":"The type hints guy."' not in result.stdout + + +def test_completion_greeter1(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_TUTORIAL010_AN.PY_COMPLETE": "complete_zsh", + "_TYPER_COMPLETE_ARGS": "tutorial010_an.py --name Sebastian --greeter Ca", + }, + ) + assert '"Camila":"The reader of books."' in result.stdout + assert '"Carlos":"The writer of scripts."' in result.stdout + assert '"Sebastian":"The type hints guy."' not in result.stdout + + +def test_completion_greeter2(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, " "], + capture_output=True, + encoding="utf-8", + env={ + **os.environ, + "_TUTORIAL010_AN.PY_COMPLETE": "complete_zsh", + "_TYPER_COMPLETE_ARGS": "tutorial010_an.py --name Sebastian --greeter Carlos --greeter ", + }, + ) + assert '"Camila":"The reader of books."' in result.stdout + assert '"Carlos":"The writer of scripts."' not in result.stdout + assert '"Sebastian":"The type hints guy."' in result.stdout + + +def test_1(): + result = runner.invoke(mod.app, ["--name", "Camila", "--name", "Sebastian"]) + assert result.exit_code == 0 + assert "Hello Camila" in result.output + assert "Hello Sebastian" in result.output + + +def test_2(): + result = runner.invoke( + mod.app, ["--name", "Camila", "--name", "Sebastian", "--greeter", "Carlos"] + ) + assert result.exit_code == 0 + assert "Hello Camila, from Carlos" in result.output + assert "Hello Sebastian, from Carlos" in result.output + + +def test_3(): + result = runner.invoke( + mod.app, ["--name", "Camila", "--greeter", "Carlos", "--greeter", "Sebastian"] + ) + assert result.exit_code == 0 + assert "Hello Camila, from Carlos and Sebastian" in result.output + + +def test_script(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, "--help"], + capture_output=True, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/typer/core.py b/typer/core.py index bb5aa48dd0..780f96bb51 100644 --- a/typer/core.py +++ b/typer/core.py @@ -56,7 +56,10 @@ def _typer_param_setup_autocompletion_compat( self: click.Parameter, *, autocompletion: Optional[ - Callable[[click.Context, List[str], str], List[Union[Tuple[str, str], str]]] + Callable[ + [click.Context, List[str], click.core.Parameter, str], + List[Union[Tuple[str, str], str, "click.shell_completion.CompletionItem"]], + ] ] = None, ) -> None: if self._custom_shell_complete is not None: @@ -78,9 +81,11 @@ def compat_autocompletion( out = [] - for c in autocompletion(ctx, [], incomplete): + for c in autocompletion(ctx, [], param, incomplete): if isinstance(c, tuple): use_completion = CompletionItem(c[0], help=c[1]) + elif isinstance(c, CompletionItem): + use_completion = c else: assert isinstance(c, str) use_completion = CompletionItem(c) diff --git a/typer/main.py b/typer/main.py index e552f6335f..22413fdcb2 100644 --- a/typer/main.py +++ b/typer/main.py @@ -1022,6 +1022,7 @@ def get_param_completion( parameters = get_params_from_function(callback) ctx_name = None args_name = None + param_name = None incomplete_name = None unassigned_params = list(parameters.values()) for param_sig in unassigned_params[:]: @@ -1032,6 +1033,9 @@ def get_param_completion( elif lenient_issubclass(origin, List): args_name = param_sig.name unassigned_params.remove(param_sig) + elif lenient_issubclass(param_sig.annotation, click.Parameter): + param_name = param_sig.name + unassigned_params.remove(param_sig) elif lenient_issubclass(param_sig.annotation, str): incomplete_name = param_sig.name unassigned_params.remove(param_sig) @@ -1043,6 +1047,9 @@ def get_param_completion( elif args_name is None and param_sig.name == "args": args_name = param_sig.name unassigned_params.remove(param_sig) + elif param_name is None and param_sig.name == "param": + param_name = param_sig.name + unassigned_params.remove(param_sig) elif incomplete_name is None and param_sig.name == "incomplete": incomplete_name = param_sig.name unassigned_params.remove(param_sig) @@ -1053,12 +1060,19 @@ def get_param_completion( f"Invalid autocompletion callback parameters: {show_params}" ) - def wrapper(ctx: click.Context, args: List[str], incomplete: Optional[str]) -> Any: + def wrapper( + ctx: click.Context, + args: List[str], + param: click.core.Parameter, + incomplete: Optional[str], + ) -> Any: use_params: Dict[str, Any] = {} if ctx_name: use_params[ctx_name] = ctx if args_name: use_params[args_name] = args + if param_name: + use_params[param_name] = param if incomplete_name: use_params[incomplete_name] = incomplete return callback(**use_params)