Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

For forbid_extra_keys raise custom ForbiddenExtraKeyError #225

Merged
merged 4 commits into from
Mar 25, 2022
Merged
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
3 changes: 2 additions & 1 deletion HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ History
(`#231 <https://github.com/python-attrs/cattrs/pull/231>`_)
* Fix unstructuring all tuples - unannotated, variable-length, homogenous and heterogenous - to `list`.
(`#226 <https://github.com/python-attrs/cattrs/issues/226>`_)

* For ``forbid_extra_keys`` raise custom ``ForbiddenExtraKeyError`` instead of generic ``Exception``.
(`#255 <https://github.com/python-attrs/cattrs/pull/225>`_)

1.10.0 (2022-01-04)
-------------------
Expand Down
2 changes: 1 addition & 1 deletion docs/customizing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ creating structure hooks with ``make_dict_structure_fn``.
>>> c.structure({"nummber": 2}, TestClass)
Traceback (most recent call last):
...
Exception: Extra fields in constructor for TestClass: nummber
ForbiddenExtraKeyError: Extra fields in constructor for TestClass: nummber
>>> hook = make_dict_structure_fn(TestClass, c, _cattrs_forbid_extra_keys=False)
>>> c.register_structure_hook(TestClass, hook)
>>> c.structure({"nummber": 2}, TestClass)
Expand Down
2 changes: 1 addition & 1 deletion docs/structuring.rst
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ Here's a small example showing how to use factory hooks to apply the `forbid_ext
>>> c.structure({"an_int": 1, "else": 2}, E)
Traceback (most recent call last):
...
Exception: Extra fields in constructor for E: else
ForbiddenExtraKeyError: Extra fields in constructor for E: else


A complex use case for hook factories is described over at :ref:`Using factory hooks`.
Expand Down
24 changes: 23 additions & 1 deletion src/cattrs/errors.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Type
from typing import Optional, Set, Type

from cattr._compat import ExceptionGroup

Expand Down Expand Up @@ -33,3 +33,25 @@ class ClassValidationError(BaseValidationError):
"""Raised when validating a class if any attributes are invalid."""

pass


class ForbiddenExtraKeysError(Exception):
"""Raised when `forbid_extra_keys` is activated and such extra keys are detected during structuring.

The attribute `extra_fields` is a sequence of those extra keys, which were the cause of this error,
and `cl` is the class which was structured with those extra keys.
"""

def __init__(
self, message: Optional[str], cl: Type, extra_fields: Set[str]
) -> None:
self.cl = cl
self.extra_fields = extra_fields

msg = (
message
if message
else f"Extra fields in constructor for {cl.__name__}: {', '.join(extra_fields)}"
)

super().__init__(msg)
33 changes: 23 additions & 10 deletions src/cattrs/gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@
is_generic,
)
from cattr._generics import deep_copy_with
from cattrs.errors import ClassValidationError, IterableValidationError
from cattrs.errors import (
ClassValidationError,
ForbiddenExtraKeysError,
IterableValidationError,
)

if TYPE_CHECKING: # pragma: no cover
from cattr.converters import Converter
Expand Down Expand Up @@ -258,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 @@ -323,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 @@ -445,15 +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
post_lines += [
" unknown_fields = set(o.keys()) - __c_a",
" if unknown_fields:",
" raise Exception(",
f" 'Extra fields in constructor for {cl_name}: ' + ', '.join(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
32 changes: 28 additions & 4 deletions tests/metadata/test_genconverter.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +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 ClassValidationError, ForbiddenExtraKeysError

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

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))
def test_forbid_extra_keys_defaults(attr_and_vals):
Expand All @@ -102,9 +108,14 @@ def test_forbid_extra_keys_defaults(attr_and_vals):
inst = cl()
unstructured = converter.unstructure(inst)
unstructured["aa"] = unstructured.pop("a")
with pytest.raises(Exception):
with pytest.raises(ClassValidationError) as cve:
converter.structure(unstructured, cl)

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():
@attr.s
Expand All @@ -122,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(Exception):
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(Exception):
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
9 changes: 8 additions & 1 deletion tests/test_gen_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from cattr._compat import adapted_fields, fields
from cattrs import Converter
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 @@ -226,9 +227,15 @@ class A:

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

with pytest.raises(Exception):
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