Skip to content
Open
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
6 changes: 5 additions & 1 deletion confuse/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 5 additions & 3 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions test/test_valid.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
Loading