hitl_tester.modules.html_merger

Merge multiple HTML files together.

(c) 2020-2024 TurnAround Factor, Inc.

CUI DISTRIBUTION CONTROL Controlled by: DLA J68 R&D SBIP CUI Category: Small Business Research and Technology Distribution/Dissemination Controls: PROTECTED BY SBIR DATA RIGHTS POC: GOV SBIP Program Manager Denise Price, 571-767-0111 Distribution authorized to U.S. Government Agencies only, to protect information not owned by the U.S. Government and protected by a contractor’s ‘limited rights’ statement, or received with the understanding that it is not routinely transmitted outside the U.S. Government (determination made September 14, 2024). Other requests for this document shall be referred to ACOR DLA Logistics Operations (J-68), 8725 John J. Kingman Rd., Suite 4317, Fort Belvoir, VA 22060-6221

SBIR DATA RIGHTS Contract No.:SP4701-23-C-0083 Contractor Name: TurnAround Factor, Inc. Contractor Address: 10365 Wood Park Ct. Suite 313 / Ashland, VA 23005 Expiration of SBIR Data Rights Period: September 24, 2029 The Government's rights to use, modify, reproduce, release, perform, display, or disclose technical data or computer software marked with this legend are restricted during the period shown as provided in paragraph (b)(4) of the Rights in Noncommercial Technical Data and Computer Software--Small Business Innovative Research (SBIR) Program clause contained in the above identified contract. No restrictions apply after the expiration date shown above. Any reproduction of technical data, computer software, or portions thereof marked with this legend must also reproduce the markings.

  1"""
  2Merge multiple HTML files together.
  3
  4(c) 2020-2024 TurnAround Factor, Inc.
  5
  6CUI DISTRIBUTION CONTROL
  7Controlled by: DLA J68 R&D SBIP
  8CUI Category: Small Business Research and Technology
  9Distribution/Dissemination Controls: PROTECTED BY SBIR DATA RIGHTS
 10POC: GOV SBIP Program Manager Denise Price, 571-767-0111
 11Distribution authorized to U.S. Government Agencies only, to protect information not owned by the
 12U.S. Government and protected by a contractor’s ‘limited rights’ statement, or received with the understanding that
 13it is not routinely transmitted outside the U.S. Government (determination made September 14, 2024). Other requests
 14for this document shall be referred to ACOR DLA Logistics Operations (J-68), 8725 John J. Kingman Rd., Suite 4317,
 15Fort Belvoir, VA 22060-6221
 16
 17SBIR DATA RIGHTS
 18Contract No.:SP4701-23-C-0083
 19Contractor Name: TurnAround Factor, Inc.
 20Contractor Address: 10365 Wood Park Ct. Suite 313 / Ashland, VA 23005
 21Expiration of SBIR Data Rights Period: September 24, 2029
 22The Government's rights to use, modify, reproduce, release, perform, display, or disclose technical data or computer
 23software marked with this legend are restricted during the period shown as provided in paragraph (b)(4) of the Rights
 24in Noncommercial Technical Data and Computer Software--Small Business Innovative Research (SBIR) Program clause
 25contained in the above identified contract. No restrictions apply after the expiration date shown above. Any
 26reproduction of technical data, computer software, or portions thereof marked with this legend must also reproduce
 27the markings.
 28"""
 29
 30import json
 31import pathlib
 32import re
 33from datetime import timedelta
 34from typing import Any
 35
 36from typing_extensions import Self
 37
 38from bs4 import BeautifulSoup
 39
 40
 41CHECKBOX_PATTERN = re.compile(r"(\d+) (.*)")
 42"""Regex match for "Digits, space, name"."""
 43
 44DURATION_PATTERN = re.compile(r"(\d+):(\d\d):(\d\d)|(\d+) ms")
 45"""Regex match for "00:00:00" or "0 ms"."""
 46
 47COUNT_DURATION_PATTERN = re.compile(r"^(\d*) tests? took (\d+:\d\d:\d\d|\d+ ms)\.")
 48"""Regex match for "1 test took 0 ms." or "2 tests took 00:00:00."."""
 49
 50
 51def merge(in_path: pathlib.Path):
 52    """Merge multiple HTML files together."""
 53
 54    # Get all valid html files
 55    paths = []
 56    for html_file in sorted(in_path.rglob("*.html"), key=lambda glob_path: glob_path.stat().st_mtime):
 57        tmp = BeautifulSoup(html_file.read_text(), features="html.parser")
 58        if (p := tmp.find("p")) and p.text.startswith("Report generated on "):
 59            paths.append(html_file.absolute())
 60    if len(paths) < 2:
 61        return
 62
 63    # Use first file as base file
 64    template_file = paths.pop(0)
 65    template_html = BeautifulSoup(template_file.read_text(), features="html.parser")
 66
 67    # Change report description
 68    if (p := template_html.find("p")) and p.text.startswith("Report generated on "):
 69        p.string = f"Composite {p.text}".capitalize()
 70
 71    # Merge test rows
 72    template_data = json.loads(template_html.find("div", {"id": "data-container"}).get("data-jsonblob"))
 73    template_data["tests"] = {f"{template_file.stem}:{key}": value for key, value in template_data["tests"].items()}
 74    for path in paths:
 75        test_html = BeautifulSoup(path.read_text(), features="html.parser")
 76        test_data = json.loads(test_html.find("div", {"id": "data-container"}).get("data-jsonblob"))
 77        test_data["tests"] = {f"{path.stem}:{key}": value for key, value in test_data["tests"].items()}
 78
 79        # Add test to row
 80        template_data["tests"].update(test_data["tests"])
 81        increment_test_count_and_duration(template_html, test_html)
 82        increment_checkbox_values(template_html, test_html)
 83        increment_environment(template_data, test_data)
 84    template_html.find("div", {"id": "data-container"})["data-jsonblob"] = json.dumps(template_data)
 85
 86    # Write to a file (using the input folder as the name)
 87    output_path = in_path.joinpath(f"Composite_{in_path.stem}").with_suffix(".html")
 88    output_path.write_text(str(template_html))
 89
 90
 91class Duration:
 92    """A string formatted duration."""
 93
 94    def __init__(self, duration: str | float | timedelta):
 95        match duration:
 96            case str():
 97                self.value = self._str_to_float(duration)
 98            case float():
 99                self.value = duration
100            case timedelta():
101                self.value = duration.total_seconds()
102
103    def __add__(self, other_duration: Self) -> Self:
104        """Increment duration."""
105        return type(self)(self.value + other_duration.value)
106
107    def __str__(self) -> str:
108        """Format duration into the pytest-html format."""
109        if self.value < 1:
110            return f"{self.value * 1000:.0f} ms"
111
112        hours, seconds = divmod(self.value, 3600)
113        minutes, seconds = divmod(seconds, 60)
114
115        return f"{hours:02.0f}:{minutes:02.0f}:{seconds:02.0f}"
116
117    @staticmethod
118    def _str_to_float(string: str) -> float:
119        """Convert formatted duration to float."""
120        if (match := DURATION_PATTERN.match(string)) is None:
121            raise ValueError(f'Invalid duration format: {string}. Should be "00:00:00" or "0 ms".')
122        return (
123            timedelta(hours=int(match[1]), minutes=int(match[2]), seconds=int(match[3])).total_seconds()  # 00:00:00
124            if match[4] is None
125            else float(match[4]) / 1000  # 0 ms
126        )
127
128
129def increment_environment(template_data: dict[Any, Any], test_data: dict[Any, Any]):
130    """Combine environment sections."""
131
132    # Append to hardware config
133    template_config = template_data["environment"]["Hardware Config"].split(", ")
134    template_config.append(test_data["environment"]["Hardware Config"])
135    template_data["environment"]["Hardware Config"] = ", ".join(dict.fromkeys(template_config))
136
137    # Append to expected time
138    estimated_duration = Duration(template_data["environment"]["Estimated Runtime (hh:mm:ss)"])
139    estimated_duration += Duration(test_data["environment"]["Estimated Runtime (hh:mm:ss)"])
140    template_data["environment"]["Estimated Runtime (hh:mm:ss)"] = str(estimated_duration)
141
142
143def increment_test_count_and_duration(template_html: BeautifulSoup, test_html: BeautifulSoup):
144    """Increment the count and duration values based on the test html."""
145
146    # Get test count/duration
147    run_count_element = test_html.find("p", {"class": "run-count"})
148    if (match := COUNT_DURATION_PATTERN.match(run_count_element.text)) is None:
149        raise RuntimeError("run-count not found")
150    test_count = int(match[1])
151    test_duration = Duration(match[2])
152
153    # Get template count/duration
154    run_count_element = template_html.find("p", {"class": "run-count"})
155    if (match := COUNT_DURATION_PATTERN.match(run_count_element.text)) is None:
156        raise RuntimeError("run-count not found")
157    test_count += int(match[1])
158    test_duration += Duration(match[2])
159
160    # Modify template count/duration
161    run_count_element.string = f"{test_count} test{'s' if test_count else ''} took {test_duration!s}."
162
163
164def increment_checkbox_values(template_html: BeautifulSoup, test_html: BeautifulSoup):
165    """Increase checkbox values based on the test html."""
166
167    for checkbox_class in ("passed", "skipped", "failed", "error", "xfailed", "xpassed", "rerun"):
168        # Get test checkbox count
169        test_checkbox_text_element = test_html.find("span", {"class": checkbox_class})
170        if (match := CHECKBOX_PATTERN.fullmatch(test_checkbox_text_element.text)) is None:
171            raise RuntimeError(f"{checkbox_class} not found")
172        total_count = int(match[1])
173
174        # Get template checkbox count
175        template_checkbox_text_element = template_html.find("span", {"class": checkbox_class})
176        if (match := CHECKBOX_PATTERN.fullmatch(template_checkbox_text_element.text)) is None:
177            raise RuntimeError(f"{checkbox_class} not found")
178        total_count += int(match[1])
179
180        # Modify template value
181        template_checkbox_text_element.string = f"{total_count} {match[2]}"
182        template_checkbox_element = template_html.find("input", {"data-test-result": checkbox_class})
183        if total_count != 0:
184            del template_checkbox_element["disabled"]
185            del template_checkbox_element["hidden"]
186
187
188if __name__ == "__main__":
189    merge(pathlib.Path(__file__).absolute().parent)
CHECKBOX_PATTERN = re.compile('(\\d+) (.*)')

Regex match for "Digits, space, name".

DURATION_PATTERN = re.compile('(\\d+):(\\d\\d):(\\d\\d)|(\\d+) ms')

Regex match for "00:00:00" or "0 ms".

COUNT_DURATION_PATTERN = re.compile('^(\\d*) tests? took (\\d+:\\d\\d:\\d\\d|\\d+ ms)\\.')

Regex match for "1 test took 0 ms." or "2 tests took 00:00:00.".

def merge(in_path: pathlib.Path):
52def merge(in_path: pathlib.Path):
53    """Merge multiple HTML files together."""
54
55    # Get all valid html files
56    paths = []
57    for html_file in sorted(in_path.rglob("*.html"), key=lambda glob_path: glob_path.stat().st_mtime):
58        tmp = BeautifulSoup(html_file.read_text(), features="html.parser")
59        if (p := tmp.find("p")) and p.text.startswith("Report generated on "):
60            paths.append(html_file.absolute())
61    if len(paths) < 2:
62        return
63
64    # Use first file as base file
65    template_file = paths.pop(0)
66    template_html = BeautifulSoup(template_file.read_text(), features="html.parser")
67
68    # Change report description
69    if (p := template_html.find("p")) and p.text.startswith("Report generated on "):
70        p.string = f"Composite {p.text}".capitalize()
71
72    # Merge test rows
73    template_data = json.loads(template_html.find("div", {"id": "data-container"}).get("data-jsonblob"))
74    template_data["tests"] = {f"{template_file.stem}:{key}": value for key, value in template_data["tests"].items()}
75    for path in paths:
76        test_html = BeautifulSoup(path.read_text(), features="html.parser")
77        test_data = json.loads(test_html.find("div", {"id": "data-container"}).get("data-jsonblob"))
78        test_data["tests"] = {f"{path.stem}:{key}": value for key, value in test_data["tests"].items()}
79
80        # Add test to row
81        template_data["tests"].update(test_data["tests"])
82        increment_test_count_and_duration(template_html, test_html)
83        increment_checkbox_values(template_html, test_html)
84        increment_environment(template_data, test_data)
85    template_html.find("div", {"id": "data-container"})["data-jsonblob"] = json.dumps(template_data)
86
87    # Write to a file (using the input folder as the name)
88    output_path = in_path.joinpath(f"Composite_{in_path.stem}").with_suffix(".html")
89    output_path.write_text(str(template_html))

Merge multiple HTML files together.

class Duration:
 92class Duration:
 93    """A string formatted duration."""
 94
 95    def __init__(self, duration: str | float | timedelta):
 96        match duration:
 97            case str():
 98                self.value = self._str_to_float(duration)
 99            case float():
100                self.value = duration
101            case timedelta():
102                self.value = duration.total_seconds()
103
104    def __add__(self, other_duration: Self) -> Self:
105        """Increment duration."""
106        return type(self)(self.value + other_duration.value)
107
108    def __str__(self) -> str:
109        """Format duration into the pytest-html format."""
110        if self.value < 1:
111            return f"{self.value * 1000:.0f} ms"
112
113        hours, seconds = divmod(self.value, 3600)
114        minutes, seconds = divmod(seconds, 60)
115
116        return f"{hours:02.0f}:{minutes:02.0f}:{seconds:02.0f}"
117
118    @staticmethod
119    def _str_to_float(string: str) -> float:
120        """Convert formatted duration to float."""
121        if (match := DURATION_PATTERN.match(string)) is None:
122            raise ValueError(f'Invalid duration format: {string}. Should be "00:00:00" or "0 ms".')
123        return (
124            timedelta(hours=int(match[1]), minutes=int(match[2]), seconds=int(match[3])).total_seconds()  # 00:00:00
125            if match[4] is None
126            else float(match[4]) / 1000  # 0 ms
127        )

A string formatted duration.

Duration(duration: str | float | datetime.timedelta)
 95    def __init__(self, duration: str | float | timedelta):
 96        match duration:
 97            case str():
 98                self.value = self._str_to_float(duration)
 99            case float():
100                self.value = duration
101            case timedelta():
102                self.value = duration.total_seconds()
def increment_environment( template_data: dict[typing.Any, typing.Any], test_data: dict[typing.Any, typing.Any]):
130def increment_environment(template_data: dict[Any, Any], test_data: dict[Any, Any]):
131    """Combine environment sections."""
132
133    # Append to hardware config
134    template_config = template_data["environment"]["Hardware Config"].split(", ")
135    template_config.append(test_data["environment"]["Hardware Config"])
136    template_data["environment"]["Hardware Config"] = ", ".join(dict.fromkeys(template_config))
137
138    # Append to expected time
139    estimated_duration = Duration(template_data["environment"]["Estimated Runtime (hh:mm:ss)"])
140    estimated_duration += Duration(test_data["environment"]["Estimated Runtime (hh:mm:ss)"])
141    template_data["environment"]["Estimated Runtime (hh:mm:ss)"] = str(estimated_duration)

Combine environment sections.

def increment_test_count_and_duration(template_html: bs4.BeautifulSoup, test_html: bs4.BeautifulSoup):
144def increment_test_count_and_duration(template_html: BeautifulSoup, test_html: BeautifulSoup):
145    """Increment the count and duration values based on the test html."""
146
147    # Get test count/duration
148    run_count_element = test_html.find("p", {"class": "run-count"})
149    if (match := COUNT_DURATION_PATTERN.match(run_count_element.text)) is None:
150        raise RuntimeError("run-count not found")
151    test_count = int(match[1])
152    test_duration = Duration(match[2])
153
154    # Get template count/duration
155    run_count_element = template_html.find("p", {"class": "run-count"})
156    if (match := COUNT_DURATION_PATTERN.match(run_count_element.text)) is None:
157        raise RuntimeError("run-count not found")
158    test_count += int(match[1])
159    test_duration += Duration(match[2])
160
161    # Modify template count/duration
162    run_count_element.string = f"{test_count} test{'s' if test_count else ''} took {test_duration!s}."

Increment the count and duration values based on the test html.

def increment_checkbox_values(template_html: bs4.BeautifulSoup, test_html: bs4.BeautifulSoup):
165def increment_checkbox_values(template_html: BeautifulSoup, test_html: BeautifulSoup):
166    """Increase checkbox values based on the test html."""
167
168    for checkbox_class in ("passed", "skipped", "failed", "error", "xfailed", "xpassed", "rerun"):
169        # Get test checkbox count
170        test_checkbox_text_element = test_html.find("span", {"class": checkbox_class})
171        if (match := CHECKBOX_PATTERN.fullmatch(test_checkbox_text_element.text)) is None:
172            raise RuntimeError(f"{checkbox_class} not found")
173        total_count = int(match[1])
174
175        # Get template checkbox count
176        template_checkbox_text_element = template_html.find("span", {"class": checkbox_class})
177        if (match := CHECKBOX_PATTERN.fullmatch(template_checkbox_text_element.text)) is None:
178            raise RuntimeError(f"{checkbox_class} not found")
179        total_count += int(match[1])
180
181        # Modify template value
182        template_checkbox_text_element.string = f"{total_count} {match[2]}"
183        template_checkbox_element = template_html.find("input", {"data-test-result": checkbox_class})
184        if total_count != 0:
185            del template_checkbox_element["disabled"]
186            del template_checkbox_element["hidden"]

Increase checkbox values based on the test html.