Skip to content

Commit 3b36a55

Browse files
mprpicclaude
andcommitted
feat(sbom): build purls with packageurl-python
Use the packageurl-python library to construct purls instead of manual string building. Support two modes for purl specification in package settings: - Full purl string (`purl` field) used as-is, e.g. `pkg:generic/my-fork@1.0.0` - Individual field overrides (`purl_type`, `purl_namespace`, `purl_name`, `purl_version`) that override the default construction from global settings and package identity The two modes are mutually exclusive, enforced by a model validator. A default purl (`pkg:pypi/<name>@<version>`) is now always generated. The global `purl_type` setting (default: `pypi`) controls the default type for all packages. Add `repository_url` to `SbomSettings` as a global purl qualifier (e.g. `?repository_url=https://packages.redhat.com`) that is added to every downstream purl. Per-package `repository_url` overrides the global value. Downstream purl construction cascades: per-package `purl` (full override) > per-package field overrides (`purl_type`, etc.) > global defaults from `SbomSettings` Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Martin Prpič <mprpic@redhat.com>
1 parent d86f938 commit 3b36a55

7 files changed

Lines changed: 311 additions & 70 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ dependencies = [
3535
"elfdeps>=0.2.0",
3636
"license-expression",
3737
"packaging",
38+
"packageurl-python",
3839
"psutil",
3940
"pydantic",
4041
"pypi_simple",

src/fromager/packagesettings/_models.py

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ class SbomSettings(pydantic.BaseModel):
3737
sbom:
3838
supplier: "Organization: ExampleCo"
3939
namespace: "https://www.example.com"
40+
purl_type: pypi
41+
repository_url: "https://example.com/simple"
4042
creators:
4143
- "Organization: ExampleCo"
4244
"""
@@ -55,6 +57,17 @@ class SbomSettings(pydantic.BaseModel):
5557
The fromager tool creator entry is always added automatically.
5658
"""
5759

60+
purl_type: str = "pypi"
61+
"""Default purl type for all packages (e.g. ``pypi``, ``generic``)"""
62+
63+
repository_url: str | None = None
64+
"""Default purl ``repository_url`` qualifier for all packages
65+
66+
When set, this URL is added to every purl as a qualifier
67+
(e.g. ``pkg:pypi/flask@2.0?repository_url=https://example.com/simple``).
68+
Can be overridden per-package in the package settings file.
69+
"""
70+
5871

5972
class ResolverDist(pydantic.BaseModel):
6073
"""Packages resolver dist
@@ -352,13 +365,60 @@ class PackageSettings(pydantic.BaseModel):
352365
"""Alternative source download settings"""
353366

354367
purl: str | None = None
355-
"""Package URL (purl) override for SBOM generation
368+
"""Full purl string override for SBOM generation
369+
370+
When set, this value is used as the complete purl for this package,
371+
bypassing the normal purl construction from individual fields.
372+
Mutually exclusive with ``purl_type``, ``purl_namespace``,
373+
``purl_name``, and ``purl_version``.
374+
"""
375+
376+
purl_type: str | None = None
377+
"""Override the purl type (e.g. ``generic`` instead of ``pypi``)"""
378+
379+
purl_namespace: str | None = None
380+
"""Override the purl namespace component"""
381+
382+
purl_name: str | None = None
383+
"""Override the purl name component (defaults to the package name)"""
384+
385+
purl_version: str | None = None
386+
"""Override the purl version component (defaults to the resolved version)"""
356387

357-
When set, this value is used instead of the default ``pkg:pypi/<name>@<version>``
358-
purl. Useful for packages that are not on PyPI or are midstream forks.
359-
Supports ``{name}`` and ``{version}`` format substitution.
388+
repository_url: str | None = None
389+
"""Per-package override for the purl ``repository_url`` qualifier
390+
391+
Overrides the global ``sbom.repository_url`` setting for this package.
392+
"""
393+
394+
upstream_purl: str | None = None
395+
"""Full purl string identifying the upstream source package.
396+
397+
When set, this is used as the upstream identity in the SBOM's
398+
GENERATED_FROM relationship. Used for packages sourced from
399+
GitHub/GitLab rather than PyPI.
400+
401+
When absent, the upstream purl is auto-derived from the downstream
402+
purl without the ``repository_url`` qualifier.
360403
"""
361404

405+
@pydantic.model_validator(mode="after")
406+
def validate_purl_fields(self) -> typing.Self:
407+
"""Ensure ``purl`` is not combined with individual purl fields."""
408+
individual_fields = [
409+
self.purl_type,
410+
self.purl_namespace,
411+
self.purl_name,
412+
self.purl_version,
413+
]
414+
if self.purl is not None and any(f is not None for f in individual_fields):
415+
raise ValueError(
416+
"'purl' cannot be combined with 'purl_type', 'purl_namespace', "
417+
"'purl_name', or 'purl_version'; use either a full purl string "
418+
"or individual purl fields"
419+
)
420+
return self
421+
362422
resolver_dist: ResolverDist = Field(default_factory=ResolverDist)
363423
"""Resolve distribution version"""
364424

src/fromager/packagesettings/_pbi.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,39 @@ def variant(self) -> Variant:
7171

7272
@property
7373
def purl(self) -> str | None:
74-
"""Package URL (purl) override for SBOM generation."""
74+
"""Full purl string override for SBOM generation."""
7575
return self._ps.purl
7676

77+
@property
78+
def purl_type(self) -> str | None:
79+
"""Per-package purl type override."""
80+
return self._ps.purl_type
81+
82+
@property
83+
def purl_namespace(self) -> str | None:
84+
"""Per-package purl namespace override."""
85+
return self._ps.purl_namespace
86+
87+
@property
88+
def purl_name(self) -> str | None:
89+
"""Per-package purl name override."""
90+
return self._ps.purl_name
91+
92+
@property
93+
def purl_version(self) -> str | None:
94+
"""Per-package purl version override."""
95+
return self._ps.purl_version
96+
97+
@property
98+
def repository_url(self) -> str | None:
99+
"""Per-package override for the purl ``repository_url`` qualifier."""
100+
return self._ps.repository_url
101+
102+
@property
103+
def upstream_purl(self) -> str | None:
104+
"""Full purl string identifying the upstream source package."""
105+
return self._ps.upstream_purl
106+
77107
@property
78108
def annotations(self) -> Annotations:
79109
"""Get Package and variant annotations

src/fromager/sbom.py

Lines changed: 96 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -13,39 +13,74 @@
1313
import typing
1414
from datetime import UTC, datetime
1515

16+
from packageurl import PackageURL
1617
from packaging.requirements import Requirement
1718
from packaging.utils import canonicalize_name
1819
from packaging.version import Version
1920

2021
if typing.TYPE_CHECKING:
2122
from . import context
23+
from .packagesettings import PackageBuildInfo, SbomSettings
2224

2325
logger = logging.getLogger(__name__)
2426

2527
SBOM_FILENAME = "fromager.spdx.json"
2628

2729

28-
def _build_purl(
30+
def _build_downstream_purl(
2931
*,
30-
package_name: str,
31-
package_version: Version,
32-
purl_override: str | None,
32+
name: str,
33+
version: Version,
34+
pbi: PackageBuildInfo,
35+
sbom_settings: SbomSettings,
3336
) -> str:
34-
"""Build a package URL for the SBOM.
37+
"""Build the downstream package URL for the wheel.
3538
36-
Returns ``pkg:pypi/<name>@<version>`` by default. If a purl override
37-
is set in per-package settings, it is used instead with
38-
``str.format()`` substitution for ``{name}`` and ``{version}``.
39+
If a full ``purl`` override is set in per-package settings, it is
40+
used as-is. Otherwise, a purl is constructed from individual field
41+
overrides (per-package) falling back to global defaults.
3942
"""
40-
if purl_override:
41-
try:
42-
return purl_override.format(name=package_name, version=package_version)
43-
except (KeyError, ValueError) as err:
44-
raise ValueError(
45-
f"invalid purl template {purl_override!r}: "
46-
"only {name} and {version} are supported"
47-
) from err
48-
return f"pkg:pypi/{package_name}@{package_version}"
43+
if pbi.purl:
44+
return pbi.purl
45+
46+
purl_type = pbi.purl_type or sbom_settings.purl_type
47+
qualifiers: dict[str, str] = {}
48+
repo_url = pbi.repository_url or sbom_settings.repository_url
49+
if repo_url:
50+
qualifiers["repository_url"] = repo_url
51+
52+
return PackageURL(
53+
type=purl_type,
54+
namespace=pbi.purl_namespace,
55+
name=pbi.purl_name or name,
56+
version=pbi.purl_version or str(version),
57+
qualifiers=qualifiers or None,
58+
).to_string()
59+
60+
61+
def _build_upstream_purl(
62+
*,
63+
name: str,
64+
version: Version,
65+
pbi: PackageBuildInfo,
66+
sbom_settings: SbomSettings,
67+
) -> str:
68+
"""Build the upstream source package URL.
69+
70+
If ``upstream_purl`` is set in per-package settings, it is used
71+
as-is. Otherwise, the upstream purl is derived from the same base
72+
as the downstream purl but without the ``repository_url`` qualifier.
73+
"""
74+
if pbi.upstream_purl:
75+
return pbi.upstream_purl
76+
77+
purl_type = pbi.purl_type or sbom_settings.purl_type
78+
return PackageURL(
79+
type=purl_type,
80+
namespace=pbi.purl_namespace,
81+
name=pbi.purl_name or name,
82+
version=pbi.purl_version or str(version),
83+
).to_string()
4984

5085

5186
def generate_sbom(
@@ -56,8 +91,9 @@ def generate_sbom(
5691
) -> dict[str, typing.Any]:
5792
"""Generate a minimal SPDX 2.3 JSON document for a wheel.
5893
59-
The document contains the wheel as the primary package and a
60-
DESCRIBES relationship from the document to the package.
94+
The document contains the downstream wheel as the primary package,
95+
the upstream source as a second package, and DESCRIBES /
96+
GENERATED_FROM relationships.
6197
"""
6298
sbom_settings = ctx.settings.sbom_settings
6399
if sbom_settings is None:
@@ -73,26 +109,48 @@ def generate_sbom(
73109

74110
namespace = f"{sbom_settings.namespace}/{name}-{version}.spdx.json"
75111

76-
package_entry: dict[str, typing.Any] = {
112+
downstream_purl = _build_downstream_purl(
113+
name=name,
114+
version=version,
115+
pbi=pbi,
116+
sbom_settings=sbom_settings,
117+
)
118+
upstream_purl = _build_upstream_purl(
119+
name=name,
120+
version=version,
121+
pbi=pbi,
122+
sbom_settings=sbom_settings,
123+
)
124+
125+
wheel_entry: dict[str, typing.Any] = {
77126
"SPDXID": "SPDXRef-wheel",
78127
"name": name,
79128
"versionInfo": str(version),
80129
"downloadLocation": "NOASSERTION",
81130
"supplier": sbom_settings.supplier,
131+
"externalRefs": [
132+
{
133+
"referenceCategory": "PACKAGE-MANAGER",
134+
"referenceType": "purl",
135+
"referenceLocator": downstream_purl,
136+
}
137+
],
82138
}
83139

84-
purl = _build_purl(
85-
package_name=name,
86-
package_version=version,
87-
purl_override=pbi.purl,
88-
)
89-
package_entry["externalRefs"] = [
90-
{
91-
"referenceCategory": "PACKAGE-MANAGER",
92-
"referenceType": "purl",
93-
"referenceLocator": purl,
94-
}
95-
]
140+
upstream_entry: dict[str, typing.Any] = {
141+
"SPDXID": "SPDXRef-upstream",
142+
"name": name,
143+
"versionInfo": str(version),
144+
"downloadLocation": "NOASSERTION",
145+
"supplier": "NOASSERTION",
146+
"externalRefs": [
147+
{
148+
"referenceCategory": "PACKAGE-MANAGER",
149+
"referenceType": "purl",
150+
"referenceLocator": upstream_purl,
151+
}
152+
],
153+
}
96154

97155
doc: dict[str, typing.Any] = {
98156
"spdxVersion": "SPDX-2.3",
@@ -104,13 +162,18 @@ def generate_sbom(
104162
"created": timestamp,
105163
"creators": creators,
106164
},
107-
"packages": [package_entry],
165+
"packages": [wheel_entry, upstream_entry],
108166
"relationships": [
109167
{
110168
"spdxElementId": "SPDXRef-DOCUMENT",
111169
"relationshipType": "DESCRIBES",
112170
"relatedSpdxElement": "SPDXRef-wheel",
113171
},
172+
{
173+
"spdxElementId": "SPDXRef-wheel",
174+
"relationshipType": "GENERATED_FROM",
175+
"relatedSpdxElement": "SPDXRef-upstream",
176+
},
114177
],
115178
}
116179
return doc

tests/conftest.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ def testdata_context(
8686
def make_sbom_ctx(
8787
tmp_path: pathlib.Path,
8888
sbom_settings: SbomSettings | None = None,
89-
purl: str | None = None,
89+
package_overrides: dict[str, typing.Any] | None = None,
9090
) -> context.WorkContext:
9191
"""Create a minimal WorkContext with SBOM settings."""
9292
settings_file = packagesettings.SettingsFile(sbom=sbom_settings)
@@ -97,10 +97,10 @@ def make_sbom_ctx(
9797
variant="cpu",
9898
max_jobs=None,
9999
)
100-
if purl is not None:
100+
if package_overrides is not None:
101101
ps = packagesettings.PackageSettings.from_mapping(
102102
"test-pkg",
103-
{"purl": purl},
103+
package_overrides,
104104
source="test",
105105
has_config=True,
106106
)

tests/test_packagesettings.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@
7373
"name": "test-pkg",
7474
"has_config": True,
7575
"purl": None,
76+
"purl_type": None,
77+
"purl_namespace": None,
78+
"purl_name": None,
79+
"purl_version": None,
80+
"repository_url": None,
81+
"upstream_purl": None,
7682
"project_override": {
7783
"remove_build_requires": ["cmake"],
7884
"update_build_requires": ["setuptools>=68.0.0", "torch"],
@@ -134,6 +140,12 @@
134140
},
135141
"has_config": True,
136142
"purl": None,
143+
"purl_type": None,
144+
"purl_namespace": None,
145+
"purl_name": None,
146+
"purl_version": None,
147+
"repository_url": None,
148+
"upstream_purl": None,
137149
"project_override": {
138150
"remove_build_requires": [],
139151
"update_build_requires": [],
@@ -174,6 +186,12 @@
174186
},
175187
"has_config": True,
176188
"purl": None,
189+
"purl_type": None,
190+
"purl_namespace": None,
191+
"purl_name": None,
192+
"purl_version": None,
193+
"repository_url": None,
194+
"upstream_purl": None,
177195
"project_override": {
178196
"remove_build_requires": [],
179197
"update_build_requires": [],

0 commit comments

Comments
 (0)