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,182 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from email.utils import getaddresses
5
+ from itertools import starmap
6
+ from typing import TYPE_CHECKING, Final
7
+
8
+ from packaging.utils import canonicalize_name
9
+
10
+ from ._graph import walk_components
11
+ from ._iri import iri_reference
12
+ from ._licenses import licenses
13
+ from ._names import purl, pypi_url, reference_type, sbom_property, tool_ref
14
+ from ._types import Dependency
15
+
16
+ if TYPE_CHECKING:
17
+ from pathlib import PurePosixPath
18
+
19
+ from ._content import BundledWheelInfo
20
+ from ._inputs import BundledWheel, RecordRow
21
+ from ._metadata import CoreMetadata
22
+ from ._types import Component, Contact, ExternalReference, Identity, Property
23
+
24
+
25
+ def bundled_wheel_component(wheel: BundledWheel, property_prefix: str, info: BundledWheelInfo) -> Component:
26
+ component = component_from_metadata(wheel.metadata)
27
+ mark_bundled(component, wheel.path, property_prefix, info)
28
+ component["hashes"] = [{"alg": "SHA-256", "content": wheel.sha256}]
29
+ component["externalReferences"] = [
30
+ *component["externalReferences"],
31
+ {"type": "distribution", "url": pypi_url(wheel.metadata.name, wheel.metadata.version)},
32
+ ]
33
+ component["evidence"] = {
34
+ "identity": [
35
+ _evidence("purl", "manifest-analysis", wheel.metadata_name),
36
+ _evidence("hash", "hash-comparison", wheel.sha256),
37
+ ]
38
+ }
39
+ vendored: Final[dict[str, Component]] = {}
40
+ for source, metadata in wheel.vendored:
41
+ child = component_from_metadata(metadata)
42
+ child["bom-ref"] = f"{component['bom-ref']}#vendored/{child['bom-ref']}"
43
+ child["properties"] = [
44
+ *child["properties"],
45
+ {"name": sbom_property(property_prefix, "vendored-manifest"), "value": source},
46
+ ]
47
+ child["evidence"] = {"identity": [_evidence("purl", "manifest-analysis", source)]}
48
+ # a vendored .dist-info comes after pip's manifest line naming the same package, so its metadata wins; keyed on
49
+ # the PEP 503 name, since the manifest and the .dist-info may spell it differently
50
+ vendored[f"{canonicalize_name(child['name'])}=={child['version']}"] = child
51
+ component["components"] = [
52
+ *(file_component(component["bom-ref"], row) for row in wheel.files),
53
+ *(vendored[key] for key in sorted(vendored)),
54
+ ]
55
+ return component
56
+
57
+
58
+ def component_from_metadata(metadata: CoreMetadata) -> Component:
59
+ name, version = metadata.name, metadata.version
60
+ component: Final[Component] = {"type": "library", "bom-ref": purl(name, version), "name": name, "version": version}
61
+ component["purl"] = component["bom-ref"]
62
+ if summary := metadata.first("summary"):
63
+ component["description"] = summary
64
+ # maintainers are who users reach today, so they win over the original authors when both are named
65
+ for names, addresses in (("maintainer", "maintainer_email"), ("author", "author_email")):
66
+ contacts = [
67
+ *(_contact(name, "") for name in metadata.values(names)),
68
+ *starmap(_contact, getaddresses(metadata.values(addresses))),
69
+ ]
70
+ if people := [contact for contact in contacts if contact]:
71
+ component["authors"] = people
72
+ break
73
+ if declared := licenses(metadata):
74
+ component["licenses"] = declared
75
+ component["externalReferences"], unlinked = _project_references(metadata)
76
+ # core metadata headers copied verbatim, so a reader sees what the distribution declared
77
+ headers: Final[list[tuple[str, list[str]]]] = [
78
+ ("requires-python", metadata.values("requires_python")),
79
+ ("requires-dist", metadata.values("requires_dist")),
80
+ ("provides-extra", metadata.values("provides_extra")),
81
+ ("classifier", metadata.values("classifiers")),
82
+ ("keywords", metadata.keywords()),
83
+ ]
84
+ component["properties"] = []
85
+ for header, values in headers:
86
+ component["properties"].extend({"name": f"python:{header}", "value": value} for value in values)
87
+ component["properties"].extend(unlinked)
88
+ return component
89
+
90
+
91
+ def _contact(name: str, email: str) -> Contact:
92
+ if email and not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", email):
93
+ # an address the schema rejects as an email, such as "jane at example.com", is kept as the name
94
+ return {"name": " ".join(part for part in (name, email) if part)}
95
+ contact: Final[Contact] = {}
96
+ if name:
97
+ contact["name"] = name
98
+ if email:
99
+ contact["email"] = email
100
+ return contact
101
+
102
+
103
+ def _project_references(metadata: CoreMetadata) -> tuple[list[ExternalReference], list[Property]]:
104
+ # a URL CycloneDX would reject is percent-encoded where that keeps what it points at and left out where it cannot
105
+ # be, as an scp-style git address; either way the header's text is kept verbatim as a property
106
+ references: Final[list[ExternalReference]] = []
107
+ unlinked: Final[list[Property]] = []
108
+ # a home page that is not an http(s) URL, such as UNKNOWN, is left out
109
+ if (home_page := metadata.first("home_page")) and re.match(r"https?:", home_page, re.IGNORECASE):
110
+ if (url := iri_reference(home_page)) is not None:
111
+ references.append({"type": "website", "url": url})
112
+ if url != home_page:
113
+ unlinked.append({"name": "python:home-page", "value": home_page})
114
+ for label, link in metadata.project_urls():
115
+ if (url := iri_reference(link)) is not None:
116
+ references.append({"type": reference_type(label), "url": url, "comment": f"Project-URL: {label}"})
117
+ if url != link:
118
+ unlinked.append({"name": "python:project-url", "value": f"{label}, {link}"})
119
+ return references, unlinked
120
+
121
+
122
+ def mark_bundled(component: Component, path: str, property_prefix: str, info: BundledWheelInfo) -> None:
123
+ # the purl names the package; the path, of a .whl or of the directory a wheel is unpacked into, tells apart copies
124
+ # of one release bundled in two places
125
+ component["bom-ref"] = f"bundled:{path}"
126
+ if info.supplier is not None:
127
+ component["supplier"] = info.supplier
128
+ component["properties"] = [
129
+ {"name": sbom_property(property_prefix, "bundled-wheel"), "value": path},
130
+ *info.properties,
131
+ *component["properties"],
132
+ ]
133
+
134
+
135
+ def _evidence(field: str, technique: str, value: str) -> Identity:
136
+ return {"field": field, "confidence": 1, "methods": [{"technique": technique, "confidence": 1, "value": value}]}
137
+
138
+
139
+ def file_component(parent_ref: str, row: RecordRow) -> Component:
140
+ return {
141
+ "type": "file",
142
+ "bom-ref": f"{parent_ref}#{row.path}",
143
+ "name": row.path,
144
+ "hashes": [{"alg": row.algorithm, "content": row.digest}],
145
+ "properties": [{"name": "size", "value": row.size}],
146
+ }
147
+
148
+
149
+ def vendored_dependencies(components: list[Component]) -> list[Dependency]:
150
+ # each bundled wheel depends on the packages it vendors, wherever it sits in the document
151
+ found: Final[list[Dependency]] = []
152
+ for component in walk_components(components):
153
+ if not component["bom-ref"].startswith("bundled:"):
154
+ continue
155
+ # a file's ref holds its path, which may itself hold "#vendored/"
156
+ libraries = (child["bom-ref"] for child in component.get("components", []) if child["type"] == "library")
157
+ if vendored := [ref for ref in libraries if "#vendored/" in ref]:
158
+ found.append(Dependency(ref=component["bom-ref"], dependsOn=vendored))
159
+ return found
160
+
161
+
162
+ def script_tool(path: PurePosixPath, sha256: str, version: str, link: str | None) -> Component:
163
+ tool: Final[Component] = {
164
+ "type": "application",
165
+ "bom-ref": tool_ref(str(path)),
166
+ "name": path.name,
167
+ "version": version,
168
+ "hashes": [{"alg": "SHA-256", "content": sha256}],
169
+ }
170
+ if link is not None:
171
+ tool["externalReferences"] = [{"type": "vcs", "url": link}]
172
+ return tool
173
+
174
+
175
+ __all__ = [
176
+ "bundled_wheel_component",
177
+ "component_from_metadata",
178
+ "file_component",
179
+ "mark_bundled",
180
+ "script_tool",
181
+ "vendored_dependencies",
182
+ ]
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from collections.abc import Callable
8
+ from pathlib import Path, PurePosixPath
9
+
10
+ from ._identity import ProjectIdentity
11
+ from ._inputs import Settings
12
+ from ._types import Component, Dependency, OrganizationalEntity, Property
13
+
14
+
15
+ class ProviderContext:
16
+ """
17
+ What a content provider may use about the build it contributes to.
18
+
19
+ :param root: the project directory
20
+ :param settings: the project's options
21
+ :param identity: how the project's SBOMs name it, including the prefix of its properties
22
+ :param read: reads a member of the artifact being described, for :meth:`read`
23
+
24
+ """
25
+
26
+ def __init__(
27
+ self, *, root: Path, settings: Settings, identity: ProjectIdentity, read: Callable[[PurePosixPath], bytes]
28
+ ) -> None:
29
+ self.root = root
30
+ self.settings = settings
31
+ self.identity = identity
32
+ self._read = read
33
+
34
+ def read(self, path: PurePosixPath) -> bytes:
35
+ """
36
+ Read a file of the artifact being described: a member of the built wheel or of the archive.
37
+
38
+ :param path: the file's path inside the artifact, as a bundled wheel's path is given
39
+ :returns: the file's bytes
40
+ :raises KeyError: when the artifact holds no such file
41
+
42
+ """
43
+ return self._read(path)
44
+
45
+
46
+ class ContentProvider:
47
+ """
48
+ Adds what a project knows beyond its metadata to its SBOM; subclass it and override what the project needs.
49
+
50
+ Name a subclass in the ``content-provider`` option, which the hook, ``python-sbom embed`` and ``python-sbom
51
+ archive`` load, or return an instance from :meth:`python_sbom.hatch.SbomBuildHook.content_provider`. When the class
52
+ lives in the project, the SBOM lists its module among the build tools.
53
+
54
+ """
55
+
56
+ # ruff: disable[unused-method-argument, no-self-use] # the defaults add nothing, whatever they are given
57
+ def bundled_wheel(self, context: ProviderContext, wheel: PurePosixPath) -> BundledWheelInfo:
58
+ """
59
+ Describe what the project does with a wheel it bundles, as a file or unpacked into a directory.
60
+
61
+ :param context: the project and the artifact being described
62
+ :param wheel: the wheel's path inside the artifact
63
+ :returns: its extra properties, and whether the project's supplier supplies it; neither by default
64
+
65
+ """
66
+ return BundledWheelInfo()
67
+
68
+ def contributions(self, context: ProviderContext) -> Contributions:
69
+ """
70
+ Add components, their dependencies, root properties and build tools, before references and the serial derive.
71
+
72
+ :param context: the project and the artifact being described
73
+ :returns: the contributions; none by default
74
+
75
+ """
76
+ return Contributions()
77
+
78
+ # ruff: enable[unused-method-argument, no-self-use]
79
+
80
+
81
+ @dataclass(frozen=True, slots=True, kw_only=True)
82
+ class BundledWheelInfo:
83
+ """
84
+ What a project adds to the component of one bundled wheel.
85
+
86
+ Both fields default to empty, so a provider names only what it adds.
87
+
88
+ :param properties: properties after ``<prefix>:bundled-wheel``, before those from the wheel's metadata
89
+ :param supplier: the wheel's supplier, or ``None`` to name none; only the project decides a bundled wheel's
90
+ supplier, never the wheel's own metadata
91
+
92
+ """
93
+
94
+ properties: list[Property] = field(default_factory=list)
95
+ supplier: OrganizationalEntity | None = None
96
+
97
+
98
+ @dataclass(frozen=True, slots=True, kw_only=True)
99
+ class Contributions:
100
+ """
101
+ What a project adds to the document beyond its bundled wheels, merged before references and the serial are derived.
102
+
103
+ Each field defaults to empty, so a provider names only what it adds. The build fails when a component or tool lacks
104
+ ``type``, ``bom-ref`` or ``name``, a ``bom-ref`` repeats, the ``formula:`` and ``workflow:`` refs of the build's
105
+ formulation included, a dependency names one that does not exist, or a ``bom-ref`` gets a second dependency entry,
106
+ as the root does if a provider adds one, since python-sbom writes the root's.
107
+
108
+ :param components: extra top-level components, such as vendored C libraries; the root depends on each, and a
109
+ component's own parts nest under its ``components``
110
+ :param dependencies: extra dependency entries, such as the dependencies of those components
111
+ :param root_properties: extra properties of the root component; their names must not start with the project's
112
+ prefix, which python-sbom's own properties use
113
+ :param tools: extra build tools
114
+
115
+ """
116
+
117
+ components: list[Component] = field(default_factory=list)
118
+ dependencies: list[Dependency] = field(default_factory=list)
119
+ root_properties: list[Property] = field(default_factory=list)
120
+ tools: list[Component] = field(default_factory=list)
121
+
122
+
123
+ __all__ = [
124
+ "BundledWheelInfo",
125
+ "ContentProvider",
126
+ "Contributions",
127
+ "ProviderContext",
128
+ ]
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import uuid
5
+ from datetime import UTC, datetime
6
+ from typing import TYPE_CHECKING, Final
7
+
8
+ from python_sbom._errors import SbomError
9
+
10
+ from ._graph import reference_problems, walk_components
11
+
12
+ if TYPE_CHECKING:
13
+ from ._content import ContentProvider, Contributions
14
+ from ._inputs import BuildFacts
15
+ from ._types import Body, Component, Document, Formula, Property
16
+
17
+
18
+ def check_refs(body: Body) -> None:
19
+ # a provider contributes refs of its own, so the document is checked before it is written rather than by validate
20
+ components: Final[list[Component]] = [
21
+ *walk_components([
22
+ body["metadata"]["component"],
23
+ *body["components"],
24
+ *body["metadata"]["tools"]["components"],
25
+ ])
26
+ ]
27
+ # formulas and workflows share the bom-ref namespace, so a component cannot take theirs
28
+ formulas: Final[list[str]] = [
29
+ ref
30
+ for formula in body.get("formulation", [])
31
+ for ref in (formula["bom-ref"], *(workflow["bom-ref"] for workflow in formula["workflows"]))
32
+ ]
33
+ if problems := reference_problems(
34
+ [*(component["bom-ref"] for component in components), *formulas], body["dependencies"]
35
+ ):
36
+ raise SbomError(problems[0])
37
+
38
+
39
+ def with_header(body: Body, serial_namespace: uuid.UUID) -> Document:
40
+ return {
41
+ "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json",
42
+ "bomFormat": "CycloneDX",
43
+ "specVersion": "1.6",
44
+ "serialNumber": f"urn:uuid:{uuid.uuid5(serial_namespace, json.dumps(body, sort_keys=True))}",
45
+ "version": 1,
46
+ **body,
47
+ }
48
+
49
+
50
+ def checked_contributions(contributions: Contributions, provider: ContentProvider) -> Contributions:
51
+ # what a provider adds is checked for the fields everything else here reads, so a mistake names the provider rather
52
+ # than surfacing as a KeyError deep in the build
53
+ for component in walk_components([*contributions.components, *contributions.tools]):
54
+ if missing := [key for key in ("type", "bom-ref", "name") if key not in component]:
55
+ name = type(provider).__qualname__
56
+ msg = f"content provider {name} added a component without {', '.join(missing)}: {component!r:.200}"
57
+ raise SbomError(msg)
58
+ return contributions
59
+
60
+
61
+ def contributed_properties(properties: list[Property], prefix: str) -> list[Property]:
62
+ if claimed := sorted(prop["name"] for prop in properties if prop["name"].startswith(f"{prefix}:")):
63
+ msg = f"contributed root properties {', '.join(claimed)} use the {prefix}: prefix python-sbom writes"
64
+ raise SbomError(msg)
65
+ return properties
66
+
67
+
68
+ def formulation(uid: str, name: str, tools: list[Component], output_ref: str, facts: BuildFacts) -> Formula:
69
+ formula: Formula = {
70
+ "bom-ref": f"formula:{uid}",
71
+ "workflows": [
72
+ {
73
+ "bom-ref": f"workflow:{uid}",
74
+ "uid": uid,
75
+ "name": name,
76
+ "taskTypes": ["build"],
77
+ "resourceReferences": [{"ref": tool["bom-ref"]} for tool in tools],
78
+ "outputs": [{"type": "artifact", "resource": {"ref": output_ref}}],
79
+ }
80
+ ],
81
+ }
82
+ if facts.epoch_from_environment:
83
+ formula["workflows"][0]["inputs"] = [
84
+ {"environmentVars": [{"name": "SOURCE_DATE_EPOCH", "value": str(facts.epoch)}]}
85
+ ]
86
+ return formula
87
+
88
+
89
+ def timestamp(facts: BuildFacts) -> str:
90
+ return datetime.fromtimestamp(facts.epoch, tz=UTC).isoformat()
91
+
92
+
93
+ __all__ = [
94
+ "check_refs",
95
+ "checked_contributions",
96
+ "contributed_properties",
97
+ "formulation",
98
+ "timestamp",
99
+ "with_header",
100
+ ]
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+ from typing import TYPE_CHECKING, Final
5
+
6
+ if TYPE_CHECKING:
7
+ from collections.abc import Iterable, Iterator
8
+
9
+ from ._types import Component, Dependency
10
+
11
+
12
+ def artifact_contents(root: Component, components: list[Component]) -> list[Component]:
13
+ # what the artifact holds or declares, wherever the document places it: the files and wheels nested under the root,
14
+ # which sit in an archive's top level, and the components; not the root itself, nor the tools that built it, so
15
+ # generation, the license policy and validation all judge the same set
16
+ return [*root.get("components", []), *components]
17
+
18
+
19
+ def shipped_libraries(contents: list[Component]) -> list[Component]:
20
+ # what the artifact carries bytes for: a declared requirement resolves at install time, and a library a bundled
21
+ # wheel vendors ships under that wheel's terms
22
+ return [
23
+ component
24
+ for component in walk_components(contents)
25
+ if component["type"] == "library"
26
+ and ("hashes" in component or any(child["type"] == "file" for child in component.get("components", [])))
27
+ ]
28
+
29
+
30
+ def walk_components(components: Iterable[Component]) -> Iterator[Component]:
31
+ # depth first, each component before the ones it nests, in document order; iterative, so a deeply nested document
32
+ # cannot exhaust the stack
33
+ pending: Final[list[Component]] = list(reversed(list(components)))
34
+ while pending:
35
+ component = pending.pop()
36
+ yield component
37
+ pending.extend(reversed(component.get("components", [])))
38
+
39
+
40
+ def reference_problems(refs: list[str], dependencies: list[Dependency]) -> list[str]:
41
+ # what generation refuses and validation reports alike: a bom-ref given twice, which formulas and workflows share
42
+ # with components; a second dependency entry for one ref, which a reader taking them as a mapping would let a valid
43
+ # entry hide an invalid one with, while the SPDX rendering keeps the edges of both; and an entry naming no component
44
+ if twice := repeated(refs):
45
+ return [f"bom-ref values repeat: {', '.join(twice)}"]
46
+ if twice := repeated(entry["ref"] for entry in dependencies):
47
+ return [f"dependencies list more than one entry for: {', '.join(twice)}"]
48
+ named: Final[set[str]] = {ref for entry in dependencies for ref in (entry["ref"], *entry.get("dependsOn", []))}
49
+ if unknown := sorted(named - set(refs)):
50
+ return [f"dependencies name bom-refs no component has: {', '.join(unknown)}"]
51
+ return []
52
+
53
+
54
+ def repeated(values: Iterable[str]) -> list[str]:
55
+ return sorted(value for value, count in Counter(values).items() if count > 1)
56
+
57
+
58
+ __all__ = [
59
+ "artifact_contents",
60
+ "reference_problems",
61
+ "repeated",
62
+ "shipped_libraries",
63
+ "walk_components",
64
+ ]
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from dataclasses import dataclass
5
+ from typing import TYPE_CHECKING
6
+
7
+ from ._iri import iri_reference
8
+ from ._names import property_prefix, reference_type
9
+
10
+ if TYPE_CHECKING:
11
+ from ._inputs import Settings
12
+ from ._metadata import CoreMetadata
13
+
14
+
15
+ def project_identity(metadata: CoreMetadata, settings: Settings) -> ProjectIdentity:
16
+ project = property_prefix(metadata.name)
17
+ repository = settings.repository
18
+ # links into the repository extend its URL, so one that cannot be an IRI reference, as an scp-style git address, is
19
+ # passed over for the next
20
+ for label, url in [] if repository else metadata.project_urls():
21
+ if reference_type(label) == "vcs" and (link := iri_reference(url)) is not None:
22
+ repository = link.rstrip("/")
23
+ break
24
+ base = repository or f"https://pypi.org/project/{project}"
25
+ return ProjectIdentity(
26
+ property_prefix=project,
27
+ repository=repository,
28
+ serial_namespace=uuid.uuid5(uuid.NAMESPACE_URL, f"{base}/sboms"),
29
+ )
30
+
31
+
32
+ @dataclass(frozen=True, slots=True, kw_only=True)
33
+ class ProjectIdentity:
34
+ """
35
+ How every SBOM of a project names it.
36
+
37
+ :param property_prefix: the project's normalized name, which starts the names of the properties python-sbom writes,
38
+ such as ``virtualenv`` in ``virtualenv:bundled-wheel``
39
+ :param repository: the repository URL, from the options or the first ``Source``, ``Repository`` or ``GitHub``
40
+ project URL, or ``None``
41
+ :param serial_namespace: the UUID namespace of the serial numbers of the project's SBOMs
42
+
43
+ """
44
+
45
+ property_prefix: str
46
+ repository: str | None
47
+ serial_namespace: uuid.UUID
48
+
49
+
50
+ __all__ = [
51
+ "ProjectIdentity",
52
+ "project_identity",
53
+ ]
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from pathlib import PurePosixPath
8
+
9
+ from ._metadata import CoreMetadata
10
+ from ._types import Component, Dependency, OrganizationalEntity
11
+
12
+
13
+ @dataclass(frozen=True, slots=True, kw_only=True)
14
+ class RecordRow:
15
+ path: str
16
+ algorithm: str
17
+ digest: str
18
+ size: str
19
+
20
+
21
+ @dataclass(frozen=True, slots=True, kw_only=True)
22
+ class Interpreter:
23
+ name: str
24
+ version: str
25
+ implementation: str
26
+
27
+
28
+ @dataclass(frozen=True, slots=True, kw_only=True)
29
+ class InstalledDistribution:
30
+ name: str
31
+ metadata: CoreMetadata
32
+ files: list[RecordRow]
33
+ depends_on: list[str]
34
+
35
+
36
+ @dataclass(frozen=True, slots=True, kw_only=True)
37
+ class Revision:
38
+ commit: str
39
+ dirty: bool
40
+
41
+
42
+ @dataclass(frozen=True, slots=True, kw_only=True)
43
+ class BuildFacts:
44
+ epoch: int
45
+ epoch_from_environment: bool
46
+ revision: Revision | None
47
+ interpreter: Interpreter
48
+ distributions: list[InstalledDistribution]
49
+
50
+
51
+ @dataclass(frozen=True, slots=True, kw_only=True)
52
+ class Settings:
53
+ """
54
+ The project's options, from ``[tool.python-sbom]``.
55
+
56
+ :param supplier: the organization that supplies the project, or ``None`` to name none
57
+ :param repository: the repository URL, or ``None`` to take the first ``Source``, ``Repository`` or ``GitHub``
58
+ project URL
59
+ :param default_branch: the branch in links to files in the repository when the build's commit is unknown or has
60
+ uncommitted changes
61
+ :param bundled_wheels: glob patterns, relative to the project directory, of wheels the project ships as data files
62
+ :param content_provider: ``module:attribute`` of the content provider, or ``None``
63
+
64
+ """
65
+
66
+ supplier: OrganizationalEntity | None
67
+ repository: str | None
68
+ default_branch: str
69
+ bundled_wheels: tuple[str, ...]
70
+ content_provider: str | None
71
+
72
+
73
+ @dataclass(frozen=True, slots=True, kw_only=True)
74
+ class BundledWheel:
75
+ path: str
76
+ sha256: str
77
+ metadata_name: str
78
+ metadata: CoreMetadata
79
+ files: list[RecordRow]
80
+ vendored: list[tuple[str, CoreMetadata]]
81
+
82
+
83
+ @dataclass(frozen=True, slots=True, kw_only=True)
84
+ class BuildScript:
85
+ path: PurePosixPath
86
+ sha256: str
87
+
88
+
89
+ @dataclass(frozen=True, slots=True, kw_only=True)
90
+ class ArchiveTree:
91
+ # what an archive holds, read from the zip without unpacking it: each distribution's metadata and members by the
92
+ # directory that holds its .dist-info, "" for the project's own, the archive's own top-level files, the digest of
93
+ # every member but the bundled wheels, and those wheels
94
+ metadata: dict[str, CoreMetadata]
95
+ owned: dict[str, list[str]]
96
+ loose: list[str]
97
+ files: dict[str, RecordRow]
98
+ bundled: dict[str, BundledWheel]
99
+
100
+
101
+ @dataclass(frozen=True, slots=True, kw_only=True)
102
+ class Artifact:
103
+ # what one artifact, a wheel or an archive, puts in its SBOM; the provenance every artifact of the project shares is
104
+ # added around it
105
+ root: Component
106
+ components: list[Component]
107
+ dependencies: list[Dependency]
108
+ build_scripts: list[BuildScript]
109
+ version: str
110
+ sbom_file: str
111
+ scope: str
112
+ workflow: tuple[str, str]
113
+ license_file: str | None
114
+ copyright: str | None
115
+
116
+
117
+ @dataclass(frozen=True, slots=True, kw_only=True)
118
+ class WheelTree:
119
+ # what a built wheel holds, read from the wheel without unpacking it: its file name, its core metadata and the
120
+ # wheels it bundles; and what the project adds: the copyright line of its license files and the scripts that built
121
+ # it
122
+ name: str
123
+ metadata: CoreMetadata
124
+ copyright: str | None
125
+ bundled: list[BundledWheel]
126
+ build_scripts: list[BuildScript]
127
+
128
+
129
+ __all__ = [
130
+ "ArchiveTree",
131
+ "Artifact",
132
+ "BuildFacts",
133
+ "BuildScript",
134
+ "BundledWheel",
135
+ "InstalledDistribution",
136
+ "Interpreter",
137
+ "RecordRow",
138
+ "Revision",
139
+ "Settings",
140
+ "WheelTree",
141
+ ]