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,46 @@
1
+ """SBOMs for Python distributions: write PEP 770 CycloneDX SBOMs into wheels."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._errors import SbomError
6
+ from ._model._content import BundledWheelInfo, ContentProvider, Contributions, ProviderContext
7
+ from ._model._identity import ProjectIdentity
8
+ from ._model._inputs import Settings
9
+ from ._model._types import (
10
+ Component,
11
+ Contact,
12
+ Dependency,
13
+ Evidence,
14
+ ExternalReference,
15
+ Hash,
16
+ Identity,
17
+ IdentityMethod,
18
+ License,
19
+ LicenseInfo,
20
+ OrganizationalEntity,
21
+ Property,
22
+ )
23
+ from .version import __version__
24
+
25
+ __all__ = [
26
+ "BundledWheelInfo",
27
+ "Component",
28
+ "Contact",
29
+ "ContentProvider",
30
+ "Contributions",
31
+ "Dependency",
32
+ "Evidence",
33
+ "ExternalReference",
34
+ "Hash",
35
+ "Identity",
36
+ "IdentityMethod",
37
+ "License",
38
+ "LicenseInfo",
39
+ "OrganizationalEntity",
40
+ "ProjectIdentity",
41
+ "Property",
42
+ "ProviderContext",
43
+ "SbomError",
44
+ "Settings",
45
+ "__version__",
46
+ ]
@@ -0,0 +1,10 @@
1
+ """Main entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from ._cli._main import main
8
+
9
+ if __name__ == "__main__":
10
+ main(sys.argv[1:])
@@ -0,0 +1 @@
1
+ """The command line: parsing options and running one command per subcommand."""
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path, PurePosixPath
5
+ from typing import Protocol
6
+
7
+ from python_sbom._generate._archive import archive_sbom
8
+
9
+ from ._parser import BuildOptions
10
+
11
+
12
+ class _Options(BuildOptions, Protocol):
13
+ archive: Path
14
+ build_scripts: list[str] | None
15
+ output: Path | None
16
+ embed: bool
17
+
18
+
19
+ def run(options: _Options) -> bool:
20
+ target, embedded = archive_sbom(
21
+ Path.cwd() if options.root is None else options.root,
22
+ options.archive,
23
+ provider=options.content_provider,
24
+ commit=options.commit,
25
+ build_env=options.build_env,
26
+ build_scripts=tuple(PurePosixPath(script) for script in options.build_scripts or []),
27
+ output=options.output,
28
+ embed=options.embed,
29
+ )
30
+ sys.stdout.write(f"wrote {target}\n")
31
+ if embedded is not None:
32
+ sys.stdout.write(f"wrote {embedded} into {options.archive.name}\n")
33
+ return True
34
+
35
+
36
+ __all__ = [
37
+ "run", # simplint: ignore[SMP402] # the parser names it for pkgutil.resolve_name
38
+ ]
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Protocol
6
+
7
+ from python_sbom._generate._embed import embed_sbom
8
+
9
+ from ._parser import BuildOptions
10
+
11
+
12
+ class _Options(BuildOptions, Protocol):
13
+ wheels: list[Path]
14
+
15
+
16
+ def run(options: _Options) -> bool:
17
+ root = Path.cwd() if options.root is None else options.root
18
+ for wheel in options.wheels:
19
+ written = embed_sbom(
20
+ wheel, root, provider=options.content_provider, commit=options.commit, build_env=options.build_env
21
+ )
22
+ sys.stdout.write(f"wrote {written} into {wheel.name}\n")
23
+ return True
24
+
25
+
26
+ __all__ = [
27
+ "run", # simplint: ignore[SMP402] # the parser names it for pkgutil.resolve_name
28
+ ]
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ import pkgutil
4
+ import sys
5
+ from typing import TYPE_CHECKING
6
+
7
+ from python_sbom._errors import SbomError
8
+
9
+ from ._parser import build_parser
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Sequence
13
+
14
+
15
+ def main(args: Sequence[str] | None = None) -> None:
16
+ options = build_parser().parse_args(args)
17
+ try:
18
+ succeeded = pkgutil.resolve_name(options.handler)(options)
19
+ except SbomError as error:
20
+ sys.stderr.write(f"python-sbom: {error}\n")
21
+ succeeded = False
22
+ if not succeeded:
23
+ raise SystemExit(1)
24
+
25
+
26
+ __all__ = [
27
+ "main",
28
+ ]
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ from argparse import ArgumentParser, ArgumentTypeError, _SubParsersAction
4
+ from pathlib import Path
5
+ from typing import Protocol
6
+
7
+ # the limits module imports nothing, so --help and --version load none of validation
8
+ from python_sbom._validate._limits import MAX_COMPONENTS
9
+ from python_sbom.version import __version__
10
+
11
+
12
+ def build_parser() -> ArgumentParser:
13
+ parser = ArgumentParser(prog="python-sbom", description="Write, convert and check the CycloneDX SBOMs of wheels.")
14
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
15
+ commands = parser.add_subparsers(required=True)
16
+ _add_embed(commands)
17
+ _add_archive(commands)
18
+ _add_spdx(commands)
19
+ _add_validate(commands)
20
+ return parser
21
+
22
+
23
+ def _add_embed(commands: _SubParsersAction[ArgumentParser]) -> None:
24
+ parser = commands.add_parser(
25
+ "embed",
26
+ help="write the SBOM into wheels another build backend built",
27
+ description="Write the SBOM the hatchling hook writes into wheels another build backend built, such as"
28
+ " meson-python. Options come from the [tool.python-sbom] table of the project's pyproject.toml.",
29
+ )
30
+ # a name, not the function, so each command imports only what it runs and --help imports none of it
31
+ parser.set_defaults(handler="python_sbom._cli._embed:run")
32
+ parser.add_argument("wheels", type=Path, nargs="+", metavar="WHEEL", help="wheel to rewrite in place")
33
+ _add_build_options(parser)
34
+
35
+
36
+ def _add_build_options(parser: ArgumentParser) -> None:
37
+ parser.add_argument(
38
+ "--root", type=Path, metavar="DIR", help="project directory of the artifact (default: current directory)"
39
+ )
40
+ parser.add_argument(
41
+ "--commit", metavar="SHA", help="source revision to record, for an artifact built from an sdist"
42
+ )
43
+ parser.add_argument(
44
+ "--build-env",
45
+ action="append",
46
+ metavar="DIR",
47
+ help="site-packages directory of the environment that built the artifact; repeat it per directory"
48
+ " (default: this interpreter's)",
49
+ )
50
+ parser.add_argument(
51
+ "--content-provider",
52
+ metavar="MODULE:ATTRIBUTE",
53
+ help="content provider, overriding the content-provider option",
54
+ )
55
+
56
+
57
+ def _add_archive(commands: _SubParsersAction[ArgumentParser]) -> None:
58
+ parser = commands.add_parser(
59
+ "archive",
60
+ help="write the SBOM of a zip archive that is not a wheel, such as a zipapp",
61
+ description="Describe a zip archive that holds the project's .dist-info at its top level, such as a zipapp:"
62
+ " the archive's own files, the project's distribution, and every wheel it bundles, as a file or unpacked,"
63
+ " described as the wheel's SBOM describes them. Options come from [tool.python-sbom].",
64
+ )
65
+ parser.set_defaults(handler="python_sbom._cli._archive:run")
66
+ parser.add_argument("archive", type=Path, metavar="ARCHIVE", help="archive to describe")
67
+ _add_build_options(parser)
68
+ parser.add_argument(
69
+ "--build-script",
70
+ dest="build_scripts",
71
+ action="append",
72
+ metavar="PATH",
73
+ help="script, relative to the project, that built the archive; repeat it per script",
74
+ )
75
+ parser.add_argument(
76
+ "--output", type=Path, metavar="FILE", help="SBOM file to write (default: <archive>.cdx.json next to it)"
77
+ )
78
+ parser.add_argument(
79
+ "--embed", action="store_true", help="also add the SBOM to the archive, at its top level as <archive>.cdx.json"
80
+ )
81
+
82
+
83
+ def _add_spdx(commands: _SubParsersAction[ArgumentParser]) -> None:
84
+ parser = commands.add_parser(
85
+ "spdx",
86
+ help="render a CycloneDX SBOM as SPDX 2.3 JSON",
87
+ description="Render a CycloneDX JSON SBOM, such as one python-sbom wrote, as an SPDX 2.3 JSON document. It"
88
+ " checks only the fields the rendering reads, so it needs no extra; python-sbom validate checks the whole"
89
+ " document.",
90
+ )
91
+ parser.set_defaults(handler="python_sbom._cli._spdx:run")
92
+ parser.add_argument("source", type=Path, metavar="SOURCE", help="CycloneDX JSON file to read")
93
+ parser.add_argument("target", type=Path, metavar="TARGET", help="SPDX JSON file to write")
94
+
95
+
96
+ def _add_validate(commands: _SubParsersAction[ArgumentParser]) -> None:
97
+ parser = commands.add_parser(
98
+ "validate",
99
+ help="validate the SBOMs in wheels and archives",
100
+ description="Check every CycloneDX SBOM a wheel or archive holds against the CycloneDX 1.6 schema; for one"
101
+ " python-sbom wrote, also its serial number, references, SPDX rendering and, with --allow-license, the licenses"
102
+ " of what it ships. Needs the validate extra: python-sbom[validate].",
103
+ )
104
+ parser.set_defaults(handler="python_sbom._cli._validate:run")
105
+ parser.add_argument(
106
+ "paths", type=Path, nargs="+", metavar="PATH", help="wheel, archive, or directory holding wheels"
107
+ )
108
+ parser.add_argument(
109
+ "--allow-license",
110
+ dest="allowed_licenses",
111
+ action="append",
112
+ metavar="SPDX-ID",
113
+ help="SPDX license id shipped components may use; repeat it per id, leave it out to skip the license policy",
114
+ )
115
+ parser.add_argument(
116
+ "--max-components",
117
+ type=_positive_int,
118
+ default=MAX_COMPONENTS,
119
+ metavar="N",
120
+ help="reject an SBOM listing more than N components, nested ones included, or larger than 1 MiB plus 1 KiB per"
121
+ " component, without checking it further, which bounds the work an untrusted SBOM can ask for (default:"
122
+ f" {MAX_COMPONENTS})",
123
+ )
124
+
125
+
126
+ def _positive_int(text: str) -> int:
127
+ # argparse reports the error through parser.error, with the usage, like any other bad argument
128
+ if text.isdecimal() and (number := int(text)) > 0:
129
+ return number
130
+ msg = f"must be a positive integer, got {text!r}"
131
+ raise ArgumentTypeError(msg)
132
+
133
+
134
+ class BuildOptions(Protocol):
135
+ root: Path | None
136
+ commit: str | None
137
+ build_env: list[str] | None
138
+ content_provider: str | None
139
+
140
+
141
+ __all__ = [
142
+ "BuildOptions",
143
+ "build_parser",
144
+ ]
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Protocol
4
+
5
+ from python_sbom._errors import SbomError
6
+ from python_sbom._formats._json import dumps
7
+ from python_sbom._formats._spdx import render_spdx
8
+ from python_sbom._validate._document import is_document, load_json
9
+ from python_sbom._validate._limits import MAX_COMPONENTS, byte_limit
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+ from python_sbom._model._types import Document
15
+
16
+
17
+ class _Options(Protocol):
18
+ source: Path
19
+ target: Path
20
+
21
+
22
+ def run(options: _Options) -> bool:
23
+ document = _read(options.source)
24
+ try:
25
+ rendered = dumps(render_spdx(document))
26
+ # ValueError: a timestamp fromisoformat rejects; AttributeError: a tools value that is neither an array nor an
27
+ # object; RecursionError: nesting json.dumps, a few frames deeper than json.loads, gives up on
28
+ except (AttributeError, KeyError, TypeError, ValueError, RecursionError) as error:
29
+ # the CLI renders without the schema check, which needs the validate extra
30
+ msg = f"{options.source} is not a CycloneDX document python-sbom can render: {error!r}"
31
+ raise SbomError(msg) from error
32
+ # bytes, so the line endings are the same on every operating system
33
+ options.target.write_bytes(rendered)
34
+ return True
35
+
36
+
37
+ def _read(source: Path) -> Document:
38
+ # the size validate accepts at its default component cap, so the parser never holds more than validate lets it
39
+ limit = byte_limit(MAX_COMPONENTS)
40
+ try:
41
+ with source.open("rb") as handle:
42
+ raw = handle.read(limit + 1)
43
+ except OSError as error:
44
+ raise SbomError(str(error)) from error
45
+ if len(raw) > limit:
46
+ msg = f"{source} is over {limit} bytes, the size limit of an SBOM python-sbom reads"
47
+ raise SbomError(msg)
48
+ try:
49
+ loaded = load_json(raw)
50
+ except SbomError as error:
51
+ msg = f"{source} {error}"
52
+ raise SbomError(msg) from error
53
+ if not is_document(loaded):
54
+ msg = f"{source} is not a CycloneDX document python-sbom can render"
55
+ raise SbomError(msg)
56
+ return loaded
57
+
58
+
59
+ __all__ = [
60
+ "run", # simplint: ignore[SMP402] # the parser names it for pkgutil.resolve_name
61
+ ]
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from typing import TYPE_CHECKING, Protocol
5
+
6
+ from python_sbom._validate._artifact import validate
7
+
8
+ if TYPE_CHECKING:
9
+ from pathlib import Path
10
+
11
+
12
+ class _Options(Protocol):
13
+ paths: list[Path]
14
+ allowed_licenses: list[str] | None
15
+ max_components: int
16
+
17
+
18
+ def run(options: _Options) -> bool:
19
+ allowed = None if options.allowed_licenses is None else frozenset(options.allowed_licenses)
20
+ if not (
21
+ artifacts := sorted(
22
+ artifact for path in options.paths for artifact in (path.glob("*.whl") if path.is_dir() else [path])
23
+ )
24
+ ):
25
+ sys.stderr.write(f"no wheel found in {', '.join(map(str, options.paths))}\n")
26
+ return False
27
+ valid = True
28
+ for artifact in artifacts:
29
+ for problem in (problems := validate(artifact, allowed, options.max_components)):
30
+ sys.stderr.write(f"SBOM invalid: {artifact.name}: {problem}\n")
31
+ if problems:
32
+ valid = False
33
+ else:
34
+ sys.stdout.write(f"SBOM in {artifact.name} is valid\n")
35
+ return valid
36
+
37
+
38
+ __all__ = [
39
+ "run", # simplint: ignore[SMP402] # the parser names it for pkgutil.resolve_name
40
+ ]
@@ -0,0 +1 @@
1
+ """What the edge reads: settings, build facts, the source revision, the project tree, the artifact and the provider."""
@@ -0,0 +1,94 @@
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._archive import bundled_members
7
+ from python_sbom._model._inputs import ArchiveTree, RecordRow
8
+ from python_sbom._model._metadata import parse_metadata
9
+ from python_sbom._model._names import archive_sbom_file_name
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Container
13
+
14
+ from python_sbom._model._metadata import CoreMetadata
15
+ from python_sbom._wheel._reader import ArtifactReader
16
+
17
+
18
+ def read_archive(artifact: ArtifactReader) -> ArchiveTree:
19
+ # every member is read from the zip by its name, never from a file system path, so a name such as /etc/hostname or
20
+ # ../outside names a member, not a file of the host
21
+ where: Final[str] = artifact.path.name
22
+ members: Final[list[str]] = sorted(
23
+ info.filename
24
+ for info in artifact.archive.infolist()
25
+ if not info.is_dir() and info.filename != archive_sbom_file_name(where)
26
+ )
27
+ metadata: Final[dict[str, CoreMetadata]] = {}
28
+ for name in members:
29
+ if not name.endswith(".dist-info/METADATA"):
30
+ continue
31
+ # files belong to a distribution by the directory that holds its .dist-info, which two cannot share
32
+ if (directory := _distribution_prefix(name)) in metadata:
33
+ msg = (
34
+ f"{where} holds more than one .dist-info in {directory or 'its top level'}, so their files cannot"
35
+ " be told apart"
36
+ )
37
+ raise SbomError(msg)
38
+ metadata[directory] = parse_metadata(artifact.read(name).decode("utf-8"))
39
+ if "" not in metadata:
40
+ msg = f"{where} holds no top-level .dist-info/METADATA naming the project"
41
+ raise SbomError(msg)
42
+ files: Final[dict[str, RecordRow]] = {
43
+ name: RecordRow(
44
+ path=name,
45
+ algorithm="SHA-256",
46
+ digest=artifact.digest(name),
47
+ size=str(artifact.archive.getinfo(name).file_size),
48
+ )
49
+ for name in members
50
+ if not name.endswith(".whl")
51
+ }
52
+ owned: Final[dict[str, list[str]]] = {prefix: [] for prefix in metadata}
53
+ loose: Final[list[str]] = []
54
+ for name in members:
55
+ # the project's own distribution holds every subdirectory no other one does
56
+ if owner := _owner(name, metadata):
57
+ owned[owner].append(name)
58
+ elif "/" in name:
59
+ owned[""].append(name)
60
+ else:
61
+ loose.append(name)
62
+ # in the order the components list them, so the first broken wheel is the one reported
63
+ wheels: Final[list[str]] = [member for member in bundled_members(loose, owned) if not member.endswith("/")]
64
+ return ArchiveTree(
65
+ metadata=metadata,
66
+ owned=owned,
67
+ loose=loose,
68
+ files=files,
69
+ bundled=dict(zip(wheels, artifact.bundled_wheels(wheels), strict=True)),
70
+ )
71
+
72
+
73
+ def _distribution_prefix(name: str) -> str:
74
+ # each distribution by the directory that holds its .dist-info, the project's own at the top level
75
+ return f"{name.rsplit('/', 2)[0]}/" if name.count("/") > 1 else ""
76
+
77
+
78
+ def _owner(name: str, distributions: Container[str]) -> str | None:
79
+ # the deepest distribution directory that holds the member, found by looking up each of its ancestor directories,
80
+ # deepest first, so the work grows with the member's depth rather than the number of distributions
81
+ parents: Final[list[str]] = name.split("/")[:-1]
82
+ return next(
83
+ (
84
+ prefix
85
+ for depth in range(len(parents), 0, -1)
86
+ if (prefix := f"{'/'.join(parents[:depth])}/") in distributions
87
+ ),
88
+ None,
89
+ )
90
+
91
+
92
+ __all__ = [
93
+ "read_archive",
94
+ ]
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import pkgutil
5
+ import sys
6
+ from importlib.machinery import PathFinder
7
+ from pathlib import Path, PurePosixPath
8
+ from typing import TYPE_CHECKING, Final
9
+
10
+ from python_sbom._errors import SbomError
11
+ from python_sbom._model._content import ContentProvider
12
+
13
+ if TYPE_CHECKING:
14
+ from python_sbom._model._inputs import Settings
15
+
16
+
17
+ def resolve_provider(
18
+ given: ContentProvider | str | None, settings: Settings, root: Path
19
+ ) -> tuple[ContentProvider, tuple[PurePosixPath, ...]]:
20
+ # an instance wins, as a hook subclass builds one; a module:attribute string overrides the option, as the CLI's flag
21
+ # does; otherwise the content-provider option, or the provider that adds nothing
22
+ if isinstance(given, ContentProvider):
23
+ provider = given
24
+ elif (spec := settings.content_provider if given is None else given) is None:
25
+ provider = ContentProvider()
26
+ else:
27
+ provider = _load(spec, root)
28
+ return provider, _provider_scripts(provider, root)
29
+
30
+
31
+ def _load(spec: str, root: Path) -> ContentProvider:
32
+ # resolve_name also takes a bare module, which is no provider
33
+ module_name, _, attribute = spec.partition(":")
34
+ if not (module_name and attribute):
35
+ msg = f"content provider {spec!r} must be `module:attribute`"
36
+ raise SbomError(msg)
37
+ if (imported := _imported_elsewhere(module_name, root)) is not None:
38
+ msg = (
39
+ f"content provider {spec!r} cannot load from {root}: this process already imported {imported}, a module of"
40
+ " the same name, and would run that instead; build each such project in a process of its own"
41
+ )
42
+ raise SbomError(msg)
43
+ sys.path.insert(0, str(root))
44
+ try:
45
+ provider = pkgutil.resolve_name(spec)
46
+ # ValueError: a name resolve_name cannot parse, such as a second colon
47
+ except (ImportError, AttributeError, ValueError) as error:
48
+ msg = f"content provider {spec!r} does not load from {root}: {error}"
49
+ raise SbomError(msg) from error
50
+ finally:
51
+ sys.path.remove(str(root))
52
+ if isinstance(provider, type) and issubclass(provider, ContentProvider):
53
+ provider = provider()
54
+ if not isinstance(provider, ContentProvider):
55
+ msg = f"content provider {spec!r} is not a ContentProvider subclass or instance"
56
+ raise SbomError(msg)
57
+ return provider
58
+
59
+
60
+ def _imported_elsewhere(module_name: str, root: Path) -> str | None:
61
+ # the import cache is per process, so a hatch build of a second project with a provider module of the same name
62
+ # would run the first project's; each package on the way down to the module, and the module itself, that the project
63
+ # provides is compared with what is already imported under its name; namespace packages merge, so only modules and
64
+ # regular packages can clash
65
+ search: list[str] | None = [str(root)]
66
+ parts: Final[list[str]] = module_name.split(".")
67
+ for depth in range(1, len(parts) + 1):
68
+ if search is None or (found := PathFinder.find_spec(".".join(parts[:depth]), search)) is None:
69
+ return None
70
+ imported = getattr(sys.modules.get(found.name), "__file__", None)
71
+ if (
72
+ found.origin is not None
73
+ and imported is not None
74
+ and Path(imported).resolve() != Path(found.origin).resolve()
75
+ ):
76
+ return imported
77
+ search = None if found.submodule_search_locations is None else list(found.submodule_search_locations)
78
+ return None
79
+
80
+
81
+ def _provider_scripts(provider: ContentProvider, root: Path) -> tuple[PurePosixPath, ...]:
82
+ # each class of the provider defined in the project shapes the document, a base class or mixin as much as the class
83
+ # itself, so the SBOM lists their modules among the build tools; python-sbom's own ContentProvider adds nothing
84
+ found: Final[dict[PurePosixPath, None]] = {}
85
+ for cls in type(provider).__mro__:
86
+ if cls in {ContentProvider, object}:
87
+ continue
88
+ if (source := _source(cls)) is None:
89
+ continue
90
+ resolved, base = Path(source).resolve(), root.resolve()
91
+ if resolved.is_relative_to(base):
92
+ found[PurePosixPath(resolved.relative_to(base).as_posix())] = None
93
+ return tuple(found)
94
+
95
+
96
+ def _source(cls: type) -> str | None:
97
+ # a method's code names the file even for a module hatchling loaded outside the import system, such as a custom
98
+ # hook script
99
+ methods = (
100
+ function.__code__.co_filename
101
+ for _, function in inspect.getmembers(cls, inspect.isfunction)
102
+ if function.__qualname__.startswith(f"{cls.__qualname__}.")
103
+ )
104
+ return next(methods, getattr(sys.modules.get(cls.__module__), "__file__", None))
105
+
106
+
107
+ __all__ = [
108
+ "resolve_provider",
109
+ ]