diff --git a/pinecone/adapters/__init__.py b/pinecone/adapters/__init__.py index e9c481ff..2f2f25e5 100644 --- a/pinecone/adapters/__init__.py +++ b/pinecone/adapters/__init__.py @@ -13,16 +13,28 @@ >>> sdk_response = adapt_query_response(openapi_response) """ +from pinecone.adapters.protocols import ( + FetchResponseAdapter, + IndexModelAdapter, + QueryResponseAdapter, + UpsertResponseAdapter, +) from pinecone.adapters.response_adapters import ( adapt_fetch_response, adapt_query_response, adapt_upsert_response, UpsertResponseTransformer, ) +from pinecone.adapters.index_adapter import adapt_index_spec __all__ = [ "adapt_fetch_response", + "adapt_index_spec", "adapt_query_response", "adapt_upsert_response", "UpsertResponseTransformer", + "FetchResponseAdapter", + "IndexModelAdapter", + "QueryResponseAdapter", + "UpsertResponseAdapter", ] diff --git a/pinecone/adapters/index_adapter.py b/pinecone/adapters/index_adapter.py new file mode 100644 index 00000000..df931d14 --- /dev/null +++ b/pinecone/adapters/index_adapter.py @@ -0,0 +1,237 @@ +"""Adapter functions for IndexModel spec resolution. + +This module provides adapter functions that handle the complex oneOf schema resolution +for IndexModel spec fields. This isolates the SDK wrapper code from the internal +structure of OpenAPI models and their deserialization logic. + +The adapter extracts spec resolution logic from the IndexModel wrapper, making it +easier to support future API format changes (e.g., schema-based dimension/metric). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from pinecone.openapi_support.model_utils import deserialize_model + +if TYPE_CHECKING: + from pinecone.adapters.protocols import IndexModelAdapter + + +def adapt_index_spec(index: "IndexModelAdapter") -> Any: + """Adapt an IndexModel's spec field, handling oneOf schema resolution. + + The OpenAPI spec for IndexModel.spec is a oneOf union of Serverless, PodBased, + and BYOC types. The OpenAPI generator's deserialization sometimes fails to properly + resolve which variant to use, leaving spec as a raw dict. This adapter manually + detects the correct type and constructs the appropriate wrapper. + + This function handles three spec types: + - serverless: Contains nested ServerlessSpecResponse with optional ReadCapacity + - pod: Contains nested PodSpec + - byoc: Contains nested ByocSpec + + Args: + index: An IndexModel-like object conforming to IndexModelAdapter protocol. + + Returns: + The deserialized IndexSpec (Serverless, PodBased, or BYOC), or None if spec + is not present. Returns Any to satisfy mypy since the actual return types + are oneOf variants that don't have a common base type accessible here. + + Example: + >>> spec = adapt_index_spec(index) + >>> if hasattr(spec, 'serverless'): + ... print(f"Cloud: {spec.serverless.cloud}") + """ + from pinecone.core.openapi.db_control.model.index_spec import IndexSpec + + # Access _data_store directly to avoid OpenAPI model attribute resolution + spec_value = index._data_store.get("spec") + if spec_value is None: + # Fallback to getattr in case spec is stored differently + spec_value = getattr(index, "spec", None) + + if not isinstance(spec_value, dict): + # Already an IndexSpec instance or None + return spec_value + + # Get configuration from the underlying model for proper deserialization + config = index._configuration + path_to_item = index._path_to_item + + # Convert to list if needed and append 'spec' to path_to_item for proper error reporting + if isinstance(path_to_item, (list, tuple)): + spec_path = list(path_to_item) + ["spec"] + else: + spec_path = ["spec"] + + # Manually detect which oneOf schema to use based on discriminator keys + if "serverless" in spec_value: + return _adapt_serverless_spec(spec_value, spec_path, config) + elif "pod" in spec_value: + return _adapt_pod_spec(spec_value, spec_path, config) + elif "byoc" in spec_value: + return _adapt_byoc_spec(spec_value, spec_path, config) + else: + # Fallback: try deserialize_model (shouldn't happen with valid API responses) + return deserialize_model( + spec_value, + IndexSpec, + spec_path, + check_type=True, + configuration=config, + spec_property_naming=False, + ) + + +def _adapt_serverless_spec(spec_value: dict[str, Any], spec_path: list[str], config: Any) -> Any: + """Adapt a serverless spec, handling nested ReadCapacity oneOf. + + Args: + spec_value: Raw spec dict from _data_store + spec_path: Path to spec in response tree for error reporting + config: OpenAPI configuration object + + Returns: + Serverless wrapper instance containing ServerlessSpecResponse + """ + from pinecone.core.openapi.db_control.model.serverless import Serverless + from pinecone.core.openapi.db_control.model.serverless_spec_response import ( + ServerlessSpecResponse, + ) + from pinecone.core.openapi.db_control.model.read_capacity_response import ReadCapacityResponse + from pinecone.core.openapi.db_control.model.read_capacity_on_demand_spec_response import ( + ReadCapacityOnDemandSpecResponse, + ) + from pinecone.core.openapi.db_control.model.read_capacity_dedicated_spec_response import ( + ReadCapacityDedicatedSpecResponse, + ) + + # Deserialize the nested serverless dict to ServerlessSpecResponse + serverless_dict = dict(spec_value["serverless"]) + + # Handle nested read_capacity if present (it's also a oneOf with discriminator) + # Preserve already-deserialized values, only deserialize dicts + read_capacity_spec = serverless_dict.get("read_capacity") + if "read_capacity" in serverless_dict and isinstance(serverless_dict["read_capacity"], dict): + read_capacity_dict = serverless_dict["read_capacity"] + mode = read_capacity_dict.get("mode") + + # Use discriminator to determine which ReadCapacity spec to use + if mode == "OnDemand": + read_capacity_spec = deserialize_model( + read_capacity_dict, + ReadCapacityOnDemandSpecResponse, + spec_path + ["serverless", "read_capacity"], + check_type=True, + configuration=config, + spec_property_naming=False, + ) + elif mode == "Dedicated": + read_capacity_spec = deserialize_model( + read_capacity_dict, + ReadCapacityDedicatedSpecResponse, + spec_path + ["serverless", "read_capacity"], + check_type=True, + configuration=config, + spec_property_naming=False, + ) + else: + # Fallback to ReadCapacityResponse (should use discriminator) + read_capacity_spec = deserialize_model( + read_capacity_dict, + ReadCapacityResponse, + spec_path + ["serverless", "read_capacity"], + check_type=True, + configuration=config, + spec_property_naming=False, + ) + + # Create ServerlessSpecResponse with all required and optional fields + serverless_spec = ServerlessSpecResponse._from_openapi_data( + cloud=serverless_dict["cloud"], + region=serverless_dict["region"], + read_capacity=read_capacity_spec, + source_collection=serverless_dict.get("source_collection"), + schema=serverless_dict.get("schema"), + _check_type=False, + _path_to_item=spec_path + ["serverless"], + _configuration=config, + _spec_property_naming=False, + ) + + # Instantiate Serverless wrapper, which IS the IndexSpec (oneOf union) + # Note: We use _check_type=False because ServerlessSpecResponse (from GET responses) + # is compatible with but not identical to ServerlessSpec (used in POST requests) + return Serverless._new_from_openapi_data( + serverless=serverless_spec, + _check_type=False, + _path_to_item=spec_path, + _configuration=config, + _spec_property_naming=False, + ) + + +def _adapt_pod_spec(spec_value: dict[str, Any], spec_path: list[str], config: Any) -> Any: + """Adapt a pod-based spec. + + Args: + spec_value: Raw spec dict from _data_store + spec_path: Path to spec in response tree for error reporting + config: OpenAPI configuration object + + Returns: + PodBased wrapper instance containing PodSpec + """ + from pinecone.core.openapi.db_control.model.pod_based import PodBased + from pinecone.core.openapi.db_control.model.pod_spec import PodSpec + + pod_spec = deserialize_model( + spec_value["pod"], + PodSpec, + spec_path + ["pod"], + check_type=True, + configuration=config, + spec_property_naming=False, + ) + + return PodBased._new_from_openapi_data( + pod=pod_spec, + _check_type=True, + _path_to_item=spec_path, + _configuration=config, + _spec_property_naming=False, + ) + + +def _adapt_byoc_spec(spec_value: dict[str, Any], spec_path: list[str], config: Any) -> Any: + """Adapt a BYOC (Bring Your Own Cloud) spec. + + Args: + spec_value: Raw spec dict from _data_store + spec_path: Path to spec in response tree for error reporting + config: OpenAPI configuration object + + Returns: + BYOC wrapper instance containing ByocSpec + """ + from pinecone.core.openapi.db_control.model.byoc import BYOC + from pinecone.core.openapi.db_control.model.byoc_spec import ByocSpec + + byoc_spec = deserialize_model( + spec_value["byoc"], + ByocSpec, + spec_path + ["byoc"], + check_type=True, + configuration=config, + spec_property_naming=False, + ) + + return BYOC._new_from_openapi_data( + byoc=byoc_spec, + _check_type=True, + _path_to_item=spec_path, + _configuration=config, + _spec_property_naming=False, + ) diff --git a/pinecone/adapters/protocols.py b/pinecone/adapters/protocols.py new file mode 100644 index 00000000..05993110 --- /dev/null +++ b/pinecone/adapters/protocols.py @@ -0,0 +1,137 @@ +"""Protocol definitions for the adapter layer. + +This module defines formal Protocol interfaces that specify the contract between +generated OpenAPI models and SDK adapter code. These protocols make it explicit +what properties and methods the SDK code depends on from the OpenAPI models, +enabling: + +- Type safety with static type checking (mypy) +- Clear documentation of adapter dependencies +- Flexibility to change OpenAPI model implementations +- Better testability through protocol-based mocking + +Each protocol corresponds to an OpenAPI model type that adapters consume. The +protocols define only the minimal interface required by adapter functions, +isolating SDK code from the full complexity of generated models. + +Usage: + >>> from pinecone.adapters.protocols import QueryResponseAdapter + >>> def adapt_query(response: QueryResponseAdapter) -> QueryResponse: + ... return QueryResponse(matches=response.matches) +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Protocol + +if TYPE_CHECKING: + from pinecone.core.openapi.db_data.models import ScoredVector, Usage + from pinecone.core.openapi.db_control.model.index_model_status import IndexModelStatus + + +class QueryResponseAdapter(Protocol): + """Protocol for OpenAPI QueryResponse objects used in adapters. + + This protocol defines the minimal interface that SDK code depends on when + adapting an OpenAPI QueryResponse to the SDK QueryResponse dataclass. + + Attributes: + matches: List of scored vectors returned by the query (optional). + namespace: The namespace that was queried (optional). + usage: Optional usage statistics for the query operation. + _data_store: Internal data storage (for accessing raw response data). + _response_info: Response metadata including headers. + """ + + matches: list[ScoredVector] | None + namespace: str | None + usage: Usage | None + _data_store: dict[str, Any] + _response_info: Any + + +class UpsertResponseAdapter(Protocol): + """Protocol for OpenAPI UpsertResponse objects used in adapters. + + This protocol defines the minimal interface that SDK code depends on when + adapting an OpenAPI UpsertResponse to the SDK UpsertResponse dataclass. + + Attributes: + upserted_count: Number of vectors that were successfully upserted (optional). + _response_info: Response metadata including headers. + """ + + upserted_count: int | None + _response_info: Any + + +class FetchResponseAdapter(Protocol): + """Protocol for OpenAPI FetchResponse objects used in adapters. + + This protocol defines the minimal interface that SDK code depends on when + adapting an OpenAPI FetchResponse to the SDK FetchResponse dataclass. + + Attributes: + namespace: The namespace from which vectors were fetched (optional). + vectors: Dictionary mapping vector IDs to Vector objects (optional). + usage: Optional usage statistics for the fetch operation. + _response_info: Response metadata including headers. + """ + + namespace: str | None + vectors: dict[str, Any] | None + usage: Usage | None + _response_info: Any + + +class IndexModelAdapter(Protocol): + """Protocol for OpenAPI IndexModel objects used in adapters. + + This protocol defines the minimal interface that SDK code depends on when + working with OpenAPI IndexModel objects. The IndexModel wrapper class + provides additional functionality on top of this protocol. + + Attributes: + name: The name of the index. + dimension: The dimensionality of vectors in the index. + metric: The distance metric used for similarity search. + host: The host URL for the index. + spec: The index specification (serverless, pod, or BYOC). + status: The current status of the index. + _data_store: Internal data storage (for accessing raw response data). + _configuration: OpenAPI configuration object. + _path_to_item: Path to this item in the response tree. + """ + + name: str + dimension: int + metric: str + host: str + spec: Any + status: IndexModelStatus + _data_store: dict[str, Any] + _configuration: Any + _path_to_item: tuple[str, ...] | list[str] + + def to_dict(self) -> dict[str, Any]: + """Convert the index model to a dictionary representation. + + Returns: + Dictionary representation of the index model. + """ + ... + + +class IndexStatusAdapter(Protocol): + """Protocol for IndexModelStatus objects used in adapters. + + This protocol defines the minimal interface that SDK code depends on when + working with index status information. + + Attributes: + ready: Whether the index is ready to serve requests. + state: The current state of the index (e.g., 'Ready', 'Initializing'). + """ + + ready: bool + state: str diff --git a/pinecone/adapters/response_adapters.py b/pinecone/adapters/response_adapters.py index c7decb58..dfffb46b 100644 --- a/pinecone/adapters/response_adapters.py +++ b/pinecone/adapters/response_adapters.py @@ -13,6 +13,11 @@ from multiprocessing.pool import ApplyResult from typing import TYPE_CHECKING, Any +from pinecone.adapters.protocols import ( + FetchResponseAdapter, + QueryResponseAdapter, + UpsertResponseAdapter, +) from pinecone.adapters.utils import extract_response_metadata if TYPE_CHECKING: @@ -21,7 +26,7 @@ from pinecone.db_data.dataclasses.upsert_response import UpsertResponse -def adapt_query_response(openapi_response: Any) -> QueryResponse: +def adapt_query_response(openapi_response: QueryResponseAdapter) -> QueryResponse: """Adapt an OpenAPI QueryResponse to the SDK QueryResponse dataclass. This function extracts fields from the OpenAPI response object and @@ -52,7 +57,7 @@ def adapt_query_response(openapi_response: Any) -> QueryResponse: openapi_response._data_store.pop("results", None) return QR( - matches=openapi_response.matches, + matches=openapi_response.matches or [], namespace=openapi_response.namespace or "", usage=openapi_response.usage if hasattr(openapi_response, "usage") and openapi_response.usage @@ -61,7 +66,7 @@ def adapt_query_response(openapi_response: Any) -> QueryResponse: ) -def adapt_upsert_response(openapi_response: Any) -> UpsertResponse: +def adapt_upsert_response(openapi_response: UpsertResponseAdapter) -> UpsertResponse: """Adapt an OpenAPI UpsertResponse to the SDK UpsertResponse dataclass. Args: @@ -80,10 +85,15 @@ def adapt_upsert_response(openapi_response: Any) -> UpsertResponse: response_info = extract_response_metadata(openapi_response) - return UR(upserted_count=openapi_response.upserted_count, _response_info=response_info) + return UR( + upserted_count=openapi_response.upserted_count + if openapi_response.upserted_count is not None + else 0, + _response_info=response_info, + ) -def adapt_fetch_response(openapi_response: Any) -> FetchResponse: +def adapt_fetch_response(openapi_response: FetchResponseAdapter) -> FetchResponse: """Adapt an OpenAPI FetchResponse to the SDK FetchResponse dataclass. This function extracts fields from the OpenAPI response object and @@ -110,8 +120,10 @@ def adapt_fetch_response(openapi_response: Any) -> FetchResponse: response_info = extract_response_metadata(openapi_response) return FR( - namespace=openapi_response.namespace, - vectors={k: Vector.from_dict(v) for k, v in openapi_response.vectors.items()}, + namespace=openapi_response.namespace or "", + vectors={k: Vector.from_dict(v) for k, v in openapi_response.vectors.items()} + if openapi_response.vectors is not None + else {}, usage=openapi_response.usage, _response_info=response_info, ) diff --git a/pinecone/db_control/models/index_model.py b/pinecone/db_control/models/index_model.py index 769667df..b58d70a6 100644 --- a/pinecone/db_control/models/index_model.py +++ b/pinecone/db_control/models/index_model.py @@ -1,27 +1,14 @@ from pinecone.core.openapi.db_control.model.index_model import IndexModel as OpenAPIIndexModel -from pinecone.core.openapi.db_control.model.index_spec import IndexSpec -from pinecone.core.openapi.db_control.model.serverless import Serverless -from pinecone.core.openapi.db_control.model.serverless_spec_response import ServerlessSpecResponse -from pinecone.core.openapi.db_control.model.read_capacity_response import ReadCapacityResponse -from pinecone.core.openapi.db_control.model.read_capacity_on_demand_spec_response import ( - ReadCapacityOnDemandSpecResponse, -) -from pinecone.core.openapi.db_control.model.read_capacity_dedicated_spec_response import ( - ReadCapacityDedicatedSpecResponse, -) -from pinecone.core.openapi.db_control.model.pod_based import PodBased -from pinecone.core.openapi.db_control.model.pod_spec import PodSpec -from pinecone.core.openapi.db_control.model.byoc import BYOC -from pinecone.core.openapi.db_control.model.byoc_spec import ByocSpec import json +from typing import Any from pinecone.utils.repr_overrides import custom_serializer -from pinecone.openapi_support.model_utils import deserialize_model +from pinecone.adapters import adapt_index_spec class IndexModel: def __init__(self, index: OpenAPIIndexModel): self.index = index - self._spec_cache = None + self._spec_cache: Any = None def __str__(self): return str(self.index) @@ -32,137 +19,15 @@ def __getattr__(self, attr): return getattr(self.index, attr) def _get_spec(self): + """Get the index spec, using adapter for oneOf schema resolution. + + Delegates to adapt_index_spec() which handles the complex logic of + deserializing serverless/pod/byoc spec variants. + """ if self._spec_cache is not None: return self._spec_cache - # Access _data_store directly to avoid OpenAPI model attribute resolution - spec_value = self.index._data_store.get("spec") - if spec_value is None: - # Fallback to getattr in case spec is stored differently - spec_value = getattr(self.index, "spec", None) - - if isinstance(spec_value, dict): - # Manually detect which oneOf schema to use and construct it directly - # This bypasses the broken oneOf matching logic in deserialize_model - # Get configuration from the underlying model if available - config = getattr(self.index, "_configuration", None) - path_to_item = getattr(self.index, "_path_to_item", ()) - # Convert to list if needed and append 'spec' to path_to_item for proper error reporting - if isinstance(path_to_item, (list, tuple)): - spec_path = list(path_to_item) + ["spec"] - else: - spec_path = ["spec"] - - # Check which oneOf key exists and construct the appropriate wrapper class - if "serverless" in spec_value: - # Deserialize the nested serverless dict to ServerlessSpecResponse - # (responses use ServerlessSpecResponse, not ServerlessSpec) - # First, handle nested read_capacity if present (it's also a oneOf with discriminator) - serverless_dict = dict(spec_value["serverless"]) - if "read_capacity" in serverless_dict and isinstance( - serverless_dict["read_capacity"], dict - ): - read_capacity_dict = serverless_dict["read_capacity"] - # Use discriminator to determine which ReadCapacity spec to use - mode = read_capacity_dict.get("mode") - if mode == "OnDemand": - read_capacity_spec = deserialize_model( - read_capacity_dict, - ReadCapacityOnDemandSpecResponse, - spec_path + ["serverless", "read_capacity"], - check_type=True, - configuration=config, - spec_property_naming=False, - ) - elif mode == "Dedicated": - read_capacity_spec = deserialize_model( - read_capacity_dict, - ReadCapacityDedicatedSpecResponse, - spec_path + ["serverless", "read_capacity"], - check_type=True, - configuration=config, - spec_property_naming=False, - ) - else: - # Fallback to ReadCapacityResponse (should use discriminator) - read_capacity_spec = deserialize_model( - read_capacity_dict, - ReadCapacityResponse, - spec_path + ["serverless", "read_capacity"], - check_type=True, - configuration=config, - spec_property_naming=False, - ) - serverless_dict["read_capacity"] = read_capacity_spec - - serverless_spec = deserialize_model( - serverless_dict, - ServerlessSpecResponse, - spec_path + ["serverless"], - check_type=True, - configuration=config, - spec_property_naming=False, - ) - # Instantiate Serverless wrapper, which IS the IndexSpec (oneOf union) - self._spec_cache = Serverless._new_from_openapi_data( - serverless=serverless_spec, - _check_type=True, - _path_to_item=spec_path, - _configuration=config, - _spec_property_naming=False, - ) - elif "pod" in spec_value: - # Deserialize the nested pod dict to PodSpec - pod_spec = deserialize_model( - spec_value["pod"], - PodSpec, - spec_path + ["pod"], - check_type=True, - configuration=config, - spec_property_naming=False, - ) - # Instantiate PodBased wrapper, which IS the IndexSpec (oneOf union) - self._spec_cache = PodBased._new_from_openapi_data( - pod=pod_spec, - _check_type=True, - _path_to_item=spec_path, - _configuration=config, - _spec_property_naming=False, - ) - elif "byoc" in spec_value: - # Deserialize the nested byoc dict to ByocSpec - byoc_spec = deserialize_model( - spec_value["byoc"], - ByocSpec, - spec_path + ["byoc"], - check_type=True, - configuration=config, - spec_property_naming=False, - ) - # Instantiate BYOC wrapper, which IS the IndexSpec (oneOf union) - self._spec_cache = BYOC._new_from_openapi_data( - byoc=byoc_spec, - _check_type=True, - _path_to_item=spec_path, - _configuration=config, - _spec_property_naming=False, - ) - else: - # Fallback: try deserialize_model (shouldn't happen with valid API responses) - self._spec_cache = deserialize_model( - spec_value, - IndexSpec, - spec_path, - check_type=True, - configuration=config, - spec_property_naming=False, - ) - elif spec_value is None: - self._spec_cache = None - else: - # Already an IndexSpec instance or some other object - self._spec_cache = spec_value - + self._spec_cache = adapt_index_spec(self.index) return self._spec_cache def __getitem__(self, key): diff --git a/tests/fixtures/db_data_models.py b/tests/fixtures/db_data_models.py index 089c48e8..4dd54a78 100644 --- a/tests/fixtures/db_data_models.py +++ b/tests/fixtures/db_data_models.py @@ -345,8 +345,8 @@ def make_scored_vector( def make_openapi_query_response( - matches: Optional[List[OpenApiScoredVector]] = None, - namespace: str = "", + matches: Optional[List[OpenApiScoredVector]] = _UNSET, # type: ignore[assignment] + namespace: Optional[str] = _UNSET, # type: ignore[assignment] usage: Optional[OpenApiUsage] = None, _check_type: bool = False, **overrides: Any, @@ -354,8 +354,8 @@ def make_openapi_query_response( """Create an OpenApiQueryResponse instance. Args: - matches: List of scored vectors - namespace: Query namespace + matches: List of scored vectors. Defaults to []. Pass None to omit. + namespace: Query namespace. Defaults to "". Pass None to omit. usage: Usage information _check_type: Whether to enable type checking **overrides: Additional fields to override @@ -363,14 +363,17 @@ def make_openapi_query_response( Returns: An OpenApiQueryResponse instance """ - if matches is None: - matches = [] + kwargs: Dict[str, Any] = {"_check_type": _check_type} + + if matches is _UNSET: + kwargs["matches"] = [] + else: + kwargs["matches"] = matches - kwargs: Dict[str, Any] = { - "matches": matches, - "namespace": namespace, - "_check_type": _check_type, - } + if namespace is _UNSET: + kwargs["namespace"] = "" + else: + kwargs["namespace"] = namespace if usage is not None: kwargs["usage"] = usage @@ -380,26 +383,34 @@ def make_openapi_query_response( def make_openapi_upsert_response( - upserted_count: int = 10, _check_type: bool = False, **overrides: Any + upserted_count: Optional[int] = _UNSET, # type: ignore[assignment] + _check_type: bool = False, + **overrides: Any, ) -> OpenApiUpsertResponse: """Create an OpenApiUpsertResponse instance. Args: - upserted_count: Number of vectors upserted + upserted_count: Number of vectors upserted. Defaults to 10. Pass None to omit. _check_type: Whether to enable type checking **overrides: Additional fields to override Returns: An OpenApiUpsertResponse instance """ - kwargs: Dict[str, Any] = {"upserted_count": upserted_count, "_check_type": _check_type} + kwargs: Dict[str, Any] = {"_check_type": _check_type} + + if upserted_count is _UNSET: + kwargs["upserted_count"] = 10 + else: + kwargs["upserted_count"] = upserted_count + kwargs.update(overrides) return OpenApiUpsertResponse(**kwargs) def make_openapi_fetch_response( - vectors: Optional[Dict[str, Dict[str, Any]]] = None, - namespace: str = "", + vectors: Optional[Dict[str, Dict[str, Any]]] = _UNSET, # type: ignore[assignment] + namespace: Optional[str] = _UNSET, # type: ignore[assignment] usage: Optional[OpenApiUsage] = None, _check_type: bool = False, **overrides: Any, @@ -407,8 +418,8 @@ def make_openapi_fetch_response( """Create an OpenApiFetchResponse instance. Args: - vectors: Dictionary mapping vector IDs to vector data - namespace: Fetch namespace + vectors: Dictionary mapping vector IDs to vector data. Defaults to {}. Pass None to omit. + namespace: Fetch namespace. Defaults to "". Pass None to omit. usage: Usage information _check_type: Whether to enable type checking **overrides: Additional fields to override @@ -416,14 +427,17 @@ def make_openapi_fetch_response( Returns: An OpenApiFetchResponse instance """ - if vectors is None: - vectors = {} + kwargs: Dict[str, Any] = {"_check_type": _check_type} + + if vectors is _UNSET: + kwargs["vectors"] = {} + else: + kwargs["vectors"] = vectors - kwargs: Dict[str, Any] = { - "vectors": vectors, - "namespace": namespace, - "_check_type": _check_type, - } + if namespace is _UNSET: + kwargs["namespace"] = "" + else: + kwargs["namespace"] = namespace if usage is not None: kwargs["usage"] = usage diff --git a/tests/unit/adapters/test_index_adapter.py b/tests/unit/adapters/test_index_adapter.py new file mode 100644 index 00000000..f583d3d3 --- /dev/null +++ b/tests/unit/adapters/test_index_adapter.py @@ -0,0 +1,150 @@ +"""Unit tests for index adapter functions. + +Tests the adapt_index_spec function that handles oneOf schema resolution +for IndexModel spec fields. +""" + +from pinecone.adapters import adapt_index_spec +from pinecone.core.openapi.db_control.model.serverless import Serverless +from pinecone.core.openapi.db_control.model.pod_based import PodBased +from pinecone.core.openapi.db_control.model.byoc import BYOC +from tests.fixtures import make_index_model + + +class TestAdaptIndexSpec: + """Test adapt_index_spec with different spec types.""" + + def test_adapt_serverless_spec_basic(self): + """Test adapting a basic serverless spec without read_capacity.""" + openapi_model = make_index_model( + name="test-index", spec={"serverless": {"cloud": "aws", "region": "us-east-1"}} + ) + + result = adapt_index_spec(openapi_model) + + assert result is not None + assert isinstance(result, Serverless) + assert hasattr(result, "serverless") + assert result.serverless.cloud == "aws" + assert result.serverless.region == "us-east-1" + + def test_adapt_pod_spec(self): + """Test adapting a pod-based spec.""" + openapi_model = make_index_model( + name="test-index", + spec={ + "pod": { + "environment": "us-east-1-aws", + "replicas": 1, + "shards": 1, + "pod_type": "p1.x1", + "pods": 1, + } + }, + ) + + result = adapt_index_spec(openapi_model) + + assert result is not None + assert isinstance(result, PodBased) + assert hasattr(result, "pod") + assert result.pod.environment == "us-east-1-aws" + assert result.pod.replicas == 1 + assert result.pod.shards == 1 + assert result.pod.pod_type == "p1.x1" + + def test_adapt_byoc_spec(self): + """Test adapting a BYOC (Bring Your Own Cloud) spec.""" + openapi_model = make_index_model( + name="test-index", spec={"byoc": {"environment": "custom-env"}} + ) + + result = adapt_index_spec(openapi_model) + + assert result is not None + assert isinstance(result, BYOC) + assert hasattr(result, "byoc") + assert result.byoc.environment == "custom-env" + + def test_adapt_spec_returns_none_when_spec_is_none(self): + """Test that None is returned when spec is not present.""" + # Create a model without spec in _data_store + openapi_model = make_index_model(name="test-index") + # Manually remove spec from _data_store to simulate missing spec + if "spec" in openapi_model._data_store: + del openapi_model._data_store["spec"] + + result = adapt_index_spec(openapi_model) + + assert result is None + + def test_adapt_spec_caching_in_index_model(self): + """Test that IndexModel properly caches the adapted spec.""" + from pinecone.db_control.models import IndexModel + + openapi_model = make_index_model( + name="test-index", spec={"serverless": {"cloud": "aws", "region": "us-east-1"}} + ) + + wrapped = IndexModel(openapi_model) + + # First access should populate cache + spec1 = wrapped.spec + assert spec1 is not None + + # Second access should return cached value + spec2 = wrapped.spec + assert spec2 is spec1 # Same object instance + + def test_adapt_spec_handles_already_deserialized_spec(self): + """Test that adapter handles specs that are already IndexSpec instances.""" + from pinecone.core.openapi.db_control.model.serverless import Serverless + from pinecone.core.openapi.db_control.model.serverless_spec_response import ( + ServerlessSpecResponse, + ) + + # Create a fully deserialized spec + serverless_spec = ServerlessSpecResponse._from_openapi_data( + cloud="aws", region="us-east-1", read_capacity=None, _check_type=False + ) + already_deserialized = Serverless._new_from_openapi_data( + serverless=serverless_spec, _check_type=False + ) + + openapi_model = make_index_model(name="test-index") + # Replace the dict spec with an already-deserialized one + openapi_model._data_store["spec"] = already_deserialized + + result = adapt_index_spec(openapi_model) + + # Should return the already-deserialized spec as-is + assert result is already_deserialized + + +class TestIndexModelAdapterProtocolCompliance: + """Test that OpenAPI IndexModel conforms to IndexModelAdapter protocol.""" + + def test_openapi_index_model_has_data_store(self): + """Verify OpenAPI IndexModel has _data_store attribute.""" + from pinecone.adapters.protocols import IndexModelAdapter + + openapi_model = make_index_model() + _protocol_check: IndexModelAdapter = openapi_model + assert hasattr(openapi_model, "_data_store") + assert isinstance(openapi_model._data_store, dict) + + def test_openapi_index_model_has_configuration(self): + """Verify OpenAPI IndexModel has _configuration attribute.""" + from pinecone.adapters.protocols import IndexModelAdapter + + openapi_model = make_index_model() + _protocol_check: IndexModelAdapter = openapi_model + assert hasattr(openapi_model, "_configuration") + + def test_openapi_index_model_has_path_to_item(self): + """Verify OpenAPI IndexModel has _path_to_item attribute.""" + from pinecone.adapters.protocols import IndexModelAdapter + + openapi_model = make_index_model() + _protocol_check: IndexModelAdapter = openapi_model + assert hasattr(openapi_model, "_path_to_item") diff --git a/tests/unit/adapters/test_protocols.py b/tests/unit/adapters/test_protocols.py new file mode 100644 index 00000000..d668edc1 --- /dev/null +++ b/tests/unit/adapters/test_protocols.py @@ -0,0 +1,212 @@ +"""Unit tests for adapter protocol compliance. + +These tests verify that the actual OpenAPI models satisfy the protocol +interfaces defined in pinecone.adapters.protocols. This ensures that the +adapter layer's contracts are maintained even as the OpenAPI models change. +""" + +from pinecone.adapters.protocols import ( + QueryResponseAdapter, + UpsertResponseAdapter, + FetchResponseAdapter, + IndexModelAdapter, + IndexStatusAdapter, +) +from pinecone.adapters.response_adapters import ( + adapt_query_response, + adapt_upsert_response, + adapt_fetch_response, +) +from tests.fixtures import ( + make_openapi_query_response, + make_openapi_upsert_response, + make_openapi_fetch_response, +) + + +class TestQueryResponseProtocolCompliance: + """Tests that OpenAPI QueryResponse satisfies QueryResponseAdapter protocol.""" + + def test_has_matches_attribute(self): + """Test that QueryResponse has matches attribute.""" + response = make_openapi_query_response(matches=[]) + # This satisfies the protocol check + _protocol_check: QueryResponseAdapter = response + assert hasattr(response, "matches") + + def test_has_namespace_attribute(self): + """Test that QueryResponse has namespace attribute.""" + response = make_openapi_query_response(matches=[], namespace="test") + _protocol_check: QueryResponseAdapter = response + assert hasattr(response, "namespace") + + def test_has_usage_attribute(self): + """Test that QueryResponse has usage attribute.""" + response = make_openapi_query_response(matches=[]) + _protocol_check: QueryResponseAdapter = response + assert hasattr(response, "usage") + + def test_has_data_store_attribute(self): + """Test that QueryResponse has _data_store attribute.""" + response = make_openapi_query_response(matches=[]) + _protocol_check: QueryResponseAdapter = response + assert hasattr(response, "_data_store") + + def test_has_response_info_attribute(self): + """Test that QueryResponse has _response_info attribute.""" + response = make_openapi_query_response(matches=[]) + _protocol_check: QueryResponseAdapter = response + assert hasattr(response, "_response_info") + + +class TestUpsertResponseProtocolCompliance: + """Tests that OpenAPI UpsertResponse satisfies UpsertResponseAdapter protocol.""" + + def test_has_upserted_count_attribute(self): + """Test that UpsertResponse has upserted_count attribute.""" + response = make_openapi_upsert_response(upserted_count=10) + _protocol_check: UpsertResponseAdapter = response + assert hasattr(response, "upserted_count") + assert response.upserted_count == 10 + + def test_has_response_info_attribute(self): + """Test that UpsertResponse has _response_info attribute.""" + response = make_openapi_upsert_response(upserted_count=10) + _protocol_check: UpsertResponseAdapter = response + assert hasattr(response, "_response_info") + + +class TestFetchResponseProtocolCompliance: + """Tests that OpenAPI FetchResponse satisfies FetchResponseAdapter protocol.""" + + def test_has_namespace_attribute(self): + """Test that FetchResponse has namespace attribute.""" + response = make_openapi_fetch_response(vectors={}, namespace="test") + _protocol_check: FetchResponseAdapter = response + assert hasattr(response, "namespace") + assert response.namespace == "test" + + def test_has_vectors_attribute(self): + """Test that FetchResponse has vectors attribute.""" + response = make_openapi_fetch_response(vectors={}) + _protocol_check: FetchResponseAdapter = response + assert hasattr(response, "vectors") + + def test_has_usage_attribute(self): + """Test that FetchResponse has usage attribute.""" + response = make_openapi_fetch_response(vectors={}) + _protocol_check: FetchResponseAdapter = response + assert hasattr(response, "usage") + + def test_has_response_info_attribute(self): + """Test that FetchResponse has _response_info attribute.""" + response = make_openapi_fetch_response(vectors={}) + _protocol_check: FetchResponseAdapter = response + assert hasattr(response, "_response_info") + + +class TestIndexModelProtocolCompliance: + """Tests that OpenAPI IndexModel satisfies IndexModelAdapter protocol.""" + + def test_openapi_index_model_has_required_attributes(self): + """Test that OpenAPI IndexModel has all required protocol attributes.""" + from pinecone.core.openapi.db_control.model.index_model import ( + IndexModel as OpenAPIIndexModel, + ) + from pinecone.core.openapi.db_control.model.index_model_status import IndexModelStatus + + # Create a minimal OpenAPI IndexModel + index = OpenAPIIndexModel._new_from_openapi_data( + name="test-index", + dimension=128, + metric="cosine", + host="test-host.pinecone.io", + spec={"serverless": {"cloud": "aws", "region": "us-east-1"}}, + status=IndexModelStatus._new_from_openapi_data(ready=True, state="Ready"), + ) + + # This satisfies the protocol check + _protocol_check: IndexModelAdapter = index + + # Verify all required attributes exist + assert hasattr(index, "name") + assert hasattr(index, "dimension") + assert hasattr(index, "metric") + assert hasattr(index, "host") + assert hasattr(index, "spec") + assert hasattr(index, "status") + assert hasattr(index, "_data_store") + assert hasattr(index, "_configuration") + assert hasattr(index, "_path_to_item") + assert hasattr(index, "to_dict") + assert callable(index.to_dict) + + +class TestIndexStatusProtocolCompliance: + """Tests that IndexModelStatus satisfies IndexStatusAdapter protocol.""" + + def test_openapi_index_status_has_required_attributes(self): + """Test that IndexModelStatus has all required protocol attributes.""" + from pinecone.core.openapi.db_control.model.index_model_status import IndexModelStatus + + status = IndexModelStatus._new_from_openapi_data(ready=True, state="Ready") + + # This satisfies the protocol check + _protocol_check: IndexStatusAdapter = status + + # Verify all required attributes exist + assert hasattr(status, "ready") + assert hasattr(status, "state") + assert status.ready is True + assert status.state == "Ready" + + +class TestAdapterNoneHandling: + """Tests that adapters handle None/optional fields correctly.""" + + def test_adapt_query_response_with_none_matches(self): + """Test that adapt_query_response handles None matches gracefully.""" + openapi_response = make_openapi_query_response(matches=None, namespace="test") + sdk_response = adapt_query_response(openapi_response) + + assert sdk_response.matches == [] + assert sdk_response.namespace == "test" + + def test_adapt_query_response_with_none_namespace(self): + """Test that adapt_query_response handles None namespace gracefully.""" + openapi_response = make_openapi_query_response(matches=[], namespace=None) + sdk_response = adapt_query_response(openapi_response) + + assert sdk_response.matches == [] + assert sdk_response.namespace == "" + + def test_adapt_upsert_response_with_none_upserted_count(self): + """Test that adapt_upsert_response handles None upserted_count gracefully.""" + openapi_response = make_openapi_upsert_response(upserted_count=None) + sdk_response = adapt_upsert_response(openapi_response) + + assert sdk_response.upserted_count == 0 + + def test_adapt_fetch_response_with_none_namespace(self): + """Test that adapt_fetch_response handles None namespace gracefully.""" + openapi_response = make_openapi_fetch_response(vectors={}, namespace=None) + sdk_response = adapt_fetch_response(openapi_response) + + assert sdk_response.namespace == "" + assert sdk_response.vectors == {} + + def test_adapt_fetch_response_with_none_vectors(self): + """Test that adapt_fetch_response handles None vectors gracefully.""" + openapi_response = make_openapi_fetch_response(vectors=None, namespace="test") + sdk_response = adapt_fetch_response(openapi_response) + + assert sdk_response.namespace == "test" + assert sdk_response.vectors == {} + + def test_adapt_fetch_response_with_all_none_optionals(self): + """Test that adapt_fetch_response handles all None optional fields.""" + openapi_response = make_openapi_fetch_response(vectors=None, namespace=None) + sdk_response = adapt_fetch_response(openapi_response) + + assert sdk_response.namespace == "" + assert sdk_response.vectors == {} diff --git a/tests/unit/adapters/test_response_adapters.py b/tests/unit/adapters/test_response_adapters.py index f2b0a662..05d0dd04 100644 --- a/tests/unit/adapters/test_response_adapters.py +++ b/tests/unit/adapters/test_response_adapters.py @@ -174,3 +174,13 @@ def test_fetch_response_has_response_info(self): assert hasattr(result, "_response_info") assert "raw_headers" in result._response_info + + def test_fetch_response_with_none_namespace(self): + """Test that None namespace is converted to empty string.""" + openapi_response = make_openapi_fetch_response(vectors={}) + # Manually set namespace to None to simulate API response + openapi_response._data_store["namespace"] = None + + result = adapt_fetch_response(openapi_response) + + assert result.namespace == ""