diff --git a/haystack/core/serialization.py b/haystack/core/serialization.py index 6342befbf35..39e6d112728 100644 --- a/haystack/core/serialization.py +++ b/haystack/core/serialization.py @@ -290,7 +290,10 @@ def default_from_dict(cls: type[T], data: dict[str, Any]) -> T: :raises DeserializationError: If the `type` field in `data` is missing or it doesn't match the type of `cls`. """ - init_params = data.get("init_parameters", {}) + # Copy so that replacing serialized sub-objects (Secret/ComponentDevice/nested components) with their + # deserialized instances below does not mutate the caller's ``data`` dict in place. Without this, a second + # deserialization of the same dict would receive already-parsed objects instead of their serialized form. + init_params = dict(data.get("init_parameters", {})) if "type" not in data: raise DeserializationError("Missing 'type' in serialization data") if data["type"] != generate_qualified_class_name(cls): diff --git a/releasenotes/notes/fix-default-from-dict-input-mutation-e26045ee8d31e24d.yaml b/releasenotes/notes/fix-default-from-dict-input-mutation-e26045ee8d31e24d.yaml new file mode 100644 index 00000000000..5f14c949949 --- /dev/null +++ b/releasenotes/notes/fix-default-from-dict-input-mutation-e26045ee8d31e24d.yaml @@ -0,0 +1,7 @@ +--- +fixes: + - | + Fixed ``default_from_dict`` (and therefore ``component_from_dict``) mutating the caller's ``data`` + dictionary in place when deserializing nested objects such as ``Secret`` or ``ComponentDevice``. + The serialized sub-dictionaries were being replaced by their deserialized instances inside the input + dict; the input is now left unchanged, so the same ``data`` can be safely deserialized more than once. diff --git a/test/core/test_serialization.py b/test/core/test_serialization.py index af96b3dd3c4..614fa83147a 100644 --- a/test/core/test_serialization.py +++ b/test/core/test_serialization.py @@ -3,6 +3,8 @@ # SPDX-License-Identifier: Apache-2.0 import sys +from copy import deepcopy +from typing import Any from unittest.mock import Mock import pytest @@ -231,6 +233,24 @@ def test_component_from_dict_with_secret(): assert comp.regular_param == "test" +def test_component_from_dict_does_not_mutate_input(): + """default_from_dict must not mutate the caller's data dict when deserializing nested objects.""" + serialized_secret = Secret.from_env_var("TEST_API_KEY").to_dict() + data: dict[str, Any] = { + "type": generate_qualified_class_name(CustomComponentWithSecrets), + "init_parameters": {"api_key": serialized_secret, "regular_param": "test"}, + } + expected = deepcopy(data) + + comp = component_from_dict(CustomComponentWithSecrets, data, "test_component") + + # the returned component is correctly deserialized ... + assert isinstance(comp.api_key, Secret) + # ... but the caller's dict is left untouched (the Secret sub-dict is not replaced in place) + assert data == expected + assert isinstance(data["init_parameters"]["api_key"], dict) + + def test_component_to_dict_and_from_dict_roundtrip_with_secret(): """Test that serialization and deserialization work together for Secrets.""" # Test roundtrip with EnvVarSecret