🐛 Fix and document extracting help form docstring
This commit is contained in:
commit
a8bc1b7b32
13 changed files with 187 additions and 22 deletions
8
.coveragerc
Normal file
8
.coveragerc
Normal file
|
@ -0,0 +1,8 @@
|
|||
[run]
|
||||
|
||||
source =
|
||||
typer
|
||||
tests
|
||||
docs/src
|
||||
|
||||
parallel = True
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -7,3 +7,5 @@ dist
|
|||
.mypy_cache
|
||||
.idea
|
||||
site
|
||||
.coverage
|
||||
htmlcov
|
||||
|
|
17
docs/src/first_steps/tutorial006.py
Normal file
17
docs/src/first_steps/tutorial006.py
Normal file
|
@ -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)
|
|
@ -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.
|
||||
```
|
||||
|
||||
</div>
|
||||
|
@ -302,6 +304,44 @@ Hello Camila Gutiérrez
|
|||
|
||||
</div>
|
||||
|
||||
## Document your CLI app
|
||||
|
||||
If you add a <abbr title="a multi-line string as the first expression inside a function (not assigned to any variable) used for documentation">docstring</abbr> 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:
|
||||
|
||||
<div class="termy">
|
||||
|
||||
```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.
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
!!! 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.
|
||||
|
|
|
@ -39,6 +39,7 @@ Documentation = "https://typer.tiangolo.com/"
|
|||
test = [
|
||||
"pytest >=4.0.0",
|
||||
"pytest-cov",
|
||||
"coverage",
|
||||
"mypy",
|
||||
"black",
|
||||
"isort"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
52
tests/test_tutorial/test_first_steps/test_tutorial006.py
Normal file
52
tests/test_tutorial/test_first_steps/test_tutorial006.py
Normal file
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue