diff --git a/pinecone/adapters/__init__.py b/pinecone/adapters/__init__.py index e9c481ff..1e41e12f 100644 --- a/pinecone/adapters/__init__.py +++ b/pinecone/adapters/__init__.py @@ -13,6 +13,13 @@ >>> sdk_response = adapt_query_response(openapi_response) """ +from pinecone.adapters.protocols import ( + FetchResponseAdapter, + IndexModelAdapter, + IndexStatusAdapter, + QueryResponseAdapter, + UpsertResponseAdapter, +) from pinecone.adapters.response_adapters import ( adapt_fetch_response, adapt_query_response, @@ -25,4 +32,9 @@ "adapt_query_response", "adapt_upsert_response", "UpsertResponseTransformer", + "FetchResponseAdapter", + "IndexModelAdapter", + "IndexStatusAdapter", + "QueryResponseAdapter", + "UpsertResponseAdapter", ] diff --git a/pinecone/adapters/protocols.py b/pinecone/adapters/protocols.py new file mode 100644 index 00000000..f4c01e3e --- /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. + namespace: The namespace that was queried. + 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] + 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. + _response_info: Response metadata including headers. + """ + + upserted_count: int + _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. + usage: Optional usage statistics for the fetch operation. + _response_info: Response metadata including headers. + """ + + namespace: str | None + vectors: dict[str, Any] + 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..b77d6f32 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 @@ -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: @@ -83,7 +88,7 @@ def adapt_upsert_response(openapi_response: Any) -> UpsertResponse: return UR(upserted_count=openapi_response.upserted_count, _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,7 +115,7 @@ def adapt_fetch_response(openapi_response: Any) -> FetchResponse: response_info = extract_response_metadata(openapi_response) return FR( - namespace=openapi_response.namespace, + namespace=openapi_response.namespace or "", vectors={k: Vector.from_dict(v) for k, v in openapi_response.vectors.items()}, usage=openapi_response.usage, _response_info=response_info, diff --git a/tests/unit/adapters/test_protocols.py b/tests/unit/adapters/test_protocols.py new file mode 100644 index 00000000..d2397b51 --- /dev/null +++ b/tests/unit/adapters/test_protocols.py @@ -0,0 +1,156 @@ +"""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 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" 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 == ""