Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for ChoiceOf (#253) #265

Merged
merged 1 commit into from
Oct 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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