From f5b073ef213d6746361fecc61143d0290bfa0e83 Mon Sep 17 00:00:00 2001 From: Ayush Date: Thu, 20 Aug 2026 17:47:30 +0530 Subject: [PATCH] fix: don't modify the input dictionary in `from_dict` `from_dict` deserialized in place, replacing values inside the caller's dictionary with live objects: a callable path became the callable, a nested component dict became the component, "overwrite" became DuplicatePolicy.OVERWRITE. The dictionary was then no longer serializable and a second `from_dict` call on it failed. `Pipeline.from_dict` already copies its input to prevent this. Do the same in the 49 `from_dict` implementations that deserialize in place, through a `_copy_serialized_data` helper wrapping the existing `_deepcopy_with_exceptions`, so components and tools that are already live objects are passed through instead of copied. Co-Authored-By: Claude Opus 5 (1M context) --- haystack/components/agents/agent.py | 3 +- .../builders/chat_prompt_builder.py | 2 + haystack/components/converters/docx.py | 2 + .../components/converters/output_adapter.py | 2 + haystack/components/converters/pdfminer.py | 2 + haystack/components/converters/pypdf.py | 2 + .../embedders/azure_document_embedder.py | 2 + .../embedders/azure_text_embedder.py | 2 + .../embedders/mock_document_embedder.py | 2 + .../embedders/mock_text_embedder.py | 2 + .../evaluators/context_relevance.py | 2 + .../components/evaluators/faithfulness.py | 2 + .../components/evaluators/llm_evaluator.py | 2 + .../image/llm_document_content_extractor.py | 2 + .../extractors/llm_metadata_extractor.py | 2 + haystack/components/generators/chat/azure.py | 2 + .../generators/chat/azure_responses.py | 2 + .../components/generators/chat/fallback.py | 3 +- haystack/components/generators/chat/llm.py | 3 +- haystack/components/generators/chat/mock.py | 2 + haystack/components/generators/chat/openai.py | 2 + .../generators/chat/openai_responses.py | 2 + haystack/components/joiners/branch.py | 2 + haystack/components/joiners/list_joiner.py | 2 + .../preprocessors/document_preprocessor.py | 2 + .../preprocessors/document_splitter.py | 2 + .../embedding_based_document_splitter.py | 3 +- haystack/components/query/query_expander.py | 2 + haystack/components/rankers/llm_ranker.py | 2 + .../retrievers/in_memory/bm25_retriever.py | 2 + .../in_memory/embedding_retriever.py | 2 + .../components/retrievers/multi_retriever.py | 2 + .../components/routers/conditional_router.py | 2 + .../components/routers/llm_messages_router.py | 2 + .../components/routers/metadata_router.py | 2 + .../components/writers/document_writer.py | 2 + .../core/super_component/super_component.py | 2 + haystack/hooks/compaction/hooks.py | 3 +- haystack/hooks/compaction/summarization.py | 3 +- haystack/hooks/from_function.py | 2 + haystack/hooks/human_in_the_loop/hooks.py | 2 + .../hooks/human_in_the_loop/strategies.py | 3 +- .../hooks/tool_result_offloading/hooks.py | 3 +- haystack/tools/agent_tool.py | 3 +- haystack/tools/component_tool.py | 3 +- haystack/tools/pipeline_tool.py | 2 + haystack/tools/searchable_toolset.py | 2 + haystack/tools/skills/skill_toolset.py | 3 +- haystack/tools/tool.py | 2 + haystack/utils/deserialization.py | 20 +++ ...oes-not-mutate-input-3b7f1c0a9d2e4f65.yaml | 8 ++ test/test_from_dict_no_mutation.py | 126 ++++++++++++++++++ 52 files changed, 252 insertions(+), 11 deletions(-) create mode 100644 releasenotes/notes/from-dict-does-not-mutate-input-3b7f1c0a9d2e4f65.yaml create mode 100644 test/test_from_dict_no_mutation.py diff --git a/haystack/components/agents/agent.py b/haystack/components/agents/agent.py index 7dc4dd583a9..84e997537ef 100644 --- a/haystack/components/agents/agent.py +++ b/haystack/components/agents/agent.py @@ -61,7 +61,7 @@ ) from haystack.utils.async_utils import _execute_component_async from haystack.utils.callable_serialization import deserialize_callable, serialize_callable -from haystack.utils.deserialization import deserialize_component_inplace +from haystack.utils.deserialization import _copy_serialized_data, deserialize_component_inplace logger = logging.getLogger(__name__) @@ -638,6 +638,7 @@ def from_dict(cls, data: dict[str, Any]) -> "Agent": :param data: Dictionary to deserialize from. :returns: Deserialized agent. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data init_params = data.get("init_parameters", {}) deserialize_component_inplace(init_params, key="chat_generator") diff --git a/haystack/components/builders/chat_prompt_builder.py b/haystack/components/builders/chat_prompt_builder.py index 91a9a426556..f174fe81edf 100644 --- a/haystack/components/builders/chat_prompt_builder.py +++ b/haystack/components/builders/chat_prompt_builder.py @@ -10,6 +10,7 @@ from haystack.dataclasses.chat_message import ChatMessage, ChatRole, TextContent from haystack.lazy_imports import LazyImport from haystack.utils import Jinja2TimeExtension +from haystack.utils.deserialization import _copy_serialized_data from haystack.utils.jinja2_chat_extension import ChatMessageExtension from haystack.utils.jinja2_extensions import _extract_template_variables_and_assignments from haystack.utils.jinja2_sandbox import HaystackSandboxedEnvironment @@ -347,6 +348,7 @@ def from_dict(cls, data: dict[str, Any]) -> "ChatPromptBuilder": :returns: The deserialized component. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data init_parameters = data["init_parameters"] template = init_parameters.get("template") if template: diff --git a/haystack/components/converters/docx.py b/haystack/components/converters/docx.py index 612ea8dca2e..36d41e3d1f3 100644 --- a/haystack/components/converters/docx.py +++ b/haystack/components/converters/docx.py @@ -15,6 +15,7 @@ from haystack.components.converters.utils import LinkFormat, get_bytestream_from_source, normalize_metadata from haystack.dataclasses import ByteStream from haystack.lazy_imports import LazyImport +from haystack.utils.deserialization import _copy_serialized_data logger = logging.getLogger(__name__) @@ -167,6 +168,7 @@ def from_dict(cls, data: dict[str, Any]) -> "DOCXToDocument": :returns: The deserialized component. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data if "table_format" in data["init_parameters"]: data["init_parameters"]["table_format"] = DOCXTableFormat.from_str(data["init_parameters"]["table_format"]) if "link_format" in data["init_parameters"]: diff --git a/haystack/components/converters/output_adapter.py b/haystack/components/converters/output_adapter.py index e019b01a7c8..a6cb8db8c0a 100644 --- a/haystack/components/converters/output_adapter.py +++ b/haystack/components/converters/output_adapter.py @@ -15,6 +15,7 @@ from haystack.core.errors import DeserializationError from haystack.core.serialization_security import _is_unsafe_deserialization from haystack.utils import deserialize_callable, deserialize_type, serialize_callable, serialize_type +from haystack.utils.deserialization import _copy_serialized_data from haystack.utils.jinja2_extensions import _extract_template_variables_and_assignments from haystack.utils.jinja2_sandbox import HaystackSandboxedEnvironment @@ -171,6 +172,7 @@ def from_dict(cls, data: dict[str, Any]) -> "OutputAdapter": :returns: The deserialized component. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data init_params = data.get("init_parameters", {}) # `unsafe=True` swaps the Jinja sandbox for a NativeEnvironment that executes arbitrary code. diff --git a/haystack/components/converters/pdfminer.py b/haystack/components/converters/pdfminer.py index dc47b4012c7..8653a777887 100644 --- a/haystack/components/converters/pdfminer.py +++ b/haystack/components/converters/pdfminer.py @@ -13,6 +13,7 @@ from haystack.components.converters.utils import LinkFormat, get_bytestream_from_source, normalize_metadata from haystack.dataclasses import ByteStream from haystack.lazy_imports import LazyImport +from haystack.utils.deserialization import _copy_serialized_data with LazyImport("Run 'pip install pdfminer.six'") as pdfminer_import: from pdfminer.converter import PDFPageAggregator @@ -150,6 +151,7 @@ def from_dict(cls, data: dict[str, Any]) -> "PDFMinerToDocument": :returns: Deserialized component. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data if "link_format" in data.get("init_parameters", {}): data["init_parameters"]["link_format"] = LinkFormat.from_str(data["init_parameters"]["link_format"]) return default_from_dict(cls, data) diff --git a/haystack/components/converters/pypdf.py b/haystack/components/converters/pypdf.py index 957a72f17eb..e799948e271 100644 --- a/haystack/components/converters/pypdf.py +++ b/haystack/components/converters/pypdf.py @@ -12,6 +12,7 @@ from haystack.components.converters.utils import LinkFormat, get_bytestream_from_source, normalize_metadata from haystack.dataclasses import ByteStream from haystack.lazy_imports import LazyImport +from haystack.utils.deserialization import _copy_serialized_data with LazyImport("Run 'pip install pypdf'") as pypdf_import: from pypdf import PdfReader @@ -167,6 +168,7 @@ def from_dict(cls, data: dict[str, Any]) -> "PyPDFToDocument": :returns: Deserialized component. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data if "link_format" in data.get("init_parameters", {}): data["init_parameters"]["link_format"] = LinkFormat.from_str(data["init_parameters"]["link_format"]) return default_from_dict(cls, data) diff --git a/haystack/components/embedders/azure_document_embedder.py b/haystack/components/embedders/azure_document_embedder.py index 38ebc03838e..77f7b472b89 100644 --- a/haystack/components/embedders/azure_document_embedder.py +++ b/haystack/components/embedders/azure_document_embedder.py @@ -10,6 +10,7 @@ from haystack import component, default_from_dict, default_to_dict, logging from haystack.components.embedders import OpenAIDocumentEmbedder from haystack.utils import Secret, deserialize_callable, serialize_callable +from haystack.utils.deserialization import _copy_serialized_data from haystack.utils.http_client import init_http_client logger = logging.getLogger(__name__) @@ -250,6 +251,7 @@ def from_dict(cls, data: dict[str, Any]) -> "AzureOpenAIDocumentEmbedder": :returns: Deserialized component. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data serialized_azure_ad_token_provider = data["init_parameters"].get("azure_ad_token_provider") if serialized_azure_ad_token_provider: data["init_parameters"]["azure_ad_token_provider"] = deserialize_callable( diff --git a/haystack/components/embedders/azure_text_embedder.py b/haystack/components/embedders/azure_text_embedder.py index 76d4c2a1b70..102a288f787 100644 --- a/haystack/components/embedders/azure_text_embedder.py +++ b/haystack/components/embedders/azure_text_embedder.py @@ -10,6 +10,7 @@ from haystack import component, default_from_dict, default_to_dict from haystack.components.embedders import OpenAITextEmbedder from haystack.utils import Secret, deserialize_callable, serialize_callable +from haystack.utils.deserialization import _copy_serialized_data from haystack.utils.http_client import init_http_client @@ -226,6 +227,7 @@ def from_dict(cls, data: dict[str, Any]) -> "AzureOpenAITextEmbedder": :returns: Deserialized component. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data serialized_azure_ad_token_provider = data["init_parameters"].get("azure_ad_token_provider") if serialized_azure_ad_token_provider: data["init_parameters"]["azure_ad_token_provider"] = deserialize_callable( diff --git a/haystack/components/embedders/mock_document_embedder.py b/haystack/components/embedders/mock_document_embedder.py index 9832267e1e3..adb948a7dd6 100644 --- a/haystack/components/embedders/mock_document_embedder.py +++ b/haystack/components/embedders/mock_document_embedder.py @@ -15,6 +15,7 @@ _estimate_usage, ) from haystack.utils import deserialize_callable, serialize_callable +from haystack.utils.deserialization import _copy_serialized_data @component @@ -122,6 +123,7 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, data: dict[str, Any]) -> MockDocumentEmbedder: """Deserialize the component from a dictionary.""" + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data init_params = data.get("init_parameters", {}) embedding_fn = init_params.get("embedding_fn") if embedding_fn: diff --git a/haystack/components/embedders/mock_text_embedder.py b/haystack/components/embedders/mock_text_embedder.py index 59650cdd976..ecfaf55be04 100644 --- a/haystack/components/embedders/mock_text_embedder.py +++ b/haystack/components/embedders/mock_text_embedder.py @@ -14,6 +14,7 @@ _estimate_usage, ) from haystack.utils import deserialize_callable, serialize_callable +from haystack.utils.deserialization import _copy_serialized_data @component @@ -105,6 +106,7 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, data: dict[str, Any]) -> MockTextEmbedder: """Deserialize the component from a dictionary.""" + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data init_params = data.get("init_parameters", {}) embedding_fn = init_params.get("embedding_fn") if embedding_fn: diff --git a/haystack/components/evaluators/context_relevance.py b/haystack/components/evaluators/context_relevance.py index e300752629b..7ac92fcebc3 100644 --- a/haystack/components/evaluators/context_relevance.py +++ b/haystack/components/evaluators/context_relevance.py @@ -11,6 +11,7 @@ from haystack.components.generators.chat.types import ChatGenerator from haystack.core.serialization import component_to_dict from haystack.utils import deserialize_chatgenerator_inplace +from haystack.utils.deserialization import _copy_serialized_data logger = logging.getLogger(__name__) @@ -264,6 +265,7 @@ def from_dict(cls, data: dict[str, Any]) -> "ContextRelevanceEvaluator": :returns: The deserialized component instance. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data if data["init_parameters"].get("chat_generator"): deserialize_chatgenerator_inplace(data["init_parameters"], key="chat_generator") return default_from_dict(cls, data) diff --git a/haystack/components/evaluators/faithfulness.py b/haystack/components/evaluators/faithfulness.py index eb7bd2f53e4..d6e9a482cfc 100644 --- a/haystack/components/evaluators/faithfulness.py +++ b/haystack/components/evaluators/faithfulness.py @@ -12,6 +12,7 @@ from haystack.components.generators.chat.types import ChatGenerator from haystack.core.serialization import component_to_dict from haystack.utils import deserialize_chatgenerator_inplace +from haystack.utils.deserialization import _copy_serialized_data logger = logging.getLogger(__name__) @@ -263,6 +264,7 @@ def from_dict(cls, data: dict[str, Any]) -> "FaithfulnessEvaluator": :returns: The deserialized component instance. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data if data["init_parameters"].get("chat_generator"): deserialize_chatgenerator_inplace(data["init_parameters"], key="chat_generator") return default_from_dict(cls, data) diff --git a/haystack/components/evaluators/llm_evaluator.py b/haystack/components/evaluators/llm_evaluator.py index 6dd6a5d3595..944ee2acd83 100644 --- a/haystack/components/evaluators/llm_evaluator.py +++ b/haystack/components/evaluators/llm_evaluator.py @@ -17,6 +17,7 @@ from haystack.core.serialization import component_to_dict from haystack.dataclasses.chat_message import ChatMessage from haystack.utils import deserialize_chatgenerator_inplace, deserialize_type, serialize_type +from haystack.utils.deserialization import _copy_serialized_data from haystack.utils.misc import _parse_dict_from_json logger = logging.getLogger(__name__) @@ -419,6 +420,7 @@ def from_dict(cls, data: dict[str, Any]) -> "LLMEvaluator": :returns: The deserialized component instance. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data data["init_parameters"]["inputs"] = [ (name, deserialize_type(type_)) for name, type_ in data["init_parameters"]["inputs"] ] diff --git a/haystack/components/extractors/image/llm_document_content_extractor.py b/haystack/components/extractors/image/llm_document_content_extractor.py index 3d0a77c0078..71099762890 100644 --- a/haystack/components/extractors/image/llm_document_content_extractor.py +++ b/haystack/components/extractors/image/llm_document_content_extractor.py @@ -21,6 +21,7 @@ from haystack.dataclasses.chat_message import ChatMessage from haystack.utils import deserialize_chatgenerator_inplace from haystack.utils.async_utils import _execute_component_async +from haystack.utils.deserialization import _copy_serialized_data from haystack.utils.misc import _parse_dict_from_json logger = logging.getLogger(__name__) @@ -237,6 +238,7 @@ def from_dict(cls, data: dict[str, Any]) -> "LLMDocumentContentExtractor": :returns: An instance of the component. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data init_params = data.get("init_parameters", {}) deserialize_chatgenerator_inplace(init_params, key="chat_generator") diff --git a/haystack/components/extractors/llm_metadata_extractor.py b/haystack/components/extractors/llm_metadata_extractor.py index 8d8ee17f56b..290116bde19 100644 --- a/haystack/components/extractors/llm_metadata_extractor.py +++ b/haystack/components/extractors/llm_metadata_extractor.py @@ -23,6 +23,7 @@ from haystack.dataclasses import ChatMessage from haystack.utils import deserialize_chatgenerator_inplace, expand_page_range from haystack.utils.async_utils import _execute_component_async +from haystack.utils.deserialization import _copy_serialized_data from haystack.utils.misc import _parse_dict_from_json logger = logging.getLogger(__name__) @@ -264,6 +265,7 @@ def from_dict(cls, data: dict[str, Any]) -> "LLMMetadataExtractor": :returns: An instance of the component. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data deserialize_chatgenerator_inplace(data["init_parameters"], key="chat_generator") return default_from_dict(cls, data) diff --git a/haystack/components/generators/chat/azure.py b/haystack/components/generators/chat/azure.py index 5b78d03c35a..999be7644dc 100644 --- a/haystack/components/generators/chat/azure.py +++ b/haystack/components/generators/chat/azure.py @@ -21,6 +21,7 @@ warm_up_tools, ) from haystack.utils import Secret, deserialize_callable, serialize_callable +from haystack.utils.deserialization import _copy_serialized_data from haystack.utils.http_client import init_http_client @@ -364,6 +365,7 @@ def from_dict(cls, data: dict[str, Any]) -> "AzureOpenAIChatGenerator": :returns: The deserialized component instance. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data deserialize_tools_or_toolset_inplace(data["init_parameters"], key="tools") init_params = data.get("init_parameters", {}) serialized_callback_handler = init_params.get("streaming_callback") diff --git a/haystack/components/generators/chat/azure_responses.py b/haystack/components/generators/chat/azure_responses.py index 31c2c269c34..cf8f482d3f6 100644 --- a/haystack/components/generators/chat/azure_responses.py +++ b/haystack/components/generators/chat/azure_responses.py @@ -14,6 +14,7 @@ from haystack.dataclasses.streaming_chunk import StreamingCallbackT from haystack.tools import ToolsType, deserialize_tools_or_toolset_inplace, serialize_tools_or_toolset from haystack.utils import Secret, deserialize_callable, serialize_callable +from haystack.utils.deserialization import _copy_serialized_data @component @@ -249,6 +250,7 @@ def from_dict(cls, data: dict[str, Any]) -> "AzureOpenAIResponsesChatGenerator": :returns: The deserialized component instance. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data # If api_key is a str, it's a callable (Secrets are handled automatically by default_from_dict) serialized_api_key = data["init_parameters"].get("api_key") if isinstance(serialized_api_key, str): diff --git a/haystack/components/generators/chat/fallback.py b/haystack/components/generators/chat/fallback.py index 091c4aa5ab9..e696272a7da 100644 --- a/haystack/components/generators/chat/fallback.py +++ b/haystack/components/generators/chat/fallback.py @@ -13,7 +13,7 @@ from haystack.dataclasses import ChatMessage, StreamingCallbackT from haystack.tools import ToolsType from haystack.utils.async_utils import _execute_component_async -from haystack.utils.deserialization import deserialize_component_inplace +from haystack.utils.deserialization import _copy_serialized_data, deserialize_component_inplace logger = logging.getLogger(__name__) @@ -73,6 +73,7 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, data: dict[str, Any]) -> FallbackChatGenerator: """Rebuild the component from a serialized representation, restoring nested chat generators.""" + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data # Reconstruct nested chat generators from their serialized dicts init_params = data.get("init_parameters", {}) serialized = init_params.get("chat_generators") or [] diff --git a/haystack/components/generators/chat/llm.py b/haystack/components/generators/chat/llm.py index 6bfae7db577..938f9667e3e 100644 --- a/haystack/components/generators/chat/llm.py +++ b/haystack/components/generators/chat/llm.py @@ -10,7 +10,7 @@ from haystack.core.serialization import component_to_dict, default_from_dict, default_to_dict from haystack.dataclasses import ChatMessage, StreamingCallbackT from haystack.utils.callable_serialization import deserialize_callable, serialize_callable -from haystack.utils.deserialization import deserialize_component_inplace +from haystack.utils.deserialization import _copy_serialized_data, deserialize_component_inplace logger = logging.getLogger(__name__) @@ -116,6 +116,7 @@ def from_dict(cls, data: dict[str, Any]) -> "LLM": :param data: Dictionary to deserialize from. :return: Deserialized LLM instance. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data init_params = data.get("init_parameters", {}) deserialize_component_inplace(init_params, key="chat_generator") diff --git a/haystack/components/generators/chat/mock.py b/haystack/components/generators/chat/mock.py index f2257dcd242..53065f64207 100644 --- a/haystack/components/generators/chat/mock.py +++ b/haystack/components/generators/chat/mock.py @@ -25,6 +25,7 @@ from haystack.dataclasses.streaming_chunk import ToolCallDelta, _invoke_streaming_callback from haystack.tools import ToolsType from haystack.utils import deserialize_callable, serialize_callable +from haystack.utils.deserialization import _copy_serialized_data logger = logging.getLogger(__name__) @@ -186,6 +187,7 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, data: dict[str, Any]) -> MockChatGenerator: """Deserialize the component from a dictionary.""" + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data init_params = data.get("init_parameters", {}) responses = init_params.get("responses") if responses is not None: diff --git a/haystack/components/generators/chat/openai.py b/haystack/components/generators/chat/openai.py index 9efd0fba650..e3a9e9d760d 100644 --- a/haystack/components/generators/chat/openai.py +++ b/haystack/components/generators/chat/openai.py @@ -49,6 +49,7 @@ warm_up_tools, ) from haystack.utils import Secret, deserialize_callable, serialize_callable +from haystack.utils.deserialization import _copy_serialized_data from haystack.utils.http_client import init_http_client logger = logging.getLogger(__name__) @@ -331,6 +332,7 @@ def from_dict(cls, data: dict[str, Any]) -> "OpenAIChatGenerator": :returns: The deserialized component instance. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data deserialize_tools_or_toolset_inplace(data["init_parameters"], key="tools") init_params = data.get("init_parameters", {}) serialized_callback_handler = init_params.get("streaming_callback") diff --git a/haystack/components/generators/chat/openai_responses.py b/haystack/components/generators/chat/openai_responses.py index 3c2b4b85325..52c62a8a2c7 100644 --- a/haystack/components/generators/chat/openai_responses.py +++ b/haystack/components/generators/chat/openai_responses.py @@ -38,6 +38,7 @@ warm_up_tools, ) from haystack.utils import Secret, deserialize_callable, serialize_callable +from haystack.utils.deserialization import _copy_serialized_data from haystack.utils.http_client import init_http_client logger = logging.getLogger(__name__) @@ -344,6 +345,7 @@ def from_dict(cls, data: dict[str, Any]) -> "OpenAIResponsesChatGenerator": :returns: The deserialized component instance. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data # we only deserialize the tools if they are haystack tools # because openai tools are not serialized in the same way tools = data["init_parameters"].get("tools") diff --git a/haystack/components/joiners/branch.py b/haystack/components/joiners/branch.py index 40bf9ce346d..4b56f91e00e 100644 --- a/haystack/components/joiners/branch.py +++ b/haystack/components/joiners/branch.py @@ -7,6 +7,7 @@ from haystack import component, default_from_dict, default_to_dict from haystack.core.component.types import GreedyVariadic from haystack.utils import deserialize_type, serialize_type +from haystack.utils.deserialization import _copy_serialized_data @component @@ -113,6 +114,7 @@ def from_dict(cls, data: dict[str, Any]) -> "BranchJoiner": :returns: A deserialized `BranchJoiner` instance. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data data["init_parameters"]["type_"] = deserialize_type(data["init_parameters"]["type_"]) return default_from_dict(cls, data) diff --git a/haystack/components/joiners/list_joiner.py b/haystack/components/joiners/list_joiner.py index ec96e04d09f..e308e179fcc 100644 --- a/haystack/components/joiners/list_joiner.py +++ b/haystack/components/joiners/list_joiner.py @@ -8,6 +8,7 @@ from haystack import component, default_from_dict, default_to_dict from haystack.core.component.types import Variadic from haystack.utils import deserialize_type, serialize_type +from haystack.utils.deserialization import _copy_serialized_data @component @@ -96,6 +97,7 @@ def from_dict(cls, data: dict[str, Any]) -> "ListJoiner": :param data: Dictionary to deserialize from. :returns: Deserialized component. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data init_parameters = data.get("init_parameters") if init_parameters is not None and init_parameters.get("list_type_") is not None: data["init_parameters"]["list_type_"] = deserialize_type(data["init_parameters"]["list_type_"]) diff --git a/haystack/components/preprocessors/document_preprocessor.py b/haystack/components/preprocessors/document_preprocessor.py index 16dc0f9782c..44e487b8d67 100644 --- a/haystack/components/preprocessors/document_preprocessor.py +++ b/haystack/components/preprocessors/document_preprocessor.py @@ -9,6 +9,7 @@ from haystack.components.preprocessors.document_cleaner import DocumentCleaner from haystack.components.preprocessors.document_splitter import DocumentSplitter, Language from haystack.utils import deserialize_callable, serialize_callable +from haystack.utils.deserialization import _copy_serialized_data @super_component @@ -192,6 +193,7 @@ def from_dict(cls, data: dict[str, Any]) -> "DocumentPreprocessor": :returns: Deserialized SuperComponent. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data splitting_function = data["init_parameters"].get("splitting_function", None) if splitting_function: data["init_parameters"]["splitting_function"] = deserialize_callable(splitting_function) diff --git a/haystack/components/preprocessors/document_splitter.py b/haystack/components/preprocessors/document_splitter.py index f93ce7bbf26..1940fac4216 100644 --- a/haystack/components/preprocessors/document_splitter.py +++ b/haystack/components/preprocessors/document_splitter.py @@ -12,6 +12,7 @@ from haystack.components.preprocessors.sentence_tokenizer import Language, SentenceSplitter, nltk_imports from haystack.core.serialization import default_from_dict, default_to_dict from haystack.utils import deserialize_callable, serialize_callable +from haystack.utils.deserialization import _copy_serialized_data logger = logging.getLogger(__name__) @@ -397,6 +398,7 @@ def from_dict(cls, data: dict[str, Any]) -> "DocumentSplitter": """ Deserializes the component from a dictionary. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data init_params = data.get("init_parameters", {}) splitting_function = init_params.get("splitting_function", None) diff --git a/haystack/components/preprocessors/embedding_based_document_splitter.py b/haystack/components/preprocessors/embedding_based_document_splitter.py index 17acefd3b7b..2a08d2eb238 100644 --- a/haystack/components/preprocessors/embedding_based_document_splitter.py +++ b/haystack/components/preprocessors/embedding_based_document_splitter.py @@ -15,7 +15,7 @@ from haystack.components.preprocessors.sentence_tokenizer import Language, SentenceSplitter from haystack.core.serialization import component_to_dict, default_from_dict, default_to_dict from haystack.utils.async_utils import _execute_component_async -from haystack.utils.deserialization import deserialize_component_inplace +from haystack.utils.deserialization import _copy_serialized_data, deserialize_component_inplace logger = logging.getLogger(__name__) @@ -585,5 +585,6 @@ def from_dict(cls, data: dict[str, Any]) -> "EmbeddingBasedDocumentSplitter": :returns: The deserialized component. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data deserialize_component_inplace(data["init_parameters"], key="document_embedder") return default_from_dict(cls, data) diff --git a/haystack/components/query/query_expander.py b/haystack/components/query/query_expander.py index 5279db12abe..3f97c393357 100644 --- a/haystack/components/query/query_expander.py +++ b/haystack/components/query/query_expander.py @@ -14,6 +14,7 @@ from haystack.dataclasses.chat_message import ChatMessage from haystack.utils import deserialize_chatgenerator_inplace from haystack.utils.async_utils import _execute_component_async +from haystack.utils.deserialization import _copy_serialized_data from haystack.utils.misc import _parse_dict_from_json logger = logging.getLogger(__name__) @@ -174,6 +175,7 @@ def from_dict(cls, data: dict[str, Any]) -> "QueryExpander": :param data: Dictionary with serialized data. :return: Deserialized component. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data init_params = data.get("init_parameters", {}) deserialize_chatgenerator_inplace(init_params, key="chat_generator") diff --git a/haystack/components/rankers/llm_ranker.py b/haystack/components/rankers/llm_ranker.py index 6ad029ce173..63a91c79696 100644 --- a/haystack/components/rankers/llm_ranker.py +++ b/haystack/components/rankers/llm_ranker.py @@ -13,6 +13,7 @@ from haystack.dataclasses import ChatMessage from haystack.utils import deserialize_chatgenerator_inplace from haystack.utils.async_utils import _execute_component_async +from haystack.utils.deserialization import _copy_serialized_data from haystack.utils.misc import _deduplicate_documents, _parse_dict_from_json logger = logging.getLogger(__name__) @@ -222,6 +223,7 @@ def from_dict(cls, data: dict[str, Any]) -> "LLMRanker": :returns: The deserialized component instance. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data init_params = data.get("init_parameters", {}) if init_params.get("chat_generator"): deserialize_chatgenerator_inplace(init_params, key="chat_generator") diff --git a/haystack/components/retrievers/in_memory/bm25_retriever.py b/haystack/components/retrievers/in_memory/bm25_retriever.py index 450f70cc561..2a23a291bc0 100644 --- a/haystack/components/retrievers/in_memory/bm25_retriever.py +++ b/haystack/components/retrievers/in_memory/bm25_retriever.py @@ -7,6 +7,7 @@ from haystack import Document, component, default_from_dict, default_to_dict from haystack.document_stores.in_memory import InMemoryDocumentStore from haystack.document_stores.types import FilterPolicy, apply_filter_policy +from haystack.utils.deserialization import _copy_serialized_data @component @@ -112,6 +113,7 @@ def from_dict(cls, data: dict[str, Any]) -> "InMemoryBM25Retriever": :returns: The deserialized component. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data init_params = data.get("init_parameters", {}) if "filter_policy" in init_params: init_params["filter_policy"] = FilterPolicy.from_str(init_params["filter_policy"]) diff --git a/haystack/components/retrievers/in_memory/embedding_retriever.py b/haystack/components/retrievers/in_memory/embedding_retriever.py index 5c2bf0dcf8f..fa110823432 100644 --- a/haystack/components/retrievers/in_memory/embedding_retriever.py +++ b/haystack/components/retrievers/in_memory/embedding_retriever.py @@ -7,6 +7,7 @@ from haystack import Document, component, default_from_dict, default_to_dict from haystack.document_stores.in_memory import InMemoryDocumentStore from haystack.document_stores.types import FilterPolicy, apply_filter_policy +from haystack.utils.deserialization import _copy_serialized_data @component @@ -128,6 +129,7 @@ def from_dict(cls, data: dict[str, Any]) -> "InMemoryEmbeddingRetriever": :returns: The deserialized component. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data init_params = data.get("init_parameters", {}) if "filter_policy" in init_params: init_params["filter_policy"] = FilterPolicy.from_str(init_params["filter_policy"]) diff --git a/haystack/components/retrievers/multi_retriever.py b/haystack/components/retrievers/multi_retriever.py index 93d8940a114..5b68dc73fb3 100644 --- a/haystack/components/retrievers/multi_retriever.py +++ b/haystack/components/retrievers/multi_retriever.py @@ -12,6 +12,7 @@ from haystack.core.serialization import component_from_dict, component_to_dict, import_class_by_name from haystack.dataclasses import Document from haystack.utils.async_utils import _execute_component_async, _gather_tasks_with_cancel +from haystack.utils.deserialization import _copy_serialized_data from haystack.utils.experimental import _experimental from haystack.utils.misc import _deduplicate_documents, _reciprocal_rank_fusion @@ -349,6 +350,7 @@ def from_dict(cls, data: dict[str, Any]) -> "MultiRetriever": :param data: Dictionary with the data to create the component. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data retrievers_data = data.get("init_parameters", {}).get("retrievers", {}) if retrievers_data: retrievers = {} diff --git a/haystack/components/routers/conditional_router.py b/haystack/components/routers/conditional_router.py index 36e313d96a6..8b365c70db7 100644 --- a/haystack/components/routers/conditional_router.py +++ b/haystack/components/routers/conditional_router.py @@ -15,6 +15,7 @@ from haystack.core.errors import DeserializationError from haystack.core.serialization_security import _is_unsafe_deserialization from haystack.utils import deserialize_callable, deserialize_type, serialize_callable, serialize_type +from haystack.utils.deserialization import _copy_serialized_data from haystack.utils.jinja2_extensions import _extract_template_variables_and_assignments from haystack.utils.jinja2_sandbox import HaystackSandboxedEnvironment from haystack.utils.type_serialization import _is_union_type @@ -364,6 +365,7 @@ def from_dict(cls, data: dict[str, Any]) -> "ConditionalRouter": :returns: The deserialized component. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data init_params = data.get("init_parameters", {}) # `unsafe=True` swaps the Jinja sandbox for a NativeEnvironment that executes arbitrary code. diff --git a/haystack/components/routers/llm_messages_router.py b/haystack/components/routers/llm_messages_router.py index 0ae31b15210..b1a0e30176d 100644 --- a/haystack/components/routers/llm_messages_router.py +++ b/haystack/components/routers/llm_messages_router.py @@ -12,6 +12,7 @@ from haystack.dataclasses import ChatMessage, ChatRole from haystack.utils import deserialize_chatgenerator_inplace from haystack.utils.async_utils import _execute_component_async +from haystack.utils.deserialization import _copy_serialized_data @component @@ -233,6 +234,7 @@ def from_dict(cls, data: dict[str, Any]) -> "LLMMessagesRouter": :returns: The deserialized component instance. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data if data["init_parameters"].get("chat_generator"): deserialize_chatgenerator_inplace(data["init_parameters"], key="chat_generator") diff --git a/haystack/components/routers/metadata_router.py b/haystack/components/routers/metadata_router.py index 42f3e11376e..68ac7ea9a9d 100644 --- a/haystack/components/routers/metadata_router.py +++ b/haystack/components/routers/metadata_router.py @@ -7,6 +7,7 @@ from haystack import Document, component, default_from_dict, default_to_dict from haystack.dataclasses import ByteStream from haystack.utils import deserialize_type, serialize_type +from haystack.utils.deserialization import _copy_serialized_data from haystack.utils.filters import document_matches_filter @@ -169,6 +170,7 @@ def from_dict(cls, data: dict[str, Any]) -> "MetadataRouter": :returns: The deserialized component instance. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data init_params = data.get("init_parameters", {}) if "output_type" in init_params: # Deserialize the output_type to its original type diff --git a/haystack/components/writers/document_writer.py b/haystack/components/writers/document_writer.py index 8493bfc336c..34afcfc6f98 100644 --- a/haystack/components/writers/document_writer.py +++ b/haystack/components/writers/document_writer.py @@ -6,6 +6,7 @@ from haystack import Document, component, default_from_dict, default_to_dict from haystack.document_stores.types import DocumentStore, DuplicatePolicy +from haystack.utils.deserialization import _copy_serialized_data @component @@ -71,6 +72,7 @@ def from_dict(cls, data: dict[str, Any]) -> "DocumentWriter": :raises DeserializationError: If the document store is not properly specified in the serialization data or its type cannot be imported. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data init_params = data.get("init_parameters", {}) if "policy" in init_params: init_params["policy"] = DuplicatePolicy[init_params["policy"]] diff --git a/haystack/core/super_component/super_component.py b/haystack/core/super_component/super_component.py index 048197bf8d8..e72b81a3bb3 100644 --- a/haystack/core/super_component/super_component.py +++ b/haystack/core/super_component/super_component.py @@ -13,6 +13,7 @@ from haystack.core.pipeline.utils import parse_connect_string from haystack.core.serialization import default_from_dict, default_to_dict, generate_qualified_class_name from haystack.core.super_component.utils import _delegate_default, _is_compatible +from haystack.utils.deserialization import _copy_serialized_data logger = logging.getLogger(__name__) @@ -490,6 +491,7 @@ def from_dict(cls, data: dict[str, Any]) -> "SuperComponent": :returns: The deserialized SuperComponent. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data # `is_pipeline_async` is a legacy key kept only for backward compatibility. data["init_parameters"].pop("is_pipeline_async", None) pipeline = Pipeline.from_dict(data["init_parameters"]["pipeline"]) diff --git a/haystack/hooks/compaction/hooks.py b/haystack/hooks/compaction/hooks.py index 40561d4d121..c0aeb0f602f 100644 --- a/haystack/hooks/compaction/hooks.py +++ b/haystack/hooks/compaction/hooks.py @@ -18,7 +18,7 @@ from haystack.hooks.compaction.utils import _estimated_context_tokens, _last_assistant_index from haystack.token_counters import ApproximateTokenCounter, TokenCounter from haystack.tools import ToolsType -from haystack.utils.deserialization import deserialize_component_inplace +from haystack.utils.deserialization import _copy_serialized_data, deserialize_component_inplace from haystack.utils.experimental import _experimental logger = logging.getLogger(__name__) @@ -305,6 +305,7 @@ def from_dict(cls, data: dict[str, Any]) -> "CompactionHook": :param data: A dictionary representation produced by `to_dict`. :returns: The deserialized `CompactionHook`. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data init_params = data.get("init_parameters", {}) for key in ("compactor", "token_counter"): if init_params.get(key) is not None: diff --git a/haystack/hooks/compaction/summarization.py b/haystack/hooks/compaction/summarization.py index eb567c0738b..97b8aaaec24 100644 --- a/haystack/hooks/compaction/summarization.py +++ b/haystack/hooks/compaction/summarization.py @@ -23,7 +23,7 @@ from haystack.token_counters import TokenCounter from haystack.token_counters.utils import _rendered_conversation from haystack.utils.async_utils import _execute_component_async -from haystack.utils.deserialization import deserialize_component_inplace +from haystack.utils.deserialization import _copy_serialized_data, deserialize_component_inplace from haystack.utils.experimental import _experimental logger = logging.getLogger(__name__) @@ -505,6 +505,7 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, data: dict[str, Any]) -> "SummarizationCompactor": """Deserialize the compactor and reconstruct its Chat Generator.""" + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data init_params = data.get("init_parameters", {}) if init_params.get("chat_generator") is not None: deserialize_component_inplace(data=init_params, key="chat_generator") diff --git a/haystack/hooks/from_function.py b/haystack/hooks/from_function.py index 3df5712c468..c54a4284f21 100644 --- a/haystack/hooks/from_function.py +++ b/haystack/hooks/from_function.py @@ -10,6 +10,7 @@ from haystack.core.serialization import default_from_dict, default_to_dict from haystack.core.type_utils import _resolve_parameter_types from haystack.utils.callable_serialization import deserialize_callable, serialize_callable +from haystack.utils.deserialization import _copy_serialized_data def _takes_single_state_argument(function: Callable) -> bool: @@ -112,6 +113,7 @@ def from_dict(cls, data: dict[str, Any]) -> "FunctionHook": :param data: The serialized hook dictionary produced by `to_dict`. :returns: The reconstructed `FunctionHook`. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data init_params = data.get("init_parameters", {}) if init_params.get("function") is not None: init_params["function"] = deserialize_callable(init_params["function"]) diff --git a/haystack/hooks/human_in_the_loop/hooks.py b/haystack/hooks/human_in_the_loop/hooks.py index c43a7459dd5..632a5e73a58 100644 --- a/haystack/hooks/human_in_the_loop/hooks.py +++ b/haystack/hooks/human_in_the_loop/hooks.py @@ -14,6 +14,7 @@ _serialize_confirmation_strategies, ) from haystack.hooks.human_in_the_loop.types import ConfirmationStrategy +from haystack.utils.deserialization import _copy_serialized_data class ConfirmationHook: @@ -133,6 +134,7 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, data: dict[str, Any]) -> "ConfirmationHook": """Deserialize the hook, reconstructing its confirmation strategies.""" + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data init_params = data.get("init_parameters", {}) if init_params.get("confirmation_strategies") is not None: init_params["confirmation_strategies"] = _deserialize_confirmation_strategies( diff --git a/haystack/hooks/human_in_the_loop/strategies.py b/haystack/hooks/human_in_the_loop/strategies.py index 21af34fc6f1..cc8ef987fa6 100644 --- a/haystack/hooks/human_in_the_loop/strategies.py +++ b/haystack/hooks/human_in_the_loop/strategies.py @@ -19,7 +19,7 @@ from haystack.hooks.human_in_the_loop.types import ConfirmationPolicy, ConfirmationStrategy, ConfirmationUI from haystack.tools import Tool from haystack.utils.async_utils import _execute_component_async -from haystack.utils.deserialization import deserialize_component_inplace +from haystack.utils.deserialization import _copy_serialized_data, deserialize_component_inplace REJECTION_FEEDBACK_TEMPLATE = "Tool execution for '{tool_name}' was rejected by the user." MODIFICATION_FEEDBACK_TEMPLATE = ( @@ -199,6 +199,7 @@ def from_dict(cls, data: dict[str, Any]) -> "BlockingConfirmationStrategy": :returns: Deserialized BlockingConfirmationStrategy. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data deserialize_component_inplace(data["init_parameters"], key="confirmation_policy") deserialize_component_inplace(data["init_parameters"], key="confirmation_ui") return default_from_dict(cls, data) diff --git a/haystack/hooks/tool_result_offloading/hooks.py b/haystack/hooks/tool_result_offloading/hooks.py index 077913bb58a..be5a94c3664 100644 --- a/haystack/hooks/tool_result_offloading/hooks.py +++ b/haystack/hooks/tool_result_offloading/hooks.py @@ -12,7 +12,7 @@ from haystack.dataclasses import ChatMessage, TextContent from haystack.dataclasses.chat_message import ToolCallResultContentT from haystack.hooks.tool_result_offloading.types import OffloadPolicy, ToolResultStore -from haystack.utils.deserialization import deserialize_component_inplace +from haystack.utils.deserialization import _copy_serialized_data, deserialize_component_inplace logger = logging.getLogger(__name__) @@ -347,6 +347,7 @@ def from_dict(cls, data: dict[str, Any]) -> "ToolResultOffloadHook": :param data: A dictionary representation produced by `to_dict`. :returns: The deserialized `ToolResultOffloadHook`. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data init_params = data.get("init_parameters", {}) if init_params.get("store") is not None: deserialize_component_inplace(init_params, key="store") diff --git a/haystack/tools/agent_tool.py b/haystack/tools/agent_tool.py index f07fcb08068..28898d61ce3 100644 --- a/haystack/tools/agent_tool.py +++ b/haystack/tools/agent_tool.py @@ -10,7 +10,7 @@ from haystack.components.agents.agent import _EXIT_REASON_MAX_STEPS from haystack.tools.component_tool import ComponentTool from haystack.tools.tool import _deserialize_outputs_to_state, _deserialize_outputs_to_string -from haystack.utils.deserialization import deserialize_component_inplace +from haystack.utils.deserialization import _copy_serialized_data, deserialize_component_inplace def _required_tool_parameters(agent: Agent, inputs_from_state: dict[str, Any] | None) -> list[str]: @@ -251,6 +251,7 @@ def from_dict(cls, data: dict[str, Any]) -> "AgentTool": :returns: The deserialized AgentTool instance. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data inner_data = data["data"] deserialize_component_inplace(data=inner_data, key="agent") diff --git a/haystack/tools/component_tool.py b/haystack/tools/component_tool.py index c2a7a5e1b40..65956cc3b17 100644 --- a/haystack/tools/component_tool.py +++ b/haystack/tools/component_tool.py @@ -26,7 +26,7 @@ _serialize_outputs_to_state, _serialize_outputs_to_string, ) -from haystack.utils.deserialization import deserialize_component_inplace +from haystack.utils.deserialization import _copy_serialized_data, deserialize_component_inplace from haystack.utils.type_serialization import _is_union_type logger = logging.getLogger(__name__) @@ -305,6 +305,7 @@ def from_dict(cls, data: dict[str, Any]) -> "ComponentTool": """ Deserializes the ComponentTool from a dictionary. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data inner_data = data["data"] deserialize_component_inplace(data=inner_data, key="component") diff --git a/haystack/tools/pipeline_tool.py b/haystack/tools/pipeline_tool.py index 9c4e5df84c2..c001574b259 100644 --- a/haystack/tools/pipeline_tool.py +++ b/haystack/tools/pipeline_tool.py @@ -14,6 +14,7 @@ _serialize_outputs_to_state, _serialize_outputs_to_string, ) +from haystack.utils.deserialization import _copy_serialized_data logger = logging.getLogger(__name__) @@ -231,6 +232,7 @@ def from_dict(cls, data: dict[str, Any]) -> "PipelineTool": :returns: The deserialized PipelineTool instance. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data inner_data = data["data"] # `is_pipeline_async` is a legacy key kept only for backward compatibility inner_data.pop("is_pipeline_async", None) diff --git a/haystack/tools/searchable_toolset.py b/haystack/tools/searchable_toolset.py index 169f7f160a0..fa596a047fb 100644 --- a/haystack/tools/searchable_toolset.py +++ b/haystack/tools/searchable_toolset.py @@ -15,6 +15,7 @@ from haystack.tools.tool import Tool, _check_duplicate_tool_names from haystack.tools.toolset import Toolset from haystack.tools.utils import flatten_tools_or_toolsets, warm_up_tools +from haystack.utils.deserialization import _copy_serialized_data if TYPE_CHECKING: from haystack.tools import ToolsType @@ -359,6 +360,7 @@ def from_dict(cls, data: dict[str, Any]) -> "SearchableToolset": :returns: New SearchableToolset instance. :raises TypeError: If a serialized catalog entry is not a subclass of Tool or Toolset. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data inner_data = data["data"] deserialize_tools_or_toolset_inplace(inner_data, key="catalog") optional_keys = ( diff --git a/haystack/tools/skills/skill_toolset.py b/haystack/tools/skills/skill_toolset.py index 37e3113b056..e2bab2fb5d0 100644 --- a/haystack/tools/skills/skill_toolset.py +++ b/haystack/tools/skills/skill_toolset.py @@ -12,7 +12,7 @@ from haystack.tools.from_function import create_tool_from_function from haystack.tools.tool import Tool from haystack.tools.toolset import Toolset -from haystack.utils.deserialization import deserialize_component_inplace +from haystack.utils.deserialization import _copy_serialized_data, deserialize_component_inplace class SkillToolset(Toolset): @@ -191,6 +191,7 @@ def from_dict(cls, data: dict[str, Any]) -> "SkillToolset": :param data: Dictionary representation of the toolset, as produced by `to_dict`. :returns: A new SkillToolset instance. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data inner_data = data["data"] deserialize_component_inplace(inner_data, key="store") return cls(**inner_data) diff --git a/haystack/tools/tool.py b/haystack/tools/tool.py index 8ac886482e8..5471234081b 100644 --- a/haystack/tools/tool.py +++ b/haystack/tools/tool.py @@ -14,6 +14,7 @@ from haystack.core.serialization import generate_qualified_class_name from haystack.tools.errors import ToolInvocationError from haystack.utils.callable_serialization import deserialize_callable, serialize_callable +from haystack.utils.deserialization import _copy_serialized_data @dataclass @@ -350,6 +351,7 @@ def from_dict(cls, data: dict[str, Any]) -> "Tool": :returns: Deserialized Tool. """ + data = _copy_serialized_data(data) # `from_dict` must not modify the caller's data init_parameters = data["data"] init_parameters["function"] = ( deserialize_callable(init_parameters["function"]) if init_parameters.get("function") is not None else None diff --git a/haystack/utils/deserialization.py b/haystack/utils/deserialization.py index 90d2b9fceaa..ee967cf20c9 100644 --- a/haystack/utils/deserialization.py +++ b/haystack/utils/deserialization.py @@ -54,3 +54,23 @@ def deserialize_component_inplace(data: dict[str, Any], key: str = "chat_generat raise DeserializationError(f"Class '{serialized_component['type']}' not correctly imported") from e data[key] = component_from_dict(cls=component_class, data=serialized_component, name=key) + + +def _copy_serialized_data(data: dict[str, Any]) -> dict[str, Any]: + """ + Return a copy of serialized data that `from_dict` implementations can safely deserialize in place. + + Deserialization replaces serialized values with live objects, for example a callable path with the callable + itself or a nested component dictionary with the component. Doing that on the dictionary passed by the caller + leaves it holding objects that are no longer serializable and that a second `from_dict` call cannot read, + which is why `Pipeline.from_dict` copies its input before deserializing it. + + :param data: + The serialized data a `from_dict` implementation received. + :returns: + A copy of the data. Objects that cannot be deep-copied, such as components and tools, are kept as they are. + """ + # Local import to avoid a circular import at module load time. + from haystack.core.pipeline.utils import _deepcopy_with_exceptions + + return _deepcopy_with_exceptions(data) diff --git a/releasenotes/notes/from-dict-does-not-mutate-input-3b7f1c0a9d2e4f65.yaml b/releasenotes/notes/from-dict-does-not-mutate-input-3b7f1c0a9d2e4f65.yaml new file mode 100644 index 00000000000..9efa4c5ee37 --- /dev/null +++ b/releasenotes/notes/from-dict-does-not-mutate-input-3b7f1c0a9d2e4f65.yaml @@ -0,0 +1,8 @@ +--- +fixes: + - | + ``from_dict`` no longer modifies the dictionary it receives. Components, tools, toolsets and hooks that deserialize + nested values - a serialized callable, a nested component, tools, a type, or an enum - used to replace those values + in the caller's dictionary with the live objects. The dictionary was then no longer serializable and a second + ``from_dict`` call on it failed, for example with ``'function' object has no attribute 'split'``. Deserialization now + works on a copy, as ``Pipeline.from_dict`` already did. diff --git a/test/test_from_dict_no_mutation.py b/test/test_from_dict_no_mutation.py new file mode 100644 index 00000000000..898ff7a54ad --- /dev/null +++ b/test/test_from_dict_no_mutation.py @@ -0,0 +1,126 @@ +# SPDX-FileCopyrightText: 2022-present deepset GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +""" +`from_dict` must leave the dictionary it receives untouched. + +Deserialization replaces serialized values with live objects, for example a callable path with the callable itself +or a nested component dictionary with the component. Doing that on the caller's dictionary leaves it holding objects +that are no longer serializable and that a second `from_dict` call cannot read. +""" + +import inspect +import json +from collections.abc import Callable +from copy import deepcopy +from typing import Any + +import pytest + +from haystack import Document, Pipeline, SuperComponent +from haystack.components.builders import ChatPromptBuilder +from haystack.components.converters import OutputAdapter +from haystack.components.generators.chat import OpenAIChatGenerator +from haystack.components.generators.utils import print_streaming_chunk +from haystack.components.joiners import BranchJoiner +from haystack.components.query import QueryExpander +from haystack.components.rankers import LLMRanker +from haystack.components.retrievers.in_memory import InMemoryBM25Retriever +from haystack.components.routers import MetadataRouter +from haystack.components.writers import DocumentWriter +from haystack.core.component.component import component +from haystack.dataclasses import ChatMessage +from haystack.document_stores.in_memory import InMemoryDocumentStore +from haystack.tools import Tool +from haystack.utils.misc import expand_page_range + + +def _tool() -> Tool: + return Tool( + name="page_range", + description="Expand a page range", + parameters={"type": "object", "properties": {"page_range": {"type": "string"}}}, + function=expand_page_range, + ) + + +def _super_component() -> SuperComponent: + pipeline = Pipeline() + pipeline.add_component("writer", DocumentWriter(document_store=InMemoryDocumentStore())) + return SuperComponent(pipeline=pipeline) + + +# One factory per deserialization style that replaces a serialized value with a live object: a callable, a nested +# component, a tool, a type and an enum. +FACTORIES: dict[str, Callable[[], Any]] = { + "callable": lambda: OpenAIChatGenerator(streaming_callback=print_streaming_chunk), + "tools": lambda: OpenAIChatGenerator(tools=[_tool()]), + "chat_generator": lambda: QueryExpander(chat_generator=OpenAIChatGenerator()), + "llm_ranker": lambda: LLMRanker(chat_generator=OpenAIChatGenerator()), + "chat_messages": lambda: ChatPromptBuilder(template=[ChatMessage.from_user("{{ query }}")]), + "output_type": lambda: OutputAdapter(template="{{ documents[0].content }}", output_type=str), + "type_": lambda: BranchJoiner(type_=list[Document]), + "enum": lambda: DocumentWriter(document_store=InMemoryDocumentStore()), + "filter_policy": lambda: InMemoryBM25Retriever(document_store=InMemoryDocumentStore()), + "router_output_type": lambda: MetadataRouter( + rules={"edge": {"field": "meta.year", "operator": "==", "value": 2025}}, output_type=list[Document] + ), + "tool": _tool, + "super_component": _super_component, +} + + +@pytest.mark.parametrize("factory", FACTORIES.values(), ids=list(FACTORIES)) +def test_from_dict_does_not_mutate_input(factory, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "test-api-key") + instance = factory() + data = instance.to_dict() + expected = deepcopy(data) + + type(instance).from_dict(data) + + assert data == expected + + +@pytest.mark.parametrize("factory", FACTORIES.values(), ids=list(FACTORIES)) +def test_from_dict_can_be_called_twice_on_the_same_data(factory, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "test-api-key") + instance = factory() + data = instance.to_dict() + + first = type(instance).from_dict(data) + second = type(instance).from_dict(data) + + # The data is still serializable, so it can be written back to disk after being deserialized. + assert json.loads(json.dumps(data)) == data + assert first.to_dict() == second.to_dict() + + +def test_registered_components_do_not_mutate_input(monkeypatch): + """Sweep every component that can be built without arguments, so new components are covered automatically.""" + monkeypatch.setenv("OPENAI_API_KEY", "test-api-key") + offenders = [] + for component_class in component.registry.values(): + parameters = list(inspect.signature(component_class.__init__).parameters.values())[1:] + required = [ + parameter + for parameter in parameters + if parameter.default is inspect.Parameter.empty + and parameter.kind not in (parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD) + ] + if required: + continue + try: + data = component_class().to_dict() + except Exception: # components that need optional dependencies or credentials to be built + continue + expected = deepcopy(data) + try: + component_class.from_dict(data) + except Exception: + continue + if data != expected: + offenders.append(component_class.__name__) + + assert offenders == []