Skip to content

Commit e49d4c2

Browse files
authored
Add async Python client (#83)
* Add async Python client * Deduplicate Python gRPC connection helpers
1 parent 363055b commit e49d4c2

10 files changed

Lines changed: 540 additions & 40 deletions

File tree

‎client/python/USAGE.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,24 @@ The client supports usage as a context manager, which automatically closes the u
3939
Example available in:
4040
```examples/context_manager_usage.py```
4141

42+
### Async Client Support
43+
44+
For async applications, use `AsyncVortexDB`. It mirrors the synchronous client API and uses `grpc.aio` under the hood.
45+
46+
Example available in:
47+
```examples/async_usage.py```
48+
49+
```python
50+
async with AsyncVortexDB(
51+
grpc_url="localhost:50051",
52+
api_key="your-api-key",
53+
) as db:
54+
point_id = await db.insert(
55+
vector=DenseVector([0.1, 0.2, 0.3]),
56+
payload=Payload.text("hello async vortex"),
57+
)
58+
```
59+
4260
---
4361

4462
## Client API
@@ -47,6 +65,25 @@ Example available in:
4765

4866
Main client class for interacting with the VortexDB gRPC server.
4967

68+
### `AsyncVortexDB`
69+
70+
Async client class for I/O-heavy applications. It has the same constructor and method names as `VortexDB`, but methods are awaitable:
71+
72+
```
73+
await db.insert(...)
74+
await db.get(...)
75+
await db.search(...)
76+
await db.delete(...)
77+
await db.close()
78+
```
79+
80+
It also supports async context manager usage:
81+
82+
```
83+
async with AsyncVortexDB(...) as db:
84+
...
85+
```
86+
5087
#### **Constructor**
5188

5289
```
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import asyncio
2+
3+
from vortexdb import AsyncVortexDB, DenseVector, Payload, Similarity
4+
5+
6+
async def main():
7+
async with AsyncVortexDB(
8+
grpc_url="localhost:50051",
9+
api_key="your-api-key",
10+
) as db:
11+
point_id = await db.insert(
12+
vector=DenseVector([0.1, 0.2, 0.3]),
13+
payload=Payload.text("hello async vortex"),
14+
)
15+
16+
point = await db.get(point_id=point_id)
17+
if point is not None:
18+
print(point.pretty())
19+
20+
results = await db.search(
21+
vector=DenseVector([0.1, 0.2, 0.3]),
22+
similarity=Similarity.COSINE,
23+
limit=5,
24+
)
25+
print(results)
26+
27+
await db.delete(point_id=point_id)
28+
29+
30+
if __name__ == "__main__":
31+
asyncio.run(main())
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import asyncio
2+
from unittest.mock import AsyncMock, Mock
3+
4+
import pytest
5+
6+
from vortexdb.async_client import AsyncVortexDB
7+
from vortexdb.async_connection import AsyncGRPCConnection
8+
from vortexdb.models import ContentType, DenseVector, Payload, Point, Similarity
9+
10+
11+
@pytest.fixture
12+
def mock_connection(monkeypatch):
13+
"""
14+
Replace AsyncGRPCConnection with a mock instance.
15+
"""
16+
conn = Mock(spec=AsyncGRPCConnection)
17+
conn.stub = Mock()
18+
conn.call = AsyncMock()
19+
conn.close = AsyncMock()
20+
monkeypatch.setattr("vortexdb.async_client.AsyncGRPCConnection", lambda _: conn)
21+
return conn
22+
23+
24+
@pytest.fixture
25+
def client(mock_connection):
26+
return AsyncVortexDB(
27+
grpc_url="localhost:50051",
28+
api_key="secret",
29+
)
30+
31+
32+
def test_async_insert_success(client, mock_connection):
33+
async def run():
34+
response = Mock()
35+
response.id = Mock()
36+
response.id.value = "point-123"
37+
38+
mock_connection.call.return_value = response
39+
40+
point_id = await client.insert(
41+
vector=DenseVector([1, 2, 3]),
42+
payload=Payload.text("hello"),
43+
)
44+
45+
assert point_id == "point-123"
46+
47+
asyncio.run(run())
48+
49+
50+
def test_async_insert_rejects_invalid_vector(client):
51+
async def run():
52+
with pytest.raises(TypeError):
53+
await client.insert(
54+
vector=[1, 2, 3],
55+
payload=Payload.text("hello"),
56+
)
57+
58+
asyncio.run(run())
59+
60+
61+
def test_async_get_point_success(client, mock_connection):
62+
async def run():
63+
proto_point = Mock()
64+
proto_point.id.id.value = "point-123"
65+
proto_point.vector.values = [1, 2, 3]
66+
proto_point.payload.content_type = ContentType.TEXT.to_proto()
67+
proto_point.payload.content = "hello"
68+
69+
mock_connection.call.return_value = proto_point
70+
71+
point = await client.get(point_id="point-123")
72+
73+
assert isinstance(point, Point)
74+
assert point.id == "point-123"
75+
assert point.payload.content == "hello"
76+
77+
asyncio.run(run())
78+
79+
80+
def test_async_get_point_not_found(client, mock_connection):
81+
async def run():
82+
mock_connection.call.return_value = None
83+
84+
result = await client.get(point_id="missing")
85+
86+
assert result is None
87+
88+
asyncio.run(run())
89+
90+
91+
def test_async_delete_success(client, mock_connection):
92+
async def run():
93+
mock_connection.call.return_value = None
94+
95+
await client.delete(point_id="point-123")
96+
97+
mock_connection.call.assert_awaited_once()
98+
99+
asyncio.run(run())
100+
101+
102+
def test_async_search_success(client, mock_connection):
103+
async def run():
104+
mock_connection.call.return_value = Mock(
105+
result_point_ids=[
106+
Mock(id=Mock(value="p1")),
107+
Mock(id=Mock(value="p2")),
108+
]
109+
)
110+
111+
results = await client.search(
112+
vector=DenseVector([1, 2, 3]),
113+
similarity=Similarity.COSINE,
114+
limit=2,
115+
)
116+
117+
assert results == ["p1", "p2"]
118+
119+
asyncio.run(run())
120+
121+
122+
def test_async_search_invalid_vector(client):
123+
async def run():
124+
with pytest.raises(TypeError):
125+
await client.search(
126+
vector=[1, 2, 3],
127+
similarity=Similarity.COSINE,
128+
limit=2,
129+
)
130+
131+
asyncio.run(run())
132+
133+
134+
def test_async_close_closes_connection(client, mock_connection):
135+
async def run():
136+
await client.close()
137+
mock_connection.close.assert_awaited_once()
138+
139+
asyncio.run(run())
140+
141+
142+
def test_async_context_manager_closes_connection(monkeypatch):
143+
async def run():
144+
conn = Mock(spec=AsyncGRPCConnection)
145+
conn.stub = Mock()
146+
conn.call = AsyncMock()
147+
conn.close = AsyncMock()
148+
monkeypatch.setattr("vortexdb.async_client.AsyncGRPCConnection", lambda _: conn)
149+
150+
async with AsyncVortexDB(
151+
grpc_url="localhost:50051",
152+
api_key="secret",
153+
) as db:
154+
assert db is not None
155+
156+
conn.close.assert_awaited_once()
157+
158+
asyncio.run(run())
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import asyncio
2+
from unittest.mock import AsyncMock, Mock, patch
3+
4+
import grpc
5+
6+
from vortexdb._grpc_common import map_grpc_error
7+
from vortexdb.async_connection import AsyncGRPCConnection
8+
from vortexdb.config import VortexDBConfig
9+
from vortexdb.exceptions import (
10+
AuthenticationError,
11+
InternalServerError,
12+
InvalidArgumentError,
13+
NotFoundError,
14+
ServiceUnavailableError,
15+
TimeoutError,
16+
)
17+
18+
19+
class FakeAioRpcError:
20+
"""
21+
Minimal AioRpcError-compatible object for mapping tests.
22+
"""
23+
24+
def __init__(self, status_code: grpc.StatusCode, details: str):
25+
self._status_code = status_code
26+
self._details = details
27+
28+
def code(self):
29+
return self._status_code
30+
31+
def details(self):
32+
return self._details
33+
34+
35+
def make_config() -> VortexDBConfig:
36+
return VortexDBConfig(
37+
grpc_url="localhost:50051",
38+
api_key="secret",
39+
timeout=3.0,
40+
)
41+
42+
43+
def test_async_channel_created_with_correct_url():
44+
with patch("grpc.aio.insecure_channel") as mock_channel:
45+
mock_channel.return_value = Mock()
46+
AsyncGRPCConnection(make_config())
47+
mock_channel.assert_called_once_with("localhost:50051")
48+
49+
50+
def test_async_metadata_is_attached():
51+
with patch("grpc.aio.insecure_channel") as mock_channel:
52+
mock_channel.return_value = Mock()
53+
connection = AsyncGRPCConnection(make_config())
54+
55+
assert ("authorization", "Bearer secret") in connection._metadata
56+
57+
58+
def test_successful_async_rpc_call():
59+
async def run():
60+
with patch("grpc.aio.insecure_channel") as mock_channel:
61+
mock_channel.return_value = Mock()
62+
connection = AsyncGRPCConnection(make_config())
63+
64+
fake_rpc = AsyncMock(return_value="ok")
65+
66+
result = await connection.call(fake_rpc, request="req")
67+
68+
fake_rpc.assert_awaited_once_with(
69+
"req",
70+
timeout=3.0,
71+
metadata=connection._metadata,
72+
)
73+
assert result == "ok"
74+
75+
asyncio.run(run())
76+
77+
78+
def test_async_grpc_error_mapping():
79+
cases = [
80+
(grpc.StatusCode.UNAUTHENTICATED, AuthenticationError),
81+
(grpc.StatusCode.NOT_FOUND, NotFoundError),
82+
(grpc.StatusCode.INVALID_ARGUMENT, InvalidArgumentError),
83+
(grpc.StatusCode.DEADLINE_EXCEEDED, TimeoutError),
84+
(grpc.StatusCode.UNAVAILABLE, ServiceUnavailableError),
85+
(grpc.StatusCode.UNKNOWN, InternalServerError),
86+
]
87+
88+
for status_code, expected_exception in cases:
89+
error = FakeAioRpcError(status_code, "boom")
90+
mapped = map_grpc_error(error)
91+
assert isinstance(mapped, expected_exception)
92+
93+
94+
def test_async_close_closes_channel():
95+
async def run():
96+
with patch("grpc.aio.insecure_channel") as mock_channel:
97+
channel = Mock()
98+
channel.close = AsyncMock()
99+
mock_channel.return_value = channel
100+
connection = AsyncGRPCConnection(make_config())
101+
102+
await connection.close()
103+
104+
channel.close.assert_awaited_once()
105+
106+
asyncio.run(run())

‎client/python/tests/test_connection.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import pytest
33
from unittest.mock import Mock, patch
44

5+
from vortexdb._grpc_common import map_grpc_error
56
from vortexdb.connection import GRPCConnection
67
from vortexdb.config import VortexDBConfig
78
from vortexdb.exceptions import (
@@ -89,18 +90,17 @@ def test_successful_rpc_call(connection):
8990
)
9091
def test_grpc_error_mapping(status_code, expected_exception, connection):
9192
error = FakeRpcError(status_code, "boom")
92-
fake_rpc = Mock(side_effect=error)
9393

94-
with pytest.raises(expected_exception):
95-
connection.call(fake_rpc, request="req")
94+
mapped = map_grpc_error(error)
95+
96+
assert isinstance(mapped, expected_exception)
9697

9798

9899
def test_unknown_grpc_error_maps_to_internal_error(connection):
99100
error = FakeRpcError(grpc.StatusCode.UNKNOWN, "unknown")
100-
fake_rpc = Mock(side_effect=error)
101+
mapped = map_grpc_error(error)
101102

102-
with pytest.raises(InternalServerError):
103-
connection.call(fake_rpc, request="req")
103+
assert isinstance(mapped, InternalServerError)
104104

105105

106106
# Clean connection closure test

‎client/python/vortexdb/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# vortexdb/__init__.py
22

33
from vortexdb.client import VortexDB
4+
from vortexdb.async_client import AsyncVortexDB
45
from vortexdb.models import (
56
DenseVector,
67
Payload,
@@ -19,6 +20,7 @@
1920

2021
__all__ = [
2122
"VortexDB",
23+
"AsyncVortexDB",
2224
"DenseVector",
2325
"Payload",
2426
"Point",

0 commit comments

Comments
 (0)