Skip to content

Commit bda17a1

Browse files
committed
feat(grpc): add GrpcIndex.describe_namespace (LSB-0050)
## Purpose Legacy callers using `GRPCIndex.describe_namespace` raised `AttributeError` because the new `GrpcIndex` did not expose the method. This wires the existing Rust `GrpcChannel.describe_namespace` RPC through to the Python surface. ## Solution - Added `describe_namespace(namespace, *, timeout_s)` to `GrpcChannelProtocol` in `_protocol.py` so mypy can verify all call sites. - Added `GrpcIndex.describe_namespace(*, name, timeout, **kwargs)` reproducing the same validation logic as `Index.describe_namespace`: accepts the legacy `namespace=` kwarg for backcompat, raises `ValidationError` when both or neither name arguments are provided, and raises on empty/whitespace names. - Parses the dict returned by the Rust channel (name, record_count, optional schema, optional indexed_fields) into a `NamespaceDescription` struct. - Added 6 unit tests covering the success path, legacy kwarg alias, both-kwargs error, empty-name error, schema+indexed_fields deserialization, and timeout forwarding. ## Usage ```python idx = GrpcIndex(host="...", api_key="...") ns = idx.describe_namespace(name="movies-en") print(ns.name, ns.record_count) ```
1 parent 3758724 commit bda17a1

3 files changed

Lines changed: 173 additions & 1 deletion

File tree

‎pinecone/grpc/__init__.py‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@
2626
)
2727
from pinecone.grpc._protocol import GrpcChannelProtocol
2828
from pinecone.grpc.future import PineconeFuture
29+
from pinecone.models.namespaces.models import (
30+
IndexedFields,
31+
NamespaceDescription,
32+
NamespaceFieldConfig,
33+
NamespaceSchema,
34+
)
2935
from pinecone.models.vectors.responses import (
3036
DescribeIndexStatsResponse,
3137
FetchResponse,
@@ -1316,6 +1322,65 @@ def search_records(
13161322
timeout=timeout,
13171323
)
13181324

1325+
def describe_namespace(
1326+
self,
1327+
*,
1328+
name: str | None = None,
1329+
timeout: float | None = None,
1330+
**kwargs: str,
1331+
) -> NamespaceDescription:
1332+
"""Describe a namespace by name.
1333+
1334+
Args:
1335+
name (str): Name of the namespace to describe.
1336+
timeout (float | None): Per-call timeout in seconds.
1337+
1338+
Returns:
1339+
:class:`NamespaceDescription` with the namespace name, record count,
1340+
and schema information.
1341+
1342+
Raises:
1343+
:exc:`ValidationError`: If the name is not a string or is empty/whitespace.
1344+
:exc:`TypeError`: If unexpected keyword arguments are passed.
1345+
"""
1346+
legacy_namespace: str | None = kwargs.pop("namespace", None)
1347+
if kwargs:
1348+
raise TypeError(
1349+
f"describe_namespace() got unexpected keyword arguments: {sorted(kwargs)!r}"
1350+
)
1351+
if name is not None and legacy_namespace is not None:
1352+
raise ValidationError("Provide either name= or namespace=, not both")
1353+
effective: str = name if name is not None else (legacy_namespace or "")
1354+
if not isinstance(effective, str):
1355+
raise ValidationError("namespace name must be a string")
1356+
if not effective or not effective.strip():
1357+
raise ValidationError("namespace name must be a non-empty string")
1358+
1359+
logger.info("Describing namespace %r via gRPC", effective)
1360+
result = self._channel.describe_namespace(effective, timeout_s=timeout)
1361+
1362+
schema: NamespaceSchema | None = None
1363+
raw_schema = result.get("schema")
1364+
if raw_schema is not None:
1365+
schema = NamespaceSchema(
1366+
fields={
1367+
k: NamespaceFieldConfig(filterable=v["filterable"])
1368+
for k, v in raw_schema.get("fields", {}).items()
1369+
}
1370+
)
1371+
1372+
indexed_fields: IndexedFields | None = None
1373+
raw_indexed = result.get("indexed_fields")
1374+
if raw_indexed is not None:
1375+
indexed_fields = IndexedFields(fields=list(raw_indexed))
1376+
1377+
return NamespaceDescription(
1378+
name=result.get("name", ""),
1379+
record_count=result.get("record_count", 0),
1380+
schema=schema,
1381+
indexed_fields=indexed_fields,
1382+
)
1383+
13191384
def close(self) -> None:
13201385
"""Close the underlying gRPC channel, REST client, and release resources."""
13211386
self._executor.shutdown(wait=True)

‎pinecone/grpc/_protocol.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,15 @@ def describe_index_stats(
101101
"""Describe index statistics."""
102102
...
103103

104+
def describe_namespace(
105+
self,
106+
namespace: str,
107+
*,
108+
timeout_s: float | None = None,
109+
) -> dict[str, Any]:
110+
"""Describe a namespace."""
111+
...
112+
104113
def close(self) -> None:
105114
"""Close the channel and release resources."""
106115
...

‎tests/unit/grpc/test_grpc_index.py‎

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
"""Unit tests for the GRPCIndex capitalisation alias (BCG-141)."""
1+
"""Unit tests for the GRPCIndex capitalisation alias (BCG-141) and GrpcIndex.describe_namespace."""
22

33
from __future__ import annotations
44

55
from unittest.mock import MagicMock, patch
66

7+
import pytest
8+
79
import pinecone.grpc
810
from pinecone.grpc import GRPCIndex, GrpcIndex
11+
from pinecone.models.namespaces.models import NamespaceDescription
912

1013
_MOCK_GRPC_MODULE_PATH = "pinecone._grpc"
1114

@@ -20,6 +23,16 @@ def _make_grpc_index_via_alias(mock_channel: MagicMock) -> GRPCIndex:
2023
)
2124

2225

26+
def _make_grpc_index(mock_channel: MagicMock) -> GrpcIndex:
27+
mock_module = MagicMock()
28+
mock_module.GrpcChannel.return_value = mock_channel
29+
with patch.dict("sys.modules", {_MOCK_GRPC_MODULE_PATH: mock_module}):
30+
return GrpcIndex(
31+
host="https://x-abc.svc.pinecone.io",
32+
api_key="k",
33+
)
34+
35+
2336
def test_legacy_GRPCIndex_alias_imports() -> None: # noqa: N802
2437
from pinecone.grpc import GRPCIndex as _GRPCIndex # noqa: F401
2538

@@ -33,3 +46,88 @@ def test_legacy_GRPCIndex_alias_constructs() -> None: # noqa: N802
3346
mock_channel.query.return_value = {"matches": [], "namespace": ""}
3447
idx = _make_grpc_index_via_alias(mock_channel)
3548
assert isinstance(idx, GrpcIndex)
49+
50+
51+
class TestGrpcDescribeNamespace:
52+
def test_grpc_describe_namespace_routes_to_channel(self) -> None:
53+
mock_channel = MagicMock()
54+
mock_channel.describe_namespace.return_value = {
55+
"name": "movies",
56+
"record_count": 42,
57+
}
58+
idx = _make_grpc_index(mock_channel)
59+
60+
result = idx.describe_namespace(name="movies")
61+
62+
mock_channel.describe_namespace.assert_called_once_with("movies", timeout_s=None)
63+
assert isinstance(result, NamespaceDescription)
64+
assert result.name == "movies"
65+
assert result.record_count == 42
66+
assert result.schema is None
67+
assert result.indexed_fields is None
68+
69+
def test_grpc_describe_namespace_accepts_legacy_namespace_kwarg(self) -> None:
70+
mock_channel = MagicMock()
71+
mock_channel.describe_namespace.return_value = {
72+
"name": "movies",
73+
"record_count": 7,
74+
}
75+
idx = _make_grpc_index(mock_channel)
76+
77+
result = idx.describe_namespace(namespace="movies") # type: ignore[call-arg]
78+
79+
mock_channel.describe_namespace.assert_called_once_with("movies", timeout_s=None)
80+
assert result.name == "movies"
81+
assert result.record_count == 7
82+
83+
def test_grpc_describe_namespace_both_kwargs_raise(self) -> None:
84+
from pinecone.errors.exceptions import ValidationError
85+
86+
mock_channel = MagicMock()
87+
idx = _make_grpc_index(mock_channel)
88+
89+
with pytest.raises(ValidationError, match=r"name=.*namespace="):
90+
idx.describe_namespace(name="movies", namespace="movies") # type: ignore[call-arg]
91+
92+
def test_grpc_describe_namespace_empty_name_raises(self) -> None:
93+
from pinecone.errors.exceptions import ValidationError
94+
95+
mock_channel = MagicMock()
96+
idx = _make_grpc_index(mock_channel)
97+
98+
with pytest.raises(ValidationError, match="non-empty"):
99+
idx.describe_namespace(name="")
100+
101+
with pytest.raises(ValidationError, match="non-empty"):
102+
idx.describe_namespace(name=" ")
103+
104+
def test_grpc_describe_namespace_with_schema_and_indexed_fields(self) -> None:
105+
mock_channel = MagicMock()
106+
mock_channel.describe_namespace.return_value = {
107+
"name": "docs",
108+
"record_count": 100,
109+
"schema": {"fields": {"genre": {"filterable": True}}},
110+
"indexed_fields": ["genre", "year"],
111+
}
112+
idx = _make_grpc_index(mock_channel)
113+
114+
result = idx.describe_namespace(name="docs")
115+
116+
assert result.name == "docs"
117+
assert result.record_count == 100
118+
assert result.schema is not None
119+
assert result.schema.fields["genre"].filterable is True
120+
assert result.indexed_fields is not None
121+
assert result.indexed_fields.fields == ["genre", "year"]
122+
123+
def test_grpc_describe_namespace_passes_timeout(self) -> None:
124+
mock_channel = MagicMock()
125+
mock_channel.describe_namespace.return_value = {
126+
"name": "ns1",
127+
"record_count": 0,
128+
}
129+
idx = _make_grpc_index(mock_channel)
130+
131+
idx.describe_namespace(name="ns1", timeout=5.0)
132+
133+
mock_channel.describe_namespace.assert_called_once_with("ns1", timeout_s=5.0)

0 commit comments

Comments
 (0)