Skip to content

Commit a0abf58

Browse files
committed
Add support for ChoiceOf (#253)
1 parent 3e4f15a commit a0abf58

File tree

4 files changed

+103
-0
lines changed

4 files changed

+103
-0
lines changed

HISTORY.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ Fixes and features:
1515
* Add support for underscore as first character in variable names in env files.
1616
(#263)
1717

18+
* Add ``ChoiceOf`` parser for enforcing configuration values belong in
19+
specified value domain. (#253)
20+
1821

1922
3.3.0 (November 6th, 2023)
2023
--------------------------

docs/parsers.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,16 @@ parses a list of some other type. For example::
113113
:noindex:
114114

115115

116+
ChoiceOf(parser, list-of-choices)
117+
---------------------------------
118+
119+
Everett provides a ``everett.manager.ChoiceOf`` parser which can enforce that
120+
configuration values belong to a specificed value domain.
121+
122+
.. autofunction:: everett.manager.ChoiceOf
123+
:noindex:
124+
125+
116126
dj_database_url
117127
---------------
118128

src/everett/manager.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636

3737

3838
__all__ = [
39+
"ChoiceOf",
3940
"ConfigDictEnv",
4041
"ConfigEnvFileEnv",
4142
"ConfigManager",
@@ -574,6 +575,52 @@ def __repr__(self) -> str:
574575
return f"<ListOf({qualname(self.sub_parser)})>"
575576

576577

578+
class ChoiceOf:
579+
"""Parser that enforces values are in a specified value domain.
580+
581+
Choices can be a list of string values that are parseable by the sub
582+
parser. For example, say you only supported two cloud providers and need
583+
the configuration value to be one of "aws" or "gcp":
584+
585+
>>> from everett.manager import ChoiceOf
586+
>>> ChoiceOf(str, choices=["aws", "gcp"])("aws")
587+
'aws'
588+
589+
Choices works with the int sub-parser:
590+
591+
>>> from everett.manager import ChoiceOf
592+
>>> ChoiceOf(int, choices=["1", "2", "3"])("1")
593+
1
594+
595+
Choices works with any sub-parser:
596+
597+
>>> from everett.manager import ChoiceOf, parse_data_size
598+
>>> ChoiceOf(parse_data_size, choices=["1kb", "1mb", "1gb"])("1mb")
599+
1000000
600+
601+
Note: The choices list is a list of strings--these are values before being
602+
parsed. This makes it easier for people who are doing configuration to know
603+
what the values they put in their configuration files need to look like.
604+
605+
"""
606+
607+
def __init__(self, parser: Callable, choices: list[str]):
608+
self.sub_parser = parser
609+
if not choices or not all(isinstance(choice, str) for choice in choices):
610+
raise ValueError(f"choices {choices!r} must be a non-empty list of strings")
611+
612+
self.choices = choices
613+
614+
def __call__(self, value: str) -> Any:
615+
parser = get_parser(self.sub_parser)
616+
if value and value in self.choices:
617+
return parser(value)
618+
raise ValueError(f"{value!r} is not a valid choice")
619+
620+
def __repr__(self) -> str:
621+
return f"<ChoiceOf({qualname(self.sub_parser)}, {self.choices})>"
622+
623+
577624
class ConfigOverrideEnv:
578625
"""Override configuration layer for testing."""
579626

tests/test_manager.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
ConfigManager,
2121
ConfigOSEnv,
2222
ConfigObjEnv,
23+
ChoiceOf,
2324
ListOf,
2425
Option,
2526
config_override,
@@ -53,6 +54,8 @@
5354
(ConfigManager.basic_config, "everett.manager.ConfigManager.basic_config"),
5455
# instance
5556
(ListOf(bool), "<ListOf(bool)>"),
57+
# instance
58+
(ChoiceOf(int, ["1", "10", "100"]), "<ChoiceOf(int, ['1', '10', '100'])>"),
5659
# instance method
5760
(ConfigOSEnv().get, "everett.manager.ConfigOSEnv.get"),
5861
],
@@ -289,6 +292,46 @@ def test_ListOf_error():
289292
)
290293

291294

295+
def test_ChoiceOf():
296+
# Supports any choice
297+
assert ChoiceOf(str, ["a", "b", "c"])("a") == "a"
298+
assert ChoiceOf(str, ["a", "b", "c"])("b") == "b"
299+
assert ChoiceOf(str, ["a", "b", "c"])("c") == "c"
300+
301+
# Supports different parsers
302+
assert ChoiceOf(int, ["1", "2", "3"])("1") == 1
303+
304+
305+
def test_ChoiceOf_bad_choices():
306+
# Must provide choices
307+
with pytest.raises(ValueError) as exc_info:
308+
ChoiceOf(str, [])
309+
assert str(exc_info.value) == "choices [] must be a non-empty list of strings"
310+
311+
# Must be a list of strings
312+
with pytest.raises(ValueError) as exc_info:
313+
ChoiceOf(str, [1, 2, 3])
314+
assert (
315+
str(exc_info.value) == "choices [1, 2, 3] must be a non-empty list of strings"
316+
)
317+
318+
319+
def test_ChoiceOf_error():
320+
# Value is the wrong case
321+
with pytest.raises(ValueError) as exc_info:
322+
ChoiceOf(str, ["A", "B", "C"])("c")
323+
assert str(exc_info.value) == "'c' is not a valid choice"
324+
325+
# Value isn't a valid choice
326+
config = ConfigManager.from_dict({"cloud_provider": "foo"})
327+
with pytest.raises(InvalidValueError) as exc_info:
328+
config("cloud_provider", parser=ChoiceOf(str, ["aws", "gcp"]))
329+
assert str(exc_info.value) == (
330+
"ValueError: 'foo' is not a valid choice\n"
331+
"CLOUD_PROVIDER requires a value parseable by <ChoiceOf(str, ['aws', 'gcp'])>"
332+
)
333+
334+
292335
class TestConfigObjEnv:
293336
def test_basic(self):
294337
class Namespace:

0 commit comments

Comments
 (0)