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,231 @@
1
+ """测试 Local API"""
2
+
3
+ import httpx
4
+ import pytest
5
+
6
+ from xparse_client import XParseClient
7
+ from xparse_client.connectors import LocalDestination, LocalSource
8
+ from xparse_client.exceptions import APIError
9
+ from xparse_client.models import ParseConfig, ParseStage
10
+
11
+
12
+ @pytest.fixture
13
+ def mock_client(respx_mock):
14
+ """创建带 mock 的客户端"""
15
+ client = XParseClient(
16
+ app_id="test-app-id",
17
+ secret_code="test-secret"
18
+ )
19
+ return client
20
+
21
+
22
+ def test_local_api_exists(mock_client):
23
+ """测试 client.local 属性存在"""
24
+ assert hasattr(mock_client, 'local')
25
+ local_api = mock_client.local
26
+ assert local_api is not None
27
+
28
+
29
+ def test_local_api_has_run_workflow(mock_client):
30
+ """测试 local.run_workflow 方法存在"""
31
+ local_api = mock_client.local
32
+ assert hasattr(local_api, 'run_workflow')
33
+ assert callable(local_api.run_workflow)
34
+
35
+
36
+ def test_run_workflow_requires_source(mock_client):
37
+ """测试 run_workflow 需要 source 参数"""
38
+ with pytest.raises(TypeError):
39
+ mock_client.local.run_workflow(
40
+ destination=LocalDestination(directory="./output"),
41
+ stages=[]
42
+ )
43
+
44
+
45
+ def test_run_workflow_requires_destination(mock_client):
46
+ """测试 run_workflow 需要 destination 参数"""
47
+ with pytest.raises(TypeError):
48
+ mock_client.local.run_workflow(
49
+ source=LocalSource(directory="./docs"),
50
+ stages=[]
51
+ )
52
+
53
+
54
+ def test_run_workflow_requires_stages(mock_client):
55
+ """测试 run_workflow 需要 stages 参数"""
56
+ with pytest.raises(TypeError):
57
+ mock_client.local.run_workflow(
58
+ source=LocalSource(directory="./docs"),
59
+ destination=LocalDestination(directory="./output")
60
+ )
61
+
62
+
63
+ def test_run_workflow_single_file_success(mock_client, respx_mock, tmp_path):
64
+ """测试单文件工作流执行成功"""
65
+ # 创建测试文件
66
+ source_dir = tmp_path / "source"
67
+ source_dir.mkdir()
68
+ test_file = source_dir / "test.txt"
69
+ test_file.write_text("test content")
70
+
71
+ dest_dir = tmp_path / "dest"
72
+ dest_dir.mkdir()
73
+
74
+ # Mock pipeline API
75
+ respx_mock.post("https://api.textin.com/api/xparse/pipeline").mock(
76
+ return_value=httpx.Response(
77
+ 200,
78
+ json={
79
+ "elements": [{"element_id": "elem_1", "type": "text", "text": "parsed"}],
80
+ "record_id": "rec_123",
81
+ },
82
+ headers={"x-request-id": "req_123"}
83
+ )
84
+ )
85
+
86
+ # 执行工作流
87
+ result = mock_client.local.run_workflow(
88
+ source=LocalSource(directory=str(source_dir)),
89
+ destination=LocalDestination(output_dir=str(dest_dir)),
90
+ stages=[ParseStage(config=ParseConfig(provider="textin"))]
91
+ )
92
+
93
+ # 验证结果
94
+ assert result.total == 1
95
+ assert result.success == 1
96
+ assert result.failed == 0
97
+ assert len(result.failed_files) == 0
98
+ assert result.duration > 0
99
+
100
+
101
+ def test_run_workflow_multiple_files(mock_client, respx_mock, tmp_path):
102
+ """测试多文件工作流执行"""
103
+ # 创建多个测试文件
104
+ source_dir = tmp_path / "source"
105
+ source_dir.mkdir()
106
+ for i in range(3):
107
+ (source_dir / f"test{i}.txt").write_text(f"content {i}")
108
+
109
+ dest_dir = tmp_path / "dest"
110
+ dest_dir.mkdir()
111
+
112
+ # Mock pipeline API
113
+ respx_mock.post("https://api.textin.com/api/xparse/pipeline").mock(
114
+ return_value=httpx.Response(
115
+ 200,
116
+ json={
117
+ "elements": [{"element_id": "elem_1", "type": "text", "text": "parsed"}],
118
+ "record_id": "rec_123",
119
+ }
120
+ )
121
+ )
122
+
123
+ result = mock_client.local.run_workflow(
124
+ source=LocalSource(directory=str(source_dir)),
125
+ destination=LocalDestination(output_dir=str(dest_dir)),
126
+ stages=[ParseStage(config=ParseConfig(provider="textin"))]
127
+ )
128
+
129
+ assert result.total == 3
130
+ assert result.success == 3
131
+ assert result.failed == 0
132
+
133
+
134
+ def test_run_workflow_with_api_error_stop(mock_client, respx_mock, tmp_path):
135
+ """测试 API 错误时停止策略"""
136
+ source_dir = tmp_path / "source"
137
+ source_dir.mkdir()
138
+ (source_dir / "test1.txt").write_text("content 1")
139
+ (source_dir / "test2.txt").write_text("content 2")
140
+
141
+ dest_dir = tmp_path / "dest"
142
+ dest_dir.mkdir()
143
+
144
+ # 第一个文件失败
145
+ respx_mock.post("https://api.textin.com/api/xparse/pipeline").mock(
146
+ return_value=httpx.Response(500, json={"error": "Server error"})
147
+ )
148
+
149
+ with pytest.raises(APIError):
150
+ mock_client.local.run_workflow(
151
+ source=LocalSource(directory=str(source_dir)),
152
+ destination=LocalDestination(output_dir=str(dest_dir)),
153
+ stages=[ParseStage(config=ParseConfig(provider="textin"))],
154
+ on_error="stop"
155
+ )
156
+
157
+
158
+ def test_run_workflow_with_api_error_continue(mock_client, respx_mock, tmp_path):
159
+ """测试 API 错误时继续策略"""
160
+ source_dir = tmp_path / "source"
161
+ source_dir.mkdir()
162
+ (source_dir / "test1.txt").write_text("content 1")
163
+ (source_dir / "test2.txt").write_text("content 2")
164
+
165
+ dest_dir = tmp_path / "dest"
166
+ dest_dir.mkdir()
167
+
168
+ # 第一个文件成功,第二个失败
169
+ # 注意:HTTP客户端会自动重试,所以我们需要mock足够多的响应
170
+ responses = [
171
+ httpx.Response(200, json={
172
+ "elements": [{"element_id": "elem_1", "type": "text", "text": "parsed"}],
173
+ "record_id": "rec_123"
174
+ }),
175
+ httpx.Response(500, json={"error": "Server error"}),
176
+ httpx.Response(500, json={"error": "Server error"}),
177
+ httpx.Response(500, json={"error": "Server error"}),
178
+ httpx.Response(500, json={"error": "Server error"}), # 重试3次都失败
179
+ ]
180
+ respx_mock.post("https://api.textin.com/api/xparse/pipeline").mock(side_effect=responses)
181
+
182
+ result = mock_client.local.run_workflow(
183
+ source=LocalSource(directory=str(source_dir)),
184
+ destination=LocalDestination(output_dir=str(dest_dir)),
185
+ stages=[ParseStage(config=ParseConfig(provider="textin"))],
186
+ on_error="continue"
187
+ )
188
+
189
+ assert result.total == 2
190
+ assert result.success == 1
191
+ assert result.failed == 1
192
+ assert len(result.failed_files) == 1
193
+ # 检查失败的是test2
194
+ assert result.failed_files[0].file_path == "test2.txt"
195
+ assert "Server error" in result.failed_files[0].error or "500" in result.failed_files[0].error
196
+
197
+
198
+ def test_run_workflow_progress_callback(mock_client, respx_mock, tmp_path):
199
+ """测试进度回调"""
200
+ source_dir = tmp_path / "source"
201
+ source_dir.mkdir()
202
+ for i in range(2):
203
+ (source_dir / f"test{i}.txt").write_text(f"content {i}")
204
+
205
+ dest_dir = tmp_path / "dest"
206
+ dest_dir.mkdir()
207
+
208
+ respx_mock.post("https://api.textin.com/api/xparse/pipeline").mock(
209
+ return_value=httpx.Response(
210
+ 200,
211
+ json={"elements": [{"element_id": "elem_1", "type": "text", "text": ""}], "record_id": "rec_123"}
212
+ )
213
+ )
214
+
215
+ # 记录进度回调
216
+ progress_calls = []
217
+ def on_progress(current, total, message):
218
+ progress_calls.append((current, total, message))
219
+
220
+ mock_client.local.run_workflow(
221
+ source=LocalSource(directory=str(source_dir)),
222
+ destination=LocalDestination(output_dir=str(dest_dir)),
223
+ stages=[ParseStage(config=ParseConfig(provider="textin"))],
224
+ progress_callback=on_progress
225
+ )
226
+
227
+ # 验证回调被调用
228
+ assert len(progress_calls) >= 2
229
+ assert progress_calls[0][1] == 2 # total
230
+ assert progress_calls[-1][0] == 2 # 最后一个是 current=2
231
+
@@ -0,0 +1,374 @@
1
+ """Parse API 测试"""
2
+
3
+ from unittest.mock import patch
4
+
5
+ import httpx
6
+ import pytest
7
+ import respx
8
+
9
+ from xparse_client import XParseClient
10
+ from xparse_client.exceptions import APIError, RequestTimeoutError
11
+ from xparse_client.models import ParseConfig, ParseResponse
12
+
13
+
14
+ class TestParseAPI:
15
+ """Parse API 测试"""
16
+
17
+ @pytest.fixture
18
+ def client(self, app_id, secret_code, server_url):
19
+ """创建测试客户端"""
20
+ return XParseClient(
21
+ app_id=app_id,
22
+ secret_code=secret_code,
23
+ server_url=server_url,
24
+ )
25
+
26
+ @respx.mock
27
+ def test_partition_success(self, client, sample_pdf_bytes, sample_response_data):
28
+ """测试同步解析成功"""
29
+ respx.post(f"{client.config.server_url}/api/xparse/parse/sync").mock(
30
+ return_value=httpx.Response(200, json=sample_response_data)
31
+ )
32
+
33
+ result = client.parse.partition(
34
+ file=sample_pdf_bytes,
35
+ filename="test.pdf",
36
+ )
37
+
38
+ assert isinstance(result, ParseResponse)
39
+ assert len(result.elements) == 1
40
+ assert result.elements[0].element_id == "elem_001"
41
+ assert result.elements[0].type == "text"
42
+
43
+ @respx.mock
44
+ def test_partition_with_config(self, client, sample_pdf_bytes, sample_response_data):
45
+ """测试带配置的同步解析"""
46
+ respx.post(f"{client.config.server_url}/api/xparse/parse/sync").mock(
47
+ return_value=httpx.Response(200, json=sample_response_data)
48
+ )
49
+
50
+ result = client.parse.partition(
51
+ file=sample_pdf_bytes,
52
+ filename="test.pdf",
53
+ config=ParseConfig(provider="textin"),
54
+ )
55
+
56
+ assert isinstance(result, ParseResponse)
57
+
58
+ @respx.mock
59
+ def test_create_async_job(self, client, sample_pdf_bytes):
60
+ """测试创建异步任务"""
61
+ respx.post(f"{client.config.server_url}/api/xparse/parse/async").mock(
62
+ return_value=httpx.Response(200, json={
63
+ "code": 200,
64
+ "data": {"job_id": "job_123"},
65
+ })
66
+ )
67
+
68
+ result = client.parse.create_async_job(
69
+ file=sample_pdf_bytes,
70
+ filename="test.pdf",
71
+ )
72
+
73
+ assert result.job_id == "job_123"
74
+
75
+ @respx.mock
76
+ def test_create_async_job_with_config(self, client, sample_pdf_bytes):
77
+ """测试创建异步任务(带配置)"""
78
+ respx.post(f"{client.config.server_url}/api/xparse/parse/async").mock(
79
+ return_value=httpx.Response(200, json={
80
+ "code": 200,
81
+ "data": {"job_id": "job_456"},
82
+ })
83
+ )
84
+
85
+ result = client.parse.create_async_job(
86
+ file=sample_pdf_bytes,
87
+ filename="test.pdf",
88
+ config=ParseConfig(provider="textin"),
89
+ )
90
+
91
+ assert result.job_id == "job_456"
92
+
93
+ @respx.mock
94
+ def test_create_async_job_with_webhook(self, client, sample_pdf_bytes):
95
+ """测试创建异步任务(带 webhook)"""
96
+ respx.post(f"{client.config.server_url}/api/xparse/parse/async").mock(
97
+ return_value=httpx.Response(200, json={
98
+ "code": 200,
99
+ "data": {"job_id": "job_789"},
100
+ })
101
+ )
102
+
103
+ result = client.parse.create_async_job(
104
+ file=sample_pdf_bytes,
105
+ filename="test.pdf",
106
+ webhook="https://example.com/callback",
107
+ )
108
+
109
+ assert result.job_id == "job_789"
110
+
111
+ @respx.mock
112
+ def test_get_result_completed(self, client):
113
+ """测试获取已完成的任务结果"""
114
+ respx.get(f"{client.config.server_url}/api/xparse/parse/async/job_123").mock(
115
+ return_value=httpx.Response(200, json={
116
+ "code": 200,
117
+ "data": {
118
+ "job_id": "job_123",
119
+ "file_id": "file_456",
120
+ "status": "completed",
121
+ "result_url": "https://example.com/result.json",
122
+ "elements": [
123
+ {"element_id": "elem_001", "type": "text", "text": "test"}
124
+ ],
125
+ },
126
+ })
127
+ )
128
+
129
+ result = client.parse.get_result(job_id="job_123")
130
+
131
+ assert result.job_id == "job_123"
132
+ assert result.file_id == "file_456"
133
+ assert result.status == "completed"
134
+ assert result.result_url == "https://example.com/result.json"
135
+ assert result.is_completed
136
+ assert not result.is_failed
137
+ assert not result.is_running
138
+ assert len(result.elements) == 1
139
+
140
+ @respx.mock
141
+ def test_get_result_in_progress(self, client):
142
+ """测试获取进行中的任务"""
143
+ respx.get(f"{client.config.server_url}/api/xparse/parse/async/job_123").mock(
144
+ return_value=httpx.Response(200, json={
145
+ "code": 200,
146
+ "data": {
147
+ "job_id": "job_123",
148
+ "status": "in_progress",
149
+ },
150
+ })
151
+ )
152
+
153
+ result = client.parse.get_result(job_id="job_123")
154
+
155
+ assert result.status == "in_progress"
156
+ assert result.is_running
157
+ assert not result.is_completed
158
+ assert not result.is_failed
159
+
160
+ @respx.mock
161
+ def test_get_result_scheduled(self, client):
162
+ """测试获取已调度但未开始的任务"""
163
+ respx.get(f"{client.config.server_url}/api/xparse/parse/async/job_123").mock(
164
+ return_value=httpx.Response(200, json={
165
+ "code": 200,
166
+ "data": {
167
+ "job_id": "job_123",
168
+ "status": "scheduled",
169
+ },
170
+ })
171
+ )
172
+
173
+ result = client.parse.get_result(job_id="job_123")
174
+
175
+ assert result.status == "scheduled"
176
+ assert result.is_running
177
+ assert not result.is_completed
178
+
179
+ @respx.mock
180
+ def test_get_result_failed(self, client):
181
+ """测试获取失败的任务"""
182
+ respx.get(f"{client.config.server_url}/api/xparse/parse/async/job_123").mock(
183
+ return_value=httpx.Response(200, json={
184
+ "code": 200,
185
+ "data": {
186
+ "job_id": "job_123",
187
+ "status": "failed",
188
+ "error_message": "处理失败",
189
+ },
190
+ })
191
+ )
192
+
193
+ result = client.parse.get_result(job_id="job_123")
194
+
195
+ assert result.status == "failed"
196
+ assert result.is_failed
197
+ assert not result.is_running
198
+ assert result.error_message == "处理失败"
199
+
200
+ @respx.mock
201
+ @patch('time.sleep')
202
+ def test_wait_for_result_success(self, mock_sleep, client):
203
+ """测试等待任务完成(成功)"""
204
+ # 第一次查询: 进行中
205
+ respx.get(f"{client.config.server_url}/api/xparse/parse/async/job_123").mock(
206
+ side_effect=[
207
+ httpx.Response(200, json={
208
+ "code": 200,
209
+ "data": {
210
+ "job_id": "job_123",
211
+ "status": "in_progress",
212
+ },
213
+ }),
214
+ httpx.Response(200, json={
215
+ "code": 200,
216
+ "data": {
217
+ "job_id": "job_123",
218
+ "status": "completed",
219
+ "elements": [
220
+ {"element_id": "elem_001", "type": "text", "text": "test"}
221
+ ],
222
+ },
223
+ }),
224
+ ]
225
+ )
226
+
227
+ result = client.parse.wait_for_result(
228
+ job_id="job_123",
229
+ poll_interval_seconds=1,
230
+ )
231
+
232
+ assert result.is_completed
233
+ assert len(result.elements) == 1
234
+ # 验证 sleep 被调用了一次
235
+ assert mock_sleep.call_count == 1
236
+ mock_sleep.assert_called_with(1)
237
+
238
+ @respx.mock
239
+ @patch('time.sleep')
240
+ def test_wait_for_result_immediate_completion(self, mock_sleep, client):
241
+ """测试等待任务完成(立即完成)"""
242
+ respx.get(f"{client.config.server_url}/api/xparse/parse/async/job_123").mock(
243
+ return_value=httpx.Response(200, json={
244
+ "code": 200,
245
+ "data": {
246
+ "job_id": "job_123",
247
+ "status": "completed",
248
+ "elements": [],
249
+ },
250
+ })
251
+ )
252
+
253
+ result = client.parse.wait_for_result(job_id="job_123")
254
+
255
+ assert result.is_completed
256
+ # 不应该 sleep
257
+ assert mock_sleep.call_count == 0
258
+
259
+ @respx.mock
260
+ @patch('time.sleep')
261
+ def test_wait_for_result_task_failed(self, mock_sleep, client):
262
+ """测试等待任务完成(任务失败)"""
263
+ respx.get(f"{client.config.server_url}/api/xparse/parse/async/job_123").mock(
264
+ return_value=httpx.Response(200, json={
265
+ "code": 200,
266
+ "data": {
267
+ "job_id": "job_123",
268
+ "status": "failed",
269
+ "error_message": "文件格式不支持",
270
+ },
271
+ })
272
+ )
273
+
274
+ with pytest.raises(APIError) as exc_info:
275
+ client.parse.wait_for_result(job_id="job_123")
276
+
277
+ assert "解析任务失败" in str(exc_info.value)
278
+ assert "文件格式不支持" in str(exc_info.value)
279
+
280
+ @respx.mock
281
+ @patch('time.time')
282
+ @patch('time.sleep')
283
+ def test_wait_for_result_timeout(self, mock_sleep, mock_time, client):
284
+ """测试等待任务超时"""
285
+ # Mock time.time() 返回递增的时间
286
+ mock_time.side_effect = [0, 5, 11] # 第一次0秒,第二次5秒,第三次11秒
287
+
288
+ respx.get(f"{client.config.server_url}/api/xparse/parse/async/job_123").mock(
289
+ return_value=httpx.Response(200, json={
290
+ "code": 200,
291
+ "data": {
292
+ "job_id": "job_123",
293
+ "status": "in_progress",
294
+ },
295
+ })
296
+ )
297
+
298
+ with pytest.raises(RequestTimeoutError) as exc_info:
299
+ client.parse.wait_for_result(
300
+ job_id="job_123",
301
+ timeout_seconds=10,
302
+ poll_interval_seconds=5,
303
+ )
304
+
305
+ assert "等待解析任务超时" in str(exc_info.value)
306
+ assert "job_123" in str(exc_info.value)
307
+
308
+ @respx.mock
309
+ @patch('time.sleep')
310
+ def test_wait_for_result_multiple_polls(self, mock_sleep, client):
311
+ """测试多次轮询后完成"""
312
+ # 模拟 3 次轮询: scheduled -> in_progress -> completed
313
+ respx.get(f"{client.config.server_url}/api/xparse/parse/async/job_123").mock(
314
+ side_effect=[
315
+ httpx.Response(200, json={
316
+ "code": 200,
317
+ "data": {"job_id": "job_123", "status": "scheduled"},
318
+ }),
319
+ httpx.Response(200, json={
320
+ "code": 200,
321
+ "data": {"job_id": "job_123", "status": "in_progress"},
322
+ }),
323
+ httpx.Response(200, json={
324
+ "code": 200,
325
+ "data": {
326
+ "job_id": "job_123",
327
+ "status": "completed",
328
+ "elements": [],
329
+ },
330
+ }),
331
+ ]
332
+ )
333
+
334
+ result = client.parse.wait_for_result(
335
+ job_id="job_123",
336
+ poll_interval_seconds=2,
337
+ )
338
+
339
+ assert result.is_completed
340
+ # 应该 sleep 2 次(前两次轮询后)
341
+ assert mock_sleep.call_count == 2
342
+
343
+ @respx.mock
344
+ def test_wait_for_result_custom_intervals(self, client):
345
+ """测试自定义轮询参数"""
346
+ respx.get(f"{client.config.server_url}/api/xparse/parse/async/job_123").mock(
347
+ return_value=httpx.Response(200, json={
348
+ "code": 200,
349
+ "data": {
350
+ "job_id": "job_123",
351
+ "status": "completed",
352
+ "elements": [],
353
+ },
354
+ })
355
+ )
356
+
357
+ result = client.parse.wait_for_result(
358
+ job_id="job_123",
359
+ timeout_seconds=300,
360
+ poll_interval_seconds=10,
361
+ )
362
+
363
+ assert result.is_completed
364
+
365
+ def test_partition_async_removed(self, client):
366
+ """测试 partition_async 方法已被移除"""
367
+ assert not hasattr(client.parse, 'partition_async')
368
+
369
+ def test_async_job_methods_still_exist(self, client):
370
+ """测试服务端异步任务方法仍然存在"""
371
+ assert hasattr(client.parse, 'create_async_job')
372
+ assert hasattr(client.parse, 'get_result')
373
+ assert hasattr(client.parse, 'wait_for_result')
374
+