Wheel SBOMs and PEP 770 embedding¶
See also: Command line for every other subcommand and the common flags, Hatchling build hook and GitHub Action for embedding at build time or in CI, and From Python for the same operations from code.
Commands that read or rewrite built .whl files: loom wheel,
embed-wheel, verify-wheel and validate-wheel.
Embed an SBOM (embed-wheel)¶
Generate an SPDX 3 SBOM and embed it in one or more built wheels, writing to
.dist-info/sboms/ and updating .dist-info/RECORD:
loom embed-wheel dist/*.whl --project-dir .
--project-dir rescans the source project so the SBOM carries project
metadata (dependencies, licence, AI models). It is never inferred from the
current directory, even when the shell sits in the project root: that
directory may not be the wheel's own project. With it, the file list and
hashes still come from the wheel itself, so they are accurate for any build
backend. Only --content-type and --extract-file-header can be affected by
the Source SBOM limitation, for a backend on the
Hatchling-based fallback: that per-file enrichment can silently fail to
attach (no data for a file, never wrong data).
Without --project-dir (and without --sbom), embed-wheel embeds a
standalone-wheel SBOM built from the wheel's own contents: no project scan,
so no AI-model enrichment and no [tool.pitloom] beyond an explicit
--config. AI models inside the wheel are still found (see
--scan-model-usage):
loom embed-wheel dist/mypackage-1.0.0-py3-none-any.whl
Or embed an existing SBOM:
loom embed-wheel dist/*.whl --sbom sbom.spdx3.json
The SBOM's declared subject name/version (PEP 503/440-normalised) is checked
against the wheel's own .dist-info/METADATA before anything is written. A
mismatch is an ERROR: that aborts the embed (exit 1, nothing written);
--allow-mismatch downgrades it to a WARNING: and embeds anyway (for
best-effort CI). A Pitloom-generated SBOM (no --sbom) is never checked: it
is built from the same metadata. An external SBOM is embedded verbatim and may
list .dist-info files.
Or use --embed on loom wheel:
loom wheel dist/mypackage-1.0.0-py3-none-any.whl --embed
It embeds the same kind of SBOM as embed-wheel: RFC 8785 canonical JSON, no
relationship descriptions, no registry update. So --pretty,
--describe-relationship and --update-id-registry warn and have no effect,
and -o FILE writes a copy of exactly what was embedded.
Other flags:
--sbom-basename NAMEsets the embedded file's basename. A trailing.spdx3.jsonis optional and dropped with aWARNING:. The default is<name>-<version>.spdx3.jsonfrom the wheel's own name/version, with a control character, whitespace,/,\or:becoming_; with no name or version inMETADATA, or a name over 255 characters, the wheel's.dist-infodirectory name (escaped the same way) is used.-o/--outputnames the modified wheel and is anERROR:with more than one wheel (ambiguous). Omit it to modify each wheel in place.--verify/--validaterun the checks below on the wheel just embedded. A failure is reported and sets the exit code, but the embed is not rolled back.
loom embed-wheel dist/*.whl --project-dir . --verify --validate
What an SBOM lists¶
Every wheel-reading command (loom wheel, generate <whl>, embed-wheel,
wheel --embed) lists the wheel's payload only: nothing under the wheel's own
.dist-info (METADATA, WHEEL, RECORD, licenses/, sboms/,
signatures). An SBOM describes the packaged project, not its container, so a
listed hash never goes stale when the embed rewrites RECORD and adds
sboms/. The Hatchling hook and loom project list no
.dist-info/licenses/* file either.
A wheel's own .dist-info is the top-level directory its file name names
(PEP 503/440 comparison). Where the file name names none, it is the only
top-level .dist-info, with a WARNING: if the file name is a wheel name. A
.dist-info vendored deeper in the tree is never the wheel's own. With none,
or several not picked out by the file name, loom wheel and loom generate
warn and name the package unknown; wheel --embed, embed-wheel,
verify-wheel and validate-wheel refuse the wheel. METADATA is read for
its headers only (at most 16 MiB and 10,000 headers); past a cap, one
WARNING: per command and the name is unknown.
Signed wheels¶
SECURITY: a wheel with a RECORD signature (RECORD.jws, RECORD.p7s)
is refused, and left untouched, by embed-wheel and wheel --embed: the
embed rewrites RECORD, so the signature would stop verifying.
--allow-signed-wheel removes the signature files and embeds (one INFO: per
file); re-sign afterwards. Like --allow-build, it has no [tool.pitloom]
equivalent: it is a per-run decision.
Pitloom sees only signatures inside the wheel. Any embed changes the wheel
file's own digest, so a signature or attestation over the file (detached GPG
.asc, Sigstore bundle, PEP 740 attestation) and a recorded wheel hash (lock
file, pip --hash) stop matching, and Pitloom cannot detect them. Embed
first, then sign, attest, upload and hash.
Refused archives¶
loom wheel, generate, wheel --embed and embed-wheel read every member.
They refuse a file that is not a ZIP, and a wheel with a member that cannot be
read (damaged, encrypted, a name that is not UTF-8), with one name twice (also
as a/M and a\M), or with a NUL in a name (zipfile cuts it there, so an
installer extracts it under another member's name). wheel --embed and
embed-wheel also refuse a wheel whose own .dist-info has a non-conforming
member name.
verify-wheel reads only the member names, the own .dist-info's METADATA
and the embedded SBOM; validate-wheel only the names and the embedded SBOM.
A damaged other member does not fail them, but a damaged member they read, a
duplicate or NUL name, or a file zipfile cannot open does.
Each refusal is one ERROR: naming the archive and, where there is one, the
member; exit 1, nothing written (not even the -o copy). With several wheels
the others are still processed. The library raises ValueError.
Verify (verify-wheel)¶
Check that a wheel's embedded SBOM is at the PEP 770 location
(.dist-info/sboms/), uses its format's recommended extension, and declares a
subject name/version matching the wheel's own .dist-info/METADATA:
loom verify-wheel dist/*.whl
loom verify-wheel dist/mypackage-1.0.0-py3-none-any.whl --sbom-filename mypackage-1.0.0.spdx3.json
loom verify-wheel dist/*.whl --fail-on-mismatch
- A missing SBOM is an
ERROR:(exit 1). - A non-conventional extension is a
WARNING:only (exit 0). - Several
sboms/entries need--sbom-filenameto pick one, elseERROR:. - A name/version mismatch is a
WARNING:(exit 0);--fail-on-mismatchmakes it anERROR:(exit 1). - If the subject name/version cannot be extracted at all (unsupported format,
or SPDX 3 with an unexpected graph shape), the cross-check is skipped with a
WARNING:naming why, regardless of--fail-on-mismatch.
Validate (validate-wheel)¶
Validate the embedded SBOM's content against its format's schema and SHACL
rules (SPDX 3 JSON-LD only, via the spdx3-validate library also behind
loom fragment validate; needs pip
install "pitloom[validate]"):
loom validate-wheel dist/*.whl
An embedded file in an unrecognised format prints a WARNING: and skips
validation (exit 0): unsupported is not invalid.
Package hash¶
The package element's verifiedUsing holds a SHA-256 Merkle root over the
wheel's payload. What it covers follows the SBOM type:
- Analyzed (
loom wheel,generate <whl>,embed-wheelwithout a project directory,wheel --embed): the wheel as built. - Source (
loom project) and the Build SBOM of the Hatchling hook: the source files the build backend selects, hashed before the build. When the build adds payload of its own (shared data, scripts, generated or repaired files) the root differs from the built wheel's, as the two describe different things. - Build with
embed-wheel --project-dir: the wheel as built, so it can differ from the hook's root for the same wheel.
A wheel's <name>-<version>.data/ directory (PEP 427: files installed outside
site-packages, such as scripts/, data/ and headers/) is payload, not
packaging metadata. It is listed and hashed under its path in the wheel, e.g.
demo-1.0.data/data/share/demo/d.txt, not its install destination. The hook
and loom project cannot see it before the build, one reason their root
differs from the built wheel's.
To recompute the root of a built wheel:
- Take every wheel member except those under the wheel's own
.dist-info(the top-level directory its file name names, compared per PEP 503 names and PEP 440 versions). Another*.dist-infodeeper in the tree is payload. Directory entries are not members. - Name each by its install-location path (POSIX, normalised). A member name
that is not one (
\,./,//,.., an absolute path) is normalised or skipped by Pitloom, each with aWARNING:; the snippet below reads names as stored, so it reproduces the root of a conforming wheel only. - Sort by that path (Python
sorted, code-point order). - A leaf is the raw 32-byte SHA-256 of the member's bytes.
- Combine adjacent pairs as
sha256(left || right); an odd last node is promoted unchanged; repeat until one node remains. - The root is that node in lowercase hex. A single file's root is its own digest; an empty payload has no hash.
A wheel with no single own .dist-info has none to leave out: loom wheel
lists and hashes every member, and the embed commands refuse it. SBOM
relationships and directory elements are not part of the root.
import hashlib
import zipfile
def package_hash(wheel: str, own_dist_info: str) -> str | None:
leaves = {}
with zipfile.ZipFile(wheel) as zf:
for name in zf.namelist():
if name.endswith("/") or name.split("/")[0] == own_dist_info:
continue
leaves[name] = hashlib.sha256(zf.read(name)).digest()
level = [leaves[name] for name in sorted(leaves)]
if not level:
return None
while len(level) > 1:
nxt = [
hashlib.sha256(level[i] + level[i + 1]).digest()
for i in range(0, len(level) - 1, 2)
]
if len(level) % 2:
nxt.append(level[-1])
level = nxt
return level[0].hex()
print(package_hash("pkg-1.0-py3-none-any.whl", "pkg-1.0.dist-info"))
From Python¶
Programmatic PEP 770 post-build wheel injection. Exact signatures: API reference.
from pathlib import Path
from pitloom.assemble import ConfigOverrides, embed_sbom_in_wheel, embed_wheel_sbom
# 1. Generate and embed SBOM in one step
modified_wheel, arcname, sbom_json, removed, floored = embed_wheel_sbom(
wheel_path=Path("dist/mypackage-1.0.0-py3-none-any.whl"),
project_dir=Path("."),
overrides=ConfigOverrides(offline=True), # optional
# ConfigOverrides also accepts build_options=BuildOptions(...) (no
# [tool.pitloom] equivalent) -- see [`--allow-build`](allow-build.md#from-python).
)
# 2. Or embed an externally-generated, pre-written SBOM file (checked)
modified_wheel, arcname, sbom_json, removed, floored = embed_wheel_sbom(
wheel_path=Path("dist/mypackage-1.0.0-py3-none-any.whl"),
sbom_path=Path("sbom.spdx3.json"),
allow_mismatch=False, # default: raise ValueError on a name/version mismatch
)
# 3. Or embed arbitrary pre-generated SBOM content (unchecked, lower-level)
modified_wheel, arcname, removed, floored = embed_sbom_in_wheel(
wheel_path=Path("dist/mypackage-1.0.0-py3-none-any.whl"),
sbom_content=sbom_json_string,
sbom_filename="custom.spdx3.json", # optional
)
removed lists any prior Pitloom-embedded SBOM entries cleaned up as part
of the embed; floored is True when the wheel's ZIP entry timestamp had
to be floored to 1980-01-01 (see Configuration).
With sbom_path= (form 2, the equivalent of the CLI's embed-wheel --sbom),
the SBOM's declared subject name/version (PEP 503/440-normalised) is
cross-checked against the wheel's own .dist-info/METADATA before
anything is written: a mismatch raises ValueError and nothing is
written, unless allow_mismatch=True downgrades it to a WARNING: log
and lets the embed proceed. Form 1 (a Pitloom-generated SBOM) is never
checked -- it's built from the same wheel metadata, so it can't diverge.
A Pitloom-generated SBOM lists the wheel's payload only (see
Wheel SBOMs). A wheel with a
RECORD signature (RECORD.jws, RECORD.p7s) raises ValueError and is left
untouched, unless allow_signed_wheel=True removes the signature the rewrite
invalidates (the removed names are returned with any stale SBOMs). Embed before
signing, attesting, uploading or hashing the wheel file.
A wheel's name and version come from its own top-level .dist-info (see
Wheel SBOMs). A wheel with one
of the problems below raises ValueError (all but the fifth the subclass
pitloom.core.wheel_dist_info.WheelRefused), naming the archive (and the
member, where one is at fault), and nothing is written:
- a member that cannot be read;
- two members with one name;
- a NUL in a member name;
- a file that is not a ZIP archive, or that
zipfilecannot open; - no single own
.dist-info(plainValueError); - (embed only) a member of its own
.dist-infounder a non-conforming name.
A file that cannot be opened at all (missing, permission denied) raises
OSError. Pass identity=(name, version) to embed_sbom_in_wheel() where
you have already read them from the wheel's METADATA: the default file
name is made from it and METADATA is not read, or warned about, again.
pitloom.extract.wheel.wheel_identity(metadata) gives that pair from the
ProjectMetadata of read_wheel(), or of generate_wheel_sbom_with_metadata()
(see Python API): None for a field the wheel did not declare.
Form 3, embed_sbom_in_wheel(), is the lower-level, unchecked archive
primitive both forms 1 and 2 converge on -- calling it directly (bypassing
embed_wheel_sbom()) skips the cross-check entirely, same as it skips
SBOM generation.
Batch embedding with EmbedFileCache¶
Embedding into several wheels from the same project_dir in a loop --
what the CLI's embed-wheel dist/*.whl --project-dir . does -- should
share one EmbedFileCache across the whole batch instead of calling
embed_wheel_sbom() per wheel with no cache: without it, each call
independently resolves project_dir's file list (and, with
--allow-build, reruns the real PEP 517 build) once per wheel instead of
once for the batch, and repeats each ineffective build flag's
WARNING: once per wheel.
from pathlib import Path
from pitloom.embed import EmbedFileCache, embed_wheel_sbom
wheels = [Path("dist/mypackage-1.0.0-py3-none-any.whl"), Path("dist/mypackage-1.0.0-py2-none-any.whl")]
with EmbedFileCache() as cache:
for wheel in wheels:
embed_wheel_sbom(
wheel_path=wheel,
project_dir=Path("."),
file_cache=cache,
)
EmbedFileCache must be used as a context manager around the whole
batch: it resolves project_dir's file list (and any --allow-build
build) once, on the batch's first call, and removes its temporary
directories on exit -- including on SIGTERM/SIGHUP or Ctrl-C, via the
same TerminationGuard the --allow-build docs describe. Every call
in one batch must use the same project_dir, file-scan settings and
build options -- a call that doesn't raises ValueError. Passing
file_cache= to embed_wheel_sbom() outside the with block raises
RuntimeError.
Advanced/batch use only -- a single embed_wheel_sbom() call needs no
file_cache and manages its own resolve-then-cleanup cycle.