Skip to content

Commit 4fd2d20

Browse files
authored
Allow clients to tag requests with a source_tag (#324)
## Problem Need to allow clients to include a `source_tag` to identify the source of their requests. ## Solution Allow clients to specify a `source_tag` field in the client constructor, that will be used to identify the traffic source, if applicable. Example: ```python from pinecone import Pinecone pc = Pinecone(api_key='foo', source_tag='bar') pc.list_indexes() ``` This would cause the user-agent to get a value like: ``` User-Agent: 'python-client-3.1.0 (urllib3:2.0.7); source_tag=bar' ``` ## Type of Change - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) ## Testing - [ ] Tests are passing - [ ] Verify source_tag included in user-agent in control plane and data plane (REST and gRPC)
1 parent ed8c2ab commit 4fd2d20

13 files changed

+137
-22
lines changed

pinecone/config/config.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from pinecone.config.openapi import OpenApiConfigFactory
66
from pinecone.core.client.configuration import Configuration as OpenApiConfiguration
77
from pinecone.utils import normalize_host
8+
from pinecone.utils.constants import SOURCE_TAG
89

910
class Config(NamedTuple):
1011
api_key: str = ""
@@ -14,6 +15,7 @@ class Config(NamedTuple):
1415
ssl_ca_certs: Optional[str] = None
1516
ssl_verify: Optional[bool] = None
1617
additional_headers: Optional[Dict[str, str]] = {}
18+
source_tag: Optional[str] = None
1719

1820
class ConfigBuilder:
1921
"""
@@ -46,13 +48,14 @@ def build(
4648
api_key = api_key or kwargs.pop("api_key", None) or os.getenv("PINECONE_API_KEY")
4749
host = host or kwargs.pop("host", None)
4850
host = normalize_host(host)
51+
source_tag = kwargs.pop(SOURCE_TAG, None)
4952

5053
if not api_key:
5154
raise PineconeConfigurationError("You haven't specified an Api-Key.")
5255
if not host:
5356
raise PineconeConfigurationError("You haven't specified a host.")
5457

55-
return Config(api_key, host, proxy_url, proxy_headers, ssl_ca_certs, ssl_verify, additional_headers)
58+
return Config(api_key, host, proxy_url, proxy_headers, ssl_ca_certs, ssl_verify, additional_headers, source_tag)
5659

5760
@staticmethod
5861
def build_openapi_config(

pinecone/control/pinecone.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,5 +633,6 @@ def Index(self, name: str = '', host: str = '', **kwargs):
633633
api_key=api_key,
634634
pool_threads=pt,
635635
openapi_config=openapi_config,
636+
source_tag=self.config.source_tag,
636637
**kwargs
637638
)

pinecone/grpc/base.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from .utils import _generate_request_id
1515
from .config import GRPCClientConfig
1616
from pinecone.utils.constants import MAX_MSG_SIZE, REQUEST_ID, CLIENT_VERSION
17+
from pinecone.utils.user_agent import get_user_agent_grpc
1718
from pinecone.exceptions import PineconeException
1819

1920
_logger = logging.getLogger(__name__)
@@ -77,7 +78,8 @@ def __init__(
7778
}
7879
)
7980

80-
self._channel = channel or self._gen_channel()
81+
options = {"grpc.primary_user_agent": get_user_agent_grpc(config)}
82+
self._channel = channel or self._gen_channel(options=options)
8183
self.stub = self.stub_class(self._channel)
8284

8385
@property

pinecone/grpc/pinecone.py

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -120,13 +120,10 @@ def Index(self, name: str = '', host: str = '', **kwargs):
120120
if name == '' and host == '':
121121
raise ValueError("Either name or host must be specified")
122122

123-
if host != '':
124-
# Use host if it is provided
125-
config = ConfigBuilder.build(api_key=self.config.api_key, host=host)
126-
return GRPCIndex(index_name=name, config=config, **kwargs)
127-
128-
if name != '':
129-
# Otherwise, get host url from describe_index using the index name
130-
index_host = self.index_host_store.get_host(self.index_api, self.config, name)
131-
config = ConfigBuilder.build(api_key=self.config.api_key, host=index_host)
132-
return GRPCIndex(index_name=name, config=config, **kwargs)
123+
# Use host if it is provided, otherwise get host from describe_index
124+
index_host = host or self.index_host_store.get_host(self.index_api, self.config, name)
125+
126+
config = ConfigBuilder.build(api_key=self.config.api_key,
127+
host=index_host,
128+
source_tag=self.config.source_tag)
129+
return GRPCIndex(index_name=name, config=config, **kwargs)

pinecone/utils/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,5 @@ class NodeType(str, enum.Enum):
2828

2929
REQUIRED_VECTOR_FIELDS = {"id", "values"}
3030
OPTIONAL_VECTOR_FIELDS = {"sparse_values", "metadata"}
31+
32+
SOURCE_TAG = "source_tag"

pinecone/utils/setup_openapi_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ def setup_openapi_client(api_klass, config, openapi_config, pool_threads):
66
configuration=openapi_config,
77
pool_threads=pool_threads
88
)
9-
api_client.user_agent = get_user_agent()
9+
api_client.user_agent = get_user_agent(config)
1010
extra_headers = config.additional_headers or {}
1111
for key, value in extra_headers.items():
1212
api_client.set_default_header(key, value)

pinecone/utils/user_agent.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,29 @@
11
import urllib3
22

33
from .version import __version__
4+
from .constants import SOURCE_TAG
5+
import re
46

5-
def get_user_agent():
6-
client_id = f"python-client-{__version__}"
7+
def _build_source_tag_field(source_tag):
8+
# normalize source tag
9+
# 1. Lowercase
10+
# 2. Limit charset to [a-z0-9_ ]
11+
# 3. Trim left/right whitespace
12+
# 4. Condense multiple spaces to one, and replace with underscore
13+
tag = source_tag.lower()
14+
tag = re.sub(r'[^a-z0-9_ ]', '', tag)
15+
tag = tag.strip()
16+
tag = "_".join(tag.split())
17+
return f"{SOURCE_TAG}={tag}"
18+
19+
def _get_user_agent(client_id, config):
720
user_agent_details = {"urllib3": urllib3.__version__}
821
user_agent = "{} ({})".format(client_id, ", ".join([f"{k}:{v}" for k, v in user_agent_details.items()]))
9-
return user_agent
22+
user_agent += f"; {_build_source_tag_field(config.source_tag)}" if config.source_tag else ""
23+
return user_agent
24+
25+
def get_user_agent(config):
26+
return _get_user_agent(f"python-client-{__version__}", config)
27+
28+
def get_user_agent_grpc(config):
29+
return _get_user_agent(f"python-client[grpc]-{__version__}", config)

tests/unit/test_config_builder.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,17 @@ def test_build_merges_key_and_host_when_openapi_config_provided(self):
2121
assert config.host == "https://my-controller-host"
2222
assert config.additional_headers == {}
2323

24+
def test_build_with_source_tag(self):
25+
config = ConfigBuilder.build(
26+
api_key="my-api-key",
27+
host="https://my-controller-host",
28+
source_tag="my-source-tag",
29+
)
30+
assert config.api_key == "my-api-key"
31+
assert config.host == "https://my-controller-host"
32+
assert config.additional_headers == {}
33+
assert config.source_tag == "my-source-tag"
34+
2435
def test_build_errors_when_no_api_key_is_present(self):
2536
with pytest.raises(PineconeConfigurationError) as e:
2637
ConfigBuilder.build()

tests/unit/test_control.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pytest
2-
from pinecone import Pinecone, PodSpec, ServerlessSpec
2+
import re
3+
from pinecone import ConfigBuilder, Pinecone, PodSpec, ServerlessSpec
34
from pinecone.core.client.models import IndexList, IndexModel
45
from pinecone.core.client.api.manage_indexes_api import ManageIndexesApi
56
from pinecone.core.client.configuration import Configuration as OpenApiConfiguration
@@ -41,6 +42,15 @@ def test_overwrite_useragent(self):
4142
assert p.index_api.api_client.default_headers['User-Agent'] == 'test-user-agent'
4243
assert len(p.index_api.api_client.default_headers) == 1
4344

45+
def test_set_source_tag_in_useragent(self):
46+
p = Pinecone(api_key="123-456-789", source_tag="test_source_tag")
47+
assert re.search(r"source_tag=test_source_tag", p.index_api.api_client.user_agent) is not None
48+
49+
def test_set_source_tag_in_useragent_via_config(self):
50+
config = ConfigBuilder.build(api_key='YOUR_API_KEY', host='https://my-host', source_tag='my_source_tag')
51+
p = Pinecone(config=config)
52+
assert re.search(r"source_tag=my_source_tag", p.index_api.api_client.user_agent) is not None
53+
4454
@pytest.mark.parametrize("timeout_value, describe_index_responses, expected_describe_index_calls, expected_sleep_calls", [
4555
# When timeout=None, describe_index is called until ready
4656
(None, [{ "status": {"ready": False}}, {"status": {"ready": True}}], 2, 1),

tests/unit/test_index_initialization.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pytest
2-
from pinecone import Pinecone
2+
import re
3+
from pinecone import ConfigBuilder, Pinecone
34

45
class TestIndexClientInitialization():
56
@pytest.mark.parametrize(
@@ -50,4 +51,15 @@ def test_overwrite_useragent(self):
5051
)
5152
assert len(index._vector_api.api_client.default_headers) == 1
5253
assert 'User-Agent' in index._vector_api.api_client.default_headers
53-
assert index._vector_api.api_client.default_headers['User-Agent'] == 'test-user-agent'
54+
assert index._vector_api.api_client.default_headers['User-Agent'] == 'test-user-agent'
55+
56+
def test_set_source_tag(self):
57+
pc = Pinecone(api_key="123-456-789", source_tag="test_source_tag")
58+
index = pc.Index(host='myhost')
59+
assert re.search(r"source_tag=test_source_tag", pc.index_api.api_client.user_agent) is not None
60+
61+
def test_set_source_tag_via_config(self):
62+
config = ConfigBuilder.build(api_key='YOUR_API_KEY', host='https://my-host', source_tag='my_source_tag')
63+
pc = Pinecone(config=config)
64+
index = pc.Index(host='myhost')
65+
assert re.search(r"source_tag=my_source_tag", pc.index_api.api_client.user_agent) is not None
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import re
2+
from pinecone.config import ConfigBuilder
3+
from pinecone.core.client.api.manage_indexes_api import ManageIndexesApi
4+
from pinecone.utils.setup_openapi_client import setup_openapi_client
5+
6+
class TestSetupOpenAPIClient():
7+
def test_setup_openapi_client(self):
8+
""
9+
# config = ConfigBuilder.build(api_key="my-api-key", host="https://my-controller-host")
10+
# api_client = setup_openapi_client(ManageIndexesApi, config=config, pool_threads=2)
11+
# # assert api_client.user_agent == "pinecone-python-client/0.0.1"

tests/unit/utils/test_user_agent.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,47 @@
11
import re
2-
from pinecone.utils.user_agent import get_user_agent
2+
from pinecone.utils.user_agent import get_user_agent, get_user_agent_grpc
3+
from pinecone.config import ConfigBuilder
34

45
class TestUserAgent():
56
def test_user_agent(self):
6-
useragent = get_user_agent()
7+
config = ConfigBuilder.build(api_key="my-api-key", host="https://my-controller-host")
8+
useragent = get_user_agent(config)
79
assert re.search(r"python-client-\d+\.\d+\.\d+", useragent) is not None
8-
assert re.search(r"urllib3:\d+\.\d+\.\d+", useragent) is not None
10+
assert re.search(r"urllib3:\d+\.\d+\.\d+", useragent) is not None
11+
12+
def test_user_agent_with_source_tag(self):
13+
config = ConfigBuilder.build(api_key="my-api-key", host="https://my-controller-host", source_tag="my_source_tag")
14+
useragent = get_user_agent(config)
15+
assert re.search(r"python-client-\d+\.\d+\.\d+", useragent) is not None
16+
assert re.search(r"urllib3:\d+\.\d+\.\d+", useragent) is not None
17+
assert re.search(r"source_tag=my_source_tag", useragent) is not None
18+
19+
def test_source_tag_is_normalized(self):
20+
config = ConfigBuilder.build(api_key="my-api-key", host="https://my-controller-host", source_tag="my source tag!!!!")
21+
useragent = get_user_agent(config)
22+
assert re.search(r"source_tag=my_source_tag", useragent) is not None
23+
24+
config = ConfigBuilder.build(api_key="my-api-key", host="https://my-controller-host", source_tag="My Source Tag")
25+
useragent = get_user_agent(config)
26+
assert re.search(r"source_tag=my_source_tag", useragent) is not None
27+
28+
config = ConfigBuilder.build(api_key="my-api-key", host="https://my-controller-host", source_tag=" My Source Tag 123 ")
29+
useragent = get_user_agent(config)
30+
assert re.search(r"source_tag=my_source_tag_123", useragent) is not None
31+
32+
config = ConfigBuilder.build(api_key="my-api-key", host="https://my-controller-host", source_tag=" My Source Tag 123 #### !! ")
33+
useragent = get_user_agent(config)
34+
assert re.search(r"source_tag=my_source_tag_123", useragent) is not None
35+
36+
def test_user_agent_grpc(self):
37+
config = ConfigBuilder.build(api_key="my-api-key", host="https://my-controller-host")
38+
useragent = get_user_agent_grpc(config)
39+
assert re.search(r"python-client\[grpc\]-\d+\.\d+\.\d+", useragent) is not None
40+
assert re.search(r"urllib3:\d+\.\d+\.\d+", useragent) is not None
41+
42+
def test_user_agent_grpc_with_source_tag(self):
43+
config = ConfigBuilder.build(api_key="my-api-key", host="https://my-controller-host", source_tag="my_source_tag")
44+
useragent = get_user_agent_grpc(config)
45+
assert re.search(r"python-client\[grpc\]-\d+\.\d+\.\d+", useragent) is not None
46+
assert re.search(r"urllib3:\d+\.\d+\.\d+", useragent) is not None
47+
assert re.search(r"source_tag=my_source_tag", useragent) is not None

tests/unit_grpc/test_grpc_index_initialization.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import re
12
from pinecone.grpc import PineconeGRPC, GRPCClientConfig
3+
from pinecone import ConfigBuilder
24

35
class TestGRPCIndexInitialization:
46
def test_init_with_default_config(self):
@@ -85,3 +87,8 @@ def test_config_passed_when_target_by_host(self):
8587
# Unset fields still get default values
8688
assert index.grpc_client_config.reuse_channel == True
8789
assert index.grpc_client_config.conn_timeout == 1
90+
91+
def test_config_passes_source_tag_when_set(self):
92+
pc = PineconeGRPC(api_key='YOUR_API_KEY', source_tag='my_source_tag')
93+
index = pc.Index(name='my-index', host='host')
94+
assert re.search(r"source_tag=my_source_tag", pc.index_api.api_client.user_agent) is not None

0 commit comments

Comments
 (0)