Skip to content

Commit

Permalink
Add support for ChoiceOf (#253)
Browse files Browse the repository at this point in the history
  • Loading branch information
willkg committed Oct 30, 2024
1 parent 3e4f15a commit a0abf58
Show file tree
Hide file tree
Showing 4 changed files with 103 additions and 0 deletions.
3 changes: 3 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
--------------------------
Expand Down
10 changes: 10 additions & 0 deletions docs/parsers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
---------------

Expand Down
47 changes: 47 additions & 0 deletions src/everett/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@


__all__ = [
"ChoiceOf",
"ConfigDictEnv",
"ConfigEnvFileEnv",
"ConfigManager",
Expand Down Expand Up @@ -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."""

Expand Down
43 changes: 43 additions & 0 deletions tests/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
ConfigManager,
ConfigOSEnv,
ConfigObjEnv,
ChoiceOf,
ListOf,
Option,
config_override,
Expand Down Expand Up @@ -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"),
],
Expand Down Expand Up @@ -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:
Expand Down

0 comments on commit a0abf58

Please sign in to comment.