blob: 54deebd73221fb61b580a17d34ef315db44ada24 [file]
# Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries.
# SPDX-License-Identifier: Apache-2.0
'''
Process a build folder and generate a comprehensive HTML dashboard
including memory use, Kconfig values, devicetree browser, sys-init
ordering, and other general details.
This incorporates results from various other scripts:
- traceconfig
- footprint
- ram_plot/rom_plot
- initlevels
'''
import argparse
import contextlib
import glob
import html
import io
import json
import logging
import os
import pickle
import re
import shutil
import subprocess
import sys
import webbrowser
from datetime import datetime
from functools import partial
from http.server import HTTPServer, SimpleHTTPRequestHandler
from pathlib import Path
import jinja2
import yaml
from elftools.elf.constants import SH_FLAGS
from elftools.elf.elffile import ELFFile
from pygments import highlight
from pygments.formatters import HtmlFormatter # pylint: disable=E0611
from pygments.lexers import DevicetreeLexer # pylint: disable=E0611
from pygments.styles import get_style_by_name
sys.path.append(str(Path(__file__).parents[1] / "build"))
from check_init_priorities import Validator
sys.path.append(str(Path(__file__).parents[1] / "dts" / "python-devicetree" / "src" / "devicetree"))
from dtlib import DT
sys.path.append(str(Path(__file__).parents[1] / "footprint"))
try:
from plot import generate_figure
from plotly.offline.offline import get_plotlyjs
except ImportError:
generate_figure = None
logger = logging.getLogger('dashboard')
# Online path to the kconfig browser.
KCONFIG_BROWSER = 'https://docs.zephyrproject.org/latest/kconfig.html'
# Set to match the CSS used in the HTML as it is set via CSS variable
# that we cannot access from Python script.
CSS_BS_TABLE_BG = '#f8f9fa'
# Constants for converting from bytes to human-friendly sizes.
MEMORY_UNIT_SIZES = {
1073741824: 'GB',
1048576: 'MB',
1024: 'KB',
1: 'Bytes',
}
def display_size(byte_cnt):
'''
Display a number of bytes in human-readable form with a size indicator.
'''
if byte_cnt in [None, '']:
return ''
compressed_bytes = byte_cnt
unit = 'Bytes'
for unit_size, unit_str in MEMORY_UNIT_SIZES.items():
if abs(byte_cnt) >= unit_size:
compressed_bytes = float(byte_cnt) / unit_size
unit = unit_str
break
return f"{compressed_bytes:.1f} {unit}".replace('.0', '')
def elf_memory_summary(elf_file):
'''
Get a summary of memory use from the given ELF file.
This function will parse the given elf file and determine the total memory
used by read-only, read-write, and executable regions.
:param elf_file: Path to an elf file.
:return: A dictionary with keys "bss", "rwdata", "rodata", "text", "other"
'''
report = {'bss': 0, 'rodata': 0, 'rwdata': 0, 'text': 0, 'other': 0}
with open(elf_file, "rb") as fd:
elf = ELFFile(fd)
for section in elf.iter_sections():
flags = section.header['sh_flags']
size = section.header['sh_size']
sh_type = section.header['sh_type']
if sh_type == 'SHT_NOBITS':
report['bss'] += size
elif sh_type == 'SHT_PROGBITS':
if flags & SH_FLAGS.SHF_EXECINSTR:
report['text'] += size
elif flags & SH_FLAGS.SHF_WRITE:
report['rwdata'] += size
elif flags & SH_FLAGS.SHF_ALLOC:
report['rodata'] += size
else:
report['other'] += size
else:
report['other'] += size
return report
class ZephyrVersion:
'''
Class representing the version of Zephyr kernel. See
https://docs.zephyrproject.org/latest/project/release_process.html
for what the fields represent.
Data is loaded from the VERSION file in the Zephyr base.
'''
FIELDS = ['version_major', 'version_minor', 'patchlevel', 'version_tweak', 'extraversion']
def __init__(self, zephyr_base):
self.version_major = None
self.version_minor = None
self.patchlevel = None
self.version_tweak = None
self.extraversion = None
version_file = zephyr_base / 'VERSION'
try:
with open(version_file, encoding='utf-8') as f:
version_data = f.readlines()
except OSError as e:
logger.error(f"Unable to read version information from {version_file}: {e}")
return
for version_line in version_data:
field, value = version_line.split('=')
field = field.strip().lower()
value = value.strip()
if field in ZephyrVersion.FIELDS:
setattr(self, field, value)
def __str__(self):
version = f"{self.version_major}.{self.version_minor}"
if self.patchlevel and self.patchlevel != '0':
version += f'.{self.patchlevel}'
if self.extraversion and self.extraversion != '0':
version += f'-{self.extraversion}'
if self.version_tweak and self.version_tweak != '0':
version += f'+{self.version_tweak}'
return version
class ZephyrToolchain:
'''
Container for the Zephyr toolchain information.
Extracted from the CMakeCCompiler.cmake file.
'''
def __init__(self, build_path):
self.cmake_c_compiler_id = None
self.cmake_c_compiler_version = None
cmake_glob = build_path / 'CMakeFiles' / '*' / 'CMakeCCompiler.cmake'
cmake_file = glob.glob(str(cmake_glob))
if cmake_file:
cmake_file = cmake_file[0]
else:
return
try:
with open(cmake_file, encoding='utf-8') as f:
lines = f.readlines()
except OSError as e:
logger.error(f"Unable to load CMakeCCompiler.cmake file {cmake_file}: {e}")
return
cmake_c_compiler_id_re = re.compile(r'\s*set\(CMAKE_C_COMPILER_ID\s+"(.*)"\)')
cmake_c_compiler_version_re = re.compile(r'\s*set\(CMAKE_C_COMPILER_VERSION\s+"(.*)"\)')
for line in lines:
if match := cmake_c_compiler_id_re.match(line):
self.cmake_c_compiler_id = match.group(1)
if match := cmake_c_compiler_version_re.match(line):
self.cmake_c_compiler_version = match.group(1)
if self.cmake_c_compiler_id and self.cmake_c_compiler_version:
break
def __str__(self):
return f"{self.cmake_c_compiler_id} {self.cmake_c_compiler_version}"
class KconfigSymbol:
'''
Class representing a KconfigSymbol and its value in a given build.
'''
def __init__(self, name, visible, sym_type, value, src, loc, build):
self.sym_type = sym_type
self.visible = bool(visible == 'y')
self.name = name
self.value = value
if sym_type == "string" and self.value is not None:
self.value = f'"{self.value}"'
self.src = src
self.loc = loc
self.build = build
def loc_html(self):
'''
Return an HTML representation of the symbol location.
'''
if self.loc and self.src in ['default', 'assign']:
fn = os.path.relpath(self.loc[0], self.build.zephyr_base)
disp_fn = os.path.normpath(fn).replace("../", "")
sym_loc = f'{disp_fn}:{self.loc[1]}'
elif self.loc and self.src in ['select', 'imply']:
if len(self.loc) > 1:
sym_loc = " || ".join(f"({html.escape(loc)})" for loc in self.loc)
else:
sym_loc = html.escape(self.loc[0])
sym_loc = f"<code>{sym_loc}</code>"
elif self.loc is None and self.src == "default":
sym_loc = "<i>(implicit)</i>"
else:
sym_loc = ''
return sym_loc
def src_html(self):
'''
Return an HTML representation of the symbol source tag.
'''
if self.src == "unset":
return ''
if self.src == "default":
return '<span class="badge bg-secondary">default</span>'
if self.src == "assign":
return '<span class="badge bg-primary">assigned</span>'
if self.src == "select":
return '<span class="badge bg-info">selected</span>'
if self.src == "imply":
return '<span class="badge bg-warning">implied</span>'
return self.src
class ZephyrDashboard:
'''
Class for collecting and processing Zephyr build artifacts to create the
HTML dashboard.
'''
def __init__(
self,
zephyr_base,
build_path,
output_path,
kernel_bin_name="zephyr",
skip_memory_report=False,
):
self.zephyr_base = Path(zephyr_base)
self.kernel_bin_name = kernel_bin_name
self.build_path = Path(build_path) if build_path else self.zephyr_base / "build"
self.output_path = Path(output_path) if output_path else self.build_path / "dashboard"
self.elf_file = self.build_path / "zephyr" / f"{self.kernel_bin_name}.elf"
self.dts_file = self.build_path / "zephyr" / "zephyr.dts"
self.kconfig_file = self.build_path / "zephyr" / ".config"
# Read details from the build_info.yml file.
with open(self.build_path / "build_info.yml", encoding='utf-8') as fd:
self.build_info = yaml.safe_load(fd)
try:
self.application = self.build_info['cmake']['application']['source-dir']
base_prefix = '^' + str(self.zephyr_base.absolute()).replace('\\', '/') + '/'
self.application = re.sub(base_prefix, '', self.application)
except KeyError:
self.application = None
try:
self.board = self.build_info['cmake']['board']['name']
except KeyError:
self.board = None
try:
self.command = self.build_info['west']['command']
self.command = re.sub(r'\S*west', 'west', self.command)
except KeyError:
self.command = None
try:
self.topdir = Path(self.build_info['west']['topdir'])
except KeyError:
self.topdir = self.zephyr_base
# Get some details on the build binary file - may not exist in all
# builds (like QEMU).
try:
bin_file = self.build_path / "zephyr" / f"{self.kernel_bin_name}.bin"
bin_stats = bin_file.stat()
self.bin_size_str = display_size(bin_stats.st_size)
except OSError:
self.bin_size_str = None
# Get some details on the build ELF file.
elf_stats = self.elf_file.stat()
self.elf_date_str = datetime.fromtimestamp(elf_stats.st_mtime).strftime("%Y-%m-%d %H:%M:%S")
self.elf_size_str = display_size(elf_stats.st_size)
self.toolchain = ZephyrToolchain(self.build_path)
self.zephyr_version = ZephyrVersion(self.zephyr_base)
self._init_kconfigs()
self._init_sysinit()
self.memory_summary = elf_memory_summary(self.elf_file)
# Create the memory reports if they are stale.
self.skip_memory_report = skip_memory_report
if not self.skip_memory_report:
memory_report = self.output_path / "all_report.json"
if not os.path.isfile(memory_report) or os.path.getmtime(
memory_report
) < os.path.getmtime(self.elf_file):
self._create_memory_reports()
def _init_sysinit(self):
'''
Get the sys-init levels and validate against DT dependencies.
'''
validator_log = io.StringIO()
stream_handler = logging.StreamHandler(validator_log)
stream_handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
validator_logger = logging.getLogger('check_init_priorities')
validator_logger.propagate = False
validator_logger.addHandler(stream_handler)
validator_logger.setLevel(logging.WARNING)
with open(self.elf_file, 'rb') as fd:
validator = Validator(self.elf_file, 'edt.pickle', validator_logger, fd)
validator.check_edt()
self.sys_init_errors = validator_log.getvalue().splitlines()
self.sys_init_levels = validator.initlevels
def _init_kconfigs(self):
'''
Load the kconfig trace information from the pickled data file and convert
to a list of KconfigSymbol objects.
'''
pickle_path = self.build_path / "zephyr" / ".config-trace.pickle"
with open(pickle_path, 'rb') as f:
trace_data = pickle.load(f)
self.kconfigs = [KconfigSymbol(*sym, build=self) for sym in trace_data]
self.kconfigs.sort(key=lambda kc: kc.name)
def _create_memory_reports(self):
'''
Use the footprint size_report tool to create the ram/rom/all JSON data.
'''
logger.info("Creating memory reports (may take a few minutes).")
# Create the output location for the report files.
self.output_path.mkdir(parents=True, exist_ok=True)
cmd = [
sys.executable,
str(self.zephyr_base / "scripts" / "footprint" / "size_report"),
'-k',
str(self.elf_file),
'-z',
str(self.zephyr_base.absolute()),
f'--workspace={self.topdir}',
'--json',
str(self.output_path / '{target}_report.json'),
'--quiet',
'--output',
'.',
'rom',
'ram',
'all',
]
logger.debug(' '.join(cmd))
try:
subprocess.check_call(cmd)
except subprocess.CalledProcessError as e:
logger.error(
f"Failed generating memory size report (exit code {e.returncode}). "
+ f"Command:\n{' '.join(e.cmd)}"
)
def _load_memory_report(self, mem_type):
'''
Load the memory report of the given type from an existing json file.
:param type: Which report, "ram", "rom", or "all"
:return: Dictionary of the loaded memory report.
'''
fname = self.output_path / f"{mem_type}_report.json"
if not os.path.isfile(fname):
logger.error(f'Memory report file "{fname}" not found.')
return None
with open(fname, encoding="utf-8") as fd:
return json.load(fd)
def _symbols_by_size(self, report, limit=None):
'''
Sort the symbols from a memory report by their size.
:param report: A memory report as created by the Zephyr size_report script.
:param limit: How many results to return.
:return: A list of all the symbols with the largest first.
'''
if not report:
return []
symbol_list = []
def flatten(symbols, lst):
for symbol in symbols:
children = symbol.get('children')
if children:
flatten(children, lst)
elif symbol['name'][0] != '(':
lst.append(symbol)
flatten(report['symbols']['children'], symbol_list)
symbol_list = sorted(symbol_list, key=lambda s: s['size'], reverse=True)
if limit:
symbol_list = symbol_list[0:limit]
return symbol_list
def _memory_report_template_context(self, mem_type, ctxt):
'''
Add the HTML template context for the memory report of the given type.
'''
report = self._load_memory_report(mem_type)
def create_node(symbol, expanded=False):
size = symbol['size']
return (
{
"expanded": expanded,
"data": {
"name": symbol['name'],
"size": size,
"displaySize": display_size(size),
"memoryType": [loc.upper() for loc in symbol.get('loc', [])],
},
}
if size
else None
)
def add_children(node, children):
if not children:
return
node['children'] = []
for symbol in sorted(children, key=lambda c: c['name']):
child_node = create_node(symbol)
if not child_node:
continue
node['children'].append(child_node)
add_children(child_node, symbol.get('children'))
def create_tree(report):
tree = {}
symbol = report.get('symbols')
if symbol:
tree['size'] = symbol['size']
tree['tree'] = []
node = create_node(symbol, expanded=True)
tree['tree'].append(node)
add_children(node, symbol.get('children'))
return tree
if report:
tree = create_tree(report)
ctxt[f'{mem_type}_report'] = json.dumps(tree)
ctxt[f'{mem_type}_report_size'] = tree['size']
if generate_figure:
figure = generate_figure(report)
figure.update_layout(paper_bgcolor=CSS_BS_TABLE_BG, height=600)
ctxt[f'{mem_type}_plot'] = figure.to_html(full_html=False, include_plotlyjs=False)
else:
ctxt[f'{mem_type}_plot'] = None
else:
ctxt[f'{mem_type}_report'] = None
ctxt[f'{mem_type}_plot'] = None
if mem_type == 'all':
ctxt['top_ten'] = self._symbols_by_size(report, 10)
def _safe_relpath(self, filename):
'''
Returns a relative path to the file from the top dir, or
the absolute path if a relative path cannot be established
(on Windows with files in different drives, for example).
'''
try:
return os.path.relpath(filename, start=self.topdir)
except ValueError:
return filename
def _edt_fancytree(self):
'''
Create the Fancytree data object for the EDT tree.
'''
edt_pickle_path = self.build_path / "zephyr" / 'edt.pickle'
with open(edt_pickle_path, "rb") as f:
edt = pickle.load(f)
# Load the same DTS file into a dtlib object so we can iterate over
# all properties (not just ones that edtlib tracks).
dt = DT(edt.dts_path, base_dir=self.topdir)
label2path = {}
def create_tree_node(dt_node, expanded=False):
edt_node = edt.get_node(dt_node.path)
if edt_node and edt_node.binding_path:
binding_path = self._safe_relpath(edt_node.binding_path)
else:
binding_path = ""
return {
"id": dt_node.path,
"expanded": expanded,
"edtNode": {
"isProperty": False,
"name": dt_node.name,
"labels": dt_node.labels,
"filename": self._safe_relpath(dt_node.filename),
"lineno": dt_node.lineno,
"description": edt_node.description if edt_node else "",
"bindingPath": binding_path,
},
}
def create_prop_tree_node(dt_prop, edt_prop):
# Extract the original text value from the string conversion of the
# property. No other way to get it from this object. Also remove
# the trailing semi-colon and extra spaces around <>.
value = re.sub('^.*= ', '', str(dt_prop))[:-1].replace('< ', '<').replace(' >', '>')
# Find all the label references and create a look-up table to the node path.
# This will be used on the render side to create links.
for m in re.finditer(r'&(\w+)', value):
try:
label2path[m.group(1)] = dt.label2node[m.group(1)].path
except KeyError:
continue
return {
"id": dt_prop.node.path + f':{dt_prop.name}',
"edtNode": {
"isProperty": True,
"name": dt_prop.name,
"value": value,
"filename": self._safe_relpath(dt_prop.filename),
"lineno": dt_prop.lineno,
"description": edt_prop.description if edt_prop else "",
"typeSpec": edt_prop.type if edt_prop else "",
"bindingPath": "",
},
}
def add_children(tree_node, parent):
tree_node['children'] = []
edt_node = edt.get_node(parent.path)
for name, dt_prop in parent.props.items():
prop_node = create_prop_tree_node(dt_prop, edt_node.props.get(name))
tree_node['children'].append(prop_node)
for _, dt_node in parent.nodes.items():
child_tree_node = create_tree_node(dt_node)
tree_node['children'].append(child_tree_node)
add_children(child_tree_node, dt_node)
tree = {'tree': [], 'label2path': label2path}
tree_node = create_tree_node(dt.root, expanded=True)
tree['tree'].append(tree_node)
add_children(tree_node, dt.root)
return tree
def create_html(self):
'''
Generate all the HTML output.
'''
templates_path = Path(__file__).parents[0] / "templates"
template_loader = jinja2.FileSystemLoader(searchpath=str(templates_path))
env = jinja2.Environment(
loader=template_loader, autoescape=True, trim_blocks=True, lstrip_blocks=True
)
env.filters['display_size'] = display_size
# Create the output directory tree.
self.output_path.mkdir(parents=True, exist_ok=True)
# Copy the static tree from the dashboard location as well as
# doc/_static for the logo images.
rpt_static = self.output_path / "static"
doc_static = self.zephyr_base / "doc" / "_static"
shutil.copytree(Path(__file__).parent.resolve() / 'static', rpt_static, dirs_exist_ok=True)
img_dir = rpt_static / "img"
img_dir.mkdir(exist_ok=True)
shutil.copy2(doc_static / "images" / "logo.svg", rpt_static / "img")
shutil.copy2(doc_static / "images" / "favicon.png", rpt_static / "img")
shutil.copy2(doc_static / "css" / "light.css", rpt_static / "css")
# Create the pygments CSS file.
pygments_style = get_style_by_name('default')
pygments_formatter = HtmlFormatter(style=pygments_style)
pygments_css = pygments_formatter.get_style_defs('.highlight')
with open(rpt_static / "css" / "pygments.css", 'w', encoding="utf-8") as fd:
fd.write(pygments_css)
# Create the plotly.js file if plotly was found.
if generate_figure:
with open(rpt_static / "js" / "plotly.js", 'w', encoding="utf-8") as fd:
fd.write(get_plotlyjs())
# ---------------------------------
# HTML: index.html
# ---------------------------------
template = env.get_template("index.html")
context = {'build': self, 'title': 'Build Summary', 'current': 'home'}
fname = self.output_path / "index.html"
logger.info(f"Creating {fname}")
with open(fname, 'w', encoding="utf-8") as fd:
fd.write(template.render(**context))
# ---------------------------------
# HTML: kconfig.html
# ---------------------------------
template = env.get_template("kconfig.html")
context = {
'build': self,
'title': 'Kconfig',
'current': 'kconfig',
'kconfig_browser': KCONFIG_BROWSER,
}
fname = os.path.join(self.output_path, 'kconfig.html')
logger.debug(f"Creating {fname}")
with open(fname, 'w', encoding="utf-8") as fd:
fd.write(template.render(**context))
# ---------------------------------
# HTML: sysinit.html
# ---------------------------------
template = env.get_template("sysinit.html")
context = {'build': self, 'title': 'Sys Init', 'current': 'sysinit'}
fname = os.path.join(self.output_path, 'sysinit.html')
logger.debug(f"Creating {fname}")
with open(fname, 'w', encoding="utf-8") as fd:
fd.write(template.render(**context))
# ---------------------------------
# HTML: dts.html
# ---------------------------------
try:
with open(self.dts_file, encoding="utf-8") as fd:
dts_contents = fd.read()
except OSError as e:
logger.warning(f'Unable to load device tree file at {self.dts_file}: {e}')
dts_contents = None
if dts_contents:
dts_contents = highlight(dts_contents, DevicetreeLexer(), pygments_formatter)
edt_tree = self._edt_fancytree()
template = env.get_template("dts.html")
context = {
'build': self,
'title': 'Device Tree',
'current': 'dts',
'file_contents': dts_contents,
'file_path': self.dts_file.absolute(),
'edt_tree': json.dumps(edt_tree),
}
fname = self.output_path / "dts.html"
logger.debug(f"Creating {fname}")
with open(fname, 'w', encoding='utf-8') as fd:
fd.write(template.render(**context))
# ---------------------------------
# HTML: memoryreport.html
# ---------------------------------
if not self.skip_memory_report:
template = env.get_template("memoryreport.html")
context = {
'build': self,
'title': 'Memory Report',
'current': 'memory',
'include_plotly_js': bool(generate_figure),
}
self._memory_report_template_context('all', context)
self._memory_report_template_context('ram', context)
self._memory_report_template_context('rom', context)
fname = self.output_path / 'memoryreport.html'
logger.debug(f"Creating {fname}")
with open(fname, 'w', encoding="utf-8") as fd:
fd.write(template.render(**context))
# ---------------------------------
# HTML: elfstats.html
# ---------------------------------
fname = self.build_path / "zephyr" / f"{self.kernel_bin_name}.stat"
try:
with open(fname, encoding="utf-8") as fd:
file_contents = fd.read()
except OSError as e:
logger.warning(f'Unable to load zephyr elf stat file at {fname}: {e}')
file_contents = ''
template = env.get_template("textviewer.html")
context = {
'build': self,
'title': 'ELF Stats',
'current': 'elfstats',
'file_contents': file_contents,
'file_path': fname.absolute(),
}
fname = self.output_path / "elfstats.html"
logger.debug(f"Creating {fname}")
with open(fname, 'w', encoding='utf-8') as fd:
fd.write(template.render(**context))
def clean_up(self):
'''
Clean-up any temporary files.
'''
for mem_type in ['ram', 'rom', 'all']:
fname = self.output_path / f"{mem_type}_report.json"
fname.unlink(missing_ok=True)
def open_browser(self):
'''
Open the default web browser to the index page of the dashboard.
Uses a one-shot HTTP server so that the browser loads from
http://localhost, which works reliably across most platforms.
'''
handler = partial(SimpleHTTPRequestHandler, directory=str(self.output_path))
server = HTTPServer(("127.0.0.1", 0), handler)
url = f"http://127.0.0.1:{server.server_port}/index.html"
logger.info("Serving dashboard at %s (Ctrl+C to stop)", url)
webbrowser.open(url)
with contextlib.suppress(KeyboardInterrupt):
server.serve_forever()
def parse_args():
"""
Parse command line arguments.
"""
parser = argparse.ArgumentParser(allow_abbrev=False)
parser.add_argument("build", help="Build directory", nargs='?', default="build")
parser.add_argument("--output", help="Output directory for index.html")
parser.add_argument("--zephyr-base", default=".", help="Zephyr base directory")
parser.add_argument("--kernel-bin-name", default="zephyr", help="Kernel binary name")
parser.add_argument(
"--skip-memory-report",
action="store_true",
help="Skip the memory report to generate faster",
)
parser.add_argument(
"--no-clean", action="store_true", help="Do not remove temporary memory map json data"
)
parser.add_argument(
"-v", "--verbose", action="store_true", help="Print extra debugging information"
)
parser.add_argument("-q", "--quiet", action="store_true", help="Print nothing to the console.")
parser.add_argument(
"--open", action="store_true", help="Open the default web browser to the dashboard."
)
return parser.parse_args()
def main():
'''
Main function when invoked from the command-line.
'''
args = parse_args()
if args.verbose:
logging.basicConfig(level=logging.DEBUG)
logger.setLevel(logging.DEBUG)
elif not args.quiet:
logging.basicConfig(level=logging.INFO)
logger.setLevel(logging.INFO)
output_path = args.output
zephyr_base = args.zephyr_base
build_path = args.build
build = ZephyrDashboard(
zephyr_base,
build_path,
output_path,
kernel_bin_name=args.kernel_bin_name,
skip_memory_report=args.skip_memory_report,
)
build.create_html()
if not args.no_clean:
build.clean_up()
if args.open:
build.open_browser()
if __name__ == "__main__":
main()