Skip to content

Commit 9d85ba8

Browse files
committed
fix(collections): add resource name validation to Collections.create
## Purpose The backend rejects collection names with uppercase letters, underscores, special characters, leading/trailing hyphens, or names longer than 45 chars with a generic 400 error. The SDK only checked for empty strings, so invalid names surfaced as confusing server errors instead of clear client-side ValidationErrors. ## Solution Added `require_valid_resource_name` helper to `pinecone/_internal/validation.py` that enforces the same constraints as the backend's `is_valid_resource_name`: - non-empty and non-whitespace - at most 45 characters - no leading or trailing hyphen - only lowercase alphanumeric and hyphen characters allowed Replaced `require_non_empty("name", name)` with `require_valid_resource_name` in both `Collections.create` and `AsyncCollections.create`. Updated docstrings to document the constraints. Added unit tests covering all rejection conditions and the happy path, plus parametrized integration tests for invalid names in both sync and async variants.
1 parent ba40649 commit 9d85ba8

6 files changed

Lines changed: 161 additions & 6 deletions

File tree

‎pinecone/_internal/validation.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,29 @@ def require_one_of(name: str, value: str, allowed: Sequence[str]) -> None:
4949
if value not in allowed:
5050
opts = ", ".join(repr(a) for a in allowed)
5151
raise ValidationError(f"{name} must be one of {opts}, got {value!r}")
52+
53+
54+
_RESOURCE_NAME_MAX_LEN = 45
55+
_RESOURCE_NAME_CHARS = frozenset("abcdefghijklmnopqrstuvwxyz0123456789-")
56+
57+
58+
def require_valid_resource_name(name: str, value: str) -> None:
59+
"""Raise ValidationError if value is not a valid Pinecone resource name.
60+
61+
Valid names are non-empty, at most 45 characters, consist only of lowercase
62+
alphanumeric characters and hyphens, and must not start or end with a hyphen.
63+
"""
64+
if not value or not value.strip():
65+
raise ValidationError(f"{name} must be a non-empty string")
66+
if len(value) > _RESOURCE_NAME_MAX_LEN:
67+
raise ValidationError(
68+
f"{name} is too long (max {_RESOURCE_NAME_MAX_LEN} characters, got {len(value)})"
69+
)
70+
if value[0] == "-":
71+
raise ValidationError(f"{name} must not start with a hyphen")
72+
if value[-1] == "-":
73+
raise ValidationError(f"{name} must not end with a hyphen")
74+
if not all(c in _RESOURCE_NAME_CHARS for c in value):
75+
raise ValidationError(
76+
f"{name} contains invalid characters; must be lowercase alphanumeric and hyphens only"
77+
)

‎pinecone/async_client/collections.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from typing import TYPE_CHECKING
77

88
from pinecone._internal.adapters.collections_adapter import CollectionsAdapter
9-
from pinecone._internal.validation import require_non_empty
9+
from pinecone._internal.validation import require_non_empty, require_valid_resource_name
1010
from pinecone.models.collections.list import CollectionList
1111
from pinecone.models.collections.model import CollectionModel
1212

@@ -57,7 +57,9 @@ async def create(self, *, name: str, source: str) -> CollectionModel:
5757
A CollectionModel describing the created collection.
5858
5959
Raises:
60-
ValidationError: If *name* or *source* is empty.
60+
ValidationError: If *name* is empty, longer than 45 characters, contains
61+
characters outside ``[a-z0-9-]``, or starts/ends with a hyphen.
62+
Also raised if *source* is empty.
6163
6264
Examples:
6365
@@ -66,7 +68,7 @@ async def create(self, *, name: str, source: str) -> CollectionModel:
6668
col = await pc.collections.create(name="my-collection", source="my-index")
6769
print(col.status)
6870
"""
69-
require_non_empty("name", name)
71+
require_valid_resource_name("name", name)
7072
require_non_empty("source", source)
7173
logger.info("Creating collection %r from source %r", name, source)
7274
response = await self._http.post("/collections", json={"name": name, "source": source})

‎pinecone/client/collections.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from typing import TYPE_CHECKING
77

88
from pinecone._internal.adapters.collections_adapter import CollectionsAdapter
9-
from pinecone._internal.validation import require_non_empty
9+
from pinecone._internal.validation import require_non_empty, require_valid_resource_name
1010
from pinecone.models.collections.list import CollectionList
1111
from pinecone.models.collections.model import CollectionModel
1212

@@ -56,7 +56,9 @@ def create(self, *, name: str, source: str) -> CollectionModel:
5656
A CollectionModel describing the created collection.
5757
5858
Raises:
59-
ValidationError: If *name* or *source* is empty.
59+
ValidationError: If *name* is empty, longer than 45 characters, contains
60+
characters outside ``[a-z0-9-]``, or starts/ends with a hyphen.
61+
Also raised if *source* is empty.
6062
ApiError: If the API returns an error response (e.g. authentication
6163
failure or server error).
6264
@@ -65,7 +67,7 @@ def create(self, *, name: str, source: str) -> CollectionModel:
6567
>>> col.status # doctest: +SKIP
6668
'Initializing'
6769
"""
68-
require_non_empty("name", name)
70+
require_valid_resource_name("name", name)
6971
require_non_empty("source", source)
7072
logger.info("Creating collection %r from source %r", name, source)
7173
response = self._http.post("/collections", json={"name": name, "source": source})

‎tests/integration/test_client.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import respx
1111

1212
from pinecone import Pinecone
13+
from pinecone.errors.exceptions import ValidationError
1314
from pinecone.models.backups.model import CreateIndexFromBackupResponse
1415
from tests.factories import make_index_response
1516

@@ -251,3 +252,27 @@ def test_create_byoc_index_method_schema_forwarded() -> None:
251252

252253
sent_body = json.loads(route.calls[0].request.content)
253254
assert sent_body["spec"]["byoc"]["schema"] == {"genre": {"type": "str"}}
255+
256+
257+
# ---------------------------------------------------------------------------
258+
# Collection name validation
259+
# ---------------------------------------------------------------------------
260+
261+
262+
@pytest.mark.parametrize(
263+
"invalid_name",
264+
[
265+
"INVALID_NAME!",
266+
"MY_COLLECTION",
267+
"-leading",
268+
"trailing-",
269+
"a" * 46,
270+
"underscore_name",
271+
"test@name",
272+
],
273+
)
274+
def test_create_collection_invalid_name(invalid_name: str) -> None:
275+
"""Collections.create raises ValidationError for invalid names before any network call."""
276+
pc = Pinecone(api_key="test-key")
277+
with pytest.raises(ValidationError):
278+
pc.collections.create(name=invalid_name, source="fake-index")

‎tests/integration/test_client_async.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import pytest
66

77
from pinecone import AsyncPinecone
8+
from pinecone.errors.exceptions import ValidationError
89

910

1011
@pytest.mark.integration
@@ -21,3 +22,27 @@ async def test_async_client_init_with_api_key(async_client: AsyncPinecone) -> No
2122

2223
assert isinstance(__version__, str)
2324
assert len(__version__) > 0
25+
26+
27+
# ---------------------------------------------------------------------------
28+
# Collection name validation
29+
# ---------------------------------------------------------------------------
30+
31+
32+
@pytest.mark.parametrize(
33+
"invalid_name",
34+
[
35+
"INVALID_NAME!",
36+
"MY_COLLECTION",
37+
"-leading",
38+
"trailing-",
39+
"a" * 46,
40+
"underscore_name",
41+
"test@name",
42+
],
43+
)
44+
async def test_create_collection_invalid_name_async(invalid_name: str) -> None:
45+
"""AsyncCollections.create raises ValidationError for invalid names before any network call."""
46+
pc = AsyncPinecone(api_key="test-key")
47+
with pytest.raises(ValidationError):
48+
await pc.collections.create(name=invalid_name, source="fake-index")
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Unit tests for require_valid_resource_name validation helper."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from pinecone._internal.validation import require_valid_resource_name
8+
from pinecone.errors.exceptions import ValidationError
9+
10+
11+
def test_valid_name_lowercase_alphanumeric() -> None:
12+
require_valid_resource_name("name", "mycollection")
13+
14+
15+
def test_valid_name_with_hyphens() -> None:
16+
require_valid_resource_name("name", "my-collection-2025")
17+
18+
19+
def test_valid_name_max_length() -> None:
20+
require_valid_resource_name("name", "a" * 45)
21+
22+
23+
def test_valid_name_single_char() -> None:
24+
require_valid_resource_name("name", "a")
25+
26+
27+
def test_empty_string_raises() -> None:
28+
with pytest.raises(ValidationError, match="non-empty"):
29+
require_valid_resource_name("name", "")
30+
31+
32+
def test_whitespace_only_raises() -> None:
33+
with pytest.raises(ValidationError, match="non-empty"):
34+
require_valid_resource_name("name", " ")
35+
36+
37+
def test_too_long_raises() -> None:
38+
with pytest.raises(ValidationError, match="too long"):
39+
require_valid_resource_name("name", "a" * 46)
40+
41+
42+
def test_leading_hyphen_raises() -> None:
43+
with pytest.raises(ValidationError, match="must not start with a hyphen"):
44+
require_valid_resource_name("name", "-leading")
45+
46+
47+
def test_trailing_hyphen_raises() -> None:
48+
with pytest.raises(ValidationError, match="must not end with a hyphen"):
49+
require_valid_resource_name("name", "trailing-")
50+
51+
52+
def test_uppercase_raises() -> None:
53+
with pytest.raises(ValidationError, match="invalid characters"):
54+
require_valid_resource_name("name", "MY_COLLECTION")
55+
56+
57+
def test_underscore_raises() -> None:
58+
with pytest.raises(ValidationError, match="invalid characters"):
59+
require_valid_resource_name("name", "under_score")
60+
61+
62+
def test_at_symbol_raises() -> None:
63+
with pytest.raises(ValidationError, match="invalid characters"):
64+
require_valid_resource_name("name", "test@name")
65+
66+
67+
def test_space_raises() -> None:
68+
with pytest.raises(ValidationError, match="invalid characters"):
69+
require_valid_resource_name("name", "my collection")
70+
71+
72+
def test_error_message_includes_param_name() -> None:
73+
with pytest.raises(ValidationError) as exc_info:
74+
require_valid_resource_name("collection_name", "INVALID!")
75+
assert "collection_name" in str(exc_info.value)

0 commit comments

Comments
 (0)