Skip to content

Commit 109f955

Browse files
committed
Emit missing-required-key errors in schema-declaration order
Required keys were collected into a `set`, so the `MultipleInvalid` produced for missing required keys came out in an arbitrary, hash-seed-dependent order (the reporter's two-key test failed ~1 in 3 runs). Store the required keys in an insertion-ordered dict (used as an ordered set) and replace the `set.discard()` calls with `dict.pop(key, None)`, so the leftover-required-key errors are emitted in the order the keys were declared in the schema. Behavior is otherwise unchanged. Closes #484
1 parent 7fec134 commit 109f955

3 files changed

Lines changed: 28 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
**Fixes**:
66

77
* [#539](https://github.com/alecthomas/voluptuous/pull/539): Raise `Invalid` instead of leaking `TypeError`/`ValueError` for non-numeric input to the `Number` validator
8+
* [#484](https://github.com/alecthomas/voluptuous/issues/484): Emit `required key not provided` errors in a stable schema-declaration order
89

910
## [0.16.0]
1011

voluptuous/schema_builder.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -240,23 +240,25 @@ def _compile_mapping(self, schema, invalid_msg=None):
240240
"""Create validator for given mapping."""
241241
invalid_msg = invalid_msg or 'mapping value'
242242

243-
# Keys that may be required
244-
all_required_keys = set(
245-
key
243+
# Keys that may be required. A dict is used as an insertion-ordered set
244+
# so that "required key not provided" errors are emitted in a stable,
245+
# schema-declaration order rather than an arbitrary set order (#484).
246+
all_required_keys = {
247+
key: None
246248
for key in schema
247249
if key is not Extra
248250
and (
249251
(self.required and not isinstance(key, (Optional, Remove)))
250252
or isinstance(key, Required)
251253
)
252-
)
254+
}
253255

254256
# Complex required keys that need special validation
255-
complex_required_keys = set(
256-
key
257+
complex_required_keys = {
258+
key: None
257259
for key in all_required_keys
258260
if isinstance(key, Required) and key.is_complex_key
259-
)
261+
}
260262

261263
# Keys that may have defaults
262264
all_default_keys = set(
@@ -325,7 +327,7 @@ def validate_mapping(path, iterable, out):
325327
errors.append(er.RequiredFieldInvalid(msg, path + [complex_key]))
326328
else:
327329
# If at least one candidate key is present, mark this complex requirement as satisfied
328-
required_keys.discard(complex_key)
330+
required_keys.pop(complex_key, None)
329331
for key, value in key_value_map.items():
330332
key_path = path + [key]
331333
remove_key = False
@@ -376,12 +378,12 @@ def validate_mapping(path, iterable, out):
376378
# key, this means that the key was provided.
377379
# Discard the required key so it does not
378380
# create an additional, noisy exception.
379-
required_keys.discard(skey)
381+
required_keys.pop(skey, None)
380382
break
381383

382384
# Key and value okay, mark as found in case it was
383385
# a Required() field.
384-
required_keys.discard(skey)
386+
required_keys.pop(skey, None)
385387

386388
break
387389
else:

voluptuous/tests/tests.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,21 @@ def test_required():
9595
schema({})
9696

9797

98+
def test_required_key_error_order_is_stable():
99+
"""Missing-required-key errors follow schema declaration order (#484).
100+
101+
Previously the required keys were stored in a set, so the emitted
102+
``MultipleInvalid`` errors came out in an arbitrary, hash-seed-dependent
103+
order. Using several keys makes an accidental correct ordering on the old
104+
code astronomically unlikely.
105+
"""
106+
keys = ['value1', 'value2', 'value3', 'value4', 'value5']
107+
schema = Schema({Required(k): str for k in keys})
108+
with pytest.raises(MultipleInvalid) as ctx:
109+
schema({})
110+
assert [e.path[0] for e in ctx.value.errors] == keys
111+
112+
98113
def test_extra_with_required():
99114
"""Verify that Required does not break Extra."""
100115
schema = Schema({Required('toaster'): str, Extra: object})

0 commit comments

Comments
 (0)