pgpro-pytest-html-merger 0.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.
@@ -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,8 @@
1
+ pgpro_pytest_html_merger/__init__.py,sha256=Zn1KFblwuFHiDRdRAiRnDBRkbPttWh44jKa5zG2ov0E,22
2
+ pgpro_pytest_html_merger/__main__.py,sha256=jA8p8-O8RR-XEiO5N6gKZWqtvCw17IRHFcpTI15_CbY,13720
3
+ pgpro_pytest_html_merger-0.2.0.dist-info/licenses/LICENSE,sha256=5TohiMS-HQU-hcTbmFI5EX4qiR4jg3hyH3k92vT4oxI,1078
4
+ pgpro_pytest_html_merger-0.2.0.dist-info/METADATA,sha256=tl6v2KT4s_vl1pCEJiQUF3C3RTGUSKaSnDaDUJYNsEM,2444
5
+ pgpro_pytest_html_merger-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
6
+ pgpro_pytest_html_merger-0.2.0.dist-info/entry_points.txt,sha256=HmadYgIgk4esiBCXRS7v6XWiQ_W9w6R20M9-cr2Qfdo,83
7
+ pgpro_pytest_html_merger-0.2.0.dist-info/top_level.txt,sha256=KBFQOHGr6PRIQzg6oxq-aTlqlsx0CJPskaNh4Kyqmhs,25
8
+ pgpro_pytest_html_merger-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pgpro-pytest-html-merger = pgpro_pytest_html_merger.__main__:cli
@@ -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 @@
1
+ pgpro_pytest_html_merger