Skip to content

Commit

Permalink
Add ForbiddenExtraKeysError to ClassValidationError if used.
Browse files Browse the repository at this point in the history
  • Loading branch information
raabf authored and Tinche committed Mar 25, 2022
1 parent b860119 commit 031eeef
Show file tree
Hide file tree
Showing 3 changed files with 52 additions and 19 deletions.
26 changes: 18 additions & 8 deletions src/cattrs/gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,10 @@ def make_dict_structure_fn(
resolve_types(cl)

allowed_fields = set()
if _cattrs_forbid_extra_keys:
globs["__c_a"] = allowed_fields
globs["__c_feke"] = ForbiddenExtraKeysError

if _cattrs_detailed_validation:
lines.append(" res = {}")
lines.append(" errors = []")
Expand Down Expand Up @@ -327,6 +331,14 @@ def make_dict_structure_fn(
f"{i}e.__note__ = 'Structuring class {cl.__qualname__} @ attribute {an}'"
)
lines.append(f"{i}errors.append(e)")

if _cattrs_forbid_extra_keys:
post_lines += [
" unknown_fields = set(o.keys()) - __c_a",
" if unknown_fields:",
" errors.append(__c_feke('', __cl, unknown_fields))",
]

post_lines.append(
f" if errors: raise __c_cve('While structuring {cl.__name__}', errors, __cl)"
)
Expand Down Expand Up @@ -449,14 +461,12 @@ def make_dict_structure_fn(
[" return __cl("] + [f" {line}" for line in invocation_lines] + [" )"]
)

if _cattrs_forbid_extra_keys:
globs["__c_a"] = allowed_fields
globs["__c_feke"] = ForbiddenExtraKeysError
lines += [
" unknown_fields = set(o.keys()) - __c_a",
" if unknown_fields:",
" raise __c_feke('', __cl, unknown_fields)",
]
if _cattrs_forbid_extra_keys:
post_lines += [
" unknown_fields = set(o.keys()) - __c_a",
" if unknown_fields:",
" raise __c_feke('', __cl, unknown_fields)",
]

# At the end, we create the function header.
internal_arg_line = ", ".join([f"{i}={i}" for i in internal_arg_parts])
Expand Down
35 changes: 26 additions & 9 deletions tests/metadata/test_genconverter.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from cattr import UnstructureStrategy
from cattr._compat import is_py39_plus, is_py310_plus
from cattr.gen import make_dict_structure_fn, override
from cattrs.errors import ForbiddenExtraKeysError
from cattrs.errors import ClassValidationError, ForbiddenExtraKeysError

from . import (
nested_typed_classes,
Expand Down Expand Up @@ -88,11 +88,13 @@ def test_forbid_extra_keys(cls_and_vals):
while bad_key in unstructured:
bad_key += "A"
unstructured[bad_key] = 1
with pytest.raises(ForbiddenExtraKeysError) as feke:
with pytest.raises(ClassValidationError) as cve:
converter.structure(unstructured, cl)

assert feke.value.cl is cl
assert feke.value.extra_fields == {bad_key}
assert len(cve.value.exceptions) == 1
assert isinstance(cve.value.exceptions[0], ForbiddenExtraKeysError)
assert cve.value.exceptions[0].cl is cl
assert cve.value.exceptions[0].extra_fields == {bad_key}


@given(simple_typed_attrs(defaults=True))
Expand All @@ -106,11 +108,13 @@ def test_forbid_extra_keys_defaults(attr_and_vals):
inst = cl()
unstructured = converter.unstructure(inst)
unstructured["aa"] = unstructured.pop("a")
with pytest.raises(ForbiddenExtraKeysError) as feke:
with pytest.raises(ClassValidationError) as cve:
converter.structure(unstructured, cl)

assert feke.value.cl is cl
assert feke.value.extra_fields == {"aa"}
assert len(cve.value.exceptions) == 1
assert isinstance(cve.value.exceptions[0], ForbiddenExtraKeysError)
assert cve.value.exceptions[0].cl is cl
assert cve.value.exceptions[0].extra_fields == {"aa"}


def test_forbid_extra_keys_nested_override():
Expand All @@ -129,17 +133,30 @@ class A:
converter.structure(unstructured, A)
# if we break it in the subclass, we need it to raise
unstructured["c"]["aa"] = 5
with pytest.raises(ForbiddenExtraKeysError):
with pytest.raises(ClassValidationError) as cve:
converter.structure(unstructured, A)

assert len(cve.value.exceptions) == 1
assert isinstance(cve.value.exceptions[0], ClassValidationError)
assert len(cve.value.exceptions[0].exceptions) == 1
assert isinstance(cve.value.exceptions[0].exceptions[0], ForbiddenExtraKeysError)
assert cve.value.exceptions[0].exceptions[0].cl is C
assert cve.value.exceptions[0].exceptions[0].extra_fields == {"aa"}

# we can "fix" that by disabling forbid_extra_keys on the subclass
hook = make_dict_structure_fn(C, converter, _cattrs_forbid_extra_keys=False)
converter.register_structure_hook(C, hook)
converter.structure(unstructured, A)
# but we should still raise at the top level
unstructured["b"] = 6
with pytest.raises(ForbiddenExtraKeysError):
with pytest.raises(ClassValidationError) as cve:
converter.structure(unstructured, A)

assert len(cve.value.exceptions) == 1
assert isinstance(cve.value.exceptions[0], ForbiddenExtraKeysError)
assert cve.value.exceptions[0].cl is A
assert cve.value.exceptions[0].extra_fields == {"b"}


@given(nested_typed_classes(defaults=True, min_attrs=1), unstructure_strats, booleans())
def test_nested_roundtrip(cls_and_vals, strat, omit_if_default):
Expand Down
10 changes: 8 additions & 2 deletions tests/test_gen_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from cattr._compat import adapted_fields, fields
from cattrs import Converter
from cattrs.errors import ForbiddenExtraKeysError
from cattrs.errors import ClassValidationError, ForbiddenExtraKeysError
from cattrs.gen import make_dict_structure_fn, make_dict_unstructure_fn, override

from . import nested_classes, simple_classes
Expand Down Expand Up @@ -227,9 +227,15 @@ class A:

assert new_inst == A(1, "str")

with pytest.raises(ForbiddenExtraKeysError):
with pytest.raises(ClassValidationError) as cve:
converter.structure({"b": 1, "c": "str"}, A)

assert len(cve.value.exceptions) == 2
assert isinstance(cve.value.exceptions[0], KeyError)
assert isinstance(cve.value.exceptions[1], ForbiddenExtraKeysError)
assert cve.value.exceptions[1].cl is A
assert cve.value.exceptions[1].extra_fields == {"c"}


def test_omitting():
converter = Converter()
Expand Down

0 comments on commit 031eeef

Please sign in to comment.