feat: suggest the correct class path when a deserialized class can't be imported

This commit is contained in:
Sebastian Husch Lee
2026-10-06 10:18:56 +02:00
parent 0046576256
commit 9cbed33430
9 changed files with 109 additions and 2 deletions
+1
View File
@@ -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
+2 -1
View File
@@ -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):
+5 -1
View File
@@ -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)
+36
View File
@@ -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 ""
@@ -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.
+24
View File
@@ -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"},
+6
View File
@@ -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(
+12
View File
@@ -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()}
+15
View File
@@ -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) == ""