diff --git a/confuse/templates.py b/confuse/templates.py index 0281d30..4ac317f 100644 --- a/confuse/templates.py +++ b/confuse/templates.py @@ -355,7 +355,11 @@ def _(self, choices: type[T], value: str) -> T: @convert_choices.register(dict) def _(self, choices: dict[K, T], value: K) -> T: - return choices[value] + try: + return choices[value] + except TypeError as exc: + # Normalize invalid lookups for Choice's error handling. + raise ValueError(value) from exc @convert_choices.register(abc.Sequence) def _(self, choices: abc.Sequence[T], value: T) -> T: diff --git a/docs/changelog.rst b/docs/changelog.rst index 680346e..684c670 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -13,9 +13,11 @@ Unreleased New features ~~~~~~~~~~~~ -.. - Bug fixes - ~~~~~~~~~ +Bug fixes +~~~~~~~~~ + +- Report unhashable values passed to dictionary-backed ``Choice`` templates as + configuration errors, allowing ``OneOf`` to try its remaining templates. .. For plugin developers diff --git a/test/test_valid.py b/test/test_valid.py index 1b5be61..ea9eb5f 100644 --- a/test/test_valid.py +++ b/test/test_valid.py @@ -257,6 +257,73 @@ def test_validate_bad_choice_in_dict(self): with pytest.raises(confuse.ConfigValueError): config["foo"].get(confuse.Choice({2: "two", 4: "four"})) + def test_unhashable_value_in_dict(self): + for value in (["red"], {"red": 1}, {"red"}): + with self.subTest(value=value): + config = _root({"foo": value}) + with pytest.raises(confuse.ConfigValueError, match="foo:"): + config["foo"].get(confuse.Choice({"red": 1})) + + def test_tuple_key_in_dict(self): + config = _root({"foo": ("red", "green")}) + assert config["foo"].get(confuse.Choice({("red", "green"): "mixed"})) == "mixed" + + def test_custom_hashable_key_in_dict(self): + class Key: + def __hash__(self): + return 123 + + def __eq__(self, other): + return isinstance(other, Key) + + config = _root({"foo": Key()}) + assert config["foo"].get(confuse.Choice({Key(): "custom"})) == "custom" + + def test_dict_subclass_missing_value(self): + class Defaults(dict[str, int | str]): + def __missing__(self, key): + return "defaulted" + + config = _root({"foo": "blue"}) + assert config["foo"].get(confuse.Choice(Defaults(red=1))) == "defaulted" + + def test_dict_subclass_unhashable_lookup(self): + class ListAware(dict[str, int | str]): + def __getitem__(self, key): + if isinstance(key, list): + return "list accepted" + return super().__getitem__(key) + + config = _root({"foo": ["red"]}) + assert config["foo"].get(confuse.Choice(ListAware(red=1))) == "list accepted" + + def test_sequence_conversion_type_error_propagates(self): + class BrokenSequence(list[str]): + def index(self, value, *args): + raise TypeError("sequence conversion failed") + + config = _root({"foo": "red"}) + choice = confuse.Choice(BrokenSequence(["red"])) + with pytest.raises(TypeError, match="sequence conversion failed"): + config["foo"].get(choice) + with pytest.raises(TypeError, match="sequence conversion failed"): + config["foo"].get(confuse.OneOf[str]([choice, str])) + + def test_enum_conversion_type_error_propagates(self): + class BrokenEnum(enum.Enum): + BLUE = "blue" + + @classmethod + def _missing_(cls, value): + raise TypeError("enum conversion failed") + + config = _root({"foo": "red"}) + choice = confuse.Choice(BrokenEnum) + with pytest.raises(TypeError, match="enum conversion failed"): + config["foo"].get(choice) + with pytest.raises(TypeError, match="enum conversion failed"): + config["foo"].get(confuse.OneOf[BrokenEnum | str]([choice, str])) + class OneOfTest(unittest.TestCase): def test_default_value(self): @@ -309,6 +376,33 @@ class BadTemplate: config.get(confuse.OneOf([BadTemplate()])) del BadTemplate + def test_dict_choice_falls_back_to_sequence(self): + config = _root({"foo": ["red"]}) + template = confuse.OneOf[int | list[str]]( + [confuse.Choice({"red": 1}), confuse.Sequence(str)] + ) + assert config["foo"].get(template) == ["red"] + + def test_dict_choice_falls_back_to_mapping(self): + config = _root({"foo": {"red": 1}}) + template = confuse.OneOf[int | dict[str, int]]( + [confuse.Choice({"red": 1}), confuse.MappingValues(int)] + ) + assert config["foo"].get(template) == {"red": 1} + + def test_dict_choice_fallback_in_mapping_template(self): + config = _root({"foo": ["red"]}) + valid = config.get( + {"foo": confuse.OneOf([confuse.Choice({"red": 1}), confuse.Sequence(str)])} + ) + assert valid["foo"] == ["red"] + + def test_unhashable_dict_choice_without_valid_fallback(self): + config = _root({"foo": ["red"]}) + template = confuse.OneOf[int]([confuse.Choice({"red": 1}), confuse.Integer()]) + with pytest.raises(confuse.ConfigValueError, match="foo:"): + config["foo"].get(template) + class StrSeqTest(unittest.TestCase): def test_string_list(self):