From 0bb1d2462c1cc624c8cc6d395cacba8ae3c9033f Mon Sep 17 00:00:00 2001 From: Vedant Agarwal Date: Wed, 8 Jul 2026 16:10:00 +0530 Subject: [PATCH] fix: stop default_from_dict from mutating the caller's data dict default_from_dict took a reference to data["init_parameters"] and replaced serialized sub-objects (Secret, ComponentDevice, nested components) with their deserialized instances in place, corrupting the caller's dict and breaking a second deserialization of the same data. Copy init_parameters before mutating, matching PipelineBase.from_dict which already deep-copies data to avoid this. Co-Authored-By: Claude Opus 4.8 --- haystack/core/serialization.py | 5 ++++- ...-dict-input-mutation-e26045ee8d31e24d.yaml | 7 +++++++ test/core/test_serialization.py | 20 +++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 releasenotes/notes/fix-default-from-dict-input-mutation-e26045ee8d31e24d.yaml 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