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.
- python_sbom/__init__.py +46 -0
- python_sbom/__main__.py +10 -0
- python_sbom/_cli/__init__.py +1 -0
- python_sbom/_cli/_archive.py +38 -0
- python_sbom/_cli/_embed.py +28 -0
- python_sbom/_cli/_main.py +28 -0
- python_sbom/_cli/_parser.py +144 -0
- python_sbom/_cli/_spdx.py +61 -0
- python_sbom/_cli/_validate.py +40 -0
- python_sbom/_collect/__init__.py +1 -0
- python_sbom/_collect/_archive.py +94 -0
- python_sbom/_collect/_content.py +109 -0
- python_sbom/_collect/_facts.py +155 -0
- python_sbom/_collect/_revision.py +93 -0
- python_sbom/_collect/_settings.py +82 -0
- python_sbom/_collect/_source.py +54 -0
- python_sbom/_collect/_wheel.py +55 -0
- python_sbom/_errors.py +15 -0
- python_sbom/_formats/__init__.py +1 -0
- python_sbom/_formats/_json.py +18 -0
- python_sbom/_formats/_spdx.py +263 -0
- python_sbom/_generate/__init__.py +1 -0
- python_sbom/_generate/_archive.py +84 -0
- python_sbom/_generate/_embed.py +74 -0
- python_sbom/_model/__init__.py +1 -0
- python_sbom/_model/_archive.py +117 -0
- python_sbom/_model/_bom.py +137 -0
- python_sbom/_model/_components.py +182 -0
- python_sbom/_model/_content.py +128 -0
- python_sbom/_model/_document.py +100 -0
- python_sbom/_model/_graph.py +64 -0
- python_sbom/_model/_identity.py +53 -0
- python_sbom/_model/_inputs.py +141 -0
- python_sbom/_model/_iri.py +96 -0
- python_sbom/_model/_licenses.py +58 -0
- python_sbom/_model/_markers.py +61 -0
- python_sbom/_model/_metadata.py +100 -0
- python_sbom/_model/_names.py +90 -0
- python_sbom/_model/_tools.py +48 -0
- python_sbom/_model/_types.py +281 -0
- python_sbom/_model/_wheel.py +89 -0
- python_sbom/_validate/__init__.py +1 -0
- python_sbom/_validate/_artifact.py +55 -0
- python_sbom/_validate/_document.py +221 -0
- python_sbom/_validate/_licenses.py +104 -0
- python_sbom/_validate/_limits.py +27 -0
- python_sbom/_validate/_schemas.py +124 -0
- python_sbom/_validate/schemas/LICENSE +201 -0
- python_sbom/_validate/schemas/bom-1.6.schema.json +5699 -0
- python_sbom/_validate/schemas/jsf-0.82.schema.json +240 -0
- python_sbom/_validate/schemas/spdx.schema.json +1662 -0
- python_sbom/_wheel/__init__.py +1 -0
- python_sbom/_wheel/_bundled.py +142 -0
- python_sbom/_wheel/_reader.py +59 -0
- python_sbom/_wheel/_record.py +55 -0
- python_sbom/_wheel/_write.py +103 -0
- python_sbom/_wheel/_zip.py +94 -0
- python_sbom/hatch/__init__.py +9 -0
- python_sbom/hatch/_hook.py +150 -0
- python_sbom/hatch/hooks.py +18 -0
- python_sbom/py.typed +0 -0
- python_sbom/version.py +24 -0
- python_sbom-0.1.1.dist-info/METADATA +95 -0
- python_sbom-0.1.1.dist-info/RECORD +69 -0
- python_sbom-0.1.1.dist-info/WHEEL +4 -0
- python_sbom-0.1.1.dist-info/entry_points.txt +5 -0
- python_sbom-0.1.1.dist-info/licenses/LICENSE +18 -0
- python_sbom-0.1.1.dist-info/licenses/src/python_sbom/_validate/schemas/LICENSE +201 -0
- python_sbom-0.1.1.dist-info/sboms/python-sbom.cdx.json +13548 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import re
|
|
5
|
+
import string
|
|
6
|
+
from typing import Final, cast
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
# RFC 3986 appendix B: splits any text into scheme, authority, path, query and fragment
|
|
10
|
+
_PARTS: Final[re.Pattern[str]] = re.compile(
|
|
11
|
+
r"(?:([^:/?#]+):)?(?://([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?", re.DOTALL
|
|
12
|
+
)
|
|
13
|
+
_SCHEME: Final[re.Pattern[str]] = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*")
|
|
14
|
+
# what may follow the host: nothing, or a colon and the port's digits
|
|
15
|
+
_PORT: Final[re.Pattern[str]] = re.compile(r"(?::[0-9]*)?")
|
|
16
|
+
_IP_FUTURE: Final[re.Pattern[str]] = re.compile(r"v[0-9A-Fa-f]+\.[A-Za-z0-9._~!$&'()*+,;=:-]+")
|
|
17
|
+
# a valid percent-encoding, or any one character
|
|
18
|
+
_TOKEN: Final[re.Pattern[str]] = re.compile(r"%[0-9A-Fa-f]{2}|.", re.DOTALL)
|
|
19
|
+
_REG_NAME: Final[str] = f"{string.ascii_letters}{string.digits}-._~!$&'()*+,;="
|
|
20
|
+
_USERINFO: Final[str] = f"{_REG_NAME}:"
|
|
21
|
+
_PATH: Final[str] = f"{_USERINFO}@/"
|
|
22
|
+
# the query and the fragment allow the same characters
|
|
23
|
+
_QUERY: Final[str] = f"{_PATH}?"
|
|
24
|
+
# the characters beyond ASCII an IRI holds unencoded, RFC 3987's ucschar
|
|
25
|
+
_UCSCHAR: Final[tuple[tuple[int, int], ...]] = (
|
|
26
|
+
(0xA0, 0xD7FF),
|
|
27
|
+
(0xF900, 0xFDCF),
|
|
28
|
+
(0xFDF0, 0xFFEF),
|
|
29
|
+
*((plane << 16, plane << 16 | 0xFFFD) for plane in range(1, 14)),
|
|
30
|
+
(0xE1000, 0xEFFFD),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def iri_reference(text: str) -> str | None:
|
|
35
|
+
# the form CycloneDX requires of a URL: a character no part of an IRI reference allows is percent-encoded, which
|
|
36
|
+
# keeps what the text points at; a scheme, IP literal or port no encoding mends, as in the scp-style
|
|
37
|
+
# git@github.com:org/repo.git, leaves None; the pattern matches any text, every part of it optional
|
|
38
|
+
match: Final = cast("re.Match[str]", _PARTS.fullmatch(text))
|
|
39
|
+
scheme, authority, path, query, fragment = match.groups()
|
|
40
|
+
if scheme is not None and not _SCHEME.fullmatch(scheme):
|
|
41
|
+
return None
|
|
42
|
+
parts: Final[list[str]] = [] if scheme is None else [f"{scheme}:"]
|
|
43
|
+
if authority is not None:
|
|
44
|
+
if (encoded := _authority(authority)) is None:
|
|
45
|
+
return None
|
|
46
|
+
parts.append(f"//{encoded}")
|
|
47
|
+
parts.append(_encoded(path, _PATH))
|
|
48
|
+
if query is not None:
|
|
49
|
+
parts.append(f"?{_encoded(query, _QUERY)}")
|
|
50
|
+
if fragment is not None:
|
|
51
|
+
parts.append(f"#{_encoded(fragment, _QUERY)}")
|
|
52
|
+
return "".join(parts)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _authority(authority: str) -> str | None:
|
|
56
|
+
userinfo, at, host_port = authority.rpartition("@")
|
|
57
|
+
if host_port.startswith("["):
|
|
58
|
+
literal, _, port = host_port[1:].partition("]")
|
|
59
|
+
if not _ip_literal(literal):
|
|
60
|
+
return None
|
|
61
|
+
host = f"[{literal}]"
|
|
62
|
+
else:
|
|
63
|
+
name, colon, number = host_port.partition(":")
|
|
64
|
+
host, port = _encoded(name, _REG_NAME), f"{colon}{number}"
|
|
65
|
+
if not _PORT.fullmatch(port):
|
|
66
|
+
return None
|
|
67
|
+
return f"{_encoded(userinfo, _USERINFO)}{at}{host}{port}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _ip_literal(literal: str) -> bool:
|
|
71
|
+
if _IP_FUTURE.fullmatch(literal):
|
|
72
|
+
return True
|
|
73
|
+
# a zone index has no place in a URI's IP literal
|
|
74
|
+
if "%" in literal:
|
|
75
|
+
return False
|
|
76
|
+
try:
|
|
77
|
+
ipaddress.IPv6Address(literal)
|
|
78
|
+
except ValueError:
|
|
79
|
+
return False
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _encoded(text: str, allowed: str) -> str:
|
|
84
|
+
return "".join(token if _kept(token, allowed) else quote(token, safe="") for token in _TOKEN.findall(text))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _kept(token: str, allowed: str) -> bool:
|
|
88
|
+
# a token longer than a character is a valid percent-encoding
|
|
89
|
+
if len(token) > 1 or token in allowed:
|
|
90
|
+
return True
|
|
91
|
+
return any(low <= ord(token) <= high for low, high in _UCSCHAR)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
__all__ = [
|
|
95
|
+
"iri_reference",
|
|
96
|
+
]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import TYPE_CHECKING, Final
|
|
5
|
+
|
|
6
|
+
from packaging.licenses import InvalidLicenseExpression, canonicalize_license_expression
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from ._metadata import CoreMetadata
|
|
10
|
+
from ._types import License
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def licenses(metadata: CoreMetadata) -> list[License]:
|
|
14
|
+
# a License-Expression is taken as declared; otherwise each License field and License classifier name is declared,
|
|
15
|
+
# and the SPDX ids they map to are added as concluded
|
|
16
|
+
if expression := metadata.first("license_expression"):
|
|
17
|
+
try:
|
|
18
|
+
return [{"expression": canonicalize_license_expression(expression), "acknowledgement": "declared"}]
|
|
19
|
+
except InvalidLicenseExpression:
|
|
20
|
+
# an expression SPDX rejects, such as "MIT WITH GPL-3.0-only", stays as the name the metadata gives
|
|
21
|
+
return [{"license": {"name": expression, "acknowledgement": "declared"}}]
|
|
22
|
+
names = [entry.rsplit(" :: ", 1)[-1] for entry in metadata.values("classifiers") if entry.startswith("License :: ")]
|
|
23
|
+
if (declared := metadata.first("license")) and "\n" not in declared:
|
|
24
|
+
names.append(declared)
|
|
25
|
+
found: Final[list[License]] = [{"license": {"name": name, "acknowledgement": "declared"}} for name in names]
|
|
26
|
+
# a license policy judges SPDX ids only, so the names stay bare when any one of them does not resolve, and the
|
|
27
|
+
# policy reports that instead of judging a partial set
|
|
28
|
+
identifiers = [identifier for name in names if (identifier := _spdx_license(name)) is not None]
|
|
29
|
+
if len(identifiers) == len(names):
|
|
30
|
+
found.extend(
|
|
31
|
+
{"license": {"id": identifier, "acknowledgement": "concluded"}} for identifier in dict.fromkeys(identifiers)
|
|
32
|
+
)
|
|
33
|
+
return found
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _spdx_license(name: str) -> str | None:
|
|
37
|
+
# only the license classifiers that name exactly one SPDX license; "BSD License" or "Apache Software License" leave
|
|
38
|
+
# the version or variant open
|
|
39
|
+
if identifier := {
|
|
40
|
+
"ISC License (ISCL)": "ISC",
|
|
41
|
+
"MIT License": "MIT",
|
|
42
|
+
"MIT No Attribution License (MIT-0)": "MIT-0",
|
|
43
|
+
"Mozilla Public License 2.0 (MPL 2.0)": "MPL-2.0",
|
|
44
|
+
"Python Software Foundation License": "PSF-2.0",
|
|
45
|
+
"The Unlicense (Unlicense)": "Unlicense",
|
|
46
|
+
}.get(name):
|
|
47
|
+
return identifier
|
|
48
|
+
try:
|
|
49
|
+
canonical = canonicalize_license_expression(name)
|
|
50
|
+
except InvalidLicenseExpression:
|
|
51
|
+
return None
|
|
52
|
+
# a CycloneDX license id holds one entry of the SPDX license list, never an expression or a LicenseRef
|
|
53
|
+
return canonical if re.fullmatch(r"[A-Za-z0-9.-]+", canonical) and not canonical.startswith("LicenseRef-") else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"licenses",
|
|
58
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Final
|
|
4
|
+
|
|
5
|
+
from packaging.markers import Marker, UndefinedComparison
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from packaging._parser import MarkerAtom, MarkerList # see _extra_verdict
|
|
9
|
+
from packaging.requirements import Requirement
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def applies_without_extras(requirement: Requirement) -> bool:
|
|
13
|
+
# the conditions on extra are decided with no extra requested and every other condition stays unknown, so the
|
|
14
|
+
# requirement applies unless its extra conditions alone rule it out; dep-logic reduces markers the same way, but it
|
|
15
|
+
# rejects extra comparisons other than == and != through assert, which python -O strips, and on import it replaces
|
|
16
|
+
# packaging's marker grammar for the whole build process
|
|
17
|
+
if requirement.marker is None:
|
|
18
|
+
return True
|
|
19
|
+
try:
|
|
20
|
+
verdict = _extra_verdict(requirement.marker._markers) # ruff: ignore[private-member-access] # see _extra_verdict
|
|
21
|
+
# packaging keeps the parsed tree private, so a release that moves or renames it leaves the requirement listed
|
|
22
|
+
# rather than dropping one that applies
|
|
23
|
+
except (AttributeError, ImportError):
|
|
24
|
+
return True
|
|
25
|
+
return verdict is not False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _extra_verdict(markers: MarkerList | MarkerAtom) -> bool | None:
|
|
29
|
+
from packaging._parser import Op, Value, Variable # ruff: ignore[import-private-name, import-outside-top-level] # packaging keeps the marker tree private, the caller handles its absence
|
|
30
|
+
|
|
31
|
+
# three-valued, with "and" binding tighter than "or", as packaging's own evaluation groups them
|
|
32
|
+
groups: list[list[bool | None]] = [[]]
|
|
33
|
+
for item in markers:
|
|
34
|
+
if item == "or":
|
|
35
|
+
groups.append([])
|
|
36
|
+
elif isinstance(item, list):
|
|
37
|
+
groups[-1].append(_extra_verdict(item))
|
|
38
|
+
elif isinstance(item, tuple):
|
|
39
|
+
match item:
|
|
40
|
+
case Variable(value="extra"), Op(value=operator), Value(value=value):
|
|
41
|
+
groups[-1].append(_no_extra_verdict(f"extra {operator} {value!r}"))
|
|
42
|
+
case Value(value=value), Op(value=operator), Variable(value="extra"):
|
|
43
|
+
groups[-1].append(_no_extra_verdict(f"{value!r} {operator} extra"))
|
|
44
|
+
case _:
|
|
45
|
+
groups[-1].append(None)
|
|
46
|
+
# ranked False < unknown < True: "and" takes the lowest verdict of a group, "or" the highest of the groups
|
|
47
|
+
verdicts: Final[tuple[bool | None, ...]] = (False, None, True)
|
|
48
|
+
return verdicts[max(min(map(verdicts.index, group), default=2) for group in groups)]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _no_extra_verdict(condition: str) -> bool | None:
|
|
52
|
+
try:
|
|
53
|
+
return Marker(condition).evaluate({"extra": ""})
|
|
54
|
+
# extra compared as a version, such as extra ~= "1.0", has no answer, so it stays unknown
|
|
55
|
+
except UndefinedComparison:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"applies_without_extras",
|
|
61
|
+
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from email.parser import HeaderParser
|
|
6
|
+
from email.policy import compat32
|
|
7
|
+
from typing import TYPE_CHECKING, Final, Literal, TypeAlias
|
|
8
|
+
|
|
9
|
+
from packaging.metadata import parse_email
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from packaging.metadata import RawMetadata
|
|
13
|
+
|
|
14
|
+
# the fields python-sbom reads, by their key in packaging's RawMetadata
|
|
15
|
+
Field: TypeAlias = Literal[
|
|
16
|
+
"author",
|
|
17
|
+
"author_email",
|
|
18
|
+
"classifiers",
|
|
19
|
+
"home_page",
|
|
20
|
+
"license",
|
|
21
|
+
"license_expression",
|
|
22
|
+
"license_files",
|
|
23
|
+
"maintainer",
|
|
24
|
+
"maintainer_email",
|
|
25
|
+
"name",
|
|
26
|
+
"provides_extra",
|
|
27
|
+
"requires_dist",
|
|
28
|
+
"requires_python",
|
|
29
|
+
"summary",
|
|
30
|
+
"version",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
# the empty line that ends the headers, as the email parser ends them; a line of whitespace continues a header
|
|
34
|
+
_BODY: Final[re.Pattern[str]] = re.compile(r"^\r?\n", re.MULTILINE)
|
|
35
|
+
# unparsed keys a field by its header name, lowercased, which is the RawMetadata key with dashes except for these
|
|
36
|
+
_HEADERS: Final[dict[str, str]] = {"classifiers": "classifier", "license_files": "license-file"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_metadata(text: str) -> CoreMetadata:
|
|
40
|
+
# not validated: the SBOM describes a distribution whose metadata breaks the specification as well, as written; the
|
|
41
|
+
# body holds the long description, which nothing reads, and parsing it took most of the time
|
|
42
|
+
headers: Final = text[: match.start()] if (match := _BODY.search(text)) else text
|
|
43
|
+
raw, unparsed = parse_email(headers)
|
|
44
|
+
return CoreMetadata(raw=raw, unparsed=unparsed, headers=headers)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
48
|
+
class CoreMetadata:
|
|
49
|
+
raw: RawMetadata
|
|
50
|
+
unparsed: dict[str, list[str]]
|
|
51
|
+
# the header block as written, for the values packaging reshapes
|
|
52
|
+
headers: str = ""
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def name(self) -> str:
|
|
56
|
+
return self.first("name") or ""
|
|
57
|
+
|
|
58
|
+
def first(self, field: Field) -> str | None:
|
|
59
|
+
return next(iter(self.values(field)), None)
|
|
60
|
+
|
|
61
|
+
def values(self, field: Field) -> list[str]:
|
|
62
|
+
# packaging keeps a field it cannot take as declared, such as a single-use field given twice, out of raw and its
|
|
63
|
+
# values as written in unparsed, from where they are still described
|
|
64
|
+
match self.raw.get(field):
|
|
65
|
+
case str() as value:
|
|
66
|
+
return [value]
|
|
67
|
+
case list() as values:
|
|
68
|
+
return values
|
|
69
|
+
return self.unparsed.get(_HEADERS.get(field, field.replace("_", "-")), [])
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def version(self) -> str:
|
|
73
|
+
return self.first("version") or ""
|
|
74
|
+
|
|
75
|
+
def keywords(self) -> list[str]:
|
|
76
|
+
# packaging splits Keywords at commas and strips each keyword, so the header is read again, as the distribution
|
|
77
|
+
# wrote it, only when one is there; packaging parses the block with the same policy
|
|
78
|
+
if "keywords" in self.raw:
|
|
79
|
+
return HeaderParser(policy=compat32).parsestr(self.headers).get_all("keywords", [])
|
|
80
|
+
return self.unparsed.get("keywords", [])
|
|
81
|
+
|
|
82
|
+
def project_urls(self) -> list[tuple[str, str]]:
|
|
83
|
+
# a label given twice sends every Project-URL to unparsed, where each splits at its first comma as packaging
|
|
84
|
+
# splits them; an entry without a URL names nothing to reference
|
|
85
|
+
pairs: Final[list[tuple[str, str]]] = (
|
|
86
|
+
list(self.raw["project_urls"].items())
|
|
87
|
+
if "project_urls" in self.raw
|
|
88
|
+
else [
|
|
89
|
+
(label.strip(), url.strip())
|
|
90
|
+
for label, _, url in (entry.partition(",") for entry in self.unparsed.get("project-url", []))
|
|
91
|
+
]
|
|
92
|
+
)
|
|
93
|
+
return [(label, url) for label, url in pairs if url]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
__all__ = [
|
|
97
|
+
"CoreMetadata",
|
|
98
|
+
"Field",
|
|
99
|
+
"parse_metadata",
|
|
100
|
+
]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Final
|
|
5
|
+
|
|
6
|
+
from packageurl import PackageURL
|
|
7
|
+
from packaging.utils import canonicalize_name
|
|
8
|
+
|
|
9
|
+
#: the metadata property in which a document names its own file; validation checks such documents in full
|
|
10
|
+
SBOM_FILE: Final[str] = "sbom:file"
|
|
11
|
+
#: the metadata property in which a document names the project's repository
|
|
12
|
+
SBOM_REPOSITORY: Final[str] = "sbom:repository"
|
|
13
|
+
# the label normalization and mapping cyclonedx-py applies to Project-URL entries, so the same metadata yields the same
|
|
14
|
+
# reference types whichever generator produced the document, plus the well-known labels of PEP 753 it lacks; funding
|
|
15
|
+
# labels have no CycloneDX type and fall to "other"
|
|
16
|
+
_URL_LABEL_TO_REFERENCE_TYPE: Final[dict[str, str]] = {
|
|
17
|
+
"bugtracker": "issue-tracker",
|
|
18
|
+
"issuetracker": "issue-tracker",
|
|
19
|
+
"issues": "issue-tracker",
|
|
20
|
+
"issue": "issue-tracker",
|
|
21
|
+
"bugs": "issue-tracker",
|
|
22
|
+
"bugreports": "issue-tracker",
|
|
23
|
+
"tracker": "issue-tracker",
|
|
24
|
+
"home": "website",
|
|
25
|
+
"homepage": "website",
|
|
26
|
+
"download": "distribution",
|
|
27
|
+
"documentation": "documentation",
|
|
28
|
+
"docs": "documentation",
|
|
29
|
+
"changelog": "release-notes",
|
|
30
|
+
"changes": "release-notes",
|
|
31
|
+
"whatsnew": "release-notes",
|
|
32
|
+
"history": "release-notes",
|
|
33
|
+
"releasenotes": "release-notes",
|
|
34
|
+
"source": "vcs",
|
|
35
|
+
"sourcecode": "vcs",
|
|
36
|
+
"repository": "vcs",
|
|
37
|
+
"github": "vcs",
|
|
38
|
+
"chat": "chat",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def sbom_property(project: str, key: str) -> str:
|
|
43
|
+
return f"{project}:{key}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def sbom_file_name(name: str) -> str:
|
|
47
|
+
return f"{property_prefix(name)}.cdx.json"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def property_prefix(name: str) -> str:
|
|
51
|
+
# the normalized project name, which prefixes the properties this package writes
|
|
52
|
+
return canonicalize_name(name)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def archive_sbom_file_name(archive: str) -> str:
|
|
56
|
+
# next to the archive, and inside it when embedded, where reading the archive again must skip it
|
|
57
|
+
return f"{archive}.cdx.json"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def purl(name: str, version: str | None = None) -> str:
|
|
61
|
+
# normalized as PEP 503 does, so every spelling of a project, jaraco.context or jaraco_context, gets one purl and a
|
|
62
|
+
# component's bom-ref agrees with its purl; packageurl only lowercases and turns underscores into dashes
|
|
63
|
+
return PackageURL(type="pypi", name=canonicalize_name(name), version=version).to_string()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def pypi_url(name: str, version: str | None = None) -> str:
|
|
67
|
+
return f"https://pypi.org/project/{name}/{version}/" if version else f"https://pypi.org/project/{name}/"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def tool_ref(ref: str) -> str:
|
|
71
|
+
# prefixed because the same distribution can be both a build tool and a component of the artifact
|
|
72
|
+
return f"tool:{ref}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def reference_type(label: str) -> str:
|
|
76
|
+
return _URL_LABEL_TO_REFERENCE_TYPE.get(re.sub(r"[^a-z]", "", label.lower()), "other")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = [
|
|
80
|
+
"SBOM_FILE",
|
|
81
|
+
"SBOM_REPOSITORY",
|
|
82
|
+
"archive_sbom_file_name",
|
|
83
|
+
"property_prefix",
|
|
84
|
+
"purl",
|
|
85
|
+
"pypi_url",
|
|
86
|
+
"reference_type",
|
|
87
|
+
"sbom_file_name",
|
|
88
|
+
"sbom_property",
|
|
89
|
+
"tool_ref",
|
|
90
|
+
]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Final
|
|
4
|
+
|
|
5
|
+
from packageurl import PackageURL
|
|
6
|
+
|
|
7
|
+
from ._components import component_from_metadata, file_component
|
|
8
|
+
from ._names import purl, tool_ref
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ._inputs import BuildFacts
|
|
12
|
+
from ._types import Component, Dependency
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def tool_components(facts: BuildFacts) -> tuple[list[Component], list[Dependency]]:
|
|
16
|
+
interpreter = facts.interpreter
|
|
17
|
+
package = PackageURL(type="generic", name=interpreter.name, version=interpreter.version).to_string()
|
|
18
|
+
tools: Final[list[Component]] = [
|
|
19
|
+
{
|
|
20
|
+
"type": "platform",
|
|
21
|
+
"bom-ref": tool_ref(package),
|
|
22
|
+
"name": interpreter.name,
|
|
23
|
+
"version": interpreter.version,
|
|
24
|
+
"purl": package,
|
|
25
|
+
"properties": [{"name": "python:implementation", "value": interpreter.implementation}],
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
refs: Final[dict[str, str]] = {
|
|
29
|
+
distribution.name: tool_ref(purl(distribution.name, distribution.metadata.version))
|
|
30
|
+
for distribution in facts.distributions
|
|
31
|
+
}
|
|
32
|
+
dependencies: Final[list[Dependency]] = []
|
|
33
|
+
for distribution in sorted(facts.distributions, key=lambda entry: entry.name):
|
|
34
|
+
component = component_from_metadata(distribution.metadata)
|
|
35
|
+
component["bom-ref"] = refs[distribution.name]
|
|
36
|
+
if distribution.files:
|
|
37
|
+
component["components"] = [file_component(component["bom-ref"], row) for row in distribution.files]
|
|
38
|
+
tools.append(component)
|
|
39
|
+
dependencies.append({
|
|
40
|
+
"ref": component["bom-ref"],
|
|
41
|
+
"dependsOn": sorted(refs[name] for name in distribution.depends_on),
|
|
42
|
+
})
|
|
43
|
+
return tools, dependencies
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"tool_components",
|
|
48
|
+
]
|