pytest-testkit 1.0.1__tar.gz → 1.0.2__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.1/pytest_testkit.egg-info → pytest_testkit-1.0.2}/PKG-INFO +43 -21
  2. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/README.md +41 -19
  3. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pyproject.toml +2 -2
  4. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit/lib/base/ssh.py +117 -24
  5. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit/lib/common/environment/device.py +243 -120
  6. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit/lib/common/log/manager.py +114 -10
  7. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2/pytest_testkit.egg-info}/PKG-INFO +43 -21
  8. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit.egg-info/requires.txt +1 -1
  9. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/requirements.txt +1 -1
  10. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/LICENSE +0 -0
  11. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/MANIFEST.in +0 -0
  12. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit/__init__.py +0 -0
  13. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit/lib/__init__.py +0 -0
  14. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit/lib/base/__init__.py +0 -0
  15. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit/lib/base/excel.py +0 -0
  16. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit/lib/base/ftp.py +0 -0
  17. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit/lib/base/http.py +0 -0
  18. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit/lib/base/local_exec.py +0 -0
  19. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit/lib/base/sftp.py +0 -0
  20. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit/lib/common/__init__.py +0 -0
  21. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit/lib/common/environment/__init__.py +0 -0
  22. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit/lib/common/environment/allocator.py +0 -0
  23. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit/lib/common/environment/manager.py +0 -0
  24. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit/lib/common/log/__init__.py +0 -0
  25. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit/lib/common/log/filters.py +0 -0
  26. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit/lib/common/log/infra_logger.py +0 -0
  27. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit/lib/pre_action/__init__.py +0 -0
  28. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit/lib/pre_action/action.py +0 -0
  29. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit/lib/pre_action/exceptions.py +0 -0
  30. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit/lib/pre_action/registry.py +0 -0
  31. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit/plugin.py +0 -0
  32. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit.egg-info/SOURCES.txt +0 -0
  33. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit.egg-info/dependency_links.txt +0 -0
  34. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit.egg-info/entry_points.txt +0 -0
  35. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/pytest_testkit.egg-info/top_level.txt +0 -0
  36. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/setup.cfg +0 -0
  37. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/tests/test_collect_case_with_inifile.py +0 -0
  38. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/tests/test_docker_proxy_creation.py +0 -0
  39. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/tests/test_docker_proxy_exit_docker.py +0 -0
  40. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/tests/test_docker_proxy_getitem.py +0 -0
  41. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/tests/test_docker_proxy_integration.py +0 -0
  42. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/tests/test_docker_proxy_is_interactive_shell.py +0 -0
  43. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/tests/test_docker_proxy_non_shell_commands.py +0 -0
  44. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/tests/test_docker_proxy_sendcmd.py +0 -0
  45. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/tests/test_docker_proxy_sendcmd_interactive.py +0 -0
  46. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/tests/test_env_nodes_filter.py +0 -0
  47. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/tests/test_get_inifile_info.py +0 -0
  48. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/tests/test_get_item_case_info.py +0 -0
  49. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/tests/test_local_exec.py +0 -0
  50. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/tests/test_local_interactive.py +0 -0
  51. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/tests/test_log_factory.py +0 -0
  52. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/tests/test_log_factory_integration.py +0 -0
  53. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/tests/test_security_features.py +0 -0
  54. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/tests/test_ssh_cwd.py +0 -0
  55. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/tests/test_ssh_file_ops.py +0 -0
  56. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/tests/test_ssh_ignore_err.py +0 -0
  57. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/tests/test_ssh_only_stdout.py +0 -0
  58. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/tests/test_ssh_remove_command_echo.py +0 -0
  59. {pytest_testkit-1.0.1 → pytest_testkit-1.0.2}/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.1
3
+ Version: 1.0.2
4
4
  Summary: Pytest plugin for Infra logging framework and environment management
5
5
  Author: Huawei
6
6
  License: MIT License
@@ -43,7 +43,7 @@ Classifier: Operating System :: OS Independent
43
43
  Requires-Python: >=3.6
44
44
  Description-Content-Type: text/markdown
45
45
  License-File: LICENSE
46
- Requires-Dist: pytest==8.3.2
46
+ Requires-Dist: pytest>=8.3.2
47
47
  Requires-Dist: requests
48
48
  Requires-Dist: paramiko
49
49
  Requires-Dist: xlrd>=2.0.0
@@ -135,7 +135,7 @@ from pytest_testkit.lib.common.log import logger
135
135
  使用 logger 记录日志:
136
136
 
137
137
  ```python
138
- logger.info(f"[Device._login] Successfully connected to device")
138
+ logger.info("Successfully connected to device")
139
139
  ```
140
140
 
141
141
  #### 日志目录结构
@@ -152,12 +152,28 @@ logs/
152
152
 
153
153
  **目录结构说明**:
154
154
  - **TestCases**: 测试用例日志根目录
155
- - **日期目录**: 按执行日期组织(格式:`YYYY-MM-DD`)
156
- - **用例子目录**: 按用例名和执行时间命名(格式:`用例名_HH-MM-SS`)
155
+ - **日期目录**: 按执行日期组织(格式:`YYYY-MM-DD`,使用本机时区)
156
+ - **用例子目录**: 按用例名和执行时间命名(格式:`用例名_HH-MM-SS`,使用本机时区)
157
157
  - **HTML 文件**: 插件生成的测试报告,文件名与用例名一致
158
158
 
159
159
  通过 `logger.path` 属性可以获取当前用例的日志归档目录路径,方便在测试用例中创建自定义文件。
160
160
 
161
+ #### HTML 报告渲染
162
+
163
+ 测试用例的 HTML 日志报告支持以下渲染特性:
164
+
165
+ - **op_type 标签**: 命令与连接日志通过 `logging` 的 `extra={"op_type": ...}` 标记操作类型,HTML 报告 Location 列显示该标签(未标记记录回退为 `文件名:行号`)。内置标签包括:
166
+ - `sendcmd[<设备短名>]`: 发送命令,消息带最近 shell 提示符前缀(如 `root@host:~#ls -la`),设置 `cwd` 时追加 `(cwd: ...)`
167
+ - `recvcmd[<设备短名>]`: 命令文本回显(stdout+stderr 合并,不打印 dict),末尾带最新提示符;无输出时显示 `(empty)`
168
+ - `login[<设备短名>]` / `loginresult[<设备短名>]`: 登录开始与结果(登录失败为 ERROR 级别)
169
+ - `loginecho[<设备短名>]`: 登录 banner(交互 channel 首次创建时吸收,在第一条 sendcmd 之前打印)
170
+ - `disconnect[<设备短名>]`: 连接关闭
171
+ - `<设备短名>` 取设备名最后一段(按 `-` 或 `/` 分割),如 `rack1/node_3` → `node_3`
172
+ - **ANSI 颜色渲染**: 报告自动把 ANSI 前景色码(30-37、90-97)转换为带颜色的 `<span>` 标签。例如命令日志中的蓝色码(`\x1b[34m`)渲染为蓝色(`#2196F3`)
173
+ - **错误词高亮**: `recvcmd` 回显中的常见错误词(error、failed、fatal、panic、traceback、timeout、"No such file or directory" 等)会以粗体红色显示,便于快速定位失败。命令行(sendcmd)与普通日志不高亮,避免误伤命令本身
174
+ - **列宽调整**: Level 列 70px、Thread 列 50px、Location 列 128px,Message 列获得更多空间
175
+ - **本地时间**: 会话目录、日期目录、用例子目录均使用本机时区时间,与报告表内日志时间戳一致
176
+
161
177
  日志级别:
162
178
 
163
179
  | 级别 | 值 | 方法 | 说明 |
@@ -411,9 +427,9 @@ def test_environment(logger):
411
427
 
412
428
  ### quiet_stdout 功能
413
429
 
414
- pytest-testkit 插件支持全局静默 `sendcmd` / `sendcmd_interactive` 的 stdout 输出,避免大量回显淹没日志。
415
- **开启后,stdout 内容不会出现在日志中,即使调用方显式传 `echo_output=True` 也会被全局压制。**
416
- exit_code、prompt、stderr 等信息不受影响。
430
+ pytest-testkit 插件支持全局静默 `sendcmd` / `sendcmd_oneshot` / `sendcmd_interactive` 的命令与回显日志,避免大量回显淹没日志。
431
+ **开启后,sendcmd / recvcmd 日志都不会打印,即使调用方显式传 `echo_output=True` 也会被全局压制。**
432
+ 命令返回值(stdout / stderr / exit_code 等)不受影响,仅影响日志输出。
417
433
 
418
434
  **支持CLI 参数 和 ini 配置,默认为禁用**
419
435
 
@@ -528,23 +544,27 @@ def test_use_ci_metadata():
528
544
 
529
545
  - **environment/environments**: 提供单设备/多设备对象
530
546
 
531
- - **sendcmd(cmd, timeout=None, env_vars=None, cwd=None, ignore_err=False, only_stdout=True)**:执行命令(非交互式)
547
+ - **sendcmd(cmd, timeout=None, env_vars=None, cwd=None, ignore_err=False, only_stdout=True, echo_output=True)**:在持久化交互 shell channel 上执行命令
532
548
  - `cmd`: 要执行的命令字符串
533
- - `timeout`: 命令执行超时时间(秒)
534
- - `env_vars`: 环境变量字典
549
+ - `timeout`: 命令执行超时时间(秒),支持 int 或 str,默认 600s
550
+ - `env_vars`: 环境变量字典,内联为单命令前缀(`VAR=value cmd`),仅对本次命令生效,不污染持久化 shell
535
551
  - `cwd`: 工作目录,命令将在该目录下执行(可选)
536
552
  - `ignore_err`: 是否忽略命令执行错误,默认 False。设为 True 时即使命令失败 success 也为 True
537
553
  - `only_stdout`: 为 True 时只返回 stdout 字符串,为 False 时返回完整字典
554
+ - `echo_output`: 是否打印 sendcmd/recvcmd 日志,默认 True
555
+ - 行为说明: 同一线程内 shell 状态(`cd`、`export`)跨调用持久化;PTY 合并 stderr 到 stdout,返回 dict 的 `stderr` 通常为空;退出码通过 `$?` 哨兵尽力捕获。需要独立进程执行时请用 `sendcmd_oneshot`
538
556
  - 返回值: 默认返回 stdout 字符串;当 `only_stdout=False` 时返回 `{'success': bool, 'stdout': str, 'stderr': str}`
539
557
 
540
- - **sendcmd_interactive(cmd, expect_prompt=None, timeout=600, cwd=None, ignore_err=False, only_stdout=True)**:执行命令(交互式)
541
- - `cmd`: 要执行的命令字符串
542
- - `expect_prompt`: 期望匹配的提示符正则表达式(可选)
543
- - `timeout`: 命令执行超时时间(秒)。默认值600s
544
- - `cwd`: 工作目录,命令将在该目录下执行(可选)
545
- - `ignore_err`: 是否忽略提示符匹配失败,默认 False。设为 True 时即使未匹配到提示符 success 也为 True
546
- - `only_stdout`: 为 True 时只返回 stdout 字符串,为 False 时返回完整字典
547
- - 返回值: 默认返回 stdout 字符串;当 `only_stdout=False` 时返回 `{'success': bool, 'stdout': str, 'stderr': str}`
558
+ - **sendcmd_oneshot(cmd, timeout=None, env_vars=None, cwd=None, ignore_err=False, only_stdout=True, echo_output=True)**:独立进程执行命令(原 sendcmd 行为)
559
+ - 每次调用启动独立的 `exec_command` / `subprocess.run` 进程,无持久 shell 状态,与交互 channel 互相独立
560
+ - SSH 路径: 以 `bash -l -c` 包装执行,原生环境变量支持,退出码为 channel 权威退出状态,stderr 为独立流
561
+ - `local_exec` 路径: 通过 `subprocess.run` 执行
562
+ - 参数与返回值与 `sendcmd` 一致(`env_vars` 通过原生环境注入而非内联前缀)
563
+
564
+ - **sendcmd_interactive(cmd, expect_prompt=None, timeout=600, cwd=None, ignore_err=False, only_stdout=True)**:等同于 `sendcmd`(保留向后兼容)
565
+ - 现直接代理给 `sendcmd`,在持久化交互 shell channel 上执行
566
+ - `expect_prompt`: 保留签名兼容但不再生效(被忽略)
567
+ - 参数与返回值与 `sendcmd` 一致
548
568
  - **输出清理**: 返回的 stdout 会自动清理以下内容,便于用户直接使用:
549
569
  - CSI 控制序列(如 `\x1b[32m` 颜色代码、`\x1b[?2004h` bracketed paste mode 等)
550
570
  - OSC 控制序列(如 `\x1b]0;title\x07` 窗口标题设置等)
@@ -608,8 +628,10 @@ def test_use_ci_metadata():
608
628
  - **DockerProxy 容器代理对象**:通过 `environment["container_name"]` 获取
609
629
  - 代理对象提供与 Device 一致的接口,自动包装 `docker exec` 命令
610
630
 
611
- - **sendcmd(cmd, timeout=None, ignore_err=False, only_stdout=True)**:非交互式命令
612
- - 每次独立执行 `docker exec {docker_name} {cmd}`
631
+ - **sendcmd(cmd, timeout=None, ignore_err=False, only_stdout=True)**:非交互式命令(docker session 感知)
632
+ - 已在该容器交互式 session 中: 直接在当前 session 执行命令
633
+ - 其他容器持有交互式 session: 直接返回失败(提示"请退出上一个交互对话")
634
+ - 否则: 包装为 `docker exec {docker_name} {cmd}` 执行(不创建持久 session)
613
635
  - 返回值: stdout 字符串或完整字典
614
636
 
615
637
  - **sendcmd_interactive(cmd, expect_prompt=None, timeout=600, cwd=None, ignore_err=False, only_stdout=True)**:交互式命令
@@ -77,7 +77,7 @@ from pytest_testkit.lib.common.log import logger
77
77
  使用 logger 记录日志:
78
78
 
79
79
  ```python
80
- logger.info(f"[Device._login] Successfully connected to device")
80
+ logger.info("Successfully connected to device")
81
81
  ```
82
82
 
83
83
  #### 日志目录结构
@@ -94,12 +94,28 @@ logs/
94
94
 
95
95
  **目录结构说明**:
96
96
  - **TestCases**: 测试用例日志根目录
97
- - **日期目录**: 按执行日期组织(格式:`YYYY-MM-DD`)
98
- - **用例子目录**: 按用例名和执行时间命名(格式:`用例名_HH-MM-SS`)
97
+ - **日期目录**: 按执行日期组织(格式:`YYYY-MM-DD`,使用本机时区)
98
+ - **用例子目录**: 按用例名和执行时间命名(格式:`用例名_HH-MM-SS`,使用本机时区)
99
99
  - **HTML 文件**: 插件生成的测试报告,文件名与用例名一致
100
100
 
101
101
  通过 `logger.path` 属性可以获取当前用例的日志归档目录路径,方便在测试用例中创建自定义文件。
102
102
 
103
+ #### HTML 报告渲染
104
+
105
+ 测试用例的 HTML 日志报告支持以下渲染特性:
106
+
107
+ - **op_type 标签**: 命令与连接日志通过 `logging` 的 `extra={"op_type": ...}` 标记操作类型,HTML 报告 Location 列显示该标签(未标记记录回退为 `文件名:行号`)。内置标签包括:
108
+ - `sendcmd[<设备短名>]`: 发送命令,消息带最近 shell 提示符前缀(如 `root@host:~#ls -la`),设置 `cwd` 时追加 `(cwd: ...)`
109
+ - `recvcmd[<设备短名>]`: 命令文本回显(stdout+stderr 合并,不打印 dict),末尾带最新提示符;无输出时显示 `(empty)`
110
+ - `login[<设备短名>]` / `loginresult[<设备短名>]`: 登录开始与结果(登录失败为 ERROR 级别)
111
+ - `loginecho[<设备短名>]`: 登录 banner(交互 channel 首次创建时吸收,在第一条 sendcmd 之前打印)
112
+ - `disconnect[<设备短名>]`: 连接关闭
113
+ - `<设备短名>` 取设备名最后一段(按 `-` 或 `/` 分割),如 `rack1/node_3` → `node_3`
114
+ - **ANSI 颜色渲染**: 报告自动把 ANSI 前景色码(30-37、90-97)转换为带颜色的 `<span>` 标签。例如命令日志中的蓝色码(`\x1b[34m`)渲染为蓝色(`#2196F3`)
115
+ - **错误词高亮**: `recvcmd` 回显中的常见错误词(error、failed、fatal、panic、traceback、timeout、"No such file or directory" 等)会以粗体红色显示,便于快速定位失败。命令行(sendcmd)与普通日志不高亮,避免误伤命令本身
116
+ - **列宽调整**: Level 列 70px、Thread 列 50px、Location 列 128px,Message 列获得更多空间
117
+ - **本地时间**: 会话目录、日期目录、用例子目录均使用本机时区时间,与报告表内日志时间戳一致
118
+
103
119
  日志级别:
104
120
 
105
121
  | 级别 | 值 | 方法 | 说明 |
@@ -353,9 +369,9 @@ def test_environment(logger):
353
369
 
354
370
  ### quiet_stdout 功能
355
371
 
356
- pytest-testkit 插件支持全局静默 `sendcmd` / `sendcmd_interactive` 的 stdout 输出,避免大量回显淹没日志。
357
- **开启后,stdout 内容不会出现在日志中,即使调用方显式传 `echo_output=True` 也会被全局压制。**
358
- exit_code、prompt、stderr 等信息不受影响。
372
+ pytest-testkit 插件支持全局静默 `sendcmd` / `sendcmd_oneshot` / `sendcmd_interactive` 的命令与回显日志,避免大量回显淹没日志。
373
+ **开启后,sendcmd / recvcmd 日志都不会打印,即使调用方显式传 `echo_output=True` 也会被全局压制。**
374
+ 命令返回值(stdout / stderr / exit_code 等)不受影响,仅影响日志输出。
359
375
 
360
376
  **支持CLI 参数 和 ini 配置,默认为禁用**
361
377
 
@@ -470,23 +486,27 @@ def test_use_ci_metadata():
470
486
 
471
487
  - **environment/environments**: 提供单设备/多设备对象
472
488
 
473
- - **sendcmd(cmd, timeout=None, env_vars=None, cwd=None, ignore_err=False, only_stdout=True)**:执行命令(非交互式)
489
+ - **sendcmd(cmd, timeout=None, env_vars=None, cwd=None, ignore_err=False, only_stdout=True, echo_output=True)**:在持久化交互 shell channel 上执行命令
474
490
  - `cmd`: 要执行的命令字符串
475
- - `timeout`: 命令执行超时时间(秒)
476
- - `env_vars`: 环境变量字典
491
+ - `timeout`: 命令执行超时时间(秒),支持 int 或 str,默认 600s
492
+ - `env_vars`: 环境变量字典,内联为单命令前缀(`VAR=value cmd`),仅对本次命令生效,不污染持久化 shell
477
493
  - `cwd`: 工作目录,命令将在该目录下执行(可选)
478
494
  - `ignore_err`: 是否忽略命令执行错误,默认 False。设为 True 时即使命令失败 success 也为 True
479
495
  - `only_stdout`: 为 True 时只返回 stdout 字符串,为 False 时返回完整字典
496
+ - `echo_output`: 是否打印 sendcmd/recvcmd 日志,默认 True
497
+ - 行为说明: 同一线程内 shell 状态(`cd`、`export`)跨调用持久化;PTY 合并 stderr 到 stdout,返回 dict 的 `stderr` 通常为空;退出码通过 `$?` 哨兵尽力捕获。需要独立进程执行时请用 `sendcmd_oneshot`
480
498
  - 返回值: 默认返回 stdout 字符串;当 `only_stdout=False` 时返回 `{'success': bool, 'stdout': str, 'stderr': str}`
481
499
 
482
- - **sendcmd_interactive(cmd, expect_prompt=None, timeout=600, cwd=None, ignore_err=False, only_stdout=True)**:执行命令(交互式)
483
- - `cmd`: 要执行的命令字符串
484
- - `expect_prompt`: 期望匹配的提示符正则表达式(可选)
485
- - `timeout`: 命令执行超时时间(秒)。默认值600s
486
- - `cwd`: 工作目录,命令将在该目录下执行(可选)
487
- - `ignore_err`: 是否忽略提示符匹配失败,默认 False。设为 True 时即使未匹配到提示符 success 也为 True
488
- - `only_stdout`: 为 True 时只返回 stdout 字符串,为 False 时返回完整字典
489
- - 返回值: 默认返回 stdout 字符串;当 `only_stdout=False` 时返回 `{'success': bool, 'stdout': str, 'stderr': str}`
500
+ - **sendcmd_oneshot(cmd, timeout=None, env_vars=None, cwd=None, ignore_err=False, only_stdout=True, echo_output=True)**:独立进程执行命令(原 sendcmd 行为)
501
+ - 每次调用启动独立的 `exec_command` / `subprocess.run` 进程,无持久 shell 状态,与交互 channel 互相独立
502
+ - SSH 路径: 以 `bash -l -c` 包装执行,原生环境变量支持,退出码为 channel 权威退出状态,stderr 为独立流
503
+ - `local_exec` 路径: 通过 `subprocess.run` 执行
504
+ - 参数与返回值与 `sendcmd` 一致(`env_vars` 通过原生环境注入而非内联前缀)
505
+
506
+ - **sendcmd_interactive(cmd, expect_prompt=None, timeout=600, cwd=None, ignore_err=False, only_stdout=True)**:等同于 `sendcmd`(保留向后兼容)
507
+ - 现直接代理给 `sendcmd`,在持久化交互 shell channel 上执行
508
+ - `expect_prompt`: 保留签名兼容但不再生效(被忽略)
509
+ - 参数与返回值与 `sendcmd` 一致
490
510
  - **输出清理**: 返回的 stdout 会自动清理以下内容,便于用户直接使用:
491
511
  - CSI 控制序列(如 `\x1b[32m` 颜色代码、`\x1b[?2004h` bracketed paste mode 等)
492
512
  - OSC 控制序列(如 `\x1b]0;title\x07` 窗口标题设置等)
@@ -550,8 +570,10 @@ def test_use_ci_metadata():
550
570
  - **DockerProxy 容器代理对象**:通过 `environment["container_name"]` 获取
551
571
  - 代理对象提供与 Device 一致的接口,自动包装 `docker exec` 命令
552
572
 
553
- - **sendcmd(cmd, timeout=None, ignore_err=False, only_stdout=True)**:非交互式命令
554
- - 每次独立执行 `docker exec {docker_name} {cmd}`
573
+ - **sendcmd(cmd, timeout=None, ignore_err=False, only_stdout=True)**:非交互式命令(docker session 感知)
574
+ - 已在该容器交互式 session 中: 直接在当前 session 执行命令
575
+ - 其他容器持有交互式 session: 直接返回失败(提示"请退出上一个交互对话")
576
+ - 否则: 包装为 `docker exec {docker_name} {cmd}` 执行(不创建持久 session)
555
577
  - 返回值: stdout 字符串或完整字典
556
578
 
557
579
  - **sendcmd_interactive(cmd, expect_prompt=None, timeout=600, cwd=None, ignore_err=False, only_stdout=True)**:交互式命令
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pytest_testkit"
7
- version = "1.0.1"
7
+ version = "1.0.2"
8
8
  description = "Pytest plugin for Infra logging framework and environment management"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -30,7 +30,7 @@ classifiers = [
30
30
  ]
31
31
  keywords = ["pytest", "plugin", "logging", "html", "infra", "test", "automation"]
32
32
  dependencies = [
33
- "pytest==8.3.2",
33
+ "pytest>=8.3.2",
34
34
  "requests",
35
35
  "paramiko",
36
36
  "xlrd>=2.0.0",
@@ -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(