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,155 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import warnings
|
|
9
|
+
from functools import cache
|
|
10
|
+
from importlib.metadata import distributions
|
|
11
|
+
from itertools import takewhile
|
|
12
|
+
from typing import TYPE_CHECKING, Final
|
|
13
|
+
|
|
14
|
+
from installer.records import InvalidRecordEntry, parse_record_file
|
|
15
|
+
from packaging.direct_url import DirectUrl, DirectUrlValidationError
|
|
16
|
+
from packaging.requirements import InvalidRequirement, Requirement
|
|
17
|
+
from packaging.utils import canonicalize_name
|
|
18
|
+
|
|
19
|
+
from python_sbom._errors import SbomError
|
|
20
|
+
from python_sbom._model._inputs import BuildFacts, InstalledDistribution, Interpreter, RecordRow
|
|
21
|
+
from python_sbom._model._metadata import parse_metadata
|
|
22
|
+
from python_sbom._wheel._record import record_rows
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Container, Sequence
|
|
26
|
+
from importlib.metadata import Distribution
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from python_sbom._model._metadata import CoreMetadata
|
|
30
|
+
|
|
31
|
+
from ._revision import RevisionReader
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def collect_facts(
|
|
35
|
+
root: Path, revision: RevisionReader, *, path: Sequence[str] | None = None, default_epoch: int | None = None
|
|
36
|
+
) -> BuildFacts:
|
|
37
|
+
if from_environment := bool(raw := os.environ.get("SOURCE_DATE_EPOCH", "")):
|
|
38
|
+
try:
|
|
39
|
+
epoch = int(raw)
|
|
40
|
+
except ValueError as error:
|
|
41
|
+
msg = f"SOURCE_DATE_EPOCH must be a Unix timestamp, got {raw!r}"
|
|
42
|
+
raise SbomError(msg) from error
|
|
43
|
+
else:
|
|
44
|
+
epoch = int(time.time()) if default_epoch is None else default_epoch
|
|
45
|
+
return BuildFacts(
|
|
46
|
+
epoch=epoch,
|
|
47
|
+
epoch_from_environment=from_environment,
|
|
48
|
+
revision=revision(root),
|
|
49
|
+
interpreter=Interpreter(
|
|
50
|
+
name=sys.implementation.name,
|
|
51
|
+
version=platform.python_version(),
|
|
52
|
+
implementation=platform.python_implementation(),
|
|
53
|
+
),
|
|
54
|
+
distributions=_installed(path),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _installed(path: Sequence[str] | None) -> list[InstalledDistribution]:
|
|
59
|
+
found: Final[dict[str, tuple[Distribution, CoreMetadata]]] = {}
|
|
60
|
+
for distribution in distributions(path=list(path)) if path is not None else distributions():
|
|
61
|
+
# where Distribution.metadata looks, a .dist-info's METADATA, then an .egg-info's PKG-INFO or the file itself
|
|
62
|
+
text = distribution.read_text("METADATA") or distribution.read_text("PKG-INFO") or distribution.read_text("")
|
|
63
|
+
# the first on the path is the one imports load, so a shadowed copy further down is left out
|
|
64
|
+
if (metadata := parse_metadata(text or "")).name and metadata.version:
|
|
65
|
+
found.setdefault(canonicalize_name(metadata.name), (distribution, metadata))
|
|
66
|
+
return [
|
|
67
|
+
InstalledDistribution(
|
|
68
|
+
name=name,
|
|
69
|
+
metadata=metadata,
|
|
70
|
+
files=_recorded_files(distribution),
|
|
71
|
+
depends_on=_depends_on(name, distribution, metadata, found),
|
|
72
|
+
)
|
|
73
|
+
for name, (distribution, metadata) in found.items()
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _recorded_files(distribution: Distribution) -> list[RecordRow]:
|
|
78
|
+
# a platform wheel's files are built for this machine's OS and architecture, and an editable install's point at a
|
|
79
|
+
# checkout, so listing either would tie the document to one machine
|
|
80
|
+
# Root-Is-Purelib as the email parser takes a field, its first occurrence among the header lines, as written after
|
|
81
|
+
# the colon, without parsing the rest of WHEEL
|
|
82
|
+
headers = takewhile(bool, (distribution.read_text("WHEEL") or "").splitlines())
|
|
83
|
+
fields = (line.partition(":") for line in headers)
|
|
84
|
+
purelib = next(
|
|
85
|
+
(
|
|
86
|
+
value.lstrip(" \t") == "true"
|
|
87
|
+
for field, colon, value in fields
|
|
88
|
+
if colon and field.lower() == "root-is-purelib"
|
|
89
|
+
),
|
|
90
|
+
False,
|
|
91
|
+
)
|
|
92
|
+
if not purelib or _editable(distribution):
|
|
93
|
+
return []
|
|
94
|
+
# RECORD read once and as written: Distribution.files would stat every row to drop files missing on disk, and build
|
|
95
|
+
# a path object for each, while the SBOM lists what the install recorded
|
|
96
|
+
rows: Final = parse_record_file((distribution.read_text("RECORD") or "").splitlines())
|
|
97
|
+
try:
|
|
98
|
+
return record_rows((row for row in rows if _describes_distribution(row[0])), distribution.name)
|
|
99
|
+
except InvalidRecordEntry as error:
|
|
100
|
+
warnings.warn(f"listed no files of {distribution.name}, its RECORD is malformed: {error}", stacklevel=2)
|
|
101
|
+
return []
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _editable(distribution: Distribution) -> bool:
|
|
105
|
+
if (text := distribution.read_text("direct_url.json")) is None:
|
|
106
|
+
return False
|
|
107
|
+
try:
|
|
108
|
+
dir_info = DirectUrl.from_dict(json.loads(text)).dir_info
|
|
109
|
+
# an installer that wrote the file wrong breaks no build: its files get listed, as for any other install
|
|
110
|
+
except (ValueError, DirectUrlValidationError) as error:
|
|
111
|
+
warnings.warn(f"read the direct_url.json of {distribution.name} as not editable: {error}", stacklevel=2)
|
|
112
|
+
return False
|
|
113
|
+
return dir_info is not None and dir_info.editable is True
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _describes_distribution(path: str) -> bool:
|
|
117
|
+
# console-script launchers live outside site-packages and embed the environment's interpreter path; the installer
|
|
118
|
+
# metadata names the frontend; uv's cache files hold paths and times; none of them says anything about the package
|
|
119
|
+
directory, _, name = path.rpartition("/")
|
|
120
|
+
return not (
|
|
121
|
+
path.startswith("../")
|
|
122
|
+
or (directory.endswith(".dist-info") and name in {"INSTALLER", "REQUESTED", "direct_url.json"})
|
|
123
|
+
or (name.startswith("uv_") and name.endswith(".json"))
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _depends_on(name: str, distribution: Distribution, metadata: CoreMetadata, installed: Container[str]) -> list[str]:
|
|
128
|
+
names: Final[set[str]] = set()
|
|
129
|
+
# an .egg-info states its requirements in requires.txt, which Distribution.requires reads
|
|
130
|
+
for line in metadata.values("requires_dist") or distribution.requires or []:
|
|
131
|
+
match _required(line):
|
|
132
|
+
case InvalidRequirement() as error:
|
|
133
|
+
# old distributions declare requirements, such as "pytz (>dev)", that today's syntax rejects
|
|
134
|
+
warnings.warn(f"left out the requirement {line!r} of {name}: {error}", stacklevel=2)
|
|
135
|
+
case str() as required if required in installed:
|
|
136
|
+
names.add(required)
|
|
137
|
+
return sorted(names)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@cache
|
|
141
|
+
def _required(line: str) -> str | InvalidRequirement | None:
|
|
142
|
+
# the normalized name a requirement needs here, None when its marker rules it out, or why it does not parse; the
|
|
143
|
+
# same requirements recur across a build environment's distributions, and the verdict holds for the process
|
|
144
|
+
try:
|
|
145
|
+
requirement = Requirement(line)
|
|
146
|
+
except InvalidRequirement as error:
|
|
147
|
+
return error
|
|
148
|
+
if requirement.marker is not None and not requirement.marker.evaluate():
|
|
149
|
+
return None
|
|
150
|
+
return canonicalize_name(requirement.name)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
__all__ = [
|
|
154
|
+
"collect_facts",
|
|
155
|
+
]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess # ruff: ignore[suspicious-subprocess-import] # runs git to read the source revision
|
|
7
|
+
import warnings
|
|
8
|
+
from typing import TYPE_CHECKING, Final, Protocol
|
|
9
|
+
|
|
10
|
+
from python_sbom._errors import SbomError
|
|
11
|
+
from python_sbom._model._inputs import Revision
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
#: the file an sdist carries its source revision in, as ``node: <commit>``, for the wheel built from it
|
|
17
|
+
REVISION_FILE: Final[str] = ".python-sbom-revision"
|
|
18
|
+
_COMMIT: Final[re.Pattern[str]] = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RevisionReader(Protocol):
|
|
22
|
+
def __call__(self, root: Path, /) -> Revision | None: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def revision_reader(commit: str | None) -> RevisionReader:
|
|
26
|
+
# a commit given on the command line stands for the checkout, so git and the files go unread
|
|
27
|
+
if commit is None:
|
|
28
|
+
return default_revision
|
|
29
|
+
revision = _commit_revision(commit, "--commit")
|
|
30
|
+
return lambda _root: revision
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def default_revision(root: Path, /) -> Revision | None:
|
|
34
|
+
# the variable exists for builds from an sdist, so it wins over whatever checkout the sdist was unpacked into
|
|
35
|
+
if commit := os.environ.get("PYTHON_SBOM_COMMIT"):
|
|
36
|
+
return _commit_revision(commit, "PYTHON_SBOM_COMMIT")
|
|
37
|
+
if (found := _git_revision(root)) is not None:
|
|
38
|
+
return found
|
|
39
|
+
# the sdist build wrote it from the revision it resolved, so it is at least as exact as an archival file
|
|
40
|
+
if (recorded := root / REVISION_FILE).is_file():
|
|
41
|
+
return _commit_revision(recorded.read_text(encoding="utf-8").strip().removeprefix("node: "), REVISION_FILE)
|
|
42
|
+
archival = root / ".git_archival.txt"
|
|
43
|
+
if archival.is_file() and (
|
|
44
|
+
match := re.search(rf"^node: ({_COMMIT.pattern})$", archival.read_text(encoding="utf-8"), re.MULTILINE)
|
|
45
|
+
):
|
|
46
|
+
return Revision(commit=match[1], dirty=False)
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _git_revision(root: Path, /) -> Revision | None:
|
|
51
|
+
resolved = root.resolve()
|
|
52
|
+
if (git := shutil.which("git")) is None or not any(
|
|
53
|
+
(directory / ".git").exists() for directory in (resolved, *resolved.parents)
|
|
54
|
+
):
|
|
55
|
+
return None
|
|
56
|
+
try:
|
|
57
|
+
# a project unpacked into another checkout, such as an sdist under its build directory, is not its source
|
|
58
|
+
_git(git, root, "ls-files", "--error-unmatch", "--", "pyproject.toml")
|
|
59
|
+
commit = _git(git, root, "rev-parse", "--verify", "--quiet", "HEAD")
|
|
60
|
+
# untracked files count, since the build packages every file .gitignore lets through
|
|
61
|
+
changes = _git(git, root, "status", "--porcelain", "--", ".")
|
|
62
|
+
except subprocess.CalledProcessError as error:
|
|
63
|
+
if error.returncode != 1: # 1: the project is untracked, or the checkout has no commit yet
|
|
64
|
+
warnings.warn(f"git cannot read the source revision in {root}: {error.stderr.strip()}", stacklevel=2)
|
|
65
|
+
return None
|
|
66
|
+
return Revision(commit=commit, dirty=bool(changes))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _git(git: str, root: Path, *arguments: str) -> str:
|
|
70
|
+
# git first runs in whichever repository encloses the project, whose fsmonitor hook would run a command from its
|
|
71
|
+
# config; no optional locks, so status never rewrites the index under another git process
|
|
72
|
+
return subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] # git from PATH with fixed arguments
|
|
73
|
+
[git, "-c", "core.fsmonitor=false", "--no-optional-locks", "-C", str(root), *arguments],
|
|
74
|
+
check=True,
|
|
75
|
+
capture_output=True,
|
|
76
|
+
text=True,
|
|
77
|
+
).stdout.strip()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _commit_revision(commit: str, origin: str) -> Revision:
|
|
81
|
+
# the commit lands in the source link, so anything but a full hash could point the link elsewhere
|
|
82
|
+
if not _COMMIT.fullmatch(commit):
|
|
83
|
+
msg = f"{origin} must be a full lowercase hex commit hash of 40 or 64 characters, got {commit!r}"
|
|
84
|
+
raise SbomError(msg)
|
|
85
|
+
return Revision(commit=commit, dirty=False)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
__all__ = [
|
|
89
|
+
"REVISION_FILE",
|
|
90
|
+
"RevisionReader",
|
|
91
|
+
"default_revision",
|
|
92
|
+
"revision_reader",
|
|
93
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from typing import TYPE_CHECKING, TypeAlias
|
|
5
|
+
|
|
6
|
+
from python_sbom._errors import SbomError
|
|
7
|
+
from python_sbom._model._inputs import Settings
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from datetime import date, datetime, time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TypeIs # new in 3.13; annotations here are never evaluated
|
|
14
|
+
|
|
15
|
+
from python_sbom._model._types import OrganizationalEntity
|
|
16
|
+
|
|
17
|
+
#: a value of a TOML table, as :mod:`tomllib` or hatchling hand it over
|
|
18
|
+
Toml: TypeAlias = "str | int | float | bool | datetime | date | time | list[Toml] | dict[str, Toml]"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def settings_from_pyproject(root: Path) -> Settings:
|
|
22
|
+
pyproject = root / "pyproject.toml"
|
|
23
|
+
try:
|
|
24
|
+
loaded = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
|
25
|
+
except FileNotFoundError as error:
|
|
26
|
+
msg = f"{root} has no pyproject.toml to read [tool.python-sbom] from"
|
|
27
|
+
raise SbomError(msg) from error
|
|
28
|
+
except tomllib.TOMLDecodeError as error:
|
|
29
|
+
msg = f"{pyproject} is not valid TOML: {error}"
|
|
30
|
+
raise SbomError(msg) from error
|
|
31
|
+
return settings_from_table(loaded.get("tool", {}).get("python-sbom", {}), f"[tool.python-sbom] of {pyproject}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def settings_from_table(table: Mapping[str, Toml], where: str) -> Settings:
|
|
35
|
+
known = {"bundled-wheels", "content-provider", "default-branch", "repository", "supplier"}
|
|
36
|
+
if unknown := sorted(table.keys() - known):
|
|
37
|
+
msg = f"{where} has unknown option(s) {', '.join(unknown)}; known: {', '.join(sorted(known))}"
|
|
38
|
+
raise SbomError(msg)
|
|
39
|
+
if not isinstance(repository := table.get("repository"), str | None):
|
|
40
|
+
msg = f"`repository` in {where} must be a string"
|
|
41
|
+
raise SbomError(msg)
|
|
42
|
+
if not isinstance(default_branch := table.get("default-branch", "main"), str):
|
|
43
|
+
msg = f"`default-branch` in {where} must be a string"
|
|
44
|
+
raise SbomError(msg)
|
|
45
|
+
if not _strings(patterns := table.get("bundled-wheels", [])):
|
|
46
|
+
msg = f"`bundled-wheels` in {where} must be an array of strings"
|
|
47
|
+
raise SbomError(msg)
|
|
48
|
+
if not isinstance(content_provider := table.get("content-provider"), str | None):
|
|
49
|
+
msg = f"`content-provider` in {where} must be a string, `module:attribute`"
|
|
50
|
+
raise SbomError(msg)
|
|
51
|
+
return Settings(
|
|
52
|
+
supplier=_supplier(table.get("supplier"), where),
|
|
53
|
+
repository=None if repository is None else repository.rstrip("/"),
|
|
54
|
+
default_branch=default_branch,
|
|
55
|
+
bundled_wheels=tuple(patterns),
|
|
56
|
+
content_provider=content_provider,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _strings(values: object) -> TypeIs[list[str]]:
|
|
61
|
+
return isinstance(values, list) and all(isinstance(value, str) for value in values)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _supplier(value: Toml | None, where: str) -> OrganizationalEntity | None:
|
|
65
|
+
if value is None:
|
|
66
|
+
return None
|
|
67
|
+
if (
|
|
68
|
+
isinstance(value, dict)
|
|
69
|
+
and value.keys() <= {"name", "url"}
|
|
70
|
+
and isinstance(name := value.get("name"), str)
|
|
71
|
+
and _strings(urls := value.get("url", []))
|
|
72
|
+
):
|
|
73
|
+
return {"name": name, "url": urls}
|
|
74
|
+
msg = f"`supplier` in {where} must be a table with a string `name` and an optional `url` string array"
|
|
75
|
+
raise SbomError(msg)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
__all__ = [
|
|
79
|
+
"Toml",
|
|
80
|
+
"settings_from_pyproject",
|
|
81
|
+
"settings_from_table",
|
|
82
|
+
]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import PurePath
|
|
4
|
+
from typing import TYPE_CHECKING, Final
|
|
5
|
+
|
|
6
|
+
from python_sbom._errors import SbomError
|
|
7
|
+
from python_sbom._model._inputs import BuildScript
|
|
8
|
+
from python_sbom._wheel._zip import sha256_hex
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
from pathlib import Path, PurePosixPath
|
|
13
|
+
|
|
14
|
+
from python_sbom._model._inputs import Settings
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def copyright_notice(root: Path, license_files: list[str]) -> str | None:
|
|
18
|
+
# the first Copyright line of the license files the project names, as they sit in the project
|
|
19
|
+
for license_file in license_files:
|
|
20
|
+
if (path := root / license_file).is_file():
|
|
21
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
22
|
+
if line.startswith("Copyright"):
|
|
23
|
+
return line
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def selected_wheels(root: Path, settings: Settings) -> list[str]:
|
|
28
|
+
wheels: Final[set[str]] = set()
|
|
29
|
+
for pattern in settings.bundled_wheels:
|
|
30
|
+
# a wheel outside the project is none the project ships, and pathlib refuses an absolute pattern outright
|
|
31
|
+
if (path := PurePath(pattern)).anchor or ".." in path.parts:
|
|
32
|
+
msg = f"`bundled-wheels` pattern {pattern!r} must be relative to the project, without `..`"
|
|
33
|
+
raise SbomError(msg)
|
|
34
|
+
wheels.update(wheel.relative_to(root).as_posix() for wheel in root.glob(pattern))
|
|
35
|
+
return sorted(wheels)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def hash_build_scripts(root: Path, paths: Iterable[PurePosixPath]) -> list[BuildScript]:
|
|
39
|
+
# str, so a Windows drive in an absolute path survives the join with the native root; a script named twice, by the
|
|
40
|
+
# caller and by the provider, is listed once
|
|
41
|
+
return [BuildScript(path=path, sha256=file_sha256(root / str(path))) for path in dict.fromkeys(paths)]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def file_sha256(path: Path) -> str:
|
|
45
|
+
with path.open("rb") as stream:
|
|
46
|
+
return sha256_hex(stream)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"copyright_notice",
|
|
51
|
+
"file_sha256",
|
|
52
|
+
"hash_build_scripts",
|
|
53
|
+
"selected_wheels",
|
|
54
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Final
|
|
4
|
+
|
|
5
|
+
from python_sbom._errors import SbomError
|
|
6
|
+
from python_sbom._model._inputs import WheelTree
|
|
7
|
+
from python_sbom._wheel._zip import read_wheel_metadata
|
|
8
|
+
|
|
9
|
+
from ._source import copyright_notice, file_sha256, hash_build_scripts, selected_wheels
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Iterable, Iterator
|
|
13
|
+
from pathlib import Path, PurePosixPath
|
|
14
|
+
|
|
15
|
+
from python_sbom._model._inputs import Settings
|
|
16
|
+
from python_sbom._wheel._reader import ArtifactReader
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def read_wheel(
|
|
20
|
+
artifact: ArtifactReader, root: Path, settings: Settings, build_scripts: Iterable[PurePosixPath]
|
|
21
|
+
) -> WheelTree:
|
|
22
|
+
_, metadata = read_wheel_metadata(artifact.archive, str(artifact.path))
|
|
23
|
+
return WheelTree(
|
|
24
|
+
name=artifact.path.name,
|
|
25
|
+
metadata=metadata,
|
|
26
|
+
copyright=copyright_notice(root, metadata.values("license_files")),
|
|
27
|
+
bundled=artifact.bundled_wheels(_shipped_copies(root, settings, artifact)),
|
|
28
|
+
build_scripts=hash_build_scripts(root, build_scripts),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _shipped_copies(root: Path, settings: Settings, artifact: ArtifactReader) -> list[str]:
|
|
33
|
+
# the bundled-wheels option selects wheels in the source tree, but the SBOM describes the bytes the artifact ships:
|
|
34
|
+
# each selected wheel is found in the artifact by its digest and read from there, so a wheel changed or left out
|
|
35
|
+
# since the build is refused rather than described; identical copies take the artifact's matching members in turn
|
|
36
|
+
shipped: Final[dict[str, list[str]]] = {}
|
|
37
|
+
for name in sorted(artifact.members):
|
|
38
|
+
if name.endswith(".whl"):
|
|
39
|
+
shipped.setdefault(artifact.digest(name), []).append(name)
|
|
40
|
+
copies: Final[dict[str, Iterator[str]]] = {digest: iter(names) for digest, names in shipped.items()}
|
|
41
|
+
found: Final[list[str]] = []
|
|
42
|
+
for source in selected_wheels(root, settings):
|
|
43
|
+
if (member := next(copies.get(file_sha256(root / source), iter(())), None)) is None:
|
|
44
|
+
msg = (
|
|
45
|
+
f"bundled wheel {source} is not in {artifact.path.name} byte for byte: it changed since the build, or"
|
|
46
|
+
" the build left it out"
|
|
47
|
+
)
|
|
48
|
+
raise SbomError(msg)
|
|
49
|
+
found.append(member)
|
|
50
|
+
return found
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"read_wheel",
|
|
55
|
+
]
|
python_sbom/_errors.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SbomError(Exception):
|
|
5
|
+
"""Base of the errors this package raises; the command line reports them without a traceback."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# validation reports every other error per document, and lets this one through, since it fails every document alike
|
|
9
|
+
class MissingExtraError(SbomError): ... # simplint: ignore[SMP704] # see above
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"MissingExtraError",
|
|
14
|
+
"SbomError",
|
|
15
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Serialization of documents: CycloneDX JSON and the SPDX 2.3 rendering."""
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from python_sbom._model._types import Document
|
|
8
|
+
|
|
9
|
+
from ._spdx import Spdx
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def dumps(document: Document | Spdx) -> bytes:
|
|
13
|
+
return f"{json.dumps(document, indent=2)}\n".encode()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"dumps",
|
|
18
|
+
]
|