Skip to content

Commit 4cef6ce

Browse files
authored
Feature: Support requiring anyOf a list of keys (#534)
* Feature: Support requiring anyOf a list of keys This adds a new feature to Voluptuous, which is somewhat akin to what json-schema does with the special key `anyOf`. `Schema({Required(Any('color', 'temperature', 'brightness')): str})` will validate that AT LEAST ONE of these three values is present. That doesn't preclude any individual validation on each of those fields to still apply. That means that in the above example, if `color` is present, brightness doesn't need to be present. But if brightness is present, all other validations of brightness (like checking that its value is a number between 0 and 100) still apply. * Simplify tests * Format stuff like black wants it
1 parent c5b63f8 commit 4cef6ce

File tree

2 files changed

+196
-0
lines changed

2 files changed

+196
-0
lines changed

voluptuous/schema_builder.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,13 @@ def _compile_mapping(self, schema, invalid_msg=None):
248248
)
249249
)
250250

251+
# Complex required keys that need special validation
252+
complex_required_keys = set(
253+
key
254+
for key in all_required_keys
255+
if isinstance(key, Required) and key.is_complex_key
256+
)
257+
251258
# Keys that may have defaults
252259
all_default_keys = set(
253260
key
@@ -300,6 +307,22 @@ def validate_mapping(path, iterable, out):
300307
key_value_map[key.schema] = key.default()
301308

302309
errors = []
310+
311+
# Check complex required keys - at least one candidate key must be present
312+
for complex_key in complex_required_keys:
313+
if not any(
314+
candidate in key_value_map
315+
for candidate in complex_key.candidate_keys
316+
):
317+
msg = (
318+
complex_key.msg
319+
if hasattr(complex_key, 'msg') and complex_key.msg
320+
else f'at least one of {complex_key.candidate_keys} is required'
321+
)
322+
errors.append(er.RequiredFieldInvalid(msg, path + [complex_key]))
323+
else:
324+
# If at least one candidate key is present, mark this complex requirement as satisfied
325+
required_keys.discard(complex_key)
303326
for key, value in key_value_map.items():
304327
key_path = path + [key]
305328
remove_key = False
@@ -1142,6 +1165,17 @@ class Required(Marker):
11421165
>>> schema = Schema({Required('key', default=list): list})
11431166
>>> schema({})
11441167
{'key': []}
1168+
1169+
Complex key validation - at least one of the specified keys must be present:
1170+
1171+
>>> from voluptuous.validators import Any
1172+
>>> schema = Schema({Required(Any('color', 'temperature', 'brightness')): str})
1173+
>>> schema({'color': 'red'}) # Valid - has color
1174+
{'color': 'red'}
1175+
>>> schema({'temperature': 'warm'}) # Valid - has temperature
1176+
{'temperature': 'warm'}
1177+
>>> schema({'color': 'blue', 'brightness': 'high'}) # Valid - has multiple
1178+
{'color': 'blue', 'brightness': 'high'}
11451179
"""
11461180

11471181
def __init__(
@@ -1153,6 +1187,31 @@ def __init__(
11531187
) -> None:
11541188
super(Required, self).__init__(schema, msg=msg, description=description)
11551189
self.default = default_factory(default)
1190+
self.is_complex_key = self._is_complex_key_validator(schema)
1191+
self.candidate_keys = (
1192+
self._extract_candidate_keys(schema) if self.is_complex_key else None
1193+
)
1194+
1195+
def _is_complex_key_validator(self, schema):
1196+
"""Check if schema is a validator that can match multiple keys."""
1197+
# Import here to avoid circular imports
1198+
from voluptuous.validators import Any
1199+
1200+
return isinstance(schema, Any)
1201+
1202+
def _extract_candidate_keys(self, schema):
1203+
"""Extract possible keys from validators like Any("key1", "key2", "key3")."""
1204+
# Import here to avoid circular imports
1205+
from voluptuous.validators import Any
1206+
1207+
if isinstance(schema, Any):
1208+
# Extract literal values (strings, ints, etc.) from Any validators
1209+
return [
1210+
v
1211+
for v in schema.validators
1212+
if isinstance(v, (str, int, float, bool, type(None)))
1213+
]
1214+
return []
11561215

11571216

11581217
class Remove(Marker):

voluptuous/tests/tests.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2037,3 +2037,140 @@ def test_humanize_error_with_none_data():
20372037

20382038
error_message = humanize_error(data, ctx.value)
20392039
assert "expected a dictionary" in error_message
2040+
2041+
2042+
def test_required_complex_key_any():
2043+
"""Test Required with Any validator for multiple possible keys"""
2044+
schema = Schema(
2045+
{Required(Any("color", "temperature", "brightness")): str, "device_id": str}
2046+
)
2047+
2048+
# Should pass - defines one of the required keys
2049+
result = schema({"color": "red", "device_id": "light1"})
2050+
assert result == {"color": "red", "device_id": "light1"}
2051+
2052+
# Should pass - defines several of the required keys
2053+
result = schema({"color": "blue", "brightness": "50%", "device_id": "light1"})
2054+
assert result == {"color": "blue", "brightness": "50%", "device_id": "light1"}
2055+
2056+
# Should fail - has none of the required keys
2057+
with pytest.raises(MultipleInvalid) as ctx:
2058+
schema({"device_id": "light1"})
2059+
2060+
error_msg = str(ctx.value)
2061+
assert (
2062+
"at least one of ['color', 'temperature', 'brightness'] is required"
2063+
in error_msg
2064+
)
2065+
2066+
2067+
def test_required_complex_key_custom_message():
2068+
"""Test Required with Any validator and custom error message"""
2069+
schema = Schema(
2070+
{
2071+
Required(
2072+
Any("color", "temperature", "brightness"),
2073+
msg="Please specify a lighting attribute",
2074+
): str,
2075+
"device_id": str,
2076+
}
2077+
)
2078+
2079+
# Should pass
2080+
schema({"color": "red", "device_id": "light1"})
2081+
2082+
# Should fail with custom message
2083+
with pytest.raises(MultipleInvalid) as ctx:
2084+
schema({"device_id": "light1"})
2085+
2086+
error_msg = str(ctx.value)
2087+
assert "Please specify a lighting attribute" in error_msg
2088+
2089+
2090+
def test_required_complex_key_mixed_types():
2091+
"""Test Required with Any validator containing mixed key types"""
2092+
schema = Schema({Required(Any("string_key", 123, 45.6)): str, "other": int})
2093+
2094+
# Should work with string key
2095+
result = schema({"string_key": "value", "other": 1})
2096+
assert result == {"string_key": "value", "other": 1}
2097+
2098+
# Should work with int key
2099+
result = schema({123: "value", "other": 1})
2100+
assert result == {123: "value", "other": 1}
2101+
2102+
# Should work with float key
2103+
result = schema({45.6: "value", "other": 1})
2104+
assert result == {45.6: "value", "other": 1}
2105+
2106+
# Should fail with none present
2107+
with pytest.raises(MultipleInvalid) as ctx:
2108+
schema({"other": 1})
2109+
2110+
error_msg = str(ctx.value)
2111+
assert "at least one of ['string_key', 123, 45.6] is required" in error_msg
2112+
2113+
2114+
def test_required_complex_key_multiple_complex_requirements():
2115+
"""Test multiple Required complex keys in same schema"""
2116+
schema = Schema(
2117+
{
2118+
Required(Any("color", "hue")): str,
2119+
Required(Any("brightness", "intensity")): str,
2120+
"device": str,
2121+
}
2122+
)
2123+
2124+
# Should pass with one from each group
2125+
result = schema({"color": "red", "brightness": "high", "device": "light"})
2126+
assert result == {"color": "red", "brightness": "high", "device": "light"}
2127+
2128+
# Should fail if missing on any group
2129+
with pytest.raises(MultipleInvalid) as ctx:
2130+
schema({"brightness": "high", "device": "light"})
2131+
2132+
error_msg = str(ctx.value)
2133+
assert "at least one of ['color', 'hue'] is required" in error_msg
2134+
2135+
2136+
def test_required_complex_key_value_validation():
2137+
"""Test that value validation still works with complex required keys"""
2138+
schema = Schema({Required(Any("color", "temperature")): str, "device": str})
2139+
2140+
# Should pass with valid string value
2141+
result = schema({"color": "red", "device": "light"})
2142+
assert result == {"color": "red", "device": "light"}
2143+
2144+
# Should fail with invalid value type
2145+
with pytest.raises(MultipleInvalid) as ctx:
2146+
schema({"color": 123, "device": "light"}) # color should be str, not int
2147+
2148+
error_msg = str(ctx.value)
2149+
assert "expected str" in error_msg
2150+
2151+
2152+
def test_complex_required_keys_with_specific_value_validation():
2153+
"""Test complex required keys combined with specific value validation for brightness range."""
2154+
schema = Schema(
2155+
{
2156+
Required(Any('color', 'temperature', 'brightness')): object,
2157+
'brightness': All(
2158+
Coerce(int), Range(min=0, max=100)
2159+
), # Additional validation for brightness specifically
2160+
'device_id': str,
2161+
}
2162+
)
2163+
2164+
# Valid - color provided, no brightness validation needed
2165+
result = schema({'color': 'red', 'device_id': 'light1'})
2166+
assert result == {'color': 'red', 'device_id': 'light1'}
2167+
2168+
# Invalid - brightness provided but out of range (255 > 100)
2169+
# Should NOT get "required field missing" error, but should get range error
2170+
with pytest.raises(MultipleInvalid) as exc_info:
2171+
schema({'brightness': '255', 'device_id': 'light1'})
2172+
2173+
# Verify it's a range error, not a missing required field error
2174+
error_msg = str(exc_info.value)
2175+
assert "required" not in error_msg.lower() # No "required field missing" error
2176+
assert "value must be at most 100" in error_msg # Range validation error

0 commit comments

Comments
 (0)