python-sbom 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. python_sbom/__init__.py +46 -0
  2. python_sbom/__main__.py +10 -0
  3. python_sbom/_cli/__init__.py +1 -0
  4. python_sbom/_cli/_archive.py +38 -0
  5. python_sbom/_cli/_embed.py +28 -0
  6. python_sbom/_cli/_main.py +28 -0
  7. python_sbom/_cli/_parser.py +144 -0
  8. python_sbom/_cli/_spdx.py +61 -0
  9. python_sbom/_cli/_validate.py +40 -0
  10. python_sbom/_collect/__init__.py +1 -0
  11. python_sbom/_collect/_archive.py +94 -0
  12. python_sbom/_collect/_content.py +109 -0
  13. python_sbom/_collect/_facts.py +155 -0
  14. python_sbom/_collect/_revision.py +93 -0
  15. python_sbom/_collect/_settings.py +82 -0
  16. python_sbom/_collect/_source.py +54 -0
  17. python_sbom/_collect/_wheel.py +55 -0
  18. python_sbom/_errors.py +15 -0
  19. python_sbom/_formats/__init__.py +1 -0
  20. python_sbom/_formats/_json.py +18 -0
  21. python_sbom/_formats/_spdx.py +263 -0
  22. python_sbom/_generate/__init__.py +1 -0
  23. python_sbom/_generate/_archive.py +84 -0
  24. python_sbom/_generate/_embed.py +74 -0
  25. python_sbom/_model/__init__.py +1 -0
  26. python_sbom/_model/_archive.py +117 -0
  27. python_sbom/_model/_bom.py +137 -0
  28. python_sbom/_model/_components.py +182 -0
  29. python_sbom/_model/_content.py +128 -0
  30. python_sbom/_model/_document.py +100 -0
  31. python_sbom/_model/_graph.py +64 -0
  32. python_sbom/_model/_identity.py +53 -0
  33. python_sbom/_model/_inputs.py +141 -0
  34. python_sbom/_model/_iri.py +96 -0
  35. python_sbom/_model/_licenses.py +58 -0
  36. python_sbom/_model/_markers.py +61 -0
  37. python_sbom/_model/_metadata.py +100 -0
  38. python_sbom/_model/_names.py +90 -0
  39. python_sbom/_model/_tools.py +48 -0
  40. python_sbom/_model/_types.py +281 -0
  41. python_sbom/_model/_wheel.py +89 -0
  42. python_sbom/_validate/__init__.py +1 -0
  43. python_sbom/_validate/_artifact.py +55 -0
  44. python_sbom/_validate/_document.py +221 -0
  45. python_sbom/_validate/_licenses.py +104 -0
  46. python_sbom/_validate/_limits.py +27 -0
  47. python_sbom/_validate/_schemas.py +124 -0
  48. python_sbom/_validate/schemas/LICENSE +201 -0
  49. python_sbom/_validate/schemas/bom-1.6.schema.json +5699 -0
  50. python_sbom/_validate/schemas/jsf-0.82.schema.json +240 -0
  51. python_sbom/_validate/schemas/spdx.schema.json +1662 -0
  52. python_sbom/_wheel/__init__.py +1 -0
  53. python_sbom/_wheel/_bundled.py +142 -0
  54. python_sbom/_wheel/_reader.py +59 -0
  55. python_sbom/_wheel/_record.py +55 -0
  56. python_sbom/_wheel/_write.py +103 -0
  57. python_sbom/_wheel/_zip.py +94 -0
  58. python_sbom/hatch/__init__.py +9 -0
  59. python_sbom/hatch/_hook.py +150 -0
  60. python_sbom/hatch/hooks.py +18 -0
  61. python_sbom/py.typed +0 -0
  62. python_sbom/version.py +24 -0
  63. python_sbom-0.1.1.dist-info/METADATA +95 -0
  64. python_sbom-0.1.1.dist-info/RECORD +69 -0
  65. python_sbom-0.1.1.dist-info/WHEEL +4 -0
  66. python_sbom-0.1.1.dist-info/entry_points.txt +5 -0
  67. python_sbom-0.1.1.dist-info/licenses/LICENSE +18 -0
  68. python_sbom-0.1.1.dist-info/licenses/src/python_sbom/_validate/schemas/LICENSE +201 -0
  69. python_sbom-0.1.1.dist-info/sboms/python-sbom.cdx.json +13548 -0
@@ -0,0 +1,263 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import re
6
+ from datetime import UTC, datetime
7
+ from typing import TYPE_CHECKING, Final, NotRequired, TypedDict
8
+
9
+ from python_sbom._model._graph import walk_components
10
+ from python_sbom._model._names import SBOM_FILE, SBOM_REPOSITORY, property_prefix, sbom_property
11
+ from python_sbom.version import __version__
12
+
13
+ if TYPE_CHECKING:
14
+ from python_sbom._model._types import (
15
+ Component,
16
+ Document,
17
+ ExternalReference,
18
+ License,
19
+ Metadata,
20
+ OrganizationalEntity,
21
+ Tools,
22
+ )
23
+
24
+ # SPDX 2.3 JSON names every CycloneDX 1.6 hash algorithm, but by names of its own
25
+ _HASH_ALGORITHMS: Final[dict[str, str]] = {
26
+ "MD5": "MD5",
27
+ "SHA-1": "SHA1",
28
+ "SHA-256": "SHA256",
29
+ "SHA-384": "SHA384",
30
+ "SHA-512": "SHA512",
31
+ "SHA3-256": "SHA3-256",
32
+ "SHA3-384": "SHA3-384",
33
+ "SHA3-512": "SHA3-512",
34
+ "BLAKE2b-256": "BLAKE2b-256",
35
+ "BLAKE2b-384": "BLAKE2b-384",
36
+ "BLAKE2b-512": "BLAKE2b-512",
37
+ "BLAKE3": "BLAKE3",
38
+ }
39
+ _LICENSE_REF: Final[re.Pattern[str]] = re.compile(r"LicenseRef-[A-Za-z0-9.-]+")
40
+
41
+
42
+ class _LegacyTool(TypedDict):
43
+ vendor: NotRequired[str]
44
+ name: NotRequired[str]
45
+ version: NotRequired[str]
46
+
47
+
48
+ class _Checksum(TypedDict):
49
+ algorithm: str
50
+ checksumValue: str
51
+
52
+
53
+ class _ExternalRef(TypedDict):
54
+ referenceCategory: str
55
+ referenceType: str
56
+ referenceLocator: str
57
+
58
+
59
+ class _Package(TypedDict):
60
+ SPDXID: str
61
+ name: str
62
+ downloadLocation: str
63
+ filesAnalyzed: bool
64
+ licenseConcluded: str
65
+ licenseDeclared: str
66
+ primaryPackagePurpose: str
67
+ versionInfo: NotRequired[str]
68
+ supplier: NotRequired[str]
69
+ checksums: NotRequired[list[_Checksum]]
70
+ homepage: NotRequired[str]
71
+ copyrightText: NotRequired[str]
72
+ summary: NotRequired[str]
73
+ comment: NotRequired[str]
74
+ externalRefs: NotRequired[list[_ExternalRef]]
75
+ licenseComments: NotRequired[str]
76
+
77
+
78
+ class _Relationship(TypedDict):
79
+ spdxElementId: str
80
+ relationshipType: str
81
+ relatedSpdxElement: str
82
+
83
+
84
+ class _CreationInfo(TypedDict):
85
+ created: str
86
+ creators: list[str]
87
+
88
+
89
+ class _ExtractedLicense(TypedDict):
90
+ licenseId: str
91
+ extractedText: str
92
+ name: str
93
+
94
+
95
+ class Spdx(TypedDict):
96
+ spdxVersion: str
97
+ dataLicense: str
98
+ SPDXID: str
99
+ name: str
100
+ documentNamespace: str
101
+ comment: str
102
+ creationInfo: _CreationInfo
103
+ packages: list[_Package]
104
+ relationships: list[_Relationship]
105
+ hasExtractedLicensingInfos: NotRequired[list[_ExtractedLicense]]
106
+
107
+
108
+ def render_spdx(document: Document) -> Spdx:
109
+ metadata: Final[Metadata] = document["metadata"]
110
+ root: Final[Component] = metadata["component"]
111
+ project: Final[str] = property_prefix(root["name"])
112
+ recorded: Final[dict[str, str]] = {prop["name"]: prop.get("value", "") for prop in metadata.get("properties", [])}
113
+ name: Final[str] = f"{root['name']}-{root['version']}" if "version" in root else root["name"]
114
+ # without a serial number the content names the document, so rendering it twice gives the same namespace
115
+ source: Final[str] = (
116
+ document["serialNumber"]
117
+ if "serialNumber" in document
118
+ else f"sha256:{hashlib.sha256(json.dumps(document, sort_keys=True).encode()).hexdigest()}"
119
+ )
120
+ repository: Final[str] = recorded.get(sbom_property(project, SBOM_REPOSITORY), "https://spdx.org/spdxdocs")
121
+ sbom_file: Final[str] = recorded.get(sbom_property(project, SBOM_FILE), "CycloneDX JSON")
122
+ tools, tool_creators = _tools(metadata)
123
+ # file components mirror wheel RECORD entries, which carry no SHA-1 and so cannot become SPDX files
124
+ components: Final[list[Component]] = [
125
+ component
126
+ for component in walk_components([root, *document.get("components", []), *tools])
127
+ if component["type"] != "file"
128
+ ]
129
+ packages: Final[list[_Package]] = [_package(component, project) for component in components]
130
+ spdx: Final[Spdx] = {
131
+ "spdxVersion": "SPDX-2.3",
132
+ "dataLicense": "CC0-1.0",
133
+ "SPDXID": "SPDXRef-DOCUMENT",
134
+ "name": name,
135
+ "documentNamespace": f"{repository}/sboms/{name}-{source.removeprefix('urn:uuid:')}",
136
+ "comment": f"Rendered from the CycloneDX SBOM {source} ({sbom_file}), which also carries per-file hashes and"
137
+ " the build formulation.",
138
+ "creationInfo": _creation_info(metadata, tool_creators),
139
+ "packages": packages,
140
+ "relationships": _relationships(document, components, tools),
141
+ }
142
+ # SPDX requires the text of every LicenseRef a package declares; the metadata names them, it holds no text
143
+ references: Final[set[str]] = set()
144
+ for package in packages:
145
+ references.update(_LICENSE_REF.findall(package["licenseDeclared"]))
146
+ if references:
147
+ spdx["hasExtractedLicensingInfos"] = [
148
+ {"licenseId": token, "extractedText": f"{token}, as named in package metadata", "name": token}
149
+ for token in sorted(references)
150
+ ]
151
+ return spdx
152
+
153
+
154
+ def _tools(metadata: Metadata) -> tuple[list[Component], list[str]]:
155
+ # CycloneDX 1.5 lists the tools as components; earlier versions as a bare array, whose entries carry no bom-ref, so
156
+ # they become creators alone
157
+ tools: Final[Tools | list[_LegacyTool] | None] = metadata.get("tools")
158
+ if isinstance(tools, list):
159
+ return [], [_creator(name, tool.get("version")) for tool in tools if (name := tool.get("name"))]
160
+ components: Final[list[Component]] = [] if tools is None else tools.get("components", [])
161
+ return components, [
162
+ _creator(tool["name"], tool.get("version")) for tool in components if tool["type"] == "application"
163
+ ]
164
+
165
+
166
+ def _creator(name: str, version: str | None) -> str:
167
+ return f"Tool: {name}" if version is None else f"Tool: {name}-{version}"
168
+
169
+
170
+ def _package(component: Component, project: str) -> _Package:
171
+ # a component carries either one SPDX expression, or the free-form names from License/classifier metadata plus the
172
+ # SPDX ids concluded from them
173
+ licenses: Final[list[License]] = component.get("licenses", [])
174
+ concluded: Final[list[str]] = [
175
+ entry["license"]["id"] for entry in licenses if "license" in entry and "id" in entry["license"]
176
+ ]
177
+ package: Final[_Package] = {
178
+ "SPDXID": _spdx_id(component["bom-ref"]),
179
+ "name": component["name"],
180
+ "downloadLocation": "NOASSERTION",
181
+ "filesAnalyzed": False,
182
+ "licenseConcluded": " AND ".join(concluded) or "NOASSERTION",
183
+ "licenseDeclared": next((entry["expression"] for entry in licenses if "expression" in entry), "NOASSERTION"),
184
+ "primaryPackagePurpose": {"application": "APPLICATION", "library": "LIBRARY"}.get(component["type"], "OTHER"),
185
+ }
186
+ if version := component.get("version"):
187
+ package["versionInfo"] = version
188
+ if (supplier := component.get("supplier")) and (organization := supplier.get("name")):
189
+ package["supplier"] = f"Organization: {organization}"
190
+ checksums: Final[list[_Checksum]] = [
191
+ {"algorithm": _HASH_ALGORITHMS[entry["alg"]], "checksumValue": entry["content"]}
192
+ for entry in component.get("hashes", [])
193
+ if entry["alg"] in _HASH_ALGORITHMS
194
+ ]
195
+ if checksums:
196
+ package["checksums"] = checksums
197
+ references: Final[list[ExternalReference]] = component.get("externalReferences", [])
198
+ if homepage := next((reference["url"] for reference in references if reference["type"] == "website"), None):
199
+ package["homepage"] = homepage
200
+ if copyright_text := component.get("copyright"):
201
+ package["copyrightText"] = copyright_text
202
+ if description := component.get("description"):
203
+ package["summary"] = description
204
+ if comment := "\n".join(
205
+ f"{prop['name']}: {prop.get('value', '')}"
206
+ for prop in component.get("properties", [])
207
+ if prop["name"].startswith(f"{project}:")
208
+ ):
209
+ package["comment"] = comment
210
+ if purl := component.get("purl"):
211
+ package["externalRefs"] = [
212
+ {"referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", "referenceLocator": purl}
213
+ ]
214
+ if package["licenseDeclared"] == "NOASSERTION" and (
215
+ names := [entry["license"]["name"] for entry in licenses if "license" in entry and "name" in entry["license"]]
216
+ ):
217
+ package["licenseComments"] = f"Declared in package metadata as: {'; '.join(names)}"
218
+ return package
219
+
220
+
221
+ def _spdx_id(bom_ref: str) -> str:
222
+ # the readable part drops every other character, so refs that differ only in those share it; only the full digest,
223
+ # not a prefix of it, keeps their ids apart
224
+ readable: Final[str] = re.sub(r"[^A-Za-z0-9.]+", "-", bom_ref).strip("-")
225
+ return f"SPDXRef-{readable}-{hashlib.sha256(bom_ref.encode()).hexdigest()}"
226
+
227
+
228
+ def _creation_info(metadata: Metadata, tool_creators: list[str]) -> _CreationInfo:
229
+ timestamp: Final[str | None] = metadata.get("timestamp")
230
+ # SPDX requires a creation time; without the CycloneDX one, the time of this rendering is the true one
231
+ created: Final[datetime] = datetime.now(UTC) if timestamp is None else datetime.fromisoformat(timestamp)
232
+ supplier: Final[OrganizationalEntity | None] = metadata.get("supplier")
233
+ organization: Final[str] = "" if supplier is None else supplier.get("name", "")
234
+ creators: Final[list[str]] = [f"Organization: {organization}"] if organization else []
235
+ creators.extend(tool_creators)
236
+ creators.append(f"Tool: python-sbom-{__version__}")
237
+ return {"created": created.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), "creators": creators}
238
+
239
+
240
+ def _relationships(document: Document, components: list[Component], tools: list[Component]) -> list[_Relationship]:
241
+ root_id: Final[str] = _spdx_id(document["metadata"]["component"]["bom-ref"])
242
+ relationships: Final[list[_Relationship]] = [_relationship("SPDXRef-DOCUMENT", "DESCRIBES", root_id)]
243
+ for parent in components:
244
+ parent_id = _spdx_id(parent["bom-ref"])
245
+ children = (child for child in parent.get("components", []) if child["type"] != "file")
246
+ relationships.extend(_relationship(parent_id, "CONTAINS", _spdx_id(child["bom-ref"])) for child in children)
247
+ for entry in document.get("dependencies", []):
248
+ dependent_id = _spdx_id(entry["ref"])
249
+ relationships.extend(
250
+ _relationship(dependent_id, "DEPENDS_ON", _spdx_id(dependency)) for dependency in entry.get("dependsOn", [])
251
+ )
252
+ relationships.extend(_relationship(_spdx_id(tool["bom-ref"]), "BUILD_TOOL_OF", root_id) for tool in tools)
253
+ return relationships
254
+
255
+
256
+ def _relationship(element: str, kind: str, related: str) -> _Relationship:
257
+ return {"spdxElementId": element, "relationshipType": kind, "relatedSpdxElement": related}
258
+
259
+
260
+ __all__ = [
261
+ "Spdx",
262
+ "render_spdx",
263
+ ]
@@ -0,0 +1 @@
1
+ """The pipelines that build a wheel's and an archive's SBOM from what _collect gathers, and write it."""
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from python_sbom._collect._archive import read_archive
6
+ from python_sbom._collect._content import resolve_provider
7
+ from python_sbom._collect._facts import collect_facts
8
+ from python_sbom._collect._revision import revision_reader
9
+ from python_sbom._collect._settings import settings_from_pyproject
10
+ from python_sbom._collect._source import copyright_notice, hash_build_scripts
11
+ from python_sbom._errors import SbomError
12
+ from python_sbom._formats._json import dumps
13
+ from python_sbom._model._archive import archive_artifact, bundled_paths
14
+ from python_sbom._model._bom import assemble
15
+ from python_sbom._model._content import ProviderContext
16
+ from python_sbom._model._document import checked_contributions
17
+ from python_sbom._model._identity import project_identity
18
+ from python_sbom._model._names import archive_sbom_file_name
19
+ from python_sbom._wheel._reader import open_artifact
20
+ from python_sbom._wheel._write import append_member
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Sequence
24
+ from pathlib import Path, PurePosixPath
25
+
26
+ from python_sbom._model._content import ContentProvider
27
+ from python_sbom._model._inputs import BuildFacts
28
+ from python_sbom._model._types import Document
29
+
30
+
31
+ def archive_sbom( # ruff: ignore[too-many-arguments] # every command-line option reaches the build
32
+ root: Path,
33
+ archive: Path,
34
+ *,
35
+ provider: ContentProvider | str | None,
36
+ commit: str | None,
37
+ build_env: Sequence[str] | None,
38
+ build_scripts: Sequence[PurePosixPath],
39
+ output: Path | None,
40
+ embed: bool,
41
+ ) -> tuple[Path, str | None]:
42
+ name = archive_sbom_file_name(archive.name)
43
+ if (target := output or archive.with_name(name)).exists() and target.samefile(archive):
44
+ msg = f"{target} is the archive {archive} itself, refusing to overwrite it with the SBOM"
45
+ raise SbomError(msg)
46
+ facts = collect_facts(root, revision_reader(commit), path=build_env)
47
+ document = describe_archive(root, archive, facts=facts, provider=provider, build_scripts=build_scripts)
48
+ target.write_bytes(content := dumps(document))
49
+ if not embed:
50
+ return target, None
51
+ append_member(archive, name, content, facts.epoch)
52
+ return target, name
53
+
54
+
55
+ def describe_archive(
56
+ root: Path,
57
+ archive: Path,
58
+ *,
59
+ facts: BuildFacts,
60
+ provider: ContentProvider | str | None,
61
+ build_scripts: Sequence[PurePosixPath],
62
+ ) -> Document:
63
+ settings = settings_from_pyproject(root)
64
+ content, provider_scripts = resolve_provider(provider, settings, root)
65
+ with open_artifact(archive) as artifact:
66
+ tree = read_archive(artifact)
67
+ project = project_identity(tree.metadata[""], settings)
68
+ context = ProviderContext(root=root, settings=settings, identity=project, read=artifact.provider_read)
69
+ built = archive_artifact(
70
+ archive.name,
71
+ tree,
72
+ project=project,
73
+ bundled_info={str(path): content.bundled_wheel(context, path) for path in bundled_paths(tree)},
74
+ build_scripts=hash_build_scripts(root, [*build_scripts, *provider_scripts]),
75
+ copyright_line=copyright_notice(root, tree.metadata[""].values("license_files")),
76
+ )
77
+ contributions = checked_contributions(content.contributions(context), content)
78
+ return assemble(built, project, settings, facts, contributions)
79
+
80
+
81
+ __all__ = [
82
+ "archive_sbom",
83
+ "describe_archive",
84
+ ]
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import PurePosixPath
4
+ from typing import TYPE_CHECKING
5
+
6
+ from python_sbom._collect._content import resolve_provider
7
+ from python_sbom._collect._facts import collect_facts
8
+ from python_sbom._collect._revision import revision_reader
9
+ from python_sbom._collect._settings import settings_from_pyproject
10
+ from python_sbom._collect._wheel import read_wheel
11
+ from python_sbom._formats._json import dumps
12
+ from python_sbom._model._bom import assemble
13
+ from python_sbom._model._content import ProviderContext
14
+ from python_sbom._model._document import checked_contributions
15
+ from python_sbom._model._identity import project_identity
16
+ from python_sbom._model._wheel import wheel_artifact
17
+ from python_sbom._wheel._reader import open_artifact
18
+ from python_sbom._wheel._write import write_sbom
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Sequence
22
+ from pathlib import Path
23
+
24
+ from python_sbom._model._content import ContentProvider
25
+ from python_sbom._model._inputs import BuildFacts, Settings
26
+
27
+
28
+ def embed_sbom(
29
+ wheel: Path,
30
+ root: Path,
31
+ *,
32
+ provider: ContentProvider | str | None,
33
+ commit: str | None,
34
+ build_env: Sequence[str] | None,
35
+ ) -> str:
36
+ settings = settings_from_pyproject(root)
37
+ content, scripts = resolve_provider(provider, settings, root)
38
+ facts = collect_facts(root, revision_reader(commit), path=build_env)
39
+ return embed_into(wheel, root, settings=settings, content=content, build_scripts=scripts, facts=facts)
40
+
41
+
42
+ def embed_into( # ruff: ignore[too-many-arguments] # what the hook and the command line each gather their own way
43
+ wheel: Path,
44
+ root: Path,
45
+ *,
46
+ settings: Settings,
47
+ content: ContentProvider,
48
+ build_scripts: Sequence[PurePosixPath],
49
+ facts: BuildFacts,
50
+ ) -> str:
51
+ # the wheel stays open while its document is built, for its metadata, its bundled wheels and the provider to read,
52
+ # and is closed before it is rewritten
53
+ with open_artifact(wheel) as artifact:
54
+ tree = read_wheel(artifact, root, settings, build_scripts)
55
+ project = project_identity(tree.metadata, settings)
56
+ context = ProviderContext(root=root, settings=settings, identity=project, read=artifact.provider_read)
57
+ built = wheel_artifact(
58
+ tree,
59
+ project=project,
60
+ settings=settings,
61
+ bundled_info={
62
+ bundled.path: content.bundled_wheel(context, PurePosixPath(bundled.path)) for bundled in tree.bundled
63
+ },
64
+ )
65
+ contributions = checked_contributions(content.contributions(context), content)
66
+ return write_sbom(
67
+ wheel, built.sbom_file, dumps(assemble(built, project, settings, facts, contributions)), facts.epoch
68
+ )
69
+
70
+
71
+ __all__ = [
72
+ "embed_into",
73
+ "embed_sbom",
74
+ ]
@@ -0,0 +1 @@
1
+ """The CycloneDX document model: its types, its inputs and the pure functions that build it."""
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import PurePosixPath
5
+ from typing import TYPE_CHECKING, Final
6
+
7
+ from packageurl import PackageURL
8
+
9
+ from ._components import (
10
+ bundled_wheel_component,
11
+ component_from_metadata,
12
+ file_component,
13
+ mark_bundled,
14
+ vendored_dependencies,
15
+ )
16
+ from ._graph import artifact_contents
17
+ from ._inputs import Artifact
18
+ from ._names import archive_sbom_file_name
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Mapping
22
+
23
+ from ._content import BundledWheelInfo
24
+ from ._identity import ProjectIdentity
25
+ from ._inputs import ArchiveTree, BuildScript
26
+ from ._types import Component
27
+
28
+
29
+ def archive_artifact( # ruff: ignore[too-many-arguments] # keyword-only, each a separate fact the edge read
30
+ name: str,
31
+ tree: ArchiveTree,
32
+ *,
33
+ project: ProjectIdentity,
34
+ bundled_info: Mapping[str, BundledWheelInfo],
35
+ build_scripts: list[BuildScript],
36
+ copyright_line: str | None,
37
+ ) -> Artifact:
38
+ # the archive's top-level .dist-info names the project; every other .dist-info marks a wheel bundled unpacked into
39
+ # its parent directory and every .whl member a bundled wheel; other files belong to the deepest distribution that
40
+ # holds them, or, at the top level, to the archive
41
+ version: Final[str] = tree.metadata[""].version
42
+ ref: Final[str] = PackageURL(type="generic", name=name, version=version).to_string()
43
+ distributions = _Distributions(tree=tree, project=project, bundled_info=bundled_info)
44
+ root: Final[Component] = {
45
+ "type": "application",
46
+ "bom-ref": ref,
47
+ "name": name,
48
+ "version": version,
49
+ "purl": ref,
50
+ "components": [
51
+ distributions.wheel(member) if member.endswith(".whl") else file_component(ref, tree.files[member])
52
+ for member in tree.loose
53
+ ],
54
+ }
55
+ components: Final[list[Component]] = [distributions.component(prefix) for prefix in sorted(tree.metadata)]
56
+ return Artifact(
57
+ root=root,
58
+ components=components,
59
+ dependencies=vendored_dependencies(artifact_contents(root, components)),
60
+ build_scripts=build_scripts,
61
+ version=version,
62
+ sbom_file=archive_sbom_file_name(name),
63
+ scope=(
64
+ "Components are the distributions the archive holds, as wheels or unpacked, each with its files; the"
65
+ " archive's own top-level files sit under the root component."
66
+ ),
67
+ workflow=("archive-build", f"build {name} and this SBOM"),
68
+ license_file=next(iter(tree.metadata[""].values("license_files")), None),
69
+ copyright=copyright_line,
70
+ )
71
+
72
+
73
+ @dataclass(frozen=True, slots=True, kw_only=True)
74
+ class _Distributions:
75
+ tree: ArchiveTree
76
+ project: ProjectIdentity
77
+ bundled_info: Mapping[str, BundledWheelInfo]
78
+
79
+ def component(self, prefix: str) -> Component:
80
+ component = component_from_metadata(self.tree.metadata[prefix])
81
+ if prefix:
82
+ # a wheel bundled unpacked, named by its directory as a .whl is by its path
83
+ directory = prefix.removesuffix("/")
84
+ mark_bundled(component, directory, self.project.property_prefix, self.bundled_info[directory])
85
+ component["components"] = [
86
+ self.wheel(name) if name.endswith(".whl") else file_component(component["bom-ref"], self.tree.files[name])
87
+ for name in self.tree.owned[prefix]
88
+ ]
89
+ return component
90
+
91
+ def wheel(self, name: str) -> Component:
92
+ return bundled_wheel_component(self.tree.bundled[name], self.project.property_prefix, self.bundled_info[name])
93
+
94
+
95
+ def bundled_paths(tree: ArchiveTree) -> list[PurePosixPath]:
96
+ return [PurePosixPath(member.removesuffix("/")) for member in bundled_members(tree.loose, tree.owned)]
97
+
98
+
99
+ def bundled_members(loose: list[str], owned: Mapping[str, list[str]]) -> list[str]:
100
+ # the bundled wheels in the order the components list them, which is the order they are read and a provider is asked
101
+ # about them: the archive's own top-level .whl members, then each wheel bundled unpacked, by its directory ending in
102
+ # "/", followed by the .whl members it holds
103
+ return [
104
+ *(name for name in loose if name.endswith(".whl")),
105
+ *(
106
+ member
107
+ for prefix in sorted(owned)
108
+ for member in ([prefix] if prefix else []) + [name for name in owned[prefix] if name.endswith(".whl")]
109
+ ),
110
+ ]
111
+
112
+
113
+ __all__ = [
114
+ "archive_artifact",
115
+ "bundled_members",
116
+ "bundled_paths",
117
+ ]
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import PurePosixPath
4
+ from typing import TYPE_CHECKING, Final
5
+ from urllib.parse import urlsplit
6
+
7
+ from ._components import script_tool
8
+ from ._document import check_refs, contributed_properties, formulation, timestamp, with_header
9
+ from ._names import SBOM_FILE, SBOM_REPOSITORY, sbom_property
10
+ from ._tools import tool_components
11
+
12
+ if TYPE_CHECKING:
13
+ from ._content import Contributions
14
+ from ._identity import ProjectIdentity
15
+ from ._inputs import Artifact, BuildFacts, Settings
16
+ from ._types import Body, Component, Document, ExternalReference, Metadata, Property
17
+
18
+
19
+ def assemble(
20
+ artifact: Artifact, project: ProjectIdentity, settings: Settings, facts: BuildFacts, contributions: Contributions
21
+ ) -> Document:
22
+ # what every artifact of the project carries alike: who supplies it, where its source lives and at which revision,
23
+ # the tools that built it and what the project contributes
24
+ root = _provenance(artifact, project, settings, facts, contributions)
25
+ components: Final[list[Component]] = [*artifact.components, *contributions.components]
26
+ environment, tool_dependencies = tool_components(facts)
27
+ tools: Final[list[Component]] = [
28
+ *(
29
+ script_tool(script.path, script.sha256, artifact.version, _file_link(project, settings, facts, script.path))
30
+ for script in artifact.build_scripts
31
+ ),
32
+ *environment,
33
+ *contributions.tools,
34
+ ]
35
+ uid, name = artifact.workflow
36
+ body: Final[Body] = {
37
+ "metadata": _metadata(tools, artifact, project, settings, facts),
38
+ "components": components,
39
+ "dependencies": [
40
+ {"ref": root["bom-ref"], "dependsOn": [component["bom-ref"] for component in components]},
41
+ *artifact.dependencies,
42
+ *tool_dependencies,
43
+ *contributions.dependencies,
44
+ ],
45
+ # what the artifact needs at run time beyond what it ships, an interpreter and the dependencies an installer
46
+ # resolves, is not listed
47
+ "compositions": [{"aggregate": "incomplete", "dependencies": [root["bom-ref"]]}],
48
+ "formulation": [formulation(uid, name, tools, root["bom-ref"], facts)],
49
+ }
50
+ check_refs(body)
51
+ return with_header(body, project.serial_namespace)
52
+
53
+
54
+ def _provenance(
55
+ artifact: Artifact, project: ProjectIdentity, settings: Settings, facts: BuildFacts, contributions: Contributions
56
+ ) -> Component:
57
+ root = artifact.root
58
+ if settings.supplier is not None:
59
+ root["supplier"] = settings.supplier
60
+ if artifact.copyright is not None:
61
+ root["copyright"] = artifact.copyright
62
+ references: Final[list[ExternalReference]] = [*root.get("externalReferences", [])]
63
+ if project.repository and urlsplit(project.repository).netloc == "github.com":
64
+ references.extend([
65
+ {"type": "security-contact", "url": f"{project.repository}/security/policy"},
66
+ {"type": "advisories", "url": f"{project.repository}/security/advisories"},
67
+ ])
68
+ if artifact.license_file is not None and (
69
+ link := _file_link(project, settings, facts, PurePosixPath(artifact.license_file))
70
+ ):
71
+ references.append({"type": "license", "url": link})
72
+ properties: Final[list[Property]] = [
73
+ *root.get("properties", []),
74
+ *contributed_properties(contributions.root_properties, project.property_prefix),
75
+ ]
76
+ if (revision := facts.revision) is not None:
77
+ if project.repository:
78
+ comment = "source revision, with uncommitted changes" if revision.dirty else "exact source revision"
79
+ references.append({
80
+ "type": "vcs",
81
+ "url": f"{project.repository}/tree/{revision.commit}",
82
+ "comment": comment,
83
+ })
84
+ properties.append({"name": sbom_property(project.property_prefix, "vcs-commit"), "value": revision.commit})
85
+ if revision.dirty:
86
+ properties.append({"name": sbom_property(project.property_prefix, "vcs-dirty"), "value": "true"})
87
+ root["externalReferences"] = references
88
+ root["properties"] = properties
89
+ return root
90
+
91
+
92
+ def _file_link(project: ProjectIdentity, settings: Settings, facts: BuildFacts, path: PurePosixPath) -> str | None:
93
+ if project.repository is None:
94
+ return None
95
+ # the commit of a clean checkout holds the file as it was built; with no commit, or uncommitted changes, the default
96
+ # branch is where the file most likely still lives
97
+ revision = facts.revision
98
+ tree = settings.default_branch if revision is None or revision.dirty else revision.commit
99
+ return f"{project.repository}/blob/{tree}/{path}"
100
+
101
+
102
+ def _metadata(
103
+ tools: list[Component],
104
+ artifact: Artifact,
105
+ project: ProjectIdentity,
106
+ settings: Settings,
107
+ facts: BuildFacts,
108
+ ) -> Metadata:
109
+ metadata: Final[Metadata] = {
110
+ "timestamp": timestamp(facts),
111
+ "lifecycles": [{"phase": "build"}],
112
+ "tools": {"components": tools},
113
+ "component": (root := artifact.root),
114
+ }
115
+ if settings.supplier is not None:
116
+ metadata["manufacturer"] = metadata["supplier"] = settings.supplier
117
+ if "authors" in root:
118
+ metadata["authors"] = root["authors"]
119
+ if "licenses" in root:
120
+ metadata["licenses"] = root["licenses"]
121
+ properties: Final[list[Property]] = [
122
+ {"name": sbom_property(project.property_prefix, "sbom:scope"), "value": artifact.scope},
123
+ # names the document's file, which marks it as one python-sbom built, so validation checks it in full
124
+ {"name": sbom_property(project.property_prefix, SBOM_FILE), "value": artifact.sbom_file},
125
+ ]
126
+ if project.repository is not None:
127
+ properties.append({
128
+ "name": sbom_property(project.property_prefix, SBOM_REPOSITORY),
129
+ "value": project.repository,
130
+ })
131
+ metadata["properties"] = properties
132
+ return metadata
133
+
134
+
135
+ __all__ = [
136
+ "assemble",
137
+ ]