xparse-client 0.2.19__py3-none-any.whl → 0.3.0b3__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.
Files changed (75) hide show
  1. example/1_basic_api_usage.py +198 -0
  2. example/2_async_job.py +210 -0
  3. example/3_local_workflow.py +300 -0
  4. example/4_advanced_workflow.py +327 -0
  5. example/README.md +128 -0
  6. example/config_example.json +95 -0
  7. tests/conftest.py +310 -0
  8. tests/unit/__init__.py +1 -0
  9. tests/unit/api/__init__.py +1 -0
  10. tests/unit/api/test_extract.py +232 -0
  11. tests/unit/api/test_local.py +231 -0
  12. tests/unit/api/test_parse.py +374 -0
  13. tests/unit/api/test_pipeline.py +369 -0
  14. tests/unit/api/test_workflows.py +108 -0
  15. tests/unit/connectors/test_ftp.py +525 -0
  16. tests/unit/connectors/test_local_connectors.py +324 -0
  17. tests/unit/connectors/test_milvus.py +368 -0
  18. tests/unit/connectors/test_qdrant.py +399 -0
  19. tests/unit/connectors/test_s3.py +598 -0
  20. tests/unit/connectors/test_smb.py +442 -0
  21. tests/unit/connectors/test_utils.py +335 -0
  22. tests/unit/models/test_local.py +54 -0
  23. tests/unit/models/test_pipeline_stages.py +144 -0
  24. tests/unit/models/test_workflows.py +55 -0
  25. tests/unit/test_base.py +437 -0
  26. tests/unit/test_client.py +110 -0
  27. tests/unit/test_config.py +160 -0
  28. tests/unit/test_exceptions.py +182 -0
  29. tests/unit/test_http.py +562 -0
  30. xparse_client/__init__.py +111 -20
  31. xparse_client/_base.py +179 -0
  32. xparse_client/_client.py +218 -0
  33. xparse_client/_config.py +221 -0
  34. xparse_client/_http.py +350 -0
  35. xparse_client/api/__init__.py +14 -0
  36. xparse_client/api/extract.py +109 -0
  37. xparse_client/api/local.py +215 -0
  38. xparse_client/api/parse.py +209 -0
  39. xparse_client/api/pipeline.py +134 -0
  40. xparse_client/api/workflows.py +204 -0
  41. xparse_client/connectors/__init__.py +45 -0
  42. xparse_client/connectors/_utils.py +138 -0
  43. xparse_client/connectors/destinations/__init__.py +45 -0
  44. xparse_client/connectors/destinations/base.py +116 -0
  45. xparse_client/connectors/destinations/local.py +91 -0
  46. xparse_client/connectors/destinations/milvus.py +229 -0
  47. xparse_client/connectors/destinations/qdrant.py +238 -0
  48. xparse_client/connectors/destinations/s3.py +163 -0
  49. xparse_client/connectors/sources/__init__.py +45 -0
  50. xparse_client/connectors/sources/base.py +74 -0
  51. xparse_client/connectors/sources/ftp.py +278 -0
  52. xparse_client/connectors/sources/local.py +176 -0
  53. xparse_client/connectors/sources/s3.py +232 -0
  54. xparse_client/connectors/sources/smb.py +259 -0
  55. xparse_client/exceptions.py +398 -0
  56. xparse_client/models/__init__.py +60 -0
  57. xparse_client/models/chunk.py +39 -0
  58. xparse_client/models/embed.py +62 -0
  59. xparse_client/models/extract.py +41 -0
  60. xparse_client/models/local.py +38 -0
  61. xparse_client/models/parse.py +136 -0
  62. xparse_client/models/pipeline.py +134 -0
  63. xparse_client/models/workflows.py +74 -0
  64. xparse_client-0.3.0b3.dist-info/METADATA +1075 -0
  65. xparse_client-0.3.0b3.dist-info/RECORD +68 -0
  66. {xparse_client-0.2.19.dist-info → xparse_client-0.3.0b3.dist-info}/WHEEL +1 -1
  67. {xparse_client-0.2.19.dist-info → xparse_client-0.3.0b3.dist-info}/licenses/LICENSE +1 -1
  68. {xparse_client-0.2.19.dist-info → xparse_client-0.3.0b3.dist-info}/top_level.txt +2 -0
  69. xparse_client/pipeline/__init__.py +0 -3
  70. xparse_client/pipeline/config.py +0 -129
  71. xparse_client/pipeline/destinations.py +0 -489
  72. xparse_client/pipeline/pipeline.py +0 -690
  73. xparse_client/pipeline/sources.py +0 -583
  74. xparse_client-0.2.19.dist-info/METADATA +0 -1050
  75. xparse_client-0.2.19.dist-info/RECORD +0 -11
@@ -0,0 +1,437 @@
1
+ """BaseAPI 测试
2
+
3
+ 测试 xparse_client._base 模块的 BaseAPI 基类。
4
+
5
+ 运行方式:
6
+ pytest tests/unit/test_base.py -v
7
+ """
8
+
9
+
10
+ import httpx
11
+ import pytest
12
+ import respx
13
+ from pydantic import BaseModel
14
+
15
+ from xparse_client._base import BaseAPI
16
+ from xparse_client._config import SDKConfiguration
17
+ from xparse_client._http import HTTPClient
18
+
19
+ # ============================================================================
20
+ # 测试用 Pydantic 模型
21
+ # ============================================================================
22
+
23
+ class SampleModel(BaseModel):
24
+ """测试用模型"""
25
+ id: str
26
+ name: str
27
+ value: int = 0
28
+
29
+
30
+ class ConcreteAPI(BaseAPI):
31
+ """用于测试的具体 API 类"""
32
+ pass
33
+
34
+
35
+ # ============================================================================
36
+ # Fixtures
37
+ # ============================================================================
38
+
39
+ @pytest.fixture
40
+ def config():
41
+ """创建测试配置"""
42
+ return SDKConfiguration(
43
+ app_id="test_app",
44
+ secret_code="test_secret",
45
+ server_url="https://api.test.com"
46
+ )
47
+
48
+
49
+ @pytest.fixture
50
+ def http_client(config):
51
+ """创建测试 HTTP 客户端"""
52
+ return HTTPClient(config)
53
+
54
+
55
+ @pytest.fixture
56
+ def api(config, http_client):
57
+ """创建测试 API 实例"""
58
+ return ConcreteAPI(config, http_client)
59
+
60
+
61
+ # ============================================================================
62
+ # 初始化测试
63
+ # ============================================================================
64
+
65
+ def test_base_api_initialization(config, http_client):
66
+ """测试 BaseAPI 初始化"""
67
+ api = ConcreteAPI(config, http_client)
68
+
69
+ assert api._config == config
70
+ assert api._http == http_client
71
+ assert api._base_path == "/api/xparse"
72
+
73
+
74
+ # ============================================================================
75
+ # HTTP 请求方法测试
76
+ # ============================================================================
77
+
78
+ @respx.mock
79
+ def test_base_api_get_request(api):
80
+ """测试 _get 方法"""
81
+ respx.get("https://api.test.com/api/xparse/test").mock(
82
+ return_value=httpx.Response(200, json={"success": True})
83
+ )
84
+
85
+ response = api._get("/test")
86
+
87
+ assert response.status_code == 200
88
+ assert response.json() == {"success": True}
89
+
90
+
91
+ @respx.mock
92
+ def test_base_api_post_request(api):
93
+ """测试 _post 方法"""
94
+ respx.post("https://api.test.com/api/xparse/test").mock(
95
+ return_value=httpx.Response(200, json={"success": True})
96
+ )
97
+
98
+ response = api._post("/test", json={"data": "value"})
99
+
100
+ assert response.status_code == 200
101
+ assert response.json() == {"success": True}
102
+
103
+
104
+ @respx.mock
105
+ def test_base_api_put_request(api):
106
+ """测试 _put 方法"""
107
+ respx.put("https://api.test.com/api/xparse/test/123").mock(
108
+ return_value=httpx.Response(200, json={"success": True})
109
+ )
110
+
111
+ response = api._put("/test/123", json={"data": "updated"})
112
+
113
+ assert response.status_code == 200
114
+
115
+
116
+ @respx.mock
117
+ def test_base_api_delete_request(api):
118
+ """测试 _delete 方法"""
119
+ respx.delete("https://api.test.com/api/xparse/test/123").mock(
120
+ return_value=httpx.Response(204)
121
+ )
122
+
123
+ response = api._delete("/test/123")
124
+
125
+ assert response.status_code == 204
126
+
127
+
128
+ @respx.mock
129
+ def test_base_api_request_with_params(api):
130
+ """测试带查询参数的请求"""
131
+ respx.get("https://api.test.com/api/xparse/test?page=1&limit=10").mock(
132
+ return_value=httpx.Response(200, json={"items": []})
133
+ )
134
+
135
+ response = api._get("/test", params={"page": 1, "limit": 10})
136
+
137
+ assert response.status_code == 200
138
+
139
+
140
+ @respx.mock
141
+ def test_base_api_request_with_headers(api):
142
+ """测试带自定义请求头的请求"""
143
+ route = respx.get("https://api.test.com/api/xparse/test").mock(
144
+ return_value=httpx.Response(200, json={})
145
+ )
146
+
147
+ api._get("/test", headers={"X-Custom-Header": "value"})
148
+
149
+ # 验证请求头
150
+ assert route.called
151
+ request = route.calls.last.request
152
+ assert "X-Custom-Header" in request.headers
153
+ assert request.headers["X-Custom-Header"] == "value"
154
+
155
+
156
+ @respx.mock
157
+ def test_base_api_request_with_timeout(api):
158
+ """测试带超时的请求"""
159
+ respx.get("https://api.test.com/api/xparse/test").mock(
160
+ return_value=httpx.Response(200, json={})
161
+ )
162
+
163
+ # 不会抛出异常
164
+ response = api._get("/test", timeout=5.0)
165
+ assert response.status_code == 200
166
+
167
+
168
+ # ============================================================================
169
+ # 响应解析测试
170
+ # ============================================================================
171
+
172
+ @respx.mock
173
+ def test_parse_response_standard_format(api):
174
+ """测试解析标准格式响应 {"code": 200, "data": {...}}"""
175
+ response_data = {
176
+ "code": 200,
177
+ "data": {
178
+ "id": "123",
179
+ "name": "test",
180
+ "value": 42
181
+ }
182
+ }
183
+
184
+ respx.get("https://api.test.com/api/xparse/test").mock(
185
+ return_value=httpx.Response(200, json=response_data)
186
+ )
187
+
188
+ response = api._get("/test")
189
+ result = api._parse_response(response, SampleModel)
190
+
191
+ assert isinstance(result, SampleModel)
192
+ assert result.id == "123"
193
+ assert result.name == "test"
194
+ assert result.value == 42
195
+
196
+
197
+ @respx.mock
198
+ def test_parse_response_direct_format(api):
199
+ """测试解析直接返回格式 {...}"""
200
+ response_data = {
201
+ "id": "123",
202
+ "name": "test",
203
+ "value": 42
204
+ }
205
+
206
+ respx.get("https://api.test.com/api/xparse/test").mock(
207
+ return_value=httpx.Response(200, json=response_data)
208
+ )
209
+
210
+ response = api._get("/test")
211
+ result = api._parse_response(response, SampleModel)
212
+
213
+ assert isinstance(result, SampleModel)
214
+ assert result.id == "123"
215
+
216
+
217
+ @respx.mock
218
+ def test_parse_response_with_default_values(api):
219
+ """测试解析响应使用模型默认值"""
220
+ response_data = {
221
+ "code": 200,
222
+ "data": {
223
+ "id": "123",
224
+ "name": "test"
225
+ # value 缺失,应使用默认值 0
226
+ }
227
+ }
228
+
229
+ respx.get("https://api.test.com/api/xparse/test").mock(
230
+ return_value=httpx.Response(200, json=response_data)
231
+ )
232
+
233
+ response = api._get("/test")
234
+ result = api._parse_response(response, SampleModel)
235
+
236
+ assert result.value == 0
237
+
238
+
239
+ @respx.mock
240
+ def test_parse_list_response_standard_format(api):
241
+ """测试解析标准格式的列表响应"""
242
+ response_data = {
243
+ "code": 200,
244
+ "data": [
245
+ {"id": "1", "name": "first", "value": 10},
246
+ {"id": "2", "name": "second", "value": 20},
247
+ ]
248
+ }
249
+
250
+ respx.get("https://api.test.com/api/xparse/test").mock(
251
+ return_value=httpx.Response(200, json=response_data)
252
+ )
253
+
254
+ response = api._get("/test")
255
+ result = api._parse_list_response(response, SampleModel)
256
+
257
+ assert isinstance(result, list)
258
+ assert len(result) == 2
259
+ assert all(isinstance(item, SampleModel) for item in result)
260
+ assert result[0].id == "1"
261
+ assert result[1].id == "2"
262
+
263
+
264
+ @respx.mock
265
+ def test_parse_list_response_direct_format(api):
266
+ """测试解析直接返回的列表"""
267
+ response_data = [
268
+ {"id": "1", "name": "first", "value": 10},
269
+ {"id": "2", "name": "second", "value": 20},
270
+ ]
271
+
272
+ respx.get("https://api.test.com/api/xparse/test").mock(
273
+ return_value=httpx.Response(200, json=response_data)
274
+ )
275
+
276
+ response = api._get("/test")
277
+ result = api._parse_list_response(response, SampleModel)
278
+
279
+ assert len(result) == 2
280
+
281
+
282
+ @respx.mock
283
+ def test_parse_list_response_single_item_as_list(api):
284
+ """测试解析单个对象为列表"""
285
+ response_data = {
286
+ "code": 200,
287
+ "data": {"id": "1", "name": "single", "value": 10}
288
+ }
289
+
290
+ respx.get("https://api.test.com/api/xparse/test").mock(
291
+ return_value=httpx.Response(200, json=response_data)
292
+ )
293
+
294
+ response = api._get("/test")
295
+ result = api._parse_list_response(response, SampleModel)
296
+
297
+ # 单个对象应该被包装成列表
298
+ assert isinstance(result, list)
299
+ assert len(result) == 1
300
+ assert result[0].id == "1"
301
+
302
+
303
+ @respx.mock
304
+ def test_parse_list_response_empty_list(api):
305
+ """测试解析空列表"""
306
+ response_data = {
307
+ "code": 200,
308
+ "data": []
309
+ }
310
+
311
+ respx.get("https://api.test.com/api/xparse/test").mock(
312
+ return_value=httpx.Response(200, json=response_data)
313
+ )
314
+
315
+ response = api._get("/test")
316
+ result = api._parse_list_response(response, SampleModel)
317
+
318
+ assert isinstance(result, list)
319
+ assert len(result) == 0
320
+
321
+
322
+ @respx.mock
323
+ def test_parse_raw_response_standard_format(api):
324
+ """测试解析原始响应(标准格式)"""
325
+ response_data = {
326
+ "code": 200,
327
+ "data": {
328
+ "key1": "value1",
329
+ "key2": 123,
330
+ "nested": {"inner": "data"}
331
+ }
332
+ }
333
+
334
+ respx.get("https://api.test.com/api/xparse/test").mock(
335
+ return_value=httpx.Response(200, json=response_data)
336
+ )
337
+
338
+ response = api._get("/test")
339
+ result = api._parse_raw_response(response)
340
+
341
+ assert result == response_data["data"]
342
+ assert result["key1"] == "value1"
343
+ assert result["nested"]["inner"] == "data"
344
+
345
+
346
+ @respx.mock
347
+ def test_parse_raw_response_direct_format(api):
348
+ """测试解析原始响应(直接格式)"""
349
+ response_data = {
350
+ "key1": "value1",
351
+ "key2": 123
352
+ }
353
+
354
+ respx.get("https://api.test.com/api/xparse/test").mock(
355
+ return_value=httpx.Response(200, json=response_data)
356
+ )
357
+
358
+ response = api._get("/test")
359
+ result = api._parse_raw_response(response)
360
+
361
+ assert result == response_data
362
+
363
+
364
+ # ============================================================================
365
+ # 路径构造测试
366
+ # ============================================================================
367
+
368
+ @respx.mock
369
+ def test_base_api_path_construction(api):
370
+ """测试 API 路径构造"""
371
+ route = respx.get("https://api.test.com/api/xparse/v1/resource/123").mock(
372
+ return_value=httpx.Response(200, json={})
373
+ )
374
+
375
+ api._get("/v1/resource/123")
376
+
377
+ assert route.called
378
+
379
+
380
+ @respx.mock
381
+ def test_base_api_path_without_leading_slash(api):
382
+ """测试路径不以斜杠开头"""
383
+ route = respx.get("https://api.test.com/api/xparseresource").mock(
384
+ return_value=httpx.Response(200, json={})
385
+ )
386
+
387
+ api._get("resource")
388
+
389
+ assert route.called
390
+
391
+
392
+ # ============================================================================
393
+ # 错误处理测试
394
+ # ============================================================================
395
+
396
+ @respx.mock
397
+ def test_parse_response_validation_error(api):
398
+ """测试响应数据验证错误"""
399
+ response_data = {
400
+ "code": 200,
401
+ "data": {
402
+ "id": "123"
403
+ # name 缺失且没有默认值,应该抛出验证错误
404
+ }
405
+ }
406
+
407
+ respx.get("https://api.test.com/api/xparse/test").mock(
408
+ return_value=httpx.Response(200, json=response_data)
409
+ )
410
+
411
+ response = api._get("/test")
412
+
413
+ from pydantic import ValidationError
414
+ with pytest.raises(ValidationError):
415
+ api._parse_response(response, SampleModel)
416
+
417
+
418
+ @respx.mock
419
+ def test_parse_list_response_validation_error(api):
420
+ """测试列表响应中的验证错误"""
421
+ response_data = {
422
+ "code": 200,
423
+ "data": [
424
+ {"id": "1", "name": "valid", "value": 10},
425
+ {"id": "2"} # name 缺失
426
+ ]
427
+ }
428
+
429
+ respx.get("https://api.test.com/api/xparse/test").mock(
430
+ return_value=httpx.Response(200, json=response_data)
431
+ )
432
+
433
+ response = api._get("/test")
434
+
435
+ from pydantic import ValidationError
436
+ with pytest.raises(ValidationError):
437
+ api._parse_list_response(response, SampleModel)
@@ -0,0 +1,110 @@
1
+ """XParseClient 测试"""
2
+
3
+ import pytest
4
+
5
+ from xparse_client import XParseClient
6
+ from xparse_client._config import SDKConfiguration
7
+
8
+
9
+ class TestXParseClient:
10
+ """XParseClient 测试"""
11
+
12
+ def test_create_with_credentials(self, app_id, secret_code):
13
+ """测试使用凭证创建客户端"""
14
+ client = XParseClient(
15
+ app_id=app_id,
16
+ secret_code=secret_code,
17
+ )
18
+ assert client.config.app_id == app_id
19
+ assert client.config.secret_code == secret_code
20
+
21
+ def test_create_with_config(self, app_id, secret_code):
22
+ """测试使用配置对象创建客户端"""
23
+ config = SDKConfiguration(
24
+ app_id=app_id,
25
+ secret_code=secret_code,
26
+ )
27
+ client = XParseClient(config=config)
28
+ assert client.config is config
29
+
30
+ def test_create_with_custom_settings(self, app_id, secret_code, server_url):
31
+ """测试自定义设置"""
32
+ client = XParseClient(
33
+ app_id=app_id,
34
+ secret_code=secret_code,
35
+ server_url=server_url,
36
+ timeout=60.0,
37
+ max_retries=5,
38
+ )
39
+ assert client.config.server_url == server_url
40
+ assert client.config.timeout == 60.0
41
+ assert client.config.max_retries == 5
42
+
43
+ def test_from_env(self, app_id, secret_code, monkeypatch):
44
+ """测试从环境变量创建"""
45
+ monkeypatch.setenv("TEXTIN_APP_ID", app_id)
46
+ monkeypatch.setenv("TEXTIN_SECRET_CODE", secret_code)
47
+
48
+ client = XParseClient.from_env()
49
+ assert client.config.app_id == app_id
50
+ assert client.config.secret_code == secret_code
51
+
52
+ def test_lazy_load_parse_api(self, app_id, secret_code):
53
+ """测试懒加载 Parse API"""
54
+ client = XParseClient(
55
+ app_id=app_id,
56
+ secret_code=secret_code,
57
+ )
58
+
59
+ # 访问 parse 属性应该返回 Parse 实例
60
+ parse_api = client.parse
61
+ assert parse_api is not None
62
+
63
+ # 再次访问应该返回同一个实例(缓存)
64
+ assert client.parse is parse_api
65
+
66
+ def test_lazy_load_extract_api(self, app_id, secret_code):
67
+ """测试懒加载 Extract API"""
68
+ client = XParseClient(
69
+ app_id=app_id,
70
+ secret_code=secret_code,
71
+ )
72
+ extract_api = client.extract
73
+ assert extract_api is not None
74
+
75
+ def test_lazy_load_pipeline_api(self, app_id, secret_code):
76
+ """测试懒加载 Pipeline API"""
77
+ client = XParseClient(
78
+ app_id=app_id,
79
+ secret_code=secret_code,
80
+ )
81
+ pipeline_api = client.pipeline
82
+ assert pipeline_api is not None
83
+
84
+ def test_unknown_attribute(self, app_id, secret_code):
85
+ """测试访问未知属性"""
86
+ client = XParseClient(
87
+ app_id=app_id,
88
+ secret_code=secret_code,
89
+ )
90
+ with pytest.raises(AttributeError):
91
+ _ = client.unknown_api
92
+
93
+ def test_context_manager(self, app_id, secret_code):
94
+ """测试上下文管理器"""
95
+ with XParseClient(app_id=app_id, secret_code=secret_code) as client:
96
+ assert client is not None
97
+
98
+ def test_close(self, app_id, secret_code):
99
+ """测试关闭客户端"""
100
+ client = XParseClient(
101
+ app_id=app_id,
102
+ secret_code=secret_code,
103
+ )
104
+ # 预热 API 缓存
105
+ _ = client.parse
106
+ assert len(client._api_cache) > 0
107
+
108
+ # 关闭应该清空缓存
109
+ client.close()
110
+ assert len(client._api_cache) == 0
@@ -0,0 +1,160 @@
1
+ """配置类测试"""
2
+
3
+ import pytest
4
+
5
+ from xparse_client._config import (
6
+ DEFAULT_MAX_RETRIES,
7
+ DEFAULT_SERVER_URL,
8
+ DEFAULT_TIMEOUT,
9
+ RetryConfiguration,
10
+ SDKConfiguration,
11
+ )
12
+ from xparse_client.exceptions import ConfigurationError
13
+
14
+
15
+ class TestSDKConfiguration:
16
+ """SDK 配置测试"""
17
+
18
+ def test_basic_config(self, app_id, secret_code):
19
+ """测试基本配置"""
20
+ config = SDKConfiguration(
21
+ app_id=app_id,
22
+ secret_code=secret_code,
23
+ )
24
+ assert config.app_id == app_id
25
+ assert config.secret_code == secret_code
26
+ assert config.server_url == DEFAULT_SERVER_URL
27
+ assert config.timeout == DEFAULT_TIMEOUT
28
+ assert config.max_retries == DEFAULT_MAX_RETRIES
29
+
30
+ def test_custom_config(self, app_id, secret_code):
31
+ """测试自定义配置"""
32
+ config = SDKConfiguration(
33
+ app_id=app_id,
34
+ secret_code=secret_code,
35
+ server_url="https://custom.api.com",
36
+ timeout=60.0,
37
+ max_retries=5,
38
+ )
39
+ assert config.server_url == "https://custom.api.com"
40
+ assert config.timeout == 60.0
41
+ assert config.max_retries == 5
42
+
43
+ def test_trailing_slash_removed(self, app_id, secret_code):
44
+ """测试移除末尾斜杠"""
45
+ config = SDKConfiguration(
46
+ app_id=app_id,
47
+ secret_code=secret_code,
48
+ server_url="https://api.com/",
49
+ )
50
+ assert config.server_url == "https://api.com"
51
+
52
+ def test_missing_app_id(self, secret_code):
53
+ """测试缺少 app_id"""
54
+ with pytest.raises(ConfigurationError) as exc_info:
55
+ SDKConfiguration(app_id="", secret_code=secret_code)
56
+ assert "app_id" in str(exc_info.value)
57
+
58
+ def test_missing_secret_code(self, app_id):
59
+ """测试缺少 secret_code"""
60
+ with pytest.raises(ConfigurationError) as exc_info:
61
+ SDKConfiguration(app_id=app_id, secret_code="")
62
+ assert "secret_code" in str(exc_info.value)
63
+
64
+ def test_invalid_timeout(self, app_id, secret_code):
65
+ """测试无效的 timeout"""
66
+ with pytest.raises(ConfigurationError) as exc_info:
67
+ SDKConfiguration(
68
+ app_id=app_id,
69
+ secret_code=secret_code,
70
+ timeout=-1,
71
+ )
72
+ assert "timeout" in str(exc_info.value)
73
+
74
+ def test_invalid_max_retries(self, app_id, secret_code):
75
+ """测试无效的 max_retries"""
76
+ with pytest.raises(ConfigurationError) as exc_info:
77
+ SDKConfiguration(
78
+ app_id=app_id,
79
+ secret_code=secret_code,
80
+ max_retries=-1,
81
+ )
82
+ assert "max_retries" in str(exc_info.value)
83
+
84
+ def test_get_auth_headers(self, app_id, secret_code):
85
+ """测试获取认证头"""
86
+ config = SDKConfiguration(
87
+ app_id=app_id,
88
+ secret_code=secret_code,
89
+ )
90
+ headers = config.get_auth_headers()
91
+ assert headers["x-ti-app-id"] == app_id
92
+ assert headers["x-ti-secret-code"] == secret_code
93
+
94
+ def test_extra_headers(self, app_id, secret_code):
95
+ """测试额外请求头"""
96
+ config = SDKConfiguration(
97
+ app_id=app_id,
98
+ secret_code=secret_code,
99
+ extra_headers={"X-Custom": "value"},
100
+ )
101
+ headers = config.get_auth_headers()
102
+ assert headers["X-Custom"] == "value"
103
+
104
+ def test_from_env(self, app_id, secret_code, monkeypatch):
105
+ """测试从环境变量创建配置"""
106
+ monkeypatch.setenv("TEXTIN_APP_ID", app_id)
107
+ monkeypatch.setenv("TEXTIN_SECRET_CODE", secret_code)
108
+
109
+ config = SDKConfiguration.from_env()
110
+ assert config.app_id == app_id
111
+ assert config.secret_code == secret_code
112
+
113
+ def test_from_env_missing(self, monkeypatch):
114
+ """测试环境变量缺失"""
115
+ monkeypatch.delenv("TEXTIN_APP_ID", raising=False)
116
+ monkeypatch.delenv("TEXTIN_SECRET_CODE", raising=False)
117
+
118
+ with pytest.raises(ConfigurationError):
119
+ SDKConfiguration.from_env()
120
+
121
+
122
+ class TestRetryConfiguration:
123
+ """重试配置测试"""
124
+
125
+ def test_default_config(self):
126
+ """测试默认配置"""
127
+ config = RetryConfiguration()
128
+ assert config.max_retries == DEFAULT_MAX_RETRIES
129
+ assert 429 in config.retry_status_codes
130
+ assert 500 in config.retry_status_codes
131
+
132
+ def test_calculate_backoff(self):
133
+ """测试退避时间计算"""
134
+ config = RetryConfiguration(backoff_base=2.0, backoff_max=60.0)
135
+
136
+ # 第0次重试: 2^0 = 1
137
+ assert config.calculate_backoff(0) == 1.0
138
+ # 第1次重试: 2^1 = 2
139
+ assert config.calculate_backoff(1) == 2.0
140
+ # 第2次重试: 2^2 = 4
141
+ assert config.calculate_backoff(2) == 4.0
142
+ # 第6次重试: 2^6 = 64 > 60,所以返回 60
143
+ assert config.calculate_backoff(6) == 60.0
144
+
145
+ def test_should_retry(self):
146
+ """测试是否应该重试"""
147
+ config = RetryConfiguration(max_retries=3)
148
+
149
+ # 可重试状态码
150
+ assert config.should_retry(429, 0) is True
151
+ assert config.should_retry(500, 0) is True
152
+ assert config.should_retry(503, 0) is True
153
+
154
+ # 不可重试状态码
155
+ assert config.should_retry(400, 0) is False
156
+ assert config.should_retry(401, 0) is False
157
+ assert config.should_retry(404, 0) is False
158
+
159
+ # 超过最大重试次数
160
+ assert config.should_retry(500, 3) is False