3e37e01979
* Support custom parameter types While most CLI parameters are strings, ints, etc, we sometimes need custom types. Currently typer has no support for it (See #77), and while there are a few hacks, the likely solution is to add a 'str' argument and parse it inside the main function. This PR adds support for custom types in 3 different ways: - Manually specifying a `click_type` - Manually specifying a `parse` function - Using a Callable type annotation (It is very common for types to have a string constructor, like `int("1")`) * Document how to parse custom objects with Typer. Typer supports parsing of cutom types with: - A user provided parser class - A click custom type parser * 🎨 [pre-commit.ci] Auto format from pre-commit.com hooks * 📝 Tweak and simplify docs * ✅ Tweak tests to run scripts and remove pragma: nocover in examples * 📝 Tweak examples for docs, add types and remove # pragma: nocover * ♻️ Tweak implementation checking for parser and Click type --------- Co-authored-by: Paulo Costa <me@paulo.costa.nom.br> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Sebastián Ramírez <tiangolo@gmail.com>
257 lines
7.9 KiB
Python
257 lines
7.9 KiB
Python
import os
|
|
import subprocess
|
|
import sys
|
|
import typing
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
import click
|
|
import pytest
|
|
import shellingham
|
|
import typer
|
|
import typer.completion
|
|
from typer.main import solve_typer_info_defaults, solve_typer_info_help
|
|
from typer.models import ParameterInfo, TyperInfo
|
|
from typer.testing import CliRunner
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
def test_help_from_info():
|
|
# Mainly for coverage/completeness
|
|
value = solve_typer_info_help(TyperInfo())
|
|
assert value is None
|
|
|
|
|
|
def test_defaults_from_info():
|
|
# Mainly for coverage/completeness
|
|
value = solve_typer_info_defaults(TyperInfo())
|
|
assert value
|
|
|
|
|
|
def test_too_may_parsers():
|
|
def custom_parser(value: str) -> int:
|
|
return int(value) # pragma: no cover
|
|
|
|
class CustomClickParser(click.ParamType):
|
|
name = "custom_parser"
|
|
|
|
def convert(
|
|
self,
|
|
value: str,
|
|
param: typing.Optional[click.Parameter],
|
|
ctx: typing.Optional[click.Context],
|
|
) -> typing.Any:
|
|
return int(value) # pragma: no cover
|
|
|
|
expected_error = (
|
|
"Multiple custom type parsers provided. "
|
|
"`parser` and `click_type` may not both be provided."
|
|
)
|
|
|
|
with pytest.raises(ValueError, match=expected_error):
|
|
ParameterInfo(parser=custom_parser, click_type=CustomClickParser())
|
|
|
|
|
|
def test_valid_parser_permutations():
|
|
def custom_parser(value: str) -> int:
|
|
return int(value) # pragma: no cover
|
|
|
|
class CustomClickParser(click.ParamType):
|
|
name = "custom_parser"
|
|
|
|
def convert(
|
|
self,
|
|
value: str,
|
|
param: typing.Optional[click.Parameter],
|
|
ctx: typing.Optional[click.Context],
|
|
) -> typing.Any:
|
|
return int(value) # pragma: no cover
|
|
|
|
ParameterInfo()
|
|
ParameterInfo(parser=custom_parser)
|
|
ParameterInfo(click_type=CustomClickParser())
|
|
|
|
|
|
def test_install_invalid_shell():
|
|
app = typer.Typer()
|
|
|
|
@app.command()
|
|
def main():
|
|
print("Hello World")
|
|
|
|
with mock.patch.object(
|
|
shellingham, "detect_shell", return_value=("xshell", "/usr/bin/xshell")
|
|
):
|
|
result = runner.invoke(app, ["--install-completion"])
|
|
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):
|
|
print(f"info name is: {ctx.info_name}")
|
|
print(f"value is: {value}")
|
|
|
|
@app.command()
|
|
def main(name: str = typer.Option(..., callback=name_callback)):
|
|
print("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):
|
|
print(f"info name is: {ctx.info_name}")
|
|
print(f"param name is: {param.name}")
|
|
print(f"value is: {value}")
|
|
|
|
@app.command()
|
|
def main(name: str = typer.Option(..., callback=name_callback)):
|
|
print("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(
|
|
[sys.executable, "-m", "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
|
|
# TODO: when deprecating Click 7, remove second option
|
|
assert (
|
|
"args is: []" in result.stderr
|
|
or "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
|
|
|
|
result = subprocess.run(
|
|
[sys.executable, "-m", "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(
|
|
[sys.executable, "-m", "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
|
|
# TODO: when deprecating Click 7, remove second option
|
|
assert (
|
|
"args is: []" in result.stderr
|
|
or "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
|
|
|
|
result = subprocess.run(
|
|
[sys.executable, "-m", "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"
|
|
|
|
|
|
def test_forward_references():
|
|
app = typer.Typer()
|
|
|
|
@app.command()
|
|
def main(arg1, arg2: int, arg3: "int", arg4: bool = False, arg5: "bool" = False):
|
|
print(f"arg1: {type(arg1)} {arg1}")
|
|
print(f"arg2: {type(arg2)} {arg2}")
|
|
print(f"arg3: {type(arg3)} {arg3}")
|
|
print(f"arg4: {type(arg4)} {arg4}")
|
|
print(f"arg5: {type(arg5)} {arg5}")
|
|
|
|
result = runner.invoke(app, ["Hello", "2", "invalid"])
|
|
# TODO: when deprecating Click 7, remove second option
|
|
|
|
assert (
|
|
"Invalid value for 'ARG3': 'invalid' is not a valid integer" in result.stdout
|
|
or "Invalid value for 'ARG3': invalid is not a valid integer" in result.stdout
|
|
)
|
|
result = runner.invoke(app, ["Hello", "2", "3", "--arg4", "--arg5"])
|
|
assert (
|
|
"arg1: <class 'str'> Hello\narg2: <class 'int'> 2\narg3: <class 'int'> 3\narg4: <class 'bool'> True\narg5: <class 'bool'> True\n"
|
|
in result.stdout
|
|
)
|
|
|
|
|
|
def test_context_settings_inheritance_single_command():
|
|
app = typer.Typer(context_settings=dict(help_option_names=["-h", "--help"]))
|
|
|
|
@app.command()
|
|
def main(name: str):
|
|
pass # pragma: nocover
|
|
|
|
result = runner.invoke(app, ["main", "-h"])
|
|
assert "Show this message and exit." in result.stdout
|