Coverage for lobster/tools/core/html_report/html_report.py: 84%

340 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-01-09 10:06 +0000

1#!/usr/bin/env python3 

2# 

3# lobster_html_report - Visualise LOBSTER report in HTML 

4# Copyright (C) 2022-2025 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) 

5# 

6# This program is free software: you can redistribute it and/or modify 

7# it under the terms of the GNU Affero General Public License as 

8# published by the Free Software Foundation, either version 3 of the 

9# License, or (at your option) any later version. 

10# 

11# This program is distributed in the hope that it will be useful, but 

12# WITHOUT ANY WARRANTY; without even the implied warranty of 

13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 

14# Affero General Public License for more details. 

15# 

16# You should have received a copy of the GNU Affero General Public 

17# License along with this program. If not, see 

18# <https://www.gnu.org/licenses/>. 

19import os.path 

20import argparse 

21import html 

22import subprocess 

23import hashlib 

24import tempfile 

25from datetime import datetime, timezone 

26from typing import Optional, Sequence 

27 

28import markdown 

29 

30from lobster.common.version import LOBSTER_VERSION 

31from lobster.htmldoc import htmldoc 

32from lobster.common.report import Report 

33from lobster.common.io import ensure_output_directory 

34from lobster.common.location import (Void_Reference, 

35 File_Reference, 

36 Github_Reference, 

37 Codebeamer_Reference) 

38from lobster.common.items import (Tracing_Status, Item, 

39 Requirement, Implementation, 

40 Activity) 

41from lobster.common.meta_data_tool_base import MetaDataToolBase 

42from lobster.tools.core.html_report.html_report_css import CSS 

43from lobster.tools.core.html_report.html_report_js import JAVA_SCRIPT 

44 

45 

46LOBSTER_GH = "https://github.com/bmw-software-engineering/lobster" 

47 

48 

49def is_dot_available(dot): 

50 try: 

51 subprocess.run([dot if dot else "dot", "-V"], 

52 stdout=subprocess.PIPE, 

53 stderr=subprocess.PIPE, 

54 encoding="UTF-8", 

55 check=True) 

56 return True 

57 except FileNotFoundError: 

58 return False 

59 

60 

61def name_hash(name): 

62 hobj = hashlib.md5() 

63 hobj.update(name.encode("UTF-8")) 

64 return hobj.hexdigest() 

65 

66 

67def xref_item(item, link=True, brief=False): 

68 assert isinstance(item, Item) 

69 assert isinstance(link, bool) 

70 assert isinstance(brief, bool) 

71 

72 if brief: 72 ↛ 73line 72 didn't jump to line 73 because the condition on line 72 was never true

73 rv = "" 

74 elif isinstance(item, Requirement): 

75 rv = html.escape(item.framework + " " + 

76 item.kind.capitalize()) 

77 elif isinstance(item, Implementation): 

78 rv = html.escape(item.language + " " + 

79 item.kind.capitalize()) 

80 else: 

81 assert isinstance(item, Activity) 

82 rv = html.escape(item.framework + " " + 

83 item.kind.capitalize()) 

84 if not brief: 84 ↛ 87line 84 didn't jump to line 87 because the condition on line 84 was always true

85 rv += " " 

86 

87 if link: 

88 rv += f"<a href='#item-{item.tag.hash()}'>{html.escape(item.name)}</a>" 

89 else: 

90 rv += html.escape(item.name) 

91 

92 return rv 

93 

94 

95def create_policy_diagram(doc, report, dot): 

96 assert isinstance(doc, htmldoc.Document) 

97 assert isinstance(report, Report) 

98 

99 graph = 'digraph "LOBSTER Tracing Policy" {\n' 

100 for level in report.config.values(): 

101 if level.kind == "requirements": 

102 style = 'shape=box, style=rounded' 

103 elif level.kind == "implementation": 

104 style = 'shape=box' 

105 else: 

106 assert level.kind == "activity" 

107 style = 'shape=hexagon' 

108 style += f', href="#sec-{name_hash(level.name)}"' 

109 

110 graph += ' n_%s [label="%s", %s];\n' % \ 

111 (name_hash(level.name), 

112 level.name, 

113 style) 

114 

115 for level in report.config.values(): 

116 source = name_hash(level.name) 

117 for target in map(name_hash, level.traces): 

118 # Not a mistake; we want to show the tracing down, whereas 

119 # in the config file we indicate how we trace up. 

120 graph += ' n_%s -> n_%s;\n' % (target, source) 

121 graph += "}\n" 

122 

123 with tempfile.TemporaryDirectory() as tmp_dir: 

124 graph_name = os.path.join(tmp_dir, "graph.dot") 

125 with open(graph_name, "w", encoding="UTF-8") as tmp_fd: 

126 tmp_fd.write(graph) 

127 svg = subprocess.run([dot if dot else "dot", "-Tsvg", graph_name], 

128 stdout=subprocess.PIPE, 

129 encoding="UTF-8", 

130 check=True) 

131 assert svg.returncode == 0 

132 image = svg.stdout[svg.stdout.index("<svg "):] 

133 

134 for line in image.splitlines(): 

135 doc.add_line(line) 

136 

137 

138def create_item_coverage(doc, report): 

139 assert isinstance(doc, htmldoc.Document) 

140 assert isinstance(report, Report) 

141 

142 doc.add_line("<table>") 

143 doc.add_line("<thead><tr>") 

144 doc.add_line("<td>Category</td>") 

145 doc.add_line("<td>Ratio</td>") 

146 doc.add_line("<td>Coverage</td>") 

147 doc.add_line("<td>OK Items</td>") 

148 doc.add_line("<td>Total Items</td>") 

149 doc.add_line("</tr><thead>") 

150 doc.add_line("<tbody>") 

151 doc.add_line("</tbody>") 

152 for level in report.config.values(): 

153 data = report.coverage[level.name] 

154 doc.add_line( 

155 f'<tr class="coverage-table-{level.name.replace(" ", "-").lower()}">' 

156 ) 

157 doc.add_line('<td><a href="#sec-%s">%s</a></td>' % 

158 (name_hash(level.name), 

159 html.escape(level.name))) 

160 doc.add_line("<td>%.1f%%</td>" % data.coverage) 

161 doc.add_line("<td>") 

162 doc.add_line('<progress value="%u" max="%u">' % 

163 (data.ok, data.items)) 

164 doc.add_line("%.2f%%" % data.coverage) 

165 doc.add_line('</progress>') 

166 doc.add_line("</td>") 

167 doc.add_line('<td align="right">%u</td>' % data.ok) 

168 doc.add_line('<td align="right">%u</td>' % data.items) 

169 doc.add_line("</tr>") 

170 doc.add_line("</table>") 

171 

172 

173def run_git_show(commit_hash, path=None): 

174 """Run `git show` command to get the commit timestamp.""" 

175 cmd = ['git'] + (['-C', path] if path else []) + [ 

176 'show', '-s', '--format=%ct', commit_hash] 

177 try: 

178 output = subprocess.run(cmd, capture_output=True, text=True, check=True) 

179 if output.stdout.strip(): 179 ↛ 185line 179 didn't jump to line 185 because the condition on line 179 was always true

180 epoch = int(output.stdout.strip()) 

181 return str(datetime.fromtimestamp(epoch, tz=timezone.utc)) + " UTC" 

182 except subprocess.CalledProcessError: 

183 location = f"submodule path: {path}" if path else "main repository" 

184 print(f"[Warning] Could not find commit {commit_hash} in {location}.") 

185 return None 

186 

187 

188def get_commit_timestamp_utc(commit_hash, submodule_path=None): 

189 """Get commit timestamp in UTC format, either from main repo or submodule.""" 

190 timestamp = run_git_show(commit_hash) 

191 if timestamp: 191 ↛ 194line 191 didn't jump to line 194 because the condition on line 191 was always true

192 return f"{timestamp}" 

193 

194 if submodule_path: 

195 timestamp = run_git_show(commit_hash, submodule_path) 

196 if timestamp: 

197 return f"{timestamp} (from submodule at {submodule_path})" 

198 

199 return "Unknown" 

200 

201 

202def write_item_box_begin(doc, item): 

203 assert isinstance(doc, htmldoc.Document) 

204 assert isinstance(item, Item) 

205 

206 doc.add_line(f'<!-- begin item {html.escape(item.tag.key())} -->') 

207 

208 doc.add_line(f'<div class="item-{html.escape(item.tracing_status.name.lower())}" ' 

209 f'id="item-{item.tag.hash()}">') 

210 

211 doc.add_line('<div class="item-name">%s %s</div>' % 

212 ('<svg class="icon"><use href="#svg-check-square"></use></svg>' 

213 if item.tracing_status in (Tracing_Status.OK, 

214 Tracing_Status.JUSTIFIED) 

215 else '<svg class="icon"><use href="#svg-alert-triangle"></use></svg>', 

216 xref_item(item, link=False))) 

217 

218 doc.add_line('<div class="attribute">Source: ') 

219 doc.add_line('<svg class="icon"><use href="#svg-external-link"></use></svg>') 

220 

221 doc.add_line(item.location.to_html()) 

222 doc.add_line("</div>") 

223 

224 

225def write_item_tracing(doc, report, item): 

226 assert isinstance(doc, htmldoc.Document) 

227 assert isinstance(report, Report) 

228 assert isinstance(item, Item) 

229 

230 doc.add_line('<div class="attribute">') 

231 if item.ref_down: 

232 doc.add_line("<div>Traces to:") 

233 doc.add_line("<ul>") 

234 for ref in item.ref_down: 

235 doc.add_line("<li>%s</li>" % xref_item(report.items[ref.key()])) 

236 doc.add_line("</ul>") 

237 doc.add_line("</div>") 

238 if item.ref_up: 

239 doc.add_line("<div>Derived from:") 

240 doc.add_line("<ul>") 

241 for ref in item.ref_up: 

242 doc.add_line("<li>%s</li>" % xref_item(report.items[ref.key()])) 

243 doc.add_line("</ul>") 

244 doc.add_line("</div>") 

245 

246 if item.tracing_status == Tracing_Status.JUSTIFIED: 

247 doc.add_line("<div>Justifications:") 

248 doc.add_line("<ul>") 

249 for msg in item.just_global + item.just_up + item.just_down: 

250 doc.add_line("<li>%s</li>" % html.escape(msg)) 

251 doc.add_line("</ul>") 

252 doc.add_line("</div>") 

253 

254 if item.messages: 

255 doc.add_line("<div>Issues:") 

256 doc.add_line("<ul>") 

257 for msg in item.messages: 

258 doc.add_line("<li>%s</li>" % html.escape(msg)) 

259 doc.add_line("</ul>") 

260 doc.add_line("</div>") 

261 

262 doc.add_line("</div>") 

263 

264 

265def write_item_box_end(doc, item): 

266 assert isinstance(doc, htmldoc.Document) 

267 

268 if getattr(item.location, "commit", None) is not None: 

269 commit_hash = item.location.commit 

270 timestamp = get_commit_timestamp_utc(commit_hash, item.location.gh_repo) 

271 doc.add_line( 

272 f'<div class="attribute">' 

273 f'Build Reference: <strong>{commit_hash}</strong> | ' 

274 f'Timestamp: {timestamp}' 

275 f'</div>' 

276 ) 

277 doc.add_line("</div>") 

278 doc.add_line('<!-- end item -->') 

279 

280 

281def generate_custom_data(report) -> str: 

282 content = [ 

283 f"{key}: {value}<br>" 

284 for key, value in report.custom_data.items() 

285 if value 

286 ] 

287 return "".join(content) 

288 

289 

290def write_html_to_file(html_content: str, output_path: str) -> None: 

291 """Write HTML content to file, creating parent directories if needed.""" 

292 ensure_output_directory(output_path) 

293 with open(output_path, "w", encoding="UTF-8") as fd: 

294 fd.write(html_content) 

295 fd.write("\n") 

296 

297 

298def write_html(report, dot, high_contrast, render_md) -> str: 

299 assert isinstance(report, Report) 

300 

301 doc = htmldoc.Document( 

302 "L.O.B.S.T.E.R.", 

303 "Lightweight Open BMW Software Traceability Evidence Report" 

304 ) 

305 

306 # Item styles 

307 doc.style["#custom-data-banner"] = { 

308 "position": "absolute", 

309 "top": "1em", 

310 "right": "2em", 

311 "font-size": "0.9em", 

312 "color": "white", 

313 } 

314 doc.style[".item-ok, .item-partial, .item-missing, .item-justified"] = { 

315 "border" : "1px solid black", 

316 "border-radius" : "0.5em", 

317 "margin-top" : "0.4em", 

318 "padding" : "0.25em", 

319 } 

320 doc.style[".item-ok:target, " 

321 ".item-partial:target, " 

322 ".item-missing:target, " 

323 ".item-justified:target"] = { 

324 "border" : "3px solid black", 

325 } 

326 doc.style[".subtle-ok, " 

327 ".subtle-partial, " 

328 ".subtle-missing, " 

329 ".subtle-justified"] = { 

330 "padding-left" : "0.2em", 

331 } 

332 doc.style[".item-ok"] = { 

333 "background-color" : "#b2e1b2" if high_contrast else "#efe", 

334 } 

335 doc.style[".item-partial"] = { 

336 "background-color" : "#ffe", 

337 } 

338 doc.style[".item-missing"] = { 

339 "background-color" : "#ffb2ff" if high_contrast else "#fee", 

340 } 

341 doc.style[".item-justified"] = { 

342 "background-color" : "#eee", 

343 } 

344 doc.style[".subtle-ok"] = { 

345 "border-left" : "0.2em solid #8f8", 

346 } 

347 doc.style[".subtle-partial"] = { 

348 "border-left" : "0.2em solid #ff8", 

349 } 

350 doc.style[".subtle-missing"] = { 

351 "border-left" : "0.2em solid #f88", 

352 } 

353 doc.style[".subtle-justified"] = { 

354 "border-left" : "0.2em solid #888", 

355 } 

356 doc.style[".item-name"] = { 

357 "font-size" : "125%", 

358 "font-weight" : "bold", 

359 } 

360 doc.style[".attribute"] = { 

361 "margin-top" : "0.5em", 

362 } 

363 

364 # Render MD 

365 if render_md: 

366 doc.style[".md_description"] = { 

367 "font-style" : "unset", 

368 } 

369 doc.style[".md_description h1"] = { 

370 "padding" : "unset", 

371 "margin" : "unset" 

372 } 

373 doc.style[".md_description h2"] = { 

374 "padding" : "unset", 

375 "margin" : "unset", 

376 "border-bottom" : "unset", 

377 "text-align" : "unset" 

378 } 

379 

380 # Columns 

381 doc.style[".columns"] = { 

382 "display" : "flex", 

383 } 

384 doc.style[".columns .column"] = { 

385 "flex" : "45%", 

386 } 

387 

388 # Tables 

389 doc.style["thead tr"] = { 

390 "font-weight" : "bold", 

391 } 

392 doc.style["tbody tr.alt"] = { 

393 "background-color" : "#eee", 

394 } 

395 

396 # Text 

397 doc.style["blockquote"] = { 

398 "font-style" : "italic", 

399 "border-left" : "0.2em solid gray", 

400 "padding-left" : "0.4em", 

401 "margin-left" : "0.5em", 

402 } 

403 

404 # Footer 

405 doc.style["footer"] = { 

406 "margin-top" : "1rem", 

407 "padding" : ".2rem", 

408 "text-align" : "right", 

409 "color" : "#666", 

410 "font-size" : ".7rem", 

411 } 

412 

413 ### Menu & Navigation 

414 doc.navbar.add_link("Overview", "#sec-overview") 

415 doc.navbar.add_link("Issues", "#sec-issues") 

416 menu = doc.navbar.add_dropdown("Detailed report") 

417 for level in report.config.values(): 

418 menu.add_link(level.name, "#sec-" + name_hash(level.name)) 

419 # doc.navbar.add_link("Software Traceability Matrix", "#matrix") 

420 if report.custom_data: 

421 content = generate_custom_data(report) 

422 doc.add_line(f'<div id="custom-data-banner">{content}</div>') 

423 menu = doc.navbar.add_dropdown("LOBSTER", "right") 

424 menu.add_link("Documentation", 

425 "%s/blob/main/README.md" % LOBSTER_GH) 

426 menu.add_link("License", 

427 "%s/blob/main/LICENSE.md" % LOBSTER_GH) 

428 menu.add_link("Source", LOBSTER_GH) 

429 

430 ### Summary (Coverage & Policy) 

431 doc.add_heading(2, "Overview", "overview", html_identifier=True) 

432 doc.add_line('<div class="columns">') 

433 doc.add_line('<div class="column">') 

434 doc.add_heading(3, "Coverage", html_identifier=True) 

435 create_item_coverage(doc, report) 

436 doc.add_line('</div>') 

437 if is_dot_available(dot): 437 ↛ 438line 437 didn't jump to line 438 because the condition on line 437 was never true

438 doc.add_line('<div class="column">') 

439 doc.add_heading(3, "Tracing policy") 

440 create_policy_diagram(doc, report, dot) 

441 doc.add_line('</div>') 

442 else: 

443 print("warning: dot utility not found, report will not " 

444 "include the tracing policy visualisation") 

445 print("> please install Graphviz (https://graphviz.org)") 

446 doc.add_line('</div>') 

447 

448 ### Filtering 

449 doc.add_heading(2, "Filtering", "filtering-options", html_identifier=True) 

450 doc.add_heading(3, "Item Filters", html_identifier=True) 

451 doc.add_line('<div id = "btnFilterItem">') 

452 doc.add_line('<button class="button buttonAll buttonActive" ' 

453 'onclick="buttonFilter(\'all\')"> Show All </button>') 

454 

455 doc.add_line('<button class ="button buttonOK" ' 

456 'onclick="buttonFilter(\'ok\')" > OK </button>') 

457 

458 doc.add_line('<button class ="button buttonMissing" ' 

459 'onclick="buttonFilter(\'missing\')" > Missing </button>') 

460 

461 doc.add_line('<button class ="button buttonPartial" ' 

462 'onclick="buttonFilter(\'partial\')" > Partial </button>') 

463 

464 doc.add_line('<button class ="button buttonJustified" ' 

465 'onclick="buttonFilter(\'justified\')" > Justified </button>') 

466 

467 doc.add_line('<button class ="button buttonWarning" ' 

468 'onclick="buttonFilter(\'warning\')" > Warning </button>') 

469 doc.add_line("</div>") 

470 

471 doc.add_heading(3, "Show Issues", html_identifier=True) 

472 doc.add_line('<div id = "ContainerBtnToggleIssue">') 

473 doc.add_line('<button class ="button buttonBlue" id="BtnToggleIssue" ' 

474 'onclick="ToggleIssues()"> Show Issues </button>') 

475 doc.add_line('</div>') 

476 

477 doc.add_heading(3, "Filter", "filter", html_identifier=True) 

478 doc.add_line('<input type="text" id="search" placeholder="Filter..." ' 

479 'onkeyup="searchItem()">') 

480 doc.add_line('<div id="search-sec-id"') 

481 

482 ### Issues 

483 doc.add_heading(2, "Issues", "issues", html_identifier=True) 

484 doc.add_line('<div id="issues-section" style="display:none">') 

485 has_issues = False 

486 for item in sorted(report.items.values(), 

487 key = lambda x: x.location.sorting_key()): 

488 if item.tracing_status not in (Tracing_Status.OK, 

489 Tracing_Status.JUSTIFIED): 

490 for message in item.messages: 

491 if not has_issues: 

492 has_issues = True 

493 doc.add_line("<ul>") 

494 doc.add_line( 

495 f'<li class="issue issue-{item.tracing_status.name.lower()}' 

496 f' issue-{item.tracing_status.name.lower()}-' 

497 f'{item.tag.namespace}">{xref_item(item)}: {message}</li>' 

498 ) 

499 if has_issues: 

500 doc.add_line("</ul>") 

501 else: 

502 doc.add_line("<div>No traceability issues found.</div>") 

503 doc.add_line("</div>") 

504 

505 ### Report 

506 file_heading = None 

507 doc.add_heading(2, "Detailed report", "detailed-report", html_identifier=True) 

508 items_by_level = {} 

509 for level in report.config: 

510 items_by_level[level] = [item 

511 for item in report.items.values() 

512 if item.level == level] 

513 for kind, title in [("requirements", 

514 "Requirements and Specification"), 

515 ("implementation", 

516 "Implementation"), 

517 ("activity", 

518 "Verification and Validation")]: 

519 doc.add_line(f'<div class="detailed-report-{title.lower().replace(" ", "-")}">') 

520 doc.add_heading(3, title, html_identifier=True) 

521 for level in report.config.values(): 

522 if level.kind != kind: 

523 continue 

524 doc.add_line(f'<div id="section-{level.name.lower().replace(" ", "-")}">') 

525 doc.add_heading(4, 

526 html.escape(level.name), 

527 name_hash(level.name), 

528 html_identifier=True, 

529 ) 

530 if items_by_level[level.name]: 530 ↛ 568line 530 didn't jump to line 568 because the condition on line 530 was always true

531 for item in sorted(items_by_level[level.name], 

532 key = lambda x: x.location.sorting_key()): 

533 if isinstance(item.location, Void_Reference): 533 ↛ 534line 533 didn't jump to line 534 because the condition on line 533 was never true

534 new_file_heading = "Unknown" 

535 elif isinstance(item.location, (File_Reference, 

536 Github_Reference)): 

537 new_file_heading = item.location.filename 

538 elif isinstance(item.location, Codebeamer_Reference): 

539 new_file_heading = "Codebeamer %s, tracker %u" % \ 

540 (item.location.cb_root, 

541 item.location.tracker) 

542 else: # pragma: no cover 

543 assert False 

544 if new_file_heading != file_heading: 

545 file_heading = new_file_heading 

546 doc.add_heading(5, html.escape(file_heading)) 

547 

548 write_item_box_begin(doc, item) 

549 if isinstance(item, Requirement) and item.status: 

550 doc.add_line('<div class="attribute">') 

551 doc.add_line("Status: %s" % html.escape(item.status)) 

552 doc.add_line('</div>') 

553 if isinstance(item, Requirement) and item.text: 

554 if render_md: 

555 bq_class = ' class="md_description"' 

556 bq_text = markdown.markdown(item.text, 

557 extensions=['tables']) 

558 else: 

559 bq_class = "" 

560 bq_text = html.escape(item.text).replace("\n", "<br>") 

561 

562 doc.add_line('<div class="attribute">') 

563 doc.add_line(f"<blockquote{bq_class}>{bq_text}</blockquote>") 

564 doc.add_line('</div>') 

565 write_item_tracing(doc, report, item) 

566 write_item_box_end(doc, item) 

567 else: 

568 doc.add_line("No items recorded at this level.") 

569 doc.add_line("</div>") # Closing tag for id #level.name 

570 doc.add_line("</div>") # Closing tag for detailed-report-<title> 

571 # Closing tag for id #search-sec-id 

572 doc.add_line("</div>") 

573 # Add LOBSTER version in the footer. 

574 doc.add_line("<footer>") 

575 doc.add_line(f"<p>LOBSTER Version: {LOBSTER_VERSION}</p>") 

576 doc.add_line("</footer>") 

577 

578 # Add the css from assets 

579 doc.css.append(CSS.lstrip()) 

580 

581 # Add javascript from assets/html_report.js file 

582 doc.scripts.append(JAVA_SCRIPT.lstrip()) 

583 

584 return doc.render() 

585 

586 

587class HtmlReportTool(MetaDataToolBase): 

588 def __init__(self): 

589 super().__init__( 

590 name="html-report", 

591 description="Visualise LOBSTER report in HTML", 

592 official=True, 

593 ) 

594 

595 ap = self._argument_parser 

596 ap.add_argument("lobster_report", 

597 nargs="?", 

598 default="report.lobster") 

599 ap.add_argument("--out", 

600 default="lobster_report.html") 

601 ap.add_argument("--dot", 

602 help="path to dot utility (https://graphviz.org), \ 

603 by default expected in PATH", 

604 default=None) 

605 ap.add_argument("--high-contrast", 

606 action="store_true", 

607 help="Uses a color palette with a higher contrast.") 

608 ap.add_argument("--render-md", 

609 action="store_true", 

610 help="Renders MD in description.") 

611 

612 def _run_impl(self, options: argparse.Namespace) -> int: 

613 if not os.path.isfile(options.lobster_report): 

614 self._argument_parser.error(f"{options.lobster_report} is not a file") 

615 

616 report = Report() 

617 report.load_report(options.lobster_report) 

618 

619 html_content = write_html( 

620 report = report, 

621 dot = options.dot, 

622 high_contrast = options.high_contrast, 

623 render_md = options.render_md, 

624 ) 

625 write_html_to_file(html_content, options.out) 

626 print(f"LOBSTER HTML report written to {options.out}") 

627 

628 return 0 

629 

630 

631def lobster_html_report( 

632 lobster_report_path: str, 

633 output_html_path: str, 

634 dot_path: str = None, 

635 high_contrast: bool = False, 

636 render_md: bool = False 

637) -> None: 

638 """ 

639 API function to generate an HTML report from a LOBSTER report file. 

640 

641 Args: 

642 lobster_report_path (str): Path to the input LOBSTER report file. 

643 output_html_path (str): Path to the output HTML file. 

644 dot_path (str, optional): Path to the Graphviz 'dot' utility. 

645 high_contrast (bool, optional): Use high contrast colors. 

646 render_md (bool, optional): Render Markdown in descriptions. 

647 """ 

648 report = Report() 

649 report.load_report(lobster_report_path) 

650 html_content = write_html( 

651 report=report, 

652 dot=dot_path, 

653 high_contrast=high_contrast, 

654 render_md=render_md, 

655 ) 

656 write_html_to_file(html_content, output_html_path) 

657 

658 

659def main(args: Optional[Sequence[str]] = None) -> int: 

660 return HtmlReportTool().run(args)