You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ar...@apache.org on 2026/08/06 11:27:18 UTC

(tooling-trusted-releases) 02/02: Support for disjunctive license choice in SBOM report

This is an automated email from the ASF dual-hosted git repository.

alitheg pushed a commit to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git

commit 36391d9fd6d9fa886234bbd6e6b71cd7fab7abda
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Thu Aug 6 12:26:59 2026 +0100

    Support for disjunctive license choice in SBOM report
---
 atr/get/sbom.py                    | 57 ++++++++++++++++----------------
 atr/sbom/components.py             | 16 ++++++---
 atr/sbom/licenses.py               | 68 ++++++++++++++++++++++++++------------
 atr/sbom/models/components.py      |  7 +++-
 atr/sbom/models/licenses.py        | 20 ++++++++++-
 tests/unit/test_sbom_components.py |  5 ++-
 tests/unit/test_sbom_licenses.py   | 30 +++++++++++++++++
 7 files changed, 146 insertions(+), 57 deletions(-)

diff --git a/atr/get/sbom.py b/atr/get/sbom.py
index adf83522..f7addf92 100644
--- a/atr/get/sbom.py
+++ b/atr/get/sbom.py
@@ -71,7 +71,7 @@ async def quality(
     if breakdown is None:
         block.p["This SBOM could not be read, so its components cannot be shown."]
     else:
-        _components_section(block, breakdown, _license_categories(task))
+        _components_section(block, breakdown)
     _license_section(block, task)
     _vulnerability_section(block, task, task_result)
 
@@ -164,7 +164,6 @@ def _component_label(item: sbom.models.components.Item) -> str:
 def _components_section(
     block: htm.Block,
     breakdown: sbom.models.components.Breakdown,
-    license_categories: dict[str, sbom.models.licenses.Category],
 ) -> None:
     if breakdown.subject is not None:
         block.p["This SBOM describes ", htm.strong[_component_label(breakdown.subject)], "."]
@@ -184,20 +183,19 @@ def _components_section(
                     htm.span(".badge.bg-secondary.me-2.font-monospace")[str(len(group.items))],
                     htm.strong[group.component_type.capitalize()],
                 ],
-                _components_table(group.items, license_categories),
+                _components_table(group.items),
             ]
         )
 
 
 def _components_table(
     items: list[sbom.models.components.Item],
-    license_categories: dict[str, sbom.models.licenses.Category],
 ) -> htm.Element:
     rows = [
         htm.tr[
             htm.td[item.name],
             htm.td[item.version or "-"],
-            htm.td[_licenses_cell(item.licenses, license_categories)],
+            htm.td[_licenses_cell(item.license_choices)],
             htm.td[htm.code[item.purl] if item.purl else "-"],
         ]
         for item in items
@@ -267,27 +265,32 @@ def _extract_vulnerability_severity(vuln: results.VulnerabilityDetails) -> str:
     return "Unknown"
 
 
-def _license_badge(name: str, categories: dict[str, sbom.models.licenses.Category]) -> htm.Element:
-    category = categories.get(name, sbom.models.licenses.Category.A)
-    return htm.div(".d-flex.align-items-center")[
-        htm.span(f".badge.me-2{_license_category_style(category)}")[str(category)],
+def _choice_badges(choice: sbom.models.licenses.Choice) -> list[htm.Element]:
+    # A single licence, or an AND that all applies, shows one badge for the whole expression. An OR
+    # ATR resolved shows the chosen half in its category colour and the halves it set aside drained
+    # of colour, so a reader sees what was on offer without the alternatives reading as a verdict
+    if choice.chosen is None:
+        return [_license_badge(choice.expression, choice.category)]
+    return [
+        _license_badge(choice.chosen, choice.category),
+        *(_license_alternative_badge(name, category) for name, category in choice.alternatives),
+    ]
+
+
+def _license_alternative_badge(name: str, category: sbom.models.licenses.Category) -> htm.Element:
+    # The category box keeps its letter but loses its colour, marking a licence ATR could have used
+    # but set aside in favour of a friendlier half of the same OR
+    return htm.div(".d-flex.align-items-center.text-muted")[
+        htm.span(".badge.me-2.bg-secondary.bg-opacity-25.text-dark")[str(category)],
         name,
     ]
 
 
-def _license_categories(task: sql.Task | None) -> dict[str, sbom.models.licenses.Category]:
-    # The score task already categorised every licence expression it found wanting, so a licence
-    # absent from both lists is Category A - no need to run the classifier a second time here
-    task_result = _score_result(task)
-    if task_result is None:
-        return {}
-    categories: dict[str, sbom.models.licenses.Category] = {}
-    for issues in (task_result.license_warnings, task_result.license_errors):
-        if issues is None:
-            continue
-        for issue in _load_license_issues(issues):
-            categories[issue.license_expression] = issue.category
-    return categories
+def _license_badge(name: str, category: sbom.models.licenses.Category) -> htm.Element:
+    return htm.div(".d-flex.align-items-center")[
+        htm.span(f".badge.me-2{_license_category_style(category)}")[str(category)],
+        name,
+    ]
 
 
 def _license_category_style(category: sbom.models.licenses.Category) -> str:
@@ -393,13 +396,11 @@ def _license_tally(
     )
 
 
-def _licenses_cell(
-    names: list[str],
-    categories: dict[str, sbom.models.licenses.Category],
-) -> htm.Element | str:
-    if not names:
+def _licenses_cell(choices: list[sbom.models.licenses.Choice]) -> htm.Element | str:
+    if not choices:
         return "-"
-    return htm.div(".d-flex.flex-column.gap-1")[*(_license_badge(name, categories) for name in names)]
+    badges = [badge for choice in choices for badge in _choice_badges(choice)]
+    return htm.div(".d-flex.flex-column.gap-1")[*badges]
 
 
 def _load_license_issues(issues: list[str]) -> list[sbom.models.licenses.Issue]:
diff --git a/atr/sbom/components.py b/atr/sbom/components.py
index 8d5d8210..f2ae3182 100644
--- a/atr/sbom/components.py
+++ b/atr/sbom/components.py
@@ -19,6 +19,8 @@ from __future__ import annotations
 
 from typing import TYPE_CHECKING
 
+from cyclonedx.model.license import LicenseExpression
+
 if TYPE_CHECKING:
     from cyclonedx.model.bom import Bom
     from cyclonedx.model.component import Component
@@ -46,14 +48,20 @@ def _item(component: Component) -> models.components.Item:
     return models.components.Item(
         name=component.name or "unknown",
         version=component.version,
-        licenses=_licenses(component),
+        license_choices=_licenses(component),
         purl=str(component.purl) if component.purl else None,
     )
 
 
-def _licenses(component: Component) -> list[str]:
-    values = {expr for choice in component.licenses if (expr := licenses.expression(choice))}
-    return sorted(values)
+def _licenses(component: Component) -> list[models.licenses.Choice]:
+    # One reading per declared licence, deduplicated by expression and sorted so the table is stable
+    readings: dict[str, models.licenses.Choice] = {}
+    for choice in component.licenses:
+        expr = licenses.expression(choice)
+        if not expr:
+            continue
+        readings.setdefault(expr, licenses.assess(expr, isinstance(choice, LicenseExpression)))
+    return [readings[expr] for expr in sorted(readings)]
 
 
 def _sort_key(item: models.components.Item) -> tuple[str, str]:
diff --git a/atr/sbom/licenses.py b/atr/sbom/licenses.py
index 92914944..e2bacf68 100644
--- a/atr/sbom/licenses.py
+++ b/atr/sbom/licenses.py
@@ -70,37 +70,21 @@ def check(
             if not license_expr:
                 continue
 
-            if isinstance(license_choice, LicenseExpression):
-                try:
-                    atoms = license_expression_atoms(license_expr)
-                except ValueError:
-                    atoms = {license_expr}
-            else:
-                atoms = {license_expr}
-
-            categories = [_atom_category(atom) for atom in atoms]
-            # "A OR B" lets the component be used under either half, so the friendliest half decides.
-            # Anything else - an AND, parentheses, or a licence standing on its own - all applies at
-            # once, so there the sternest half wins
-            if isinstance(license_choice, LicenseExpression) and _is_simple_disjunction(license_expr):
-                category = min(categories, key=_category_severity)
-            else:
-                category = max(categories, key=_category_severity)
-
-            # A licence ATR cannot place reads as None here, and that is as concerning as Category X
+            reading = assess(license_expr, isinstance(license_choice, LicenseExpression))
             issue = models.licenses.Issue(
                 component_name=name,
                 component_version=version,
                 license_expression=license_expr,
-                category=category if (category is not None) else models.licenses.Category.X,
-                any_unknown=(category is None),
+                category=reading.category,
+                any_unknown=reading.any_unknown,
+                chosen=reading.chosen,
                 scope=scope,
                 component_type=type,
             )
-            if category == models.licenses.Category.A:
+            if reading.category == models.licenses.Category.A:
                 if include_all:
                     good.append(issue)
-            elif category == models.licenses.Category.B:
+            elif reading.category == models.licenses.Category.B:
                 # Category B may ship in a binary but not in source, so a source release makes it an error
                 (errors if is_source_release else warnings).append(issue)
             else:
@@ -109,6 +93,40 @@ def check(
     return good, warnings, errors
 
 
+def assess(license_expr: str, is_expression: bool) -> models.licenses.Choice:
+    # Read one declared licence. "A OR B" lets the component be used under either half, so the
+    # friendliest half decides and the rest are set aside as alternatives. Anything else - an AND,
+    # parentheses, or a licence standing on its own - all applies at once, so there the sternest
+    # half wins and there is no choice to offer
+    if is_expression:
+        try:
+            atoms = license_expression_atoms(license_expr)
+        except ValueError:
+            atoms = {license_expr}
+    else:
+        atoms = {license_expr}
+
+    ordered = sorted(atoms)
+    categories = {atom: _atom_category(atom) for atom in ordered}
+
+    if is_expression and _is_simple_disjunction(license_expr):
+        chosen = min(ordered, key=lambda atom: _category_severity(categories[atom]))
+        return models.licenses.Choice(
+            expression=license_expr,
+            category=_resolved_category(categories[chosen]),
+            any_unknown=(categories[chosen] is None),
+            chosen=chosen,
+            alternatives=[(atom, _resolved_category(categories[atom])) for atom in ordered if atom != chosen],
+        )
+
+    worst = max(categories.values(), key=_category_severity)
+    return models.licenses.Choice(
+        expression=license_expr,
+        category=_resolved_category(worst),
+        any_unknown=(worst is None),
+    )
+
+
 def expression(license_choice: License) -> str | None:
     # A licence is either an SPDX expression or a single licence, and a single licence carries a
     # name of its own where it has no SPDX identifier to give
@@ -158,6 +176,12 @@ def _name_to_id() -> dict[str, str]:
     return index
 
 
+def _resolved_category(category: models.licenses.Category | None) -> models.licenses.Category:
+    # A licence ATR cannot place reads as None, and that is as concerning as Category X, so X stands
+    # in for the unknown
+    return category if (category is not None) else models.licenses.Category.X
+
+
 def _normalised_name(text: str) -> str:
     words = []
     for word in _NAME_SPLIT.split(text.casefold()):
diff --git a/atr/sbom/models/components.py b/atr/sbom/models/components.py
index eae0fe99..c5150424 100644
--- a/atr/sbom/models/components.py
+++ b/atr/sbom/models/components.py
@@ -17,15 +17,20 @@
 
 from __future__ import annotations
 
+from typing import TYPE_CHECKING
+
 import pydantic
 
 from .base import Strict
 
+if TYPE_CHECKING:
+    from . import licenses
+
 
 class Item(Strict):
     name: str
     version: str | None = None
-    licenses: list[str] = pydantic.Field(default_factory=list)
+    license_choices: list[licenses.Choice] = pydantic.Field(default_factory=list)
     purl: str | None = None
 
 
diff --git a/atr/sbom/models/licenses.py b/atr/sbom/models/licenses.py
index 18f23ed2..8182f892 100644
--- a/atr/sbom/models/licenses.py
+++ b/atr/sbom/models/licenses.py
@@ -34,6 +34,18 @@ class Category(enum.Enum):
         return self.name
 
 
+class Choice(Strict):
+    # ATR's reading of one declared licence. Where an OR let the component be used under the
+    # friendliest of several licences, `chosen` names that half and `alternatives` holds the halves
+    # ATR set aside; for a single licence, or an AND that all applies at once, `chosen` is None and
+    # the whole `expression` carries the category
+    expression: str
+    category: Category
+    any_unknown: bool = False
+    chosen: str | None = None
+    alternatives: list[tuple[str, Category]] = pydantic.Field(default_factory=list)
+
+
 class Issue(Strict):
     component_name: str
     component_version: str | None
@@ -41,6 +53,8 @@ class Issue(Strict):
     license_expression: str
     category: Category
     any_unknown: bool = False
+    # The half of an OR ATR chose, where there was a choice. None where the whole expression applies
+    chosen: str | None = None
     scope: str | None = None
 
     @pydantic.field_validator("category", mode="before")
@@ -51,4 +65,8 @@ class Issue(Strict):
     def __str__(self):
         type_str = "Component" if (self.component_type is None) else self.component_type
         version_str = f"@{self.component_version}" if (self.component_version != "UNKNOWN") else ""
-        return f"{type_str} {self.component_name}{version_str} declares license {self.license_expression}"
+        licence = self.license_expression
+        if (self.chosen is not None) and (self.chosen != self.license_expression):
+            # An OR let ATR take the friendliest half, so name that half but keep the original on show
+            licence = f"{self.chosen} (chosen from {self.license_expression})"
+        return f"{type_str} {self.component_name}{version_str} declares license {licence}"
diff --git a/tests/unit/test_sbom_components.py b/tests/unit/test_sbom_components.py
index f4c11d4a..5591a5af 100644
--- a/tests/unit/test_sbom_components.py
+++ b/tests/unit/test_sbom_components.py
@@ -46,7 +46,10 @@ def test_breakdown() -> None:
         ("application", ["gamma"]),
         ("library", ["alpha", "beta"]),
     ]
-    assert breakdown.groups[1].items[0].licenses == ["Apache-2.0"]
+    licence_choices = breakdown.groups[1].items[0].license_choices
+    assert [(choice.expression, str(choice.category), choice.chosen) for choice in licence_choices] == [
+        ("Apache-2.0", "A", None)
+    ]
 
     # A BOM need not say what it is about
     assert components.breakdown(sboms.with_components({"type": "library", "name": "alpha"})).subject is None
diff --git a/tests/unit/test_sbom_licenses.py b/tests/unit/test_sbom_licenses.py
index 8b96dc73..ab890f00 100644
--- a/tests/unit/test_sbom_licenses.py
+++ b/tests/unit/test_sbom_licenses.py
@@ -30,6 +30,23 @@ def test_expression() -> None:
     assert licenses.expression(cdx_license.DisjunctiveLicense(name="Some bespoke licence")) == "Some bespoke licence"
 
 
+def test_assess_picks_the_friendliest_half_of_a_disjunction_and_sets_the_rest_aside() -> None:
+    reading = licenses.assess("Apache-2.0 OR GPL-3.0-only", is_expression=True)
+
+    assert (reading.category, reading.chosen) == (models.licenses.Category.A, "Apache-2.0")
+    # The half not taken is kept so the report can show what else was on offer
+    assert reading.alternatives == [("GPL-3.0-only", models.licenses.Category.X)]
+
+
+def test_assess_offers_no_choice_for_a_single_licence_or_a_conjunction() -> None:
+    single = licenses.assess("Apache-2.0", is_expression=False)
+    conjunction = licenses.assess("Apache-2.0 AND GPL-3.0-only", is_expression=True)
+
+    # A single licence, or an AND that all applies, carries the whole expression with no half to pick
+    assert (single.chosen, single.alternatives) == (None, [])
+    assert (conjunction.chosen, conjunction.category) == (None, models.licenses.Category.X)
+
+
 def test_check() -> None:
     good, warnings, errors = licenses.check(
         sboms.with_components(
@@ -111,6 +128,19 @@ def test_check_a_disjunction_settles_on_the_friendliest_category() -> None:
     assert errors == []
 
 
+def test_check_records_the_chosen_half_of_a_disjunction_it_still_flags() -> None:
+    # "B OR X" still warns, but only on the friendlier B half, and the report keeps the half it settled on
+    _good, warnings, _errors = licenses.check(
+        sboms.with_components(
+            {"type": "library", "name": "weak-or-forbidden", "licenses": [{"expression": "EPL-2.0 OR GPL-3.0-only"}]},
+        ),
+    )
+
+    assert [(issue.category, issue.chosen, issue.license_expression) for issue in warnings] == [
+        (models.licenses.Category.B, "EPL-2.0", "EPL-2.0 OR GPL-3.0-only"),
+    ]
+
+
 def test_check_a_conjunction_settles_on_the_sternest_category() -> None:
     # "A AND B" binds the component to both, so the sterner half decides
     _good, _warnings, errors = licenses.check(


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org