From fd5a9b842da2185b3d04046c1fa38aa533e209da Mon Sep 17 00:00:00 2001 From: Fabian Raab Date: Fri, 25 Mar 2022 20:40:38 +0100 Subject: [PATCH] Add ForbiddenExtraKeysError to ClassValidationError if used. --- src/cattrs/gen.py | 26 ++++++++++++++------- tests/metadata/test_genconverter.py | 35 +++++++++++++++++++++-------- tests/test_gen_dict.py | 10 +++++++-- 3 files changed, 52 insertions(+), 19 deletions(-) diff --git a/src/cattrs/gen.py b/src/cattrs/gen.py index d906b515..84a2e2fc 100644 --- a/src/cattrs/gen.py +++ b/src/cattrs/gen.py @@ -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 = []") @@ -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)" ) @@ -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]) diff --git a/tests/metadata/test_genconverter.py b/tests/metadata/test_genconverter.py index f79749d5..c56ae43f 100644 --- a/tests/metadata/test_genconverter.py +++ b/tests/metadata/test_genconverter.py @@ -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, @@ -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)) @@ -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(): @@ -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): diff --git a/tests/test_gen_dict.py b/tests/test_gen_dict.py index e7e431b7..3f7f0701 100644 --- a/tests/test_gen_dict.py +++ b/tests/test_gen_dict.py @@ -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 @@ -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()