Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion haystack/core/serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions test/core/test_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading