Skip to content

Commit b09b3c8

Browse files
authored
Merge pull request #83 from bact/fix-spdxid
Handle None case of spdxId
2 parents 278ea81 + fcb3fce commit b09b3c8

12 files changed

Lines changed: 83 additions & 54 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,12 @@ and this project adheres to
4141
`/pitloom:enrich` to generate and enrich SBOMs directly from Claude
4242
Code ([#82])
4343

44+
### Fixed
45+
46+
- Handle None case of spdxId ([#83])
47+
4448
[#82]: https://github.com/bact/pitloom/pull/82
49+
[#83]: https://github.com/bact/pitloom/pull/83
4550

4651
## [0.8.0] - 2026-05-29
4752

src/pitloom/assemble/spdx3/ai.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@
99
import json
1010
from typing import Any
1111

12-
from spdx_python_model import v3_0_1 as spdx3
12+
from spdx_python_model.bindings import v3_0_1 as spdx3
1313

1414
from pitloom.assemble.spdx3.dataset import add_datasets_for_model
1515
from pitloom.assemble.spdx3.deps import build_license_elements
1616
from pitloom.core.ai_metadata import AiModelMetadata
1717
from pitloom.core.models import generate_spdx_id
18-
from pitloom.export.spdx3_json import Spdx3JsonExporter
18+
from pitloom.export.spdx3_json import Spdx3JsonExporter, require_spdx_id
1919

2020
# Valid SPDX 3 ai_safetyRiskAssessmentType enum values (lowercase).
2121
_SAFETY_RISK_VALUES = {"high", "medium", "low", "serious"}
@@ -200,7 +200,7 @@ def add_ai_models(
200200

201201
if ai_model.datasets:
202202
add_datasets_for_model(
203-
ai_package_spdx_id=ai_pkg.spdxId,
203+
ai_package_spdx_id=require_spdx_id(ai_pkg),
204204
datasets=ai_model.datasets,
205205
creation_info=creation_info,
206206
doc_name=doc_name,
@@ -211,7 +211,7 @@ def add_ai_models(
211211
if ai_model.license:
212212
rel_declared, rel_concluded = build_license_elements(
213213
license_id=ai_model.license,
214-
package_spdx_id=ai_pkg.spdxId,
214+
package_spdx_id=require_spdx_id(ai_pkg),
215215
license_provenance=ai_model.provenance.get(
216216
"license",
217217
"Source: model file / Hugging Face Hub",
@@ -231,7 +231,7 @@ def add_ai_models(
231231
doc_uuid=doc_uuid,
232232
),
233233
from_=main_package_spdx_id,
234-
to=[ai_pkg.spdxId],
234+
to=[require_spdx_id(ai_pkg)],
235235
relationshipType=spdx3.RelationshipType.contains,
236236
creationInfo=creation_info,
237237
)

src/pitloom/assemble/spdx3/dataset.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,31 @@
66

77
from __future__ import annotations
88

9-
from spdx_python_model import v3_0_1 as spdx3
9+
from spdx_python_model.bindings import v3_0_1 as spdx3
1010

1111
from pitloom.core.dataset_metadata import DatasetMetadata, DatasetReference
1212
from pitloom.core.models import generate_spdx_id
13-
from pitloom.export.spdx3_json import Spdx3JsonExporter
13+
from pitloom.export.spdx3_json import Spdx3JsonExporter, require_spdx_id
1414

1515
# Mapping from DatasetReference.role strings to SPDX 3.0.1 RelationshipType.
1616
# finetunedOn, validatedOn, pretrainedOn do not exist in SPDX 3.0.1;
1717
# they fall back to RelationshipType.other with a comment (see _role_to_rel).
18-
_ROLE_TO_RELATIONSHIP: dict[str, spdx3.RelationshipType] = {
18+
# RelationshipType members are plain str NamedIndividual IRIs, not an enum type.
19+
_ROLE_TO_RELATIONSHIP: dict[str, str] = {
1920
"trainedOn": spdx3.RelationshipType.trainedOn,
2021
"testedOn": spdx3.RelationshipType.testedOn,
2122
}
2223

2324
# PresenceType mapping for has_sensitive_personal_information.
24-
_PRESENCE_MAP: dict[str, spdx3.PresenceType] = {
25+
# PresenceType members are plain str NamedIndividual IRIs, not an enum type.
26+
_PRESENCE_MAP: dict[str, str] = {
2527
"yes": spdx3.PresenceType.yes,
2628
"no": spdx3.PresenceType.no,
2729
"noAssertion": spdx3.PresenceType.noAssertion,
2830
}
2931

3032

31-
def _role_to_rel(role: str) -> tuple[spdx3.RelationshipType, str | None]:
33+
def _role_to_rel(role: str) -> tuple[str, str | None]:
3234
"""Return the SPDX RelationshipType and optional fallback comment for *role*.
3335
3436
Returns:
@@ -112,7 +114,8 @@ def _build_dataset_package(
112114
# dataset_datasetType is required by the SPDX model; always set it.
113115
# Map known string names to enum values, skip unknowns silently.
114116
# Fall back to [noAssertion] when no type information is available.
115-
type_values: list[spdx3.dataset_DatasetType] = []
117+
# dataset_DatasetType members are plain str NamedIndividual IRIs.
118+
type_values: list[str] = []
116119
for type_name in meta.dataset_types:
117120
enum_val = getattr(spdx3.dataset_DatasetType, type_name, None)
118121
if enum_val is not None:
@@ -210,7 +213,7 @@ def add_datasets_for_model(
210213
),
211214
creationInfo=creation_info,
212215
from_=ai_package_spdx_id,
213-
to=[dataset_pkg.spdxId],
216+
to=[require_spdx_id(dataset_pkg)],
214217
relationshipType=rel_type,
215218
)
216219
if fallback_comment:

src/pitloom/assemble/spdx3/deps.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@
1010
from importlib.metadata import metadata as get_pkg_metadata
1111
from importlib.metadata import version as get_package_version
1212

13-
from spdx_python_model import v3_0_1 as spdx3
13+
from spdx_python_model.bindings import v3_0_1 as spdx3
1414

1515
from pitloom.core.models import build_pypi_purl, generate_spdx_id
16-
from pitloom.export.spdx3_json import Spdx3JsonExporter
16+
from pitloom.export.spdx3_json import Spdx3JsonExporter, require_spdx_id
1717

1818
# Operators used in PEP 508 dependency specifiers, ordered longest-first to
1919
# avoid splitting on a prefix of a multi-character operator (e.g. "==" before "=").
@@ -147,7 +147,7 @@ def _enrich_from_installed(
147147
if license_id and license_id != "UNKNOWN":
148148
rel_declared, _ = build_license_elements(
149149
license_id=license_id,
150-
package_spdx_id=dep_package.spdxId,
150+
package_spdx_id=require_spdx_id(dep_package),
151151
license_provenance=provenance_source,
152152
creation_info=creation_info,
153153
doc_name=doc_name,
@@ -208,7 +208,7 @@ def build_license_elements(
208208
license_text.simplelicensing_licenseText = license_id
209209
license_text.comment = f"Metadata provenance: license: {license_provenance}"
210210
exporter.add_license(license_text)
211-
license_spdx_id = license_text.spdxId
211+
license_spdx_id = require_spdx_id(license_text)
212212

213213
# The license actually found in the Software Artifact.
214214
rel_has_declared_license = spdx3.Relationship(
@@ -303,7 +303,7 @@ def add_dependencies(
303303
"Relationship", doc_name=doc_name, doc_uuid=doc_uuid
304304
),
305305
from_=main_package_spdx_id,
306-
to=[dep_package.spdxId],
306+
to=[require_spdx_id(dep_package)],
307307
relationshipType=spdx3.RelationshipType.dependsOn,
308308
creationInfo=creation_info,
309309
)

src/pitloom/assemble/spdx3/document.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from datetime import datetime, timezone
1010
from pathlib import Path
1111

12-
from spdx_python_model import v3_0_1 as spdx3
12+
from spdx_python_model.bindings import v3_0_1 as spdx3
1313

1414
from pitloom.assemble.spdx3.ai import _build_ai_package, add_ai_models
1515
from pitloom.assemble.spdx3.dataset import add_datasets_for_model
@@ -23,7 +23,7 @@
2323
compute_doc_uuid,
2424
generate_spdx_id,
2525
)
26-
from pitloom.export.spdx3_json import Spdx3JsonExporter
26+
from pitloom.export.spdx3_json import Spdx3JsonExporter, require_spdx_id
2727

2828

2929
def _parse_iso_datetime(value: str) -> datetime:
@@ -87,9 +87,9 @@ def _build_creation_bundle(
8787
creationInfo=spdx_ci,
8888
)
8989

90-
spdx_ci.createdBy = [creator.spdxId]
90+
spdx_ci.createdBy = [require_spdx_id(creator)]
9191
if tool is not None:
92-
spdx_ci.createdUsing = [tool.spdxId]
92+
spdx_ci.createdUsing = [require_spdx_id(tool)]
9393
return spdx_ci, creator, tool
9494

9595

@@ -179,7 +179,7 @@ def _add_package_files(
179179
)
180180
directory_file.software_fileKind = spdx3.software_FileKindType.directory
181181
exporter.add_file(directory_file)
182-
dir_spdx_ids[directory_name] = directory_file.spdxId
182+
dir_spdx_ids[directory_name] = require_spdx_id(directory_file)
183183

184184
parent_id = (
185185
main_package.spdxId
@@ -192,7 +192,7 @@ def _add_package_files(
192192
"Relationship", doc_name=metadata.name, doc_uuid=doc_uuid
193193
),
194194
from_=parent_id,
195-
to=[directory_file.spdxId],
195+
to=[require_spdx_id(directory_file)],
196196
relationshipType=spdx3.RelationshipType.contains,
197197
creationInfo=spdx_ci,
198198
)
@@ -211,7 +211,7 @@ def _add_package_files(
211211
)
212212
]
213213
exporter.add_file(package_entry)
214-
file_spdx_ids[package_file.distribution_path] = package_entry.spdxId
214+
file_spdx_ids[package_file.distribution_path] = require_spdx_id(package_entry)
215215

216216
parent_id = (
217217
dir_spdx_ids[parent_paths[-1].as_posix()]
@@ -224,7 +224,7 @@ def _add_package_files(
224224
"Relationship", doc_name=metadata.name, doc_uuid=doc_uuid
225225
),
226226
from_=parent_id,
227-
to=[package_entry.spdxId],
227+
to=[require_spdx_id(package_entry)],
228228
relationshipType=spdx3.RelationshipType.contains,
229229
creationInfo=spdx_ci,
230230
)
@@ -303,7 +303,7 @@ def build(doc: DocumentModel, merkle_root: str | None = None) -> Spdx3JsonExport
303303
if metadata.license_name:
304304
rel_declared, rel_concluded = build_license_elements(
305305
license_id=metadata.license_name,
306-
package_spdx_id=main_package.spdxId,
306+
package_spdx_id=require_spdx_id(main_package),
307307
license_provenance=metadata.provenance.get(
308308
"license", "Source: pyproject.toml | Field: project.license"
309309
),
@@ -320,7 +320,7 @@ def build(doc: DocumentModel, merkle_root: str | None = None) -> Spdx3JsonExport
320320
add_dependencies(
321321
dependencies=metadata.dependencies,
322322
dep_provenance=metadata.provenance.get("dependencies", "Unknown source"),
323-
main_package_spdx_id=main_package.spdxId,
323+
main_package_spdx_id=require_spdx_id(main_package),
324324
creation_info=spdx_ci,
325325
doc_name=metadata.name,
326326
doc_uuid=doc_uuid,
@@ -345,7 +345,7 @@ def build(doc: DocumentModel, merkle_root: str | None = None) -> Spdx3JsonExport
345345
)
346346
add_ai_models(
347347
ai_models=doc.ai_models,
348-
main_package_spdx_id=main_package.spdxId,
348+
main_package_spdx_id=require_spdx_id(main_package),
349349
file_spdx_ids=file_spdx_ids,
350350
creation_info=spdx_ci,
351351
doc_name=metadata.name,
@@ -418,9 +418,9 @@ def build_model(
418418
creationInfo=spdx_ci,
419419
)
420420

421-
spdx_ci.createdBy = [creator.spdxId]
421+
spdx_ci.createdBy = [require_spdx_id(creator)]
422422
if tool is not None:
423-
spdx_ci.createdUsing = [tool.spdxId]
423+
spdx_ci.createdUsing = [require_spdx_id(tool)]
424424

425425
exporter.add_creation_info(spdx_ci)
426426
exporter.add_person(creator)
@@ -433,7 +433,7 @@ def build_model(
433433
if model.license:
434434
rel_declared, rel_concluded = build_license_elements(
435435
license_id=model.license,
436-
package_spdx_id=ai_pkg.spdxId,
436+
package_spdx_id=require_spdx_id(ai_pkg),
437437
license_provenance=model.provenance.get(
438438
"license",
439439
"Source: model file / Hugging Face Hub",
@@ -448,7 +448,7 @@ def build_model(
448448

449449
if model.datasets:
450450
add_datasets_for_model(
451-
ai_package_spdx_id=ai_pkg.spdxId,
451+
ai_package_spdx_id=require_spdx_id(ai_pkg),
452452
datasets=model.datasets,
453453
creation_info=spdx_ci,
454454
doc_name=doc_name,

src/pitloom/assemble/spdx3/fragments.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import logging
1010
from pathlib import Path
1111

12-
from spdx_python_model import v3_0_1 as spdx3
12+
from spdx_python_model.bindings import v3_0_1 as spdx3
1313

1414
from pitloom.export.spdx3_json import Spdx3JsonExporter
1515

src/pitloom/export/spdx3_json.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from typing import Any
1313

1414
import rfc8785
15-
from spdx_python_model import v3_0_1 as spdx3
15+
from spdx_python_model.bindings import v3_0_1 as spdx3
1616

1717
# Lower value = earlier in @graph. Types not listed here get priority 4.
1818
# Order rationale:
@@ -28,6 +28,25 @@
2828
}
2929

3030

31+
def require_spdx_id(element: spdx3.Element) -> str:
32+
"""Return *element*'s ``spdxId``, narrowing away the ``None`` case.
33+
34+
``spdxId`` is typed ``str | None`` upstream because the SHACL model
35+
allows an Element to be constructed without one, but every element
36+
pitloom builds is always given an explicit ``spdxId`` at construction
37+
time (see :func:`pitloom.core.models.generate_spdx_id`). This helper
38+
exists only to satisfy mypy at the many call sites that pass an
39+
element's ``spdxId`` on to APIs expecting a plain ``str``.
40+
41+
Raises:
42+
ValueError: If *element* has no ``spdxId`` assigned, which would
43+
indicate a bug in how the element was constructed.
44+
"""
45+
if element.spdxId is None:
46+
raise ValueError(f"{element!r} has no spdxId assigned")
47+
return element.spdxId
48+
49+
3150
def _graph_sort_key(element: dict[str, Any]) -> tuple[int, str, str]:
3251
"""Return a deterministic sort key for a @graph element.
3352
@@ -252,7 +271,7 @@ def add_license(
252271
self.object_set.add(simple_licensing_text)
253272
license_id: str | None = simple_licensing_text.simplelicensing_licenseText
254273
if license_id:
255-
self._license_index[license_id] = simple_licensing_text.spdxId
274+
self._license_index[license_id] = require_spdx_id(simple_licensing_text)
256275

257276
def add_relationship(self, relationship: spdx3.Relationship) -> None:
258277
"""Add a relationship to the document.

src/pitloom/loom.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@
1111
from pathlib import Path
1212
from uuid import uuid4
1313

14-
from spdx_python_model import v3_0_1 as spdx3
14+
from spdx_python_model.bindings import v3_0_1 as spdx3
1515

1616
from pitloom.core.models import generate_spdx_id
17-
from pitloom.export.spdx3_json import Spdx3JsonExporter
17+
from pitloom.export.spdx3_json import Spdx3JsonExporter, require_spdx_id
1818

1919

2020
def _get_caller_info() -> str:
@@ -62,7 +62,7 @@ def __init__(self, output_file: str, pretty: bool = False):
6262
name="Pitloom SDK (Automated Run)",
6363
creationInfo=self.creation_info,
6464
)
65-
self.creation_info.createdBy = [person.spdxId]
65+
self.creation_info.createdBy = [require_spdx_id(person)]
6666

6767
self.exporter = Spdx3JsonExporter()
6868
self.exporter.add_person(person)
@@ -238,7 +238,7 @@ def finalize(self) -> None:
238238
self.doc_uuid,
239239
),
240240
from_=self.model.spdxId,
241-
to=[dataset.spdxId],
241+
to=[require_spdx_id(dataset)],
242242
relationshipType=spdx3.RelationshipType.trainedOn,
243243
creationInfo=self.creation_info,
244244
)
@@ -254,15 +254,15 @@ def finalize(self) -> None:
254254
self.doc_uuid,
255255
),
256256
from_=self.model.spdxId,
257-
to=[dataset.spdxId],
257+
to=[require_spdx_id(dataset)],
258258
relationshipType=spdx3.RelationshipType.testedOn,
259259
creationInfo=self.creation_info,
260260
)
261261
self.exporter.add_relationship(rel)
262262

263263
# output_dataset hasInput input_datasets (dataset lineage / preprocessing)
264264
if self.output_datasets and self.input_datasets:
265-
input_ids = [ds.spdxId for ds in self.input_datasets]
265+
input_ids = [require_spdx_id(ds) for ds in self.input_datasets]
266266
for output_ds in self.output_datasets:
267267
rel = spdx3.Relationship(
268268
spdxId=generate_spdx_id(

0 commit comments

Comments
 (0)