diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 15993b2c8..1a4e0ed84 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,12 +1,63 @@ Changelog --------- +3.3.0 (unreleased) +****************** + +Features: + +- ``fields.Nested`` may take a callable that returns a schema instance. + Use this to resolve order-of-declaration issues when schemas nest each other (:issue:`1146`). + +.. code-block:: python + + # <3.3 + class AlbumSchema(Schema): + title = fields.Str() + artist = fields.Nested("ArtistSchema", only=("name",)) + + + class ArtistSchema(Schema): + name = fields.Str() + albums = fields.List(fields.Nested(AlbumSchema)) + + + # >=3.3 + class AlbumSchema(Schema): + title = fields.Str() + artist = fields.Nested(lambda: ArtistSchema(only=("name",))) + + + class ArtistSchema(Schema): + name = fields.Str() + albums = fields.List(fields.Nested(AlbumSchema)) + +Deprecations: + +- Passing the string ``"self"`` to ``fields.Nested`` is deprecated. + Use a callable instead. + +.. code-block:: python + + from marshmallow import Schema, fields + + # <3.3 + class PersonSchema(Schema): + partner = fields.Nested("self", exclude=("partner",)) + friends = fields.List(fields.Nested("self")) + + + # >=3.3 + class PersonSchema(Schema): + partner = fields.Nested(lambda: PersonSchema(exclude=("partner"))) + friends = fields.List(fields.Nested(lambda: PersonSchema())) + 3.2.2 (2019-11-04) ****************** Bug fixes: -- Don't load fields for which ``load_only`` and ``dump_only`` are both ``True`` (:pr:`1448`). +- Don't load fields for which ``load_only`` and ``dump_only`` are both ``True`` (:pr:`1448`). - Fix types in ``marshmallow.validate`` (:pr:`1446`). Support: diff --git a/docs/nesting.rst b/docs/nesting.rst index 66da5df70..6ded19d7a 100644 --- a/docs/nesting.rst +++ b/docs/nesting.rst @@ -22,7 +22,7 @@ Schemas can be nested to represent relationships between objects (e.g. foreign k self.title = title self.author = author # A User object -Use a :class:`Nested ` field to represent the relationship, passing in a nested schema class. +Use a :class:`Nested ` field to represent the relationship, passing in a nested schema. .. code-block:: python @@ -53,24 +53,24 @@ The serialized blog will have the nested user representation. # 'created_at': '2014-08-17T14:58:57.600623+00:00'}} .. note:: - If the field is a collection of nested objects, you must set ``many=True``. + If the field is a collection of nested objects, pass the `Nested ` field to `List `. .. code-block:: python - collaborators = fields.Nested(UserSchema, many=True) + collaborators = fields.List(fields.Nested(UserSchema)) .. _specifying-nested-fields: Specifying Which Fields to Nest ------------------------------- -You can explicitly specify which attributes of the nested objects you want to serialize with the ``only`` argument. +You can explicitly specify which attributes of the nested objects you want to (de)serialize with the ``only`` argument to the schema. .. code-block:: python class BlogSchema2(Schema): title = fields.String() - author = fields.Nested(UserSchema, only=["email"]) + author = fields.Nested(UserSchema(only=("email",))) schema = BlogSchema2() @@ -81,7 +81,7 @@ You can explicitly specify which attributes of the nested objects you want to se # 'author': {'email': u'monty@python.org'} # } -You can represent the attributes of deeply nested objects using dot delimiters. +Dotted paths may be passed to ``only`` and ``exclude`` to specify nested attributes. .. code-block:: python @@ -89,7 +89,7 @@ You can represent the attributes of deeply nested objects using dot delimiters. blog = fields.Nested(BlogSchema2) - schema = SiteSchema(only=["blog.author.email"]) + schema = SiteSchema(only=("blog.author.email",)) result = schema.dump(site) pprint(result) # { @@ -125,8 +125,6 @@ You can replace nested data with a single value (or flat list of values if ``man # } -You can also exclude fields by passing in an ``exclude`` list. This argument also allows representing the attributes of deeply nested objects using dot delimiters. - .. _partial-loading: Partial Loading @@ -168,27 +166,29 @@ You can specify a subset of the fields to allow partial loading using dot delimi Two-way Nesting --------------- -If you have two objects that nest each other, you can refer to a nested schema by its class name. This allows you to nest Schemas that have not yet been defined. - +If you have two objects that nest each other, you can pass a callable to `Nested `. +This allows you to resolve order-of-declaration issues, such as when one schema nests a schema that is declared below it. -For example, a representation of an ``Author`` model might include the books that have a foreign-key (many-to-one) relationship to it. Correspondingly, a representation of a ``Book`` will include its author representation. +For example, a representation of an ``Author`` model might include the books that have a many-to-one relationship to it. +Correspondingly, a representation of a ``Book`` will include its author representation. .. code-block:: python - class AuthorSchema(Schema): - # Make sure to use the 'only' or 'exclude' params + class BookSchema(Schema): + id = fields.Int(dump_only=True) + title = fields.Str() + + # Make sure to use the 'only' or 'exclude' # to avoid infinite recursion - books = fields.Nested("BookSchema", many=True, exclude=("author",)) + author = fields.Nested(lambda: AuthorSchema(only=("id", "title"))) - class Meta: - fields = ("id", "name", "books") + class AuthorSchema(Schema): + id = fields.Int(dump_only=True) + title = fields.Str() - class BookSchema(Schema): - author = fields.Nested(AuthorSchema, only=("id", "name")) + books = fields.List(fields.Nested(BookSchema(exclude=("author",)))) - class Meta: - fields = ("id", "title", "author") .. code-block:: python @@ -221,27 +221,54 @@ For example, a representation of an ``Author`` model might include the books tha # ] # } +You can also pass a class name as a string to `Nested `. +This is useful for avoiding circular imports when your schemas are located in different modules. + +.. code-block:: python + + # books.py + from marshmallow import Schema, fields + + + class BookSchema(Schema): + id = fields.Int(dump_only=True) + title = fields.Str() + + author = fields.Nested("AuthorSchema", only=("id", "title")) + +.. code-block:: python + + # authors.py + from marshmallow import Schema, fields + + + class AuthorSchema(Schema): + id = fields.Int(dump_only=True) + title = fields.Str() + + books = fields.List(fields.Nested("BookSchema", exclude=("author",))) + .. note:: - If you need to, you can also pass the full, module-qualified path to `fields.Nested`. :: - books = fields.Nested('path.to.BookSchema', - many=True, exclude=('author', )) + If you have multiple schemas with the same class name, you must pass the full, module-qualified path. :: + + author = fields.Nested("authors.BookSchema", only=("id", "title")) .. _self-nesting: Nesting A Schema Within Itself ------------------------------ -If the object to be marshalled has a relationship to an object of the same type, you can nest the `Schema` within itself by passing ``"self"`` (with quotes) to the :class:`Nested ` constructor. +If the object to be marshalled has a relationship to an object of the same type, you can nest the `Schema` within itself by passing a callable that returns an instance of the same schema. .. code-block:: python class UserSchema(Schema): name = fields.String() email = fields.Email() - friends = fields.Nested("self", many=True) # Use the 'exclude' argument to avoid infinite recursion - employer = fields.Nested("self", exclude=("employer",), default=None) + employer = fields.Nested(lambda: UserSchema(exclude=("employer",))) + friends = fields.List(fields.Nested(lambda: UserSchema())) user = User("Steve", "steve@example.com") diff --git a/docs/upgrading.rst b/docs/upgrading.rst index c01d13a9d..ea7d6f9d2 100644 --- a/docs/upgrading.rst +++ b/docs/upgrading.rst @@ -3,6 +3,55 @@ Upgrading to Newer Releases This section documents migration paths to new releases. +Upgrading to 3.3 +++++++++++++++++ + +In 3.3, `fields.Nested ` may take a callable that returns a schema instance. +Use this to resolve order-of-declaration issues when schemas nest each other. + +.. code-block:: python + + from marshmallow import Schema, fields + + # <3.3 + class AlbumSchema(Schema): + title = fields.Str() + artist = fields.Nested("ArtistSchema", only=("name",)) + + + class ArtistSchema(Schema): + name = fields.Str() + albums = fields.List(fields.Nested(AlbumSchema)) + + + # >=3.3 + class AlbumSchema(Schema): + title = fields.Str() + artist = fields.Nested(lambda: ArtistSchema(only=("name",))) + + + class ArtistSchema(Schema): + name = fields.Str() + albums = fields.List(fields.Nested(AlbumSchema)) + +A callable should also be used when nesting a schema within itself. +Passing ``"self"`` is deprecated. + +.. code-block:: python + + from marshmallow import Schema, fields + + # <3.3 + class PersonSchema(Schema): + partner = fields.Nested("self", exclude=("partner",)) + friends = fields.List(fields.Nested("self")) + + + # >=3.3 + class PersonSchema(Schema): + partner = fields.Nested(lambda: PersonSchema(exclude=("partner"))) + friends = fields.List(fields.Nested(lambda: PersonSchema())) + .. _upgrading_3_0: Upgrading to 3.0 diff --git a/docs/why.rst b/docs/why.rst index 08444e301..9f86bbaa1 100644 --- a/docs/why.rst +++ b/docs/why.rst @@ -33,8 +33,8 @@ As an example, you might have a JSON endpoint for retrieving all information abo class GameStateSchema(Schema): _id = fields.UUID(required=True) - players = fields.Nested(PlayerSchema, many=True) score = fields.Nested(ScoreSchema) + players = fields.List(fields.Nested(PlayerSchema)) last_changed = fields.DateTime(format="rfc") class Meta: diff --git a/examples/peewee_example.py b/examples/peewee_example.py index a28c4fefe..d4da8bf92 100644 --- a/examples/peewee_example.py +++ b/examples/peewee_example.py @@ -77,7 +77,7 @@ def wrap(self, data, many, **kwargs): class TodoSchema(Schema): id = fields.Int(dump_only=True) done = fields.Boolean(attribute="is_done", missing=False) - user = fields.Nested(UserSchema, exclude=("joined_on", "password"), dump_only=True) + user = fields.Nested(UserSchema(exclude=("joined_on", "password")), dump_only=True) content = fields.Str(required=True) posted_on = fields.DateTime(dump_only=True) diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py index 003c33ae8..95392e75a 100644 --- a/src/marshmallow/fields.py +++ b/src/marshmallow/fields.py @@ -437,10 +437,19 @@ class Nested(Field): Examples: :: - user = fields.Nested(UserSchema) - user2 = fields.Nested('UserSchema') # Equivalent to above - collaborators = fields.Nested(UserSchema, many=True, only=('id',)) - parent = fields.Nested('self') + class ChildSchema(Schema): + id = fields.Str() + name = fields.Str() + # Use lambda functions when you need two-way nesting or self-nesting + parent = fields.Nested(lambda: ParentSchema(only=("id",)), dump_only=True) + siblings = fields.List(fields.Nested(lambda: ChildSchema(only=("id", "name")))) + + class ParentSchema(Schema): + id = fields.Str() + children = fields.List( + fields.Nested(ChildSchema(only=("id", "parent", "siblings"))) + ) + spouse = fields.Nested(lambda: ParentSchema(only=("id",))) When passing a `Schema ` instance as the first argument, the instance's ``exclude``, ``only``, and ``many`` attributes will be respected. @@ -471,7 +480,7 @@ class Nested(Field): def __init__( self, - nested: typing.Union[SchemaABC, type, str], + nested: typing.Union[SchemaABC, type, str, typing.Callable[[], SchemaABC]], *, default: typing.Any = missing_, only: types.StrSequenceOrSet = None, @@ -487,6 +496,12 @@ def __init__( raise StringNotCollectionError( '"exclude" should be a collection of strings.' ) + if nested == "self": + warnings.warn( + "Passing 'self' to `Nested` is deprecated. " + "Use `Nested(lambda: MySchema(...))` instead.", + DeprecationWarning, + ) self.nested = nested self.only = only self.exclude = exclude @@ -505,8 +520,13 @@ def schema(self): if not self._schema: # Inherit context from parent. context = getattr(self.parent, "context", {}) - if isinstance(self.nested, SchemaABC): - self._schema = copy.copy(self.nested) + if callable(self.nested) and not isinstance(self.nested, type): + nested = self.nested() + else: + nested = self.nested + + if isinstance(nested, SchemaABC): + self._schema = copy.copy(nested) self._schema.context.update(context) # Respect only and exclude passed from parent and re-initialize fields set_class = self._schema.set_class @@ -521,20 +541,17 @@ def schema(self): 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 - elif not isinstance(self.nested, (str, bytes)): + if isinstance(nested, type) and issubclass(nested, SchemaABC): + schema_class = nested + elif not isinstance(nested, (str, bytes)): raise ValueError( - "Nested fields must be passed a " - "Schema, not {}.".format(self.nested.__class__) + "`Nested` fields must be passed a " + "`Schema`, not {}.".format(nested.__class__) ) - elif self.nested == "self": - ret = self - while not isinstance(ret, SchemaABC): - ret = ret.parent - schema_class = ret.__class__ + elif nested == "self": + schema_class = self.root.__class__ else: - schema_class = class_registry.get_class(self.nested) + schema_class = class_registry.get_class(nested) self._schema = schema_class( many=self.many, only=self.only, diff --git a/tests/base.py b/tests/base.py index 438221661..b2ffa87cc 100644 --- a/tests/base.py +++ b/tests/base.py @@ -275,21 +275,21 @@ class UserRelativeUrlSchema(UserSchema): class BlogSchema(Schema): title = fields.String() user = fields.Nested(UserSchema) - collaborators = fields.Nested(UserSchema, many=True) + collaborators = fields.List(fields.Nested(UserSchema())) categories = fields.List(fields.String) id = fields.String() class BlogUserMetaSchema(Schema): user = fields.Nested(UserMetaSchema()) - collaborators = fields.Nested(UserMetaSchema, many=True) + collaborators = fields.List(fields.Nested(UserMetaSchema())) class BlogSchemaMeta(Schema): """Same as BlogSerializer but using ``fields`` options.""" user = fields.Nested(UserSchema) - collaborators = fields.Nested(UserSchema, many=True) + collaborators = fields.List(fields.Nested(UserSchema())) class Meta: fields = ("title", "user", "collaborators", "categories", "id") @@ -298,7 +298,7 @@ class Meta: class BlogOnlySchema(Schema): title = fields.String() user = fields.Nested(UserSchema) - collaborators = fields.Nested(UserSchema, only=("id",), many=True) + collaborators = fields.List(fields.Nested(UserSchema(only=("id",)))) class BlogSchemaExclude(BlogSchema): diff --git a/tests/test_fields.py b/tests/test_fields.py index 846e1611c..8671be959 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -377,6 +377,34 @@ class Family(Schema): "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_lambda_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(lambda: 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_partial_propagated_to_nested(self): class Child(Schema): name = fields.String(required=True) diff --git a/tests/test_schema.py b/tests/test_schema.py index 2be35b775..2106e2352 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -1136,11 +1136,9 @@ class Meta: def test_nested_custom_set_not_implementing_getitem(): - """ - This test checks that Marshmallow can serialize implementations of - :mod:`collections.abc.MutableSequence`, with ``__getitem__`` arguments - that are not integers. - """ + # This test checks that marshmallow can serialize implementations of + # :mod:`collections.abc.MutableSequence`, with ``__getitem__`` arguments + # that are not integers. class ListLikeParent: """ @@ -1226,6 +1224,55 @@ class ParentSchema(Schema): assert "bah" not in grand_child +def test_nested_lambda(): + class ChildSchema(Schema): + id = fields.Str() + name = fields.Str() + parent = fields.Nested(lambda: ParentSchema(only=("id",)), dump_only=True) + siblings = fields.List(fields.Nested(lambda: ChildSchema(only=("id", "name")))) + + class ParentSchema(Schema): + id = fields.Str() + spouse = fields.Nested(lambda: ParentSchema(only=("id",))) + children = fields.List( + fields.Nested(lambda: ChildSchema(only=("id", "parent", "siblings"))) + ) + + sch = ParentSchema() + data_to_load = { + "id": "p1", + "spouse": {"id": "p2"}, + "children": [{"id": "c1", "siblings": [{"id": "c2", "name": "sis"}]}], + } + loaded = sch.load(data_to_load) + assert loaded == data_to_load + + data_to_dump = dict( + id="p2", + spouse=dict(id="p2"), + children=[ + dict( + id="c1", + name="bar", + parent=dict(id="p2"), + siblings=[dict(id="c2", name="sis")], + ) + ], + ) + dumped = sch.dump(data_to_dump) + assert dumped == { + "id": "p2", + "spouse": {"id": "p2"}, + "children": [ + { + "id": "c1", + "parent": {"id": "p2"}, + "siblings": [{"id": "c2", "name": "sis"}], + } + ], + } + + @pytest.mark.parametrize("data_key", ("f1", "f5", None)) def test_data_key_collision(data_key): class MySchema(Schema): @@ -2163,13 +2210,15 @@ def employer(self): def user(self, employer): return User(name="Tom", employer=employer, age=28) - def test_nesting_schema_within_itself(self, user, employer): - class SelfSchema(Schema): - name = fields.String() - age = fields.Integer() - employer = fields.Nested("self", exclude=("employer",)) + def test_nesting_schema_by_passing_lambda(self, user, employer): + class SelfReferencingSchema(Schema): + name = fields.Str() + age = fields.Int() + employer = fields.Nested( + lambda: SelfReferencingSchema(exclude=("employer",)) + ) - data = SelfSchema().dump(user) + data = SelfReferencingSchema().dump(user) assert data["name"] == user.name assert data["employer"]["name"] == employer.name assert data["employer"]["age"] == employer.age @@ -2185,9 +2234,24 @@ class SelfReferencingSchema(Schema): assert data["employer"]["name"] == employer.name assert data["employer"]["age"] == employer.age + def test_nesting_schema_self_string(self, user, employer): + with pytest.warns( + DeprecationWarning, match="Passing 'self' to `Nested` is deprecated" + ): + + class SelfSchema(Schema): + name = fields.String() + age = fields.Integer() + employer = fields.Nested("self", exclude=("employer",)) + + data = SelfSchema().dump(user) + assert data["name"] == user.name + assert data["employer"]["name"] == employer.name + assert data["employer"]["age"] == employer.age + def test_nesting_within_itself_meta(self, user, employer): class SelfSchema(Schema): - employer = fields.Nested("self", exclude=("employer",)) + employer = fields.Nested(lambda: SelfSchema(exclude=("employer",))) class Meta: additional = ("name", "age") @@ -2200,7 +2264,7 @@ class Meta: def test_nested_self_with_only_param(self, user, employer): class SelfSchema(Schema): - employer = fields.Nested("self", only=("name",)) + employer = fields.Nested(lambda: SelfSchema(only=("name",))) class Meta: fields = ("name", "employer") @@ -2210,10 +2274,14 @@ class Meta: assert data["employer"]["name"] == employer.name assert "age" not in data["employer"] - def test_multiple_pluck_self_field(self, user): + def test_multiple_pluck_self_lambda(self, user): class MultipleSelfSchema(Schema): - emp = fields.Pluck("self", "name", attribute="employer") - rels = fields.Pluck("self", "name", many=True, attribute="relatives") + emp = fields.Pluck( + lambda: MultipleSelfSchema(), "name", attribute="employer" + ) + rels = fields.Pluck( + lambda: MultipleSelfSchema(), "name", many=True, attribute="relatives" + ) class Meta: fields = ("name", "emp", "rels") @@ -2225,9 +2293,28 @@ class Meta: relative = data["rels"][0] assert relative == user.relatives[0].name - def test_nested_self_many(self): + def test_multiple_pluck_self_string(self, user): + with pytest.warns( + DeprecationWarning, match="Passing 'self' to `Nested` is deprecated" + ): + + class MultipleSelfSchema(Schema): + emp = fields.Pluck("self", "name", attribute="employer") + rels = fields.Pluck("self", "name", many=True, attribute="relatives") + + class Meta: + fields = ("name", "emp", "rels") + + schema = MultipleSelfSchema() + user.relatives = [User(name="Bar", age=12), User(name="Baz", age=34)] + data = schema.dump(user) + assert len(data["rels"]) == len(user.relatives) + relative = data["rels"][0] + assert relative == user.relatives[0].name + + def test_nested_self_many_lambda(self): class SelfManySchema(Schema): - relatives = fields.Nested("self", many=True) + relatives = fields.Nested(lambda: SelfManySchema(), many=True) class Meta: additional = ("name", "age") @@ -2240,9 +2327,28 @@ class Meta: assert data["relatives"][0]["name"] == person.relatives[0].name assert data["relatives"][0]["age"] == person.relatives[0].age + def test_nested_self_many_string(self): + with pytest.warns( + DeprecationWarning, match="Passing 'self' to `Nested` is deprecated" + ): + + class SelfManySchema(Schema): + relatives = fields.Nested("self", many=True) + + class Meta: + additional = ("name", "age") + + person = User(name="Foo") + person.relatives = [User(name="Bar", age=12), User(name="Baz", age=34)] + data = SelfManySchema().dump(person) + assert data["name"] == person.name + assert len(data["relatives"]) == len(person.relatives) + assert data["relatives"][0]["name"] == person.relatives[0].name + assert data["relatives"][0]["age"] == person.relatives[0].age + def test_nested_self_list(self): class SelfListSchema(Schema): - relatives = fields.List(fields.Nested("self")) + relatives = fields.List(fields.Nested(lambda: SelfListSchema())) class Meta: additional = ("name", "age") @@ -2255,6 +2361,25 @@ class Meta: assert data["relatives"][0]["name"] == person.relatives[0].name assert data["relatives"][0]["age"] == person.relatives[0].age + def test_nested_self_list_string(self): + with pytest.warns( + DeprecationWarning, match="Passing 'self' to `Nested` is deprecated" + ): + + class SelfListSchema(Schema): + relatives = fields.List(fields.Nested("self")) + + class Meta: + additional = ("name", "age") + + person = User(name="Foo") + person.relatives = [User(name="Bar", age=12), User(name="Baz", age=34)] + data = SelfListSchema().dump(person) + assert data["name"] == person.name + assert len(data["relatives"]) == len(person.relatives) + assert data["relatives"][0]["name"] == person.relatives[0].name + assert data["relatives"][0]["age"] == person.relatives[0].age + class RequiredUserSchema(Schema): name = fields.Field(required=True)