Skip to content

Commit 8812223

Browse files
fix: stop default_from_dict from mutating the caller's data dict (#11931)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c695025 commit 8812223

3 files changed

Lines changed: 31 additions & 1 deletion

File tree

haystack/core/serialization.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,10 @@ def default_from_dict(cls: type[T], data: dict[str, Any]) -> T:
290290
:raises DeserializationError:
291291
If the `type` field in `data` is missing or it doesn't match the type of `cls`.
292292
"""
293-
init_params = data.get("init_parameters", {})
293+
# Copy so that replacing serialized sub-objects (Secret/ComponentDevice/nested components) with their
294+
# deserialized instances below does not mutate the caller's ``data`` dict in place. Without this, a second
295+
# deserialization of the same dict would receive already-parsed objects instead of their serialized form.
296+
init_params = dict(data.get("init_parameters", {}))
294297
if "type" not in data:
295298
raise DeserializationError("Missing 'type' in serialization data")
296299
if data["type"] != generate_qualified_class_name(cls):
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
fixes:
3+
- |
4+
Fixed ``default_from_dict`` (and therefore ``component_from_dict``) mutating the caller's ``data``
5+
dictionary in place when deserializing nested objects such as ``Secret`` or ``ComponentDevice``.
6+
The serialized sub-dictionaries were being replaced by their deserialized instances inside the input
7+
dict; the input is now left unchanged, so the same ``data`` can be safely deserialized more than once.

test/core/test_serialization.py

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

55
import sys
6+
from copy import deepcopy
7+
from typing import Any
68
from unittest.mock import Mock
79

810
import pytest
@@ -231,6 +233,24 @@ def test_component_from_dict_with_secret():
231233
assert comp.regular_param == "test"
232234

233235

236+
def test_component_from_dict_does_not_mutate_input():
237+
"""default_from_dict must not mutate the caller's data dict when deserializing nested objects."""
238+
serialized_secret = Secret.from_env_var("TEST_API_KEY").to_dict()
239+
data: dict[str, Any] = {
240+
"type": generate_qualified_class_name(CustomComponentWithSecrets),
241+
"init_parameters": {"api_key": serialized_secret, "regular_param": "test"},
242+
}
243+
expected = deepcopy(data)
244+
245+
comp = component_from_dict(CustomComponentWithSecrets, data, "test_component")
246+
247+
# the returned component is correctly deserialized ...
248+
assert isinstance(comp.api_key, Secret)
249+
# ... but the caller's dict is left untouched (the Secret sub-dict is not replaced in place)
250+
assert data == expected
251+
assert isinstance(data["init_parameters"]["api_key"], dict)
252+
253+
234254
def test_component_to_dict_and_from_dict_roundtrip_with_secret():
235255
"""Test that serialization and deserialization work together for Secrets."""
236256
# Test roundtrip with EnvVarSecret

0 commit comments

Comments
 (0)