From 3338703bf556cb628b9079b9c4cd8562960832d2 Mon Sep 17 00:00:00 2001 From: Tobias Kaestner Date: Fri, 9 Oct 2026 16:20:08 +0200 Subject: [PATCH 1/3] feat: sphinx: need layouts that hide chosen incoming links sphinx-needs shows every incoming link of a need. On the Zephyr requirements document one requirement has 434 "covered by" links to test results, and one test case of the test specification has 67 "has results" links. The lists hide the rest of the need. need_layouts.need_layout(hide_backlinks=[...]) gives the sphinx-needs "clean" layout without the incoming side of the named links. links_line(hide_backlinks) gives the links line alone, for a document's own layout in place of <>. zdocs_conf re-exports both for conf.py. The engine writes the exclude list itself: no layout string is parsed or changed. A name that is no link name (a space, a quote) is an error. The links stay in the need data, so needs.json, need tables and need flows still have them. On the Zephyr build, hiding covers and assesses on requirements and result_of on test cases takes the logging requirements page from 606 KB to 102 KB and the logging test-spec page from 323 KB to 86 KB. "verified by" and the other links stay. Unit 414 (+11), acceptance 321/321, zdocs docs 0 warnings. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tobias Kaestner --- doc/api/python/support-modules.rst | 8 ++ doc/manual/reference/directives-and-roles.rst | 36 +++++++++ sphinx/_scripts/_tests/.gitignore | 3 + .../_tests/roots/test-needlayouts/conf.py | 44 +++++++++++ .../_tests/roots/test-needlayouts/index.rst | 23 ++++++ sphinx/_scripts/_tests/test_need_layouts.py | 78 +++++++++++++++++++ sphinx/_scripts/need_layouts.py | 65 ++++++++++++++++ sphinx/zdocs_conf.py | 1 + 8 files changed, 258 insertions(+) create mode 100644 sphinx/_scripts/_tests/.gitignore create mode 100644 sphinx/_scripts/_tests/roots/test-needlayouts/conf.py create mode 100644 sphinx/_scripts/_tests/roots/test-needlayouts/index.rst create mode 100644 sphinx/_scripts/_tests/test_need_layouts.py create mode 100644 sphinx/_scripts/need_layouts.py diff --git a/doc/api/python/support-modules.rst b/doc/api/python/support-modules.rst index 5481537..95c234b 100644 --- a/doc/api/python/support-modules.rst +++ b/doc/api/python/support-modules.rst @@ -13,6 +13,14 @@ Which optional need fields a consumer declared. .. automodule:: needs_fields :members: +``need_layouts`` +------------------ + +Need layouts that a document's ``conf.py`` gets from the engine. + +.. automodule:: need_layouts + :members: + ``doxygen_parser`` -------------------- diff --git a/doc/manual/reference/directives-and-roles.rst b/doc/manual/reference/directives-and-roles.rst index 9336518..d2a12aa 100644 --- a/doc/manual/reference/directives-and-roles.rst +++ b/doc/manual/reference/directives-and-roles.rst @@ -456,6 +456,42 @@ stage-2 build (the XML cannot tell, since Doxygen writes a unknown requirement" warning fails the Doxygen document through ``ZDOCS_DOXYGEN_WARN_FAIL_PATTERNS`` (:doc:`consumer-contract`). +Need layouts +------------ + +Not a directive: helpers for a document's ``conf.py``, which it imports from +``zdocs_conf``. sphinx-needs shows all incoming links of a need. A requirement +can have hundreds of test results that cover it, and this list hides the rest +of the need. Source: :external+zdocs-api:py:mod:`need_layouts`. + +``need_layout()`` gives the sphinx-needs ``clean`` layout. ``hide_backlinks`` +names the links whose incoming side the layout does not show: + +.. code-block:: python + + from zdocs_conf import configure, need_layout + + needs_layouts = {"requirement": need_layout(hide_backlinks=["covers"])} + needs_default_layout = "requirement" + +A document with its own layout uses ``links_line()`` in place of +``<>``: + +.. code-block:: python + + from zdocs_conf import configure, links_line + + "meta": [ + '<>', + links_line(["result_of"]), + ], + +Use the link name from ``needs_config.toml`` (``covers``), not its +``incoming`` title (``covered by``). A name with a space or a quote is an +error. A name that is no link type of the document is not an error, but it +hides nothing. The outgoing side of the link stays. The links also stay in the +need data, so ``needs.json``, need tables and need flows still show them. + Doxylink prefixes ----------------- diff --git a/sphinx/_scripts/_tests/.gitignore b/sphinx/_scripts/_tests/.gitignore new file mode 100644 index 0000000..16fd48e --- /dev/null +++ b/sphinx/_scripts/_tests/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +.pytest_cache/ +roots/*/_build/ diff --git a/sphinx/_scripts/_tests/roots/test-needlayouts/conf.py b/sphinx/_scripts/_tests/roots/test-needlayouts/conf.py new file mode 100644 index 0000000..3a4bca8 --- /dev/null +++ b/sphinx/_scripts/_tests/roots/test-needlayouts/conf.py @@ -0,0 +1,44 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) # _scripts/ + +from need_layouts import links_line, need_layout # noqa: E402 + +extensions = ["sphinx_needs"] +master_doc = "index" +exclude_patterns = ["_build"] + +needs_types = [ + dict(directive="req", title="Requirement", prefix="REQ_", color="#FDEBD0", style="node"), + dict(directive="spec", title="Test Case", prefix="SPEC_", color="#E2EFDA", style="node"), + dict(directive="res", title="Test Result", prefix="RES_", color="#DDEBF7", style="node"), +] +needs_id_regex = r"^[A-Za-z][A-Za-z0-9_-]+" +needs_links = { + "verifies": {"description": "verifies", "incoming": "verified by", "outgoing": "verifies"}, + "covers": {"description": "covers", "incoming": "covered by", "outgoing": "covers"}, +} +needs_build_json = True +suppress_warnings = ["config.cache"] + +needs_layouts = { + # The engine's layout, without the incoming "covered by" links. + "slim": need_layout(hide_backlinks=["covers"]), + # A document's own layout, with the engine's links line in it. + "own": { + "grid": "simple", + "layout": { + "head": ['<> <>'], + "meta": [ + '<>', + links_line(["covers"]), + ], + }, + }, +} +needs_default_layout = "slim" diff --git a/sphinx/_scripts/_tests/roots/test-needlayouts/index.rst b/sphinx/_scripts/_tests/roots/test-needlayouts/index.rst new file mode 100644 index 0000000..0ef366d --- /dev/null +++ b/sphinx/_scripts/_tests/roots/test-needlayouts/index.rst @@ -0,0 +1,23 @@ +Need layouts +============ + +.. req:: The requirement + :id: REQ_1 + :status: open + +.. req:: The requirement in the document's own layout + :id: REQ_2 + :status: open + :layout: own + +.. req:: The requirement in the sphinx-needs layout + :id: REQ_3 + :layout: clean + +.. spec:: A test case + :id: SPEC_1 + :verifies: REQ_1, REQ_2, REQ_3 + +.. res:: A test result + :id: RES_1 + :covers: REQ_1, REQ_2, REQ_3 diff --git a/sphinx/_scripts/_tests/test_need_layouts.py b/sphinx/_scripts/_tests/test_need_layouts.py new file mode 100644 index 0000000..03e7bde --- /dev/null +++ b/sphinx/_scripts/_tests/test_need_layouts.py @@ -0,0 +1,78 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +"""need_layouts: need layouts that a document's conf.py gets from the engine.""" + +import json +from pathlib import Path + +import pytest +from need_layouts import links_line, need_layout + +_ROOT = str(Path(__file__).parent / "roots" / "test-needlayouts") + + +# -- The generated layout strings ------------------------------------------------ + +def test_without_hidden_links_the_links_line_shows_all(): + assert links_line() == "<>" + + +def test_a_hidden_link_is_excluded_by_its_incoming_name(): + assert links_line(["covers", "trace"]) == ( + '<>' + ) + + +@pytest.mark.parametrize("name", ["covered by", 'x"]', "", "a>>b"]) +def test_a_name_that_is_no_link_name_is_an_error(name): + with pytest.raises(ValueError, match="no link name"): + links_line([name]) + + +def test_the_layout_is_the_clean_layout_of_sphinx_needs(): + from sphinx_needs.defaults import LAYOUTS + + assert need_layout() == LAYOUTS["clean"] + + +# -- In a build ---------------------------------------------------------------- + +def _need_html(app, need_id): + """The rendered need ``need_id`` in index.html.""" + html = (Path(app.outdir) / "index.html").read_text() + start = html.index(f'id="{need_id}"') + return html[start:html.index("", start)] + + +@pytest.mark.sphinx("html", srcdir=_ROOT) +def test_a_hidden_backlink_is_not_shown(app): + app.build() + req = _need_html(app, "REQ_1") + assert "verified by" in req and "SPEC_1" in req + assert "covered by" not in req and "RES_1" not in req + + +@pytest.mark.sphinx("html", srcdir=_ROOT) +def test_a_documents_own_layout_can_use_the_links_line(app): + app.build() + req = _need_html(app, "REQ_2") + assert "state:" in req + assert "verified by" in req + assert "covered by" not in req + + +@pytest.mark.sphinx("html", srcdir=_ROOT) +def test_another_layout_still_shows_every_backlink(app): + app.build() + req = _need_html(app, "REQ_3") + assert "covered by" in req and "RES_1" in req + + +@pytest.mark.sphinx("html", srcdir=_ROOT) +def test_the_hidden_links_stay_in_the_need_data(app): + app.build() + needs = json.loads((Path(app.outdir) / "needs.json").read_text()) + req = next(iter(needs["versions"].values()))["needs"]["REQ_1"] + assert req["covers_back"] == ["RES_1"] diff --git a/sphinx/_scripts/need_layouts.py b/sphinx/_scripts/need_layouts.py new file mode 100644 index 0000000..95e4dd8 --- /dev/null +++ b/sphinx/_scripts/need_layouts.py @@ -0,0 +1,65 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +"""Need layouts that a document's ``conf.py`` gets from the engine. + +Not a Sphinx extension: a document calls :func:`need_layout` in its +``conf.py`` (re-exported by ``zdocs_conf``) and puts the result in its own +``needs_layouts``:: + + from zdocs_conf import configure, need_layout + + needs_layouts = {"requirement": need_layout(hide_backlinks=["covers"])} + needs_default_layout = "requirement" + +sphinx-needs shows every incoming link of a need. A requirement can have +hundreds of test results that cover it, and that list hides the rest of the +need. ``hide_backlinks`` names the links whose incoming side the layout does +not show. The links stay in the need data, so ``needs.json``, need tables and +need flows still have them. +""" + +import re + +#: A link name as sphinx-needs accepts it: an option name. +_LINK_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$") + +#: The head line of the sphinx-needs "clean" layout. +_CLEAN_HEAD = ( + '<>: **<>** <> ' + '<> ' +) + + +def links_line(hide_backlinks=()): + """The layout line that shows the links of a need, without ``hide_backlinks``. + + For a document's own layout: put it in place of ``<>``. + """ + for name in hide_backlinks: + if not _LINK_NAME.match(name): + raise ValueError( + f"hide_backlinks: {name!r} is no link name; give the name of the " + f"link, not its incoming title" + ) + if not hide_backlinks: + return "<>" + exclude = ", ".join(f'"{name}_back"' for name in hide_backlinks) + return f"<>" + + +def need_layout(hide_backlinks=()): + """The sphinx-needs "clean" layout, without the incoming links ``hide_backlinks``. + + ``hide_backlinks`` are link names from ``needs_config.toml`` (``"covers"``), + not their incoming titles (``"covered by"``). + """ + return { + "grid": "simple", + "layout": { + "head": [_CLEAN_HEAD], + "meta": ["<>", links_line(hide_backlinks)], + }, + } diff --git a/sphinx/zdocs_conf.py b/sphinx/zdocs_conf.py index cdfde6b..4e03430 100644 --- a/sphinx/zdocs_conf.py +++ b/sphinx/zdocs_conf.py @@ -51,6 +51,7 @@ import docrefs # noqa: E402 (needs the sys.path above) from doc_control import latex_escape # noqa: E402 (same) +from need_layouts import links_line, need_layout # noqa: E402,F401 (for conf.py) #: Defines the macro ``doc_control`` emits for the PDF-only sign-off block. #: From 63c13176c3e67dd587c6e8e7b0666ec18f70eac1 Mon Sep 17 00:00:00 2001 From: Tobias Kaestner Date: Fri, 9 Oct 2026 20:14:52 +0200 Subject: [PATCH 2/3] feat: sphinx: the zdocs need card The sphinx-needs layouts show the fields of a need before its text, in a small font, with every field and link at the same weight. The text, the part a reader came for, is last. need_layout() now gives the zdocs need card: a head with the type, the ID, a pill (the status, or the verdict of an adequacy need) and the title; then the text; then the fields and links. fields=[(name, label)] chooses the fields and their labels; hide_backlinks stays. zdocs-needs.css styles a need whose layout name starts with "zdocs", so a document's own layouts keep the sphinx-needs look. The rows keep the sphinx-needs order in the HTML and CSS shows the text first, so the PDF does not change. The need_cards extension gives each card the color of its type from needs_config.toml and adds zd-status- / zd-verdict- to the need table, for the pill colors. The need data does not change. The references that the engine writes into a generated need (Depends on, Source, See also, Declared in) go into a need-refs container, which the card shows as one block under the text. Unit 439, acceptance 321/321, zdocs docs 0 warnings. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tobias Kaestner --- doc/api/python/extensions.rst | 8 + doc/manual/reference/directives-and-roles.rst | 48 ++- .../_tests/roots/test-needcards/conf.py | 18 + .../_tests/roots/test-needcards/index.rst | 10 + sphinx/_extensions/_tests/test_need_cards.py | 67 ++++ sphinx/_extensions/need_cards.py | 105 ++++++ .../_tests/roots/test-needlayouts/conf.py | 6 +- sphinx/_scripts/_tests/test_need_layouts.py | 43 ++- sphinx/_scripts/_tests/test_rst_builders.py | 60 ++++ sphinx/_scripts/need_layouts.py | 83 +++-- sphinx/_scripts/rst_builders.py | 47 +-- sphinx/_static/zdocs-needs.css | 314 ++++++++++++++++++ sphinx/zdocs_conf.py | 6 +- 13 files changed, 754 insertions(+), 61 deletions(-) create mode 100644 sphinx/_extensions/_tests/roots/test-needcards/conf.py create mode 100644 sphinx/_extensions/_tests/roots/test-needcards/index.rst create mode 100644 sphinx/_extensions/_tests/test_need_cards.py create mode 100644 sphinx/_extensions/need_cards.py create mode 100644 sphinx/_static/zdocs-needs.css diff --git a/doc/api/python/extensions.rst b/doc/api/python/extensions.rst index 2d7a690..c53eec1 100644 --- a/doc/api/python/extensions.rst +++ b/doc/api/python/extensions.rst @@ -82,6 +82,14 @@ Re-reads a document when its sphinx-needs TOML file or the needs it imports chan .. automodule:: needs_config_state :members: +``need_cards`` +---------------- + +The type colors and the status and verdict classes of the zdocs need cards. + +.. automodule:: need_cards + :members: + ``xref_builder`` ------------------ diff --git a/doc/manual/reference/directives-and-roles.rst b/doc/manual/reference/directives-and-roles.rst index d2a12aa..846283c 100644 --- a/doc/manual/reference/directives-and-roles.rst +++ b/doc/manual/reference/directives-and-roles.rst @@ -456,23 +456,57 @@ stage-2 build (the XML cannot tell, since Doxygen writes a unknown requirement" warning fails the Doxygen document through ``ZDOCS_DOXYGEN_WARN_FAIL_PATTERNS`` (:doc:`consumer-contract`). +.. _need-layouts: + Need layouts ------------ Not a directive: helpers for a document's ``conf.py``, which it imports from -``zdocs_conf``. sphinx-needs shows all incoming links of a need. A requirement -can have hundreds of test results that cover it, and this list hides the rest -of the need. Source: :external+zdocs-api:py:mod:`need_layouts`. +``zdocs_conf``. Source: :external+zdocs-api:py:mod:`need_layouts`. -``need_layout()`` gives the sphinx-needs ``clean`` layout. ``hide_backlinks`` -names the links whose incoming side the layout does not show: +``need_layout()`` gives the zdocs need card: a head with the type, the ID, a +pill and the title; then the text of the need; then its fields and links. +``zdocs-needs.css`` styles a need whose layout name starts with ``zdocs``, so +give the layout such a name: .. code-block:: python from zdocs_conf import configure, need_layout - needs_layouts = {"requirement": need_layout(hide_backlinks=["covers"])} - needs_default_layout = "requirement" + needs_layouts = { + "zdocs_requirement": need_layout( + fields=[("rtype", "Kind"), ("component", "Component")], + hide_backlinks=["covers"], + ), + } + needs_default_layout = "zdocs_requirement" + +``fields`` + ``(name, label)`` pairs, in the order to show. A field without a value + shows no row. Without ``fields``, the card shows every field that has a + value, under its own name; ``fields=[]`` shows the links only. A label is + plain words. +``hide_backlinks`` + The links whose incoming side the card does not show. A requirement can + have hundreds of test results that cover it, and that list hides the rest + of the need. +``pill`` + The field that the head shows as a pill: ``status`` (the default), or + ``verdict`` for an adequacy need. The pill of a test result status and of + a verdict has the color of its value. + +The rows stay in the sphinx-needs order (head, fields, text) in the HTML; the +CSS shows the text before the fields. So the PDF and a screen reader still +give the fields first. + +The ``need_cards`` extension (loaded by ``zdocs_conf``) gives each card the +``color`` of its need type in ``needs_config.toml`` as the accent, and the +class ``zd-status-`` / ``zd-verdict-`` that colors the pill. +A type without a color stays grey. + +The engine puts the references of a generated need (Depends on, Source, See +also, Declared in) in a ``need-refs`` container: the card shows them as one +block under the text. A document with its own layout uses ``links_line()`` in place of ``<>``: diff --git a/sphinx/_extensions/_tests/roots/test-needcards/conf.py b/sphinx/_extensions/_tests/roots/test-needcards/conf.py new file mode 100644 index 0000000..790684a --- /dev/null +++ b/sphinx/_extensions/_tests/roots/test-needcards/conf.py @@ -0,0 +1,18 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) # _extensions/ + +extensions = ["sphinx_needs", "need_cards"] +master_doc = "index" +exclude_patterns = ["_build"] +suppress_warnings = ["config.cache"] + +needs_types = [ + dict(directive="req", title="Requirement", prefix="REQ_", color="#FDEBD0", style="node"), + dict(directive="spec", title="Test Case", prefix="SPEC_", color="", style="node"), +] diff --git a/sphinx/_extensions/_tests/roots/test-needcards/index.rst b/sphinx/_extensions/_tests/roots/test-needcards/index.rst new file mode 100644 index 0000000..e90225c --- /dev/null +++ b/sphinx/_extensions/_tests/roots/test-needcards/index.rst @@ -0,0 +1,10 @@ +Need cards +========== + +.. req:: A requirement + :id: REQ_1 + :status: draft + +.. spec:: A test case + :id: SPEC_1 + :status: Passed / OK diff --git a/sphinx/_extensions/_tests/test_need_cards.py b/sphinx/_extensions/_tests/test_need_cards.py new file mode 100644 index 0000000..f30ff61 --- /dev/null +++ b/sphinx/_extensions/_tests/test_need_cards.py @@ -0,0 +1,67 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +"""need_cards: the type colors and status classes of the need cards.""" + +from pathlib import Path + +import pytest +from need_cards import CSS_NAME, status_class, type_colors_css + +_ROOT = str(Path(__file__).parent / "roots" / "test-needcards") + + +def test_a_type_gets_its_color(): + css = type_colors_css([{"directive": "req", "color": "#FDEBD0"}]) + assert css == "table.need.need.need.need.needs_type_req { --zd-type: #FDEBD0; }\n" + + +@pytest.mark.parametrize("color", ["#abc", "#abcd", "#aabbcc", "#aabbccdd"]) +def test_a_hex_color_of_a_css_length_gets_a_rule(color): + assert f"--zd-type: {color};" in type_colors_css([{"directive": "req", "color": color}]) + + +@pytest.mark.parametrize( + "color", ["", None, "red; } body { x: y", "url(x)", "#12", "#12345", "#1234567", "#123456789"] +) +def test_a_type_without_a_css_color_gets_no_rule(color): + assert type_colors_css([{"directive": "req", "color": color}]) == "\n" + + +def test_the_class_has_the_type_name_without_white_space(): + css = type_colors_css([{"directive": "my type", "color": "teal"}]) + assert ".needs_type_mytype " in css + + +@pytest.mark.sphinx("html", srcdir=_ROOT) +def test_the_page_links_the_css_and_the_build_writes_it(app): + app.build() + assert CSS_NAME in (Path(app.outdir) / "index.html").read_text() + css = (Path(app.outdir) / "_static" / CSS_NAME).read_text() + assert "needs_type_req { --zd-type: #FDEBD0; }" in css + assert "needs_type_spec" not in css + + +@pytest.mark.parametrize( + "status, cls", + [("passed", "zd-status-passed"), ("Draft", "zd-status-draft"), + ("Passed / OK", "zd-status-passed-ok"), ("", None), (None, None)], +) +def test_the_status_class(status, cls): + assert status_class(status) == cls + + +def test_the_class_of_a_verdict(): + assert status_class("no-cov", "verdict") == "zd-verdict-no-cov" + + +@pytest.mark.sphinx("html", srcdir=_ROOT) +def test_each_need_table_gets_its_status_class(app): + app.build() + html = (Path(app.outdir) / "index.html").read_text() + req = html[html.rfind("`` and ``zd-verdict-`` to each need table that +has the field (``zd-status-passed``, ``zd-verdict-partial``), so that the card +can color the pill. The need data does not change. +""" + +import re +from pathlib import Path + +from docutils import nodes + +CSS_NAME = "zdocs-need-types.css" + +#: A CSS color as needs_config.toml gives it: #rgb(a) / #rrggbb(aa) or a name. +_COLOR = re.compile(r"^(#([0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})|[A-Za-z]+)$") + + +def type_colors_css(types): + """The CSS rules for ``types`` (the sphinx-needs ``needs_types`` list).""" + rules = [] + for need_type in types: + color = str(need_type.get("color") or "").strip() + if not _COLOR.match(color): + continue + # sphinx-needs: "needs_type_" + the type name without white space. + name = "".join(need_type["directive"].split()) + # One class more than the default in zdocs-needs.css, so the load + # order of the two files does not matter. + rules.append(f"table.need.need.need.need.needs_type_{name} {{ --zd-type: {color}; }}") + return "\n".join(rules) + "\n" + + +#: The fields that a card shows as a pill: the engine's status and verdict. +PILL_FIELDS = ("status", "verdict") + + +def status_class(value, field="status"): + """The class for ``value`` of ``field``: ``zd--`` and the value in + lower case, with each run of other characters than letters and digits as + one ``-``. ``None`` for no value.""" + slug = re.sub(r"[^a-z0-9]+", "-", str(value or "").lower()).strip("-") + return f"zd-{field}-{slug}" if slug else None + + +def _add_status_classes(app, doctree, docname): + from sphinx_needs.data import SphinxNeedsData + + needs = SphinxNeedsData(app.env).get_needs_view() + for table in doctree.findall(nodes.table): + if "need" not in table["classes"] or not table["ids"]: + continue + try: + need = needs[table["ids"][0]] + except KeyError: + continue + for field in PILL_FIELDS: + cls = status_class(need.get(field), field) + if cls and cls not in table["classes"]: + table["classes"].append(cls) + + +def _write(app, exception): + if exception is not None or app.builder.format != "html": + return + from sphinx_needs.config import NeedsSphinxConfig + + static = Path(app.outdir) / "_static" + static.mkdir(parents=True, exist_ok=True) + css = type_colors_css(NeedsSphinxConfig(app.config).types) + path = static / CSS_NAME + if not path.exists() or path.read_text() != css: + path.write_text(css) + + +def _add_css(app): + if app.builder.format == "html": + app.add_css_file(CSS_NAME) + + +def setup(app): + app.connect("builder-inited", _add_css) + # After sphinx-needs replaced each need node with its table + # (process_need_nodes, doctree-resolved at the default priority 500). + app.connect("doctree-resolved", _add_status_classes, priority=600) + # After the HTML builder copied the static files, which it does at the end. + app.connect("build-finished", _write) + return {"version": "1.0", "parallel_read_safe": True, "parallel_write_safe": True} diff --git a/sphinx/_scripts/_tests/roots/test-needlayouts/conf.py b/sphinx/_scripts/_tests/roots/test-needlayouts/conf.py index 3a4bca8..f097fcf 100644 --- a/sphinx/_scripts/_tests/roots/test-needlayouts/conf.py +++ b/sphinx/_scripts/_tests/roots/test-needlayouts/conf.py @@ -27,8 +27,8 @@ suppress_warnings = ["config.cache"] needs_layouts = { - # The engine's layout, without the incoming "covered by" links. - "slim": need_layout(hide_backlinks=["covers"]), + # The engine's card, without the incoming "covered by" links. + "zdocs_slim": need_layout(fields=[("status", "State")], hide_backlinks=["covers"]), # A document's own layout, with the engine's links line in it. "own": { "grid": "simple", @@ -41,4 +41,4 @@ }, }, } -needs_default_layout = "slim" +needs_default_layout = "zdocs_slim" diff --git a/sphinx/_scripts/_tests/test_need_layouts.py b/sphinx/_scripts/_tests/test_need_layouts.py index 03e7bde..d0620ea 100644 --- a/sphinx/_scripts/_tests/test_need_layouts.py +++ b/sphinx/_scripts/_tests/test_need_layouts.py @@ -8,7 +8,7 @@ from pathlib import Path import pytest -from need_layouts import links_line, need_layout +from need_layouts import field_line, links_line, need_layout _ROOT = str(Path(__file__).parent / "roots" / "test-needlayouts") @@ -27,14 +27,32 @@ def test_a_hidden_link_is_excluded_by_its_incoming_name(): @pytest.mark.parametrize("name", ["covered by", 'x"]', "", "a>>b"]) def test_a_name_that_is_no_link_name_is_an_error(name): - with pytest.raises(ValueError, match="no link name"): + with pytest.raises(ValueError, match="no name"): links_line([name]) -def test_the_layout_is_the_clean_layout_of_sphinx_needs(): - from sphinx_needs.defaults import LAYOUTS +def test_the_card_has_the_head_the_fields_and_the_links(): + layout = need_layout(fields=[("rtype", "Kind")], hide_backlinks=["covers"]) + assert layout["grid"] == "simple" + assert layout["layout"]["head"] == [ + '<> <> <>', + '<>', + ] + assert layout["layout"]["meta"] == [ + '<>', + '<>', + ] - assert need_layout() == LAYOUTS["clean"] + +def test_without_fields_the_card_shows_all_but_the_status(): + meta = need_layout()["layout"]["meta"] + assert meta == ['<>', "<>"] + + +@pytest.mark.parametrize("label", ['Kind"', "a\\b", "**bold**", "<>", ""]) +def test_a_label_that_is_no_plain_words_is_an_error(label): + with pytest.raises(ValueError, match="plain words"): + field_line("rtype", label) # -- In a build ---------------------------------------------------------------- @@ -42,7 +60,7 @@ def test_the_layout_is_the_clean_layout_of_sphinx_needs(): def _need_html(app, need_id): """The rendered need ``need_id`` in index.html.""" html = (Path(app.outdir) / "index.html").read_text() - start = html.index(f'id="{need_id}"') + start = html.rfind("", start)] @@ -52,6 +70,8 @@ def test_a_hidden_backlink_is_not_shown(app): req = _need_html(app, "REQ_1") assert "verified by" in req and "SPEC_1" in req assert "covered by" not in req and "RES_1" not in req + assert "needs_layout_zdocs_slim" in req + assert 'State:' in req @pytest.mark.sphinx("html", srcdir=_ROOT) @@ -76,3 +96,14 @@ def test_the_hidden_links_stay_in_the_need_data(app): needs = json.loads((Path(app.outdir) / "needs.json").read_text()) req = next(iter(needs["versions"].values()))["needs"]["REQ_1"] assert req["covers_back"] == ["RES_1"] + + +def test_the_pill_can_be_another_field(): + layout = need_layout(pill="verdict") + assert layout["layout"]["head"][0] == '<> <> <>' + assert layout["layout"]["meta"][0] == '<>' + + +def test_a_pill_that_is_no_field_name_is_an_error(): + with pytest.raises(ValueError, match="no name"): + need_layout(pill='status")>> <`__", + "**See also:** ``k_queue_get()``", + ] + assert rst.index("The queue keeps the order.") < rst.index(".. container:: need-refs") + + +def test_a_test_case_without_references_has_no_container(): + rst = rb.build_need_rst(_base_info(source_file="", doxygen_url=""), "kernel.queue") + assert "need-refs" not in rst + + +def test_the_references_of_a_symbol_are_in_the_container(): + info = { + "name": "k_queue_init", + "kind": "function", + "brief": "Initialize a queue.", + "doxygen_url": "", + "satisfies": [], + "depends_on": ["CONFIG_MULTITHREADING"], + "source_file": "queue.h (line 39)", + } + rst = rb.build_symbol_need_rst(info) + _, refs = _container(rst) + assert refs == [ + "**Depends on:** ``CONFIG_MULTITHREADING``", + "**Declared in:** ``queue.h (line 39)``", + ] + # Every body line is indented under the directive: nothing ends the need early. + body = rst.split("\n")[2:] + assert all(line == "" or line.startswith(" ") for line in body) diff --git a/sphinx/_scripts/need_layouts.py b/sphinx/_scripts/need_layouts.py index 95e4dd8..5d392b8 100644 --- a/sphinx/_scripts/need_layouts.py +++ b/sphinx/_scripts/need_layouts.py @@ -6,12 +6,26 @@ Not a Sphinx extension: a document calls :func:`need_layout` in its ``conf.py`` (re-exported by ``zdocs_conf``) and puts the result in its own -``needs_layouts``:: +``needs_layouts``, under a name that starts with ``zdocs``:: from zdocs_conf import configure, need_layout - needs_layouts = {"requirement": need_layout(hide_backlinks=["covers"])} - needs_default_layout = "requirement" + needs_layouts = { + "zdocs_requirement": need_layout( + fields=[("rtype", "Kind"), ("component", "Component")], + hide_backlinks=["covers"], + ) + } + needs_default_layout = "zdocs_requirement" + +The layout is a card: a head with the type, the ID, the status and the title; +then the text of the need; then its fields and links. ``zdocs-needs.css`` +styles a need whose layout name starts with ``zdocs`` (the HTML class +``needs_layout_``), and the ``need_cards`` extension gives each +need type its ``color`` from the needs configuration. + +The rows stay in the sphinx-needs order (head, fields, text); the CSS shows +the fields last. So the PDF and a screen reader still give the fields first. sphinx-needs shows every incoming link of a need. A requirement can have hundreds of test results that cover it, and that list hides the rest of the @@ -22,15 +36,21 @@ import re -#: A link name as sphinx-needs accepts it: an option name. -_LINK_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$") +#: A link or field name as sphinx-needs accepts it: an option name. +_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$") + +#: A field label: words, not RST or the quotes of the layout call. +_LABEL = re.compile(r"^[\w][\w .,()/-]*$") + +def _head(pill): + """The head: type, ID and the ``pill`` field on one line, the title on the next.""" + return [f'<> <> <>', '<>'] -#: The head line of the sphinx-needs "clean" layout. -_CLEAN_HEAD = ( - '<>: **<>** <> ' - '<> ' -) + +def _check_names(what, names): + for name in names: + if not _NAME.match(name): + raise ValueError(f"{what}: {name!r} is no name; give the name, not its title") def links_line(hide_backlinks=()): @@ -38,28 +58,41 @@ def links_line(hide_backlinks=()): For a document's own layout: put it in place of ``<>``. """ - for name in hide_backlinks: - if not _LINK_NAME.match(name): - raise ValueError( - f"hide_backlinks: {name!r} is no link name; give the name of the " - f"link, not its incoming title" - ) + _check_names("hide_backlinks", hide_backlinks) if not hide_backlinks: return "<>" exclude = ", ".join(f'"{name}_back"' for name in hide_backlinks) return f"<>" -def need_layout(hide_backlinks=()): - """The sphinx-needs "clean" layout, without the incoming links ``hide_backlinks``. +def field_line(name, label): + """The layout line for field ``name``, shown as ``label:``.""" + _check_names("fields", [name]) + if not _LABEL.match(label): + raise ValueError(f"fields: the label {label!r} of {name!r} must be plain words") + return f'<>' + + +def need_layout(fields=None, hide_backlinks=(), pill="status"): + """The zdocs need card. + + ``fields`` is a list of ``(name, label)`` pairs, in the order to show. If it + is ``None``, the card shows every field that has a value, under its own + name. + + ``hide_backlinks`` are link names from ``needs_config.toml`` + (``"covers"``), not their incoming titles (``"covered by"``). - ``hide_backlinks`` are link names from ``needs_config.toml`` (``"covers"``), - not their incoming titles (``"covered by"``). + ``pill`` is the field that the head shows as a pill, next to the ID: the + ``status``, or the ``verdict`` of an adequacy need. ``zdocs-needs.css`` + colors the pill of a test result status and of a verdict. """ + _check_names("pill", [pill]) + if fields is None: + meta = [f'<>'] + else: + meta = [field_line(name, label) for name, label in fields] return { "grid": "simple", - "layout": { - "head": [_CLEAN_HEAD], - "meta": ["<>", links_line(hide_backlinks)], - }, + "layout": {"head": _head(pill), "meta": [*meta, links_line(hide_backlinks)]}, } diff --git a/sphinx/_scripts/rst_builders.py b/sphinx/_scripts/rst_builders.py index 44319ff..e320450 100644 --- a/sphinx/_scripts/rst_builders.py +++ b/sphinx/_scripts/rst_builders.py @@ -91,10 +91,25 @@ def _depends_on_rst(info, depends_field): return [], [] options = [f" :depends_on: {'; '.join(conditions)}"] if depends_field else [] label = info.get("depends_label") or "Depends on" - body = [f" **{label}:** " + "; ".join(f"``{c}``" for c in conditions), ""] + body = [f"**{label}:** " + "; ".join(f"``{c}``" for c in conditions)] return options, body +def _refs_rst(paragraphs): + """The need body lines for the reference paragraphs (Depends on, Source, See also). + + They go into a ``container`` with the class ``need-refs``, so that a need + layout can set them apart from the text (zdocs-needs.css). The text of each + paragraph does not change. No paragraphs give no lines. + """ + if not paragraphs: + return [] + lines = [" .. container:: need-refs", ""] + for paragraph in paragraphs: + lines += [f" {paragraph}", ""] + return lines + + def build_need_rst( info, suite_name, module_path="", suite_title="", need_names=None, depends_field=False, id_scope=None, @@ -162,18 +177,14 @@ def build_need_rst( lines.append(f" {sline}" if sline else "") lines.append("") - lines += depends_body - + refs = list(depends_body) if source_file and doxygen_url: - lines.append(f" **Source:** `{source_file} <{doxygen_url}>`__") - lines.append("") + refs.append(f"**Source:** `{source_file} <{doxygen_url}>`__") elif source_file: - lines.append(f" **Source:** {source_file}") - lines.append("") - + refs.append(f"**Source:** {source_file}") if see_rst: - lines.append(f" {see_rst}") - lines.append("") + refs.append(see_rst) + lines += _refs_rst(refs) return "\n".join(lines) @@ -252,15 +263,12 @@ def build_procedure_need_rst( lines.append(f" :``{pname}``: {pdesc}") lines.append("") + refs = [] if source_file and doxygen_url: - lines.append( - f" **Source:** :c:func:`{name}` — `{source_file} <{doxygen_url}>`__" - ) - lines.append("") - + refs.append(f"**Source:** :c:func:`{name}` — `{source_file} <{doxygen_url}>`__") if see_rst_str: - lines.append(f" {see_rst_str}") - lines.append("") + refs.append(see_rst_str) + lines += _refs_rst(refs) return "\n".join(lines) @@ -388,9 +396,10 @@ def build_symbol_need_rst(info, need_names=None, depends_field=False): if info["doxygen_url"]: head = f"`{kind.capitalize()} {name} <{info['doxygen_url']}>`__" lines += [f" {head}" + (f" — {info['brief']}" if info["brief"] else ""), ""] - lines += depends_body + refs = list(depends_body) if info["source_file"]: - lines += [f" **Declared in:** ``{info['source_file']}``", ""] + refs.append(f"**Declared in:** ``{info['source_file']}``") + lines += _refs_rst(refs) return "\n".join(lines) diff --git a/sphinx/_static/zdocs-needs.css b/sphinx/_static/zdocs-needs.css new file mode 100644 index 0000000..50bb989 --- /dev/null +++ b/sphinx/_static/zdocs-needs.css @@ -0,0 +1,314 @@ +/* + * Copyright (c) 2026 inovex GmbH + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/* + * The zdocs need card (need_layouts.need_layout()). + * + * Only for a need whose layout name starts with "zdocs": sphinx-needs gives + * the table the class needs_layout_. A document's own layouts keep the + * sphinx-needs look. + * + * The rules start with table.need.need.need, as sphinx-needs' own do, so that + * they win over the theme without !important. + * + * Each need type has its color in --zd-type. The need_cards extension + * sets it from the "color" of the type in the needs configuration + * (zdocs-need-types.css). The other colors come from it. + */ + +table.need.need.need[class*=" needs_layout_zdocs"] { + --zd-type: #eeeeee; + --zd-accent: #666666; + --zd-accent: oklch(from var(--zd-type) 0.52 calc(c * 4 + 0.02) h); + --zd-border: #d5d9de; + --zd-label: #5f6670; + --zd-text: #1d2329; + --zd-mono: SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + + display: block; + margin: 1.5em 0; + background: #fff; + border: 1px solid var(--zd-border); + border-left: 5px solid var(--zd-accent); + border-radius: 6px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + overflow: hidden; +} + +/* + * Show the head, the text, then the fields. The rows stay in the sphinx-needs + * order (head, meta, content) in the HTML, so the PDF does not change; the + * table body becomes a flex column and the meta row moves to the end. + */ +table.need.need.need[class*=" needs_layout_zdocs"] > tbody { + display: flex; + flex-direction: column; +} +table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr, +table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr > td { + display: block; +} +table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.head { order: 0; } +table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content { order: 1; } +/* A need without text (most test results) has no text row. */ +table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content:not(:has(> td > *)) { + display: none; +} +table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.meta { order: 2; } + +/* No theme row stripes in a card */ +.rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr > td { + background: transparent; +} + +/* -- Head: type, ID and status; then the title ------------------------------ */ + +table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.head { + background: color-mix(in srgb, var(--zd-type) 55%, #fff); +} +table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.head > td:not([rowspan]) { + padding: 0.7em 1.25em 0.75em; + border-bottom: 1px solid color-mix(in srgb, var(--zd-type) 70%, var(--zd-border)); +} +table.need.need.need[class*=" needs_layout_zdocs"] tr.head .needs_type_name { + margin-right: 0.6em; + color: var(--zd-accent); + font-size: 0.75em; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} +table.need.need.need[class*=" needs_layout_zdocs"] tr.head .needs-id a { + color: #2b3a4a; + font-family: var(--zd-mono); + font-size: 0.85em; +} +table.need.need.need[class*=" needs_layout_zdocs"] tr.head :is(.needs_status, .needs_verdict) { + display: inline-block; + margin-left: 0.7em; + padding: 0.05em 0.6em; + border: 1px solid var(--zd-accent); + border-radius: 999px; + color: var(--zd-accent); + font-size: 0.72em; + font-weight: 600; + text-transform: capitalize; /* "draft" and "Draft" look the same */ + vertical-align: 0.1em; +} +/* + * The status of a test result and the verdict of an adequacy need (the + * need_cards extension adds zd-status- and zd-verdict-): + * green good, red failing, amber a gap, grey not judged or not run. Other + * statuses, like the "Draft" of a requirement, keep the type accent. + */ +table.need.need.need[class*=" needs_layout_zdocs"]:is(.zd-status-passed, .zd-verdict-true) tr.head :is(.needs_status, .needs_verdict) { + border-color: #1a7f37; + background: #dafbe1; + color: #116329; +} +table.need.need.need[class*=" needs_layout_zdocs"]:is(.zd-status-failed, .zd-status-error, .zd-verdict-broken) tr.head :is(.needs_status, .needs_verdict) { + border-color: #cf222e; + background: #ffebe9; + color: #a40e26; +} +table.need.need.need[class*=" needs_layout_zdocs"]:is(.zd-status-skipped, .zd-status-filtered, .zd-status-blocked, .zd-status-not-run, .zd-verdict-unattributed, .zd-verdict-unresolved, .zd-verdict-no-cov) tr.head :is(.needs_status, .needs_verdict) { + border-color: #8c959f; + background: #eaeef2; + color: #424a53; +} +/* A gap, not a failure: some of the code, or no code at all. */ +table.need.need.need[class*=" needs_layout_zdocs"]:is(.zd-verdict-partial, .zd-verdict-no-impl) tr.head :is(.needs_status, .needs_verdict) { + border-color: #bf8700; + background: #fff8c5; + color: #7d4e00; +} +table.need.need.need[class*=" needs_layout_zdocs"] tr.head .needs_title { + display: block; + margin-top: 0.2em; + color: var(--zd-text); + font-size: 1.2em; + font-weight: 600; + line-height: 1.3; +} + +/* -- The text of the need ----------------------------------------------------- */ + +/* + * The size, line height and paragraph gap of the page text around the card. + * The rtd theme sets 90 % / .9rem and a 1rem line height in every table cell + * (html.writer-html5 .rst-content table.docutils td > p), and a need is a table. + */ +html .rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content > td { + padding: 1.1rem 1.25rem 0.5rem; + font-size: 1rem; + line-height: 1.5rem; +} +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content > td p { + margin: 0 0 0.75rem; + font-size: 1rem; + line-height: 1.5rem; +} + +/* The brief that zdocs writes first in a test case (rst-class need-brief) */ +table.need.need.need[class*=" needs_layout_zdocs"] tr.content p.need-brief { + color: var(--zd-text); + font-weight: 600; +} + +/* + * A paragraph right before a list ("Test steps:", "Expected result:") is its + * heading. Not the first paragraph: there it is the introduction of the text + * (a test procedure starts with "... performs the following sequence:"). + */ +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content > td > p:not(:first-child):has(+ ul, + ol) { + margin: 1rem 0 0.25rem; + color: var(--zd-text); + font-weight: 600; +} + +/* Lists: inside the text column, one line height apart */ +html .rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content > td :is(ul, ol) { + margin: 0 0 0.75rem 1.4rem; + padding: 0; +} +html .rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content > td ul > li { + list-style: disc; +} +html .rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content > td li { + margin: 0.1rem 0; + line-height: 1.5rem; +} +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content > td li > p { + margin: 0; + line-height: 1.5rem; +} + +/* + * A field list in the text (the parameters of a test procedure): the name in + * code type, the description beside it, without the theme's boxes. + */ +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content > td dl.field-list { + display: grid; + grid-template-columns: max-content 1fr; + column-gap: 1em; + margin: 0 0 0.75rem; +} +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content > td dl.field-list > dt { + margin: 0; + padding: 0; + border: none; + background: none; + font-weight: 400; + line-height: 1.5rem; +} +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content > td dl.field-list > dt code { + padding: 0; + border: none; + background: none; + color: var(--zd-text); + font-family: var(--zd-mono); + font-size: 0.9em; +} +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content > td dl.field-list > dd { + margin: 0; + padding: 0; +} + +/* + * The references that zdocs writes at the end of a test case (Depends on, + * Source, See also; container need-refs): a label column like the fields. + */ +html .rst-content table.need.need.need[class*=" needs_layout_zdocs"] div.need-refs { + margin: 1rem 0 0.25rem; + padding-top: 0.6rem; + border-top: 1px dashed #e1e4e8; +} +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.content > td div.need-refs > p { + margin: 0.15rem 0; + padding-left: 9.5em; + text-indent: -9.5em; + font-size: 0.9rem; + line-height: 1.6; +} +html .rst-content table.need.need.need[class*=" needs_layout_zdocs"] div.need-refs > p > strong:first-child { + display: inline-block; + width: calc(9.5em - 0.21em); /* the space after the label follows */ + color: var(--zd-label); + font-weight: 400; + text-indent: 0; +} + +/* -- Fields and links: one label/value grid ------------------------------------ */ + +html .rst-content table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.meta > td { + background: #fafbfc; + font-size: 0.9rem; + line-height: 1.6; +} +table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.meta > td:not([rowspan]) { + padding: 0.5rem 1.25rem 0.6rem; + border-top: 1px solid #e6e9ec; + border-bottom: none; +} +table.need.need.need[class*=" needs_layout_zdocs"] tr.meta div.line-block { + margin: 0; +} +table.need.need.need[class*=" needs_layout_zdocs"] tr.meta div.line { + min-height: 0; + margin: 0; + padding: 0; +} +/* The wrappers around a row take no box of their own. */ +table.need.need.need[class*=" needs_layout_zdocs"] tr.meta div.line:has(div.line), +table.need.need.need[class*=" needs_layout_zdocs"] tr.meta div.line:has(div.line) > span, +table.need.need.need[class*=" needs_layout_zdocs"] tr.meta div.line:has(div.line) > span > span, +table.need.need.need[class*=" needs_layout_zdocs"] tr.meta div.line span:has(.needs_label) { + display: contents; +} +/* + * A row is a line without lines in it: a field (label and data spans) or a + * link line (the text "Verified by:" and a span with the links). + */ +table.need.need.need[class*=" needs_layout_zdocs"] tr.meta div.line:not(:has(div.line)) { + display: grid; + grid-template-columns: 8.5em 1fr; + column-gap: 1em; + padding: 0.1em 0; + color: var(--zd-label); + line-height: 1.6; +} +/* A need without fields and links (a test procedure) has no fields row. */ +table.need.need.need[class*=" needs_layout_zdocs"] > tbody > tr.meta:not(:has(.needs_data, a, .needs_dead_link)) { + display: none; +} +/* A field without a value is a line with one empty span: no row. */ +table.need.need.need[class*=" needs_layout_zdocs"] tr.meta div.line:has(> span:empty:only-child) { + display: none; +} +table.need.need.need[class*=" needs_layout_zdocs"] tr.meta .needs_data, +table.need.need.need[class*=" needs_layout_zdocs"] tr.meta div.line:not(:has(div.line)) > span:not(:has(.needs_label)) { + color: var(--zd-text); +} +/* Links, and links to a need that does not exist (a span, not an a) */ +table.need.need.need[class*=" needs_layout_zdocs"] tr.meta :is(a, .needs_dead_link) { + font-family: var(--zd-mono); + font-size: 0.95em; + white-space: nowrap; +} +/* The arrow of a link into another document: every second link has one. */ +table.need.need.need[class*=" needs_layout_zdocs"] a.external_link::after { + content: none; +} + +/* + * The engine's own fields that hold code or names from the build: the test + * function, module path and suite; the platform, scenario and twister ID. + */ +table.need.need.need[class*=" needs_layout_zdocs"] tr.meta :is(.needs_test_function, .needs_test_module, .needs_suite, .needs_platform, .needs_scenario, .needs_twister_id) .needs_data { + font-family: var(--zd-mono); + font-size: 0.95em; +} + diff --git a/sphinx/zdocs_conf.py b/sphinx/zdocs_conf.py index 4e03430..32599f4 100644 --- a/sphinx/zdocs_conf.py +++ b/sphinx/zdocs_conf.py @@ -135,6 +135,10 @@ def configure( # Also when the needs it imports from a peer's needs.json change: # else a page does not show a new incoming link from the peer. "needs_config_state", + # For the zdocs need cards (zdocs-needs.css): the color of each need + # type, from the needs configuration (one small CSS file), and a class + # for the status of each need. + "need_cards", # The `doc_control` directive: the controlled-document header (owner, # classification, approval dates, version). Registers `signature_section` # and `releaselevel` as config values, so it must be loaded even by @@ -418,7 +422,7 @@ def configure( namespace["html_static_path"] = [str(ZDOCS_DOC_DIR / "_static")] + [ str(p) for p in (static_path or []) ] - namespace["html_css_files"] = ["zdocs-sphinx.css"] + list(css_files or []) + namespace["html_css_files"] = ["zdocs-sphinx.css", "zdocs-needs.css"] + list(css_files or []) if refs is not None: # Consumed by the cross-document navigation in the page template. namespace["html_context"] = {"reference_groups": refs.reference_groups} From 9def8c38d22793bd0d81a4ec28f13f244c92d859 Mon Sep 17 00:00:00 2001 From: Tobias Kaestner Date: Fri, 9 Oct 2026 20:15:00 +0200 Subject: [PATCH 3/3] feat: testcoverage: the adequacy need as a card The body of an adequacy need started with "Verdict partial, evidence passing, coverage run ...", which repeats the fields, and listed every other test of every body, up to ten matrix keys each: 2074 keys for k_sched_lock, which its own tests run anyway. The need now has the title of its requirement. The body: * one sentence that says why the verdict is what it is ("The own tests run 1 of the 2 symbols that coverage can judge. Only other tests run z_check_stack_sentinel."), adequacy_summary(); * a table with one row per body: the function whose body was read (z_vrfy_k_msgq_num_used_get), its form and place, the lines the own tests ran of the lines any test ran, and the number of other test cases; no table for a requirement without a body; * the other tests only for a body that the own tests never run: the places where a @verifies can go. A case is named once, and the cases that ran the same lines are one group; * the verifying tests, and the fields the consumer does not declare. The cells use the roles zd-adq-ok / -miss / -none / -loc (a class of the same name); zdocs-needs.css colors them in a zdocs card. adequacy.need_titles() reads the requirement titles from the needs.json files that the directive already reads. The verdicts and the need data do not change, apart from the title. The per-test line ranges of the own tests are not in the body any more. Unit 451 (+12), acceptance 321/321, zdocs docs 0 warnings. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tobias Kaestner --- doc/manual/reference/directives-and-roles.rst | 25 ++- sphinx/_extensions/_tests/test_adequacy.py | 84 ++++++- sphinx/_extensions/test_coverage.py | 209 +++++++++++++----- sphinx/_scripts/adequacy.py | 11 + sphinx/_static/zdocs-needs.css | 64 ++++++ 5 files changed, 335 insertions(+), 58 deletions(-) diff --git a/doc/manual/reference/directives-and-roles.rst b/doc/manual/reference/directives-and-roles.rst index 846283c..e8b52b3 100644 --- a/doc/manual/reference/directives-and-roles.rst +++ b/doc/manual/reference/directives-and-roles.rst @@ -366,9 +366,25 @@ The verdicts: The directive assesses a requirement if the run ran at least one of its verifying test cases. It renders a summary of the run, a table of the -verdicts, and one section per verdict. Each need lists its symbols and -bodies. For each body, it gives the lines that each own test ran, and the -other tests that ran the body. +verdicts, and one section per verdict. A need has the title of its +requirement. Its body has: + +* One sentence that says why the verdict is what it is ("The own tests run 1 + of the 2 symbols that coverage can judge. Only other tests run + ``k_inline``."). +* A table with one row per body: the function (``z_vrfy_k_sem_give``), its + form and place, the lines that the own tests ran of the lines that any test + of the run ran, and the number of other test cases that ran it. A + requirement with no body (``unresolved``, ``no-impl``) has no table. +* For each body that the own tests never run, the other tests that do: the + places where a ``@verifies`` can go. A test case is named once. The test + cases that ran the same lines are one group. +* The verifying tests, and the fields that the consumer does not declare + (``verdict``, ``evidence``, ``coverage_run``). + +The table cells use the roles ``zd-adq-ok``, ``zd-adq-miss``, ``zd-adq-none`` +and ``zd-adq-loc``. Each gives the text a class of the same name. The zdocs +need card (:ref:`need-layouts`) colors them. The id of a need is ``ADQ-/``. ``testcoverage_id_prefix`` sets the prefix. The type, the link and the fields are roles, as for the other @@ -385,7 +401,8 @@ directives: Declare the type and the link in your ``needs_config.toml``. The directive sets a field only if your ``needs_config.toml`` declares it (string fields). -The body of the need always shows the same information. The fields: +The body shows the verdict, the evidence and the run when they are not +fields. The fields: ``verdict`` One of the verdicts above. diff --git a/sphinx/_extensions/_tests/test_adequacy.py b/sphinx/_extensions/_tests/test_adequacy.py index eb43f00..fbcad7d 100644 --- a/sphinx/_extensions/_tests/test_adequacy.py +++ b/sphinx/_extensions/_tests/test_adequacy.py @@ -220,7 +220,7 @@ def _needs(): needs[f"IMPL-{sym}"] = {"id": f"IMPL-{sym}", "type": "impl", "title": sym, "satisfies": reqs} for r in REQS: - needs[r] = {"id": r, "type": "req", "title": r} + needs[r] = {"id": r, "type": "req", "title": f"Title of {r}"} return {"current_version": "1.0", "versions": {"1.0": {"needs": needs}}} @@ -472,7 +472,85 @@ def test_adequacy_rst_sets_the_declared_fields_under_the_consumers_names(tree): assert ":layout:" not in rst with_layout = tc.build_adequacy_rst("R-VRFY", results["R-VRFY"], run, names, (), "Q", "adq") assert " :layout: adq" in with_layout and " :id: Q-cov-run/R-VRFY" in with_layout - assert f"own :need:`TSPEC-1`: lines {VRFY[0]}-{VRFY[0] + 2}" in rst + titled = tc.build_adequacy_rst("R-VRFY", results["R-VRFY"], run, names, title="Obj init") + assert titled[0] == ".. judgement:: Obj init" + + +def test_adequacy_rst_shows_the_undeclared_fields_in_the_body(tree): + run, results, _, _ = _assess(tree) + rst = "\n".join(tc.build_adequacy_rst("R-DEF", results["R-DEF"], run, fields={"verdict"})) + assert " **Verifying tests:** :need:`TSPEC-3`" in rst + assert "**Verdict:**" not in rst + assert " **Evidence:** passing" in rst + assert " **Coverage run:** cov-run" in rst + every = set(tc.ADEQUACY_FIELD_ROLES) + rst = "\n".join(tc.build_adequacy_rst("R-DEF", results["R-DEF"], run, fields=every)) + assert "**Evidence:**" not in rst and "**Coverage run:**" not in rst + + +def test_adequacy_rst_gives_one_row_per_body(tree): + run, results, _, _ = _assess(tree) + rst = "\n".join(tc.build_adequacy_rst("R-VRFY", results["R-VRFY"], run)) + # The function whose body was read, its form and place, the own lines of + # the lines any test ran, and the other test cases. + assert " * - ``z_impl_k_obj_init``" in rst + assert f":zd-adq-loc:`implementation, kernel/obj.c:{IMPL[0]}-{IMPL[1]}`" in rst + assert ":zd-adq-none:`no test ran it`" in rst + assert " * - ``z_vrfy_k_obj_init``" in rst + assert ":zd-adq-ok:`3 of 3 lines`" in rst + # The own tests run every body they should: no list of other tests. + assert "Other tests that run" not in rst + + +def test_adequacy_rst_names_the_other_tests_of_a_missed_body(tree): + run, results, _, _ = _assess(tree) + rst = "\n".join(tc.build_adequacy_rst("R-BROKEN", results["R-BROKEN"], run)) + assert f":zd-adq-miss:`0 of {len(_span(PLAIN))} lines`" in rst + assert " - 3 test cases" in rst + assert " Other tests that run ``k_plain``:" in rst + # One group per set of lines. Groups of one size go by their first case. + a, b = PLAIN + assert (f" * lines {a}-{b}: :need:`TSPEC-3`\n" + f" * lines {a}, {a + 2}: :need:`TSPEC-8`\n" + f" * lines {a + 2}: :need:`TSPEC-10`") in rst + + +def test_adequacy_rst_without_a_body_has_no_table(tree): + run, results, _, _ = _assess(tree) + for req in ("R-MACRO", "R-NOIMPL"): + rst = "\n".join(tc.build_adequacy_rst(req, results[req], run)) + assert "list-table" not in rst + + +@pytest.mark.parametrize("req, sentence", [ + ("R-VRFY", "The own tests run the satisfying symbol."), + ("R-PARTIAL", "The own tests run 1 of the 2 symbols that coverage can judge. " + "Only other tests run ``k_inline``."), + ("R-BROKEN", "Only other tests run ``k_plain``. The own tests never do."), + ("R-UNATTR", "No test of the run runs a body of the satisfying symbols. " + "Coverage cannot judge the link."), + ("R-MACRO", "``k_macro`` has no function body in the files that the directive searched."), + ("R-NOIMPL", "No symbol satisfies this requirement."), + ("R-NOCOV", "The run has no coverage data for the own tests."), +]) +def test_the_summary_says_why(tree, req, sentence): + _, results, _, _ = _assess(tree) + assert tc.adequacy_summary(results[req]) == sentence + + +def test_the_summary_of_true_names_the_symbols_that_coverage_cannot_judge(): + def body(sym, own, any_): + return {"sym": sym, "file": "k.c", "own": own, "any": any_} + two = {"verdict": "true", "impls": [body("a", 1, 1), body("b", 2, 2)]} + assert tc.adequacy_summary(two) == "The own tests run both satisfying symbols." + three = {"verdict": "true", "impls": [body("a", 1, 1), body("b", 1, 1), body("c", 1, 1)]} + assert tc.adequacy_summary(three) == "The own tests run all 3 satisfying symbols." + unjudged = {"verdict": "true", "impls": [body("a", 1, 1), body("b", 0, 0), + {"sym": "M", "file": None, "own": 0, "any": 0}]} + assert tc.adequacy_summary(unjudged) == ( + "The own tests run the one symbol that coverage can judge. " + "Coverage cannot judge ``b`` and ``M``." + ) _CONF = """\ @@ -523,10 +601,12 @@ def test_directive_emits_one_adequacy_need_per_requirement(tree, make_app, tmp_p assert five["assesses"] == ["R-PARTIAL"] assert five["judged_symbols"] == "k_inline; k_plain" assert five["coverage_run"] == "cov-run" + assert five["title"] == "Title of R-PARTIAL" # the requirement's title log = app._warning.getvalue() assert "WARNING" not in log, log html = (Path(app.outdir) / "index.html").read_text() assert "Verdict broken" in html and 'id="ADQ-cov-run/R-VRFY"' in html + assert '' in html def test_directive_reads_the_consumers_file_set(tree, make_app, tmp_path): diff --git a/sphinx/_extensions/test_coverage.py b/sphinx/_extensions/test_coverage.py index f1f373a..802256b 100644 --- a/sphinx/_extensions/test_coverage.py +++ b/sphinx/_extensions/test_coverage.py @@ -22,6 +22,7 @@ collect_links, line_ranges, load_coverage_run, + need_titles, ) from docutils import nodes from docutils.parsers.rst import Directive, directives @@ -48,9 +49,22 @@ "true": "The own tests run every symbol that coverage can judge.", } -#: At most this many other tests are named for one body. +#: At most this many test cases are named for one group of other tests. _OTHER_TESTS_SHOWN = 10 +#: The function whose body the directive reads, by body form. +_BODY_NAME = {"impl": "z_impl_{}", "vrfy": "z_vrfy_{}", "def": "{}", "inline": "{}"} +_BODY_FORM = {"impl": "implementation", "vrfy": "verifier", "def": "definition", + "inline": "static inline"} + +#: The roles of the body table cells: each gives an inline node with the +#: role name as its class, for ``zdocs-needs.css``. +CELL_ROLES = ("zd-adq-ok", "zd-adq-miss", "zd-adq-none", "zd-adq-loc") + +#: The engine fields that the body shows when the consumer does not declare them. +_BODY_FALLBACK = (("verdict", "Verdict"), ("evidence", "Evidence"), + ("coverage_run", "Coverage run")) + def _need_names(config): """The role->name mapping of every vocabulary the directive reads or writes.""" @@ -73,12 +87,6 @@ def _natural(uid): return [int(t) if t.isdigit() else t for t in re.split(r"(\d+)", uid)] -def _need_ref(key, run): - """A matrix key as the case needs it stands for, else as a literal.""" - cases = sorted(run.case_of_key.get(key, ())) - return ", ".join(f":need:`{c}`" for c in cases) if cases else f"``{key}``" - - def _symbol_hits(impls): """``"k_sem_init: own 11, any 11"`` per symbol, joined with ``"; "``.""" own, any_, order = Counter(), Counter(), [] @@ -90,10 +98,98 @@ def _symbol_hits(impls): return "; ".join(f"{s}: own {own[s]}, any {any_[s]}" for s in order) -def build_adequacy_rst(req, res, run, need_names=None, fields=(), prefix="ADQ", layout=""): +def _code_list(names): + """```a``, ``b`` and ``c```.""" + names = [f"``{n}``" for n in names] + return names[0] if len(names) == 1 else ", ".join(names[:-1]) + " and " + names[-1] + + +def _symbol_states(impls): + """``{symbol: (has a body, a test of the run ran it, an own test ran it)}``.""" + states = {} + for d in impls: + body, seen, hit = states.get(d["sym"], (False, False, False)) + states[d["sym"]] = (body or d["file"] is not None, seen or d["any"] > 0, + hit or d["own"] > 0) + return states + + +def adequacy_summary(res): + """One sentence (RST) that says why the verdict of ``res`` is what it is.""" + verdict = res["verdict"] + states = _symbol_states(res["impls"]) + judged = [s for s, (_, seen, _) in states.items() if seen] + missed = [s for s in judged if not states[s][2]] + not_judged = [s for s in states if s not in judged] + if verdict == "no-impl": + return "No symbol satisfies this requirement." + if verdict == "unresolved": + verb = "has" if len(states) == 1 else "have" + return (f"{_code_list(list(states))} {verb} no function body in the files " + f"that the directive searched.") + if verdict == "no-cov": + return "The run has no coverage data for the own tests." + if verdict == "unattributed": + return ("No test of the run runs a body of the satisfying symbols. Coverage " + "cannot judge the link.") + if verdict == "partial": + return (f"The own tests run {len(judged) - len(missed)} of the {len(judged)} " + f"symbols that coverage can judge. Only other tests run " + f"{_code_list(missed)}.") + if verdict == "broken": + return f"Only other tests run {_code_list(missed)}. The own tests never do." + # true + if not not_judged: + return {1: "The own tests run the satisfying symbol.", + 2: "The own tests run both satisfying symbols."}.get( + len(judged), f"The own tests run all {len(judged)} satisfying symbols.") + what = "the one symbol" if len(judged) == 1 else f"all {len(judged)} symbols" + return (f"The own tests run {what} that coverage can judge. Coverage cannot " + f"judge {_code_list(not_judged)}.") + + +def _own_cell(d): + if d["any"] == 0: + return ":zd-adq-none:`no test ran it`" + role = "zd-adq-ok" if d["own"] else "zd-adq-miss" + return f":{role}:`{d['own']} of {d['any']} lines`" + + +def _other_groups(d, run): + """The other tests of body ``d`` as ``[(line ranges, [case refs])]``. + + A case once, however many matrix keys it has; a key that no spec case has + stays a literal. The cases that ran the same lines form one group: the + largest group first, then by the first case. + """ + groups = {} + for key, hit in d["other_tests"].items(): + refs = groups.setdefault(line_ranges(hit), []) + for case in sorted(run.case_of_key.get(key, ())) or [None]: + ref = f":need:`{case}`" if case else f"``{key}``" + if ref not in refs: + refs.append(ref) + groups = [(r, sorted(c, key=_natural)) for r, c in groups.items()] + return sorted(groups, key=lambda g: (-len(g[1]), _natural(g[1][0]))) + + +def _other_count(d, run): + return len({c for key in d["other_tests"] for c in run.case_of_key.get(key, ()) or [key]}) + + +def build_adequacy_rst(req, res, run, need_names=None, fields=(), prefix="ADQ", layout="", + title=None): """RST lines for the adequacy need of ``req`` (``res`` from `adequacy.assess`). - ``layout``: the sphinx-needs layout of the need, if not empty. + ``layout``: the sphinx-needs layout of the need, if not empty. ``title``: + the title of the requirement; the need has it as its own title. Without it, + the title is ``Adequacy of ``. + + The body says why the verdict is what it is. A table gives each body of a + satisfying symbol: the lines that the own tests ran, of the lines that any + test of the run ran, and the number of other test cases that ran it. For + a body that the own tests never run, the body names the other tests that + do. A field that the consumer does not declare is in the body too. """ values = { "verdict": res["verdict"], @@ -103,7 +199,7 @@ def build_adequacy_rst(req, res, run, need_names=None, fields=(), prefix="ADQ", "symbol_hits": _symbol_hits(res["impls"]), } lines = [ - f".. {_need_name(need_names, 'adequacy')}:: Adequacy of {req}", + f".. {_need_name(need_names, 'adequacy')}:: {title or f'Adequacy of {req}'}", f" :id: {adequacy_need_id(run.name, req, prefix)}", f" :{_need_name(need_names, 'assesses')}: {req}", ] @@ -112,60 +208,61 @@ def build_adequacy_rst(req, res, run, need_names=None, fields=(), prefix="ADQ", lines.append(f" :{_need_name(need_names, role)}: {values[role]}") if layout: lines.append(f" :layout: {layout}") - lines += [ - "", - f" Verdict ``{res['verdict']}``, evidence ``{res['evidence']}``, " - f"coverage run ``{run.name}``.", - "", - ] - cases = ", ".join(f":need:`{c}`" for c in res["cases"]) - lines += [f" Verifying test cases: {cases}.", ""] - if not res["impls"]: - lines += [" No symbol satisfies this requirement.", ""] - return lines - lines += [ - " .. list-table:: Satisfying symbols", - " :header-rows: 1", - " :widths: 20 10 30 10 10", - "", - " * - Symbol", - " - Body", - " - Location", - " - Own lines", - " - Any lines", - ] - for d in res["impls"]: - loc = f"``{d['file']}:{d['a']}-{d['b']}``" if d["file"] else "no body found" + lines += ["", f" {adequacy_summary(res)}", ""] + bodies = [d for d in res["impls"] if d["file"]] + if bodies: lines += [ - f" * - ``{d['sym']}``", - f" - {d['variant'] or '—'}", - f" - {loc}", - f" - {d['own']}", - f" - {d['any']}", + " .. list-table::", + " :header-rows: 1", + " :class: zd-adq-bodies", + "", + " * - Body", + " - Own tests ran", + " - Other tests", ] - lines.append("") - for d in res["impls"]: - if not d["file"] or not (d["own_tests"] or d["other_tests"]): + for d in bodies: + name = _BODY_NAME[d["variant"]].format(d["sym"]) + others = _other_count(d, run) + others = f"{others} test case{'s' if others != 1 else ''}" if others else "none" + lines += [ + f" * - ``{name}``", + "", + f" :zd-adq-loc:`{_BODY_FORM[d['variant']]}, " + f"{d['file']}:{d['a']}-{d['b']}`", + f" - {_own_cell(d)}", + f" - {others}", + ] + lines.append("") + no_body = [s for s, (body, _, _) in _symbol_states(res["impls"]).items() if not body] + if bodies and no_body: + lines += [f" No body found: {_code_list(no_body)}.", ""] + # Who runs the code that the own tests miss: where a @verifies can go. + for d in bodies: + if d["own"] or not d["other_tests"]: continue - lines += [f" ``{d['sym']}`` ({d['variant']}, ``{d['file']}:{d['a']}-{d['b']}``):", ""] - for case, hit in d["own_tests"].items(): - lines.append(f" * own :need:`{case}`: lines {line_ranges(hit)}") - others = list(d["other_tests"].items()) - for key, hit in others[:_OTHER_TESTS_SHOWN]: - lines.append(f" * other {_need_ref(key, run)}: lines {line_ranges(hit)}") - if len(others) > _OTHER_TESTS_SHOWN: - lines.append(f" * and {len(others) - _OTHER_TESTS_SHOWN} other tests") + name = _BODY_NAME[d["variant"]].format(d["sym"]) + lines += [f" Other tests that run ``{name}``:", ""] + for ranges, refs in _other_groups(d, run): + more = len(refs) - _OTHER_TESTS_SHOWN + tail = f" and {more} more" if more > 0 else "" + lines.append(f" * lines {ranges}: {', '.join(refs[:_OTHER_TESTS_SHOWN])}{tail}") lines.append("") + cases = ", ".join(f":need:`{c}`" for c in res["cases"]) + lines += [" .. container:: need-refs", "", f" **Verifying tests:** {cases}", ""] + for role, label in _BODY_FALLBACK: + if role not in fields: + lines += [f" **{label}:** {values[role]}", ""] return lines def build_coverage_rst( results, run, source, impl_loc, need_names=None, fields=(), prefix="ADQ", layout="", - impl_files=IMPL_PATTERNS, + impl_files=IMPL_PATTERNS, titles=None, ): """RST lines for the whole directive: run summary, distribution, needs by verdict. ``impl_files``: the body files that the run searched, for the summary. + ``titles``: the title of each requirement, for the title of its need. """ counts = Counter(r["verdict"] for r in results.values()) symbols = sorted({s for r in results.values() for s in r["symbols"]}) @@ -208,7 +305,8 @@ def build_coverage_rst( lines += [heading, "-" * len(heading), ""] for req in reqs: lines += build_adequacy_rst( - req, results[req], run, need_names, fields, prefix, layout + req, results[req], run, need_names, fields, prefix, layout, + (titles or {}).get(req), ) return lines @@ -299,6 +397,7 @@ def run(self): getattr(config, "testcoverage_id_prefix", "ADQ"), self.options.get("layout", "").strip(), impl_files, + need_titles(json_paths), ) dump_dir = getattr(config, "dump_generated_rst", "") if dump_dir: @@ -316,7 +415,13 @@ def run(self): return container.children +def _cell_role(name, rawtext, text, lineno, inliner, options=None, content=None): + return [nodes.inline(rawtext, text, classes=[name])], [] + + def setup(app): + for role in CELL_ROLES: + app.add_role(role, _cell_role) # The per-test coverage run directory (ZDOCS_COVERAGE_OUT): twister.json, # coverage/test_matrix.json and zephyr.sha. app.add_config_value("coverage_output_dir", "", "env") diff --git a/sphinx/_scripts/adequacy.py b/sphinx/_scripts/adequacy.py index 5ca26a1..1c54b6d 100644 --- a/sphinx/_scripts/adequacy.py +++ b/sphinx/_scripts/adequacy.py @@ -79,6 +79,7 @@ "CoverageRun", "load_coverage_run", "collect_links", + "need_titles", "evidence", "adequacy", "assess", @@ -502,6 +503,16 @@ def collect_links(json_paths, need_names=None): return SpecLookup(entries), dict(verified_by), dict(satisfied_by), set(seen) +def need_titles(json_paths): + """``{need id: title}`` of the needs in the needs.json files (the first file wins).""" + titles = {} + for path in json_paths: + for nid, need in _needs_of(path).items(): + if need.get("title"): + titles.setdefault(nid, need["title"]) + return titles + + # --- verdicts -------------------------------------------------------------------------- diff --git a/sphinx/_static/zdocs-needs.css b/sphinx/_static/zdocs-needs.css index 50bb989..7effdd5 100644 --- a/sphinx/_static/zdocs-needs.css +++ b/sphinx/_static/zdocs-needs.css @@ -312,3 +312,67 @@ table.need.need.need[class*=" needs_layout_zdocs"] tr.meta :is(.needs_test_funct font-size: 0.95em; } +/* + * The body table of an adequacy need (testcoverage): one row per body of a + * satisfying symbol. The roles zd-adq-ok / -miss / -none give the lines that + * the own tests ran as a chip; zd-adq-loc is the form and place of the body. + */ +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] tr.content table.zd-adq-bodies { + width: 100%; + margin: 0 0 0.75rem; + border: none; + border-collapse: collapse; + font-size: 0.9rem; +} +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] tr.content table.zd-adq-bodies colgroup { + display: none; +} +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] tr.content table.zd-adq-bodies :is(th, td) { + padding: 0.35rem 0.75rem 0.35rem 0; + border: none; + border-bottom: 1px solid var(--zd-border); + background: transparent; + vertical-align: top; + white-space: normal; + font-size: inherit; + line-height: 1.35; +} +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] tr.content table.zd-adq-bodies th { + border-bottom-width: 2px; + color: var(--zd-label); + font-weight: 600; +} +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] tr.content table.zd-adq-bodies :is(th, td):first-child { + width: 55%; +} +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] tr.content table.zd-adq-bodies :is(th, td):nth-child(2) { + width: 22%; +} +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] tr.content table.zd-adq-bodies td p { + margin: 0; + font-size: inherit; + line-height: inherit; +} +html.writer-html5 .rst-content table.need.need.need[class*=" needs_layout_zdocs"] tr.content table.zd-adq-bodies code { + padding: 0; + border: none; + background: none; + color: var(--zd-text); + font-size: 0.95em; + font-weight: 600; +} +table.need.need.need[class*=" needs_layout_zdocs"] .zd-adq-loc { + color: var(--zd-label); + font-family: var(--zd-mono); + font-size: 0.85em; +} +table.need.need.need[class*=" needs_layout_zdocs"] :is(.zd-adq-ok, .zd-adq-miss, .zd-adq-none) { + display: inline-block; + padding: 0 0.5em; + border-radius: 999px; + font-size: 0.9em; + white-space: nowrap; +} +table.need.need.need[class*=" needs_layout_zdocs"] .zd-adq-ok { background: #e3f3e6; color: #1e6b33; } +table.need.need.need[class*=" needs_layout_zdocs"] .zd-adq-miss { background: #fbe3e3; color: #a12828; } +table.need.need.need[class*=" needs_layout_zdocs"] .zd-adq-none { background: #eceef1; color: #5f6670; }