Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/extensions/score_sphinx_needs_templates/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
)

Expand All @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions src/extensions/score_sphinx_needs_templates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 31 additions & 23 deletions src/extensions/score_sphinx_needs_templates/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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' <div class="empty-pie">no {safety_label} {subject.lower()}</div>',
]
)
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",
"",
' <div class="empty-pie">',
f' <span class="empty-pie-title">{subject} {name} ({safety_label})</span>',
f' <span class="empty-pie-text">no {safety_label} {subject.lower()}</span>',
" </div>",
]
)
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.
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 [])}",
Expand Down Expand Up @@ -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
Expand Down
224 changes: 224 additions & 0 deletions src/extensions/score_sphinx_needs_templates/report_pie.py
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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('<div class="empty-pie">') == 3
assert "no QM requirements" in row
assert "score-report-pie" not in row
assert ".. grid::" not in row
assert row.count('<div class="empty-pie">no QM requirements</div>') == 1


def test_safety_pie_row_reference_pie_only_on_request() -> None:
Expand All @@ -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:
Expand Down
Loading
Loading