From 8bf9eccf048eb68c6b3dcfc337ba683bb6a343c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sat, 28 Dec 2019 14:23:49 +0100 Subject: [PATCH 1/4] :bug: Fix extracting help from docstrings in functions handling defaults when including Typer groups and explicit None --- typer/completion.py | 5 ++-- typer/main.py | 70 ++++++++++++++++++++++++++++++++++++--------- 2 files changed, 60 insertions(+), 15 deletions(-) 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, From cf75cada76d2321efab48d1cf4c0105912e74868 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sat, 28 Dec 2019 14:24:54 +0100 Subject: [PATCH 2/4] :memo: Update First Steps to include how to document in a docstring --- docs/src/first_steps/tutorial006.py | 17 +++++++++++ docs/tutorial/first-steps.md | 44 +++++++++++++++++++++++++++-- 2 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 docs/src/first_steps/tutorial006.py 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. From e02475a78482d7f298a6c702e0935a8174a295db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sat, 28 Dec 2019 14:25:32 +0100 Subject: [PATCH 3/4] :white_check_mark: Add tests for tutorial006 in First Steps, to test help from docstring --- .../test_first_steps/test_tutorial006.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 tests/test_tutorial/test_first_steps/test_tutorial006.py 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..7858a69 --- /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", "--parallel-mode", mod.__file__, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout From f00e10083c557ff4af52ca5446d028d43d74b5a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sat, 28 Dec 2019 14:44:08 +0100 Subject: [PATCH 4/4] :white_check_mark: Fix test coverage handling --- .coveragerc | 8 ++++++++ .gitignore | 2 ++ pyproject.toml | 1 + tests/test_tutorial/test_first_steps/test_tutorial001.py | 2 +- tests/test_tutorial/test_first_steps/test_tutorial002.py | 2 +- tests/test_tutorial/test_first_steps/test_tutorial003.py | 2 +- tests/test_tutorial/test_first_steps/test_tutorial004.py | 2 +- tests/test_tutorial/test_first_steps/test_tutorial005.py | 2 +- tests/test_tutorial/test_first_steps/test_tutorial006.py | 2 +- 9 files changed, 17 insertions(+), 6 deletions(-) create mode 100644 .coveragerc 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/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 index 7858a69..12ec281 100644 --- a/tests/test_tutorial/test_first_steps/test_tutorial006.py +++ b/tests/test_tutorial/test_first_steps/test_tutorial006.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",