Repository: marshmallow-code/marshmallow

Issue:
Dotted `only` and `exclude` not working for nested schema instances
#1229 fixed propagation of `only` and `exclude` for nested schema classes but no instances.
Dotted strings passed to `only` and `exclude` are not respected when a schema instance is nested.


```python
from marshmallow import Schema, fields


class Child(Schema):
    name = fields.String()
    age = fields.Integer()


class Parent(Schema):
    favorite = fields.Nested(Child, only=("name", "age"))
    children = fields.List(fields.Nested(Child, only=("name", "age")))


class Parent2(Schema):
    favorite = fields.Nested(Child(only=("name", "age")))
    children = fields.List(fields.Nested(Child(only=("name", "age"))))


family = {
    "favorite": {"name": "Lily", "age": 15},
    "children": [{"name": "Tommy", "age": 12}, {"name": "Lily", "age": 15}],
}
# should be the same
print(Parent(only=("favorite.age", "children.name")).dump(family))
print(Parent2(only=("favorite.age", "children.name")).dump(family))
```

Expected output:

```
{'children': [{'name': 'Tommy'}, {'name': 'Lily'}], 'favorite': {'age': 15}}
{'children': [{'name': 'Tommy'}, {'name': 'Lily'}], 'favorite': {'age': 15}}
```

Actual output:

```
{'favorite': {'age': 15}, 'children': [{'name': 'Tommy'}, {'name': 'Lily'}]}
{'favorite': {'age': 15, 'name': 'Lily'}, 'children': [{'age': 12, 'name': 'Tommy'}, {'age': 15, 'name': 'Lily'}]}
```


Discussion:
sloria: Merging this because I don't want this bug and the bug in #1160 in the wild for too long.

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 5d010b98..f547143c 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,6 +1,14 @@
 Changelog
 ---------
 
+3.0.4 (unreleased)
+++++++++++++++++++
+
+Bug fixes:
+
+- Fix propagating dot-delimited `only` and `exclude` parameters to nested schema instances (:issue:`1384`).
+- Includes bug fix from 2.20.4 (:issue:`1160`).
+
 3.0.3 (2019-09-04)
 ++++++++++++++++++
 
diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py
index 2858cde7..8c5b8568 100644
--- a/src/marshmallow/fields.py
+++ b/src/marshmallow/fields.py
@@ -468,8 +468,17 @@ class Nested(Field):
             # Inherit context from parent.
             context = getattr(self.parent, "context", {})
             if isinstance(self.nested, SchemaABC):
-                self._schema = self.nested
+                self._schema = copy.deepcopy(self.nested)
                 self._schema.context.update(context)
+                # Respect only and exclude passed from parent and re-initialize fields
+                set_class = self._schema.set_class
+                if self.only is not None:
+                    original = self._schema.only
+                    self._schema.only = set_class(self.only).intersection(original)
+                if self.exclude:
+                    original = self._schema.exclude
+                    self._schema.exclude = set_class(self.exclude).union(original)
+                self._schema._init_fields()
             else:
                 if isinstance(self.nested, type) and issubclass(self.nested, SchemaABC):
                     schema_class = self.nested
diff --git a/src/marshmallow/schema.py b/src/marshmallow/schema.py
index afd2f5db..7614f575 100644
--- a/src/marshmallow/schema.py
+++ b/src/marshmallow/schema.py
@@ -378,16 +378,10 @@ class BaseSchema(base.SchemaABC):
         self.context = context or {}
         self._normalize_nested_options()
         #: Dictionary mapping field_names -> :class:`Field` objects
-        self.fields = self._init_fields()
-        self.dump_fields, self.load_fields = self.dict_class(), self.dict_class()
-        for field_name, field_obj in self.fields.items():
-            if field_obj.load_only:
-                self.load_fields[field_name] = field_obj
-            elif field_obj.dump_only:
-                self.dump_fields[field_name] = field_obj
-            else:
-                self.load_fields[field_name] = field_obj
-                self.dump_fields[field_name] = field_obj
+        self.fields = None
+        self.load_fields = None
+        self.dump_fields = None
+        self._init_fields()
         messages = {}
         messages.update(self._default_error_messages)
         for cls in reversed(self.__class__.__mro__):
@@ -757,6 +751,7 @@ class BaseSchema(base.SchemaABC):
         self, data, *, many=None, partial=None, unknown=None, postprocess=True
     ):
         """Deserialize `data`, returning the deserialized result.
+        This method is private API.
 
         :param data: The data to deserialize.
         :param bool many: Whether to deserialize `data` as a collection. If `None`, the
@@ -844,7 +839,9 @@ class BaseSchema(base.SchemaABC):
         return result
 
     def _normalize_nested_options(self):
-        """Apply then flatten nested schema options"""
+        """Apply then flatten nested schema options.
+        This method is private API.
+        """
         if self.only is not None:
             # Apply the only option to nested fields.
             self.__apply_nested_option("only", self.only, "intersection")
@@ -878,7 +875,9 @@ class BaseSchema(base.SchemaABC):
             setattr(self.declared_fields[key], option_name, new_options)
 
     def _init_fields(self):
-        """Update fields based on schema options."""
+        """Update self.fields, self.load_fields, and self.dump_fields based on schema options.
+        This method is private API.
+        """
         if self.opts.fields:
             available_field_names = self.set_class(self.opts.fields)
         else:
@@ -913,10 +912,19 @@ class BaseSchema(base.SchemaABC):
             self._bind_field(field_name, field_obj)
             fields_dict[field_name] = field_obj
 
+        load_fields, dump_fields = self.dict_class(), self.dict_class()
+        for field_name, field_obj in fields_dict.items():
+            if field_obj.load_only:
+                load_fields[field_name] = field_obj
+            elif field_obj.dump_only:
+                dump_fields[field_name] = field_obj
+            else:
+                load_fields[field_name] = field_obj
+                dump_fields[field_name] = field_obj
+
         dump_data_keys = [
             field_obj.data_key if field_obj.data_key is not None else name
-            for name, field_obj in fields_dict.items()
-            if not field_obj.load_only
+            for name, field_obj in dump_fields.items()
         ]
         if len(dump_data_keys) != len(set(dump_data_keys)):
             data_keys_duplicates = {
@@ -928,12 +936,7 @@ class BaseSchema(base.SchemaABC):
                 "Check the following field names and "
                 "data_key arguments: {}".format(list(data_keys_duplicates))
             )
-
-        load_attributes = [
-            obj.attribute or name
-            for name, obj in fields_dict.items()
-            if not obj.dump_only
-        ]
+        load_attributes = [obj.attribute or name for name, obj in load_fields.items()]
         if len(load_attributes) != len(set(load_attributes)):
             attributes_duplicates = {
                 x for x in load_attributes if load_attributes.count(x) > 1
@@ -945,7 +948,9 @@ class BaseSchema(base.SchemaABC):
                 "attribute arguments: {}".format(list(attributes_duplicates))
             )
 
-        return fields_dict
+        self.fields = fields_dict
+        self.dump_fields = dump_fields
+        self.load_fields = load_fields
 
     def on_bind_field(self, field_name, field_obj):
         """Hook to modify a field when it is bound to the `Schema`.


Test patch:
diff --git a/tests/test_fields.py b/tests/test_fields.py
index 751f7cd9..f4cb3bd2 100644
--- a/tests/test_fields.py
+++ b/tests/test_fields.py
@@ -294,10 +294,15 @@ class TestListNested:
         assert getattr(schema.fields["children"].inner.schema, param) == {"name"}
 
     @pytest.mark.parametrize(
-        ("param", "expected"),
-        (("only", {"name"}), ("exclude", {"name", "surname", "age"})),
+        ("param", "expected_attribute", "expected_dump"),
+        (
+            ("only", {"name"}, {"children": [{"name": "Lily"}]}),
+            ("exclude", {"name", "surname", "age"}, {"children": [{}]}),
+        ),
     )
-    def test_list_nested_only_and_exclude_merged_with_nested(self, param, expected):
+    def test_list_nested_class_only_and_exclude_merged_with_nested(
+        self, param, expected_attribute, expected_dump
+    ):
         class Child(Schema):
             name = fields.String()
             surname = fields.String()
@@ -307,7 +312,70 @@ class TestListNested:
             children = fields.List(fields.Nested(Child, **{param: ("name", "surname")}))
 
         schema = Family(**{param: ["children.name", "children.age"]})
-        assert getattr(schema.fields["children"].inner, param) == expected
+        assert getattr(schema.fields["children"].inner, param) == expected_attribute
+
+        family = {"children": [{"name": "Lily", "surname": "Martinez", "age": 15}]}
+        assert schema.dump(family) == expected_dump
+
+    def test_list_nested_class_multiple_dumps(self):
+        class Child(Schema):
+            name = fields.String()
+            surname = fields.String()
+            age = fields.Integer()
+
+        class Family(Schema):
+            children = fields.List(fields.Nested(Child, only=("name", "age")))
+
+        family = {"children": [{"name": "Lily", "surname": "Martinez", "age": 15}]}
+        assert Family(only=("children.age",)).dump(family) == {
+            "children": [{"age": 15}]
+        }
+        assert Family(only=("children.name",)).dump(family) == {
+            "children": [{"name": "Lily"}]
+        }
+
+    @pytest.mark.parametrize(
+        ("param", "expected_attribute", "expected_dump"),
+        (
+            ("only", {"name"}, {"children": [{"name": "Lily"}]}),
+            ("exclude", {"name", "surname", "age"}, {"children": [{}]}),
+        ),
+    )
+    def test_list_nested_instance_only_and_exclude_merged_with_nested(
+        self, param, expected_attribute, expected_dump
+    ):
+        class Child(Schema):
+            name = fields.String()
+            surname = fields.String()
+            age = fields.Integer()
+
+        class Family(Schema):
+            children = fields.List(fields.Nested(Child(**{param: ("name", "surname")})))
+
+        schema = Family(**{param: ["children.name", "children.age"]})
+        assert (
+            getattr(schema.fields["children"].inner.schema, param) == expected_attribute
+        )
+
+        family = {"children": [{"name": "Lily", "surname": "Martinez", "age": 15}]}
+        assert schema.dump(family) == expected_dump
+
+    def test_list_nested_instance_multiple_dumps(self):
+        class Child(Schema):
+            name = fields.String()
+            surname = fields.String()
+            age = fields.Integer()
+
+        class Family(Schema):
+            children = fields.List(fields.Nested(Child(only=("name", "age"))))
+
+        family = {"children": [{"name": "Lily", "surname": "Martinez", "age": 15}]}
+        assert Family(only=("children.age",)).dump(family) == {
+            "children": [{"age": 15}]
+        }
+        assert Family(only=("children.name",)).dump(family) == {
+            "children": [{"name": "Lily"}]
+        }
 
     def test_list_nested_partial_propagated_to_nested(self):
         class Child(Schema):