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.
Files changed (59) hide show
  1. {pytest_testkit-1.0.2a2/pytest_testkit.egg-info → pytest_testkit-1.0.2b1}/PKG-INFO +1 -1
  2. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pyproject.toml +1 -1
  3. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/base/ssh.py +117 -24
  4. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/common/environment/device.py +243 -120
  5. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/common/log/infra_logger.py +9 -9
  6. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/common/log/manager.py +114 -10
  7. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1/pytest_testkit.egg-info}/PKG-INFO +1 -1
  8. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/LICENSE +0 -0
  9. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/MANIFEST.in +0 -0
  10. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/README.md +0 -0
  11. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/__init__.py +0 -0
  12. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/__init__.py +0 -0
  13. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/base/__init__.py +0 -0
  14. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/base/excel.py +0 -0
  15. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/base/ftp.py +0 -0
  16. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/base/http.py +0 -0
  17. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/base/local_exec.py +0 -0
  18. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/base/sftp.py +0 -0
  19. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/common/__init__.py +0 -0
  20. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/common/environment/__init__.py +0 -0
  21. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/common/environment/allocator.py +0 -0
  22. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/common/environment/manager.py +0 -0
  23. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/common/log/__init__.py +0 -0
  24. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/common/log/filters.py +0 -0
  25. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/pre_action/__init__.py +0 -0
  26. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/pre_action/action.py +0 -0
  27. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/pre_action/exceptions.py +0 -0
  28. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/lib/pre_action/registry.py +0 -0
  29. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit/plugin.py +0 -0
  30. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit.egg-info/SOURCES.txt +0 -0
  31. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit.egg-info/dependency_links.txt +0 -0
  32. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit.egg-info/entry_points.txt +0 -0
  33. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit.egg-info/requires.txt +0 -0
  34. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/pytest_testkit.egg-info/top_level.txt +0 -0
  35. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/requirements.txt +0 -0
  36. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/setup.cfg +0 -0
  37. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_collect_case_with_inifile.py +0 -0
  38. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_docker_proxy_creation.py +0 -0
  39. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_docker_proxy_exit_docker.py +0 -0
  40. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_docker_proxy_getitem.py +0 -0
  41. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_docker_proxy_integration.py +0 -0
  42. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_docker_proxy_is_interactive_shell.py +0 -0
  43. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_docker_proxy_non_shell_commands.py +0 -0
  44. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_docker_proxy_sendcmd.py +0 -0
  45. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_docker_proxy_sendcmd_interactive.py +0 -0
  46. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_env_nodes_filter.py +0 -0
  47. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_get_inifile_info.py +0 -0
  48. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_get_item_case_info.py +0 -0
  49. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_local_exec.py +0 -0
  50. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_local_interactive.py +0 -0
  51. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_log_factory.py +0 -0
  52. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_log_factory_integration.py +0 -0
  53. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_security_features.py +0 -0
  54. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_ssh_cwd.py +0 -0
  55. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_ssh_file_ops.py +0 -0
  56. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_ssh_ignore_err.py +0 -0
  57. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_ssh_only_stdout.py +0 -0
  58. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_ssh_remove_command_echo.py +0 -0
  59. {pytest_testkit-1.0.2a2 → pytest_testkit-1.0.2b1}/tests/test_ssh_success.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest_testkit
3
- Version: 1.0.2a2
3
+ Version: 1.0.2b1
4
4
  Summary: Pytest plugin for Infra logging framework and environment management
5
5
  Author: Huawei
6
6
  License: MIT License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pytest_testkit"
7
- version = "1.0.2a2"
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 _clean_interactive_output(raw_output: str, cmd: str) -> str:
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(
@@ -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
- Each call starts a new docker exec process (independent of interactive session).
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
- logger.info(f"[Device._login] Logging in to device: {self.name}")
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(f"[Device._login] ssh login failed for {self.__str__()}")
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(f"[Device._login] Successfully connected to {self.__str__()}")
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
- If local_exec is True, executes command locally via subprocess instead of SSH.
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
- 日志策略: 第 1 行打印命令, 第 2 行打印 exit/stdout/stderr.
631
- 失败且 ignore_err=False 时第 2 行升级为 ERROR.
632
- echo_output=False 时不打印 stdout.
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
- env_vars: Environment variables for the command
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 stdout in the 2nd log line; if False, only print exit_code
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
- # 第 1 行: 默认打印执行的命令, 受 echo_output 和 _SESSION_QUIET_STDOUT 控制
651
- if echo_output and not _SESSION_QUIET_STDOUT:
652
- logger.info(f"[Device.sendcmd] $ {LogColor.CYAN}{cmd}{LogColor.RESET}")
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
- logger.debug(f"[Device.sendcmd] local_exec=True, executing locally: {cmd}")
656
- param = LocalCmdParam(
657
- cmd=cmd,
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().cmd(param)
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
- param = SshCmdParam(
670
- cmd=cmd,
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 = self._thread_local.sshConnect.ssh_cmd(param)
767
+ result_dict = ssh_connect.ssh_cmd_interactive(param)
678
768
 
679
- # 第 2 行: 打印 exit / stdout / stderr
680
- self._log_exec_result("sendcmd", result_dict, ignore_err, echo_output, None)
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 _log_exec_result(
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
- expect_prompt=None,
744
- timeout=600,
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 with interactive input.
819
+ """Send command to the device as a one-shot execution (original sendcmd logic).
751
820
 
752
- Automatically logs in if not already logged in in current thread.
753
- Each thread maintains its own independent login session.
754
- If local_exec is True, executes command locally via subprocess instead of SSH.
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
- 日志策略: 第 1 行打印命令, 第 2 行打印 prompt/exit/stdout.
757
- 失败(prompt 未匹配或 exit != 0)且 ignore_err=False 时第 2 行升级为 ERROR.
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
- expect_prompt: Expected prompt to wait for
763
- timeout: Timeout in seconds, supports int or str (e.g., 600 or "600")
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 prompt not matched
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 stdout in the 2nd log line; if False, only print exit_code/prompt
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.sendcmd_interactive] local_exec=True, executing interactively: {cmd}"
850
+ f"[Device.sendcmd_oneshot] local_exec=True, executing locally: {cmd}"
787
851
  )
788
- param = LocalCmdInteractiveParam(
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().cmd_interactive(param)
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 = SshCmdInteractiveParam(
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.ssh_cmd_interactive(param)
873
+ result_dict = self._thread_local.sshConnect.ssh_cmd(param)
810
874
 
811
- # 第 2 行: 打印 prompt / exit / stdout
812
- matched_prompt = result_dict.get("matched_prompt")
813
- self._log_exec_result(
814
- "sendcmd_interactive", result_dict, ignore_err, echo_output, matched_prompt
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"[Device._disconnect] Disconnected local exec for {self.__str__()}"
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(f"[Device._disconnect] Disconnected from {self.__str__()}")
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.info(
961
- f"[Device.disconnect] Failed to disconnect device {self.__str__()}: {e}"
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
@@ -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=4, **kwargs)
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=4, **kwargs)
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=4, **kwargs)
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=4, **kwargs)
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=4, **kwargs)
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=4, **kwargs)
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=4, **kwargs)
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=4, **kwargs)
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=4, **kwargs)
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, timezone
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
- location_str = f"{record.filename}:{record.lineno}"
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(tz=timezone.utc)
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: 90px; min-width: 90px; }}
277
- th:nth-child(3), td:nth-child(3) {{ width: 70px; min-width: 70px; }}
278
- th:nth-child(4), td:nth-child(4) {{ width: 320px; min-width: 280px; }}
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(tz=timezone.utc).strftime("%Y-%m-%d-%H_%M_%S")
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(tz=timezone.utc)
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}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest_testkit
3
- Version: 1.0.2a2
3
+ Version: 1.0.2b1
4
4
  Summary: Pytest plugin for Infra logging framework and environment management
5
5
  Author: Huawei
6
6
  License: MIT License