diff --git a/docs/tutorial/options-autocompletion.md b/docs/tutorial/options-autocompletion.md index b6dd0698b6..737a2c5e31 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. @@ -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..8b109e9dfb 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,8 @@ err_console = Console(stderr=True) -def complete_name(ctx: typer.Context, args: List[str], incomplete: str): - err_console.print(f"{args}") - names = ctx.params.get("name") or [] +def complete_name(ctx: typer.Context, param: Parameter, incomplete: str): + 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..f7182403e3 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,8 @@ err_console = Console(stderr=True) -def complete_name(ctx: typer.Context, args: List[str], incomplete: str): - err_console.print(f"{args}") - names = ctx.params.get("name") or [] +def complete_name(ctx: typer.Context, param: Parameter, incomplete: str): + 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 e17f3628c9..08a5db27a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -181,6 +181,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..7d37d6df46 100644 --- a/tests/test_others.py +++ b/tests/test_others.py @@ -176,7 +176,8 @@ def test_completion_untyped_parameters(): }, ) assert "info name is: completion_no_types.py" in result.stderr - assert "args is: []" in result.stderr + assert "args is: ['--name', 'Sebastian', '--name']" 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 @@ -202,7 +203,8 @@ 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 "args is: ['--name', 'Sebastian', '--name']" 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_tutorial008.py b/tests/test_tutorial/test_options_autocompletion/test_tutorial008.py index 0874f23c5d..4ead1a01da 100644 --- a/tests/test_tutorial/test_options_autocompletion/test_tutorial008.py +++ b/tests/test_tutorial/test_options_autocompletion/test_tutorial008.py @@ -23,7 +23,7 @@ def test_completion(): assert '"Camila":"The reader of books."' in result.stdout assert '"Carlos":"The writer of scripts."' in result.stdout assert '"Sebastian":"The type hints guy."' in result.stdout - assert "[]" in result.stderr + assert "--name" in result.stderr def test_1(): diff --git a/tests/test_tutorial/test_options_autocompletion/test_tutorial008_an.py b/tests/test_tutorial/test_options_autocompletion/test_tutorial008_an.py index cb2481a67c..75ebf42965 100644 --- a/tests/test_tutorial/test_options_autocompletion/test_tutorial008_an.py +++ b/tests/test_tutorial/test_options_autocompletion/test_tutorial008_an.py @@ -23,7 +23,7 @@ def test_completion(): assert '"Camila":"The reader of books."' in result.stdout assert '"Carlos":"The writer of scripts."' in result.stdout assert '"Sebastian":"The type hints guy."' in result.stdout - assert "[]" in result.stderr + assert "--name" in result.stderr def test_1(): diff --git a/tests/test_tutorial/test_options_autocompletion/test_tutorial009.py b/tests/test_tutorial/test_options_autocompletion/test_tutorial009.py index 3c7eb0cc64..f168780a5c 100644 --- a/tests/test_tutorial/test_options_autocompletion/test_tutorial009.py +++ b/tests/test_tutorial/test_options_autocompletion/test_tutorial009.py @@ -23,7 +23,6 @@ def test_completion(): 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 - assert "[]" in result.stderr def test_1(): diff --git a/tests/test_tutorial/test_options_autocompletion/test_tutorial009_an.py b/tests/test_tutorial/test_options_autocompletion/test_tutorial009_an.py index 56182ac3b9..b710c934e8 100644 --- a/tests/test_tutorial/test_options_autocompletion/test_tutorial009_an.py +++ b/tests/test_tutorial/test_options_autocompletion/test_tutorial009_an.py @@ -23,7 +23,6 @@ def test_completion(): 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 - assert "[]" in result.stderr def test_1(): 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/_completion_classes.py b/typer/_completion_classes.py index f0bb89c3cc..26e321c032 100644 --- a/typer/_completion_classes.py +++ b/typer/_completion_classes.py @@ -42,6 +42,9 @@ def get_completion_args(self) -> Tuple[List[str], str]: except IndexError: incomplete = "" + obj = self.ctx_args.setdefault("obj", {}) + if isinstance(obj, dict): + obj.setdefault("args", args) return args, incomplete def format_completion(self, item: click.shell_completion.CompletionItem) -> str: @@ -77,6 +80,11 @@ def get_completion_args(self) -> Tuple[List[str], str]: args = args[:-1] else: incomplete = "" + + obj = self.ctx_args.setdefault("obj", {}) + if isinstance(obj, dict): + obj.setdefault("args", args) + return args, incomplete def format_completion(self, item: click.shell_completion.CompletionItem) -> str: @@ -128,6 +136,11 @@ def get_completion_args(self) -> Tuple[List[str], str]: args = args[:-1] else: incomplete = "" + + obj = self.ctx_args.setdefault("obj", {}) + if isinstance(obj, dict): + obj.setdefault("args", args) + return args, incomplete def format_completion(self, item: click.shell_completion.CompletionItem) -> str: @@ -177,6 +190,11 @@ def get_completion_args(self) -> Tuple[List[str], str]: incomplete = os.getenv("_TYPER_COMPLETE_WORD_TO_COMPLETE", "") cwords = click.parser.split_arg_string(completion_args) args = cwords[1:-1] if incomplete else cwords[1:] + + obj = self.ctx_args.setdefault("obj", {}) + if isinstance(obj, dict): + obj.setdefault("args", args) + return args, incomplete def format_completion(self, item: click.shell_completion.CompletionItem) -> str: diff --git a/typer/core.py b/typer/core.py index 4dc24ada70..2caceed1cd 100644 --- a/typer/core.py +++ b/typer/core.py @@ -59,7 +59,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, click.core.Parameter, str], + List[Union[Tuple[str, str], str, "click.shell_completion.CompletionItem"]], + ] ] = None, ) -> None: if self._custom_shell_complete is not None: @@ -81,9 +84,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 36737e49ef..bedbd626d8 100644 --- a/typer/main.py +++ b/typer/main.py @@ -1023,6 +1023,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[:]: @@ -1033,6 +1034,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) @@ -1044,6 +1048,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) @@ -1054,12 +1061,17 @@ 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, 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 + obj = ctx.obj or {} + use_params[args_name] = obj.get("args", []) if isinstance(obj, dict) else [] + if param_name: + use_params[param_name] = param if incomplete_name: use_params[incomplete_name] = incomplete return callback(**use_params)