mirror of
https://github.com/deepset-ai/haystack.git
synced 2026-10-12 02:12:50 +00:00
feat: suggest the correct class path when a deserialized class can't be imported
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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"},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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) == ""
|
||||
|
||||
Reference in New Issue
Block a user