redis-http-client 0.1.1__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.
- redis_http_client/__init__.py +4 -0
- redis_http_client/client.py +443 -0
- redis_http_client/other_logger.py +22 -0
- redis_http_client/sub_run.py +55 -0
- redis_http_client/to_method.py +242 -0
- redis_http_client-0.1.1.dist-info/METADATA +149 -0
- redis_http_client-0.1.1.dist-info/RECORD +10 -0
- redis_http_client-0.1.1.dist-info/WHEEL +5 -0
- redis_http_client-0.1.1.dist-info/licenses/LICENSE +21 -0
- redis_http_client-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
import traceback
|
|
5
|
+
import aiohttp
|
|
6
|
+
import requests
|
|
7
|
+
from urllib3.util import Retry
|
|
8
|
+
from requests.adapters import HTTPAdapter
|
|
9
|
+
from redis_http_client.other_logger import other_logger
|
|
10
|
+
from aiohttp.client_exceptions import (
|
|
11
|
+
ServerDisconnectedError, ClientOSError, ClientPayloadError, ClientConnectionError
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RedisHttpClient:
|
|
16
|
+
def __init__(self,
|
|
17
|
+
spider_redis_manager_service_url,
|
|
18
|
+
base_url: str = None,
|
|
19
|
+
mode: str = "async",
|
|
20
|
+
local=False,
|
|
21
|
+
force_close: bool = False,
|
|
22
|
+
limit: int = 1000
|
|
23
|
+
):
|
|
24
|
+
"""
|
|
25
|
+
mode: "async" 使用 aiohttp, "sync" 使用 requests
|
|
26
|
+
decode_responses: True → 返回 JSON 结果 (字符串)
|
|
27
|
+
False → 返回原始 bytes
|
|
28
|
+
"""
|
|
29
|
+
if local:
|
|
30
|
+
self.base_url = base_url or f"{spider_redis_manager_service_url}/redis/exec/local"
|
|
31
|
+
self.pipeline_url = f"{spider_redis_manager_service_url}/redis/pipeline/local"
|
|
32
|
+
else:
|
|
33
|
+
self.base_url = base_url or f"{spider_redis_manager_service_url}/redis/exec"
|
|
34
|
+
self.pipeline_url = f"{spider_redis_manager_service_url}/redis/pipeline"
|
|
35
|
+
if mode not in ("async", "sync"):
|
|
36
|
+
raise ValueError("mode must be 'async' or 'sync'")
|
|
37
|
+
self.mode = mode
|
|
38
|
+
self._fail_count = 0
|
|
39
|
+
# 异步 session 延迟初始化
|
|
40
|
+
self.session = None
|
|
41
|
+
self._lock = asyncio.Lock()
|
|
42
|
+
self._session_created_at = 0
|
|
43
|
+
# 同步 session 立即初始化 + 连接池
|
|
44
|
+
if self.mode == "sync":
|
|
45
|
+
self.sync_session = requests.Session()
|
|
46
|
+
retry_strategy = Retry(
|
|
47
|
+
total=3,
|
|
48
|
+
backoff_factor=0.3,
|
|
49
|
+
status_forcelist=[500, 502, 503, 504],
|
|
50
|
+
allowed_methods=["HEAD", "GET", "OPTIONS", "POST"],
|
|
51
|
+
)
|
|
52
|
+
adapter = HTTPAdapter(
|
|
53
|
+
pool_connections=50, # 连接池大小
|
|
54
|
+
pool_maxsize=1000, # 单个 host 最大连接数
|
|
55
|
+
max_retries=retry_strategy,
|
|
56
|
+
)
|
|
57
|
+
self.sync_session.mount("http://", adapter)
|
|
58
|
+
self.sync_session.mount("https://", adapter)
|
|
59
|
+
else:
|
|
60
|
+
self.sync_session = None
|
|
61
|
+
# 记录session的创建时间
|
|
62
|
+
self._session_created_at = time.time()
|
|
63
|
+
# 计数 每隔xx秒输出一次日志
|
|
64
|
+
self._request_count = 0
|
|
65
|
+
self.max_tries = 5
|
|
66
|
+
self.force_close = force_close
|
|
67
|
+
self.limit = limit
|
|
68
|
+
|
|
69
|
+
async def _init_session(self, force=False):
|
|
70
|
+
"""延迟初始化 aiohttp session,带锁"""
|
|
71
|
+
# 保证只有一个协程能创建/关闭 session
|
|
72
|
+
if force or self.session is None or self.session.closed:
|
|
73
|
+
async with self._lock:
|
|
74
|
+
if force or self.session is None or self.session.closed:
|
|
75
|
+
connector = aiohttp.TCPConnector(
|
|
76
|
+
limit=self.limit,
|
|
77
|
+
ttl_dns_cache=60,
|
|
78
|
+
keepalive_timeout=50,
|
|
79
|
+
enable_cleanup_closed=True,
|
|
80
|
+
force_close=self.force_close,
|
|
81
|
+
ssl=False,
|
|
82
|
+
)
|
|
83
|
+
timeout = aiohttp.ClientTimeout(
|
|
84
|
+
total=120, # 容忍整体请求更久
|
|
85
|
+
connect=10, # 连接最长 10s
|
|
86
|
+
sock_connect=10,
|
|
87
|
+
sock_read=100 # Redis pipeline 大响应时给足时间
|
|
88
|
+
)
|
|
89
|
+
self.session = aiohttp.ClientSession(connector=connector, timeout=timeout)
|
|
90
|
+
self._session_created_at = time.time()
|
|
91
|
+
other_logger.info("[RedisHttpClient] 新建 aiohttp session")
|
|
92
|
+
|
|
93
|
+
async def _recreate_session(self):
|
|
94
|
+
# 冷却防抖(建议 >=10s)
|
|
95
|
+
if time.time() - self._session_created_at < 10:
|
|
96
|
+
other_logger.warning("[RedisHttpClient] recreate skipped (cooldown)")
|
|
97
|
+
return
|
|
98
|
+
try:
|
|
99
|
+
# 锁内:原子“新建并替换”
|
|
100
|
+
async with self._lock:
|
|
101
|
+
old = self.session
|
|
102
|
+
connector = aiohttp.TCPConnector(
|
|
103
|
+
limit=self.limit,
|
|
104
|
+
ttl_dns_cache=60,
|
|
105
|
+
keepalive_timeout=50,
|
|
106
|
+
enable_cleanup_closed=True,
|
|
107
|
+
force_close=self.force_close,
|
|
108
|
+
ssl=False,
|
|
109
|
+
)
|
|
110
|
+
timeout = aiohttp.ClientTimeout(
|
|
111
|
+
total=120, # 容忍整体请求更久
|
|
112
|
+
connect=10, # 连接最长 10s
|
|
113
|
+
sock_connect=10,
|
|
114
|
+
sock_read=100 # Redis pipeline 大响应时给足时间
|
|
115
|
+
)
|
|
116
|
+
new_session = aiohttp.ClientSession(connector=connector, timeout=timeout)
|
|
117
|
+
self.session = new_session
|
|
118
|
+
self._session_created_at = time.time()
|
|
119
|
+
other_logger.warning("[RedisHttpClient] session swapped")
|
|
120
|
+
except Exception:
|
|
121
|
+
other_logger.error(traceback.format_exc())
|
|
122
|
+
return
|
|
123
|
+
# 🔕 锁外:**延迟**关闭旧 session,给在途请求留出时间
|
|
124
|
+
|
|
125
|
+
if old and not old.closed:
|
|
126
|
+
asyncio.create_task(self._delayed_close(old))
|
|
127
|
+
|
|
128
|
+
async def _delayed_close(self, session, delay=1.0):
|
|
129
|
+
"""延迟关闭 session,给正在使用的请求留出时间"""
|
|
130
|
+
try:
|
|
131
|
+
other_logger.info("[RedisHttpClient] start close old session ")
|
|
132
|
+
# 防止多次异步触发 _delayed_close() 重复关闭。
|
|
133
|
+
if not session or getattr(session, "_closing", False):
|
|
134
|
+
return
|
|
135
|
+
setattr(session, "_closing", True)
|
|
136
|
+
await asyncio.sleep(delay)
|
|
137
|
+
if session and not session.closed:
|
|
138
|
+
await session.close()
|
|
139
|
+
other_logger.info("[RedisHttpClient] old session closed !")
|
|
140
|
+
except Exception:
|
|
141
|
+
other_logger.warning(traceback.format_exc())
|
|
142
|
+
|
|
143
|
+
async def _check_pool_health(self):
|
|
144
|
+
other_logger.info("[RedisHttpClient] 检查 redis pool health")
|
|
145
|
+
if not self.session or not self.session.connector:
|
|
146
|
+
return
|
|
147
|
+
dead_conns = 0
|
|
148
|
+
self.session.connector._cleanup_closed()
|
|
149
|
+
for key, conns in list(self.session.connector._conns.items()):
|
|
150
|
+
for conn, _ in list(conns):
|
|
151
|
+
if not conn:
|
|
152
|
+
continue
|
|
153
|
+
transport = getattr(conn, "transport", None)
|
|
154
|
+
if not transport:
|
|
155
|
+
# 没 transport -> 真死连接
|
|
156
|
+
continue
|
|
157
|
+
if transport.is_closing():
|
|
158
|
+
# socket 已关闭
|
|
159
|
+
conn.close()
|
|
160
|
+
dead_conns += 1
|
|
161
|
+
if dead_conns > 0:
|
|
162
|
+
other_logger.warning(f"[RedisHttpClient] 清理 {dead_conns} 个死连接")
|
|
163
|
+
|
|
164
|
+
async def close(self):
|
|
165
|
+
"""关闭 aiohttp/requests session"""
|
|
166
|
+
if self.session and not self.session.closed:
|
|
167
|
+
await self.session.close()
|
|
168
|
+
if self.sync_session:
|
|
169
|
+
self.sync_session.close()
|
|
170
|
+
|
|
171
|
+
# ------------------ async 部分 ------------------
|
|
172
|
+
async def _fetch_async(self, url, method="GET", data=None, headers=None, params=None, resp_type="json"):
|
|
173
|
+
await self._init_session()
|
|
174
|
+
|
|
175
|
+
start_time = time.time()
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
for attempt in range(self.max_tries):
|
|
179
|
+
session = self.session
|
|
180
|
+
if session is None or session.closed:
|
|
181
|
+
other_logger.warning("[RedisHttpClient] session closed before request, re-init...")
|
|
182
|
+
await self._recreate_session()
|
|
183
|
+
session = self.session
|
|
184
|
+
self._request_count += 1
|
|
185
|
+
if self._request_count % 500 == 0: # 每500次请求输出一次
|
|
186
|
+
other_logger.info(
|
|
187
|
+
f"[RedisHttpClient]链接池: 当前正在被使用的连接数量: {len(session.connector._acquired)} "
|
|
188
|
+
f"连接池中当前可用的空闲连接数量: {sum(len(conn_list) for conn_list in session.connector._conns.values())} "
|
|
189
|
+
f" 连接池允许的最大连接总数: {session.connector.limit} "
|
|
190
|
+
)
|
|
191
|
+
if self._request_count % 200 == 0:
|
|
192
|
+
await self._check_pool_health()
|
|
193
|
+
try:
|
|
194
|
+
async with session.request(method, url, headers=headers, params=params, json=data,
|
|
195
|
+
ssl=False) as response:
|
|
196
|
+
if response.status != 200:
|
|
197
|
+
text = await response.text()
|
|
198
|
+
# 服务端400但包含Redis错误 → 说明是可恢复的
|
|
199
|
+
if response.status == 400 and any(
|
|
200
|
+
k in text.lower() for k in
|
|
201
|
+
["redis", "connection", "timeout", "broken", "db", "error"]
|
|
202
|
+
):
|
|
203
|
+
other_logger.warning(
|
|
204
|
+
f"[RedisHttpClient] retryable 400: {text[:200]} ({attempt + 1}/{self.max_tries})"
|
|
205
|
+
)
|
|
206
|
+
await asyncio.sleep(0.3 * (attempt + 1))
|
|
207
|
+
continue
|
|
208
|
+
# 其他400、403、404不重试
|
|
209
|
+
raise Exception(f"[HTTP {response.status}] {text}")
|
|
210
|
+
if resp_type == "json":
|
|
211
|
+
result = await response.json()
|
|
212
|
+
elif resp_type == "text":
|
|
213
|
+
result = await response.text()
|
|
214
|
+
elif resp_type == "bytes":
|
|
215
|
+
result = await response.read()
|
|
216
|
+
duration = (time.time() - start_time) * 1000
|
|
217
|
+
# other_logger.info(f"[RedisHttpClient] {url} ok, cost={duration:.2f}ms")
|
|
218
|
+
return result
|
|
219
|
+
except RuntimeError as e:
|
|
220
|
+
if "Session is closed" in str(e):
|
|
221
|
+
other_logger.warning(
|
|
222
|
+
f"[RedisHttpClient] session closed mid-flight ({attempt + 1}/3); recreating")
|
|
223
|
+
await self._recreate_session()
|
|
224
|
+
await asyncio.sleep(0.2)
|
|
225
|
+
continue
|
|
226
|
+
raise
|
|
227
|
+
|
|
228
|
+
except asyncio.TimeoutError:
|
|
229
|
+
other_logger.warning(f"[RedisHttpClient] TimeoutError ({attempt + 1}/{self.max_tries}): {url}")
|
|
230
|
+
await asyncio.sleep(0.3 * (attempt + 1))
|
|
231
|
+
continue
|
|
232
|
+
except asyncio.CancelledError:
|
|
233
|
+
other_logger.warning(f"[RedisHttpClient] CancelledError ({attempt + 1}/{self.max_tries}): {url}")
|
|
234
|
+
raise # 不要吞掉取消异常
|
|
235
|
+
except (ServerDisconnectedError, ClientOSError, ClientConnectionError, ClientPayloadError) as e:
|
|
236
|
+
other_logger.warning(
|
|
237
|
+
f"[RedisHttpClient] 'ServerDisconnectedError, ClientOSError, ClientConnectionError, ClientPayloadError'"
|
|
238
|
+
f" ({attempt + 1}/{self.max_tries}): {url}")
|
|
239
|
+
await asyncio.sleep(0.3 * (attempt + 1))
|
|
240
|
+
if attempt == self.max_tries - 2:
|
|
241
|
+
await self._recreate_session()
|
|
242
|
+
else:
|
|
243
|
+
await self._check_pool_health()
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
raise Exception(f"[aiohttp] Failed after {self.max_tries} retries, url={url}")
|
|
247
|
+
except Exception as e:
|
|
248
|
+
other_logger.error(f'[RedisHttpClient] Unhandled error: {traceback.format_exc()}')
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
# ------------------ sync 部分 ------------------
|
|
252
|
+
def _fetch_sync(self, url: str, method: str = "GET", data=None, headers=None,
|
|
253
|
+
params=None, resp_type: str = "json"):
|
|
254
|
+
if not self.sync_session:
|
|
255
|
+
raise RuntimeError("Sync session not initialized")
|
|
256
|
+
try:
|
|
257
|
+
for i in range(3):
|
|
258
|
+
try:
|
|
259
|
+
resp = self.sync_session.request(
|
|
260
|
+
method, url,
|
|
261
|
+
headers=headers,
|
|
262
|
+
params=params,
|
|
263
|
+
json=data,
|
|
264
|
+
timeout=(10, 60), # 连接10s,读取60s(你原来60,60也行)
|
|
265
|
+
verify=False
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# 关键:先把非 200 的情况打印出来
|
|
269
|
+
if resp.status_code != 200:
|
|
270
|
+
ct = resp.headers.get("Content-Type", "")
|
|
271
|
+
other_logger.error(
|
|
272
|
+
f"[RedisHttpClient][sync] HTTP {resp.status_code} url={url} "
|
|
273
|
+
f"Content-Type={ct} body={resp.text[:500]!r}"
|
|
274
|
+
)
|
|
275
|
+
# 让 Retry/上层逻辑决定是否重试
|
|
276
|
+
time.sleep(0.3 * (i + 1))
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
if resp_type == "json":
|
|
280
|
+
# 关键:body 为空直接返回 None(避免 JSONDecodeError)
|
|
281
|
+
if not resp.content:
|
|
282
|
+
other_logger.error(
|
|
283
|
+
f"[RedisHttpClient][sync] empty body url={url} headers={dict(resp.headers)}"
|
|
284
|
+
)
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
ct = resp.headers.get("Content-Type", "")
|
|
288
|
+
if "application/json" not in ct.lower():
|
|
289
|
+
other_logger.error(
|
|
290
|
+
f"[RedisHttpClient][sync] non-json content url={url} "
|
|
291
|
+
f"Content-Type={ct} body={resp.text[:500]!r}"
|
|
292
|
+
)
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
return resp.json()
|
|
296
|
+
|
|
297
|
+
elif resp_type == "text":
|
|
298
|
+
return resp.text
|
|
299
|
+
elif resp_type == "bytes":
|
|
300
|
+
return resp.content
|
|
301
|
+
else:
|
|
302
|
+
raise ValueError("Invalid resp_type")
|
|
303
|
+
|
|
304
|
+
except Exception as e:
|
|
305
|
+
other_logger.warning(f"[RedisHttpClient][sync] retry {i + 1}/3 {type(e).__name__}: {e}")
|
|
306
|
+
other_logger.warning(f"[RedisHttpClient][sync] error {traceback.format_exc()}")
|
|
307
|
+
try:
|
|
308
|
+
other_logger.info(
|
|
309
|
+
f"status={resp.status_code} "
|
|
310
|
+
f"ct={resp.headers.get('Content-Type')} "
|
|
311
|
+
f"ce={resp.headers.get('Content-Encoding')} "
|
|
312
|
+
f"len={len(resp.content)} "
|
|
313
|
+
f"first16={resp.content[:16]!r}"
|
|
314
|
+
f"data={data}"
|
|
315
|
+
)
|
|
316
|
+
except Exception as e:
|
|
317
|
+
pass
|
|
318
|
+
# 关键:清理可能的“半死连接”
|
|
319
|
+
time.sleep(0.3 * (i + 1))
|
|
320
|
+
continue
|
|
321
|
+
|
|
322
|
+
except Exception as e:
|
|
323
|
+
other_logger.info('[RedisHttpClient] unhandled error: {}'.format(traceback.format_exc()))
|
|
324
|
+
return {}
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
# ------------------ 动态方法代理 ------------------
|
|
328
|
+
def __getattr__(self, command: str):
|
|
329
|
+
if self.mode == "async":
|
|
330
|
+
async def wrapper(*args, **kwargs):
|
|
331
|
+
try:
|
|
332
|
+
return await self._request_redis_exec_async(command, list(args), kwargs)
|
|
333
|
+
except Exception as e:
|
|
334
|
+
other_logger.error(f"Redis async command error [{command}]: {traceback.format_exc()}")
|
|
335
|
+
return None # 返回 None 或自定义默认值,防止抛出异常
|
|
336
|
+
|
|
337
|
+
return wrapper
|
|
338
|
+
else:
|
|
339
|
+
def wrapper(*args, **kwargs):
|
|
340
|
+
try:
|
|
341
|
+
return self._request_redis_exec_sync(command, list(args), kwargs)
|
|
342
|
+
except Exception as e:
|
|
343
|
+
other_logger.error(f"Redis sync command error [{command}]: {traceback.format_exc()}")
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
return wrapper
|
|
347
|
+
|
|
348
|
+
def _maybe_json_decode(self, obj):
|
|
349
|
+
"""安全地对嵌套的 JSON 字符串进行一次解包"""
|
|
350
|
+
if isinstance(obj, str):
|
|
351
|
+
s = obj.strip()
|
|
352
|
+
if s and s[0] in '[{' and s[-1] in ']}':
|
|
353
|
+
try:
|
|
354
|
+
return json.loads(s)
|
|
355
|
+
except Exception:
|
|
356
|
+
return obj
|
|
357
|
+
return obj
|
|
358
|
+
|
|
359
|
+
# ------------------ redis 执行方法 ------------------
|
|
360
|
+
async def _request_redis_exec_async(self, command: str, args_list: list, kwargs: dict = None):
|
|
361
|
+
headers = {"accept": "application/json", "Content-Type": "application/json"}
|
|
362
|
+
data = {
|
|
363
|
+
"command": command,
|
|
364
|
+
"args": args_list,
|
|
365
|
+
"kwargs": kwargs or {}
|
|
366
|
+
}
|
|
367
|
+
result = await self._real_exec(data, headers)
|
|
368
|
+
return result
|
|
369
|
+
|
|
370
|
+
def _request_redis_exec_sync(self, command: str, args_list: list, kwargs: dict = None):
|
|
371
|
+
headers = {"accept": "application/json", "Content-Type": "application/json", "Connection": "close"}
|
|
372
|
+
data = {
|
|
373
|
+
"command": command,
|
|
374
|
+
"args": args_list,
|
|
375
|
+
"kwargs": kwargs or {}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
result = self._fetch_sync(
|
|
379
|
+
self.base_url, "POST", data=data, headers=headers,
|
|
380
|
+
resp_type="json"
|
|
381
|
+
)
|
|
382
|
+
return result
|
|
383
|
+
|
|
384
|
+
async def redis_pipeline_async(self, commands: list):
|
|
385
|
+
"""
|
|
386
|
+
commands: [
|
|
387
|
+
{"command": "GETBIT", "args": ["my_bloom_key", 1234], "kwargs": {}},
|
|
388
|
+
{"command": "GETBIT", "args": ["my_bloom_key", 4567], "kwargs": {}}
|
|
389
|
+
]
|
|
390
|
+
"""
|
|
391
|
+
headers = {"accept": "application/json", "Content-Type": "application/json"}
|
|
392
|
+
data = {"commands": []}
|
|
393
|
+
for cmd in commands:
|
|
394
|
+
data["commands"].append({
|
|
395
|
+
"command": cmd["command"],
|
|
396
|
+
"args": cmd.get("args", []),
|
|
397
|
+
"kwargs": cmd.get("kwargs", {})
|
|
398
|
+
})
|
|
399
|
+
result = await self._real_pipeline(data, headers)
|
|
400
|
+
if len(commands) > 1:
|
|
401
|
+
converted = []
|
|
402
|
+
for c, r in zip(commands, result):
|
|
403
|
+
converted.append(self._maybe_json_decode(r))
|
|
404
|
+
return converted
|
|
405
|
+
else:
|
|
406
|
+
maybe = self._maybe_json_decode(result[0])
|
|
407
|
+
if isinstance(maybe, (list, dict)):
|
|
408
|
+
result = maybe
|
|
409
|
+
return result
|
|
410
|
+
|
|
411
|
+
def redis_pipeline_sync(self, commands: list):
|
|
412
|
+
headers = {"accept": "application/json", "Content-Type": "application/json", "Connection": "close"}
|
|
413
|
+
data = {"commands": []}
|
|
414
|
+
for cmd in commands:
|
|
415
|
+
data["commands"].append({
|
|
416
|
+
"command": cmd["command"],
|
|
417
|
+
"args": cmd.get("args", []),
|
|
418
|
+
"kwargs": cmd.get("kwargs", {})
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
result = self._fetch_sync(
|
|
422
|
+
self.pipeline_url, "POST", data=data, headers=headers, resp_type="json"
|
|
423
|
+
)
|
|
424
|
+
if len(commands) > 1:
|
|
425
|
+
converted = []
|
|
426
|
+
for c, r in zip(commands, result):
|
|
427
|
+
converted.append(self._maybe_json_decode(r))
|
|
428
|
+
return converted
|
|
429
|
+
else:
|
|
430
|
+
maybe = self._maybe_json_decode(result[0])
|
|
431
|
+
if isinstance(maybe, (list, dict)):
|
|
432
|
+
result = maybe
|
|
433
|
+
return result
|
|
434
|
+
|
|
435
|
+
async def _real_pipeline(self, data, headers):
|
|
436
|
+
return await self._fetch_async(
|
|
437
|
+
self.pipeline_url, "POST", data=data, headers=headers, resp_type="json"
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
async def _real_exec(self, data, headers):
|
|
441
|
+
return await self._fetch_async(
|
|
442
|
+
self.base_url, "POST", data=data, headers=headers, resp_type="json"
|
|
443
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class OtherLogger(object):
|
|
2
|
+
def __init__(self, types='[other_logger]'):
|
|
3
|
+
import sys
|
|
4
|
+
from loguru import logger
|
|
5
|
+
logger.remove()
|
|
6
|
+
logger.add(sys.stderr, format="{time:YYYY-MM-DD HH:mm:ss} {extra[types]} {level}: {message}", enqueue=True)
|
|
7
|
+
self.logger = logger.bind(types=types)
|
|
8
|
+
|
|
9
|
+
def info(self, message):
|
|
10
|
+
self.logger.info(message)
|
|
11
|
+
|
|
12
|
+
def error(self, message):
|
|
13
|
+
self.logger.error(message)
|
|
14
|
+
|
|
15
|
+
def warning(self, message):
|
|
16
|
+
self.logger.warning(message)
|
|
17
|
+
|
|
18
|
+
def debug(self, message):
|
|
19
|
+
self.logger.debug(message)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
other_logger = OtherLogger()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import asyncio, threading, os, time
|
|
2
|
+
|
|
3
|
+
'''额外起一个子线程,用于跑其他的loop
|
|
4
|
+
为了解决scrapy线程多层loop嵌套的情况。,loop用的过多,没有剩余的loop去处理redis-http-client的请求,导致超时
|
|
5
|
+
'''
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SubLoopRunner:
|
|
9
|
+
def __init__(self):
|
|
10
|
+
self.loop = None
|
|
11
|
+
|
|
12
|
+
def _build(self):
|
|
13
|
+
self.pid = os.getpid()
|
|
14
|
+
self.loop = asyncio.new_event_loop()
|
|
15
|
+
self.thread = threading.Thread(target=self._run, daemon=True, name=f"SubLoop-{self.pid}")
|
|
16
|
+
self.thread.start()
|
|
17
|
+
# 等到 loop 真跑起来
|
|
18
|
+
for _ in range(100):
|
|
19
|
+
"""避免loop未被创建"""
|
|
20
|
+
if self.loop.is_running():
|
|
21
|
+
break
|
|
22
|
+
time.sleep(0.1)
|
|
23
|
+
if not self.loop.is_running():
|
|
24
|
+
raise RuntimeError("SubLoop failed to start")
|
|
25
|
+
|
|
26
|
+
def _ensure_alive(self):
|
|
27
|
+
"""不再init时初始化loop,避免 在主进程创建 loop,否则爬虫启动后,主进程fork后的子进程无法拿到fork后loop线程,只能拿到subloop_runner对象,
|
|
28
|
+
|
|
29
|
+
"""
|
|
30
|
+
if self.loop is None or os.getpid() != self.pid or self.loop.is_closed():
|
|
31
|
+
try:
|
|
32
|
+
# 尝试优雅停止旧 loop(若在子进程调用,这里多半是 no-op)
|
|
33
|
+
self.loop.call_soon_threadsafe(self.loop.stop)
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
self._build()
|
|
37
|
+
|
|
38
|
+
def _run(self):
|
|
39
|
+
"""
|
|
40
|
+
在子线程中运行事件循环的主循环(loop.run_forever()),让 loop 真正处理异步任务
|
|
41
|
+
就相当于是「子线程中的 asyncio 事件调度器」
|
|
42
|
+
|
|
43
|
+
"""
|
|
44
|
+
asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy())
|
|
45
|
+
|
|
46
|
+
asyncio.set_event_loop(self.loop)
|
|
47
|
+
self.loop.run_forever()
|
|
48
|
+
|
|
49
|
+
def run_coroutine(self, coro):
|
|
50
|
+
"""run_coroutine_threadsafe 此方法线程安全"""
|
|
51
|
+
self._ensure_alive()
|
|
52
|
+
return asyncio.run_coroutine_threadsafe(coro, self.loop)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
subloop_runner = SubLoopRunner()
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import platform
|
|
4
|
+
import aiohttp
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def to_str(value):
|
|
8
|
+
if value is None:
|
|
9
|
+
return None
|
|
10
|
+
return str(value)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def to_int(value):
|
|
14
|
+
if value is None:
|
|
15
|
+
return None
|
|
16
|
+
return int(value)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def to_bool(value):
|
|
20
|
+
if value is None:
|
|
21
|
+
return None
|
|
22
|
+
if isinstance(value, bool):
|
|
23
|
+
return value
|
|
24
|
+
if isinstance(value, (int, str)):
|
|
25
|
+
return str(value).lower() in ("1", "true", "ok")
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def to_set(value):
|
|
30
|
+
if value is None:
|
|
31
|
+
return set()
|
|
32
|
+
return set(value)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def to_list(value):
|
|
36
|
+
if value is None:
|
|
37
|
+
return []
|
|
38
|
+
return list(value)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _unpack_cursor_items(value, default_items):
|
|
42
|
+
if not value:
|
|
43
|
+
return 0, default_items
|
|
44
|
+
cursor = value[0]
|
|
45
|
+
try:
|
|
46
|
+
cursor = int(cursor)
|
|
47
|
+
except Exception:
|
|
48
|
+
# 有的实现返回的是字符串 '0'
|
|
49
|
+
cursor = int(str(cursor))
|
|
50
|
+
items = value[1] if len(value) > 1 else default_items
|
|
51
|
+
return cursor, items
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def to_scan(value):
|
|
55
|
+
# SCAN -> (cursor, [keys...])
|
|
56
|
+
cursor, items = _unpack_cursor_items(value, [])
|
|
57
|
+
# 确保是 list
|
|
58
|
+
return cursor, list(items) if items is not None else []
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def to_sscan(value):
|
|
62
|
+
# SSCAN -> (cursor, [members...])
|
|
63
|
+
cursor, items = _unpack_cursor_items(value, [])
|
|
64
|
+
return cursor, list(items) if items is not None else []
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def to_hscan(value):
|
|
68
|
+
# HSCAN -> (cursor, {field: value}) 或 (cursor, [f1, v1, f2, v2, ...])
|
|
69
|
+
cursor, items = _unpack_cursor_items(value, {})
|
|
70
|
+
if isinstance(items, dict):
|
|
71
|
+
return cursor, items
|
|
72
|
+
# 兼容交替列表形式
|
|
73
|
+
items = list(items) if items is not None else []
|
|
74
|
+
if len(items) % 2 != 0:
|
|
75
|
+
# 长度异常时,尽力配对,多出来的最后一个丢弃或给 None(这里选择丢弃)
|
|
76
|
+
items = items[:len(items) // 2 * 2]
|
|
77
|
+
return cursor, dict(zip(items[0::2], items[1::2]))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def to_zscan(value):
|
|
81
|
+
# ZSCAN -> (cursor, [(member, score), ...]) 或 (cursor, [member, score, ...])
|
|
82
|
+
cursor, items = _unpack_cursor_items(value, [])
|
|
83
|
+
# 常见两种格式:list-of-tuples 或 扁平交替列表
|
|
84
|
+
# 统一成 list[(member, float(score))]
|
|
85
|
+
if isinstance(items, dict):
|
|
86
|
+
# 极少数实现可能给 dict;统一转 list
|
|
87
|
+
pairs = [(m, float(s)) for m, s in items.items()]
|
|
88
|
+
return cursor, pairs
|
|
89
|
+
|
|
90
|
+
items = list(items) if items is not None else []
|
|
91
|
+
if items and not isinstance(items[0], (list, tuple)):
|
|
92
|
+
# 扁平交替:[member, score, member, score, ...]
|
|
93
|
+
if len(items) % 2 != 0:
|
|
94
|
+
items = items[:len(items) // 2 * 2]
|
|
95
|
+
it = iter(items)
|
|
96
|
+
pairs = []
|
|
97
|
+
for m, s in zip(it, it):
|
|
98
|
+
try:
|
|
99
|
+
pairs.append((m, float(s)))
|
|
100
|
+
except Exception:
|
|
101
|
+
# 分数解析失败时保持原样
|
|
102
|
+
pairs.append((m, s))
|
|
103
|
+
return cursor, pairs
|
|
104
|
+
else:
|
|
105
|
+
# 已经是 [(member, score), ...] 形式
|
|
106
|
+
pairs = []
|
|
107
|
+
for m, s in items:
|
|
108
|
+
try:
|
|
109
|
+
pairs.append((m, float(s)))
|
|
110
|
+
except Exception:
|
|
111
|
+
pairs.append((m, s))
|
|
112
|
+
return cursor, pairs
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def to_eval(value):
|
|
116
|
+
if isinstance(value, str):
|
|
117
|
+
try:
|
|
118
|
+
return json.loads(value)
|
|
119
|
+
except Exception:
|
|
120
|
+
return value
|
|
121
|
+
return value
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def to_zrange(value):
|
|
125
|
+
# 可能是 list[str] / list[ [member, score], ... ] / 扁平 ["m1", 1.0, "m2", 2.0] / 或者单个字符串
|
|
126
|
+
if value is None:
|
|
127
|
+
return []
|
|
128
|
+
|
|
129
|
+
# 已是字符串(且没被 _maybe_json_decode 解开),按单元素处理,避免被 list() 拆字符
|
|
130
|
+
if isinstance(value, str):
|
|
131
|
+
return [value]
|
|
132
|
+
|
|
133
|
+
if isinstance(value, list):
|
|
134
|
+
# 形如 [["a", 1.0], ["b", 2.0]]
|
|
135
|
+
if all(isinstance(i, list) and len(i) == 2 for i in value):
|
|
136
|
+
return [(str(k), float(v)) for k, v in value]
|
|
137
|
+
# 形如 ["a", 1.0, "b", 2.0]
|
|
138
|
+
if len(value) % 2 == 0 and any(isinstance(v, (int, float)) for v in value[1::2]):
|
|
139
|
+
return list(zip(map(str, value[0::2]), map(float, value[1::2])))
|
|
140
|
+
# 纯成员列表 ["a","b","c"]
|
|
141
|
+
return [str(x) for x in value]
|
|
142
|
+
|
|
143
|
+
# 其它情况兜底成列表
|
|
144
|
+
return [value]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
REDIS_TYPE_MAP = {
|
|
148
|
+
# === String 相关 ===
|
|
149
|
+
"get": to_str, # key 不存在 -> None
|
|
150
|
+
"set": to_bool, # "OK" -> True
|
|
151
|
+
"incr": to_int, # 自增后的值
|
|
152
|
+
"incrby": to_int, # 自增后的值
|
|
153
|
+
"getbit": to_int, # 返回 0 / 1
|
|
154
|
+
"setbit": to_int,
|
|
155
|
+
"strlen": to_int, # 字符串长度
|
|
156
|
+
"bitcount": to_int, # bit count
|
|
157
|
+
|
|
158
|
+
# === Key 通用 ===
|
|
159
|
+
"delete": to_int, # 删除 key 个数
|
|
160
|
+
"exists": to_int, # 存在的 key 个数
|
|
161
|
+
"expire": to_bool, # True/False
|
|
162
|
+
"keys": to_list, # 列表
|
|
163
|
+
"ttl": to_int, # 剩余过期秒数
|
|
164
|
+
|
|
165
|
+
# === Hash ===
|
|
166
|
+
"hset": to_int, # 新增/修改数量
|
|
167
|
+
"hget": to_str, # 不存在 -> None
|
|
168
|
+
"hdel": to_int, # 删除字段数
|
|
169
|
+
"hincrby": to_int,
|
|
170
|
+
"hexists": to_bool,
|
|
171
|
+
"hscan": to_hscan,
|
|
172
|
+
# "hgetall": to_dict, # dict
|
|
173
|
+
"hlen": to_int,
|
|
174
|
+
"hkeys": to_list,
|
|
175
|
+
"hvals": to_list,
|
|
176
|
+
|
|
177
|
+
# === Set ===
|
|
178
|
+
"sadd": to_int,
|
|
179
|
+
"smembers": to_set,
|
|
180
|
+
"sismember": to_int,
|
|
181
|
+
"srem": to_int,
|
|
182
|
+
"spop": to_str, # 单个值(批量 -> list)
|
|
183
|
+
"srandmember": to_str, # 单个值(批量 -> list)
|
|
184
|
+
"scard": to_int,
|
|
185
|
+
"sscan": to_sscan,
|
|
186
|
+
"sdiff": to_set,
|
|
187
|
+
|
|
188
|
+
# === List ===
|
|
189
|
+
"lpush": to_int,
|
|
190
|
+
"rpush": to_int,
|
|
191
|
+
"lpop": to_str,
|
|
192
|
+
"rpop": to_str,
|
|
193
|
+
"lrange": to_list,
|
|
194
|
+
"llen": to_int,
|
|
195
|
+
"lindex": to_str,
|
|
196
|
+
"rpoplpush": to_str,
|
|
197
|
+
"lrem": to_int,
|
|
198
|
+
|
|
199
|
+
# === ZSet ===
|
|
200
|
+
"zadd": to_int,
|
|
201
|
+
"zremrangebyrank": to_int,
|
|
202
|
+
"zscore": to_str, # (可转 float)
|
|
203
|
+
"zremrangebyscore": to_int,
|
|
204
|
+
"zincrby": to_str, # (可转 float)
|
|
205
|
+
"zcount": to_int,
|
|
206
|
+
"zcard": to_int,
|
|
207
|
+
"zrem": to_int,
|
|
208
|
+
"zscan": to_zscan,
|
|
209
|
+
"zrange": to_zrange,
|
|
210
|
+
"zrangebyscore": to_zrange,
|
|
211
|
+
|
|
212
|
+
# === 脚本 / 特殊 ===
|
|
213
|
+
"eval": to_eval,
|
|
214
|
+
"scan": to_scan,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def create_async_session(self):
|
|
219
|
+
"""创建同步的session"""
|
|
220
|
+
connector = aiohttp.TCPConnector(
|
|
221
|
+
limit=self.limit,
|
|
222
|
+
ttl_dns_cache=60,
|
|
223
|
+
enable_cleanup_closed=True,
|
|
224
|
+
force_close=False,
|
|
225
|
+
ssl=False,
|
|
226
|
+
)
|
|
227
|
+
timeout = aiohttp.ClientTimeout(
|
|
228
|
+
total=120, # 容忍整体请求更久
|
|
229
|
+
connect=10, # 连接最长 10s
|
|
230
|
+
sock_connect=10,
|
|
231
|
+
sock_read=100 # Redis pipeline 大响应时给足时间
|
|
232
|
+
)
|
|
233
|
+
return aiohttp.ClientSession(connector=connector, timeout=timeout)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
SYSTEM_NAME = platform.system().lower()
|
|
237
|
+
# 等待删除
|
|
238
|
+
CUR_DIR = os.path.abspath(os.path.realpath(os.path.dirname(__file__)))
|
|
239
|
+
if SYSTEM_NAME in ["windows", "darwin"]:
|
|
240
|
+
local_env = True
|
|
241
|
+
else:
|
|
242
|
+
local_env = False
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: redis-http-client
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A Redis client over HTTP supporting both sync (requests) and async (aiohttp) modes.
|
|
5
|
+
Author-email: victor <xianyu.wu@innodealing.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, http://git.innodealing.cn/dm_spider_service/redis-http-client.git
|
|
8
|
+
Project-URL: Source, http://git.innodealing.cn/dm_spider_service/redis-http-client.git
|
|
9
|
+
Project-URL: Issues, http://git.innodealing.cn/dm_spider_service/redis-http-client.git/issues
|
|
10
|
+
Keywords: redis,http,client,asyncio,requests,aiohttp
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Topic :: Database
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: aiohttp>=3.9.0
|
|
25
|
+
Requires-Dist: requests>=2.31.0
|
|
26
|
+
Requires-Dist: urllib3>=2.0.0
|
|
27
|
+
Requires-Dist: loguru>=0.7.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: build>=1.2.1; extra == "dev"
|
|
30
|
+
Requires-Dist: twine>=5.1.0; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# redis-http-client
|
|
34
|
+
|
|
35
|
+
A lightweight Redis client over HTTP, supporting both async (`aiohttp`) and sync (`requests`) execution modes.
|
|
36
|
+
|
|
37
|
+
## Features
|
|
38
|
+
|
|
39
|
+
- Async mode based on `aiohttp`
|
|
40
|
+
- Sync mode based on `requests`
|
|
41
|
+
- Dynamic Redis command proxy via `__getattr__`
|
|
42
|
+
- Pipeline support (`redis_pipeline_async` / `redis_pipeline_sync`)
|
|
43
|
+
- Built-in retry and connection-pool handling
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install redis-http-client
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quick Start
|
|
52
|
+
|
|
53
|
+
### Async
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import asyncio
|
|
57
|
+
from redis_http_client import RedisHttpClient
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def main():
|
|
61
|
+
client = RedisHttpClient(
|
|
62
|
+
spider_redis_manager_service_url="http://127.0.0.1:8000",
|
|
63
|
+
mode="async",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
result = await client.get("my_key")
|
|
67
|
+
print(result)
|
|
68
|
+
|
|
69
|
+
await client.close()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
asyncio.run(main())
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Sync
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from redis_http_client import RedisHttpClient
|
|
79
|
+
|
|
80
|
+
client = RedisHttpClient(
|
|
81
|
+
spider_redis_manager_service_url="http://127.0.0.1:8000",
|
|
82
|
+
mode="sync",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
result = client.get("my_key")
|
|
86
|
+
print(result)
|
|
87
|
+
|
|
88
|
+
# sync mode reuses internal requests session; close when done
|
|
89
|
+
import asyncio
|
|
90
|
+
asyncio.run(client.close())
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Pipeline
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
commands = [
|
|
97
|
+
{"command": "GET", "args": ["k1"], "kwargs": {}},
|
|
98
|
+
{"command": "GET", "args": ["k2"], "kwargs": {}},
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
# async
|
|
102
|
+
# results = await client.redis_pipeline_async(commands)
|
|
103
|
+
|
|
104
|
+
# sync
|
|
105
|
+
# results = client.redis_pipeline_sync(commands)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Build
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
python -m pip install --upgrade build twine
|
|
112
|
+
python -m build
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Build outputs are generated in `dist/`.
|
|
116
|
+
|
|
117
|
+
## Upload To PyPI
|
|
118
|
+
|
|
119
|
+
### 1. TestPyPI (recommended first)
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
twine check dist/*
|
|
123
|
+
twine upload -r testpypi dist/*
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### 2. PyPI
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
twine upload dist/*
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
You can use API token authentication:
|
|
133
|
+
|
|
134
|
+
- username: `__token__`
|
|
135
|
+
- password: `pypi-xxxx...`
|
|
136
|
+
|
|
137
|
+
## Versioning
|
|
138
|
+
|
|
139
|
+
Current version: `0.1.1`
|
|
140
|
+
|
|
141
|
+
When releasing a new version:
|
|
142
|
+
|
|
143
|
+
1. Update `version` in `pyproject.toml`
|
|
144
|
+
2. Update `__version__` in `redis_http_client/__init__.py`
|
|
145
|
+
3. Rebuild and upload
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
redis_http_client/__init__.py,sha256=M_wG2m6zdekKIjW8vdJfEIOhW8PL3F0A3NOzSlakju0,89
|
|
2
|
+
redis_http_client/client.py,sha256=aeGtmYWxshUGK2h8Ep5JaBrpzlV_ffHjb3kyU0S2fPg,19824
|
|
3
|
+
redis_http_client/other_logger.py,sha256=2u9jPJO8xdvCoJdVrmOwTdV6eyymi3mphCd3vE5tItE,612
|
|
4
|
+
redis_http_client/sub_run.py,sha256=wxwrqnLxjfq631By8dXRYOfClR3fO3iZQa0pkpridO8,1999
|
|
5
|
+
redis_http_client/to_method.py,sha256=l0tag9ZW080LEJAaPPMm7dpddccIGu6qHEEiw-ZblJE,6701
|
|
6
|
+
redis_http_client-0.1.1.dist-info/licenses/LICENSE,sha256=-2JYyW4-MV-uO1CCQBNTsdszf692hXmJ4kZwClcDcYU,1063
|
|
7
|
+
redis_http_client-0.1.1.dist-info/METADATA,sha256=37MUI37lzgYtOL20pcBiSnlu4fPe3T31L2RnZVZ1BFE,3380
|
|
8
|
+
redis_http_client-0.1.1.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
|
|
9
|
+
redis_http_client-0.1.1.dist-info/top_level.txt,sha256=kd-xEfx6RmrvD6RzdYVbCu28y6GhY0Vz4kFTvfVuw_A,18
|
|
10
|
+
redis_http_client-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 victor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
redis_http_client
|