pytest-testcase-collector 1.0.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Huawei
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,272 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytest-testcase-collector
3
+ Version: 1.0.0
4
+ Summary: Pytest plugin for collecting test metadata and generating XML report
5
+ Author: Huawei
6
+ Author-email:
7
+ License: MIT License
8
+
9
+ Copyright (c) 2026 Huawei
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+
29
+ Keywords: pytest,pytest-plugin,metadata,xml-report,test-collector
30
+ Classifier: Development Status :: 4 - Beta
31
+ Classifier: Framework :: Pytest
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: Topic :: Software Development :: Testing
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Programming Language :: Python :: 3.8
36
+ Classifier: Programming Language :: Python :: 3.9
37
+ Classifier: Programming Language :: Python :: 3.10
38
+ Classifier: Programming Language :: Python :: 3.11
39
+ Classifier: Programming Language :: Python :: 3.12
40
+ Classifier: License :: OSI Approved :: MIT License
41
+ Classifier: Operating System :: OS Independent
42
+ Requires-Python: >=3.8
43
+ Description-Content-Type: text/markdown
44
+ License-File: LICENSE
45
+ Requires-Dist: pytest>=6.0
46
+ Provides-Extra: dev
47
+ Requires-Dist: build; extra == "dev"
48
+ Requires-Dist: twine; extra == "dev"
49
+ Dynamic: license-file
50
+
51
+ # pytest-testcase-collector
52
+
53
+ 一个 pytest 插件,可自动收集测试用例报告数据(名称、级别、类型、所属类/模块、代码仓等),并生成指定格式的 XML 文件。
54
+
55
+ ## 核心功能
56
+
57
+ 1. **元数据采集**:提取用例名称、级别(`@pytest.mark.case_info(level)`)、类型(`@pytest.mark.case_info(type)`)、所属类/模块信息,支持参数化用例
58
+ 2. **Git 信息集成**:自动识别 Git 仓库,获取仓库 URL、分支信息(非 Git 仓库自动兼容)
59
+ 3. **XML 生成**:按固定结构生成包含流水线/任务标识的 XML 元数据文件
60
+ 4. **Flaky 重试支持**:支持标记不稳定测试用例进行自动重试,满足最小通过次数要求
61
+ 5. **环境变量控制**:支持通过环境变量控制 JUnit XML 输出和元数据收集行为
62
+ 6. **灵活配置**:支持命令行指定输出路径、流水线 ID 等参数,提供完善的错误处理
63
+
64
+ ## 快速开始
65
+
66
+ ### 1. 安装插件
67
+
68
+ #### 方式 1:从 whl 包安装(推荐)
69
+
70
+ ```bash
71
+ # 先构建 whl 包(需提前安装 build 模块)
72
+ pip install build
73
+ python -m build --wheel
74
+
75
+ # 安装生成的whl包
76
+ pip install dist/pytest_report_uploader-1.0.0-py3-none-any.whl
77
+ ```
78
+
79
+ #### 方式 2:源码开发模式安装
80
+
81
+ ```bash
82
+ pip install -e .
83
+ ```
84
+
85
+ ### 2. 使用示例
86
+
87
+ #### 仅收集用例 + 生成元数据(推荐)
88
+
89
+ ```bash
90
+ pytest test_first.py --pipeline-id 123456 --pipeline-run-id 789012 --job-id 345678 --collect-only
91
+ ```
92
+
93
+ #### 运行测试 + 生成元数据
94
+
95
+ ```bash
96
+ pytest test_first.py --pipeline-id 123456 --pipeline-run-id 789012 --job-id 345678
97
+ ```
98
+
99
+ #### 使用环境变量控制行为
100
+
101
+ ```bash
102
+ # 启用自动 JUnit XML 输出
103
+ export ENABLE_JUNITXML_OUTPUT=1
104
+ export RESULTS_XML_DIR=./test-results
105
+ pytest test_first.py
106
+
107
+ # 禁用元数据收集
108
+ export DISABLE_METADATA_COLLECTION=1
109
+ pytest test_first.py
110
+ ```
111
+
112
+ ### 3. 命令行参数说明
113
+
114
+ 所有参数均为可选,未指定则对应字段留空
115
+
116
+ | 参数 | 说明 | 默认值 |
117
+ |------|------|--------|
118
+ | `--pipeline-id` | 流水线唯一标识 | 无 |
119
+ | `--pipeline-run-id` | 流水线运行记录 ID | 无 |
120
+ | `--job-id` | 任务/作业唯一标识 | 无 |
121
+ | `--metadata-output` | 元数据 XML 输出文件路径 | `metadata.xml` |
122
+
123
+ ### 4. 环境变量说明
124
+
125
+ | 环境变量 | 说明 | 默认值 |
126
+ |---------|------|--------|
127
+ | `ENABLE_JUNITXML_OUTPUT` | 设置为 `1`/`true`/`yes` 时自动添加 `--junitxml` 参数 | 不启用 |
128
+ | `RESULTS_XML_DIR` | JUnit XML 输出目录 | `results_xml_dir` |
129
+ | `DISABLE_METADATA_COLLECTION` | 设置为 `1`/`true`/`yes` 时禁用元数据收集和 XML 生成 | 启用 |
130
+
131
+ ## Flaky 测试重试
132
+
133
+ ### 使用方式
134
+
135
+ ```python
136
+ import pytest
137
+
138
+
139
+ @pytest.mark.flaky(reruns=3, reruns_delay=2, min_pass=2)
140
+ def test_flaky_test():
141
+ """不稳定的测试用例,最多重试3次,每次间隔2秒,至少通过2次"""
142
+ import random
143
+
144
+ assert random.choice([True, False, True])
145
+ ```
146
+
147
+ ### 参数说明
148
+
149
+ | 参数 | 说明 | 默认值 |
150
+ |------|------|--------|
151
+ | `reruns` | 重试次数 | 1 |
152
+ | `reruns_delay` | 重试间隔(秒) | 0 |
153
+ | `min_pass` | 最小通过次数(需同时设置 reruns > 0) | 1 |
154
+
155
+ **注意**:只有同时设置 `reruns > 0` 和 `min_pass > 0` 时,才会启用自定义重试逻辑。
156
+
157
+ ## 示例展示
158
+
159
+ ### 测试用例示例
160
+
161
+ ```python
162
+ import pytest
163
+
164
+
165
+ @pytest.mark.case_info(level="L0", type="Functional")
166
+ def test_first():
167
+ assert 1 == 1
168
+
169
+
170
+ @pytest.mark.case_info(level="L1", type="Functional")
171
+ @pytest.mark.parametrize("a,b", [("3+5", 8), ("3+2", 5)])
172
+ def test_second(a, b):
173
+ assert eval(a) == b
174
+
175
+
176
+ @pytest.mark.flaky(reruns=3, min_pass=2)
177
+ def test_flaky_example():
178
+ import random
179
+
180
+ assert random.choice([True, False])
181
+ ```
182
+
183
+ ### 生成的 XML 示例
184
+
185
+ ```xml
186
+ <?xml version='1.0' encoding='utf-8'?>
187
+ <metadata>
188
+ <pipelineId>123456</pipelineId>
189
+ <pipelineRunId>789012</pipelineRunId>
190
+ <jobId>345678</jobId>
191
+ <frameType>pytest</frameType>
192
+ <repoUrl>https://gitcode.com/openlibing/openlibing-pytest-executor.git</repoUrl>
193
+ <repoBranch>main</repoBranch>
194
+ <testCases>
195
+ <testCase>
196
+ <name>test_first</name>
197
+ <level>L0</level>
198
+ <type>Functional</type>
199
+ <className>test_first</className>
200
+ <filePath>test_first.py</filePath>
201
+ </testCase>
202
+ <testCase>
203
+ <name>test_second[3+5-8]</name>
204
+ <level>L1</level>
205
+ <type>Functional</type>
206
+ <className>test_first</className>
207
+ <filePath>test_first.py</filePath>
208
+ </testCase>
209
+ <testCase>
210
+ <name>test_second[3+2-5]</name>
211
+ <level>L1</level>
212
+ <type>Functional</type>
213
+ <className>test_first</className>
214
+ <filePath>test_first.py</filePath>
215
+ </testCase>
216
+ </testCases>
217
+ </metadata>
218
+ ```
219
+
220
+ ## 技术架构
221
+
222
+ ### 整体流程
223
+
224
+ ```
225
+ 命令行参数处理 → 测试用例收集 → 元数据提取 → Git 仓库信息获取 → XML 文件生成 → 测试执行(支持 flaky 重试)→ JUnit XML 修改
226
+ ```
227
+
228
+ ### 项目结构
229
+
230
+ ```
231
+ pytest-testcase-collector/
232
+ ├── pytest_testcase_collector/ # 插件主包
233
+ │ ├── __init__.py # 包初始化
234
+ │ ├── conftest.py # pytest 钩子实现(核心)
235
+ │ ├── metadata_collector.py # 元数据收集核心逻辑
236
+ │ ├── flaky_rerun.py # flaky 用例重试处理
237
+ │ ├── git_info.py # Git 信息获取
238
+ │ ├── xml_generator.py # XML 生成
239
+ │ └── error_handler.py # 错误处理
240
+ ├── test_first.py # 测试示例
241
+ ├── readMe.md # 说明文档
242
+ ├── setup.py # 安装配置
243
+ ├── pyproject.toml # 依赖与元数据管理
244
+ └── pytest.ini # pytest 配置
245
+ ```
246
+
247
+ ### 核心钩子函数
248
+
249
+ | 函数 | 作用 | 执行时机 |
250
+ |------|------|----------|
251
+ | `pytest_load_initial_conftests` | 根据环境变量添加 `--junitxml` 参数 | 命令行参数解析后,插件初始化前 |
252
+ | `pytest_sessionstart` | 初始化元数据存储 | 测试会话开始时 |
253
+ | `pytest_collection_modifyitems` | 收集每个测试用例的元数据 | 测试用例收集完成后 |
254
+ | `pytest_collection_finish` | 生成元数据 XML 文件 | 测试用例收集完成后 |
255
+ | `pytest_runtest_protocol` | 处理 flaky 测试重试逻辑 | 每个测试用例执行前 |
256
+ | `pytest_sessionfinish` | 修改 JUnit XML 添加 flaky 标识 | 测试会话结束时 |
257
+
258
+ ## 错误处理
259
+
260
+ 插件内置完善的异常处理机制:
261
+
262
+ - **元数据提取失败**:输出明确的错误提示,不中断整体流程
263
+ - **XML 生成失败**:记录详细日志并提示可能的原因(如路径无写入权限)
264
+ - **Git 信息获取失败**:自动跳过并将 `repoUrl`/`repoBranch` 置空,兼容非 Git 环境
265
+ - **Flaky 重试失败**:记录重试次数和结果,不影响其他测试执行
266
+
267
+ ## 注意事项
268
+
269
+ 1. 使用 `--collect-only` 参数时,仅收集用例元数据,不执行测试
270
+ 2. 元数据收集默认启用,可通过 `DISABLE_METADATA_COLLECTION=1` 禁用
271
+ 3. JUnit XML 输出默认关闭,可通过 `ENABLE_JUNITXML_OUTPUT=1` 启用
272
+ 4. Flaky 重试需要同时设置 `reruns > 0` 和 `min_pass > 0` 才会生效
@@ -0,0 +1,54 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pytest-testcase-collector"
7
+ version = "1.0.0"
8
+ description = "Pytest plugin for collecting test metadata and generating XML report"
9
+ readme = "readMe.md"
10
+ requires-python = ">=3.8"
11
+ license = { file = "LICENSE" }
12
+ authors = [{ name = "Huawei" }]
13
+ keywords = [
14
+ "pytest",
15
+ "pytest-plugin",
16
+ "metadata",
17
+ "xml-report",
18
+ "test-collector",
19
+ ]
20
+ classifiers = [
21
+ "Development Status :: 4 - Beta",
22
+ "Framework :: Pytest",
23
+ "Intended Audience :: Developers",
24
+ "Topic :: Software Development :: Testing",
25
+ "Programming Language :: Python :: 3",
26
+ "Programming Language :: Python :: 3.8",
27
+ "Programming Language :: Python :: 3.9",
28
+ "Programming Language :: Python :: 3.10",
29
+ "Programming Language :: Python :: 3.11",
30
+ "Programming Language :: Python :: 3.12",
31
+ "License :: OSI Approved :: MIT License",
32
+ "Operating System :: OS Independent",
33
+ ]
34
+ dependencies = [
35
+ "pytest>=6.0",
36
+ ]
37
+
38
+ [project.optional-dependencies]
39
+ dev = [
40
+ "build",
41
+ "twine",
42
+ ]
43
+
44
+ [project.entry-points."pytest11"]
45
+ testcase-collector = "pytest_testcase_collector.conftest"
46
+
47
+ [tool.setuptools.packages.find]
48
+ where = ["."]
49
+ include = ["pytest_testcase_collector*"]
50
+
51
+ [tool.pytest.ini_options]
52
+ markers = [
53
+ "metadata: Custom metadata for tests",
54
+ ]
@@ -0,0 +1,2 @@
1
+ # pytest-testcase-collector plugin
2
+ __version__ = "0.1.0"
@@ -0,0 +1,173 @@
1
+ import os
2
+ from datetime import datetime, timezone
3
+
4
+ import pytest
5
+
6
+ from .flaky_rerun import run_with_retries, modify_junit_xml_with_flaky_info
7
+ from .metadata_collector import collect_metadata
8
+ from .git_info import get_git_info
9
+ from .xml_generator import generate_xml
10
+ from .error_handler import handle_error
11
+
12
+
13
+ def pytest_configure(config):
14
+ """配置插件"""
15
+ config.addinivalue_line("markers", "flaky: mark test to be retried on failure")
16
+
17
+
18
+ def pytest_addoption(parser):
19
+ """添加命令行参数"""
20
+ group = parser.getgroup("collect_metadata")
21
+ group.addoption("--pipeline-id", dest="pipeline_id", default=None, help="流水线ID")
22
+ group.addoption(
23
+ "--pipeline-run-id", dest="pipeline_run_id", default=None, help="流水线运行ID"
24
+ )
25
+ group.addoption("--job-id", dest="job_id", default=None, help="任务ID")
26
+ group.addoption(
27
+ "--metadata-output",
28
+ dest="metadata_output",
29
+ default="metadata.xml",
30
+ help="元数据XML输出文件路径",
31
+ )
32
+
33
+
34
+ @pytest.hookimpl(tryfirst=True)
35
+ def pytest_runtest_protocol(item, nextitem):
36
+ """自定义测试协议处理重试,使用 hookwrapper 确保不影响其他插件"""
37
+
38
+ # 1. 使用公共 API 获取标记信息
39
+ # 检查是否有 flaky 标记 (pytest-rerunfailures 使用的标记)
40
+ flaky_marker = item.get_closest_marker("flaky")
41
+
42
+ # 从标记参数中安全获取重试次数,默认为 0
43
+ reruns = flaky_marker.kwargs.get("reruns", 0) if flaky_marker else 0
44
+ min_pass = flaky_marker.kwargs.get("min_pass", 0) if flaky_marker else 0
45
+
46
+ # 2. 逻辑判断:如果同时需要重试和满足最小通过次数,则接管协议
47
+ if reruns > 0 and min_pass > 0:
48
+ # 执行我们自己的重试逻辑
49
+ run_with_retries(item, nextitem)
50
+ # 返回 True 表示已完成,不继续后续协议
51
+ return True
52
+
53
+ # 如果没有 min_pass,或者没有重试,则让 pytest 使用默认协议
54
+ return None
55
+
56
+
57
+ def pytest_sessionfinish(session, exitstatus):
58
+ """会话结束后修改 JUnit XML 文件以添加 flaky 标识"""
59
+ try:
60
+ junit_xml_file = session.config.getoption("--junitxml", None)
61
+ modify_junit_xml_with_flaky_info(junit_xml_file)
62
+ except Exception:
63
+ # 即使修改 XML 出错也不影响测试结果
64
+ pass
65
+
66
+
67
+ def _should_collect_metadata():
68
+ """
69
+ 检查是否应该收集元数据
70
+ 环境变量 DISABLE_METADATA_COLLECTION=1 时禁用
71
+ """
72
+ return os.environ.get("DISABLE_METADATA_COLLECTION", "").lower() not in (
73
+ "1",
74
+ "true",
75
+ "yes",
76
+ )
77
+
78
+
79
+ @pytest.hookimpl(hookwrapper=True)
80
+ def pytest_sessionstart(session):
81
+ yield
82
+ """测试会话开始时初始化"""
83
+ if _should_collect_metadata():
84
+ session.test_metadata = []
85
+ else:
86
+ session.test_metadata = None
87
+
88
+
89
+ @pytest.hookimpl(hookwrapper=True)
90
+ def pytest_collection_modifyitems(session, config, items):
91
+ yield
92
+ """收集测试用例元数据"""
93
+ # 如果禁用了元数据收集,直接返回
94
+ if session.test_metadata is None:
95
+ return
96
+
97
+ for item in items:
98
+ try:
99
+ metadata = collect_metadata(item)
100
+ session.test_metadata.append(metadata)
101
+ except Exception as e:
102
+ handle_error(f"收集测试用例元数据失败: {item.name}", e)
103
+ try:
104
+ flaky_marker = item.get_closest_marker("flaky")
105
+ if flaky_marker:
106
+ item.flaky_reruns = flaky_marker.kwargs.get("reruns", 1) # 默认重试1次
107
+ item.flaky_delay = flaky_marker.kwargs.get("reruns_delay", 0)
108
+ item.min_pass = flaky_marker.kwargs.get("min_pass", 1)
109
+ else:
110
+ # 没有 flaky 标记的测试,默认重试次数为0
111
+ item.flaky_reruns = 0
112
+ item.flaky_delay = 0
113
+ except Exception:
114
+ # 如果处理 flaky 标记失败,不中断执行
115
+ pass
116
+
117
+
118
+ @pytest.hookimpl(hookwrapper=True)
119
+ def pytest_collection_finish(session):
120
+ yield
121
+ """测试用例收集完成时生成XML文件"""
122
+ # 如果禁用了元数据收集,直接返回
123
+ if session.test_metadata is None:
124
+ return
125
+
126
+ try:
127
+ # 获取命令行参数
128
+ pipeline_id = session.config.getoption("pipeline_id")
129
+ pipeline_run_id = session.config.getoption("pipeline_run_id")
130
+ job_id = session.config.getoption("job_id")
131
+ output_file = session.config.getoption("metadata_output")
132
+
133
+ # 获取Git仓库信息
134
+ git_info = get_git_info()
135
+
136
+ job_info = {
137
+ "pipeline_id": pipeline_id,
138
+ "pipeline_run_id": pipeline_run_id,
139
+ "job_id": job_id,
140
+ }
141
+ # 生成XML文件
142
+ generate_xml(
143
+ job_info=job_info,
144
+ test_cases=session.test_metadata,
145
+ git_info=git_info,
146
+ output_file=output_file,
147
+ )
148
+ except Exception as e:
149
+ handle_error("生成元数据XML文件失败", e)
150
+
151
+
152
+ def pytest_load_initial_conftests(early_config, parser, args):
153
+ """
154
+ 这是修改 junitxml 路径的黄金时机。
155
+ 此时命令行参数已解析,但 junitxml 插件尚未初始化。
156
+ """
157
+ enable_junitxml = os.environ.get("ENABLE_JUNITXML_OUTPUT", "").lower() in (
158
+ "1",
159
+ "true",
160
+ "yes",
161
+ )
162
+
163
+ # 检查命令行是否已经有 --junitxml
164
+ has_cmd_junit = any(arg.startswith("--junitxml") for arg in args)
165
+
166
+ if enable_junitxml and not has_cmd_junit:
167
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
168
+ report_name = f"results_{timestamp}.xml"
169
+ output_dir = os.getenv("RESULTS_XML_DIR", "results_xml_dir")
170
+ os.makedirs(output_dir, exist_ok=True)
171
+ full_path = os.path.join(output_dir, report_name)
172
+
173
+ args.append(f"--junitxml={full_path}")
@@ -0,0 +1,13 @@
1
+ import logging
2
+
3
+ # 配置日志
4
+ logging.basicConfig(
5
+ level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
6
+ )
7
+ logger = logging.getLogger("pytest-collect-metadata")
8
+
9
+
10
+ def handle_error(message, exception):
11
+ """处理错误"""
12
+ # 记录错误信息
13
+ logger.error(f"{message}: {str(exception)}", exc_info=True)