xparse-client 0.2.20__py3-none-any.whl → 0.3.0b2__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.
- example/1_basic_api_usage.py +198 -0
- example/2_async_job.py +210 -0
- example/3_local_workflow.py +300 -0
- example/4_advanced_workflow.py +327 -0
- example/README.md +128 -0
- example/config_example.json +95 -0
- tests/conftest.py +310 -0
- tests/unit/__init__.py +1 -0
- tests/unit/api/__init__.py +1 -0
- tests/unit/api/test_extract.py +232 -0
- tests/unit/api/test_local.py +231 -0
- tests/unit/api/test_parse.py +374 -0
- tests/unit/api/test_pipeline.py +369 -0
- tests/unit/api/test_workflows.py +108 -0
- tests/unit/connectors/test_ftp.py +525 -0
- tests/unit/connectors/test_local_connectors.py +324 -0
- tests/unit/connectors/test_milvus.py +368 -0
- tests/unit/connectors/test_qdrant.py +399 -0
- tests/unit/connectors/test_s3.py +598 -0
- tests/unit/connectors/test_smb.py +442 -0
- tests/unit/connectors/test_utils.py +335 -0
- tests/unit/models/test_local.py +54 -0
- tests/unit/models/test_pipeline_stages.py +144 -0
- tests/unit/models/test_workflows.py +55 -0
- tests/unit/test_base.py +437 -0
- tests/unit/test_client.py +110 -0
- tests/unit/test_config.py +160 -0
- tests/unit/test_exceptions.py +182 -0
- tests/unit/test_http.py +562 -0
- xparse_client/__init__.py +110 -20
- xparse_client/_base.py +179 -0
- xparse_client/_client.py +218 -0
- xparse_client/_config.py +221 -0
- xparse_client/_http.py +350 -0
- xparse_client/api/__init__.py +14 -0
- xparse_client/api/extract.py +109 -0
- xparse_client/api/local.py +188 -0
- xparse_client/api/parse.py +209 -0
- xparse_client/api/pipeline.py +132 -0
- xparse_client/api/workflows.py +204 -0
- xparse_client/connectors/__init__.py +45 -0
- xparse_client/connectors/_utils.py +138 -0
- xparse_client/connectors/destinations/__init__.py +45 -0
- xparse_client/connectors/destinations/base.py +116 -0
- xparse_client/connectors/destinations/local.py +91 -0
- xparse_client/connectors/destinations/milvus.py +229 -0
- xparse_client/connectors/destinations/qdrant.py +238 -0
- xparse_client/connectors/destinations/s3.py +163 -0
- xparse_client/connectors/sources/__init__.py +45 -0
- xparse_client/connectors/sources/base.py +74 -0
- xparse_client/connectors/sources/ftp.py +278 -0
- xparse_client/connectors/sources/local.py +176 -0
- xparse_client/connectors/sources/s3.py +232 -0
- xparse_client/connectors/sources/smb.py +259 -0
- xparse_client/exceptions.py +398 -0
- xparse_client/models/__init__.py +60 -0
- xparse_client/models/chunk.py +39 -0
- xparse_client/models/embed.py +62 -0
- xparse_client/models/extract.py +41 -0
- xparse_client/models/local.py +38 -0
- xparse_client/models/parse.py +136 -0
- xparse_client/models/pipeline.py +132 -0
- xparse_client/models/workflows.py +74 -0
- xparse_client-0.3.0b2.dist-info/METADATA +1075 -0
- xparse_client-0.3.0b2.dist-info/RECORD +68 -0
- {xparse_client-0.2.20.dist-info → xparse_client-0.3.0b2.dist-info}/WHEEL +1 -1
- {xparse_client-0.2.20.dist-info → xparse_client-0.3.0b2.dist-info}/licenses/LICENSE +1 -1
- {xparse_client-0.2.20.dist-info → xparse_client-0.3.0b2.dist-info}/top_level.txt +2 -0
- xparse_client/pipeline/__init__.py +0 -3
- xparse_client/pipeline/config.py +0 -163
- xparse_client/pipeline/destinations.py +0 -489
- xparse_client/pipeline/pipeline.py +0 -860
- xparse_client/pipeline/sources.py +0 -583
- xparse_client-0.2.20.dist-info/METADATA +0 -1050
- xparse_client-0.2.20.dist-info/RECORD +0 -11
tests/unit/test_http.py
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
"""HTTP 客户端测试
|
|
2
|
+
|
|
3
|
+
测试 HTTPClient 的所有功能,包括:
|
|
4
|
+
- 基础请求功能
|
|
5
|
+
- HTTP 状态码错误映射
|
|
6
|
+
- 业务 code 错误处理
|
|
7
|
+
- 重试机制
|
|
8
|
+
- 超时和网络错误
|
|
9
|
+
- Request ID 提取
|
|
10
|
+
- 错误消息格式处理
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
import pytest
|
|
17
|
+
import respx
|
|
18
|
+
|
|
19
|
+
from xparse_client._config import SDKConfiguration
|
|
20
|
+
from xparse_client._http import HTTPClient, raise_for_status
|
|
21
|
+
from xparse_client.exceptions import (
|
|
22
|
+
APIError,
|
|
23
|
+
AuthenticationError,
|
|
24
|
+
NotFoundError,
|
|
25
|
+
PermissionDeniedError,
|
|
26
|
+
RateLimitError,
|
|
27
|
+
RequestTimeoutError,
|
|
28
|
+
ServerError,
|
|
29
|
+
ValidationError,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture
|
|
34
|
+
def config():
|
|
35
|
+
"""测试用配置"""
|
|
36
|
+
return SDKConfiguration(
|
|
37
|
+
app_id="test-app-id",
|
|
38
|
+
secret_code="test-secret-code",
|
|
39
|
+
server_url="https://api.test.com",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.fixture
|
|
44
|
+
def http_client(config):
|
|
45
|
+
"""测试用 HTTP 客户端"""
|
|
46
|
+
return HTTPClient(config)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ============================================================================
|
|
50
|
+
# 基础功能测试
|
|
51
|
+
# ============================================================================
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_http_client_initialization(config):
|
|
55
|
+
"""测试 HTTPClient 正确初始化"""
|
|
56
|
+
client = HTTPClient(config)
|
|
57
|
+
|
|
58
|
+
assert client.config == config
|
|
59
|
+
assert client.retry_config is not None
|
|
60
|
+
assert client._client is not None
|
|
61
|
+
assert client._owns_client is True
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@respx.mock
|
|
65
|
+
def test_http_get_request(http_client):
|
|
66
|
+
"""测试 GET 请求"""
|
|
67
|
+
# Mock 响应
|
|
68
|
+
respx.get("https://api.test.com/test").mock(
|
|
69
|
+
return_value=httpx.Response(200, json={"code": 200, "data": "success"})
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# 执行请求
|
|
73
|
+
response = http_client.request("GET", "/test")
|
|
74
|
+
|
|
75
|
+
# 验证
|
|
76
|
+
assert response.status_code == 200
|
|
77
|
+
assert response.json()["data"] == "success"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@respx.mock
|
|
81
|
+
def test_http_post_request(http_client):
|
|
82
|
+
"""测试 POST 请求"""
|
|
83
|
+
# Mock 响应
|
|
84
|
+
respx.post("https://api.test.com/test").mock(
|
|
85
|
+
return_value=httpx.Response(200, json={"code": 200, "data": "created"})
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# 执行请求
|
|
89
|
+
response = http_client.request("POST", "/test", json={"key": "value"})
|
|
90
|
+
|
|
91
|
+
# 验证
|
|
92
|
+
assert response.status_code == 200
|
|
93
|
+
assert response.json()["data"] == "created"
|
|
94
|
+
|
|
95
|
+
# 验证请求内容
|
|
96
|
+
request = respx.calls.last.request
|
|
97
|
+
assert request.method == "POST"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@respx.mock
|
|
101
|
+
def test_http_auth_headers_auto_added(http_client):
|
|
102
|
+
"""测试认证头自动添加"""
|
|
103
|
+
# Mock 响应
|
|
104
|
+
respx.get("https://api.test.com/test").mock(
|
|
105
|
+
return_value=httpx.Response(200, json={"code": 200})
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# 执行请求
|
|
109
|
+
http_client.request("GET", "/test")
|
|
110
|
+
|
|
111
|
+
# 验证请求头
|
|
112
|
+
request = respx.calls.last.request
|
|
113
|
+
assert "x-ti-app-id" in request.headers
|
|
114
|
+
assert request.headers["x-ti-app-id"] == "test-app-id"
|
|
115
|
+
assert "x-ti-secret-code" in request.headers
|
|
116
|
+
assert request.headers["x-ti-secret-code"] == "test-secret-code"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ============================================================================
|
|
120
|
+
# HTTP 状态码错误映射测试
|
|
121
|
+
# ============================================================================
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_http_error_400_validation():
|
|
125
|
+
"""HTTP 400 → ValidationError"""
|
|
126
|
+
response = httpx.Response(
|
|
127
|
+
400,
|
|
128
|
+
json={"message": "参数错误"},
|
|
129
|
+
request=httpx.Request("GET", "https://api.test.com/test"),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
133
|
+
raise_for_status(response)
|
|
134
|
+
|
|
135
|
+
assert "参数错误" in str(exc_info.value)
|
|
136
|
+
assert exc_info.value.details["status_code"] == 400
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_http_error_401_authentication():
|
|
140
|
+
"""HTTP 401 → AuthenticationError"""
|
|
141
|
+
response = httpx.Response(
|
|
142
|
+
401,
|
|
143
|
+
json={"message": "认证失败"},
|
|
144
|
+
request=httpx.Request("GET", "https://api.test.com/test"),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
with pytest.raises(AuthenticationError) as exc_info:
|
|
148
|
+
raise_for_status(response)
|
|
149
|
+
|
|
150
|
+
assert "认证失败" in str(exc_info.value)
|
|
151
|
+
assert exc_info.value.status_code == 401
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_http_error_403_permission_denied():
|
|
155
|
+
"""HTTP 403 → PermissionDeniedError"""
|
|
156
|
+
response = httpx.Response(
|
|
157
|
+
403,
|
|
158
|
+
json={"message": "权限不足"},
|
|
159
|
+
request=httpx.Request("GET", "https://api.test.com/test"),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
with pytest.raises(PermissionDeniedError) as exc_info:
|
|
163
|
+
raise_for_status(response)
|
|
164
|
+
|
|
165
|
+
assert "权限不足" in str(exc_info.value)
|
|
166
|
+
assert exc_info.value.status_code == 403
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_http_error_404_not_found():
|
|
170
|
+
"""HTTP 404 → NotFoundError"""
|
|
171
|
+
response = httpx.Response(
|
|
172
|
+
404,
|
|
173
|
+
json={"message": "资源不存在"},
|
|
174
|
+
request=httpx.Request("GET", "https://api.test.com/test"),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
with pytest.raises(NotFoundError) as exc_info:
|
|
178
|
+
raise_for_status(response)
|
|
179
|
+
|
|
180
|
+
assert "资源不存在" in str(exc_info.value)
|
|
181
|
+
assert exc_info.value.status_code == 404
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_http_error_429_rate_limit():
|
|
185
|
+
"""HTTP 429 → RateLimitError(含 retry_after 提取)"""
|
|
186
|
+
response = httpx.Response(
|
|
187
|
+
429,
|
|
188
|
+
headers={"Retry-After": "60"},
|
|
189
|
+
json={"message": "请求频率超限"},
|
|
190
|
+
request=httpx.Request("GET", "https://api.test.com/test"),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
with pytest.raises(RateLimitError) as exc_info:
|
|
194
|
+
raise_for_status(response)
|
|
195
|
+
|
|
196
|
+
assert "请求频率超限" in str(exc_info.value)
|
|
197
|
+
assert exc_info.value.status_code == 429
|
|
198
|
+
assert exc_info.value.retry_after == 60
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_http_error_500_server_error():
|
|
202
|
+
"""HTTP 5xx → ServerError"""
|
|
203
|
+
response = httpx.Response(
|
|
204
|
+
500,
|
|
205
|
+
json={"message": "服务器内部错误"},
|
|
206
|
+
request=httpx.Request("GET", "https://api.test.com/test"),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
with pytest.raises(ServerError) as exc_info:
|
|
210
|
+
raise_for_status(response)
|
|
211
|
+
|
|
212
|
+
assert "服务器内部错误" in str(exc_info.value)
|
|
213
|
+
assert exc_info.value.status_code == 500
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ============================================================================
|
|
217
|
+
# 业务错误测试(重要!)⭐
|
|
218
|
+
# ============================================================================
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_http_200_business_code_400(business_error_response):
|
|
222
|
+
"""HTTP 200 + {"code": 400} → ValidationError"""
|
|
223
|
+
response = httpx.Response(
|
|
224
|
+
200,
|
|
225
|
+
json=business_error_response,
|
|
226
|
+
request=httpx.Request("GET", "https://api.test.com/test"),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
230
|
+
raise_for_status(response)
|
|
231
|
+
|
|
232
|
+
assert "参数错误" in str(exc_info.value)
|
|
233
|
+
assert exc_info.value.details["request_id"] == "abc123"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_http_200_business_code_200_success():
|
|
237
|
+
"""HTTP 200 + {"code": 200} → 正常返回"""
|
|
238
|
+
response = httpx.Response(
|
|
239
|
+
200,
|
|
240
|
+
json={"code": 200, "data": "success", "message": "success"},
|
|
241
|
+
request=httpx.Request("GET", "https://api.test.com/test"),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# 不应该抛出异常
|
|
245
|
+
raise_for_status(response)
|
|
246
|
+
assert response.status_code == 200
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def test_http_200_business_code_401():
|
|
250
|
+
"""HTTP 200 + {"code": 401} → AuthenticationError"""
|
|
251
|
+
response = httpx.Response(
|
|
252
|
+
200,
|
|
253
|
+
json={"code": 401, "message": "认证失败", "x_request_id": "req_123"},
|
|
254
|
+
request=httpx.Request("GET", "https://api.test.com/test"),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
with pytest.raises(AuthenticationError) as exc_info:
|
|
258
|
+
raise_for_status(response)
|
|
259
|
+
|
|
260
|
+
assert "认证失败" in str(exc_info.value)
|
|
261
|
+
assert exc_info.value.details["request_id"] == "req_123"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_http_200_business_code_500():
|
|
265
|
+
"""HTTP 200 + {"code": 500} → ServerError"""
|
|
266
|
+
response = httpx.Response(
|
|
267
|
+
200,
|
|
268
|
+
json={"code": 500, "message": "服务器错误", "x_request_id": "req_456"},
|
|
269
|
+
request=httpx.Request("GET", "https://api.test.com/test"),
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
with pytest.raises(ServerError) as exc_info:
|
|
273
|
+
raise_for_status(response)
|
|
274
|
+
|
|
275
|
+
assert "服务器错误" in str(exc_info.value)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ============================================================================
|
|
279
|
+
# 重试机制测试
|
|
280
|
+
# ============================================================================
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@respx.mock
|
|
284
|
+
def test_http_retry_on_500(http_client):
|
|
285
|
+
"""500 错误自动重试,最终成功"""
|
|
286
|
+
# Mock 响应:前两次失败,第三次成功
|
|
287
|
+
route = respx.post("https://api.test.com/test")
|
|
288
|
+
route.side_effect = [
|
|
289
|
+
httpx.Response(500, json={"message": "server error"}), # 第一次失败
|
|
290
|
+
httpx.Response(500, json={"message": "server error"}), # 第二次失败
|
|
291
|
+
httpx.Response(200, json={"code": 200, "data": "success"}), # 第三次成功
|
|
292
|
+
]
|
|
293
|
+
|
|
294
|
+
# 执行请求
|
|
295
|
+
response = http_client.request("POST", "/test", json={})
|
|
296
|
+
|
|
297
|
+
# 验证最终成功
|
|
298
|
+
assert response.status_code == 200
|
|
299
|
+
assert response.json()["data"] == "success"
|
|
300
|
+
|
|
301
|
+
# 验证重试了 3 次
|
|
302
|
+
assert len(respx.calls) == 3
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@respx.mock
|
|
306
|
+
def test_http_retry_exponential_backoff(http_client):
|
|
307
|
+
"""验证指数退避时间"""
|
|
308
|
+
# Mock 响应:前两次失败,第三次成功
|
|
309
|
+
route = respx.post("https://api.test.com/test")
|
|
310
|
+
route.side_effect = [
|
|
311
|
+
httpx.Response(500),
|
|
312
|
+
httpx.Response(500),
|
|
313
|
+
httpx.Response(200, json={"code": 200}),
|
|
314
|
+
]
|
|
315
|
+
|
|
316
|
+
start_time = time.time()
|
|
317
|
+
response = http_client.request("POST", "/test", json={})
|
|
318
|
+
elapsed = time.time() - start_time
|
|
319
|
+
|
|
320
|
+
# 验证重试延迟约 1s + 2s = 3s(指数退避:2^0, 2^1)
|
|
321
|
+
assert elapsed >= 3.0
|
|
322
|
+
assert response.status_code == 200
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@respx.mock
|
|
326
|
+
def test_http_retry_max_attempts_reached(http_client):
|
|
327
|
+
"""达到最大重试次数后抛出异常"""
|
|
328
|
+
# Mock 响应:所有请求都失败
|
|
329
|
+
route = respx.post("https://api.test.com/test")
|
|
330
|
+
route.side_effect = [
|
|
331
|
+
httpx.Response(500) for _ in range(10) # 超过 max_retries
|
|
332
|
+
]
|
|
333
|
+
|
|
334
|
+
# 应该抛出 ServerError
|
|
335
|
+
with pytest.raises(ServerError):
|
|
336
|
+
http_client.request("POST", "/test", json={})
|
|
337
|
+
|
|
338
|
+
# 验证调用了 max_retries + 1 次(初始请求 + 重试次数)
|
|
339
|
+
expected_calls = http_client.retry_config.max_retries + 1
|
|
340
|
+
assert len(respx.calls) == expected_calls
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@respx.mock
|
|
344
|
+
def test_http_no_retry_on_400(http_client):
|
|
345
|
+
"""400 错误不重试(客户端错误)"""
|
|
346
|
+
# Mock 响应:400 错误
|
|
347
|
+
respx.post("https://api.test.com/test").mock(
|
|
348
|
+
return_value=httpx.Response(400, json={"message": "bad request"})
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
# 应该立即抛出异常,不重试
|
|
352
|
+
with pytest.raises(ValidationError):
|
|
353
|
+
http_client.request("POST", "/test", json={})
|
|
354
|
+
|
|
355
|
+
# 验证只调用了 1 次
|
|
356
|
+
assert len(respx.calls) == 1
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# ============================================================================
|
|
360
|
+
# 超时和网络错误测试
|
|
361
|
+
# ============================================================================
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@respx.mock
|
|
365
|
+
def test_http_timeout(http_client):
|
|
366
|
+
"""请求超时 → RequestTimeoutError"""
|
|
367
|
+
# Mock 超时
|
|
368
|
+
respx.post("https://api.test.com/test").mock(
|
|
369
|
+
side_effect=httpx.TimeoutException("Request timeout")
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
# 应该抛出 RequestTimeoutError
|
|
373
|
+
with pytest.raises(RequestTimeoutError) as exc_info:
|
|
374
|
+
http_client.request("POST", "/test", json={})
|
|
375
|
+
|
|
376
|
+
assert "请求超时" in str(exc_info.value)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@respx.mock
|
|
380
|
+
def test_http_connection_error(http_client):
|
|
381
|
+
"""连接失败 → 重试后抛出 APIError"""
|
|
382
|
+
# Mock 连接错误
|
|
383
|
+
respx.post("https://api.test.com/test").mock(
|
|
384
|
+
side_effect=httpx.ConnectError("Connection failed")
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
# 应该抛出 APIError(因为重试后仍失败)
|
|
388
|
+
with pytest.raises(APIError) as exc_info:
|
|
389
|
+
http_client.request("POST", "/test", json={})
|
|
390
|
+
|
|
391
|
+
assert "请求失败" in str(exc_info.value)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# ============================================================================
|
|
395
|
+
# Request ID 和错误消息测试
|
|
396
|
+
# ============================================================================
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def test_http_request_id_extraction():
|
|
400
|
+
"""从响应头提取 x-request-id"""
|
|
401
|
+
response = httpx.Response(
|
|
402
|
+
500,
|
|
403
|
+
headers={"x-request-id": "test-req-123"},
|
|
404
|
+
json={"message": "服务器错误"},
|
|
405
|
+
request=httpx.Request("GET", "https://api.test.com/test"),
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
with pytest.raises(ServerError) as exc_info:
|
|
409
|
+
raise_for_status(response)
|
|
410
|
+
|
|
411
|
+
# 验证 request_id 被提取
|
|
412
|
+
assert exc_info.value.details["request_id"] == "test-req-123"
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def test_http_error_message_json_format():
|
|
416
|
+
"""JSON 格式错误响应的消息提取"""
|
|
417
|
+
response = httpx.Response(
|
|
418
|
+
400,
|
|
419
|
+
json={"message": "参数错误:字段 name 必填"},
|
|
420
|
+
request=httpx.Request("GET", "https://api.test.com/test"),
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
424
|
+
raise_for_status(response)
|
|
425
|
+
|
|
426
|
+
assert "参数错误:字段 name 必填" in str(exc_info.value)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def test_http_error_message_json_error_field():
|
|
430
|
+
"""JSON 格式错误响应(使用 error 字段)"""
|
|
431
|
+
response = httpx.Response(
|
|
432
|
+
400,
|
|
433
|
+
json={"error": "Invalid parameter"},
|
|
434
|
+
request=httpx.Request("GET", "https://api.test.com/test"),
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
438
|
+
raise_for_status(response)
|
|
439
|
+
|
|
440
|
+
assert "Invalid parameter" in str(exc_info.value)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def test_http_error_message_html_format():
|
|
444
|
+
"""HTML 格式错误响应(返回截断文本)"""
|
|
445
|
+
html_response = "<html><body><h1>500 Internal Server Error</h1></body></html>"
|
|
446
|
+
response = httpx.Response(
|
|
447
|
+
500,
|
|
448
|
+
text=html_response,
|
|
449
|
+
request=httpx.Request("GET", "https://api.test.com/test"),
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
with pytest.raises(ServerError) as exc_info:
|
|
453
|
+
raise_for_status(response)
|
|
454
|
+
|
|
455
|
+
# 验证错误消息包含 HTML 内容(截断到 500 字符)
|
|
456
|
+
assert "500 Internal Server Error" in str(exc_info.value)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def test_http_error_message_plain_text():
|
|
460
|
+
"""纯文本格式错误响应"""
|
|
461
|
+
response = httpx.Response(
|
|
462
|
+
500,
|
|
463
|
+
text="Internal Server Error",
|
|
464
|
+
request=httpx.Request("GET", "https://api.test.com/test"),
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
with pytest.raises(ServerError) as exc_info:
|
|
468
|
+
raise_for_status(response)
|
|
469
|
+
|
|
470
|
+
assert "Internal Server Error" in str(exc_info.value)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def test_http_error_message_empty_response():
|
|
474
|
+
"""空响应时使用状态码"""
|
|
475
|
+
response = httpx.Response(
|
|
476
|
+
500,
|
|
477
|
+
text="",
|
|
478
|
+
request=httpx.Request("GET", "https://api.test.com/test"),
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
with pytest.raises(ServerError) as exc_info:
|
|
482
|
+
raise_for_status(response)
|
|
483
|
+
|
|
484
|
+
assert "HTTP 500" in str(exc_info.value)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
# ============================================================================
|
|
488
|
+
# HTTPClient 便捷方法测试
|
|
489
|
+
# ============================================================================
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
@respx.mock
|
|
493
|
+
def test_http_get_method(http_client):
|
|
494
|
+
"""测试 get() 便捷方法"""
|
|
495
|
+
respx.get("https://api.test.com/test").mock(
|
|
496
|
+
return_value=httpx.Response(200, json={"code": 200, "data": "success"})
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
response = http_client.get("/test")
|
|
500
|
+
|
|
501
|
+
assert response.status_code == 200
|
|
502
|
+
assert response.json()["data"] == "success"
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
@respx.mock
|
|
506
|
+
def test_http_post_method(http_client):
|
|
507
|
+
"""测试 post() 便捷方法"""
|
|
508
|
+
respx.post("https://api.test.com/test").mock(
|
|
509
|
+
return_value=httpx.Response(200, json={"code": 200, "data": "created"})
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
response = http_client.post("/test", json={"key": "value"})
|
|
513
|
+
|
|
514
|
+
assert response.status_code == 200
|
|
515
|
+
assert response.json()["data"] == "created"
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
@respx.mock
|
|
519
|
+
def test_http_put_method(http_client):
|
|
520
|
+
"""测试 put() 便捷方法"""
|
|
521
|
+
respx.put("https://api.test.com/test").mock(
|
|
522
|
+
return_value=httpx.Response(200, json={"code": 200, "data": "updated"})
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
response = http_client.put("/test", json={"key": "new_value"})
|
|
526
|
+
|
|
527
|
+
assert response.status_code == 200
|
|
528
|
+
assert response.json()["data"] == "updated"
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
@respx.mock
|
|
532
|
+
def test_http_delete_method(http_client):
|
|
533
|
+
"""测试 delete() 便捷方法"""
|
|
534
|
+
respx.delete("https://api.test.com/test").mock(
|
|
535
|
+
return_value=httpx.Response(200, json={"code": 200, "data": "deleted"})
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
response = http_client.delete("/test")
|
|
539
|
+
|
|
540
|
+
assert response.status_code == 200
|
|
541
|
+
assert response.json()["data"] == "deleted"
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def test_http_client_close(config):
|
|
545
|
+
"""测试客户端关闭"""
|
|
546
|
+
client = HTTPClient(config)
|
|
547
|
+
|
|
548
|
+
# 关闭客户端
|
|
549
|
+
client.close()
|
|
550
|
+
|
|
551
|
+
# 验证客户端已关闭
|
|
552
|
+
assert client._client.is_closed
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def test_http_client_context_manager(config):
|
|
556
|
+
"""测试上下文管理器"""
|
|
557
|
+
with HTTPClient(config) as client:
|
|
558
|
+
assert client is not None
|
|
559
|
+
assert not client._client.is_closed
|
|
560
|
+
|
|
561
|
+
# 退出上下文后应该关闭
|
|
562
|
+
assert client._client.is_closed
|
xparse_client/__init__.py
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
|
+
# ruff: noqa: E402
|
|
2
|
+
"""xparse-client SDK
|
|
3
|
+
|
|
4
|
+
面向 Agent 和 RAG 的文档处理 Pipeline 客户端。
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
>>> from xparse_client import XParseClient
|
|
8
|
+
>>>
|
|
9
|
+
>>> # 创建客户端
|
|
10
|
+
>>> client = XParseClient(
|
|
11
|
+
... app_id="your-app-id",
|
|
12
|
+
... secret_code="your-secret-code"
|
|
13
|
+
... )
|
|
14
|
+
>>>
|
|
15
|
+
>>> # 单文件解析
|
|
16
|
+
>>> result = client.parse.partition(file=file_bytes, filename="doc.pdf")
|
|
17
|
+
>>>
|
|
18
|
+
>>> # 本地批处理
|
|
19
|
+
>>> from xparse_client.connectors import LocalSource, MilvusDestination
|
|
20
|
+
>>> from xparse_client.models import PipelineStage, ParseConfig
|
|
21
|
+
>>>
|
|
22
|
+
>>> result = client.local.run_workflow(
|
|
23
|
+
... source=LocalSource(directory="./docs"),
|
|
24
|
+
... destination=MilvusDestination(...),
|
|
25
|
+
... stages=[PipelineStage(type="parse", config=ParseConfig())]
|
|
26
|
+
... )
|
|
27
|
+
>>>
|
|
28
|
+
>>> # 远程工作流(需要服务端支持)
|
|
29
|
+
>>> workflow = client.workflows.create(
|
|
30
|
+
... name="daily-processing",
|
|
31
|
+
... source_id="src_123",
|
|
32
|
+
... destination_id="dst_456",
|
|
33
|
+
... stages=[...]
|
|
34
|
+
... )
|
|
35
|
+
>>> job_id = client.workflows.run(workflow.workflow_id)
|
|
36
|
+
|
|
37
|
+
迁移指南:
|
|
38
|
+
如果你正在使用旧版 Pipeline 类,请参考迁移指南:
|
|
39
|
+
https://github.com/textin/xparse-client/blob/master/docs/migration-guide.md
|
|
40
|
+
"""
|
|
41
|
+
|
|
1
42
|
import logging
|
|
2
|
-
from pathlib import Path
|
|
3
43
|
import re
|
|
44
|
+
from pathlib import Path
|
|
4
45
|
|
|
5
46
|
logging.basicConfig(
|
|
6
47
|
level=logging.INFO,
|
|
@@ -8,31 +49,80 @@ logging.basicConfig(
|
|
|
8
49
|
encoding='utf-8'
|
|
9
50
|
)
|
|
10
51
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
52
|
+
# ============================================================
|
|
53
|
+
# 新版 SDK 入口(v0.3.0+)
|
|
54
|
+
# ============================================================
|
|
55
|
+
|
|
56
|
+
from ._client import XParseClient
|
|
57
|
+
from ._config import RetryConfiguration, SDKConfiguration
|
|
58
|
+
from .exceptions import (
|
|
59
|
+
APIError,
|
|
60
|
+
AuthenticationError,
|
|
61
|
+
ConfigurationError,
|
|
62
|
+
ConnectorError,
|
|
63
|
+
DestinationError,
|
|
64
|
+
NotFoundError,
|
|
65
|
+
PermissionDeniedError,
|
|
66
|
+
PipelineError,
|
|
67
|
+
RateLimitError,
|
|
68
|
+
RequestTimeoutError,
|
|
69
|
+
ServerError,
|
|
70
|
+
SourceError,
|
|
71
|
+
ValidationError,
|
|
72
|
+
XParseClientError,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# ============================================================
|
|
76
|
+
# 新版模型(推荐使用)
|
|
77
|
+
# ============================================================
|
|
78
|
+
from .models import (
|
|
79
|
+
AsyncJobResponse,
|
|
80
|
+
Element,
|
|
81
|
+
ElementMetadata,
|
|
82
|
+
ExtractConfig,
|
|
83
|
+
JobStatusResponse,
|
|
84
|
+
ParseConfig,
|
|
85
|
+
ParseResponse,
|
|
86
|
+
PipelineConfig,
|
|
87
|
+
PipelineResponse,
|
|
88
|
+
PipelineStage,
|
|
89
|
+
PipelineStats,
|
|
90
|
+
)
|
|
15
91
|
|
|
16
92
|
__all__ = [
|
|
93
|
+
# 新版 SDK 入口
|
|
94
|
+
'XParseClient',
|
|
95
|
+
'SDKConfiguration',
|
|
96
|
+
'RetryConfiguration',
|
|
97
|
+
|
|
98
|
+
# 异常类
|
|
99
|
+
'XParseClientError',
|
|
100
|
+
'ConfigurationError',
|
|
101
|
+
'ValidationError',
|
|
102
|
+
'APIError',
|
|
103
|
+
'AuthenticationError',
|
|
104
|
+
'PermissionDeniedError',
|
|
105
|
+
'NotFoundError',
|
|
106
|
+
'RateLimitError',
|
|
107
|
+
'ServerError',
|
|
108
|
+
'RequestTimeoutError',
|
|
109
|
+
'ConnectorError',
|
|
110
|
+
'SourceError',
|
|
111
|
+
'DestinationError',
|
|
112
|
+
'PipelineError',
|
|
113
|
+
|
|
114
|
+
# 新版模型
|
|
17
115
|
'ParseConfig',
|
|
18
|
-
'
|
|
19
|
-
'
|
|
116
|
+
'Element',
|
|
117
|
+
'ElementMetadata',
|
|
118
|
+
'ParseResponse',
|
|
119
|
+
'AsyncJobResponse',
|
|
120
|
+
'JobStatusResponse',
|
|
20
121
|
'ExtractConfig',
|
|
21
|
-
'
|
|
122
|
+
'PipelineStage',
|
|
22
123
|
'PipelineStats',
|
|
23
124
|
'PipelineConfig',
|
|
24
|
-
'
|
|
25
|
-
'S3Source',
|
|
26
|
-
'LocalSource',
|
|
27
|
-
'FtpSource',
|
|
28
|
-
'SmbSource',
|
|
29
|
-
'Destination',
|
|
30
|
-
'MilvusDestination',
|
|
31
|
-
'QdrantDestination',
|
|
32
|
-
'LocalDestination',
|
|
33
|
-
'S3Destination',
|
|
34
|
-
'Pipeline',
|
|
35
|
-
'create_pipeline_from_config',
|
|
125
|
+
'PipelineResponse',
|
|
36
126
|
]
|
|
37
127
|
|
|
38
128
|
# 自动从 pyproject.toml 读取版本号
|