diff --git a/docs/tutorial/parameter-types/dict.md b/docs/tutorial/parameter-types/dict.md new file mode 100644 index 0000000000..0fae3db467 --- /dev/null +++ b/docs/tutorial/parameter-types/dict.md @@ -0,0 +1,28 @@ +# Dict + +You can declare a *CLI parameter* to be a standard Python `dict`: + +{* docs_src/parameter_types/dict/tutorial001_an.py hl[5] *} + +Check it: + +
+ +```console +// Run your program +$ python main.py --user-info '{"name": "Camila", "age": 15, "height": 1.7, "female": true}' + +Name: Camila +User attributes: ['age', 'female', 'height', 'name'] + +``` + +
+ +This can be particularly useful when you want to include JSON input: + +```python +import json + +data = json.loads(user_input) +``` diff --git a/docs_src/parameter_types/dict/__init__.py b/docs_src/parameter_types/dict/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/parameter_types/dict/tutorial001.py b/docs_src/parameter_types/dict/tutorial001.py new file mode 100644 index 0000000000..2f0cd61559 --- /dev/null +++ b/docs_src/parameter_types/dict/tutorial001.py @@ -0,0 +1,10 @@ +import typer + + +def main(user_info: dict = typer.Option()): + print(f"Name: {user_info.get('name', 'Unknown')}") + print(f"User attributes: {sorted(user_info.keys())}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs_src/parameter_types/dict/tutorial001_an.py b/docs_src/parameter_types/dict/tutorial001_an.py new file mode 100644 index 0000000000..92b507a322 --- /dev/null +++ b/docs_src/parameter_types/dict/tutorial001_an.py @@ -0,0 +1,11 @@ +import typer +from typing_extensions import Annotated + + +def main(user_info: Annotated[dict, typer.Option()]): + print(f"Name: {user_info.get('name', 'Unknown')}") + print(f"User attributes: {sorted(user_info.keys())}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/tests/test_others.py b/tests/test_others.py index 1078e63d1f..3499188cf6 100644 --- a/tests/test_others.py +++ b/tests/test_others.py @@ -1,3 +1,4 @@ +import json import os import subprocess import sys @@ -12,7 +13,7 @@ import typer.completion from typer.core import _split_opt from typer.main import solve_typer_info_defaults, solve_typer_info_help -from typer.models import ParameterInfo, TyperInfo +from typer.models import DictParamType, ParameterInfo, TyperInfo from typer.testing import CliRunner from .utils import requires_completion_permission @@ -278,3 +279,19 @@ def test_split_opt(): prefix, opt = _split_opt("verbose") assert prefix == "" assert opt == "verbose" + + +def test_json_param_type_convert(): + data = {"name": "Camila", "age": 15, "height_meters": 1.7, "female": True} + converted = DictParamType().convert(json.dumps(data), None, None) + assert data == converted + + +def test_json_param_type_convert_dict_input(): + data = {"name": "Camila", "age": 15, "height_meters": 1.7, "female": True} + converted = DictParamType().convert(data, None, None) + assert data == converted + + +def test_dict_param_tyoe_name(): + assert repr(DictParamType()) == "DICT" diff --git a/tests/test_tutorial/test_parameter_types/test_dict/__init__.py b/tests/test_tutorial/test_parameter_types/test_dict/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/test_tutorial/test_parameter_types/test_dict/test_tutorial001.py b/tests/test_tutorial/test_parameter_types/test_dict/test_tutorial001.py new file mode 100644 index 0000000000..e91dc73d19 --- /dev/null +++ b/tests/test_tutorial/test_parameter_types/test_dict/test_tutorial001.py @@ -0,0 +1,49 @@ +import json +import subprocess +import sys + +import typer +from typer.testing import CliRunner + +from docs_src.parameter_types.dict import tutorial001 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 "--user-info" in result.output + assert "DICT" in result.output + + +def test_params(): + data = {"name": "Camila", "age": 15, "height": 1.7, "female": True} + result = runner.invoke( + app, + [ + "--user-info", + json.dumps(data), + ], + ) + assert result.exit_code == 0 + assert "Name: Camila" in result.output + assert "User attributes: ['age', 'female', 'height', 'name']" in result.output + + +def test_invalid(): + result = runner.invoke(app, ["--user-info", "Camila"]) + assert result.exit_code != 0 + assert "Expecting value: line 1 column 1 (char 0)" in result.exc_info[1].args[0] + + +def test_script(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, "--help"], + capture_output=True, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_tutorial/test_parameter_types/test_dict/test_tutorial001_an.py b/tests/test_tutorial/test_parameter_types/test_dict/test_tutorial001_an.py new file mode 100644 index 0000000000..8e7e06a719 --- /dev/null +++ b/tests/test_tutorial/test_parameter_types/test_dict/test_tutorial001_an.py @@ -0,0 +1,49 @@ +import json +import subprocess +import sys + +import typer +from typer.testing import CliRunner + +from docs_src.parameter_types.dict import tutorial001_an 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 "--user-info" in result.output + assert "DICT" in result.output + + +def test_params(): + data = {"name": "Camila", "age": 15, "height": 1.7, "female": True} + result = runner.invoke( + app, + [ + "--user-info", + json.dumps(data), + ], + ) + assert result.exit_code == 0 + assert "Name: Camila" in result.output + assert "User attributes: ['age', 'female', 'height', 'name']" in result.output + + +def test_invalid(): + result = runner.invoke(app, ["--user-info", "Camila"]) + assert result.exit_code != 0 + assert "Expecting value: line 1 column 1 (char 0)" in result.exc_info[1].args[0] + + +def test_script(): + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, "--help"], + capture_output=True, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/typer/main.py b/typer/main.py index 36737e49ef..e746c27b5a 100644 --- a/typer/main.py +++ b/typer/main.py @@ -35,6 +35,7 @@ Default, DefaultPlaceholder, DeveloperExceptionConfig, + DictParamType, FileBinaryRead, FileBinaryWrite, FileText, @@ -710,8 +711,10 @@ def get_click_type( elif parameter_info.parser is not None: return click.types.FuncParamType(parameter_info.parser) - elif annotation is str: + elif annotation in [str, bytes]: return click.STRING + elif annotation is dict: + return DictParamType() elif annotation is int: if parameter_info.min is not None or parameter_info.max is not None: min_ = None diff --git a/typer/models.py b/typer/models.py index 544e504761..37e5933a60 100644 --- a/typer/models.py +++ b/typer/models.py @@ -1,5 +1,6 @@ import inspect import io +import json from typing import ( TYPE_CHECKING, Any, @@ -20,7 +21,6 @@ from .core import TyperCommand, TyperGroup from .main import Typer - NoneType = type(None) AnyType = Type[Any] @@ -52,6 +52,23 @@ class CallbackParam(click.Parameter): pass +class DictParamType(click.ParamType): + name = "dict" + + def convert( + self, + value: Any, + param: Optional["click.Parameter"], + ctx: Optional["click.Context"], + ) -> Any: + if isinstance(value, dict): + return value + return json.loads(value) + + def __repr__(self) -> str: + return "DICT" + + class DefaultPlaceholder: """ You shouldn't use this class directly.