pytest-testkit 1.0.2a2__tar.gz → 1.0.2b1__tar.gz
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.
- {pytest_testkit-1.0.2a2/pytest_testkit.egg-info → pytest_testkit-1.0.2b1}/PKG-INFO +1 -1
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pyproject.toml +1 -1
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/base/ssh.py +117 -24
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/common/environment/device.py +243 -120
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/common/log/infra_logger.py +9 -9
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/common/log/manager.py +114 -10
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1/pytest_testkit.egg-info}/PKG-INFO +1 -1
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/LICENSE +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/MANIFEST.in +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/README.md +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/__init__.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/__init__.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/base/__init__.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/base/excel.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/base/ftp.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/base/http.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/base/local_exec.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/base/sftp.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/common/__init__.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/common/environment/__init__.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/common/environment/allocator.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/common/environment/manager.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/common/log/__init__.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/common/log/filters.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/pre_action/__init__.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/pre_action/action.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/pre_action/exceptions.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/pre_action/registry.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/plugin.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit.egg-info/SOURCES.txt +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit.egg-info/dependency_links.txt +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit.egg-info/entry_points.txt +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit.egg-info/requires.txt +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit.egg-info/top_level.txt +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/requirements.txt +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/setup.cfg +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_collect_case_with_inifile.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_docker_proxy_creation.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_docker_proxy_exit_docker.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_docker_proxy_getitem.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_docker_proxy_integration.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_docker_proxy_is_interactive_shell.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_docker_proxy_non_shell_commands.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_docker_proxy_sendcmd.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_docker_proxy_sendcmd_interactive.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_env_nodes_filter.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_get_inifile_info.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_get_item_case_info.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_local_exec.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_local_interactive.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_log_factory.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_log_factory_integration.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_security_features.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_ssh_cwd.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_ssh_file_ops.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_ssh_ignore_err.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_ssh_only_stdout.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_ssh_remove_command_echo.py +0 -0
- {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_ssh_success.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pytest_testkit"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.2b1"
|
|
8
8
|
description = "Pytest plugin for Infra logging framework and environment management"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -69,6 +69,8 @@ class Ssh:
|
|
|
69
69
|
|
|
70
70
|
self.recv_timeout = False # 上一次recv操作是否超时
|
|
71
71
|
self.send_only = False # 是否发送过sendonly命令
|
|
72
|
+
self.login_echo = "" # 首次交互 channel 登录时的 banner 回显
|
|
73
|
+
self.last_prompt = "" # 最近一次从输出中识别到的 shell 提示符
|
|
72
74
|
self.client = None
|
|
73
75
|
self.jump_client = None
|
|
74
76
|
self.target_client = None
|
|
@@ -169,7 +171,30 @@ class Ssh:
|
|
|
169
171
|
return "\n".join(cleaned_lines)
|
|
170
172
|
|
|
171
173
|
@staticmethod
|
|
172
|
-
def
|
|
174
|
+
def _extract_last_prompt(raw_output: str) -> str:
|
|
175
|
+
"""从交互输出中提取最后一次出现的 shell 提示符。
|
|
176
|
+
|
|
177
|
+
用于在日志中还原真实终端效果: 发送命令时带上下文提示符。
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
raw_output: 原始的终端输出(已去除 ANSI)
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
str: 识别到的提示符, 未识别返回空串
|
|
184
|
+
"""
|
|
185
|
+
prompt_regex = re.compile(
|
|
186
|
+
r"^\s*((?:\([^)]+\)\s*)*(?:\[.+@.+\s+.+\]|[^@\s]+@[^:\s]+:[^\s]*|"
|
|
187
|
+
r"bash-[0-9.]+|sh-[0-9.]+)[#$]|[$#%>])\s*$",
|
|
188
|
+
re.MULTILINE,
|
|
189
|
+
)
|
|
190
|
+
last = ""
|
|
191
|
+
for line in raw_output.splitlines():
|
|
192
|
+
m = prompt_regex.match(line)
|
|
193
|
+
if m:
|
|
194
|
+
last = m.group(1)
|
|
195
|
+
return last
|
|
196
|
+
|
|
197
|
+
def _clean_interactive_output(self, raw_output: str, cmd: str) -> str:
|
|
173
198
|
"""清理交互式命令输出中的多余信息。
|
|
174
199
|
|
|
175
200
|
Args:
|
|
@@ -182,6 +207,11 @@ class Ssh:
|
|
|
182
207
|
# Step 1: 移除 ANSI 转义序列
|
|
183
208
|
output = Ssh._remove_ansi_escapes(raw_output)
|
|
184
209
|
|
|
210
|
+
# 记录输出中最后一次出现的提示符, 供日志还原终端上下文
|
|
211
|
+
prompt = Ssh._extract_last_prompt(output)
|
|
212
|
+
if prompt:
|
|
213
|
+
self.last_prompt = prompt
|
|
214
|
+
|
|
185
215
|
# Step 2: 移除命令回显
|
|
186
216
|
output = Ssh.remove_command_echo(output, cmd)
|
|
187
217
|
|
|
@@ -213,7 +243,6 @@ class Ssh:
|
|
|
213
243
|
)
|
|
214
244
|
return False
|
|
215
245
|
|
|
216
|
-
self.logger.info("[Ssh.login] Starting login to target ip")
|
|
217
246
|
try:
|
|
218
247
|
self.target_client = paramiko.SSHClient()
|
|
219
248
|
self.target_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
@@ -227,7 +256,6 @@ class Ssh:
|
|
|
227
256
|
timeout=10,
|
|
228
257
|
)
|
|
229
258
|
self.target_client.get_transport().set_keepalive(keepalive)
|
|
230
|
-
self.logger.info("[Ssh.login] connect to target ip success")
|
|
231
259
|
except Exception as e:
|
|
232
260
|
self.logger.error(
|
|
233
261
|
f"[Ssh.login] target exception details: {traceback.format_exc()}"
|
|
@@ -251,8 +279,6 @@ class Ssh:
|
|
|
251
279
|
self.logger.debug(
|
|
252
280
|
f"[Ssh._jump_exec_command] executed: {cmd}, exit_code={exit_code}"
|
|
253
281
|
)
|
|
254
|
-
if exit_code != 0:
|
|
255
|
-
self.logger.warning(f"[Ssh._jump_exec_command] error: {stderr_content}")
|
|
256
282
|
if exit_code == 0:
|
|
257
283
|
success = True
|
|
258
284
|
elif param.ignore_err:
|
|
@@ -309,6 +335,90 @@ class Ssh:
|
|
|
309
335
|
return self._jump_exec_command(param)
|
|
310
336
|
return self._build_result(False, "No client", "", param.only_stdout)
|
|
311
337
|
|
|
338
|
+
def _drain_login_banner(self, channel):
|
|
339
|
+
"""首次创建交互 channel 后发送一个空命令, 吸收登录 banner/欢迎信息。
|
|
340
|
+
|
|
341
|
+
发送的空命令不打印日志; 收到的回显保存到 self.login_echo,
|
|
342
|
+
可作为 login 的回显查看。这样后续真正执行的命令输出不受 banner 干扰。
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
channel: 交互式 shell channel
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
str: 吸收到的登录 banner + 空命令回显
|
|
349
|
+
"""
|
|
350
|
+
banner = b""
|
|
351
|
+
# 先等一小段时间让 banner 输出
|
|
352
|
+
banner_start = time.time()
|
|
353
|
+
while time.time() - banner_start < 2:
|
|
354
|
+
if channel.recv_ready():
|
|
355
|
+
while channel.recv_ready():
|
|
356
|
+
chunk = channel.recv(4096)
|
|
357
|
+
if not chunk:
|
|
358
|
+
break
|
|
359
|
+
banner += chunk
|
|
360
|
+
else:
|
|
361
|
+
time.sleep(0.05)
|
|
362
|
+
# 已经能看到提示符就不再等满 2 秒
|
|
363
|
+
if self._build_prompt_regex().search(
|
|
364
|
+
banner.decode("utf-8", errors="replace")
|
|
365
|
+
):
|
|
366
|
+
break
|
|
367
|
+
|
|
368
|
+
# 发送空命令(单个换行), 触发一次新提示符
|
|
369
|
+
channel.send("\n")
|
|
370
|
+
# 注意: 只检查 send 之后的增量数据, 而非累计 banner。
|
|
371
|
+
# 阶段1 的宽松正则可能被 banner 里的伪提示符($ % > 结尾行)误匹配,
|
|
372
|
+
# 若此处仍检查累计 banner, 会立即 break 而等不到真实新提示符,
|
|
373
|
+
# 导致 last_prompt 为空、第一条 sendcmd 日志无提示符前缀
|
|
374
|
+
echo_new = b""
|
|
375
|
+
echo_start = time.time()
|
|
376
|
+
while time.time() - echo_start < 3:
|
|
377
|
+
if channel.recv_ready():
|
|
378
|
+
while channel.recv_ready():
|
|
379
|
+
chunk = channel.recv(4096)
|
|
380
|
+
if not chunk:
|
|
381
|
+
break
|
|
382
|
+
banner += chunk
|
|
383
|
+
echo_new += chunk
|
|
384
|
+
else:
|
|
385
|
+
time.sleep(0.05)
|
|
386
|
+
if echo_new and self._build_prompt_regex().search(
|
|
387
|
+
echo_new.decode("utf-8", errors="replace")
|
|
388
|
+
):
|
|
389
|
+
break
|
|
390
|
+
|
|
391
|
+
self.login_echo = banner.decode("utf-8", errors="replace")
|
|
392
|
+
# banner 末尾的提示符同时记录到 last_prompt,
|
|
393
|
+
# 使第一条 sendcmd 日志即带提示符前缀
|
|
394
|
+
prompt = self._extract_last_prompt(self._remove_ansi_escapes(self.login_echo))
|
|
395
|
+
if prompt:
|
|
396
|
+
self.last_prompt = prompt
|
|
397
|
+
return self.login_echo
|
|
398
|
+
|
|
399
|
+
def prepare_interactive_channel(self):
|
|
400
|
+
"""预创建交互 shell channel 并吸收登录 banner。
|
|
401
|
+
|
|
402
|
+
在登录后立即调用, 使 banner 回显(login_echo)在第一条命令之前就绪。
|
|
403
|
+
幂等: channel 已存在时不做任何事。
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
str: 吸收到的 banner 回显 (存入 self.login_echo)
|
|
407
|
+
"""
|
|
408
|
+
if self.client:
|
|
409
|
+
if not self.channel or not self.channel.active:
|
|
410
|
+
self.channel = self.client.invoke_shell()
|
|
411
|
+
self.channel.resize_pty(width=1000, height=5000)
|
|
412
|
+
self._drain_login_banner(self.channel)
|
|
413
|
+
return self.login_echo
|
|
414
|
+
if self.target_client:
|
|
415
|
+
if not self.target_channel or not self.target_channel.active:
|
|
416
|
+
self.target_channel = self.target_client.invoke_shell()
|
|
417
|
+
self.target_channel.resize_pty(width=1000, height=5000)
|
|
418
|
+
self._drain_login_banner(self.target_channel)
|
|
419
|
+
return self.login_echo
|
|
420
|
+
return ""
|
|
421
|
+
|
|
312
422
|
def ssh_cmd_interactive(self, param: SshCmdInteractiveParam):
|
|
313
423
|
"""Execute interactive command on SSH server.
|
|
314
424
|
|
|
@@ -329,11 +439,13 @@ class Ssh:
|
|
|
329
439
|
if not self.channel or not self.channel.active:
|
|
330
440
|
self.channel = self.client.invoke_shell()
|
|
331
441
|
self.channel.resize_pty(width=1000, height=5000)
|
|
442
|
+
self._drain_login_banner(self.channel)
|
|
332
443
|
return self._direct_exec_cmd_interactive(param)
|
|
333
444
|
if self.target_client:
|
|
334
445
|
if not self.target_channel or not self.target_channel.active:
|
|
335
446
|
self.target_channel = self.target_client.invoke_shell()
|
|
336
447
|
self.target_channel.resize_pty(width=1000, height=5000)
|
|
448
|
+
self._drain_login_banner(self.target_channel)
|
|
337
449
|
return self._jump_exec_cmd_interactive(param)
|
|
338
450
|
return self._build_result(False, "No client", "", param.only_stdout)
|
|
339
451
|
|
|
@@ -465,12 +577,10 @@ class Ssh:
|
|
|
465
577
|
self.client = paramiko.SSHClient()
|
|
466
578
|
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
467
579
|
try:
|
|
468
|
-
self.logger.info("[Ssh.login] Starting login process...")
|
|
469
580
|
self.client.connect(
|
|
470
581
|
self.target_ip, self.target_port, self.target_user, self.target_pwd
|
|
471
582
|
)
|
|
472
583
|
self.client.get_transport().set_keepalive(keepalive)
|
|
473
|
-
self.logger.info("[Ssh.login] Finish connect()...")
|
|
474
584
|
return True
|
|
475
585
|
except Exception as e:
|
|
476
586
|
self.logger.error(
|
|
@@ -511,8 +621,6 @@ class Ssh:
|
|
|
511
621
|
self.logger.debug(
|
|
512
622
|
f"[Ssh._direct_exec_cmd] executed: {cmd}, exit_code={exit_code}"
|
|
513
623
|
)
|
|
514
|
-
if exit_code != 0:
|
|
515
|
-
self.logger.warning(f"[Ssh._direct_exec_cmd] error: {stderr_content}")
|
|
516
624
|
if exit_code == 0:
|
|
517
625
|
success = True
|
|
518
626
|
elif param.ignore_err:
|
|
@@ -531,9 +639,6 @@ class Ssh:
|
|
|
531
639
|
try:
|
|
532
640
|
with SCPClient(self.client.get_transport()) as scp:
|
|
533
641
|
scp.put(local_path, remote_path, recursive=True)
|
|
534
|
-
self.logger.info(
|
|
535
|
-
f"[Ssh._direct_put_file] Uploaded {local_path} to {remote_path} via SCP"
|
|
536
|
-
)
|
|
537
642
|
return True
|
|
538
643
|
except Exception as e:
|
|
539
644
|
self.logger.error(
|
|
@@ -545,10 +650,6 @@ class Ssh:
|
|
|
545
650
|
try:
|
|
546
651
|
with SCPClient(self.client.get_transport()) as scp:
|
|
547
652
|
scp.get(remote_path, local_path, recursive=True)
|
|
548
|
-
self.logger.info(
|
|
549
|
-
f"[Ssh._direct_get_file] Downloaded {remote_path}"
|
|
550
|
-
f" to {local_path} via SCP"
|
|
551
|
-
)
|
|
552
653
|
return True
|
|
553
654
|
except Exception as e:
|
|
554
655
|
self.logger.error(
|
|
@@ -560,10 +661,6 @@ class Ssh:
|
|
|
560
661
|
try:
|
|
561
662
|
with SCPClient(self.target_client.get_transport()) as scp:
|
|
562
663
|
scp.put(local_path, remote_path, recursive=True)
|
|
563
|
-
self.logger.info(
|
|
564
|
-
f"[Ssh._jump_put_file] Uploaded {local_path} to "
|
|
565
|
-
f"{remote_path} via SCP through jump server"
|
|
566
|
-
)
|
|
567
664
|
return True
|
|
568
665
|
except Exception as e:
|
|
569
666
|
self.logger.error(
|
|
@@ -575,10 +672,6 @@ class Ssh:
|
|
|
575
672
|
try:
|
|
576
673
|
with SCPClient(self.target_client.get_transport()) as scp:
|
|
577
674
|
scp.get(remote_path, local_path, recursive=True)
|
|
578
|
-
self.logger.info(
|
|
579
|
-
f"[Ssh._jump_get_file] Downloaded {remote_path} to "
|
|
580
|
-
f"{local_path} via SCP through jump server"
|
|
581
|
-
)
|
|
582
675
|
return True
|
|
583
676
|
except Exception as e:
|
|
584
677
|
self.logger.error(
|
{pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/common/environment/device.py
RENAMED
|
@@ -7,6 +7,7 @@ and connection information.
|
|
|
7
7
|
|
|
8
8
|
import shutil
|
|
9
9
|
import re
|
|
10
|
+
import shlex
|
|
10
11
|
import threading
|
|
11
12
|
import sys
|
|
12
13
|
from dataclasses import dataclass
|
|
@@ -62,6 +63,26 @@ def set_session_quiet_stdout(enabled: bool) -> None:
|
|
|
62
63
|
_SESSION_QUIET_STDOUT = bool(enabled)
|
|
63
64
|
|
|
64
65
|
|
|
66
|
+
def _short_name(name: str) -> str:
|
|
67
|
+
"""取设备名的最后一段用于日志显示。
|
|
68
|
+
|
|
69
|
+
如 'training_solution_two_nodes-computing_nodes-node_2' -> 'node_2',
|
|
70
|
+
'rack1/node_3' -> 'node_3'。不按 '_' 分割, 保留 node_2 这类名字。
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
name: 完整设备名
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
str: 最后一段; 无分隔符时原样返回
|
|
77
|
+
"""
|
|
78
|
+
if not name:
|
|
79
|
+
return name
|
|
80
|
+
for sep in ("/", "-"):
|
|
81
|
+
if sep in name:
|
|
82
|
+
return name.rsplit(sep, 1)[-1]
|
|
83
|
+
return name
|
|
84
|
+
|
|
85
|
+
|
|
65
86
|
@dataclass
|
|
66
87
|
class DeviceParam:
|
|
67
88
|
name: str
|
|
@@ -161,7 +182,12 @@ class DockerProxy:
|
|
|
161
182
|
"""
|
|
162
183
|
Execute a non-interactive command in the Docker container.
|
|
163
184
|
|
|
164
|
-
|
|
185
|
+
Executes on the device's persistent interactive shell channel, with
|
|
186
|
+
docker session awareness:
|
|
187
|
+
- If already in this container's interactive session: executes cmd directly
|
|
188
|
+
- If another container has an active session: returns failure immediately
|
|
189
|
+
- Otherwise: wraps as 'docker exec {docker_name} {cmd}' (does not create
|
|
190
|
+
a persistent session)
|
|
165
191
|
|
|
166
192
|
Args:
|
|
167
193
|
cmd: Command to execute
|
|
@@ -173,6 +199,26 @@ class DockerProxy:
|
|
|
173
199
|
str (when only_stdout=True): stdout content
|
|
174
200
|
dict (when only_stdout=False): {'success': bool, 'stdout': str, 'stderr': str}
|
|
175
201
|
"""
|
|
202
|
+
current_session = self._device.current_docker_session
|
|
203
|
+
|
|
204
|
+
if current_session == self._docker_name:
|
|
205
|
+
# 已在该容器的交互式 session 中, 直接执行命令
|
|
206
|
+
logger.info(f"[DockerProxy.sendcmd] Executing in docker session: {cmd}")
|
|
207
|
+
return self._device.sendcmd(
|
|
208
|
+
cmd, timeout=timeout, ignore_err=ignore_err, only_stdout=only_stdout
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
if current_session:
|
|
212
|
+
# 其他容器持有交互式 session, 直接返回失败
|
|
213
|
+
logger.warning(
|
|
214
|
+
f"[DockerProxy.sendcmd] Another session '{current_session}' "
|
|
215
|
+
f"is active, cannot execute in '{self._docker_name}'"
|
|
216
|
+
)
|
|
217
|
+
error_msg = "请退出上一个交互对话"
|
|
218
|
+
if only_stdout:
|
|
219
|
+
return error_msg
|
|
220
|
+
return {"success": False, "stdout": "", "stderr": error_msg}
|
|
221
|
+
|
|
176
222
|
docker_cmd = f"docker exec {self._docker_name} {cmd}"
|
|
177
223
|
return self._device.sendcmd(
|
|
178
224
|
docker_cmd, timeout=timeout, ignore_err=ignore_err, only_stdout=only_stdout
|
|
@@ -540,9 +586,6 @@ class Device:
|
|
|
540
586
|
bool: True if login successful
|
|
541
587
|
"""
|
|
542
588
|
if self.local_exec:
|
|
543
|
-
logger.info(
|
|
544
|
-
f"[Device._login] local_exec=True, skipping SSH login for {self.name}"
|
|
545
|
-
)
|
|
546
589
|
self._set_logged_in(True)
|
|
547
590
|
return True
|
|
548
591
|
|
|
@@ -550,7 +593,11 @@ class Device:
|
|
|
550
593
|
if self._get_logged_in():
|
|
551
594
|
return True
|
|
552
595
|
|
|
553
|
-
|
|
596
|
+
# 登录动作日志: HTML location 列显示 login[设备短名]
|
|
597
|
+
logger.info(
|
|
598
|
+
f"Logging in to device: {self.name}",
|
|
599
|
+
extra={"op_type": f"login[{_short_name(self.name)}]"},
|
|
600
|
+
)
|
|
554
601
|
|
|
555
602
|
# Implement actual SSH connection logic
|
|
556
603
|
# 由于有跳板机,需要先登录跳板机,再登录目标设备
|
|
@@ -570,10 +617,25 @@ class Device:
|
|
|
570
617
|
)
|
|
571
618
|
login_result = self._thread_local.sshConnect.login()
|
|
572
619
|
if login_result is False:
|
|
573
|
-
logger.error(
|
|
620
|
+
logger.error(
|
|
621
|
+
f"ssh login failed for {self.__str__()}",
|
|
622
|
+
extra={"op_type": f"loginresult[{_short_name(self.name)}]"},
|
|
623
|
+
)
|
|
574
624
|
return False
|
|
575
625
|
self._set_logged_in(True)
|
|
576
|
-
logger.info(
|
|
626
|
+
logger.info(
|
|
627
|
+
f"Successfully connected to {self.__str__()}",
|
|
628
|
+
extra={"op_type": f"loginresult[{_short_name(self.name)}]"},
|
|
629
|
+
)
|
|
630
|
+
# 登录后立即预创建交互 channel 并吸收 banner,
|
|
631
|
+
# 保证 loginecho 出现在第一条 sendcmd 之前
|
|
632
|
+
login_echo = self._thread_local.sshConnect.prepare_interactive_channel()
|
|
633
|
+
if login_echo:
|
|
634
|
+
logger.info(
|
|
635
|
+
login_echo,
|
|
636
|
+
extra={"op_type": f"loginecho[{_short_name(self.name)}]"},
|
|
637
|
+
)
|
|
638
|
+
self._thread_local.sshConnect._login_echo_logged = True
|
|
577
639
|
return True
|
|
578
640
|
|
|
579
641
|
def _sftp_login(self):
|
|
@@ -621,24 +683,32 @@ class Device:
|
|
|
621
683
|
only_stdout=True,
|
|
622
684
|
echo_output=True,
|
|
623
685
|
):
|
|
624
|
-
"""Send command to the device.
|
|
686
|
+
"""Send command to the device via the persistent interactive shell channel.
|
|
625
687
|
|
|
626
688
|
Automatically logs in if not already logged in in current thread.
|
|
627
689
|
Each thread maintains its own independent login session.
|
|
628
|
-
|
|
690
|
+
Shell state (cd, export) persists across sendcmd calls within the same thread.
|
|
691
|
+
|
|
692
|
+
Behavior notes:
|
|
693
|
+
- env_vars is inlined as a per-command prefix (VAR=value cmd), scoped to
|
|
694
|
+
this command only, does not pollute the persistent shell
|
|
695
|
+
- stderr is merged into stdout by the PTY; result dict 'stderr' is usually ''
|
|
696
|
+
- Exit code is best-effort captured via the $? sentinel
|
|
697
|
+
- 一次性执行需求请用 sendcmd_oneshot (SSH exec_command / subprocess.run)
|
|
629
698
|
|
|
630
|
-
日志策略:
|
|
631
|
-
|
|
632
|
-
echo_output=False
|
|
699
|
+
日志策略: sendcmd[设备] 打印命令(带 shell 提示符前缀),
|
|
700
|
+
recvcmd[设备] 打印文本回显(stdout+stderr 合并, 不打印 dict).
|
|
701
|
+
echo_output=False 或全局静默时不打印回显.
|
|
633
702
|
|
|
634
703
|
Args:
|
|
635
704
|
cmd: Command to execute
|
|
636
|
-
timeout: Timeout in seconds, supports int or str (e.g., 30 or "30")
|
|
637
|
-
|
|
705
|
+
timeout: Timeout in seconds, supports int or str (e.g., 30 or "30"),
|
|
706
|
+
defaults to 600 when None
|
|
707
|
+
env_vars: Environment variables for the command (inlined as VAR=value prefix)
|
|
638
708
|
cwd: Working directory to execute command in
|
|
639
709
|
ignore_err: If True, success=True even when command fails
|
|
640
710
|
only_stdout: If True, return only stdout string; if False, return dict
|
|
641
|
-
echo_output: If True, print
|
|
711
|
+
echo_output: If True, print the recvcmd echo log; if False, skip it
|
|
642
712
|
|
|
643
713
|
Returns:
|
|
644
714
|
str (when only_stdout=True): stdout content
|
|
@@ -646,125 +716,127 @@ class Device:
|
|
|
646
716
|
"""
|
|
647
717
|
if isinstance(timeout, str):
|
|
648
718
|
timeout = int(timeout)
|
|
719
|
+
if timeout is None:
|
|
720
|
+
timeout = 600
|
|
649
721
|
|
|
650
|
-
#
|
|
651
|
-
|
|
652
|
-
|
|
722
|
+
# env_vars 内联为单命令前缀 (VAR=value cmd), 不污染持久化 shell
|
|
723
|
+
actual_cmd = cmd
|
|
724
|
+
if env_vars:
|
|
725
|
+
prefix = " ".join(f"{k}={shlex.quote(str(v))}" for k, v in env_vars.items())
|
|
726
|
+
actual_cmd = f"{prefix} {cmd}"
|
|
653
727
|
|
|
654
728
|
if self.local_exec:
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
729
|
+
if not self._get_logged_in():
|
|
730
|
+
self._login()
|
|
731
|
+
logger.debug(
|
|
732
|
+
f"[Device.sendcmd] local_exec=True, executing interactively: {cmd}"
|
|
733
|
+
)
|
|
734
|
+
param = LocalCmdInteractiveParam(
|
|
735
|
+
cmd=actual_cmd,
|
|
658
736
|
timeout=timeout,
|
|
659
|
-
environment=env_vars,
|
|
660
737
|
cwd=cwd,
|
|
661
738
|
ignore_err=ignore_err,
|
|
662
739
|
only_stdout=False, # 强制拿 dict 用于日志
|
|
663
740
|
)
|
|
664
|
-
result_dict = self._get_local_client().
|
|
741
|
+
result_dict = self._get_local_client().cmd_interactive(param)
|
|
742
|
+
ssh_connect = None
|
|
665
743
|
else:
|
|
666
744
|
if not self._get_logged_in():
|
|
667
745
|
self._login()
|
|
668
746
|
|
|
669
|
-
|
|
670
|
-
|
|
747
|
+
ssh_connect = self._thread_local.sshConnect
|
|
748
|
+
# login banner 回显兜底: channel 为惰性创建时 banner 在首次交互命令
|
|
749
|
+
# 内部才被吸收, 此刻有内容可提前打印, 保证顺序在 sendcmd 之前
|
|
750
|
+
if getattr(ssh_connect, "login_echo", "") and not getattr(
|
|
751
|
+
ssh_connect, "_login_echo_logged", False
|
|
752
|
+
):
|
|
753
|
+
logger.info(
|
|
754
|
+
ssh_connect.login_echo,
|
|
755
|
+
extra={"op_type": f"loginecho[{_short_name(self.name)}]"},
|
|
756
|
+
)
|
|
757
|
+
ssh_connect._login_echo_logged = True
|
|
758
|
+
|
|
759
|
+
param = SshCmdInteractiveParam(
|
|
760
|
+
cmd=actual_cmd,
|
|
761
|
+
expect_prompt=None,
|
|
671
762
|
timeout=timeout,
|
|
672
|
-
environment=env_vars,
|
|
673
763
|
cwd=cwd,
|
|
674
764
|
ignore_err=ignore_err,
|
|
675
765
|
only_stdout=False, # 强制拿 dict 用于日志
|
|
676
766
|
)
|
|
677
|
-
result_dict =
|
|
767
|
+
result_dict = ssh_connect.ssh_cmd_interactive(param)
|
|
678
768
|
|
|
679
|
-
#
|
|
680
|
-
|
|
769
|
+
# 发送命令日志: HTML location 列显示 sendcmd[设备短名],
|
|
770
|
+
# 消息带上 shell 提示符还原终端效果
|
|
771
|
+
if echo_output and not _SESSION_QUIET_STDOUT:
|
|
772
|
+
node_tag = f"[{_short_name(self.name)}]"
|
|
773
|
+
prompt = getattr(ssh_connect, "last_prompt", "") if ssh_connect else ""
|
|
774
|
+
send_msg = f"{prompt}{cmd}" if prompt else cmd
|
|
775
|
+
if cwd is not None:
|
|
776
|
+
send_msg += f" (cwd: {cwd})"
|
|
777
|
+
logger.info(
|
|
778
|
+
f"{LogColor.CYAN}{send_msg}{LogColor.RESET}",
|
|
779
|
+
extra={"op_type": f"sendcmd{node_tag}"},
|
|
780
|
+
)
|
|
781
|
+
# 回显日志: 只打印文本回显(stdout+stderr), 不打印 dict,
|
|
782
|
+
# 末尾带上最新提示符
|
|
783
|
+
echo_parts = []
|
|
784
|
+
stdout = result_dict.get("stdout", "") or ""
|
|
785
|
+
stderr = result_dict.get("stderr", "") or ""
|
|
786
|
+
if stdout:
|
|
787
|
+
echo_parts.append(stdout)
|
|
788
|
+
if stderr:
|
|
789
|
+
echo_parts.append(stderr)
|
|
790
|
+
result_echo = "\n".join(echo_parts)
|
|
791
|
+
if result_echo:
|
|
792
|
+
new_prompt = (
|
|
793
|
+
getattr(ssh_connect, "last_prompt", "") if ssh_connect else ""
|
|
794
|
+
)
|
|
795
|
+
recv_msg = (
|
|
796
|
+
result_echo
|
|
797
|
+
if result_echo.endswith(new_prompt)
|
|
798
|
+
else (f"{result_echo}\n{new_prompt}" if new_prompt else result_echo)
|
|
799
|
+
)
|
|
800
|
+
logger.info(recv_msg, extra={"op_type": f"recvcmd{node_tag}"})
|
|
801
|
+
else:
|
|
802
|
+
logger.info("(empty)", extra={"op_type": f"recvcmd{node_tag}"})
|
|
681
803
|
|
|
682
804
|
# 按用户原始 only_stdout 决定返回值
|
|
683
805
|
if only_stdout:
|
|
684
806
|
return result_dict.get("stdout", "")
|
|
685
807
|
return result_dict
|
|
686
808
|
|
|
687
|
-
def
|
|
688
|
-
self,
|
|
689
|
-
method: str,
|
|
690
|
-
result_dict: dict,
|
|
691
|
-
ignore_err: bool,
|
|
692
|
-
echo_output: bool,
|
|
693
|
-
matched_prompt,
|
|
694
|
-
):
|
|
695
|
-
"""打 sendcmd / sendcmd_interactive 的第 2 行日志。
|
|
696
|
-
|
|
697
|
-
Args:
|
|
698
|
-
method: 方法名, 用于日志前缀 ('sendcmd' / 'sendcmd_interactive')
|
|
699
|
-
result_dict: 底层返回的 dict (only_stdout=False 模式)
|
|
700
|
-
ignore_err: 用户传入的 ignore_err
|
|
701
|
-
echo_output: 用户传入的 echo_output
|
|
702
|
-
matched_prompt: 仅 sendcmd_interactive 用; sendcmd 传 None
|
|
703
|
-
"""
|
|
704
|
-
success = result_dict.get("success", True)
|
|
705
|
-
stdout = result_dict.get("stdout", "") or ""
|
|
706
|
-
stderr = result_dict.get("stderr", "") or ""
|
|
707
|
-
exit_code = result_dict.get("exit_code")
|
|
708
|
-
|
|
709
|
-
# ignore_err=True 时即使底层 success=False 也按成功处理(不打 ERROR)
|
|
710
|
-
is_failure = (not success) and (not ignore_err)
|
|
711
|
-
|
|
712
|
-
# echo_output=False 或 全局静默开关开启时, 任何返回值都不打印
|
|
713
|
-
if not echo_output or _SESSION_QUIET_STDOUT:
|
|
714
|
-
return
|
|
715
|
-
|
|
716
|
-
parts = []
|
|
717
|
-
if matched_prompt is not None:
|
|
718
|
-
parts.append(f"prompt={matched_prompt}")
|
|
719
|
-
if exit_code is not None:
|
|
720
|
-
suffix = " (ignored)" if ignore_err and exit_code != 0 else ""
|
|
721
|
-
parts.append(f"exit={exit_code}{suffix}")
|
|
722
|
-
if stdout:
|
|
723
|
-
parts.append(f"stdout={stdout}")
|
|
724
|
-
if stderr:
|
|
725
|
-
parts.append(f"stderr={stderr}")
|
|
726
|
-
|
|
727
|
-
if not parts:
|
|
728
|
-
# 拿不到 exit_code 等任何信息时的兜底
|
|
729
|
-
if is_failure:
|
|
730
|
-
parts.append("FAILED")
|
|
731
|
-
else:
|
|
732
|
-
parts.append("ok")
|
|
733
|
-
|
|
734
|
-
line = f"[Device.{method}] {', '.join(parts)}"
|
|
735
|
-
if is_failure:
|
|
736
|
-
logger.warning(line)
|
|
737
|
-
else:
|
|
738
|
-
logger.info(line)
|
|
739
|
-
|
|
740
|
-
def sendcmd_interactive(
|
|
809
|
+
def sendcmd_oneshot(
|
|
741
810
|
self,
|
|
742
811
|
cmd,
|
|
743
|
-
|
|
744
|
-
|
|
812
|
+
timeout=None,
|
|
813
|
+
env_vars=None,
|
|
745
814
|
cwd=None,
|
|
746
815
|
ignore_err=False,
|
|
747
816
|
only_stdout=True,
|
|
748
817
|
echo_output=True,
|
|
749
818
|
):
|
|
750
|
-
"""Send command to the device
|
|
819
|
+
"""Send command to the device as a one-shot execution (original sendcmd logic).
|
|
751
820
|
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
821
|
+
Each call starts an independent exec_command / subprocess.run process:
|
|
822
|
+
- SSH path: wraps the command in 'bash -l -c' and runs it via
|
|
823
|
+
exec_command with native environment support; exit code is the
|
|
824
|
+
authoritative channel exit status; stderr is a separate stream
|
|
825
|
+
- local_exec path: runs via subprocess.run
|
|
826
|
+
|
|
827
|
+
No persistent shell state; independent of the interactive channel.
|
|
755
828
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
echo_output=False 时不打印 stdout.
|
|
829
|
+
日志策略与 sendcmd 相同: sendcmd[设备] 打印命令,
|
|
830
|
+
recvcmd[设备] 打印文本回显(stdout+stderr 合并, 不打印 dict).
|
|
759
831
|
|
|
760
832
|
Args:
|
|
761
833
|
cmd: Command to execute
|
|
762
|
-
|
|
763
|
-
|
|
834
|
+
timeout: Timeout in seconds, supports int or str (e.g., 30 or "30")
|
|
835
|
+
env_vars: Environment variables for the command
|
|
764
836
|
cwd: Working directory to execute command in
|
|
765
|
-
ignore_err: If True, success=True even when
|
|
837
|
+
ignore_err: If True, success=True even when command fails
|
|
766
838
|
only_stdout: If True, return only stdout string; if False, return dict
|
|
767
|
-
echo_output: If True, print
|
|
839
|
+
echo_output: If True, print the recvcmd echo log; if False, skip it
|
|
768
840
|
|
|
769
841
|
Returns:
|
|
770
842
|
str (when only_stdout=True): stdout content
|
|
@@ -773,52 +845,98 @@ class Device:
|
|
|
773
845
|
if isinstance(timeout, str):
|
|
774
846
|
timeout = int(timeout)
|
|
775
847
|
|
|
776
|
-
# 第 1 行: 默认打印执行的命令, 受 echo_output 和 _SESSION_QUIET_STDOUT 控制
|
|
777
|
-
if echo_output and not _SESSION_QUIET_STDOUT:
|
|
778
|
-
logger.info(
|
|
779
|
-
f"[Device.sendcmd_interactive] $ {LogColor.CYAN}{cmd}{LogColor.RESET}"
|
|
780
|
-
)
|
|
781
|
-
|
|
782
848
|
if self.local_exec:
|
|
783
|
-
if not self._get_logged_in():
|
|
784
|
-
self._login()
|
|
785
849
|
logger.debug(
|
|
786
|
-
f"[Device.
|
|
850
|
+
f"[Device.sendcmd_oneshot] local_exec=True, executing locally: {cmd}"
|
|
787
851
|
)
|
|
788
|
-
param =
|
|
852
|
+
param = LocalCmdParam(
|
|
789
853
|
cmd=cmd,
|
|
790
854
|
timeout=timeout,
|
|
855
|
+
environment=env_vars,
|
|
791
856
|
cwd=cwd,
|
|
792
857
|
ignore_err=ignore_err,
|
|
793
858
|
only_stdout=False, # 强制拿 dict 用于日志
|
|
794
|
-
expect_prompt=expect_prompt,
|
|
795
859
|
)
|
|
796
|
-
result_dict = self._get_local_client().
|
|
860
|
+
result_dict = self._get_local_client().cmd(param)
|
|
797
861
|
else:
|
|
798
862
|
if not self._get_logged_in():
|
|
799
863
|
self._login()
|
|
800
864
|
|
|
801
|
-
param =
|
|
865
|
+
param = SshCmdParam(
|
|
802
866
|
cmd=cmd,
|
|
803
|
-
expect_prompt=expect_prompt,
|
|
804
867
|
timeout=timeout,
|
|
868
|
+
environment=env_vars,
|
|
805
869
|
cwd=cwd,
|
|
806
870
|
ignore_err=ignore_err,
|
|
807
871
|
only_stdout=False, # 强制拿 dict 用于日志
|
|
808
872
|
)
|
|
809
|
-
result_dict = self._thread_local.sshConnect.
|
|
873
|
+
result_dict = self._thread_local.sshConnect.ssh_cmd(param)
|
|
810
874
|
|
|
811
|
-
#
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
875
|
+
# 命令与回显日志: sendcmd[设备短名] / recvcmd[设备短名]
|
|
876
|
+
if echo_output and not _SESSION_QUIET_STDOUT:
|
|
877
|
+
node_tag = f"[{_short_name(self.name)}]"
|
|
878
|
+
logger.info(
|
|
879
|
+
f"{LogColor.CYAN}{cmd}{LogColor.RESET}",
|
|
880
|
+
extra={"op_type": f"sendcmd{node_tag}"},
|
|
881
|
+
)
|
|
882
|
+
echo_parts = []
|
|
883
|
+
stdout = result_dict.get("stdout", "") or ""
|
|
884
|
+
stderr = result_dict.get("stderr", "") or ""
|
|
885
|
+
if stdout:
|
|
886
|
+
echo_parts.append(stdout)
|
|
887
|
+
if stderr:
|
|
888
|
+
echo_parts.append(stderr)
|
|
889
|
+
result_echo = "\n".join(echo_parts)
|
|
890
|
+
if result_echo:
|
|
891
|
+
logger.info(result_echo, extra={"op_type": f"recvcmd{node_tag}"})
|
|
892
|
+
else:
|
|
893
|
+
logger.info("(empty)", extra={"op_type": f"recvcmd{node_tag}"})
|
|
816
894
|
|
|
817
895
|
# 按用户原始 only_stdout 决定返回值
|
|
818
896
|
if only_stdout:
|
|
819
897
|
return result_dict.get("stdout", "")
|
|
820
898
|
return result_dict
|
|
821
899
|
|
|
900
|
+
def sendcmd_interactive(
|
|
901
|
+
self,
|
|
902
|
+
cmd,
|
|
903
|
+
expect_prompt=None,
|
|
904
|
+
timeout=600,
|
|
905
|
+
cwd=None,
|
|
906
|
+
ignore_err=False,
|
|
907
|
+
only_stdout=True,
|
|
908
|
+
echo_output=True,
|
|
909
|
+
):
|
|
910
|
+
"""Send command to the device with interactive input.
|
|
911
|
+
|
|
912
|
+
Same functionality as sendcmd (persistent interactive shell channel);
|
|
913
|
+
simply calls sendcmd. Kept for backward compatibility with existing
|
|
914
|
+
call sites.
|
|
915
|
+
|
|
916
|
+
日志策略与 sendcmd 相同: sendcmd[设备] 打印命令, recvcmd[设备] 打印文本回显.
|
|
917
|
+
|
|
918
|
+
Args:
|
|
919
|
+
cmd: Command to execute
|
|
920
|
+
expect_prompt: Unused, kept for signature compatibility
|
|
921
|
+
timeout: Timeout in seconds, supports int or str (e.g., 600 or "600")
|
|
922
|
+
cwd: Working directory to execute command in
|
|
923
|
+
ignore_err: If True, success=True even when prompt not matched
|
|
924
|
+
only_stdout: If True, return only stdout string; if False, return dict
|
|
925
|
+
echo_output: If True, print stdout in the 2nd log line; if False, only print exit_code/prompt
|
|
926
|
+
|
|
927
|
+
Returns:
|
|
928
|
+
str (when only_stdout=True): stdout content
|
|
929
|
+
dict (when only_stdout=False): {'success': bool, 'stdout': str, 'stderr': str}
|
|
930
|
+
"""
|
|
931
|
+
return self.sendcmd(
|
|
932
|
+
cmd,
|
|
933
|
+
timeout=timeout,
|
|
934
|
+
cwd=cwd,
|
|
935
|
+
ignore_err=ignore_err,
|
|
936
|
+
only_stdout=only_stdout,
|
|
937
|
+
echo_output=echo_output,
|
|
938
|
+
)
|
|
939
|
+
|
|
822
940
|
def upload_file(self, local_file, remote_file, trans_type="SCP"):
|
|
823
941
|
"""Upload a local file to remote device.
|
|
824
942
|
|
|
@@ -942,7 +1060,8 @@ class Device:
|
|
|
942
1060
|
self._local_client.close()
|
|
943
1061
|
self._set_logged_in(False)
|
|
944
1062
|
logger.info(
|
|
945
|
-
f"
|
|
1063
|
+
f"Disconnected local exec for {self.__str__()}",
|
|
1064
|
+
extra={"op_type": f"disconnect[{_short_name(self.name)}]"},
|
|
946
1065
|
)
|
|
947
1066
|
return True
|
|
948
1067
|
|
|
@@ -953,12 +1072,16 @@ class Device:
|
|
|
953
1072
|
self._thread_local.sshConnect.close()
|
|
954
1073
|
self._set_logged_in(False)
|
|
955
1074
|
|
|
956
|
-
logger.info(
|
|
1075
|
+
logger.info(
|
|
1076
|
+
f"Disconnected from {self.__str__()}",
|
|
1077
|
+
extra={"op_type": f"disconnect[{_short_name(self.name)}]"},
|
|
1078
|
+
)
|
|
957
1079
|
|
|
958
1080
|
return True
|
|
959
1081
|
except Exception as e:
|
|
960
|
-
logger.
|
|
961
|
-
f"
|
|
1082
|
+
logger.error(
|
|
1083
|
+
f"Failed to disconnect device {self.__str__()}: {e}",
|
|
1084
|
+
extra={"op_type": f"disconnect[{_short_name(self.name)}]"},
|
|
962
1085
|
)
|
|
963
1086
|
|
|
964
1087
|
return False
|
{pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/common/log/infra_logger.py
RENAMED
|
@@ -53,39 +53,39 @@ class InfraLogger(logging.Logger):
|
|
|
53
53
|
|
|
54
54
|
def tc_start(self, msg, *args, **kwargs):
|
|
55
55
|
if self.isEnabledFor(FileLogLevel.TC_START):
|
|
56
|
-
self._log(FileLogLevel.TC_START, msg, args, stacklevel=
|
|
56
|
+
self._log(FileLogLevel.TC_START, msg, args, stacklevel=2, **kwargs)
|
|
57
57
|
|
|
58
58
|
def tc_end(self, msg="Test Case End", *args, **kwargs):
|
|
59
59
|
if self.isEnabledFor(FileLogLevel.TC_END):
|
|
60
|
-
self._log(FileLogLevel.TC_END, msg, args, stacklevel=
|
|
60
|
+
self._log(FileLogLevel.TC_END, msg, args, stacklevel=2, **kwargs)
|
|
61
61
|
|
|
62
62
|
def tc_step(self, msg, *args, **kwargs):
|
|
63
63
|
if self.isEnabledFor(FileLogLevel.TC_STEP):
|
|
64
|
-
self._log(FileLogLevel.TC_STEP, msg, args, stacklevel=
|
|
64
|
+
self._log(FileLogLevel.TC_STEP, msg, args, stacklevel=2, **kwargs)
|
|
65
65
|
|
|
66
66
|
def pass_info(self, msg, *args, **kwargs):
|
|
67
67
|
if self.isEnabledFor(FileLogLevel.PASS):
|
|
68
|
-
self._log(FileLogLevel.PASS, msg, args, stacklevel=
|
|
68
|
+
self._log(FileLogLevel.PASS, msg, args, stacklevel=2, **kwargs)
|
|
69
69
|
|
|
70
70
|
def fail(self, msg, *args, **kwargs):
|
|
71
71
|
if self.isEnabledFor(FileLogLevel.FAIL):
|
|
72
|
-
self._log(FileLogLevel.FAIL, msg, args, stacklevel=
|
|
72
|
+
self._log(FileLogLevel.FAIL, msg, args, stacklevel=2, **kwargs)
|
|
73
73
|
|
|
74
74
|
def pre_set(self, msg, *args, **kwargs):
|
|
75
75
|
if self.isEnabledFor(FileLogLevel.PRE_SET):
|
|
76
|
-
self._log(FileLogLevel.PRE_SET, msg, args, stacklevel=
|
|
76
|
+
self._log(FileLogLevel.PRE_SET, msg, args, stacklevel=2, **kwargs)
|
|
77
77
|
|
|
78
78
|
def post_set(self, msg, *args, **kwargs):
|
|
79
79
|
if self.isEnabledFor(FileLogLevel.POST_SET):
|
|
80
|
-
self._log(FileLogLevel.POST_SET, msg, args, stacklevel=
|
|
80
|
+
self._log(FileLogLevel.POST_SET, msg, args, stacklevel=2, **kwargs)
|
|
81
81
|
|
|
82
82
|
def cmd(self, msg, *args, **kwargs):
|
|
83
83
|
if self.isEnabledFor(FileLogLevel.CMD):
|
|
84
|
-
self._log(FileLogLevel.CMD, msg, args, stacklevel=
|
|
84
|
+
self._log(FileLogLevel.CMD, msg, args, stacklevel=2, **kwargs)
|
|
85
85
|
|
|
86
86
|
def cmd_response(self, msg, *args, **kwargs):
|
|
87
87
|
if self.isEnabledFor(FileLogLevel.CMD_RESPONSE):
|
|
88
|
-
self._log(FileLogLevel.CMD_RESPONSE, msg, args, stacklevel=
|
|
88
|
+
self._log(FileLogLevel.CMD_RESPONSE, msg, args, stacklevel=2, **kwargs)
|
|
89
89
|
|
|
90
90
|
def set_header(self, html_content: Optional[str], append: bool = False) -> None:
|
|
91
91
|
thread_id = threading.current_thread().ident
|
|
@@ -3,7 +3,7 @@ import os
|
|
|
3
3
|
import re
|
|
4
4
|
import threading
|
|
5
5
|
import unicodedata
|
|
6
|
-
from datetime import datetime
|
|
6
|
+
from datetime import datetime
|
|
7
7
|
from typing import Dict, Optional
|
|
8
8
|
|
|
9
9
|
from .infra_logger import (
|
|
@@ -77,6 +77,97 @@ class LogFileManager:
|
|
|
77
77
|
del self._handlers[thread_id]
|
|
78
78
|
self.generate_html_report(logs, case_id, status, duration, thread_id)
|
|
79
79
|
|
|
80
|
+
# ANSI 前景色码 -> HTML 颜色
|
|
81
|
+
_ANSI_COLORS = {
|
|
82
|
+
"30": "#000000",
|
|
83
|
+
"31": "#F44336",
|
|
84
|
+
"32": "#2E8B57",
|
|
85
|
+
"33": "#FFC107",
|
|
86
|
+
"34": "#2196F3",
|
|
87
|
+
"35": "#9C27B0",
|
|
88
|
+
"36": "#00BCD4",
|
|
89
|
+
"37": "#9E9E9E",
|
|
90
|
+
"90": "#616161",
|
|
91
|
+
"91": "#FF5252",
|
|
92
|
+
"92": "#69F0AE",
|
|
93
|
+
"93": "#FFD740",
|
|
94
|
+
"94": "#40C4FF",
|
|
95
|
+
"95": "#E040FB",
|
|
96
|
+
"96": "#64FFDA",
|
|
97
|
+
"97": "#FFFFFF",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# 命令回显中标红的常用错误词 (大小写不敏感, 单词边界匹配)
|
|
101
|
+
_ERROR_WORDS = re.compile(
|
|
102
|
+
r"\b(error|errors|failed|fail|fails|failure|fatal|panic|exception|"
|
|
103
|
+
r"traceback|aborted|denied|refused|timeout|timed out|segmentation fault|"
|
|
104
|
+
r"core dumped|critical|unreachable|not found|"
|
|
105
|
+
r"no such file or directory)\b",
|
|
106
|
+
re.IGNORECASE,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def _ansi_to_html(cls, text):
|
|
111
|
+
"""把 ANSI 转义序列转换为带颜色的 HTML span 标签。
|
|
112
|
+
|
|
113
|
+
支持前景色 (30-37, 90-97) 和 reset (0)。
|
|
114
|
+
在 _escape_html 之后调用: ANSI 转义序列本身不含 HTML 特殊字符,
|
|
115
|
+
转义不影响其识别, 而转换产生的 span 标签不会再被处理。
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
text: 包含 ANSI 转义序列的文本
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
str: 带 HTML 颜色标签的文本
|
|
122
|
+
"""
|
|
123
|
+
result = []
|
|
124
|
+
i = 0
|
|
125
|
+
n = len(text)
|
|
126
|
+
while i < n:
|
|
127
|
+
# 找到下一个 ESC[
|
|
128
|
+
idx = text.find("\x1b[", i)
|
|
129
|
+
if idx == -1:
|
|
130
|
+
result.append(text[i:])
|
|
131
|
+
break
|
|
132
|
+
result.append(text[i:idx])
|
|
133
|
+
# 找到序列结束符(字母)
|
|
134
|
+
end = idx + 2
|
|
135
|
+
while end < n and not text[end].isalpha():
|
|
136
|
+
end += 1
|
|
137
|
+
if end >= n:
|
|
138
|
+
result.append(text[idx:])
|
|
139
|
+
break
|
|
140
|
+
seq = text[idx + 2 : end] # 如 "36" 或 "0"
|
|
141
|
+
final = text[end] # 结束字母, 如 "m"
|
|
142
|
+
if final == "m":
|
|
143
|
+
if seq == "0" or seq == "":
|
|
144
|
+
result.append("</span>")
|
|
145
|
+
elif seq in cls._ANSI_COLORS:
|
|
146
|
+
result.append(f'<span style="color:{cls._ANSI_COLORS[seq]}">')
|
|
147
|
+
else:
|
|
148
|
+
# 其他修饰(加粗/背景等), 忽略序列本身
|
|
149
|
+
pass
|
|
150
|
+
# 非 'm' 结束的序列(如光标移动)也直接丢弃
|
|
151
|
+
i = end + 1
|
|
152
|
+
return "".join(result)
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def _highlight_error_words(cls, text):
|
|
156
|
+
"""把回显文本中的常用错误单词标红。
|
|
157
|
+
|
|
158
|
+
在 _escape_html / _ansi_to_html 之后调用, 生成的 span 标签
|
|
159
|
+
不会再被处理。错误词不会出现在已有标签的属性值中, 替换安全。
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
text: HTML 转义后的回显文本
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
str: 错误词标红后的文本
|
|
166
|
+
"""
|
|
167
|
+
return cls._ERROR_WORDS.sub(
|
|
168
|
+
r'<span style="color:#F44336;font-weight:bold">\g<0></span>', text
|
|
169
|
+
)
|
|
170
|
+
|
|
80
171
|
@staticmethod
|
|
81
172
|
def _escape_html(text):
|
|
82
173
|
if not text:
|
|
@@ -122,9 +213,10 @@ class LogFileManager:
|
|
|
122
213
|
text = text.encode("latin-1").decode("utf-8", errors="replace")
|
|
123
214
|
except (UnicodeEncodeError, UnicodeDecodeError):
|
|
124
215
|
logger.debug("latin-1/utf-8 re-decode skipped due to encoding error")
|
|
125
|
-
# 只过滤控制字符,保留中文等合法非 ASCII
|
|
216
|
+
# 只过滤控制字符,保留中文等合法非 ASCII 字符。
|
|
217
|
+
# 保留 ESC(\x1b): 后续 _ansi_to_html 需要识别 ANSI 颜色序列
|
|
126
218
|
text = "".join(
|
|
127
|
-
c for c in text if c in "\n\r\t" or unicodedata.category(c)[0] != "C"
|
|
219
|
+
c for c in text if c in "\n\r\t\x1b" or unicodedata.category(c)[0] != "C"
|
|
128
220
|
)
|
|
129
221
|
try:
|
|
130
222
|
encoded = text.encode("windows-1252")
|
|
@@ -196,13 +288,25 @@ class LogFileManager:
|
|
|
196
288
|
for record in logs:
|
|
197
289
|
formatted_msg = record.getMessage()
|
|
198
290
|
formatted_msg = self._fix_encoding_issues(formatted_msg)
|
|
291
|
+
# 优先使用 op_type (sendcmd/recvcmd/login/loginecho 等操作类型),
|
|
292
|
+
# 否则回退到默认的 文件名:行号
|
|
293
|
+
op_type = getattr(record, "op_type", None)
|
|
294
|
+
# 先 HTML 转义, 再把 ANSI 颜色码转为带颜色的 span 标签
|
|
295
|
+
# (ANSI 转义序列本身不含 HTML 特殊字符, 转义不影响其识别)
|
|
199
296
|
formatted_msg = self._escape_html(formatted_msg)
|
|
297
|
+
formatted_msg = self._ansi_to_html(formatted_msg)
|
|
298
|
+
# 命令回显(recvcmd)中的常用错误单词标红
|
|
299
|
+
if op_type and op_type.startswith("recvcmd"):
|
|
300
|
+
formatted_msg = self._highlight_error_words(formatted_msg)
|
|
200
301
|
time_str = datetime.fromtimestamp(record.created).strftime(
|
|
201
302
|
"%Y-%m-%d %H:%M:%S.%f"
|
|
202
303
|
)[:-3]
|
|
203
304
|
level_str = record.levelname
|
|
204
305
|
thread_str = record.thread
|
|
205
|
-
|
|
306
|
+
if op_type:
|
|
307
|
+
location_str = op_type
|
|
308
|
+
else:
|
|
309
|
+
location_str = f"{record.filename}:{record.lineno}"
|
|
206
310
|
|
|
207
311
|
parsed_logs.append({
|
|
208
312
|
"time": time_str,
|
|
@@ -220,7 +324,7 @@ class LogFileManager:
|
|
|
220
324
|
|
|
221
325
|
case_dir_path = getattr(_local, "case_log_path", None)
|
|
222
326
|
if not case_dir_path:
|
|
223
|
-
now = datetime.now(
|
|
327
|
+
now = datetime.now()
|
|
224
328
|
date_dir = now.strftime("%Y-%m-%d")
|
|
225
329
|
time_dir = now.strftime("%H-%M-%S")
|
|
226
330
|
case_dir_name = f"{safe_name}_{time_dir}"
|
|
@@ -273,9 +377,9 @@ class LogFileManager:
|
|
|
273
377
|
.TRACE {{ color: #757575; }}
|
|
274
378
|
.TC_STEP {{ color: #1976D2; font-weight: bold; }}
|
|
275
379
|
th:nth-child(1), td:nth-child(1) {{ width: 220px; min-width: 150px; }}
|
|
276
|
-
th:nth-child(2), td:nth-child(2) {{ width:
|
|
277
|
-
th:nth-child(3), td:nth-child(3) {{ width:
|
|
278
|
-
th:nth-child(4), td:nth-child(4) {{ width:
|
|
380
|
+
th:nth-child(2), td:nth-child(2) {{ width: 70px; min-width: 60px; }}
|
|
381
|
+
th:nth-child(3), td:nth-child(3) {{ width: 50px; min-width: 40px; }}
|
|
382
|
+
th:nth-child(4), td:nth-child(4) {{ width: 128px; min-width: 112px; }}
|
|
279
383
|
th:nth-child(5), td:nth-child(5) {{ width: auto; }}
|
|
280
384
|
|
|
281
385
|
/* Step row status colors - all cells in the row */
|
|
@@ -459,7 +563,7 @@ class LogManager:
|
|
|
459
563
|
log_dir = os.path.abspath(log_path)
|
|
460
564
|
else:
|
|
461
565
|
log_dir = os.path.join(
|
|
462
|
-
log_path, datetime.now(
|
|
566
|
+
log_path, datetime.now().strftime("%Y-%m-%d-%H_%M_%S")
|
|
463
567
|
)
|
|
464
568
|
log_dir = os.path.abspath(log_dir)
|
|
465
569
|
|
|
@@ -496,7 +600,7 @@ class LogManager:
|
|
|
496
600
|
safe_case_name = re.sub(r"[^\w\-_.]", "_", case_name)
|
|
497
601
|
log_filename = f"{safe_case_name}.log"
|
|
498
602
|
|
|
499
|
-
now = datetime.now(
|
|
603
|
+
now = datetime.now()
|
|
500
604
|
date_dir = now.strftime("%Y-%m-%d")
|
|
501
605
|
time_dir = now.strftime("%H-%M-%S")
|
|
502
606
|
case_dir_name = f"{safe_case_name}_{time_dir}"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/common/environment/__init__.py
RENAMED
|
File without changes
|
{pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/common/environment/allocator.py
RENAMED
|
File without changes
|
{pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/common/environment/manager.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/pre_action/exceptions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_docker_proxy_is_interactive_shell.py
RENAMED
|
File without changes
|
{pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_docker_proxy_non_shell_commands.py
RENAMED
|
File without changes
|
|
File without changes
|
{pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_docker_proxy_sendcmd_interactive.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|