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.
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