1313import typing
1414from datetime import UTC , datetime
1515
16+ from packageurl import PackageURL
1617from packaging .requirements import Requirement
17- from packaging .utils import canonicalize_name
18+ from packaging .utils import NormalizedName , canonicalize_name
1819from packaging .version import Version
1920
2021if typing .TYPE_CHECKING :
2122 from . import context
23+ from .packagesettings import PackageBuildInfo , SbomSettings
2224
2325logger = logging .getLogger (__name__ )
2426
2527SBOM_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
5188def 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
0 commit comments