Skip to content

Commit 6c5d4c4

Browse files
committed
Attach canonical SPDX URLs to each identifier
1 parent efc7dca commit 6c5d4c4

5 files changed

Lines changed: 78 additions & 9 deletions

File tree

changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
- [zerobrew] Add `upgrade` and `upgrade_all` support by wrapping the `zb upgrade` command introduced in zerobrew `0.3.0`, and bump the minimum required `zb` version from `0.2.0` to `0.3.0`. `0.3.0` also fixes the Linux Python install failures reported in https://github.com/lucasgelfond/zerobrew/issues/336, so the `zerobrew` install tests now run as stable on both x86 and ARM Linux.
1414
- [mpm] Add a `scope` attribute (`ManagerScope.SYSTEM` or `ManagerScope.PROJECT`) to `PackageManager` to distinguish system-wide installers from project-local dependency managers, plus a `discover_projects()` extension point reserved for the latter. All maintained managers are system-scoped; project scope is not supported yet.
1515
- [mpm] Make `mpm sbom` maximalist by default: the renderer now collects rich per-package metadata (license, supplier, originator, homepage, source URL, checksums, declared dependency graph) from each manager and embeds it in both SPDX and CycloneDX exports. `brew`'s extractor sources data from a single `brew info --json=v2 --installed` shell-out and, when `HOMEBREW_SBOM=1` was set at install time, splices per-formula SBOM files from the Cellar into the aggregate document under auditable `externalDocumentRefs`; `pip`'s extractor reads each distribution's `METADATA` file via `importlib.metadata` with no shell-outs. A `--bundled / --minimal` flag (default `--bundled`) restores the previous bare inventory shape. Bumps the CycloneDX schema to `1.7` and raises the `mpm[sbom]` floor on `cyclonedx-python-lib` from `>=11.2` to `>=11.4`, the first release to expose the `JsonV1Dot7` outputter and `SchemaVersion.V1_7` enum.
16+
- [mpm] Attach canonical SPDX URLs to each identifier inside compound license expressions in CycloneDX 1.7 output (`licenses[].expressionDetails[]`), so consumers like Dependency-Track and GUAC can dereference every license referenced by `AND`/`OR`/`WITH` clauses. Bumps the `mpm[sbom]` floor on `cyclonedx-python-lib` from `>=11.4` to `>=11.9`, the first release to expose `LicenseExpression.details` and the `LicenseExpressionDetails` payload.
1617
- [mpm] Rename the global `--stats / --no-stats` flag to `--summary / --no-summary`, and move the rendering surface into a dedicated `meta_package_manager/summary.py` module. The underlying `SBOM.stats()` data accessor keeps its name. Breaking change for scripts that pass the old flag.
1718
- [mpm] Move `cyclonedx-python-lib` and `spdx-tools` out of the runtime install into a new `[sbom]` extra: `pip install meta-package-manager` no longer pulls CycloneDX or SPDX, and `mpm sbom` requires `pip install meta-package-manager[sbom]`. `packageurl-python` stays in the runtime install since it is used by `meta_package_manager.package` and `meta_package_manager.specifier`. Drops `jsonschema`, `rfc3987-syntax`, `lark`, and `lxml` from `mpm`'s install footprint by moving CycloneDX schema validation into the test suite. Breaking change for anyone that relied on `mpm sbom` from a default install.
1819
- [mpm] Reduce default-verbosity noise on operational subcommands: captured stderr from underlying CLIs now logs at DEBUG instead of WARNING/ERROR; per-manager `Skip` and `does not implement` messages drop to DEBUG for implicit manager selection but stay at INFO/WARNING when the user explicitly targeted the manager; a one-line error summary fires at the end of any subcommand whose CLIs accumulated errors.

meta_package_manager/sbom/cyclonedx.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,11 @@
5353
from cyclonedx.model.bom import Bom
5454
from cyclonedx.model.component import Component, ComponentType
5555
from cyclonedx.model.contact import OrganizationalContact, OrganizationalEntity
56-
from cyclonedx.model.license import DisjunctiveLicense, LicenseExpression
56+
from cyclonedx.model.license import (
57+
DisjunctiveLicense,
58+
LicenseExpression,
59+
LicenseExpressionDetails,
60+
)
5761
from cyclonedx.model.lifecycle import LifecyclePhase, PredefinedLifecycle
5862
from cyclonedx.output import make_outputter
5963
from cyclonedx.output.json import JsonV1Dot7
@@ -253,7 +257,24 @@ def _licenses_for(metadata: PackageMetadata) -> list:
253257
else:
254258
return out
255259
if parsed is not None:
256-
out.append(LicenseExpression(value=candidate))
260+
# Attach SPDX canonical URLs to each identifier inside the
261+
# expression. ``parsed.symbols`` is the deduped symbol set
262+
# produced by the ``license_expression`` parser; every
263+
# ``LicenseRef-`` and unknown-symbol case has already been
264+
# rejected by ``_parse_license_expression``. Sorting by key
265+
# keeps the emitted ``details`` order deterministic across
266+
# runs, independent of CycloneDX's internal ``SortedSet``.
267+
identifiers = sorted(
268+
{getattr(s, "key", None) or str(s) for s in parsed.symbols},
269+
)
270+
details = tuple(
271+
LicenseExpressionDetails(
272+
license_identifier=ident,
273+
url=XsUri(f"https://spdx.org/licenses/{ident}.html"),
274+
)
275+
for ident in identifiers
276+
)
277+
out.append(LicenseExpression(value=candidate, details=details))
257278
else:
258279
out.append(DisjunctiveLicense(name=candidate))
259280
return out

pyproject.toml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -198,9 +198,12 @@ xml = [ "click-extra[xml]" ]
198198
# with `mpm[sbom]`. cyclonedx-python-lib 11.4.0 is the first to expose the
199199
# CycloneDX 1.7 schema (`JsonV1Dot7`, `SchemaVersion.V1_7`) that
200200
# `meta_package_manager.sbom.CycloneDX` targets, and is also new enough to
201-
# support Python 3.14; spdx-tools 0.8.2 is the first version we used to
202-
# implement SPDX support.
203-
sbom = [ "cyclonedx-python-lib>=11.4", "spdx-tools>=0.8.2" ]
201+
# support Python 3.14; 11.9.0 introduced `LicenseExpression.details` for
202+
# per-identifier license-expression metadata (`LicenseExpressionDetails`),
203+
# which the renderer uses to attach canonical SPDX URLs to each identifier
204+
# inside a compound expression. spdx-tools 0.8.2 is the first version we
205+
# used to implement SPDX support.
206+
sbom = [ "cyclonedx-python-lib>=11.9", "spdx-tools>=0.8.2" ]
204207

205208
[project.scripts]
206209
mpm = "meta_package_manager.__main__:main"

tests/test_cli_sbom.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,50 @@ def test_bundled_mode_cyclonedx_populates_rich_fields():
411411
assert_valid_cyclonedx(c.export(), ExportFormat.JSON)
412412

413413

414+
@pytest.mark.parametrize(
415+
("expression", "expected_identifiers"),
416+
(
417+
# Two-license compound expression.
418+
("MIT AND Apache-2.0", ("Apache-2.0", "MIT")),
419+
# License combined with an SPDX exception via WITH.
420+
(
421+
"MIT WITH Classpath-exception-2.0",
422+
("Classpath-exception-2.0", "MIT"),
423+
),
424+
# Nested parenthesized expression with three identifiers.
425+
(
426+
"MIT OR (Apache-2.0 AND BSD-3-Clause)",
427+
("Apache-2.0", "BSD-3-Clause", "MIT"),
428+
),
429+
# Duplicates in the source collapse to a single ``details`` entry.
430+
("MIT AND MIT", ("MIT",)),
431+
),
432+
)
433+
def test_cyclonedx_compound_license_expression_details(
434+
expression, expected_identifiers
435+
):
436+
"""Compound expressions emit ``LicenseExpression.details`` with a
437+
canonical SPDX URL per identifier, deduped and sorted by identifier.
438+
"""
439+
c = CycloneDX()
440+
c.init_doc()
441+
c.set_scan_completeness(bundled=True)
442+
manager = _as_manager(_StubManager("brew", "Homebrew Formulae"))
443+
pkg = _make_package("brew", "curl", "8.9.0")
444+
md = PackageMetadata(license_declared=expression, license_concluded=expression)
445+
c.add_package(manager, pkg, md)
446+
c.finalize()
447+
doc = json.loads(c.export())
448+
license_obj = doc["components"][0]["licenses"][0]
449+
assert license_obj["expression"] == expression
450+
details = license_obj["expressionDetails"]
451+
assert tuple(d["licenseIdentifier"] for d in details) == expected_identifiers
452+
assert tuple(d["url"] for d in details) == tuple(
453+
f"https://spdx.org/licenses/{ident}.html" for ident in expected_identifiers
454+
)
455+
assert_valid_cyclonedx(c.export(), ExportFormat.JSON)
456+
457+
414458
@pytest.mark.parametrize(
415459
("declared", "expected_present"),
416460
(

uv.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)