Skip to content

Commit a7cea9a

Browse files
Python: Stop accessing private Azure SDK attributes in Azure AI Search connector (#13971)
### Motivation and Context CI fails with `AttributeError: 'SearchIndexClient' object has no attribute '_endpoint'` when `azure-search-documents` resolves to 12.0.0 (released 2026-04-01). The 12.0.0 release migrated from AutoRest to TypeSpec code generation, removing private attributes `_endpoint` and `_credential` from `SearchIndexClient`. CI uses `uv sync -U` which upgrades past the lockfile, pulling in 12.0.0 since `pyproject.toml` specifies `>= 11.6.0b4` with no upper bound. ### Description Stop reading `SearchIndexClient._endpoint` and `._credential`. Instead, store endpoint and credential as explicit fields on `AzureAISearchStore` and `AzureAISearchCollection`, and pass them directly when constructing `SearchClient`. **Changes:** - `_get_search_client()` accepts explicit `endpoint` + `credential` params instead of extracting from `SearchIndexClient` private attrs - `_resolve_credential()` new helper extracted from `_get_search_index_client()` for reuse - `AzureAISearchStore` / `AzureAISearchCollection` store `search_endpoint` and `search_credential` as fields, thread them through construction - Tests updated to assert on public fields instead of private SDK attributes ### Contribution Checklist - [x] The code builds clean without any errors or warnings - [x] All unit tests pass with both `azure-search-documents==11.7.0b2` and `==12.0.0` - [x] No breaking changes to public API --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent fdc6e68 commit a7cea9a

4 files changed

Lines changed: 133 additions & 33 deletions

File tree

python/pyproject.toml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ milvus = [
103103
"milvus >= 2.3,<2.3.8; platform_system != 'Windows'"
104104
]
105105
mistralai = [
106-
"mistralai >= 1.2,< 2.0"
106+
"mistralai >= 1.2,< 2.4.6"
107107
]
108108
mongo = [
109109
"pymongo >= 4.8.0, < 4.16",
@@ -118,9 +118,7 @@ ollama = [
118118
onnx = [
119119
# onnxruntime>=1.24.0 dropped Python 3.10 support; pin to last compatible version for 3.10.
120120
"onnxruntime==1.22.1; python_version == '3.10'",
121-
# onnxruntime 1.26.0 has no macOS ARM64 wheel; pin to last compatible version.
122-
# https://github.com/microsoft/onnxruntime/issues/28441
123-
"onnxruntime>=1.24.3, <1.26.0; python_version > '3.10'",
121+
"onnxruntime>=1.24.3; python_version > '3.10'",
124122
"onnxruntime-genai==0.9.0"
125123
]
126124
oracledb = [

python/semantic_kernel/connectors/azure_ai_search.py

Lines changed: 80 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from collections.abc import Sequence
88
from typing import Any, ClassVar, Final, Generic, TypeVar
99

10-
from azure.core.credentials import AzureKeyCredential, TokenCredential
10+
from azure.core.credentials import AzureKeyCredential
1111
from azure.core.credentials_async import AsyncTokenCredential
1212
from azure.search.documents.aio import SearchClient
1313
from azure.search.documents.indexes.aio import SearchIndexClient
@@ -149,23 +149,47 @@ class AzureAISearchSettings(KernelBaseSettings):
149149

150150

151151
def _get_search_client(
152-
search_index_client: SearchIndexClient, collection_name: str | None, **kwargs: Any
152+
endpoint: str,
153+
collection_name: str | None,
154+
credential: "AzureKeyCredential | AsyncTokenCredential",
155+
**kwargs: Any,
153156
) -> SearchClient:
154157
"""Create a search client for a collection."""
155158
if not collection_name:
156159
raise VectorStoreInitializationException("Collection name is required to create a search client.")
157160
try:
158-
return SearchClient(search_index_client._endpoint, collection_name, search_index_client._credential, **kwargs)
161+
return SearchClient(endpoint, collection_name, credential, **kwargs)
159162
except ValueError as exc:
160163
raise VectorStoreInitializationException(
161164
f"Failed to create Azure Cognitive Search client for collection {collection_name}."
162165
) from exc
163166

164167

168+
def _resolve_credential(
169+
azure_ai_search_settings: AzureAISearchSettings,
170+
azure_credential: AzureKeyCredential | None = None,
171+
token_credential: "AsyncTokenCredential | None" = None,
172+
) -> "AzureKeyCredential | AsyncTokenCredential":
173+
"""Resolve the credential to use for Azure AI Search.
174+
175+
Args:
176+
azure_ai_search_settings: Azure AI Search settings.
177+
azure_credential: Optional Azure credentials (default: {None}).
178+
token_credential: Optional Token credential (default: {None}).
179+
"""
180+
if azure_credential:
181+
return azure_credential
182+
if token_credential:
183+
return token_credential
184+
if azure_ai_search_settings.api_key:
185+
return AzureKeyCredential(azure_ai_search_settings.api_key.get_secret_value())
186+
raise ServiceInitializationError("Error: missing Azure AI Search client credentials.")
187+
188+
165189
def _get_search_index_client(
166190
azure_ai_search_settings: AzureAISearchSettings,
167191
azure_credential: AzureKeyCredential | None = None,
168-
token_credential: "AsyncTokenCredential | TokenCredential | None" = None,
192+
token_credential: "AsyncTokenCredential | None" = None,
169193
) -> SearchIndexClient:
170194
"""Return a client for Azure AI Search.
171195
@@ -174,20 +198,11 @@ def _get_search_index_client(
174198
azure_credential: Optional Azure credentials (default: {None}).
175199
token_credential: Optional Token credential (default: {None}).
176200
"""
177-
# Credentials
178-
credential: "AzureKeyCredential | AsyncTokenCredential | TokenCredential | None" = None
179-
if azure_credential:
180-
credential = azure_credential
181-
elif token_credential:
182-
credential = token_credential
183-
elif azure_ai_search_settings.api_key:
184-
credential = AzureKeyCredential(azure_ai_search_settings.api_key.get_secret_value())
185-
else:
186-
raise ServiceInitializationError("Error: missing Azure AI Search client credentials.")
201+
credential = _resolve_credential(azure_ai_search_settings, azure_credential, token_credential)
187202

188203
return SearchIndexClient(
189204
endpoint=str(azure_ai_search_settings.endpoint),
190-
credential=credential, # type: ignore
205+
credential=credential,
191206
headers=prepend_semantic_kernel_to_user_agent({}) if APP_INFO else None,
192207
)
193208

@@ -286,6 +301,8 @@ class AzureAISearchCollection(
286301

287302
search_client: SearchClient
288303
search_index_client: SearchIndexClient
304+
search_endpoint: str | None = None
305+
search_credential: Any = None
289306
supported_key_types: ClassVar[set[str] | None] = {"str"}
290307
supported_vector_types: ClassVar[set[str] | None] = {"float", "int"}
291308
supported_search_types: ClassVar[set[SearchType]] = {SearchType.VECTOR, SearchType.KEYWORD_HYBRID}
@@ -299,6 +316,7 @@ def __init__(
299316
search_index_client: SearchIndexClient | None = None,
300317
search_client: SearchClient | None = None,
301318
embedding_generator: "EmbeddingGeneratorBase | None" = None,
319+
search_credential: "AzureKeyCredential | AsyncTokenCredential | None" = None,
302320
**kwargs: Any,
303321
) -> None:
304322
"""Initializes a new instance of the AzureAISearchCollection class.
@@ -319,13 +337,16 @@ def __init__(
319337
used for creating and deleting indexes.
320338
search_client: The search client for interacting with Azure AI Search,
321339
used for record operations.
340+
search_credential: The credential used to authenticate with Azure AI Search.
341+
If not provided, it will be resolved from azure_credentials, token_credentials,
342+
or api_key in kwargs/environment.
322343
embedding_generator: The embedding generator, optional.
323344
**kwargs: Additional keyword arguments, including:
324345
The same keyword arguments used for AzureAISearchVectorStore:
325-
search_endpoint: str | None = None,
346+
search_endpoint: The endpoint of the Azure AI Search service, optional.
326347
api_key: str | None = None,
327348
azure_credentials: AzureKeyCredential | None = None,
328-
token_credentials: AsyncTokenCredential | TokenCredential | None = None,
349+
token_credentials: AsyncTokenCredential | None = None,
329350
env_file_path: str | None = None,
330351
env_file_encoding: str | None = None
331352
@@ -343,6 +364,8 @@ def __init__(
343364
collection_name=collection_name,
344365
search_client=search_client,
345366
search_index_client=search_index_client,
367+
search_endpoint=kwargs.get("search_endpoint"),
368+
search_credential=search_credential,
346369
managed_search_index_client=False,
347370
managed_client=False,
348371
embedding_generator=embedding_generator,
@@ -360,14 +383,24 @@ def __init__(
360383
)
361384
except ValidationError as exc:
362385
raise VectorStoreInitializationException("Failed to create Azure Cognitive Search settings.") from exc
386+
endpoint = str(azure_ai_search_settings.endpoint)
387+
credential = search_credential or _resolve_credential(
388+
azure_ai_search_settings,
389+
azure_credential=kwargs.get("azure_credentials"),
390+
token_credential=kwargs.get("token_credentials"),
391+
)
363392
super().__init__(
364393
record_type=record_type,
365394
definition=definition,
366395
collection_name=azure_ai_search_settings.index_name,
367396
search_client=_get_search_client(
368-
search_index_client=search_index_client, collection_name=azure_ai_search_settings.index_name
397+
endpoint=endpoint,
398+
collection_name=azure_ai_search_settings.index_name,
399+
credential=credential,
369400
),
370401
search_index_client=search_index_client,
402+
search_endpoint=endpoint,
403+
search_credential=credential,
371404
managed_search_index_client=False,
372405
embedding_generator=embedding_generator,
373406
)
@@ -383,6 +416,12 @@ def __init__(
383416
)
384417
except ValidationError as exc:
385418
raise VectorStoreInitializationException("Failed to create Azure Cognitive Search settings.") from exc
419+
endpoint = str(azure_ai_search_settings.endpoint)
420+
credential = search_credential or _resolve_credential(
421+
azure_ai_search_settings,
422+
azure_credential=kwargs.get("azure_credentials"),
423+
token_credential=kwargs.get("token_credentials"),
424+
)
386425
search_index_client = _get_search_index_client(
387426
azure_ai_search_settings=azure_ai_search_settings,
388427
azure_credential=kwargs.get("azure_credentials"),
@@ -393,10 +432,13 @@ def __init__(
393432
definition=definition,
394433
collection_name=azure_ai_search_settings.index_name,
395434
search_client=_get_search_client(
396-
search_index_client=search_index_client,
397-
collection_name=azure_ai_search_settings.index_name, # type: ignore
435+
endpoint=endpoint,
436+
collection_name=azure_ai_search_settings.index_name,
437+
credential=credential,
398438
),
399439
search_index_client=search_index_client,
440+
search_endpoint=endpoint,
441+
search_credential=credential,
400442
embedding_generator=embedding_generator,
401443
)
402444

@@ -711,20 +753,24 @@ class AzureAISearchStore(VectorStore):
711753
"""Azure AI Search store implementation."""
712754

713755
search_index_client: SearchIndexClient
756+
search_endpoint: str | None = None
757+
search_credential: Any = None
714758

715759
def __init__(
716760
self,
717761
search_endpoint: str | None = None,
718762
api_key: str | None = None,
719763
azure_credentials: "AzureKeyCredential | None" = None,
720-
token_credentials: "AsyncTokenCredential | TokenCredential | None" = None,
764+
token_credentials: "AsyncTokenCredential | None" = None,
721765
search_index_client: SearchIndexClient | None = None,
722766
embedding_generator: "EmbeddingGeneratorBase | None" = None,
723767
env_file_path: str | None = None,
724768
env_file_encoding: str | None = None,
725769
) -> None:
726770
"""Initializes a new instance of the AzureAISearchStore class."""
727771
managed_client: bool = False
772+
endpoint: str | None = None
773+
credential: AzureKeyCredential | AsyncTokenCredential | None = None
728774
if not search_index_client:
729775
try:
730776
azure_ai_search_settings = AzureAISearchSettings(
@@ -735,15 +781,26 @@ def __init__(
735781
)
736782
except ValidationError as exc:
737783
raise VectorStoreInitializationException("Failed to create Azure AI Search settings.") from exc
784+
endpoint = str(azure_ai_search_settings.endpoint)
785+
credential = _resolve_credential(
786+
azure_ai_search_settings,
787+
azure_credential=azure_credentials,
788+
token_credential=token_credentials,
789+
)
738790
search_index_client = _get_search_index_client(
739791
azure_ai_search_settings=azure_ai_search_settings,
740792
azure_credential=azure_credentials,
741793
token_credential=token_credentials,
742794
)
743795
managed_client = True
796+
else:
797+
endpoint = search_endpoint
798+
credential = azure_credentials or token_credentials or (AzureKeyCredential(api_key) if api_key else None)
744799

745800
super().__init__(
746801
search_index_client=search_index_client,
802+
search_endpoint=endpoint,
803+
search_credential=credential,
747804
managed_client=managed_client,
748805
embedding_generator=embedding_generator,
749806
)
@@ -777,6 +834,8 @@ def get_collection(
777834
search_index_client=self.search_index_client,
778835
search_client=search_client,
779836
embedding_generator=embedding_generator or self.embedding_generator,
837+
search_credential=self.search_credential,
838+
search_endpoint=self.search_endpoint,
780839
**kwargs,
781840
)
782841

python/tests/unit/connectors/memory/test_azure_ai_search.py

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
AzureAISearchStore,
1717
_definition_to_azure_ai_search_index,
1818
_get_search_index_client,
19+
_resolve_credential,
1920
)
2021
from semantic_kernel.exceptions import (
2122
ServiceInitializationError,
@@ -171,8 +172,6 @@ def test_init_with_search_index_client(azure_ai_search_unit_test_env, definition
171172
@mark.parametrize("exclude_list", [["AZURE_AI_SEARCH_INDEX_NAME"]], indirect=True)
172173
def test_init_with_search_index_client_fail(azure_ai_search_unit_test_env, definition):
173174
search_index_client = MagicMock(spec=SearchIndexClient)
174-
search_index_client._endpoint = "test-endpoint"
175-
search_index_client._credential = "test-credential"
176175
with raises(VectorStoreInitializationException):
177176
AzureAISearchCollection(
178177
record_type=dict,
@@ -234,13 +233,15 @@ async def test_ensure_collection_deleted(collection, mock_ensure_collection_dele
234233
await collection.ensure_collection_deleted()
235234

236235

236+
@mark.parametrize("distance_function", [("cosine_distance")])
237237
async def test_create_index_from_index(collection, mock_ensure_collection_exists):
238238
from azure.search.documents.indexes.models import SearchIndex
239239

240240
index = MagicMock(spec=SearchIndex)
241241
await collection.ensure_collection_exists(index=index)
242242

243243

244+
@mark.parametrize("distance_function", [("cosine_distance")])
244245
async def test_create_index_from_definition(collection, mock_ensure_collection_exists):
245246
from azure.search.documents.indexes.models import SearchIndex
246247

@@ -301,32 +302,74 @@ def test_get_collection(vector_store, definition):
301302
assert collection.collection_name == "test"
302303
assert collection.search_index_client == vector_store.search_index_client
303304
assert collection.search_client is not None
304-
assert collection.search_client._endpoint == vector_store.search_index_client._endpoint
305+
assert collection.search_endpoint == vector_store.search_endpoint
306+
assert collection.search_credential == vector_store.search_credential
307+
308+
309+
def test_get_collection_with_provided_search_index_client(azure_ai_search_unit_test_env, definition):
310+
"""Test that get_collection works when AzureAISearchStore is created with a pre-built search_index_client.
311+
312+
When search_index_client is provided directly, search_endpoint and search_credential
313+
are not resolved at store creation time. get_collection() should still succeed
314+
by falling back to environment variables for endpoint/credential resolution.
315+
"""
316+
search_index_client = MagicMock(spec=SearchIndexClient)
317+
store = AzureAISearchStore(search_index_client=search_index_client)
318+
assert store.search_endpoint is None
319+
assert store.search_credential is None
320+
321+
collection = store.get_collection(
322+
collection_name="test",
323+
record_type=dict,
324+
definition=definition,
325+
)
326+
assert collection is not None
327+
assert collection.collection_name == "test"
328+
assert collection.search_index_client == search_index_client
329+
assert collection.search_client is not None
305330

306331

307332
@mark.parametrize("exclude_list", [["AZURE_AI_SEARCH_API_KEY"]], indirect=True)
308333
def test_get_search_index_client(azure_ai_search_unit_test_env):
309-
from azure.core.credentials import AzureKeyCredential, TokenCredential
334+
from azure.core.credentials import AzureKeyCredential
335+
from azure.core.credentials_async import AsyncTokenCredential
310336

311337
settings = AzureAISearchSettings(**azure_ai_search_unit_test_env, env_file_path="test.env")
312338

313339
azure_credential = MagicMock(spec=AzureKeyCredential)
314340
client = _get_search_index_client(settings, azure_credential=azure_credential)
315341
assert client is not None
316-
assert client._credential == azure_credential
317342

318-
token_credential = MagicMock(spec=TokenCredential)
343+
token_credential = MagicMock(spec=AsyncTokenCredential)
319344
client2 = _get_search_index_client(
320345
settings,
321346
token_credential=token_credential,
322347
)
323348
assert client2 is not None
324-
assert client2._credential == token_credential
325349

326350
with raises(ServiceInitializationError):
327351
_get_search_index_client(settings)
328352

329353

354+
@mark.parametrize("exclude_list", [["AZURE_AI_SEARCH_API_KEY"]], indirect=True)
355+
def test_resolve_credential(azure_ai_search_unit_test_env):
356+
from azure.core.credentials import AzureKeyCredential
357+
from azure.core.credentials_async import AsyncTokenCredential
358+
359+
settings = AzureAISearchSettings(**azure_ai_search_unit_test_env, env_file_path="test.env")
360+
361+
azure_credential = MagicMock(spec=AzureKeyCredential)
362+
resolved = _resolve_credential(settings, azure_credential=azure_credential)
363+
assert resolved == azure_credential
364+
365+
token_credential = MagicMock(spec=AsyncTokenCredential)
366+
resolved = _resolve_credential(settings, token_credential=token_credential)
367+
assert resolved == token_credential
368+
369+
with raises(ServiceInitializationError):
370+
_resolve_credential(settings)
371+
372+
330373
@mark.parametrize("include_vectors", [True, False])
331374
async def test_search_vectorized_search(collection, mock_search, include_vectors):
332375
results = await collection.search(vector=[0.1, 0.2, 0.3], include_vectors=include_vectors)

python/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)