diff --git a/src/dvsim/cli/admin.py b/src/dvsim/cli/admin.py index b0f3a503..a6541660 100644 --- a/src/dvsim/cli/admin.py +++ b/src/dvsim/cli/admin.py @@ -22,12 +22,45 @@ def cli() -> None: """ +@cli.group() +def dashboard() -> None: + """Dashboard helper commands.""" + + +@dashboard.command("gen") +@click.argument( + "json_path", + type=click.Path(exists=True, file_okay=True, dir_okay=False, path_type=Path), +) +@click.argument( + "output_dir", + type=click.Path(file_okay=False, dir_okay=True, path_type=Path), +) +@click.option( + "--base-url", + default=None, + type=str, +) +def dashboard_gen(json_path: Path, output_dir: Path, base_url: str | None) -> None: + """Generate a dashboard from a existing results JSON.""" + from dvsim.sim.dashboard import gen_dashboard # noqa: PLC0415 + from dvsim.sim.data import SimResultsSummary # noqa: PLC0415 + + results: SimResultsSummary = SimResultsSummary.load(path=json_path) + + gen_dashboard( + summary=results, + path=output_dir, + base_url=base_url, + ) + + @cli.group() def report() -> None: """Reporting helper commands.""" -@report.command() +@report.command("gen") @click.argument( "json_path", type=click.Path(exists=True, file_okay=True, dir_okay=False, path_type=Path), @@ -36,10 +69,10 @@ def report() -> None: "output_dir", type=click.Path(file_okay=False, dir_okay=True, path_type=Path), ) -def gen(json_path: Path, output_dir: Path) -> None: +def report_gen(json_path: Path, output_dir: Path) -> None: """Generate a report from a existing results JSON.""" - from dvsim.sim.data import SimResultsSummary - from dvsim.sim.report import gen_reports + from dvsim.sim.data import SimResultsSummary # noqa: PLC0415 + from dvsim.sim.report import gen_reports # noqa: PLC0415 summary: SimResultsSummary = SimResultsSummary.load(path=json_path) flow_results = summary.load_flow_results( diff --git a/src/dvsim/report/artifacts.py b/src/dvsim/report/artifacts.py new file mode 100644 index 00000000..669ddea3 --- /dev/null +++ b/src/dvsim/report/artifacts.py @@ -0,0 +1,83 @@ +# Copyright lowRISC contributors (OpenTitan project). +# Licensed under the Apache License, Version 2.0, see LICENSE for details. +# SPDX-License-Identifier: Apache-2.0 + +"""Reporting artifacts.""" + +from collections.abc import Callable, Iterable +from pathlib import Path +from typing import TypeAlias + +from dvsim.templates.render import render_static + +__all__ = ( + "ReportArtifacts", + "display_report", + "render_static_content", + "write_report", +) + +# Report rendering returns mappings of relative report paths to (string) contents. +ReportArtifacts: TypeAlias = dict[str, str] + + +def write_report(files: ReportArtifacts, root: Path) -> None: + """Write rendered report artifacts to the file system, relative to a given path. + + Args: + files: the output report artifacts from rendering simulation results. + root: the path to write the report files relative to. + + """ + for relative_path, content in files.items(): + path = root / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + + +def display_report( + files: ReportArtifacts, sink: Callable[[str], None] = print, *, with_headers: bool = False +) -> None: + """Emit the report artifacts to some textual sink. + + Prints to stdout by default, but can also write to a logger by overriding the sink. + + Args: + files: the output report artifacts from rendering simulation results. + sink: a callable that accepts a string. Default is `print` to stdout. + with_headers: a boolean controlling whether to emit artifact path names as headers. + + """ + for path, content in files.items(): + header = f"\n--- {path} ---\n" if with_headers else "" + sink(header + content + "\n") + + +def render_static_content( + static_files: Iterable[str], + outdir: Path | None = None, +) -> ReportArtifacts: + """Render static artifacts. + + These are files are just copied over as they don't need to be templated. + Where an outdir is specified the rendered artifacts are saved to that + directory eagerly as each file is rendered. + + Args: + static_files: iterable of relative file paths as strings + outdir: optional output directory + + Returns: + Report artifacts that have been rendered. + + """ + artifacts = {} + + for name in static_files: + artifacts[name] = render_static(path=name) + if outdir is not None: + artifact_path = outdir / name + artifact_path.parent.mkdir(parents=True, exist_ok=True) + artifact_path.write_text(artifacts[name]) + + return artifacts diff --git a/src/dvsim/sim/dashboard.py b/src/dvsim/sim/dashboard.py new file mode 100644 index 00000000..5d2ecc17 --- /dev/null +++ b/src/dvsim/sim/dashboard.py @@ -0,0 +1,61 @@ +# Copyright lowRISC contributors (OpenTitan project). +# Licensed under the Apache License, Version 2.0, see LICENSE for details. +# SPDX-License-Identifier: Apache-2.0 + +"""Generate dashboard. + +The dashboard is a cut down version of the full report where a simpler summary +is required than the full report simulation summary. This is intended to be used +on a separate website and links back to the detailed report if required. + +This is intended to generate a dashboard that could be used on the OpenTitan +and automatically i.e. https://opentitan.org/dashboard/index.html +""" + +from pathlib import Path + +from dvsim.logging import log +from dvsim.report.artifacts import render_static_content +from dvsim.sim.data import SimResultsSummary +from dvsim.templates.render import render_template + +__all__ = ("gen_dashboard",) + + +def gen_dashboard( + summary: SimResultsSummary, + path: Path, + base_url: str | None = None, +) -> None: + """Generate a summary dashboard. + + Args: + summary: overview of the block results + path: output directory path + base_url: override the base URL for links + + """ + log.debug("generating results dashboard") + + path.parent.mkdir(parents=True, exist_ok=True) + + # Generate the JS and CSS files + render_static_content( + static_files=[ + "css/style.css", + "css/bootstrap.min.css", + "js/bootstrap.bundle.min.js", + "js/htmx.min.js", + ], + outdir=path, + ) + + (path / "dashboard.html").write_text( + render_template( + path="dashboard/dashboard.html", + data={ + "summary": summary, + "base_url": base_url, + }, + ) + ) diff --git a/src/dvsim/sim/report.py b/src/dvsim/sim/report.py index 1cf27a36..56824ac3 100644 --- a/src/dvsim/sim/report.py +++ b/src/dvsim/sim/report.py @@ -5,17 +5,18 @@ """Generate reports.""" from collections import defaultdict -from collections.abc import Callable, Collection, Iterable, Mapping +from collections.abc import Collection, Iterable, Mapping from datetime import datetime from pathlib import Path -from typing import Any, Protocol, TypeAlias +from typing import Any, Protocol from tabulate import tabulate from dvsim.logging import log +from dvsim.report.artifacts import ReportArtifacts, display_report, render_static_content from dvsim.report.data import IPMeta from dvsim.sim.data import SimFlowResults, SimResultsSummary -from dvsim.templates.render import render_static, render_template +from dvsim.templates.render import render_template from dvsim.utils import TS_FORMAT_LONG from dvsim.utils.fs import relative_to @@ -24,9 +25,7 @@ "JsonReportRenderer", "MarkdownReportRenderer", "ReportRenderer", - "display_report", "gen_reports", - "write_report", ) @@ -41,10 +40,6 @@ def _indent_by_levels(lines: Iterable[tuple[int, str]], indent_spaces: int = 4) return "\n".join(" " * lvl * indent_spaces + msg for lvl, msg in lines) -# Report rendering returns mappings of relative report paths to (string) contents. -ReportArtifacts: TypeAlias = dict[str, str] - - class ReportRenderer(Protocol): """Renders/formats result reports, returning mappings of relative paths to (string) content.""" @@ -135,27 +130,17 @@ def render( (outdir / "index.html").write_text(artifacts["index.html"]) # Generate other static site contents - artifacts.update(self.render_static_content(outdir)) - - return artifacts - - def render_static_content(self, outdir: Path | None = None) -> ReportArtifacts: - """Render static CSS / JS artifacts for HTML report generation.""" - static_files = [ - "css/style.css", - "css/bootstrap.min.css", - "js/bootstrap.bundle.min.js", - "js/htmx.min.js", - ] - - artifacts = {} - - for name in static_files: - artifacts[name] = render_static(path=name) - if outdir is not None: - artifact_path = outdir / name - artifact_path.parent.mkdir(parents=True, exist_ok=True) - artifact_path.write_text(artifacts[name]) + artifacts.update( + render_static_content( + static_files=[ + "css/style.css", + "css/bootstrap.min.css", + "js/bootstrap.bundle.min.js", + "js/htmx.min.js", + ], + outdir=outdir, + ) + ) return artifacts @@ -449,38 +434,6 @@ def render_summary(self, summary: SimResultsSummary) -> ReportArtifacts: return {"report.md": report_md} -def write_report(files: ReportArtifacts, root: Path) -> None: - """Write rendered report artifacts to the file system, relative to a given path. - - Args: - files: the output report artifacts from rendering simulation results. - root: the path to write the report files relative to. - - """ - for relative_path, content in files.items(): - path = root / relative_path - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content) - - -def display_report( - files: ReportArtifacts, sink: Callable[[str], None] = print, *, with_headers: bool = False -) -> None: - """Emit the report artifacts to some textual sink. - - Prints to stdout by default, but can also write to a logger by overriding the sink. - - Args: - files: the output report artifacts from rendering simulation results. - sink: a callable that accepts a string. Default is `print` to stdout. - with_headers: a boolean controlling whether to emit artifact path names as headers. - - """ - for path, content in files.items(): - header = f"\n--- {path} ---\n" if with_headers else "" - sink(header + content + "\n") - - def gen_reports( summary: SimResultsSummary, flow_results: Mapping[str, SimFlowResults], diff --git a/src/dvsim/templates/dashboard/dashboard.html b/src/dvsim/templates/dashboard/dashboard.html new file mode 100644 index 00000000..363bf470 --- /dev/null +++ b/src/dvsim/templates/dashboard/dashboard.html @@ -0,0 +1,114 @@ + +{% set top = summary.top %} +{% set timestamp = summary.timestamp %} +{% set version = summary.version %} + + + + + + Simulation Dashboard + + + + + + +
+
+
+
 
+ + {{ timestamp.strftime("%d/%m/%Y %H:%M:%S") }} + + {% if version %} + + DVSim: v{{ version }} + + {% endif %} + + sha: {{ top.commit_short }} + + + Branch: {{ top.branch }} + +
+
+ + {% macro coverage_cell(cov, kind) %} + {% if cov and cov|attr(kind) is not none %} + {% set value = cov|attr(kind) %} + {{ "%.1f"|format(value) }} % + {% else %} + - + {% endif %} + {% endmacro %} + +
+
+ + + + + + + + + + + + + + + + + + + + + + {% for block_name in summary.flow_results.keys()|sort %} + {% set flow = summary.flow_results[block_name] %} + + + + + {% set cov = flow.coverage %} + {{ coverage_cell(cov, "average") }} + + {% set code = cov|attr("code") %} + {{ coverage_cell(code, "average") }} + {{ coverage_cell(cov, "functional") }} + {{ coverage_cell(cov, "assertion") }} + + + {% endfor %} + +
BlockTestsCoverage Summary
TotalPassingOverallCodeFunctionalAssertion
+ {% if base_url is not none %} + + {{ flow.block.variant_name(sep='/') | lower }} + + {% else %} + {{ flow.block.variant_name(sep='/') | lower }} + {% endif %} + {{ flow.total }} + {{ "%.1f" | format(flow.percent) }} % +
+
+
+
+ + + + diff --git a/tests/report/__init__.py b/tests/report/__init__.py new file mode 100644 index 00000000..be87f9c2 --- /dev/null +++ b/tests/report/__init__.py @@ -0,0 +1,5 @@ +# Copyright lowRISC contributors (OpenTitan project). +# Licensed under the Apache License, Version 2.0, see LICENSE for details. +# SPDX-License-Identifier: Apache-2.0 + +"""Test report subsystem.""" diff --git a/tests/report/test_artifacts.py b/tests/report/test_artifacts.py new file mode 100644 index 00000000..e54d70d4 --- /dev/null +++ b/tests/report/test_artifacts.py @@ -0,0 +1,42 @@ +# Copyright lowRISC contributors (OpenTitan project). +# Licensed under the Apache License, Version 2.0, see LICENSE for details. +# SPDX-License-Identifier: Apache-2.0 + +"""Test report artifacts.""" + +from pathlib import Path + +from hamcrest import assert_that, empty, equal_to, is_not + +from dvsim.report.artifacts import render_static_content + +__all__ = () + + +class TestRenderStaticContent: + """Test render_static_content.""" + + @staticmethod + def test_artifacts() -> None: + """Test that static files are able to be rendered.""" + artifacts = render_static_content( + static_files=["css/style.css"], + ) + + assert_that(set(artifacts.keys()), equal_to({"css/style.css"})) + assert_that(artifacts["css/style.css"], is_not(empty())) + + @staticmethod + def test_render_to_file(tmp_path: Path) -> None: + """Test that static files are saved to outdir.""" + artifacts = render_static_content( + static_files=["css/style.css"], + outdir=tmp_path, + ) + + output_file = tmp_path / "css/style.css" + assert_that(output_file.exists(), equal_to(True)) + assert_that( + output_file.read_text(), + equal_to(artifacts["css/style.css"]), + )