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

325 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-26 14:55 +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 

26 

27import markdown 

28 

29from lobster.html import htmldoc 

30from lobster.report import Report 

31from lobster.location import (Void_Reference, 

32 File_Reference, 

33 Github_Reference, 

34 Codebeamer_Reference) 

35from lobster.items import (Tracing_Status, Item, 

36 Requirement, Implementation, Activity) 

37from lobster.version import get_version 

38 

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

40 

41 

42def is_dot_available(dot): 

43 try: 

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

45 stdout=subprocess.PIPE, 

46 stderr=subprocess.PIPE, 

47 encoding="UTF-8", 

48 check=True) 

49 return True 

50 except FileNotFoundError: 

51 return False 

52 

53 

54def name_hash(name): 

55 hobj = hashlib.md5() 

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

57 return hobj.hexdigest() 

58 

59 

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

61 assert isinstance(item, Item) 

62 assert isinstance(link, bool) 

63 assert isinstance(brief, bool) 

64 

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

66 rv = "" 

67 elif isinstance(item, Requirement): 

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

69 item.kind.capitalize()) 

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

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

72 item.kind.capitalize()) 

73 else: 

74 assert isinstance(item, Activity) 

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

76 item.kind.capitalize()) 

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

78 rv += " " 

79 

80 if link: 

81 rv += "<a href='#item-%s'>%s</a>" % (item.tag.hash(), 

82 item.name) 

83 else: 

84 rv += "%s" % 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 += ', href="#sec-%s"' % 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("<tr>") 

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

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

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

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

153 doc.add_line("<td>") 

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

155 (data.ok, data.items)) 

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

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

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

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

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

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

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

163 

164 

165def run_git_show(commit_hash, path=None): 

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

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

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

169 try: 

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

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

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

173 return datetime.fromtimestamp(epoch, tz=timezone.utc) 

174 except subprocess.CalledProcessError: 

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

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

177 return None 

178 

179 

180def get_commit_timestamp_utc(commit_hash, submodule_path=None): 

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

182 timestamp = run_git_show(commit_hash) 

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

184 return f"{timestamp}" 

185 

186 if submodule_path: 

187 timestamp = run_git_show(commit_hash, submodule_path) 

188 if timestamp: 

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

190 

191 return "Unknown" 

192 

193 

194def write_item_box_begin(doc, item): 

195 assert isinstance(doc, htmldoc.Document) 

196 assert isinstance(item, Item) 

197 

198 doc.add_line('<!-- begin item %s -->' % html.escape(item.tag.key())) 

199 

200 doc.add_line('<div class="item-%s" id="item-%s">' % 

201 (item.tracing_status.name.lower(), 

202 item.tag.hash())) 

203 

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

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

206 if item.tracing_status in (Tracing_Status.OK, 

207 Tracing_Status.JUSTIFIED) 

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

209 xref_item(item, link=False))) 

210 

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

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

213 

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

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

216 

217 

218def write_item_tracing(doc, report, item): 

219 assert isinstance(doc, htmldoc.Document) 

220 assert isinstance(report, Report) 

221 assert isinstance(item, Item) 

222 

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

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

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

226 doc.add_line("<ul>") 

227 for ref in item.ref_down: 

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

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

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

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

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

233 doc.add_line("<ul>") 

234 for ref in item.ref_up: 

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

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

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

238 

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

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

241 doc.add_line("<ul>") 

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

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

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

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

246 

247 if item.messages: 

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

249 doc.add_line("<ul>") 

250 for msg in item.messages: 

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

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

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

254 

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

256 

257 

258def write_item_box_end(doc, item): 

259 assert isinstance(doc, htmldoc.Document) 

260 

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

262 commit_hash = item.location.commit 

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

264 doc.add_line( 

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

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

267 f'Timestamp: {timestamp}' 

268 f'</div>' 

269 ) 

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

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

272 

273 

274def generate_custom_data(report) -> str: 

275 content = [ 

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

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

278 if value 

279 ] 

280 return "".join(content) 

281 

282 

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

284 assert isinstance(report, Report) 

285 

286 doc = htmldoc.Document( 

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

288 "Lightweight Open BMW Software Traceability Evidence Report" 

289 ) 

290 

291 # Item styles 

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

293 "position": "absolute", 

294 "top": "1em", 

295 "right": "2em", 

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

297 "color": "white", 

298 } 

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

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

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

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

303 "padding" : "0.25em", 

304 } 

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

306 ".item-partial:target, " 

307 ".item-missing:target, " 

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

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

310 } 

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

312 ".subtle-partial, " 

313 ".subtle-missing, " 

314 ".subtle-justified"] = { 

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

316 } 

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

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

319 } 

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

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

322 } 

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

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

325 } 

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

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

328 } 

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

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

331 } 

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

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

334 } 

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

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

337 } 

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

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

340 } 

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

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

343 "font-weight" : "bold", 

344 } 

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

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

347 } 

348 

349 # Render MD 

350 if render_md: 

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

352 "font-style" : "unset", 

353 } 

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

355 "padding" : "unset", 

356 "margin" : "unset" 

357 } 

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

359 "padding" : "unset", 

360 "margin" : "unset", 

361 "border-bottom" : "unset", 

362 "text-align" : "unset" 

363 } 

364 

365 # Columns 

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

367 "display" : "flex", 

368 } 

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

370 "flex" : "45%", 

371 } 

372 

373 # Tables 

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

375 "font-weight" : "bold", 

376 } 

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

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

379 } 

380 

381 # Text 

382 doc.style["blockquote"] = { 

383 "font-style" : "italic", 

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

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

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

387 } 

388 

389 ### Menu & Navigation 

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

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

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

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

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

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

396 if report.custom_data: 

397 content = generate_custom_data(report) 

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

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

400 menu.add_link("Documentation", 

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

402 menu.add_link("License", 

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

404 menu.add_link("Source", LOBSTER_GH) 

405 

406 ### Summary (Coverage & Policy) 

407 doc.add_heading(2, "Overview", "overview") 

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

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

410 doc.add_heading(3, "Coverage") 

411 create_item_coverage(doc, report) 

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

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

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

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

416 create_policy_diagram(doc, report, dot) 

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

418 else: 

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

420 "include the tracing policy visualisation") 

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

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

423 

424 ### Filtering 

425 doc.add_heading(2, "Filtering", "filtering-options") 

426 doc.add_heading(3, "Item Filters") 

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

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

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

430 

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

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

433 

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

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

436 

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

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

439 

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

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

442 

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

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

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

446 

447 doc.add_heading(3, "Show Issues") 

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

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

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

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

452 

453 doc.add_heading(3, "Filter", "filter") 

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

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

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

457 

458 ### Issues 

459 doc.add_heading(2, "Issues", "issues") 

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

461 has_issues = False 

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

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

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

465 Tracing_Status.JUSTIFIED): 

466 for message in item.messages: 

467 if not has_issues: 

468 has_issues = True 

469 doc.add_line("<ul>") 

470 doc.add_line("<li class=\"issue issue-%s\">%s: %s</li>" % 

471 (item.tracing_status.name.lower(), 

472 xref_item(item), 

473 message)) 

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

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

476 else: 

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

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

479 

480 ### Report 

481 file_heading = None 

482 doc.add_heading(2, "Detailed report", "detailed-report") 

483 items_by_level = {} 

484 for level in report.config: 

485 items_by_level[level] = [item 

486 for item in report.items.values() 

487 if item.level == level] 

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

489 "Requirements and Specification"), 

490 ("implementation", 

491 "Implementation"), 

492 ("activity", 

493 "Verification and Validation")]: 

494 doc.add_heading(3, title) 

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

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

497 continue 

498 doc.add_heading(4, 

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

500 name_hash(level["name"])) 

501 if items_by_level[level["name"]]: 501 ↛ 539line 501 didn't jump to line 539 because the condition on line 501 was always true

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

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

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

505 new_file_heading = "Unknown" 

506 elif isinstance(item.location, (File_Reference, 

507 Github_Reference)): 

508 new_file_heading = item.location.filename 

509 elif isinstance(item.location, Codebeamer_Reference): 

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

511 (item.location.cb_root, 

512 item.location.tracker) 

513 else: # pragma: no cover 

514 assert False 

515 if new_file_heading != file_heading: 

516 file_heading = new_file_heading 

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

518 

519 write_item_box_begin(doc, item) 

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

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

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

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

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

525 if render_md: 

526 bq_class = ' class="md_description"' 

527 bq_text = markdown.markdown(item.text, 

528 extensions=['tables']) 

529 else: 

530 bq_class = "" 

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

532 

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

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

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

536 write_item_tracing(doc, report, item) 

537 write_item_box_end(doc, item) 

538 else: 

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

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

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

542 

543 # Add the css from assets 

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

545 file_path = dir_path + "/assets" 

546 for filename in os.listdir(file_path): 

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

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

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

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

551 

552 # Add javascript from assets/html_report.js file 

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

554 file_path = dir_path + "/assets" 

555 for filename in os.listdir(file_path): 

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

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

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

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

560 

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

562 

563 

564ap = argparse.ArgumentParser() 

565 

566 

567@get_version(ap) 

568def main(): 

569 # lobster-trace: core_html_report_req.Dummy_Requirement 

570 ap.add_argument("lobster_report", 

571 nargs="?", 

572 default="report.lobster") 

573 ap.add_argument("--out", 

574 default="lobster_report.html") 

575 ap.add_argument("--dot", 

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

577 by default expected in PATH", 

578 default=None) 

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

580 action="store_true", 

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

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

583 action="store_true", 

584 help="Renders MD in description.") 

585 options = ap.parse_args() 

586 

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

588 ap.error(f"{options.lobster_report} is not a file") 

589 

590 report = Report() 

591 report.load_report(options.lobster_report) 

592 

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

594 write_html(fd = fd, 

595 report = report, 

596 dot = options.dot, 

597 high_contrast = options.high_contrast, 

598 render_md = options.render_md) 

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

600 

601 

602if __name__ == "__main__": 

603 main()