Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/actions/create-index/action.yml
Original file line number Diff line number Diff line change
@@ -30,6 +30,15 @@ outputs:
index_name:
description: 'The name of the index, including randomized suffix'
value: ${{ steps.create-index.outputs.index_name }}
index_host:
description: 'The host of the index'
value: ${{ steps.create-index.outputs.index_host }}
index_dimension:
description: 'The dimension of the index'
value: ${{ steps.create-index.outputs.index_dimension }}
index_metric:
description: 'The metric of the index'
value: ${{ steps.create-index.outputs.index_metric }}

runs:
using: 'composite'
55 changes: 55 additions & 0 deletions .github/actions/test-data-plane-asyncio/action.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: 'Test Data Plane'
description: 'Runs tests on the Pinecone data plane'

inputs:
metric:
description: 'The metric of the index'
required: true
dimension:
description: 'The dimension of the index'
required: true
host:
description: 'The host of the index'
required: true
use_grpc:
description: 'Whether to use gRPC or REST'
required: true
freshness_timeout_seconds:
description: 'The number of seconds to wait for the index to become fresh'
required: false
default: '60'
PINECONE_API_KEY:
description: 'The Pinecone API key'
required: true

outputs:
index_name:
description: 'The name of the index, including randomized suffix'
value: ${{ steps.create-index.outputs.index_name }}

runs:
using: 'composite'
steps:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python_version }}

- name: Setup Poetry
uses: ./.github/actions/setup-poetry
with:
include_grpc: ${{ inputs.use_grpc }}
include_dev: 'true'

- name: Run data plane tests
id: data-plane-tests
shell: bash
run: poetry run pytest tests/integration/data_asyncio
env:
PINECONE_API_KEY: ${{ inputs.PINECONE_API_KEY }}
USE_GRPC: ${{ inputs.use_grpc }}
METRIC: ${{ inputs.metric }}
INDEX_HOST: ${{ inputs.host }}
DIMENSION: ${{ inputs.dimension }}
SPEC: ${{ inputs.spec }}
FRESHNESS_TIMEOUT_SECONDS: ${{ inputs.freshness_timeout_seconds }}
27 changes: 13 additions & 14 deletions .github/workflows/alpha-release.yaml
Original file line number Diff line number Diff line change
@@ -24,22 +24,22 @@ on:
default: 'rc1'

jobs:
unit-tests:
uses: './.github/workflows/testing-unit.yaml'
secrets: inherit
integration-tests:
uses: './.github/workflows/testing-integration.yaml'
secrets: inherit
dependency-tests:
uses: './.github/workflows/testing-dependency.yaml'
secrets: inherit
# unit-tests:
# uses: './.github/workflows/testing-unit.yaml'
# secrets: inherit
# integration-tests:
# uses: './.github/workflows/testing-integration.yaml'
# secrets: inherit
# dependency-tests:
# uses: './.github/workflows/testing-dependency.yaml'
# secrets: inherit

pypi:
uses: './.github/workflows/publish-to-pypi.yaml'
needs:
- unit-tests
- integration-tests
- dependency-tests
# needs:
# - unit-tests
# - integration-tests
# - dependency-tests
with:
isPrerelease: true
ref: ${{ inputs.ref }}
@@ -49,4 +49,3 @@ jobs:
secrets:
PYPI_USERNAME: __token__
PYPI_PASSWORD: ${{ secrets.PROD_PYPI_PUBLISH_TOKEN }}

58 changes: 58 additions & 0 deletions .github/workflows/testing-integration.yaml
Original file line number Diff line number Diff line change
@@ -32,6 +32,64 @@ jobs:
PINECONE_DEBUG_CURL: 'true'
PINECONE_API_KEY: '${{ secrets.PINECONE_API_KEY }}'

data-plane-setup:
name: Create index
runs-on: ubuntu-latest
outputs:
index_name: ${{ steps.setup-index.outputs.index_name }}
index_host: ${{ steps.setup-index.outputs.index_host }}
index_dimension: ${{ steps.setup-index.outputs.index_dimension }}
index_metric: ${{ steps.setup-index.outputs.index_metric }}
steps:
- uses: actions/checkout@v4
- name: Create index
id: setup-index
uses: ./.github/actions/create-index
timeout-minutes: 5
with:
dimension: 100
metric: 'cosine'
PINECONE_API_KEY: ${{ secrets.PINECONE_API_KEY }}


test-data-plane-asyncio:
name: Data plane asyncio integration tests
runs-on: ubuntu-latest
needs:
- data-plane-setup
outputs:
index_name: ${{ needs.data-plane-setup.outputs.index_name }}
strategy:
fail-fast: false
matrix:
python_version: [3.8, 3.12]
use_grpc: [true]
spec:
- '{ "asyncio": { "environment": "us-east1-gcp" }}'
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/test-data-plane-asyncio
with:
python_version: '${{ matrix.python_version }}'
use_grpc: '${{ matrix.use_grpc }}'
metric: '${{ needs.data-plane-setup.outputs.index_metric }}'
dimension: '${{ needs.data-plane-setup.outputs.index_dimension }}'
host: '${{ needs.data-plane-setup.outputs.index_host }}'
PINECONE_API_KEY: '${{ secrets.PINECONE_API_KEY }}'
freshness_timeout_seconds: 600

data-plane-asyncio-cleanup:
name: Deps cleanup
runs-on: ubuntu-latest
needs:
- test-data-plane-asyncio
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/delete-index
with:
index_name: '${{ needs.test-data-plane-asyncio.outputs.index_name }}'
PINECONE_API_KEY: '${{ secrets.PINECONE_API_KEY }}'

data-plane-serverless:
name: Data plane serverless integration tests
runs-on: ubuntu-latest
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -137,7 +137,7 @@ venv.bak/
.ropeproject

# pdocs documentation
# We want to exclude any locally generated artifacts, but we rely on
# We want to exclude any locally generated artifacts, but we rely on
# keeping documentation assets in the docs/ folder.
docs/*
!docs/pinecone-python-client-fork.png
@@ -155,4 +155,6 @@ dmypy.json
*.hdf5
*~

tests/integration/proxy_config/logs
tests/integration/proxy_config/logs
*.parquet
app*.py
16 changes: 9 additions & 7 deletions pinecone/control/pinecone.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import time
import logging
from typing import Optional, Dict, Any, Union, List, Tuple, Literal
from typing import Optional, Dict, Any, Union, Literal

from .index_host_store import IndexHostStore

@@ -10,7 +10,12 @@
from pinecone.core.openapi.shared.api_client import ApiClient


from pinecone.utils import normalize_host, setup_openapi_client, build_plugin_setup_client
from pinecone.utils import (
normalize_host,
setup_openapi_client,
build_plugin_setup_client,
parse_non_empty_args,
)
from pinecone.core.openapi.control.models import (
CreateCollectionRequest,
CreateIndexRequest,
@@ -317,9 +322,6 @@ def create_index(

api_instance = self.index_api

def _parse_non_empty_args(args: List[Tuple[str, Any]]) -> Dict[str, Any]:
return {arg_name: val for arg_name, val in args if val is not None}

if deletion_protection in ["enabled", "disabled"]:
dp = DeletionProtection(deletion_protection)
else:
@@ -329,7 +331,7 @@ def _parse_non_empty_args(args: List[Tuple[str, Any]]) -> Dict[str, Any]:
if "serverless" in spec:
index_spec = IndexSpec(serverless=ServerlessSpecModel(**spec["serverless"]))
elif "pod" in spec:
args_dict = _parse_non_empty_args(
args_dict = parse_non_empty_args(
[
("environment", spec["pod"].get("environment")),
("metadata_config", spec["pod"].get("metadata_config")),
@@ -351,7 +353,7 @@ def _parse_non_empty_args(args: List[Tuple[str, Any]]) -> Dict[str, Any]:
serverless=ServerlessSpecModel(cloud=spec.cloud, region=spec.region)
)
elif isinstance(spec, PodSpec):
args_dict = _parse_non_empty_args(
args_dict = parse_non_empty_args(
[
("replicas", spec.replicas),
("shards", spec.shards),
3 changes: 3 additions & 0 deletions pinecone/exceptions/__init__.py
Original file line number Diff line number Diff line change
@@ -12,6 +12,8 @@
)
from .exceptions import PineconeConfigurationError, PineconeProtocolError, ListConversionException

PineconeNotFoundException = NotFoundException

__all__ = [
"PineconeConfigurationError",
"PineconeProtocolError",
@@ -22,6 +24,7 @@
"PineconeApiKeyError",
"PineconeApiException",
"NotFoundException",
"PineconeNotFoundException",
"UnauthorizedException",
"ForbiddenException",
"ServiceException",
1 change: 1 addition & 0 deletions pinecone/grpc/__init__.py
Original file line number Diff line number Diff line change
@@ -45,6 +45,7 @@
"""

from .index_grpc import GRPCIndex
from .index_grpc_asyncio import GRPCIndexAsyncio
from .pinecone import PineconeGRPC
from .config import GRPCClientConfig
from .future import PineconeGrpcFuture
17 changes: 11 additions & 6 deletions pinecone/grpc/base.py
Original file line number Diff line number Diff line change
@@ -10,6 +10,7 @@
from pinecone import Config
from .config import GRPCClientConfig
from .grpc_runner import GrpcRunner
from .utils import normalize_endpoint
from concurrent.futures import ThreadPoolExecutor

from pinecone_plugin_interface import load_and_install as install_plugins
@@ -22,8 +23,6 @@ class GRPCIndexBase(ABC):
Base class for grpc-based interaction with Pinecone indexes
"""

_pool = None

def __init__(
self,
index_name: str,
@@ -32,6 +31,7 @@ def __init__(
grpc_config: Optional[GRPCClientConfig] = None,
pool_threads: Optional[int] = None,
_endpoint_override: Optional[str] = None,
use_asyncio: Optional[bool] = False,
):
self.config = config
self.grpc_client_config = grpc_config or GRPCClientConfig()
@@ -43,7 +43,7 @@ def __init__(
index_name=index_name, config=config, grpc_config=self.grpc_client_config
)
self.channel_factory = GrpcChannelFactory(
config=self.config, grpc_client_config=self.grpc_client_config, use_asyncio=False
config=self.config, grpc_client_config=self.grpc_client_config, use_asyncio=use_asyncio
)
self._channel = channel or self._gen_channel()
self.stub = self.stub_class(self._channel)
@@ -74,9 +74,7 @@ def stub_class(self):
pass

def _endpoint(self):
grpc_host = self.config.host.replace("https://", "")
if ":" not in grpc_host:
grpc_host = f"{grpc_host}:443"
grpc_host = normalize_endpoint(self.config.host)
return self._endpoint_override if self._endpoint_override else grpc_host

def _gen_channel(self):
@@ -111,3 +109,10 @@ def __enter__(self):

def __exit__(self, exc_type, exc_value, traceback):
self.close()

async def __aenter__(self):
return self

async def __aexit__(self, exc_type, exc_value, traceback):
self.close()
return True
82 changes: 69 additions & 13 deletions pinecone/grpc/grpc_runner.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
from functools import wraps
from typing import Dict, Tuple, Optional

@@ -7,10 +8,19 @@
from .utils import _generate_request_id
from .config import GRPCClientConfig
from pinecone.utils.constants import REQUEST_ID, CLIENT_VERSION
from pinecone.exceptions.exceptions import PineconeException
from grpc import CallCredentials, Compression
from grpc import CallCredentials, Compression, StatusCode
from grpc.aio import AioRpcError
from google.protobuf.message import Message

from pinecone.exceptions import (
PineconeException,
PineconeApiValueError,
PineconeApiException,
UnauthorizedException,
PineconeNotFoundException,
ServiceException,
)


class GrpcRunner:
def __init__(self, index_name: str, config: Config, grpc_config: GRPCClientConfig):
@@ -49,7 +59,7 @@ def wrapped():
compression=compression,
)
except _InactiveRpcError as e:
raise PineconeException(e._state.debug_error_string) from e
self._map_exception(e, e._state.code, e._state.details)

return wrapped()

@@ -62,22 +72,34 @@ async def run_asyncio(
credentials: Optional[CallCredentials] = None,
wait_for_ready: Optional[bool] = None,
compression: Optional[Compression] = None,
semaphore: Optional[asyncio.Semaphore] = None,
):
@wraps(func)
async def wrapped():
user_provided_metadata = metadata or {}
_metadata = self._prepare_metadata(user_provided_metadata)
try:
return await func(
request,
timeout=timeout,
metadata=_metadata,
credentials=credentials,
wait_for_ready=wait_for_ready,
compression=compression,
)
except _InactiveRpcError as e:
raise PineconeException(e._state.debug_error_string) from e
if semaphore is not None:
async with semaphore:
return await func(
request,
timeout=timeout,
metadata=_metadata,
credentials=credentials,
wait_for_ready=wait_for_ready,
compression=compression,
)
else:
return await func(
request,
timeout=timeout,
metadata=_metadata,
credentials=credentials,
wait_for_ready=wait_for_ready,
compression=compression,
)
except AioRpcError as e:
self._map_exception(e, e.code(), e.details())

return await wrapped()

@@ -95,3 +117,37 @@ def _prepare_metadata(

def _request_metadata(self) -> Dict[str, str]:
return {REQUEST_ID: _generate_request_id()}

def _map_exception(self, e: Exception, code: Optional[StatusCode], details: Optional[str]):
# Client / connection issues
details = details or ""

if code in [StatusCode.DEADLINE_EXCEEDED]:
raise TimeoutError(details) from e

# Permissions stuff
if code in [StatusCode.PERMISSION_DENIED, StatusCode.UNAUTHENTICATED]:
raise UnauthorizedException(status=code, reason=details) from e

# 400ish stuff
if code in [StatusCode.NOT_FOUND]:
raise PineconeNotFoundException(status=code, reason=details) from e
if code in [StatusCode.INVALID_ARGUMENT, StatusCode.OUT_OF_RANGE]:
raise PineconeApiValueError(details) from e
if code in [
StatusCode.ALREADY_EXISTS,
StatusCode.FAILED_PRECONDITION,
StatusCode.UNIMPLEMENTED,
StatusCode.RESOURCE_EXHAUSTED,
]:
raise PineconeApiException(status=code, reason=details) from e

# 500ish stuff
if code in [StatusCode.INTERNAL, StatusCode.UNAVAILABLE]:
raise ServiceException(status=code, reason=details) from e
if code in [StatusCode.UNKNOWN, StatusCode.DATA_LOSS, StatusCode.ABORTED]:
# abandon hope, all ye who enter here
raise PineconeException(code, details) from e

# If you get here, you're in a bad place
raise PineconeException(code, details) from e
83 changes: 39 additions & 44 deletions pinecone/grpc/index_grpc.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
import logging
from typing import Optional, Dict, Union, List, Tuple, Any, TypedDict, Iterable, cast
from typing import Optional, Dict, Union, List, Iterable, cast

from google.protobuf import json_format

from tqdm.autonotebook import tqdm
from concurrent.futures import as_completed, Future


from pinecone.utils import parse_non_empty_args
from .utils import (
dict_to_proto_struct,
parse_fetch_response,
parse_query_response,
parse_stats_response,
parse_sparse_values_arg,
)
from .vector_factory_grpc import VectorFactoryGRPC
from .base import GRPCIndexBase
from .future import PineconeGrpcFuture
from .sparse_vector import SparseVectorTypedDict
from .config import GRPCClientConfig

from pinecone.core.openapi.data.models import (
FetchResponse,
@@ -39,23 +45,38 @@
from pinecone import Vector as NonGRPCVector
from pinecone.data.query_results_aggregator import QueryNamespacesResults, QueryResultsAggregator
from pinecone.core.grpc.protos.vector_service_pb2_grpc import VectorServiceStub
from .base import GRPCIndexBase
from .future import PineconeGrpcFuture

from pinecone.config import Config
from grpc._channel import Channel


__all__ = ["GRPCIndex", "GRPCVector", "GRPCQueryVector", "GRPCSparseValues"]

_logger = logging.getLogger(__name__)


class SparseVectorTypedDict(TypedDict):
indices: List[int]
values: List[float]


class GRPCIndex(GRPCIndexBase):
"""A client for interacting with a Pinecone index via GRPC API."""

def __init__(
self,
index_name: str,
config: Config,
channel: Optional[Channel] = None,
grpc_config: Optional[GRPCClientConfig] = None,
pool_threads: Optional[int] = None,
_endpoint_override: Optional[str] = None,
):
super().__init__(
index_name=index_name,
config=config,
channel=channel,
grpc_config=grpc_config,
pool_threads=pool_threads,
_endpoint_override=_endpoint_override,
use_asyncio=False,
)

@property
def stub_class(self):
return VectorServiceStub
@@ -134,7 +155,7 @@ def upsert(

vectors = list(map(VectorFactoryGRPC.build, vectors))
if async_req:
args_dict = self._parse_non_empty_args([("namespace", namespace)])
args_dict = parse_non_empty_args([("namespace", namespace)])
request = UpsertRequest(vectors=vectors, **args_dict, **kwargs)
future = self.runner.run(self.stub.Upsert.future, request, timeout=timeout)
return PineconeGrpcFuture(future)
@@ -160,7 +181,7 @@ def upsert(
def _upsert_batch(
self, vectors: List[GRPCVector], namespace: Optional[str], timeout: Optional[int], **kwargs
) -> UpsertResponse:
args_dict = self._parse_non_empty_args([("namespace", namespace)])
args_dict = parse_non_empty_args([("namespace", namespace)])
request = UpsertRequest(vectors=vectors, **args_dict)
return self.runner.run(self.stub.Upsert, request, timeout=timeout, **kwargs)

@@ -267,7 +288,7 @@ def delete(
else:
filter_struct = None

args_dict = self._parse_non_empty_args(
args_dict = parse_non_empty_args(
[
("ids", ids),
("delete_all", delete_all),
@@ -308,7 +329,7 @@ def fetch(
"""
timeout = kwargs.pop("timeout", None)

args_dict = self._parse_non_empty_args([("namespace", namespace)])
args_dict = parse_non_empty_args([("namespace", namespace)])

request = FetchRequest(ids=ids, **args_dict, **kwargs)

@@ -379,8 +400,8 @@ def query(
else:
filter_struct = None

sparse_vector = self._parse_sparse_values_arg(sparse_vector)
args_dict = self._parse_non_empty_args(
sparse_vector = parse_sparse_values_arg(sparse_vector)
args_dict = parse_non_empty_args(
[
("vector", vector),
("id", id),
@@ -495,8 +516,8 @@ def update(
set_metadata_struct = None

timeout = kwargs.pop("timeout", None)
sparse_values = self._parse_sparse_values_arg(sparse_values)
args_dict = self._parse_non_empty_args(
sparse_values = parse_sparse_values_arg(sparse_values)
args_dict = parse_non_empty_args(
[
("values", values),
("set_metadata", set_metadata_struct),
@@ -545,7 +566,7 @@ def list_paginated(
Returns: SimpleListResponse object which contains the list of ids, the namespace name, pagination information, and usage showing the number of read_units consumed.
"""
args_dict = self._parse_non_empty_args(
args_dict = parse_non_empty_args(
[
("prefix", prefix),
("limit", limit),
@@ -624,36 +645,10 @@ def describe_index_stats(
filter_struct = dict_to_proto_struct(filter)
else:
filter_struct = None
args_dict = self._parse_non_empty_args([("filter", filter_struct)])
args_dict = parse_non_empty_args([("filter", filter_struct)])
timeout = kwargs.pop("timeout", None)

request = DescribeIndexStatsRequest(**args_dict)
response = self.runner.run(self.stub.DescribeIndexStats, request, timeout=timeout)
json_response = json_format.MessageToDict(response)
return parse_stats_response(json_response)

@staticmethod
def _parse_non_empty_args(args: List[Tuple[str, Any]]) -> Dict[str, Any]:
return {arg_name: val for arg_name, val in args if val is not None}

@staticmethod
def _parse_sparse_values_arg(
sparse_values: Optional[Union[GRPCSparseValues, SparseVectorTypedDict]],
) -> Optional[GRPCSparseValues]:
if sparse_values is None:
return None

if isinstance(sparse_values, GRPCSparseValues):
return sparse_values

if (
not isinstance(sparse_values, dict)
or "indices" not in sparse_values
or "values" not in sparse_values
):
raise ValueError(
"Invalid sparse values argument. Expected a dict of: {'indices': List[int], 'values': List[float]}."
f"Received: {sparse_values}"
)

return GRPCSparseValues(indices=sparse_values["indices"], values=sparse_values["values"])
639 changes: 639 additions & 0 deletions pinecone/grpc/index_grpc_asyncio.py

Large diffs are not rendered by default.

14 changes: 13 additions & 1 deletion pinecone/grpc/pinecone.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from ..control.pinecone import Pinecone
from ..config.config import ConfigBuilder
from .index_grpc import GRPCIndex
from .index_grpc_asyncio import GRPCIndexAsyncio


class PineconeGRPC(Pinecone):
@@ -118,6 +119,12 @@ def Index(self, name: str = "", host: str = "", **kwargs):
index.query(vector=[...], top_k=10)
```
"""
return self._init_index(name=name, host=host, use_asyncio=False, **kwargs)

def AsyncioIndex(self, name: str = "", host: str = "", **kwargs):
return self._init_index(name=name, host=host, use_asyncio=True, **kwargs)

def _init_index(self, name: str, host: str, use_asyncio=False, **kwargs):
if name == "" and host == "":
raise ValueError("Either name or host must be specified")

@@ -133,4 +140,9 @@ def Index(self, name: str = "", host: str = "", **kwargs):
proxy_url=self.config.proxy_url,
ssl_ca_certs=self.config.ssl_ca_certs,
)
return GRPCIndex(index_name=name, config=config, pool_threads=pt, **kwargs)


if use_asyncio:
return GRPCIndexAsyncio(index_name=name, config=config, pool_threads=pt, **kwargs)
else:
return GRPCIndex(index_name=name, config=config, pool_threads=pt, **kwargs)
14 changes: 14 additions & 0 deletions pinecone/grpc/query_results.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from typing import TypedDict, List, Dict, Any


class ScoredVectorTypedDict(TypedDict):
id: str
score: float
values: List[float]
metadata: dict


class QueryResultsTypedDict(TypedDict):
matches: List[ScoredVectorTypedDict]
namespace: str
usage: Dict[str, Any]
183 changes: 183 additions & 0 deletions pinecone/grpc/query_results_aggregator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
from typing import List, Tuple, Optional, Any, Dict
import json
import heapq
from pinecone.core.openapi.data.models import Usage

from dataclasses import dataclass, asdict


@dataclass
class ScoredVectorWithNamespace:
namespace: str
score: float
id: str
values: List[float]
sparse_values: dict
metadata: dict

def __init__(self, aggregate_results_heap_tuple: Tuple[float, int, object, str]):
json_vector = aggregate_results_heap_tuple[2]
self.namespace = aggregate_results_heap_tuple[3]
self.id = json_vector.get("id") # type: ignore
self.score = json_vector.get("score") # type: ignore
self.values = json_vector.get("values") # type: ignore
self.sparse_values = json_vector.get("sparse_values", None) # type: ignore
self.metadata = json_vector.get("metadata", None) # type: ignore

def __getitem__(self, key):
if hasattr(self, key):
return getattr(self, key)
else:
raise KeyError(f"'{key}' not found in ScoredVectorWithNamespace")

def __repr__(self):
return json.dumps(self._truncate(asdict(self)), indent=4)

def __json__(self):
return self._truncate(asdict(self))

def _truncate(self, obj, max_items=2):
"""
Recursively traverse and truncate lists that exceed max_items length.
Only display the "... X more" message if at least 2 elements are hidden.
"""
if obj is None:
return None # Skip None values
elif isinstance(obj, list):
filtered_list = [self._truncate(i, max_items) for i in obj if i is not None]
if len(filtered_list) > max_items:
# Show the truncation message only if more than 1 item is hidden
remaining_items = len(filtered_list) - max_items
if remaining_items > 1:
return filtered_list[:max_items] + [f"... {remaining_items} more"]
else:
# If only 1 item remains, show it
return filtered_list
return filtered_list
elif isinstance(obj, dict):
# Recursively process dictionaries, omitting None values
return {k: self._truncate(v, max_items) for k, v in obj.items() if v is not None}
return obj


@dataclass
class QueryNamespacesResults:
usage: Usage
matches: List[ScoredVectorWithNamespace]

def __getitem__(self, key):
if hasattr(self, key):
return getattr(self, key)
else:
raise KeyError(f"'{key}' not found in QueryNamespacesResults")

def __repr__(self):
return json.dumps(
{
"usage": self.usage.to_dict(),
"matches": [match.__json__() for match in self.matches],
},
indent=4,
)


class QueryResultsAggregregatorNotEnoughResultsError(Exception):
def __init__(self):
super().__init__(
"Cannot interpret results without at least two matches. In order to aggregate results from multiple queries, top_k must be greater than 1 in order to correctly infer the similarity metric from scores."
)


class QueryResultsAggregatorInvalidTopKError(Exception):
def __init__(self, top_k: int):
super().__init__(
f"Invalid top_k value {top_k}. To aggregate results from multiple queries the top_k must be at least 2."
)


class QueryResultsAggregator:
def __init__(self, top_k: int):
if top_k < 2:
raise QueryResultsAggregatorInvalidTopKError(top_k)
self.top_k = top_k
self.usage_read_units = 0
self.heap: List[Tuple[float, int, object, str]] = []
self.insertion_counter = 0
self.is_dotproduct = None
self.read = False
self.final_results: Optional[QueryNamespacesResults] = None

def _is_dotproduct_index(self, matches):
# The interpretation of the score depends on the similar metric used.
# Unlike other index types, in indexes configured for dotproduct,
# a higher score is better. We have to infer this is the case by inspecting
# the order of the scores in the results.
for i in range(1, len(matches)):
if matches[i].get("score") > matches[i - 1].get("score"): # Found an increase
return False
return True

def _dotproduct_heap_item(self, match, ns):
return (match.get("score"), -self.insertion_counter, match, ns)

def _non_dotproduct_heap_item(self, match, ns):
return (-match.get("score"), -self.insertion_counter, match, ns)

def _process_matches(self, matches, ns, heap_item_fn):
for match in matches:
self.insertion_counter += 1
if len(self.heap) < self.top_k:
heapq.heappush(self.heap, heap_item_fn(match, ns))
else:
# Assume we have dotproduct scores sorted in descending order
if self.is_dotproduct and match["score"] < self.heap[0][0]:
# No further matches can improve the top-K heap
break
elif not self.is_dotproduct and match["score"] > -self.heap[0][0]:
# No further matches can improve the top-K heap
break
heapq.heappushpop(self.heap, heap_item_fn(match, ns))

def add_results(self, results: Dict[str, Any]):
if self.read:
# This is mainly just to sanity check in test cases which get quite confusing
# if you read results twice due to the heap being emptied when constructing
# the ordered results.
raise ValueError("Results have already been read. Cannot add more results.")

matches = results.get("matches", [])
ns: str = results.get("namespace", "")
self.usage_read_units += results.get("usage", {}).get("readUnits", 0)

if len(matches) == 0:
return

if self.is_dotproduct is None:
if len(matches) == 1:
# This condition should match the second time we add results containing
# only one match. We need at least two matches in a single response in order
# to infer the similarity metric
raise QueryResultsAggregregatorNotEnoughResultsError()
self.is_dotproduct = self._is_dotproduct_index(matches)

if self.is_dotproduct:
self._process_matches(matches, ns, self._dotproduct_heap_item)
else:
self._process_matches(matches, ns, self._non_dotproduct_heap_item)

def get_results(self) -> QueryNamespacesResults:
if self.read:
if self.final_results is not None:
return self.final_results
else:
# I don't think this branch can ever actually be reached, but the type checker disagrees
raise ValueError("Results have already been read. Cannot get results again.")
self.read = True

self.final_results = QueryNamespacesResults(
usage=Usage(read_units=self.usage_read_units),
matches=[
ScoredVectorWithNamespace(heapq.heappop(self.heap)) for _ in range(len(self.heap))
][::-1],
)
return self.final_results
6 changes: 6 additions & 0 deletions pinecone/grpc/sparse_vector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from typing import TypedDict, List


class SparseVectorTypedDict(TypedDict):
indices: List[int]
values: List[float]
33 changes: 32 additions & 1 deletion pinecone/grpc/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Optional
from typing import Optional, Union
from google.protobuf import json_format
from google.protobuf.message import Message

@@ -14,6 +14,8 @@
DescribeIndexStatsResponse,
NamespaceSummary,
)
from pinecone.core.grpc.protos.vector_service_pb2 import SparseValues as GRPCSparseValues
from .sparse_vector import SparseVectorTypedDict

from google.protobuf.struct_pb2 import Struct

@@ -22,6 +24,13 @@ def _generate_request_id() -> str:
return str(uuid.uuid4())


def normalize_endpoint(endpoint: str) -> str:
grpc_host = endpoint.replace("https://", "")
if ":" not in grpc_host:
grpc_host = f"{grpc_host}:443"
return grpc_host


def dict_to_proto_struct(d: Optional[dict]) -> "Struct":
if not d:
d = {}
@@ -109,3 +118,25 @@ def parse_stats_response(response: dict):
total_vector_count=total_vector_count,
_check_type=False,
)


def parse_sparse_values_arg(
sparse_values: Optional[Union[GRPCSparseValues, SparseVectorTypedDict]],
) -> Optional[GRPCSparseValues]:
if sparse_values is None:
return None

if isinstance(sparse_values, GRPCSparseValues):
return sparse_values

if (
not isinstance(sparse_values, dict)
or "indices" not in sparse_values
or "values" not in sparse_values
):
raise ValueError(
"Invalid sparse values argument. Expected a dict of: {'indices': List[int], 'values': List[float]}."
f"Received: {sparse_values}"
)

return GRPCSparseValues(indices=sparse_values["indices"], values=sparse_values["values"])
1,270 changes: 667 additions & 603 deletions poetry.lock

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -77,8 +77,8 @@ numpy = [
]
pandas = ">=1.3.5"
pdoc = "^14.1.0"
pytest = "8.0.0"
pytest-asyncio = "0.15.1"
pytest = "8.3.3"
pytest-asyncio = "0.24.0"
pytest-cov = "2.10.1"
pytest-mock = "3.6.1"
pytest-timeout = "2.2.0"
@@ -96,6 +96,10 @@ grpc = ["grpcio", "googleapis-common-protos", "lz4", "protobuf", "protoc-gen-ope
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[tool.pytest.ini_options]
asyncio_mode = 'auto'
# asyncio_default_fixture_loop_scope = 'session'

[tool.ruff]
exclude = [
".eggs",
12 changes: 10 additions & 2 deletions scripts/create.py
Original file line number Diff line number Diff line change
@@ -59,14 +59,22 @@ def generate_index_name(test_name: str) -> str:

def main():
pc = Pinecone(api_key=read_env_var("PINECONE_API_KEY"))

index_name = generate_index_name(read_env_var("NAME_PREFIX") + random_string(20))
dimension = int(read_env_var("DIMENSION"))
metric = read_env_var("METRIC")

pc.create_index(
name=index_name,
metric=read_env_var("METRIC"),
dimension=int(read_env_var("DIMENSION")),
metric=metric,
dimension=dimension,
spec={"serverless": {"cloud": read_env_var("CLOUD"), "region": read_env_var("REGION")}},
)
desc = pc.describe_index(index_name)
write_gh_output("index_name", index_name)
write_gh_output("index_host", desc.host)
write_gh_output("index_metric", metric)
write_gh_output("index_dimension", dimension)


if __name__ == "__main__":
Empty file.
55 changes: 55 additions & 0 deletions tests/integration/data_asyncio/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import pytest
import os
from ..helpers import get_environment_var, random_string


@pytest.fixture(scope="session")
def api_key():
return get_environment_var("PINECONE_API_KEY")


@pytest.fixture(scope="session")
def host():
return get_environment_var("INDEX_HOST")


@pytest.fixture(scope="session")
def dimension():
return int(get_environment_var("DIMENSION"))


def use_grpc():
return os.environ.get("USE_GRPC", "false") == "true"


def build_client(api_key):
if use_grpc():
from pinecone.grpc import PineconeGRPC

return PineconeGRPC(api_key=api_key)
else:
from pinecone import Pinecone

return Pinecone(
api_key=api_key, additional_headers={"sdk-test-suite": "pinecone-python-client"}
)


@pytest.fixture(scope="session")
async def pc(api_key):
return build_client(api_key=api_key)


@pytest.fixture(scope="session")
async def asyncio_idx(pc, host):
return pc.AsyncioIndex(host=host)


@pytest.fixture(scope="session")
async def namespace():
return random_string(10)


@pytest.fixture(scope="session")
async def list_namespace():
return random_string(10)
97 changes: 97 additions & 0 deletions tests/integration/data_asyncio/test_upsert.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import pytest
from pinecone import Vector
from .conftest import use_grpc
from ..helpers import random_string
from .utils import build_asyncio_idx, embedding_values, poll_for_freshness


@pytest.mark.parametrize("target_namespace", ["", random_string(20)])
@pytest.mark.skipif(use_grpc() == False, reason="Currently only GRPC supports asyncio")
async def test_upsert_to_default_namespace(host, dimension, target_namespace):
asyncio_idx = build_asyncio_idx(host)

def emb():
return embedding_values(dimension)

# Upsert with tuples
await asyncio_idx.upsert(
vectors=[("1", emb()), ("2", emb()), ("3", emb())], namespace=target_namespace
)

# Upsert with objects
await asyncio_idx.upsert(
vectors=[
Vector(id="4", values=emb()),
Vector(id="5", values=emb()),
Vector(id="6", values=emb()),
],
namespace=target_namespace,
)

# Upsert with dict
await asyncio_idx.upsert(
vectors=[
{"id": "7", "values": emb()},
{"id": "8", "values": emb()},
{"id": "9", "values": emb()},
],
namespace=target_namespace,
)

await poll_for_freshness(asyncio_idx, target_namespace, 9)

# # Check the vector count reflects some data has been upserted
stats = await asyncio_idx.describe_index_stats()
assert stats.total_vector_count >= 9
# default namespace could have other stuff from other tests
if target_namespace != "":
assert stats.namespaces[target_namespace].vector_count == 9


# @pytest.mark.parametrize("target_namespace", [
# "",
# random_string(20),
# ])
# @pytest.mark.skipif(
# os.getenv("METRIC") != "dotproduct", reason="Only metric=dotprodouct indexes support hybrid"
# )
# async def test_upsert_to_namespace_with_sparse_embedding_values(pc, host, dimension, target_namespace):
# asyncio_idx = pc.AsyncioIndex(host=host)

# # Upsert with sparse values object
# await asyncio_idx.upsert(
# vectors=[
# Vector(
# id="1",
# values=embedding_values(dimension),
# sparse_values=SparseValues(indices=[0, 1], values=embedding_values()),
# )
# ],
# namespace=target_namespace,
# )

# # Upsert with sparse values dict
# await asyncio_idx.upsert(
# vectors=[
# {
# "id": "2",
# "values": embedding_values(dimension),
# "sparse_values": {"indices": [0, 1], "values": embedding_values()},
# },
# {
# "id": "3",
# "values": embedding_values(dimension),
# "sparse_values": {"indices": [0, 1], "values": embedding_values()},
# },
# ],
# namespace=target_namespace,
# )

# await poll_for_freshness(asyncio_idx, target_namespace, 9)

# # Check the vector count reflects some data has been upserted
# stats = await asyncio_idx.describe_index_stats()
# assert stats.total_vector_count >= 9

# if (target_namespace != ""):
# assert stats.namespaces[target_namespace].vector_count == 9
234 changes: 234 additions & 0 deletions tests/integration/data_asyncio/test_upsert_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import os
import pytest
from pinecone.grpc import Vector, SparseValues
from ..helpers import fake_api_key
from .utils import build_asyncio_idx, embedding_values
from pinecone import PineconeException, PineconeApiValueError
from pinecone.grpc import PineconeGRPC as Pinecone


class TestUpsertApiKeyMissing:
async def test_upsert_fails_when_api_key_invalid(self, host):
with pytest.raises(PineconeException):
pc = Pinecone(
api_key=fake_api_key(),
additional_headers={"sdk-test-suite": "pinecone-python-client"},
)
asyncio_idx = pc.AsyncioIndex(host=host)
await asyncio_idx.upsert(
vectors=[
Vector(id="1", values=embedding_values()),
Vector(id="2", values=embedding_values()),
]
)

@pytest.mark.skipif(
os.getenv("USE_GRPC") != "true", reason="Only test grpc client when grpc extras"
)
async def test_upsert_fails_when_api_key_invalid_grpc(self, host):
with pytest.raises(PineconeException):
from pinecone.grpc import PineconeGRPC

pc = PineconeGRPC(api_key=fake_api_key())
asyncio_idx = pc.AsyncioIndex(host=host)
await asyncio_idx.upsert(
vectors=[
Vector(id="1", values=embedding_values()),
Vector(id="2", values=embedding_values()),
]
)


class TestUpsertFailsWhenDimensionMismatch:
async def test_upsert_fails_when_dimension_mismatch_objects(self, host):
with pytest.raises(PineconeApiValueError):
asyncio_idx = build_asyncio_idx(host)
await asyncio_idx.upsert(
vectors=[
Vector(id="1", values=embedding_values(2)),
Vector(id="2", values=embedding_values(3)),
]
)

async def test_upsert_fails_when_dimension_mismatch_tuples(self, host):
asyncio_idx = build_asyncio_idx(host)
with pytest.raises(PineconeException):
await asyncio_idx.upsert(
vectors=[("1", embedding_values(2)), ("2", embedding_values(3))]
)

async def test_upsert_fails_when_dimension_mismatch_dicts(self, host):
asyncio_idx = build_asyncio_idx(host)
with pytest.raises(PineconeException):
await asyncio_idx.upsert(
vectors=[
{"id": "1", "values": embedding_values(2)},
{"id": "2", "values": embedding_values(3)},
]
)


@pytest.mark.skipif(
os.getenv("METRIC") != "dotproduct", reason="Only metric=dotprodouct indexes support hybrid"
)
class TestUpsertFailsSparseValuesDimensionMismatch:
async def test_upsert_fails_when_sparse_values_indices_values_mismatch_objects(self, host):
asyncio_idx = build_asyncio_idx(host)
with pytest.raises(PineconeException):
await asyncio_idx.upsert(
vectors=[
Vector(
id="1",
values=[0.1, 0.1],
sparse_values=SparseValues(indices=[0], values=[0.5, 0.5]),
)
]
)
with pytest.raises(PineconeException):
await asyncio_idx.upsert(
vectors=[
Vector(
id="1",
values=[0.1, 0.1],
sparse_values=SparseValues(indices=[0, 1], values=[0.5]),
)
]
)

async def test_upsert_fails_when_sparse_values_in_tuples(self, host):
asyncio_idx = build_asyncio_idx(host)
with pytest.raises(ValueError):
await asyncio_idx.upsert(
vectors=[
("1", SparseValues(indices=[0], values=[0.5])),
("2", SparseValues(indices=[0, 1, 2], values=[0.5, 0.5, 0.5])),
]
)

async def test_upsert_fails_when_sparse_values_indices_values_mismatch_dicts(self, host):
asyncio_idx = build_asyncio_idx(host)
with pytest.raises(PineconeException):
await asyncio_idx.upsert(
vectors=[
{
"id": "1",
"values": [0.2, 0.2],
"sparse_values": SparseValues(indices=[0], values=[0.5, 0.5]),
}
]
)
with pytest.raises(PineconeException):
await asyncio_idx.upsert(
vectors=[
{
"id": "1",
"values": [0.1, 0.2],
"sparse_values": SparseValues(indices=[0, 1], values=[0.5]),
}
]
)


class TestUpsertFailsWhenValuesMissing:
async def test_upsert_fails_when_values_missing_objects(self, host):
asyncio_idx = build_asyncio_idx(host)
with pytest.raises(PineconeApiValueError):
await asyncio_idx.upsert(vectors=[Vector(id="1"), Vector(id="2")])

async def test_upsert_fails_when_values_missing_tuples(self, host):
asyncio_idx = build_asyncio_idx(host)
with pytest.raises(ValueError):
await asyncio_idx.upsert(vectors=[("1",), ("2",)])

async def test_upsert_fails_when_values_missing_dicts(self, host):
asyncio_idx = build_asyncio_idx(host)
with pytest.raises(ValueError):
await asyncio_idx.upsert(vectors=[{"id": "1"}, {"id": "2"}])


class TestUpsertFailsWhenValuesWrongType:
async def test_upsert_fails_when_values_wrong_type_objects(self, host):
asyncio_idx = build_asyncio_idx(host)
with pytest.raises(TypeError):
await asyncio_idx.upsert(
vectors=[Vector(id="1", values="abc"), Vector(id="2", values="def")]
)

async def test_upsert_fails_when_values_wrong_type_tuples(self, host):
asyncio_idx = build_asyncio_idx(host)
if os.environ.get("USE_GRPC", "false") == "true":
expected_exception = TypeError
else:
expected_exception = PineconeException

with pytest.raises(expected_exception):
await asyncio_idx.upsert(vectors=[("1", "abc"), ("2", "def")])

async def test_upsert_fails_when_values_wrong_type_dicts(self, host):
asyncio_idx = build_asyncio_idx(host)
with pytest.raises(TypeError):
await asyncio_idx.upsert(
vectors=[{"id": "1", "values": "abc"}, {"id": "2", "values": "def"}]
)


class TestUpsertFailsWhenVectorsMissing:
async def test_upsert_fails_when_vectors_empty(self, host):
asyncio_idx = build_asyncio_idx(host)
with pytest.raises(PineconeException):
await asyncio_idx.upsert(vectors=[])

async def test_upsert_fails_when_vectors_wrong_type(self, host):
asyncio_idx = build_asyncio_idx(host)
with pytest.raises(ValueError):
await asyncio_idx.upsert(vectors="abc")

async def test_upsert_fails_when_vectors_missing(self, host):
asyncio_idx = build_asyncio_idx(host)
with pytest.raises(TypeError):
await asyncio_idx.upsert()


# class TestUpsertIdMissing:
# async def test_upsert_fails_when_id_is_missing_objects(self, host):
# with pytest.raises(TypeError):
# idx.upsert(
# vectors=[
# Vector(id="1", values=embedding_values()),
# Vector(values=embedding_values()),
# ]
# )

# async def test_upsert_fails_when_id_is_missing_tuples(self, host):
# with pytest.raises(ValueError):
# idx.upsert(vectors=[("1", embedding_values()), (embedding_values())])

# async def test_upsert_fails_when_id_is_missing_dicts(self, host):
# with pytest.raises(ValueError):
# idx.upsert(
# vectors=[{"id": "1", "values": embedding_values()}, {"values": embedding_values()}]
# )


# class TestUpsertIdWrongType:
# async def test_upsert_fails_when_id_wrong_type_objects(self, host):
# with pytest.raises(Exception):
# idx.upsert(
# vectors=[
# Vector(id="1", values=embedding_values()),
# Vector(id=2, values=embedding_values()),
# ]
# )

# async def test_upsert_fails_when_id_wrong_type_tuples(self, host):
# with pytest.raises(Exception):
# idx.upsert(vectors=[("1", embedding_values()), (2, embedding_values())])

# async def test_upsert_fails_when_id_wrong_type_dicts(self, host):
# with pytest.raises(Exception):
# idx.upsert(
# vectors=[
# {"id": "1", "values": embedding_values()},
# {"id": 2, "values": embedding_values()},
# ]
# )
31 changes: 31 additions & 0 deletions tests/integration/data_asyncio/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import random
import asyncio
from pinecone.grpc import PineconeGRPC as Pinecone


def build_asyncio_idx(host):
return Pinecone().AsyncioIndex(host=host)


def embedding_values(dimension=2):
return [random.random() for _ in range(dimension)]


async def poll_for_freshness(asyncio_idx, namespace, expected_count):
total_wait = 0
delta = 2
while True:
stats = await asyncio_idx.describe_index_stats()
if stats.namespaces.get(namespace, None) is not None:
if stats.namespaces[namespace].vector_count >= expected_count:
print(
f"Found {stats.namespaces[namespace].vector_count} vectors in namespace '{namespace}' after {total_wait} seconds"
)
break
await asyncio.sleep(delta)
total_wait += delta

if total_wait > 60:
raise TimeoutError(
f"Timed out waiting for vectors to appear in namespace '{namespace}'"
)
553 changes: 553 additions & 0 deletions tests/unit_grpc/test_query_results_aggregator.py

Large diffs are not rendered by default.