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