pytest-platform-adapter 1.0.0__py3-none-any.whl → 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_platform_adapter/plugin.py +417 -14
- {pytest_platform_adapter-1.0.0.dist-info → pytest_platform_adapter-1.2.0.dist-info}/METADATA +80 -2
- pytest_platform_adapter-1.2.0.dist-info/RECORD +8 -0
- {pytest_platform_adapter-1.0.0.dist-info → pytest_platform_adapter-1.2.0.dist-info}/WHEEL +1 -1
- pytest_platform_adapter-1.0.0.dist-info/RECORD +0 -8
- {pytest_platform_adapter-1.0.0.dist-info → pytest_platform_adapter-1.2.0.dist-info}/entry_points.txt +0 -0
- {pytest_platform_adapter-1.0.0.dist-info → pytest_platform_adapter-1.2.0.dist-info/licenses}/LICENSE +0 -0
- {pytest_platform_adapter-1.0.0.dist-info → pytest_platform_adapter-1.2.0.dist-info}/top_level.txt +0 -0
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import json
|
|
3
3
|
import os
|
|
4
|
-
from
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Dict, List, Optional, Set
|
|
5
6
|
|
|
6
7
|
import requests
|
|
7
|
-
from allure_pytest.utils import allure_title
|
|
8
|
+
from allure_pytest.utils import allure_label, allure_title
|
|
8
9
|
import logging
|
|
9
10
|
import pytest
|
|
11
|
+
from _pytest.stash import StashKey
|
|
10
12
|
|
|
11
13
|
logger = logging.getLogger('pytest-platform-adapter')
|
|
12
14
|
logger.setLevel(logging.INFO)
|
|
@@ -30,6 +32,45 @@ platform_path = None
|
|
|
30
32
|
pipeline_name = None
|
|
31
33
|
build_number = None
|
|
32
34
|
platform_use_https = False
|
|
35
|
+
ENV_SETTINGS_KEY: StashKey["EnvCheckSettings"] = StashKey()
|
|
36
|
+
ENV_RUNTIME_KEY: StashKey["EnvCheckRuntime"] = StashKey()
|
|
37
|
+
ITEM_KIND_KEY: StashKey[str] = StashKey()
|
|
38
|
+
BEHAVIOR_KEY: StashKey[Optional[str]] = StashKey()
|
|
39
|
+
ENV_XFAIL_REASON_KEY: StashKey[Optional[str]] = StashKey()
|
|
40
|
+
FORCED_ITEMS_KEY: StashKey[List[pytest.Item]] = StashKey()
|
|
41
|
+
STASH_SENTINEL = object()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class EnvCheckSettings:
|
|
46
|
+
mode: str = 'all'
|
|
47
|
+
behavior_scope: str = 'feature'
|
|
48
|
+
fail_action: str = 'skip'
|
|
49
|
+
collect_mode: str = 'force' # force(强制)/auto(自动)
|
|
50
|
+
global_nodeids: List[str] = field(default_factory=list)
|
|
51
|
+
behavior_nodeids: List[str] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
def enable_global(self) -> bool:
|
|
54
|
+
return self.mode in {'global', 'all'} and bool(self.global_nodeids)
|
|
55
|
+
|
|
56
|
+
def enable_behavior(self) -> bool:
|
|
57
|
+
return self.mode in {'behavior', 'all'} and bool(self.behavior_nodeids)
|
|
58
|
+
|
|
59
|
+
def enabled(self) -> bool:
|
|
60
|
+
if self.mode == 'off':
|
|
61
|
+
return False
|
|
62
|
+
return self.enable_global() or self.enable_behavior()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class EnvCheckRuntime:
|
|
67
|
+
global_failures: Dict[str, str] = field(default_factory=dict)
|
|
68
|
+
behavior_failures: Dict[str, str] = field(default_factory=dict)
|
|
69
|
+
|
|
70
|
+
def first_global_failure(self) -> Optional[str]:
|
|
71
|
+
if not self.global_failures:
|
|
72
|
+
return None
|
|
73
|
+
return next(iter(self.global_failures.values()))
|
|
33
74
|
|
|
34
75
|
|
|
35
76
|
def pytest_addoption(parser):
|
|
@@ -52,6 +93,26 @@ def pytest_addoption(parser):
|
|
|
52
93
|
default=False,
|
|
53
94
|
help='扫描模式:快速生成 Allure 报告而不实际执行测试'
|
|
54
95
|
)
|
|
96
|
+
group.addoption(
|
|
97
|
+
'--env-check-mode',
|
|
98
|
+
action='store',
|
|
99
|
+
default='all',
|
|
100
|
+
choices=['off', 'global', 'behavior', 'all'],
|
|
101
|
+
help='环境检查模式:off(禁用)/global(仅全局)/behavior(仅行为)/all(全部)'
|
|
102
|
+
)
|
|
103
|
+
group.addoption(
|
|
104
|
+
'--env-check-scope',
|
|
105
|
+
action='store',
|
|
106
|
+
default=None,
|
|
107
|
+
help='特性级检查所使用的 Allure 标签层级:epic/feature/story'
|
|
108
|
+
)
|
|
109
|
+
group.addoption(
|
|
110
|
+
'--env-check-collect-mode',
|
|
111
|
+
action='store',
|
|
112
|
+
default=None,
|
|
113
|
+
choices=['force', 'auto'],
|
|
114
|
+
help='环境检查收集模式覆盖:force(强制收集并执行)/auto(仅执行已收集到的检查用例)'
|
|
115
|
+
)
|
|
55
116
|
parser.addini(
|
|
56
117
|
'platform_ip',
|
|
57
118
|
help='自动化平台API IP',
|
|
@@ -72,28 +133,79 @@ def pytest_addoption(parser):
|
|
|
72
133
|
help='上报自动化平台时启用HTTPS,默认不启用',
|
|
73
134
|
default=False
|
|
74
135
|
)
|
|
136
|
+
parser.addini(
|
|
137
|
+
'platform_env_global_checks',
|
|
138
|
+
type='linelist',
|
|
139
|
+
help='全局环境检查用例的绝对NodeID列表',
|
|
140
|
+
default=[]
|
|
141
|
+
)
|
|
142
|
+
parser.addini(
|
|
143
|
+
'platform_env_behavior_checks',
|
|
144
|
+
type='linelist',
|
|
145
|
+
help='特性级环境检查用例的绝对NodeID列表',
|
|
146
|
+
default=[]
|
|
147
|
+
)
|
|
148
|
+
parser.addini(
|
|
149
|
+
'platform_env_behavior_scope',
|
|
150
|
+
help='特性级检查的Allure层级(epic/feature/story)',
|
|
151
|
+
default='feature'
|
|
152
|
+
)
|
|
153
|
+
parser.addini(
|
|
154
|
+
'platform_env_fail_action',
|
|
155
|
+
help='环境检查失败后对业务用例的处理方式(skip/xfail/none)',
|
|
156
|
+
default='skip'
|
|
157
|
+
)
|
|
158
|
+
parser.addini(
|
|
159
|
+
'platform_env_collect_mode',
|
|
160
|
+
help='环境检查收集模式:force(强制收集并执行)/auto(仅执行已收集到的检查用例)',
|
|
161
|
+
default='force'
|
|
162
|
+
)
|
|
75
163
|
|
|
76
|
-
|
|
164
|
+
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
|
|
77
165
|
def pytest_collection_modifyitems(config, items):
|
|
78
166
|
"""
|
|
79
167
|
hook收集用例的过程,给--case_ids和--case_ids_file提供支持
|
|
80
168
|
修改测试用例集合,根据提供的测试用例ID过滤测试用例
|
|
81
169
|
"""
|
|
170
|
+
settings = get_env_settings(config)
|
|
171
|
+
forced_nodeids = _collect_forced_nodeids(settings)
|
|
172
|
+
# 预先快照强制环境检查用例,供后续无视 -m / -k 过滤使用。
|
|
173
|
+
if env_checks_enabled(settings) and settings.collect_mode == 'force':
|
|
174
|
+
forced_items: List[pytest.Item] = []
|
|
175
|
+
seen: Set[str] = set()
|
|
176
|
+
for item in items:
|
|
177
|
+
if _is_forced_item(item.nodeid, forced_nodeids) and item.nodeid not in seen:
|
|
178
|
+
forced_items.append(item)
|
|
179
|
+
seen.add(item.nodeid)
|
|
180
|
+
config.stash[FORCED_ITEMS_KEY] = forced_items
|
|
181
|
+
yield
|
|
182
|
+
# 缺失检查告警:force 模式下使用 pre-deselect 的快照判断,避免被 -m/-k 误判。
|
|
183
|
+
if env_checks_enabled(settings) and settings.collect_mode == 'force':
|
|
184
|
+
forced_items_snapshot = config.stash.get(FORCED_ITEMS_KEY, [])
|
|
185
|
+
available_ids = {item.nodeid for item in forced_items_snapshot}
|
|
186
|
+
else:
|
|
187
|
+
available_ids = {item.nodeid for item in items}
|
|
188
|
+
missing_forced: List[str] = []
|
|
189
|
+
for prefix in forced_nodeids:
|
|
190
|
+
if not any(n == prefix or n.startswith(prefix + "::") for n in available_ids):
|
|
191
|
+
missing_forced.append(prefix)
|
|
192
|
+
for missing in sorted(missing_forced):
|
|
193
|
+
logger.warning(f"配置的环境检查用例 {missing} 未被收集,检查 NodeID 是否正确")
|
|
194
|
+
|
|
82
195
|
target_ids = get_target_test_ids(config)
|
|
83
|
-
if not target_ids:
|
|
84
|
-
test_stats['total'] = len(items) # 更新总用例数
|
|
85
|
-
return
|
|
86
196
|
selected = []
|
|
87
197
|
deselected = []
|
|
88
198
|
for item in items:
|
|
199
|
+
nodeid = item.nodeid
|
|
200
|
+
should_force_run = _is_forced_item(nodeid, forced_nodeids)
|
|
89
201
|
title = allure_title(item)
|
|
90
202
|
test_id = get_test_id_from_title(title)
|
|
91
|
-
if test_id in target_ids:
|
|
203
|
+
if not target_ids or should_force_run or test_id in target_ids:
|
|
92
204
|
selected.append(item)
|
|
93
205
|
else:
|
|
94
206
|
deselected.append(item)
|
|
95
|
-
# 检测 ID
|
|
96
|
-
if test_id in cases_ids:
|
|
207
|
+
# 检测 ID 是否有重复,只是单纯的检查一下,不影响执行。用例 id 为 1 的是环境检查脚本
|
|
208
|
+
if test_id in cases_ids and test_id != "0":
|
|
97
209
|
# 为 None 在这里就不用打印log了,因为在 get_test_id_from_title 里面就会报错一次
|
|
98
210
|
if test_id is None:
|
|
99
211
|
continue
|
|
@@ -102,11 +214,24 @@ def pytest_collection_modifyitems(config, items):
|
|
|
102
214
|
cases_ids.add(test_id)
|
|
103
215
|
if deselected:
|
|
104
216
|
config.hook.pytest_deselected(items=deselected)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
217
|
+
items[:] = selected
|
|
218
|
+
|
|
219
|
+
# force 模式下无视 -m / -k 的 deselect:把缓存的环境检查重新插回来
|
|
220
|
+
if env_checks_enabled(settings) and settings.collect_mode == 'force':
|
|
221
|
+
forced_items = config.stash.get(FORCED_ITEMS_KEY, [])
|
|
222
|
+
current_ids = {item.nodeid for item in items}
|
|
223
|
+
for forced_item in forced_items:
|
|
224
|
+
if forced_item.nodeid not in current_ids:
|
|
225
|
+
items.append(forced_item)
|
|
226
|
+
current_ids.add(forced_item.nodeid)
|
|
227
|
+
|
|
228
|
+
selected_ids = [get_test_id_from_title(allure_title(item)) for item in items]
|
|
229
|
+
test_stats['total'] = len(items) # 更新总用例数
|
|
230
|
+
if target_ids:
|
|
231
|
+
logger.info("目标测试用例ID (%d个): %s", len(target_ids), target_ids)
|
|
232
|
+
logger.info("实际执行用例ID (%d个): %s", len(selected_ids), selected_ids)
|
|
233
|
+
if env_checks_enabled(settings):
|
|
234
|
+
_apply_env_check_collection_logic(config, settings, items)
|
|
110
235
|
|
|
111
236
|
|
|
112
237
|
@pytest.hookimpl(hookwrapper=True)
|
|
@@ -122,6 +247,18 @@ def pytest_runtest_setup(item):
|
|
|
122
247
|
if item.config.getoption('--scan'):
|
|
123
248
|
# logger.info(f"扫描模式:跳过测试用例 {allure_title(item)}前置")
|
|
124
249
|
pytest.skip("扫描模式已启动,跳过测试用例前置")
|
|
250
|
+
settings = get_env_settings(item.config)
|
|
251
|
+
if env_checks_enabled(settings):
|
|
252
|
+
runtime = get_env_runtime(item.config)
|
|
253
|
+
kind = _get_item_kind(item)
|
|
254
|
+
global_reason = runtime.first_global_failure()
|
|
255
|
+
if global_reason and kind != 'global_check':
|
|
256
|
+
_apply_env_failure_action(settings, item, global_reason)
|
|
257
|
+
elif kind == 'business':
|
|
258
|
+
behavior = _ensure_behavior_stashed(item, settings.behavior_scope)
|
|
259
|
+
behavior_reason = runtime.behavior_failures.get(behavior) if behavior else None
|
|
260
|
+
if behavior_reason:
|
|
261
|
+
_apply_env_failure_action(settings, item, behavior_reason)
|
|
125
262
|
yield
|
|
126
263
|
|
|
127
264
|
|
|
@@ -133,6 +270,39 @@ def pytest_runtest_teardown(item):
|
|
|
133
270
|
yield
|
|
134
271
|
|
|
135
272
|
|
|
273
|
+
@pytest.hookimpl(hookwrapper=True)
|
|
274
|
+
def pytest_runtest_makereport(item, call):
|
|
275
|
+
outcome = yield
|
|
276
|
+
report = outcome.get_result()
|
|
277
|
+
settings = get_env_settings(item.config)
|
|
278
|
+
if not env_checks_enabled(settings):
|
|
279
|
+
return
|
|
280
|
+
if report.when != 'call':
|
|
281
|
+
return
|
|
282
|
+
kind = _get_item_kind(item)
|
|
283
|
+
if kind not in {'global_check', 'behavior_check'}:
|
|
284
|
+
return
|
|
285
|
+
runtime = get_env_runtime(item.config)
|
|
286
|
+
if report.failed:
|
|
287
|
+
reason = _format_env_failure_message(kind, item, report)
|
|
288
|
+
if kind == 'global_check':
|
|
289
|
+
runtime.global_failures[item.nodeid] = reason
|
|
290
|
+
else:
|
|
291
|
+
behavior = _ensure_behavior_stashed(item, settings.behavior_scope)
|
|
292
|
+
if behavior:
|
|
293
|
+
runtime.behavior_failures[behavior] = reason
|
|
294
|
+
else:
|
|
295
|
+
logger.warning("特性级环境检查 %s 缺少 %s 标签,无法绑定到具体行为", item.nodeid,
|
|
296
|
+
settings.behavior_scope)
|
|
297
|
+
else:
|
|
298
|
+
if kind == 'global_check':
|
|
299
|
+
runtime.global_failures.pop(item.nodeid, None)
|
|
300
|
+
else:
|
|
301
|
+
behavior = _ensure_behavior_stashed(item, settings.behavior_scope)
|
|
302
|
+
if behavior:
|
|
303
|
+
runtime.behavior_failures.pop(behavior, None)
|
|
304
|
+
|
|
305
|
+
|
|
136
306
|
@pytest.hookimpl
|
|
137
307
|
def pytest_runtest_logreport(report):
|
|
138
308
|
"""
|
|
@@ -209,6 +379,35 @@ def pytest_configure(config):
|
|
|
209
379
|
scan_enable, platform_ip, platform_port, platform_path, platform_use_https = config.getoption(
|
|
210
380
|
'--scan'), config.getini('platform_ip'), config.getini('platform_port'), config.getini(
|
|
211
381
|
'platform_path'), config.getini('platform_use_https')
|
|
382
|
+
settings = build_env_check_settings(config)
|
|
383
|
+
config.stash[ENV_SETTINGS_KEY] = settings
|
|
384
|
+
config.stash[ENV_RUNTIME_KEY] = EnvCheckRuntime()
|
|
385
|
+
# 在强制模式下,将声明的环境检查 NodeID 注入到 pytest 收集参数中,
|
|
386
|
+
# 以保证即使用户只选择了业务目录也能收集到检查用例。
|
|
387
|
+
if env_checks_enabled(settings) and settings.collect_mode == 'force':
|
|
388
|
+
try:
|
|
389
|
+
from pathlib import Path
|
|
390
|
+
|
|
391
|
+
existing_args = set(config.args or [])
|
|
392
|
+
rootpath = getattr(config, "rootpath", Path.cwd())
|
|
393
|
+
forced_args: List[str] = []
|
|
394
|
+
for nodeid in _collect_forced_nodeids(settings):
|
|
395
|
+
if nodeid in existing_args:
|
|
396
|
+
continue
|
|
397
|
+
# 仅对文件路径存在的 NodeID 进行注入,避免明显的 not found。
|
|
398
|
+
path_part = nodeid.split("::", 1)[0]
|
|
399
|
+
p = Path(path_part)
|
|
400
|
+
if not p.is_absolute():
|
|
401
|
+
p = rootpath / p
|
|
402
|
+
if p.exists():
|
|
403
|
+
forced_args.append(nodeid)
|
|
404
|
+
else:
|
|
405
|
+
logger.warning("强制收集环境检查失败:文件不存在 %s", nodeid)
|
|
406
|
+
if forced_args:
|
|
407
|
+
config.args.extend(forced_args)
|
|
408
|
+
logger.debug("已强制注入环境检查收集参数: %s", forced_args)
|
|
409
|
+
except Exception as e:
|
|
410
|
+
logger.warning("强制收集环境检查参数注入异常:%s", e)
|
|
212
411
|
pipeline_name = os.environ.get("JOB_NAME")
|
|
213
412
|
build_number = os.environ.get("BUILD_NUMBER")
|
|
214
413
|
handler = logging.StreamHandler()
|
|
@@ -220,6 +419,10 @@ def pytest_configure(config):
|
|
|
220
419
|
"markers",
|
|
221
420
|
"allure_title: 使用allure标题标记测试用例"
|
|
222
421
|
)
|
|
422
|
+
config.addinivalue_line(
|
|
423
|
+
"markers",
|
|
424
|
+
"environment_check: 标记环境检查用例"
|
|
425
|
+
)
|
|
223
426
|
|
|
224
427
|
|
|
225
428
|
def get_test_ids_from_file(file_path: str) -> List[str]:
|
|
@@ -263,3 +466,203 @@ def get_test_id_from_title(title: Optional[str]) -> Optional[str]:
|
|
|
263
466
|
else:
|
|
264
467
|
logger.error(f'存在无法解析用例ID的用例,用例标题为:{title}')
|
|
265
468
|
return None
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def build_env_check_settings(config) -> EnvCheckSettings:
|
|
472
|
+
mode = config.getoption('--env-check-mode') or 'all'
|
|
473
|
+
scope_opt = config.getoption('--env-check-scope')
|
|
474
|
+
scope_ini = config.getini('platform_env_behavior_scope') or 'feature'
|
|
475
|
+
scope = (scope_opt or scope_ini or 'feature').strip().lower()
|
|
476
|
+
if scope not in {'epic', 'feature', 'story'}:
|
|
477
|
+
logger.warning("未识别的行为层级 %s,回退到 feature", scope)
|
|
478
|
+
scope = 'feature'
|
|
479
|
+
fail_action_raw = config.getini('platform_env_fail_action')
|
|
480
|
+
fail_action = (fail_action_raw or 'skip').strip().lower()
|
|
481
|
+
if fail_action not in {'skip', 'xfail', 'none'}:
|
|
482
|
+
logger.warning("platform_env_fail_action=%s 不受支持,改用 skip", fail_action_raw)
|
|
483
|
+
fail_action = 'skip'
|
|
484
|
+
collect_mode_opt = config.getoption('--env-check-collect-mode')
|
|
485
|
+
collect_mode_raw = collect_mode_opt or config.getini('platform_env_collect_mode')
|
|
486
|
+
collect_mode = (collect_mode_raw or 'force').strip().lower()
|
|
487
|
+
if collect_mode in {'强制'}:
|
|
488
|
+
collect_mode = 'force'
|
|
489
|
+
elif collect_mode in {'自动'}:
|
|
490
|
+
collect_mode = 'auto'
|
|
491
|
+
if collect_mode not in {'force', 'auto'}:
|
|
492
|
+
logger.warning("platform_env_collect_mode=%s 不受支持,改用 force", collect_mode_raw)
|
|
493
|
+
collect_mode = 'force'
|
|
494
|
+
global_nodes = _normalize_nodeids(config.getini('platform_env_global_checks'))
|
|
495
|
+
behavior_nodes = _normalize_nodeids(config.getini('platform_env_behavior_checks'))
|
|
496
|
+
if mode not in {'off', 'global', 'behavior', 'all'}:
|
|
497
|
+
logger.warning("未识别的 env-check-mode %s,回退到 all", mode)
|
|
498
|
+
mode = 'all'
|
|
499
|
+
return EnvCheckSettings(
|
|
500
|
+
mode=mode,
|
|
501
|
+
behavior_scope=scope,
|
|
502
|
+
fail_action=fail_action,
|
|
503
|
+
collect_mode=collect_mode,
|
|
504
|
+
global_nodeids=global_nodes,
|
|
505
|
+
behavior_nodeids=behavior_nodes,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def env_checks_enabled(settings: Optional[EnvCheckSettings]) -> bool:
|
|
510
|
+
return bool(settings and settings.enabled())
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _normalize_nodeids(values: Optional[List[str]]) -> List[str]:
|
|
514
|
+
if not values:
|
|
515
|
+
return []
|
|
516
|
+
normalized = []
|
|
517
|
+
for nodeid in values:
|
|
518
|
+
if not nodeid:
|
|
519
|
+
continue
|
|
520
|
+
value = nodeid.strip()
|
|
521
|
+
if value:
|
|
522
|
+
normalized.append(value)
|
|
523
|
+
return normalized
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def get_env_settings(config) -> EnvCheckSettings:
|
|
527
|
+
return config.stash.get(ENV_SETTINGS_KEY, EnvCheckSettings())
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def get_env_runtime(config) -> EnvCheckRuntime:
|
|
531
|
+
return config.stash.get(ENV_RUNTIME_KEY, EnvCheckRuntime())
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _collect_forced_nodeids(settings: Optional[EnvCheckSettings]) -> Set[str]:
|
|
535
|
+
if not env_checks_enabled(settings):
|
|
536
|
+
return set()
|
|
537
|
+
forced = set()
|
|
538
|
+
if settings.enable_global():
|
|
539
|
+
forced.update(settings.global_nodeids)
|
|
540
|
+
if settings.enable_behavior():
|
|
541
|
+
forced.update(settings.behavior_nodeids)
|
|
542
|
+
return forced
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _is_forced_item(nodeid: str, forced_nodeids: Set[str]) -> bool:
|
|
546
|
+
"""支持用父级 NodeID(如模块/类)声明的强制收集。"""
|
|
547
|
+
if nodeid in forced_nodeids:
|
|
548
|
+
return True
|
|
549
|
+
for forced in forced_nodeids:
|
|
550
|
+
if nodeid.startswith(forced + "::"):
|
|
551
|
+
return True
|
|
552
|
+
return False
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _apply_env_check_collection_logic(config, settings: EnvCheckSettings, items: List[pytest.Item]) -> None:
|
|
556
|
+
ordered: List[pytest.Item] = []
|
|
557
|
+
consumed: Set[str] = set()
|
|
558
|
+
missing_behaviors: List[str] = []
|
|
559
|
+
|
|
560
|
+
# 1) 全局检查:支持使用模块/类 NodeID 作为前缀声明。
|
|
561
|
+
for prefix in settings.global_nodeids:
|
|
562
|
+
for item in items:
|
|
563
|
+
if item.nodeid in consumed:
|
|
564
|
+
continue
|
|
565
|
+
if item.nodeid == prefix or item.nodeid.startswith(prefix + "::"):
|
|
566
|
+
ordered.append(item)
|
|
567
|
+
consumed.add(item.nodeid)
|
|
568
|
+
item.stash[ITEM_KIND_KEY] = 'global_check'
|
|
569
|
+
item.stash[BEHAVIOR_KEY] = None
|
|
570
|
+
|
|
571
|
+
# 2) 特性级检查:同样支持前缀声明,按行为(epic/feature/story)绑定。
|
|
572
|
+
behavior_checks: Dict[str, pytest.Item] = {}
|
|
573
|
+
for prefix in settings.behavior_nodeids:
|
|
574
|
+
for item in items:
|
|
575
|
+
if item.nodeid in consumed:
|
|
576
|
+
continue
|
|
577
|
+
if item.nodeid != prefix and not item.nodeid.startswith(prefix + "::"):
|
|
578
|
+
continue
|
|
579
|
+
behavior = _ensure_behavior_stashed(item, settings.behavior_scope)
|
|
580
|
+
if not behavior:
|
|
581
|
+
missing_behaviors.append(item.nodeid)
|
|
582
|
+
continue
|
|
583
|
+
if behavior in behavior_checks:
|
|
584
|
+
logger.warning(
|
|
585
|
+
"特性级检查 %s 重复定义,沿用首次声明的用例 %s",
|
|
586
|
+
behavior,
|
|
587
|
+
behavior_checks[behavior].nodeid,
|
|
588
|
+
)
|
|
589
|
+
continue
|
|
590
|
+
behavior_checks[behavior] = item
|
|
591
|
+
consumed.add(item.nodeid)
|
|
592
|
+
item.stash[ITEM_KIND_KEY] = 'behavior_check'
|
|
593
|
+
|
|
594
|
+
if missing_behaviors:
|
|
595
|
+
logger.warning(
|
|
596
|
+
"下列特性级检查缺少 %s 标签:%s",
|
|
597
|
+
settings.behavior_scope,
|
|
598
|
+
missing_behaviors,
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
# 3) 按业务用例的行为插入对应检查。
|
|
602
|
+
behavior_inserted: Set[str] = set()
|
|
603
|
+
for item in items:
|
|
604
|
+
if item.nodeid in consumed:
|
|
605
|
+
continue
|
|
606
|
+
if item.stash.get(ITEM_KIND_KEY, STASH_SENTINEL) is STASH_SENTINEL:
|
|
607
|
+
item.stash[ITEM_KIND_KEY] = 'business'
|
|
608
|
+
behavior = _ensure_behavior_stashed(item, settings.behavior_scope)
|
|
609
|
+
if behavior and behavior in behavior_checks and behavior not in behavior_inserted:
|
|
610
|
+
ordered.append(behavior_checks[behavior])
|
|
611
|
+
behavior_inserted.add(behavior)
|
|
612
|
+
ordered.append(item)
|
|
613
|
+
|
|
614
|
+
# 4) 未匹配到业务用例的特性级检查:force 模式下不执行;auto 模式保持原行为(放到末尾执行)。
|
|
615
|
+
if settings.collect_mode != 'force':
|
|
616
|
+
for behavior, check_item in behavior_checks.items():
|
|
617
|
+
if behavior not in behavior_inserted:
|
|
618
|
+
ordered.append(check_item)
|
|
619
|
+
behavior_inserted.add(behavior)
|
|
620
|
+
|
|
621
|
+
if ordered:
|
|
622
|
+
items[:] = ordered
|
|
623
|
+
logger.debug(
|
|
624
|
+
"最终的环境检查执行顺序: %s",
|
|
625
|
+
[item.nodeid for item in items],
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _ensure_behavior_stashed(item: pytest.Item, scope: str) -> Optional[str]:
|
|
630
|
+
behavior = item.stash.get(BEHAVIOR_KEY, STASH_SENTINEL)
|
|
631
|
+
if behavior is not STASH_SENTINEL:
|
|
632
|
+
return behavior
|
|
633
|
+
labels = allure_label(item, scope) or []
|
|
634
|
+
value = labels[0] if labels else None
|
|
635
|
+
item.stash[BEHAVIOR_KEY] = value
|
|
636
|
+
return value
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _get_item_kind(item: pytest.Item) -> Optional[str]:
|
|
640
|
+
return item.stash.get(ITEM_KIND_KEY, None)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _apply_env_failure_action(settings: EnvCheckSettings, item: pytest.Item, reason: str) -> None:
|
|
644
|
+
if settings.fail_action == 'none':
|
|
645
|
+
return
|
|
646
|
+
if settings.fail_action == 'xfail':
|
|
647
|
+
_mark_item_expected_failure(item, reason)
|
|
648
|
+
return
|
|
649
|
+
pytest.skip(reason)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _mark_item_expected_failure(item: pytest.Item, reason: str) -> None:
|
|
653
|
+
existing = item.stash.get(ENV_XFAIL_REASON_KEY, None)
|
|
654
|
+
if existing:
|
|
655
|
+
return
|
|
656
|
+
item.add_marker(pytest.mark.xfail(reason=reason, run=True, strict=False))
|
|
657
|
+
item.stash[ENV_XFAIL_REASON_KEY] = reason
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _format_env_failure_message(kind: str, item: pytest.Item, report: pytest.TestReport) -> str:
|
|
661
|
+
prefix = "全局" if kind == 'global_check' else "特性级"
|
|
662
|
+
detail = ""
|
|
663
|
+
longrepr = getattr(report, "longreprtext", "")
|
|
664
|
+
if longrepr:
|
|
665
|
+
first_line = longrepr.strip().splitlines()[0]
|
|
666
|
+
if first_line:
|
|
667
|
+
detail = f": {first_line}"
|
|
668
|
+
return f"{prefix}环境检查失败({item.nodeid}){detail}"
|
{pytest_platform_adapter-1.0.0.dist-info → pytest_platform_adapter-1.2.0.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest_platform_adapter
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Pytest集成自动化平台插件
|
|
5
5
|
Author-email: BlackYau <blackyau426@gmail.com>
|
|
6
6
|
Maintainer-email: BlackYau <blackyau426@gmail.com>
|
|
@@ -32,6 +32,7 @@ Description-Content-Type: text/markdown
|
|
|
32
32
|
License-File: LICENSE
|
|
33
33
|
Requires-Dist: pytest>=6.2.5
|
|
34
34
|
Requires-Dist: allure-pytest>=2.9.45
|
|
35
|
+
Dynamic: license-file
|
|
35
36
|
|
|
36
37
|
# pytest-platform-adapter
|
|
37
38
|
|
|
@@ -47,6 +48,7 @@ Pytest集成自动化平台插件
|
|
|
47
48
|
- 根据用例 `allure.title` 中的 ID 筛选执行的用例
|
|
48
49
|
- 跳过所有用例的执行,快速生成 Allure 报告
|
|
49
50
|
- 周期性的通过 RESTApi 上报执行用例的整体进度
|
|
51
|
+
- 在执行前插入全局 / 特性级环境检查用例,失败时可批量 skip/xfail 后续业务用例
|
|
50
52
|
|
|
51
53
|
## 环境依赖
|
|
52
54
|
|
|
@@ -56,12 +58,52 @@ Pytest集成自动化平台插件
|
|
|
56
58
|
|
|
57
59
|
## 安装
|
|
58
60
|
|
|
61
|
+
### 在线安装
|
|
62
|
+
|
|
63
|
+
#### 官网在线安装
|
|
64
|
+
|
|
59
65
|
执行以下命令即可完成安装
|
|
60
66
|
|
|
61
67
|
```shell
|
|
62
68
|
pip install pytest-platform-adapter
|
|
63
69
|
```
|
|
64
70
|
|
|
71
|
+
#### 国内镜像站在线安装
|
|
72
|
+
|
|
73
|
+
如果安装遇到网络相关问题,也可以使用临时使用清华大学 PyPI 源安装本插件,安装命令如下:
|
|
74
|
+
|
|
75
|
+
```shell
|
|
76
|
+
pip install -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple pytest-platform-adapter
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### 离线安装
|
|
80
|
+
|
|
81
|
+
#### 官网下载离线安装
|
|
82
|
+
|
|
83
|
+
前往 PyPI 官网中的 pytest-platform-adapter 项目文件下载界面 https://pypi.org/project/pytest-platform-adapter/#files
|
|
84
|
+
|
|
85
|
+
下载最新的 Built Distribution(构建发布版本),例如:`pytest_platform_adapter-x.x.x-py3-none-any.whl` 传输到需要离线安装的环境上
|
|
86
|
+
|
|
87
|
+
然后在命令行中使用以下命令安装(你需要提前安装 `pytest` 和 `allure-pytest`)
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
# 需要把下面的 x.x.x 替换为实际的版本号
|
|
91
|
+
pip install pytest_platform_adapter-x.x.x-py3-none-any.whl
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
#### 国内镜像站下载离线安装
|
|
95
|
+
|
|
96
|
+
前往清华大学 PyPI 源本插件的下载文件页 https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/pytest-platform-adapter/
|
|
97
|
+
|
|
98
|
+
下载最新(版本号最大)的扩展名为 `.whl` 的构建发布版本文件,例如:`pytest_platform_adapter-x.x.x-py3-none-any.whl` 传输到需要离线安装的环境上
|
|
99
|
+
|
|
100
|
+
然后在命令行中使用以下命令安装(你需要提前安装 `pytest` 和 `allure-pytest`)
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
# 需要把下面的 x.x.x 替换为实际的版本号
|
|
104
|
+
pip install pytest_platform_adapter-x.x.x-py3-none-any.whl
|
|
105
|
+
```
|
|
106
|
+
|
|
65
107
|
## 使用方法
|
|
66
108
|
|
|
67
109
|
### 根据 Allure title 中的 ID 筛选用例
|
|
@@ -137,6 +179,42 @@ platform_use_https = False
|
|
|
137
179
|
|
|
138
180
|
其中 `JOB_NAME` 和 `BUILD_NUMBER` 是通过环境变量获取的。
|
|
139
181
|
|
|
182
|
+
### 环境检查
|
|
183
|
+
|
|
184
|
+
环境检查由额外的 pytest 用例构成,通过配置它们的 NodeID 可以在收集阶段将这些用例插入到业务用例之前执行。可同时启用“全局检查”和“特性级检查”。
|
|
185
|
+
|
|
186
|
+
#### 配置项-推荐
|
|
187
|
+
|
|
188
|
+
在 `pytest.ini` 中添加以下内容:
|
|
189
|
+
|
|
190
|
+
```ini
|
|
191
|
+
[pytest]
|
|
192
|
+
platform_env_global_checks =
|
|
193
|
+
tests/env_check/test_global_env_check.py::TestGlobalEnvCheck
|
|
194
|
+
platform_env_behavior_checks =
|
|
195
|
+
tests/env_check/test_function_env_check.py::TestFunctionEnvCheck
|
|
196
|
+
platform_env_behavior_scope = epic # epic / feature / story
|
|
197
|
+
platform_env_fail_action = skip # skip / xfail / none
|
|
198
|
+
platform_env_collect_mode = force # force / auto
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
- `platform_env_global_checks`:在每次用例执行前跑一遍的检查用例列表。
|
|
202
|
+
- `platform_env_behavior_checks`:特性级检查用例列表,插件会读取这些用例标签并在同一特性的首个用例前插入执行。
|
|
203
|
+
- `platform_env_behavior_scope`:定义特性的级别可选的有 epic/feature/story(分别对应 Allure Report 的 behaviors 中的一级目录、二级目录、三级目录)
|
|
204
|
+
- `platform_env_fail_action`:检查失败后对后续业务用例的处理方式,`skip` 为直接跳过、`xfail` 为继续执行并动态加上 `xfail`(失败记为 XFAIL、通过记为 XPASS)、`none` 仅记录日志不干预执行。
|
|
205
|
+
- `platform_env_collect_mode`:环境检查收集模式,默认 `force`。
|
|
206
|
+
- `force`(强制):即使本次命令行只收集了业务目录,也会强制把 `platform_env_global_checks` / `platform_env_behavior_checks` 声明的 NodeID 注入收集范围;环境检查用例不受 `-m`/`-k` 等筛选影响永远执行;全局检查永远执行;特性级检查仅在本次业务用例中存在同一行为标签(scope 对应 epic/feature/story)的情况下才执行,没有匹配的特性级检查不会执行。
|
|
207
|
+
- `auto`(自动):不额外注入收集参数,仅执行本次 pytest 收集到的检查用例;如果声明的 NodeID 没有被收集到则不会执行。
|
|
208
|
+
|
|
209
|
+
#### 命令行
|
|
210
|
+
|
|
211
|
+
- `--env-check-mode={off,global,behavior,all}`:快速控制启用的检查范围。
|
|
212
|
+
- `--env-check-scope=<epic|feature|story>`:临时覆盖行为层级设定。
|
|
213
|
+
- `--env-check-collect-mode={force,auto}`:临时覆盖 `platform_env_collect_mode`,控制环境检查的收集/执行策略。
|
|
214
|
+
- 当 `platform_env_fail_action=xfail` 时,会在受影响的业务用例上自动添加 `pytest.mark.xfail(run=True, strict=False)`,用例依旧执行,只是失败时记为 XFAIL。
|
|
215
|
+
|
|
216
|
+
当检查用例失败时,插件会在 `pytest_runtest_setup` 阶段统一对后续业务用例执行 `skip` 或 `xfail`,并在日志中说明对应的检查节点。检查用例自身依旧遵循 pytest 原生语义,可继续使用 `skip/xfail/flaky` 等标记。
|
|
217
|
+
|
|
140
218
|
|
|
141
219
|
## 调试方法
|
|
142
220
|
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
pytest_platform_adapter/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
pytest_platform_adapter/plugin.py,sha256=QvKwWrlxDuZ8GMNjZ-bHP4FIEFO5kd86U4e0OBPoRRw,26634
|
|
3
|
+
pytest_platform_adapter-1.2.0.dist-info/licenses/LICENSE,sha256=QOkNlcvUHENY86LE8OEpmtwMM24v76pgF4FmkEk8aXI,1064
|
|
4
|
+
pytest_platform_adapter-1.2.0.dist-info/METADATA,sha256=d7lMZYTmi9Zp1x5KgsbTubTfmBFE7VCYSShJkpRuCvg,11429
|
|
5
|
+
pytest_platform_adapter-1.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
6
|
+
pytest_platform_adapter-1.2.0.dist-info/entry_points.txt,sha256=VorMHllVSkGNUUsVJOCrDMeW0-oLco5XcrmdfxND48c,61
|
|
7
|
+
pytest_platform_adapter-1.2.0.dist-info/top_level.txt,sha256=OnTaKf7GCc7yqBHQ4H2XLK8PAz7oHslUZKEsd1t5BpA,24
|
|
8
|
+
pytest_platform_adapter-1.2.0.dist-info/RECORD,,
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
pytest_platform_adapter/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
pytest_platform_adapter/plugin.py,sha256=xNdbIscIhajhfe4EJOvdNw2psrjrTV-uofdCCSlOJZU,10336
|
|
3
|
-
pytest_platform_adapter-1.0.0.dist-info/LICENSE,sha256=QOkNlcvUHENY86LE8OEpmtwMM24v76pgF4FmkEk8aXI,1064
|
|
4
|
-
pytest_platform_adapter-1.0.0.dist-info/METADATA,sha256=afNaCFnL1KfaykAo8e3V7Y3rQY4AOAc1tC49iiiecRE,7010
|
|
5
|
-
pytest_platform_adapter-1.0.0.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
|
6
|
-
pytest_platform_adapter-1.0.0.dist-info/entry_points.txt,sha256=VorMHllVSkGNUUsVJOCrDMeW0-oLco5XcrmdfxND48c,61
|
|
7
|
-
pytest_platform_adapter-1.0.0.dist-info/top_level.txt,sha256=OnTaKf7GCc7yqBHQ4H2XLK8PAz7oHslUZKEsd1t5BpA,24
|
|
8
|
-
pytest_platform_adapter-1.0.0.dist-info/RECORD,,
|
{pytest_platform_adapter-1.0.0.dist-info → pytest_platform_adapter-1.2.0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{pytest_platform_adapter-1.0.0.dist-info → pytest_platform_adapter-1.2.0.dist-info/licenses}/LICENSE
RENAMED
|
File without changes
|
{pytest_platform_adapter-1.0.0.dist-info → pytest_platform_adapter-1.2.0.dist-info}/top_level.txt
RENAMED
|
File without changes
|