trpc-redis-python 1.0.0__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.
@@ -0,0 +1,296 @@
1
+ # Tencent is pleased to support the open source community by making tRPC-Python-Database available.
2
+ #
3
+ # Copyright (C) 2026 Tencent. All rights reserved.
4
+ #
5
+ # tRPC-Python-Database is licensed under Apache-2.0.
6
+ """
7
+ Redis transport module for tRPC framework.
8
+
9
+ This module provides a transport implementation for Redis connections, which is used by the tRPC client
10
+ to handle Redis-specific message serialization and deserialization.
11
+
12
+ Usage:
13
+ The transport is registered automatically as a plugin and is used internally by the tRPC Redis client.
14
+
15
+ One transport instance serves every thread of a process. Connection pools are
16
+ cached per thread and per event loop, because a ``redis.asyncio`` pool is
17
+ bound to the loop it first runs on. A forked child starts with an empty cache.
18
+ """
19
+
20
+ import asyncio
21
+ import os
22
+ import threading
23
+ import time
24
+ import weakref
25
+ from typing import Any
26
+ from typing import Dict
27
+ from typing import Optional
28
+ from typing import Tuple
29
+
30
+ import redis.asyncio as redis
31
+ from redis import exceptions as redis_exceptions
32
+
33
+ from trpc_sdk.abc import BaseClientTransport
34
+ from trpc_sdk.abc import PluginInitPos
35
+ from trpc_sdk.abc import PluginType
36
+ from trpc_sdk.abc import TrpcContext
37
+ from trpc_sdk.exceptions import ClientDecodeError
38
+ from trpc_sdk.exceptions import ClientEncodeError
39
+ from trpc_sdk.exceptions import ClientInvokeError
40
+ from trpc_sdk.exceptions import ClientTimeoutError
41
+ from trpc_sdk.plugin import register_plugin
42
+ from trpc_sdk.transport import RoundTripOptions
43
+
44
+ from ._constants import DECODE_RESPONSES
45
+ from ._constants import DECODE_RESPONSES_DEFAULT
46
+ from ._constants import HOST
47
+ from ._constants import IDLE_TIMEOUT_DEFAULT
48
+ from ._constants import MAX_CONNECTIONS
49
+ from ._constants import MAX_CONNECTIONS_DEFAULT
50
+ from ._constants import PORT
51
+ from ._constants import PASSWORD
52
+ from ._constants import REDIS_NAME
53
+ from ._constants import RequestMode
54
+ from ._constants import SOCKET_CONNECT_TIMEOUT
55
+ from ._types import Request
56
+ from ._types import Response
57
+
58
+
59
+ class _LoopPools(threading.local):
60
+ """Per-thread map from event loop to that loop's connection pools.
61
+
62
+ Loops are keyed by object rather than ``id()`` so a closed loop's id
63
+ cannot be reused to return a pool bound to the dead loop.
64
+ """
65
+
66
+ def __init__(self):
67
+ """Start the calling thread with no pools."""
68
+ super().__init__()
69
+ self.pools: Dict[asyncio.AbstractEventLoop, Dict[str, redis.ConnectionPool]] = {}
70
+
71
+ def for_loop(self, loop: asyncio.AbstractEventLoop) -> Dict[str, redis.ConnectionPool]:
72
+ """Return the pool dict of ``loop``, creating it on first use.
73
+
74
+ Entries of closed loops are dropped when a new loop is seen.
75
+
76
+ Args:
77
+ loop: The running event loop of the calling thread.
78
+
79
+ Returns:
80
+ Mutable mapping from pool key to connection pool.
81
+ """
82
+ pools = self.pools.get(loop, None)
83
+ if pools is None:
84
+ for stale in [old for old in self.pools if old.is_closed()]:
85
+ del self.pools[stale]
86
+ pools = {}
87
+ self.pools[loop] = pools
88
+ return pools
89
+
90
+
91
+ _TRANSPORTS: "weakref.WeakSet[RedisClientTransport]" = weakref.WeakSet()
92
+
93
+
94
+ def _reset_pools_after_fork():
95
+ """Drop every pool inherited from the parent process."""
96
+ for transport in list(_TRANSPORTS):
97
+ transport.reset_pools()
98
+
99
+
100
+ if hasattr(os, "register_at_fork"):
101
+ os.register_at_fork(after_in_child=_reset_pools_after_fork)
102
+
103
+
104
+ @register_plugin(PluginType.CLIENT_TRANSPORT, REDIS_NAME, init_pos=PluginInitPos.WORKER)
105
+ class RedisClientTransport(BaseClientTransport):
106
+ """
107
+ Transport implementation for Redis connections in the tRPC framework.
108
+ Handles connection pooling, command, pipeline, and pubsub operations.
109
+ """
110
+
111
+ def __init__(self):
112
+ """
113
+ Initialize the RedisClientTransport with the default idle timeout and an empty pool cache.
114
+ """
115
+ super().__init__()
116
+ self.idle_timeout = IDLE_TIMEOUT_DEFAULT
117
+ self._loop_pools = _LoopPools()
118
+ _TRANSPORTS.add(self)
119
+
120
+ def reset_pools(self):
121
+ """
122
+ Forget every cached connection pool, in all threads.
123
+ """
124
+ self._loop_pools = _LoopPools()
125
+
126
+ async def _process_pipeline(self, ctx: TrpcContext, pool: redis.ConnectionPool):
127
+ """
128
+ Process pipeline requests and return a pipe object.
129
+
130
+ Args:
131
+ ctx (TrpcContext): The context object.
132
+ pool (redis.ConnectionPool): The connection pool.
133
+
134
+ Returns:
135
+ str: An empty string (pipe object is set in response).
136
+ """
137
+ pipe = await self.get_pipe(pool)
138
+ ctx.msg.set_client_rsp_head(Response(pipe))
139
+ return ""
140
+
141
+ async def _process_command(self, ctx: TrpcContext, pool: redis.ConnectionPool):
142
+ """
143
+ Process command requests and return a response object.
144
+
145
+ Args:
146
+ ctx (TrpcContext): The context object.
147
+ pool (redis.ConnectionPool): The connection pool.
148
+
149
+ Returns:
150
+ Any: The reply from Redis.
151
+ """
152
+ begin = time.time() * 1000
153
+ req: Request = ctx.msg.client_req_head
154
+ timeout = None
155
+ if ctx.deadline > 0:
156
+ remain = (ctx.deadline - begin) / 1000.0
157
+ if remain <= 0:
158
+ raise ClientTimeoutError("reach deadline")
159
+ timeout = remain
160
+ try:
161
+ reply = await asyncio.wait_for(self.get_response(pool, req), timeout)
162
+ ctx.msg.set_client_rsp_head(Response(reply))
163
+ except asyncio.TimeoutError as exc:
164
+ raise ClientTimeoutError(f"redis client timeout {timeout}, command {req.command} args {req.args}") from exc
165
+ except TypeError as exc:
166
+ raise ClientEncodeError(f"redis client args error {exc}, command {req.command} args {req.args}") from exc
167
+ except redis_exceptions.ResponseError as exc:
168
+ raise ClientDecodeError(f"redis client reply error {exc} command {req.command} args {req.args}") from exc
169
+ except redis_exceptions.RedisError as exc:
170
+ raise ClientInvokeError(f"redis client protocol error {exc} command {req.command} args {req.args}") from exc
171
+ except Exception as exc: # pylint: disable=broad-except
172
+ raise ClientInvokeError(f"redis client unknown error {exc} command {req.command} args {req.args}") from exc
173
+ return reply
174
+
175
+ async def _process_pubsub(self, ctx: TrpcContext, pool: redis.ConnectionPool):
176
+ """
177
+ Process pubsub requests and return a pubsub object.
178
+
179
+ Args:
180
+ ctx (TrpcContext): The context object.
181
+ pool (redis.ConnectionPool): The connection pool.
182
+
183
+ Returns:
184
+ str: An empty string (pubsub object is set in response).
185
+ """
186
+ req: Request = ctx.msg.client_req_head
187
+ async with redis.Redis(connection_pool=pool) as conn:
188
+ pubsub = conn.pubsub()
189
+ await pubsub.subscribe(*req.args, **req.kwargs)
190
+ ctx.msg.set_client_rsp_head(Response(pubsub))
191
+ return ""
192
+
193
+ async def round_trip(self, ctx: TrpcContext, _: bytes, options: RoundTripOptions):
194
+ """
195
+ Send a request to Redis and return the response or pipe object.
196
+
197
+ Args:
198
+ ctx (TrpcContext): The client context.
199
+ _ (bytes): The request data (unused).
200
+ options (RoundTripOptions): The round trip options.
201
+
202
+ Returns:
203
+ Any: The response or pipe object, depending on the request mode.
204
+ """
205
+ msg = ctx.msg
206
+ req: Request = msg.client_req_head
207
+ address = options.address
208
+ redis_config = getattr(options, REDIS_NAME, {})
209
+ pool = await self.get_conn(address, options.password, redis_config)
210
+ if req.mode == RequestMode.PIPELINE:
211
+ return await self._process_pipeline(ctx, pool)
212
+ elif req.mode == RequestMode.COMMAND:
213
+ return await self._process_command(ctx, pool)
214
+ elif req.mode == RequestMode.PUBSUB:
215
+ return await self._process_pubsub(ctx, pool)
216
+ else:
217
+ raise ClientInvokeError(f"redis client unknown mode {req.mode}")
218
+
219
+ @staticmethod
220
+ def _set_default(key: str, value: Any, redis_config: Dict, force: bool = False):
221
+ """
222
+ Set a default value in the redis_config dictionary.
223
+
224
+ Args:
225
+ key (str): The configuration key.
226
+ value (Any): The value to set.
227
+ redis_config (Dict): The configuration dictionary.
228
+ force (bool): If True, overwrite existing value.
229
+ """
230
+ if force:
231
+ redis_config[key] = value
232
+ elif key not in redis_config:
233
+ redis_config[key] = value
234
+
235
+ async def get_conn(self, address: Tuple[str, int], password: Optional[str],
236
+ redis_config: Dict) -> redis.ConnectionPool:
237
+ """
238
+ Get or create a Redis connection pool for the running event loop.
239
+
240
+ Lookup and creation do not await, so no lock is needed within one loop;
241
+ other threads use their own cache.
242
+
243
+ Args:
244
+ address (Tuple[str, int]): The address tuple (host, port).
245
+ password (Optional[str]): The password for Redis.
246
+ redis_config (Dict): The Redis configuration dictionary. Not modified.
247
+
248
+ Returns:
249
+ redis.ConnectionPool: The connection pool object.
250
+ """
251
+ redis_pool = self._loop_pools.for_loop(asyncio.get_running_loop())
252
+ key = f"{address[0]}:{address[1]}:{password or 'none'}"
253
+ conn_pool = redis_pool.get(key, None)
254
+ if conn_pool is not None:
255
+ return conn_pool
256
+ pool_config = dict(redis_config or {})
257
+ self._set_default(SOCKET_CONNECT_TIMEOUT, self.idle_timeout, pool_config)
258
+ self._set_default(MAX_CONNECTIONS, MAX_CONNECTIONS_DEFAULT, pool_config)
259
+ self._set_default(DECODE_RESPONSES, DECODE_RESPONSES_DEFAULT, pool_config)
260
+ self._set_default(HOST, address[0], pool_config, force=True)
261
+ self._set_default(PORT, address[1], pool_config, force=True)
262
+ if password is not None:
263
+ self._set_default(PASSWORD, password, pool_config, force=True)
264
+ conn_pool = redis.ConnectionPool(**pool_config)
265
+ redis_pool[key] = conn_pool
266
+ return conn_pool
267
+
268
+ @staticmethod
269
+ async def get_response(pool: redis.ConnectionPool, req: Request) -> Any:
270
+ """
271
+ Get the response data from Redis.
272
+
273
+ Args:
274
+ pool (redis.ConnectionPool): The connection pool.
275
+ req (Request): The request object.
276
+
277
+ Returns:
278
+ Any: The response data.
279
+ """
280
+ async with redis.Redis(connection_pool=pool) as conn:
281
+ return await conn.execute_command(req.command, *req.args, **req.kwargs)
282
+
283
+ @staticmethod
284
+ async def get_pipe(pool: redis.ConnectionPool) -> Any:
285
+ """
286
+ Get a pipeline object from Redis.
287
+
288
+ Args:
289
+ pool (redis.ConnectionPool): The connection pool.
290
+
291
+ Returns:
292
+ Any: The pipeline object.
293
+ """
294
+ async with redis.Redis(connection_pool=pool) as conn:
295
+ pipe = conn.pipeline()
296
+ return pipe
trpc_redis/_types.py ADDED
@@ -0,0 +1,65 @@
1
+ # Tencent is pleased to support the open source community by making tRPC-Python-Database available.
2
+ #
3
+ # Copyright (C) 2026 Tencent. All rights reserved.
4
+ #
5
+ # tRPC-Python-Database is licensed under Apache-2.0.
6
+ """
7
+ Redis request and response types for tRPC framework.
8
+
9
+ The client stores a :class:`Request` in ``msg.client_req_head`` and the transport replies with a
10
+ :class:`Response` in ``msg.client_rsp_head``.
11
+ """
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+ from typing import Dict
15
+ from typing import Optional
16
+ from typing import Sequence
17
+
18
+ from ._constants import RequestMode
19
+
20
+
21
+ @dataclass
22
+ class Request:
23
+ """Mode and command payload for one Redis call.
24
+
25
+ ``args`` and ``kwargs`` default to an empty tuple and dict. A single list
26
+ argument is unpacked, so ``args=[["a", "b"]]`` becomes ``("a", "b")``.
27
+
28
+ Attributes:
29
+ mode: Command, pipeline, or pub/sub dispatch key.
30
+ command: Redis command name. Unused for pipeline and pub/sub.
31
+ args: Positional arguments for the command.
32
+ kwargs: Keyword arguments for the command.
33
+ """
34
+
35
+ mode: RequestMode
36
+ command: Optional[str] = None
37
+ args: Optional[Sequence] = None
38
+ kwargs: Optional[Dict] = None
39
+
40
+ def __post_init__(self):
41
+ if self.args and len(self.args) == 1 and isinstance(self.args[0], list):
42
+ self.args = tuple(self.args[0])
43
+ self.args = self.args or tuple()
44
+ self.kwargs = self.kwargs or dict()
45
+
46
+ def set_pipeline(self, pipeline: bool):
47
+ """
48
+ Set the request mode to pipeline or command.
49
+
50
+ Args:
51
+ pipeline (bool): If True, set mode to PIPELINE; else COMMAND.
52
+ """
53
+ self.mode = RequestMode.PIPELINE if pipeline else RequestMode.COMMAND
54
+
55
+
56
+ @dataclass
57
+ class Response:
58
+ """Transport reply for one Redis call.
59
+
60
+ Attributes:
61
+ reply: Command reply, or the ``Pipeline`` / ``PubSub`` object for
62
+ pipeline and pub/sub requests.
63
+ """
64
+
65
+ reply: Any = None
trpc_redis/version.py ADDED
@@ -0,0 +1,12 @@
1
+ # Tencent is pleased to support the open source community by making tRPC-Python-Database available.
2
+ #
3
+ # Copyright (C) 2026 Tencent. All rights reserved.
4
+ #
5
+ # tRPC-Python-Database is licensed under Apache-2.0.
6
+ """
7
+ TRPC Redis Version Module
8
+
9
+ This module defines the version information for TRPC Redis.
10
+ """
11
+
12
+ __version__ = '1.0.0'