@josephyan/qingflow-cli 0.2.0-beta.55
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.
- package/README.md +30 -0
- package/docs/local-agent-install.md +235 -0
- package/entry_point.py +13 -0
- package/npm/bin/qingflow.mjs +5 -0
- package/npm/lib/runtime.mjs +204 -0
- package/npm/scripts/postinstall.mjs +16 -0
- package/package.json +34 -0
- package/pyproject.toml +67 -0
- package/qingflow +15 -0
- package/src/qingflow_mcp/__init__.py +5 -0
- package/src/qingflow_mcp/__main__.py +5 -0
- package/src/qingflow_mcp/backend_client.py +547 -0
- package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
- package/src/qingflow_mcp/builder_facade/models.py +985 -0
- package/src/qingflow_mcp/builder_facade/service.py +8243 -0
- package/src/qingflow_mcp/cli/__init__.py +1 -0
- package/src/qingflow_mcp/cli/commands/__init__.py +15 -0
- package/src/qingflow_mcp/cli/commands/app.py +40 -0
- package/src/qingflow_mcp/cli/commands/auth.py +78 -0
- package/src/qingflow_mcp/cli/commands/builder.py +184 -0
- package/src/qingflow_mcp/cli/commands/common.py +47 -0
- package/src/qingflow_mcp/cli/commands/imports.py +86 -0
- package/src/qingflow_mcp/cli/commands/record.py +202 -0
- package/src/qingflow_mcp/cli/commands/task.py +87 -0
- package/src/qingflow_mcp/cli/commands/workspace.py +33 -0
- package/src/qingflow_mcp/cli/context.py +48 -0
- package/src/qingflow_mcp/cli/formatters.py +269 -0
- package/src/qingflow_mcp/cli/json_io.py +50 -0
- package/src/qingflow_mcp/cli/main.py +147 -0
- package/src/qingflow_mcp/config.py +221 -0
- package/src/qingflow_mcp/errors.py +66 -0
- package/src/qingflow_mcp/import_store.py +121 -0
- package/src/qingflow_mcp/json_types.py +18 -0
- package/src/qingflow_mcp/list_type_labels.py +76 -0
- package/src/qingflow_mcp/server.py +211 -0
- package/src/qingflow_mcp/server_app_builder.py +387 -0
- package/src/qingflow_mcp/server_app_user.py +317 -0
- package/src/qingflow_mcp/session_store.py +289 -0
- package/src/qingflow_mcp/solution/__init__.py +6 -0
- package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
- package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
- package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +466 -0
- package/src/qingflow_mcp/solution/compiler/icon_utils.py +113 -0
- package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
- package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
- package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
- package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
- package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
- package/src/qingflow_mcp/solution/design_session.py +222 -0
- package/src/qingflow_mcp/solution/design_store.py +100 -0
- package/src/qingflow_mcp/solution/executor.py +2339 -0
- package/src/qingflow_mcp/solution/normalizer.py +23 -0
- package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
- package/src/qingflow_mcp/solution/run_store.py +244 -0
- package/src/qingflow_mcp/solution/spec_models.py +853 -0
- package/src/qingflow_mcp/tools/__init__.py +1 -0
- package/src/qingflow_mcp/tools/ai_builder_tools.py +2063 -0
- package/src/qingflow_mcp/tools/app_tools.py +850 -0
- package/src/qingflow_mcp/tools/approval_tools.py +833 -0
- package/src/qingflow_mcp/tools/auth_tools.py +697 -0
- package/src/qingflow_mcp/tools/base.py +81 -0
- package/src/qingflow_mcp/tools/code_block_tools.py +679 -0
- package/src/qingflow_mcp/tools/directory_tools.py +648 -0
- package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
- package/src/qingflow_mcp/tools/file_tools.py +385 -0
- package/src/qingflow_mcp/tools/import_tools.py +1971 -0
- package/src/qingflow_mcp/tools/navigation_tools.py +177 -0
- package/src/qingflow_mcp/tools/package_tools.py +240 -0
- package/src/qingflow_mcp/tools/portal_tools.py +131 -0
- package/src/qingflow_mcp/tools/qingbi_report_tools.py +269 -0
- package/src/qingflow_mcp/tools/record_tools.py +12739 -0
- package/src/qingflow_mcp/tools/role_tools.py +94 -0
- package/src/qingflow_mcp/tools/solution_tools.py +3887 -0
- package/src/qingflow_mcp/tools/task_context_tools.py +1423 -0
- package/src/qingflow_mcp/tools/task_tools.py +843 -0
- package/src/qingflow_mcp/tools/view_tools.py +280 -0
- package/src/qingflow_mcp/tools/workflow_tools.py +312 -0
- package/src/qingflow_mcp/tools/workspace_tools.py +219 -0
|
@@ -0,0 +1,547 @@
|
|
|
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 request(
|
|
92
|
+
self,
|
|
93
|
+
method: str,
|
|
94
|
+
context: BackendRequestContext,
|
|
95
|
+
path: str,
|
|
96
|
+
*,
|
|
97
|
+
params: JSONObject | None = None,
|
|
98
|
+
json_body: JSONValue = None,
|
|
99
|
+
unwrap: bool = True,
|
|
100
|
+
) -> JSONValue:
|
|
101
|
+
return self.request_with_meta(
|
|
102
|
+
method,
|
|
103
|
+
context,
|
|
104
|
+
path,
|
|
105
|
+
params=params,
|
|
106
|
+
json_body=json_body,
|
|
107
|
+
unwrap=unwrap,
|
|
108
|
+
).data
|
|
109
|
+
|
|
110
|
+
def request_with_meta(
|
|
111
|
+
self,
|
|
112
|
+
method: str,
|
|
113
|
+
context: BackendRequestContext,
|
|
114
|
+
path: str,
|
|
115
|
+
*,
|
|
116
|
+
params: JSONObject | None = None,
|
|
117
|
+
json_body: JSONValue = None,
|
|
118
|
+
unwrap: bool = True,
|
|
119
|
+
) -> BackendResponse:
|
|
120
|
+
return self._request_with_meta(
|
|
121
|
+
method,
|
|
122
|
+
self._build_url(context.base_url, path),
|
|
123
|
+
params=params,
|
|
124
|
+
json_body=json_body,
|
|
125
|
+
headers=self._base_headers(
|
|
126
|
+
context.token,
|
|
127
|
+
context.ws_id,
|
|
128
|
+
context.qf_request_id,
|
|
129
|
+
qf_version=context.qf_version,
|
|
130
|
+
),
|
|
131
|
+
unwrap=unwrap,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def describe_route(self, context: BackendRequestContext) -> JSONObject:
|
|
135
|
+
qf_version, source = self._resolve_qf_version(context.qf_version)
|
|
136
|
+
if context.qf_version is not None and context.qf_version_source:
|
|
137
|
+
source = context.qf_version_source
|
|
138
|
+
return {
|
|
139
|
+
"base_url": normalize_base_url(context.base_url) or context.base_url,
|
|
140
|
+
"qf_version": qf_version,
|
|
141
|
+
"qf_version_source": source,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
def upload_binary(
|
|
145
|
+
self,
|
|
146
|
+
url: str,
|
|
147
|
+
content: bytes,
|
|
148
|
+
*,
|
|
149
|
+
content_type: str | None = None,
|
|
150
|
+
headers: dict[str, str] | None = None,
|
|
151
|
+
) -> JSONObject:
|
|
152
|
+
request_headers = dict(headers or {})
|
|
153
|
+
if content_type:
|
|
154
|
+
request_headers.setdefault("Content-Type", content_type)
|
|
155
|
+
try:
|
|
156
|
+
response = self._client.put(url, content=content, headers=request_headers or None)
|
|
157
|
+
except httpx.RequestError as exc:
|
|
158
|
+
raise QingflowApiError(category="network", message=str(exc))
|
|
159
|
+
if response.status_code >= 400:
|
|
160
|
+
raise QingflowApiError(
|
|
161
|
+
category="http",
|
|
162
|
+
message=self._extract_message(response.text) or f"HTTP {response.status_code}",
|
|
163
|
+
http_status=response.status_code,
|
|
164
|
+
)
|
|
165
|
+
return {
|
|
166
|
+
"status_code": response.status_code,
|
|
167
|
+
"headers": dict(response.headers),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
def upload_form_file(
|
|
171
|
+
self,
|
|
172
|
+
url: str,
|
|
173
|
+
*,
|
|
174
|
+
form_fields: dict[str, str],
|
|
175
|
+
file_field: str,
|
|
176
|
+
file_name: str,
|
|
177
|
+
content: bytes,
|
|
178
|
+
content_type: str | None = None,
|
|
179
|
+
headers: dict[str, str] | None = None,
|
|
180
|
+
) -> JSONObject:
|
|
181
|
+
try:
|
|
182
|
+
response = self._client.post(
|
|
183
|
+
url,
|
|
184
|
+
data=form_fields,
|
|
185
|
+
files={file_field: (file_name, content, content_type or "application/octet-stream")},
|
|
186
|
+
headers=headers or None,
|
|
187
|
+
)
|
|
188
|
+
except httpx.RequestError as exc:
|
|
189
|
+
raise QingflowApiError(category="network", message=str(exc))
|
|
190
|
+
if response.status_code >= 400:
|
|
191
|
+
raise QingflowApiError(
|
|
192
|
+
category="http",
|
|
193
|
+
message=self._extract_message(response.text) or f"HTTP {response.status_code}",
|
|
194
|
+
http_status=response.status_code,
|
|
195
|
+
)
|
|
196
|
+
body: JSONValue = None
|
|
197
|
+
if response.content:
|
|
198
|
+
try:
|
|
199
|
+
body = response.json()
|
|
200
|
+
except ValueError:
|
|
201
|
+
body = response.text
|
|
202
|
+
return {
|
|
203
|
+
"status_code": response.status_code,
|
|
204
|
+
"headers": dict(response.headers),
|
|
205
|
+
"body": body,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
def download_binary(self, url: str, *, headers: dict[str, str] | None = None) -> bytes:
|
|
209
|
+
try:
|
|
210
|
+
response = self._client.get(url, headers=headers or None)
|
|
211
|
+
except httpx.RequestError as exc:
|
|
212
|
+
raise QingflowApiError(category="network", message=str(exc))
|
|
213
|
+
if response.status_code >= 400:
|
|
214
|
+
raise QingflowApiError(
|
|
215
|
+
category="http",
|
|
216
|
+
message=self._extract_message(response.text) or f"HTTP {response.status_code}",
|
|
217
|
+
http_status=response.status_code,
|
|
218
|
+
)
|
|
219
|
+
return response.content
|
|
220
|
+
|
|
221
|
+
def request_multipart(
|
|
222
|
+
self,
|
|
223
|
+
method: str,
|
|
224
|
+
context: BackendRequestContext,
|
|
225
|
+
path: str,
|
|
226
|
+
*,
|
|
227
|
+
data: dict[str, Any] | None = None,
|
|
228
|
+
files: dict[str, tuple[str, bytes, str | None]] | None = None,
|
|
229
|
+
unwrap: bool = True,
|
|
230
|
+
) -> JSONValue:
|
|
231
|
+
return self.request_multipart_with_meta(
|
|
232
|
+
method,
|
|
233
|
+
context,
|
|
234
|
+
path,
|
|
235
|
+
data=data,
|
|
236
|
+
files=files,
|
|
237
|
+
unwrap=unwrap,
|
|
238
|
+
).data
|
|
239
|
+
|
|
240
|
+
def request_multipart_with_meta(
|
|
241
|
+
self,
|
|
242
|
+
method: str,
|
|
243
|
+
context: BackendRequestContext,
|
|
244
|
+
path: str,
|
|
245
|
+
*,
|
|
246
|
+
data: dict[str, Any] | None = None,
|
|
247
|
+
files: dict[str, tuple[str, bytes, str | None]] | None = None,
|
|
248
|
+
unwrap: bool = True,
|
|
249
|
+
) -> BackendResponse:
|
|
250
|
+
headers = self._base_headers(
|
|
251
|
+
context.token,
|
|
252
|
+
context.ws_id,
|
|
253
|
+
context.qf_request_id,
|
|
254
|
+
qf_version=context.qf_version,
|
|
255
|
+
)
|
|
256
|
+
request_files: dict[str, tuple[str, bytes, str | None]] | None = None
|
|
257
|
+
if files:
|
|
258
|
+
request_files = {key: value for key, value in files.items()}
|
|
259
|
+
try:
|
|
260
|
+
response = self._client.request(
|
|
261
|
+
method.upper(),
|
|
262
|
+
self._build_url(context.base_url, path),
|
|
263
|
+
data=data,
|
|
264
|
+
files=request_files,
|
|
265
|
+
headers=headers,
|
|
266
|
+
)
|
|
267
|
+
except httpx.RequestError as exc:
|
|
268
|
+
raise QingflowApiError(category="network", message=str(exc), request_id=headers["Qf-Request-Id"])
|
|
269
|
+
parsed = self._parse_response(response, headers["Qf-Request-Id"], unwrap=unwrap)
|
|
270
|
+
return BackendResponse(
|
|
271
|
+
data=parsed,
|
|
272
|
+
headers=dict(response.headers),
|
|
273
|
+
request_id=headers["Qf-Request-Id"],
|
|
274
|
+
http_status=response.status_code,
|
|
275
|
+
qf_response_version=self._extract_response_qf_version(response.headers),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def start_socket_data_import(
|
|
279
|
+
self,
|
|
280
|
+
context: BackendRequestContext,
|
|
281
|
+
*,
|
|
282
|
+
app_key: str,
|
|
283
|
+
being_enter_auditing: bool,
|
|
284
|
+
view_key: str | None,
|
|
285
|
+
excel_url: str,
|
|
286
|
+
excel_name: str,
|
|
287
|
+
ack_timeout_seconds: float = 8.0,
|
|
288
|
+
initial_wait_seconds: float = 4.0,
|
|
289
|
+
) -> dict[str, Any]:
|
|
290
|
+
try:
|
|
291
|
+
import socketio # type: ignore[import-not-found]
|
|
292
|
+
except ImportError as exc:
|
|
293
|
+
raise QingflowApiError(
|
|
294
|
+
category="config",
|
|
295
|
+
message=f"socket.io client dependency is missing: {exc}",
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
socket_base_url = self._build_socket_base_url(context.base_url)
|
|
299
|
+
import_result: dict[str, Any] = {
|
|
300
|
+
"import_id": None,
|
|
301
|
+
"process_id_str": None,
|
|
302
|
+
"status": "accepted",
|
|
303
|
+
"warnings": [],
|
|
304
|
+
"initial_event": None,
|
|
305
|
+
"failure_event": None,
|
|
306
|
+
}
|
|
307
|
+
initial_event_received = Event()
|
|
308
|
+
failure_event_received = Event()
|
|
309
|
+
sio = socketio.Client(reconnection=False, logger=False, engineio_logger=False)
|
|
310
|
+
|
|
311
|
+
def _unwrap_socket_event_payload(payload: Any) -> Any:
|
|
312
|
+
if isinstance(payload, list) and payload:
|
|
313
|
+
return payload[0]
|
|
314
|
+
return payload
|
|
315
|
+
|
|
316
|
+
def _handle_initial(payload: Any) -> None:
|
|
317
|
+
normalized_payload = _unwrap_socket_event_payload(payload)
|
|
318
|
+
import_result["initial_event"] = normalized_payload
|
|
319
|
+
if isinstance(normalized_payload, dict):
|
|
320
|
+
process_id = normalized_payload.get("processIdStr") or normalized_payload.get("process_id_str") or normalized_payload.get("processId")
|
|
321
|
+
if process_id is not None:
|
|
322
|
+
import_result["process_id_str"] = str(process_id)
|
|
323
|
+
initial_event_received.set()
|
|
324
|
+
|
|
325
|
+
def _handle_failure(payload: Any) -> None:
|
|
326
|
+
normalized_payload = _unwrap_socket_event_payload(payload)
|
|
327
|
+
import_result["failure_event"] = normalized_payload
|
|
328
|
+
if isinstance(normalized_payload, dict):
|
|
329
|
+
process_id = normalized_payload.get("processIdStr") or normalized_payload.get("process_id_str") or normalized_payload.get("processId")
|
|
330
|
+
if process_id is not None:
|
|
331
|
+
import_result["process_id_str"] = str(process_id)
|
|
332
|
+
failure_event_received.set()
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
sio.connect(
|
|
336
|
+
socket_base_url,
|
|
337
|
+
transports=["websocket"],
|
|
338
|
+
socketio_path="socket.io",
|
|
339
|
+
headers=self._base_headers(
|
|
340
|
+
context.token,
|
|
341
|
+
context.ws_id,
|
|
342
|
+
qf_version=context.qf_version,
|
|
343
|
+
),
|
|
344
|
+
wait_timeout=ack_timeout_seconds,
|
|
345
|
+
)
|
|
346
|
+
sio.emit("token", context.token)
|
|
347
|
+
sleep(0.2)
|
|
348
|
+
ack = sio.call(
|
|
349
|
+
"dataImport",
|
|
350
|
+
(
|
|
351
|
+
context.token,
|
|
352
|
+
app_key,
|
|
353
|
+
bool(being_enter_auditing),
|
|
354
|
+
view_key,
|
|
355
|
+
False,
|
|
356
|
+
excel_url,
|
|
357
|
+
excel_name,
|
|
358
|
+
),
|
|
359
|
+
timeout=ack_timeout_seconds,
|
|
360
|
+
)
|
|
361
|
+
ack_payload = ack[0] if isinstance(ack, list) and ack else ack
|
|
362
|
+
if isinstance(ack_payload, dict):
|
|
363
|
+
error_code = ack_payload.get("error")
|
|
364
|
+
ack_message = ack_payload.get("message")
|
|
365
|
+
import_id = ack_payload.get("data")
|
|
366
|
+
if error_code not in (None, 0):
|
|
367
|
+
raise QingflowApiError(
|
|
368
|
+
category="backend",
|
|
369
|
+
message=str(ack_message or f"socket import rejected with error {error_code}"),
|
|
370
|
+
details={"socket_error_code": error_code, "import_id": import_id},
|
|
371
|
+
)
|
|
372
|
+
else:
|
|
373
|
+
import_id = ack_payload
|
|
374
|
+
if not import_id:
|
|
375
|
+
raise QingflowApiError(category="backend", message="socket import ack did not return import_id")
|
|
376
|
+
import_result["import_id"] = str(import_id)
|
|
377
|
+
sio.on(f"dataImportRes_{import_result['import_id']}", _handle_initial)
|
|
378
|
+
sio.on(f"dataImportFail_{import_result['import_id']}", _handle_failure)
|
|
379
|
+
if not initial_event_received.wait(timeout=initial_wait_seconds) and not failure_event_received.wait(timeout=0.1):
|
|
380
|
+
import_result["warnings"].append(
|
|
381
|
+
{
|
|
382
|
+
"code": "IMPORT_SOCKET_INITIAL_EVENT_PENDING",
|
|
383
|
+
"message": "Import ack received, but no initial progress payload arrived within the initial wait window.",
|
|
384
|
+
}
|
|
385
|
+
)
|
|
386
|
+
except Exception as exc:
|
|
387
|
+
message = str(exc)
|
|
388
|
+
if "timeout" in message.lower():
|
|
389
|
+
raise QingflowApiError(
|
|
390
|
+
category="network",
|
|
391
|
+
message="socket import ack timed out",
|
|
392
|
+
details={"error_code": "IMPORT_SOCKET_ACK_TIMEOUT"},
|
|
393
|
+
)
|
|
394
|
+
if isinstance(exc, QingflowApiError):
|
|
395
|
+
raise
|
|
396
|
+
raise QingflowApiError(category="network", message=message or "socket import failed")
|
|
397
|
+
finally:
|
|
398
|
+
try:
|
|
399
|
+
if sio.connected:
|
|
400
|
+
sio.disconnect()
|
|
401
|
+
except Exception:
|
|
402
|
+
pass
|
|
403
|
+
return import_result
|
|
404
|
+
|
|
405
|
+
def _request_with_meta(
|
|
406
|
+
self,
|
|
407
|
+
method: str,
|
|
408
|
+
url: str,
|
|
409
|
+
*,
|
|
410
|
+
params: JSONObject | None,
|
|
411
|
+
json_body: JSONValue,
|
|
412
|
+
headers: dict[str, str],
|
|
413
|
+
unwrap: bool,
|
|
414
|
+
) -> BackendResponse:
|
|
415
|
+
attempts = 2 if method.upper() == "GET" else 1
|
|
416
|
+
last_error: QingflowApiError | None = None
|
|
417
|
+
for _ in range(attempts):
|
|
418
|
+
try:
|
|
419
|
+
response = self._client.request(method.upper(), url, params=params, json=json_body, headers=headers)
|
|
420
|
+
parsed = self._parse_response(response, headers["Qf-Request-Id"], unwrap=unwrap)
|
|
421
|
+
return BackendResponse(
|
|
422
|
+
data=parsed,
|
|
423
|
+
headers=dict(response.headers),
|
|
424
|
+
request_id=headers["Qf-Request-Id"],
|
|
425
|
+
http_status=response.status_code,
|
|
426
|
+
qf_response_version=self._extract_response_qf_version(response.headers),
|
|
427
|
+
)
|
|
428
|
+
except httpx.RequestError as exc:
|
|
429
|
+
last_error = QingflowApiError(category="network", message=str(exc), request_id=headers["Qf-Request-Id"])
|
|
430
|
+
assert last_error is not None
|
|
431
|
+
raise last_error
|
|
432
|
+
|
|
433
|
+
def _parse_response(self, response: httpx.Response, request_id: str, *, unwrap: bool) -> JSONValue:
|
|
434
|
+
payload: JSONValue
|
|
435
|
+
try:
|
|
436
|
+
payload = response.json()
|
|
437
|
+
except ValueError:
|
|
438
|
+
payload = response.text
|
|
439
|
+
if response.status_code >= 400:
|
|
440
|
+
raise QingflowApiError(
|
|
441
|
+
category="http",
|
|
442
|
+
message=self._extract_message(payload) or f"HTTP {response.status_code}",
|
|
443
|
+
backend_code=self._extract_code(payload),
|
|
444
|
+
request_id=request_id,
|
|
445
|
+
http_status=response.status_code,
|
|
446
|
+
)
|
|
447
|
+
if not unwrap:
|
|
448
|
+
return payload
|
|
449
|
+
return self._unwrap_payload(payload, request_id, response.status_code)
|
|
450
|
+
|
|
451
|
+
def _unwrap_payload(self, payload: JSONValue, request_id: str, http_status: int) -> JSONValue:
|
|
452
|
+
if not isinstance(payload, dict):
|
|
453
|
+
return payload
|
|
454
|
+
if "success" in payload:
|
|
455
|
+
if not bool(payload.get("success")):
|
|
456
|
+
raise QingflowApiError(
|
|
457
|
+
category="backend",
|
|
458
|
+
message=self._extract_message(payload) or "Qingflow request failed",
|
|
459
|
+
backend_code=self._extract_code(payload),
|
|
460
|
+
request_id=request_id,
|
|
461
|
+
http_status=http_status,
|
|
462
|
+
)
|
|
463
|
+
return self._extract_success_data(payload)
|
|
464
|
+
code = self._extract_code(payload)
|
|
465
|
+
if code not in (None, 0, "0"):
|
|
466
|
+
raise QingflowApiError(
|
|
467
|
+
category="backend",
|
|
468
|
+
message=self._extract_message(payload) or "Qingflow request failed",
|
|
469
|
+
backend_code=code,
|
|
470
|
+
request_id=request_id,
|
|
471
|
+
http_status=http_status,
|
|
472
|
+
)
|
|
473
|
+
if code in (0, "0"):
|
|
474
|
+
return self._extract_success_data(payload)
|
|
475
|
+
return payload
|
|
476
|
+
|
|
477
|
+
def _extract_success_data(self, payload: JSONObject) -> JSONValue:
|
|
478
|
+
for key in ("result", "data", "page", "obj"):
|
|
479
|
+
if key in payload:
|
|
480
|
+
return payload[key]
|
|
481
|
+
return payload
|
|
482
|
+
|
|
483
|
+
def _extract_message(self, payload: JSONValue) -> str | None:
|
|
484
|
+
if not isinstance(payload, dict):
|
|
485
|
+
return str(payload) if payload else None
|
|
486
|
+
for key in ("message", "msg", "errMsg", "error", "detail"):
|
|
487
|
+
value = payload.get(key)
|
|
488
|
+
if value:
|
|
489
|
+
return str(value)
|
|
490
|
+
return None
|
|
491
|
+
|
|
492
|
+
def _extract_code(self, payload: JSONValue) -> JSONScalar:
|
|
493
|
+
if isinstance(payload, dict):
|
|
494
|
+
return payload.get("code", payload.get("errCode"))
|
|
495
|
+
return None
|
|
496
|
+
|
|
497
|
+
def _base_headers(
|
|
498
|
+
self,
|
|
499
|
+
token: str | None,
|
|
500
|
+
ws_id: int | None,
|
|
501
|
+
request_id: str | None = None,
|
|
502
|
+
*,
|
|
503
|
+
qf_version: str | None = None,
|
|
504
|
+
) -> dict[str, str]:
|
|
505
|
+
headers = {
|
|
506
|
+
"User-Agent": DEFAULT_USER_AGENT,
|
|
507
|
+
"Qf-Request-Id": request_id or str(uuid4()),
|
|
508
|
+
}
|
|
509
|
+
resolved_qf_version, _ = self._resolve_qf_version(qf_version)
|
|
510
|
+
if resolved_qf_version:
|
|
511
|
+
headers["Cookie"] = f"qfVersion={resolved_qf_version}"
|
|
512
|
+
if token:
|
|
513
|
+
headers["token"] = token
|
|
514
|
+
if ws_id is not None:
|
|
515
|
+
headers["wsId"] = str(ws_id)
|
|
516
|
+
return headers
|
|
517
|
+
|
|
518
|
+
def _resolve_qf_version(self, explicit_qf_version: str | None) -> tuple[str | None, str]:
|
|
519
|
+
if explicit_qf_version is not None:
|
|
520
|
+
normalized = str(explicit_qf_version).strip() or None
|
|
521
|
+
return normalized, "context"
|
|
522
|
+
if self._default_qf_version:
|
|
523
|
+
return self._default_qf_version, "default_config"
|
|
524
|
+
return None, "unset"
|
|
525
|
+
|
|
526
|
+
def _extract_response_qf_version(self, headers: httpx.Headers) -> str | None:
|
|
527
|
+
value = headers.get("x-q-response-version")
|
|
528
|
+
if value is None:
|
|
529
|
+
return None
|
|
530
|
+
normalized = str(value).strip()
|
|
531
|
+
return normalized or None
|
|
532
|
+
|
|
533
|
+
def _build_url(self, base_url: str, path: str) -> str:
|
|
534
|
+
normalized = normalize_base_url(base_url)
|
|
535
|
+
if not normalized:
|
|
536
|
+
raise QingflowApiError.config_error("base_url is required")
|
|
537
|
+
return f"{normalized}/{path.lstrip('/')}"
|
|
538
|
+
|
|
539
|
+
def _build_socket_base_url(self, base_url: str) -> str:
|
|
540
|
+
normalized = normalize_base_url(base_url)
|
|
541
|
+
if not normalized:
|
|
542
|
+
raise QingflowApiError.config_error("base_url is required")
|
|
543
|
+
parsed = urlsplit(normalized)
|
|
544
|
+
path = parsed.path.rstrip("/")
|
|
545
|
+
if path.endswith("/api"):
|
|
546
|
+
path = path[:-4]
|
|
547
|
+
return urlunsplit((parsed.scheme, parsed.netloc, path or "", "", ""))
|