From eac5c13de1cf6abccba76b59945993acfccd3164 Mon Sep 17 00:00:00 2001 From: Will Kahn-Greene Date: Wed, 30 Oct 2024 10:20:59 -0400 Subject: [PATCH] Support enforcing nonempty values in ListOf (#268) This adds empty value validation to ListOf. --- src/everett/manager.py | 48 ++++++++++++++++++++++++++++++++++------- tests/test_manager.py | 30 +++++++++++++++++++------- tests/test_sphinxext.py | 2 +- 3 files changed, 63 insertions(+), 17 deletions(-) diff --git a/src/everett/manager.py b/src/everett/manager.py index 209d51a..5c3ee26 100644 --- a/src/everett/manager.py +++ b/src/everett/manager.py @@ -40,18 +40,18 @@ "ConfigDictEnv", "ConfigEnvFileEnv", "ConfigManager", - "ConfigOSEnv", "ConfigObjEnv", - "ListOf", - "Option", + "ConfigOSEnv", "config_override", "get_config_for_class", "get_runtime_config", + "ListOf", + "Option", "parse_bool", "parse_class", "parse_data_size", - "parse_time_period", "parse_env_file", + "parse_time_period", ] @@ -534,7 +534,7 @@ def get_key_from_envs(envs: Iterable[Any], key: str) -> Union[str, NoValue]: class ListOf: - """Parse a comma-separated list of things. + """Parse a delimiter-separated list of things. After delimiting items, this strips the whitespace at the beginning and end of each string. Then it passes each string into the parser to get the final @@ -550,6 +550,25 @@ class ListOf: >>> ListOf(str)('1, 2 ,3,4') ['1', '2', '3', '4'] + ``ListOf`` defaults to using a comma as a delimiter, but supports other + delimiters: + + >>> ListOf(str, delimiter=":")("/path/a/:/path/b/") + ['/path/a/', '/path/b/'] + + ``ListOf`` supports raising a configuration error when one of the values + is an empty string: + + >>> ListOf(str, allow_empty=False)("a,,b") + Traceback (most recent call last): + ... + ValueError: 'a,,b' can not have empty values + + The user will get a configuration error like this:: + + ValueError: 'a,,b' can not have empty values + NAMES requires a value parseable by + Note: This doesn't handle quotes or backslashes or any complicated string parsing. @@ -560,19 +579,32 @@ class ListOf: """ - def __init__(self, parser: Callable, delimiter: str = ","): + def __init__( + self, parser: Callable, delimiter: str = ",", allow_empty: bool = True + ): self.sub_parser = parser self.delimiter = delimiter + self.allow_empty = allow_empty def __call__(self, value: str) -> list[Any]: parser = get_parser(self.sub_parser) if value: - return [parser(token.strip()) for token in value.split(self.delimiter)] + parsed_values = [] + for token in value.split(self.delimiter): + token = token.strip() + if not token and not self.allow_empty: + raise ValueError(f"{value!r} can not have empty values") + parsed_values.append(parser(token)) + return parsed_values else: return [] def __repr__(self) -> str: - return f"" + return ( + f"" + ) class ChoiceOf: diff --git a/tests/test_manager.py b/tests/test_manager.py index 5533589..105b752 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -9,20 +9,18 @@ from everett import ( ConfigurationError, - InvalidValueError, ConfigurationMissingError, + InvalidValueError, NO_VALUE, ) import everett.manager from everett.manager import ( + ChoiceOf, ConfigDictEnv, ConfigEnvFileEnv, ConfigManager, - ConfigOSEnv, ConfigObjEnv, - ChoiceOf, - ListOf, - Option, + ConfigOSEnv, config_override, generate_uppercase_key, get_config_for_class, @@ -30,11 +28,13 @@ get_parser, get_runtime_config, listify, + ListOf, + Option, parse_bool, parse_class, parse_data_size, - parse_time_period, parse_env_file, + parse_time_period, qualname, ) @@ -53,7 +53,7 @@ # class method (ConfigManager.basic_config, "everett.manager.ConfigManager.basic_config"), # instance - (ListOf(bool), ""), + (ListOf(bool), ""), # instance (ChoiceOf(int, ["1", "10", "100"]), ""), # instance method @@ -279,6 +279,7 @@ def test_ListOf(): assert ListOf(int)("1,2,3") == [1, 2, 3] assert ListOf(str)("1 , 2, 3") == ["1", "2", "3"] assert ListOf(int, delimiter=":")("1:2") == [1, 2] + assert ListOf(str)("a,,b") == ["a", "", "b"] def test_ListOf_error(): @@ -288,7 +289,20 @@ def test_ListOf_error(): assert str(exc_info.value) == ( "ValueError: 'badbool' is not a valid bool value\n" - "BOOLS requires a value parseable by " + "BOOLS requires a value parseable by " + "" + ) + + +def test_ListOf_allow_empty_error(): + config = ConfigManager.from_dict({"names": "bob,,alice"}) + with pytest.raises(InvalidValueError) as exc_info: + config("names", parser=ListOf(str, allow_empty=False)) + + assert str(exc_info.value) == ( + "ValueError: 'bob,,alice' can not have empty values\n" + "NAMES requires a value parseable by " + "" ) diff --git a/tests/test_sphinxext.py b/tests/test_sphinxext.py index 8849133..c22d7cd 100644 --- a/tests/test_sphinxext.py +++ b/tests/test_sphinxext.py @@ -520,7 +520,7 @@ def test_option_parser(self, tmpdir, capsys): user_listof Parser: - ** + ** Required: Yes