Coverage for lobster/tools/core/html_report/html_report.py: 11%
325 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-26 14:55 +0000
« 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
27import markdown
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
39LOBSTER_GH = "https://github.com/bmw-software-engineering/lobster"
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
54def name_hash(name):
55 hobj = hashlib.md5()
56 hobj.update(name.encode("UTF-8"))
57 return hobj.hexdigest()
60def xref_item(item, link=True, brief=False):
61 assert isinstance(item, Item)
62 assert isinstance(link, bool)
63 assert isinstance(brief, bool)
65 if brief:
66 rv = ""
67 elif isinstance(item, Requirement):
68 rv = html.escape(item.framework + " " +
69 item.kind.capitalize())
70 elif isinstance(item, Implementation):
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:
78 rv += " "
80 if link:
81 rv += "<a href='#item-%s'>%s</a>" % (item.tag.hash(),
82 item.name)
83 else:
84 rv += "%s" % item.name
86 return rv
89def create_policy_diagram(doc, report, dot):
90 assert isinstance(doc, htmldoc.Document)
91 assert isinstance(report, Report)
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"])
104 graph += ' n_%s [label="%s", %s];\n' % \
105 (name_hash(level["name"]),
106 level["name"],
107 style)
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"
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 "):]
128 for line in image.splitlines():
129 doc.add_line(line)
132def create_item_coverage(doc, report):
133 assert isinstance(doc, htmldoc.Document)
134 assert isinstance(report, Report)
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>")
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
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:
184 return f"{timestamp}"
186 if submodule_path: 186 ↛ 187line 186 didn't jump to line 187 because the condition on line 186 was never true
187 timestamp = run_git_show(commit_hash, submodule_path)
188 if timestamp:
189 return f"{timestamp} (from submodule at {submodule_path})"
191 return "Unknown"
194def write_item_box_begin(doc, item):
195 assert isinstance(doc, htmldoc.Document)
196 assert isinstance(item, Item)
198 doc.add_line('<!-- begin item %s -->' % html.escape(item.tag.key()))
200 doc.add_line('<div class="item-%s" id="item-%s">' %
201 (item.tracing_status.name.lower(),
202 item.tag.hash()))
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)))
211 doc.add_line('<div class="attribute">Source: ')
212 doc.add_line('<svg class="icon"><use href="#svg-external-link"></use></svg>')
214 doc.add_line(item.location.to_html())
215 doc.add_line("</div>")
218def write_item_tracing(doc, report, item):
219 assert isinstance(doc, htmldoc.Document)
220 assert isinstance(report, Report)
221 assert isinstance(item, Item)
223 doc.add_line('<div class="attribute">')
224 if item.ref_down:
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:
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>")
239 if item.tracing_status == Tracing_Status.JUSTIFIED:
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>")
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>")
255 doc.add_line("</div>")
258def write_item_box_end(doc, item):
259 assert isinstance(doc, htmldoc.Document)
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 -->')
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)
283def write_html(fd, report, dot, high_contrast, render_md):
284 assert isinstance(report, Report)
286 doc = htmldoc.Document(
287 "L.O.B.S.T.E.R.",
288 "Lightweight Open BMW Software Traceability Evidence Report"
289 )
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 }
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 }
365 # Columns
366 doc.style[".columns"] = {
367 "display" : "flex",
368 }
369 doc.style[".columns .column"] = {
370 "flex" : "45%",
371 }
373 # Tables
374 doc.style["thead tr"] = {
375 "font-weight" : "bold",
376 }
377 doc.style["tbody tr.alt"] = {
378 "background-color" : "#eee",
379 }
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 }
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)
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):
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>')
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>')
431 doc.add_line('<button class ="button buttonOK" '
432 'onclick="buttonFilter(\'ok\')" > OK </button>')
434 doc.add_line('<button class ="button buttonMissing" '
435 'onclick="buttonFilter(\'missing\')" > Missing </button>')
437 doc.add_line('<button class ="button buttonPartial" '
438 'onclick="buttonFilter(\'partial\')" > Partial </button>')
440 doc.add_line('<button class ="button buttonJustified" '
441 'onclick="buttonFilter(\'justified\')" > Justified </button>')
443 doc.add_line('<button class ="button buttonWarning" '
444 'onclick="buttonFilter(\'warning\')" > Warning </button>')
445 doc.add_line("</div>")
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>')
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"')
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:
475 doc.add_line("</ul>")
476 else:
477 doc.add_line("<div>No traceability issues found.</div>")
478 doc.add_line("</div>")
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"]]:
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):
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))
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>")
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>")
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()))
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()))
561 fd.write(doc.render() + "\n")
564ap = argparse.ArgumentParser()
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()
587 if not os.path.isfile(options.lobster_report):
588 ap.error(f"{options.lobster_report} is not a file")
590 report = Report()
591 report.load_report(options.lobster_report)
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)
602if __name__ == "__main__":
603 main()