Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
50 changes: 47 additions & 3 deletions pinecone/_internal/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,54 @@

from __future__ import annotations

from collections.abc import Sequence
from typing import Any, overload
import functools
import inspect
from collections.abc import Awaitable, Callable, Sequence
from typing import Any, ParamSpec, TypeVar, cast, overload

from pinecone.errors.exceptions import ValidationError
from pinecone.errors.exceptions import PineconeTypeError, ValidationError

_P = ParamSpec("_P")
_R = TypeVar("_R")


def reject_positional_args(example: str) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
"""Give a keyword-only instance method a teaching error on positional misuse.

The interpreter's own failure ("takes 1 positional argument but N were
given") names neither the parameters nor the fix. This guard raises
:class:`PineconeTypeError` (a ``TypeError`` subclass) carrying *example*,
the exact call shape to use instead. ``inspect.signature`` still reports
the wrapped method's real keyword-only signature via ``__wrapped__``.
The bound ``self`` is the only positional argument allowed through.
"""

def decorate(fn: Callable[_P, _R]) -> Callable[_P, _R]:
message = (
f"{fn.__name__}() accepts keyword arguments only: {example}. "
"Positional arguments are rejected because the parameter order "
"changed across SDK generations."
)
if inspect.iscoroutinefunction(fn):
async_fn = cast("Callable[_P, Awaitable[_R]]", fn)

@functools.wraps(fn)
async def async_wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
if len(args) > 1:
raise PineconeTypeError(message)
return await async_fn(*args, **kwargs)

return cast("Callable[_P, _R]", async_wrapper)
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

@functools.wraps(fn)
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
if len(args) > 1:
raise PineconeTypeError(message)
return fn(*args, **kwargs)

return wrapper

return decorate


@overload
Expand Down
7 changes: 6 additions & 1 deletion pinecone/async_client/async_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@
_validate_host,
_vector_to_dict,
)
from pinecone._internal.validation import require_in_range, require_positive
from pinecone._internal.validation import (
reject_positional_args,
require_in_range,
require_positive,
)
from pinecone._internal.vector_factory import VectorFactory
from pinecone.errors.exceptions import PineconeValueError, ValidationError
from pinecone.models.imports.list import ImportList
Expand Down Expand Up @@ -138,6 +142,7 @@ def host(self) -> str:
"""The data plane host URL for this index."""
return self._host

@reject_positional_args('upsert_records(namespace="...", records=[...])')
async def upsert_records(
self,
*,
Expand Down
7 changes: 6 additions & 1 deletion pinecone/grpc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@
from pinecone._internal.config import PineconeConfig
from pinecone._internal.constants import DATA_PLANE_API_VERSION
from pinecone._internal.data_plane_helpers import _build_search_records_body, _validate_host
from pinecone._internal.validation import require_in_range, require_positive
from pinecone._internal.validation import (
reject_positional_args,
require_in_range,
require_positive,
)
from pinecone._internal.vector_factory import VectorFactory
from pinecone.errors.exceptions import (
PineconeValueError,
Expand Down Expand Up @@ -1312,6 +1316,7 @@ def query_namespaces_async(
)
)

@reject_positional_args('upsert_records(namespace="...", records=[...])')
def upsert_records(
self,
*,
Expand Down
7 changes: 6 additions & 1 deletion pinecone/index/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@
_validate_host,
_vector_to_dict,
)
from pinecone._internal.validation import require_in_range, require_positive
from pinecone._internal.validation import (
reject_positional_args,
require_in_range,
require_positive,
)
from pinecone._internal.vector_factory import VectorFactory
from pinecone.errors.exceptions import PineconeValueError, ValidationError
from pinecone.models.imports.list import ImportList
Expand Down Expand Up @@ -465,6 +469,7 @@ def upsert_from_dataframe(
timeout=timeout,
)

@reject_positional_args('upsert_records(namespace="...", records=[...])')
def upsert_records(
self,
*,
Expand Down
12 changes: 11 additions & 1 deletion tests/unit/test_async_upsert_records.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import respx

from pinecone.async_client.async_index import AsyncIndex
from pinecone.errors.exceptions import ValidationError
from pinecone.errors.exceptions import PineconeTypeError, ValidationError
from pinecone.models.vectors.responses import UpsertRecordsResponse

INDEX_HOST = "my-index-abc123.svc.pinecone.io"
Expand Down Expand Up @@ -140,3 +140,13 @@ async def test_async_upsert_records_id_must_be_string(self) -> None:
namespace="test-ns",
records=[{"_id": 123, "text": "hello"}],
)

@pytest.mark.anyio
async def test_async_upsert_records_positional_args_raise_teaching_error(self) -> None:
"""Positional misuse names the keyword-only call shape, not a bare TypeError."""
idx = _make_async_index()
with pytest.raises(PineconeTypeError) as excinfo:
await idx.upsert_records("test-ns", [{"_id": "r1", "text": "hello"}]) # type: ignore[misc]
message = str(excinfo.value)
assert "keyword arguments only" in message
assert 'upsert_records(namespace="...", records=[...])' in message
12 changes: 12 additions & 0 deletions tests/unit/test_grpc_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
NotFoundError,
PineconeConnectionError,
PineconeTimeoutError,
PineconeTypeError,
ServiceError,
UnauthorizedError,
ValidationError,
Expand Down Expand Up @@ -986,6 +987,17 @@ def test_no_double_bracket_in_propagated_not_found_error(
class TestGrpcIndexUpsertRecords:
"""GrpcIndex.upsert_records() delegates to REST (NDJSON) endpoint."""

def test_upsert_records_positional_args_raise_teaching_error(
self, mock_channel: MagicMock
) -> None:
"""Positional misuse names the keyword-only call shape, not a bare TypeError."""
idx = _make_grpc_index(mock_channel, host=_INDEX_HOST)
with pytest.raises(PineconeTypeError) as excinfo:
idx.upsert_records("test-ns", [{"_id": "r1", "text": "hello"}]) # type: ignore[misc]
message = str(excinfo.value)
assert "keyword arguments only" in message
assert 'upsert_records(namespace="...", records=[...])' in message

@respx.mock
def test_upsert_records_basic(self, mock_channel: MagicMock) -> None:
respx.post(_UPSERT_RECORDS_URL).mock(return_value=httpx.Response(201))
Expand Down
17 changes: 16 additions & 1 deletion tests/unit/test_upsert_records.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@

from __future__ import annotations

import inspect
import json

import httpx
import pytest
import respx

from pinecone import Index
from pinecone.errors.exceptions import ValidationError
from pinecone.errors.exceptions import PineconeTypeError, ValidationError
from pinecone.models.vectors.responses import UpsertRecordsResponse

INDEX_HOST = "my-index-abc123.svc.pinecone.io"
Expand Down Expand Up @@ -169,6 +170,20 @@ def test_upsert_records_keyword_only(self) -> None:
with pytest.raises(TypeError):
idx.upsert_records([{"_id": "r1"}], "test-ns") # type: ignore[misc]

def test_upsert_records_positional_args_raise_teaching_error(self) -> None:
"""Positional misuse names the keyword-only call shape, not a bare TypeError."""
idx = _make_index()
with pytest.raises(PineconeTypeError) as excinfo:
idx.upsert_records("test-ns", [{"_id": "r1", "text": "hello"}]) # type: ignore[misc]
message = str(excinfo.value)
assert "keyword arguments only" in message
assert 'upsert_records(namespace="...", records=[...])' in message

def test_upsert_records_signature_reports_keyword_only_params(self) -> None:
params = inspect.signature(Index.upsert_records).parameters
assert params["records"].kind is inspect.Parameter.KEYWORD_ONLY
assert params["namespace"].kind is inspect.Parameter.KEYWORD_ONLY

@respx.mock
def test_upsert_records_preserves_metadata_fields(self) -> None:
"""Non-field_map fields (e.g. 'category') are forwarded in the NDJSON payload.
Expand Down
Loading