diff --git a/README.md b/README.md index 272d16df14..51edf6ce51 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,7 @@ --- -Typer is library to build CLI applications that users will love using and developers will love creating. Based on Python 3.6+ type hints. - -**Typer** is FastAPI's little sibling. And it's intended to be the FastAPI of CLIs. +Typer is library for building CLI applications that users will **love using** and developers will **love creating**. Based on Python 3.6+ type hints. The key features are: @@ -36,11 +34,19 @@ The key features are: * **Start simple**: The simplest example adds only 2 lines of code to your app: **1 import, 1 function call**. * **Grow large**: Grow in complexity as much as you want, create arbitrarily complex trees of commands and groups of subcommands, with options and arguments. +## FastAPI of CLIs + + + +**Typer** is FastAPI's little sibling. + +And it's intended to be the FastAPI of CLIs. + ## Requirements Python 3.6+ -Typer stands on the shoulders of a giant. Its only internal dependency is Click. +**Typer** stands on the shoulders of a giant. Its only internal dependency is Click. ## Installation @@ -219,7 +225,7 @@ Goodbye Ms. Camila. Have a good day. ### Recap -In summary, you declare **once** the types of parameters (*arguments* and *options*) as function parameters. +In summary, you declare **once** the types of parameters (*CLI arguments* and *CLI options*) as function parameters. You do that with standard modern Python types. @@ -258,7 +264,7 @@ But you can also install extras: * Or any other tool, e.g. wasabi, blessings. * shellingham: and Typer will automatically detect the current shell when installing completion. * With `shellingham` you can just use `--install-completion`. - * Without `shellingham`, you have to pass a *CLI Option value* with the name of the shell to install completion, e.g. `--install-completion bash`. + * Without `shellingham`, you have to pass the name of the shell to install completion for, e.g. `--install-completion bash`. You can install `typer` with `colorama` and `shellingham` with `pip install typer[all]`. @@ -270,7 +276,7 @@ For example: * click-spinner: to show the user that you are loading data. A Click plug-in. * There are several other Click plug-ins at click-contrib that you can explore. -* tabulate: to automatically display tabular data nicely. Independent of Click or typer. +* tabulate: to automatically display tabular data nicely. Independent of Click or Typer. * tqdm: a fast, extensible progress bar, alternative to Typer's own `typer.progressbar()`. * etc... you can re-use many of the great available tools for building CLIs. diff --git a/docs/alternatives.md b/docs/alternatives.md index 2efc136b8b..0f93697557 100644 --- a/docs/alternatives.md +++ b/docs/alternatives.md @@ -24,7 +24,7 @@ Hug is a library to create APIs and CLIs, it uses parameters in functions to dec It inspired a lot of the ideas in **FastAPI** and **Typer**. !!! check "Inspired **Typer** to" - Use function parameters to declare *CLI Arguments* and *CLI Options* as it simplifies a lot the development experience. + Use function parameters to declare *CLI arguments* and *CLI options* as it simplifies a lot the development experience. ### Plac @@ -50,7 +50,7 @@ Click is one of the most widely used libraries to create CLIs in Python. It's a very powerful tool and there are many CLIs built with it. It is what powers **Typer** underneath. -It also uses functions with parameters for *CLI Arguments* and *CLI Options*, but the declaration of the specific *CLI Arguments*, *CLI Options*, types, etc, is done in decorators on top of the function. This requires some code repetition (e.g. a *CLI Option* name `--verbose` and a variable name `verbose`) and synchronization between two places related to the same information (the decorator and the parameter function). +It also uses functions with parameters for *CLI arguments* and *CLI options*, but the declaration of the specific *CLI arguments*, *CLI options*, types, etc, is done in decorators on top of the function. This requires some code repetition (e.g. a *CLI Option* name `--verbose` and a variable name `verbose`) and synchronization between two places related to the same information (the decorator and the parameter function). It uses decorators on top of functions to modify the actual value of those functions, converting them to instances of a specific class. This is a clever trick, but code editors can't provide great support for autocompletion that way. diff --git a/docs/features.md b/docs/features.md index a48fd626a9..bd4f9480cd 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,8 +1,10 @@ ## Design based on **FastAPI** + + **Typer** is FastAPI's little sibling. -It follows the same design and ideas. If you know FastAPI, you already know **Typer**... more or less. +It follows the same design and ideas. If you know **FastAPI**, you already know **Typer**... more or less. ## Just Modern Python @@ -56,7 +58,7 @@ The resulting CLI apps created with **Typer** have the nice features of many "pr * `--install-completion`: Install completion for the current shell. * `--show-completion`: Show completion for the current shell, to copy it or customize the installation. - If you didn't add `shellingham` those *CLI Options* take a parameter with the name of the shell to install completion for, e.g.: + If you didn't add `shellingham` those *CLI options* take a value with the name of the shell to install completion for, e.g.: * `--install-completion bash`. * `--show-completion powershell`. @@ -64,7 +66,7 @@ The resulting CLI apps created with **Typer** have the nice features of many "pr Then you can tell the user to install completion after installing your CLI program and the rest will just work. !!! tip - **Typer**'s completion is implemented internally, it uses ideas and components from Click and ideas from `click-completion`, but it doesn't use `click-completion` internally. + **Typer**'s completion is implemented internally, it uses ideas and components from Click and ideas from `click-completion`, but it doesn't use `click-completion` and re-implements some of the relevant parts of Click. Then it extends those ideas with features and bug fixes. For example, **Typer** programs also support modern versions of PowerShell (e.g. in Windows 10) among all the other shells. diff --git a/docs/index.md b/docs/index.md index 272d16df14..51edf6ce51 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,9 +24,7 @@ --- -Typer is library to build CLI applications that users will love using and developers will love creating. Based on Python 3.6+ type hints. - -**Typer** is FastAPI's little sibling. And it's intended to be the FastAPI of CLIs. +Typer is library for building CLI applications that users will **love using** and developers will **love creating**. Based on Python 3.6+ type hints. The key features are: @@ -36,11 +34,19 @@ The key features are: * **Start simple**: The simplest example adds only 2 lines of code to your app: **1 import, 1 function call**. * **Grow large**: Grow in complexity as much as you want, create arbitrarily complex trees of commands and groups of subcommands, with options and arguments. +## FastAPI of CLIs + + + +**Typer** is FastAPI's little sibling. + +And it's intended to be the FastAPI of CLIs. + ## Requirements Python 3.6+ -Typer stands on the shoulders of a giant. Its only internal dependency is Click. +**Typer** stands on the shoulders of a giant. Its only internal dependency is Click. ## Installation @@ -219,7 +225,7 @@ Goodbye Ms. Camila. Have a good day. ### Recap -In summary, you declare **once** the types of parameters (*arguments* and *options*) as function parameters. +In summary, you declare **once** the types of parameters (*CLI arguments* and *CLI options*) as function parameters. You do that with standard modern Python types. @@ -258,7 +264,7 @@ But you can also install extras: * Or any other tool, e.g. wasabi, blessings. * shellingham: and Typer will automatically detect the current shell when installing completion. * With `shellingham` you can just use `--install-completion`. - * Without `shellingham`, you have to pass a *CLI Option value* with the name of the shell to install completion, e.g. `--install-completion bash`. + * Without `shellingham`, you have to pass the name of the shell to install completion for, e.g. `--install-completion bash`. You can install `typer` with `colorama` and `shellingham` with `pip install typer[all]`. @@ -270,7 +276,7 @@ For example: * click-spinner: to show the user that you are loading data. A Click plug-in. * There are several other Click plug-ins at click-contrib that you can explore. -* tabulate: to automatically display tabular data nicely. Independent of Click or typer. +* tabulate: to automatically display tabular data nicely. Independent of Click or Typer. * tqdm: a fast, extensible progress bar, alternative to Typer's own `typer.progressbar()`. * etc... you can re-use many of the great available tools for building CLIs. diff --git a/docs/src/commands/context/tutorial001.py b/docs/src/commands/context/tutorial001.py new file mode 100644 index 0000000000..2a9bb682ab --- /dev/null +++ b/docs/src/commands/context/tutorial001.py @@ -0,0 +1,25 @@ +import typer + +app = typer.Typer() + + +@app.command() +def create(username: str): + typer.echo(f"Creating user: {username}") + + +@app.command() +def delete(username: str): + typer.echo(f"Deleting user: {username}") + + +@app.callback() +def main(ctx: typer.Context): + """ + Manage users in the awesome CLI app. + """ + typer.echo(f"About to execute command: {ctx.invoked_subcommand}") + + +if __name__ == "__main__": + app() diff --git a/docs/src/commands/context/tutorial002.py b/docs/src/commands/context/tutorial002.py new file mode 100644 index 0000000000..096058dd95 --- /dev/null +++ b/docs/src/commands/context/tutorial002.py @@ -0,0 +1,25 @@ +import typer + +app = typer.Typer() + + +@app.command() +def create(username: str): + typer.echo(f"Creating user: {username}") + + +@app.command() +def delete(username: str): + typer.echo(f"Deleting user: {username}") + + +@app.callback(invoke_without_command=True) +def main(): + """ + Manage users in the awesome CLI app. + """ + typer.echo("Initializing database") + + +if __name__ == "__main__": + app() diff --git a/docs/src/commands/context/tutorial003.py b/docs/src/commands/context/tutorial003.py new file mode 100644 index 0000000000..e4470e0b26 --- /dev/null +++ b/docs/src/commands/context/tutorial003.py @@ -0,0 +1,26 @@ +import typer + +app = typer.Typer() + + +@app.command() +def create(username: str): + typer.echo(f"Creating user: {username}") + + +@app.command() +def delete(username: str): + typer.echo(f"Deleting user: {username}") + + +@app.callback(invoke_without_command=True) +def main(ctx: typer.Context): + """ + Manage users in the awesome CLI app. + """ + if ctx.invoked_subcommand is None: + typer.echo("Initializing database") + + +if __name__ == "__main__": + app() diff --git a/docs/src/commands/context/tutorial004.py b/docs/src/commands/context/tutorial004.py new file mode 100644 index 0000000000..9280aed636 --- /dev/null +++ b/docs/src/commands/context/tutorial004.py @@ -0,0 +1,15 @@ +import typer + +app = typer.Typer() + + +@app.command( + context_settings={"allow_extra_args": True, "ignore_unknown_options": True} +) +def main(ctx: typer.Context): + for extra_arg in ctx.args: + typer.echo(f"Got extra arg: {extra_arg}") + + +if __name__ == "__main__": + app() diff --git a/docs/src/options/autocompletion/tutorial001.py b/docs/src/options/autocompletion/tutorial001.py new file mode 100644 index 0000000000..a5ad1dc56d --- /dev/null +++ b/docs/src/options/autocompletion/tutorial001.py @@ -0,0 +1,9 @@ +import typer + + +def main(name: str = typer.Option("World", help="The name to say hi to.")): + typer.echo(f"Hello {name}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs/src/options/autocompletion/tutorial002.py b/docs/src/options/autocompletion/tutorial002.py new file mode 100644 index 0000000000..87e17aab57 --- /dev/null +++ b/docs/src/options/autocompletion/tutorial002.py @@ -0,0 +1,17 @@ +import typer + + +def complete_name(): + return ["Camila", "Carlos", "Sebastian"] + + +def main( + name: str = typer.Option( + "World", help="The name to say hi to.", autocompletion=complete_name + ) +): + typer.echo(f"Hello {name}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs/src/options/autocompletion/tutorial003.py b/docs/src/options/autocompletion/tutorial003.py new file mode 100644 index 0000000000..7ab6d7ad30 --- /dev/null +++ b/docs/src/options/autocompletion/tutorial003.py @@ -0,0 +1,23 @@ +import typer + +valid_names = ["Camila", "Carlos", "Sebastian"] + + +def complete_name(incomplete: str): + completion = [] + for name in valid_names: + if name.startswith(incomplete): + completion.append(name) + return completion + + +def main( + name: str = typer.Option( + "World", help="The name to say hi to.", autocompletion=complete_name + ) +): + typer.echo(f"Hello {name}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs/src/options/autocompletion/tutorial004.py b/docs/src/options/autocompletion/tutorial004.py new file mode 100644 index 0000000000..9352194751 --- /dev/null +++ b/docs/src/options/autocompletion/tutorial004.py @@ -0,0 +1,28 @@ +import typer + +valid_completion_items = [ + ("Camila", "The reader of books."), + ("Carlos", "The writer of scripts."), + ("Sebastian", "The type hints guy."), +] + + +def complete_name(incomplete: str): + completion = [] + for name, help_text in valid_completion_items: + if name.startswith(incomplete): + completion_item = (name, help_text) + completion.append(completion_item) + return completion + + +def main( + name: str = typer.Option( + "World", help="The name to say hi to.", autocompletion=complete_name + ) +): + typer.echo(f"Hello {name}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs/src/options/autocompletion/tutorial005.py b/docs/src/options/autocompletion/tutorial005.py new file mode 100644 index 0000000000..04d517d4f5 --- /dev/null +++ b/docs/src/options/autocompletion/tutorial005.py @@ -0,0 +1,25 @@ +import typer + +valid_completion_items = [ + ("Camila", "The reader of books."), + ("Carlos", "The writer of scripts."), + ("Sebastian", "The type hints guy."), +] + + +def complete_name(incomplete: str): + for name, help_text in valid_completion_items: + if name.startswith(incomplete): + yield (name, help_text) + + +def main( + name: str = typer.Option( + "World", help="The name to say hi to.", autocompletion=complete_name + ) +): + typer.echo(f"Hello {name}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs/src/options/autocompletion/tutorial006.py b/docs/src/options/autocompletion/tutorial006.py new file mode 100644 index 0000000000..ea64c676fc --- /dev/null +++ b/docs/src/options/autocompletion/tutorial006.py @@ -0,0 +1,12 @@ +from typing import List + +import typer + + +def main(name: List[str] = typer.Option(["World"], help="The name to say hi to.")): + for each_name in name: + typer.echo(f"Hello {each_name}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs/src/options/autocompletion/tutorial007.py b/docs/src/options/autocompletion/tutorial007.py new file mode 100644 index 0000000000..7d7b1b46e2 --- /dev/null +++ b/docs/src/options/autocompletion/tutorial007.py @@ -0,0 +1,29 @@ +from typing import List + +import typer + +valid_completion_items = [ + ("Camila", "The reader of books."), + ("Carlos", "The writer of scripts."), + ("Sebastian", "The type hints guy."), +] + + +def complete_name(ctx: typer.Context, incomplete: str): + names = ctx.params.get("name") or [] + for name, help_text in valid_completion_items: + if name.startswith(incomplete) and name not in names: + yield (name, help_text) + + +def main( + name: List[str] = typer.Option( + ["World"], help="The name to say hi to.", autocompletion=complete_name + ) +): + for n in name: + typer.echo(f"Hello {n}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs/src/options/autocompletion/tutorial008.py b/docs/src/options/autocompletion/tutorial008.py new file mode 100644 index 0000000000..92daa4ea01 --- /dev/null +++ b/docs/src/options/autocompletion/tutorial008.py @@ -0,0 +1,29 @@ +from typing import List + +import typer + +valid_completion_items = [ + ("Camila", "The reader of books."), + ("Carlos", "The writer of scripts."), + ("Sebastian", "The type hints guy."), +] + + +def complete_name(args: List[str], incomplete: str): + typer.echo(f"{args}", err=True) + for name, help_text in valid_completion_items: + if name.startswith(incomplete): + yield (name, help_text) + + +def main( + name: List[str] = typer.Option( + ["World"], help="The name to say hi to.", autocompletion=complete_name + ) +): + for n in name: + typer.echo(f"Hello {n}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs/src/options/autocompletion/tutorial009.py b/docs/src/options/autocompletion/tutorial009.py new file mode 100644 index 0000000000..04b0c2df72 --- /dev/null +++ b/docs/src/options/autocompletion/tutorial009.py @@ -0,0 +1,30 @@ +from typing import List + +import typer + +valid_completion_items = [ + ("Camila", "The reader of books."), + ("Carlos", "The writer of scripts."), + ("Sebastian", "The type hints guy."), +] + + +def complete_name(ctx: typer.Context, args: List[str], incomplete: str): + typer.echo(f"{args}", err=True) + names = ctx.params.get("name") or [] + for name, help_text in valid_completion_items: + if name.startswith(incomplete) and name not in names: + yield (name, help_text) + + +def main( + name: List[str] = typer.Option( + ["World"], help="The name to say hi to.", autocompletion=complete_name + ) +): + for n in name: + typer.echo(f"Hello {n}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs/src/options/callback/tutorial001.py b/docs/src/options/callback/tutorial001.py new file mode 100644 index 0000000000..c717ca8a8d --- /dev/null +++ b/docs/src/options/callback/tutorial001.py @@ -0,0 +1,15 @@ +import typer + + +def name_callback(value: str): + if value != "Camila": + raise typer.BadParameter("Only Camila is allowed") + return value + + +def main(name: str = typer.Option(..., callback=name_callback)): + typer.echo(f"Hello {name}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs/src/options/callback/tutorial002.py b/docs/src/options/callback/tutorial002.py new file mode 100644 index 0000000000..d579e56b96 --- /dev/null +++ b/docs/src/options/callback/tutorial002.py @@ -0,0 +1,16 @@ +import typer + + +def name_callback(value: str): + typer.echo("Validating name") + if value != "Camila": + raise typer.BadParameter("Only Camila is allowed") + return value + + +def main(name: str = typer.Option(..., callback=name_callback)): + typer.echo(f"Hello {name}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs/src/options/callback/tutorial003.py b/docs/src/options/callback/tutorial003.py new file mode 100644 index 0000000000..f93ac566d5 --- /dev/null +++ b/docs/src/options/callback/tutorial003.py @@ -0,0 +1,18 @@ +import typer + + +def name_callback(ctx: typer.Context, value: str): + if ctx.resilient_parsing: + return + typer.echo("Validating name") + if value != "Camila": + raise typer.BadParameter("Only Camila is allowed") + return value + + +def main(name: str = typer.Option(..., callback=name_callback)): + typer.echo(f"Hello {name}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs/src/options/callback/tutorial004.py b/docs/src/options/callback/tutorial004.py new file mode 100644 index 0000000000..27d13e9ff5 --- /dev/null +++ b/docs/src/options/callback/tutorial004.py @@ -0,0 +1,18 @@ +import typer + + +def name_callback(ctx: typer.Context, param: typer.CallbackParam, value: str): + if ctx.resilient_parsing: + return + typer.echo(f"Validating param: {param.name}") + if value != "Camila": + raise typer.BadParameter("Only Camila is allowed") + return value + + +def main(name: str = typer.Option(..., callback=name_callback)): + typer.echo(f"Hello {name}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs/src/options/password/tutorial001.py b/docs/src/options/password/tutorial001.py new file mode 100644 index 0000000000..f9346b2e9d --- /dev/null +++ b/docs/src/options/password/tutorial001.py @@ -0,0 +1,11 @@ +import typer + + +def main( + name: str, email: str = typer.Option(..., prompt=True, confirmation_prompt=True) +): + typer.echo(f"Hello {name}, your email is {email}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs/src/options/password/tutorial002.py b/docs/src/options/password/tutorial002.py new file mode 100644 index 0000000000..3eb3935375 --- /dev/null +++ b/docs/src/options/password/tutorial002.py @@ -0,0 +1,15 @@ +import typer + + +def main( + name: str, + password: str = typer.Option( + ..., prompt=True, confirmation_prompt=True, hide_input=True + ), +): + typer.echo(f"Hello {name}. Doing something very secure with password.") + typer.echo(f"...just kidding, here it is, very insecure: {password}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs/src/options/prompt/tutorial002.py b/docs/src/options/prompt/tutorial002.py index d9a19a9c76..ec0f1c7722 100644 --- a/docs/src/options/prompt/tutorial002.py +++ b/docs/src/options/prompt/tutorial002.py @@ -2,8 +2,7 @@ def main( - name: str, - lastname: str = typer.Option(..., prompt="Please tell me your last name"), + name: str, lastname: str = typer.Option(..., prompt="Please tell me your last name") ): typer.echo(f"Hello {name} {lastname}") diff --git a/docs/src/options/version/tutorial001.py b/docs/src/options/version/tutorial001.py new file mode 100644 index 0000000000..ff5e68ad7a --- /dev/null +++ b/docs/src/options/version/tutorial001.py @@ -0,0 +1,20 @@ +import typer + +__version__ = "0.1.0" + + +def version_callback(value: bool): + if value: + typer.echo(f"Awesome CLI Version: {__version__}") + raise typer.Exit() + + +def main( + name: str = typer.Option("World"), + version: bool = typer.Option(None, "--version", callback=version_callback), +): + typer.echo(f"Hello {name}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs/src/options/version/tutorial002.py b/docs/src/options/version/tutorial002.py new file mode 100644 index 0000000000..6be8aa8a82 --- /dev/null +++ b/docs/src/options/version/tutorial002.py @@ -0,0 +1,25 @@ +import typer + +__version__ = "0.1.0" + + +def version_callback(value: bool): + if value: + typer.echo(f"Awesome CLI Version: {__version__}") + raise typer.Exit() + + +def name_callback(name: str): + if name != "Camila": + raise typer.BadParameter("Only Camila is allowed") + + +def main( + name: str = typer.Option(..., callback=name_callback), + version: bool = typer.Option(None, "--version", callback=version_callback), +): + typer.echo(f"Hello {name}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs/src/options/version/tutorial003.py b/docs/src/options/version/tutorial003.py new file mode 100644 index 0000000000..509559827f --- /dev/null +++ b/docs/src/options/version/tutorial003.py @@ -0,0 +1,28 @@ +import typer + +__version__ = "0.1.0" + + +def version_callback(value: bool): + if value: + typer.echo(f"Awesome CLI Version: {__version__}") + raise typer.Exit() + + +def name_callback(name: str): + if name != "Camila": + raise typer.BadParameter("Only Camila is allowed") + return name + + +def main( + name: str = typer.Option(..., callback=name_callback), + version: bool = typer.Option( + None, "--version", callback=version_callback, is_eager=True + ), +): + typer.echo(f"Hello {name}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs/src/parameter_types/index/tutorial001.py b/docs/src/parameter_types/index/tutorial001.py index ac2ab81144..c394ecc955 100644 --- a/docs/src/parameter_types/index/tutorial001.py +++ b/docs/src/parameter_types/index/tutorial001.py @@ -1,9 +1,7 @@ import typer -def main( - name: str, age: int = 20, height_meters: float = 1.89, female: bool = True, -): +def main(name: str, age: int = 20, height_meters: float = 1.89, female: bool = True): typer.echo(f"NAME is {name}, of type: {type(name)}") typer.echo(f"--age is {age}, of type: {type(age)}") typer.echo(f"--height-meters is {height_meters}, of type: {type(height_meters)}") diff --git a/docs/src/printing/tutorial003.py b/docs/src/printing/tutorial003.py new file mode 100644 index 0000000000..220a5e1feb --- /dev/null +++ b/docs/src/printing/tutorial003.py @@ -0,0 +1,9 @@ +import typer + + +def main(): + typer.echo(f"Here is something written to standard error", err=True) + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs/src/testing/app01/__init__.py b/docs/src/testing/app01/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/src/testing/app01/main.py b/docs/src/testing/app01/main.py new file mode 100644 index 0000000000..a059d86d9b --- /dev/null +++ b/docs/src/testing/app01/main.py @@ -0,0 +1,14 @@ +import typer + +app = typer.Typer() + + +@app.command() +def main(name: str, city: str = None): + typer.echo(f"Hello {name}") + if city: + typer.echo(f"Let's have a coffee in {city}") + + +if __name__ == "__main__": + app() diff --git a/docs/src/testing/app01/test_main.py b/docs/src/testing/app01/test_main.py new file mode 100644 index 0000000000..95716239c3 --- /dev/null +++ b/docs/src/testing/app01/test_main.py @@ -0,0 +1,12 @@ +from typer.testing import CliRunner + +from .main import app + +runner = CliRunner() + + +def test_app(): + result = runner.invoke(app, ["Camila", "--city", "Berlin"]) + assert result.exit_code == 0 + assert "Hello Camila" in result.stdout + assert "Let's have a coffee in Berlin" in result.stdout diff --git a/docs/src/testing/app02/__init__.py b/docs/src/testing/app02/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/src/testing/app02/main.py b/docs/src/testing/app02/main.py new file mode 100644 index 0000000000..1b3d6c7c56 --- /dev/null +++ b/docs/src/testing/app02/main.py @@ -0,0 +1,12 @@ +import typer + +app = typer.Typer() + + +@app.command() +def main(name: str, email: str = typer.Option(..., prompt=True)): + typer.echo(f"Hello {name}, your email is: {email}") + + +if __name__ == "__main__": + app() diff --git a/docs/src/testing/app02/test_main.py b/docs/src/testing/app02/test_main.py new file mode 100644 index 0000000000..ffe01743ee --- /dev/null +++ b/docs/src/testing/app02/test_main.py @@ -0,0 +1,11 @@ +from typer.testing import CliRunner + +from .main import app + +runner = CliRunner() + + +def test_app(): + result = runner.invoke(app, ["Camila"], input="camila@example.com\n") + assert result.exit_code == 0 + assert "Hello Camila, your email is: camila@example.com" in result.stdout diff --git a/docs/src/testing/app03/__init__.py b/docs/src/testing/app03/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/src/testing/app03/main.py b/docs/src/testing/app03/main.py new file mode 100644 index 0000000000..4dfefdeafa --- /dev/null +++ b/docs/src/testing/app03/main.py @@ -0,0 +1,9 @@ +import typer + + +def main(name: str = "World"): + typer.echo(f"Hello {name}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs/src/testing/app03/test_main.py b/docs/src/testing/app03/test_main.py new file mode 100644 index 0000000000..425c491f11 --- /dev/null +++ b/docs/src/testing/app03/test_main.py @@ -0,0 +1,15 @@ +import typer +from typer.testing import CliRunner + +from .main import main + +app = typer.Typer() +app.command()(main) + +runner = CliRunner() + + +def test_app(): + result = runner.invoke(app, ["--name", "Camila"]) + assert result.exit_code == 0 + assert "Hello Camila" in result.stdout diff --git a/docs/tutorial/arguments.md b/docs/tutorial/arguments.md index 92d03ab733..872425ce61 100644 --- a/docs/tutorial/arguments.md +++ b/docs/tutorial/arguments.md @@ -47,7 +47,7 @@ Now let's see an alternative way to create the same *CLI argument*: {!./src/arguments/tutorial001.py!} ``` -Before you had this function parameter: +Before, you had this function parameter: ```Python name: str diff --git a/docs/tutorial/commands/callback.md b/docs/tutorial/commands/callback.md index 904291ba40..41974ecf01 100644 --- a/docs/tutorial/commands/callback.md +++ b/docs/tutorial/commands/callback.md @@ -162,3 +162,24 @@ Creating user: Camila ``` + +## Click Group + +If you come from Click, this **Typer** callback is the equivalent of the function in a Click Group. + +For example: + +```Python +import click + +@click.group() +def cli(): + pass +``` + +The original function `cli` would be the equivalent of a Typer callback. + +!!! note "Technical Details" + When using Click, it converts that `cli` variable to a Click `Group` object. And then the original function no longer exists in that variable. + + **Typer** doesn't do that, the callback function is not modified, only registered in the `typer.Typer` app. This is intentional, it's part of **Typer**'s design, to allow having editor auto completion and type checks. diff --git a/docs/tutorial/commands/context.md b/docs/tutorial/commands/context.md new file mode 100644 index 0000000000..f9bdd31bf5 --- /dev/null +++ b/docs/tutorial/commands/context.md @@ -0,0 +1,130 @@ +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`. + +You might have read it in [CLI Option Callback and Context](../options/callback-and-context.md){.internal-link target=_blank}. + +The same way, in commands or in the main `Typer` callback you can access the context by declaring a function parameter of type `typer.Context`. + +## Getting the context + +For example, let's say that you want to execute some logic in a `Typer` callback depending on the subcommand that is being called. + +You can get the name of the subcommand from the context: + +```Python hl_lines="17 21" +{!./src/commands/context/tutorial001.py!} +``` + +Check it: + +
+ +```console +$ python main.py create Camila + +// We get the message from the callback +About to execute command: create +Creating user: Camila + +$ python main.py delete Camila + +// We get the message from the callback, this time with delete +About to execute command: delete +Deleting user: Camila +``` + +
+ +## Executable callback + +By default, the callback is only executed right before executing a command. + +And if no command is provided, the help message is shown. + +But we could make it run even without a subcommand with `invoke_without_command=True`: + +```Python hl_lines="16" +{!./src/commands/context/tutorial002.py!} +``` + +Check it: + +
+ +```console +$ python main.py + +// The callback is executed, we don't get the default help message +Initializing database + +// Try with a command +$ python main.py create Camila + +// The callback is still executed +Initializing database +Creating user: Camila +``` + +
+ +## Exclusive executable callback + +We might not want the callback to be executed if there's already other command that will be executed. + +For that, we can get the `typer.Context` and check if there's an invoked command in `ctx.invoked_subcommand`. + +If it's `None`, it means that we are not calling a subcommand but the main program (the callback) directly: + +```Python hl_lines="17 21" +{!./src/commands/context/tutorial003.py!} +``` + +Check it: + +
+ +```console +$ python main.py + +// The callback is executed +Initializing database + +// Check it with a subcommand +$ python main.py create Camila + +// This time the callback is not executed +Creating user: Camila +``` + +
+ +## Configuring the context + +You can pass configurations for the context when creating a command or callback. + +To read more about the available configurations check the docs for Click's `Context`. + +For example, you could keep additional *CLI parameters* not declared in your CLI program with `ignore_unknown_options` and `allow_extra_args`. + +Then you can access those extra raw *CLI parameters* as a `list` of `str` in `ctx.args`: + +```Python hl_lines="7 9 10" +{!./src/commands/context/tutorial004.py!} +``` + +
+ +```console +$ python main.py --name Camila --city Berlin + +Got extra arg: --name +Got extra arg: Camila +Got extra arg: --city +Got extra arg: Berlin +``` + +
+ +!!! tip + Notice that it saves all the extra *CLI parameters* as a raw `list` of `str`, including the *CLI option* names and values, everything together. diff --git a/docs/tutorial/commands/index.md b/docs/tutorial/commands/index.md index da290b9d59..9bb8c8e187 100644 --- a/docs/tutorial/commands/index.md +++ b/docs/tutorial/commands/index.md @@ -1,4 +1,4 @@ -We have seen how to create a CLI program with possibly several *CLI Options* and *CLI Arguments*. +We have seen how to create a CLI program with possibly several *CLI options* and *CLI arguments*. But **Typer** allows you to create CLI programs with several commands (also known as subcommands). @@ -29,7 +29,7 @@ Another command of `git` is `git pull`, it also has some *CLI parameters*. It's like if the same big program `git` had several small programs inside. !!! tip - A command looks the same as a *CLI argument*, it's just some name without a preceding `--`. But commands have predefined name, and are used to group different sets of functionalities into the same CLI application. + A command looks the same as a *CLI argument*, it's just some name without a preceding `--`. But commands have a predefined name, and are used to group different sets of functionalities into the same CLI application. ## Command or subcommand @@ -45,7 +45,7 @@ Here I'll use **CLI application** or **program** to refer to the program you are Before creating CLI applications with multiple commands/subcommands we need to understand how to create an explicit `typer.Typer()` application. -In the *CLI Options* and *CLI Argument* tutorials you have seen how to create a single function and then pass that function to `typer.run()`. +In the *CLI options* and *CLI argument* tutorials you have seen how to create a single function and then pass that function to `typer.run()`. For example: @@ -168,6 +168,15 @@ Notice that the help text now shows the 2 commands: `create` and `delete`. !!! tip By default, the names of the commands are generated from the function name. +## Click Group + +If you come from Click, a `typer.Typer` app with subcommands is more or less the equivalent of a Click Group. + +!!! note "Technical Details" + A `typer.Typer` app is *not* a Click Group, but it provides the equivalent functionality. And it creates a Click Group when calling it. + + It is not directly a Group because **Typer** doesn't modify the functions in your code to convert them to another type of object, it only registers them. + ## Decorator Technical Details When you use `@app.command()` the function under the decorator is registered in the **Typer** application and is then used later by the application. diff --git a/docs/tutorial/first-steps.md b/docs/tutorial/first-steps.md index 10c0b49114..159dab7354 100644 --- a/docs/tutorial/first-steps.md +++ b/docs/tutorial/first-steps.md @@ -1,6 +1,6 @@ ## The simplest example -The simplest Typer file could look like this: +The simplest **Typer** file could look like this: ```Python {!./src/first_steps/tutorial001.py!} @@ -173,7 +173,7 @@ The program knows it has to show the size because it sees `--size`, not because A *CLI option* like `--size` doesn't depend on the order like a *CLI argument*. -So, if you put the `--size` *before* the *CLI argument*, it still works: +So, if you put the `--size` *before* the *CLI argument*, it still works (in fact, that's the most common way of doing it):
@@ -419,7 +419,7 @@ And a parameter like `name`, that doesn't have a default value, is considered *r ### In CLIs -When talking about command line interfaces/applications, the words **"argument"** and **"parameter"** are commonly used to refer to that data passed to a CLI app, those parameters. +When talking about command line interface applications, the words **"argument"** and **"parameter"** are commonly used to refer to that data passed to a CLI app, those parameters. But those words **don't imply** anything about the data being required, needing to be passed in a certain order, nor having a flag like `--lastname`. @@ -431,7 +431,7 @@ In reality, the parameters that require an order can be made *optional* too. And To try and make it a bit easier, we'll normally use the words "parameter" or "argument" to refer to Python functions. -We'll use ***CLI argument*** to refer to those *CLI parameters* that depend on an order. That are **required** by default. +We'll use ***CLI argument*** to refer to those *CLI parameters* that depend on the specific order. That are **required** by default. And we'll use ***CLI option*** to refer to those *CLI parameters* that depend on a name that starts with `--` (like `--lastname`). That are **optional** by default. @@ -469,7 +469,7 @@ Hello World ...and it will give you auto completion in your terminal when you hit TAB for all your code. -So you can use it to have auto completion as you continue with the tutorial. +So you can use it to have auto completion for your own scripts as you continue with the tutorial. !!! tip Your CLI application built with **Typer** won't need [Typer CLI](../typer-cli.md){.internal-link target=_blank} to have auto completion once you create a Python package. diff --git a/docs/tutorial/index.md b/docs/tutorial/index.md index 77732af2c1..fb1576d77a 100644 --- a/docs/tutorial/index.md +++ b/docs/tutorial/index.md @@ -11,7 +11,7 @@ def type_example(name: str, formal: bool = False, intro: str = None): pass ``` -And your editor (and Typer) will know that: +And your editor (and **Typer**) will know that: * `name` is of type `str` and is a required parameter. * `formal` is a `bool` and is by default `False`. @@ -19,7 +19,7 @@ And your editor (and Typer) will know that: These type hints are what give you autocomplete in your editor and several other features. -Typer is based on these type hints. +**Typer** is based on these type hints. ## Intro @@ -49,7 +49,7 @@ $ python main.py It is **HIGHLY encouraged** that you write or copy the code, edit it and run it locally. -Using it in your editor is what really shows you the benefits of Typer, seeing how little code you have to write, all the type checks, autocompletion, etc. +Using it in your editor is what really shows you the benefits of **Typer**, seeing how little code you have to write, all the type checks, autocompletion, etc. And running the examples is what will really help you understand what is going on. @@ -68,9 +68,9 @@ For the tutorial, you might want to install it with all the optional dependencie ```console $ pip install typer[all] ---> 100% -Successfully installed typer click colorama click-completion +Successfully installed typer click colorama shellingham ```
-...that also includes `colorama` and `click-completion`. +...that also includes `colorama` and `shellingham`. diff --git a/docs/tutorial/options/autocompletion.md b/docs/tutorial/options/autocompletion.md new file mode 100644 index 0000000000..156e5f6c7d --- /dev/null +++ b/docs/tutorial/options/autocompletion.md @@ -0,0 +1,363 @@ +As you have seen, apps built with **Typer** have completion in your shell that works when you create a Python package or using **Typer CLI**. + +It normally completes *CLI options*, *CLI arguments*, and subcommands (that you will learn about later). + +But you can also provide auto completion for the **values** of *CLI options* and *CLI arguments*. We will learn about that here. + +## Review completion + +Before checking how to provide custom completions, let's check again how it works. + +After installing completion (for your own Python package or for **Typer CLI**), when you use your CLI program and start adding a *CLI option* with `--` an then hit TAB, your shell will show you the available *CLI options* (the same for *CLI arguments*, etc). + +To check it quickly without creating a new Python package, install [Typer CLI](../../typer-cli.md){.internal-link target=_blank}. + +Then let's create small example script: + +```Python +{!./src/options/autocompletion/tutorial001.py!} +``` + +And let's try it with **Typer CLI** to get completion: + +
+ +```console +// Hit the TAB key in your keyboard below where you see the: [TAB] +$ typer ./main.py [TAB][TAB] + +// Depending on your terminal/shell you will get some completion like this ✨ +run -- Run the provided Typer app. +utils -- Extra utility commands for Typer apps. + +// Then try with "run" and -- +$ typer ./main.py run --[TAB][TAB] + +// You will get completion for --name, depending on your terminal it will look something like this +--name -- The name to say hi to. + +// And you can run it as if it was with Python directly +$ typer ./main.py run --name Camila + +Hello Camila +``` + +
+ +## Custom completion for values + +Right now we get completion for the *CLI option* names, but not for the values. + +We can provide completion for the values creating an `autocompletion` function, similar to the `callback` functions from [CLI Option Callback and Context](./callback-and-context.md){.internal-link target=_blank}: + +```Python hl_lines="4 5 10" +{!./src/options/autocompletion/tutorial002.py!} +``` + +We return a `list` of strings from the `complete_name()` function. + +And then we get those values when using completion: + +
+ +```console +$ typer ./main.py run --name [TAB][TAB] + +// We get the values returned from the function πŸŽ‰ +Camila Carlos Sebastian +``` + +
+ +We got the basics working. Now let's improve it. + +## Check the incomplete value + +Right now, we always return those values, even if users start typing `Sebast` and then hit TAB, they will also get the completion for `Camila` and `Carlos` (depending on the shell), while we should only get completion for `Sebastian`. + +But we can fix that so that it always works correctly. + +Modify the `complete_name()` function to receive a parameter of type `str`, it will contain the incomplete value. + +Then we can check and return only the values that start with the incomplete value from the command line: + +```Python hl_lines="6 7 8 9 10 11" +{!./src/options/autocompletion/tutorial003.py!} +``` + +Now let's try it: + +
+ +```console +$ typer ./main.py run --name Ca[TAB][TAB] + +// We get the values returned from the function that start with Ca πŸŽ‰ +Camila Carlos +``` + +
+ +Now we are only returning the valid values, that start with `Ca`, we are no longer returning `Sebastian` as a completion option. + +!!! tip + You have to declare the incomplete value of type `str` and that's what you will receive in the function. + + No matter if the actual value will be an `int`, or something else, when doing completion, you will only get a `str` as the incomplete value. + + And the same way, you can only return `str`, not `int`, etc. + +## Add help to completions + +Right now we are returning a `list` of `str`. + +But some shells (Zsh, Fish, PowerShell) are capable of showing extra help text for completion. + +We can provide that extra help text so that those shells can show it. + +In the `complete_name()` function, instead of providing one `str` per completion element, we provide a `tuple` with 2 items. The first item is the actual completion string, and the second item is the help text. + +So, in the end, we return a `list` of `tuples` of `str`: + +```Python hl_lines="3 4 5 6 7 10 11 12 13 14 15 16" +{!./src/options/autocompletion/tutorial004.py!} +``` + +!!! tip + If you want to have help text for each item, make sure each item in the list is a `tuple`. Not a `list`. + + Click checks specifically for a `tuple` when extracting the help text. + + So in the end, the return will be a `list` (or other iterable) of `tuples` of 2 `str`. + +!!! info + The help text will be visible in Zsh, Fish, and PowerShell. + + Bash doesn't support showing the help text, but completion will still work the same. + +If you have a shell like Zsh, it would look like: + +
+ +```console +$ typer ./main.py run --name [TAB][TAB] + +// We get the completion items with their help text πŸŽ‰ +Camila -- The reader of books. +Carlos -- The writer of scripts. +Sebastian -- The type hints guy. +``` + +
+ +## Simplify with `yield` + +Instead of creating and returning a list with values (`str` or `tuple`), we can use `yield` with each value that we want in the completion. + +That way our function will be a generator that **Typer** (actually Click) can iterate: + +```Python hl_lines="10 11 12 13" +{!./src/options/autocompletion/tutorial005.py!} +``` + +That simplifies our code a bit and works the same. + +!!! tip + If all the `yield` part seems complex for you, don't worry, you can just use the version with the `list` above. + + In the end, that's just to save us a couple of lines of code. + +!!! info + The function can use `yield`, so it doesn't have to return strictly a `list`, it just has to be iterable. + + But each of the elements for completion has to be a `str` or a `tuple` (when containing a help text). + +## Access other *CLI parameters* with the Context + +Let's say that now we want to modify the program to be able to "say hi" to multiple people at the same time. + +So, we will allow multiple `--name` *CLI options*. + +!!! tip + You will learn more about *CLI parameters* with multiple values later in the tutorial. + + So, for now, take this as a sneak peek πŸ˜‰. + +For this we use a `List` of `str`: + +```Python hl_lines="6 7 8" +{!./src/options/autocompletion/tutorial006.py!} +``` + +And then we can use it like: + +
+ +```console +$ typer ./main.py run --name Camila --name Sebastian + +Hello Camila +Hello Sebastian +``` + +
+ +### Getting completion for multiple values + +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. + +But you can access the context by declaring a function parameter of type `typer.Context`. + +And from that context you can get the current values for each parameter. + +```Python hl_lines="12 13 15" +{!./src/options/autocompletion/tutorial007.py!} +``` + +We are getting the `names` already provided with `--name` in the command line before this completion was triggered. + +If there's no `--name` in the command line, it will be `None`, so we use `or []` to make sure we have a `list` (even if empty) to check its contents later. + +Then, when we have a completion candidate, we check if each `name` was already provided with `--name` by checking if it's in that list of `names` with `name not in names`. + +And then we `yield` each item that has not being used yet. + +Check it: + +
+ +```console +$ typer ./main.py run --name [TAB][TAB] + +// The first time we trigger completion, we get all the names +Camila -- The reader of books. +Carlos -- The writer of scripts. +Sebastian -- The type hints guy. + +// Add a name and trigger completion again +$ typer ./main.py run --name Sebastian --name Ca[TAB][TAB] + +// Now we get completion only for the names we haven't used πŸŽ‰ +Camila -- The reader of books. +Carlos -- The writer of scripts. + +// And if we add another of the available names: +$ typer ./main.py run --name Sebastian --name Camila --name [TAB][TAB] + +// We get completion for the only available one +Carlos -- The writer of scripts. +``` + +
+ +!!! tip + It's quite possible that if there's only one option left, your shell will complete it right away instead of showing the option with the help text, to save you more typing. + +## 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. + +For example, something like `["typer", "main.py", "run", "--name"]`. + +!!! tip + This would be for advanced scenarios, in most use cases you would be better of using the context. + + But it's still possible if you need it. + +As a simple example, let's show it on the screen before completion. + +Because completion is based on the output printed by your program (handled internally by **Typer**), during completion we can't just print something else as we normally do. + +### Printing to "standard error" + +!!! tip + If you need a refresher about what is "standard output" and "standard error" check the section in [Printing and Colors: "Standard Output" and "Standard Error"](../printing.md#standard-output-and-standard-error){.internal-link target=_blank}. + +The completion system only reads from "standard output", so, printing to "standard error" won't break completion. πŸš€ + +You can print to "standard error" with `typer.echo("some text", err=True)`. + +Using `err=True` tells **Typer** (actually Click) that the output should be shown in "standard error". + +```Python hl_lines="12 13" +{!./src/options/autocompletion/tutorial008.py!} +``` + +We get all the *CLI parameters* as a raw `list` of `str` by declaring a parameter with type `List[str]`, here it's named `args`. + +!!! tip + Here we name the list of all the raw *CLI parameters* `args` because that's the convention with Click. + + But it doesn't contain only *CLI arguments*, it has everything, including *CLI options* and values, as a raw `list` of `str`. + +And then we just print it to "standard error". + +
+ +```console +$ typer ./main.py run --name [TAB][TAB] + +// First we see the raw CLI parameters +['./main.py', 'run', '--name'] + +// And then we see the actual completion +Camila -- The reader of books. +Carlos -- The writer of scripts. +Sebastian -- The type hints guy. +``` + +
+ +!!! tip + This is a very simple (and quite useless) example, just so you know how it works and that you can use it. + + 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`: + +```Python hl_lines="12" +{!./src/options/autocompletion/tutorial009.py!} +``` + +Check it: + +
+ +```console +$ typer ./main.py run --name [TAB][TAB] + +// First we see the raw CLI parameters +['./main.py', 'run', '--name'] + +// And then we see the actual completion +Camila -- The reader of books. +Carlos -- The writer of scripts. +Sebastian -- The type hints guy. + +$ typer ./main.py run --name Sebastian --name Ca[TAB][TAB] + +// Again, we see the raw CLI parameters +['./main.py', 'run', '--name', 'Sebastian', '--name'] + +// And then we see the rest of the valid completion items +Camila -- The reader of books. +Carlos -- The writer of scripts. +``` + +
+ +## Types, types everywhere + +**Typer** uses the type declarations to detect what it has to provide to your `autocompletion` function. + +You can declare function parameters of these types: + +* `str`: for the incomplete value. +* `typer.Context`: for the current context. +* `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/tutorial/options/callback-and-context.md b/docs/tutorial/options/callback-and-context.md new file mode 100644 index 0000000000..034548d9ec --- /dev/null +++ b/docs/tutorial/options/callback-and-context.md @@ -0,0 +1,217 @@ +In some occasions you might want to have some custom logic for a specific *CLI parameter* (for a *CLI option* or *CLI argument*) that is executed with the value received from the terminal. + +In those cases you can use a *CLI parameter* callback function. + +## Validate *CLI parameters* + +For example, you could do some validation before the rest of the code is executed. + +```Python hl_lines="4 5 6 7 10" +{!./src/options/callback/tutorial001.py!} +``` + +Here you pass a function to `typer.Option()` or `typer.Argument()` with the keyword argument `callback`. + +The function receives the value from the command line. It can do anything with it, and then return the value. + +In this case, if the `--name` is not `Camila` we raise a `typer.BadParameter()` exception. + +The `BadParameter` exception is special, it shows the error with the parameter that generated it. + +Check it: + +
+ +```console +$ python main.py --name Camila + +Hello Camila + +$ python main.py --name Rick + +Usage: main.py [OPTIONS] + +// We get the error from the callback +Error: Invalid value for '--name': Only Camila is allowed +``` + +
+ +## Handling completion + +There's something to be aware of with callbacks and completion that requires some small special handling. + +But first let's just use completion in your shell (Bash, Zsh, Fish, or PowerShell). + +After installing completion (for your own Python package or for **Typer CLI**), when you use your CLI program and start adding a *CLI option* with `--` an then hit TAB, your shell will show you the available *CLI options* (the same for *CLI arguments*, etc). + +To check it quickly without creating a new Python package, install [Typer CLI](../../typer-cli.md){.internal-link target=_blank} and use it with the previous script: + +
+ +```console +// Hit the TAB key in your keyboard below where you see the: [TAB] +$ typer ./main.py [TAB][TAB] + +// Depending on your terminal/shell you will get some completion like this ✨ +run -- Run the provided Typer app. +utils -- Extra utility commands for Typer apps. + +// Then try with "run" and --help +$ typer ./main.py run --help + +// You get a help text with your CLI options as you normally would +Usage: typer run [OPTIONS] + + Run the provided Typer app. + +Options: + --name TEXT [required] + --help Show this message and exit. + +// Then try completion with your program +$ typer ./main.py run --[TAB][TAB] + +// You get completion for CLI options +--help -- Show this message and exit. +--name + +// And you can run it as if it was with Python directly +$ typer ./main.py run --name Camila + +Hello Camila +``` + +
+ +### How shell completion works + +The way it works internally is that the shell/terminal will call your CLI program with some special environment variables (that hold the current *CLI parameters*, etc) and your CLI program will print some special values that the shell will use to present completion. All this is handled for you by **Typer** behind the scenes. + +But the main **important point** is that it is all based on values printed by your program that the shell reads. + +### Breaking completion in a callback + +Let's say that when the callback is running, we want to show a message saying that it's validating the name: + +```Python hl_lines="5" +{!./src/options/callback/tutorial002.py!} +``` + +And because the callback will be called when the shell calls your program asking for completion, that message `"Validating name"` will be printed and it will break completion. + +It will look something like: + +
+ +```console +// Run it normally +$ typer ./main.py run --name Camila + +// See the extra message "Validating name" +Validating name +Hello Camila + +$ typer ./main.py run --[TAB][TAB] + +// Some weird broken error message ⛔️ +(eval):1: command not found: Validating +rutyper ./main.pyed Typer app. +``` + +
+ +### Fix completion - using 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`. + +The "context" has some additional data about the current execution of your program: + +```Python hl_lines="4 5 6" +{!./src/options/callback/tutorial003.py!} +``` + +The `ctx.resilient_parsing` will be `True` when handling completion, so you can just return without printing anything else. + +But it will be `False` when calling the program normally. So you can continue the execution of your previous code. + +That's all is needed to fix completion πŸš€ + +Check it: + +
+ +```console +$ typer ./main.py run --[TAB][TAB] + +// Now it works correctly πŸŽ‰ +--help -- Show this message and exit. +--name + +// And you can call it normally +$ typer ./main.py run --name Camila + +Validating name +Hello Camila +``` + +
+ +## Using the `CallbackParam` object + +The same way you can access the `typer.Context` by declaring a function parameter with its value, you can declare another function parameter with type `typer.CallbackParam` to get the specific Click `Parameter` object. + +```Python hl_lines="4 7" +{!./src/options/callback/tutorial004.py!} +``` + +It's probably not very common, but you could do it if you need it. + +For example if you had a callback that could be used by several *CLI parameters*, that way the callback could know which parameter is each time. + +Check it: + +
+ +```console +$ python main.py --name Camila + +Validating param: name +Hello Camila +``` + +
+ +## Technical Details + +Because you get the relevant data in the callback function based on standard Python type annotations, you get type checks and autocompletion in your editor for free. + +And **Typer** will make sure you get the function parameters you want. + +You don't have to worry about their names, their order, etc. + +As it's based on standard Python types, it "**just works**". ✨ + +### Click's `Parameter` + +The `typer.CallbackParam` is actually just a sub-class of Click's `Parameter`, so you get all the right completion in your editor. + +### Callback with type annotations + +You can get the `typer.Context` and the `typer.CallbackParam` simply by declaring a function parameter of each type. + +The order doesn't matter, the name of the function parameters doesn't matter. + +You could also get only the `typer.CallbackParam` and not the `typer.Context`, or vice versa, it will still work. + +### `value` function parameter + +The `value` function parameter in the callback can also have any name (e.g. `lastname`) and any type, but it should have the same type annotation as in the main function, because that's what it will receive. + +It's also possible to not declare its type. It will still work. + +And it's possible to not declare the `value` parameter at all, and, for example, only get the `typer.Context`. That will also work. diff --git a/docs/tutorial/options/password.md b/docs/tutorial/options/password.md new file mode 100644 index 0000000000..ae5e3eb474 --- /dev/null +++ b/docs/tutorial/options/password.md @@ -0,0 +1,53 @@ +Apart from having a prompt, you can make a *CLI option* have a `confirmation_prompt=True`: + +```Python hl_lines="5" +{!./src/options/password/tutorial001.py!} +``` + +And the CLI program will ask for confirmation: + +
+ +```console +$ python main.py Camila + +// It prompts for the email +# Email: $ camila@example.com +# Repeat for confirmation: $ camila@example.com + +Hello Camila, your email is camila@example.com +``` + +
+ +## A Password prompt + +When receiving a password, it is very common (in most shells) to not show anything on the screen while typing the password. + +The program will still receive the password, but nothing will be shown on screen, not even `****`. + +You can achieve the same using `hide_input=True`. + +And if you combine it with `confirmation_prompt=True` you can easily receive a password with double confirmation: + +```Python hl_lines="6 7 8" +{!./src/options/password/tutorial002.py!} +``` + +Check it: + +
+ +```console +$ python main.py Camila + +// It prompts for the password, but doesn't show anything when you type +# Password: $ +# Repeat for confirmation: $ + +// Let's imagine the password typed was "typerrocks" +Hello Camila. Doing something very secure with password. +...just kidding, here it is, very insecure: typerrocks +``` + +
diff --git a/docs/tutorial/options/prompt.md b/docs/tutorial/options/prompt.md index 70a1c43180..8e603710fd 100644 --- a/docs/tutorial/options/prompt.md +++ b/docs/tutorial/options/prompt.md @@ -24,7 +24,7 @@ Hello Camila GutiΓ©rrez You can also set a custom prompt, passing the string that you want to use instead of just `True`: -```Python hl_lines="6" +```Python hl_lines="5" {!./src/options/prompt/tutorial002.py!} ``` diff --git a/docs/tutorial/options/version.md b/docs/tutorial/options/version.md new file mode 100644 index 0000000000..4bd0a7eee2 --- /dev/null +++ b/docs/tutorial/options/version.md @@ -0,0 +1,107 @@ +You could use a callback to implement a `--version` *CLI option*. + +It would show the version of your CLI program and then it would terminate it. Even before any other *CLI parameter* is processed. + +## First version of `--version` + +Let's see a first version of how it could look like: + +```Python hl_lines="6 7 8 9 14" +{!./src/options/version/tutorial001.py!} +``` + +!!! tip + Notice that we don't have to get the `typer.Context` and check for `ctx.resilient_parsing` for completion to work, because we only print and modify the program when `--version` is passed, otherwise, nothing is printed or changed from the callback. + +If the `--version` *CLI option* is passed, we get a value `True` in the callback. + +Then we can print the version and raise `typer.Exit()` to make sure the program is terminated before anything else is executed. + +We also declare the explicit *CLI option* name `--version`, because we don't want an automatic `--no-version`, it would look awkward. + +Check it: + +
+ +```console +$ python main.py --help + +// We get a --version, and don't get an awkward --no-version πŸŽ‰ +Usage: main.py [OPTIONS] + +Options: + --version + --name TEXT + --install-completion Install completion for the current shell. + --show-completion Show completion for the current shell, to copy it or customize the installation. + + --help Show this message and exit. + + +// We can call it normally +$ python main.py --name Camila + +Hello Camila + +// And we can get the version +$ python main.py --version + +Awesome CLI Version: 0.1.0 + +// Because we exit in the callback, we don't get a "Hello World" message after the version πŸš€ +``` + +
+ +## Previous parameters and `is_eager` + +But now let's say that the `--name` *CLI option* that we declared before `--version` is required, and it has a callback that could exit the program: + +```Python hl_lines="12 13 14 18" +{!./src/options/version/tutorial002.py!} +``` + +Then our CLI program could not work as expected in some cases as it is *right now*, because if we use `--version` after `--name` then the callback for `--name` will be processed before and we can get its error: + +
+ +```console +$ python main.py --name Rick --version + +Only Camila is allowed +Aborted! +``` + +
+ +!!! tip + We don't have to check for `ctx.resilient_parsing` in the `name_callback()` for completion to work, because we are not using `typer.echo()`, instead we are raising a `typer.BadParameter`. + +!!! note "Technical Details" + `typer.BadParameter` prints the error to "standard error", not to "standard output", and because the completion system only reads from "standard output", it won't break completion. + +!!! info + If you need a refresher about what is "standard output" and "standard error" check the section in [Printing and Colors: "Standard Output" and "Standard Error"](../printing.md#standard-output-and-standard-error){.internal-link target=_blank}. + +### Fix with `is_eager` + +For those cases, we can mark a *CLI parameter* (a *CLI option* or *CLI argument*) with `is_eager=True`. + +That will tell **Typer** (actually Click) that it should process this *CLI parameter* before the others: + +```Python hl_lines="21" +{!./src/options/version/tutorial003.py!} +``` + +Check it: + +
+ +```console +$ python main.py --name Rick --version + +// Now we only get the version, and the name is not used +Awesome CLI Version: 0.1.0 +``` + +
diff --git a/docs/tutorial/parameter-types/index.md b/docs/tutorial/parameter-types/index.md index ebb93606f8..526ffed254 100644 --- a/docs/tutorial/parameter-types/index.md +++ b/docs/tutorial/parameter-types/index.md @@ -6,7 +6,7 @@ When you declare a *CLI parameter* with some type **Typer** will convert the dat For example: -```Python hl_lines="5" +```Python hl_lines="4" {!./src/parameter_types/index/tutorial001.py!} ``` diff --git a/docs/tutorial/printing.md b/docs/tutorial/printing.md index e616323a20..cc5c509bcd 100644 --- a/docs/tutorial/printing.md +++ b/docs/tutorial/printing.md @@ -40,7 +40,7 @@ You can create colored strings to output to the terminal with `typer.style()`, t ``` !!! tip - The parameters `fg` and `bg` receive strings with the color names. You could simply pass `fg="green"` and `bg="red"`. + The parameters `fg` and `bg` receive strings with the color names for the "**f**ore**g**round" and "**b**ack**g**round" colors. You could simply pass `fg="green"` and `bg="red"`. But **Typer** provides them all as variables like `typer.colors.GREEN` just so you can use autocompletion while selecting them. @@ -81,3 +81,60 @@ Check it: python main.py Camila Welcome here Camila + +## "Standard Output" and "Standard Error" + +The way printing works underneath is that the **operating system** (Linux, Windows, macOS) treats what we print as if our CLI program was **writing text** to a "**virtual file**" called "**standard output**". + +When our code "prints" things it is actually "writing" to this "virtual file" of "standard output". + +This might seem strange, but that's how the CLI program and the operating system interact with each other. + +And then the operating system **shows on the screen** whatever our CLI program "**wrote**" to that "**virtual file**" called "**standard output**". + +### Standard Error + +And there's another "**virtual file**" called "**standard error**" that is normally only used for errors. + +But we can also "print" to "standard error". And both are shown on the terminal to the users. + +!!! info + If you use PowerShell it's quite possible that what you print to "standard error" won't be shown in the terminal. + + In PowerShell, to see "standard error" you would have to check the variable `$Error`. + + But it will work normally in Bash, Zsh, and Fish. + +### Printing to "standard error" + +You can print to "standard error" with `typer.echo("some text", err=True)`. + +Using `err=True` tells **Typer** (actually Click) that the output should be shown in "standard error". + +```Python hl_lines="5" +{!./src/printing/tutorial003.py!} +``` + +When you try it in the terminal, it will probably just look the same: + +
+ +```console +$ python main.py + +Here is something written to standard error +``` + +
+ +### "Standard Input" + +As a final detail, when you type text in your keyboard to your terminal, the operating system also considers it another "**virtual file**" that you are writing text to. + +This virtual file is called "**standard input**". + +### What is this for + +Right now this probably seems quite useless πŸ€·β€β™‚. + +But understanding that will come handy in the future, for example for autocompletion and testing. diff --git a/docs/tutorial/terminating.md b/docs/tutorial/terminating.md index 49f8cc6e96..e11367f942 100644 --- a/docs/tutorial/terminating.md +++ b/docs/tutorial/terminating.md @@ -87,7 +87,7 @@ $ echo $? !!! tip - The error code might be used by other programs (for example a Bash script) that execute with your CLI program. + The error code might be used by other programs (for example a Bash script) that execute your CLI program. ## Abort diff --git a/docs/tutorial/testing.md b/docs/tutorial/testing.md new file mode 100644 index 0000000000..c675cd09de --- /dev/null +++ b/docs/tutorial/testing.md @@ -0,0 +1,213 @@ +Testing **Typer** applications is very easy with pytest. + +Let's say you have an application `app/main.py` with: + +```Python +{!./src/testing/app01/main.py!} +``` + +So, you would use it like: + +
+ +```console +$ python main.py Camila --city Berlin + +Hello Camila +Let's have a coffee in Berlin +``` + +
+ +And the directory also has an empty `app/__init__.py` file. + +So, the `app` is a "Python package". + +## Test the app + +### Import and create a `CliRunner` + +Create another file/module `app/test_main.py`. + +Import `CliRunner` and create a `runner` object. + +This runner is what will "invoke" or "call" your command line application. + +```Python hl_lines="1 5" +{!./src/testing/app01/test_main.py!} +``` + +!!! tip + It's important that the name of the file starts with `test_`, that way pytest will be able to detect it and use it automatically. + +### Call the app + +Then create a function `test_app()`. + +And inside of the function, use the `runner` to `invoke` the application. + +The first parameter to `runner.invoke()` is a `Typer` app. + +The second parameter is a `list` of `str`, with all the text you would pass in the command line, right as you would pass it: + +```Python hl_lines="8 9" +{!./src/testing/app01/test_main.py!} +``` + +!!! tip + The name of the function has to start with `test_`, that way pytest can detect it and use it automatically. + +### Check the result + +Then, inside of the test function, add `assert` statements to ensure that everything in the result of the call is as it should be. + +```Python hl_lines="10 11 12" +{!./src/testing/app01/test_main.py!} +``` + +Here we are checking that the exit code is 0, as it is for programs that exit without errors. + +Then we check that the text printed to "standard output" contains the text that our CLI program prints. + +!!! tip + You could also check `result.stderr` for "standard error". + +!!! info + If you need a refresher about what is "standard output" and "standard error" check the section in [Printing and Colors: "Standard Output" and "Standard Error"](printing.md#standard-output-and-standard-error){.internal-link target=_blank}. + +### Call `pytest` + +Then you can call `pytest` in your directory and it will run your tests: + +
+ +```console +$ pytest + +================ test session starts ================ +platform linux -- Python 3.6.9, pytest-5.3.5, py-1.8.1, pluggy-0.13.1 +rootdir: /home/user/code/superawesome-cli/app +plugins: forked-1.1.3, xdist-1.31.0, cov-2.8.1 +collected 1 item + +---> 100% + +test_main.py . [100%] + +================= 1 passed in 0.03s ================= +``` + +
+ +## Testing input + +If you have a CLI with prompts, like: + +```Python hl_lines="7" +{!./src/testing/app02/main.py!} +``` + +That you would use like: + +
+ +```console +$ python main.py Camila + +# Email: $ camila@example.com + +Hello Camila, your email is: camila@example.com +``` + +
+ +You can test the input typed in the terminal using `input="camila@example.com\n"`. + +This is because what you type in the terminal goes to "**standard input**" and is handled by the operating system as if it was a "virtual file". + +!!! info + If you need a refresher about what is "standard output", "standard error", and "standard input" check the section in [Printing and Colors: "Standard Output" and "Standard Error"](printing.md#standard-output-and-standard-error){.internal-link target=_blank}. + +When you hit the ENTER key after typing the email, that is just a "new line character". And in Python that is represented with `"\n"`. + +So, if you use `input="camila@example.com\n"` it means: "type `camila@example.com` in the terminal, then hit the ENTER key": + +```Python hl_lines="9" +{!./src/testing/app02/test_main.py!} +``` + +## Test a function + +If you have a script and you never created an explicit `typer.Typer` app, like: + +```Python hl_lines="9" +{!./src/testing/app03/main.py!} +``` + +...you can still test it, by creating an app during testing: + +```Python hl_lines="6 7 13" +{!./src/testing/app03/test_main.py!} +``` + +Of course, if you are testing that script, it's probably easier/cleaner to just create the explicit `typer.Typer` app in `main.py` instead of creating it just during the test. + +But if you want to keep it that way, e.g. because it's a simple example in documentation, then you can use that trick. + +### About the `app.command` decorator + +Notice the `app.command()(main)`. + +If it's not obvious what it's doing, continue reading... + +You would normally write something like: + +```Python +@app.command() +def main(name: str = "World"): + # Some code here +``` + +But `@app.command()` is just a decorator. + +That's equivalent to: + +```Python +def main(name: str = "World"): + # Some code here + +decorator = app.command() + +new_main = decorator(main) +main = new_main +``` + +`app.command()` returns a function (`decorator`) that takes another function as it's only parameter (`main`). + +And by using the `@something` you normally tell Python to replace the thing below (the function `main`) with the return of the `decorator` function (`new_main`). + +Now, in the specific case of **Typer**, the decorator doesn't change the original function. It registers it internally and returns it unmodified. + +So, `new_main` is actually the same original `main`. + +So, in the case of **Typer**, as it doesn't really modify the decorated function, that would be equivalent to: + +```Python +def main(name: str = "World"): + # Some code here + +decorator = app.command() + +decorator(main) +``` + +But then we don't need to create the variable `decorator` to use it below, we can just use it directly: + +```Python +def main(name: str = "World"): + # Some code here + +app.command()(main) +``` + +...that's it. It's still probably simpler to just create the explicit `typer.Typer` in the `main.py` file πŸ˜…. diff --git a/docs/typer-cli.md b/docs/typer-cli.md index e316c93ac8..3ffd13541d 100644 --- a/docs/typer-cli.md +++ b/docs/typer-cli.md @@ -272,7 +272,7 @@ Hello Camila ## Options -You can specify one of the following **CLI Options**: +You can specify one of the following **CLI options**: * `--app`: the name of the variable with a `Typer()` object to run as the main app. * `--func`: the name of the variable with a function that would be used with `typer.run()`. diff --git a/mkdocs.yml b/mkdocs.yml index 8a2ef84f22..a549c73439 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -31,7 +31,11 @@ nav: - CLI Options with Help: 'tutorial/options/help.md' - Required CLI Options: 'tutorial/options/required.md' - CLI Option Prompt: 'tutorial/options/prompt.md' + - Password CLI Option and Confirmation Prompt: 'tutorial/options/password.md' - CLI Option Name: 'tutorial/options/name.md' + - CLI Option Callback and Context: 'tutorial/options/callback-and-context.md' + - Version CLI Option, is_eager: 'tutorial/options/version.md' + - CLI Option autocompletion: 'tutorial/options/autocompletion.md' - Commands: - Commands Intro: 'tutorial/commands/index.md' - Command CLI Arguments: 'tutorial/commands/arguments.md' @@ -40,6 +44,7 @@ nav: - Custom Command Name: 'tutorial/commands/name.md' - Typer Callback: 'tutorial/commands/callback.md' - One or Multiple Commands: 'tutorial/commands/one-or-multiple.md' + - Using the Context: 'tutorial/commands/context.md' - CLI Parameter Types: - CLI Parameter Types Intro: 'tutorial/parameter-types/index.md' - Number: 'tutorial/parameter-types/number.md' @@ -65,6 +70,7 @@ nav: - Progress Bar: 'tutorial/progressbar.md' - CLI Application Directory: 'tutorial/app-dir.md' - Launching Applications: 'tutorial/launch.md' + - Testing: 'tutorial/testing.md' - Typer CLI - completion for small scripts: 'typer-cli.md' - Alternatives, Inspiration and Comparisons: 'alternatives.md' - Help Typer - Get Help: 'help-typer.md' diff --git a/mypy.ini b/mypy.ini index 9b9c8684b5..1de5f75762 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,6 +1,5 @@ [mypy] ignore_missing_imports = True -disallow_untyped_defs = True -[mypy-tests.*] -disallow_untyped_defs = False +[mypy-typer.*] +disallow_untyped_defs = True diff --git a/pyproject.toml b/pyproject.toml index 4405fe5a06..ec70681948 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,10 @@ doc = [ "mkdocs-material", "markdown-include" ] +dev = [ + "autoflake", + "flake8", +] all = [ "colorama", "shellingham" diff --git a/tests/assets/completion_no_types.py b/tests/assets/completion_no_types.py new file mode 100644 index 0000000000..5ec41d5673 --- /dev/null +++ b/tests/assets/completion_no_types.py @@ -0,0 +1,23 @@ +import typer + +app = typer.Typer() + + +def complete(ctx, args, incomplete): + typer.echo(f"info name is: {ctx.info_name}", err=True) + typer.echo(f"args is: {args}", err=True) + typer.echo(f"incomplete is: {incomplete}", err=True) + return [ + ("Camila", "The reader of books."), + ("Carlos", "The writer of scripts."), + ("Sebastian", "The type hints guy."), + ] + + +@app.command() +def main(name: str = typer.Option("World", autocompletion=complete)): + typer.echo(f"Hello {name}") + + +if __name__ == "__main__": + app() diff --git a/tests/assets/completion_no_types_order.py b/tests/assets/completion_no_types_order.py new file mode 100644 index 0000000000..f4df229109 --- /dev/null +++ b/tests/assets/completion_no_types_order.py @@ -0,0 +1,23 @@ +import typer + +app = typer.Typer() + + +def complete(args, incomplete, ctx): + typer.echo(f"info name is: {ctx.info_name}", err=True) + typer.echo(f"args is: {args}", err=True) + typer.echo(f"incomplete is: {incomplete}", err=True) + return [ + ("Camila", "The reader of books."), + ("Carlos", "The writer of scripts."), + ("Sebastian", "The type hints guy."), + ] + + +@app.command() +def main(name: str = typer.Option("World", autocompletion=complete)): + typer.echo(f"Hello {name}") + + +if __name__ == "__main__": + app() diff --git a/tests/test_completion/test_completion.py b/tests/test_completion/test_completion.py index ba48885026..ec2ac88ffd 100644 --- a/tests/test_completion/test_completion.py +++ b/tests/test_completion/test_completion.py @@ -16,7 +16,7 @@ def test_show_completion(): stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", - env={**os.environ, "SHELL": "/bin/bash", "_TYPER_COMPLETE_TESTING": "True",}, + env={**os.environ, "SHELL": "/bin/bash", "_TYPER_COMPLETE_TESTING": "True"}, ) assert "_TUTORIAL001.PY_COMPLETE=complete_bash" in result.stdout @@ -35,7 +35,7 @@ def test_install_completion(): stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", - env={**os.environ, "SHELL": "/bin/bash", "_TYPER_COMPLETE_TESTING": "True",}, + env={**os.environ, "SHELL": "/bin/bash", "_TYPER_COMPLETE_TESTING": "True"}, ) new_text = bash_completion_path.read_text() bash_completion_path.write_text(text) diff --git a/tests/test_others.py b/tests/test_others.py index e7741a58a9..93f3e815e6 100644 --- a/tests/test_others.py +++ b/tests/test_others.py @@ -1,6 +1,11 @@ +import os +import subprocess +from pathlib import Path from typing import Optional from unittest import mock +import click +import pytest import shellingham import typer import typer.completion @@ -61,7 +66,6 @@ def test_install_invalid_shell(): def main(): typer.echo("Hello World") - typer.completion.Shells with mock.patch.object( shellingham, "detect_shell", return_value=("xshell", "/usr/bin/xshell") ): @@ -69,3 +73,129 @@ def main(): assert "Shell xshell is not supported." in result.stdout result = runner.invoke(app) assert "Hello World" in result.stdout + + +def test_callback_too_many_parameters(): + app = typer.Typer() + + def name_callback(ctx, param, val1, val2): + pass # pragma: nocover + + @app.command() + def main(name: str = typer.Option(..., callback=name_callback)): + pass # pragma: nocover + + with pytest.raises(click.ClickException) as exc_info: + runner.invoke(app, ["--name", "Camila"]) + assert ( + exc_info.value.message == "Too many CLI parameter callback function parameters" + ) + + +def test_callback_2_untyped_parameters(): + app = typer.Typer() + + def name_callback(ctx, value): + typer.echo(f"info name is: {ctx.info_name}") + typer.echo(f"value is: {value}") + + @app.command() + def main(name: str = typer.Option(..., callback=name_callback)): + typer.echo("Hello World") + + result = runner.invoke(app, ["--name", "Camila"]) + assert "info name is: main" in result.stdout + assert "value is: Camila" in result.stdout + + +def test_callback_3_untyped_parameters(): + app = typer.Typer() + + def name_callback(ctx, param, value): + typer.echo(f"info name is: {ctx.info_name}") + typer.echo(f"param name is: {param.name}") + typer.echo(f"value is: {value}") + + @app.command() + def main(name: str = typer.Option(..., callback=name_callback)): + typer.echo("Hello World") + + result = runner.invoke(app, ["--name", "Camila"]) + assert "info name is: main" in result.stdout + assert "param name is: name" in result.stdout + assert "value is: Camila" in result.stdout + + +def test_completion_untyped_parameters(): + file_path = Path(__file__).parent / "assets/completion_no_types.py" + result = subprocess.run( + ["coverage", "run", str(file_path)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + env={ + **os.environ, + "_COMPLETION_NO_TYPES.PY_COMPLETE": "complete_zsh", + "_TYPER_COMPLETE_ARGS": "completion_no_types.py --name Sebastian --name Ca", + "_TYPER_COMPLETE_TESTING": "True", + }, + ) + assert "info name is: completion_no_types.py" in result.stderr + assert "args is: ['--name', 'Sebastian', '--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 + assert '"Sebastian":"The type hints guy."' in result.stdout + + result = subprocess.run( + ["coverage", "run", str(file_path)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Hello World" in result.stdout + + +def test_completion_untyped_parameters_different_order_correct_names(): + file_path = Path(__file__).parent / "assets/completion_no_types_order.py" + result = subprocess.run( + ["coverage", "run", str(file_path)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + env={ + **os.environ, + "_COMPLETION_NO_TYPES_ORDER.PY_COMPLETE": "complete_zsh", + "_TYPER_COMPLETE_ARGS": "completion_no_types_order.py --name Sebastian --name Ca", + "_TYPER_COMPLETE_TESTING": "True", + }, + ) + assert "info name is: completion_no_types_order.py" in result.stderr + assert "args is: ['--name', 'Sebastian', '--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 + assert '"Sebastian":"The type hints guy."' in result.stdout + + result = subprocess.run( + ["coverage", "run", str(file_path)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Hello World" in result.stdout + + +def test_autocompletion_too_many_parameters(): + app = typer.Typer() + + def name_callback(ctx, args, incomplete, val2): + pass # pragma: nocover + + @app.command() + def main(name: str = typer.Option(..., autocompletion=name_callback)): + pass # pragma: nocover + + with pytest.raises(click.ClickException) as exc_info: + runner.invoke(app, ["--name", "Camila"]) + assert exc_info.value.message == "Invalid autocompletion callback parameters: val2" diff --git a/tests/test_tutorial/test_commands/test_context/__init__.py b/tests/test_tutorial/test_commands/test_context/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/test_tutorial/test_commands/test_context/test_tutorial001.py b/tests/test_tutorial/test_commands/test_context/test_tutorial001.py new file mode 100644 index 0000000000..cc4e453db1 --- /dev/null +++ b/tests/test_tutorial/test_commands/test_context/test_tutorial001.py @@ -0,0 +1,32 @@ +import subprocess +from commands.context import tutorial001 as mod + +from typer.testing import CliRunner + +app = mod.app + +runner = CliRunner() + + +def test_create(): + result = runner.invoke(app, ["create", "Camila"]) + assert result.exit_code == 0 + assert "About to execute command: create" in result.output + assert "Creating user: Camila" in result.output + + +def test_delete(): + result = runner.invoke(app, ["delete", "Camila"]) + assert result.exit_code == 0 + assert "About to execute command: delete" in result.output + assert "Deleting user: Camila" in result.output + + +def test_script(): + result = subprocess.run( + ["coverage", "run", mod.__file__, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_tutorial/test_commands/test_context/test_tutorial002.py b/tests/test_tutorial/test_commands/test_context/test_tutorial002.py new file mode 100644 index 0000000000..645cd29904 --- /dev/null +++ b/tests/test_tutorial/test_commands/test_context/test_tutorial002.py @@ -0,0 +1,38 @@ +import subprocess +from commands.context import tutorial002 as mod + +from typer.testing import CliRunner + +app = mod.app + +runner = CliRunner() + + +def test_create(): + result = runner.invoke(app, ["create", "Camila"]) + assert result.exit_code == 0 + assert "Initializing database" in result.output + assert "Creating user: Camila" in result.output + + +def test_delete(): + result = runner.invoke(app, ["delete", "Camila"]) + assert result.exit_code == 0 + assert "Initializing database" in result.output + assert "Deleting user: Camila" in result.output + + +def test_callback(): + result = runner.invoke(app) + assert result.exit_code == 0 + assert "Initializing database" in result.output + + +def test_script(): + result = subprocess.run( + ["coverage", "run", mod.__file__, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_tutorial/test_commands/test_context/test_tutorial003.py b/tests/test_tutorial/test_commands/test_context/test_tutorial003.py new file mode 100644 index 0000000000..be6c699415 --- /dev/null +++ b/tests/test_tutorial/test_commands/test_context/test_tutorial003.py @@ -0,0 +1,38 @@ +import subprocess +from commands.context import tutorial003 as mod + +from typer.testing import CliRunner + +app = mod.app + +runner = CliRunner() + + +def test_create(): + result = runner.invoke(app, ["create", "Camila"]) + assert result.exit_code == 0 + assert "Initializing database" not in result.output + assert "Creating user: Camila" in result.output + + +def test_delete(): + result = runner.invoke(app, ["delete", "Camila"]) + assert result.exit_code == 0 + assert "Initializing database" not in result.output + assert "Deleting user: Camila" in result.output + + +def test_callback(): + result = runner.invoke(app) + assert result.exit_code == 0 + assert "Initializing database" in result.output + + +def test_script(): + result = subprocess.run( + ["coverage", "run", mod.__file__, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_tutorial/test_commands/test_context/test_tutorial004.py b/tests/test_tutorial/test_commands/test_context/test_tutorial004.py new file mode 100644 index 0000000000..a74e5c186c --- /dev/null +++ b/tests/test_tutorial/test_commands/test_context/test_tutorial004.py @@ -0,0 +1,27 @@ +import subprocess +from commands.context import tutorial004 as mod + +from typer.testing import CliRunner + +app = mod.app + +runner = CliRunner() + + +def test_1(): + result = runner.invoke(app, ["--name", "Camila", "--city", "Berlin"]) + assert result.exit_code == 0 + assert "Got extra arg: --name" in result.output + assert "Got extra arg: Camila" in result.output + assert "Got extra arg: --city" in result.output + assert "Got extra arg: Berlin" in result.output + + +def test_script(): + result = subprocess.run( + ["coverage", "run", mod.__file__, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_tutorial/test_commands/test_help/test_tutorial001.py b/tests/test_tutorial/test_commands/test_help/test_tutorial001.py index eac69509dc..92d45e64f7 100644 --- a/tests/test_tutorial/test_commands/test_help/test_tutorial001.py +++ b/tests/test_tutorial/test_commands/test_help/test_tutorial001.py @@ -77,21 +77,21 @@ def test_no_delete(): def test_delete_all(): - result = runner.invoke(app, ["delete-all",], input="y\n") + result = runner.invoke(app, ["delete-all"], input="y\n") assert result.exit_code == 0 assert "Are you sure you want to delete ALL users? [y/N]:" in result.output assert "Deleting all users" in result.output def test_no_delete_all(): - result = runner.invoke(app, ["delete-all",], input="n\n") + result = runner.invoke(app, ["delete-all"], input="n\n") assert result.exit_code == 0 assert "Are you sure you want to delete ALL users? [y/N]:" in result.output assert "Operation cancelled" in result.output def test_init(): - result = runner.invoke(app, ["init",]) + result = runner.invoke(app, ["init"]) assert result.exit_code == 0 assert "Initializing user database" in result.output diff --git a/tests/test_tutorial/test_options/test_callback/__init__.py b/tests/test_tutorial/test_options/test_callback/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/test_tutorial/test_options/test_callback/test_tutorial001.py b/tests/test_tutorial/test_options/test_callback/test_tutorial001.py new file mode 100644 index 0000000000..1e75a26984 --- /dev/null +++ b/tests/test_tutorial/test_options/test_callback/test_tutorial001.py @@ -0,0 +1,33 @@ +import subprocess + +import typer +from typer.testing import CliRunner + +from options.callback import tutorial001 as mod + +runner = CliRunner() + +app = typer.Typer() +app.command()(mod.main) + + +def test_1(): + result = runner.invoke(app, ["--name", "Camila"]) + assert result.exit_code == 0 + assert "Hello Camila" in result.output + + +def test_2(): + result = runner.invoke(app, ["--name", "rick"]) + assert result.exit_code != 0 + assert "Error: Invalid value for '--name': Only Camila is allowed" in result.output + + +def test_script(): + result = subprocess.run( + ["coverage", "run", mod.__file__, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_tutorial/test_options/test_callback/test_tutorial003.py b/tests/test_tutorial/test_options/test_callback/test_tutorial003.py new file mode 100644 index 0000000000..d841c20e4b --- /dev/null +++ b/tests/test_tutorial/test_options/test_callback/test_tutorial003.py @@ -0,0 +1,52 @@ +import os +import subprocess + +import typer +from typer.testing import CliRunner + +from options.callback import tutorial003 as mod + +runner = CliRunner() + +app = typer.Typer() +app.command()(mod.main) + + +def test_1(): + result = runner.invoke(app, ["--name", "Camila"]) + assert result.exit_code == 0 + assert "Validating name" in result.output + assert "Hello Camila" in result.output + + +def test_2(): + result = runner.invoke(app, ["--name", "rick"]) + assert result.exit_code != 0 + assert "Error: Invalid value for '--name': Only Camila is allowed" in result.output + + +def test_script(): + result = subprocess.run( + ["coverage", "run", mod.__file__, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout + + +def test_completion(): + result = subprocess.run( + ["coverage", "run", mod.__file__, " "], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + env={ + **os.environ, + "_TUTORIAL003.PY_COMPLETE": "complete_bash", + "COMP_WORDS": "tutorial003.py --", + "COMP_CWORD": "1", + "_TYPER_COMPLETE_TESTING": "True", + }, + ) + assert "--name" in result.stdout diff --git a/tests/test_tutorial/test_options/test_callback/test_tutorial004.py b/tests/test_tutorial/test_options/test_callback/test_tutorial004.py new file mode 100644 index 0000000000..b0900f05ed --- /dev/null +++ b/tests/test_tutorial/test_options/test_callback/test_tutorial004.py @@ -0,0 +1,52 @@ +import os +import subprocess + +import typer +from typer.testing import CliRunner + +from options.callback import tutorial004 as mod + +runner = CliRunner() + +app = typer.Typer() +app.command()(mod.main) + + +def test_1(): + result = runner.invoke(app, ["--name", "Camila"]) + assert result.exit_code == 0 + assert "Validating param: name" in result.output + assert "Hello Camila" in result.output + + +def test_2(): + result = runner.invoke(app, ["--name", "rick"]) + assert result.exit_code != 0 + assert "Error: Invalid value for '--name': Only Camila is allowed" in result.output + + +def test_script(): + result = subprocess.run( + ["coverage", "run", mod.__file__, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout + + +def test_completion(): + result = subprocess.run( + ["coverage", "run", mod.__file__, " "], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + env={ + **os.environ, + "_TUTORIAL004.PY_COMPLETE": "complete_bash", + "COMP_WORDS": "tutorial004.py --", + "COMP_CWORD": "1", + "_TYPER_COMPLETE_TESTING": "True", + }, + ) + assert "--name" in result.stdout diff --git a/tests/test_tutorial/test_options/test_completion/__init__.py b/tests/test_tutorial/test_options/test_completion/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/test_tutorial/test_options/test_completion/test_tutorial002.py b/tests/test_tutorial/test_options/test_completion/test_tutorial002.py new file mode 100644 index 0000000000..923430150b --- /dev/null +++ b/tests/test_tutorial/test_options/test_completion/test_tutorial002.py @@ -0,0 +1,46 @@ +import os +import subprocess + +import typer +from typer.testing import CliRunner + +from options.autocompletion import tutorial002 as mod + +runner = CliRunner() + +app = typer.Typer() +app.command()(mod.main) + + +def test_completion(): + result = subprocess.run( + ["coverage", "run", mod.__file__, " "], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + env={ + **os.environ, + "_TUTORIAL002.PY_COMPLETE": "complete_zsh", + "_TYPER_COMPLETE_ARGS": "tutorial002.py --name ", + "_TYPER_COMPLETE_TESTING": "True", + }, + ) + assert "Camila" in result.stdout + assert "Carlos" in result.stdout + assert "Sebastian" in result.stdout + + +def test_1(): + result = runner.invoke(app, ["--name", "Camila"]) + assert result.exit_code == 0 + assert "Hello Camila" in result.output + + +def test_script(): + result = subprocess.run( + ["coverage", "run", mod.__file__, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_tutorial/test_options/test_completion/test_tutorial003.py b/tests/test_tutorial/test_options/test_completion/test_tutorial003.py new file mode 100644 index 0000000000..293e973673 --- /dev/null +++ b/tests/test_tutorial/test_options/test_completion/test_tutorial003.py @@ -0,0 +1,46 @@ +import os +import subprocess + +import typer +from typer.testing import CliRunner + +from options.autocompletion import tutorial003 as mod + +runner = CliRunner() + +app = typer.Typer() +app.command()(mod.main) + + +def test_completion(): + result = subprocess.run( + ["coverage", "run", mod.__file__, " "], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + env={ + **os.environ, + "_TUTORIAL003.PY_COMPLETE": "complete_zsh", + "_TYPER_COMPLETE_ARGS": "tutorial003.py --name Seb", + "_TYPER_COMPLETE_TESTING": "True", + }, + ) + assert "Camila" not in result.stdout + assert "Carlos" not in result.stdout + assert "Sebastian" in result.stdout + + +def test_1(): + result = runner.invoke(app, ["--name", "Camila"]) + assert result.exit_code == 0 + assert "Hello Camila" in result.output + + +def test_script(): + result = subprocess.run( + ["coverage", "run", mod.__file__, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_tutorial/test_options/test_completion/test_tutorial004.py b/tests/test_tutorial/test_options/test_completion/test_tutorial004.py new file mode 100644 index 0000000000..b453338a49 --- /dev/null +++ b/tests/test_tutorial/test_options/test_completion/test_tutorial004.py @@ -0,0 +1,46 @@ +import os +import subprocess + +import typer +from typer.testing import CliRunner + +from options.autocompletion import tutorial004 as mod + +runner = CliRunner() + +app = typer.Typer() +app.command()(mod.main) + + +def test_completion(): + result = subprocess.run( + ["coverage", "run", mod.__file__, " "], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + env={ + **os.environ, + "_TUTORIAL004.PY_COMPLETE": "complete_zsh", + "_TYPER_COMPLETE_ARGS": "tutorial004.py --name ", + "_TYPER_COMPLETE_TESTING": "True", + }, + ) + 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 + + +def test_1(): + result = runner.invoke(app, ["--name", "Camila"]) + assert result.exit_code == 0 + assert "Hello Camila" in result.output + + +def test_script(): + result = subprocess.run( + ["coverage", "run", mod.__file__, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_tutorial/test_options/test_completion/test_tutorial007.py b/tests/test_tutorial/test_options/test_completion/test_tutorial007.py new file mode 100644 index 0000000000..2e96cabb60 --- /dev/null +++ b/tests/test_tutorial/test_options/test_completion/test_tutorial007.py @@ -0,0 +1,47 @@ +import os +import subprocess + +import typer +from typer.testing import CliRunner + +from options.autocompletion import tutorial007 as mod + +runner = CliRunner() + +app = typer.Typer() +app.command()(mod.main) + + +def test_completion(): + result = subprocess.run( + ["coverage", "run", mod.__file__, " "], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + env={ + **os.environ, + "_TUTORIAL007.PY_COMPLETE": "complete_zsh", + "_TYPER_COMPLETE_ARGS": "tutorial007.py --name Sebastian --name ", + "_TYPER_COMPLETE_TESTING": "True", + }, + ) + 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_1(): + result = runner.invoke(app, ["--name", "Camila", "--name", "Sebastian"]) + assert result.exit_code == 0 + assert "Hello Camila" in result.output + assert "Hello Sebastian" in result.output + + +def test_script(): + result = subprocess.run( + ["coverage", "run", mod.__file__, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_tutorial/test_options/test_completion/test_tutorial008.py b/tests/test_tutorial/test_options/test_completion/test_tutorial008.py new file mode 100644 index 0000000000..f75c089990 --- /dev/null +++ b/tests/test_tutorial/test_options/test_completion/test_tutorial008.py @@ -0,0 +1,48 @@ +import os +import subprocess + +import typer +from typer.testing import CliRunner + +from options.autocompletion import tutorial008 as mod + +runner = CliRunner() + +app = typer.Typer() +app.command()(mod.main) + + +def test_completion(): + result = subprocess.run( + ["coverage", "run", mod.__file__, " "], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + env={ + **os.environ, + "_TUTORIAL008.PY_COMPLETE": "complete_zsh", + "_TYPER_COMPLETE_ARGS": "tutorial008.py --name ", + "_TYPER_COMPLETE_TESTING": "True", + }, + ) + 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 "['--name']" in result.stderr + + +def test_1(): + result = runner.invoke(app, ["--name", "Camila", "--name", "Sebastian"]) + assert result.exit_code == 0 + assert "Hello Camila" in result.output + assert "Hello Sebastian" in result.output + + +def test_script(): + result = subprocess.run( + ["coverage", "run", mod.__file__, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_tutorial/test_options/test_completion/test_tutorial009.py b/tests/test_tutorial/test_options/test_completion/test_tutorial009.py new file mode 100644 index 0000000000..3bfe72e247 --- /dev/null +++ b/tests/test_tutorial/test_options/test_completion/test_tutorial009.py @@ -0,0 +1,48 @@ +import os +import subprocess + +import typer +from typer.testing import CliRunner + +from options.autocompletion import tutorial009 as mod + +runner = CliRunner() + +app = typer.Typer() +app.command()(mod.main) + + +def test_completion(): + result = subprocess.run( + ["coverage", "run", mod.__file__, " "], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + env={ + **os.environ, + "_TUTORIAL009.PY_COMPLETE": "complete_zsh", + "_TYPER_COMPLETE_ARGS": "tutorial009.py --name Sebastian --name ", + "_TYPER_COMPLETE_TESTING": "True", + }, + ) + 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 "['--name', 'Sebastian', '--name']" in result.stderr + + +def test_1(): + result = runner.invoke(app, ["--name", "Camila", "--name", "Sebastian"]) + assert result.exit_code == 0 + assert "Hello Camila" in result.output + assert "Hello Sebastian" in result.output + + +def test_script(): + result = subprocess.run( + ["coverage", "run", mod.__file__, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_tutorial/test_options/test_version/__init__.py b/tests/test_tutorial/test_options/test_version/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/test_tutorial/test_options/test_version/test_tutorial003.py b/tests/test_tutorial/test_options/test_version/test_tutorial003.py new file mode 100644 index 0000000000..c00904b5e1 --- /dev/null +++ b/tests/test_tutorial/test_options/test_version/test_tutorial003.py @@ -0,0 +1,57 @@ +import os +import subprocess + +import typer +from typer.testing import CliRunner + +from options.version import tutorial003 as mod + +runner = CliRunner() + +app = typer.Typer() +app.command()(mod.main) + + +def test_1(): + result = runner.invoke(app, ["--name", "Rick", "--version"]) + assert result.exit_code == 0 + assert "Awesome CLI Version: 0.1.0" in result.output + + +def test_2(): + result = runner.invoke(app, ["--name", "rick"]) + assert result.exit_code != 0 + assert "Error: Invalid value for '--name': Only Camila is allowed" in result.output + + +def test_3(): + result = runner.invoke(app, ["--name", "Camila"]) + assert result.exit_code == 0 + assert "Hello Camila" in result.output + + +def test_script(): + result = subprocess.run( + ["coverage", "run", mod.__file__, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout + + +def test_completion(): + result = subprocess.run( + ["coverage", "run", mod.__file__, " "], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + env={ + **os.environ, + "_TUTORIAL003.PY_COMPLETE": "complete_bash", + "COMP_WORDS": "tutorial003.py --name Rick --v", + "COMP_CWORD": "3", + "_TYPER_COMPLETE_TESTING": "True", + }, + ) + assert "--version" in result.stdout diff --git a/tests/test_tutorial/test_testing/__init__.py b/tests/test_tutorial/test_testing/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/test_tutorial/test_testing/test_app01.py b/tests/test_tutorial/test_testing/test_app01.py new file mode 100644 index 0000000000..c7c45dbe73 --- /dev/null +++ b/tests/test_tutorial/test_testing/test_app01.py @@ -0,0 +1,18 @@ +import subprocess + +from testing.app01 import main as mod +from testing.app01.test_main import test_app + + +def test_app01(): + test_app() + + +def test_script(): + result = subprocess.run( + ["coverage", "run", mod.__file__, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_tutorial/test_testing/test_app02.py b/tests/test_tutorial/test_testing/test_app02.py new file mode 100644 index 0000000000..ff5a98f6ac --- /dev/null +++ b/tests/test_tutorial/test_testing/test_app02.py @@ -0,0 +1,18 @@ +import subprocess + +from testing.app02 import main as mod +from testing.app02.test_main import test_app + + +def test_app02(): + test_app() + + +def test_script(): + result = subprocess.run( + ["coverage", "run", mod.__file__, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_tutorial/test_testing/test_app03.py b/tests/test_tutorial/test_testing/test_app03.py new file mode 100644 index 0000000000..3938c8ec9d --- /dev/null +++ b/tests/test_tutorial/test_testing/test_app03.py @@ -0,0 +1,18 @@ +import subprocess + +from testing.app03 import main as mod +from testing.app03.test_main import test_app + + +def test_app03(): + test_app() + + +def test_script(): + result = subprocess.run( + ["coverage", "run", mod.__file__, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/typer/__init__.py b/typer/__init__.py index 442a0d42ab..ff4d3b8ec2 100644 --- a/typer/__init__.py +++ b/typer/__init__.py @@ -2,7 +2,7 @@ __version__ = "0.0.11" -from click.exceptions import Abort, Exit +from click.exceptions import Abort, BadParameter, Exit from click.termui import ( clear, confirm, @@ -29,5 +29,12 @@ from . import colors from .main import Typer, run -from .models import Context, FileBinaryRead, FileBinaryWrite, FileText, FileTextWrite +from .models import ( + CallbackParam, + Context, + FileBinaryRead, + FileBinaryWrite, + FileText, + FileTextWrite, +) from .params import Argument, Option diff --git a/typer/main.py b/typer/main.py index 936fc31238..807ae8e7e0 100644 --- a/typer/main.py +++ b/typer/main.py @@ -321,7 +321,9 @@ def solve_typer_info_defaults(typer_info: TyperInfo) -> TyperInfo: pass # Priority 3: Value set in subapp = typer.Typer() try: - instance_value = getattr(typer_info.typer_instance.info, name) # type: ignore + instance_value = getattr( + typer_info.typer_instance.info, name # type: ignore + ) if not isinstance(instance_value, DefaultPlaceholder): values[name] = instance_value continue @@ -698,12 +700,14 @@ def get_click_param( # Parameter required=required, default=default_value, - callback=parameter_info.callback, + callback=get_param_callback( + callback=parameter_info.callback, convertor=convertor + ), metavar=parameter_info.metavar, expose_value=parameter_info.expose_value, is_eager=parameter_info.is_eager, envvar=parameter_info.envvar, - autocompletion=parameter_info.autocompletion, + autocompletion=get_param_completion(parameter_info.autocompletion), ), convertor, ) @@ -721,18 +725,121 @@ def get_click_param( nargs=nargs, # Parameter default=default_value, - callback=parameter_info.callback, + callback=get_param_callback( + callback=parameter_info.callback, convertor=convertor + ), metavar=parameter_info.metavar, expose_value=parameter_info.expose_value, is_eager=parameter_info.is_eager, envvar=parameter_info.envvar, - autocompletion=parameter_info.autocompletion, + autocompletion=get_param_completion(parameter_info.autocompletion), ), convertor, ) assert False, "A click.Parameter should be returned" # pragma no cover +def get_param_callback( + *, callback: Optional[Callable] = None, convertor: Optional[Callable] = None +) -> Optional[Callable]: + if not callback: + return None + signature = inspect.signature(callback) + ctx_name = None + click_param_name = None + value_name = None + untyped_names: List[str] = [] + for param_name, param_sig in signature.parameters.items(): + if lenient_issubclass(param_sig.annotation, click.Context): + ctx_name = param_name + elif lenient_issubclass(param_sig.annotation, click.Parameter): + click_param_name = param_name + else: + untyped_names.append(param_name) + # Extract value param name first + if untyped_names: + value_name = untyped_names.pop() + # If context and Click param were not typed (old/Click callback style) extract them + if untyped_names: + if ctx_name is None: + ctx_name = untyped_names.pop(0) + if click_param_name is None: + if untyped_names: + click_param_name = untyped_names.pop(0) + if untyped_names: + raise click.ClickException( + "Too many CLI parameter callback function parameters" + ) + + def wrapper(ctx: click.Context, param: click.Parameter, value: Any) -> Any: + use_params: Dict[str, Any] = {} + if ctx_name: + use_params[ctx_name] = ctx + if click_param_name: + use_params[click_param_name] = param + if value_name: + if convertor: + use_value = convertor(value) + else: + use_value = value + use_params[value_name] = use_value + return callback(**use_params) # type: ignore + + update_wrapper(wrapper, callback) + return wrapper + + +def get_param_completion(callback: Optional[Callable] = None) -> Optional[Callable]: + if not callback: + return None + signature = inspect.signature(callback) + ctx_name = None + args_name = None + incomplete_name = None + unassigned_params = [param for param in signature.parameters.values()] + for param_sig in unassigned_params[:]: + origin = getattr(param_sig.annotation, "__origin__", None) + if lenient_issubclass(param_sig.annotation, click.Context): + ctx_name = param_sig.name + unassigned_params.remove(param_sig) + elif lenient_issubclass(origin, List): + args_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) + # If there are still unassigned parameters (not typed), extract by name + for param_sig in unassigned_params[:]: + if ctx_name is None and param_sig.name == "ctx": + ctx_name = param_sig.name + unassigned_params.remove(param_sig) + elif args_name is None and param_sig.name == "args": + args_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) + # Extract value param name first + if unassigned_params: + show_params = " ".join([param.name for param in unassigned_params]) + raise click.ClickException( + f"Invalid autocompletion callback parameters: {show_params}" + ) + + def wrapper(ctx: click.Context, args: List[str], 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 incomplete_name: + use_params[incomplete_name] = incomplete + return callback(**use_params) # type: ignore + + update_wrapper(wrapper, callback) + return wrapper + + def run(function: Callable) -> Any: app = Typer() app.command()(function) diff --git a/typer/models.py b/typer/models.py index b0bf9d0dee..824979e62c 100644 --- a/typer/models.py +++ b/typer/models.py @@ -45,6 +45,10 @@ class FileBinaryWrite(io.BufferedWriter): pass +class CallbackParam(click.Parameter): + pass + + class DefaultPlaceholder: """ You shouldn't use this class directly. @@ -82,7 +86,7 @@ def __init__( *, cls: Optional[Type[click.Command]] = None, context_settings: Optional[Dict[Any, Any]] = None, - callback: Optional[Callable[..., Any]] = None, + callback: Optional[Callable] = None, help: Optional[str] = None, epilog: Optional[str] = None, short_help: Optional[str] = None, @@ -120,7 +124,7 @@ def __init__( result_callback: Optional[Callable] = Default(None), # Command context_settings: Optional[Dict[Any, Any]] = Default(None), - callback: Optional[Callable[..., Any]] = Default(None), + callback: Optional[Callable] = Default(None), help: Optional[str] = Default(None), epilog: Optional[str] = Default(None), short_help: Optional[str] = Default(None), @@ -154,14 +158,12 @@ def __init__( *, default: Optional[Any] = None, param_decls: Optional[Sequence[str]] = None, - callback: Optional[Callable[[click.Context, click.Parameter, str], Any]] = None, + callback: Optional[Callable] = None, metavar: Optional[str] = None, expose_value: bool = True, is_eager: bool = False, envvar: Optional[Union[str, List[str]]] = None, - autocompletion: Optional[ - Callable[[click.Context, List[str], str], List[str]] - ] = None, + autocompletion: Optional[Callable] = None, # Choice case_sensitive: bool = True, # Numbers @@ -226,14 +228,12 @@ def __init__( # ParameterInfo default: Optional[Any] = None, param_decls: Optional[Sequence[str]] = None, - callback: Optional[Callable[[click.Context, click.Parameter, str], Any]] = None, + callback: Optional[Callable] = None, metavar: Optional[str] = None, expose_value: bool = True, is_eager: bool = False, envvar: Optional[Union[str, List[str]]] = None, - autocompletion: Optional[ - Callable[[click.Context, List[str], str], List[str]] - ] = None, + autocompletion: Optional[Callable] = None, # Option show_default: bool = False, prompt: Union[bool, str] = False, @@ -325,14 +325,12 @@ def __init__( # ParameterInfo default: Optional[Any] = None, param_decls: Optional[Sequence[str]] = None, - callback: Optional[Callable[[click.Context, click.Parameter, str], Any]] = None, + callback: Optional[Callable] = None, metavar: Optional[str] = None, expose_value: bool = True, is_eager: bool = False, envvar: Optional[Union[str, List[str]]] = None, - autocompletion: Optional[ - Callable[[click.Context, List[str], str], List[str]] - ] = None, + autocompletion: Optional[Callable] = None, # Choice case_sensitive: bool = True, # Numbers diff --git a/typer/params.py b/typer/params.py index 3ee37deeff..e8ecf5a266 100644 --- a/typer/params.py +++ b/typer/params.py @@ -1,7 +1,5 @@ from typing import Any, Callable, List, Optional, Type, Union -import click - from .models import ArgumentInfo, OptionInfo @@ -9,14 +7,12 @@ def Option( # Parameter default: Optional[Any], *param_decls: str, - callback: Optional[Callable[[click.Context, click.Parameter, str], Any]] = None, + callback: Optional[Callable] = None, metavar: Optional[str] = None, expose_value: bool = True, is_eager: bool = False, envvar: Optional[Union[str, List[str]]] = None, - autocompletion: Optional[ - Callable[[click.Context, List[str], str], List[str]] - ] = None, + autocompletion: Optional[Callable] = None, # Option show_default: bool = False, prompt: Union[bool, str] = False, @@ -107,14 +103,12 @@ def Argument( # Parameter default: Optional[Any], *, - callback: Optional[Callable[[click.Context, click.Parameter, str], Any]] = None, + callback: Optional[Callable] = None, metavar: Optional[str] = None, expose_value: bool = True, is_eager: bool = False, envvar: Optional[Union[str, List[str]]] = None, - autocompletion: Optional[ - Callable[[click.Context, List[str], str], List[str]] - ] = None, + autocompletion: Optional[Callable] = None, # Choice case_sensitive: bool = True, # Numbers