Skip to content

Commit 3fc8f08

Browse files
vidigoatanakin87
andauthored
fix: prevent Answer.from_dict methods from mutating their input dict (#11908)
Co-authored-by: Stefano Fiorucci <stefanofiorucci@gmail.com>
1 parent 8812223 commit 3fc8f08

3 files changed

Lines changed: 74 additions & 8 deletions

File tree

haystack/dataclasses/answer.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,16 +79,20 @@ def from_dict(cls, data: dict[str, Any]) -> "ExtractedAnswer":
7979
:returns:
8080
Deserialized object.
8181
"""
82+
# Shallow-copy the init parameters so `from_dict` stays side-effect free: the nested
83+
# replacements below otherwise mutate the caller's dict in place, corrupting it for reuse
84+
# (a second deserialization of the same dict would then receive already-parsed objects).
8285
init_params = data.get("init_parameters", {})
86+
new_params = dict(init_params)
8387
if (doc := init_params.get("document")) is not None:
84-
data["init_parameters"]["document"] = Document.from_dict(doc)
88+
new_params["document"] = Document.from_dict(doc)
8589

8690
if (offset := init_params.get("document_offset")) is not None:
87-
data["init_parameters"]["document_offset"] = ExtractedAnswer.Span(**offset)
91+
new_params["document_offset"] = ExtractedAnswer.Span(**offset)
8892

8993
if (offset := init_params.get("context_offset")) is not None:
90-
data["init_parameters"]["context_offset"] = ExtractedAnswer.Span(**offset)
91-
return default_from_dict(cls, data)
94+
new_params["context_offset"] = ExtractedAnswer.Span(**offset)
95+
return default_from_dict(cls, {**data, "init_parameters": new_params})
9296

9397

9498
@_warn_on_inplace_mutation
@@ -133,14 +137,20 @@ def from_dict(cls, data: dict[str, Any]) -> "GeneratedAnswer":
133137
:returns:
134138
Deserialized object.
135139
"""
140+
# Shallow-copy the init parameters so `from_dict` stays side-effect free: the nested
141+
# replacements below otherwise mutate the caller's dict in place, corrupting it for reuse
142+
# (a second deserialization of the same dict would then receive already-parsed objects).
136143
init_params = data.get("init_parameters", {})
144+
new_params = dict(init_params)
137145

138146
if (documents := init_params.get("documents")) is not None:
139-
init_params["documents"] = [Document.from_dict(d) for d in documents]
147+
new_params["documents"] = [Document.from_dict(d) for d in documents]
140148

141-
meta = init_params.get("meta", {})
149+
# Shallow-copy `meta` before touching `all_messages` so the caller's nested dict is
150+
# left untouched as well.
151+
meta = dict(init_params.get("meta", {}))
142152
if (all_messages := meta.get("all_messages")) and isinstance(all_messages[0], dict):
143153
meta["all_messages"] = [ChatMessage.from_dict(m) for m in all_messages]
144-
init_params["meta"] = meta
154+
new_params["meta"] = meta
145155

146-
return default_from_dict(cls, data)
156+
return default_from_dict(cls, {**data, "init_parameters": new_params})
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
fixes:
3+
- |
4+
Fixed ``GeneratedAnswer.from_dict`` and ``ExtractedAnswer.from_dict`` mutating their input
5+
dictionary in place. They replaced nested values (``documents``, ``document``, offsets and
6+
``meta["all_messages"]``) directly inside the passed dictionary, so deserializing the same
7+
dictionary a second time received already-parsed objects and raised an ``AttributeError``.
8+
Both methods now copy their input before deserializing and are side-effect free.

test/dataclasses/test_answer.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# SPDX-License-Identifier: Apache-2.0
44

55
import warnings
6+
from copy import deepcopy
67

78
import pytest
89

@@ -101,6 +102,30 @@ def test_from_dict(self):
101102
assert answer.context_offset == ExtractedAnswer.Span(14, 16)
102103
assert answer.meta == {"meta_key": "meta_value"}
103104

105+
def test_from_dict_does_not_mutate_input(self):
106+
data = {
107+
"type": "haystack.dataclasses.answer.ExtractedAnswer",
108+
"init_parameters": {
109+
"data": "42",
110+
"query": "What is the answer?",
111+
"document": {
112+
"id": "8f800a524b139484fc719ecc35f971a080de87618319bc4836b784d69baca57f",
113+
"content": "I thought a lot about this. The answer is 42.",
114+
},
115+
"context": "The answer is 42.",
116+
"score": 1.0,
117+
"document_offset": {"start": 42, "end": 44},
118+
"context_offset": {"start": 14, "end": 16},
119+
"meta": {"meta_key": "meta_value"},
120+
},
121+
}
122+
snapshot = deepcopy(data)
123+
first = ExtractedAnswer.from_dict(data)
124+
# from_dict must not mutate its input dictionary
125+
assert data == snapshot
126+
# deserializing the same dictionary again must still work and be equal
127+
assert ExtractedAnswer.from_dict(data) == first
128+
104129
def test_no_warning_on_init(self):
105130
with warnings.catch_warnings():
106131
warnings.simplefilter("error", Warning)
@@ -264,6 +289,29 @@ def test_from_dict_with_chat_message_in_meta(self):
264289
assert answer.meta["meta_key"] == "meta_value"
265290
assert answer.meta["all_messages"] == [ChatMessage.from_user("What is the answer?")]
266291

292+
def test_from_dict_does_not_mutate_input(self):
293+
data = {
294+
"type": "haystack.dataclasses.answer.GeneratedAnswer",
295+
"init_parameters": {
296+
"data": "42",
297+
"query": "What is the answer?",
298+
"documents": [
299+
{"id": "1", "content": "The answer is 42."},
300+
{"id": "2", "content": "I believe the answer is 42."},
301+
],
302+
"meta": {
303+
"meta_key": "meta_value",
304+
"all_messages": [ChatMessage.from_user("What is the answer?").to_dict()],
305+
},
306+
},
307+
}
308+
snapshot = deepcopy(data)
309+
first = GeneratedAnswer.from_dict(data)
310+
# from_dict must not mutate its input dictionary
311+
assert data == snapshot
312+
# deserializing the same dictionary again must still work and be equal
313+
assert GeneratedAnswer.from_dict(data) == first
314+
267315
def test_from_dict_with_empty_all_messages(self):
268316
# An empty `all_messages` list must not crash deserialization: `is not None`
269317
# let `[]` through and then indexed `all_messages[0]`, raising IndexError.

0 commit comments

Comments
 (0)