diff --git a/pinecone/openapi_support/api_client.py b/pinecone/openapi_support/api_client.py index afd6b96c..e62d333e 100644 --- a/pinecone/openapi_support/api_client.py +++ b/pinecone/openapi_support/api_client.py @@ -212,9 +212,14 @@ def __call_api( response_info = extract_response_info(headers) if isinstance(return_data, dict): return_data["_response_info"] = response_info - else: + elif not isinstance(return_data, (str, int, float, bool, bytes, type(None))): # Dynamic attribute assignment on OpenAPI models - setattr(return_data, "_response_info", response_info) + # Skip primitive types that don't support attribute assignment + try: + setattr(return_data, "_response_info", response_info) + except (AttributeError, TypeError): + # If setattr fails (e.g., on immutable types), skip silently + pass if _return_http_data_only: return return_data diff --git a/pinecone/openapi_support/asyncio_api_client.py b/pinecone/openapi_support/asyncio_api_client.py index 58a3a869..def3e578 100644 --- a/pinecone/openapi_support/asyncio_api_client.py +++ b/pinecone/openapi_support/asyncio_api_client.py @@ -177,9 +177,14 @@ async def __call_api( response_info = extract_response_info(headers) if isinstance(return_data, dict): return_data["_response_info"] = response_info - else: + elif not isinstance(return_data, (str, int, float, bool, bytes, type(None))): # Dynamic attribute assignment on OpenAPI models - setattr(return_data, "_response_info", response_info) + # Skip primitive types that don't support attribute assignment + try: + setattr(return_data, "_response_info", response_info) + except (AttributeError, TypeError): + # If setattr fails (e.g., on immutable types), skip silently + pass if _return_http_data_only: return return_data diff --git a/tests/integration/rest_asyncio/db/data/test_delete.py b/tests/integration/rest_asyncio/db/data/test_delete.py new file mode 100644 index 00000000..c64cbcfe --- /dev/null +++ b/tests/integration/rest_asyncio/db/data/test_delete.py @@ -0,0 +1,202 @@ +import pytest +import logging +from pinecone import Vector +from .conftest import build_asyncioindex_client, poll_until_lsn_reconciled_async +from tests.integration.helpers import random_string, embedding_values + +logger = logging.getLogger(__name__) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("target_namespace", [random_string(20)]) +async def test_delete_by_ids(index_host, dimension, target_namespace): + """Test deleting vectors by IDs in asyncio""" + asyncio_idx = build_asyncioindex_client(index_host) + + try: + # Upsert some vectors + vectors_to_upsert = [ + Vector(id=f"vec_{i}", values=embedding_values(dimension)) for i in range(5) + ] + upsert_response = await asyncio_idx.upsert( + vectors=vectors_to_upsert, namespace=target_namespace, show_progress=False + ) + + # Wait for upsert to be indexed + await poll_until_lsn_reconciled_async( + asyncio_idx, upsert_response._response_info, namespace=target_namespace + ) + + # Verify vectors exist + fetch_response = await asyncio_idx.fetch( + ids=["vec_0", "vec_1"], namespace=target_namespace + ) + assert len(fetch_response.vectors) == 2 + + # Delete specific vectors by IDs + delete_response = await asyncio_idx.delete( + ids=["vec_0", "vec_1"], namespace=target_namespace + ) + logger.info(f"Delete response: {delete_response}") + + # Verify deletion - this is the critical part that was failing + assert delete_response is not None + assert isinstance(delete_response, dict) + + # Wait for delete to be indexed + await poll_until_lsn_reconciled_async( + asyncio_idx, delete_response.get("_response_info", {}), namespace=target_namespace + ) + + # Verify vectors are deleted + fetch_response = await asyncio_idx.fetch( + ids=["vec_0", "vec_1"], namespace=target_namespace + ) + assert len(fetch_response.vectors) == 0 + + # Verify remaining vectors still exist + fetch_response = await asyncio_idx.fetch( + ids=["vec_2", "vec_3", "vec_4"], namespace=target_namespace + ) + assert len(fetch_response.vectors) == 3 + + finally: + await asyncio_idx.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("target_namespace", [random_string(20)]) +async def test_delete_all_in_namespace(index_host, dimension, target_namespace): + """Test deleting all vectors in a namespace - the original bug scenario""" + asyncio_idx = build_asyncioindex_client(index_host) + + try: + # Upsert some vectors + vectors_to_upsert = [ + Vector(id=f"vec_{i}", values=embedding_values(dimension)) for i in range(10) + ] + upsert_response = await asyncio_idx.upsert( + vectors=vectors_to_upsert, namespace=target_namespace, show_progress=False + ) + + # Wait for upsert to be indexed + await poll_until_lsn_reconciled_async( + asyncio_idx, upsert_response._response_info, namespace=target_namespace + ) + + # Verify vectors exist + stats = await asyncio_idx.describe_index_stats() + namespace_stats = stats.namespaces.get(target_namespace) + assert namespace_stats is not None + assert namespace_stats.vector_count == 10 + + # Delete all vectors in namespace - THIS WAS FAILING WITH AttributeError + delete_response = await asyncio_idx.delete( + delete_all=True, namespace=target_namespace + ) + logger.info(f"Delete all response: {delete_response}") + + # Verify the response doesn't cause AttributeError + assert delete_response is not None + assert isinstance(delete_response, dict) + + # Wait for delete to be indexed + if "_response_info" in delete_response: + await poll_until_lsn_reconciled_async( + asyncio_idx, delete_response["_response_info"], namespace=target_namespace + ) + + # Verify all vectors are deleted + stats = await asyncio_idx.describe_index_stats() + namespace_stats = stats.namespaces.get(target_namespace) + # Namespace might not exist anymore or have 0 vectors + assert namespace_stats is None or namespace_stats.vector_count == 0 + + finally: + await asyncio_idx.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("target_namespace", [random_string(20)]) +async def test_delete_by_filter(index_host, dimension, target_namespace): + """Test deleting vectors by filter in asyncio""" + asyncio_idx = build_asyncioindex_client(index_host) + + try: + # Upsert vectors with metadata + vectors_to_upsert = [ + Vector( + id=f"vec_{i}", + values=embedding_values(dimension), + metadata={"category": "A" if i % 2 == 0 else "B"}, + ) + for i in range(10) + ] + upsert_response = await asyncio_idx.upsert( + vectors=vectors_to_upsert, namespace=target_namespace, show_progress=False + ) + + # Wait for upsert to be indexed + await poll_until_lsn_reconciled_async( + asyncio_idx, upsert_response._response_info, namespace=target_namespace + ) + + # Delete vectors with filter + delete_response = await asyncio_idx.delete( + filter={"category": {"$eq": "A"}}, namespace=target_namespace + ) + logger.info(f"Delete by filter response: {delete_response}") + + # Verify deletion response + assert delete_response is not None + assert isinstance(delete_response, dict) + + # Wait for delete to be indexed + if "_response_info" in delete_response: + await poll_until_lsn_reconciled_async( + asyncio_idx, delete_response["_response_info"], namespace=target_namespace + ) + + # Verify only category A vectors are deleted (approximately 5 vectors) + stats = await asyncio_idx.describe_index_stats() + namespace_stats = stats.namespaces.get(target_namespace) + # Should have about 5 vectors remaining (category B) + assert namespace_stats is not None + assert namespace_stats.vector_count <= 5 + + finally: + await asyncio_idx.close() + + +@pytest.mark.asyncio +async def test_delete_response_has_response_info(index_host, dimension): + """Test that delete response includes _response_info metadata""" + asyncio_idx = build_asyncioindex_client(index_host) + target_namespace = random_string(20) + + try: + # Upsert a vector + upsert_response = await asyncio_idx.upsert( + vectors=[Vector(id="test_vec", values=embedding_values(dimension))], + namespace=target_namespace, + show_progress=False, + ) + + # Wait for upsert to be indexed + await poll_until_lsn_reconciled_async( + asyncio_idx, upsert_response._response_info, namespace=target_namespace + ) + + # Delete the vector + delete_response = await asyncio_idx.delete(ids=["test_vec"], namespace=target_namespace) + + # Verify response structure - this validates the fix + assert isinstance(delete_response, dict) + # _response_info should be present for dict responses + assert "_response_info" in delete_response + assert "raw_headers" in delete_response["_response_info"] + + logger.info(f"Delete response with metadata: {delete_response}") + + finally: + await asyncio_idx.close() diff --git a/tests/unit/test_response_info_assignment.py b/tests/unit/test_response_info_assignment.py new file mode 100644 index 00000000..55d04428 --- /dev/null +++ b/tests/unit/test_response_info_assignment.py @@ -0,0 +1,228 @@ +"""Test that response_info assignment handles all types correctly""" +import pytest +from unittest.mock import Mock +from pinecone.openapi_support.api_client import ApiClient +from pinecone.openapi_support.asyncio_api_client import AsyncioApiClient +from pinecone.config.openapi_configuration import Configuration + + +class TestResponseInfoAssignment: + """Test that _response_info assignment works for all response types""" + + def setup_method(self): + """Set up test fixtures""" + self.config = Configuration() + + def test_sync_api_client_dict_response(self, mocker): + """Test that dict responses get _response_info as a key""" + api_client = ApiClient(self.config) + + # Mock the request method to return a dict response + mock_response = Mock() + mock_response.data = b'{}' + mock_response.status = 200 + mock_response.getheaders = Mock(return_value={'x-pinecone-request-latency-ms': '100'}) + mock_response.getheader = Mock(side_effect=lambda x: 'application/json' if x == 'content-type' else None) + + mocker.patch.object(api_client, 'request', return_value=mock_response) + + # Call the API + result = api_client.call_api( + resource_path='/test', + method='POST', + response_type=(dict,), + _return_http_data_only=True, + ) + + # Verify _response_info is set as a dict key + assert isinstance(result, dict) + assert '_response_info' in result + + def test_sync_api_client_string_response(self, mocker): + """Test that string responses don't cause AttributeError""" + api_client = ApiClient(self.config) + + # Mock the request method to return a string response + mock_response = Mock() + mock_response.data = b'"success"' + mock_response.status = 200 + mock_response.getheaders = Mock(return_value={'x-pinecone-request-latency-ms': '100'}) + mock_response.getheader = Mock(side_effect=lambda x: 'application/json' if x == 'content-type' else None) + + mocker.patch.object(api_client, 'request', return_value=mock_response) + + # This should not raise AttributeError when trying to set _response_info + try: + api_client.call_api( + resource_path='/test', + method='POST', + response_type=(str,), + _return_http_data_only=True, + _check_type=False, + ) + # If we get a string back, it should not have _response_info + # (we don't check what type we get back because it depends on deserializer behavior) + except AttributeError as e: + if "'str' object has no attribute '_response_info'" in str(e): + pytest.fail(f"Should not raise AttributeError for string response: {e}") + # Other AttributeErrors may be raised by deserializer for invalid types + + def test_sync_api_client_none_response(self, mocker): + """Test that None responses are handled correctly""" + api_client = ApiClient(self.config) + + # Mock the request method to return no content + mock_response = Mock() + mock_response.data = b'' + mock_response.status = 204 + mock_response.getheaders = Mock(return_value={'x-pinecone-request-latency-ms': '100'}) + mock_response.getheader = Mock(side_effect=lambda x: None) + + mocker.patch.object(api_client, 'request', return_value=mock_response) + + # This should not raise AttributeError + try: + result = api_client.call_api( + resource_path='/test', + method='DELETE', + response_type=None, + _return_http_data_only=True, + ) + assert result is None + except AttributeError as e: + pytest.fail(f"Should not raise AttributeError for None response: {e}") + + @pytest.mark.asyncio + @pytest.mark.skip(reason="Requires asyncio extras") + async def test_asyncio_api_client_dict_response(self, mocker): + """Test that dict responses get _response_info as a key in asyncio""" + api_client = AsyncioApiClient(self.config) + + # Mock the request method to return a dict response + mock_response = Mock() + mock_response.data = b'{}' + mock_response.status = 200 + mock_response.getheaders = Mock(return_value={'x-pinecone-request-latency-ms': '100'}) + mock_response.getheader = Mock(side_effect=lambda x: 'application/json' if x == 'content-type' else None) + + async def mock_request(*args, **kwargs): + return mock_response + + mocker.patch.object(api_client, 'request', side_effect=mock_request) + + try: + # Call the API + result = await api_client.call_api( + resource_path='/test', + method='POST', + response_type=(dict,), + _return_http_data_only=True, + ) + + # Verify _response_info is set as a dict key + assert isinstance(result, dict) + assert '_response_info' in result + finally: + await api_client.close() + + @pytest.mark.asyncio + @pytest.mark.skip(reason="Requires asyncio extras") + async def test_asyncio_api_client_string_response(self, mocker): + """Test that string responses don't cause AttributeError in asyncio""" + api_client = AsyncioApiClient(self.config) + + # Mock the request method to return a string response + mock_response = Mock() + mock_response.data = b'"success"' + mock_response.status = 200 + mock_response.getheaders = Mock(return_value={'x-pinecone-request-latency-ms': '100'}) + mock_response.getheader = Mock(side_effect=lambda x: 'application/json' if x == 'content-type' else None) + + async def mock_request(*args, **kwargs): + return mock_response + + mocker.patch.object(api_client, 'request', side_effect=mock_request) + + # This should not raise AttributeError when trying to set _response_info + try: + await api_client.call_api( + resource_path='/test', + method='POST', + response_type=(str,), + _return_http_data_only=True, + _check_type=False, + ) + # If we get a string back, it should not have _response_info + except AttributeError as e: + if "'str' object has no attribute '_response_info'" in str(e): + pytest.fail(f"Should not raise AttributeError for string response: {e}") + # Other AttributeErrors may be raised by deserializer for invalid types + finally: + await api_client.close() + + @pytest.mark.asyncio + @pytest.mark.skip(reason="Requires asyncio extras") + async def test_asyncio_api_client_none_response(self, mocker): + """Test that None responses are handled correctly in asyncio""" + api_client = AsyncioApiClient(self.config) + + # Mock the request method to return no content + mock_response = Mock() + mock_response.data = b'' + mock_response.status = 204 + mock_response.getheaders = Mock(return_value={'x-pinecone-request-latency-ms': '100'}) + mock_response.getheader = Mock(side_effect=lambda x: None) + + async def mock_request(*args, **kwargs): + return mock_response + + mocker.patch.object(api_client, 'request', side_effect=mock_request) + + # This should not raise AttributeError + try: + result = await api_client.call_api( + resource_path='/test', + method='DELETE', + response_type=None, + _return_http_data_only=True, + ) + assert result is None + except AttributeError as e: + pytest.fail(f"Should not raise AttributeError for None response: {e}") + finally: + await api_client.close() + + def test_sync_api_client_model_response(self, mocker): + """Test that OpenAPI model responses get _response_info as an attribute""" + api_client = ApiClient(self.config) + + # Create a mock model class that supports attribute assignment + class MockModel: + def __init__(self): + pass + + # Mock the request and deserializer + mock_response = Mock() + mock_response.data = b'{"test": "value"}' + mock_response.status = 200 + mock_response.getheaders = Mock(return_value={'x-pinecone-request-latency-ms': '100'}) + mock_response.getheader = Mock(side_effect=lambda x: 'application/json' if x == 'content-type' else None) + + mocker.patch.object(api_client, 'request', return_value=mock_response) + + # Mock the deserializer to return a model instance + mock_model_instance = MockModel() + mocker.patch('pinecone.openapi_support.deserializer.Deserializer.deserialize', + return_value=mock_model_instance) + mocker.patch('pinecone.openapi_support.deserializer.Deserializer.decode_response') + + # Call the API + result = api_client.call_api( + resource_path='/test', + method='GET', + response_type=(MockModel,), + _return_http_data_only=True, + ) + + # Verify _response_info is set as an attribute + assert hasattr(result, '_response_info')