Skip to content

Commit 231d29e

Browse files
committed
Added support for reading and writing SPDX 3.0.1 documents
Signed-off-by: Johnathan Robson <jrobson@blackduck.com>
1 parent cef432a commit 231d29e

149 files changed

Lines changed: 4931 additions & 966 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ dependencies = [
3737
"pyyaml",
3838
"rdflib",
3939
"semantic_version",
40+
"spdx-python-model",
4041
"uritools",
4142
"xmltodict",
4243
]

src/spdx_tools/spdx/writer/rdf/writer_utils.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
from spdx_tools.spdx.datetime_conversions import datetime_to_iso_string
1212
from spdx_tools.spdx.model import SpdxNoAssertion, SpdxNone
13-
from spdx_tools.spdx.rdfschema.namespace import SPDX_NAMESPACE
1413
from spdx_tools.spdx.validation.spdx_id_validators import is_valid_internal_spdx_id
1514

1615

@@ -28,7 +27,7 @@ def add_literal_or_no_assertion_or_none(value: Any, graph: Graph, parent: Node,
2827
if value is None:
2928
return
3029
if isinstance(value, SpdxNone):
31-
graph.add((parent, predicate, SPDX_NAMESPACE.none))
30+
graph.add((parent, predicate, Literal(str(value))))
3231
return
3332
add_literal_or_no_assertion(value, graph, parent, predicate)
3433

@@ -37,7 +36,7 @@ def add_literal_or_no_assertion(value: Any, graph: Graph, parent: Node, predicat
3736
if value is None:
3837
return
3938
if isinstance(value, SpdxNoAssertion):
40-
graph.add((parent, predicate, SPDX_NAMESPACE.noassertion))
39+
graph.add((parent, predicate, Literal(str(value))))
4140
return
4241
add_optional_literal(value, graph, parent, predicate)
4342

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
import importlib
2+
import inspect
3+
import os
4+
from typing import Dict, List, Optional
5+
6+
from spdx_python_model import v3_0_1 as spdx_3_0
7+
8+
from spdx_tools.spdx3.model.element import Element
9+
from spdx_tools.spdx.casing_tools import camel_case_to_snake_case, snake_case_to_camel_case
10+
11+
FIRST_PART_OF_IRI = "https://spdx.org/rdf"
12+
SPDX_VERSION = "3.0.1"
13+
14+
15+
# These should not be instantiated and will only every be referenced as an URI,
16+
# so we don't want to try to map them to a domain class
17+
INDIVIDUAL_ELEMENT_CLASSES = {
18+
"IndividualElement", # concrete class for individuals of Element
19+
"IndividualLicensingInfo", # concrete class for individuals of LicensingInfo
20+
"NoAssertionElement", # the rest are individuals that should only be represented as URIs
21+
"NoneElement",
22+
"SpdxOrganization",
23+
"NoAssertionLicense",
24+
"NoneLicense",
25+
}
26+
27+
28+
LOWER_CASE_TO_CORRECT_CASE_PROFILE_NAMES = {
29+
"core": "Core",
30+
"software": "Software",
31+
"security": "Security",
32+
"simplelicensing": "SimpleLicensing",
33+
"expandedlicensing": "ExpandedLicensing",
34+
"dataset": "Dataset",
35+
"ai": "AI",
36+
"build": "Build",
37+
"extension": "Extension",
38+
}
39+
40+
41+
def convert_binding_iri_to_domain_class(iri: str) -> Optional[type]:
42+
"""
43+
Converts a binding class IRI () to the corresponding domain class type if one exists.
44+
Returns None if there is no corresponding domain class that would be instantiated,
45+
such as for Individuals such as NoAssertionElement or NoneLicense, who should only be represented
46+
as URI strings in the domain model. Or if the type is abstract.
47+
Raises an exception if the domain class cannot be found and the binding class is not abstract.
48+
"""
49+
50+
# check if the IRI is valid and if it is for an individual that is abstract
51+
shacl_class = spdx_3_0.SHACLObject.CLASSES.get(iri)
52+
if shacl_class is None:
53+
raise ValueError(f"Could not find SPDX class for IRI: {iri}")
54+
55+
if shacl_class.IS_ABSTRACT:
56+
return None
57+
58+
profile = "Core"
59+
relevant_path = iri.replace(FIRST_PART_OF_IRI + "/" + SPDX_VERSION + "/terms/", "")
60+
split_path = relevant_path.split("/")
61+
62+
if len(split_path) != 2:
63+
raise ValueError("Failed to convert SPDX IRI. Did the IRI format change?")
64+
65+
profile = split_path[0].lower()
66+
67+
class_name = split_path[1]
68+
69+
if class_name in INDIVIDUAL_ELEMENT_CLASSES:
70+
return None
71+
72+
# check special name cases because these names don't match the spdx class names
73+
if class_name == "ExternalRef":
74+
class_name = "ExternalReference"
75+
if class_name == "ExternalRefType":
76+
class_name = "ExternalReferenceType"
77+
78+
# this is here because SPDX 3.0.1 DictionaryEntry is represented in
79+
# Python by tuple[str, str]
80+
if class_name == "DictionaryEntry":
81+
return tuple
82+
83+
# special case for the core profile because it is not in a subdirectory
84+
module_name = "spdx_tools.spdx3.model"
85+
if profile != "core":
86+
module_name += f".{profile}"
87+
try:
88+
target_module = importlib.import_module(module_name)
89+
except ModuleNotFoundError as e:
90+
raise ModuleNotFoundError(
91+
f"Could not find module '{module_name}'. It probably hasn't been implemented yet."
92+
) from e
93+
94+
try:
95+
return getattr(target_module, class_name)
96+
except AttributeError as e:
97+
raise AttributeError(
98+
(
99+
f"Could not find domain class '{class_name}' in module '{module_name}'."
100+
"It probably hasn't been implemented yet or was not added to its module's __init__.py file"
101+
)
102+
) from e
103+
104+
105+
def get_binding_type_name_from_domain_object(element: Element) -> str:
106+
"""
107+
Returns the SPDX type name for an element, including profile prefix if applicable.
108+
For example: "software_Package" or "CreationInfo"
109+
"""
110+
profile_prefix = get_spdx_profile_prefix(type(element))
111+
type_name = element.__class__.__name__
112+
return get_binding_type_name_from_profile_and_domain_class_name(profile_prefix, type_name)
113+
114+
115+
def get_binding_type_name_from_profile_and_domain_class_name(profile: str, domain_class_name: str) -> str:
116+
profile_prefix = profile.lower()
117+
type_name = domain_class_name
118+
119+
# special case for external reference
120+
if type_name == "ExternalReference":
121+
type_name = "ExternalRef"
122+
123+
return f"{profile_prefix}_{type_name}".strip("_")
124+
125+
126+
def get_binding_iri_from_profile_and_domain_class_name(profile: str, domain_class_name: str) -> str:
127+
if profile not in LOWER_CASE_TO_CORRECT_CASE_PROFILE_NAMES and profile != "":
128+
raise ValueError(
129+
(
130+
f"Unknown SPDX profile name: '{profile}'. Known profiles are: "
131+
f"{list(LOWER_CASE_TO_CORRECT_CASE_PROFILE_NAMES.keys())} or empty string for core profile"
132+
)
133+
)
134+
135+
iri_case_profile_name = LOWER_CASE_TO_CORRECT_CASE_PROFILE_NAMES.get(profile.lower(), "Core")
136+
137+
class_name = domain_class_name
138+
if class_name == "ExternalReference":
139+
class_name = "ExternalRef"
140+
if class_name == "ExternalReferenceType":
141+
class_name = "ExternalRefType"
142+
143+
return f"{FIRST_PART_OF_IRI}/{SPDX_VERSION}/terms/{iri_case_profile_name}/{class_name}"
144+
145+
146+
def get_binding_attribute_names(binding_class) -> set[str]:
147+
return {py_name for py_name, iri, compact in binding_class().property_keys()}
148+
149+
150+
def _get_class_directory_name(obj: object) -> str:
151+
"""
152+
Returns the directory that the passed in class is defined in.
153+
154+
This is used for finding the name of the directory that an implementation
155+
of an Element is in, which is the same as its profile.
156+
"""
157+
# If we're passed an instance, get its class
158+
if not isinstance(obj, type):
159+
obj = obj.__class__
160+
161+
# Get the module of the class
162+
module = inspect.getmodule(obj)
163+
if not module:
164+
return ""
165+
166+
# Get the file path of the module
167+
file_path = module.__file__
168+
if not file_path:
169+
return ""
170+
171+
# Get the directory containing the file
172+
dir_path = os.path.dirname(file_path)
173+
174+
# Return just the name of the directory (not the full path)
175+
return os.path.basename(dir_path)
176+
177+
178+
def get_spdx_profile_prefix(element: type) -> str:
179+
"""
180+
Returns the profile prefix of a domain element type.
181+
"""
182+
directory_name = _get_class_directory_name(element)
183+
184+
# If the class is in the model directory, then it is part of the Core profile and does not have a profile prefix
185+
if directory_name == "model":
186+
directory_name = ""
187+
188+
return directory_name
189+
190+
191+
def get_attributes_by_domain_class_hierarchy(obj: object) -> Dict[type, List[str]]:
192+
"""
193+
Returns a list of tuples containing each parent class name and the attributes
194+
defined in that class (including the class itself).
195+
196+
Args:
197+
obj: The object to analyze
198+
199+
Returns:
200+
List of tuples of (class_name, [attribute_names]) for each class in the inheritance hierarchy
201+
"""
202+
# Get the class of the object if an instance was provided
203+
cls = obj if isinstance(obj, type) else obj.__class__
204+
205+
# Get the Method Resolution Order (MRO) - the inheritance hierarchy
206+
class_hierarchy = cls.__mro__
207+
208+
result = {}
209+
all_attrs = set() # Track attributes we've already seen
210+
211+
# Stop at these classes as that is as far down the class hierarchy as we need to go
212+
stop_classes = {
213+
"Element",
214+
"CreationInfo",
215+
"ExternalIdentifier",
216+
"ExternalMap",
217+
"DictionaryEntry",
218+
"ExternalRef",
219+
"PositiveIntegerRange",
220+
"NamespaceMap",
221+
"IntegrityMethod",
222+
"Hash",
223+
"PackageVerificationCode",
224+
}
225+
226+
# Iterate through each class in the hierarchy
227+
for parent_class in class_hierarchy:
228+
# Get all attributes defined directly in this class (not inherited)
229+
class_attrs = set()
230+
for attr_name in parent_class.__dict__.keys():
231+
# Skip private/special attributes and methods
232+
if not attr_name.startswith("__") and not attr_name.startswith("_abc_"):
233+
class_attrs.add(attr_name)
234+
235+
# Remove attributes already found in child classes
236+
unique_attrs = class_attrs - all_attrs
237+
all_attrs.update(unique_attrs)
238+
239+
# Add to result if there are any attributes
240+
if unique_attrs:
241+
result[parent_class] = sorted(unique_attrs)
242+
if parent_class.__name__ in stop_classes:
243+
break
244+
245+
return result
246+
247+
248+
def get_binding_property_name_from_element_field_name_and_prefix(element_field_name: str, profile_prefix: str) -> str:
249+
# special cases
250+
# the spdx_id field is always mapped to _id in the binding classes
251+
if element_field_name == "spdx_id":
252+
return "_id"
253+
254+
field_name = element_field_name
255+
if field_name == "from_element":
256+
return "from_"
257+
258+
if field_name == "external_reference":
259+
return "externalRef"
260+
261+
if field_name == "external_reference_type":
262+
return "externalRefType"
263+
264+
if field_name == "imports":
265+
return "import_"
266+
267+
return f"{profile_prefix}_{snake_case_to_camel_case(field_name)}".strip("_")
268+
269+
270+
def get_domain_property_name_from_binding_property_name(binding_property_name: str) -> str:
271+
# Special cases. These do not follow the naming conventions.
272+
if binding_property_name == "_id":
273+
return "spdx_id"
274+
elif binding_property_name == "from_":
275+
return "from_element"
276+
elif binding_property_name == "externalRef":
277+
return "external_reference"
278+
elif binding_property_name == "externalRefType":
279+
return "external_reference_type"
280+
elif binding_property_name == "import_":
281+
return "imports"
282+
283+
# Typical cases
284+
if "_" in binding_property_name:
285+
# A profile prefix is present because there is a "_" present.
286+
# Remove it because the domain model's property names do not have profile prefixes.
287+
# These binding_property_name(s) are formatted like "software_Package",
288+
# "simplelicensing_LicenseAddition", or "ElementCollection".
289+
prefix, _, suffix = binding_property_name.partition("_")
290+
if len(suffix) == 0:
291+
# this shouldn't happen, but just in case
292+
domain_property_name = prefix
293+
else:
294+
domain_property_name = suffix
295+
else:
296+
# This is a "core" profile property, so it doesn't have a profile prefix
297+
domain_property_name = binding_property_name
298+
299+
# The (Python) domain properties are Python properties, so they are snake case
300+
return camel_case_to_snake_case(domain_property_name)
301+
302+
303+
def is_binding_object_kind_not_iri_and_the_field_is_id(binding_obj, pyname: str) -> bool:
304+
return (
305+
isinstance(binding_obj, spdx_3_0.SHACLObject)
306+
and binding_obj.NODE_KIND != spdx_3_0.NodeKind.IRI
307+
and pyname == "_id"
308+
)

0 commit comments

Comments
 (0)