diff --git a/python/semantic_kernel/connectors/ai/hugging_face/hf_prompt_execution_settings.py b/python/semantic_kernel/connectors/ai/hugging_face/hf_prompt_execution_settings.py index ada6bcd112ee..2d0fca83e0af 100644 --- a/python/semantic_kernel/connectors/ai/hugging_face/hf_prompt_execution_settings.py +++ b/python/semantic_kernel/connectors/ai/hugging_face/hf_prompt_execution_settings.py @@ -4,13 +4,17 @@ from typing import TYPE_CHECKING, Any from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings +from semantic_kernel.exceptions import ServiceInitializationError if TYPE_CHECKING: from transformers import GenerationConfig -imported = importlib.import_module("transformers") -ready = imported is not None and hasattr(imported, "GenerationConfig") +try: + imported = importlib.import_module("transformers") + ready = imported is not None and hasattr(imported, "GenerationConfig") +except ImportError: + ready = False class HuggingFacePromptExecutionSettings(PromptExecutionSettings): @@ -27,10 +31,12 @@ class HuggingFacePromptExecutionSettings(PromptExecutionSettings): def get_generation_config(self) -> "GenerationConfig": """Get the generation config.""" - from transformers import GenerationConfig - if not ready: - raise ImportError("transformers is not installed.") + raise ServiceInitializationError( + "transformers is not installed. Please install it with `pip install semantic-kernel[hugging_face]`." + ) + + from transformers import GenerationConfig return GenerationConfig( **self.model_dump( diff --git a/python/semantic_kernel/connectors/ai/onnx/services/onnx_gen_ai_completion_base.py b/python/semantic_kernel/connectors/ai/onnx/services/onnx_gen_ai_completion_base.py index 9bafaa38b31f..9bfb0038331a 100644 --- a/python/semantic_kernel/connectors/ai/onnx/services/onnx_gen_ai_completion_base.py +++ b/python/semantic_kernel/connectors/ai/onnx/services/onnx_gen_ai_completion_base.py @@ -37,7 +37,9 @@ def __init__(self, ai_model_path: str, **kwargs) -> None: ServiceInitializationError: When model cannot be loaded """ if not ready: - raise ImportError("onnxruntime-genai is not installed.") + raise ServiceInitializationError( + "onnxruntime-genai is not installed. Please install it with `pip install semantic-kernel[onnx]`." + ) try: json_gen_ai_config = os.path.join(ai_model_path + "/genai_config.json") with open(json_gen_ai_config) as file: @@ -86,8 +88,7 @@ async def _generate_next_token_async( while not generator.is_done(): generator.generate_next_token() - new_token_choices = [self.tokenizer_stream.decode(token) for token in generator.get_next_tokens()] - yield new_token_choices + yield [self.tokenizer_stream.decode(token) for token in generator.get_next_tokens()] del generator except Exception as ex: raise ServiceInvalidResponseError("Failed Inference with ONNX", ex) from ex diff --git a/python/semantic_kernel/connectors/memory.py b/python/semantic_kernel/connectors/memory.py index a4a9e68c61ac..2221806190eb 100644 --- a/python/semantic_kernel/connectors/memory.py +++ b/python/semantic_kernel/connectors/memory.py @@ -1,8 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. import importlib +from typing import Any -_IMPORTS = { +_IMPORTS: dict[str, str] = { "AzureAISearchCollection": ".azure_ai_search", "AzureAISearchSettings": ".azure_ai_search", "AzureAISearchStore": ".azure_ai_search", @@ -25,6 +26,9 @@ "MongoDBAtlasCollection": ".mongodb", "MongoDBAtlasSettings": ".mongodb", "MongoDBAtlasStore": ".mongodb", + "OracleCollection": ".oracle", + "OracleSettings": ".oracle", + "OracleStore": ".oracle", "RedisStore": ".redis", "RedisSettings": ".redis", "RedisCollectionTypes": ".redis", @@ -44,14 +48,38 @@ "SqlSettings": ".sql_server", } +_EXTRA_MAP: dict[str, str] = { + ".azure_ai_search": "azure", + ".azure_cosmos_db": "azure", + ".chroma": "chroma", + ".faiss": "faiss", + ".mongodb": "mongo", + ".oracle": "oracledb", + ".pinecone": "pinecone", + ".postgres": "postgres", + ".qdrant": "qdrant", + ".redis": "redis", + ".sql_server": "sql", + ".weaviate": "weaviate", +} + -def __getattr__(name: str): +def __getattr__(name: str) -> Any: if name in _IMPORTS: submod_name = _IMPORTS[name] - module = importlib.import_module(submod_name, package=__name__) - return getattr(module, name) + try: + module = importlib.import_module(submod_name, package=__package__) + return getattr(module, name) + except (ModuleNotFoundError, ImportError) as ex: + extra = _EXTRA_MAP.get(submod_name) + if extra: + raise ModuleNotFoundError( + f"Could not import {name} from {submod_name}. " + f"Please install the optional dependency with `pip install semantic-kernel[{extra}]`." + ) from ex + raise raise AttributeError(f"module {__name__} has no attribute {name}") -def __dir__(): +def __dir__() -> list[str]: return list(_IMPORTS.keys()) diff --git a/python/semantic_kernel/connectors/memory.pyi b/python/semantic_kernel/connectors/memory.pyi index 7f7e2745dd10..2ac6c7ebfb86 100644 --- a/python/semantic_kernel/connectors/memory.pyi +++ b/python/semantic_kernel/connectors/memory.pyi @@ -14,6 +14,7 @@ from .chroma import ChromaCollection, ChromaStore from .faiss import FaissCollection, FaissStore from .in_memory import InMemoryCollection, InMemoryStore from .mongodb import MongoDBAtlasCollection, MongoDBAtlasSettings, MongoDBAtlasStore +from .oracle import OracleCollection, OracleSettings, OracleStore from .pinecone import PineconeCollection, PineconeSettings, PineconeStore from .postgres import PostgresCollection, PostgresSettings, PostgresStore from .qdrant import QdrantCollection, QdrantSettings, QdrantStore @@ -41,6 +42,9 @@ __all__ = [ "MongoDBAtlasCollection", "MongoDBAtlasSettings", "MongoDBAtlasStore", + "OracleCollection", + "OracleSettings", + "OracleStore", "PineconeCollection", "PineconeSettings", "PineconeStore", diff --git a/python/semantic_kernel/connectors/memory_stores/chroma/chroma_memory_store.py b/python/semantic_kernel/connectors/memory_stores/chroma/chroma_memory_store.py index 7d2cffe1b870..fe741b789827 100644 --- a/python/semantic_kernel/connectors/memory_stores/chroma/chroma_memory_store.py +++ b/python/semantic_kernel/connectors/memory_stores/chroma/chroma_memory_store.py @@ -24,7 +24,7 @@ import chromadb.config from chromadb.api.models.Collection import Collection -if sys.version_info >= (3, 12): +if sys.version_info >= (3, 13): from warnings import deprecated else: from typing_extensions import deprecated @@ -76,7 +76,8 @@ def __init__( except ImportError as exc: raise ServiceInitializationError( - "Could not import chromadb python package. Please install it with `pip install chromadb`." + "Could not import chromadb python package. " + "Please install it with `pip install semantic-kernel[chroma]`." ) from exc if client_settings: diff --git a/python/semantic_kernel/connectors/search.py b/python/semantic_kernel/connectors/search.py index 25ac66dcd64b..b182990fafc0 100644 --- a/python/semantic_kernel/connectors/search.py +++ b/python/semantic_kernel/connectors/search.py @@ -3,11 +3,11 @@ import importlib _IMPORTS = { - "GoogleSearch": ".google", - "GoogleSearchSettings": ".google", - "GoogleSearchResult": ".google", - "GoogleSearchResponse": ".google", - "GoogleSearchInformation": ".google", + "GoogleSearch": ".google_search", + "GoogleSearchSettings": ".google_search", + "GoogleSearchResult": ".google_search", + "GoogleSearchResponse": ".google_search", + "GoogleSearchInformation": ".google_search", "BraveSearch": ".brave", "BraveSettings": ".brave", "BraveWebPages": ".brave", @@ -19,7 +19,7 @@ def __getattr__(name: str): if name in _IMPORTS: submod_name = _IMPORTS[name] - module = importlib.import_module(submod_name, package=__name__) + module = importlib.import_module(submod_name, package=__package__) return getattr(module, name) raise AttributeError(f"module {__name__} has no attribute {name}") diff --git a/python/tests/unit/connectors/test_optional_connector_dependencies.py b/python/tests/unit/connectors/test_optional_connector_dependencies.py new file mode 100644 index 000000000000..3336919846cc --- /dev/null +++ b/python/tests/unit/connectors/test_optional_connector_dependencies.py @@ -0,0 +1,163 @@ +# Copyright (c) Microsoft. All rights reserved. + +import importlib +import sys +from unittest.mock import patch + +import pytest + +import semantic_kernel.connectors.memory as memory_module +import semantic_kernel.connectors.search as search_module +from semantic_kernel.connectors.ai.hugging_face.hf_prompt_execution_settings import ( + HuggingFacePromptExecutionSettings, +) +from semantic_kernel.connectors.ai.onnx.services.onnx_gen_ai_completion_base import ( + OnnxGenAICompletionBase, +) +from semantic_kernel.connectors.memory_stores.chroma.chroma_memory_store import ( + ChromaMemoryStore, +) +from semantic_kernel.exceptions import ServiceInitializationError + + +@pytest.mark.parametrize( + "symbol_name,expected_extra", + [ + ("AzureAISearchCollection", "azure"), + ("AzureAISearchSettings", "azure"), + ("AzureAISearchStore", "azure"), + ("CosmosNoSqlCollection", "azure"), + ("CosmosNoSqlCompositeKey", "azure"), + ("CosmosNoSqlSettings", "azure"), + ("CosmosNoSqlStore", "azure"), + ("CosmosMongoCollection", "azure"), + ("CosmosMongoSettings", "azure"), + ("CosmosMongoStore", "azure"), + ("ChromaCollection", "chroma"), + ("ChromaStore", "chroma"), + ("PostgresCollection", "postgres"), + ("PostgresSettings", "postgres"), + ("PostgresStore", "postgres"), + ("FaissCollection", "faiss"), + ("FaissStore", "faiss"), + ("MongoDBAtlasCollection", "mongo"), + ("MongoDBAtlasSettings", "mongo"), + ("MongoDBAtlasStore", "mongo"), + ("OracleCollection", "oracledb"), + ("OracleSettings", "oracledb"), + ("OracleStore", "oracledb"), + ("RedisStore", "redis"), + ("RedisSettings", "redis"), + ("RedisCollectionTypes", "redis"), + ("RedisHashsetCollection", "redis"), + ("RedisJsonCollection", "redis"), + ("QdrantCollection", "qdrant"), + ("QdrantSettings", "qdrant"), + ("QdrantStore", "qdrant"), + ("WeaviateCollection", "weaviate"), + ("WeaviateSettings", "weaviate"), + ("WeaviateStore", "weaviate"), + ("PineconeCollection", "pinecone"), + ("PineconeSettings", "pinecone"), + ("PineconeStore", "pinecone"), + ("SqlServerCollection", "sql"), + ("SqlServerStore", "sql"), + ("SqlSettings", "sql"), + ], +) +def test_memory_lazy_import_missing_dependency(symbol_name: str, expected_extra: str): + """Test that importing a connector raises ModuleNotFoundError with the exact install extra.""" + submod_name = memory_module._IMPORTS[symbol_name] + + def mock_import_module(name: str, package: str | None = None): + if name == submod_name: + raise ModuleNotFoundError(f"No module named 'fake_{expected_extra}'", name=f"fake_{expected_extra}") + return importlib.__import__(name) + + with ( + patch("importlib.import_module", side_effect=mock_import_module), + pytest.raises(ModuleNotFoundError) as exc_info, + ): + getattr(memory_module, symbol_name) + + assert f"pip install semantic-kernel[{expected_extra}]" in str(exc_info.value) + assert symbol_name in str(exc_info.value) + + +def test_memory_in_memory_import(): + """Test that built-in InMemory store and collection import successfully without extra dependencies.""" + in_memory_col = getattr(memory_module, "InMemoryCollection") + assert in_memory_col is not None + + in_memory_store = getattr(memory_module, "InMemoryStore") + assert in_memory_store is not None + + +def test_memory_unknown_attribute(): + """Test that accessing an unknown attribute in memory module raises AttributeError.""" + with pytest.raises( + AttributeError, + match="module semantic_kernel.connectors.memory has no attribute NonExistentStore", + ): + getattr(memory_module, "NonExistentStore") + + +def test_memory_dir(): + """Test that __dir__ lists all available memory symbols.""" + dir_symbols = dir(memory_module) + for symbol in [ + "ChromaCollection", + "QdrantCollection", + "WeaviateCollection", + "PineconeCollection", + "PostgresCollection", + "RedisStore", + "MongoDBAtlasCollection", + "FaissCollection", + "OracleCollection", + "SqlServerCollection", + "AzureAISearchCollection", + "CosmosNoSqlCollection", + "InMemoryCollection", + ]: + assert symbol in dir_symbols + + +def test_search_imports(): + """Test that search connectors can be imported and non-existent attribute raises AttributeError.""" + google_search = getattr(search_module, "GoogleSearch") + assert google_search is not None + + brave_search = getattr(search_module, "BraveSearch") + assert brave_search is not None + + with pytest.raises(AttributeError, match="has no attribute NonExistentSearch"): + getattr(search_module, "NonExistentSearch") + + +def test_chroma_memory_store_missing_dependency(): + """Test that ChromaMemoryStore raises ServiceInitializationError when chromadb is missing.""" + with ( + patch.dict(sys.modules, {"chromadb": None, "chromadb.config": None}), + pytest.raises(ServiceInitializationError, match=r"pip install semantic-kernel\[chroma\]"), + ): + ChromaMemoryStore() + + +def test_onnx_gen_ai_completion_missing_dependency(): + """Test that OnnxGenAICompletionBase raises ServiceInitializationError when onnxruntime-genai is missing.""" + with ( + patch("semantic_kernel.connectors.ai.onnx.services.onnx_gen_ai_completion_base.ready", False), + pytest.raises(ServiceInitializationError, match=r"pip install semantic-kernel\[onnx\]"), + ): + OnnxGenAICompletionBase(ai_model_path="fake_path") + + +def test_hugging_face_settings_missing_dependency(): + """Test that HuggingFacePromptExecutionSettings raises ServiceInitializationError when transformers is missing.""" + with ( + patch("semantic_kernel.connectors.ai.hugging_face.hf_prompt_execution_settings.ready", False), + pytest.raises(ServiceInitializationError, match=r"pip install semantic-kernel\[hugging_face\]"), + ): + settings = HuggingFacePromptExecutionSettings() + settings.get_generation_config()