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)
Regex match for "Digits, space, name".
Regex match for "00:00:00" or "0 ms".
Regex match for "1 test took 0 ms." or "2 tests took 00:00:00.".
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.
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.
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.
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.
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.