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,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
+ ]