yearning-cli 0.1.5__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.
- mysql_proxy/README.md +32 -0
- mysql_proxy/__init__.py +1 -0
- mysql_proxy/protocol.py +342 -0
- mysql_proxy/server.py +661 -0
- pyproject.toml +33 -0
- yearning_cli/__init__.py +1 -0
- yearning_cli/__main__.py +3 -0
- yearning_cli/cli.py +459 -0
- yearning_cli/client.py +636 -0
- yearning_cli-0.1.5.dist-info/METADATA +192 -0
- yearning_cli-0.1.5.dist-info/RECORD +13 -0
- yearning_cli-0.1.5.dist-info/WHEEL +4 -0
- yearning_cli-0.1.5.dist-info/entry_points.txt +2 -0
yearning_cli/client.py
ADDED
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
"""Yearning API client."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
import msgpack
|
|
10
|
+
import requests
|
|
11
|
+
import websocket
|
|
12
|
+
|
|
13
|
+
STATE_FILE = os.path.expanduser("~/.yearning_state")
|
|
14
|
+
CONFIG_FILE = os.path.expanduser("~/.sqlrc")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _read_json_file(path):
|
|
18
|
+
try:
|
|
19
|
+
with open(path, encoding="utf-8") as f:
|
|
20
|
+
data = json.load(f)
|
|
21
|
+
return data if isinstance(data, dict) else {}
|
|
22
|
+
except (OSError, json.JSONDecodeError):
|
|
23
|
+
return {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_state():
|
|
27
|
+
"""Load persisted CLI state."""
|
|
28
|
+
state = _read_json_file(STATE_FILE)
|
|
29
|
+
if not isinstance(state.get("query_orders"), dict):
|
|
30
|
+
state["query_orders"] = {}
|
|
31
|
+
return state
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def read_config():
|
|
35
|
+
"""Load CLI config from ~/.sqlrc."""
|
|
36
|
+
cfg = {}
|
|
37
|
+
if not os.path.exists(CONFIG_FILE):
|
|
38
|
+
return cfg
|
|
39
|
+
try:
|
|
40
|
+
with open(CONFIG_FILE, encoding="utf-8") as f:
|
|
41
|
+
for line in f:
|
|
42
|
+
line = line.strip()
|
|
43
|
+
if not line or line.startswith("#"):
|
|
44
|
+
continue
|
|
45
|
+
if ":" in line:
|
|
46
|
+
key, val = line.split(":", 1)
|
|
47
|
+
cfg[key.strip()] = val.strip()
|
|
48
|
+
except OSError:
|
|
49
|
+
return {}
|
|
50
|
+
return cfg
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def write_state(state):
|
|
54
|
+
"""Persist CLI state atomically."""
|
|
55
|
+
tmp_file = f"{STATE_FILE}.{os.getpid()}.{threading.get_ident()}.tmp"
|
|
56
|
+
with open(tmp_file, "w", encoding="utf-8") as f:
|
|
57
|
+
json.dump(state, f)
|
|
58
|
+
os.chmod(tmp_file, 0o600)
|
|
59
|
+
os.replace(tmp_file, STATE_FILE)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _log(msg, verbose=False):
|
|
63
|
+
"""Print debug message to stderr if verbose mode is on."""
|
|
64
|
+
if verbose:
|
|
65
|
+
print(f"[DEBUG] {msg}", file=sys.stderr, flush=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TokenExpiredError(RuntimeError):
|
|
69
|
+
"""Raised when the persisted Yearning token is no longer accepted."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
AUTH_EXPIRED_MESSAGE = "本地 token 已过期或无效,请重新运行: sql login"
|
|
73
|
+
AUTO_LOGIN_UNAVAILABLE_MESSAGE = (
|
|
74
|
+
"本地 token 已过期或无效,且缺少自动登录凭据。"
|
|
75
|
+
"请设置 YEARNING_USER/YEARNING_PASS,或在 ~/.sqlrc 配置 yearning_user/yearning_pass"
|
|
76
|
+
)
|
|
77
|
+
AUTH_EXPIRED_KEYWORDS = (
|
|
78
|
+
"token",
|
|
79
|
+
"jwt",
|
|
80
|
+
"expired",
|
|
81
|
+
"expire",
|
|
82
|
+
"unauthorized",
|
|
83
|
+
"forbidden",
|
|
84
|
+
"未登录",
|
|
85
|
+
"登录",
|
|
86
|
+
"过期",
|
|
87
|
+
"无效",
|
|
88
|
+
"认证",
|
|
89
|
+
"鉴权",
|
|
90
|
+
"授权",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _looks_like_auth_error(value):
|
|
95
|
+
if isinstance(value, dict):
|
|
96
|
+
value = value.get("text") or value.get("error") or value.get("message") or value.get("msg") or ""
|
|
97
|
+
if not isinstance(value, str):
|
|
98
|
+
return False
|
|
99
|
+
text = str(value).lower()
|
|
100
|
+
return any(keyword.lower() in text for keyword in AUTH_EXPIRED_KEYWORDS)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class YearningClient:
|
|
104
|
+
_login_lock = threading.Lock()
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
base_url="http://192.168.1.135",
|
|
109
|
+
verbose=False,
|
|
110
|
+
username="",
|
|
111
|
+
password="",
|
|
112
|
+
auto_login=True,
|
|
113
|
+
max_auto_login_attempts=2,
|
|
114
|
+
):
|
|
115
|
+
self.base_url = base_url.rstrip("/")
|
|
116
|
+
self.api_base = f"{self.base_url}/api/v2"
|
|
117
|
+
self.verbose = verbose
|
|
118
|
+
self.session = requests.Session()
|
|
119
|
+
self.username = username
|
|
120
|
+
self.password = password
|
|
121
|
+
self.auto_login = auto_login
|
|
122
|
+
self.max_auto_login_attempts = max_auto_login_attempts
|
|
123
|
+
self.token = self._load_token()
|
|
124
|
+
|
|
125
|
+
def _log(self, msg):
|
|
126
|
+
_log(msg, self.verbose)
|
|
127
|
+
|
|
128
|
+
def _load_token(self):
|
|
129
|
+
data = read_state()
|
|
130
|
+
token = data.get("token", "")
|
|
131
|
+
if token:
|
|
132
|
+
self._log(f"Loaded token from {STATE_FILE}")
|
|
133
|
+
return token
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
def _save_token(self, token, username):
|
|
137
|
+
state = read_state()
|
|
138
|
+
state["token"] = token
|
|
139
|
+
state["username"] = username
|
|
140
|
+
write_state(state)
|
|
141
|
+
|
|
142
|
+
def _auth_credentials(self):
|
|
143
|
+
cfg = read_config()
|
|
144
|
+
state = read_state()
|
|
145
|
+
username = (
|
|
146
|
+
self.username
|
|
147
|
+
or os.environ.get("YEARNING_USER", "")
|
|
148
|
+
or cfg.get("yearning_user", "")
|
|
149
|
+
or cfg.get("username", "")
|
|
150
|
+
or cfg.get("user", "")
|
|
151
|
+
or state.get("username", "")
|
|
152
|
+
)
|
|
153
|
+
password = (
|
|
154
|
+
self.password
|
|
155
|
+
or os.environ.get("YEARNING_PASS", "")
|
|
156
|
+
or cfg.get("yearning_pass", "")
|
|
157
|
+
or cfg.get("yearning_password", "")
|
|
158
|
+
or cfg.get("password", "")
|
|
159
|
+
or cfg.get("pass", "")
|
|
160
|
+
)
|
|
161
|
+
return username, password
|
|
162
|
+
|
|
163
|
+
def _adopt_newer_token_from_state(self):
|
|
164
|
+
token = self._load_token()
|
|
165
|
+
if token and token != self.token:
|
|
166
|
+
self._log("Adopted refreshed token from state")
|
|
167
|
+
self.token = token
|
|
168
|
+
return True
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
def _login_for_retry(self):
|
|
172
|
+
if not self.auto_login:
|
|
173
|
+
raise TokenExpiredError(AUTH_EXPIRED_MESSAGE)
|
|
174
|
+
|
|
175
|
+
username, password = self._auth_credentials()
|
|
176
|
+
if not username or not password:
|
|
177
|
+
raise TokenExpiredError(AUTO_LOGIN_UNAVAILABLE_MESSAGE)
|
|
178
|
+
|
|
179
|
+
with self._login_lock:
|
|
180
|
+
if self._adopt_newer_token_from_state():
|
|
181
|
+
return
|
|
182
|
+
self._log("Auto login with configured credentials")
|
|
183
|
+
self.login(username, password)
|
|
184
|
+
|
|
185
|
+
def _with_auto_login(self, label, operation):
|
|
186
|
+
login_attempts = 0
|
|
187
|
+
adopted_state_token = False
|
|
188
|
+
last_error = None
|
|
189
|
+
|
|
190
|
+
def login_until_success():
|
|
191
|
+
nonlocal login_attempts, last_error
|
|
192
|
+
while login_attempts < self.max_auto_login_attempts:
|
|
193
|
+
login_attempts += 1
|
|
194
|
+
self._log(f"{label} token invalid, auto login attempt {login_attempts}")
|
|
195
|
+
try:
|
|
196
|
+
self._login_for_retry()
|
|
197
|
+
return
|
|
198
|
+
except TokenExpiredError as login_error:
|
|
199
|
+
last_error = login_error
|
|
200
|
+
if str(login_error) == AUTO_LOGIN_UNAVAILABLE_MESSAGE:
|
|
201
|
+
raise
|
|
202
|
+
except Exception as login_error:
|
|
203
|
+
last_error = login_error
|
|
204
|
+
|
|
205
|
+
raise TokenExpiredError(
|
|
206
|
+
f"自动登录失败(已尝试 {login_attempts} 次): {last_error}"
|
|
207
|
+
) from last_error
|
|
208
|
+
|
|
209
|
+
while True:
|
|
210
|
+
if not self.token:
|
|
211
|
+
login_until_success()
|
|
212
|
+
try:
|
|
213
|
+
return operation()
|
|
214
|
+
except TokenExpiredError as e:
|
|
215
|
+
last_error = e
|
|
216
|
+
if not adopted_state_token and self._adopt_newer_token_from_state():
|
|
217
|
+
adopted_state_token = True
|
|
218
|
+
continue
|
|
219
|
+
login_until_success()
|
|
220
|
+
|
|
221
|
+
def ensure_authenticated(self):
|
|
222
|
+
"""Ensure a token exists, using configured credentials when needed."""
|
|
223
|
+
return self._with_auto_login("认证检查", lambda: True)
|
|
224
|
+
|
|
225
|
+
def login(self, username, password):
|
|
226
|
+
"""Login and save token."""
|
|
227
|
+
self._log(f"Logging in as {username} to {self.base_url}")
|
|
228
|
+
resp = self.session.post(
|
|
229
|
+
f"{self.base_url}/login",
|
|
230
|
+
json={"username": username, "password": password},
|
|
231
|
+
timeout=10,
|
|
232
|
+
)
|
|
233
|
+
self._log(f"Login response status: {resp.status_code}")
|
|
234
|
+
data = resp.json()
|
|
235
|
+
self._log(f"Login response: {json.dumps(data, ensure_ascii=False)[:200]}")
|
|
236
|
+
if data.get("code") != 1200:
|
|
237
|
+
raise ValueError(f"登录失败: {data.get('text', data.get('error', '未知错误'))}")
|
|
238
|
+
payload = data.get("payload", {})
|
|
239
|
+
token = payload.get("token", "")
|
|
240
|
+
if not token:
|
|
241
|
+
raise ValueError(f"登录响应中未找到 token: {data}")
|
|
242
|
+
self._save_token(token, username)
|
|
243
|
+
self.username = username
|
|
244
|
+
self.password = password
|
|
245
|
+
self.token = token
|
|
246
|
+
self._log("Token saved successfully")
|
|
247
|
+
return token
|
|
248
|
+
|
|
249
|
+
def _headers(self):
|
|
250
|
+
return {"Authorization": f"Bearer {self.token}"}
|
|
251
|
+
|
|
252
|
+
def _json_response(self, resp, label):
|
|
253
|
+
if resp.status_code in (401, 403):
|
|
254
|
+
raise TokenExpiredError(AUTH_EXPIRED_MESSAGE)
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
data = resp.json()
|
|
258
|
+
except ValueError as e:
|
|
259
|
+
body = resp.text.strip()
|
|
260
|
+
if _looks_like_auth_error(body):
|
|
261
|
+
raise TokenExpiredError(AUTH_EXPIRED_MESSAGE) from e
|
|
262
|
+
raise ValueError(f"{label} 响应不是有效 JSON: {body[:200]}") from e
|
|
263
|
+
|
|
264
|
+
if isinstance(data, dict):
|
|
265
|
+
if data.get("code") in (401, 403):
|
|
266
|
+
raise TokenExpiredError(AUTH_EXPIRED_MESSAGE)
|
|
267
|
+
text = data.get("text") or data.get("error") or data.get("message") or data.get("msg")
|
|
268
|
+
if text and _looks_like_auth_error(text):
|
|
269
|
+
raise TokenExpiredError(AUTH_EXPIRED_MESSAGE)
|
|
270
|
+
return data
|
|
271
|
+
|
|
272
|
+
if _looks_like_auth_error(data):
|
|
273
|
+
raise TokenExpiredError(AUTH_EXPIRED_MESSAGE)
|
|
274
|
+
|
|
275
|
+
raise ValueError(f"{label} 响应格式异常: {data!r}")
|
|
276
|
+
|
|
277
|
+
def list_sources(self, idc=""):
|
|
278
|
+
return self._with_auto_login(
|
|
279
|
+
"数据源列表",
|
|
280
|
+
lambda: self._list_sources_once(idc),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
def _list_sources_once(self, idc=""):
|
|
284
|
+
"""List data sources. idc can be empty to list all."""
|
|
285
|
+
self._log(f"Fetching sources: tp=query, idc={idc}")
|
|
286
|
+
resp = self.session.get(
|
|
287
|
+
f"{self.api_base}/fetch/source",
|
|
288
|
+
headers=self._headers(),
|
|
289
|
+
params={"tp": "query", "idc": idc},
|
|
290
|
+
timeout=10,
|
|
291
|
+
)
|
|
292
|
+
data = self._json_response(resp, "数据源列表")
|
|
293
|
+
self._log(f"Sources response code: {data.get('code')}")
|
|
294
|
+
payload = data.get("payload", [])
|
|
295
|
+
if _looks_like_auth_error(payload):
|
|
296
|
+
raise TokenExpiredError(AUTH_EXPIRED_MESSAGE)
|
|
297
|
+
if not isinstance(payload, list):
|
|
298
|
+
raise ValueError(f"数据源列表响应格式异常: {payload!r}")
|
|
299
|
+
return payload
|
|
300
|
+
|
|
301
|
+
def list_databases(self, source_id):
|
|
302
|
+
return self._with_auto_login(
|
|
303
|
+
"数据库列表",
|
|
304
|
+
lambda: self._list_databases_once(source_id),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
def _list_databases_once(self, source_id):
|
|
308
|
+
"""List databases for a source."""
|
|
309
|
+
self._log(f"Fetching databases for source_id={source_id}")
|
|
310
|
+
resp = self.session.get(
|
|
311
|
+
f"{self.api_base}/query/schema",
|
|
312
|
+
headers=self._headers(),
|
|
313
|
+
params={"source_id": source_id},
|
|
314
|
+
timeout=10,
|
|
315
|
+
)
|
|
316
|
+
data = self._json_response(resp, "数据库列表")
|
|
317
|
+
self._log(f"Databases response code: {data.get('code')}")
|
|
318
|
+
payload = data.get("payload") or {}
|
|
319
|
+
if not isinstance(payload, dict):
|
|
320
|
+
if _looks_like_auth_error(payload):
|
|
321
|
+
raise TokenExpiredError(AUTH_EXPIRED_MESSAGE)
|
|
322
|
+
raise ValueError(f"数据库列表响应格式异常: {payload!r}")
|
|
323
|
+
info = payload.get("info", [])
|
|
324
|
+
if not isinstance(info, list):
|
|
325
|
+
raise ValueError(f"数据库列表响应格式异常: {info!r}")
|
|
326
|
+
return [
|
|
327
|
+
db.get("title", db.get("name", "")) if isinstance(db, dict) else str(db)
|
|
328
|
+
for db in info
|
|
329
|
+
]
|
|
330
|
+
|
|
331
|
+
def list_tables(self, source_id, database):
|
|
332
|
+
return self._with_auto_login(
|
|
333
|
+
"表列表",
|
|
334
|
+
lambda: self._list_tables_once(source_id, database),
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
def _list_tables_once(self, source_id, database):
|
|
338
|
+
"""List tables for a database."""
|
|
339
|
+
self._log(f"Fetching tables for source_id={source_id}, db={database}")
|
|
340
|
+
resp = self.session.get(
|
|
341
|
+
f"{self.api_base}/query/tables",
|
|
342
|
+
headers=self._headers(),
|
|
343
|
+
params={"source_id": source_id, "schema": database},
|
|
344
|
+
timeout=10,
|
|
345
|
+
)
|
|
346
|
+
data = self._json_response(resp, "表列表")
|
|
347
|
+
self._log(f"Tables response code: {data.get('code')}")
|
|
348
|
+
payload = data.get("payload") or {}
|
|
349
|
+
if not isinstance(payload, dict):
|
|
350
|
+
if _looks_like_auth_error(payload):
|
|
351
|
+
raise TokenExpiredError(AUTH_EXPIRED_MESSAGE)
|
|
352
|
+
raise ValueError(f"表列表响应格式异常: {payload!r}")
|
|
353
|
+
tables = payload.get("table", [])
|
|
354
|
+
if not isinstance(tables, list):
|
|
355
|
+
raise ValueError(f"表列表响应格式异常: {tables!r}")
|
|
356
|
+
return [
|
|
357
|
+
t.get("title", t.get("name", "")) if isinstance(t, dict) else str(t)
|
|
358
|
+
for t in tables
|
|
359
|
+
]
|
|
360
|
+
|
|
361
|
+
def _is_query_order_ready(self):
|
|
362
|
+
"""Check whether the current query order is usable."""
|
|
363
|
+
try:
|
|
364
|
+
resp = self.session.get(
|
|
365
|
+
f"{self.api_base}/fetch/query_status",
|
|
366
|
+
headers=self._headers(),
|
|
367
|
+
timeout=10,
|
|
368
|
+
)
|
|
369
|
+
data = self._json_response(resp, "查询状态")
|
|
370
|
+
self._log(f"Query status: {data}")
|
|
371
|
+
if data.get("payload") is True:
|
|
372
|
+
return True
|
|
373
|
+
except TokenExpiredError:
|
|
374
|
+
raise
|
|
375
|
+
except Exception as e:
|
|
376
|
+
self._log(f"Query status check failed: {e}")
|
|
377
|
+
|
|
378
|
+
try:
|
|
379
|
+
resp = self.session.get(
|
|
380
|
+
f"{self.api_base}/fetch/is_query",
|
|
381
|
+
headers=self._headers(),
|
|
382
|
+
timeout=10,
|
|
383
|
+
)
|
|
384
|
+
data = self._json_response(resp, "查询权限状态")
|
|
385
|
+
self._log(f"Is query: {data}")
|
|
386
|
+
payload = data.get("payload", {})
|
|
387
|
+
if not isinstance(payload, dict):
|
|
388
|
+
if _looks_like_auth_error(payload):
|
|
389
|
+
raise TokenExpiredError(AUTH_EXPIRED_MESSAGE)
|
|
390
|
+
return False
|
|
391
|
+
if payload.get("status") is True:
|
|
392
|
+
return True
|
|
393
|
+
except TokenExpiredError:
|
|
394
|
+
raise
|
|
395
|
+
except Exception as e:
|
|
396
|
+
self._log(f"Is query check failed: {e}")
|
|
397
|
+
|
|
398
|
+
return False
|
|
399
|
+
|
|
400
|
+
def create_query_order(self, source_id, text="CLI query", verify_wait=0.5):
|
|
401
|
+
return self._with_auto_login(
|
|
402
|
+
"创建查询工单",
|
|
403
|
+
lambda: self._create_query_order_once(source_id, text, verify_wait),
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
def _create_query_order_once(self, source_id, text="CLI query", verify_wait=0.5):
|
|
407
|
+
"""Create a query order. Returns True if order is ready for query."""
|
|
408
|
+
self._log(f"Creating query order for source_id={source_id}")
|
|
409
|
+
# First undo any existing orders
|
|
410
|
+
try:
|
|
411
|
+
resp = self.session.delete(
|
|
412
|
+
f"{self.api_base}/query/undo",
|
|
413
|
+
headers=self._headers(),
|
|
414
|
+
timeout=10,
|
|
415
|
+
)
|
|
416
|
+
self._log(f"Undo response: {resp.text[:200]}")
|
|
417
|
+
if resp.status_code in (401, 403) or _looks_like_auth_error(resp.text):
|
|
418
|
+
raise TokenExpiredError(AUTH_EXPIRED_MESSAGE)
|
|
419
|
+
except TokenExpiredError:
|
|
420
|
+
raise
|
|
421
|
+
except Exception as e:
|
|
422
|
+
self._log(f"Undo failed (may be ok): {e}")
|
|
423
|
+
|
|
424
|
+
# Create new order
|
|
425
|
+
resp = self.session.post(
|
|
426
|
+
f"{self.api_base}/query/post",
|
|
427
|
+
headers=self._headers(),
|
|
428
|
+
json={"source_id": source_id, "text": text, "export": 0},
|
|
429
|
+
timeout=10,
|
|
430
|
+
)
|
|
431
|
+
self._log(f"Create order response: status={resp.status_code}, body='{resp.text[:200]}'")
|
|
432
|
+
if resp.status_code in (401, 403) or _looks_like_auth_error(resp.text):
|
|
433
|
+
raise TokenExpiredError(AUTH_EXPIRED_MESSAGE)
|
|
434
|
+
|
|
435
|
+
# If response is empty with 200, likely auto-approve mode (ReferQueryOrder returns without JSON)
|
|
436
|
+
# If response contains ORDER_IS_CREATE, needs manual approval
|
|
437
|
+
body = resp.text.strip().strip('"')
|
|
438
|
+
if resp.status_code == 200 and body == "":
|
|
439
|
+
self._log("Empty response from create, assuming auto-approved")
|
|
440
|
+
return True
|
|
441
|
+
|
|
442
|
+
if resp.status_code == 200 and (body == "" or "已创建" in body):
|
|
443
|
+
if self._is_query_order_ready():
|
|
444
|
+
return True
|
|
445
|
+
time.sleep(verify_wait)
|
|
446
|
+
return self._is_query_order_ready()
|
|
447
|
+
|
|
448
|
+
return False
|
|
449
|
+
|
|
450
|
+
def execute_query(self, source_id, database, sql, timeout=30):
|
|
451
|
+
return self._with_auto_login(
|
|
452
|
+
"执行查询",
|
|
453
|
+
lambda: self._execute_query_once(source_id, database, sql, timeout),
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
def _execute_query_once(self, source_id, database, sql, timeout=30):
|
|
457
|
+
"""Execute SQL query via WebSocket and return results.
|
|
458
|
+
|
|
459
|
+
Returns dict with:
|
|
460
|
+
- success: bool
|
|
461
|
+
- columns: list of column names
|
|
462
|
+
- rows: list of row dicts
|
|
463
|
+
- query_time: ms
|
|
464
|
+
- error: error message if failed
|
|
465
|
+
"""
|
|
466
|
+
scheme = "wss" if self.base_url.startswith("https") else "ws"
|
|
467
|
+
host = self.base_url.replace("http://", "").replace("https://", "").rstrip("/")
|
|
468
|
+
ws_url = f"{scheme}://{host}{self.api_base.replace(self.base_url, '')}/query/results?source_id={source_id}"
|
|
469
|
+
|
|
470
|
+
self._log(f"WebSocket URL: {ws_url}")
|
|
471
|
+
self._log(f"Token: {self.token[:20]}... (len={len(self.token)})")
|
|
472
|
+
self._log(f"Database: {database}")
|
|
473
|
+
self._log(f"SQL: {sql}")
|
|
474
|
+
|
|
475
|
+
result = {"success": False, "columns": [], "rows": [], "query_time": 0, "error": ""}
|
|
476
|
+
received = threading.Event()
|
|
477
|
+
message_count = [0]
|
|
478
|
+
|
|
479
|
+
def on_message(ws, message):
|
|
480
|
+
message_count[0] += 1
|
|
481
|
+
try:
|
|
482
|
+
data = msgpack.unpackb(message, raw=False)
|
|
483
|
+
self._log(f"Message #{message_count[0]}: {json.dumps(data, ensure_ascii=False, default=str)[:500]}")
|
|
484
|
+
|
|
485
|
+
if not isinstance(data, dict):
|
|
486
|
+
if _looks_like_auth_error(data):
|
|
487
|
+
result["error"] = AUTH_EXPIRED_MESSAGE
|
|
488
|
+
else:
|
|
489
|
+
result["error"] = f"响应格式异常: {data!r}"
|
|
490
|
+
result["success"] = False
|
|
491
|
+
received.set()
|
|
492
|
+
return
|
|
493
|
+
|
|
494
|
+
# Check for heartbeat first
|
|
495
|
+
heartbeat = data.get("heartbeat") or data.get("HeartBeat")
|
|
496
|
+
if heartbeat:
|
|
497
|
+
# Respond to server heartbeat PING with PONG to keep connection alive
|
|
498
|
+
if heartbeat == 1 or str(heartbeat).upper() == "PING":
|
|
499
|
+
self._log("Received heartbeat PING, sending PONG")
|
|
500
|
+
try:
|
|
501
|
+
pong_msg = msgpack.packb({"heartbeat": "PONG"})
|
|
502
|
+
ws.send(pong_msg, opcode=websocket.ABNF.OPCODE_BINARY)
|
|
503
|
+
except Exception as e:
|
|
504
|
+
self._log(f"Failed to send PONG: {e}")
|
|
505
|
+
return
|
|
506
|
+
|
|
507
|
+
error = data.get("error", "")
|
|
508
|
+
if error:
|
|
509
|
+
result["error"] = AUTH_EXPIRED_MESSAGE if _looks_like_auth_error(error) else error
|
|
510
|
+
result["success"] = False
|
|
511
|
+
received.set()
|
|
512
|
+
return
|
|
513
|
+
|
|
514
|
+
# Yearning source code logic (from queryResults struct):
|
|
515
|
+
# - Success: {Export, Error:"", Results, QueryTime} — Status defaults to false (zero value)
|
|
516
|
+
# - No valid order: {Status: true} — sent when no approved order found or order expired
|
|
517
|
+
# - Heartbeat: {HeartBeat: PING, IsOnly} — Status defaults to false
|
|
518
|
+
# So status=False + results = SUCCESS, status=True + no results = no valid order
|
|
519
|
+
status = data.get("status")
|
|
520
|
+
results = data.get("results") or data.get("Results")
|
|
521
|
+
|
|
522
|
+
if results:
|
|
523
|
+
# Query success (status=False is the default for data responses)
|
|
524
|
+
first = results[0]
|
|
525
|
+
fields = first.get("field") or first.get("Field", [])
|
|
526
|
+
if fields and isinstance(fields[0], dict):
|
|
527
|
+
result["columns"] = [
|
|
528
|
+
f.get("title", f.get("dataIndex", "?")) for f in fields
|
|
529
|
+
]
|
|
530
|
+
else:
|
|
531
|
+
result["columns"] = [str(f) for f in fields]
|
|
532
|
+
raw_data = first.get("data") or first.get("Data", [])
|
|
533
|
+
if raw_data and isinstance(raw_data[0], dict):
|
|
534
|
+
result["rows"] = [
|
|
535
|
+
[row.get(col, "") for col in result["columns"]]
|
|
536
|
+
for row in raw_data
|
|
537
|
+
]
|
|
538
|
+
else:
|
|
539
|
+
result["rows"] = raw_data
|
|
540
|
+
result["query_time"] = data.get("query_time") or data.get("QueryTime", 0)
|
|
541
|
+
result["export"] = data.get("export", False)
|
|
542
|
+
result["success"] = True
|
|
543
|
+
received.set()
|
|
544
|
+
return
|
|
545
|
+
|
|
546
|
+
if status is True:
|
|
547
|
+
# Informational: no valid order / order expired / server ready
|
|
548
|
+
# Do NOT overwrite existing successful results
|
|
549
|
+
if not result["success"]:
|
|
550
|
+
result["error"] = "查询工单已过期或不存在,请先在 Web 界面提交查询工单并通过审核"
|
|
551
|
+
result["success"] = False
|
|
552
|
+
received.set()
|
|
553
|
+
return
|
|
554
|
+
|
|
555
|
+
# Heartbeat or other unknown message (status=False, no results, no error)
|
|
556
|
+
self._log(f"Unhandled message, keys: {list(data.keys())}")
|
|
557
|
+
|
|
558
|
+
except Exception as e:
|
|
559
|
+
result["error"] = f"解析响应失败: {e}"
|
|
560
|
+
received.set()
|
|
561
|
+
|
|
562
|
+
def on_error(ws, error):
|
|
563
|
+
self._log(f"WebSocket error: {error}")
|
|
564
|
+
if _looks_like_auth_error(error):
|
|
565
|
+
result["error"] = AUTH_EXPIRED_MESSAGE
|
|
566
|
+
else:
|
|
567
|
+
result["error"] = f"WebSocket 错误: {error}"
|
|
568
|
+
received.set()
|
|
569
|
+
|
|
570
|
+
def on_close(ws, close_status_code, close_msg):
|
|
571
|
+
self._log(f"WebSocket closed: code={close_status_code}, msg={close_msg}")
|
|
572
|
+
if not received.is_set() and not result["success"]:
|
|
573
|
+
if close_status_code in (401, 403) or _looks_like_auth_error(close_msg):
|
|
574
|
+
result["error"] = AUTH_EXPIRED_MESSAGE
|
|
575
|
+
elif close_status_code is None:
|
|
576
|
+
# Server dropped TCP connection without close frame
|
|
577
|
+
# Typical causes: server-side query timeout, reverse proxy timeout,
|
|
578
|
+
# or heartbeat keepalive failure
|
|
579
|
+
result["error"] = (
|
|
580
|
+
"连接被服务端断开(无关闭帧)。"
|
|
581
|
+
"可能原因:查询超时(Yearning 默认 60 秒)、反向代理超时、或心跳保活失败"
|
|
582
|
+
)
|
|
583
|
+
else:
|
|
584
|
+
result["error"] = f"连接关闭: code={close_status_code}, msg={close_msg}"
|
|
585
|
+
received.set()
|
|
586
|
+
|
|
587
|
+
def on_open(ws):
|
|
588
|
+
self._log("WebSocket connected")
|
|
589
|
+
# Send init (type=2) to establish DB connection
|
|
590
|
+
init_msg = msgpack.packb({"type": 2})
|
|
591
|
+
ws.send(init_msg, opcode=websocket.ABNF.OPCODE_BINARY)
|
|
592
|
+
self._log(f"Sent init (type=2): {init_msg.hex()}")
|
|
593
|
+
|
|
594
|
+
# Send query (type=4) immediately after init
|
|
595
|
+
# The server processes messages sequentially, so this will be handled after init
|
|
596
|
+
query_msg = msgpack.packb({
|
|
597
|
+
"type": 4,
|
|
598
|
+
"sql": sql,
|
|
599
|
+
"schema": database,
|
|
600
|
+
})
|
|
601
|
+
self._log(f"Sending query (type=4): {query_msg.hex()}")
|
|
602
|
+
ws.send(query_msg, opcode=websocket.ABNF.OPCODE_BINARY)
|
|
603
|
+
self._log("Query sent successfully")
|
|
604
|
+
|
|
605
|
+
self._log("Connecting to WebSocket...")
|
|
606
|
+
ws = websocket.WebSocketApp(
|
|
607
|
+
ws_url,
|
|
608
|
+
subprotocols=[self.token],
|
|
609
|
+
on_open=on_open,
|
|
610
|
+
on_message=on_message,
|
|
611
|
+
on_error=on_error,
|
|
612
|
+
on_close=on_close,
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
ws_thread = threading.Thread(
|
|
616
|
+
target=ws.run_forever,
|
|
617
|
+
kwargs={
|
|
618
|
+
# WebSocket-level ping keepalive: send PING every 30s
|
|
619
|
+
# to prevent reverse proxy / load balancer from dropping idle connections
|
|
620
|
+
"ping_interval": 30,
|
|
621
|
+
"ping_timeout": 10,
|
|
622
|
+
},
|
|
623
|
+
daemon=True,
|
|
624
|
+
)
|
|
625
|
+
ws_thread.start()
|
|
626
|
+
|
|
627
|
+
self._log(f"Waiting for response (timeout={timeout}s)...")
|
|
628
|
+
if not received.wait(timeout=timeout):
|
|
629
|
+
self._log("Timeout waiting for response")
|
|
630
|
+
result["error"] = f"查询超时 ({timeout}s)"
|
|
631
|
+
|
|
632
|
+
ws.close()
|
|
633
|
+
self._log(f"Final result: success={result['success']}, error={result['error']}, rows={len(result['rows'])}")
|
|
634
|
+
if not result["success"] and _looks_like_auth_error(result.get("error", "")):
|
|
635
|
+
raise TokenExpiredError(AUTH_EXPIRED_MESSAGE)
|
|
636
|
+
return result
|