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

332 statements  

« prev     ^ index     » next       coverage.py v7.10.2, created at 2025-08-06 09:51 +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 

25import sys 

26from datetime import datetime, timezone 

27 

28import markdown 

29 

30from lobster.html import htmldoc 

31from lobster.report import Report 

32from lobster.location import (Void_Reference, 

33 File_Reference, 

34 Github_Reference, 

35 Codebeamer_Reference) 

36from lobster.items import (Tracing_Status, Item, 

37 Requirement, Implementation, Activity) 

38from lobster.meta_data_tool_base import MetaDataToolBase 

39 

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

41 

42 

43def is_dot_available(dot): 

44 try: 

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

46 stdout=subprocess.PIPE, 

47 stderr=subprocess.PIPE, 

48 encoding="UTF-8", 

49 check=True) 

50 return True 

51 except FileNotFoundError: 

52 return False 

53 

54 

55def name_hash(name): 

56 hobj = hashlib.md5() 

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

58 return hobj.hexdigest() 

59 

60 

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

62 assert isinstance(item, Item) 

63 assert isinstance(link, bool) 

64 assert isinstance(brief, bool) 

65 

66 if brief: 

67 rv = "" 

68 elif isinstance(item, Requirement): 

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

70 item.kind.capitalize()) 

71 elif isinstance(item, Implementation): 

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

73 item.kind.capitalize()) 

74 else: 

75 assert isinstance(item, Activity) 

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

77 item.kind.capitalize()) 

78 if not brief: 

79 rv += " " 

80 

81 if link: 

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

83 else: 

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

85 

86 return rv 

87 

88 

89def create_policy_diagram(doc, report, dot): 

90 assert isinstance(doc, htmldoc.Document) 

91 assert isinstance(report, Report) 

92 

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

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

95 if level["kind"] == "requirements": 

96 style = 'shape=box, style=rounded' 

97 elif level["kind"] == "implementation": 

98 style = 'shape=box' 

99 else: 

100 assert level["kind"] == "activity" 

101 style = 'shape=hexagon' 

102 style += f', href="#sec-{name_hash(level["name"])}"' 

103 

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

105 (name_hash(level["name"]), 

106 level["name"], 

107 style) 

108 

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

110 source = name_hash(level["name"]) 

111 for target in map(name_hash, level["traces"]): 

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

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

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

115 graph += "}\n" 

116 

117 with tempfile.TemporaryDirectory() as tmp_dir: 

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

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

120 tmp_fd.write(graph) 

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

122 stdout=subprocess.PIPE, 

123 encoding="UTF-8", 

124 check=True) 

125 assert svg.returncode == 0 

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

127 

128 for line in image.splitlines(): 

129 doc.add_line(line) 

130 

131 

132def create_item_coverage(doc, report): 

133 assert isinstance(doc, htmldoc.Document) 

134 assert isinstance(report, Report) 

135 

136 doc.add_line("<table>") 

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

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

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

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

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

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

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

144 doc.add_line("<tbody>") 

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

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

147 data = report.coverage[level["name"]] 

148 doc.add_line( 

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

150 ) 

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

152 (name_hash(level["name"]), 

153 html.escape(level["name"]))) 

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

155 doc.add_line("<td>") 

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

157 (data.ok, data.items)) 

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

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

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

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

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

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

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

165 

166 

167def run_git_show(commit_hash, path=None): 

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

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

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

171 try: 

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

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

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

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

176 except subprocess.CalledProcessError: 

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

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

179 return None 

180 

181 

182def get_commit_timestamp_utc(commit_hash, submodule_path=None): 

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

184 timestamp = run_git_show(commit_hash) 

185 if timestamp: 

186 return f"{timestamp}" 

187 

188 if submodule_path: 188 ↛ 189line 188 didn't jump to line 189 because the condition on line 188 was never true

189 timestamp = run_git_show(commit_hash, submodule_path) 

190 if timestamp: 

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

192 

193 return "Unknown" 

194 

195 

196def write_item_box_begin(doc, item): 

197 assert isinstance(doc, htmldoc.Document) 

198 assert isinstance(item, Item) 

199 

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

201 

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

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

204 

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

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

207 if item.tracing_status in (Tracing_Status.OK, 

208 Tracing_Status.JUSTIFIED) 

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

210 xref_item(item, link=False))) 

211 

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

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

214 

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

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

217 

218 

219def write_item_tracing(doc, report, item): 

220 assert isinstance(doc, htmldoc.Document) 

221 assert isinstance(report, Report) 

222 assert isinstance(item, Item) 

223 

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

225 if item.ref_down: 

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

227 doc.add_line("<ul>") 

228 for ref in item.ref_down: 

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

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

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

232 if item.ref_up: 

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

234 doc.add_line("<ul>") 

235 for ref in item.ref_up: 

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

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

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

239 

240 if item.tracing_status == Tracing_Status.JUSTIFIED: 

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

242 doc.add_line("<ul>") 

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

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

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

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

247 

248 if item.messages: 

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

250 doc.add_line("<ul>") 

251 for msg in item.messages: 

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

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

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

255 

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

257 

258 

259def write_item_box_end(doc, item): 

260 assert isinstance(doc, htmldoc.Document) 

261 

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

263 commit_hash = item.location.commit 

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

265 doc.add_line( 

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

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

268 f'Timestamp: {timestamp}' 

269 f'</div>' 

270 ) 

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

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

273 

274 

275def generate_custom_data(report) -> str: 

276 content = [ 

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

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

279 if value 

280 ] 

281 return "".join(content) 

282 

283 

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

285 assert isinstance(report, Report) 

286 

287 doc = htmldoc.Document( 

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

289 "Lightweight Open BMW Software Traceability Evidence Report" 

290 ) 

291 

292 # Item styles 

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

294 "position": "absolute", 

295 "top": "1em", 

296 "right": "2em", 

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

298 "color": "white", 

299 } 

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

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

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

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

304 "padding" : "0.25em", 

305 } 

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

307 ".item-partial:target, " 

308 ".item-missing:target, " 

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

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

311 } 

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

313 ".subtle-partial, " 

314 ".subtle-missing, " 

315 ".subtle-justified"] = { 

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

317 } 

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

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

320 } 

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

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

323 } 

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

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

326 } 

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

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

329 } 

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

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

332 } 

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

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

335 } 

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

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

338 } 

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

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

341 } 

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

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

344 "font-weight" : "bold", 

345 } 

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

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

348 } 

349 

350 # Render MD 

351 if render_md: 

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

353 "font-style" : "unset", 

354 } 

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

356 "padding" : "unset", 

357 "margin" : "unset" 

358 } 

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

360 "padding" : "unset", 

361 "margin" : "unset", 

362 "border-bottom" : "unset", 

363 "text-align" : "unset" 

364 } 

365 

366 # Columns 

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

368 "display" : "flex", 

369 } 

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

371 "flex" : "45%", 

372 } 

373 

374 # Tables 

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

376 "font-weight" : "bold", 

377 } 

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

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

380 } 

381 

382 # Text 

383 doc.style["blockquote"] = { 

384 "font-style" : "italic", 

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

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

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

388 } 

389 

390 ### Menu & Navigation 

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

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

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

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

395 menu.add_link(level["name"], "#sec-" + name_hash(level["name"])) 

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

397 if report.custom_data: 

398 content = generate_custom_data(report) 

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

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

401 menu.add_link("Documentation", 

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

403 menu.add_link("License", 

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

405 menu.add_link("Source", LOBSTER_GH) 

406 

407 ### Summary (Coverage & Policy) 

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

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

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

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

412 create_item_coverage(doc, report) 

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

414 if is_dot_available(dot): 

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

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

417 create_policy_diagram(doc, report, dot) 

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

419 else: 

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

421 "include the tracing policy visualisation") 

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

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

424 

425 ### Filtering 

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

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

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

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

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

431 

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

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

434 

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

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

437 

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

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

440 

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

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

443 

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

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

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

447 

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

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

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

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

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

453 

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

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

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

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

458 

459 ### Issues 

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

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

462 has_issues = False 

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

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

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

466 Tracing_Status.JUSTIFIED): 

467 for message in item.messages: 

468 if not has_issues: 

469 has_issues = True 

470 doc.add_line("<ul>") 

471 doc.add_line( 

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

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

474 ) 

475 if has_issues: 

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

477 else: 

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

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

480 

481 ### Report 

482 file_heading = None 

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

484 items_by_level = {} 

485 for level in report.config: 

486 items_by_level[level] = [item 

487 for item in report.items.values() 

488 if item.level == level] 

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

490 "Requirements and Specification"), 

491 ("implementation", 

492 "Implementation"), 

493 ("activity", 

494 "Verification and Validation")]: 

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

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

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

498 if level["kind"] != kind: 

499 continue 

500 doc.add_heading(4, 

501 html.escape(level["name"]), 

502 name_hash(level["name"]), 

503 html_identifier=True, 

504 ) 

505 if items_by_level[level["name"]]: 

506 for item in sorted(items_by_level[level["name"]], 

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

508 if isinstance(item.location, Void_Reference): 

509 new_file_heading = "Unknown" 

510 elif isinstance(item.location, (File_Reference, 

511 Github_Reference)): 

512 new_file_heading = item.location.filename 

513 elif isinstance(item.location, Codebeamer_Reference): 

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

515 (item.location.cb_root, 

516 item.location.tracker) 

517 else: # pragma: no cover 

518 assert False 

519 if new_file_heading != file_heading: 

520 file_heading = new_file_heading 

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

522 html_identifier=True) 

523 

524 write_item_box_begin(doc, item) 

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

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

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

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

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

530 if render_md: 

531 bq_class = ' class="md_description"' 

532 bq_text = markdown.markdown(item.text, 

533 extensions=['tables']) 

534 else: 

535 bq_class = "" 

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

537 

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

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

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

541 write_item_tracing(doc, report, item) 

542 write_item_box_end(doc, item) 

543 else: 

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

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

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

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

548 

549 # Add the css from assets 

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

551 file_path = dir_path + "/assets" 

552 for filename in os.listdir(file_path): 

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

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

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

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

557 

558 # Add javascript from assets/html_report.js file 

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

560 file_path = dir_path + "/assets" 

561 for filename in os.listdir(file_path): 

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

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

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

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

566 

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

568 

569 

570class HtmlReportTool(MetaDataToolBase): 

571 def __init__(self): 

572 super().__init__( 

573 name="html-report", 

574 description="Visualise LOBSTER report in HTML", 

575 official=True, 

576 ) 

577 

578 ap = self._argument_parser 

579 ap.add_argument("lobster_report", 

580 nargs="?", 

581 default="report.lobster") 

582 ap.add_argument("--out", 

583 default="lobster_report.html") 

584 ap.add_argument("--dot", 

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

586 by default expected in PATH", 

587 default=None) 

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

589 action="store_true", 

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

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

592 action="store_true", 

593 help="Renders MD in description.") 

594 

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

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

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

598 

599 report = Report() 

600 report.load_report(options.lobster_report) 

601 

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

603 write_html( 

604 fd = fd, 

605 report = report, 

606 dot = options.dot, 

607 high_contrast = options.high_contrast, 

608 render_md = options.render_md, 

609 ) 

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

611 

612 return 0 

613 

614 

615def main() -> int: 

616 return HtmlReportTool().run() 

617 

618 

619if __name__ == "__main__": 

620 sys.exit(main())