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
« 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
28import markdown
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
42LOBSTER_GH = "https://github.com/bmw-software-engineering/lobster"
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
57def name_hash(name):
58 hobj = hashlib.md5()
59 hobj.update(name.encode("UTF-8"))
60 return hobj.hexdigest()
63def xref_item(item, link=True, brief=False):
64 assert isinstance(item, Item)
65 assert isinstance(link, bool)
66 assert isinstance(brief, bool)
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 += " "
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)
88 return rv
91def create_policy_diagram(doc, report, dot):
92 assert isinstance(doc, htmldoc.Document)
93 assert isinstance(report, Report)
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)}"'
106 graph += ' n_%s [label="%s", %s];\n' % \
107 (name_hash(level.name),
108 level.name,
109 style)
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"
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 "):]
130 for line in image.splitlines():
131 doc.add_line(line)
134def create_item_coverage(doc, report):
135 assert isinstance(doc, htmldoc.Document)
136 assert isinstance(report, Report)
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>")
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
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}"
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})"
195 return "Unknown"
198def write_item_box_begin(doc, item):
199 assert isinstance(doc, htmldoc.Document)
200 assert isinstance(item, Item)
202 doc.add_line(f'<!-- begin item {html.escape(item.tag.key())} -->')
204 doc.add_line(f'<div class="item-{html.escape(item.tracing_status.name.lower())}" '
205 f'id="item-{item.tag.hash()}">')
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)))
214 doc.add_line('<div class="attribute">Source: ')
215 doc.add_line('<svg class="icon"><use href="#svg-external-link"></use></svg>')
217 doc.add_line(item.location.to_html())
218 doc.add_line("</div>")
221def write_item_tracing(doc, report, item):
222 assert isinstance(doc, htmldoc.Document)
223 assert isinstance(report, Report)
224 assert isinstance(item, Item)
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>")
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>")
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>")
258 doc.add_line("</div>")
261def write_item_box_end(doc, item):
262 assert isinstance(doc, htmldoc.Document)
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 -->')
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)
286def write_html(fd, report, dot, high_contrast, render_md):
287 assert isinstance(report, Report)
289 doc = htmldoc.Document(
290 "L.O.B.S.T.E.R.",
291 "Lightweight Open BMW Software Traceability Evidence Report"
292 )
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 }
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 }
368 # Columns
369 doc.style[".columns"] = {
370 "display" : "flex",
371 }
372 doc.style[".columns .column"] = {
373 "flex" : "45%",
374 }
376 # Tables
377 doc.style["thead tr"] = {
378 "font-weight" : "bold",
379 }
380 doc.style["tbody tr.alt"] = {
381 "background-color" : "#eee",
382 }
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 }
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 }
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)
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>')
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>')
443 doc.add_line('<button class ="button buttonOK" '
444 'onclick="buttonFilter(\'ok\')" > OK </button>')
446 doc.add_line('<button class ="button buttonMissing" '
447 'onclick="buttonFilter(\'missing\')" > Missing </button>')
449 doc.add_line('<button class ="button buttonPartial" '
450 'onclick="buttonFilter(\'partial\')" > Partial </button>')
452 doc.add_line('<button class ="button buttonJustified" '
453 'onclick="buttonFilter(\'justified\')" > Justified </button>')
455 doc.add_line('<button class ="button buttonWarning" '
456 'onclick="buttonFilter(\'warning\')" > Warning </button>')
457 doc.add_line("</div>")
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>')
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"')
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>")
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)
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>")
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>")
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()))
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()))
582 fd.write(doc.render() + "\n")
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 )
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.")
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")
614 report = Report()
615 report.load_report(options.lobster_report)
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)
627 return 0
630def main(args: Optional[Sequence[str]] = None) -> int:
631 return HtmlReportTool().run(args)