From 49b5361aea1089a9eeacbb906ac8dc57159f3bd4 Mon Sep 17 00:00:00 2001 From: Vidit Patankar Date: Wed, 8 Jul 2026 13:29:21 +0530 Subject: [PATCH 1/4] fix: prevent GeneratedAnswer.from_dict and ExtractedAnswer.from_dict from mutating their input dict Both from_dict methods wrote parsed Document/ChatMessage/Span objects back into the caller's dictionary, so deserializing the same dict a second time received already-parsed objects and raised AttributeError. Document.from_dict already guards against this with data.copy(); this extends the same idea with deepcopy, since the mutated values are nested. Adds regression tests for both classes. --- haystack/dataclasses/answer.py | 9 ++++ ...-dict-input-mutation-ac713885d47e54d6.yaml | 8 ++++ test/dataclasses/test_answer.py | 48 +++++++++++++++++++ 3 files changed, 65 insertions(+) create mode 100644 releasenotes/notes/fix-answer-from-dict-input-mutation-ac713885d47e54d6.yaml diff --git a/haystack/dataclasses/answer.py b/haystack/dataclasses/answer.py index ae48465b405..3ddda57c0a9 100644 --- a/haystack/dataclasses/answer.py +++ b/haystack/dataclasses/answer.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: Apache-2.0 +from copy import deepcopy from dataclasses import asdict, dataclass, field from typing import Any, Optional, Protocol, runtime_checkable @@ -79,6 +80,10 @@ def from_dict(cls, data: dict[str, Any]) -> "ExtractedAnswer": :returns: Deserialized object. """ + # Deep-copy so `from_dict` stays side-effect free: the nested replacements below + # otherwise mutate the caller's dict in place, corrupting it for reuse (a second + # deserialization of the same dict would then receive already-parsed objects). + data = deepcopy(data) init_params = data.get("init_parameters", {}) if (doc := init_params.get("document")) is not None: data["init_parameters"]["document"] = Document.from_dict(doc) @@ -133,6 +138,10 @@ def from_dict(cls, data: dict[str, Any]) -> "GeneratedAnswer": :returns: Deserialized object. """ + # Deep-copy so `from_dict` stays side-effect free: the nested replacements below + # otherwise mutate the caller's dict in place, corrupting it for reuse (a second + # deserialization of the same dict would then receive already-parsed objects). + data = deepcopy(data) init_params = data.get("init_parameters", {}) if (documents := init_params.get("documents")) is not None: diff --git a/releasenotes/notes/fix-answer-from-dict-input-mutation-ac713885d47e54d6.yaml b/releasenotes/notes/fix-answer-from-dict-input-mutation-ac713885d47e54d6.yaml new file mode 100644 index 00000000000..bcd3b81e572 --- /dev/null +++ b/releasenotes/notes/fix-answer-from-dict-input-mutation-ac713885d47e54d6.yaml @@ -0,0 +1,8 @@ +--- +fixes: + - | + Fixed `GeneratedAnswer.from_dict` and `ExtractedAnswer.from_dict` mutating their input + dictionary in place. They replaced nested values (`documents`, `document`, offsets and + `meta["all_messages"]`) directly inside the passed dictionary, so deserializing the same + dictionary a second time received already-parsed objects and raised an ``AttributeError``. + Both methods now deep-copy their input and are side-effect free. diff --git a/test/dataclasses/test_answer.py b/test/dataclasses/test_answer.py index fa41d635718..f6bfc95dca3 100644 --- a/test/dataclasses/test_answer.py +++ b/test/dataclasses/test_answer.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: Apache-2.0 import warnings +from copy import deepcopy import pytest @@ -101,6 +102,30 @@ def test_from_dict(self): assert answer.context_offset == ExtractedAnswer.Span(14, 16) assert answer.meta == {"meta_key": "meta_value"} + def test_from_dict_does_not_mutate_input(self): + data = { + "type": "haystack.dataclasses.answer.ExtractedAnswer", + "init_parameters": { + "data": "42", + "query": "What is the answer?", + "document": { + "id": "8f800a524b139484fc719ecc35f971a080de87618319bc4836b784d69baca57f", + "content": "I thought a lot about this. The answer is 42.", + }, + "context": "The answer is 42.", + "score": 1.0, + "document_offset": {"start": 42, "end": 44}, + "context_offset": {"start": 14, "end": 16}, + "meta": {"meta_key": "meta_value"}, + }, + } + snapshot = deepcopy(data) + first = ExtractedAnswer.from_dict(data) + # from_dict must not mutate its input dictionary + assert data == snapshot + # deserializing the same dictionary again must still work and be equal + assert ExtractedAnswer.from_dict(data) == first + def test_no_warning_on_init(self): with warnings.catch_warnings(): warnings.simplefilter("error", Warning) @@ -264,6 +289,29 @@ def test_from_dict_with_chat_message_in_meta(self): assert answer.meta["meta_key"] == "meta_value" assert answer.meta["all_messages"] == [ChatMessage.from_user("What is the answer?")] + def test_from_dict_does_not_mutate_input(self): + data = { + "type": "haystack.dataclasses.answer.GeneratedAnswer", + "init_parameters": { + "data": "42", + "query": "What is the answer?", + "documents": [ + {"id": "1", "content": "The answer is 42."}, + {"id": "2", "content": "I believe the answer is 42."}, + ], + "meta": { + "meta_key": "meta_value", + "all_messages": [ChatMessage.from_user("What is the answer?").to_dict()], + }, + }, + } + snapshot = deepcopy(data) + first = GeneratedAnswer.from_dict(data) + # from_dict must not mutate its input dictionary + assert data == snapshot + # deserializing the same dictionary again must still work and be equal + assert GeneratedAnswer.from_dict(data) == first + def test_no_warning_on_init(self): with warnings.catch_warnings(): warnings.simplefilter("error", Warning) From b0e324013cad49077bce14b5ca21246ab073197b Mon Sep 17 00:00:00 2001 From: Vidit Patankar Date: Wed, 8 Jul 2026 17:05:41 +0530 Subject: [PATCH 2/4] Use RST double backticks in release note --- ...ix-answer-from-dict-input-mutation-ac713885d47e54d6.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/releasenotes/notes/fix-answer-from-dict-input-mutation-ac713885d47e54d6.yaml b/releasenotes/notes/fix-answer-from-dict-input-mutation-ac713885d47e54d6.yaml index bcd3b81e572..a06979550ee 100644 --- a/releasenotes/notes/fix-answer-from-dict-input-mutation-ac713885d47e54d6.yaml +++ b/releasenotes/notes/fix-answer-from-dict-input-mutation-ac713885d47e54d6.yaml @@ -1,8 +1,8 @@ --- fixes: - | - Fixed `GeneratedAnswer.from_dict` and `ExtractedAnswer.from_dict` mutating their input - dictionary in place. They replaced nested values (`documents`, `document`, offsets and - `meta["all_messages"]`) directly inside the passed dictionary, so deserializing the same + Fixed ``GeneratedAnswer.from_dict`` and ``ExtractedAnswer.from_dict`` mutating their input + dictionary in place. They replaced nested values (``documents``, ``document``, offsets and + ``meta["all_messages"]``) directly inside the passed dictionary, so deserializing the same dictionary a second time received already-parsed objects and raised an ``AttributeError``. Both methods now deep-copy their input and are side-effect free. From af00c19ecc21396bc19a56eb31f43cc9b444263f Mon Sep 17 00:00:00 2001 From: Vidit Patankar Date: Thu, 9 Jul 2026 20:52:43 +0530 Subject: [PATCH 3/4] Avoid deepcopy in Answer.from_dict per review feedback --- haystack/dataclasses/answer.py | 35 +++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/haystack/dataclasses/answer.py b/haystack/dataclasses/answer.py index a9b42d1e129..02070aba4a4 100644 --- a/haystack/dataclasses/answer.py +++ b/haystack/dataclasses/answer.py @@ -2,7 +2,6 @@ # # SPDX-License-Identifier: Apache-2.0 -from copy import deepcopy from dataclasses import asdict, dataclass, field from typing import Any, Optional, Protocol, runtime_checkable @@ -80,20 +79,20 @@ def from_dict(cls, data: dict[str, Any]) -> "ExtractedAnswer": :returns: Deserialized object. """ - # Deep-copy so `from_dict` stays side-effect free: the nested replacements below - # otherwise mutate the caller's dict in place, corrupting it for reuse (a second - # deserialization of the same dict would then receive already-parsed objects). - data = deepcopy(data) + # Shallow-copy the init parameters so `from_dict` stays side-effect free: the nested + # replacements below otherwise mutate the caller's dict in place, corrupting it for reuse + # (a second deserialization of the same dict would then receive already-parsed objects). init_params = data.get("init_parameters", {}) + new_params = dict(init_params) if (doc := init_params.get("document")) is not None: - data["init_parameters"]["document"] = Document.from_dict(doc) + new_params["document"] = Document.from_dict(doc) if (offset := init_params.get("document_offset")) is not None: - data["init_parameters"]["document_offset"] = ExtractedAnswer.Span(**offset) + new_params["document_offset"] = ExtractedAnswer.Span(**offset) if (offset := init_params.get("context_offset")) is not None: - data["init_parameters"]["context_offset"] = ExtractedAnswer.Span(**offset) - return default_from_dict(cls, data) + new_params["context_offset"] = ExtractedAnswer.Span(**offset) + return default_from_dict(cls, {**data, "init_parameters": new_params}) @_warn_on_inplace_mutation @@ -138,18 +137,20 @@ def from_dict(cls, data: dict[str, Any]) -> "GeneratedAnswer": :returns: Deserialized object. """ - # Deep-copy so `from_dict` stays side-effect free: the nested replacements below - # otherwise mutate the caller's dict in place, corrupting it for reuse (a second - # deserialization of the same dict would then receive already-parsed objects). - data = deepcopy(data) + # Shallow-copy the init parameters so `from_dict` stays side-effect free: the nested + # replacements below otherwise mutate the caller's dict in place, corrupting it for reuse + # (a second deserialization of the same dict would then receive already-parsed objects). init_params = data.get("init_parameters", {}) + new_params = dict(init_params) if (documents := init_params.get("documents")) is not None: - init_params["documents"] = [Document.from_dict(d) for d in documents] + new_params["documents"] = [Document.from_dict(d) for d in documents] - meta = init_params.get("meta", {}) + # Shallow-copy `meta` before touching `all_messages` so the caller's nested dict is + # left untouched as well. + meta = dict(init_params.get("meta", {})) if (all_messages := meta.get("all_messages")) and isinstance(all_messages[0], dict): meta["all_messages"] = [ChatMessage.from_dict(m) for m in all_messages] - init_params["meta"] = meta + new_params["meta"] = meta - return default_from_dict(cls, data) + return default_from_dict(cls, {**data, "init_parameters": new_params}) From cef5098a775cdc5ae18764a18eedd319e654b7b4 Mon Sep 17 00:00:00 2001 From: Stefano Fiorucci Date: Thu, 9 Jul 2026 17:29:41 +0200 Subject: [PATCH 4/4] Update releasenotes/notes/fix-answer-from-dict-input-mutation-ac713885d47e54d6.yaml --- .../fix-answer-from-dict-input-mutation-ac713885d47e54d6.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/releasenotes/notes/fix-answer-from-dict-input-mutation-ac713885d47e54d6.yaml b/releasenotes/notes/fix-answer-from-dict-input-mutation-ac713885d47e54d6.yaml index a06979550ee..901d0f2013b 100644 --- a/releasenotes/notes/fix-answer-from-dict-input-mutation-ac713885d47e54d6.yaml +++ b/releasenotes/notes/fix-answer-from-dict-input-mutation-ac713885d47e54d6.yaml @@ -5,4 +5,4 @@ fixes: dictionary in place. They replaced nested values (``documents``, ``document``, offsets and ``meta["all_messages"]``) directly inside the passed dictionary, so deserializing the same dictionary a second time received already-parsed objects and raised an ``AttributeError``. - Both methods now deep-copy their input and are side-effect free. + Both methods now copy their input before deserializing and are side-effect free.