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

337 statements  

« prev     ^ index     » next       coverage.py v7.10.5, created at 2025-08-27 13:02 +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.location import (Void_Reference, 

34 File_Reference, 

35 Github_Reference, 

36 Codebeamer_Reference) 

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

38 Requirement, Implementation, 

39 Activity) 

40from lobster.common.meta_data_tool_base import MetaDataToolBase 

41 

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

43 

44 

45def is_dot_available(dot): 

46 try: 

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

48 stdout=subprocess.PIPE, 

49 stderr=subprocess.PIPE, 

50 encoding="UTF-8", 

51 check=True) 

52 return True 

53 except FileNotFoundError: 

54 return False 

55 

56 

57def name_hash(name): 

58 hobj = hashlib.md5() 

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

60 return hobj.hexdigest() 

61 

62 

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

64 assert isinstance(item, Item) 

65 assert isinstance(link, bool) 

66 assert isinstance(brief, bool) 

67 

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

69 rv = "" 

70 elif isinstance(item, Requirement): 

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

72 item.kind.capitalize()) 

73 elif isinstance(item, Implementation): 73 ↛ 77line 73 didn't jump to line 77 because the condition on line 73 was always true

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

75 item.kind.capitalize()) 

76 else: 

77 assert isinstance(item, Activity) 

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

79 item.kind.capitalize()) 

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

81 rv += " " 

82 

83 if link: 

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

85 else: 

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

87 

88 return rv 

89 

90 

91def create_policy_diagram(doc, report, dot): 

92 assert isinstance(doc, htmldoc.Document) 

93 assert isinstance(report, Report) 

94 

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

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

97 if level.kind == "requirements": 

98 style = 'shape=box, style=rounded' 

99 elif level.kind == "implementation": 

100 style = 'shape=box' 

101 else: 

102 assert level.kind == "activity" 

103 style = 'shape=hexagon' 

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

105 

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

107 (name_hash(level.name), 

108 level.name, 

109 style) 

110 

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

112 source = name_hash(level.name) 

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

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

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

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

117 graph += "}\n" 

118 

119 with tempfile.TemporaryDirectory() as tmp_dir: 

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

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

122 tmp_fd.write(graph) 

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

124 stdout=subprocess.PIPE, 

125 encoding="UTF-8", 

126 check=True) 

127 assert svg.returncode == 0 

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

129 

130 for line in image.splitlines(): 

131 doc.add_line(line) 

132 

133 

134def create_item_coverage(doc, report): 

135 assert isinstance(doc, htmldoc.Document) 

136 assert isinstance(report, Report) 

137 

138 doc.add_line("<table>") 

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

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

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

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

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

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

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

146 doc.add_line("<tbody>") 

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

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

149 data = report.coverage[level.name] 

150 doc.add_line( 

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

152 ) 

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

154 (name_hash(level.name), 

155 html.escape(level.name))) 

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

157 doc.add_line("<td>") 

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

159 (data.ok, data.items)) 

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

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

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

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

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

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

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

167 

168 

169def run_git_show(commit_hash, path=None): 

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

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

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

173 try: 

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

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

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

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

178 except subprocess.CalledProcessError: 

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

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

181 return None 

182 

183 

184def get_commit_timestamp_utc(commit_hash, submodule_path=None): 

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

186 timestamp = run_git_show(commit_hash) 

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

188 return f"{timestamp}" 

189 

190 if submodule_path: 

191 timestamp = run_git_show(commit_hash, submodule_path) 

192 if timestamp: 

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

194 

195 return "Unknown" 

196 

197 

198def write_item_box_begin(doc, item): 

199 assert isinstance(doc, htmldoc.Document) 

200 assert isinstance(item, Item) 

201 

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

203 

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

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

206 

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

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

209 if item.tracing_status in (Tracing_Status.OK, 

210 Tracing_Status.JUSTIFIED) 

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

212 xref_item(item, link=False))) 

213 

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

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

216 

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

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

219 

220 

221def write_item_tracing(doc, report, item): 

222 assert isinstance(doc, htmldoc.Document) 

223 assert isinstance(report, Report) 

224 assert isinstance(item, Item) 

225 

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

227 if item.ref_down: 227 ↛ 228line 227 didn't jump to line 228 because the condition on line 227 was never true

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

229 doc.add_line("<ul>") 

230 for ref in item.ref_down: 

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

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

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

234 if item.ref_up: 234 ↛ 235line 234 didn't jump to line 235 because the condition on line 234 was never true

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

236 doc.add_line("<ul>") 

237 for ref in item.ref_up: 

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

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

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

241 

242 if item.tracing_status == Tracing_Status.JUSTIFIED: 242 ↛ 243line 242 didn't jump to line 243 because the condition on line 242 was never true

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

244 doc.add_line("<ul>") 

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

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

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

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

249 

250 if item.messages: 

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

252 doc.add_line("<ul>") 

253 for msg in item.messages: 

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

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

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

257 

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

259 

260 

261def write_item_box_end(doc, item): 

262 assert isinstance(doc, htmldoc.Document) 

263 

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

265 commit_hash = item.location.commit 

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

267 doc.add_line( 

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

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

270 f'Timestamp: {timestamp}' 

271 f'</div>' 

272 ) 

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

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

275 

276 

277def generate_custom_data(report) -> str: 

278 content = [ 

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

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

281 if value 

282 ] 

283 return "".join(content) 

284 

285 

286def write_html(fd, report, dot, high_contrast, render_md): 

287 assert isinstance(report, Report) 

288 

289 doc = htmldoc.Document( 

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

291 "Lightweight Open BMW Software Traceability Evidence Report" 

292 ) 

293 

294 # Item styles 

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

296 "position": "absolute", 

297 "top": "1em", 

298 "right": "2em", 

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

300 "color": "white", 

301 } 

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

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

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

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

306 "padding" : "0.25em", 

307 } 

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

309 ".item-partial:target, " 

310 ".item-missing:target, " 

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

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

313 } 

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

315 ".subtle-partial, " 

316 ".subtle-missing, " 

317 ".subtle-justified"] = { 

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

319 } 

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

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

322 } 

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

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

325 } 

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

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

328 } 

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

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

331 } 

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

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

334 } 

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

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

337 } 

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

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

340 } 

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

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

343 } 

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

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

346 "font-weight" : "bold", 

347 } 

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

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

350 } 

351 

352 # Render MD 

353 if render_md: 

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

355 "font-style" : "unset", 

356 } 

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

358 "padding" : "unset", 

359 "margin" : "unset" 

360 } 

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

362 "padding" : "unset", 

363 "margin" : "unset", 

364 "border-bottom" : "unset", 

365 "text-align" : "unset" 

366 } 

367 

368 # Columns 

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

370 "display" : "flex", 

371 } 

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

373 "flex" : "45%", 

374 } 

375 

376 # Tables 

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

378 "font-weight" : "bold", 

379 } 

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

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

382 } 

383 

384 # Text 

385 doc.style["blockquote"] = { 

386 "font-style" : "italic", 

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

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

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

390 } 

391 

392 # Footer 

393 doc.style["footer"] = { 

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

395 "padding" : ".2rem", 

396 "text-align" : "right", 

397 "color" : "#666", 

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

399 } 

400 

401 ### Menu & Navigation 

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

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

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

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

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

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

408 if report.custom_data: 

409 content = generate_custom_data(report) 

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

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

412 menu.add_link("Documentation", 

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

414 menu.add_link("License", 

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

416 menu.add_link("Source", LOBSTER_GH) 

417 

418 ### Summary (Coverage & Policy) 

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

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

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

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

423 create_item_coverage(doc, report) 

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

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

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

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

428 create_policy_diagram(doc, report, dot) 

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

430 else: 

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

432 "include the tracing policy visualisation") 

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

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

435 

436 ### Filtering 

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

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

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

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

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

442 

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

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

445 

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

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

448 

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

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

451 

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

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

454 

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

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

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

458 

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

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

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

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

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

464 

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

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

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

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

469 

470 ### Issues 

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

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

473 has_issues = False 

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

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

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

477 Tracing_Status.JUSTIFIED): 

478 for message in item.messages: 

479 if not has_issues: 

480 has_issues = True 

481 doc.add_line("<ul>") 

482 doc.add_line( 

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

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

485 ) 

486 if has_issues: 486 ↛ 489line 486 didn't jump to line 489 because the condition on line 486 was always true

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

488 else: 

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

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

491 

492 ### Report 

493 file_heading = None 

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

495 items_by_level = {} 

496 for level in report.config: 

497 items_by_level[level] = [item 

498 for item in report.items.values() 

499 if item.level == level] 

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

501 "Requirements and Specification"), 

502 ("implementation", 

503 "Implementation"), 

504 ("activity", 

505 "Verification and Validation")]: 

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

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

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

509 if level.kind != kind: 

510 continue 

511 doc.add_heading(4, 

512 html.escape(level.name), 

513 name_hash(level.name), 

514 html_identifier=True, 

515 ) 

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

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

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

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

520 new_file_heading = "Unknown" 

521 elif isinstance(item.location, (File_Reference, 521 ↛ 524line 521 didn't jump to line 524 because the condition on line 521 was always true

522 Github_Reference)): 

523 new_file_heading = item.location.filename 

524 elif isinstance(item.location, Codebeamer_Reference): 

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

526 (item.location.cb_root, 

527 item.location.tracker) 

528 else: # pragma: no cover 

529 assert False 

530 if new_file_heading != file_heading: 

531 file_heading = new_file_heading 

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

533 html_identifier=True) 

534 

535 write_item_box_begin(doc, item) 

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

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

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

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

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

541 if render_md: 

542 bq_class = ' class="md_description"' 

543 bq_text = markdown.markdown(item.text, 

544 extensions=['tables']) 

545 else: 

546 bq_class = "" 

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

548 

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

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

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

552 write_item_tracing(doc, report, item) 

553 write_item_box_end(doc, item) 

554 else: 

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

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

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

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

559 # Add LOBSTER version in the footer. 

560 doc.add_line("<footer>") 

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

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

563 

564 # Add the css from assets 

565 dir_path = os.path.dirname(os.path.abspath(__file__)) 

566 file_path = dir_path + "/assets" 

567 for filename in os.listdir(file_path): 

568 if filename.endswith(".css"): 

569 filename = os.path.join(file_path, filename) 

570 with open(filename, "r", encoding="UTF-8") as styles: 

571 doc.css.append("".join(styles.readlines())) 

572 

573 # Add javascript from assets/html_report.js file 

574 dir_path = os.path.dirname(os.path.abspath(__file__)) 

575 file_path = dir_path + "/assets" 

576 for filename in os.listdir(file_path): 

577 if filename.endswith(".js"): 

578 filename = os.path.join(file_path, filename) 

579 with open(filename, "r", encoding="UTF-8") as scripts: 

580 doc.scripts.append("".join(scripts.readlines())) 

581 

582 fd.write(doc.render() + "\n") 

583 

584 

585class HtmlReportTool(MetaDataToolBase): 

586 def __init__(self): 

587 super().__init__( 

588 name="html-report", 

589 description="Visualise LOBSTER report in HTML", 

590 official=True, 

591 ) 

592 

593 ap = self._argument_parser 

594 ap.add_argument("lobster_report", 

595 nargs="?", 

596 default="report.lobster") 

597 ap.add_argument("--out", 

598 default="lobster_report.html") 

599 ap.add_argument("--dot", 

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

601 by default expected in PATH", 

602 default=None) 

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

604 action="store_true", 

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

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

607 action="store_true", 

608 help="Renders MD in description.") 

609 

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

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

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

613 

614 report = Report() 

615 report.load_report(options.lobster_report) 

616 

617 with open(options.out, "w", encoding="UTF-8") as fd: 

618 write_html( 

619 fd = fd, 

620 report = report, 

621 dot = options.dot, 

622 high_contrast = options.high_contrast, 

623 render_md = options.render_md, 

624 ) 

625 print("LOBSTER HTML report written to %s" % options.out) 

626 

627 return 0 

628 

629 

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

631 return HtmlReportTool().run(args)