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
4 changes: 4 additions & 0 deletions src/extensions/score_metamodel/metamodel.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,10 @@ needs_types:
author: ^.*$
approver: ^.*$
reviewer: ^.*$
# Scopes module_verification_report/platform_verification_report to
# requirements with valid_from <= report_version. Unset means unscoped
# (all requirements shown), used e.g. for the "_latest" reports.
report_version: ^v(0|[1-9]\d*)\.(0|[1-9]\d*)(\.(0|[1-9]\d*))?$
# req-Id: tool_req__docs_doc_generic_mandatory
mandatory_links:
realizes: workproduct
Expand Down
72 changes: 72 additions & 0 deletions src/extensions/score_sphinx_needs_templates/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,72 @@ def __call__(self, need_type: str) -> list[NeedItem]:
_needs_of_type_callable = _NeedsOfType()


def _parse_version(value: str) -> tuple[int, int, int]:
"""Parse a ``valid_from``/``report_version``-style milestone string.

Accepts ``vMAJOR.MINOR`` or ``vMAJOR.MINOR.PATCH`` (e.g. ``v0.8`` or
``v1.0.1``), matching the format enforced by the metamodel for
``valid_from``/``valid_until``/``report_version``.
"""
numbers = [int(part) for part in value.strip().lstrip("vV").split(".")]
while len(numbers) < 3:
numbers.append(0)
return (numbers[0], numbers[1], numbers[2])


class _RequirementInReportVersion:
"""Decide whether a requirement Need belongs to a ``report_version`` scope.

``feat_req`` (and ``stkh_req``) carry ``valid_from`` directly. ``comp_req``
has no ``valid_from`` of its own, so its scope is inherited from the
``feat_req`` Need(s) it is ``derived_from``. A requirement without a
resolvable ``valid_from`` (directly or through ``derived_from``) is
excluded whenever a ``report_version`` scope is active, matching the rule
that only requirements with ``valid_from`` set are considered relevant for
a given release.

Calling with an empty/``None`` ``report_version`` always returns ``True``,
which keeps unscoped reports (e.g. "latest") showing every requirement as
before.
"""

def __call__(self, need: NeedItem, report_version: str | None) -> bool:
if not report_version:
return True

valid_from = need.get("valid_from")
if valid_from:
try:
return _parse_version(valid_from) <= _parse_version(report_version)
except ValueError:
return False
Comment on lines +214 to +219

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, and you're right that it's inconsistent with the documented valid_from/valid_until semantics (tool_req__docs_req_attr_validity_correctness: "from" inclusive, "until" exclusive).

This is intentional scope for this PR, though: report_version here answers "everything relevant as of release X" (a snapshot of what should exist by then), not "everything still valid in release X" (which would additionally need the valid_until exclusion you describe). We deliberately kept it to the simpler valid_from-only check for the initial version of this feature - happy to open a follow-up to add report_version < valid_until support (with comp_req inheriting the full interval via derived_from, as you suggest) once there's a concrete need for it, rather than speculatively building it now.

Added focused unit tests for the current behavior (including the comp_req/derived_from inheritance path and malformed-value handling) in b71c770.

Comment on lines +218 to +219

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my opinion this can never happen, or am I thinking wrong?

I do like defensive programming, but this seems a bit weird.
If anything this defence may should be in the parser.


linked_feat_reqs = _linked_needs_callable(need["id"], "derived_from")
return any(self(feat_req, report_version) for feat_req in linked_feat_reqs)


_req_in_report_version_callable = _RequirementInReportVersion()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Single variable indirections are useless imo.
Makes it harder to read.

You are only using htem on 2 places, so this makes no sense to put into a variable that is also the same lenght...



class _AnyRequirementInReportVersion:
"""Decide whether a Feature/Component has any requirement in scope.

Used to drop an entire Feature/Component section from the report when
``report_version`` is set and none of its requirements qualify, instead of
rendering an empty section. An empty/``None`` ``report_version`` always
returns ``True`` (unscoped reports keep every Feature/Component, even
ones without any requirement at all, as before).
"""

def __call__(self, reqs: list[NeedItem], report_version: str | None) -> bool:
if not report_version:
return True
return any(_req_in_report_version_callable(req, report_version) for req in reqs)
Comment thread
MaximilianSoerenPollak marked this conversation as resolved.


_any_req_in_report_version_callable = _AnyRequirementInReportVersion()


def _post_templates_requiring_reread(app: Sphinx) -> set[str]:
"""Return post-template names opting into the post-merge rendering pass."""
template_folder = _needs_template_folder()
Expand Down Expand Up @@ -262,6 +328,12 @@ def setup(app: Sphinx) -> dict[str, object]:
)
app.config.needs_render_context.setdefault("linked_needs", _linked_needs_callable)
app.config.needs_render_context.setdefault("needs_of_type", _needs_of_type_callable)
app.config.needs_render_context.setdefault(
"req_in_report_version", _req_in_report_version_callable
)
app.config.needs_render_context.setdefault(
"any_req_in_report_version", _any_req_in_report_version_callable
)
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
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,104 @@ def test_backlinks_merge_indexed_and_new_outgoing_links(
linked = linked_needs_class()("REQ", "fully_verifies_back")

assert [need["id"] for need in linked] == ["TC-old", "TC-new"]


def test_parse_version_pads_missing_patch() -> None:
"""A two-component milestone implies patch ``0`` for comparison purposes."""
parse_version = vars(templates)["_parse_version"]

assert parse_version("v0.8") == (0, 8, 0)
assert parse_version("v1.0.1") == (1, 0, 1)
assert parse_version("V2.3") == (2, 3, 0)


def test_req_in_report_version_unscoped_report_version_always_true() -> None:
"""No ``report_version`` means the report is unscoped ("latest")."""
req_in_report_version = vars(templates)["_RequirementInReportVersion"]()
need = FakeNeed("feat_req__x")

assert req_in_report_version(need, None) is True
assert req_in_report_version(need, "") is True


def test_req_in_report_version_direct_valid_from_boundary() -> None:
"""``valid_from`` is inclusive: equal or later report_version is in scope."""
req_in_report_version = vars(templates)["_RequirementInReportVersion"]()
need = FakeNeed("feat_req__x")
need["valid_from"] = "v0.8"

assert req_in_report_version(need, "v0.7") is False
assert req_in_report_version(need, "v0.8") is True
assert req_in_report_version(need, "v0.9") is True


def test_req_in_report_version_malformed_valid_from_is_excluded() -> None:
"""An unparsable ``valid_from`` must not raise; it just excludes the need."""
req_in_report_version = vars(templates)["_RequirementInReportVersion"]()
need = FakeNeed("feat_req__x")
need["valid_from"] = "not-a-version"

assert req_in_report_version(need, "v1.0") is False


def test_req_in_report_version_missing_valid_from_without_derived_from_is_excluded(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A requirement with no ``valid_from`` and nothing to inherit from is out."""
req_in_report_version = vars(templates)["_RequirementInReportVersion"]()
comp_req = FakeNeed("comp_req__x")
monkeypatch.setattr(
templates, "_get_available_needs", lambda: {"comp_req__x": comp_req}
)

assert req_in_report_version(comp_req, "v1.0") is False


def test_req_in_report_version_comp_req_inherits_via_derived_from(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``comp_req`` has no ``valid_from`` of its own; it inherits its scope from
the ``feat_req`` it is ``derived_from``."""
req_in_report_version = vars(templates)["_RequirementInReportVersion"]()
feat_req = FakeNeed("feat_req__x")
feat_req["valid_from"] = "v0.8"
comp_req = FakeNeed("comp_req__x", links=[FakeLink("feat_req__x")])
needs = {"feat_req__x": feat_req, "comp_req__x": comp_req}
monkeypatch.setattr(templates, "_get_available_needs", lambda: needs)

assert req_in_report_version(comp_req, "v0.7") is False
assert req_in_report_version(comp_req, "v0.8") is True


def test_any_req_in_report_version_unscoped_true_even_for_empty_list() -> None:
"""Unscoped ("latest") reports keep Features/Components with no requirement."""
any_req_in_report_version = vars(templates)["_AnyRequirementInReportVersion"]()

assert any_req_in_report_version([], None) is True


def test_any_req_in_report_version_scoped_empty_list_is_excluded() -> None:
"""A scoped report has nothing to show for a Feature/Component with no reqs."""
any_req_in_report_version = vars(templates)["_AnyRequirementInReportVersion"]()

assert any_req_in_report_version([], "v1.0") is False


def test_any_req_in_report_version_true_if_at_least_one_requirement_matches() -> None:
any_req_in_report_version = vars(templates)["_AnyRequirementInReportVersion"]()
out_of_scope = FakeNeed("feat_req__a")
out_of_scope["valid_from"] = "v2.0"
in_scope = FakeNeed("feat_req__b")
in_scope["valid_from"] = "v0.8"

assert any_req_in_report_version([out_of_scope, in_scope], "v1.0") is True


def test_any_req_in_report_version_false_if_no_requirement_matches() -> None:
any_req_in_report_version = vars(templates)["_AnyRequirementInReportVersion"]()
a = FakeNeed("feat_req__a")
a["valid_from"] = "v2.0"
b = FakeNeed("feat_req__b")
b["valid_from"] = "v3.0"

assert any_req_in_report_version([a, b], "v1.0") is False
46 changes: 38 additions & 8 deletions src/needs_templates/module_verification_report.need
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,21 @@
per-component navigation.
#}
{% set module_id = "mod__" ~ id|replace("doc__", "")|replace("_verification_report", "") %}
{# Unset/empty ``report_version`` keeps the report unscoped (all components
and requirements shown), which is what the "_latest" report relies on. #}
{% set report_version = report_version|default(None, true) %}
{# Resolve the component list from the module's outgoing graph links. A module
may list a component more than once, so deduplicate the NeedItems by ID. #}
{% set components_in_mod = linked_needs(module_id, "includes")|unique(attribute="id")|list %}
may list a component more than once, so deduplicate the NeedItems by ID.
A component with no requirement in scope is dropped from the report
entirely instead of rendering an empty section. #}
{% set ns_components = namespace(list=[]) %}
{% for component in linked_needs(module_id, "includes")|unique(attribute="id")|list %}
{% set component_reqs = linked_needs(component["id"], "satisfied_by_back")|selectattr("type", "eq", "comp_req")|list %}
{% if any_req_in_report_version(component_reqs, report_version) %}
{% set ns_components.list = ns_components.list + [component] %}
{% endif %}
{% endfor %}
{% set components_in_mod = ns_components.list %}

{% set component_workproducts = [
["wp__requirements_inspect", "Requirements Inspection"],
Expand Down Expand Up @@ -51,6 +63,13 @@
{%- endfor %}
{% endmacro %}

{#- Bracketed, quoted Sphinx-Needs ``id in [...]`` filter literal for a list
of Need IDs, used to scope needpie/needtable filters below to the
requirements that are in scope for ``report_version``. -#}
{% macro id_filter_list(ids) -%}
[{% for req_id in ids %}"{{ req_id }}"{% if not loop.last %}, {% endif %}{% endfor %}]
{%- endmacro %}

.. raw:: html

<style>
Expand Down Expand Up @@ -110,6 +129,17 @@ Component Overview

<hr style="border-top: 2px solid #333333; margin: 0.5em 0 1.5em 0;">

{% set component_reqs_all = linked_needs(component_id, "satisfied_by_back")
|selectattr("type", "eq", "comp_req")
|list %}
{% set ns = namespace(req_ids=[]) %}
{% for req in component_reqs_all %}
{% if req_in_report_version(req, report_version) %}
{% set ns.req_ids = ns.req_ids + [req["id"]] %}
{% endif %}
{% endfor %}
{% set component_req_filter = 'id in ' ~ id_filter_list(ns.req_ids) %}

Component Requirements Statistics
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Expand All @@ -123,8 +153,8 @@ Component Requirements Statistics
:colors: #37a12d, #ca2828
:legend:

type == "comp_req" and "{{ component_id }}" in satisfied_by and status == "valid"
type == "comp_req" and "{{ component_id }}" in satisfied_by and status == "invalid"
type == "comp_req" and "{{ component_id }}" in satisfied_by and status == "valid" and {{ component_req_filter }}
type == "comp_req" and "{{ component_id }}" in satisfied_by and status == "invalid" and {{ component_req_filter }}

.. grid-item::

Expand All @@ -133,9 +163,9 @@ Component Requirements Statistics
:colors: #37a12d, #f0a500, #ca2828
:legend:

type == "comp_req" and "{{ component_id }}" in satisfied_by and fully_verifies_back
type == "comp_req" and "{{ component_id }}" in satisfied_by and partially_verifies_back and not fully_verifies_back
type == "comp_req" and "{{ component_id }}" in satisfied_by and not fully_verifies_back and not partially_verifies_back
type == "comp_req" and "{{ component_id }}" in satisfied_by and fully_verifies_back and {{ component_req_filter }}
type == "comp_req" and "{{ component_id }}" in satisfied_by and partially_verifies_back and not fully_verifies_back and {{ component_req_filter }}
type == "comp_req" and "{{ component_id }}" in satisfied_by and not fully_verifies_back and not partially_verifies_back and {{ component_req_filter }}

Component Architecture Statistics
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Expand Down Expand Up @@ -173,7 +203,7 @@ verification status and the tests that (fully or partially) verify them:
:animate: fade-in

.. needtable::
:filter: type == "comp_req" and "{{ component_id }}" in satisfied_by
:filter: type == "comp_req" and "{{ component_id }}" in satisfied_by and {{ component_req_filter }}
:style: table
:columns: id;title;safety;status;testlink
:colwidths: 13,22,8,10,47
Expand Down
42 changes: 35 additions & 7 deletions src/needs_templates/platform_verification_report.need
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,19 @@
work — and real headings are what give the report its TOC entries and
per-feature navigation.
#}
{% set report_features = needs_of_type("feat")|unique(attribute="id")|sort(attribute="title")|list %}
{# Unset/empty ``report_version`` keeps the report unscoped (all Features and
requirements shown), which is what the "_latest" report relies on. #}
{% set report_version = report_version|default(None, true) %}
{#- A Feature with no requirement in scope is dropped from the report
entirely instead of rendering an empty section. -#}
{% set ns_features = namespace(list=[]) %}
{% for feature in needs_of_type("feat")|unique(attribute="id")|sort(attribute="title")|list %}
{% set feature_reqs = linked_needs(feature["id"], "satisfied_by_back")|selectattr("type", "eq", "feat_req")|list %}
{% if any_req_in_report_version(feature_reqs, report_version) %}
{% set ns_features.list = ns_features.list + [feature] %}
{% endif %}
{% endfor %}
{% set report_features = ns_features.list %}

{% set feature_workproducts = [
["wp__requirements_inspect", "Requirements Inspection"],
Expand All @@ -41,6 +53,12 @@
{%- endfor %}
{% endmacro %}

{#- Bracketed, quoted Sphinx-Needs ``id in [...]`` filter literal for a list
of Need IDs, used to scope needpie/needtable filters below to the
requirements that are in scope for ``report_version``. -#}
{% macro id_filter_list(ids) -%}
[{% for req_id in ids %}"{{ req_id }}"{% if not loop.last %}, {% endif %}{% endfor %}]
{%- endmacro %}
.. raw:: html

<style>
Expand Down Expand Up @@ -100,6 +118,16 @@ Feature Overview

<hr style="border-top: 2px solid #333333; margin: 0.5em 0 1.5em 0;">

{% set feature_reqs_all = linked_needs(feature_id, "satisfied_by_back")
|selectattr("type", "eq", "feat_req")
|list %}
{% set ns = namespace(req_ids=[]) %}
{% for req in feature_reqs_all %}
{% if req_in_report_version(req, report_version) %}
{% set ns.req_ids = ns.req_ids + [req["id"]] %}
{% endif %}
{% endfor %}
{% set feature_req_filter = 'id in ' ~ id_filter_list(ns.req_ids) %}
Requirements Statistics
^^^^^^^^^^^^^^^^^^^^^^^

Expand All @@ -113,8 +141,8 @@ Requirements Statistics
:colors: #37a12d, #ca2828
:legend:

type == "feat_req" and "{{ feature_id }}" in satisfied_by and status == "valid"
type == "feat_req" and "{{ feature_id }}" in satisfied_by and status == "invalid"
type == "feat_req" and "{{ feature_id }}" in satisfied_by and status == "valid" and {{ feature_req_filter }}
type == "feat_req" and "{{ feature_id }}" in satisfied_by and status == "invalid" and {{ feature_req_filter }}

.. grid-item::

Expand All @@ -123,15 +151,15 @@ Requirements Statistics
:colors: #37a12d, #f0a500, #ca2828
:legend:

type == "feat_req" and "{{ feature_id }}" in satisfied_by and fully_verifies_back
type == "feat_req" and "{{ feature_id }}" in satisfied_by and partially_verifies_back and not fully_verifies_back
type == "feat_req" and "{{ feature_id }}" in satisfied_by and not fully_verifies_back and not partially_verifies_back
type == "feat_req" and "{{ feature_id }}" in satisfied_by and fully_verifies_back and {{ feature_req_filter }}
type == "feat_req" and "{{ feature_id }}" in satisfied_by and partially_verifies_back and not fully_verifies_back and {{ feature_req_filter }}
type == "feat_req" and "{{ feature_id }}" in satisfied_by and not fully_verifies_back and not partially_verifies_back and {{ feature_req_filter }}

.. dropdown:: Show requirements table
:animate: fade-in

.. needtable::
:filter: type == "feat_req" and "{{ feature_id }}" in satisfied_by
:filter: type == "feat_req" and "{{ feature_id }}" in satisfied_by and {{ feature_req_filter }}
:style: table
:columns: id;title;safety;status;testlink
:colwidths: 13,22,8,10,47
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,15 @@
},
"type": "array"
},
"report_version": {
"default": "",
"description": "Added by needs_fields config",
"field_type": "extra",
"type": [
"string",
"null"
]
},
"reqtype": {
"default": "",
"description": "Added by needs_fields config",
Expand Down
Loading
Loading