tretool 0.2.1__py3-none-any.whl → 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.
tretool/httplib.py ADDED
@@ -0,0 +1,730 @@
1
+ import urllib.request
2
+ import urllib.parse
3
+ import urllib.error
4
+ import json
5
+ import time
6
+ import os
7
+ import mimetypes
8
+ import http.cookiejar
9
+ import socket
10
+ from http.client import HTTPResponse
11
+ from typing import Optional, Dict, Union, Any, List, Tuple, Iterator
12
+ from concurrent.futures import Future
13
+ from dataclasses import dataclass
14
+
15
+
16
+ class NetworkError(Exception):
17
+ """网络请求异常基类"""
18
+ pass
19
+
20
+
21
+ class RetryExhaustedError(NetworkError):
22
+ """重试次数耗尽异常"""
23
+ pass
24
+
25
+
26
+ class TimeoutError(NetworkError):
27
+ """请求超时异常"""
28
+ pass
29
+
30
+
31
+ class AuthError(NetworkError):
32
+ """认证失败异常"""
33
+ pass
34
+
35
+
36
+ @dataclass
37
+ class RetryConfig:
38
+ """重试配置"""
39
+ max_retries: int = 3
40
+ backoff_factor: float = 0.1
41
+ retry_on: Tuple[int, ...] = (500, 502, 503, 504)
42
+
43
+
44
+ class HttpResponse:
45
+ """HTTP响应封装类"""
46
+
47
+ def __init__(self, response: HTTPResponse):
48
+ self.status_code = response.status
49
+ self.headers = dict(response.getheaders())
50
+ self._content = response.read()
51
+ self._response = response
52
+ self.url = response.geturl()
53
+
54
+ @property
55
+ def content(self) -> bytes:
56
+ """获取原始字节内容"""
57
+ return self._content
58
+
59
+ @property
60
+ def text(self) -> str:
61
+ """获取文本内容(UTF-8解码)"""
62
+ return self._content.decode('utf-8')
63
+
64
+ def json(self) -> Any:
65
+ """解析JSON内容"""
66
+ return json.loads(self.text)
67
+
68
+ def close(self):
69
+ """关闭响应"""
70
+ self._response.close()
71
+
72
+ def __str__(self) -> str:
73
+ return f"<HttpResponse [{self.status_code}]>"
74
+
75
+
76
+ class HttpClient:
77
+ """
78
+ 增强版HTTP客户端
79
+ 基于urllib标准库实现,支持多种高级功能
80
+ """
81
+
82
+ def __init__(self,
83
+ base_url: str = "",
84
+ timeout: float = 10.0,
85
+ retry_config: Optional[RetryConfig] = None,
86
+ proxy: Optional[Dict[str, str]] = None,
87
+ auth: Optional[Tuple[str, str]] = None,
88
+ cookie_jar: Optional[http.cookiejar.CookieJar] = None):
89
+ """
90
+ 初始化HTTP客户端
91
+
92
+ :param base_url: 基础URL,所有请求会基于此URL
93
+ :param timeout: 请求超时时间(秒)
94
+ :param retry_config: 重试配置
95
+ :param proxy: 代理配置,如 {'http': 'http://proxy.example.com:8080'}
96
+ :param auth: 基本认证 (username, password)
97
+ :param cookie_jar: Cookie存储对象
98
+ """
99
+ self.base_url = base_url.rstrip('/')
100
+ self.timeout = timeout
101
+ self.retry_config = retry_config or RetryConfig()
102
+ self.proxy = proxy
103
+ self.auth = auth
104
+ self.cookie_jar = cookie_jar
105
+
106
+ # 初始化opener
107
+ self._build_opener()
108
+
109
+ self.default_headers = {
110
+ 'User-Agent': 'Python HttpClient/2.0',
111
+ 'Accept': 'application/json',
112
+ }
113
+
114
+ def _build_opener(self):
115
+ """构建urllib opener"""
116
+ handlers = []
117
+
118
+ # 添加代理支持
119
+ if self.proxy:
120
+ proxy_handler = urllib.request.ProxyHandler(self.proxy)
121
+ handlers.append(proxy_handler)
122
+
123
+ # 添加Cookie支持
124
+ if self.cookie_jar:
125
+ cookie_handler = urllib.request.HTTPCookieProcessor(self.cookie_jar)
126
+ handlers.append(cookie_handler)
127
+
128
+ # 添加基本认证支持
129
+ if self.auth:
130
+ password_mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
131
+ password_mgr.add_password(None, self.base_url, self.auth[0], self.auth[1])
132
+ auth_handler = urllib.request.HTTPBasicAuthHandler(password_mgr)
133
+ handlers.append(auth_handler)
134
+
135
+ self.opener = urllib.request.build_opener(*handlers)
136
+
137
+ def _request(self,
138
+ method: str,
139
+ endpoint: str,
140
+ params: Optional[Dict] = None,
141
+ data: Optional[Union[Dict, str, bytes]] = None,
142
+ json_data: Optional[Any] = None,
143
+ headers: Optional[Dict] = None,
144
+ files: Optional[Dict[str, Union[str, Tuple[str, bytes]]]] = None,
145
+ timeout: Optional[float] = None) -> HttpResponse:
146
+ """
147
+ 内部请求方法
148
+
149
+ :param method: HTTP方法(GET, POST等)
150
+ :param endpoint: 请求端点
151
+ :param params: URL参数
152
+ :param data: 请求体数据
153
+ :param json_data: JSON格式的请求体
154
+ :param headers: 请求头
155
+ :param files: 要上传的文件 {'name': filepath} 或 {'name': ('filename', content)}
156
+ :param timeout: 本次请求超时时间(覆盖默认值)
157
+ :return: HttpResponse对象
158
+ :raises NetworkError: 当请求失败时抛出
159
+ """
160
+ url = f"{self.base_url}/{endpoint.lstrip('/')}"
161
+ headers = {**self.default_headers, **(headers or {})}
162
+ timeout = timeout or self.timeout
163
+
164
+ # 处理URL参数
165
+ if params:
166
+ url = f"{url}?{urllib.parse.urlencode(params)}"
167
+
168
+ # 处理请求体数据
169
+ body: Optional[bytes] = None
170
+ content_type = headers.get('Content-Type', '')
171
+
172
+ if files:
173
+ boundary = '----------boundary_' + str(int(time.time()))
174
+ headers['Content-Type'] = f'multipart/form-data; boundary={boundary}'
175
+ body = self._encode_multipart_formdata(files, boundary)
176
+ elif json_data is not None:
177
+ body = json.dumps(json_data).encode('utf-8')
178
+ headers['Content-Type'] = 'application/json'
179
+ elif data is not None:
180
+ if isinstance(data, dict):
181
+ body = urllib.parse.urlencode(data).encode('utf-8')
182
+ if 'Content-Type' not in headers:
183
+ headers['Content-Type'] = 'application/x-www-form-urlencoded'
184
+ elif isinstance(data, str):
185
+ body = data.encode('utf-8')
186
+ elif isinstance(data, bytes):
187
+ body = data
188
+
189
+ # 创建请求对象
190
+ req = urllib.request.Request(url, data=body, headers=headers, method=method.upper())
191
+
192
+ # 实现重试机制
193
+ last_exception = None
194
+ for attempt in range(self.retry_config.max_retries + 1):
195
+ try:
196
+ response = self.opener.open(req, timeout=timeout)
197
+ return HttpResponse(response)
198
+ except urllib.error.HTTPError as e:
199
+ last_exception = e
200
+ if e.code in (401, 403):
201
+ raise AuthError(f"认证失败: {e.code} {e.reason}") from e
202
+ if e.code not in self.retry_config.retry_on:
203
+ raise NetworkError(f"HTTP错误 {e.code}: {e.reason}") from e
204
+ except urllib.error.URLError as e:
205
+ last_exception = e
206
+ if isinstance(e.reason, socket.timeout):
207
+ raise TimeoutError(f"请求超时: {str(e)}") from e
208
+ except Exception as e:
209
+ last_exception = e
210
+ raise NetworkError(f"请求失败: {str(e)}") from e
211
+
212
+ # 如果还有重试机会,等待一段时间
213
+ if attempt < self.retry_config.max_retries:
214
+ sleep_time = self.retry_config.backoff_factor * (2 ** attempt)
215
+ time.sleep(sleep_time)
216
+
217
+ raise RetryExhaustedError(f"重试{self.retry_config.max_retries}次后仍然失败: {str(last_exception)}")
218
+
219
+ def _encode_multipart_formdata(self, files: Dict[str, Union[str, Tuple[str, bytes]]], boundary: str) -> bytes:
220
+ """编码multipart/form-data请求体"""
221
+ lines = []
222
+
223
+ for name, value in files.items():
224
+ if isinstance(value, tuple):
225
+ filename, content = value
226
+ else:
227
+ filename = os.path.basename(value)
228
+ with open(value, 'rb') as f:
229
+ content = f.read()
230
+
231
+ mime_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
232
+
233
+ lines.append(f'--{boundary}')
234
+ lines.append(f'Content-Disposition: form-data; name="{name}"; filename="{filename}"')
235
+ lines.append(f'Content-Type: {mime_type}')
236
+ lines.append('')
237
+ lines.append(content)
238
+
239
+ lines.append(f'--{boundary}--')
240
+ lines.append('')
241
+
242
+ return '\r\n'.join(lines).encode('utf-8')
243
+
244
+ # ========== HTTP方法 ==========
245
+ def get(self, endpoint: str, params: Optional[Dict] = None,
246
+ headers: Optional[Dict] = None, timeout: Optional[float] = None) -> HttpResponse:
247
+ """发送GET请求"""
248
+ return self._request('GET', endpoint, params=params, headers=headers, timeout=timeout)
249
+
250
+ def post(self, endpoint: str, data: Optional[Union[Dict, str, bytes]] = None,
251
+ json_data: Optional[Any] = None, headers: Optional[Dict] = None,
252
+ files: Optional[Dict] = None, timeout: Optional[float] = None) -> HttpResponse:
253
+ """发送POST请求"""
254
+ return self._request('POST', endpoint, data=data, json_data=json_data,
255
+ headers=headers, files=files, timeout=timeout)
256
+
257
+ def put(self, endpoint: str, data: Optional[Union[Dict, str, bytes]] = None,
258
+ json_data: Optional[Any] = None, headers: Optional[Dict] = None,
259
+ timeout: Optional[float] = None) -> HttpResponse:
260
+ """发送PUT请求"""
261
+ return self._request('PUT', endpoint, data=data, json_data=json_data,
262
+ headers=headers, timeout=timeout)
263
+
264
+ def delete(self, endpoint: str, headers: Optional[Dict] = None,
265
+ timeout: Optional[float] = None) -> HttpResponse:
266
+ """发送DELETE请求"""
267
+ return self._request('DELETE', endpoint, headers=headers, timeout=timeout)
268
+
269
+ def head(self, endpoint: str, params: Optional[Dict] = None,
270
+ headers: Optional[Dict] = None, timeout: Optional[float] = None) -> HttpResponse:
271
+ """发送HEAD请求"""
272
+ return self._request('HEAD', endpoint, params=params, headers=headers, timeout=timeout)
273
+
274
+ def patch(self, endpoint: str, data: Optional[Union[Dict, str, bytes]] = None,
275
+ json_data: Optional[Any] = None, headers: Optional[Dict] = None,
276
+ timeout: Optional[float] = None) -> HttpResponse:
277
+ """发送PATCH请求"""
278
+ return self._request('PATCH', endpoint, data=data, json_data=json_data,
279
+ headers=headers, timeout=timeout)
280
+
281
+ def options(self, endpoint: str, headers: Optional[Dict] = None,
282
+ timeout: Optional[float] = None) -> HttpResponse:
283
+ """发送OPTIONS请求"""
284
+ return self._request('OPTIONS', endpoint, headers=headers, timeout=timeout)
285
+
286
+ # ========== 高级功能 ==========
287
+ def stream(self, endpoint: str, params: Optional[Dict] = None,
288
+ headers: Optional[Dict] = None, timeout: Optional[float] = None,
289
+ chunk_size: int = 8192) -> Iterator[bytes]:
290
+ """
291
+ 流式下载大文件
292
+
293
+ :param chunk_size: 每次读取的块大小(字节)
294
+ :return: 生成器,每次产生一个数据块
295
+ """
296
+ url = f"{self.base_url}/{endpoint.lstrip('/')}"
297
+ headers = {**self.default_headers, **(headers or {})}
298
+ timeout = timeout or self.timeout
299
+
300
+ if params:
301
+ url = f"{url}?{urllib.parse.urlencode(params)}"
302
+
303
+ req = urllib.request.Request(url, headers=headers, method='GET')
304
+
305
+ try:
306
+ response = self.opener.open(req, timeout=timeout)
307
+ while True:
308
+ chunk = response.read(chunk_size)
309
+ if not chunk:
310
+ break
311
+ yield chunk
312
+ finally:
313
+ response.close()
314
+
315
+ def download(self, endpoint: str, file_path: str, params: Optional[Dict] = None,
316
+ headers: Optional[Dict] = None, timeout: Optional[float] = None,
317
+ chunk_size: int = 8192, show_progress: bool = False) -> None:
318
+ """
319
+ 下载文件到本地
320
+
321
+ :param file_path: 保存路径
322
+ :param show_progress: 是否显示进度信息
323
+ """
324
+ total_size = 0
325
+ downloaded = 0
326
+
327
+ # 先获取文件大小
328
+ try:
329
+ head_resp = self.head(endpoint, params=params, headers=headers, timeout=timeout)
330
+ total_size = int(head_resp.headers.get('Content-Length', 0))
331
+ except Exception:
332
+ pass
333
+
334
+ # 下载文件
335
+ with open(file_path, 'wb') as f:
336
+ for chunk in self.stream(endpoint, params=params, headers=headers,
337
+ timeout=timeout, chunk_size=chunk_size):
338
+ f.write(chunk)
339
+ downloaded += len(chunk)
340
+
341
+ if show_progress and total_size > 0:
342
+ percent = (downloaded / total_size) * 100
343
+ print(f"\r下载进度: {percent:.2f}% ({downloaded}/{total_size} bytes)", end='')
344
+
345
+ if show_progress:
346
+ print()
347
+
348
+ def upload(self, endpoint: str, files: Dict[str, Union[str, Tuple[str, bytes]]],
349
+ headers: Optional[Dict] = None, timeout: Optional[float] = None) -> HttpResponse:
350
+ """上传文件"""
351
+ return self._request('POST', endpoint, files=files, headers=headers, timeout=timeout)
352
+
353
+ # ========== 配置方法 ==========
354
+ def set_default_header(self, key: str, value: str):
355
+ """设置默认请求头"""
356
+ self.default_headers[key] = value
357
+
358
+ def clear_default_headers(self):
359
+ """清除所有默认请求头"""
360
+ self.default_headers.clear()
361
+
362
+ def set_basic_auth(self, username: str, password: str):
363
+ """设置基本认证"""
364
+ self.auth = (username, password)
365
+ self._build_opener()
366
+
367
+ def set_bearer_token(self, token: str):
368
+ """设置Bearer Token认证"""
369
+ self.default_headers['Authorization'] = f'Bearer {token}'
370
+
371
+ def set_proxy(self, proxy: Dict[str, str]):
372
+ """设置代理"""
373
+ self.proxy = proxy
374
+ self._build_opener()
375
+
376
+ def set_cookies(self, cookie_jar: http.cookiejar.CookieJar):
377
+ """设置Cookie存储"""
378
+ self.cookie_jar = cookie_jar
379
+ self._build_opener()
380
+
381
+ def add_cookie(self, name: str, value: str, domain: str|None = None, path: str = '/'):
382
+ """添加单个Cookie"""
383
+ if not self.cookie_jar:
384
+ self.cookie_jar = http.cookiejar.CookieJar()
385
+ self._build_opener()
386
+
387
+ cookie = http.cookiejar.Cookie(
388
+ version=0,
389
+ name=name,
390
+ value=value,
391
+ port=None,
392
+ port_specified=False,
393
+ domain=domain or urllib.parse.urlparse(self.base_url).netloc,
394
+ domain_specified=bool(domain),
395
+ domain_initial_dot=False,
396
+ path=path,
397
+ path_specified=True,
398
+ secure=False,
399
+ expires=None,
400
+ discard=True,
401
+ comment=None,
402
+ comment_url=None,
403
+ rest={'HttpOnly': None},
404
+ rfc2109=False
405
+ )
406
+ self.cookie_jar.set_cookie(cookie)
407
+
408
+ def clear_cookies(self):
409
+ """清除所有Cookies"""
410
+ if self.cookie_jar:
411
+ self.cookie_jar.clear()
412
+
413
+ def follow_redirect(self, max_redirects: int = 5):
414
+ """设置是否跟随重定向"""
415
+ # urllib默认会跟随重定向,此方法用于可能的未来扩展
416
+ pass
417
+
418
+
419
+ class SimpleNetwork:
420
+ """
421
+ 增强版简单网络请求工具
422
+ 提供静态方法方便一次性请求
423
+ """
424
+
425
+ @staticmethod
426
+ def request(method: str,
427
+ url: str,
428
+ params: Optional[Dict] = None,
429
+ data: Optional[Union[Dict, str, bytes]] = None,
430
+ json_data: Optional[Any] = None,
431
+ headers: Optional[Dict] = None,
432
+ files: Optional[Dict[str, Union[str, Tuple[str, bytes]]]] = None,
433
+ timeout: float = 10.0,
434
+ retry_config: Optional[RetryConfig] = None,
435
+ proxy: Optional[Dict[str, str]] = None,
436
+ auth: Optional[Tuple[str, str]] = None) -> HttpResponse:
437
+ """
438
+ 发送HTTP请求
439
+
440
+ :param method: HTTP方法
441
+ :param url: 请求URL
442
+ :param params: URL参数
443
+ :param data: 请求体数据
444
+ :param json_data: JSON格式的请求体
445
+ :param headers: 请求头
446
+ :param files: 要上传的文件
447
+ :param timeout: 超时时间(秒)
448
+ :param retry_config: 重试配置
449
+ :param proxy: 代理配置
450
+ :param auth: 基本认证 (username, password)
451
+ :return: HttpResponse对象
452
+ """
453
+ # 创建临时客户端
454
+ client = HttpClient(
455
+ base_url=url.split('?')[0].rsplit('/', 1)[0],
456
+ timeout=timeout,
457
+ retry_config=retry_config,
458
+ proxy=proxy,
459
+ auth=auth
460
+ )
461
+
462
+ endpoint = '/' + url.split('?')[0].split('/', 3)[-1] if '/' in url[8:] else ''
463
+
464
+ try:
465
+ return client._request(
466
+ method=method,
467
+ endpoint=endpoint,
468
+ params=params,
469
+ data=data,
470
+ json_data=json_data,
471
+ headers=headers,
472
+ files=files,
473
+ timeout=timeout
474
+ )
475
+ finally:
476
+ if hasattr(client, 'opener'):
477
+ client.opener.close()
478
+
479
+ @staticmethod
480
+ def get(url: str, params: Optional[Dict] = None,
481
+ headers: Optional[Dict] = None, timeout: float = 10.0,
482
+ retry_config: Optional[RetryConfig] = None,
483
+ proxy: Optional[Dict[str, str]] = None,
484
+ auth: Optional[Tuple[str, str]] = None) -> HttpResponse:
485
+ """发送GET请求"""
486
+ return SimpleNetwork.request(
487
+ 'GET', url, params=params, headers=headers,
488
+ timeout=timeout, retry_config=retry_config,
489
+ proxy=proxy, auth=auth
490
+ )
491
+
492
+ @staticmethod
493
+ def post(url: str, data: Optional[Union[Dict, str, bytes]] = None,
494
+ json_data: Optional[Any] = None, headers: Optional[Dict] = None,
495
+ files: Optional[Dict] = None, timeout: float = 10.0,
496
+ retry_config: Optional[RetryConfig] = None,
497
+ proxy: Optional[Dict[str, str]] = None,
498
+ auth: Optional[Tuple[str, str]] = None) -> HttpResponse:
499
+ """发送POST请求"""
500
+ return SimpleNetwork.request(
501
+ 'POST', url, data=data, json_data=json_data,
502
+ headers=headers, files=files, timeout=timeout,
503
+ retry_config=retry_config, proxy=proxy, auth=auth
504
+ )
505
+
506
+ @staticmethod
507
+ def put(url: str, data: Optional[Union[Dict, str, bytes]] = None,
508
+ json_data: Optional[Any] = None, headers: Optional[Dict] = None,
509
+ timeout: float = 10.0, retry_config: Optional[RetryConfig] = None,
510
+ proxy: Optional[Dict[str, str]] = None,
511
+ auth: Optional[Tuple[str, str]] = None) -> HttpResponse:
512
+ """发送PUT请求"""
513
+ return SimpleNetwork.request(
514
+ 'PUT', url, data=data, json_data=json_data,
515
+ headers=headers, timeout=timeout,
516
+ retry_config=retry_config, proxy=proxy, auth=auth
517
+ )
518
+
519
+ @staticmethod
520
+ def delete(url: str, headers: Optional[Dict] = None,
521
+ timeout: float = 10.0, retry_config: Optional[RetryConfig] = None,
522
+ proxy: Optional[Dict[str, str]] = None,
523
+ auth: Optional[Tuple[str, str]] = None) -> HttpResponse:
524
+ """发送DELETE请求"""
525
+ return SimpleNetwork.request(
526
+ 'DELETE', url, headers=headers,
527
+ timeout=timeout, retry_config=retry_config,
528
+ proxy=proxy, auth=auth
529
+ )
530
+
531
+ @staticmethod
532
+ def head(url: str, params: Optional[Dict] = None,
533
+ headers: Optional[Dict] = None, timeout: float = 10.0,
534
+ retry_config: Optional[RetryConfig] = None,
535
+ proxy: Optional[Dict[str, str]] = None,
536
+ auth: Optional[Tuple[str, str]] = None) -> HttpResponse:
537
+ """发送HEAD请求"""
538
+ return SimpleNetwork.request(
539
+ 'HEAD', url, params=params, headers=headers,
540
+ timeout=timeout, retry_config=retry_config,
541
+ proxy=proxy, auth=auth
542
+ )
543
+
544
+ @staticmethod
545
+ def patch(url: str, data: Optional[Union[Dict, str, bytes]] = None,
546
+ json_data: Optional[Any] = None, headers: Optional[Dict] = None,
547
+ timeout: float = 10.0, retry_config: Optional[RetryConfig] = None,
548
+ proxy: Optional[Dict[str, str]] = None,
549
+ auth: Optional[Tuple[str, str]] = None) -> HttpResponse:
550
+ """发送PATCH请求"""
551
+ return SimpleNetwork.request(
552
+ 'PATCH', url, data=data, json_data=json_data,
553
+ headers=headers, timeout=timeout,
554
+ retry_config=retry_config, proxy=proxy, auth=auth
555
+ )
556
+
557
+ @staticmethod
558
+ def options(url: str, headers: Optional[Dict] = None,
559
+ timeout: float = 10.0, retry_config: Optional[RetryConfig] = None,
560
+ proxy: Optional[Dict[str, str]] = None,
561
+ auth: Optional[Tuple[str, str]] = None) -> HttpResponse:
562
+ """发送OPTIONS请求"""
563
+ return SimpleNetwork.request(
564
+ 'OPTIONS', url, headers=headers,
565
+ timeout=timeout, retry_config=retry_config,
566
+ proxy=proxy, auth=auth
567
+ )
568
+
569
+ @staticmethod
570
+ def upload(url: str, files: Dict[str, Union[str, Tuple[str, bytes]]],
571
+ headers: Optional[Dict] = None, timeout: float = 10.0,
572
+ retry_config: Optional[RetryConfig] = None,
573
+ proxy: Optional[Dict[str, str]] = None,
574
+ auth: Optional[Tuple[str, str]] = None) -> HttpResponse:
575
+ """上传文件"""
576
+ return SimpleNetwork.request(
577
+ 'POST', url, files=files, headers=headers,
578
+ timeout=timeout, retry_config=retry_config,
579
+ proxy=proxy, auth=auth
580
+ )
581
+
582
+ @staticmethod
583
+ def download_file(url: str, file_path: str, params: Optional[Dict] = None,
584
+ headers: Optional[Dict] = None, timeout: float = 30.0,
585
+ retry_config: Optional[RetryConfig] = None,
586
+ proxy: Optional[Dict[str, str]] = None,
587
+ auth: Optional[Tuple[str, str]] = None,
588
+ chunk_size: int = 8192, show_progress: bool = False) -> None:
589
+ """
590
+ 下载文件到本地
591
+
592
+ :param file_path: 保存路径
593
+ :param show_progress: 是否显示进度信息
594
+ """
595
+ client = HttpClient(
596
+ base_url=url.split('?')[0].rsplit('/', 1)[0],
597
+ timeout=timeout,
598
+ retry_config=retry_config,
599
+ proxy=proxy,
600
+ auth=auth
601
+ )
602
+
603
+ endpoint = '/' + url.split('?')[0].split('/', 3)[-1] if '/' in url[8:] else ''
604
+
605
+ try:
606
+ client.download(endpoint, file_path, params=params, headers=headers,
607
+ timeout=timeout, chunk_size=chunk_size,
608
+ show_progress=show_progress)
609
+ finally:
610
+ if hasattr(client, 'opener'):
611
+ client.opener.close()
612
+
613
+
614
+ class AsyncHttpClient:
615
+ """异步HTTP客户端(基于线程池实现)"""
616
+
617
+ def __init__(self, max_workers: int = 4, **kwargs):
618
+ """
619
+ :param max_workers: 线程池大小
620
+ :param kwargs: 同HttpClient的参数
621
+ """
622
+ from concurrent.futures import ThreadPoolExecutor
623
+ self.executor = ThreadPoolExecutor(max_workers=max_workers)
624
+ self.sync_client = HttpClient(**kwargs)
625
+
626
+ def request(self, method: str, endpoint: str, **kwargs) -> Future[HttpResponse]:
627
+ """异步请求"""
628
+ return self.executor.submit(self.sync_client._request, method, endpoint, **kwargs)
629
+
630
+ def get(self, endpoint: str, **kwargs) -> Future[HttpResponse]:
631
+ """异步GET请求"""
632
+ return self.request('GET', endpoint, **kwargs)
633
+
634
+ def post(self, endpoint: str, **kwargs) -> Future[HttpResponse]:
635
+ """异步POST请求"""
636
+ return self.request('POST', endpoint, **kwargs)
637
+
638
+ def put(self, endpoint: str, **kwargs) -> Future[HttpResponse]:
639
+ """异步PUT请求"""
640
+ return self.request('PUT', endpoint, **kwargs)
641
+
642
+ def delete(self, endpoint: str, **kwargs) -> Future[HttpResponse]:
643
+ """异步DELETE请求"""
644
+ return self.request('DELETE', endpoint, **kwargs)
645
+
646
+ def head(self, endpoint: str, **kwargs) -> Future[HttpResponse]:
647
+ """异步HEAD请求"""
648
+ return self.request('HEAD', endpoint, **kwargs)
649
+
650
+ def patch(self, endpoint: str, **kwargs) -> Future[HttpResponse]:
651
+ """异步PATCH请求"""
652
+ return self.request('PATCH', endpoint, **kwargs)
653
+
654
+ def options(self, endpoint: str, **kwargs) -> Future[HttpResponse]:
655
+ """异步OPTIONS请求"""
656
+ return self.request('OPTIONS', endpoint, **kwargs)
657
+
658
+ def stream(self, endpoint: str, **kwargs) -> Future[Iterator[bytes]]:
659
+ """异步流式下载"""
660
+ return self.executor.submit(lambda: list(self.sync_client.stream(endpoint, **kwargs)))
661
+
662
+ def download(self, endpoint: str, file_path: str, **kwargs) -> Future[None]:
663
+ """异步下载文件"""
664
+ return self.executor.submit(self.sync_client.download, endpoint, file_path, **kwargs)
665
+
666
+ def upload(self, endpoint: str, files: Dict[str, Union[str, Tuple[str, bytes]]], **kwargs) -> Future[HttpResponse]:
667
+ """异步上传文件"""
668
+ return self.request('POST', endpoint, files=files, **kwargs)
669
+
670
+ def close(self):
671
+ """关闭客户端"""
672
+ self.executor.shutdown(wait=True)
673
+
674
+ def __enter__(self):
675
+ return self
676
+
677
+ def __exit__(self, exc_type, exc_val, exc_tb):
678
+ self.close()
679
+
680
+
681
+ class HttpUtils:
682
+ """HTTP实用工具类"""
683
+
684
+ @staticmethod
685
+ def parse_query_params(url: str) -> Dict[str, List[str]]:
686
+ """解析URL查询参数"""
687
+ parsed = urllib.parse.urlparse(url)
688
+ return urllib.parse.parse_qs(parsed.query)
689
+
690
+ @staticmethod
691
+ def build_url(base_url: str, params: Dict) -> str:
692
+ """构建带参数的URL"""
693
+ query = urllib.parse.urlencode(params, doseq=True)
694
+ return f"{base_url}?{query}" if query else base_url
695
+
696
+ @staticmethod
697
+ def encode_form_data(data: Dict) -> bytes:
698
+ """编码表单数据"""
699
+ return urllib.parse.urlencode(data).encode('utf-8')
700
+
701
+ @staticmethod
702
+ def get_content_type(filename: str) -> str:
703
+ """根据文件名获取Content-Type"""
704
+ return mimetypes.guess_type(filename)[0] or 'application/octet-stream'
705
+
706
+ @staticmethod
707
+ def set_global_timeout(timeout: float):
708
+ """设置urllib全局超时时间"""
709
+ socket.setdefaulttimeout(timeout)
710
+
711
+ @staticmethod
712
+ def disable_ssl_verify():
713
+ """禁用SSL验证(不推荐,仅用于测试)"""
714
+ import ssl
715
+ ssl._create_default_https_context = ssl._create_unverified_context
716
+
717
+ @staticmethod
718
+ def format_headers(headers: Dict) -> str:
719
+ """格式化请求头为字符串"""
720
+ return '\n'.join(f'{k}: {v}' for k, v in headers.items())
721
+
722
+ @staticmethod
723
+ def parse_cookies(cookie_str: str) -> Dict[str, str]:
724
+ """解析Cookie字符串为字典"""
725
+ return dict(pair.split('=', 1) for pair in cookie_str.split('; '))
726
+
727
+ @staticmethod
728
+ def encode_url(url: str) -> str:
729
+ """编码URL中的特殊字符"""
730
+ return urllib.parse.quote(url, safe=':/?&=')