pgpro-pytest-html-merger 0.2.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.
- pgpro_pytest_html_merger-0.2.0/LICENSE +21 -0
- pgpro_pytest_html_merger-0.2.0/PKG-INFO +68 -0
- pgpro_pytest_html_merger-0.2.0/README.md +55 -0
- pgpro_pytest_html_merger-0.2.0/pyproject.toml +37 -0
- pgpro_pytest_html_merger-0.2.0/setup.cfg +4 -0
- pgpro_pytest_html_merger-0.2.0/src/pgpro_pytest_html_merger/__init__.py +1 -0
- pgpro_pytest_html_merger-0.2.0/src/pgpro_pytest_html_merger/__main__.py +448 -0
- pgpro_pytest_html_merger-0.2.0/src/pgpro_pytest_html_merger.egg-info/PKG-INFO +68 -0
- pgpro_pytest_html_merger-0.2.0/src/pgpro_pytest_html_merger.egg-info/SOURCES.txt +11 -0
- pgpro_pytest_html_merger-0.2.0/src/pgpro_pytest_html_merger.egg-info/dependency_links.txt +1 -0
- pgpro_pytest_html_merger-0.2.0/src/pgpro_pytest_html_merger.egg-info/entry_points.txt +2 -0
- pgpro_pytest_html_merger-0.2.0/src/pgpro_pytest_html_merger.egg-info/requires.txt +2 -0
- pgpro_pytest_html_merger-0.2.0/src/pgpro_pytest_html_merger.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Postgres Professional
|
|
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,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pgpro-pytest-html-merger
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A professional tool to merge multiple pytest-html reports into a single one with consistent metadata
|
|
5
|
+
Author-email: Postgres Professional <info@postgrespro.ru>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: beautifulsoup4>=4.11.1
|
|
11
|
+
Requires-Dist: packaging>=21.0
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# pgpro-pytest-html-merger
|
|
15
|
+
A professional tool to merge multiple pytest-html reports into a single, consistent HTML report. Developed and maintained by Postgres Professional.
|
|
16
|
+
|
|
17
|
+
## Key Features
|
|
18
|
+
- Smart Merging: Combines test results, logs, and metadata from multiple sources.
|
|
19
|
+
- Flexible Input: Supports individual files and entire directories.
|
|
20
|
+
- Customizable: Set your own report title and output filename.
|
|
21
|
+
- Modern Support: Fully compatible with Python 3.8 through 3.14.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
You can install the package directly from the repository (until it's published to PyPI):
|
|
25
|
+
```bash
|
|
26
|
+
pip install pgpro-pytest-html-merger
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
After installation, the tool is available via the pgpro-pytest-html-merger command.
|
|
31
|
+
|
|
32
|
+
### Basic Examples
|
|
33
|
+
Merge all reports in a directory:
|
|
34
|
+
```bash
|
|
35
|
+
pgpro-pytest-html-merger -i ./reports -o summary.html
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Merge specific files with a custom title:
|
|
39
|
+
```bash
|
|
40
|
+
pgpro-pytest-html-merger report1.html report2.html -o final.html --title "Nightly Build"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Combine directories and individual files:
|
|
44
|
+
``` bash
|
|
45
|
+
pgpro-pytest-html-merger -i ./unit-tests -i ./e2e-tests extra-report.html -o full-report.html
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Command Line Arguments
|
|
49
|
+
|
|
50
|
+
| Argument | Shorthand | Description | Default |
|
|
51
|
+
| :--- | :--- | :--- | :--- |
|
|
52
|
+
| `--input-dir` | `-i` | Directory containing HTML reports (can be used multiple times) | None |
|
|
53
|
+
| `--out` | `-o` | Name of the output HTML report | `merged.html` |
|
|
54
|
+
| `--title` | `-t` | Title of the output HTML report | None |
|
|
55
|
+
| `--verbose` | `-v` | Level of logging verbosity | 3 |
|
|
56
|
+
| `html_files` | | Positional arguments for individual HTML files | None |
|
|
57
|
+
|
|
58
|
+
## Contributing
|
|
59
|
+
1. Fork the repository.
|
|
60
|
+
2. Create your feature branch (`git checkout -b feature/amazing-feature`).
|
|
61
|
+
3. Commit your changes (`git commit -m 'feat: add some amazing feature'`).
|
|
62
|
+
4. Push to the branch (`git push origin feature/amazing-feature`).
|
|
63
|
+
5. Open a Pull Request.
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
This project is licensed under the MIT License - see the LICENSE file for details.
|
|
67
|
+
|
|
68
|
+
© 2026 Postgres Professional
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# pgpro-pytest-html-merger
|
|
2
|
+
A professional tool to merge multiple pytest-html reports into a single, consistent HTML report. Developed and maintained by Postgres Professional.
|
|
3
|
+
|
|
4
|
+
## Key Features
|
|
5
|
+
- Smart Merging: Combines test results, logs, and metadata from multiple sources.
|
|
6
|
+
- Flexible Input: Supports individual files and entire directories.
|
|
7
|
+
- Customizable: Set your own report title and output filename.
|
|
8
|
+
- Modern Support: Fully compatible with Python 3.8 through 3.14.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
You can install the package directly from the repository (until it's published to PyPI):
|
|
12
|
+
```bash
|
|
13
|
+
pip install pgpro-pytest-html-merger
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
After installation, the tool is available via the pgpro-pytest-html-merger command.
|
|
18
|
+
|
|
19
|
+
### Basic Examples
|
|
20
|
+
Merge all reports in a directory:
|
|
21
|
+
```bash
|
|
22
|
+
pgpro-pytest-html-merger -i ./reports -o summary.html
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Merge specific files with a custom title:
|
|
26
|
+
```bash
|
|
27
|
+
pgpro-pytest-html-merger report1.html report2.html -o final.html --title "Nightly Build"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Combine directories and individual files:
|
|
31
|
+
``` bash
|
|
32
|
+
pgpro-pytest-html-merger -i ./unit-tests -i ./e2e-tests extra-report.html -o full-report.html
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Command Line Arguments
|
|
36
|
+
|
|
37
|
+
| Argument | Shorthand | Description | Default |
|
|
38
|
+
| :--- | :--- | :--- | :--- |
|
|
39
|
+
| `--input-dir` | `-i` | Directory containing HTML reports (can be used multiple times) | None |
|
|
40
|
+
| `--out` | `-o` | Name of the output HTML report | `merged.html` |
|
|
41
|
+
| `--title` | `-t` | Title of the output HTML report | None |
|
|
42
|
+
| `--verbose` | `-v` | Level of logging verbosity | 3 |
|
|
43
|
+
| `html_files` | | Positional arguments for individual HTML files | None |
|
|
44
|
+
|
|
45
|
+
## Contributing
|
|
46
|
+
1. Fork the repository.
|
|
47
|
+
2. Create your feature branch (`git checkout -b feature/amazing-feature`).
|
|
48
|
+
3. Commit your changes (`git commit -m 'feat: add some amazing feature'`).
|
|
49
|
+
4. Push to the branch (`git push origin feature/amazing-feature`).
|
|
50
|
+
5. Open a Pull Request.
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
This project is licensed under the MIT License - see the LICENSE file for details.
|
|
54
|
+
|
|
55
|
+
© 2026 Postgres Professional
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pgpro-pytest-html-merger"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "A professional tool to merge multiple pytest-html reports into a single one with consistent metadata"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
|
|
13
|
+
authors = [
|
|
14
|
+
{name = "Postgres Professional", email = "info@postgrespro.ru"}
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
dependencies = [
|
|
18
|
+
"beautifulsoup4>=4.11.1",
|
|
19
|
+
"packaging>=21.0",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
pgpro-pytest-html-merger = "pgpro_pytest_html_merger.__main__:cli"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
|
27
|
+
include = ["pgpro_pytest_html_merger*"]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
testpaths = ["tests"]
|
|
31
|
+
log_file_level = "NOTSET"
|
|
32
|
+
log_file_format = "%(levelname)8s [%(asctime)s] %(message)s"
|
|
33
|
+
log_file_date_format = "%Y-%m-%d %H:%M:%S"
|
|
34
|
+
|
|
35
|
+
[tool.flake8]
|
|
36
|
+
extend-ignore = ["E501"]
|
|
37
|
+
exclude = [".git", "__pycache__", "env", "venv"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import bs4
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import re
|
|
9
|
+
import glob
|
|
10
|
+
import typing
|
|
11
|
+
import collections
|
|
12
|
+
|
|
13
|
+
from packaging.version import Version
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_arguments():
|
|
19
|
+
command_parser = argparse.ArgumentParser(
|
|
20
|
+
description="A professional tool to merge multiple pytest-html reports into a single one with consistent metadata.", # noqa: E501
|
|
21
|
+
epilog="Example: pgpro-pytest-html-merger -i ./reports -o summary.html --title 'Nightly Build'", # noqa: E501
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
command_parser.add_argument(
|
|
25
|
+
"--out",
|
|
26
|
+
"-o",
|
|
27
|
+
help="name of the output html report",
|
|
28
|
+
action="store",
|
|
29
|
+
dest="out",
|
|
30
|
+
default="merged.html",
|
|
31
|
+
type=str,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
command_parser.add_argument(
|
|
35
|
+
"--input-dir",
|
|
36
|
+
"-i",
|
|
37
|
+
help="directory containing html reports to merge (can be used multiple times)", # noqa: E501
|
|
38
|
+
action="append",
|
|
39
|
+
dest="input_dirs",
|
|
40
|
+
type=str,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
command_parser.add_argument(
|
|
44
|
+
"--title",
|
|
45
|
+
"-t",
|
|
46
|
+
help="title of the output html report",
|
|
47
|
+
action="store",
|
|
48
|
+
dest="title",
|
|
49
|
+
type=str,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
command_parser.add_argument(
|
|
53
|
+
"--verbose",
|
|
54
|
+
"-v",
|
|
55
|
+
help="level of logging verbosity",
|
|
56
|
+
default=3,
|
|
57
|
+
action="count",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
command_parser.add_argument(
|
|
61
|
+
"html_files",
|
|
62
|
+
help="html files generated by pytest-html",
|
|
63
|
+
action="store",
|
|
64
|
+
nargs="*",
|
|
65
|
+
type=str,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# parse options
|
|
69
|
+
options = command_parser.parse_args()
|
|
70
|
+
|
|
71
|
+
return options
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class PytestHTMLReportMerger:
|
|
75
|
+
C_MININAL_PYTEST_HTML_VERSION = "4.0.2"
|
|
76
|
+
|
|
77
|
+
_summary_count: int
|
|
78
|
+
_summary_duration: float
|
|
79
|
+
_summary_outcome: typing.Dict[str, int]
|
|
80
|
+
_summary_tests: typing.Dict[str, typing.Any]
|
|
81
|
+
_summary_envs: typing.Dict[str, typing.Any]
|
|
82
|
+
|
|
83
|
+
def __init__(self):
|
|
84
|
+
self.base = None
|
|
85
|
+
self._summary_count = 0
|
|
86
|
+
self._summary_duration = 0.0
|
|
87
|
+
self._summary_outcome = {}
|
|
88
|
+
self._summary_tests = {}
|
|
89
|
+
self._summary_envs = {}
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _parse_frac(frac_str: str) -> float:
|
|
94
|
+
assert type(frac_str) is str
|
|
95
|
+
|
|
96
|
+
main_part = frac_str[:6]
|
|
97
|
+
tail = frac_str[6:7]
|
|
98
|
+
|
|
99
|
+
fraction_val = int(main_part) if main_part else 0
|
|
100
|
+
power_of_10 = len(main_part)
|
|
101
|
+
|
|
102
|
+
if tail and int(tail[0]) > 4:
|
|
103
|
+
fraction_val += 1
|
|
104
|
+
|
|
105
|
+
return fraction_val / (10**power_of_10)
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def _parse_duration_to_seconds(duration_val: typing.Any) -> float:
|
|
109
|
+
if isinstance(duration_val, (int, float)):
|
|
110
|
+
return float(duration_val)
|
|
111
|
+
|
|
112
|
+
val_str = str(duration_val).strip()
|
|
113
|
+
|
|
114
|
+
# 1. HH:MM:SS.mmmmmm (fractional is optional)
|
|
115
|
+
hms_match = re.fullmatch(r"(\d+):(\d{2}):(\d{2})(?:\.(\d+))?", val_str)
|
|
116
|
+
if hms_match:
|
|
117
|
+
h, m, s, frac_str = hms_match.groups()
|
|
118
|
+
|
|
119
|
+
total_seconds = int(h) * 3600 + int(m) * 60 + int(s)
|
|
120
|
+
|
|
121
|
+
if frac_str is not None:
|
|
122
|
+
total_seconds += __class__._parse_frac(frac_str)
|
|
123
|
+
|
|
124
|
+
return float(total_seconds)
|
|
125
|
+
|
|
126
|
+
# 2. Forma "number ms"
|
|
127
|
+
ms_match = re.fullmatch(r"^(\d+(?:\.\d+)?)\s*ms$", val_str)
|
|
128
|
+
if ms_match:
|
|
129
|
+
return float(ms_match.group(1)) / 1000.0
|
|
130
|
+
|
|
131
|
+
# 3. Clear seconds (with/without fraction)
|
|
132
|
+
sec_match = re.fullmatch(r"^(\d+(?:\.\d+)?)$", val_str)
|
|
133
|
+
if sec_match:
|
|
134
|
+
return float(sec_match.group(1))
|
|
135
|
+
|
|
136
|
+
raise ValueError(f"Invalid duration format: '{duration_val}'")
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def _format_time(duration):
|
|
140
|
+
assert type(duration) is float
|
|
141
|
+
assert duration >= 0
|
|
142
|
+
|
|
143
|
+
if duration < 1:
|
|
144
|
+
return "{} ms".format(int(duration * 1000))
|
|
145
|
+
|
|
146
|
+
minutes, seconds = divmod(int(duration), 60)
|
|
147
|
+
hours, minutes = divmod(minutes, 60)
|
|
148
|
+
return "{:02d}:{:02d}:{:02d}".format(
|
|
149
|
+
int(hours),
|
|
150
|
+
int(minutes),
|
|
151
|
+
int(seconds),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# --------------------------------------------------------------------
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _extract_pytest_html_version(
|
|
157
|
+
report_path,
|
|
158
|
+
report_soup: bs4.BeautifulSoup,
|
|
159
|
+
) -> str:
|
|
160
|
+
assert report_path is not None
|
|
161
|
+
assert isinstance(report_soup, bs4.BeautifulSoup)
|
|
162
|
+
|
|
163
|
+
"""
|
|
164
|
+
Robustly extract pytest-html version from the report footer/header.
|
|
165
|
+
Example text: '... by pytest-html v4.0.2'
|
|
166
|
+
"""
|
|
167
|
+
link = report_soup.find("a", href=re.compile(r"pytest-html"))
|
|
168
|
+
if link is None:
|
|
169
|
+
raise RuntimeError(
|
|
170
|
+
"Report does not have section with pytest-html link.",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
parent_text = link.parent.get_text()
|
|
174
|
+
|
|
175
|
+
# Looking for pattern 'v' and digits (v4.0.2)
|
|
176
|
+
match = re.search(r"v(\d+\.\d+\.\d+[\w\.]*)", parent_text)
|
|
177
|
+
if not match:
|
|
178
|
+
__class__._raise_err__cant_extract_report_version(
|
|
179
|
+
report_path,
|
|
180
|
+
parent_text,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
return match.group(1)
|
|
184
|
+
|
|
185
|
+
def _process_test(self, test: typing.Dict[str, typing.Any]) -> None:
|
|
186
|
+
assert test is not None
|
|
187
|
+
self._summary_duration += __class__._parse_duration_to_seconds(
|
|
188
|
+
test.get("duration", 0.0)
|
|
189
|
+
)
|
|
190
|
+
test_outcome = test.get("result", "unknown").lower()
|
|
191
|
+
self._summary_outcome[test_outcome] = (
|
|
192
|
+
self._summary_outcome.get(test_outcome, 0) + 1
|
|
193
|
+
)
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
def process_report(self, report_path):
|
|
197
|
+
html_doc = ""
|
|
198
|
+
with open(report_path, "r") as f:
|
|
199
|
+
html_doc = f.read()
|
|
200
|
+
soup = bs4.BeautifulSoup(html_doc, features="html.parser")
|
|
201
|
+
|
|
202
|
+
html_version = __class__._extract_pytest_html_version(
|
|
203
|
+
report_path,
|
|
204
|
+
soup,
|
|
205
|
+
)
|
|
206
|
+
assert type(html_version) is str
|
|
207
|
+
|
|
208
|
+
min_version = Version(__class__.C_MININAL_PYTEST_HTML_VERSION)
|
|
209
|
+
if Version(html_version) < min_version:
|
|
210
|
+
__class__._raise_err__unsupported_html_version(
|
|
211
|
+
report_path,
|
|
212
|
+
html_version,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# copy the base report
|
|
216
|
+
if self.base is None:
|
|
217
|
+
self.base = copy.copy(soup)
|
|
218
|
+
|
|
219
|
+
# load json data from the current report
|
|
220
|
+
report_data_container = soup.select("#data-container")[0]
|
|
221
|
+
report_jsonblob = report_data_container.get("data-jsonblob")
|
|
222
|
+
report_data = json.loads(report_jsonblob)
|
|
223
|
+
|
|
224
|
+
# Calculate summary
|
|
225
|
+
for _, test_data in report_data.get("tests", {}).items():
|
|
226
|
+
if type(test_data) is list:
|
|
227
|
+
# Reruns case ...
|
|
228
|
+
assert len(test_data) > 0
|
|
229
|
+
for test in test_data:
|
|
230
|
+
self._process_test(test)
|
|
231
|
+
elif type(test_data) is dict:
|
|
232
|
+
self._process_test(test_data)
|
|
233
|
+
else:
|
|
234
|
+
raise RuntimeError(
|
|
235
|
+
"Unexpected test_data type: {}.".format(
|
|
236
|
+
type(test_data).__name__,
|
|
237
|
+
),
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
self._summary_count += 1
|
|
241
|
+
new_test_key = str(self._summary_count)
|
|
242
|
+
self._summary_tests[new_test_key] = copy.deepcopy(test_data)
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
# Update envs
|
|
246
|
+
self._summary_envs.update(report_data.get("environment", {}))
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
def write_report(self, report_path, report_title):
|
|
250
|
+
assert type(self.base) is not None
|
|
251
|
+
if report_title is None:
|
|
252
|
+
report_title = os.path.basename(report_path)
|
|
253
|
+
|
|
254
|
+
# reset the title in the <head><title> element
|
|
255
|
+
ele = self.base.select("#head-title")[0]
|
|
256
|
+
ele.string = report_title
|
|
257
|
+
|
|
258
|
+
# reset the title in the <body><h1> element
|
|
259
|
+
ele = self.base.select("#title")[0]
|
|
260
|
+
ele.string = report_title
|
|
261
|
+
|
|
262
|
+
# save the updated total tests and total time.
|
|
263
|
+
base_element = self.base.select(".run-count")[0]
|
|
264
|
+
test_suffix = "test" if len(self._summary_tests) == 1 else "tests"
|
|
265
|
+
base_element.string = "{} {} took {}.".format(
|
|
266
|
+
len(self._summary_tests),
|
|
267
|
+
test_suffix,
|
|
268
|
+
__class__._format_time(self._summary_duration),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# update the filter counts
|
|
272
|
+
for key in [
|
|
273
|
+
"passed",
|
|
274
|
+
"skipped",
|
|
275
|
+
"failed",
|
|
276
|
+
"error",
|
|
277
|
+
"xfailed",
|
|
278
|
+
"xpassed",
|
|
279
|
+
"rerun",
|
|
280
|
+
"retried",
|
|
281
|
+
]:
|
|
282
|
+
value = self._summary_outcome.get(key, 0)
|
|
283
|
+
|
|
284
|
+
# find the base's value for the key
|
|
285
|
+
base_elements = self.base.select(f".filters .{key}")
|
|
286
|
+
assert base_elements is not None
|
|
287
|
+
|
|
288
|
+
if len(base_elements) == 0:
|
|
289
|
+
# pytest-html 4.0.2 does not have "retried"
|
|
290
|
+
log.debug(
|
|
291
|
+
f"Filter element for '{key}' not found in HTML template. Skipping UI update for this key." # noqa: E501
|
|
292
|
+
)
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
assert len(base_elements) == 1, "key: {}, len: {}.".format(
|
|
296
|
+
key, len(base_elements)
|
|
297
|
+
)
|
|
298
|
+
base_element0 = base_elements[0]
|
|
299
|
+
assert base_element0.string is not None, "key: {}".format(key)
|
|
300
|
+
matches = re.search(r"(\d+)", base_element0.string)
|
|
301
|
+
base_value = int(matches.groups()[0])
|
|
302
|
+
|
|
303
|
+
# save the updated count to the base
|
|
304
|
+
base_element0.string = re.sub(
|
|
305
|
+
r"\d+",
|
|
306
|
+
str(value),
|
|
307
|
+
base_element0.string,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
# remove the base's disabled filter if the soup value was not zero
|
|
311
|
+
if base_value == 0 and value > 0:
|
|
312
|
+
ele = self.base.select(f"[data-test-result='{key}']")[0]
|
|
313
|
+
del ele["disabled"]
|
|
314
|
+
|
|
315
|
+
# load json data from the base report
|
|
316
|
+
base_data_container = self.base.select("#data-container")[0]
|
|
317
|
+
base_jsonblob = base_data_container.get("data-jsonblob")
|
|
318
|
+
base_data = json.loads(base_jsonblob)
|
|
319
|
+
|
|
320
|
+
# reset the title in the footer's data-jsonblob
|
|
321
|
+
base_data["title"] = report_title
|
|
322
|
+
|
|
323
|
+
base_data["tests"] = self._summary_tests
|
|
324
|
+
base_data["environment"] = self._summary_envs
|
|
325
|
+
|
|
326
|
+
# write the json data back to the html element's attribute
|
|
327
|
+
base_data_container["data-jsonblob"] = json.dumps(base_data)
|
|
328
|
+
|
|
329
|
+
# write to file
|
|
330
|
+
with open(report_path, "w", encoding="utf-8") as f:
|
|
331
|
+
f.write(str(self.base.prettify(formatter="html5")))
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
@staticmethod
|
|
335
|
+
def _raise_err__no_section_with_version(report_path) -> typing.NoReturn:
|
|
336
|
+
err_msg = "Report [{}] does not have section with pytest-html link.".format( # noqa: E501
|
|
337
|
+
report_path
|
|
338
|
+
)
|
|
339
|
+
raise RuntimeError(err_msg)
|
|
340
|
+
|
|
341
|
+
@staticmethod
|
|
342
|
+
def _raise_err__cant_extract_report_version(
|
|
343
|
+
report_path,
|
|
344
|
+
text,
|
|
345
|
+
) -> typing.NoReturn:
|
|
346
|
+
assert report_path is not None
|
|
347
|
+
err_msg = "Cannot extract pytest-html version from {0!r}. Source file is [{1}]".format( # noqa: E501
|
|
348
|
+
text,
|
|
349
|
+
report_path,
|
|
350
|
+
)
|
|
351
|
+
raise RuntimeError(err_msg)
|
|
352
|
+
|
|
353
|
+
@staticmethod
|
|
354
|
+
def _raise_err__unsupported_html_version(
|
|
355
|
+
report_path, version: str
|
|
356
|
+
) -> typing.NoReturn:
|
|
357
|
+
assert report_path is not None
|
|
358
|
+
assert type(version) is str
|
|
359
|
+
err_msg = "Source file [{}] has an unsupported version [{}]. The minimal supported version is [{}].".format( # noqa: E501
|
|
360
|
+
report_path,
|
|
361
|
+
version,
|
|
362
|
+
__class__.C_MININAL_PYTEST_HTML_VERSION,
|
|
363
|
+
)
|
|
364
|
+
raise RuntimeError(err_msg)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def main(arguments):
|
|
368
|
+
raw_files = []
|
|
369
|
+
has_errors = False
|
|
370
|
+
|
|
371
|
+
# 1. Collect from directories
|
|
372
|
+
if arguments.input_dirs:
|
|
373
|
+
for directory in arguments.input_dirs:
|
|
374
|
+
abs_dir = os.path.abspath(directory)
|
|
375
|
+
if not os.path.isdir(abs_dir):
|
|
376
|
+
log.error(f"Input directory does not exist: '{directory}'")
|
|
377
|
+
has_errors = True
|
|
378
|
+
continue
|
|
379
|
+
|
|
380
|
+
pattern = os.path.join(abs_dir, "*.html")
|
|
381
|
+
found = glob.glob(pattern)
|
|
382
|
+
raw_files.extend(found)
|
|
383
|
+
|
|
384
|
+
# 2. Collect from positional files
|
|
385
|
+
if arguments.html_files:
|
|
386
|
+
for f in arguments.html_files:
|
|
387
|
+
abs_file = os.path.abspath(f)
|
|
388
|
+
if not os.path.isfile(abs_file):
|
|
389
|
+
log.error(
|
|
390
|
+
f"Invalid input: '{f}' is not a file or does not exist.",
|
|
391
|
+
)
|
|
392
|
+
has_errors = True
|
|
393
|
+
continue
|
|
394
|
+
raw_files.append(abs_file)
|
|
395
|
+
|
|
396
|
+
# --- THE DEDUPLICATION CHECK ---
|
|
397
|
+
counts = collections.Counter(raw_files)
|
|
398
|
+
duplicates = [path for path, count in counts.items() if count > 1]
|
|
399
|
+
|
|
400
|
+
if duplicates:
|
|
401
|
+
for d in duplicates:
|
|
402
|
+
log.error(f"Duplicate input file detected: '{d}'")
|
|
403
|
+
has_errors = True
|
|
404
|
+
|
|
405
|
+
# 3. Final check
|
|
406
|
+
if has_errors:
|
|
407
|
+
log.error(
|
|
408
|
+
"Termination due to input errors (duplicates or missing files).",
|
|
409
|
+
)
|
|
410
|
+
sys.exit(1)
|
|
411
|
+
|
|
412
|
+
if not raw_files:
|
|
413
|
+
log.error("No HTML reports found to process.")
|
|
414
|
+
return
|
|
415
|
+
|
|
416
|
+
# 4. Sorting
|
|
417
|
+
raw_files.sort()
|
|
418
|
+
|
|
419
|
+
# Process each report file
|
|
420
|
+
report_merger = PytestHTMLReportMerger()
|
|
421
|
+
|
|
422
|
+
for infile in raw_files:
|
|
423
|
+
log.info(f"Processing report: {infile}")
|
|
424
|
+
report_merger.process_report(infile)
|
|
425
|
+
|
|
426
|
+
# Finalize and write the aggregated report to disk
|
|
427
|
+
report_merger.write_report(arguments.out, arguments.title)
|
|
428
|
+
log.info(
|
|
429
|
+
f"Successfully merged {len(raw_files)} reports into {arguments.out}",
|
|
430
|
+
)
|
|
431
|
+
return
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def cli():
|
|
435
|
+
arguments = parse_arguments()
|
|
436
|
+
|
|
437
|
+
logging.basicConfig(level=int((6 - arguments.verbose) * 10))
|
|
438
|
+
|
|
439
|
+
log.debug(f"opts = {arguments}")
|
|
440
|
+
|
|
441
|
+
main(arguments)
|
|
442
|
+
|
|
443
|
+
log.debug("exiting")
|
|
444
|
+
return
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
if __name__ == "__main__":
|
|
448
|
+
cli()
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pgpro-pytest-html-merger
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A professional tool to merge multiple pytest-html reports into a single one with consistent metadata
|
|
5
|
+
Author-email: Postgres Professional <info@postgrespro.ru>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: beautifulsoup4>=4.11.1
|
|
11
|
+
Requires-Dist: packaging>=21.0
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# pgpro-pytest-html-merger
|
|
15
|
+
A professional tool to merge multiple pytest-html reports into a single, consistent HTML report. Developed and maintained by Postgres Professional.
|
|
16
|
+
|
|
17
|
+
## Key Features
|
|
18
|
+
- Smart Merging: Combines test results, logs, and metadata from multiple sources.
|
|
19
|
+
- Flexible Input: Supports individual files and entire directories.
|
|
20
|
+
- Customizable: Set your own report title and output filename.
|
|
21
|
+
- Modern Support: Fully compatible with Python 3.8 through 3.14.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
You can install the package directly from the repository (until it's published to PyPI):
|
|
25
|
+
```bash
|
|
26
|
+
pip install pgpro-pytest-html-merger
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
After installation, the tool is available via the pgpro-pytest-html-merger command.
|
|
31
|
+
|
|
32
|
+
### Basic Examples
|
|
33
|
+
Merge all reports in a directory:
|
|
34
|
+
```bash
|
|
35
|
+
pgpro-pytest-html-merger -i ./reports -o summary.html
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Merge specific files with a custom title:
|
|
39
|
+
```bash
|
|
40
|
+
pgpro-pytest-html-merger report1.html report2.html -o final.html --title "Nightly Build"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Combine directories and individual files:
|
|
44
|
+
``` bash
|
|
45
|
+
pgpro-pytest-html-merger -i ./unit-tests -i ./e2e-tests extra-report.html -o full-report.html
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Command Line Arguments
|
|
49
|
+
|
|
50
|
+
| Argument | Shorthand | Description | Default |
|
|
51
|
+
| :--- | :--- | :--- | :--- |
|
|
52
|
+
| `--input-dir` | `-i` | Directory containing HTML reports (can be used multiple times) | None |
|
|
53
|
+
| `--out` | `-o` | Name of the output HTML report | `merged.html` |
|
|
54
|
+
| `--title` | `-t` | Title of the output HTML report | None |
|
|
55
|
+
| `--verbose` | `-v` | Level of logging verbosity | 3 |
|
|
56
|
+
| `html_files` | | Positional arguments for individual HTML files | None |
|
|
57
|
+
|
|
58
|
+
## Contributing
|
|
59
|
+
1. Fork the repository.
|
|
60
|
+
2. Create your feature branch (`git checkout -b feature/amazing-feature`).
|
|
61
|
+
3. Commit your changes (`git commit -m 'feat: add some amazing feature'`).
|
|
62
|
+
4. Push to the branch (`git push origin feature/amazing-feature`).
|
|
63
|
+
5. Open a Pull Request.
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
This project is licensed under the MIT License - see the LICENSE file for details.
|
|
67
|
+
|
|
68
|
+
© 2026 Postgres Professional
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/pgpro_pytest_html_merger/__init__.py
|
|
5
|
+
src/pgpro_pytest_html_merger/__main__.py
|
|
6
|
+
src/pgpro_pytest_html_merger.egg-info/PKG-INFO
|
|
7
|
+
src/pgpro_pytest_html_merger.egg-info/SOURCES.txt
|
|
8
|
+
src/pgpro_pytest_html_merger.egg-info/dependency_links.txt
|
|
9
|
+
src/pgpro_pytest_html_merger.egg-info/entry_points.txt
|
|
10
|
+
src/pgpro_pytest_html_merger.egg-info/requires.txt
|
|
11
|
+
src/pgpro_pytest_html_merger.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pgpro_pytest_html_merger
|