Skip to content

Commit 8556bea

Browse files
authored
Merge pull request #1032 from mprpic/granular-purl-config
feat(sbom): build purls with packageurl-python
2 parents ae4b557 + b9bdce3 commit 8556bea

10 files changed

Lines changed: 320 additions & 77 deletions

File tree

docs/reference/config-reference.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ For example `flash_attn.yaml`.
2020

2121
.. autopydantic_model:: fromager.packagesettings.ProjectOverride
2222

23+
.. autopydantic_model:: fromager.packagesettings.PurlConfig
24+
2325
.. autopydantic_model:: fromager.packagesettings.SbomSettings
2426

2527
Global Settings

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/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
GitOptions,
88
PackageSettings,
99
ProjectOverride,
10+
PurlConfig,
1011
ResolverDist,
1112
SbomSettings,
1213
VariantInfo,
@@ -46,6 +47,7 @@
4647
"PackageVersion",
4748
"PatchMap",
4849
"ProjectOverride",
50+
"PurlConfig",
4951
"RawAnnotations",
5052
"ResolverDist",
5153
"SbomSettings",

src/fromager/packagesettings/_models.py

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import pydantic
1212
import yaml
13+
from packageurl import PackageURL
1314
from packaging.requirements import Requirement
1415
from packaging.utils import canonicalize_name
1516
from pydantic import Field
@@ -37,6 +38,8 @@ class SbomSettings(pydantic.BaseModel):
3738
sbom:
3839
supplier: "Organization: ExampleCo"
3940
namespace: "https://www.example.com"
41+
purl_type: pypi
42+
repository_url: "https://example.com/simple"
4043
creators:
4144
- "Organization: ExampleCo"
4245
"""
@@ -55,6 +58,78 @@ class SbomSettings(pydantic.BaseModel):
5558
The fromager tool creator entry is always added automatically.
5659
"""
5760

61+
purl_type: str = "pypi"
62+
"""Default purl type for all packages (e.g. ``pypi``, ``generic``)"""
63+
64+
repository_url: str | None = None
65+
"""Default purl ``repository_url`` qualifier for all packages
66+
67+
When set, this URL is added to every purl as a qualifier
68+
(e.g. ``pkg:pypi/flask@2.0?repository_url=https://example.com/simple``).
69+
Can be overridden per-package in the package settings file.
70+
"""
71+
72+
73+
class PurlConfig(pydantic.BaseModel):
74+
"""Per-package purl configuration for SBOM generation.
75+
76+
Allows overriding individual purl components or specifying an
77+
upstream purl for packages sourced from GitHub/GitLab.
78+
79+
.. versionadded:: 0.81.0
80+
81+
::
82+
83+
purl:
84+
type: generic
85+
name: custom-name
86+
repository_url: "https://example.com/simple"
87+
upstream: "pkg:github/org/repo@v1.0.0"
88+
"""
89+
90+
model_config = MODEL_CONFIG
91+
92+
type: str | None = None
93+
"""Override the purl type (e.g. ``generic`` instead of ``pypi``)"""
94+
95+
namespace: str | None = None
96+
"""Override the purl namespace component"""
97+
98+
name: str | None = None
99+
"""Override the purl name component (defaults to the package name)"""
100+
101+
version: str | None = None
102+
"""Override the purl version component (defaults to the resolved version)"""
103+
104+
repository_url: str | None = None
105+
"""Per-package override for the purl ``repository_url`` qualifier.
106+
107+
Overrides the global ``sbom.repository_url`` setting for this package.
108+
"""
109+
110+
upstream: str | None = None
111+
"""Full purl string identifying the upstream source package.
112+
113+
When set, this is used as the upstream identity in the SBOM's
114+
GENERATED_FROM relationship. Used for packages sourced from
115+
GitHub/GitLab rather than PyPI.
116+
117+
When absent, the upstream purl is auto-derived from the downstream
118+
purl without the ``repository_url`` qualifier.
119+
"""
120+
121+
@pydantic.field_validator("upstream")
122+
@classmethod
123+
def validate_upstream_purl(cls, v: str | None) -> str | None:
124+
"""Validate that upstream is a valid purl string."""
125+
if v is None:
126+
return v
127+
try:
128+
PackageURL.from_string(v)
129+
except ValueError as err:
130+
raise ValueError(f"invalid upstream purl {v!r}") from err
131+
return v
132+
58133

59134
class ResolverDist(pydantic.BaseModel):
60135
"""Packages resolver dist
@@ -351,12 +426,14 @@ class PackageSettings(pydantic.BaseModel):
351426
download_source: DownloadSource = Field(default_factory=DownloadSource)
352427
"""Alternative source download settings"""
353428

354-
purl: str | None = None
355-
"""Package URL (purl) override for SBOM generation
429+
purl: PurlConfig | None = None
430+
"""Purl configuration for SBOM generation.
431+
432+
A ``PurlConfig`` object with individual field overrides and upstream
433+
source identification.
356434
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.
435+
.. versionchanged:: 0.81.0
436+
The *purl* option now requires a valid PURL config object instead of a string.
360437
"""
361438

362439
resolver_dist: ResolverDist = Field(default_factory=ResolverDist)

src/fromager/packagesettings/_pbi.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
GitOptions,
1919
PackageSettings,
2020
ProjectOverride,
21+
PurlConfig,
2122
VariantInfo,
2223
)
2324
from ._templates import _resolve_template, substitute_template
@@ -70,8 +71,8 @@ def variant(self) -> Variant:
7071
return self._variant
7172

7273
@property
73-
def purl(self) -> str | None:
74-
"""Package URL (purl) override for SBOM generation."""
74+
def purl_config(self) -> PurlConfig | None:
75+
"""Per-package purl configuration for SBOM generation."""
7576
return self._ps.purl
7677

7778
@property

src/fromager/sbom.py

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

16+
from packageurl import PackageURL
1617
from packaging.requirements import Requirement
17-
from packaging.utils import canonicalize_name
18+
from packaging.utils import NormalizedName, 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,
33-
) -> str:
34-
"""Build a package URL for the SBOM.
35-
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}``.
32+
name: NormalizedName,
33+
version: Version,
34+
pbi: PackageBuildInfo,
35+
sbom_settings: SbomSettings,
36+
) -> PackageURL:
37+
"""Build the downstream package URL for the wheel.
38+
39+
A purl is constructed from ``PurlConfig`` field overrides
40+
(per-package) falling back to global defaults.
3941
"""
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}"
42+
pc = pbi.purl_config
43+
purl_type = (pc.type if pc else None) or sbom_settings.purl_type
44+
qualifiers: dict[str, str] = {}
45+
repo_url = (pc.repository_url if pc else None) or sbom_settings.repository_url
46+
if repo_url:
47+
qualifiers["repository_url"] = repo_url
48+
49+
return PackageURL(
50+
type=purl_type,
51+
namespace=pc.namespace if pc else None,
52+
name=(pc.name if pc else None) or name,
53+
version=(pc.version if pc else None) or str(version),
54+
qualifiers=qualifiers or None,
55+
)
56+
57+
58+
def _build_upstream_purl(
59+
*,
60+
name: NormalizedName,
61+
version: Version,
62+
pbi: PackageBuildInfo,
63+
sbom_settings: SbomSettings,
64+
) -> PackageURL:
65+
"""Build the upstream source package URL.
66+
67+
If ``upstream`` is set in the per-package ``PurlConfig``, it is
68+
used as-is. Otherwise, the upstream purl is derived from the same
69+
base as the downstream purl but without the ``repository_url``
70+
qualifier.
71+
"""
72+
pc = pbi.purl_config
73+
if pc and pc.upstream:
74+
return PackageURL.from_string(pc.upstream)
75+
76+
purl_type = pc.type if pc else None
77+
purl_namespace = pc.namespace if pc else None
78+
purl_name = pc.name if pc else None
79+
purl_version = pc.version if pc else None
80+
return PackageURL(
81+
type=purl_type or sbom_settings.purl_type,
82+
namespace=purl_namespace,
83+
name=purl_name or name,
84+
version=purl_version or str(version),
85+
)
4986

5087

5188
def generate_sbom(
@@ -56,8 +93,9 @@ def generate_sbom(
5693
) -> dict[str, typing.Any]:
5794
"""Generate a minimal SPDX 2.3 JSON document for a wheel.
5895
59-
The document contains the wheel as the primary package and a
60-
DESCRIBES relationship from the document to the package.
96+
The document contains the downstream wheel as the primary package,
97+
the upstream source as a second package, and DESCRIBES /
98+
GENERATED_FROM relationships.
6199
"""
62100
sbom_settings = ctx.settings.sbom_settings
63101
if sbom_settings is None:
@@ -73,26 +111,48 @@ def generate_sbom(
73111

74112
namespace = f"{sbom_settings.namespace}/{name}-{version}.spdx.json"
75113

76-
package_entry: dict[str, typing.Any] = {
114+
downstream = _build_downstream_purl(
115+
name=name,
116+
version=version,
117+
pbi=pbi,
118+
sbom_settings=sbom_settings,
119+
)
120+
upstream = _build_upstream_purl(
121+
name=name,
122+
version=version,
123+
pbi=pbi,
124+
sbom_settings=sbom_settings,
125+
)
126+
127+
wheel_entry: dict[str, typing.Any] = {
77128
"SPDXID": "SPDXRef-wheel",
78-
"name": name,
79-
"versionInfo": str(version),
129+
"name": downstream.name,
130+
"versionInfo": downstream.version or str(version),
80131
"downloadLocation": "NOASSERTION",
81132
"supplier": sbom_settings.supplier,
133+
"externalRefs": [
134+
{
135+
"referenceCategory": "PACKAGE-MANAGER",
136+
"referenceType": "purl",
137+
"referenceLocator": downstream.to_string(),
138+
}
139+
],
82140
}
83141

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-
]
142+
upstream_entry: dict[str, typing.Any] = {
143+
"SPDXID": "SPDXRef-upstream",
144+
"name": upstream.name,
145+
"versionInfo": upstream.version or str(version),
146+
"downloadLocation": "NOASSERTION",
147+
"supplier": "NOASSERTION",
148+
"externalRefs": [
149+
{
150+
"referenceCategory": "PACKAGE-MANAGER",
151+
"referenceType": "purl",
152+
"referenceLocator": upstream.to_string(),
153+
}
154+
],
155+
}
96156

97157
doc: dict[str, typing.Any] = {
98158
"spdxVersion": "SPDX-2.3",
@@ -104,13 +164,18 @@ def generate_sbom(
104164
"created": timestamp,
105165
"creators": creators,
106166
},
107-
"packages": [package_entry],
167+
"packages": [wheel_entry, upstream_entry],
108168
"relationships": [
109169
{
110170
"spdxElementId": "SPDXRef-DOCUMENT",
111171
"relationshipType": "DESCRIBES",
112172
"relatedSpdxElement": "SPDXRef-wheel",
113173
},
174+
{
175+
"spdxElementId": "SPDXRef-wheel",
176+
"relationshipType": "GENERATED_FROM",
177+
"relatedSpdxElement": "SPDXRef-upstream",
178+
},
114179
],
115180
}
116181
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
)

0 commit comments

Comments
 (0)