Skip to content

Commit 701f6b6

Browse files
authored
Wire up plugin interface (#353)
## Problem We need a way to develop, test, demo, version and release features when they are in a preview or pre-release state independent from the stable core sdk. New features should only enter the sdk core when rapid refinement and iteration has tapered off and we're ready to stabilize the feature. This will help us innovate and ship faster. ## Solution We have implemented a plugin interface that allows us to extend the `Pinecone` class with experimental features defined elsewhere. For now, this plugin interface is only intended for internal use at Pinecone. There are three pieces involved here: the changes in this diff, a small separate package called `pinecone_plugin_interface`, and the plugin implementations themself. Currently, there are no publicly available plugins. The `Pinecone` class here imports a `load_and_install` function from the `pinecone_plugin_interface` package. When invoked, this function scans the user's environment looking for packages that have been implemented within a namespace package called `pinecone_plugins`. If it finds any, it will use the `client_builder` function supplied by the `Pinecone` class to pass user configuration into the plugin for consistent handling of configurations such as api keys, proxy settings, etc. Although the amount of code involved in `pinecone_plugin_interface` is quite meager, externalizing it should give some flexibility to share code between the sdk and plugin implementations without creating a circular dependency should we ever wish to include a plugin as a dependency of the sdk (after functionality has stabilized). I have been defensive here by wrapping the plugin installation in a try/catch. I want to be confident that the mere presence of an invalid or broken plugin cannot break the rest of the sdk. ## Type of Change - [x] New feature (non-breaking change which adds functionality) ## Test Plan Tests should be green. I added some unit tests covering the new builder function stuff. As far as integration testing, I can't really add those here until we have publicly available plugins. But I've done manual testing to gain confidence.
1 parent cab72a1 commit 701f6b6

File tree

8 files changed

+191
-13
lines changed

8 files changed

+191
-13
lines changed

pinecone/control/pinecone.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import time
22
import warnings
3+
import logging
34
from typing import Optional, Dict, Any, Union, List, cast, NamedTuple
45

56
from .index_host_store import IndexHostStore
67

78
from pinecone.config import PineconeConfig, Config, ConfigBuilder
89

910
from pinecone.core.client.api.manage_indexes_api import ManageIndexesApi
10-
from pinecone.utils import normalize_host, setup_openapi_client
11+
from pinecone.core.client.api_client import ApiClient
12+
13+
from pinecone.utils import normalize_host, setup_openapi_client, build_plugin_setup_client
1114
from pinecone.core.client.models import (
1215
CreateCollectionRequest,
1316
CreateIndexRequest,
@@ -20,6 +23,10 @@
2023

2124
from pinecone.data import Index
2225

26+
from pinecone_plugin_interface import load_and_install as install_plugins
27+
28+
logger = logging.getLogger(__name__)
29+
2330
class Pinecone:
2431

2532
def __init__(
@@ -203,11 +210,34 @@ def __init__(
203210
if index_api:
204211
self.index_api = index_api
205212
else:
206-
self.index_api = setup_openapi_client(ManageIndexesApi, self.config, self.openapi_config, pool_threads)
213+
self.index_api = setup_openapi_client(
214+
api_client_klass=ApiClient,
215+
api_klass=ManageIndexesApi,
216+
config=self.config,
217+
openapi_config=self.openapi_config,
218+
pool_threads=pool_threads
219+
)
207220

208221
self.index_host_store = IndexHostStore()
209222
""" @private """
210223

224+
self.load_plugins()
225+
226+
def load_plugins(self):
227+
""" @private """
228+
try:
229+
# I don't expect this to ever throw, but wrapping this in a
230+
# try block just in case to make sure a bad plugin doesn't
231+
# halt client initialization.
232+
openapi_client_builder = build_plugin_setup_client(
233+
config=self.config,
234+
openapi_config=self.openapi_config,
235+
pool_threads=self.pool_threads
236+
)
237+
install_plugins(self, openapi_client_builder)
238+
except Exception as e:
239+
logger.error(f"Error loading plugins: {e}")
240+
211241
def create_index(
212242
self,
213243
name: str,

pinecone/data/index.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,13 @@ def __init__(
8686
)
8787
openapi_config = ConfigBuilder.build_openapi_config(self._config, openapi_config)
8888

89-
self._vector_api = setup_openapi_client(DataPlaneApi, self._config, openapi_config, pool_threads)
89+
self._vector_api = setup_openapi_client(
90+
api_client_klass=ApiClient,
91+
api_klass=DataPlaneApi,
92+
config=self._config,
93+
openapi_config=openapi_config,
94+
pool_threads=pool_threads
95+
)
9096

9197
def __enter__(self):
9298
return self

pinecone/utils/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@
55
from .fix_tuple_length import fix_tuple_length
66
from .convert_to_list import convert_to_list
77
from .normalize_host import normalize_host
8-
from .setup_openapi_client import setup_openapi_client
8+
from .setup_openapi_client import setup_openapi_client, build_plugin_setup_client
99
from .docslinks import docslinks
Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,33 @@
1-
from pinecone.core.client.api_client import ApiClient
21
from .user_agent import get_user_agent
2+
import copy
33

4-
def setup_openapi_client(api_klass, config, openapi_config, pool_threads):
5-
api_client = ApiClient(
4+
def setup_openapi_client(api_client_klass, api_klass, config, openapi_config, pool_threads, api_version=None, **kwargs):
5+
# It is important that we allow the user to pass in a reference to api_client_klass
6+
# instead of creating a direct dependency on ApiClient because plugins have their
7+
# own ApiClient implementations. Even if those implementations seem like they should
8+
# be functionally identical, they are not the same class and have references to
9+
# different copies of the ModelNormal class. Therefore cannot be used interchangeably.
10+
# without breaking the generated client code anywhere it is relying on isinstance to make
11+
# a decision about something.
12+
if kwargs.get("host"):
13+
openapi_config = copy.deepcopy(openapi_config)
14+
openapi_config._base_path = kwargs['host']
15+
16+
api_client = api_client_klass(
617
configuration=openapi_config,
718
pool_threads=pool_threads
819
)
920
api_client.user_agent = get_user_agent(config)
1021
extra_headers = config.additional_headers or {}
1122
for key, value in extra_headers.items():
1223
api_client.set_default_header(key, value)
24+
25+
if api_version:
26+
api_client.set_default_header("X-Pinecone-API-Version", api_version)
1327
client = api_klass(api_client)
1428
return client
29+
30+
def build_plugin_setup_client(config, openapi_config, pool_threads):
31+
def setup_plugin_client(api_client_klass, api_klass, api_version, **kwargs):
32+
return setup_openapi_client(api_client_klass, api_klass, config, openapi_config, pool_threads, api_version, **kwargs)
33+
return setup_plugin_client

poetry.lock

Lines changed: 12 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ googleapis-common-protos = { version = ">=1.53.0", optional = true }
6767
lz4 = { version = ">=3.1.3", optional = true }
6868
protobuf = { version = "^4.25", optional = true }
6969
protoc-gen-openapiv2 = {version = "^0.0.1", optional = true }
70+
pinecone-plugin-interface = "^0.0.7"
7071

7172
[tool.poetry.group.types]
7273
optional = true

tests/unit/test_control.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pytest
22
import re
3+
from unittest.mock import patch
34
from pinecone import ConfigBuilder, Pinecone, PodSpec, ServerlessSpec
45
from pinecone.core.client.models import IndexList, IndexModel
56
from pinecone.core.client.api.manage_indexes_api import ManageIndexesApi
@@ -17,6 +18,18 @@ def index_list_response():
1718
])
1819

1920
class TestControl:
21+
def test_plugins_are_installed(self):
22+
with patch('pinecone.control.pinecone.install_plugins') as mock_install_plugins:
23+
p = Pinecone(api_key='asdf')
24+
mock_install_plugins.assert_called_once()
25+
26+
def test_bad_plugin_doesnt_break_sdk(self):
27+
with patch('pinecone.control.pinecone.install_plugins', side_effect=Exception("bad plugin")):
28+
try:
29+
p = Pinecone(api_key='asdf')
30+
except Exception as e:
31+
assert False, f"Unexpected exception: {e}"
32+
2033
def test_default_host(self):
2134
p = Pinecone(api_key="123-456-789")
2235
assert p.index_api.api_client.configuration.host == "https://api.pinecone.io"
Lines changed: 103 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,109 @@
1+
import pytest
12
import re
23
from pinecone.config import ConfigBuilder
34
from pinecone.core.client.api.manage_indexes_api import ManageIndexesApi
4-
from pinecone.utils.setup_openapi_client import setup_openapi_client
5+
from pinecone.core.client.api_client import ApiClient
6+
from pinecone.utils.setup_openapi_client import setup_openapi_client, build_plugin_setup_client
57

68
class TestSetupOpenAPIClient():
79
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"
10+
config = ConfigBuilder.build(
11+
api_key="my-api-key",
12+
host="https://my-controller-host"
13+
)
14+
openapi_config = ConfigBuilder.build_openapi_config(config)
15+
assert openapi_config.host == "https://my-controller-host"
16+
17+
control_plane_client = setup_openapi_client(ApiClient, ManageIndexesApi, config=config, openapi_config=openapi_config, pool_threads=2)
18+
user_agent_regex = re.compile(r"python-client-\d+\.\d+\.\d+ \(urllib3\:\d+\.\d+\.\d+\)")
19+
assert re.match(user_agent_regex, control_plane_client.api_client.user_agent)
20+
assert re.match(user_agent_regex, control_plane_client.api_client.default_headers['User-Agent'])
21+
22+
def test_setup_openapi_client_with_api_version(self):
23+
config = ConfigBuilder.build(
24+
api_key="my-api-key",
25+
host="https://my-controller-host",
26+
)
27+
openapi_config = ConfigBuilder.build_openapi_config(config)
28+
assert openapi_config.host == "https://my-controller-host"
29+
30+
control_plane_client = setup_openapi_client(ApiClient, ManageIndexesApi, config=config, openapi_config=openapi_config, pool_threads=2, api_version="2024-04")
31+
user_agent_regex = re.compile(r"python-client-\d+\.\d+\.\d+ \(urllib3\:\d+\.\d+\.\d+\)")
32+
assert re.match(user_agent_regex, control_plane_client.api_client.user_agent)
33+
assert re.match(user_agent_regex, control_plane_client.api_client.default_headers['User-Agent'])
34+
assert control_plane_client.api_client.default_headers['X-Pinecone-API-Version'] == "2024-04"
35+
36+
37+
class TestBuildPluginSetupClient():
38+
@pytest.mark.parametrize("plugin_api_version,plugin_host", [
39+
(None, None),
40+
("2024-07", "https://my-plugin-host")
41+
])
42+
def test_setup_openapi_client_with_host_override(self, plugin_api_version, plugin_host):
43+
# These configurations represent the configurations that the core sdk
44+
# (e.g. Pinecone class) will have built prior to invoking the plugin setup.
45+
# In real usage, this takes place during the Pinecone class initialization
46+
# and pulls together configuration from all sources (kwargs and env vars).
47+
# It reflects a merging of the user's configuration and the defaults set
48+
# by the sdk.
49+
config = ConfigBuilder.build(
50+
api_key="my-api-key",
51+
host="https://api.pinecone.io",
52+
source_tag="my_source_tag",
53+
proxy_url="http://my-proxy.com",
54+
ssl_ca_certs="path/to/bundle.pem"
55+
)
56+
openapi_config = ConfigBuilder.build_openapi_config(config)
57+
58+
# The core sdk (e.g. Pinecone class) will be responsible for invoking the
59+
# build_plugin_setup_client method before passing the result to the plugin
60+
# install method. This is
61+
# somewhat like currying the openapi setup function, because we want some
62+
# information to be controled by the core sdk (e.g. the user-agent string,
63+
# proxy settings, etc) while allowing the plugin to pass the parts of the
64+
# configuration that are relevant to it such as api version, base url if
65+
# served from somewhere besides api.pinecone.io, etc.
66+
client_builder = build_plugin_setup_client(config=config, openapi_config=openapi_config, pool_threads=2)
67+
68+
# The plugin machinery in pinecone_plugin_interface will be the one to call
69+
# this client_builder function using classes and other config it discovers inside the
70+
# pinecone_plugin namespace package. Putting plugin configuration and references
71+
# to the implementation classes into a spot where the pinecone_plugin_interface
72+
# can find them is the responsibility of the plugin developer.
73+
#
74+
# Passing ManagedIndexesApi and ApiClient here are just a standin for testing
75+
# purposes; in a real plugin, the class would be something else related
76+
# to a new feature, but to test that this setup works I just need a FooApi
77+
# class generated off the openapi spec.
78+
plugin_api=ManageIndexesApi
79+
plugin_client = client_builder(
80+
api_client_klass=ApiClient,
81+
api_klass=plugin_api,
82+
api_version=plugin_api_version,
83+
host=plugin_host
84+
)
85+
86+
# Returned client is an instance of the input class
87+
assert isinstance(plugin_client, plugin_api)
88+
89+
# We want requests from plugins to have a user-agent matching the host SDK.
90+
user_agent_regex = re.compile(r"python-client-\d+\.\d+\.\d+ \(urllib3\:\d+\.\d+\.\d+\)")
91+
assert re.match(user_agent_regex, plugin_client.api_client.user_agent)
92+
assert re.match(user_agent_regex, plugin_client.api_client.default_headers['User-Agent'])
93+
94+
# User agent still contains the source tag that was set in the sdk config
95+
assert 'my_source_tag' in plugin_client.api_client.default_headers['User-Agent']
96+
97+
# Proxy settings should be passed from the core sdk to the plugin client
98+
assert plugin_client.api_client.configuration.proxy == "http://my-proxy.com"
99+
assert plugin_client.api_client.configuration.ssl_ca_cert == "path/to/bundle.pem"
100+
101+
# Plugins need to be able to pass their own API version (optionally)
102+
assert plugin_client.api_client.default_headers.get('X-Pinecone-API-Version') == plugin_api_version
103+
104+
# Plugins need to be able to override the host (optionally)
105+
if plugin_host:
106+
assert plugin_client.api_client.configuration._base_path == plugin_host
107+
else:
108+
# When plugin does not set a host, it should default to the host set in the core sdk
109+
assert plugin_client.api_client.configuration._base_path == "https://api.pinecone.io"

0 commit comments

Comments
 (0)