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

Forbid extra fields when structuring dictionaries into attrs classes #142

Merged
merged 1 commit into from
Apr 14, 2021
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
2 changes: 2 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ History
* ``GenConverter`` has support for easy overriding of collection unstructuring types (for example, unstructure all sets to lists) through its ``unstruct_collection_overrides`` argument.
(`#137 <https://github.com/Tinche/cattrs/pull/137>`_)
* Unstructuring mappings with ``GenConverter`` is significantly faster.
* ``GenConverter`` supports strict handling of unexpected dictionary keys through its ``forbid_extra_keys`` argument.
(`#142 <https://github.com/Tinche/cattrs/pull/142>`_)

1.4.0 (2021-03-21)
------------------
Expand Down
34 changes: 33 additions & 1 deletion docs/customizing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ a lot of ``cattrs`` machinery and be significantly faster than normal ``cattrs``
Another reason is that it's possible to override behavior on a per-attribute basis.

Currently, the overrides only support generating dictionary un/structuring functions
(as opposed to tuples), and support ``omit_if_default`` and ``rename``.
(as opposed to tuples), and support ``omit_if_default``, ``forbid_extra_keys`` and
``rename``.

``omit_if_default``
-------------------
Expand Down Expand Up @@ -81,6 +82,37 @@ but not to the `DateTime` field.

This override has no effect when generating structuring functions.

``forbid_extra_keys``
---------------------

By default ``cattrs`` is lenient in accepting unstructured input. If extra
keys are present in a dictionary, they will be ignored when generating a
structured object. Sometimes it may be desirable to enforce a stricter
contract, and to raise an error when unknown keys are present - in particular
when fields have default values this may help with catching typos.
`forbid_extra_keys` can also be enabled (or disabled) on a per-class basis when
creating structure hooks with ``make_dict_structure_fn``.

.. doctest::

>>> from cattr.gen import make_dict_structure_fn
>>>
>>> @attr.s
... class TestClass:
... number: int = attr.ib(default=1)
>>>
>>> c = cattr.GenConverter(forbid_extra_keys=True)
>>> c.structure({"nummber": 2}, TestClass)
Traceback (most recent call last):
...
Exception: Extra fields in constructor for TestClass: nummber
>>> hook = make_dict_structure_fn(TestClass, c, _cattr_forbid_extra_keys=False)
>>> c.register_structure_hook(TestClass, hook)
>>> c.structure({"nummber": 2}, TestClass)
TestClass(number=1)

This behavior can only be applied to classes or to the default for the
`GenConverter`, and has no effect when generating unstructuring functions.

``rename``
----------
Expand Down
10 changes: 9 additions & 1 deletion src/cattr/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,7 @@ class GenConverter(Converter):

__slots__ = (
"omit_if_default",
"forbid_extra_keys",
"type_overrides",
"_unstruct_collection_overrides",
)
Expand All @@ -498,13 +499,15 @@ def __init__(
dict_factory: Callable[[], Any] = dict,
unstruct_strat: UnstructureStrategy = UnstructureStrategy.AS_DICT,
omit_if_default: bool = False,
forbid_extra_keys: bool = False,
type_overrides: Mapping[Type, AttributeOverride] = {},
unstruct_collection_overrides: Mapping[Type, Callable] = {},
):
super().__init__(
dict_factory=dict_factory, unstruct_strat=unstruct_strat
)
self.omit_if_default = omit_if_default
self.forbid_extra_keys = forbid_extra_keys
self.type_overrides = dict(type_overrides)

self._unstruct_collection_overrides = unstruct_collection_overrides
Expand Down Expand Up @@ -635,7 +638,12 @@ def gen_structure_attrs_fromdict(self, cl: Type[T]) -> T:
for a in attribs
if a.type in self.type_overrides
}
h = make_dict_structure_fn(cl, self, **attrib_overrides)
h = make_dict_structure_fn(
cl,
self,
_cattrs_forbid_extra_keys=self.forbid_extra_keys,
**attrib_overrides
)
self._structure_func.register_cls_list([(cl, h)], direct=True)
# only direct dispatch so that subclasses get separately generated
return h
Expand Down
14 changes: 13 additions & 1 deletion src/cattr/gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,9 @@ def generate_mapping(cl: Type, old_mapping):
return cls(**mapping)


def make_dict_structure_fn(cl: Type, converter, **kwargs):
def make_dict_structure_fn(
cl: Type, converter, _cattrs_forbid_extra_keys: bool = False, **kwargs
):
"""Generate a specialized dict structuring function for an attrs class."""

mapping = None
Expand Down Expand Up @@ -181,6 +183,16 @@ def make_dict_structure_fn(cl: Type, converter, **kwargs):
f" res['{ian}'] = {struct_handler_name}(o['{kn}'], __c_t_{an})"
)
lines.append(" }")
if _cattrs_forbid_extra_keys:
allowed_fields = {a.name for a in attrs}
globs["__c_a"] = allowed_fields
post_lines += [
" unknown_fields = set(o.keys()) - __c_a",
" if unknown_fields:",
Tinche marked this conversation as resolved.
Show resolved Hide resolved
" raise Exception(",
f" 'Extra fields in constructor for {cl_name}: ' + ', '.join(unknown_fields)"
" )",
]

total_lines = lines + post_lines + [" return __cl(**res)"]

Expand Down
75 changes: 74 additions & 1 deletion tests/metadata/test_genconverter.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

from cattr import GenConverter as Converter
from cattr import UnstructureStrategy
from cattr.gen import override
from cattr.gen import make_dict_structure_fn, override

from . import (
nested_typed_classes,
Expand Down Expand Up @@ -59,6 +59,79 @@ def test_simple_roundtrip_defaults(attr_and_vals, strat):
assert inst == converter.structure(converter.unstructure(inst), cl)


@given(simple_typed_classes() | simple_typed_dataclasses(), unstructure_strats)
def test_simple_roundtrip_with_extra_keys_forbidden(cls_and_vals, strat):
"""
Simple classes can be unstructured and restructured with forbid_extra_keys=True.
"""
converter = Converter(unstruct_strat=strat, forbid_extra_keys=True)
cl, vals = cls_and_vals
inst = cl(*vals)
unstructured = converter.unstructure(inst)
assert "Hyp" not in repr(unstructured)
assert inst == converter.structure(unstructured, cl)


@given(simple_typed_classes() | simple_typed_dataclasses())
def test_forbid_extra_keys(cls_and_vals):
"""
Restructuring fails when extra keys are present (when configured)
"""
converter = Converter(forbid_extra_keys=True)
cl, vals = cls_and_vals
inst = cl(*vals)
unstructured = converter.unstructure(inst)
bad_key = list(unstructured)[0] + "A" if unstructured else "Hyp"
while bad_key in unstructured:
bad_key += "A"
unstructured[bad_key] = 1
with pytest.raises(Exception):
converter.structure(unstructured, cl)


@given(simple_typed_attrs(defaults=True))
def test_forbid_extra_keys_defaults(attr_and_vals):
"""
Restructuring fails when a dict key is renamed (if forbid_extra_keys set)
"""
a, _ = attr_and_vals
cl = make_class("HypClass", {"a": a})
converter = Converter(forbid_extra_keys=True)
inst = cl()
unstructured = converter.unstructure(inst)
unstructured["aa"] = unstructured.pop("a")
with pytest.raises(Exception):
converter.structure(unstructured, cl)


def test_forbid_extra_keys_nested_override():
@attr.s
class C:
a = attr.ib(type=int, default=1)

@attr.s
class A:
c = attr.ib(type=C)
a = attr.ib(type=int, default=2)

converter = Converter(forbid_extra_keys=True)
unstructured = {"a": 3, "c": {"a": 4}}
# at this point, structuring should still work
converter.structure(unstructured, A)
# if we break it in the subclass, we need it to raise
unstructured["c"]["aa"] = 5
with pytest.raises(Exception):
converter.structure(unstructured, A)
# we can "fix" that by disabling forbid_extra_keys on the subclass
hook = make_dict_structure_fn(C, converter, _cattr_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):
converter.structure(unstructured, A)


@given(
nested_typed_classes(defaults=True, min_attrs=1),
unstructure_strats,
Expand Down