Repository: willkg/everett (language: python)

Issue:
consider a choices parser
I've had several situations where I want a parser that requires the value to be one of a list of possible values. I'd like to do something like:

```python
from everett.manager import ConfigManager

config = ConfigManager.basic_config()

CLOUD_PROVIDER = config(
    "CLOUD_PROVIDER",
    default="AWS",
    parser=OneOf(["AWS", "GCP"]),
)
```

and then have that work for "AWS" and "GCP" and raise a`ValueError` specifying one of the valid values for everything else.




Discussion / hints:
I rethought the API. I want it to support different sub-parsers. Given that, I thought it should mirror `ListOf`. Examples:

```python
>>> from everett.manager import ChoiceOf, parse_data_size
>>> ChoiceOf(str, choices=["aws", "gcp"])("aws")
"aws"
 >>> ChoiceOf(int, choices=["1", "2", "3"])("1")
1
 >>> ChoiceOf(parse_data_size, choices=["1kb", "1mb", "1gb"])("1mb")
1000000
```

That gives a lot of flexibility. It forces values to be consistent in the places they show up that the user sees them (documentation, error messages, etc). It also allows the parsed value to be convenient for programming.

Fix patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index f0c5648..9c70ee6 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -15,6 +15,9 @@ Fixes and features:
 * Add support for underscore as first character in variable names in env files.
   (#263)
 
+* Add ``ChoiceOf`` parser for enforcing configuration values belong in
+  specified value domain. (#253)
+
 
 3.3.0 (November 6th, 2023)
 --------------------------
diff --git a/docs/parsers.rst b/docs/parsers.rst
index bf062bf..7234044 100644
--- a/docs/parsers.rst
+++ b/docs/parsers.rst
@@ -113,6 +113,16 @@ parses a list of some other type. For example::
    :noindex:
 
 
+ChoiceOf(parser, list-of-choices)
+---------------------------------
+
+Everett provides a ``everett.manager.ChoiceOf`` parser which can enforce that
+configuration values belong to a specificed value domain.
+
+.. autofunction:: everett.manager.ChoiceOf
+   :noindex:
+
+
 dj_database_url
 ---------------
 
diff --git a/src/everett/manager.py b/src/everett/manager.py
index dac4b48..209d51a 100644
--- a/src/everett/manager.py
+++ b/src/everett/manager.py
@@ -36,6 +36,7 @@
 
 
 __all__ = [
+    "ChoiceOf",
     "ConfigDictEnv",
     "ConfigEnvFileEnv",
     "ConfigManager",
@@ -574,6 +575,52 @@ def __repr__(self) -> str:
         return f"<ListOf({qualname(self.sub_parser)})>"
 
 
+class ChoiceOf:
+    """Parser that enforces values are in a specified value domain.
+
+    Choices can be a list of string values that are parseable by the sub
+    parser. For example, say you only supported two cloud providers and need
+    the configuration value to be one of "aws" or "gcp":
+
+    >>> from everett.manager import ChoiceOf
+    >>> ChoiceOf(str, choices=["aws", "gcp"])("aws")
+    'aws'
+
+    Choices works with the int sub-parser:
+
+    >>> from everett.manager import ChoiceOf
+    >>> ChoiceOf(int, choices=["1", "2", "3"])("1")
+    1
+
+    Choices works with any sub-parser:
+
+    >>> from everett.manager import ChoiceOf, parse_data_size
+    >>> ChoiceOf(parse_data_size, choices=["1kb", "1mb", "1gb"])("1mb")
+    1000000
+
+    Note: The choices list is a list of strings--these are values before being
+    parsed. This makes it easier for people who are doing configuration to know
+    what the values they put in their configuration files need to look like.
+
+    """
+
+    def __init__(self, parser: Callable, choices: list[str]):
+        self.sub_parser = parser
+        if not choices or not all(isinstance(choice, str) for choice in choices):
+            raise ValueError(f"choices {choices!r} must be a non-empty list of strings")
+
+        self.choices = choices
+
+    def __call__(self, value: str) -> Any:
+        parser = get_parser(self.sub_parser)
+        if value and value in self.choices:
+            return parser(value)
+        raise ValueError(f"{value!r} is not a valid choice")
+
+    def __repr__(self) -> str:
+        return f"<ChoiceOf({qualname(self.sub_parser)}, {self.choices})>"
+
+
 class ConfigOverrideEnv:
     """Override configuration layer for testing."""
 


Test patch:
diff --git a/tests/test_manager.py b/tests/test_manager.py
index 8f6dd38..5533589 100644
--- a/tests/test_manager.py
+++ b/tests/test_manager.py
@@ -20,6 +20,7 @@
     ConfigManager,
     ConfigOSEnv,
     ConfigObjEnv,
+    ChoiceOf,
     ListOf,
     Option,
     config_override,
@@ -53,6 +54,8 @@
         (ConfigManager.basic_config, "everett.manager.ConfigManager.basic_config"),
         # instance
         (ListOf(bool), "<ListOf(bool)>"),
+        # instance
+        (ChoiceOf(int, ["1", "10", "100"]), "<ChoiceOf(int, ['1', '10', '100'])>"),
         # instance method
         (ConfigOSEnv().get, "everett.manager.ConfigOSEnv.get"),
     ],
@@ -289,6 +292,46 @@ def test_ListOf_error():
     )
 
 
+def test_ChoiceOf():
+    # Supports any choice
+    assert ChoiceOf(str, ["a", "b", "c"])("a") == "a"
+    assert ChoiceOf(str, ["a", "b", "c"])("b") == "b"
+    assert ChoiceOf(str, ["a", "b", "c"])("c") == "c"
+
+    # Supports different parsers
+    assert ChoiceOf(int, ["1", "2", "3"])("1") == 1
+
+
+def test_ChoiceOf_bad_choices():
+    # Must provide choices
+    with pytest.raises(ValueError) as exc_info:
+        ChoiceOf(str, [])
+    assert str(exc_info.value) == "choices [] must be a non-empty list of strings"
+
+    # Must be a list of strings
+    with pytest.raises(ValueError) as exc_info:
+        ChoiceOf(str, [1, 2, 3])
+    assert (
+        str(exc_info.value) == "choices [1, 2, 3] must be a non-empty list of strings"
+    )
+
+
+def test_ChoiceOf_error():
+    # Value is the wrong case
+    with pytest.raises(ValueError) as exc_info:
+        ChoiceOf(str, ["A", "B", "C"])("c")
+    assert str(exc_info.value) == "'c' is not a valid choice"
+
+    # Value isn't a valid choice
+    config = ConfigManager.from_dict({"cloud_provider": "foo"})
+    with pytest.raises(InvalidValueError) as exc_info:
+        config("cloud_provider", parser=ChoiceOf(str, ["aws", "gcp"]))
+    assert str(exc_info.value) == (
+        "ValueError: 'foo' is not a valid choice\n"
+        "CLOUD_PROVIDER requires a value parseable by <ChoiceOf(str, ['aws', 'gcp'])>"
+    )
+
+
 class TestConfigObjEnv:
     def test_basic(self):
         class Namespace: