From 9cbed33430670e7df9650c3410e05d80922ab35d Mon Sep 17 00:00:00 2001 From: Sebastian Husch Lee Date: Tue, 6 Oct 2026 10:18:56 +0200 Subject: [PATCH] feat: suggest the correct class path when a deserialized class can't be imported --- haystack/core/pipeline/base.py | 1 + haystack/utils/base_serialization.py | 3 +- haystack/utils/deserialization.py | 6 +++- haystack/utils/type_serialization.py | 36 +++++++++++++++++++ ...path-on-import-error-8387d68706b04f1a.yaml | 8 +++++ test/core/pipeline/test_pipeline_base.py | 24 +++++++++++++ test/utils/test_base_serialization.py | 6 ++++ test/utils/test_deserialization.py | 12 +++++++ test/utils/test_type_serialization.py | 15 ++++++++ 9 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 releasenotes/notes/suggest-class-path-on-import-error-8387d68706b04f1a.yaml diff --git a/haystack/core/pipeline/base.py b/haystack/core/pipeline/base.py index 855fddbe7b..ee0b893283 100644 --- a/haystack/core/pipeline/base.py +++ b/haystack/core/pipeline/base.py @@ -256,6 +256,7 @@ class PipelineBase: # noqa: PLW1641 raise PipelineError( f"Component '{component_type}' (name: '{name}') not imported. Please " f"check that the package is installed and the component path is correct." + f"{type_serialization._class_path_hint(component_type)}" ) from e # Create a new one diff --git a/haystack/utils/base_serialization.py b/haystack/utils/base_serialization.py index 43b30947d3..559e20f56b 100644 --- a/haystack/utils/base_serialization.py +++ b/haystack/utils/base_serialization.py @@ -11,6 +11,7 @@ from haystack import logging from haystack.core.errors import DeserializationError, SerializationError from haystack.core.serialization import generate_qualified_class_name, import_class_by_name from haystack.utils import deserialize_callable, serialize_callable +from haystack.utils.type_serialization import _class_path_hint logger = logging.getLogger(__name__) @@ -305,7 +306,7 @@ def _deserialize_value(value: dict[str, Any]) -> Any: try: cls = import_class_by_name(value_type) except (ImportError, ValueError) as e: - raise DeserializationError(f"Class '{value_type}' not correctly imported") from e + raise DeserializationError(f"Class '{value_type}' not correctly imported.{_class_path_hint(value_type)}") from e # try from_dict (e.g. Haystack dataclasses and Components) if hasattr(cls, "from_dict") and callable(cls.from_dict): diff --git a/haystack/utils/deserialization.py b/haystack/utils/deserialization.py index 90d2b9fcea..af05d768ba 100644 --- a/haystack/utils/deserialization.py +++ b/haystack/utils/deserialization.py @@ -6,6 +6,7 @@ from typing import Any from haystack.core.errors import DeserializationError from haystack.core.serialization import component_from_dict, import_class_by_name +from haystack.utils.type_serialization import _class_path_hint def deserialize_chatgenerator_inplace(data: dict[str, Any], key: str = "chat_generator") -> None: @@ -51,6 +52,9 @@ def deserialize_component_inplace(data: dict[str, Any], key: str = "chat_generat try: component_class = import_class_by_name(serialized_component["type"]) except ImportError as e: - raise DeserializationError(f"Class '{serialized_component['type']}' not correctly imported") from e + raise DeserializationError( + f"Class '{serialized_component['type']}' not correctly imported." + f"{_class_path_hint(serialized_component['type'])}" + ) from e data[key] = component_from_dict(cls=component_class, data=serialized_component, name=key) diff --git a/haystack/utils/type_serialization.py b/haystack/utils/type_serialization.py index 80b767cf05..fc40985dda 100644 --- a/haystack/utils/type_serialization.py +++ b/haystack/utils/type_serialization.py @@ -19,6 +19,7 @@ from haystack.core.serialization_security import ( _check_not_deserialization_internal, _check_resolved_module_allowed, _check_traversable_attribute, + _is_module_allowed, mark_deserialization_internal, ) @@ -344,3 +345,38 @@ def _import_class_by_name(fully_qualified_name: str) -> Any: except (ImportError, AttributeError) as error: logger.exception("Failed to import '{full_name}'", full_name=fully_qualified_name) raise ImportError(f"Could not import '{fully_qualified_name}'") from error + + +@mark_deserialization_internal +def _class_path_hint(fully_qualified_name: str) -> str: + """ + Suggest the correct import path for a class whose fully qualified name failed to import. + + The class name is looked up in the declared module and then in its parent package, which covers a class that + lives in a sibling module, such as `chat.openai.OpenAIResponsesChatGenerator` instead of + `chat.openai_responses.OpenAIResponsesChatGenerator`. Modules outside the deserialization allowlist are never + imported. + + :param fully_qualified_name: The fully qualified class name that failed to import. + :returns: A sentence like ` Did you mean 'package.module.ClassName'?`, or an empty string if no class with that + name is found in an allowlisted module. + """ + if "." not in fully_qualified_name: + return "" + + module_path, class_name = fully_qualified_name.rsplit(".", 1) + parent_package = module_path.rpartition(".")[0] + for candidate_module in (module_path, parent_package): + if not candidate_module or not _is_module_allowed(candidate_module): + continue + try: + candidate = getattr(thread_safe_import(candidate_module), class_name) + except (ImportError, AttributeError): + continue + if not isinstance(candidate, type) or not _is_module_allowed(candidate.__module__): + continue + suggestion = f"{candidate.__module__}.{candidate.__name__}" + if suggestion != fully_qualified_name: + return f" Did you mean '{suggestion}'?" + + return "" diff --git a/releasenotes/notes/suggest-class-path-on-import-error-8387d68706b04f1a.yaml b/releasenotes/notes/suggest-class-path-on-import-error-8387d68706b04f1a.yaml new file mode 100644 index 0000000000..d652fbf940 --- /dev/null +++ b/releasenotes/notes/suggest-class-path-on-import-error-8387d68706b04f1a.yaml @@ -0,0 +1,8 @@ +--- +enhancements: + - | + When a pipeline or component is deserialized with a class path that can't be imported, the error now suggests + the correct path if a class with that name exists in the declared module or its parent package. For example, + ``haystack.components.generators.chat.openai.OpenAIResponsesChatGenerator`` now gets + ``Did you mean 'haystack.components.generators.chat.openai_responses.OpenAIResponsesChatGenerator'?``. + Only modules on the deserialization allowlist are searched. diff --git a/test/core/pipeline/test_pipeline_base.py b/test/core/pipeline/test_pipeline_base.py index 431ea36e4f..f1abfe0041 100644 --- a/test/core/pipeline/test_pipeline_base.py +++ b/test/core/pipeline/test_pipeline_base.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: Apache-2.0 import logging +import re from collections.abc import Iterable from typing import Any from unittest.mock import patch @@ -2013,6 +2014,29 @@ class TestPipelineBaseFromDict: err.match(r"Component .+ not imported.") + @pytest.mark.parametrize( + "component_type", + [ + pytest.param( + "haystack.components.generators.chat.openai.OpenAIResponsesChatGenerator", id="sibling-module" + ), + pytest.param( + "haystack.components.generators.chat.openai_response.OpenAIResponsesChatGenerator", + id="misspelled-module", + ), + pytest.param("haystack.components.generators.chat.OpenAIResponsesChatGenerator", id="package-export"), + ], + ) + def test_from_dict_with_wrong_component_path_suggests_correct_path(self, component_type): + data = {"components": {"generator": {"type": component_type, "init_parameters": {}}}, "connections": []} + with pytest.raises( + PipelineError, + match=re.escape( + "Did you mean 'haystack.components.generators.chat.openai_responses.OpenAIResponsesChatGenerator'?" + ), + ): + PipelineBase.from_dict(data) + def test_from_dict_rejects_untrusted_component_module(self): data = { "metadata": {"test": "test"}, diff --git a/test/utils/test_base_serialization.py b/test/utils/test_base_serialization.py index 683f35c4a7..d930b6beb8 100644 --- a/test/utils/test_base_serialization.py +++ b/test/utils/test_base_serialization.py @@ -486,6 +486,12 @@ class TestErrorHandling: {"serialization_schema": {"type": "test_base_serialization.NonExistentClass"}, "serialized_data": {}} ) + def test_deserialize_value_with_schema_class_with_wrong_path_suggests_correct_path(self): + with pytest.raises(DeserializationError, match=r"Did you mean 'haystack\.dataclasses\.document\.Document'\?"): + _deserialize_value_with_schema( + {"serialization_schema": {"type": "haystack.dataclasses.documents.Document"}, "serialized_data": {}} + ) + def test_deserialize_value_with_schema_class_name_without_module(self): with pytest.raises(DeserializationError, match="Class 'NonExistentClass' not correctly imported"): _deserialize_value_with_schema( diff --git a/test/utils/test_deserialization.py b/test/utils/test_deserialization.py index 320f313075..76656eb186 100644 --- a/test/utils/test_deserialization.py +++ b/test/utils/test_deserialization.py @@ -3,6 +3,8 @@ # SPDX-License-Identifier: Apache-2.0 +import re + import pytest from haystack.components.generators.chat.openai import OpenAIChatGenerator @@ -44,6 +46,16 @@ class TestDeserializeComponentInplace: with pytest.raises(DeserializationError): deserialize_component_inplace(data) + def test_class_with_wrong_path_suggests_correct_path(self): + data = {"chat_generator": {"type": "haystack.components.generators.chat.openai.OpenAIResponsesChatGenerator"}} + with pytest.raises( + DeserializationError, + match=re.escape( + "Did you mean 'haystack.components.generators.chat.openai_responses.OpenAIResponsesChatGenerator'?" + ), + ): + deserialize_component_inplace(data) + def test_component_no_from_dict_method(self): chat_generator = ChatGeneratorWithoutFromDict() data = {"chat_generator": chat_generator.to_dict()} diff --git a/test/utils/test_type_serialization.py b/test/utils/test_type_serialization.py index a8c5a2e626..dadb10799a 100644 --- a/test/utils/test_type_serialization.py +++ b/test/utils/test_type_serialization.py @@ -16,6 +16,7 @@ from haystack.core.serialization_security import _DENIED_BUILTIN_NAMES from haystack.dataclasses import Answer, ByteStream, ChatMessage, Document from haystack.utils.type_serialization import ( _build_pep604_union_type, + _class_path_hint, _is_union_type, _parse_pep604_union_args, deserialize_type, @@ -509,3 +510,17 @@ if sys.version_info < (3, 14): assert serialize_type(Optional[dict]) == "typing.Optional[dict]" assert serialize_type(Optional[float]) == "typing.Optional[float]" assert serialize_type(Optional[bool]) == "typing.Optional[bool]" + + +@pytest.mark.parametrize( + "fully_qualified_name", + [ + pytest.param("haystack.components.generators.chat.openai_responses.OpenAIResponsesChatGenerator", id="correct"), + pytest.param("haystack.components.generators.chat.openai.NonExistentClass", id="unknown-class"), + pytest.param("haystack.components.generators.OpenAIResponsesChatGenerator", id="not-in-module-or-parent"), + pytest.param("NonExistentClass", id="no-module"), + pytest.param("os.path.join", id="not-allowlisted"), + ], +) +def test_class_path_hint_without_suggestion(fully_qualified_name): + assert _class_path_hint(fully_qualified_name) == ""