kachedb 0.1.0a2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kachedb/__init__.py +71 -0
- kachedb/_version.py +3 -0
- kachedb/async_client.py +169 -0
- kachedb/client.py +216 -0
- kachedb/connection.py +143 -0
- kachedb/descriptor.py +120 -0
- kachedb/exceptions.py +25 -0
- kachedb/pipeline.py +213 -0
- kachedb/pool.py +229 -0
- kachedb/py.typed +0 -0
- kachedb/resp.py +252 -0
- kachedb/tensor.py +196 -0
- kachedb-0.1.0a2.dist-info/METADATA +250 -0
- kachedb-0.1.0a2.dist-info/RECORD +17 -0
- kachedb-0.1.0a2.dist-info/WHEEL +4 -0
- kachedb-0.1.0a2.dist-info/licenses/LICENSE-APACHE +176 -0
- kachedb-0.1.0a2.dist-info/licenses/LICENSE-MIT +21 -0
kachedb/__init__.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""
|
|
2
|
+
KacheDB — Python client for the KacheDB zero-copy storage engine.
|
|
3
|
+
|
|
4
|
+
Install::
|
|
5
|
+
|
|
6
|
+
pip install kachedb
|
|
7
|
+
pip install kachedb[torch] # for PyTorch tensor support
|
|
8
|
+
|
|
9
|
+
Quickstart::
|
|
10
|
+
|
|
11
|
+
from kachedb import KacheClient
|
|
12
|
+
|
|
13
|
+
with KacheClient(host="127.0.0.1", port=6379) as client:
|
|
14
|
+
client.set("user:1", "alice", ex=3600)
|
|
15
|
+
print(client.get("user:1")) # b"alice"
|
|
16
|
+
|
|
17
|
+
Async::
|
|
18
|
+
|
|
19
|
+
from kachedb import AsyncKacheClient
|
|
20
|
+
|
|
21
|
+
async with AsyncKacheClient() as client:
|
|
22
|
+
await client.set("key", "value")
|
|
23
|
+
print(await client.get("key"))
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from ._version import __version__
|
|
27
|
+
from .async_client import AsyncKacheClient
|
|
28
|
+
from .client import KacheClient
|
|
29
|
+
from .connection import Connection
|
|
30
|
+
from .descriptor import TENSOR_DESCRIPTOR_MAGIC, TensorBlockDescriptor, TensorDType
|
|
31
|
+
from .exceptions import (
|
|
32
|
+
ConnectionError,
|
|
33
|
+
KacheDBError,
|
|
34
|
+
PoolExhaustedError,
|
|
35
|
+
ProtocolError,
|
|
36
|
+
ResponseError,
|
|
37
|
+
TimeoutError,
|
|
38
|
+
)
|
|
39
|
+
from .pipeline import AsyncPipeline, Pipeline
|
|
40
|
+
from .pool import AsyncConnectionPool, ConnectionPool
|
|
41
|
+
from .tensor import attach_shm, detach_all, read_tensor, read_torch_tensor
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"TENSOR_DESCRIPTOR_MAGIC",
|
|
45
|
+
"AsyncConnectionPool",
|
|
46
|
+
"AsyncKacheClient",
|
|
47
|
+
"AsyncPipeline",
|
|
48
|
+
# Connection
|
|
49
|
+
"Connection",
|
|
50
|
+
"ConnectionError",
|
|
51
|
+
"ConnectionPool",
|
|
52
|
+
# Clients
|
|
53
|
+
"KacheClient",
|
|
54
|
+
# Exceptions
|
|
55
|
+
"KacheDBError",
|
|
56
|
+
# Pipeline
|
|
57
|
+
"Pipeline",
|
|
58
|
+
"PoolExhaustedError",
|
|
59
|
+
"ProtocolError",
|
|
60
|
+
"ResponseError",
|
|
61
|
+
# Tensor / Zero-Copy
|
|
62
|
+
"TensorBlockDescriptor",
|
|
63
|
+
"TensorDType",
|
|
64
|
+
"TimeoutError",
|
|
65
|
+
# Version
|
|
66
|
+
"__version__",
|
|
67
|
+
"attach_shm",
|
|
68
|
+
"detach_all",
|
|
69
|
+
"read_tensor",
|
|
70
|
+
"read_torch_tensor",
|
|
71
|
+
]
|
kachedb/_version.py
ADDED
kachedb/async_client.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Async KacheDB client using ``asyncio``.
|
|
3
|
+
|
|
4
|
+
Provides the same high-level API as :class:`~kachedb.client.KacheClient`
|
|
5
|
+
but uses non-blocking ``asyncio`` streams for use in async ML inference
|
|
6
|
+
pipelines (vLLM, SGLang, etc.).
|
|
7
|
+
|
|
8
|
+
Usage::
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from kachedb import AsyncKacheClient
|
|
12
|
+
|
|
13
|
+
async def main():
|
|
14
|
+
async with AsyncKacheClient() as client:
|
|
15
|
+
await client.set("user:1", "alice", ex=3600)
|
|
16
|
+
print(await client.get("user:1"))
|
|
17
|
+
|
|
18
|
+
asyncio.run(main())
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import TYPE_CHECKING, Any
|
|
24
|
+
|
|
25
|
+
from .exceptions import ConnectionError
|
|
26
|
+
from .pipeline import AsyncPipeline
|
|
27
|
+
from .pool import AsyncConnectionPool
|
|
28
|
+
from .resp import AsyncRespReader, RespValue, encode_command
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
import asyncio
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AsyncKacheClient:
|
|
35
|
+
"""High-level async KacheDB client.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
host : str
|
|
40
|
+
Server hostname or IP address.
|
|
41
|
+
port : int
|
|
42
|
+
Server TCP port.
|
|
43
|
+
decode_responses : bool
|
|
44
|
+
If ``True``, decode byte responses to UTF-8 strings.
|
|
45
|
+
max_connections : int
|
|
46
|
+
Maximum async connection pool size.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
host: str = "127.0.0.1",
|
|
52
|
+
port: int = 6379,
|
|
53
|
+
*,
|
|
54
|
+
decode_responses: bool = False,
|
|
55
|
+
max_connections: int = 10,
|
|
56
|
+
) -> None:
|
|
57
|
+
self.host = host
|
|
58
|
+
self.port = port
|
|
59
|
+
self.decode_responses = decode_responses
|
|
60
|
+
self._pool = AsyncConnectionPool(
|
|
61
|
+
host=host,
|
|
62
|
+
port=port,
|
|
63
|
+
max_connections=max_connections,
|
|
64
|
+
decode_responses=decode_responses,
|
|
65
|
+
)
|
|
66
|
+
self._reader: asyncio.StreamReader | None = None
|
|
67
|
+
self._writer: asyncio.StreamWriter | None = None
|
|
68
|
+
self._resp_reader: AsyncRespReader | None = None
|
|
69
|
+
|
|
70
|
+
async def connect(self) -> AsyncKacheClient:
|
|
71
|
+
"""Establish a dedicated async connection."""
|
|
72
|
+
self._reader, self._writer, self._resp_reader = await self._pool.get_connection()
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
async def close(self) -> None:
|
|
76
|
+
"""Release the connection back to the pool."""
|
|
77
|
+
if self._writer is not None and self._reader is not None and self._resp_reader is not None:
|
|
78
|
+
await self._pool.release_connection(self._reader, self._writer, self._resp_reader)
|
|
79
|
+
self._reader = None
|
|
80
|
+
self._writer = None
|
|
81
|
+
self._resp_reader = None
|
|
82
|
+
|
|
83
|
+
async def disconnect_all(self) -> None:
|
|
84
|
+
"""Close all pooled connections."""
|
|
85
|
+
await self._pool.disconnect_all()
|
|
86
|
+
|
|
87
|
+
async def __aenter__(self) -> AsyncKacheClient:
|
|
88
|
+
return await self.connect()
|
|
89
|
+
|
|
90
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
91
|
+
await self.close()
|
|
92
|
+
|
|
93
|
+
# ── Internal Helpers ──────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
async def _execute(self, *args: str | bytes) -> RespValue:
|
|
96
|
+
"""Execute a single command and return the response."""
|
|
97
|
+
if self._writer is None or self._resp_reader is None:
|
|
98
|
+
raise ConnectionError("Not connected to KacheDB")
|
|
99
|
+
|
|
100
|
+
data = encode_command(list(args))
|
|
101
|
+
self._writer.write(data)
|
|
102
|
+
await self._writer.drain()
|
|
103
|
+
|
|
104
|
+
response = await self._resp_reader.read_response()
|
|
105
|
+
|
|
106
|
+
if self.decode_responses and isinstance(response, bytes):
|
|
107
|
+
return response.decode("utf-8")
|
|
108
|
+
|
|
109
|
+
return response
|
|
110
|
+
|
|
111
|
+
# ── Redis-Compatible Commands ─────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
async def ping(self, message: str | None = None) -> str:
|
|
114
|
+
"""Send ``PING`` and return ``PONG`` or the echoed message."""
|
|
115
|
+
args: list[str | bytes] = ["PING"]
|
|
116
|
+
if message is not None:
|
|
117
|
+
args.append(message)
|
|
118
|
+
result = await self._execute(*args)
|
|
119
|
+
return str(result) if result is not None else "PONG"
|
|
120
|
+
|
|
121
|
+
async def get(self, key: str | bytes) -> bytes | str | None:
|
|
122
|
+
"""Retrieve the value for *key*."""
|
|
123
|
+
return await self._execute("GET", key) # type: ignore[return-value]
|
|
124
|
+
|
|
125
|
+
async def set(
|
|
126
|
+
self,
|
|
127
|
+
key: str | bytes,
|
|
128
|
+
value: str | bytes,
|
|
129
|
+
*,
|
|
130
|
+
ex: int | None = None,
|
|
131
|
+
px: int | None = None,
|
|
132
|
+
) -> bool:
|
|
133
|
+
"""Store *value* under *key* with an optional TTL."""
|
|
134
|
+
args: list[str | bytes] = ["SET", key, value]
|
|
135
|
+
if ex is not None:
|
|
136
|
+
args.extend(["EX", str(ex)])
|
|
137
|
+
elif px is not None:
|
|
138
|
+
args.extend(["PX", str(px)])
|
|
139
|
+
result = await self._execute(*args)
|
|
140
|
+
return result == "OK"
|
|
141
|
+
|
|
142
|
+
async def mget(self, *keys: str | bytes) -> list[bytes | str | None]:
|
|
143
|
+
"""Batch-retrieve values for multiple keys."""
|
|
144
|
+
if not keys:
|
|
145
|
+
return []
|
|
146
|
+
result = await self._execute("MGET", *keys)
|
|
147
|
+
return result if isinstance(result, list) else [] # type: ignore[return-value]
|
|
148
|
+
|
|
149
|
+
async def delete(self, *keys: str | bytes) -> int:
|
|
150
|
+
"""Delete one or more keys."""
|
|
151
|
+
if not keys:
|
|
152
|
+
return 0
|
|
153
|
+
result = await self._execute("DEL", *keys)
|
|
154
|
+
return int(result) if isinstance(result, int) else 0
|
|
155
|
+
|
|
156
|
+
async def exists(self, *keys: str | bytes) -> int:
|
|
157
|
+
"""Check existence of one or more keys."""
|
|
158
|
+
if not keys:
|
|
159
|
+
return 0
|
|
160
|
+
result = await self._execute("EXISTS", *keys)
|
|
161
|
+
return int(result) if isinstance(result, int) else 0
|
|
162
|
+
|
|
163
|
+
# ── Pipeline ──────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
def pipeline(self) -> AsyncPipeline:
|
|
166
|
+
"""Create an async pipeline for batching commands."""
|
|
167
|
+
if self._writer is None or self._resp_reader is None:
|
|
168
|
+
raise ConnectionError("Not connected to KacheDB")
|
|
169
|
+
return AsyncPipeline(self._writer, self._resp_reader)
|
kachedb/client.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Synchronous KacheDB client.
|
|
3
|
+
|
|
4
|
+
Provides a high-level, Redis-like API for communicating with a KacheDB
|
|
5
|
+
daemon over TCP using the RESP2 wire protocol.
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
from kachedb import KacheClient
|
|
10
|
+
|
|
11
|
+
with KacheClient() as client:
|
|
12
|
+
client.set("user:1", "alice", ex=3600)
|
|
13
|
+
print(client.get("user:1")) # b"alice"
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
from .pipeline import Pipeline
|
|
21
|
+
from .pool import ConnectionPool
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from .connection import Connection
|
|
25
|
+
from .resp import RespValue
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class KacheClient:
|
|
29
|
+
"""High-level synchronous KacheDB client.
|
|
30
|
+
|
|
31
|
+
Supports both direct connection mode and connection pool mode.
|
|
32
|
+
|
|
33
|
+
Parameters
|
|
34
|
+
----------
|
|
35
|
+
host : str
|
|
36
|
+
Server hostname or IP address.
|
|
37
|
+
port : int
|
|
38
|
+
Server TCP port.
|
|
39
|
+
socket_timeout : float | None
|
|
40
|
+
Socket timeout in seconds.
|
|
41
|
+
decode_responses : bool
|
|
42
|
+
If ``True``, decode byte responses to UTF-8 strings.
|
|
43
|
+
max_connections : int
|
|
44
|
+
Maximum pool size. Set to ``1`` to disable pooling.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
host: str = "127.0.0.1",
|
|
50
|
+
port: int = 6379,
|
|
51
|
+
*,
|
|
52
|
+
socket_timeout: float | None = 5.0,
|
|
53
|
+
decode_responses: bool = False,
|
|
54
|
+
max_connections: int = 10,
|
|
55
|
+
) -> None:
|
|
56
|
+
self.host = host
|
|
57
|
+
self.port = port
|
|
58
|
+
self._pool = ConnectionPool(
|
|
59
|
+
host=host,
|
|
60
|
+
port=port,
|
|
61
|
+
max_connections=max_connections,
|
|
62
|
+
socket_timeout=socket_timeout,
|
|
63
|
+
decode_responses=decode_responses,
|
|
64
|
+
)
|
|
65
|
+
self._conn: Connection | None = None
|
|
66
|
+
|
|
67
|
+
def connect(self) -> KacheClient:
|
|
68
|
+
"""Establish a dedicated connection (used with context manager)."""
|
|
69
|
+
self._conn = self._pool.get_connection()
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def close(self) -> None:
|
|
73
|
+
"""Release the dedicated connection back to the pool."""
|
|
74
|
+
if self._conn is not None:
|
|
75
|
+
self._pool.release_connection(self._conn)
|
|
76
|
+
self._conn = None
|
|
77
|
+
|
|
78
|
+
def disconnect_all(self) -> None:
|
|
79
|
+
"""Close all connections in the pool."""
|
|
80
|
+
self._pool.disconnect_all()
|
|
81
|
+
|
|
82
|
+
def __enter__(self) -> KacheClient:
|
|
83
|
+
return self.connect()
|
|
84
|
+
|
|
85
|
+
def __exit__(self, *args: Any) -> None:
|
|
86
|
+
self.close()
|
|
87
|
+
|
|
88
|
+
# ── Internal Helpers ──────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
def _get_conn(self) -> Connection:
|
|
91
|
+
"""Get the active connection or checkout from pool."""
|
|
92
|
+
if self._conn is not None:
|
|
93
|
+
return self._conn
|
|
94
|
+
# For non-context-manager usage: get a connection per-call.
|
|
95
|
+
return self._pool.get_connection()
|
|
96
|
+
|
|
97
|
+
def _release_conn(self, conn: Connection) -> None:
|
|
98
|
+
"""Release if it was a per-call checkout."""
|
|
99
|
+
if self._conn is None:
|
|
100
|
+
self._pool.release_connection(conn)
|
|
101
|
+
|
|
102
|
+
def _execute(self, *args: str | bytes) -> RespValue:
|
|
103
|
+
"""Execute a single command and return the response."""
|
|
104
|
+
conn = self._get_conn()
|
|
105
|
+
try:
|
|
106
|
+
conn.send_command(*args)
|
|
107
|
+
return conn.read_response()
|
|
108
|
+
except Exception:
|
|
109
|
+
# On error, discard the connection.
|
|
110
|
+
conn.disconnect()
|
|
111
|
+
raise
|
|
112
|
+
finally:
|
|
113
|
+
self._release_conn(conn)
|
|
114
|
+
|
|
115
|
+
# ── Redis-Compatible Commands ─────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
def ping(self, message: str | None = None) -> str:
|
|
118
|
+
"""Send ``PING`` and return ``PONG`` or the echoed message.
|
|
119
|
+
|
|
120
|
+
Parameters
|
|
121
|
+
----------
|
|
122
|
+
message : str | None
|
|
123
|
+
Optional message to echo back.
|
|
124
|
+
"""
|
|
125
|
+
args: list[str | bytes] = ["PING"]
|
|
126
|
+
if message is not None:
|
|
127
|
+
args.append(message)
|
|
128
|
+
result = self._execute(*args)
|
|
129
|
+
return str(result) if result is not None else "PONG"
|
|
130
|
+
|
|
131
|
+
def get(self, key: str | bytes) -> bytes | str | None:
|
|
132
|
+
"""Retrieve the value for *key*.
|
|
133
|
+
|
|
134
|
+
Returns ``None`` if the key does not exist or has expired.
|
|
135
|
+
"""
|
|
136
|
+
return self._execute("GET", key) # type: ignore[return-value]
|
|
137
|
+
|
|
138
|
+
def set(
|
|
139
|
+
self,
|
|
140
|
+
key: str | bytes,
|
|
141
|
+
value: str | bytes,
|
|
142
|
+
*,
|
|
143
|
+
ex: int | None = None,
|
|
144
|
+
px: int | None = None,
|
|
145
|
+
) -> bool:
|
|
146
|
+
"""Store *value* under *key* with an optional TTL.
|
|
147
|
+
|
|
148
|
+
Parameters
|
|
149
|
+
----------
|
|
150
|
+
key : str | bytes
|
|
151
|
+
The cache key.
|
|
152
|
+
value : str | bytes
|
|
153
|
+
The value to store (binary-safe, up to 2 MB).
|
|
154
|
+
ex : int | None
|
|
155
|
+
Expiration time in **seconds**.
|
|
156
|
+
px : int | None
|
|
157
|
+
Expiration time in **milliseconds**.
|
|
158
|
+
|
|
159
|
+
Returns
|
|
160
|
+
-------
|
|
161
|
+
bool
|
|
162
|
+
``True`` if the server acknowledged with ``OK``.
|
|
163
|
+
"""
|
|
164
|
+
args: list[str | bytes] = ["SET", key, value]
|
|
165
|
+
if ex is not None:
|
|
166
|
+
args.extend(["EX", str(ex)])
|
|
167
|
+
elif px is not None:
|
|
168
|
+
args.extend(["PX", str(px)])
|
|
169
|
+
result = self._execute(*args)
|
|
170
|
+
return result == "OK"
|
|
171
|
+
|
|
172
|
+
def mget(self, *keys: str | bytes) -> list[bytes | str | None]:
|
|
173
|
+
"""Batch-retrieve values for multiple keys in a single round-trip.
|
|
174
|
+
|
|
175
|
+
Returns a list of values (or ``None`` for missing/expired keys)
|
|
176
|
+
in the same order as the input keys.
|
|
177
|
+
"""
|
|
178
|
+
if not keys:
|
|
179
|
+
return []
|
|
180
|
+
result = self._execute("MGET", *keys)
|
|
181
|
+
return result if isinstance(result, list) else [] # type: ignore[return-value]
|
|
182
|
+
|
|
183
|
+
def delete(self, *keys: str | bytes) -> int:
|
|
184
|
+
"""Delete one or more keys.
|
|
185
|
+
|
|
186
|
+
Returns the number of keys that were actually removed.
|
|
187
|
+
"""
|
|
188
|
+
if not keys:
|
|
189
|
+
return 0
|
|
190
|
+
result = self._execute("DEL", *keys)
|
|
191
|
+
return int(result) if isinstance(result, int) else 0
|
|
192
|
+
|
|
193
|
+
def exists(self, *keys: str | bytes) -> int:
|
|
194
|
+
"""Check existence of one or more keys.
|
|
195
|
+
|
|
196
|
+
Returns the count of keys that exist and have not expired.
|
|
197
|
+
"""
|
|
198
|
+
if not keys:
|
|
199
|
+
return 0
|
|
200
|
+
result = self._execute("EXISTS", *keys)
|
|
201
|
+
return int(result) if isinstance(result, int) else 0
|
|
202
|
+
|
|
203
|
+
# ── Pipeline ──────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
def pipeline(self) -> Pipeline:
|
|
206
|
+
"""Create a pipeline for batching multiple commands.
|
|
207
|
+
|
|
208
|
+
Usage::
|
|
209
|
+
|
|
210
|
+
pipe = client.pipeline()
|
|
211
|
+
pipe.set("a", "1")
|
|
212
|
+
pipe.get("a")
|
|
213
|
+
results = pipe.execute()
|
|
214
|
+
"""
|
|
215
|
+
conn = self._get_conn()
|
|
216
|
+
return Pipeline(conn)
|
kachedb/connection.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Low-level TCP connection to a KacheDB server.
|
|
3
|
+
|
|
4
|
+
Manages the socket lifecycle, RESP encoding, and buffered response reading.
|
|
5
|
+
Used internally by :class:`~kachedb.client.KacheClient` and the connection pool.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import builtins
|
|
11
|
+
import contextlib
|
|
12
|
+
import socket
|
|
13
|
+
|
|
14
|
+
from .exceptions import ConnectionError, TimeoutError
|
|
15
|
+
from .resp import RespReader, RespValue, encode_command
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Connection:
|
|
19
|
+
"""A single TCP connection to a KacheDB daemon.
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
----------
|
|
23
|
+
host : str
|
|
24
|
+
Server hostname or IP address.
|
|
25
|
+
port : int
|
|
26
|
+
Server TCP port.
|
|
27
|
+
socket_timeout : float | None
|
|
28
|
+
Socket timeout in seconds. ``None`` for blocking without timeout.
|
|
29
|
+
decode_responses : bool
|
|
30
|
+
If ``True``, decode byte responses to UTF-8 strings.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
__slots__ = (
|
|
34
|
+
"_reader",
|
|
35
|
+
"_sock",
|
|
36
|
+
"decode_responses",
|
|
37
|
+
"host",
|
|
38
|
+
"port",
|
|
39
|
+
"socket_timeout",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
host: str = "127.0.0.1",
|
|
45
|
+
port: int = 6379,
|
|
46
|
+
*,
|
|
47
|
+
socket_timeout: float | None = 5.0,
|
|
48
|
+
decode_responses: bool = False,
|
|
49
|
+
) -> None:
|
|
50
|
+
self.host = host
|
|
51
|
+
self.port = port
|
|
52
|
+
self.socket_timeout = socket_timeout
|
|
53
|
+
self.decode_responses = decode_responses
|
|
54
|
+
self._sock: socket.socket | None = None
|
|
55
|
+
self._reader: RespReader | None = None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def is_connected(self) -> bool:
|
|
59
|
+
"""Return ``True`` if the socket is open."""
|
|
60
|
+
return self._sock is not None
|
|
61
|
+
|
|
62
|
+
def connect(self) -> None:
|
|
63
|
+
"""Establish TCP connection to the KacheDB server."""
|
|
64
|
+
if self._sock is not None:
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
sock = socket.create_connection(
|
|
69
|
+
(self.host, self.port),
|
|
70
|
+
timeout=self.socket_timeout,
|
|
71
|
+
)
|
|
72
|
+
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
73
|
+
self._sock = sock
|
|
74
|
+
self._reader = RespReader(sock)
|
|
75
|
+
except OSError as exc:
|
|
76
|
+
raise ConnectionError(
|
|
77
|
+
f"Failed to connect to KacheDB at {self.host}:{self.port}: {exc}"
|
|
78
|
+
) from exc
|
|
79
|
+
|
|
80
|
+
def disconnect(self) -> None:
|
|
81
|
+
"""Close the TCP connection."""
|
|
82
|
+
if self._sock is not None:
|
|
83
|
+
with contextlib.suppress(OSError):
|
|
84
|
+
self._sock.close()
|
|
85
|
+
self._sock = None
|
|
86
|
+
self._reader = None
|
|
87
|
+
|
|
88
|
+
def send_command(self, *args: str | bytes) -> None:
|
|
89
|
+
"""Encode and send a single RESP command."""
|
|
90
|
+
if self._sock is None:
|
|
91
|
+
raise ConnectionError("Not connected to KacheDB")
|
|
92
|
+
|
|
93
|
+
data = encode_command(list(args))
|
|
94
|
+
try:
|
|
95
|
+
self._sock.sendall(data)
|
|
96
|
+
except builtins.TimeoutError as exc:
|
|
97
|
+
self.disconnect()
|
|
98
|
+
raise TimeoutError(f"Timeout sending command to KacheDB: {exc}") from exc
|
|
99
|
+
except OSError as exc:
|
|
100
|
+
self.disconnect()
|
|
101
|
+
raise ConnectionError(f"Error sending command to KacheDB: {exc}") from exc
|
|
102
|
+
|
|
103
|
+
def send_packed(self, data: bytes) -> None:
|
|
104
|
+
"""Send pre-encoded bytes (used by pipeline)."""
|
|
105
|
+
if self._sock is None:
|
|
106
|
+
raise ConnectionError("Not connected to KacheDB")
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
self._sock.sendall(data)
|
|
110
|
+
except builtins.TimeoutError as exc:
|
|
111
|
+
self.disconnect()
|
|
112
|
+
raise TimeoutError(f"Timeout sending data to KacheDB: {exc}") from exc
|
|
113
|
+
except OSError as exc:
|
|
114
|
+
self.disconnect()
|
|
115
|
+
raise ConnectionError(f"Error sending data to KacheDB: {exc}") from exc
|
|
116
|
+
|
|
117
|
+
def read_response(self) -> RespValue:
|
|
118
|
+
"""Read and decode one RESP response."""
|
|
119
|
+
if self._reader is None:
|
|
120
|
+
raise ConnectionError("Not connected to KacheDB")
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
response = self._reader.read_response()
|
|
124
|
+
except builtins.TimeoutError as exc:
|
|
125
|
+
self.disconnect()
|
|
126
|
+
raise TimeoutError(f"Timeout reading response from KacheDB: {exc}") from exc
|
|
127
|
+
except OSError as exc:
|
|
128
|
+
self.disconnect()
|
|
129
|
+
raise ConnectionError(f"Error reading response from KacheDB: {exc}") from exc
|
|
130
|
+
|
|
131
|
+
if self.decode_responses and isinstance(response, bytes):
|
|
132
|
+
return response.decode("utf-8")
|
|
133
|
+
|
|
134
|
+
return response
|
|
135
|
+
|
|
136
|
+
def check_health(self) -> bool:
|
|
137
|
+
"""Send a PING and verify PONG response. Returns ``False`` on any error."""
|
|
138
|
+
try:
|
|
139
|
+
self.send_command("PING")
|
|
140
|
+
response = self.read_response()
|
|
141
|
+
return response == "PONG"
|
|
142
|
+
except Exception:
|
|
143
|
+
return False
|