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