Skip to content

Commit c648c1e

Browse files
authoredFeb 11, 2025··
Isolate Recipe defaults to prevent modification via instances (#509)
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.
1 parent c0af32a commit c648c1e

File tree

2 files changed

+37
-0
lines changed

2 files changed

+37
-0
lines changed
 

‎model_bakery/recipe.py

+3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import collections
2+
import copy
23
import itertools
34
from typing import (
45
Any,
@@ -79,6 +80,8 @@ def _mapping( # noqa: C901
7980
mapping[k] = v.recipe.prepare(_using=_using, **recipe_attrs)
8081
elif isinstance(v, related):
8182
mapping[k] = v.make
83+
elif isinstance(v, collections.abc.Container):
84+
mapping[k] = copy.deepcopy(v)
8285

8386
mapping.update(new_attrs)
8487
mapping.update(rel_fields_attrs)

‎tests/test_recipes.py

+34
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from random import choice # noqa
55
from unittest.mock import patch
66

7+
from django.db import connection
78
from django.utils.timezone import now
89

910
import pytest
@@ -34,6 +35,7 @@
3435
"blog": "https://joe.example.com",
3536
"days_since_last_login": 4,
3637
"birth_time": now(),
38+
"data": {"one": 1},
3739
}
3840
person_recipe = Recipe(Person, **recipe_attrs)
3941
user_recipe = Recipe(User)
@@ -68,6 +70,8 @@ def test_flat_model_make_recipe_with_the_correct_attributes(self):
6870
assert person.appointment == recipe_attrs["appointment"]
6971
assert person.blog == recipe_attrs["blog"]
7072
assert person.days_since_last_login == recipe_attrs["days_since_last_login"]
73+
assert person.data is not recipe_attrs["data"]
74+
assert person.data == recipe_attrs["data"]
7175
assert person.id is not None
7276

7377
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):
8084
assert person.appointment == recipe_attrs["appointment"]
8185
assert person.blog == recipe_attrs["blog"]
8286
assert person.days_since_last_login == recipe_attrs["days_since_last_login"]
87+
assert person.data is not recipe_attrs["data"]
88+
assert person.data == recipe_attrs["data"]
8389
assert person.id is None
8490

8591
def test_accepts_callable(self):
@@ -171,6 +177,34 @@ def test_defining_recipes_str(self):
171177
except AttributeError as e:
172178
pytest.fail(f"{e}")
173179

180+
def test_recipe_dict_attribute_isolation(self):
181+
person1 = person_recipe.make()
182+
person2 = person_recipe.make()
183+
person2.data["two"] = 2
184+
person3 = person_recipe.make()
185+
186+
# Mutation on instances must have no side effect on their recipe definition,
187+
# or on other instances of the same recipe.
188+
assert person1.data == {"one": 1}
189+
assert person2.data == {"one": 1, "two": 2}
190+
assert person3.data == {"one": 1}
191+
192+
@pytest.mark.skipif(
193+
connection.vendor != "postgresql", reason="PostgreSQL specific tests"
194+
)
195+
def test_recipe_list_attribute_isolation(self):
196+
pg_person_recipe = person_recipe.extend(acquaintances=[1, 2, 3])
197+
person1 = pg_person_recipe.make()
198+
person2 = pg_person_recipe.make()
199+
person2.acquaintances.append(4)
200+
person3 = pg_person_recipe.make()
201+
202+
# Mutation on instances must have no side effect on their recipe definition,
203+
# or on other instances of the same recipe.
204+
assert person1.acquaintances == [1, 2, 3]
205+
assert person2.acquaintances == [1, 2, 3, 4]
206+
assert person3.acquaintances == [1, 2, 3]
207+
174208

175209
@pytest.mark.django_db
176210
class TestExecutingRecipes:

0 commit comments

Comments
 (0)