pytest-testkit 1.0.0__tar.gz → 1.0.1__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.0/pytest_testkit.egg-info → pytest_testkit-1.0.1}/PKG-INFO +44 -1
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/README.md +43 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pyproject.toml +1 -1
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/base/ssh.py +5 -5
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/common/environment/device.py +13 -8
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/common/log/manager.py +11 -7
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/plugin.py +186 -63
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1/pytest_testkit.egg-info}/PKG-INFO +44 -1
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit.egg-info/SOURCES.txt +1 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/requirements.txt +1 -0
- pytest_testkit-1.0.1/tests/test_env_nodes_filter.py +349 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/LICENSE +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/MANIFEST.in +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/__init__.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/__init__.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/base/__init__.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/base/excel.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/base/ftp.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/base/http.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/base/local_exec.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/base/sftp.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/common/__init__.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/common/environment/__init__.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/common/environment/allocator.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/common/environment/manager.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/common/log/__init__.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/common/log/filters.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/common/log/infra_logger.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/pre_action/__init__.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/pre_action/action.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/pre_action/exceptions.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/pre_action/registry.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit.egg-info/dependency_links.txt +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit.egg-info/entry_points.txt +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit.egg-info/requires.txt +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit.egg-info/top_level.txt +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/setup.cfg +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_collect_case_with_inifile.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_docker_proxy_creation.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_docker_proxy_exit_docker.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_docker_proxy_getitem.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_docker_proxy_integration.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_docker_proxy_is_interactive_shell.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_docker_proxy_non_shell_commands.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_docker_proxy_sendcmd.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_docker_proxy_sendcmd_interactive.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_get_inifile_info.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_get_item_case_info.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_local_exec.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_local_interactive.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_log_factory.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_log_factory_integration.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_security_features.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_ssh_cwd.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_ssh_file_ops.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_ssh_ignore_err.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_ssh_only_stdout.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_ssh_remove_command_echo.py +0 -0
- {pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/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.
|
|
3
|
+
Version: 1.0.1
|
|
4
4
|
Summary: Pytest plugin for Infra logging framework and environment management
|
|
5
5
|
Author: Huawei
|
|
6
6
|
License: MIT License
|
|
@@ -430,6 +430,49 @@ pytest --quiet-stdout --testbed=testbed.json testcase/
|
|
|
430
430
|
quiet_stdout = true
|
|
431
431
|
```
|
|
432
432
|
|
|
433
|
+
### CI 运行元数据(ci_env_info)
|
|
434
|
+
|
|
435
|
+
执行器(pytest-executor)在调度用例时,会把 CI 流水线的运行元数据序列化成**单个**
|
|
436
|
+
`ci_env_info` 环境变量注入用例进程,覆盖 runner / remote / local 三种执行模式。
|
|
437
|
+
用例侧统一通过读取该环境变量获取当前流水线上下文,无需关心运行在哪种模式。
|
|
438
|
+
|
|
439
|
+
#### 字段说明
|
|
440
|
+
|
|
441
|
+
环境变量 `ci_env_info` 的值是一个 JSON 字符串,固定包含以下白名单字段(值均为字符串):
|
|
442
|
+
|
|
443
|
+
| 字段名 | 含义 | 示例 |
|
|
444
|
+
|:-------------|:-----------------|:------------------|
|
|
445
|
+
| project_name | CI 项目名称 | "openlibing/demo" |
|
|
446
|
+
| workflow_name| 工作流名称 | "nightly" |
|
|
447
|
+
| job_name | 任务(job)名称 | "run-pytest" |
|
|
448
|
+
| run_number | 流水线运行序号 | "128" |
|
|
449
|
+
| step_name | 当前步骤名称 | "pytest-orch" |
|
|
450
|
+
|
|
451
|
+
#### 读取方式
|
|
452
|
+
|
|
453
|
+
```python
|
|
454
|
+
import json
|
|
455
|
+
import os
|
|
456
|
+
|
|
457
|
+
def test_use_ci_metadata():
|
|
458
|
+
raw = os.environ.get("ci_env_info", "")
|
|
459
|
+
info = json.loads(raw) if raw else {}
|
|
460
|
+
job_name = info.get("job_name", "")
|
|
461
|
+
run_number = info.get("run_number", "")
|
|
462
|
+
# 例如按流水线序号归档产物、按 job 名分支断言逻辑
|
|
463
|
+
assert isinstance(job_name, str)
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
#### 取值约定
|
|
467
|
+
|
|
468
|
+
- **白名单字段恒在**:无论入参是否提供,解析后的 JSON 一定包含上表 5 个字段,缺省值为 `""`。
|
|
469
|
+
- **值经过清洗**:单个字段值会剔除 NUL、换行等控制字符,并按 128 字符截断;
|
|
470
|
+
非字符串标量(如数字)转为字符串,非标量(字典/列表)降级为 `""`。
|
|
471
|
+
- **非法输入不阻断用例**:入参结构不合法、缺失或类型错误时一律降级为空串并告警,
|
|
472
|
+
不会中断用例调度;未注入时 `ci_env_info` 为 `""`。
|
|
473
|
+
- **纯 ASCII 安全**:JSON 以 `ensure_ascii` 序列化,含中文等非 ASCII 值时以 `\uXXXX`
|
|
474
|
+
转义,避免经 SSH 传输后乱码。
|
|
475
|
+
- 该变量由执行器注入,本地直接 `pytest` 调试时通常为空,用例代码应对空值做好兜底。
|
|
433
476
|
|
|
434
477
|
### 支持的 fixture
|
|
435
478
|
|
|
@@ -372,6 +372,49 @@ pytest --quiet-stdout --testbed=testbed.json testcase/
|
|
|
372
372
|
quiet_stdout = true
|
|
373
373
|
```
|
|
374
374
|
|
|
375
|
+
### CI 运行元数据(ci_env_info)
|
|
376
|
+
|
|
377
|
+
执行器(pytest-executor)在调度用例时,会把 CI 流水线的运行元数据序列化成**单个**
|
|
378
|
+
`ci_env_info` 环境变量注入用例进程,覆盖 runner / remote / local 三种执行模式。
|
|
379
|
+
用例侧统一通过读取该环境变量获取当前流水线上下文,无需关心运行在哪种模式。
|
|
380
|
+
|
|
381
|
+
#### 字段说明
|
|
382
|
+
|
|
383
|
+
环境变量 `ci_env_info` 的值是一个 JSON 字符串,固定包含以下白名单字段(值均为字符串):
|
|
384
|
+
|
|
385
|
+
| 字段名 | 含义 | 示例 |
|
|
386
|
+
|:-------------|:-----------------|:------------------|
|
|
387
|
+
| project_name | CI 项目名称 | "openlibing/demo" |
|
|
388
|
+
| workflow_name| 工作流名称 | "nightly" |
|
|
389
|
+
| job_name | 任务(job)名称 | "run-pytest" |
|
|
390
|
+
| run_number | 流水线运行序号 | "128" |
|
|
391
|
+
| step_name | 当前步骤名称 | "pytest-orch" |
|
|
392
|
+
|
|
393
|
+
#### 读取方式
|
|
394
|
+
|
|
395
|
+
```python
|
|
396
|
+
import json
|
|
397
|
+
import os
|
|
398
|
+
|
|
399
|
+
def test_use_ci_metadata():
|
|
400
|
+
raw = os.environ.get("ci_env_info", "")
|
|
401
|
+
info = json.loads(raw) if raw else {}
|
|
402
|
+
job_name = info.get("job_name", "")
|
|
403
|
+
run_number = info.get("run_number", "")
|
|
404
|
+
# 例如按流水线序号归档产物、按 job 名分支断言逻辑
|
|
405
|
+
assert isinstance(job_name, str)
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
#### 取值约定
|
|
409
|
+
|
|
410
|
+
- **白名单字段恒在**:无论入参是否提供,解析后的 JSON 一定包含上表 5 个字段,缺省值为 `""`。
|
|
411
|
+
- **值经过清洗**:单个字段值会剔除 NUL、换行等控制字符,并按 128 字符截断;
|
|
412
|
+
非字符串标量(如数字)转为字符串,非标量(字典/列表)降级为 `""`。
|
|
413
|
+
- **非法输入不阻断用例**:入参结构不合法、缺失或类型错误时一律降级为空串并告警,
|
|
414
|
+
不会中断用例调度;未注入时 `ci_env_info` 为 `""`。
|
|
415
|
+
- **纯 ASCII 安全**:JSON 以 `ensure_ascii` 序列化,含中文等非 ASCII 值时以 `\uXXXX`
|
|
416
|
+
转义,避免经 SSH 传输后乱码。
|
|
417
|
+
- 该变量由执行器注入,本地直接 `pytest` 调试时通常为空,用例代码应对空值做好兜底。
|
|
375
418
|
|
|
376
419
|
### 支持的 fixture
|
|
377
420
|
|
|
@@ -245,8 +245,8 @@ class Ssh:
|
|
|
245
245
|
stdin, stdout, stderr = self.target_client.exec_command(
|
|
246
246
|
cmd_prefix, timeout=param.timeout, environment=param.environment
|
|
247
247
|
)
|
|
248
|
-
stdout_content = stdout.read().decode("utf-8").strip()
|
|
249
|
-
stderr_content = stderr.read().decode("utf-8").strip()
|
|
248
|
+
stdout_content = stdout.read().decode("utf-8", errors="replace").strip()
|
|
249
|
+
stderr_content = stderr.read().decode("utf-8", errors="replace").strip()
|
|
250
250
|
exit_code = stdout.channel.recv_exit_status()
|
|
251
251
|
self.logger.debug(
|
|
252
252
|
f"[Ssh._jump_exec_command] executed: {cmd}, exit_code={exit_code}"
|
|
@@ -394,7 +394,7 @@ class Ssh:
|
|
|
394
394
|
while (
|
|
395
395
|
channel.recv_ready() and recv_iterations < max_recv_iterations
|
|
396
396
|
):
|
|
397
|
-
chunk = channel.recv(4096).decode("utf-8")
|
|
397
|
+
chunk = channel.recv(4096).decode("utf-8", errors="replace")
|
|
398
398
|
if not chunk:
|
|
399
399
|
break # recv返回空数据,防止死循环
|
|
400
400
|
echo_output += chunk
|
|
@@ -505,8 +505,8 @@ class Ssh:
|
|
|
505
505
|
stdin, stdout, stderr = self.client.exec_command(
|
|
506
506
|
cmd_prefix, timeout=param.timeout, environment=param.environment
|
|
507
507
|
)
|
|
508
|
-
stdout_content = stdout.read().decode("utf-8").strip()
|
|
509
|
-
stderr_content = stderr.read().decode("utf-8").strip()
|
|
508
|
+
stdout_content = stdout.read().decode("utf-8", errors="replace").strip()
|
|
509
|
+
stderr_content = stderr.read().decode("utf-8", errors="replace").strip()
|
|
510
510
|
exit_code = stdout.channel.recv_exit_status()
|
|
511
511
|
self.logger.debug(
|
|
512
512
|
f"[Ssh._direct_exec_cmd] executed: {cmd}, exit_code={exit_code}"
|
{pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/common/environment/device.py
RENAMED
|
@@ -647,8 +647,9 @@ class Device:
|
|
|
647
647
|
if isinstance(timeout, str):
|
|
648
648
|
timeout = int(timeout)
|
|
649
649
|
|
|
650
|
-
# 第 1 行:
|
|
651
|
-
|
|
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}")
|
|
652
653
|
|
|
653
654
|
if self.local_exec:
|
|
654
655
|
logger.debug(f"[Device.sendcmd] local_exec=True, executing locally: {cmd}")
|
|
@@ -708,14 +709,17 @@ class Device:
|
|
|
708
709
|
# ignore_err=True 时即使底层 success=False 也按成功处理(不打 ERROR)
|
|
709
710
|
is_failure = (not success) and (not ignore_err)
|
|
710
711
|
|
|
712
|
+
# echo_output=False 或 全局静默开关开启时, 任何返回值都不打印
|
|
713
|
+
if not echo_output or _SESSION_QUIET_STDOUT:
|
|
714
|
+
return
|
|
715
|
+
|
|
711
716
|
parts = []
|
|
712
717
|
if matched_prompt is not None:
|
|
713
718
|
parts.append(f"prompt={matched_prompt}")
|
|
714
719
|
if exit_code is not None:
|
|
715
720
|
suffix = " (ignored)" if ignore_err and exit_code != 0 else ""
|
|
716
721
|
parts.append(f"exit={exit_code}{suffix}")
|
|
717
|
-
|
|
718
|
-
if echo_output and stdout and not _SESSION_QUIET_STDOUT:
|
|
722
|
+
if stdout:
|
|
719
723
|
parts.append(f"stdout={stdout}")
|
|
720
724
|
if stderr:
|
|
721
725
|
parts.append(f"stderr={stderr}")
|
|
@@ -769,10 +773,11 @@ class Device:
|
|
|
769
773
|
if isinstance(timeout, str):
|
|
770
774
|
timeout = int(timeout)
|
|
771
775
|
|
|
772
|
-
# 第 1 行:
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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
|
+
)
|
|
776
781
|
|
|
777
782
|
if self.local_exec:
|
|
778
783
|
if not self._get_logged_in():
|
|
@@ -2,6 +2,7 @@ import logging
|
|
|
2
2
|
import os
|
|
3
3
|
import re
|
|
4
4
|
import threading
|
|
5
|
+
import unicodedata
|
|
5
6
|
from datetime import datetime, timezone
|
|
6
7
|
from typing import Dict, Optional
|
|
7
8
|
|
|
@@ -117,16 +118,19 @@ class LogFileManager:
|
|
|
117
118
|
return ""
|
|
118
119
|
try:
|
|
119
120
|
if b"\xc3" in text.encode("utf-8", errors="replace"):
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
)
|
|
121
|
+
# 严格 latin-1 编码:遇到中文(>U+00FF)会抛错被跳过,避免误伤
|
|
122
|
+
text = text.encode("latin-1").decode("utf-8", errors="replace")
|
|
123
123
|
except (UnicodeEncodeError, UnicodeDecodeError):
|
|
124
124
|
logger.debug("latin-1/utf-8 re-decode skipped due to encoding error")
|
|
125
|
-
|
|
125
|
+
# 只过滤控制字符,保留中文等合法非 ASCII 字符
|
|
126
|
+
text = "".join(
|
|
127
|
+
c for c in text if c in "\n\r\t" or unicodedata.category(c)[0] != "C"
|
|
128
|
+
)
|
|
126
129
|
try:
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
)
|
|
130
|
+
encoded = text.encode("windows-1252")
|
|
131
|
+
# 仅当存在高位字节(疑似 mojibake)时才尝试二次还原
|
|
132
|
+
if any(0x80 <= b <= 0xFF for b in encoded):
|
|
133
|
+
text = encoded.decode("utf-8", errors="replace")
|
|
130
134
|
except (UnicodeEncodeError, UnicodeDecodeError):
|
|
131
135
|
logger.debug("windows-1252/utf-8 re-decode skipped due to encoding error")
|
|
132
136
|
return text
|
|
@@ -501,28 +501,28 @@ def _get_inifile_info(config):
|
|
|
501
501
|
cfg = configparser.ConfigParser()
|
|
502
502
|
cfg.read(config.inifile, encoding="utf-8")
|
|
503
503
|
|
|
504
|
+
# Split a raw value into tokens. Accept comma, whitespace, or
|
|
505
|
+
# comma+whitespace as separators, e.g. "a,b", "a b", "a, b", "a , b".
|
|
506
|
+
# Empty tokens are dropped.
|
|
507
|
+
def _split_tokens(raw):
|
|
508
|
+
if not raw:
|
|
509
|
+
return []
|
|
510
|
+
return [tok.strip() for tok in re.split(r"[,\s]+", raw) if tok.strip()]
|
|
511
|
+
|
|
504
512
|
# Read all attributes from [case_info] section
|
|
505
|
-
# Split values
|
|
513
|
+
# Split values to support multiple values per attribute (comma/space separated)
|
|
506
514
|
aim_attrs = {}
|
|
507
515
|
if cfg.has_section("case_info"):
|
|
508
516
|
for key, value in cfg.items("case_info"):
|
|
509
|
-
|
|
510
|
-
values = [v.strip() for v in value.split(",") if v.strip()]
|
|
511
|
-
aim_attrs[key] = values
|
|
517
|
+
aim_attrs[key] = _split_tokens(value)
|
|
512
518
|
|
|
513
519
|
# Read test_ids include/exclude/pre_test
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
include_ids =
|
|
518
|
-
exclude_ids =
|
|
519
|
-
pre_test_ids =
|
|
520
|
-
include_ids.pop("", None)
|
|
521
|
-
exclude_ids.pop("", None)
|
|
522
|
-
pre_test_ids.pop("", None)
|
|
523
|
-
include_ids = {tid.strip(): 1 for tid in include_ids if tid.strip()}
|
|
524
|
-
exclude_ids = {tid.strip(): 1 for tid in exclude_ids if tid.strip()}
|
|
525
|
-
pre_test_ids = {tid.strip(): 1 for tid in pre_test_ids if tid.strip()}
|
|
520
|
+
include_raw = cfg.get("test_ids", "include", fallback="")
|
|
521
|
+
exclude_raw = cfg.get("test_ids", "exclude", fallback="")
|
|
522
|
+
pre_test_raw = cfg.get("pre_test", "pre_test", fallback="")
|
|
523
|
+
include_ids = {tid: 1 for tid in _split_tokens(include_raw)}
|
|
524
|
+
exclude_ids = {tid: 1 for tid in _split_tokens(exclude_raw)}
|
|
525
|
+
pre_test_ids = {tid: 1 for tid in _split_tokens(pre_test_raw)}
|
|
526
526
|
except Exception as e:
|
|
527
527
|
logger.info(f"get inifile err: {e}, return default val")
|
|
528
528
|
return {}, {}, {}, {}
|
|
@@ -576,10 +576,13 @@ def _collect_case_with_inifile(config, items):
|
|
|
576
576
|
"""
|
|
577
577
|
Filter test cases based on pytest.ini configuration.
|
|
578
578
|
|
|
579
|
-
Filtering logic:
|
|
580
|
-
1.
|
|
581
|
-
2. case_info attributes:
|
|
582
|
-
3.
|
|
579
|
+
Filtering logic (priority high -> low):
|
|
580
|
+
1. include_ids: if configured, only keep matching items (ordered by ini) and return
|
|
581
|
+
2. case_info attributes: subset match if configured
|
|
582
|
+
3. exclude_ids: drop matching items from whatever was kept above (or from all items)
|
|
583
|
+
4. Otherwise keep all items
|
|
584
|
+
|
|
585
|
+
All branches finally reorder by pre_test_ids.
|
|
583
586
|
"""
|
|
584
587
|
# Get ini file filtering criteria
|
|
585
588
|
aim_attrs, include_ids, exclude_ids, pre_test_ids = _get_inifile_info(config)
|
|
@@ -657,56 +660,69 @@ def _collect_case_with_inifile(config, items):
|
|
|
657
660
|
_apply_pre_test_order(items, pre_test_ids)
|
|
658
661
|
return
|
|
659
662
|
|
|
660
|
-
if
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
_apply_pre_test_order(items, pre_test_ids)
|
|
670
|
-
return
|
|
663
|
+
# Step 2: filter by case_info attributes if configured (subset match)
|
|
664
|
+
# If aim_attrs is empty, keep all items for now and let exclude_ids handle removal.
|
|
665
|
+
if aim_attrs:
|
|
666
|
+
attr_filtered_items = []
|
|
667
|
+
for item in items:
|
|
668
|
+
case_info_marker = item.get_closest_marker("case_info")
|
|
669
|
+
if not case_info_marker:
|
|
670
|
+
logger.info(f"item: {item}, match no case_info case")
|
|
671
|
+
continue
|
|
671
672
|
|
|
672
|
-
|
|
673
|
-
if not aim_attrs:
|
|
674
|
-
logger.info("inifile no case_info attributes, match all case")
|
|
675
|
-
_apply_pre_test_order(items, pre_test_ids)
|
|
676
|
-
return
|
|
673
|
+
case_attrs = _get_item_case_info(case_info_marker)
|
|
677
674
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
675
|
+
# Check if all aim_attrs are present in case_attrs with matching values
|
|
676
|
+
# aim_attrs values are lists, case must match one of the values
|
|
677
|
+
all_match = True
|
|
678
|
+
for attr_k, aim_values in aim_attrs.items():
|
|
679
|
+
case_value = case_attrs.get(attr_k)
|
|
680
|
+
if case_value is None or case_value not in aim_values:
|
|
681
|
+
all_match = False
|
|
682
|
+
break
|
|
686
683
|
|
|
687
|
-
|
|
684
|
+
logger.info(f"item: match[{all_match}]{item}[{case_attrs}]")
|
|
688
685
|
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
all_match = True
|
|
692
|
-
for attr_k, aim_values in aim_attrs.items():
|
|
693
|
-
case_value = case_attrs.get(attr_k)
|
|
694
|
-
if case_value is None or case_value not in aim_values:
|
|
695
|
-
all_match = False
|
|
696
|
-
break
|
|
686
|
+
if all_match:
|
|
687
|
+
attr_filtered_items.append(item)
|
|
697
688
|
|
|
698
|
-
|
|
689
|
+
items[:] = attr_filtered_items
|
|
690
|
+
deselected = [
|
|
691
|
+
item for item in original_items if item not in attr_filtered_items
|
|
692
|
+
]
|
|
693
|
+
if deselected:
|
|
694
|
+
config.hook.pytest_deselected(items=deselected)
|
|
695
|
+
logger.info(
|
|
696
|
+
f"filter case with inifile: attributes[{aim_attrs}]\nfiltered cases: {items}"
|
|
697
|
+
)
|
|
699
698
|
|
|
700
|
-
|
|
701
|
-
|
|
699
|
+
# Step 3: apply exclude_ids if configured (drop matching items)
|
|
700
|
+
if exclude_ids:
|
|
701
|
+
# Build set of items currently kept, then drop any whose id is in exclude_ids.
|
|
702
|
+
kept_items = []
|
|
703
|
+
excluded_items = []
|
|
704
|
+
for item in items:
|
|
705
|
+
nodeid_parts = item.nodeid.split("::")
|
|
706
|
+
if len(nodeid_parts) >= 2:
|
|
707
|
+
item_full_id = "::".join(nodeid_parts[1:])
|
|
708
|
+
item_method_id = nodeid_parts[-1]
|
|
709
|
+
if exclude_ids.get(item_full_id, None) or exclude_ids.get(
|
|
710
|
+
item_method_id, None
|
|
711
|
+
):
|
|
712
|
+
excluded_items.append(item)
|
|
713
|
+
continue
|
|
714
|
+
kept_items.append(item)
|
|
715
|
+
|
|
716
|
+
items[:] = kept_items
|
|
717
|
+
if excluded_items:
|
|
718
|
+
config.hook.pytest_deselected(items=excluded_items)
|
|
719
|
+
logger.info(
|
|
720
|
+
f"filter case with exclude id: exclude id list: [{list(exclude_ids.keys())}]\n"
|
|
721
|
+
f"excluded items: {[item.nodeid for item in excluded_items]}"
|
|
722
|
+
)
|
|
723
|
+
logger.info(f"final id list: {kept_items}")
|
|
702
724
|
|
|
703
|
-
|
|
704
|
-
deselected = [item for item in original_items if item not in filtered_items]
|
|
705
|
-
if deselected:
|
|
706
|
-
config.hook.pytest_deselected(items=deselected)
|
|
707
|
-
logger.info(
|
|
708
|
-
f"filter case with inifile: attributes[{aim_attrs}]\nfiltered cases: {items}"
|
|
709
|
-
)
|
|
725
|
+
# Final: reorder by pre_test_ids
|
|
710
726
|
_apply_pre_test_order(items, pre_test_ids)
|
|
711
727
|
|
|
712
728
|
|
|
@@ -729,12 +745,118 @@ def _get_item_env_info(env_marker):
|
|
|
729
745
|
raise Exception("get None template")
|
|
730
746
|
|
|
731
747
|
|
|
748
|
+
def _parse_env_nodes_config(config):
|
|
749
|
+
"""
|
|
750
|
+
Parse env_nodes from [env_info] section of pytest.ini.
|
|
751
|
+
|
|
752
|
+
The env_nodes key is declared under [env_info] (a user-defined section,
|
|
753
|
+
not recognized by pytest.getini), so we read it directly via configparser
|
|
754
|
+
from the ini file pytest is using.
|
|
755
|
+
|
|
756
|
+
Format: '<template_type>.<node_name>' entries, comma-separated.
|
|
757
|
+
Example: 't1.n1, t1.n2, t2.n3' -> {t1: {n1, n2}, t2: {n3}}.
|
|
758
|
+
|
|
759
|
+
Returns:
|
|
760
|
+
dict[str, set[str]]: template_type -> set of node_names to keep.
|
|
761
|
+
Empty dict when not configured or section missing.
|
|
762
|
+
"""
|
|
763
|
+
inifile = getattr(config, "inifile", None)
|
|
764
|
+
if not inifile:
|
|
765
|
+
return {}
|
|
766
|
+
|
|
767
|
+
parser = configparser.ConfigParser()
|
|
768
|
+
try:
|
|
769
|
+
parser.read(inifile, encoding="utf-8")
|
|
770
|
+
except configparser.Error as e:
|
|
771
|
+
logger.info(f"parse inifile for env_info err: {e}, return default val")
|
|
772
|
+
return {}
|
|
773
|
+
|
|
774
|
+
if not parser.has_section("env_info"):
|
|
775
|
+
return {}
|
|
776
|
+
|
|
777
|
+
raw = parser.get("env_info", "env_nodes", fallback="").strip()
|
|
778
|
+
if not raw:
|
|
779
|
+
return {}
|
|
780
|
+
|
|
781
|
+
result: dict = {}
|
|
782
|
+
for entry in raw.split(","):
|
|
783
|
+
entry = entry.strip()
|
|
784
|
+
if not entry:
|
|
785
|
+
continue
|
|
786
|
+
if "." not in entry:
|
|
787
|
+
logger.warning(
|
|
788
|
+
f"env_nodes entry ignored (missing '.'): {entry!r}, "
|
|
789
|
+
f"expected format '<template_type>.<node_name>'"
|
|
790
|
+
)
|
|
791
|
+
continue
|
|
792
|
+
ttype, node_name = entry.split(".", 1)
|
|
793
|
+
ttype = ttype.strip()
|
|
794
|
+
node_name = node_name.strip()
|
|
795
|
+
if not ttype or not node_name:
|
|
796
|
+
logger.warning(f"env_nodes entry ignored (empty parts): {entry!r}")
|
|
797
|
+
continue
|
|
798
|
+
result.setdefault(ttype, set()).add(node_name)
|
|
799
|
+
|
|
800
|
+
return result
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _apply_env_nodes_filter(env_info: dict, env_nodes_cfg: dict) -> dict:
|
|
804
|
+
"""
|
|
805
|
+
Filter env_info according to env_nodes_cfg.
|
|
806
|
+
|
|
807
|
+
Rules:
|
|
808
|
+
- template_type not in cfg: leave env_info unchanged (no processing).
|
|
809
|
+
- template_type in cfg:
|
|
810
|
+
* Keep only nodes listed in cfg[template_type]; discard others.
|
|
811
|
+
* If any configured node name is missing from env_definition -> raise.
|
|
812
|
+
* After filtering, env_definition must still contain at least one machine
|
|
813
|
+
(sum of machines across kept nodes > 0); otherwise raise.
|
|
814
|
+
|
|
815
|
+
Returns the (possibly mutated) env_info dict.
|
|
816
|
+
"""
|
|
817
|
+
if not env_nodes_cfg:
|
|
818
|
+
return env_info
|
|
819
|
+
|
|
820
|
+
ttype = env_info.get("template_type")
|
|
821
|
+
if ttype not in env_nodes_cfg:
|
|
822
|
+
return env_info
|
|
823
|
+
|
|
824
|
+
allowed = env_nodes_cfg[ttype]
|
|
825
|
+
env_def: dict = env_info.get("env_definition", {}) or {}
|
|
826
|
+
|
|
827
|
+
missing = sorted(n for n in allowed if n not in env_def)
|
|
828
|
+
if missing:
|
|
829
|
+
raise ValueError(
|
|
830
|
+
f"env_nodes filter for template_type={ttype!r} expects {sorted(allowed)}, "
|
|
831
|
+
f"but env_definition has nodes {sorted(env_def.keys())}; "
|
|
832
|
+
f"missing: {missing}"
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
filtered_def = {n: env_def[n] for n in env_def if n in allowed}
|
|
836
|
+
|
|
837
|
+
total_machines = sum(
|
|
838
|
+
len(machines)
|
|
839
|
+
for machines in filtered_def.values()
|
|
840
|
+
if isinstance(machines, list)
|
|
841
|
+
)
|
|
842
|
+
if total_machines == 0:
|
|
843
|
+
raise ValueError(
|
|
844
|
+
f"env_nodes filter for template_type={ttype!r} kept {sorted(filtered_def)}, "
|
|
845
|
+
f"but the resulting env_definition contains zero machines. "
|
|
846
|
+
f"Check env_nodes config and template definitions."
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
env_info["env_definition"] = filtered_def
|
|
850
|
+
return env_info
|
|
851
|
+
|
|
852
|
+
|
|
732
853
|
def _collect_env_and_remote_for_testcase(config, items):
|
|
733
854
|
"""
|
|
734
855
|
收集过滤后的用例的环境信息
|
|
735
856
|
"""
|
|
736
857
|
try:
|
|
737
858
|
env_var_name = config.getoption("--env-var", "COLLECTED_INFO")
|
|
859
|
+
env_nodes_cfg = _parse_env_nodes_config(config)
|
|
738
860
|
|
|
739
861
|
if not hasattr(config, "collected_infos"):
|
|
740
862
|
config.collected_infos = {} # 用于报错收集后的信息
|
|
@@ -744,6 +866,7 @@ def _collect_env_and_remote_for_testcase(config, items):
|
|
|
744
866
|
if env_marker:
|
|
745
867
|
try:
|
|
746
868
|
env_info = _get_item_env_info(env_marker)
|
|
869
|
+
env_info = _apply_env_nodes_filter(env_info, env_nodes_cfg)
|
|
747
870
|
remote_marker = item.get_closest_marker("remote_run")
|
|
748
871
|
env_info.update({"remote_run": bool(remote_marker)})
|
|
749
872
|
config.collected_infos[item.nodeid] = env_info
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest_testkit
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.1
|
|
4
4
|
Summary: Pytest plugin for Infra logging framework and environment management
|
|
5
5
|
Author: Huawei
|
|
6
6
|
License: MIT License
|
|
@@ -430,6 +430,49 @@ pytest --quiet-stdout --testbed=testbed.json testcase/
|
|
|
430
430
|
quiet_stdout = true
|
|
431
431
|
```
|
|
432
432
|
|
|
433
|
+
### CI 运行元数据(ci_env_info)
|
|
434
|
+
|
|
435
|
+
执行器(pytest-executor)在调度用例时,会把 CI 流水线的运行元数据序列化成**单个**
|
|
436
|
+
`ci_env_info` 环境变量注入用例进程,覆盖 runner / remote / local 三种执行模式。
|
|
437
|
+
用例侧统一通过读取该环境变量获取当前流水线上下文,无需关心运行在哪种模式。
|
|
438
|
+
|
|
439
|
+
#### 字段说明
|
|
440
|
+
|
|
441
|
+
环境变量 `ci_env_info` 的值是一个 JSON 字符串,固定包含以下白名单字段(值均为字符串):
|
|
442
|
+
|
|
443
|
+
| 字段名 | 含义 | 示例 |
|
|
444
|
+
|:-------------|:-----------------|:------------------|
|
|
445
|
+
| project_name | CI 项目名称 | "openlibing/demo" |
|
|
446
|
+
| workflow_name| 工作流名称 | "nightly" |
|
|
447
|
+
| job_name | 任务(job)名称 | "run-pytest" |
|
|
448
|
+
| run_number | 流水线运行序号 | "128" |
|
|
449
|
+
| step_name | 当前步骤名称 | "pytest-orch" |
|
|
450
|
+
|
|
451
|
+
#### 读取方式
|
|
452
|
+
|
|
453
|
+
```python
|
|
454
|
+
import json
|
|
455
|
+
import os
|
|
456
|
+
|
|
457
|
+
def test_use_ci_metadata():
|
|
458
|
+
raw = os.environ.get("ci_env_info", "")
|
|
459
|
+
info = json.loads(raw) if raw else {}
|
|
460
|
+
job_name = info.get("job_name", "")
|
|
461
|
+
run_number = info.get("run_number", "")
|
|
462
|
+
# 例如按流水线序号归档产物、按 job 名分支断言逻辑
|
|
463
|
+
assert isinstance(job_name, str)
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
#### 取值约定
|
|
467
|
+
|
|
468
|
+
- **白名单字段恒在**:无论入参是否提供,解析后的 JSON 一定包含上表 5 个字段,缺省值为 `""`。
|
|
469
|
+
- **值经过清洗**:单个字段值会剔除 NUL、换行等控制字符,并按 128 字符截断;
|
|
470
|
+
非字符串标量(如数字)转为字符串,非标量(字典/列表)降级为 `""`。
|
|
471
|
+
- **非法输入不阻断用例**:入参结构不合法、缺失或类型错误时一律降级为空串并告警,
|
|
472
|
+
不会中断用例调度;未注入时 `ci_env_info` 为 `""`。
|
|
473
|
+
- **纯 ASCII 安全**:JSON 以 `ensure_ascii` 序列化,含中文等非 ASCII 值时以 `\uXXXX`
|
|
474
|
+
转义,避免经 SSH 传输后乱码。
|
|
475
|
+
- 该变量由执行器注入,本地直接 `pytest` 调试时通常为空,用例代码应对空值做好兜底。
|
|
433
476
|
|
|
434
477
|
### 支持的 fixture
|
|
435
478
|
|
|
@@ -41,6 +41,7 @@ tests/test_docker_proxy_is_interactive_shell.py
|
|
|
41
41
|
tests/test_docker_proxy_non_shell_commands.py
|
|
42
42
|
tests/test_docker_proxy_sendcmd.py
|
|
43
43
|
tests/test_docker_proxy_sendcmd_interactive.py
|
|
44
|
+
tests/test_env_nodes_filter.py
|
|
44
45
|
tests/test_get_inifile_info.py
|
|
45
46
|
tests/test_get_item_case_info.py
|
|
46
47
|
tests/test_local_exec.py
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for env_nodes filter applied during pytest_collection_modifyitems.
|
|
3
|
+
|
|
4
|
+
env_nodes is configured in pytest.ini under [env_info] section (a user-defined
|
|
5
|
+
section that pytest.getini does NOT expose, so the plugin reads it via
|
|
6
|
+
configparser from config.inifile).
|
|
7
|
+
|
|
8
|
+
Format:
|
|
9
|
+
|
|
10
|
+
env_nodes = <template_type>.<node_name>[, <template_type>.<node_name>...]
|
|
11
|
+
|
|
12
|
+
Behavior:
|
|
13
|
+
- template_type not in cfg: env_info unchanged
|
|
14
|
+
- template_type in cfg:
|
|
15
|
+
* only keep listed nodes in env_definition
|
|
16
|
+
* if any configured node missing from env_definition -> ValueError
|
|
17
|
+
* after filtering, env_definition must contain >=1 machine total -> ValueError
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import tempfile
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import pytest
|
|
24
|
+
from pytest_testkit.plugin import (
|
|
25
|
+
_apply_env_nodes_filter,
|
|
26
|
+
_parse_env_nodes_config,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _write_ini(content):
|
|
31
|
+
"""Write a temp ini file and return its path."""
|
|
32
|
+
fd, path = tempfile.mkstemp(suffix=".ini")
|
|
33
|
+
try:
|
|
34
|
+
with open(fd, "w", encoding="utf-8") as f:
|
|
35
|
+
f.write(content)
|
|
36
|
+
except Exception:
|
|
37
|
+
Path(path).unlink(missing_ok=True)
|
|
38
|
+
raise
|
|
39
|
+
return path
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class MockConfig:
|
|
43
|
+
"""Minimal pytest Config mock that exposes inifile and getini (any key)."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, inifile=""):
|
|
46
|
+
self.inifile = inifile
|
|
47
|
+
|
|
48
|
+
def getini(self, key):
|
|
49
|
+
# The plugin no longer relies on getini for env_nodes, but keep it
|
|
50
|
+
# so it behaves like a real pytest Config when the plugin calls it.
|
|
51
|
+
return ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# -----------------------------_parse_env_nodes_config----------------------------
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_parse_returns_empty_when_no_inifile():
|
|
58
|
+
cfg = MockConfig(inifile="")
|
|
59
|
+
assert _parse_env_nodes_config(cfg) == {}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_parse_returns_empty_when_inifile_missing():
|
|
63
|
+
cfg = MockConfig(inifile="/nonexistent/path/to.ini")
|
|
64
|
+
assert _parse_env_nodes_config(cfg) == {}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_parse_returns_empty_when_no_env_info_section():
|
|
68
|
+
path = _write_ini("[other_section]\nfoo = bar\n")
|
|
69
|
+
try:
|
|
70
|
+
cfg = MockConfig(inifile=path)
|
|
71
|
+
assert _parse_env_nodes_config(cfg) == {}
|
|
72
|
+
finally:
|
|
73
|
+
Path(path).unlink()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_parse_returns_empty_when_env_info_section_has_no_env_nodes_key():
|
|
77
|
+
path = _write_ini("[env_info]\nother_key = value\n")
|
|
78
|
+
try:
|
|
79
|
+
cfg = MockConfig(inifile=path)
|
|
80
|
+
assert _parse_env_nodes_config(cfg) == {}
|
|
81
|
+
finally:
|
|
82
|
+
Path(path).unlink()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_parse_single_entry():
|
|
86
|
+
path = _write_ini(
|
|
87
|
+
"[pytest]\nfoo = bar\n\n[env_info]\nenv_nodes = single_device.nodes_A2\n"
|
|
88
|
+
)
|
|
89
|
+
try:
|
|
90
|
+
cfg = MockConfig(inifile=path)
|
|
91
|
+
assert _parse_env_nodes_config(cfg) == {"single_device": {"nodes_A2"}}
|
|
92
|
+
finally:
|
|
93
|
+
Path(path).unlink()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_parse_multiple_entries_same_template():
|
|
97
|
+
path = _write_ini("[env_info]\nenv_nodes = t1.n1, t1.n2, t1.n3\n")
|
|
98
|
+
try:
|
|
99
|
+
cfg = MockConfig(inifile=path)
|
|
100
|
+
assert _parse_env_nodes_config(cfg) == {"t1": {"n1", "n2", "n3"}}
|
|
101
|
+
finally:
|
|
102
|
+
Path(path).unlink()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_parse_multiple_template_types():
|
|
106
|
+
path = _write_ini("[env_info]\nenv_nodes = t1.n1, t1.n2, t2.n3\n")
|
|
107
|
+
try:
|
|
108
|
+
cfg = MockConfig(inifile=path)
|
|
109
|
+
assert _parse_env_nodes_config(cfg) == {"t1": {"n1", "n2"}, "t2": {"n3"}}
|
|
110
|
+
finally:
|
|
111
|
+
Path(path).unlink()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_parse_extra_whitespace_tolerated():
|
|
115
|
+
path = _write_ini("[env_info]\nenv_nodes = t1.n1 , t2.n2 \n")
|
|
116
|
+
try:
|
|
117
|
+
cfg = MockConfig(inifile=path)
|
|
118
|
+
assert _parse_env_nodes_config(cfg) == {"t1": {"n1"}, "t2": {"n2"}}
|
|
119
|
+
finally:
|
|
120
|
+
Path(path).unlink()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_parse_malformed_entry_ignored_but_valid_kept():
|
|
124
|
+
# 'no_dot', 'empty.', '.empty' are ignored; only 't1.n1' is kept.
|
|
125
|
+
path = _write_ini("[env_info]\nenv_nodes = no_dot, t1.n1, empty., .empty\n")
|
|
126
|
+
try:
|
|
127
|
+
cfg = MockConfig(inifile=path)
|
|
128
|
+
assert _parse_env_nodes_config(cfg) == {"t1": {"n1"}}
|
|
129
|
+
finally:
|
|
130
|
+
Path(path).unlink()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_parse_node_name_can_contain_dot():
|
|
134
|
+
# split with maxsplit=1, so 't1.foo.bar' -> ('t1', 'foo.bar').
|
|
135
|
+
path = _write_ini("[env_info]\nenv_nodes = t1.foo.bar\n")
|
|
136
|
+
try:
|
|
137
|
+
cfg = MockConfig(inifile=path)
|
|
138
|
+
assert _parse_env_nodes_config(cfg) == {"t1": {"foo.bar"}}
|
|
139
|
+
finally:
|
|
140
|
+
Path(path).unlink()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_parse_strips_whitespace_inside_entries():
|
|
144
|
+
path = _write_ini("[env_info]\nenv_nodes = t1. n1 , t2.n2\n")
|
|
145
|
+
try:
|
|
146
|
+
cfg = MockConfig(inifile=path)
|
|
147
|
+
assert _parse_env_nodes_config(cfg) == {"t1": {"n1"}, "t2": {"n2"}}
|
|
148
|
+
finally:
|
|
149
|
+
Path(path).unlink()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_parse_env_nodes_in_other_section_ignored():
|
|
153
|
+
# env_nodes written under [pytest] should NOT be picked up: the plugin
|
|
154
|
+
# only reads [env_info] section.
|
|
155
|
+
path = _write_ini("[pytest]\nenv_nodes = t1.n1\n")
|
|
156
|
+
try:
|
|
157
|
+
cfg = MockConfig(inifile=path)
|
|
158
|
+
assert _parse_env_nodes_config(cfg) == {}
|
|
159
|
+
finally:
|
|
160
|
+
Path(path).unlink()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_parse_full_pytest_ini_layout():
|
|
164
|
+
# Realistic pytest.ini: [pytest] for builtins + [env_info] for ours.
|
|
165
|
+
path = _write_ini(
|
|
166
|
+
"[pytest]\n"
|
|
167
|
+
"infra_log_dir = logs\n"
|
|
168
|
+
"infra_log_level = INFO\n"
|
|
169
|
+
"error_log_fail = true\n"
|
|
170
|
+
"\n"
|
|
171
|
+
"[env_info]\n"
|
|
172
|
+
"env_nodes = single_device.nodes_1, multi_device.nodes_main\n"
|
|
173
|
+
)
|
|
174
|
+
try:
|
|
175
|
+
cfg = MockConfig(inifile=path)
|
|
176
|
+
assert _parse_env_nodes_config(cfg) == {
|
|
177
|
+
"single_device": {"nodes_1"},
|
|
178
|
+
"multi_device": {"nodes_main"},
|
|
179
|
+
}
|
|
180
|
+
finally:
|
|
181
|
+
Path(path).unlink()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ---------------------------_apply_env_nodes_filter----------------------------
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _make_env_info(template_type, env_definition):
|
|
188
|
+
return {"template_type": template_type, "env_definition": env_definition}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_filter_skipped_when_template_type_not_in_cfg():
|
|
192
|
+
env_info = _make_env_info(
|
|
193
|
+
"other_type",
|
|
194
|
+
{"nodes_A": [{"name": "device_a"}]},
|
|
195
|
+
)
|
|
196
|
+
cfg = {"single_device": {"nodes_A2"}}
|
|
197
|
+
out = _apply_env_nodes_filter(env_info, cfg)
|
|
198
|
+
assert out is env_info
|
|
199
|
+
assert out["env_definition"] == {"nodes_A": [{"name": "device_a"}]}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_filter_skipped_when_cfg_empty():
|
|
203
|
+
env_info = _make_env_info(
|
|
204
|
+
"single_device",
|
|
205
|
+
{"nodes_A": [{"name": "device_a"}]},
|
|
206
|
+
)
|
|
207
|
+
out = _apply_env_nodes_filter(env_info, {})
|
|
208
|
+
assert out is env_info
|
|
209
|
+
assert out["env_definition"] == {"nodes_A": [{"name": "device_a"}]}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_filter_keeps_only_configured_nodes():
|
|
213
|
+
env_info = _make_env_info(
|
|
214
|
+
"single_device",
|
|
215
|
+
{
|
|
216
|
+
"nodes_A1": [{"name": "device_a1"}],
|
|
217
|
+
"nodes_A2": [{"name": "device_a2"}],
|
|
218
|
+
"nodes_A3": [{"name": "device_a3"}],
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
cfg = {"single_device": {"nodes_A2"}}
|
|
222
|
+
out = _apply_env_nodes_filter(env_info, cfg)
|
|
223
|
+
assert "env_definition" in out
|
|
224
|
+
assert set(out["env_definition"].keys()) == {"nodes_A2"}
|
|
225
|
+
assert out["env_definition"]["nodes_A2"] == [{"name": "device_a2"}]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_filter_keeps_multiple_nodes_for_same_template():
|
|
229
|
+
env_info = _make_env_info(
|
|
230
|
+
"t1",
|
|
231
|
+
{
|
|
232
|
+
"n1": [{"name": "d1"}],
|
|
233
|
+
"n2": [{"name": "d2"}],
|
|
234
|
+
"n3": [{"name": "d3"}],
|
|
235
|
+
},
|
|
236
|
+
)
|
|
237
|
+
cfg = {"t1": {"n1", "n3"}}
|
|
238
|
+
out = _apply_env_nodes_filter(env_info, cfg)
|
|
239
|
+
assert set(out["env_definition"].keys()) == {"n1", "n3"}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_filter_raises_when_configured_node_missing():
|
|
243
|
+
env_info = _make_env_info(
|
|
244
|
+
"single_device",
|
|
245
|
+
{"nodes_A1": [{"name": "device_a1"}]},
|
|
246
|
+
)
|
|
247
|
+
cfg = {"single_device": {"nodes_A2"}}
|
|
248
|
+
with pytest.raises(ValueError, match="nodes_A2"):
|
|
249
|
+
_apply_env_nodes_filter(env_info, cfg)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_filter_raises_when_one_of_many_configured_nodes_missing():
|
|
253
|
+
env_info = _make_env_info(
|
|
254
|
+
"t1",
|
|
255
|
+
{"n1": [{"name": "d1"}]},
|
|
256
|
+
)
|
|
257
|
+
cfg = {"t1": {"n1", "n2"}}
|
|
258
|
+
with pytest.raises(ValueError, match="n2"):
|
|
259
|
+
_apply_env_nodes_filter(env_info, cfg)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_filter_raises_when_all_kept_nodes_empty_machine_list():
|
|
263
|
+
env_info = _make_env_info("t1", {"n1": []})
|
|
264
|
+
cfg = {"t1": {"n1"}}
|
|
265
|
+
with pytest.raises(ValueError, match="zero machines"):
|
|
266
|
+
_apply_env_nodes_filter(env_info, cfg)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_filter_raises_when_no_node_kept_at_all():
|
|
270
|
+
env_info = _make_env_info("t1", {"other": [{"name": "d"}]})
|
|
271
|
+
cfg = {"t1": {"n1"}}
|
|
272
|
+
with pytest.raises(ValueError, match="missing"):
|
|
273
|
+
_apply_env_nodes_filter(env_info, cfg)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def test_filter_preserves_machine_payload_after_filter():
|
|
277
|
+
env_info = _make_env_info(
|
|
278
|
+
"t1",
|
|
279
|
+
{
|
|
280
|
+
"n1": [{"name": "d1", "pool": "default"}],
|
|
281
|
+
"n2": [{"name": "d2", "pool": "default"}],
|
|
282
|
+
},
|
|
283
|
+
)
|
|
284
|
+
cfg = {"t1": {"n2"}}
|
|
285
|
+
out = _apply_env_nodes_filter(env_info, cfg)
|
|
286
|
+
assert out["env_definition"]["n2"] == [{"name": "d2", "pool": "default"}]
|
|
287
|
+
assert "n1" not in out["env_definition"]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_filter_does_not_affect_other_template_types_in_cfg():
|
|
291
|
+
env_info_a = _make_env_info(
|
|
292
|
+
"t1",
|
|
293
|
+
{"n1": [{"name": "d1"}], "n2": [{"name": "d2"}]},
|
|
294
|
+
)
|
|
295
|
+
env_info_b = _make_env_info("t2", {"x": [{"name": "dx"}]})
|
|
296
|
+
cfg = {"t1": {"n1"}, "t2": {"x"}}
|
|
297
|
+
out_a = _apply_env_nodes_filter(env_info_a, cfg)
|
|
298
|
+
out_b = _apply_env_nodes_filter(env_info_b, cfg)
|
|
299
|
+
assert set(out_a["env_definition"].keys()) == {"n1"}
|
|
300
|
+
assert set(out_b["env_definition"].keys()) == {"x"}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def test_filter_with_unrelated_cfg_keys_returns_unchanged():
|
|
304
|
+
env_info = _make_env_info(
|
|
305
|
+
"completely_different",
|
|
306
|
+
{"nodes_A": [{"name": "device_a"}]},
|
|
307
|
+
)
|
|
308
|
+
cfg = {"single_device": {"nodes_A2"}, "multi_device": {"nodes_B1"}}
|
|
309
|
+
out = _apply_env_nodes_filter(env_info, cfg)
|
|
310
|
+
assert out is env_info
|
|
311
|
+
assert out["env_definition"] == {"nodes_A": [{"name": "device_a"}]}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# ---------------------------integration: parse + filter--------------------------
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_integration_single_template_match():
|
|
318
|
+
path = _write_ini(
|
|
319
|
+
"[env_info]\nenv_nodes = single_device.nodes_1, multi_device.nodes_main\n"
|
|
320
|
+
)
|
|
321
|
+
try:
|
|
322
|
+
cfg = MockConfig(inifile=path)
|
|
323
|
+
parsed = _parse_env_nodes_config(cfg)
|
|
324
|
+
|
|
325
|
+
env_info_single = _make_env_info(
|
|
326
|
+
"single_device",
|
|
327
|
+
{
|
|
328
|
+
"nodes_1": [{"name": "device"}],
|
|
329
|
+
"nodes_2": [{"name": "other"}],
|
|
330
|
+
},
|
|
331
|
+
)
|
|
332
|
+
env_info_multi = _make_env_info(
|
|
333
|
+
"multi_device",
|
|
334
|
+
{
|
|
335
|
+
"nodes_main": [{"name": "m1"}, {"name": "m2"}],
|
|
336
|
+
"nodes_extra": [{"name": "extra"}],
|
|
337
|
+
},
|
|
338
|
+
)
|
|
339
|
+
env_info_other = _make_env_info("local_exec", {"nodes_x": [{"name": "x"}]})
|
|
340
|
+
|
|
341
|
+
out_single = _apply_env_nodes_filter(env_info_single, parsed)
|
|
342
|
+
out_multi = _apply_env_nodes_filter(env_info_multi, parsed)
|
|
343
|
+
out_other = _apply_env_nodes_filter(env_info_other, parsed)
|
|
344
|
+
|
|
345
|
+
assert set(out_single["env_definition"].keys()) == {"nodes_1"}
|
|
346
|
+
assert set(out_multi["env_definition"].keys()) == {"nodes_main"}
|
|
347
|
+
assert out_other["env_definition"] == {"nodes_x": [{"name": "x"}]}
|
|
348
|
+
finally:
|
|
349
|
+
Path(path).unlink()
|
|
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.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/common/environment/__init__.py
RENAMED
|
File without changes
|
{pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/common/environment/allocator.py
RENAMED
|
File without changes
|
{pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/pytest_testkit/lib/common/environment/manager.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/tests/test_docker_proxy_is_interactive_shell.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pytest_testkit-1.0.0 → pytest_testkit-1.0.1}/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
|