From bf8f3491785064bab34a134dcd775599f117f259 Mon Sep 17 00:00:00 2001 From: dzcode <9089037+dzcode@users.noreply.github.com> Date: Mon, 2 May 2022 15:07:40 -0600 Subject: [PATCH 1/7] Add EnumChoice type --- src/click/types.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/click/types.py b/src/click/types.py index b45ee53d0a..9c219c056b 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -1,3 +1,4 @@ +import enum import os import stat import typing as t @@ -329,6 +330,49 @@ def shell_complete( return [CompletionItem(c) for c in matched] +class EnumChoice(Choice): + """The choice type allows a value to be checked against a fixed set + of supported values. All of these values have to be strings. + + You should only pass a list or tuple of choices. Other iterables + (like generators) may lead to surprising results. + + The resulting value will always be one of the originally passed choices + regardless of ``case_sensitive`` or any ``ctx.token_normalize_func`` + being specified. + + See :ref:`choice-opts` for an example. + + :param case_sensitive: Set to false to make choices case + insensitive. Defaults to true. + """ + + name = "enum_choice" + + def __init__( + self, + choices: t.Type[enum.Enum], + case_sensitive: bool = True, + use_value: bool = False, + ) -> None: + self.use_value: bool = use_value + if self.use_value: + self.choice_to_enum = {str(enum.value): enum for enum in choices} + else: + self.choice_to_enum = {str(enum.name): enum for enum in choices} + self.choices = list(self.choice_to_enum) + self.case_sensitive = case_sensitive + + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: + normed_value = super().convert(value, param, ctx) + return self.choice_to_enum[normed_value] + + def __repr__(self) -> str: + return f"EnumChoice({list(self.choices)})" + + class DateTime(ParamType): """The DateTime type converts date strings into `datetime` objects. From c25ff23d18cc4f39b96e573ade8ca9e183010491 Mon Sep 17 00:00:00 2001 From: dzcode <9089037+dzcode@users.noreply.github.com> Date: Mon, 2 May 2022 15:07:58 -0600 Subject: [PATCH 2/7] Expose EnumChoice publically --- src/click/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/click/__init__.py b/src/click/__init__.py index a6e9799729..716353f3e7 100644 --- a/src/click/__init__.py +++ b/src/click/__init__.py @@ -52,6 +52,7 @@ from .types import BOOL as BOOL from .types import Choice as Choice from .types import DateTime as DateTime +from .types import EnumChoice as EnumChoice from .types import File as File from .types import FLOAT as FLOAT from .types import FloatRange as FloatRange From fb10b56f5239c96c5d465aa0d50c077b861fbdd8 Mon Sep 17 00:00:00 2001 From: dzcode <9089037+dzcode@users.noreply.github.com> Date: Mon, 2 May 2022 15:08:16 -0600 Subject: [PATCH 3/7] Add tests for EnumChoice --- tests/test_basic.py | 85 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/tests/test_basic.py b/tests/test_basic.py index d68b96299b..c0e05ae10f 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,4 +1,5 @@ import os +from enum import Enum from itertools import chain import pytest @@ -403,6 +404,90 @@ def cli(method): assert "{foo|bar|baz}" in result.output +def test_enum_choice_argument(runner): + class MockEnum(Enum): + foo = 1 + bar = 2 + baz = 3 + + @click.command() + @click.argument("method", type=click.EnumChoice(MockEnum)) + def cli(method): + click.echo(method) + + result = runner.invoke(cli, ["foo"]) + assert not result.exception + assert result.output == "MockEnum.foo\n" + + result = runner.invoke(cli, ["meh"]) + assert result.exit_code == 2 + assert ( + "Invalid value for '{foo|bar|baz}': 'meh' is not one of 'foo'," + " 'bar', 'baz'." in result.output + ) + + result = runner.invoke(cli, ["--help"]) + assert "{foo|bar|baz}" in result.output + + +def test_enum_choice_argument_using_values(runner): + class MockEnum(Enum): + foo = 1 + bar = 2 + baz = 3 + + @click.command() + @click.argument("method", type=click.EnumChoice(MockEnum, use_value=True)) + def cli(method): + click.echo(method) + + result = runner.invoke(cli, ["1"]) + assert not result.exception + assert result.output == "MockEnum.foo\n" + + result = runner.invoke(cli, ["4"]) + assert result.exit_code == 2 + assert ( + "Invalid value for '{1|2|3}': '4' is not one of '1'," + " '2', '3'." in result.output + ) + + result = runner.invoke(cli, ["--help"]) + assert "{1|2|3}" in result.output + + +def test_enum_choice_argument_case_insensitive(runner): + class MockEnum(Enum): + foo = 1 + bar = 2 + baz = 3 + + @click.command() + @click.argument("method", type=click.EnumChoice(MockEnum, case_sensitive=False)) + def cli(method): + click.echo(method) + + result = runner.invoke(cli, ["FOO"]) + assert not result.exception + assert result.output == "MockEnum.foo\n" + + +def test_enum_choice_argument_case_insensitive_values(runner): + class MockEnum(Enum): + foo = "foo" + bar = "bar" + baz = "baz" + + @click.command() + @click.argument("method", type=click.EnumChoice(MockEnum, case_sensitive=False)) + def cli(method): + click.echo(method) + + result = runner.invoke(cli, ["FOO"]) + assert not result.exception + assert result.output == "MockEnum.foo\n" + + def test_datetime_option_default(runner): @click.command() @click.option("--start_date", type=click.DateTime()) From 8ced4f16fb4b4c9d04408fe607e4b12b4feaf9f4 Mon Sep 17 00:00:00 2001 From: dzcode <9089037+dzcode@users.noreply.github.com> Date: Mon, 2 May 2022 15:12:16 -0600 Subject: [PATCH 4/7] Update CHANGES.rst --- CHANGES.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 3893438d5c..bc1400876d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,10 @@ Version 8.2.0 Unreleased +- Add ``EnumChoice`` ``ParamType`` that can accept an ``enum.Enum`` type + and use its keys or values as choices that map back to the ``Enum``. + :issue:`605` + Version 8.1.3 ------------- From c6f2eef6f64210fc13495f02e374e3ad91cf04da Mon Sep 17 00:00:00 2001 From: dzcode <9089037+dzcode@users.noreply.github.com> Date: Mon, 2 May 2022 15:38:22 -0600 Subject: [PATCH 5/7] add docs --- docs/options.rst | 79 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/docs/options.rst b/docs/options.rst index 5c23badba4..10a995eedd 100644 --- a/docs/options.rst +++ b/docs/options.rst @@ -414,6 +414,85 @@ Choices should be unique after considering the effects of .. _option-prompting: +.. _enum-choice-opts: + +Enum Choice Options +-------------- + +Similar to the :class:`Choice` type, the :class:`EnumChoice` type may +be used to have the parameter be a choice of :class:`enum.Enum` names or values +that may to `enum.Enum` objects when parsed. It can be instantiated +with an `enum.Enum` class. The enum object corresponding to the passed name +or value (depending on settings) will be returned. + +Example: + +.. click:example:: + + class MockEnum(Enum): + FOO = "foo" + BAR = "bar" + BAZ = "baz" + + @click.command() + @click.option('--foo-opt', + type=click.EnumChoice(MockEnum) + def handle_foo(foo_opt): + click.echo(foo_opt) + +What it looks like: + +.. click:run:: + + invoke(handle_foo, args=['--foo-opt=FOO']) + println() + invoke(handle_foo, args=['--foo-opt=BAR']) + println() + invoke(handle_foo, args=['--foo-opt=foo']) + println() + invoke(handle_foo, args=['--foo-opt=other']) + println() + invoke(handle_foo, args=['--help']) + +Using the ``use_value`` parameter will cause the parser to use the enum +values as choices instead of the names. All enum values must be unique. Example: + +.. click:example:: + + class MockEnum(Enum): + FOO = "foo" + BAR = "bar" + BAZ = "baz" + + @click.command() + @click.option('--foo-opt', + type=click.EnumChoice(MockEnum, use_value=True) + def handle_foo(foo_opt): + click.echo(foo_opt) + +What it looks like: + +.. click:run:: + + invoke(handle_foo, args=['--foo-opt=foo']) + println() + invoke(handle_foo, args=['--foo-opt=bar']) + println() + invoke(handle_foo, args=['--foo-opt=FOO']) + println() + invoke(handle_foo, args=['--foo-opt=other']) + println() + invoke(handle_foo, args=['--help']) + +Choices work with options that have ``multiple=True``. If a ``default`` +value is given with ``multiple=True``, it should be a list or tuple of +valid choices. + +The ``case_sensitive`` parameter works identically to the :class:`Choice` type, +operating whatever the choices derived from the enum are. + +.. versionadded:: 8.2 + Prompting --------- From 9990ed83dfd1a2782d9f134b89569e11f0bd5943 Mon Sep 17 00:00:00 2001 From: dzcode <9089037+dzcode@users.noreply.github.com> Date: Mon, 2 May 2022 15:39:44 -0600 Subject: [PATCH 6/7] Ensure unique values --- src/click/types.py | 31 ++++++++++++++++++++++--------- tests/test_basic.py | 23 +++++++++++++++++++++++ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/src/click/types.py b/src/click/types.py index 9c219c056b..2b770cd2ff 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -331,20 +331,27 @@ def shell_complete( class EnumChoice(Choice): - """The choice type allows a value to be checked against a fixed set - of supported values. All of these values have to be strings. + """The :class:`EnumChoice` type allows a value to be checked against either the + keys or the values of an :class:`~enum.Enum`. - You should only pass a list or tuple of choices. Other iterables - (like generators) may lead to surprising results. + If using :param:`use_value` is `True`, all enum values should be unique. + The inputted value will then be converted to the corresponding enum object. - The resulting value will always be one of the originally passed choices - regardless of ``case_sensitive`` or any ``ctx.token_normalize_func`` - being specified. + The resulting value will be an instance of the Enum class corresponding + to the user's key or value passed in. See :ref:`choice-opts` for an example. - :param case_sensitive: Set to false to make choices case - insensitive. Defaults to true. + :param choices: `enum.Enum` type that will be used to create CLI choices. + :param case_sensitive: Set to `False` to make choices case + insensitive. Defaults to `True`. + :param use_value: Set to `True` to use enum values as choices instead + of enum keys. Defaults to `False`. + + :raises ValueError: Raised when `use_value` is True but enum values are not + unique. + + .. versionadded:: 8.2 """ name = "enum_choice" @@ -357,6 +364,12 @@ def __init__( ) -> None: self.use_value: bool = use_value if self.use_value: + try: + enum.unique(choices) + except ValueError as err: + raise ValueError( + "All values in `choices` must be unique if `use_value` is `True`" + ) from err self.choice_to_enum = {str(enum.value): enum for enum in choices} else: self.choice_to_enum = {str(enum.name): enum for enum in choices} diff --git a/tests/test_basic.py b/tests/test_basic.py index c0e05ae10f..1aab0285f8 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -488,6 +488,29 @@ def cli(method): assert result.output == "MockEnum.foo\n" +def test_enum_choice_argument_non_unique_values(runner): + class MockEnum(Enum): + foo = 1 + bar = 2 + baz = 2 + + with pytest.raises(ValueError): + + @click.command() + @click.argument("method", type=click.EnumChoice(MockEnum, use_value=True)) + def bad_cli(method): + click.echo(method) + + @click.command() + @click.argument("method", type=click.EnumChoice(MockEnum, use_value=False)) + def cli(method): + click.echo(method) + + result = runner.invoke(cli, ["foo"]) + assert not result.exception, "Values do not need to be unique if using enum names" + assert result.output == "MockEnum.foo\n" + + def test_datetime_option_default(runner): @click.command() @click.option("--start_date", type=click.DateTime()) From 2a7997fc9441eaf90505801c37f8952cf232ab68 Mon Sep 17 00:00:00 2001 From: dzcode <9089037+dzcode@users.noreply.github.com> Date: Mon, 2 May 2022 15:42:39 -0600 Subject: [PATCH 7/7] add init --- src/click/types.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/click/types.py b/src/click/types.py index 2b770cd2ff..422115e288 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -373,14 +373,16 @@ def __init__( self.choice_to_enum = {str(enum.value): enum for enum in choices} else: self.choice_to_enum = {str(enum.name): enum for enum in choices} - self.choices = list(self.choice_to_enum) - self.case_sensitive = case_sensitive + super().__init__( + choices=list(self.choice_to_enum), + case_sensitive=case_sensitive, + ) def convert( self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] ) -> t.Any: normed_value = super().convert(value, param, ctx) - return self.choice_to_enum[normed_value] + return self.choice_to_enum.get(normed_value) def __repr__(self) -> str: return f"EnumChoice({list(self.choices)})"