diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..6c140c4 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,8 @@ +[run] + +source = + typer + tests + docs/src + +parallel = True diff --git a/.gitignore b/.gitignore index 42ac371..bd4cbf4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ dist .mypy_cache .idea site +.coverage +htmlcov diff --git a/docs/src/first_steps/tutorial006.py b/docs/src/first_steps/tutorial006.py new file mode 100644 index 0000000..d373bbc --- /dev/null +++ b/docs/src/first_steps/tutorial006.py @@ -0,0 +1,17 @@ +import typer + + +def main(name: str, lastname: str = "", formal: bool = False): + """ + Say hi to NAME, optionally with a --lastname. + + If --formal is used, say hi very formally. + """ + if formal: + typer.echo(f"Good day Ms. {name} {lastname}.") + else: + typer.echo(f"Hello {name} {lastname}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs/tutorial/first-steps.md b/docs/tutorial/first-steps.md index 9e0fa70..9231626 100644 --- a/docs/tutorial/first-steps.md +++ b/docs/tutorial/first-steps.md @@ -22,10 +22,12 @@ Hello World // Now check the --help $ python main.py --help -Usage: main.py [OPTIONS] +Usage: tryit.py [OPTIONS] Options: - --help Show this message and exit. + --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. ``` @@ -302,6 +304,44 @@ Hello Camila Gutiérrez +## Document your CLI app + +If you add a docstring to your function it will be used in the help text: + +```Python hl_lines="5 6 7 8 9" +{!./src/first_steps/tutorial006.py!} +``` + +Now see it with the `--help` option: + +
+ +```console +$ python main.py --help + +Usage: tutorial006.py [OPTIONS] NAME + + Say hi to NAME, optionally with a --lastname. + + If --formal is used, say hi very formally. + +Options: + --lastname TEXT + --formal / --no-formal + --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. +``` + +
+ +!!! tip + You should document the *CLI arguments* in the docstring. + + There is another place to document the *CLI options* that will show up next to them in the help text as with `--install-completion` or `--help`, you will learn that later in the tutorial. + + But *CLI arguments* are normally used for the most necessary things, so you should document them here in the *docstring*. + ## Arguments, options, parameters, optional, required Be aware that these terms refer to multiple things depending on the context, and sadly, those "contexts" mix frequently, so it's easy to get confused. diff --git a/pyproject.toml b/pyproject.toml index ef73bc9..d72b45d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ Documentation = "https://typer.tiangolo.com/" test = [ "pytest >=4.0.0", "pytest-cov", + "coverage", "mypy", "black", "isort" diff --git a/tests/test_tutorial/test_first_steps/test_tutorial001.py b/tests/test_tutorial/test_first_steps/test_tutorial001.py index d7b8473..0184c42 100644 --- a/tests/test_tutorial/test_first_steps/test_tutorial001.py +++ b/tests/test_tutorial/test_first_steps/test_tutorial001.py @@ -17,7 +17,7 @@ def test_cli(): def test_script(): result = subprocess.run( - ["coverage", "run", "--parallel-mode", mod.__file__, "--help"], + ["coverage", "run", mod.__file__, "--help"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", diff --git a/tests/test_tutorial/test_first_steps/test_tutorial002.py b/tests/test_tutorial/test_first_steps/test_tutorial002.py index 9c70a3e..080b4f0 100644 --- a/tests/test_tutorial/test_first_steps/test_tutorial002.py +++ b/tests/test_tutorial/test_first_steps/test_tutorial002.py @@ -25,7 +25,7 @@ def test_2(): def test_script(): result = subprocess.run( - ["coverage", "run", "--parallel-mode", mod.__file__, "--help"], + ["coverage", "run", mod.__file__, "--help"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", diff --git a/tests/test_tutorial/test_first_steps/test_tutorial003.py b/tests/test_tutorial/test_first_steps/test_tutorial003.py index bf0038d..cccc5c2 100644 --- a/tests/test_tutorial/test_first_steps/test_tutorial003.py +++ b/tests/test_tutorial/test_first_steps/test_tutorial003.py @@ -25,7 +25,7 @@ def test_2(): def test_script(): result = subprocess.run( - ["coverage", "run", "--parallel-mode", mod.__file__, "--help"], + ["coverage", "run", mod.__file__, "--help"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", diff --git a/tests/test_tutorial/test_first_steps/test_tutorial004.py b/tests/test_tutorial/test_first_steps/test_tutorial004.py index e00f5a2..5415f4c 100644 --- a/tests/test_tutorial/test_first_steps/test_tutorial004.py +++ b/tests/test_tutorial/test_first_steps/test_tutorial004.py @@ -37,7 +37,7 @@ def test_formal_3(): def test_script(): result = subprocess.run( - ["coverage", "run", "--parallel-mode", mod.__file__, "--help"], + ["coverage", "run", mod.__file__, "--help"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", diff --git a/tests/test_tutorial/test_first_steps/test_tutorial005.py b/tests/test_tutorial/test_first_steps/test_tutorial005.py index 4aa2e72..6d96399 100644 --- a/tests/test_tutorial/test_first_steps/test_tutorial005.py +++ b/tests/test_tutorial/test_first_steps/test_tutorial005.py @@ -44,7 +44,7 @@ def test_formal_1(): def test_script(): result = subprocess.run( - ["coverage", "run", "--parallel-mode", mod.__file__, "--help"], + ["coverage", "run", mod.__file__, "--help"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", diff --git a/tests/test_tutorial/test_first_steps/test_tutorial006.py b/tests/test_tutorial/test_first_steps/test_tutorial006.py new file mode 100644 index 0000000..12ec281 --- /dev/null +++ b/tests/test_tutorial/test_first_steps/test_tutorial006.py @@ -0,0 +1,52 @@ +import subprocess + +import typer +from typer.testing import CliRunner + +from first_steps import tutorial006 as mod + +runner = CliRunner() + +app = typer.Typer() +app.command()(mod.main) + + +def test_help(): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "Say hi to NAME, optionally with a --lastname." in result.output + assert "If --formal is used, say hi very formally." in result.output + + +def test_1(): + result = runner.invoke(app, ["Camila"]) + assert result.exit_code == 0 + assert "Hello Camila" in result.output + + +def test_option_lastname(): + result = runner.invoke(app, ["Camila", "--lastname", "Gutiérrez"]) + assert result.exit_code == 0 + assert "Hello Camila Gutiérrez" in result.output + + +def test_option_lastname_2(): + result = runner.invoke(app, ["--lastname", "Gutiérrez", "Camila"]) + assert result.exit_code == 0 + assert "Hello Camila Gutiérrez" in result.output + + +def test_formal_1(): + result = runner.invoke(app, ["Camila", "--lastname", "Gutiérrez", "--formal"]) + assert result.exit_code == 0 + assert "Good day Ms. Camila Gutiérrez." 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/typer/completion.py b/typer/completion.py index 4823137..bb7cba6 100644 --- a/typer/completion.py +++ b/typer/completion.py @@ -1,3 +1,4 @@ +import sys from typing import Any import click @@ -14,14 +15,14 @@ def install_callback(ctx: click.Context, param: click.Parameter, value: Any) -> return value shell, path = click_completion.core.install() click.echo(f"{shell} completion installed in {path}") - exit(0) + sys.exit(0) def show_callback(ctx: click.Context, param: click.Parameter, value: Any) -> Any: if not value or ctx.resilient_parsing: return value click.echo(click_completion.core.get_code()) - exit(0) + sys.exit(0) # Create a fake command function to extract the completion parameters diff --git a/typer/main.py b/typer/main.py index 8ae0efa..78e510d 100644 --- a/typer/main.py +++ b/typer/main.py @@ -259,28 +259,67 @@ def get_group_name(typer_info: TyperInfo) -> Optional[str]: return None +def solve_typer_info_help(typer_info: TyperInfo) -> str: + # Priority 1a: Value was set in app.add_typer() + if not isinstance(typer_info.help, DefaultPlaceholder): + return inspect.cleandoc(typer_info.help or "") + # Priority 1b: Value was set in app.add_typer(), in callback docstring + if typer_info.callback: + doc = inspect.getdoc(typer_info.callback) + if doc: + return doc + try: + # Priority 2a: Value was set in @subapp.callback() + doc = typer_info.typer_instance.registered_callback.help + if not isinstance(doc, DefaultPlaceholder): + return inspect.cleandoc(doc or "") + # Priority 2b: Value was set in @subapp.callback(), in callback docstring + doc = inspect.getdoc(typer_info.typer_instance.registered_callback.callback) + if doc: + return doc + except AttributeError: + pass + try: + # Priority 3a: Value set in subapp = typer.Typer() + instance_value = typer_info.typer_instance.info.help + if not isinstance(instance_value, DefaultPlaceholder): + return inspect.cleandoc(instance_value or "") + doc = inspect.getdoc(typer_info.typer_instance.callback) + if doc: + return doc + except AttributeError: + pass + # Value not set, use the default + return typer_info.help.value + + def solve_typer_info_defaults(typer_info: TyperInfo) -> TyperInfo: - values = {} + values: Dict[str, Any] = {} + values["help"] = solve_typer_info_help(typer_info) name = None for name, value in typer_info.__dict__.items(): # Priority 1: Value was set in app.add_typer() if not isinstance(value, DefaultPlaceholder): values[name] = value continue - if typer_info.typer_instance: - if typer_info.typer_instance.registered_callback: - callback_value = getattr( - typer_info.typer_instance.registered_callback, name - ) - # Priority 2: Value was set in @subapp.callback() - if not isinstance(callback_value, DefaultPlaceholder): - values[name] = callback_value - continue - instance_value = getattr(typer_info.typer_instance.info, name) - # Priority 3: Value set in subapp = typer.Typer() + # Priority 2: Value was set in @subapp.callback() + try: + callback_value = getattr( + typer_info.typer_instance.registered_callback, name # type: ignore + ) + if not isinstance(callback_value, DefaultPlaceholder): + values[name] = callback_value + continue + except AttributeError: + pass + # Priority 3: Value set in subapp = typer.Typer() + try: + instance_value = getattr(typer_info.typer_instance.info, name) # type: ignore if not isinstance(instance_value, DefaultPlaceholder): values[name] = instance_value continue + except AttributeError: + pass # Value not set, use the default values[name] = value.value if values["name"] is None: @@ -359,6 +398,11 @@ def get_params_convertors_ctx_param_name_from_function( def get_command_from_info(command_info: CommandInfo) -> click.Command: assert command_info.callback, "A command must have a callback function" name = command_info.name or get_command_name(command_info.callback.__name__) + use_help = command_info.help + if use_help is None: + use_help = inspect.getdoc(command_info.callback) + else: + use_help = inspect.cleandoc(use_help) ( params, convertors, @@ -375,7 +419,7 @@ def get_command_from_info(command_info: CommandInfo) -> click.Command: context_param_name=context_param_name, ), params=params, # type: ignore - help=command_info.help, + help=use_help, epilog=command_info.epilog, short_help=command_info.short_help, options_metavar=command_info.options_metavar,