|
| 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