From 7589c332beeea0096d8201b59365db0f950e9d84 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Fri, 16 Oct 2026 09:31:17 +0000 Subject: [PATCH 01/10] feat: larger verification report pies via two-line titles Split each report pie title into two lines and crop the now blank left 13% of the needpie image, so the charts grow by 15% while a row keeps three pies. --- src/extensions/score_sphinx_needs_templates/__init__.py | 9 +++++++-- .../tests/test_needs_templates.py | 2 +- src/needs_templates/module_verification_report.need | 7 ++++++- src/needs_templates/platform_verification_report.need | 7 ++++++- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py index d7f3f44d8..b748cd934 100644 --- a/src/extensions/score_sphinx_needs_templates/__init__.py +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -417,8 +417,13 @@ def grid_item(name: str, pie: list[str]) -> None: ] ) else: - lines.append( - f" .. needpie:: {chart_prefix} {name} ({safety_label})" + # A continuation line keeps its newline in the directive argument, + # so matplotlib draws a two-line, narrower title. + lines.extend( + [ + f" .. needpie:: {chart_prefix}", + f" {name} ({safety_label})", + ] ) lines.extend(f" {line}".rstrip() for line in pie) 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 8e2a15c61..f913c2448 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 @@ -273,7 +273,7 @@ def test_safety_pie_row_reference_pie_only_on_request() -> None: 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 ".. needpie:: Stakeholder Requirements\n Status (QM)\n" in with_ref def test_id_filter_list_renders_quoted_literal() -> None: diff --git a/src/needs_templates/module_verification_report.need b/src/needs_templates/module_verification_report.need index 6df98553a..fe0838703 100644 --- a/src/needs_templates/module_verification_report.need +++ b/src/needs_templates/module_verification_report.need @@ -84,9 +84,14 @@ /* 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-"] { - width: 100%; + width: 115%; + margin-left: -15%; + max-width: none; height: auto; } + /* The left 13% of a needpie image is blank, so cropping it enlarges the + chart by 15% without hiding the two-line title, labels or legend. */ + .score-pie-row > .sd-col { overflow: hidden; } /* ``.sd-row`` is a flex container without a justification of its own, so this centres the two pies in the three-column raster. */ .score-pie-row { justify-content: center; } diff --git a/src/needs_templates/platform_verification_report.need b/src/needs_templates/platform_verification_report.need index 62efcf1e8..1c31703e7 100644 --- a/src/needs_templates/platform_verification_report.need +++ b/src/needs_templates/platform_verification_report.need @@ -77,9 +77,14 @@ /* 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-"] { - width: 100%; + width: 115%; + margin-left: -15%; + max-width: none; height: auto; } + /* The left 13% of a needpie image is blank, so cropping it enlarges the + chart by 15% without hiding the two-line title, labels or legend. */ + .score-pie-row > .sd-col { overflow: hidden; } /* ``.sd-row`` is a flex container without a justification of its own, so this centres the rows that leave the third column empty. */ .score-pie-row { justify-content: center; } From 5cb46e6ee0843837b99ede94c80a216cb8e16d17 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Fri, 16 Oct 2026 10:20:35 +0000 Subject: [PATCH 02/10] feat: lay out report pies with the legend below the chart Sphinx-Needs draws every needpie at 8x4 inches with the legend beside it, so the circle covers only ~35% of the image width. Re-lay out only the pies written by safety_pie_row (recognised by their two-line title) as a 5x6 inch portrait chart with the legend underneath and without the duplicate slice labels. The circle now fills ~80% of its column. The CSS crop of the previous commit is dropped; other needpie charts stay untouched. --- .../score_sphinx_needs_templates/__init__.py | 57 ++++++++++++++ .../tests/test_needs_templates.py | 78 +++++++++++++++++++ .../module_verification_report.need | 9 +-- .../platform_verification_report.need | 9 +-- 4 files changed, 139 insertions(+), 14 deletions(-) diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py index b748cd934..6d979b5e9 100644 --- a/src/extensions/score_sphinx_needs_templates/__init__.py +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -10,12 +10,17 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* +import re from pathlib import Path +from typing import Any +import sphinx_needs.directives.needpie +from docutils import nodes from sphinx.application import Sphinx from sphinx.environment import BuildEnvironment from sphinx_needs.data import SphinxNeedsData from sphinx_needs.need_item import NeedItem +from sphinx_needs.utils import save_matplotlib_figure from src.helper_lib import config_setdefault @@ -466,6 +471,57 @@ def grid_item(name: str, pie: list[str]) -> None: _safety_pie_row_callable = _SafetyPieRow() +# The second title line written by ``_SafetyPieRow``; it tells the report pies +# apart from every other needpie in the build. +_REPORT_PIE_TITLE = re.compile( + r"\n(?:Status|Test Coverage|Reference Coverage) \([^()\n]+\)$" +) + + +def _relayout_report_pie(figure: Any) -> None: + """Re-lay out a report pie as a portrait chart with the legend underneath. + + Sphinx-Needs draws every needpie at 8x4 inches with the legend beside it, + so the circle covers only ~35% of the image width. The slice labels repeat + the legend and are hidden, which lets the circle fill ~80% of the width. + """ + axes = figure.axes[0] + figure.set_size_inches(5, 6) + figure.subplots_adjust(left=0.02, right=0.98, top=0.87, bottom=0.17) + axes.set_ylim(-1.05, 1.05) + axes.title.set_wrap(True) + legend = axes.get_legend() + if legend is None: + return + names = [text.get_text() for text in legend.get_texts()] + for text in axes.texts: + label = text.get_text().strip() + if label and any(name.startswith(label) for name in names): + text.set_visible(False) + else: + text.set_fontsize(10) + # ``Axes.legend`` replaces the old legend; its "legend" heading carries no + # information and is dropped. + axes.legend( + list(legend.legend_handles), + names, + loc="upper center", + bbox_to_anchor=(0.5, -0.02), + frameon=False, + borderaxespad=0, + handlelength=1.2, + fontsize=10, + ) + + +def _save_needpie_figure( + app: Sphinx, figure: Any, basename: str, fromdocname: str +) -> nodes.image: + """Drop-in for ``save_matplotlib_figure`` that touches report pies only.""" + if figure.axes and _REPORT_PIE_TITLE.search(figure.axes[0].get_title()): + _relayout_report_pie(figure) + return save_matplotlib_figure(app, figure, basename, fromdocname) + def _post_templates_requiring_reread(app: Sphinx) -> set[str]: """Return post-template names opting into the post-merge rendering pass.""" @@ -572,6 +628,7 @@ def setup(app: Sphinx) -> dict[str, object]: app.config.needs_render_context.setdefault( "safety_pie_row", _safety_pie_row_callable ) + sphinx_needs.directives.needpie.save_matplotlib_figure = _save_needpie_figure 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/tests/test_needs_templates.py b/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py index f913c2448..54a8f759a 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 @@ -12,14 +12,21 @@ # ******************************************************************************* """Tests for graph traversal helpers used by Sphinx-Needs templates.""" +import re from collections.abc import Iterable from pathlib import Path +from typing import Any +import matplotlib import pytest import score_sphinx_needs_templates as templates from score_pytest.attribute_plugin import add_test_properties from sphinx.testing.util import SphinxTestApp +matplotlib.use("Agg") + +import matplotlib.pyplot # noqa: E402 + class FakeLink: """Small test double for the Sphinx-Needs link objects.""" @@ -276,6 +283,77 @@ def test_safety_pie_row_reference_pie_only_on_request() -> None: assert ".. needpie:: Stakeholder Requirements\n Status (QM)\n" in with_ref +def _needpie_figure(title: str, legend: bool = True) -> Any: + """Build a figure shaped the way Sphinx-Needs leaves a needpie chart.""" + figure, axes = matplotlib.pyplot.subplots( + figsize=(8, 4), subplot_kw={"aspect": "equal"} + ) + pie: Any = axes.pie([3, 1], labels=[" valid", " invalid"], autopct="%1.1f%%") + if legend: + axes.legend( + pie[0], + ["valid", "invalid"], + title="legend", + loc="center left", + bbox_to_anchor=(0.8, 0, 0.5, 1), + ) + axes.set_title(title) + return figure + + +def test_report_pie_title_marks_safety_pie_row_charts_only() -> None: + safety_pie_row = vars(templates)["_SafetyPieRow"]() + report_pie_title = vars(templates)["_REPORT_PIE_TITLE"] + + row = safety_pie_row("Comp", "Requirements", "B", "ASIL_B", "ASIL B", 2, True) + titles = re.findall(r"\.\. needpie:: (.*)\n +(.*)\n", row) + + assert len(titles) == 3 + assert all(report_pie_title.search(f"{a}\n{b}") for a, b in titles) + assert not report_pie_title.search("Comp Requirements Status (ASIL B)") + + +def test_report_pie_moves_legend_below_and_hides_slice_labels() -> None: + relayout = vars(templates)["_relayout_report_pie"] + figure = _needpie_figure("Comp Requirements\nStatus (ASIL B)") + axes = figure.axes[0] + + relayout(figure) + figure.canvas.draw() + + assert tuple(figure.get_size_inches()) == (5, 6) + assert axes.get_legend().get_window_extent().y1 < axes.get_window_extent().y0 + visible = [t.get_text() for t in axes.texts if t.get_visible()] + assert visible == ["75.0%", "25.0%"] + matplotlib.pyplot.close(figure) + + +def test_report_pie_without_legend_keeps_slice_labels() -> None: + relayout = vars(templates)["_relayout_report_pie"] + figure = _needpie_figure("Comp Requirements\nStatus (QM)", legend=False) + + relayout(figure) + + assert figure.axes[0].get_legend() is None + assert all(t.get_visible() for t in figure.axes[0].texts) + matplotlib.pyplot.close(figure) + + +def test_other_needpies_are_saved_unchanged(monkeypatch: pytest.MonkeyPatch) -> None: + saved: list[Any] = [] + monkeypatch.setattr( + templates, "save_matplotlib_figure", lambda *args: saved.append(args) + ) + save_needpie_figure = vars(templates)["_save_needpie_figure"] + figure = _needpie_figure("Open issues") + + save_needpie_figure(None, figure, "need_pie_x", "index") + + assert tuple(figure.get_size_inches()) == (8, 4) + assert len(saved) == 1 + matplotlib.pyplot.close(figure) + + def test_id_filter_list_renders_quoted_literal() -> None: id_filter_list = vars(templates)["_IdFilterList"]() diff --git a/src/needs_templates/module_verification_report.need b/src/needs_templates/module_verification_report.need index fe0838703..e8b500e45 100644 --- a/src/needs_templates/module_verification_report.need +++ b/src/needs_templates/module_verification_report.need @@ -84,14 +84,9 @@ /* 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-"] { - width: 115%; - margin-left: -15%; - max-width: none; + width: 100%; height: auto; } - /* The left 13% of a needpie image is blank, so cropping it enlarges the - chart by 15% without hiding the two-line title, labels or legend. */ - .score-pie-row > .sd-col { overflow: hidden; } /* ``.sd-row`` is a flex container without a justification of its own, so this centres the two pies in the three-column raster. */ .score-pie-row { justify-content: center; } @@ -108,7 +103,7 @@ .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; + aspect-ratio: 5 / 6; padding: 0.5em; text-align: center; border: 2px dashed #b0b0b0; border-radius: 6px; color: #6c757d; } .empty-pie-title { font-weight: 600; } diff --git a/src/needs_templates/platform_verification_report.need b/src/needs_templates/platform_verification_report.need index 1c31703e7..173d4fddb 100644 --- a/src/needs_templates/platform_verification_report.need +++ b/src/needs_templates/platform_verification_report.need @@ -77,14 +77,9 @@ /* 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-"] { - width: 115%; - margin-left: -15%; - max-width: none; + width: 100%; height: auto; } - /* The left 13% of a needpie image is blank, so cropping it enlarges the - chart by 15% without hiding the two-line title, labels or legend. */ - .score-pie-row > .sd-col { overflow: hidden; } /* ``.sd-row`` is a flex container without a justification of its own, so this centres the rows that leave the third column empty. */ .score-pie-row { justify-content: center; } @@ -101,7 +96,7 @@ .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; + aspect-ratio: 5 / 6; padding: 0.5em; text-align: center; border: 2px dashed #b0b0b0; border-radius: 6px; color: #6c757d; } .empty-pie-title { font-weight: 600; } From c002a42094ec7e88444a0af77ce2664e908f988d Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Fri, 16 Oct 2026 11:01:38 +0000 Subject: [PATCH 03/10] feat: medium report pies with larger slice numbers Shrink the report pie circle from ~80% to ~60% of its column and set the percentages and counts inside the slices to 17pt bold, the legend to 12pt and the title to 13pt, so the numbers read well three charts per row. --- .../score_sphinx_needs_templates/__init__.py | 16 +++++++++++----- .../tests/test_needs_templates.py | 2 +- .../module_verification_report.need | 2 +- .../platform_verification_report.need | 2 +- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py index 6d979b5e9..8b8012827 100644 --- a/src/extensions/score_sphinx_needs_templates/__init__.py +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -483,12 +483,18 @@ def _relayout_report_pie(figure: Any) -> None: Sphinx-Needs draws every needpie at 8x4 inches with the legend beside it, so the circle covers only ~35% of the image width. The slice labels repeat - the legend and are hidden, which lets the circle fill ~80% of the width. + the legend and are hidden, which lets the circle fill ~60% of the width + and leaves room for large slice numbers. """ axes = figure.axes[0] - figure.set_size_inches(5, 6) - figure.subplots_adjust(left=0.02, right=0.98, top=0.87, bottom=0.17) + width, height = 5.0, 4.8 + # Inches reserved above the circle for the title and below it for the legend. + figure.set_size_inches(width, height) + figure.subplots_adjust( + left=0.02, right=0.98, top=1 - 0.75 / height, bottom=1.05 / height + ) axes.set_ylim(-1.05, 1.05) + axes.title.set_fontsize(13) axes.title.set_wrap(True) legend = axes.get_legend() if legend is None: @@ -499,7 +505,7 @@ def _relayout_report_pie(figure: Any) -> None: if label and any(name.startswith(label) for name in names): text.set_visible(False) else: - text.set_fontsize(10) + text.set_fontsize(17) # ``Axes.legend`` replaces the old legend; its "legend" heading carries no # information and is dropped. axes.legend( @@ -510,7 +516,7 @@ def _relayout_report_pie(figure: Any) -> None: frameon=False, borderaxespad=0, handlelength=1.2, - fontsize=10, + fontsize=12, ) 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 54a8f759a..7d55b5943 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 @@ -321,7 +321,7 @@ def test_report_pie_moves_legend_below_and_hides_slice_labels() -> None: relayout(figure) figure.canvas.draw() - assert tuple(figure.get_size_inches()) == (5, 6) + assert tuple(figure.get_size_inches()) == (5, 4.8) assert axes.get_legend().get_window_extent().y1 < axes.get_window_extent().y0 visible = [t.get_text() for t in axes.texts if t.get_visible()] assert visible == ["75.0%", "25.0%"] diff --git a/src/needs_templates/module_verification_report.need b/src/needs_templates/module_verification_report.need index e8b500e45..2bac9683c 100644 --- a/src/needs_templates/module_verification_report.need +++ b/src/needs_templates/module_verification_report.need @@ -103,7 +103,7 @@ .empty-pie { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.4em; - aspect-ratio: 5 / 6; padding: 0.5em; text-align: center; + aspect-ratio: 25 / 24; padding: 0.5em; text-align: center; border: 2px dashed #b0b0b0; border-radius: 6px; color: #6c757d; } .empty-pie-title { font-weight: 600; } diff --git a/src/needs_templates/platform_verification_report.need b/src/needs_templates/platform_verification_report.need index 173d4fddb..bffd484ee 100644 --- a/src/needs_templates/platform_verification_report.need +++ b/src/needs_templates/platform_verification_report.need @@ -96,7 +96,7 @@ .empty-pie { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.4em; - aspect-ratio: 5 / 6; padding: 0.5em; text-align: center; + aspect-ratio: 25 / 24; padding: 0.5em; text-align: center; border: 2px dashed #b0b0b0; border-radius: 6px; color: #6c757d; } .empty-pie-title { font-weight: 600; } From 2b8e4d6b7a3e2e3d626ae2cfd0aaa156e76a2443 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Fri, 16 Oct 2026 11:55:18 +0000 Subject: [PATCH 04/10] fix: enlarge report pie numbers even when legends are suppressed reference_integration patches matplotlib's Axes.legend to a no-op, so report pies arrive without a legend and were left with 8pt numbers. Recognise the slice values by their Sphinx-Needs format and enlarge them regardless; slice labels stay visible when there is no legend to repeat them. --- .../score_sphinx_needs_templates/__init__.py | 19 ++++++++++--------- .../tests/test_needs_templates.py | 14 +++++++++++--- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py index 8b8012827..709ba650e 100644 --- a/src/extensions/score_sphinx_needs_templates/__init__.py +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -476,6 +476,8 @@ def grid_item(name: str, pie: list[str]) -> None: _REPORT_PIE_TITLE = re.compile( r"\n(?:Status|Test Coverage|Reference Coverage) \([^()\n]+\)$" ) +# Sphinx-Needs writes the value inside a slice as "%\n()". +_SLICE_VALUE = re.compile(r"^[\d.]+%\n\(\d+\)$") def _relayout_report_pie(figure: Any) -> None: @@ -484,7 +486,8 @@ def _relayout_report_pie(figure: Any) -> None: Sphinx-Needs draws every needpie at 8x4 inches with the legend beside it, so the circle covers only ~35% of the image width. The slice labels repeat the legend and are hidden, which lets the circle fill ~60% of the width - and leaves room for large slice numbers. + and leaves room for large slice numbers. Projects that suppress needpie + legends (``Axes.legend`` patched away) keep their slice labels instead. """ axes = figure.axes[0] width, height = 5.0, 4.8 @@ -497,20 +500,18 @@ def _relayout_report_pie(figure: Any) -> None: axes.title.set_fontsize(13) axes.title.set_wrap(True) legend = axes.get_legend() - if legend is None: - return - names = [text.get_text() for text in legend.get_texts()] for text in axes.texts: - label = text.get_text().strip() - if label and any(name.startswith(label) for name in names): - text.set_visible(False) - else: + if _SLICE_VALUE.match(text.get_text()): text.set_fontsize(17) + elif legend is not None: + text.set_visible(False) + if legend is None: + return # ``Axes.legend`` replaces the old legend; its "legend" heading carries no # information and is dropped. axes.legend( list(legend.legend_handles), - names, + [text.get_text() for text in legend.get_texts()], loc="upper center", bbox_to_anchor=(0.5, -0.02), frameon=False, 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 7d55b5943..5562a0f08 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 @@ -288,7 +288,11 @@ def _needpie_figure(title: str, legend: bool = True) -> Any: figure, axes = matplotlib.pyplot.subplots( figsize=(8, 4), subplot_kw={"aspect": "equal"} ) - pie: Any = axes.pie([3, 1], labels=[" valid", " invalid"], autopct="%1.1f%%") + pie: Any = axes.pie( + [3, 1], + labels=[" valid", " invalid"], + autopct=lambda pct: f"{pct:.1f}%\n({round(pct * 4 / 100)})", + ) if legend: axes.legend( pie[0], @@ -324,18 +328,22 @@ def test_report_pie_moves_legend_below_and_hides_slice_labels() -> None: assert tuple(figure.get_size_inches()) == (5, 4.8) assert axes.get_legend().get_window_extent().y1 < axes.get_window_extent().y0 visible = [t.get_text() for t in axes.texts if t.get_visible()] - assert visible == ["75.0%", "25.0%"] + assert visible == ["75.0%\n(3)", "25.0%\n(1)"] + assert all(t.get_fontsize() == 17 for t in axes.texts if t.get_visible()) matplotlib.pyplot.close(figure) def test_report_pie_without_legend_keeps_slice_labels() -> None: + """Projects may patch ``Axes.legend`` away; the numbers must grow anyway.""" relayout = vars(templates)["_relayout_report_pie"] figure = _needpie_figure("Comp Requirements\nStatus (QM)", legend=False) + texts = figure.axes[0].texts relayout(figure) assert figure.axes[0].get_legend() is None - assert all(t.get_visible() for t in figure.axes[0].texts) + assert all(t.get_visible() for t in texts) + assert sorted(t.get_fontsize() for t in texts) == [12, 12, 17, 17] matplotlib.pyplot.close(figure) From 48eddce11dc98acd8c80acfbfb3b4967eeeaae0b Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Fri, 16 Oct 2026 11:55:57 +0000 Subject: [PATCH 05/10] test: expect default-size slice labels without a legend --- .../score_sphinx_needs_templates/tests/test_needs_templates.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 5562a0f08..8600f98ef 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 @@ -343,7 +343,7 @@ def test_report_pie_without_legend_keeps_slice_labels() -> None: assert figure.axes[0].get_legend() is None assert all(t.get_visible() for t in texts) - assert sorted(t.get_fontsize() for t in texts) == [12, 12, 17, 17] + assert sorted(t.get_fontsize() for t in texts) == [10, 10, 17, 17] matplotlib.pyplot.close(figure) From 805babd8d80a79f7cc40dc1f16a2b80579b61ebb Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Fri, 16 Oct 2026 12:09:23 +0000 Subject: [PATCH 06/10] feat: drop legend and slice labels from report pies Both verification reports explain the slice colours once at the top of the page, so the per-chart legend and the slice labels only cost space. Report pies now show the two-line title and the enlarged slice numbers in a 5x4 inch figure, also where a project patches Axes.legend away. --- .../score_sphinx_needs_templates/__init__.py | 34 ++++++------------- .../tests/test_needs_templates.py | 34 +++++++------------ .../module_verification_report.need | 2 +- .../platform_verification_report.need | 2 +- 4 files changed, 25 insertions(+), 47 deletions(-) diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py index 709ba650e..69d1b4484 100644 --- a/src/extensions/score_sphinx_needs_templates/__init__.py +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -481,44 +481,32 @@ def grid_item(name: str, pie: list[str]) -> None: def _relayout_report_pie(figure: Any) -> None: - """Re-lay out a report pie as a portrait chart with the legend underneath. + """Re-lay out a report pie as a compact chart showing only its numbers. Sphinx-Needs draws every needpie at 8x4 inches with the legend beside it, - so the circle covers only ~35% of the image width. The slice labels repeat - the legend and are hidden, which lets the circle fill ~60% of the width - and leaves room for large slice numbers. Projects that suppress needpie - legends (``Axes.legend`` patched away) keep their slice labels instead. + so the circle covers only ~35% of the image width. The reports explain the + slice colours once at the top of the page, so the legend and the slice + labels are dropped; the circle fills ~60% of the width and the numbers in + the slices are enlarged. """ axes = figure.axes[0] - width, height = 5.0, 4.8 - # Inches reserved above the circle for the title and below it for the legend. + width, height = 5.0, 4.0 + # Inches reserved above the circle for the title and below it as margin. figure.set_size_inches(width, height) figure.subplots_adjust( - left=0.02, right=0.98, top=1 - 0.75 / height, bottom=1.05 / height + left=0.02, right=0.98, top=1 - 0.75 / height, bottom=0.15 / height ) axes.set_ylim(-1.05, 1.05) axes.title.set_fontsize(13) axes.title.set_wrap(True) legend = axes.get_legend() + if legend is not None: + legend.remove() for text in axes.texts: if _SLICE_VALUE.match(text.get_text()): text.set_fontsize(17) - elif legend is not None: + else: text.set_visible(False) - if legend is None: - return - # ``Axes.legend`` replaces the old legend; its "legend" heading carries no - # information and is dropped. - axes.legend( - list(legend.legend_handles), - [text.get_text() for text in legend.get_texts()], - loc="upper center", - bbox_to_anchor=(0.5, -0.02), - frameon=False, - borderaxespad=0, - handlelength=1.2, - fontsize=12, - ) def _save_needpie_figure( 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 8600f98ef..49999a173 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 @@ -317,33 +317,23 @@ def test_report_pie_title_marks_safety_pie_row_charts_only() -> None: assert not report_pie_title.search("Comp Requirements Status (ASIL B)") -def test_report_pie_moves_legend_below_and_hides_slice_labels() -> None: - relayout = vars(templates)["_relayout_report_pie"] - figure = _needpie_figure("Comp Requirements\nStatus (ASIL B)") - axes = figure.axes[0] +@pytest.mark.parametrize("legend", [True, False]) +def test_report_pie_shows_only_enlarged_slice_numbers(legend: bool) -> None: + """The page explains the colours, so legend and slice labels are dropped. - relayout(figure) - figure.canvas.draw() - - assert tuple(figure.get_size_inches()) == (5, 4.8) - assert axes.get_legend().get_window_extent().y1 < axes.get_window_extent().y0 - visible = [t.get_text() for t in axes.texts if t.get_visible()] - assert visible == ["75.0%\n(3)", "25.0%\n(1)"] - assert all(t.get_fontsize() == 17 for t in axes.texts if t.get_visible()) - matplotlib.pyplot.close(figure) - - -def test_report_pie_without_legend_keeps_slice_labels() -> None: - """Projects may patch ``Axes.legend`` away; the numbers must grow anyway.""" + ``legend=False`` covers projects that patch ``Axes.legend`` away. + """ relayout = vars(templates)["_relayout_report_pie"] - figure = _needpie_figure("Comp Requirements\nStatus (QM)", legend=False) - texts = figure.axes[0].texts + figure = _needpie_figure("Comp Requirements\nStatus (ASIL B)", legend=legend) + axes = figure.axes[0] relayout(figure) - assert figure.axes[0].get_legend() is None - assert all(t.get_visible() for t in texts) - assert sorted(t.get_fontsize() for t in texts) == [10, 10, 17, 17] + assert tuple(figure.get_size_inches()) == (5, 4) + assert axes.get_legend() is None + visible = [t for t in axes.texts if t.get_visible()] + assert [t.get_text() for t in visible] == ["75.0%\n(3)", "25.0%\n(1)"] + assert all(t.get_fontsize() == 17 for t in visible) matplotlib.pyplot.close(figure) diff --git a/src/needs_templates/module_verification_report.need b/src/needs_templates/module_verification_report.need index 2bac9683c..643ee4579 100644 --- a/src/needs_templates/module_verification_report.need +++ b/src/needs_templates/module_verification_report.need @@ -103,7 +103,7 @@ .empty-pie { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.4em; - aspect-ratio: 25 / 24; padding: 0.5em; text-align: center; + aspect-ratio: 4 / 3; padding: 0.5em; text-align: center; border: 2px dashed #b0b0b0; border-radius: 6px; color: #6c757d; } .empty-pie-title { font-weight: 600; } diff --git a/src/needs_templates/platform_verification_report.need b/src/needs_templates/platform_verification_report.need index bffd484ee..12b91af30 100644 --- a/src/needs_templates/platform_verification_report.need +++ b/src/needs_templates/platform_verification_report.need @@ -96,7 +96,7 @@ .empty-pie { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.4em; - aspect-ratio: 25 / 24; padding: 0.5em; text-align: center; + aspect-ratio: 4 / 3; padding: 0.5em; text-align: center; border: 2px dashed #b0b0b0; border-radius: 6px; color: #6c757d; } .empty-pie-title { font-weight: 600; } From d5fd89d73c00f50a1ceac8097dac02d639a74f36 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Fri, 16 Oct 2026 13:37:14 +0000 Subject: [PATCH 07/10] style: report pie numbers at 14pt --- src/extensions/score_sphinx_needs_templates/__init__.py | 2 +- .../score_sphinx_needs_templates/tests/test_needs_templates.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py index 69d1b4484..6082cf6f9 100644 --- a/src/extensions/score_sphinx_needs_templates/__init__.py +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -504,7 +504,7 @@ def _relayout_report_pie(figure: Any) -> None: legend.remove() for text in axes.texts: if _SLICE_VALUE.match(text.get_text()): - text.set_fontsize(17) + text.set_fontsize(14) else: text.set_visible(False) 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 49999a173..abb123604 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 @@ -333,7 +333,7 @@ def test_report_pie_shows_only_enlarged_slice_numbers(legend: bool) -> None: assert axes.get_legend() is None visible = [t for t in axes.texts if t.get_visible()] assert [t.get_text() for t in visible] == ["75.0%\n(3)", "25.0%\n(1)"] - assert all(t.get_fontsize() == 17 for t in visible) + assert all(t.get_fontsize() == 14 for t in visible) matplotlib.pyplot.close(figure) From 52c5f9cdc2c0b5688e1d78621f3365d0d83aa6c5 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Mon, 19 Oct 2026 08:53:05 +0000 Subject: [PATCH 08/10] refactor: draw report pies with a dedicated score-report-pie directive Replace the global patch of needpie's save_matplotlib_figure, which picked the report pies by their title, with an explicit directive used only by safety_pie_row. It counts one Sphinx-Needs filter per content line like needpie and draws the compact report chart itself; every other needpie stays untouched. --- .../score_sphinx_needs_templates/BUILD | 10 +- .../score_sphinx_needs_templates/README.md | 5 + .../score_sphinx_needs_templates/__init__.py | 63 +------ .../report_pie.py | 161 ++++++++++++++++++ .../tests/test_needs_templates.py | 85 +-------- .../tests/test_report_pie.py | 115 +++++++++++++ .../module_verification_report.need | 4 +- .../platform_verification_report.need | 4 +- 8 files changed, 304 insertions(+), 143 deletions(-) create mode 100644 src/extensions/score_sphinx_needs_templates/report_pie.py create mode 100644 src/extensions/score_sphinx_needs_templates/tests/test_report_pie.py 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 6082cf6f9..b3055dc80 100644 --- a/src/extensions/score_sphinx_needs_templates/__init__.py +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -10,20 +10,17 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -import re from pathlib import Path -from typing import Any -import sphinx_needs.directives.needpie -from docutils import nodes from sphinx.application import Sphinx from sphinx.environment import BuildEnvironment from sphinx_needs.data import SphinxNeedsData from sphinx_needs.need_item import NeedItem -from sphinx_needs.utils import save_matplotlib_figure 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 @@ -422,11 +419,10 @@ def grid_item(name: str, pie: list[str]) -> None: ] ) else: - # A continuation line keeps its newline in the directive argument, - # so matplotlib draws a two-line, narrower title. + # The continuation line keeps the chart title on two narrow lines. lines.extend( [ - f" .. needpie:: {chart_prefix}", + f" .. score-report-pie:: {chart_prefix}", f" {name} ({safety_label})", ] ) @@ -437,7 +433,6 @@ def grid_item(name: str, pie: list[str]) -> None: [ f":labels: {', '.join(label for label, _ in statuses)}", f":colors: {status_colors}", - ":legend:", "", *(f"{scope} and {condition}" for _, condition in statuses), ], @@ -447,7 +442,6 @@ def grid_item(name: str, pie: list[str]) -> None: [ ":labels: fully covered, partially covered, not covered", ":colors: #37a12d, #f0a500, #ca2828", - ":legend:", "", f"{scope} and fully_verifies_back", f"{scope} and partially_verifies_back and not fully_verifies_back", @@ -460,7 +454,6 @@ def grid_item(name: str, pie: list[str]) -> None: [ ":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 [])}", @@ -471,52 +464,6 @@ def grid_item(name: str, pie: list[str]) -> None: _safety_pie_row_callable = _SafetyPieRow() -# The second title line written by ``_SafetyPieRow``; it tells the report pies -# apart from every other needpie in the build. -_REPORT_PIE_TITLE = re.compile( - r"\n(?:Status|Test Coverage|Reference Coverage) \([^()\n]+\)$" -) -# Sphinx-Needs writes the value inside a slice as "%\n()". -_SLICE_VALUE = re.compile(r"^[\d.]+%\n\(\d+\)$") - - -def _relayout_report_pie(figure: Any) -> None: - """Re-lay out a report pie as a compact chart showing only its numbers. - - Sphinx-Needs draws every needpie at 8x4 inches with the legend beside it, - so the circle covers only ~35% of the image width. The reports explain the - slice colours once at the top of the page, so the legend and the slice - labels are dropped; the circle fills ~60% of the width and the numbers in - the slices are enlarged. - """ - axes = figure.axes[0] - width, height = 5.0, 4.0 - # Inches reserved above the circle for the title and below it as margin. - figure.set_size_inches(width, height) - figure.subplots_adjust( - left=0.02, right=0.98, top=1 - 0.75 / height, bottom=0.15 / height - ) - axes.set_ylim(-1.05, 1.05) - axes.title.set_fontsize(13) - axes.title.set_wrap(True) - legend = axes.get_legend() - if legend is not None: - legend.remove() - for text in axes.texts: - if _SLICE_VALUE.match(text.get_text()): - text.set_fontsize(14) - else: - text.set_visible(False) - - -def _save_needpie_figure( - app: Sphinx, figure: Any, basename: str, fromdocname: str -) -> nodes.image: - """Drop-in for ``save_matplotlib_figure`` that touches report pies only.""" - if figure.axes and _REPORT_PIE_TITLE.search(figure.axes[0].get_title()): - _relayout_report_pie(figure) - return save_matplotlib_figure(app, figure, basename, fromdocname) - def _post_templates_requiring_reread(app: Sphinx) -> set[str]: """Return post-template names opting into the post-merge rendering pass.""" @@ -623,7 +570,7 @@ def setup(app: Sphinx) -> dict[str, object]: app.config.needs_render_context.setdefault( "safety_pie_row", _safety_pie_row_callable ) - sphinx_needs.directives.needpie.save_matplotlib_figure = _save_needpie_figure + 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..9fa6b502a --- /dev/null +++ b/src/extensions/score_sphinx_needs_templates/report_pie.py @@ -0,0 +1,161 @@ +# ******************************************************************************* +# 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.docutils import SphinxDirective +from sphinx_needs.config import NeedsSphinxConfig +from sphinx_needs.data import SphinxNeedsData +from sphinx_needs.filter_common import filter_needs_parts +from sphinx_needs.utils import save_matplotlib_figure + +_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.05 + + +class ReportPie(nodes.General, nodes.Element): + pass + + +class ReportPieDirective(SphinxDirective): + """Pie chart with one slice per content line, each a Sphinx-Needs filter.""" + + has_content = True + optional_arguments = 1 + final_argument_whitespace = True + option_spec = { + "labels": directives.unchanged_required, + "colors": 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", "")) + if not (len(filters) == len(labels) == len(colors)): + raise self.error( + "score-report-pie needs one label and one colour per filter line" + ) + node = ReportPie( + title=self.arguments[0] if self.arguments else "", + filters=filters, + 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: list[int], 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) + 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 _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"] + sizes = [ + len(filter_needs_parts(need_list, needs_config, line, location=pie)) + for line in filters + ] + counts = ", ".join( + f"{label} {size}" 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 abb123604..afbb23056 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 @@ -12,21 +12,14 @@ # ******************************************************************************* """Tests for graph traversal helpers used by Sphinx-Needs templates.""" -import re from collections.abc import Iterable from pathlib import Path -from typing import Any -import matplotlib import pytest import score_sphinx_needs_templates as templates from score_pytest.attribute_plugin import add_test_properties from sphinx.testing.util import SphinxTestApp -matplotlib.use("Agg") - -import matplotlib.pyplot # noqa: E402 - class FakeLink: """Small test double for the Sphinx-Needs link objects.""" @@ -261,7 +254,7 @@ def test_safety_pie_row_empty_class_renders_placeholders_only() -> None: row = safety_pie_row("Comp", "Requirements", "BASE", "QM", "QM", 0, True) - assert "needpie" not in row + assert "score-report-pie" not in row assert row.count('
') == 3 assert "no QM requirements" in row @@ -275,81 +268,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\n Status (QM)\n" in with_ref - - -def _needpie_figure(title: str, legend: bool = True) -> Any: - """Build a figure shaped the way Sphinx-Needs leaves a needpie chart.""" - figure, axes = matplotlib.pyplot.subplots( - figsize=(8, 4), subplot_kw={"aspect": "equal"} - ) - pie: Any = axes.pie( - [3, 1], - labels=[" valid", " invalid"], - autopct=lambda pct: f"{pct:.1f}%\n({round(pct * 4 / 100)})", - ) - if legend: - axes.legend( - pie[0], - ["valid", "invalid"], - title="legend", - loc="center left", - bbox_to_anchor=(0.8, 0, 0.5, 1), - ) - axes.set_title(title) - return figure - - -def test_report_pie_title_marks_safety_pie_row_charts_only() -> None: - safety_pie_row = vars(templates)["_SafetyPieRow"]() - report_pie_title = vars(templates)["_REPORT_PIE_TITLE"] - - row = safety_pie_row("Comp", "Requirements", "B", "ASIL_B", "ASIL B", 2, True) - titles = re.findall(r"\.\. needpie:: (.*)\n +(.*)\n", row) - - assert len(titles) == 3 - assert all(report_pie_title.search(f"{a}\n{b}") for a, b in titles) - assert not report_pie_title.search("Comp Requirements Status (ASIL B)") - - -@pytest.mark.parametrize("legend", [True, False]) -def test_report_pie_shows_only_enlarged_slice_numbers(legend: bool) -> None: - """The page explains the colours, so legend and slice labels are dropped. - - ``legend=False`` covers projects that patch ``Axes.legend`` away. - """ - relayout = vars(templates)["_relayout_report_pie"] - figure = _needpie_figure("Comp Requirements\nStatus (ASIL B)", legend=legend) - axes = figure.axes[0] - - relayout(figure) - - assert tuple(figure.get_size_inches()) == (5, 4) - assert axes.get_legend() is None - visible = [t for t in axes.texts if t.get_visible()] - assert [t.get_text() for t in visible] == ["75.0%\n(3)", "25.0%\n(1)"] - assert all(t.get_fontsize() == 14 for t in visible) - matplotlib.pyplot.close(figure) - - -def test_other_needpies_are_saved_unchanged(monkeypatch: pytest.MonkeyPatch) -> None: - saved: list[Any] = [] - monkeypatch.setattr( - templates, "save_matplotlib_figure", lambda *args: saved.append(args) + assert ( + ".. score-report-pie:: Stakeholder Requirements\n Status (QM)\n" + in with_ref ) - save_needpie_figure = vars(templates)["_save_needpie_figure"] - figure = _needpie_figure("Open issues") - - save_needpie_figure(None, figure, "need_pie_x", "index") - - assert tuple(figure.get_size_inches()) == (8, 4) - assert len(saved) == 1 - matplotlib.pyplot.close(figure) 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..9b54a4e0a --- /dev/null +++ b/src/extensions/score_sphinx_needs_templates/tests/test_report_pie.py @@ -0,0 +1,115 @@ +# ******************************************************************************* +# 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 + + +def test_draw_report_pie_shows_only_large_slice_numbers() -> None: + figure = draw_report_pie( + "Comp Requirements\nStatus (QM)", [1, 30, 0], ["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 + # 1 of 31 is below 5% and 0 is empty, so only the 30 carries a number. + assert [t.get_text() for t in visible] == ["96.8%\n(30)"] + assert visible[0].get_fontsize() == 14 + 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 Date: Mon, 19 Oct 2026 11:56:18 +0000 Subject: [PATCH 09/10] feat: show an empty safety class as one slim bar instead of a pie row A safety class without any Need rendered three full-height dashed placeholders. It now gets a single one-line bar under its heading, so empty rows take little space. --- .../score_sphinx_needs_templates/__init__.py | 52 ++++++++++--------- .../tests/test_needs_templates.py | 6 +-- .../module_verification_report.need | 11 ++-- .../platform_verification_report.need | 11 ++-- 4 files changed, 39 insertions(+), 41 deletions(-) diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py index b3055dc80..21f41a7c6 100644 --- a/src/extensions/score_sphinx_needs_templates/__init__.py +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -400,33 +400,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: - # The continuation line keeps the chart title on two narrow lines. - lines.extend( - [ - f" .. score-report-pie:: {chart_prefix}", - f" {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) grid_item( "Status", 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 afbb23056..97336a954 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 @@ -249,14 +249,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 "score-report-pie" not in row - assert row.count('
') == 3 - assert "no QM requirements" 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: diff --git a/src/needs_templates/module_verification_report.need b/src/needs_templates/module_verification_report.need index 8e611c2e8..37ca9a5e3 100644 --- a/src/needs_templates/module_verification_report.need +++ b/src/needs_templates/module_verification_report.need @@ -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: 5 / 4; 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 f75fdf370..ae3e3f61a 100644 --- a/src/needs_templates/platform_verification_report.need +++ b/src/needs_templates/platform_verification_report.need @@ -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: 5 / 4; 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 { From 3e34a9a91bb6cc052735a092a05a41d28d023a20 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Mon, 19 Oct 2026 12:12:36 +0000 Subject: [PATCH 10/10] feat: number report pie slices down to 2% at half font size Slices between 2% and 5% now carry their percentage and count at half the font size; only slices below 2% stay unnumbered. --- .../report_pie.py | 6 +++++- .../tests/test_report_pie.py | 21 ++++++++++++------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/extensions/score_sphinx_needs_templates/report_pie.py b/src/extensions/score_sphinx_needs_templates/report_pie.py index 9fa6b502a..a7243b813 100644 --- a/src/extensions/score_sphinx_needs_templates/report_pie.py +++ b/src/extensions/score_sphinx_needs_templates/report_pie.py @@ -42,7 +42,9 @@ _VALUE_FONT_SIZE = 14 _TITLE_FONT_SIZE = 13 # Slices below this share get no number, as their text would overlap. -_MIN_LABELLED_SHARE = 0.05 +_MIN_LABELLED_SHARE = 0.02 +# Slices below this share get their number at half size to fit. +_SMALL_SLICE_SHARE = 0.05 class ReportPie(nodes.General, nodes.Element): @@ -114,6 +116,8 @@ def draw_report_pie(title: str, sizes: list[int], colors: list[str]) -> Figure: 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 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 index 9b54a4e0a..96fda69fb 100644 --- a/src/extensions/score_sphinx_needs_templates/tests/test_report_pie.py +++ b/src/extensions/score_sphinx_needs_templates/tests/test_report_pie.py @@ -21,19 +21,26 @@ from sphinx.testing.util import SphinxTestApp -def test_draw_report_pie_shows_only_large_slice_numbers() -> None: - figure = draw_report_pie( - "Comp Requirements\nStatus (QM)", [1, 30, 0], ["g", "y", "r"] - ) +@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 - # 1 of 31 is below 5% and 0 is empty, so only the 30 carries a number. - assert [t.get_text() for t in visible] == ["96.8%\n(30)"] - assert visible[0].get_fontsize() == 14 + assert [(t.get_text(), t.get_fontsize()) for t in visible] == expected assert axes.get_title() == "Comp Requirements\nStatus (QM)"