@josephyan/qingflow-cli 0.2.0-beta.1000

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 (92) hide show
  1. package/README.md +31 -0
  2. package/docs/local-agent-install.md +309 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow.mjs +5 -0
  5. package/npm/lib/runtime.mjs +346 -0
  6. package/npm/scripts/postinstall.mjs +16 -0
  7. package/package.json +34 -0
  8. package/pyproject.toml +67 -0
  9. package/qingflow +15 -0
  10. package/src/qingflow_mcp/__init__.py +37 -0
  11. package/src/qingflow_mcp/__main__.py +5 -0
  12. package/src/qingflow_mcp/backend_client.py +649 -0
  13. package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
  14. package/src/qingflow_mcp/builder_facade/models.py +1846 -0
  15. package/src/qingflow_mcp/builder_facade/service.py +16502 -0
  16. package/src/qingflow_mcp/cli/__init__.py +1 -0
  17. package/src/qingflow_mcp/cli/commands/__init__.py +18 -0
  18. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  19. package/src/qingflow_mcp/cli/commands/auth.py +112 -0
  20. package/src/qingflow_mcp/cli/commands/builder.py +539 -0
  21. package/src/qingflow_mcp/cli/commands/chart.py +18 -0
  22. package/src/qingflow_mcp/cli/commands/common.py +62 -0
  23. package/src/qingflow_mcp/cli/commands/imports.py +96 -0
  24. package/src/qingflow_mcp/cli/commands/portal.py +25 -0
  25. package/src/qingflow_mcp/cli/commands/record.py +331 -0
  26. package/src/qingflow_mcp/cli/commands/repo.py +80 -0
  27. package/src/qingflow_mcp/cli/commands/task.py +141 -0
  28. package/src/qingflow_mcp/cli/commands/view.py +18 -0
  29. package/src/qingflow_mcp/cli/commands/workspace.py +110 -0
  30. package/src/qingflow_mcp/cli/context.py +60 -0
  31. package/src/qingflow_mcp/cli/formatters.py +573 -0
  32. package/src/qingflow_mcp/cli/json_io.py +50 -0
  33. package/src/qingflow_mcp/cli/main.py +186 -0
  34. package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
  35. package/src/qingflow_mcp/cli/terminal_ui.py +173 -0
  36. package/src/qingflow_mcp/config.py +407 -0
  37. package/src/qingflow_mcp/errors.py +66 -0
  38. package/src/qingflow_mcp/id_utils.py +49 -0
  39. package/src/qingflow_mcp/import_store.py +121 -0
  40. package/src/qingflow_mcp/json_types.py +18 -0
  41. package/src/qingflow_mcp/list_type_labels.py +76 -0
  42. package/src/qingflow_mcp/public_surface.py +243 -0
  43. package/src/qingflow_mcp/repository_store.py +71 -0
  44. package/src/qingflow_mcp/response_trim.py +841 -0
  45. package/src/qingflow_mcp/server.py +216 -0
  46. package/src/qingflow_mcp/server_app_builder.py +543 -0
  47. package/src/qingflow_mcp/server_app_user.py +386 -0
  48. package/src/qingflow_mcp/session_store.py +369 -0
  49. package/src/qingflow_mcp/solution/__init__.py +6 -0
  50. package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
  51. package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
  52. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  53. package/src/qingflow_mcp/solution/compiler/form_compiler.py +495 -0
  54. package/src/qingflow_mcp/solution/compiler/icon_utils.py +187 -0
  55. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  56. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  57. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  58. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  59. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
  60. package/src/qingflow_mcp/solution/design_session.py +222 -0
  61. package/src/qingflow_mcp/solution/design_store.py +100 -0
  62. package/src/qingflow_mcp/solution/executor.py +2398 -0
  63. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  64. package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
  65. package/src/qingflow_mcp/solution/run_store.py +244 -0
  66. package/src/qingflow_mcp/solution/spec_models.py +855 -0
  67. package/src/qingflow_mcp/tools/__init__.py +1 -0
  68. package/src/qingflow_mcp/tools/ai_builder_tools.py +3449 -0
  69. package/src/qingflow_mcp/tools/app_tools.py +926 -0
  70. package/src/qingflow_mcp/tools/approval_tools.py +1062 -0
  71. package/src/qingflow_mcp/tools/auth_tools.py +1133 -0
  72. package/src/qingflow_mcp/tools/base.py +281 -0
  73. package/src/qingflow_mcp/tools/code_block_tools.py +777 -0
  74. package/src/qingflow_mcp/tools/custom_button_tools.py +202 -0
  75. package/src/qingflow_mcp/tools/directory_tools.py +675 -0
  76. package/src/qingflow_mcp/tools/feedback_tools.py +238 -0
  77. package/src/qingflow_mcp/tools/file_tools.py +409 -0
  78. package/src/qingflow_mcp/tools/import_tools.py +2223 -0
  79. package/src/qingflow_mcp/tools/navigation_tools.py +210 -0
  80. package/src/qingflow_mcp/tools/package_tools.py +326 -0
  81. package/src/qingflow_mcp/tools/portal_tools.py +158 -0
  82. package/src/qingflow_mcp/tools/qingbi_report_tools.py +374 -0
  83. package/src/qingflow_mcp/tools/record_tools.py +14291 -0
  84. package/src/qingflow_mcp/tools/repository_dev_tools.py +552 -0
  85. package/src/qingflow_mcp/tools/resource_read_tools.py +503 -0
  86. package/src/qingflow_mcp/tools/role_tools.py +112 -0
  87. package/src/qingflow_mcp/tools/solution_tools.py +4054 -0
  88. package/src/qingflow_mcp/tools/task_context_tools.py +2986 -0
  89. package/src/qingflow_mcp/tools/task_tools.py +889 -0
  90. package/src/qingflow_mcp/tools/view_tools.py +335 -0
  91. package/src/qingflow_mcp/tools/workflow_tools.py +376 -0
  92. package/src/qingflow_mcp/tools/workspace_tools.py +266 -0
@@ -0,0 +1,649 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from threading import Event
5
+ from time import sleep
6
+ from typing import Any
7
+ from urllib.parse import urlsplit, urlunsplit
8
+ from uuid import uuid4
9
+
10
+ import httpx
11
+
12
+ from .config import DEFAULT_USER_AGENT, get_default_qf_version, get_timeout_seconds, normalize_base_url
13
+ from .errors import QingflowApiError
14
+ from .json_types import JSONObject, JSONScalar, JSONValue
15
+
16
+
17
+ @dataclass(slots=True)
18
+ class BackendRequestContext:
19
+ base_url: str
20
+ token: str
21
+ ws_id: int | None
22
+ qf_request_id: str | None = None
23
+ qf_version: str | None = None
24
+ qf_version_source: str | None = None
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class BackendResponse:
29
+ data: JSONValue
30
+ headers: dict[str, str]
31
+ request_id: str
32
+ http_status: int
33
+ qf_response_version: str | None = None
34
+
35
+
36
+ class BackendClient:
37
+ def __init__(self, timeout: float | None = None, client: httpx.Client | None = None) -> None:
38
+ self._owns_client = client is None
39
+ self._default_qf_version = get_default_qf_version()
40
+ self._client = client or httpx.Client(
41
+ timeout=timeout or get_timeout_seconds(),
42
+ follow_redirects=True,
43
+ trust_env=False,
44
+ )
45
+
46
+ def close(self) -> None:
47
+ if self._owns_client:
48
+ self._client.close()
49
+
50
+ def public_request(
51
+ self,
52
+ method: str,
53
+ base_url: str,
54
+ path: str,
55
+ *,
56
+ params: JSONObject | None = None,
57
+ json_body: JSONValue = None,
58
+ unwrap: bool = True,
59
+ qf_version: str | None = None,
60
+ ) -> JSONValue:
61
+ return self.public_request_with_meta(
62
+ method,
63
+ base_url,
64
+ path,
65
+ params=params,
66
+ json_body=json_body,
67
+ unwrap=unwrap,
68
+ qf_version=qf_version,
69
+ ).data
70
+
71
+ def public_request_with_meta(
72
+ self,
73
+ method: str,
74
+ base_url: str,
75
+ path: str,
76
+ *,
77
+ params: JSONObject | None = None,
78
+ json_body: JSONValue = None,
79
+ unwrap: bool = True,
80
+ qf_version: str | None = None,
81
+ ) -> BackendResponse:
82
+ return self._request_with_meta(
83
+ method,
84
+ self._build_url(base_url, path),
85
+ params=params,
86
+ json_body=json_body,
87
+ headers=self._base_headers(None, None, qf_version=qf_version),
88
+ unwrap=unwrap,
89
+ )
90
+
91
+ def public_request_with_headers(
92
+ self,
93
+ method: str,
94
+ base_url: str,
95
+ path: str,
96
+ *,
97
+ params: JSONObject | None = None,
98
+ json_body: JSONValue = None,
99
+ unwrap: bool = True,
100
+ qf_version: str | None = None,
101
+ headers: dict[str, str] | None = None,
102
+ ) -> BackendResponse:
103
+ request_headers = self._base_headers(None, None, qf_version=qf_version)
104
+ if headers:
105
+ request_headers.update({key: value for key, value in headers.items() if value is not None})
106
+ return self._request_with_meta(
107
+ method,
108
+ self._build_url(base_url, path),
109
+ params=params,
110
+ json_body=json_body,
111
+ headers=request_headers,
112
+ unwrap=unwrap,
113
+ )
114
+
115
+ def request(
116
+ self,
117
+ method: str,
118
+ context: BackendRequestContext,
119
+ path: str,
120
+ *,
121
+ params: JSONObject | None = None,
122
+ json_body: JSONValue = None,
123
+ unwrap: bool = True,
124
+ ) -> JSONValue:
125
+ return self.request_with_meta(
126
+ method,
127
+ context,
128
+ path,
129
+ params=params,
130
+ json_body=json_body,
131
+ unwrap=unwrap,
132
+ ).data
133
+
134
+ def request_with_meta(
135
+ self,
136
+ method: str,
137
+ context: BackendRequestContext,
138
+ path: str,
139
+ *,
140
+ params: JSONObject | None = None,
141
+ json_body: JSONValue = None,
142
+ unwrap: bool = True,
143
+ ) -> BackendResponse:
144
+ return self._request_with_meta(
145
+ method,
146
+ self._build_url(context.base_url, path),
147
+ params=params,
148
+ json_body=json_body,
149
+ headers=self._base_headers(
150
+ context.token,
151
+ context.ws_id,
152
+ context.qf_request_id,
153
+ qf_version=context.qf_version,
154
+ ),
155
+ unwrap=unwrap,
156
+ )
157
+
158
+ def stream_request(
159
+ self,
160
+ method: str,
161
+ context: BackendRequestContext,
162
+ path: str,
163
+ *,
164
+ params: JSONObject | None = None,
165
+ json_body: JSONValue = None,
166
+ headers: dict[str, str] | None = None,
167
+ ) -> list[str]:
168
+ request_headers = self._base_headers(
169
+ context.token,
170
+ context.ws_id,
171
+ context.qf_request_id,
172
+ qf_version=context.qf_version,
173
+ )
174
+ if headers:
175
+ request_headers.update({key: value for key, value in headers.items() if value is not None})
176
+ return self._stream_lines(
177
+ method,
178
+ self._build_url(context.base_url, path),
179
+ params=params,
180
+ json_body=json_body,
181
+ headers=request_headers,
182
+ )
183
+
184
+ def public_stream_request(
185
+ self,
186
+ method: str,
187
+ base_url: str,
188
+ path: str,
189
+ *,
190
+ params: JSONObject | None = None,
191
+ json_body: JSONValue = None,
192
+ headers: dict[str, str] | None = None,
193
+ qf_version: str | None = None,
194
+ ) -> list[str]:
195
+ request_headers = self._base_headers(None, None, qf_version=qf_version)
196
+ if headers:
197
+ request_headers.update({key: value for key, value in headers.items() if value is not None})
198
+ return self._stream_lines(
199
+ method,
200
+ self._build_url(base_url, path),
201
+ params=params,
202
+ json_body=json_body,
203
+ headers=request_headers,
204
+ )
205
+
206
+ def describe_route(self, context: BackendRequestContext) -> JSONObject:
207
+ qf_version, source = self._resolve_qf_version(context.qf_version)
208
+ if context.qf_version is not None and context.qf_version_source:
209
+ source = context.qf_version_source
210
+ return {
211
+ "base_url": normalize_base_url(context.base_url) or context.base_url,
212
+ "qf_version": qf_version,
213
+ "qf_version_source": source,
214
+ }
215
+
216
+ def upload_binary(
217
+ self,
218
+ url: str,
219
+ content: bytes,
220
+ *,
221
+ content_type: str | None = None,
222
+ headers: dict[str, str] | None = None,
223
+ ) -> JSONObject:
224
+ request_headers = dict(headers or {})
225
+ if content_type:
226
+ request_headers.setdefault("Content-Type", content_type)
227
+ try:
228
+ response = self._client.put(url, content=content, headers=request_headers or None)
229
+ except httpx.RequestError as exc:
230
+ raise QingflowApiError(category="network", message=str(exc))
231
+ if response.status_code >= 400:
232
+ raise QingflowApiError(
233
+ category="http",
234
+ message=self._extract_message(response.text) or f"HTTP {response.status_code}",
235
+ http_status=response.status_code,
236
+ )
237
+ return {
238
+ "status_code": response.status_code,
239
+ "headers": dict(response.headers),
240
+ }
241
+
242
+ def upload_form_file(
243
+ self,
244
+ url: str,
245
+ *,
246
+ form_fields: dict[str, str],
247
+ file_field: str,
248
+ file_name: str,
249
+ content: bytes,
250
+ content_type: str | None = None,
251
+ headers: dict[str, str] | None = None,
252
+ ) -> JSONObject:
253
+ try:
254
+ response = self._client.post(
255
+ url,
256
+ data=form_fields,
257
+ files={file_field: (file_name, content, content_type or "application/octet-stream")},
258
+ headers=headers or None,
259
+ )
260
+ except httpx.RequestError as exc:
261
+ raise QingflowApiError(category="network", message=str(exc))
262
+ if response.status_code >= 400:
263
+ raise QingflowApiError(
264
+ category="http",
265
+ message=self._extract_message(response.text) or f"HTTP {response.status_code}",
266
+ http_status=response.status_code,
267
+ )
268
+ body: JSONValue = None
269
+ if response.content:
270
+ try:
271
+ body = response.json()
272
+ except ValueError:
273
+ body = response.text
274
+ return {
275
+ "status_code": response.status_code,
276
+ "headers": dict(response.headers),
277
+ "body": body,
278
+ }
279
+
280
+ def download_binary(self, url: str, *, headers: dict[str, str] | None = None) -> bytes:
281
+ try:
282
+ response = self._client.get(url, headers=headers or None)
283
+ except httpx.RequestError as exc:
284
+ raise QingflowApiError(category="network", message=str(exc))
285
+ if response.status_code >= 400:
286
+ raise QingflowApiError(
287
+ category="http",
288
+ message=self._extract_message(response.text) or f"HTTP {response.status_code}",
289
+ http_status=response.status_code,
290
+ )
291
+ return response.content
292
+
293
+ def request_multipart(
294
+ self,
295
+ method: str,
296
+ context: BackendRequestContext,
297
+ path: str,
298
+ *,
299
+ data: dict[str, Any] | None = None,
300
+ files: dict[str, tuple[str, bytes, str | None]] | None = None,
301
+ unwrap: bool = True,
302
+ ) -> JSONValue:
303
+ return self.request_multipart_with_meta(
304
+ method,
305
+ context,
306
+ path,
307
+ data=data,
308
+ files=files,
309
+ unwrap=unwrap,
310
+ ).data
311
+
312
+ def request_multipart_with_meta(
313
+ self,
314
+ method: str,
315
+ context: BackendRequestContext,
316
+ path: str,
317
+ *,
318
+ data: dict[str, Any] | None = None,
319
+ files: dict[str, tuple[str, bytes, str | None]] | None = None,
320
+ unwrap: bool = True,
321
+ ) -> BackendResponse:
322
+ headers = self._base_headers(
323
+ context.token,
324
+ context.ws_id,
325
+ context.qf_request_id,
326
+ qf_version=context.qf_version,
327
+ )
328
+ request_files: dict[str, tuple[str, bytes, str | None]] | None = None
329
+ if files:
330
+ request_files = {key: value for key, value in files.items()}
331
+ try:
332
+ response = self._client.request(
333
+ method.upper(),
334
+ self._build_url(context.base_url, path),
335
+ data=data,
336
+ files=request_files,
337
+ headers=headers,
338
+ )
339
+ except httpx.RequestError as exc:
340
+ raise QingflowApiError(category="network", message=str(exc), request_id=headers["Qf-Request-Id"])
341
+ parsed = self._parse_response(response, headers["Qf-Request-Id"], unwrap=unwrap)
342
+ return BackendResponse(
343
+ data=parsed,
344
+ headers=dict(response.headers),
345
+ request_id=headers["Qf-Request-Id"],
346
+ http_status=response.status_code,
347
+ qf_response_version=self._extract_response_qf_version(response.headers),
348
+ )
349
+
350
+ def start_socket_data_import(
351
+ self,
352
+ context: BackendRequestContext,
353
+ *,
354
+ app_key: str,
355
+ being_enter_auditing: bool,
356
+ view_key: str | None,
357
+ excel_url: str,
358
+ excel_name: str,
359
+ ack_timeout_seconds: float = 8.0,
360
+ initial_wait_seconds: float = 4.0,
361
+ ) -> dict[str, Any]:
362
+ try:
363
+ import socketio # type: ignore[import-not-found]
364
+ except ImportError as exc:
365
+ raise QingflowApiError(
366
+ category="config",
367
+ message=f"socket.io client dependency is missing: {exc}",
368
+ )
369
+
370
+ socket_base_url = self._build_socket_base_url(context.base_url)
371
+ import_result: dict[str, Any] = {
372
+ "import_id": None,
373
+ "process_id_str": None,
374
+ "status": "accepted",
375
+ "warnings": [],
376
+ "initial_event": None,
377
+ "failure_event": None,
378
+ }
379
+ initial_event_received = Event()
380
+ failure_event_received = Event()
381
+ sio = socketio.Client(reconnection=False, logger=False, engineio_logger=False)
382
+
383
+ def _unwrap_socket_event_payload(payload: Any) -> Any:
384
+ if isinstance(payload, list) and payload:
385
+ return payload[0]
386
+ return payload
387
+
388
+ def _handle_initial(payload: Any) -> None:
389
+ normalized_payload = _unwrap_socket_event_payload(payload)
390
+ import_result["initial_event"] = normalized_payload
391
+ if isinstance(normalized_payload, dict):
392
+ process_id = normalized_payload.get("processIdStr") or normalized_payload.get("process_id_str") or normalized_payload.get("processId")
393
+ if process_id is not None:
394
+ import_result["process_id_str"] = str(process_id)
395
+ initial_event_received.set()
396
+
397
+ def _handle_failure(payload: Any) -> None:
398
+ normalized_payload = _unwrap_socket_event_payload(payload)
399
+ import_result["failure_event"] = normalized_payload
400
+ if isinstance(normalized_payload, dict):
401
+ process_id = normalized_payload.get("processIdStr") or normalized_payload.get("process_id_str") or normalized_payload.get("processId")
402
+ if process_id is not None:
403
+ import_result["process_id_str"] = str(process_id)
404
+ failure_event_received.set()
405
+
406
+ try:
407
+ sio.connect(
408
+ socket_base_url,
409
+ transports=["websocket"],
410
+ socketio_path="socket.io",
411
+ headers=self._base_headers(
412
+ context.token,
413
+ context.ws_id,
414
+ qf_version=context.qf_version,
415
+ ),
416
+ wait_timeout=ack_timeout_seconds,
417
+ )
418
+ sio.emit("token", context.token)
419
+ sleep(0.2)
420
+ ack = sio.call(
421
+ "dataImport",
422
+ (
423
+ context.token,
424
+ app_key,
425
+ bool(being_enter_auditing),
426
+ view_key,
427
+ False,
428
+ excel_url,
429
+ excel_name,
430
+ ),
431
+ timeout=ack_timeout_seconds,
432
+ )
433
+ ack_payload = ack[0] if isinstance(ack, list) and ack else ack
434
+ if isinstance(ack_payload, dict):
435
+ error_code = ack_payload.get("error")
436
+ ack_message = ack_payload.get("message")
437
+ import_id = ack_payload.get("data")
438
+ if error_code not in (None, 0):
439
+ raise QingflowApiError(
440
+ category="backend",
441
+ message=str(ack_message or f"socket import rejected with error {error_code}"),
442
+ details={"socket_error_code": error_code, "import_id": import_id},
443
+ )
444
+ else:
445
+ import_id = ack_payload
446
+ if not import_id:
447
+ raise QingflowApiError(category="backend", message="socket import ack did not return import_id")
448
+ import_result["import_id"] = str(import_id)
449
+ sio.on(f"dataImportRes_{import_result['import_id']}", _handle_initial)
450
+ sio.on(f"dataImportFail_{import_result['import_id']}", _handle_failure)
451
+ if not initial_event_received.wait(timeout=initial_wait_seconds) and not failure_event_received.wait(timeout=0.1):
452
+ import_result["warnings"].append(
453
+ {
454
+ "code": "IMPORT_SOCKET_INITIAL_EVENT_PENDING",
455
+ "message": "Import ack received, but no initial progress payload arrived within the initial wait window.",
456
+ }
457
+ )
458
+ except Exception as exc:
459
+ message = str(exc)
460
+ if "timeout" in message.lower():
461
+ raise QingflowApiError(
462
+ category="network",
463
+ message="socket import ack timed out",
464
+ details={"error_code": "IMPORT_SOCKET_ACK_TIMEOUT"},
465
+ )
466
+ if isinstance(exc, QingflowApiError):
467
+ raise
468
+ raise QingflowApiError(category="network", message=message or "socket import failed")
469
+ finally:
470
+ try:
471
+ if sio.connected:
472
+ sio.disconnect()
473
+ except Exception:
474
+ pass
475
+ return import_result
476
+
477
+ def _request_with_meta(
478
+ self,
479
+ method: str,
480
+ url: str,
481
+ *,
482
+ params: JSONObject | None,
483
+ json_body: JSONValue,
484
+ headers: dict[str, str],
485
+ unwrap: bool,
486
+ ) -> BackendResponse:
487
+ attempts = 2 if method.upper() == "GET" else 1
488
+ last_error: QingflowApiError | None = None
489
+ for _ in range(attempts):
490
+ try:
491
+ response = self._client.request(method.upper(), url, params=params, json=json_body, headers=headers)
492
+ parsed = self._parse_response(response, headers["Qf-Request-Id"], unwrap=unwrap)
493
+ return BackendResponse(
494
+ data=parsed,
495
+ headers=dict(response.headers),
496
+ request_id=headers["Qf-Request-Id"],
497
+ http_status=response.status_code,
498
+ qf_response_version=self._extract_response_qf_version(response.headers),
499
+ )
500
+ except httpx.RequestError as exc:
501
+ last_error = QingflowApiError(category="network", message=str(exc), request_id=headers["Qf-Request-Id"])
502
+ assert last_error is not None
503
+ raise last_error
504
+
505
+ def _stream_lines(
506
+ self,
507
+ method: str,
508
+ url: str,
509
+ *,
510
+ params: JSONObject | None,
511
+ json_body: JSONValue,
512
+ headers: dict[str, str],
513
+ ) -> list[str]:
514
+ try:
515
+ with self._client.stream(method.upper(), url, params=params, json=json_body, headers=headers) as response:
516
+ request_id = headers["Qf-Request-Id"]
517
+ if response.status_code >= 400:
518
+ raw_bytes = response.read()
519
+ payload: JSONValue
520
+ try:
521
+ payload = response.json()
522
+ except ValueError:
523
+ payload = raw_bytes.decode("utf-8", errors="replace")
524
+ raise QingflowApiError(
525
+ category="http",
526
+ message=self._extract_message(payload) or f"HTTP {response.status_code}",
527
+ backend_code=self._extract_code(payload),
528
+ request_id=request_id,
529
+ http_status=response.status_code,
530
+ )
531
+ return [line for line in response.iter_lines()]
532
+ except httpx.RequestError as exc:
533
+ raise QingflowApiError(category="network", message=str(exc), request_id=headers["Qf-Request-Id"])
534
+
535
+ def _parse_response(self, response: httpx.Response, request_id: str, *, unwrap: bool) -> JSONValue:
536
+ payload: JSONValue
537
+ try:
538
+ payload = response.json()
539
+ except ValueError:
540
+ payload = response.text
541
+ if response.status_code >= 400:
542
+ raise QingflowApiError(
543
+ category="http",
544
+ message=self._extract_message(payload) or f"HTTP {response.status_code}",
545
+ backend_code=self._extract_code(payload),
546
+ request_id=request_id,
547
+ http_status=response.status_code,
548
+ )
549
+ if not unwrap:
550
+ return payload
551
+ return self._unwrap_payload(payload, request_id, response.status_code)
552
+
553
+ def _unwrap_payload(self, payload: JSONValue, request_id: str, http_status: int) -> JSONValue:
554
+ if not isinstance(payload, dict):
555
+ return payload
556
+ if "success" in payload:
557
+ if not bool(payload.get("success")):
558
+ raise QingflowApiError(
559
+ category="backend",
560
+ message=self._extract_message(payload) or "Qingflow request failed",
561
+ backend_code=self._extract_code(payload),
562
+ request_id=request_id,
563
+ http_status=http_status,
564
+ )
565
+ return self._extract_success_data(payload)
566
+ code = self._extract_code(payload)
567
+ if code not in (None, 0, "0"):
568
+ raise QingflowApiError(
569
+ category="backend",
570
+ message=self._extract_message(payload) or "Qingflow request failed",
571
+ backend_code=code,
572
+ request_id=request_id,
573
+ http_status=http_status,
574
+ )
575
+ if code in (0, "0"):
576
+ return self._extract_success_data(payload)
577
+ return payload
578
+
579
+ def _extract_success_data(self, payload: JSONObject) -> JSONValue:
580
+ for key in ("result", "data", "page", "obj"):
581
+ if key in payload:
582
+ return payload[key]
583
+ return payload
584
+
585
+ def _extract_message(self, payload: JSONValue) -> str | None:
586
+ if not isinstance(payload, dict):
587
+ return str(payload) if payload else None
588
+ for key in ("message", "msg", "errMsg", "error", "detail"):
589
+ value = payload.get(key)
590
+ if value:
591
+ return str(value)
592
+ return None
593
+
594
+ def _extract_code(self, payload: JSONValue) -> JSONScalar:
595
+ if isinstance(payload, dict):
596
+ return payload.get("code", payload.get("errCode"))
597
+ return None
598
+
599
+ def _base_headers(
600
+ self,
601
+ token: str | None,
602
+ ws_id: int | None,
603
+ request_id: str | None = None,
604
+ *,
605
+ qf_version: str | None = None,
606
+ ) -> dict[str, str]:
607
+ headers = {
608
+ "User-Agent": DEFAULT_USER_AGENT,
609
+ "Qf-Request-Id": request_id or str(uuid4()),
610
+ }
611
+ resolved_qf_version, _ = self._resolve_qf_version(qf_version)
612
+ if resolved_qf_version:
613
+ headers["Cookie"] = f"qfVersion={resolved_qf_version}"
614
+ if token:
615
+ headers["token"] = token
616
+ if ws_id is not None:
617
+ headers["wsId"] = str(ws_id)
618
+ return headers
619
+
620
+ def _resolve_qf_version(self, explicit_qf_version: str | None) -> tuple[str | None, str]:
621
+ if explicit_qf_version is not None:
622
+ normalized = str(explicit_qf_version).strip() or None
623
+ return normalized, "context"
624
+ if self._default_qf_version:
625
+ return self._default_qf_version, "default_config"
626
+ return None, "unset"
627
+
628
+ def _extract_response_qf_version(self, headers: httpx.Headers) -> str | None:
629
+ value = headers.get("x-q-response-version")
630
+ if value is None:
631
+ return None
632
+ normalized = str(value).strip()
633
+ return normalized or None
634
+
635
+ def _build_url(self, base_url: str, path: str) -> str:
636
+ normalized = normalize_base_url(base_url)
637
+ if not normalized:
638
+ raise QingflowApiError.config_error("base_url is required")
639
+ return f"{normalized}/{path.lstrip('/')}"
640
+
641
+ def _build_socket_base_url(self, base_url: str) -> str:
642
+ normalized = normalize_base_url(base_url)
643
+ if not normalized:
644
+ raise QingflowApiError.config_error("base_url is required")
645
+ parsed = urlsplit(normalized)
646
+ path = parsed.path.rstrip("/")
647
+ if path.endswith("/api"):
648
+ path = path[:-4]
649
+ return urlunsplit((parsed.scheme, parsed.netloc, path or "", "", ""))
@@ -0,0 +1,3 @@
1
+ from .service import AiBuilderFacade
2
+
3
+ __all__ = ["AiBuilderFacade"]