Skip to content

Commit 9b97235

Browse files
committed
Make "propagate_unknown" behavior explicit
This adds a new option, `propagate_unknown` which defaults to False. It controls the behavior of `unknown` with respect to nested structures. Anywhere that `unknown` can be set, `propagate_unknown` can be set. That means it can be applied to a schema instance, a load call, schema.Meta, or to fields.Nested . When set, nested deserialize calls will get the same value for `unknown` which their parent call got and they will receive `propagate_unknown=True`. The new flag is completely opt-in and therefore backwards compatible with any current usages of marshmallow. Once you opt in to this behavior on a schema, you don't need to worry about making sure it's set by nested schemas that you use. In the name of simplicity, this sacrifices a bit of flexibility. A schema with `propagate_unknown=True, unknown=...` will override the `unknown` settings on any of its child schemas. Tests cover usage as a schema instantiation arg and as a load arg for some simple data structures.
1 parent 95d1828 commit 9b97235

File tree

4 files changed

+124
-55
lines changed

4 files changed

+124
-55
lines changed

src/marshmallow/base.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,25 @@ def dump(self, obj, *, many: bool = None):
3838
def dumps(self, obj, *, many: bool = None):
3939
raise NotImplementedError
4040

41-
def load(self, data, *, many: bool = None, partial=None, unknown=None):
41+
def load(
42+
self,
43+
data,
44+
*,
45+
many: bool = None,
46+
partial=None,
47+
unknown=None,
48+
propagate_unknown=None
49+
):
4250
raise NotImplementedError
4351

4452
def loads(
45-
self, json_data, *, many: bool = None, partial=None, unknown=None, **kwargs
53+
self,
54+
json_data,
55+
*,
56+
many: bool = None,
57+
partial=None,
58+
unknown=None,
59+
propagate_unknown=None,
60+
**kwargs
4661
):
4762
raise NotImplementedError

src/marshmallow/fields.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,7 @@ def __init__(
474474
exclude: types.StrSequenceOrSet = (),
475475
many: bool = False,
476476
unknown: str = None,
477+
propagate_unknown: bool = None,
477478
**kwargs
478479
):
479480
# Raise error if only or exclude is passed as string, not list of strings
@@ -494,6 +495,7 @@ def __init__(
494495
self.exclude = exclude
495496
self.many = many
496497
self.unknown = unknown
498+
self.propagate_unknown = propagate_unknown
497499
self._schema = None # Cached Schema instance
498500
super().__init__(default=default, **kwargs)
499501

@@ -571,18 +573,32 @@ def _test_collection(self, value):
571573
if many and not utils.is_collection(value):
572574
raise self.make_error("type", input=value, type=value.__class__.__name__)
573575

574-
def _load(self, value, data, partial=None, unknown=None):
576+
def _load(self, value, data, partial=None, unknown=None, propagate_unknown=None):
575577
try:
576578
valid_data = self.schema.load(
577-
value, unknown=unknown or self.unknown, partial=partial,
579+
value,
580+
unknown=unknown or self.unknown,
581+
propagate_unknown=propagate_unknown
582+
if propagate_unknown is not None
583+
else self.propagate_unknown,
584+
partial=partial,
578585
)
579586
except ValidationError as error:
580587
raise ValidationError(
581588
error.messages, valid_data=error.valid_data
582589
) from error
583590
return valid_data
584591

585-
def _deserialize(self, value, attr, data, partial=None, unknown=None, **kwargs):
592+
def _deserialize(
593+
self,
594+
value,
595+
attr,
596+
data,
597+
partial=None,
598+
unknown=None,
599+
propagate_unknown=None,
600+
**kwargs
601+
):
586602
"""Same as :meth:`Field._deserialize` with additional ``partial`` argument.
587603
588604
:param bool|tuple partial: For nested schemas, the ``partial``
@@ -592,7 +608,13 @@ def _deserialize(self, value, attr, data, partial=None, unknown=None, **kwargs):
592608
Add ``partial`` parameter.
593609
"""
594610
self._test_collection(value)
595-
return self._load(value, data, partial=partial, unknown=unknown)
611+
return self._load(
612+
value,
613+
data,
614+
partial=partial,
615+
unknown=unknown,
616+
propagate_unknown=propagate_unknown,
617+
)
596618

597619

598620
class Pluck(Nested):

src/marshmallow/schema.py

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ def __init__(self, meta, ordered: bool = False):
228228
self.load_only = getattr(meta, "load_only", ())
229229
self.dump_only = getattr(meta, "dump_only", ())
230230
self.unknown = getattr(meta, "unknown", RAISE)
231+
self.propagate_unknown = getattr(meta, "propagate_unknown", False)
231232
self.register = getattr(meta, "register", True)
232233

233234

@@ -372,7 +373,8 @@ def __init__(
372373
load_only: types.StrSequenceOrSet = (),
373374
dump_only: types.StrSequenceOrSet = (),
374375
partial: typing.Union[bool, types.StrSequenceOrSet] = False,
375-
unknown: str = None
376+
unknown: str = None,
377+
propagate_unknown: bool = None
376378
):
377379
# Raise error if only or exclude is passed as string, not list of strings
378380
if only is not None and not is_collection(only):
@@ -389,6 +391,7 @@ def __init__(
389391
self.dump_only = set(dump_only) or set(self.opts.dump_only)
390392
self.partial = partial
391393
self.unknown = unknown or self.opts.unknown
394+
self.propagate_unknown = propagate_unknown or self.opts.propagate_unknown
392395
self.context = context or {}
393396
self._normalize_nested_options()
394397
#: Dictionary mapping field_names -> :class:`Field` objects
@@ -592,6 +595,7 @@ def _deserialize(
592595
many: bool = False,
593596
partial=False,
594597
unknown=RAISE,
598+
propagate_unknown=False,
595599
index=None
596600
) -> typing.Union[_T, typing.List[_T]]:
597601
"""Deserialize ``data``.
@@ -625,6 +629,7 @@ def _deserialize(
625629
many=False,
626630
partial=partial,
627631
unknown=unknown,
632+
propagate_unknown=propagate_unknown,
628633
index=idx,
629634
),
630635
)
@@ -648,7 +653,7 @@ def _deserialize(
648653
partial_is_collection and attr_name in partial
649654
):
650655
continue
651-
d_kwargs = {}
656+
d_kwargs = {} # type: typing.Dict[str, typing.Any]
652657
# Allow partial loading of nested schemas.
653658
if partial_is_collection:
654659
prefix = field_name + "."
@@ -660,11 +665,9 @@ def _deserialize(
660665
else:
661666
d_kwargs["partial"] = partial
662667

663-
try:
664-
if self.context["propagate_unknown_to_nested"]:
665-
d_kwargs["unknown"] = unknown
666-
except KeyError:
667-
pass
668+
if propagate_unknown:
669+
d_kwargs["unknown"] = unknown
670+
d_kwargs["propagate_unknown"] = True
668671

669672
getter = lambda val: field_obj.deserialize(
670673
val, field_name, data, **d_kwargs
@@ -705,7 +708,8 @@ def load(
705708
*,
706709
many: bool = None,
707710
partial: typing.Union[bool, types.StrSequenceOrSet] = None,
708-
unknown: str = None
711+
unknown: str = None,
712+
propagate_unknown: bool = None
709713
):
710714
"""Deserialize a data structure to an object defined by this Schema's fields.
711715
@@ -728,7 +732,12 @@ def load(
728732
if invalid data are passed.
729733
"""
730734
return self._do_load(
731-
data, many=many, partial=partial, unknown=unknown, postprocess=True
735+
data,
736+
many=many,
737+
partial=partial,
738+
unknown=unknown,
739+
propagate_unknown=propagate_unknown,
740+
postprocess=True,
732741
)
733742

734743
def loads(
@@ -738,6 +747,7 @@ def loads(
738747
many: bool = None,
739748
partial: typing.Union[bool, types.StrSequenceOrSet] = None,
740749
unknown: str = None,
750+
propagate_unknown: bool = None,
741751
**kwargs
742752
):
743753
"""Same as :meth:`load`, except it takes a JSON string as input.
@@ -761,7 +771,13 @@ def loads(
761771
if invalid data are passed.
762772
"""
763773
data = self.opts.render_module.loads(json_data, **kwargs)
764-
return self.load(data, many=many, partial=partial, unknown=unknown)
774+
return self.load(
775+
data,
776+
many=many,
777+
partial=partial,
778+
unknown=unknown,
779+
propagate_unknown=propagate_unknown,
780+
)
765781

766782
def _run_validator(
767783
self,
@@ -822,6 +838,7 @@ def _do_load(
822838
many: bool = None,
823839
partial: typing.Union[bool, types.StrSequenceOrSet] = None,
824840
unknown: str = None,
841+
propagate_unknown: bool = None,
825842
postprocess: bool = True
826843
):
827844
"""Deserialize `data`, returning the deserialized result.
@@ -843,8 +860,8 @@ def _do_load(
843860
error_store = ErrorStore()
844861
errors = {} # type: typing.Dict[str, typing.List[str]]
845862
many = self.many if many is None else bool(many)
846-
self.context["propagate_unknown_to_nested"] = unknown is not None
847863
unknown = unknown or self.unknown
864+
propagate_unknown = propagate_unknown or self.propagate_unknown
848865
if partial is None:
849866
partial = self.partial
850867
# Run preprocessors
@@ -868,6 +885,7 @@ def _do_load(
868885
many=many,
869886
partial=partial,
870887
unknown=unknown,
888+
propagate_unknown=propagate_unknown,
871889
)
872890
# Run field-level validation
873891
self._invoke_field_validators(

tests/test_fields.py

Lines changed: 52 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -308,56 +308,70 @@ class ShelfSchema(Schema):
308308

309309
@pytest.fixture
310310
def data_nested_unknown(self):
311-
return {
312-
"spam": {"meat": "pork", "add-on": "eggs"},
313-
}
311+
return {"spam": {"meat": "pork", "add-on": "eggs"}}
314312

315313
@pytest.fixture
316314
def multi_nested_data_with_unknown(self, data_nested_unknown):
317-
return {
318-
"can": data_nested_unknown,
315+
return {"can": data_nested_unknown, "box": {"foo": "bar"}}
316+
317+
@pytest.mark.parametrize(
318+
"schema_kwargs,load_kwargs",
319+
[
320+
({}, {"propagate_unknown": True, "unknown": INCLUDE}),
321+
({"propagate_unknown": True}, {"unknown": INCLUDE}),
322+
({"propagate_unknown": True, "unknown": INCLUDE}, {}),
323+
({"unknown": INCLUDE}, {"propagate_unknown": True}),
324+
],
325+
)
326+
def test_propagate_unknown_include(
327+
self,
328+
schema_kwargs,
329+
load_kwargs,
330+
data_nested_unknown,
331+
multi_nested_data_with_unknown,
332+
):
333+
data = self.ShelfSchema(**schema_kwargs).load(
334+
multi_nested_data_with_unknown, **load_kwargs
335+
)
336+
assert data == {
337+
"can": {"spam": {"meat": "pork", "add-on": "eggs"}},
319338
"box": {"foo": "bar"},
320339
}
321340

322-
@pytest.mark.parametrize("schema_kw", ({}, {"unknown": INCLUDE}))
323-
def test_raises_when_unknown_passed_to_first_level_nested(
324-
self, schema_kw, data_nested_unknown,
325-
):
326-
with pytest.raises(ValidationError) as exc_info:
327-
self.CanSchema(**schema_kw).load(data_nested_unknown)
328-
assert exc_info.value.messages == {"spam": {"add-on": ["Unknown field."]}}
341+
data = self.CanSchema(**schema_kwargs).load(data_nested_unknown, **load_kwargs)
342+
assert data == {"spam": {"meat": "pork", "add-on": "eggs"}}
329343

330344
@pytest.mark.parametrize(
331-
"load_kw,expected_data",
332-
(
333-
({"unknown": INCLUDE}, {"spam": {"meat": "pork", "add-on": "eggs"}}),
334-
({"unknown": EXCLUDE}, {"spam": {"meat": "pork"}}),
335-
),
345+
"schema_kwargs,load_kwargs",
346+
[
347+
({}, {"propagate_unknown": True, "unknown": EXCLUDE}),
348+
({"propagate_unknown": True}, {"unknown": EXCLUDE}),
349+
({"propagate_unknown": True, "unknown": EXCLUDE}, {}),
350+
({"unknown": EXCLUDE}, {"propagate_unknown": True}),
351+
],
336352
)
337-
def test_processes_when_unknown_stated_directly(
338-
self, load_kw, data_nested_unknown, expected_data,
353+
def test_propagate_unknown_exclude(
354+
self,
355+
schema_kwargs,
356+
load_kwargs,
357+
data_nested_unknown,
358+
multi_nested_data_with_unknown,
339359
):
340-
data = self.CanSchema().load(data_nested_unknown, **load_kw)
341-
assert data == expected_data
360+
data = self.ShelfSchema(**schema_kwargs).load(
361+
multi_nested_data_with_unknown, **load_kwargs
362+
)
363+
assert data == {"can": {"spam": {"meat": "pork"}}}
342364

343-
@pytest.mark.parametrize(
344-
"load_kw,expected_data",
345-
(
346-
(
347-
{"unknown": INCLUDE},
348-
{
349-
"can": {"spam": {"meat": "pork", "add-on": "eggs"}},
350-
"box": {"foo": "bar"},
351-
},
352-
),
353-
({"unknown": EXCLUDE}, {"can": {"spam": {"meat": "pork"}}}),
354-
),
355-
)
356-
def test_propagates_unknown_to_multi_nested_fields(
357-
self, load_kw, expected_data, multi_nested_data_with_unknown,
365+
data = self.CanSchema(**schema_kwargs).load(data_nested_unknown, **load_kwargs)
366+
assert data == {"spam": {"meat": "pork"}}
367+
368+
@pytest.mark.parametrize("schema_kw", ({}, {"unknown": INCLUDE}))
369+
def test_raises_when_unknown_passed_to_first_level_nested(
370+
self, schema_kw, data_nested_unknown
358371
):
359-
data = self.ShelfSchema().load(multi_nested_data_with_unknown, **load_kw)
360-
assert data == expected_data
372+
with pytest.raises(ValidationError) as exc_info:
373+
self.CanSchema(**schema_kw).load(data_nested_unknown)
374+
assert exc_info.value.messages == {"spam": {"add-on": ["Unknown field."]}}
361375

362376

363377
class TestListNested:

0 commit comments

Comments
 (0)