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"" +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"" + + class ConfigOverrideEnv: """Override configuration layer for testing.""" 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), ""), + # instance + (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 " + ) + + class TestConfigObjEnv: def test_basic(self): class Namespace: