yuehua-ziniao-webdriver 0.1.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.
- yuehua_ziniao_webdriver/__init__.py +149 -0
- yuehua_ziniao_webdriver/browser.py +258 -0
- yuehua_ziniao_webdriver/client.py +435 -0
- yuehua_ziniao_webdriver/config.py +265 -0
- yuehua_ziniao_webdriver/exceptions.py +237 -0
- yuehua_ziniao_webdriver/http_client.py +214 -0
- yuehua_ziniao_webdriver/process.py +270 -0
- yuehua_ziniao_webdriver/store.py +429 -0
- yuehua_ziniao_webdriver/types.py +172 -0
- yuehua_ziniao_webdriver/utils.py +310 -0
- yuehua_ziniao_webdriver-0.1.0.dist-info/METADATA +438 -0
- yuehua_ziniao_webdriver-0.1.0.dist-info/RECORD +15 -0
- yuehua_ziniao_webdriver-0.1.0.dist-info/WHEEL +5 -0
- yuehua_ziniao_webdriver-0.1.0.dist-info/licenses/LICENSE +21 -0
- yuehua_ziniao_webdriver-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""主客户端模块
|
|
2
|
+
|
|
3
|
+
提供 ZiniaoClient 主类,是 SDK 的核心接口。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from typing import List, Dict, Optional, Union
|
|
11
|
+
|
|
12
|
+
from .config import ZiniaoConfig
|
|
13
|
+
from .types import Store, ConfigSource
|
|
14
|
+
from .http_client import HttpClient
|
|
15
|
+
from .process import ProcessManager
|
|
16
|
+
from .store import StoreManager
|
|
17
|
+
from .browser import BrowserSession
|
|
18
|
+
from .exceptions import (
|
|
19
|
+
ClientNotStartedError,
|
|
20
|
+
UnsupportedVersionError,
|
|
21
|
+
CoreUpdateError,
|
|
22
|
+
ConfigurationError
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ZiniaoClient:
|
|
29
|
+
"""紫鸟浏览器客户端
|
|
30
|
+
|
|
31
|
+
这是 SDK 的主要入口类,提供所有核心功能。
|
|
32
|
+
|
|
33
|
+
使用示例:
|
|
34
|
+
```python
|
|
35
|
+
from yuehua_ziniao_webdriver import ZiniaoClient, ZiniaoConfig
|
|
36
|
+
|
|
37
|
+
config = ZiniaoConfig(
|
|
38
|
+
client_path=r"D:\ziniao\ziniao.exe",
|
|
39
|
+
company="企业名",
|
|
40
|
+
username="用户名",
|
|
41
|
+
password="密码"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
client = ZiniaoClient(config)
|
|
45
|
+
client.start()
|
|
46
|
+
|
|
47
|
+
# 通过名称打开店铺
|
|
48
|
+
session = client.open_store_by_name("我的店铺")
|
|
49
|
+
|
|
50
|
+
client.stop()
|
|
51
|
+
```
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, config: ConfigSource) -> None:
|
|
55
|
+
"""初始化紫鸟客户端
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
config: 配置对象、配置字典或配置文件路径
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
ConfigurationError: 配置无效
|
|
62
|
+
"""
|
|
63
|
+
# 解析配置
|
|
64
|
+
self.config = self._parse_config(config)
|
|
65
|
+
|
|
66
|
+
# 初始化组件
|
|
67
|
+
self.http_client = HttpClient(
|
|
68
|
+
port=self.config.socket_port,
|
|
69
|
+
timeout=self.config.request_timeout,
|
|
70
|
+
max_retries=self.config.max_retries,
|
|
71
|
+
retry_delay=self.config.retry_delay
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
self.process_manager = ProcessManager(
|
|
75
|
+
client_path=self.config.client_path,
|
|
76
|
+
socket_port=self.config.socket_port,
|
|
77
|
+
version=self.config.version
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
self.store_manager = StoreManager(
|
|
81
|
+
http_client=self.http_client,
|
|
82
|
+
user_info=self.config.get_user_info()
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
self._started = False
|
|
86
|
+
|
|
87
|
+
logger.info(f"紫鸟客户端已初始化:{self.config}")
|
|
88
|
+
|
|
89
|
+
def _parse_config(self, config: ConfigSource) -> ZiniaoConfig:
|
|
90
|
+
"""解析配置
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
config: 配置源
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
ZiniaoConfig: 解析后的配置对象
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
ConfigurationError: 配置解析失败
|
|
100
|
+
"""
|
|
101
|
+
if isinstance(config, ZiniaoConfig):
|
|
102
|
+
return config
|
|
103
|
+
|
|
104
|
+
elif isinstance(config, dict):
|
|
105
|
+
return ZiniaoConfig.from_dict(config) # type: ignore
|
|
106
|
+
|
|
107
|
+
elif isinstance(config, str):
|
|
108
|
+
# 假设是文件路径
|
|
109
|
+
return ZiniaoConfig.from_json_file(config)
|
|
110
|
+
|
|
111
|
+
else:
|
|
112
|
+
raise ConfigurationError(
|
|
113
|
+
f"不支持的配置类型:{type(config)}",
|
|
114
|
+
{"type": str(type(config))}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def start(
|
|
118
|
+
self,
|
|
119
|
+
kill_existing: bool = False,
|
|
120
|
+
update_core: bool = False,
|
|
121
|
+
wait_time: int = 5
|
|
122
|
+
) -> None:
|
|
123
|
+
"""启动紫鸟客户端
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
kill_existing: 是否自动关闭已存在的进程,默认 False(会询问用户)
|
|
127
|
+
update_core: 是否在启动后更新内核,默认 False
|
|
128
|
+
wait_time: 启动后等待时间(秒),默认 5
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
BrowserStartError: 启动失败
|
|
132
|
+
"""
|
|
133
|
+
if self._started:
|
|
134
|
+
logger.warning("客户端已启动,跳过重复启动")
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
logger.info("=== 启动紫鸟客户端 ===")
|
|
138
|
+
|
|
139
|
+
# 关闭已存在的进程
|
|
140
|
+
if not self.process_manager.kill_existing_process(force=kill_existing):
|
|
141
|
+
logger.info("用户取消启动")
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
# 启动客户端
|
|
145
|
+
self.process_manager.start_browser(wait_time=wait_time)
|
|
146
|
+
|
|
147
|
+
self._started = True
|
|
148
|
+
|
|
149
|
+
# 更新内核(如果需要)
|
|
150
|
+
if update_core:
|
|
151
|
+
logger.info("=== 更新浏览器内核 ===")
|
|
152
|
+
self.update_core()
|
|
153
|
+
|
|
154
|
+
def stop(self) -> None:
|
|
155
|
+
"""关闭紫鸟客户端
|
|
156
|
+
|
|
157
|
+
会先关闭客户端进程,然后发送退出命令。
|
|
158
|
+
"""
|
|
159
|
+
if not self._started:
|
|
160
|
+
logger.warning("客户端未启动,无需关闭")
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
logger.info("=== 关闭紫鸟客户端 ===")
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
# 发送退出命令
|
|
167
|
+
self._send_exit()
|
|
168
|
+
|
|
169
|
+
# 等待一下让客户端处理
|
|
170
|
+
time.sleep(2)
|
|
171
|
+
|
|
172
|
+
# 终止进程(如果还在运行)
|
|
173
|
+
self.process_manager.terminate()
|
|
174
|
+
|
|
175
|
+
except Exception as e:
|
|
176
|
+
logger.error(f"关闭客户端时出错:{e}")
|
|
177
|
+
|
|
178
|
+
finally:
|
|
179
|
+
self._started = False
|
|
180
|
+
logger.info("客户端已关闭")
|
|
181
|
+
|
|
182
|
+
def update_core(self, max_wait_time: int = 300) -> None:
|
|
183
|
+
"""更新浏览器内核
|
|
184
|
+
|
|
185
|
+
需要客户端版本 5.285.7 以上。
|
|
186
|
+
会循环调用直到更新完成或超时。
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
max_wait_time: 最大等待时间(秒),默认 300(5分钟)
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
ClientNotStartedError: 客户端未启动
|
|
193
|
+
UnsupportedVersionError: 版本不支持
|
|
194
|
+
CoreUpdateError: 更新失败
|
|
195
|
+
"""
|
|
196
|
+
if not self._started:
|
|
197
|
+
raise ClientNotStartedError()
|
|
198
|
+
|
|
199
|
+
logger.info("开始更新内核...")
|
|
200
|
+
|
|
201
|
+
start_time = time.time()
|
|
202
|
+
|
|
203
|
+
while True:
|
|
204
|
+
# 检查超时
|
|
205
|
+
if time.time() - start_time > max_wait_time:
|
|
206
|
+
raise CoreUpdateError(
|
|
207
|
+
f"更新内核超时({max_wait_time} 秒)",
|
|
208
|
+
{"max_wait_time": max_wait_time}
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# 发送更新请求
|
|
212
|
+
data = {
|
|
213
|
+
"action": "updateCore",
|
|
214
|
+
"requestId": str(uuid.uuid4()),
|
|
215
|
+
}
|
|
216
|
+
data.update(self.config.get_user_info())
|
|
217
|
+
|
|
218
|
+
result = self.http_client.send_request(data, retry_on_none=True)
|
|
219
|
+
|
|
220
|
+
if result is None:
|
|
221
|
+
logger.info("等待客户端启动...")
|
|
222
|
+
time.sleep(2)
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
status_code = result.get("statusCode")
|
|
226
|
+
|
|
227
|
+
if status_code is None or status_code == -10003:
|
|
228
|
+
raise UnsupportedVersionError(
|
|
229
|
+
"updateCore",
|
|
230
|
+
required_version="5.285.7"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
elif status_code == 0:
|
|
234
|
+
logger.info("内核更新完成")
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
else:
|
|
238
|
+
logger.info(
|
|
239
|
+
f"等待更新内核:{json.dumps(result, ensure_ascii=False)}"
|
|
240
|
+
)
|
|
241
|
+
time.sleep(2)
|
|
242
|
+
|
|
243
|
+
def get_store_list(self, use_cache: bool = False) -> List[Store]:
|
|
244
|
+
"""获取店铺列表
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
use_cache: 是否使用缓存,默认 False
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
List[Store]: 店铺列表
|
|
251
|
+
|
|
252
|
+
Raises:
|
|
253
|
+
ClientNotStartedError: 客户端未启动
|
|
254
|
+
StoreOperationError: 获取失败
|
|
255
|
+
"""
|
|
256
|
+
if not self._started:
|
|
257
|
+
raise ClientNotStartedError()
|
|
258
|
+
|
|
259
|
+
return self.store_manager.get_store_list(use_cache=use_cache)
|
|
260
|
+
|
|
261
|
+
def find_stores_by_name(
|
|
262
|
+
self,
|
|
263
|
+
name: str,
|
|
264
|
+
exact_match: bool = False
|
|
265
|
+
) -> List[Store]:
|
|
266
|
+
"""通过名称搜索店铺
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
name: 店铺名称
|
|
270
|
+
exact_match: 是否精确匹配,False 则模糊匹配
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
List[Store]: 匹配的店铺列表
|
|
274
|
+
|
|
275
|
+
Raises:
|
|
276
|
+
ClientNotStartedError: 客户端未启动
|
|
277
|
+
"""
|
|
278
|
+
if not self._started:
|
|
279
|
+
raise ClientNotStartedError()
|
|
280
|
+
|
|
281
|
+
return self.store_manager.find_stores_by_name(
|
|
282
|
+
name,
|
|
283
|
+
exact_match_mode=exact_match
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def open_store(
|
|
287
|
+
self,
|
|
288
|
+
store_id: str,
|
|
289
|
+
**options
|
|
290
|
+
) -> BrowserSession:
|
|
291
|
+
"""通过店铺 ID 打开店铺
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
store_id: 店铺 ID 或 OAuth 标识
|
|
295
|
+
**options: 打开店铺的选项
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
BrowserSession: 浏览器会话对象
|
|
299
|
+
|
|
300
|
+
Raises:
|
|
301
|
+
ClientNotStartedError: 客户端未启动
|
|
302
|
+
StoreOperationError: 打开失败
|
|
303
|
+
"""
|
|
304
|
+
if not self._started:
|
|
305
|
+
raise ClientNotStartedError()
|
|
306
|
+
|
|
307
|
+
return self.store_manager.open_store(store_id, **options)
|
|
308
|
+
|
|
309
|
+
def open_store_by_name(
|
|
310
|
+
self,
|
|
311
|
+
store_name: str,
|
|
312
|
+
exact_match: bool = False,
|
|
313
|
+
**options
|
|
314
|
+
) -> BrowserSession:
|
|
315
|
+
"""通过店铺名称打开店铺
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
store_name: 店铺名称(支持模糊匹配)
|
|
319
|
+
exact_match: 是否精确匹配,默认 False(模糊匹配)
|
|
320
|
+
**options: 打开店铺的选项
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
BrowserSession: 浏览器会话对象
|
|
324
|
+
|
|
325
|
+
Raises:
|
|
326
|
+
ClientNotStartedError: 客户端未启动
|
|
327
|
+
StoreNotFoundError: 未找到匹配的店铺
|
|
328
|
+
MultipleStoresFoundError: 找到多个匹配的店铺
|
|
329
|
+
StoreOperationError: 打开失败
|
|
330
|
+
"""
|
|
331
|
+
if not self._started:
|
|
332
|
+
raise ClientNotStartedError()
|
|
333
|
+
|
|
334
|
+
return self.store_manager.open_store_by_name(
|
|
335
|
+
store_name,
|
|
336
|
+
exact_match_mode=exact_match,
|
|
337
|
+
**options
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
def open_stores_by_names(
|
|
341
|
+
self,
|
|
342
|
+
store_names: List[str],
|
|
343
|
+
max_workers: int = 3,
|
|
344
|
+
exact_match: bool = False,
|
|
345
|
+
**options
|
|
346
|
+
) -> Dict[str, BrowserSession]:
|
|
347
|
+
"""并发打开多个店铺(通过店铺名称)
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
store_names: 店铺名称列表
|
|
351
|
+
max_workers: 最大并发数,默认 3
|
|
352
|
+
exact_match: 是否精确匹配,默认 False
|
|
353
|
+
**options: 打开店铺的选项
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
Dict[str, BrowserSession]: 店铺名称到浏览器会话的映射
|
|
357
|
+
|
|
358
|
+
Note:
|
|
359
|
+
如果某个店铺打开失败,会记录错误但不会中断其他店铺的打开。
|
|
360
|
+
返回的字典中只包含成功打开的店铺。
|
|
361
|
+
|
|
362
|
+
Raises:
|
|
363
|
+
ClientNotStartedError: 客户端未启动
|
|
364
|
+
"""
|
|
365
|
+
if not self._started:
|
|
366
|
+
raise ClientNotStartedError()
|
|
367
|
+
|
|
368
|
+
return self.store_manager.open_stores_by_names(
|
|
369
|
+
store_names,
|
|
370
|
+
max_workers=max_workers,
|
|
371
|
+
exact_match_mode=exact_match,
|
|
372
|
+
**options
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
def close_store(self, store_id: str) -> None:
|
|
376
|
+
"""关闭店铺
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
store_id: 店铺 ID/OAuth
|
|
380
|
+
|
|
381
|
+
Raises:
|
|
382
|
+
ClientNotStartedError: 客户端未启动
|
|
383
|
+
StoreOperationError: 关闭失败
|
|
384
|
+
"""
|
|
385
|
+
if not self._started:
|
|
386
|
+
raise ClientNotStartedError()
|
|
387
|
+
|
|
388
|
+
self.store_manager.close_store(store_id)
|
|
389
|
+
|
|
390
|
+
def _send_exit(self) -> None:
|
|
391
|
+
"""发送退出命令到客户端"""
|
|
392
|
+
data = {
|
|
393
|
+
"action": "exit",
|
|
394
|
+
"requestId": str(uuid.uuid4())
|
|
395
|
+
}
|
|
396
|
+
data.update(self.config.get_user_info())
|
|
397
|
+
|
|
398
|
+
logger.debug("发送退出命令...")
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
self.http_client.send_request(data)
|
|
402
|
+
except Exception as e:
|
|
403
|
+
logger.warning(f"发送退出命令失败:{e}")
|
|
404
|
+
|
|
405
|
+
def is_started(self) -> bool:
|
|
406
|
+
"""检查客户端是否已启动
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
bool: 已启动返回 True
|
|
410
|
+
"""
|
|
411
|
+
return self._started
|
|
412
|
+
|
|
413
|
+
def is_process_running(self) -> bool:
|
|
414
|
+
"""检查客户端进程是否正在运行
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
bool: 运行中返回 True
|
|
418
|
+
"""
|
|
419
|
+
return self.process_manager.is_running()
|
|
420
|
+
|
|
421
|
+
def __enter__(self) -> "ZiniaoClient":
|
|
422
|
+
"""上下文管理器入口,自动启动客户端"""
|
|
423
|
+
if not self._started:
|
|
424
|
+
self.start()
|
|
425
|
+
return self
|
|
426
|
+
|
|
427
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
428
|
+
"""上下文管理器退出,自动关闭客户端"""
|
|
429
|
+
self.stop()
|
|
430
|
+
|
|
431
|
+
def __repr__(self) -> str:
|
|
432
|
+
return (
|
|
433
|
+
f"ZiniaoClient(version='{self.config.version}', "
|
|
434
|
+
f"port={self.config.socket_port}, started={self._started})"
|
|
435
|
+
)
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""配置管理模块
|
|
2
|
+
|
|
3
|
+
提供配置类和配置加载功能。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from dataclasses import dataclass, field, asdict
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional, Dict, Any
|
|
11
|
+
|
|
12
|
+
from .types import VersionType, ConfigDict
|
|
13
|
+
from .exceptions import ConfigurationError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ZiniaoConfig:
|
|
18
|
+
"""紫鸟浏览器客户端配置类
|
|
19
|
+
|
|
20
|
+
使用 dataclass 提供类型提示和默认值。
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
client_path: 紫鸟客户端可执行文件路径(必需)
|
|
24
|
+
socket_port: 客户端通信端口,默认 16851
|
|
25
|
+
company: 企业名称(用于登录)
|
|
26
|
+
username: 用户名(用于登录)
|
|
27
|
+
password: 密码(用于登录)
|
|
28
|
+
version: 客户端版本,"v5" 或 "v6",默认 "v6"
|
|
29
|
+
request_timeout: HTTP 请求超时时间(秒),默认 120
|
|
30
|
+
max_retries: 失败重试次数,默认 3
|
|
31
|
+
retry_delay: 重试延迟时间(秒),默认 2.0
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
client_path: str
|
|
35
|
+
socket_port: int = 16851
|
|
36
|
+
company: str = ""
|
|
37
|
+
username: str = ""
|
|
38
|
+
password: str = ""
|
|
39
|
+
version: VersionType = "v6"
|
|
40
|
+
request_timeout: int = 120
|
|
41
|
+
max_retries: int = 3
|
|
42
|
+
retry_delay: float = 2.0
|
|
43
|
+
|
|
44
|
+
def __post_init__(self) -> None:
|
|
45
|
+
"""初始化后的验证"""
|
|
46
|
+
self.validate()
|
|
47
|
+
|
|
48
|
+
def validate(self) -> None:
|
|
49
|
+
"""验证配置有效性
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ConfigurationError: 当配置无效时
|
|
53
|
+
"""
|
|
54
|
+
# 验证必需字段
|
|
55
|
+
if not self.client_path:
|
|
56
|
+
raise ConfigurationError("client_path 不能为空")
|
|
57
|
+
|
|
58
|
+
# 验证客户端路径是否存在
|
|
59
|
+
if not os.path.exists(self.client_path):
|
|
60
|
+
raise ConfigurationError(
|
|
61
|
+
f"客户端路径不存在:{self.client_path}",
|
|
62
|
+
{"path": self.client_path}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# 验证端口范围
|
|
66
|
+
if not (1024 <= self.socket_port <= 65535):
|
|
67
|
+
raise ConfigurationError(
|
|
68
|
+
f"端口号必须在 1024-65535 之间,当前值:{self.socket_port}",
|
|
69
|
+
{"port": self.socket_port}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# 验证版本
|
|
73
|
+
if self.version not in ("v5", "v6"):
|
|
74
|
+
raise ConfigurationError(
|
|
75
|
+
f"version 必须是 'v5' 或 'v6',当前值:{self.version}",
|
|
76
|
+
{"version": self.version}
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# 验证超时时间
|
|
80
|
+
if self.request_timeout <= 0:
|
|
81
|
+
raise ConfigurationError(
|
|
82
|
+
f"request_timeout 必须大于 0,当前值:{self.request_timeout}",
|
|
83
|
+
{"timeout": self.request_timeout}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# 验证重试次数
|
|
87
|
+
if self.max_retries < 0:
|
|
88
|
+
raise ConfigurationError(
|
|
89
|
+
f"max_retries 不能为负数,当前值:{self.max_retries}",
|
|
90
|
+
{"retries": self.max_retries}
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# 验证重试延迟
|
|
94
|
+
if self.retry_delay < 0:
|
|
95
|
+
raise ConfigurationError(
|
|
96
|
+
f"retry_delay 不能为负数,当前值:{self.retry_delay}",
|
|
97
|
+
{"delay": self.retry_delay}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_dict(cls, config_dict: ConfigDict) -> "ZiniaoConfig":
|
|
102
|
+
"""从字典创建配置对象
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
config_dict: 配置字典
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
ZiniaoConfig: 配置对象
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
ConfigurationError: 当配置无效时
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
return cls(**config_dict) # type: ignore
|
|
115
|
+
except TypeError as e:
|
|
116
|
+
raise ConfigurationError(f"配置字典格式错误:{e}", {"dict": config_dict})
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def from_json_file(cls, file_path: str) -> "ZiniaoConfig":
|
|
120
|
+
"""从 JSON 文件加载配置
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
file_path: JSON 配置文件路径
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
ZiniaoConfig: 配置对象
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
ConfigurationError: 当文件不存在或格式错误时
|
|
130
|
+
"""
|
|
131
|
+
path = Path(file_path)
|
|
132
|
+
|
|
133
|
+
if not path.exists():
|
|
134
|
+
raise ConfigurationError(
|
|
135
|
+
f"配置文件不存在:{file_path}",
|
|
136
|
+
{"path": file_path}
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
141
|
+
config_dict = json.load(f)
|
|
142
|
+
return cls.from_dict(config_dict)
|
|
143
|
+
except json.JSONDecodeError as e:
|
|
144
|
+
raise ConfigurationError(
|
|
145
|
+
f"JSON 文件格式错误:{e}",
|
|
146
|
+
{"path": file_path, "error": str(e)}
|
|
147
|
+
)
|
|
148
|
+
except Exception as e:
|
|
149
|
+
raise ConfigurationError(
|
|
150
|
+
f"加载配置文件失败:{e}",
|
|
151
|
+
{"path": file_path, "error": str(e)}
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def from_env(cls, prefix: str = "ZINIAO_") -> "ZiniaoConfig":
|
|
156
|
+
"""从环境变量加载配置
|
|
157
|
+
|
|
158
|
+
环境变量命名规则:前缀 + 大写字段名
|
|
159
|
+
例如:ZINIAO_CLIENT_PATH, ZINIAO_SOCKET_PORT
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
prefix: 环境变量前缀,默认 "ZINIAO_"
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
ZiniaoConfig: 配置对象
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
ConfigurationError: 当必需的环境变量不存在时
|
|
169
|
+
"""
|
|
170
|
+
config_dict: Dict[str, Any] = {}
|
|
171
|
+
|
|
172
|
+
# 字段映射:Python 字段名 -> 环境变量名
|
|
173
|
+
field_mapping = {
|
|
174
|
+
"client_path": f"{prefix}CLIENT_PATH",
|
|
175
|
+
"socket_port": f"{prefix}SOCKET_PORT",
|
|
176
|
+
"company": f"{prefix}COMPANY",
|
|
177
|
+
"username": f"{prefix}USERNAME",
|
|
178
|
+
"password": f"{prefix}PASSWORD",
|
|
179
|
+
"version": f"{prefix}VERSION",
|
|
180
|
+
"request_timeout": f"{prefix}REQUEST_TIMEOUT",
|
|
181
|
+
"max_retries": f"{prefix}MAX_RETRIES",
|
|
182
|
+
"retry_delay": f"{prefix}RETRY_DELAY",
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
# 从环境变量读取
|
|
186
|
+
for field_name, env_name in field_mapping.items():
|
|
187
|
+
env_value = os.getenv(env_name)
|
|
188
|
+
if env_value is not None:
|
|
189
|
+
# 类型转换
|
|
190
|
+
if field_name == "socket_port":
|
|
191
|
+
config_dict[field_name] = int(env_value)
|
|
192
|
+
elif field_name == "request_timeout":
|
|
193
|
+
config_dict[field_name] = int(env_value)
|
|
194
|
+
elif field_name == "max_retries":
|
|
195
|
+
config_dict[field_name] = int(env_value)
|
|
196
|
+
elif field_name == "retry_delay":
|
|
197
|
+
config_dict[field_name] = float(env_value)
|
|
198
|
+
else:
|
|
199
|
+
config_dict[field_name] = env_value
|
|
200
|
+
|
|
201
|
+
# 检查必需字段
|
|
202
|
+
if "client_path" not in config_dict:
|
|
203
|
+
raise ConfigurationError(
|
|
204
|
+
f"环境变量 {prefix}CLIENT_PATH 未设置",
|
|
205
|
+
{"required_env": f"{prefix}CLIENT_PATH"}
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
return cls.from_dict(config_dict) # type: ignore
|
|
209
|
+
|
|
210
|
+
def to_dict(self) -> ConfigDict:
|
|
211
|
+
"""将配置转换为字典
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
ConfigDict: 配置字典
|
|
215
|
+
"""
|
|
216
|
+
return asdict(self) # type: ignore
|
|
217
|
+
|
|
218
|
+
def to_json_file(self, file_path: str, indent: int = 2) -> None:
|
|
219
|
+
"""将配置保存到 JSON 文件
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
file_path: 目标文件路径
|
|
223
|
+
indent: JSON 缩进空格数,默认 2
|
|
224
|
+
|
|
225
|
+
Raises:
|
|
226
|
+
ConfigurationError: 当保存失败时
|
|
227
|
+
"""
|
|
228
|
+
try:
|
|
229
|
+
path = Path(file_path)
|
|
230
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
|
|
232
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
233
|
+
json.dump(self.to_dict(), f, ensure_ascii=False, indent=indent)
|
|
234
|
+
except Exception as e:
|
|
235
|
+
raise ConfigurationError(
|
|
236
|
+
f"保存配置文件失败:{e}",
|
|
237
|
+
{"path": file_path, "error": str(e)}
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def get_user_info(self) -> Dict[str, str]:
|
|
241
|
+
"""获取用户登录信息字典
|
|
242
|
+
|
|
243
|
+
用于 HTTP 请求。
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
Dict[str, str]: 包含 company, username, password 的字典
|
|
247
|
+
"""
|
|
248
|
+
return {
|
|
249
|
+
"company": self.company,
|
|
250
|
+
"username": self.username,
|
|
251
|
+
"password": self.password
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
def __repr__(self) -> str:
|
|
255
|
+
"""安全的字符串表示(隐藏密码)"""
|
|
256
|
+
return (
|
|
257
|
+
f"ZiniaoConfig("
|
|
258
|
+
f"client_path='{self.client_path}', "
|
|
259
|
+
f"socket_port={self.socket_port}, "
|
|
260
|
+
f"company='{self.company}', "
|
|
261
|
+
f"username='{self.username}', "
|
|
262
|
+
f"password='***', "
|
|
263
|
+
f"version='{self.version}'"
|
|
264
|
+
f")"
|
|
265
|
+
)
|