From fb3a1eb7941d40ddcd66f6ff812405e05d1ea76f Mon Sep 17 00:00:00 2001 From: Gilles Cornu Date: Tue, 10 Dec 2024 14:48:23 +0100 Subject: [PATCH] Isolate Recipe defaults to prevent modification via instances With this change, every baked instance holds an isolated copy of the recipe default attributes of any Container class (e.g., dict or list). This prevents from affecting the parent recipe, and other (existing or future) instances, when such fields are modified on an instance. --- model_bakery/recipe.py | 3 +++ tests/test_recipes.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/model_bakery/recipe.py b/model_bakery/recipe.py index d2f11e7e..039afbc9 100644 --- a/model_bakery/recipe.py +++ b/model_bakery/recipe.py @@ -1,4 +1,5 @@ import collections +import copy import itertools from typing import ( Any, @@ -79,6 +80,8 @@ def _mapping( # noqa: C901 mapping[k] = v.recipe.prepare(_using=_using, **recipe_attrs) elif isinstance(v, related): mapping[k] = v.make + elif isinstance(v, collections.abc.Container): + mapping[k] = copy.deepcopy(v) mapping.update(new_attrs) mapping.update(rel_fields_attrs) diff --git a/tests/test_recipes.py b/tests/test_recipes.py index 077fe850..82c18f75 100644 --- a/tests/test_recipes.py +++ b/tests/test_recipes.py @@ -4,6 +4,7 @@ from random import choice # noqa from unittest.mock import patch +from django.db import connection from django.utils.timezone import now import pytest @@ -34,6 +35,7 @@ "blog": "https://joe.example.com", "days_since_last_login": 4, "birth_time": now(), + "data": {"one": 1}, } person_recipe = Recipe(Person, **recipe_attrs) user_recipe = Recipe(User) @@ -68,6 +70,8 @@ def test_flat_model_make_recipe_with_the_correct_attributes(self): assert person.appointment == recipe_attrs["appointment"] assert person.blog == recipe_attrs["blog"] assert person.days_since_last_login == recipe_attrs["days_since_last_login"] + assert person.data is not recipe_attrs["data"] + assert person.data == recipe_attrs["data"] assert person.id is not None def test_flat_model_prepare_recipe_with_the_correct_attributes(self): @@ -80,6 +84,8 @@ def test_flat_model_prepare_recipe_with_the_correct_attributes(self): assert person.appointment == recipe_attrs["appointment"] assert person.blog == recipe_attrs["blog"] assert person.days_since_last_login == recipe_attrs["days_since_last_login"] + assert person.data is not recipe_attrs["data"] + assert person.data == recipe_attrs["data"] assert person.id is None def test_accepts_callable(self): @@ -171,6 +177,34 @@ def test_defining_recipes_str(self): except AttributeError as e: pytest.fail(f"{e}") + def test_recipe_dict_attribute_isolation(self): + person1 = person_recipe.make() + person2 = person_recipe.make() + person2.data["two"] = 2 + person3 = person_recipe.make() + + # Mutation on instances must have no side effect on their recipe definition, + # or on other instances of the same recipe. + assert person1.data == {"one": 1} + assert person2.data == {"one": 1, "two": 2} + assert person3.data == {"one": 1} + + @pytest.mark.skipif( + connection.vendor != "postgresql", reason="PostgreSQL specific tests" + ) + def test_recipe_list_attribute_isolation(self): + pg_person_recipe = person_recipe.extend(acquaintances=[1, 2, 3]) + person1 = pg_person_recipe.make() + person2 = pg_person_recipe.make() + person2.acquaintances.append(4) + person3 = pg_person_recipe.make() + + # Mutation on instances must have no side effect on their recipe definition, + # or on other instances of the same recipe. + assert person1.acquaintances == [1, 2, 3] + assert person2.acquaintances == [1, 2, 3, 4] + assert person3.acquaintances == [1, 2, 3] + @pytest.mark.django_db class TestExecutingRecipes: