diff --git a/src/extensions/score_sphinx_needs_templates/BUILD b/src/extensions/score_sphinx_needs_templates/BUILD index d09d9b057..e2f571f3e 100644 --- a/src/extensions/score_sphinx_needs_templates/BUILD +++ b/src/extensions/score_sphinx_needs_templates/BUILD @@ -16,7 +16,10 @@ load("@score_tools//score_pytest:pytest.bzl", "score_pytest") filegroup( name = "all_sources", - srcs = ["__init__.py"], + srcs = [ + "__init__.py", + "report_pie.py", + ], visibility = ["//visibility:public"], ) @@ -35,7 +38,10 @@ score_pytest( # The template regression test builds a temporary Sphinx HTML project, # so it needs more time than a pure Python unit test. size = "medium", - srcs = ["tests/test_needs_templates.py"], + srcs = [ + "tests/test_needs_templates.py", + "tests/test_report_pie.py", + ], deps = [ ":score_sphinx_needs_templates", "//src/extensions/score_metamodel:score_metamodel", diff --git a/src/extensions/score_sphinx_needs_templates/README.md b/src/extensions/score_sphinx_needs_templates/README.md index 31d0e8111..0fd3f399d 100644 --- a/src/extensions/score_sphinx_needs_templates/README.md +++ b/src/extensions/score_sphinx_needs_templates/README.md @@ -27,6 +27,11 @@ The extension provides: * the `needs_of_type(need_type)` helper for selecting Needs by type; * the `id_filter_list`, `workproduct_rows` and `safety_pie_row` helpers, which render RST fragments shared by several report templates; +* the `score-report-pie` directive used by `safety_pie_row`: it counts one + Sphinx-Needs filter per content line like `needpie`, but draws a compact + chart without legend and slice labels and with large slice numbers, sized + for three charts per row (`:labels:` and `:colors:` are required, one per + line; the labels go into the image's alt text); * support for graph-driven `post_template`s that are rendered after parallel Need collection has been merged; * ordinary Sphinx page navigation for sections generated by those diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py index 322c4a290..6d445c884 100644 --- a/src/extensions/score_sphinx_needs_templates/__init__.py +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -19,6 +19,8 @@ from src.helper_lib import config_setdefault +from . import report_pie + _build_environment: BuildEnvironment | None = None # Post-templates containing this marker need a second read after parallel Need @@ -423,29 +425,37 @@ def __call__( f".. rubric:: {safety_label} — {subject}", f" :class: safety-class-heading safety-{safety_css}", "", - ".. grid:: 1 2 3 3", - " :gutter: 2", - " :class-row: score-pie-row", ] + if count == 0: + lines.extend( + [ + ".. raw:: html", + "", + f'
no {safety_label} {subject.lower()}
', + ] + ) + return "\n".join(lines) + "\n" + + lines.extend( + [ + ".. grid:: 1 2 3 3", + " :gutter: 2", + " :class-row: score-pie-row", + ] + ) def grid_item(name: str, pie: list[str]) -> None: - lines.extend(["", " .. grid-item::", ""]) - if count == 0: - lines.extend( - [ - " .. raw:: html", - "", - '
', - f' {subject} {name} ({safety_label})', - f' no {safety_label} {subject.lower()}', - "
", - ] - ) - else: - lines.append( - f" .. needpie:: {chart_prefix} {name} ({safety_label})" - ) - lines.extend(f" {line}".rstrip() for line in pie) + # The continuation line keeps the chart title on two narrow lines. + lines.extend( + [ + "", + " .. grid-item::", + "", + f" .. score-report-pie:: {chart_prefix}", + f" {name} ({safety_label})", + ] + ) + lines.extend(f" {line}".rstrip() for line in pie) def counted_by(func: str) -> list[str]: # sphinx-needs splits the call on its last ".", so encode the version. @@ -460,7 +470,6 @@ def counted_by(func: str) -> list[str]: [ f":labels: {', '.join(label for label, _ in statuses)}", f":colors: {status_colors}", - ":legend:", *( counted_by("get_component_status_metrics") if component @@ -476,7 +485,6 @@ def counted_by(func: str) -> list[str]: [ ":labels: fully covered, partially covered, not covered", ":colors: #37a12d, #f0a500, #ca2828", - ":legend:", *( counted_by("get_component_coverage_metrics") if component @@ -495,7 +503,6 @@ def counted_by(func: str) -> list[str]: [ ":labels: referenced by lower, not referenced", ":colors: #37a12d, #ca2828", - ":legend:", "", f"id in {_id_filter_list_callable(referenced or [])}", f"id in {_id_filter_list_callable(missing or [])}", @@ -612,6 +619,7 @@ def setup(app: Sphinx) -> dict[str, object]: app.config.needs_render_context.setdefault( "safety_pie_row", _safety_pie_row_callable ) + report_pie.setup(app) app.connect("builder-inited", _capture_build_environment) # Run after the source-code linker has injected generated testcase Needs and # their verification backlinks (priority 525), so report templates can diff --git a/src/extensions/score_sphinx_needs_templates/report_pie.py b/src/extensions/score_sphinx_needs_templates/report_pie.py new file mode 100644 index 000000000..eb3074abb --- /dev/null +++ b/src/extensions/score_sphinx_needs_templates/report_pie.py @@ -0,0 +1,224 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""``score-report-pie``: the pie chart of the verification reports. + +It counts its slices exactly like ``needpie`` (one Sphinx-Needs filter per +content line, evaluated once all Needs are resolved), but draws a compact chart +built for three charts per row: no legend and no slice labels, since the +reports explain the colours once per page, and large numbers in the slices. +``needpie`` itself stays untouched for every other chart. +""" + +import hashlib +from collections.abc import Iterator, Sequence +from contextlib import contextmanager +from typing import Any + +from docutils import nodes +from docutils.parsers.rst import directives +from matplotlib import style +from matplotlib.figure import Figure +from matplotlib.text import Text +from sphinx.application import Sphinx +from sphinx.util import logging +from sphinx.util.docutils import SphinxDirective +from sphinx_needs.config import NeedsSphinxConfig +from sphinx_needs.data import SphinxNeedsData +from sphinx_needs.exceptions import NeedsInvalidFilter +from sphinx_needs.filter_common import filter_needs_parts +from sphinx_needs.utils import ( + check_and_get_external_filter_func, + save_matplotlib_figure, +) +from sphinx_needs.views import NeedsAndPartsListView + +_FIGURE_INCHES = (5.0, 4.0) +# Inches kept free above the circle for the two-line title and below it. +_TITLE_INCHES, _BOTTOM_INCHES = 0.75, 0.15 +_VALUE_FONT_SIZE = 14 +_TITLE_FONT_SIZE = 13 +# Slices below this share get no number, as their text would overlap. +_MIN_LABELLED_SHARE = 0.02 +# Slices below this share get their number at half size to fit. +_SMALL_SLICE_SHARE = 0.05 + + +logger = logging.getLogger(__name__) + + +class ReportPie(nodes.General, nodes.Element): + pass + + +class ReportPieDirective(SphinxDirective): + """Pie chart with one slice per content line, each a Sphinx-Needs filter. + + Alternatively ``:filter-func:`` names a function that fills the slice sizes, + called exactly like ``needpie`` calls it. + """ + + has_content = True + optional_arguments = 1 + final_argument_whitespace = True + option_spec = { + "labels": directives.unchanged_required, + "colors": directives.unchanged_required, + "filter-func": directives.unchanged_required, + } + + def run(self) -> Sequence[nodes.Node]: + content: list[str] = list(self.content) + filters = [line for line in content if line.strip()] + labels = _split(self.options.get("labels", "")) + colors = _split(self.options.get("colors", "")) + filter_func = str(self.options.get("filter-func", "")) + if filter_func and filters: + raise self.error("score-report-pie takes filter lines or :filter-func:") + if len(labels) != len(colors) or ( + not filter_func and len(filters) != len(labels) + ): + raise self.error( + "score-report-pie needs one label and one colour per slice" + ) + node = ReportPie( + title=self.arguments[0] if self.arguments else "", + filters=filters, + filter_func=filter_func, + labels=labels, + colors=colors, + target_id=f"score-report-pie-{self.env.docname}-" + f"{self.env.new_serialno('score-report-pie')}", + ) + self.set_source_info(node) + return [node] + + +def _split(value: str) -> list[str]: + return [part.strip() for part in value.split(",") if part.strip()] + + +@contextmanager +def _report_pie_style() -> Iterator[None]: + """Pin the matplotlib settings, which other charts may have changed globally.""" + with style.context(["default", {"savefig.bbox": "standard"}]): + yield + + +def draw_report_pie(title: str, sizes: Sequence[float], colors: list[str]) -> Figure: + """Draw the chart; ``sizes`` must contain at least one non-zero value.""" + total = sum(sizes) + height = _FIGURE_INCHES[1] + figure = Figure(figsize=_FIGURE_INCHES) + figure.subplots_adjust( + left=0.02, + right=0.98, + top=1 - _TITLE_INCHES / height, + bottom=_BOTTOM_INCHES / height, + ) + axes = figure.add_subplot(aspect="equal") + pie: Any = axes.pie( + sizes, + colors=colors, + startangle=90, + autopct=lambda pct: f"{pct:.1f}%\n({round(pct * total / 100)})", + textprops={"fontsize": _VALUE_FONT_SIZE, "fontweight": "bold"}, + ) + values: list[Text] = pie[2] + for size, value in zip(sizes, values, strict=True): + value.set_visible(size >= total * _MIN_LABELLED_SHARE) + if size < total * _SMALL_SLICE_SHARE: + value.set_fontsize(_VALUE_FONT_SIZE / 2) + axes.set(xlim=(-1.05, 1.05), ylim=(-1.05, 1.05)) + axes.set_title(title, fontsize=_TITLE_FONT_SIZE, wrap=True) + return figure + + +def _call_filter_func(signature: str, need_list: NeedsAndPartsListView) -> list[float]: + """Call a ``needpie``-style filter function and return its slice sizes.""" + result = check_and_get_external_filter_func(signature) + assert result is not None + args = result.args.split(",") if result.args else [] + sizes: list[float] = [] + result.func( + needs=need_list, + results=sizes, + **{f"arg{index + 1}": arg for index, arg in enumerate(args)}, + ) + return sizes + + +def _process_report_pies(app: Sphinx, doctree: nodes.document, docname: str) -> None: + pies: list[nodes.Element] = list(doctree.findall(ReportPie)) + if not pies: + return + needs_config = NeedsSphinxConfig(app.config) + need_list = SphinxNeedsData(app.env).get_needs_view().to_list_with_parts() + for pie in pies: + title: str = pie["title"] + filters: list[str] = pie["filters"] + labels: list[str] = pie["labels"] + target_id: str = pie["target_id"] + filter_func: str = pie["filter_func"] + sizes: list[float] + if filter_func: + try: + sizes = _call_filter_func(filter_func, need_list) + except NeedsInvalidFilter as error: + logger.warning( + "score-report-pie: %s", error, location=pie, type="score_report_pie" + ) + pie.replace_self(nodes.paragraph(text=f"{title}: invalid filter")) + continue + if len(sizes) != len(labels): + logger.warning( + "score-report-pie: %s returned %d values for %d labels", + filter_func, + len(sizes), + len(labels), + location=pie, + type="score_report_pie", + ) + pie.replace_self(nodes.paragraph(text=f"{title}: invalid filter")) + continue + else: + sizes = [ + float( + len(filter_needs_parts(need_list, needs_config, line, location=pie)) + ) + for line in filters + ] + counts = ", ".join( + f"{label} {size:g}" for label, size in zip(labels, sizes, strict=True) + ) + alt = f"{' '.join(title.split())}: {counts}" + if not any(sizes): + pie.replace_self(nodes.paragraph(text=f"{alt} (no Needs found)")) + continue + digest = hashlib.sha256(target_id.encode()).hexdigest()[:10] + with _report_pie_style(): + image = save_matplotlib_figure( + app, + draw_report_pie(title, sizes, pie["colors"]), + f"score_report_pie_{digest}", + docname, + ) + image["alt"] = alt + image["classes"].append("score-report-pie") + pie.replace_self(image) + + +def setup(app: Sphinx) -> None: + app.add_node(ReportPie) + app.add_directive("score-report-pie", ReportPieDirective) + # After Sphinx-Needs (default priority) has resolved all Need nodes. + app.connect("doctree-resolved", _process_report_pies, priority=600) diff --git a/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py b/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py index b00580225..a90a7db73 100644 --- a/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py +++ b/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py @@ -240,14 +240,14 @@ def test_safety_pie_row_qm_counts_inspected_as_valid() -> None: assert ":class: safety-class-heading safety-asil-b" in asil -def test_safety_pie_row_empty_class_renders_placeholders_only() -> None: +def test_safety_pie_row_empty_class_renders_one_slim_placeholder() -> None: safety_pie_row = vars(templates)["_SafetyPieRow"]() row = safety_pie_row("Comp", "Requirements", "BASE", "QM", "QM", 0, True) - assert "needpie" not in row - assert row.count('
') == 3 - assert "no QM requirements" in row + assert "score-report-pie" not in row + assert ".. grid::" not in row + assert row.count('
no QM requirements
') == 1 def test_safety_pie_row_reference_pie_only_on_request() -> None: @@ -259,12 +259,15 @@ def test_safety_pie_row_reference_pie_only_on_request() -> None: ) assert "Reference Coverage" not in without - assert without.count(".. needpie::") == 2 - assert with_ref.count(".. needpie::") == 3 + assert without.count(".. score-report-pie::") == 2 + assert with_ref.count(".. score-report-pie::") == 3 assert 'id in ["a", "b"]' in with_ref assert 'id in ["c"]' in with_ref # An empty title must not leave a leading blank in the chart title. - assert ".. needpie:: Stakeholder Requirements Status (QM)" in with_ref + assert ( + ".. score-report-pie:: Stakeholder Requirements\n Status (QM)\n" + in with_ref + ) def test_id_filter_list_renders_quoted_literal() -> None: diff --git a/src/extensions/score_sphinx_needs_templates/tests/test_report_pie.py b/src/extensions/score_sphinx_needs_templates/tests/test_report_pie.py new file mode 100644 index 000000000..b97eb84a1 --- /dev/null +++ b/src/extensions/score_sphinx_needs_templates/tests/test_report_pie.py @@ -0,0 +1,186 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Tests for the ``score-report-pie`` directive of the verification reports.""" + +import re +from pathlib import Path + +import pytest +from score_pytest.attribute_plugin import add_test_properties +from score_sphinx_needs_templates.report_pie import draw_report_pie +from sphinx.testing.util import SphinxTestApp + + +@pytest.mark.parametrize( + ("sizes", "expected"), + [ + # 1 of 31 is 3.2%: still numbered, at half size. + ([1, 30, 0], [("3.2%\n(1)", 7), ("96.8%\n(30)", 14)]), + # 1 of 61 is 1.6%: too small for any number; 0 is never numbered. + ([1, 60, 0], [("98.4%\n(60)", 14)]), + ], +) +def test_draw_report_pie_sizes_slice_numbers_by_share( + sizes: list[int], expected: list[tuple[str, float]] +) -> None: + figure = draw_report_pie("Comp Requirements\nStatus (QM)", sizes, ["g", "y", "r"]) + axes = figure.axes[0] + + visible = [t for t in axes.texts if t.get_visible() and t.get_text()] + + assert tuple(figure.get_size_inches()) == (5, 4) + assert axes.get_legend() is None + assert [(t.get_text(), t.get_fontsize()) for t in visible] == expected + assert axes.get_title() == "Comp Requirements\nStatus (QM)" + + +@add_test_properties( + partially_verifies=[], + test_type="interface-test", + derivation_technique="explorative-testing", +) +def test_report_pie_counts_filters_and_leaves_needpie_untouched( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Slices are counted per filter line; a plain needpie keeps its own layout.""" + monkeypatch.setenv("BUILD_WORKSPACE_DIRECTORY", str(tmp_path)) + (tmp_path / "conf.py").write_text( + 'extensions = ["sphinx_needs", "score_sphinx_needs_templates"]\n' + 'master_doc = "index"\n', + encoding="utf-8", + ) + (tmp_path / "index.rst").write_text( + """ +Report +====== + +.. req:: First open + :id: REQ_001 + :status: open + +.. req:: Second open + :id: REQ_002 + :status: open + +.. req:: Closed + :id: REQ_003 + :status: closed + +.. score-report-pie:: Comp Requirements + Status (QM) + :labels: open, closed + :colors: #37a12d, #ca2828 + + type == "req" and status == "open" + type == "req" and status == "closed" + +.. needpie:: Plain chart + :labels: open, closed + + type == "req" and status == "open" + type == "req" and status == "closed" +""", + encoding="utf-8", + ) + + app = SphinxTestApp( + srcdir=tmp_path, + outdir=tmp_path / "_build", + buildername="html", + freshenv=True, + ) + try: + app.build(force_all=True) + html = (app.outdir / "index.html").read_text(encoding="utf-8") + images = app.outdir / "_images" + report_svgs = list(images.glob("score_report_pie_*.svg")) + needpie_svgs = list(images.glob("need_pie_*.svg")) + report_svg = report_svgs[0].read_text(encoding="utf-8") + needpie_svg = needpie_svgs[0].read_text(encoding="utf-8") + finally: + app.cleanup() + + assert len(report_svgs) == len(needpie_svgs) == 1 + assert re.search( + r'Comp Requirements Status \(QM\): open 2, closed 1 None: + """``:filter-func:`` receives the Needs and its arguments like in ``needpie``.""" + monkeypatch.setenv("BUILD_WORKSPACE_DIRECTORY", str(tmp_path)) + monkeypatch.syspath_prepend(str(tmp_path)) + (tmp_path / "pie_counts.py").write_text( + "def by_status(needs, results, arg1, arg2):\n" + " for status in (arg1, arg2):\n" + ' results.append(sum(n["status"] == status for n in needs))\n', + encoding="utf-8", + ) + (tmp_path / "conf.py").write_text( + 'extensions = ["sphinx_needs", "score_sphinx_needs_templates"]\n' + 'master_doc = "index"\n', + encoding="utf-8", + ) + (tmp_path / "index.rst").write_text( + """ +Report +====== + +.. req:: Open + :id: REQ_001 + :status: open + +.. req:: Closed + :id: REQ_002 + :status: closed + +.. req:: Also closed + :id: REQ_003 + :status: closed + +.. score-report-pie:: Comp Requirements + Status (QM) + :labels: open, closed + :colors: #37a12d, #ca2828 + :filter-func: pie_counts.by_status(open,closed) +""", + encoding="utf-8", + ) + + app = SphinxTestApp( + srcdir=tmp_path, + outdir=tmp_path / "_build", + buildername="html", + freshenv=True, + ) + try: + app.build(force_all=True) + html = (app.outdir / "index.html").read_text(encoding="utf-8") + finally: + app.cleanup() + + assert 'alt="Comp Requirements Status (QM): open 1, closed 2"' in html diff --git a/src/needs_templates/module_verification_report.need b/src/needs_templates/module_verification_report.need index 160473087..013b44a4c 100644 --- a/src/needs_templates/module_verification_report.need +++ b/src/needs_templates/module_verification_report.need @@ -83,7 +83,7 @@ /* Scales every chart to its column; together with the fixed three-column raster this gives every chart the same size as in the platform report. */ - .score-pie-row img[id^="needpie-"] { + .score-pie-row img.score-report-pie { width: 100%; height: auto; } @@ -99,15 +99,12 @@ p.rubric.safety-class-heading.safety-asil-b { background: #1f4e79; } p.rubric.safety-class-heading.safety-qm { background: #6c757d; } - /* Matches the aspect ratio of the rendered pie charts. */ + /* Stands in for a whole pie row of a safety class without any Need. */ .empty-pie { - display: flex; flex-direction: column; align-items: center; - justify-content: center; gap: 0.4em; - aspect-ratio: 16 / 9; padding: 0.5em; text-align: center; - border: 2px dashed #b0b0b0; border-radius: 6px; color: #6c757d; + padding: 0.2em 0.8em; margin-bottom: 0.5em; + border: 2px dashed #b0b0b0; border-radius: 6px; + color: #6c757d; font-style: italic; } - .empty-pie-title { font-weight: 600; } - .empty-pie-text { font-style: italic; } .chart-legend { display: flex; flex-wrap: wrap; gap: 1.2em 2.5em; } .chart-legend-group { diff --git a/src/needs_templates/platform_verification_report.need b/src/needs_templates/platform_verification_report.need index 62efcf1e8..ae3e3f61a 100644 --- a/src/needs_templates/platform_verification_report.need +++ b/src/needs_templates/platform_verification_report.need @@ -76,7 +76,7 @@ .wp-doc-table td .dataTables_wrapper .dataTables_paginate { display: none; } /* Scales every chart to its column; together with the fixed three-column raster this gives every chart in the report the same size. */ - .score-pie-row img[id^="needpie-"] { + .score-pie-row img.score-report-pie { width: 100%; height: auto; } @@ -92,15 +92,12 @@ p.rubric.safety-class-heading.safety-asil-b { background: #1f4e79; } p.rubric.safety-class-heading.safety-qm { background: #6c757d; } - /* Matches the aspect ratio of the rendered pie charts. */ + /* Stands in for a whole pie row of a safety class without any Need. */ .empty-pie { - display: flex; flex-direction: column; align-items: center; - justify-content: center; gap: 0.4em; - aspect-ratio: 16 / 9; padding: 0.5em; text-align: center; - border: 2px dashed #b0b0b0; border-radius: 6px; color: #6c757d; + padding: 0.2em 0.8em; margin-bottom: 0.5em; + border: 2px dashed #b0b0b0; border-radius: 6px; + color: #6c757d; font-style: italic; } - .empty-pie-title { font-weight: 600; } - .empty-pie-text { font-style: italic; } .chart-legend { display: flex; flex-wrap: wrap; gap: 1.2em 2.5em; } .chart-legend-group {