FOSSology  4.7.1
Open Source License Compliance by Open Source Software
Spdx3Report.py
1 #!/usr/bin/env python3
2 
3 #
4 # SPDX-FileCopyrightText: © 2026 Siemens AG
5 # SPDX-FileContributor: Shatakshi Tiwari <shatakshi.tiwari@siemens.com>
6 #
7 # SPDX-License-Identifier: GPL-2.0-only
8 
9 """
10 SPDX 3.0 Report class.
11 
12 """
13 
14 import hashlib
15 import json
16 import logging
17 import os
18 import re
19 import uuid
20 from datetime import datetime, timezone
21 
22 import rdflib
23 from rdflib import BNode, Literal, Namespace, RDF, RDFS, URIRef
24 from rdflib.util import SUFFIX_FORMAT_MAP
25 from semantic_version import Version
26 
27 from spdx_tools.spdx3.model import (
28  CreationInfo,
29  Hash,
30  HashAlgorithm,
31  Organization,
32  ProfileIdentifierType,
33  Tool,
34  Relationship,
35  RelationshipType,
36  SpdxDocument,
37 )
38 from spdx_tools.spdx3.model.software import (
39  File as SpdxFile,
40  Package,
41  SoftwarePurpose,
42 )
43 from license_expression import get_spdx_licensing
44 from spdx_tools.spdx3.model.licensing import (
45  AnyLicenseInfo,
46  ConjunctiveLicenseSet,
47  CustomLicense,
48  DisjunctiveLicenseSet,
49  NoAssertionLicense,
50 )
51 from spdx_tools.spdx3.payload import Payload
52 from spdx_tools.spdx3.writer.json_ld import json_ld_writer as _json_ld_writer_mod
53 from spdx_tools.spdx3.writer.json_ld.json_ld_converter import (
54  convert_payload_to_json_ld_list_of_elements,
55 )
56 
57 from .CliOptions import CliOptions
58 from .ReportBase import ReportBase
59 from .Scanners import Scanners, ScanResultList
60 
61 SPDX3_SPEC_VERSION = Version("3.0.1")
62 TOOL_NAME = "FOSSology CI Scanner"
63 
64 
65 class LicenseExpression(AnyLicenseInfo):
66 
67  def __init__(self, license_expression: str):
68  self.license_expressionlicense_expression = license_expression
69 
70 
72  """
73  Handle SPDX 3.0 reports.
74 
75  :ivar cli_options: CliOptions object
76  :ivar scanner: Scanners object
77  :ivar payload: spdx_tools Payload holding all SPDX 3.0 elements
78  :ivar root_package: Root Package element for the scanned project
79  :ivar creation_info: Shared CreationInfo for all elements
80  """
81 
82  def __init__(self, cli_options: CliOptions, scanner: Scanners):
83  """
84  Initialise the SPDX 3.0 report builder.
85 
86  :param cli_options: CliOptions to use
87  :param scanner: Scanners to use
88  """
89  self.cli_optionscli_optionscli_options = cli_options
90  self.scannerscannerscanner = scanner
91  self.payloadpayload = Payload()
92  self._rel_idx_rel_idx = 0
93  self._file_idx_file_idx = 0
94  self._spdx_licensing_spdx_licensing = get_spdx_licensing()
95  self._license_cache: dict[str, object] = {}
96 
97  # --- Derive project metadata from scan packages ---
98  parent_package = self.scannerscannerscanner.get_scan_packages().parent_package
99  project_name = (parent_package.get('name') or '').strip()
100  if not project_name:
101  if self.cli_optionscli_optionscli_options.parser is not None:
102  project_name = getattr(
103  self.cli_optionscli_optionscli_options.parser, 'root_component_name', ''
104  ) or ''
105  if not project_name:
106  project_name = "NA"
107 
108  org_name = (parent_package.get('author') or 'Unknown').strip() or 'Unknown'
109 
110  # --- Base URI ---
111  self._base_base = self._make_base_uri_make_base_uri(project_name)
112 
113  # --- CreationInfo (shared by every element) ---
114  org_id = (
115  f"{self._base}/Agent/"
116  f"{re.sub(r'[^a-zA-Z0-9]', '', org_name)}"
117  )
118  tool_id = f"{self._base}/Tool/fossology-scanner"
119 
120  self.creation_infocreation_info = CreationInfo(
121  spec_version=SPDX3_SPEC_VERSION,
122  created=datetime.now(timezone.utc),
123  created_by=[org_id],
124  created_using=[tool_id],
125  profile=[
126  ProfileIdentifierType.CORE,
127  ProfileIdentifierType.SOFTWARE,
128  ProfileIdentifierType.LICENSING,
129  ],
130  )
131 
132  # --- Agent / Tool elements ---
133  org_elem = Organization(
134  spdx_id=org_id,
135  creation_info=self.creation_infocreation_info,
136  name=org_name,
137  )
138  tool_elem = Tool(
139  spdx_id=tool_id,
140  creation_info=self.creation_infocreation_info,
141  name=TOOL_NAME,
142  )
143  self.payloadpayload.add_element(org_elem)
144  self.payloadpayload.add_element(tool_elem)
145 
146  # --- Root Package ---
147  safe_pkg = re.sub(r"[^a-zA-Z0-9._-]", "-", project_name)
148  root_pkg_id = f"{self._base}/Package/{safe_pkg}"
149  self.root_packageroot_package = Package(
150  spdx_id=root_pkg_id,
151  name=project_name,
152  creation_info=self.creation_infocreation_info,
153  primary_purpose=SoftwarePurpose.APPLICATION,
154  )
155  self.payloadpayload.add_element(self.root_packageroot_package)
156 
157  # ------------------------------------------------------------------
158  # Public API
159  # ------------------------------------------------------------------
160 
161  def finalize_document(self):
162  """
163  Process all scan results (parent + dependencies), create File elements
164  and Relationships, then build the SpdxDocument.
165  """
166  # Process parent package
167  parent = self.scannerscannerscanner.get_scan_packages().parent_package
168  self._process_component_process_component(parent, self.root_packageroot_package.spdx_id)
169 
170  # Process each dependency
171  for _purl, component in (
172  self.scannerscannerscanner.get_scan_packages().dependencies.items()
173  ):
174  dep_pkg = self._get_or_create_dep_package_get_or_create_dep_package(component)
175  self._process_component_process_component(component, dep_pkg.spdx_id)
176 
177  # DEPENDS_ON relationship from root → dependency
178  dep_rel = Relationship(
179  spdx_id=f"{self._base}/Relationship/{self._rel_idx}",
180  from_element=self.root_packageroot_package.spdx_id,
181  relationship_type=RelationshipType.DEPENDS_ON,
182  to=[dep_pkg.spdx_id],
183  creation_info=self.creation_infocreation_info,
184  )
185  self.payloadpayload.add_element(dep_rel)
186  self._rel_idx_rel_idx += 1
187 
188  # --- Build SpdxDocument ---
189  all_ids = [e.spdx_id for e in self.payloadpayload.get_full_map().values()]
190 
191  describes_rel_id = f"{self._base}/Relationship/{self._rel_idx}"
192  all_ids.append(describes_rel_id)
193 
194  doc_id = f"{self._base}/Document"
195  spdx_doc = SpdxDocument(
196  spdx_id=doc_id,
197  name=self.root_packageroot_package.name,
198  element=all_ids,
199  root_element=[self.root_packageroot_package.spdx_id],
200  creation_info=self.creation_infocreation_info,
201  )
202  self.payloadpayload.add_element(spdx_doc)
203 
204  describes_rel = Relationship(
205  spdx_id=describes_rel_id,
206  from_element=doc_id,
207  relationship_type=RelationshipType.DESCRIBES,
208  to=[self.root_packageroot_package.spdx_id],
209  creation_info=self.creation_infocreation_info,
210  )
211  self.payloadpayload.add_element(describes_rel)
212  self._rel_idx_rel_idx += 1
213 
214  logging.info("SPDX 3.0 document finalized with %d elements.",
215  len(self.payloadpayload.get_full_map()))
216 
217  def write_report(self, file_name: str):
218 
219  os.makedirs(os.path.dirname(os.path.abspath(file_name)), exist_ok=True)
220 
221  # --- Build RDF graph from spdx-tools model objects ---
222  element_list = convert_payload_to_json_ld_list_of_elements(self.payloadpayload)
223  context_path = os.path.join(
224  os.path.dirname(_json_ld_writer_mod.__file__), "context.json",
225  )
226  with open(context_path, "r", encoding="utf-8") as ctx_file:
227  context = json.load(ctx_file)
228 
229  # Inject LicenseExpression type and property into the JSON-LD context.
230  # The spdx-tools library does not yet implement the SimpleLicensing
231  # LicenseExpression class, so we add the mappings here.
232  ctx_inner = context if "@context" not in context else context["@context"]
233  ctx_inner["LicenseExpression"] = "licensing:LicenseExpression"
234  ctx_inner["licenseExpression"] = {
235  "@id": "licensing:licenseExpression",
236  "@type": "xsd:string"
237  }
238 
239  json_ld_dict = {"@context": context, "@graph": element_list}
240  json_ld_str = json.dumps(json_ld_dict)
241 
242  g = rdflib.Graph()
243  g.parse(data=json_ld_str, format="json-ld")
244 
245  # --- Apply fixups ---
246  self._deduplicate_creation_info_deduplicate_creation_info(g, rdflib)
247  self._fixup_rdf_graph_fixup_rdf_graph(g, rdflib)
248 
249  # --- Serialize to target format ---
250  out_format = self._rdf_format_for_file_rdf_format_for_file(file_name)
251 
252  if out_format in ("xml", "pretty-xml"):
253  self._sanitize_xml_literals_sanitize_xml_literals(g, rdflib)
254 
255  g.serialize(destination=file_name, format=out_format)
256  logging.info("SPDX 3.0 %s written to: %s (%d bytes)",
257  out_format.upper(), file_name, os.path.getsize(file_name))
258 
259  # --- Validate ---
260  self._validate_report_validate_report(file_name)
261 
262  # ------------------------------------------------------------------
263  # Internal helpers
264  # ------------------------------------------------------------------
265 
266  @staticmethod
268  """
269  Load the SPDX SHACL model bundled with spdx-tools.
270 
271  :return: (rdflib.Graph, shacl_path_str)
272  """
273  shacl_path = os.path.join(os.path.dirname(_json_ld_writer_mod.__file__), "model.ttl")
274  model = rdflib.Graph()
275  with open(shacl_path, "r", encoding="utf-8") as f:
276  model.parse(data=f.read(), format="turtle")
277  return model, shacl_path
278 
279  def _deduplicate_creation_info(self, g, rdflib):
280  """
281  Merge identical CreationInfo blank nodes into a single named node.
282 
283  The spdx-tools JSON-LD serializer inlines CreationInfo as blank nodes,
284  causing duplication. This replaces all of them with one shared URI node.
285  """
286  SPDX_CORE = Namespace("https://spdx.org/rdf/Core/")
287  ci_type = SPDX_CORE.CreationInfo
288 
289  # Collect all blank nodes of type CreationInfo
290  ci_bnodes = [
291  s for s in g.subjects(RDF.type, ci_type) if isinstance(s, BNode)
292  ]
293  if len(ci_bnodes) <= 1:
294  return
295 
296  # Create a single named node for the shared CreationInfo
297  ci_uri = URIRef(f"{self._base}/CreationInfo/shared")
298 
299  # Copy all triples from the first blank node to the named node
300  first = ci_bnodes[0]
301  for p, o in g.predicate_objects(first):
302  g.add((ci_uri, p, o))
303 
304  # Replace all references and remove old blank node triples
305  for bnode in ci_bnodes:
306  for s, p in list(g.subject_predicates(bnode)):
307  g.remove((s, p, bnode))
308  g.add((s, p, ci_uri))
309  for p, o in list(g.predicate_objects(bnode)):
310  g.remove((bnode, p, o))
311 
312  logging.info("Deduplicated %d CreationInfo nodes into 1", len(ci_bnodes))
313 
314  def _fixup_rdf_graph(self, g, rdflib):
315 
316  SH = Namespace("http://www.w3.org/ns/shacl#")
317 
318  model, _ = self._load_spdx_shacl_model_load_spdx_shacl_model()
319 
320  # ------------------------------------------------------------------
321  # 1. Build transitive rdfs:subClassOf closure from the SHACL ontology
322  # ------------------------------------------------------------------
323  subclass_map: dict[URIRef, set[URIRef]] = {}
324  for child, parent in model.subject_objects(RDFS.subClassOf):
325  if isinstance(child, URIRef) and isinstance(parent, URIRef):
326  subclass_map.setdefault(child, set()).add(parent)
327 
328  # Inject LicenseExpression type hierarchy (not in bundled SHACL model).
329  # Per the SPDX 3.0.1 spec, LicenseExpression is:
330  # LicenseExpression → AnyLicenseInfo → LicenseField
331  SPDX_LIC = Namespace("https://spdx.org/rdf/Licensing/")
332  subclass_map.setdefault(
333  SPDX_LIC.LicenseExpression, set()
334  ).add(SPDX_LIC.AnyLicenseInfo)
335 
336  # Compute transitive closure (child → all ancestors)
337  changed = True
338  while changed:
339  changed = False
340  for child, parents in list(subclass_map.items()):
341  for parent in list(parents):
342  grandparents = subclass_map.get(parent, set())
343  new_ancestors = grandparents - parents
344  if new_ancestors:
345  parents.update(new_ancestors)
346  changed = True
347 
348  # ------------------------------------------------------------------
349  # 2. Collect sh:property shapes with sh:class constraints
350  # ------------------------------------------------------------------
351  # property_path → expected sh:class
352  class_constraints: dict[URIRef, URIRef] = {}
353  for shape in model.subjects(SH.property, None):
354  for prop_node in model.objects(shape, SH.property):
355  sh_path = model.value(prop_node, SH.path)
356  sh_class = model.value(prop_node, SH["class"])
357  if sh_path and sh_class and isinstance(sh_path, URIRef):
358  class_constraints[sh_path] = sh_class
359 
360  # ------------------------------------------------------------------
361  # 3. Fix typed literals → IRI references for sh:class properties
362  # The spdx-tools JSON-LD context.json mis-declares some properties
363  # with @type set to a class name instead of @id, producing typed
364  # literals (e.g. "file"^^SoftwarePurpose) where IRIs are expected.
365  # ------------------------------------------------------------------
366  for prop_path, expected_class in class_constraints.items():
367  for s, o in list(g.subject_objects(prop_path)):
368  if isinstance(o, Literal):
369  val = str(o)
370  if val.startswith(("http://", "https://", "urn:")):
371  # Value is already a valid URI string → promote to IRI
372  iri = URIRef(val)
373  else:
374  # Enum-style value → construct <ClassIRI/value>
375  iri = URIRef(f"{expected_class}/{val}")
376  g.remove((s, prop_path, o))
377  g.add((s, prop_path, iri))
378 
379  # ------------------------------------------------------------------
380  # 4. Add superclass types to all typed instances in the data graph
381  # ------------------------------------------------------------------
382  for child_class, ancestors in subclass_map.items():
383  for subject in list(g.subjects(RDF.type, child_class)):
384  for ancestor in ancestors:
385  g.add((subject, RDF.type, ancestor))
386 
387  # ------------------------------------------------------------------
388  # 5. Declare enum / class instances for IRI-valued sh:class properties
389  # If a value is a URIRef but has no rdf:type matching the expected
390  # sh:class (or any of its subclasses), add the type declaration.
391  # ------------------------------------------------------------------
392  for prop_path, expected_class in class_constraints.items():
393  for _, o in g.subject_objects(prop_path):
394  if isinstance(o, URIRef):
395  existing_types = set(g.objects(o, RDF.type))
396  # Check if any existing type is the expected class or a subclass
397  if not self._type_satisfies_type_satisfies(
398  existing_types, expected_class, subclass_map
399  ):
400  g.add((o, RDF.type, expected_class))
401 
402  @staticmethod
403  def _type_satisfies(existing_types, expected_class, subclass_map):
404  """
405  Check whether any of *existing_types* equals *expected_class* or is
406  a known subclass of it (according to *subclass_map*).
407  """
408  for t in existing_types:
409  if t == expected_class:
410  return True
411  if expected_class in subclass_map.get(t, set()):
412  return True
413  return False
414 
415  def _process_component(self, component: dict, parent_pkg_id: str):
416  """
417  Read SCANNER_RESULTS and COPYRIGHT_RESULT from *component* dict,
418  create File elements, and wire CONTAINS relationships.
419  """
420  # Merge license + copyright results by file path
421  file_data: dict[str, dict] = {}
422 
423  for scan_result in component.get('SCANNER_RESULTS', []):
424  path = scan_result.file
425  entry = file_data.setdefault(path, {
426  'scan_result': scan_result, 'licenses': [], 'copyrights': []
427  })
428  for lic in scan_result.result:
429  lic_str = lic.get('license', '') if isinstance(lic, dict) else str(lic)
430  if lic_str:
431  entry['licenses'].append(lic_str)
432 
433  for cr_result in component.get('COPYRIGHT_RESULT', []):
434  path = cr_result.file
435  entry = file_data.setdefault(path, {
436  'scan_result': cr_result, 'licenses': [], 'copyrights': []
437  })
438  for cpy in cr_result.result:
439  text = cpy.get('content', '') if isinstance(cpy, dict) else str(cpy)
440  if text:
441  entry['copyrights'].append(text)
442 
443  for path, data in file_data.items():
444  # Skip .git/ directory contents
445  if path.startswith('.git/') or '/.git/' in path:
446  continue
447 
448  scan_result = data['scan_result']
449  file_id = f"{self._base}/File/{self._file_idx}"
450  self._file_idx_file_idx += 1
451 
452  # Hashes
453  hashes = []
454  try:
455  sha256_hash = hashlib.sha256()
456  with open(scan_result.path, "rb") as fh:
457  for chunk in iter(lambda: fh.read(4096), b""):
458  sha256_hash.update(chunk)
459  hashes.append(
460  Hash(algorithm=HashAlgorithm.SHA256,
461  hash_value=sha256_hash.hexdigest())
462  )
463  except (OSError, AttributeError):
464  pass
465 
466  # Copyright text
467  copyright_text = "\n".join(data['copyrights']) or None
468 
469  # Concluded license
470  licenses = sorted(set(data['licenses']))
471  concluded_license = self._resolve_licenses_resolve_licenses(licenses)
472 
473  f_elem = SpdxFile(
474  spdx_id=file_id,
475  name=path,
476  creation_info=self.creation_infocreation_info,
477  copyright_text=copyright_text,
478  verified_using=hashes if hashes else None,
479  primary_purpose=SoftwarePurpose.FILE,
480  concluded_license=concluded_license,
481  )
482  self.payloadpayload.add_element(f_elem)
483 
484  # CONTAINS relationship
485  contains_rel = Relationship(
486  spdx_id=f"{self._base}/Relationship/{self._rel_idx}",
487  from_element=parent_pkg_id,
488  relationship_type=RelationshipType.CONTAINS,
489  to=[file_id],
490  creation_info=self.creation_infocreation_info,
491  )
492  self.payloadpayload.add_element(contains_rel)
493  self._rel_idx_rel_idx += 1
494 
495  def _resolve_licenses(self, licenses: list[str]):
496  """
497  Resolve a list of license identifiers to the correct SPDX 3.0 model
498  objects.
499 
500  - SPDX-listed licenses → ListedLicense (ExpandedLicensing)
501  - Non-SPDX licenses → CustomLicense with LicenseRef- prefix
502  - Multiple licenses → ConjunctiveLicenseSet (AND semantics)
503  - No licenses → NoAssertionLicense
504 
505  Objects are cached so the same license ID reuses one instance.
506  """
507  if not licenses:
508  return NoAssertionLicense()
509 
510  resolved = [self._get_license_object_get_license_object(lic) for lic in licenses]
511 
512  if len(resolved) == 1:
513  return resolved[0]
514  return ConjunctiveLicenseSet(member=resolved)
515 
516  def _get_license_object(self, lic_id: str):
517  """Return a cached LicenseExpression or CustomLicense for a single ID.
518 
519  For SPDX-listed licenses, creates a LicenseExpression with just the
520  spdx expression string (no full license object needed per the spec).
521  For custom/unknown licenses, creates a CustomLicense with licenseText
522  as required by the SPDX 3.0 spec (minCount=1).
523  """
524  if lic_id in self._license_cache:
525  return self._license_cache[lic_id]
526 
527  if not self._spdx_licensing_spdx_licensing.validate(lic_id).invalid_symbols:
528  # Known SPDX-listed license — use LicenseExpression with just the
529  # spdx identifier string. No need for a full License object.
530  obj = LicenseExpression(
531  license_expression=lic_id,
532  )
533  else:
534  # Non-SPDX / custom license — requires licenseText (minCount=1)
535  safe = re.sub(r"[^a-zA-Z0-9._-]", "-", lic_id)
536  ref_id = lic_id if lic_id.startswith("LicenseRef-") else f"LicenseRef-fossology-{safe}"
537  # Strip LicenseRef- prefix for the human-readable name
538  # (matches SPDX 2.3 behaviour where license_name = original scanner value)
539  display_name = lic_id.removeprefix("LicenseRef-") if lic_id.startswith("LicenseRef-") else lic_id
540  obj = CustomLicense(
541  license_id=ref_id,
542  license_name=display_name,
543  license_text=f"The license text for {ref_id} has to be entered.",
544  )
545 
546  self._license_cache[lic_id] = obj
547  return obj
548 
549  def _get_or_create_dep_package(self, component: dict) -> Package:
550  """
551  Create (or retrieve cached) a dependency Package element.
552  """
553  pkg_name = component.get('name', 'UNKNOWN')
554  pkg_version = component.get('version', 'UNKNOWN')
555  safe = re.sub(r"[^a-zA-Z0-9._-]", "-", f"{pkg_name}-{pkg_version}")
556  pkg_id = f"{self._base}/Package/{safe}"
557 
558  # Check if already in payload
559  existing = self.payloadpayload.get_full_map().get(pkg_id)
560  if existing is not None:
561  return existing
562 
563  dep_pkg = Package(
564  spdx_id=pkg_id,
565  name=pkg_name,
566  creation_info=self.creation_infocreation_info,
567  primary_purpose=SoftwarePurpose.LIBRARY,
568  )
569  self.payloadpayload.add_element(dep_pkg)
570  return dep_pkg
571 
572  @staticmethod
573  def _make_base_uri(doc_name: str) -> str:
574  """Generate a unique base URI for the document."""
575  unique = uuid.uuid4().hex[:12]
576  safe = re.sub(r"[^a-zA-Z0-9._-]", "-", doc_name)
577  return f"urn:spdx:{safe}/{unique}"
578 
579  # Regex matching characters forbidden in XML 1.0.
580  # Allowed: #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF]
581  _XML_INVALID_RE = re.compile(
582  '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x84\x86-\x9f'
583  '\ud800-\udfff\ufdd0-\ufdef\ufffe\uffff]'
584  )
585 
586  @staticmethod
587  def _sanitize_xml_literals(g, rdflib):
588  """
589  Remove characters that are invalid in XML 1.0 from all Literal values.
590 
591  rdflib's RDF/XML serializer writes these characters verbatim, but
592  XML parsers (expat) reject them on re-read, causing 'not well-formed'
593  errors. Only needed for XML-based output formats.
594  """
595  for s, p, o in list(g):
596  if isinstance(o, Literal):
597  val = str(o)
598  clean = Spdx3Report._XML_INVALID_RE.sub('', val)
599  if clean != val:
600  g.remove((s, p, o))
601  g.add((s, p, Literal(clean, datatype=o.datatype, lang=o.language)))
602 
603  @staticmethod
604  def _rdf_format_for_file(file_name: str) -> str:
605  """
606  Determine the rdflib serialization format from a file extension.
607 
608  Uses rdflib's own ``SUFFIX_FORMAT_MAP`` so that new formats are
609  supported automatically without code changes. Falls back to
610  ``json-ld`` for unknown extensions.
611  """
612  ext = os.path.splitext(file_name)[1].lstrip(".").lower()
613  return SUFFIX_FORMAT_MAP.get(ext, "json-ld")
614 
615  def _validate_report(self, file_name: str) -> None:
616  """
617  Validate the written SPDX 3.0 report using pyshacl against the
618  SHACL schema bundled with spdx-tools.
619 
620  Works for any RDF serialization format (Turtle, JSON-LD, RDF/XML, etc.).
621  """
622  data_format = self._rdf_format_for_file_rdf_format_for_file(file_name)
623 
624  logging.info("Validating SPDX 3.0 report against SHACL schema...")
625  try:
626  import pyshacl
627 
628  shacl_graph, _ = self._load_spdx_shacl_model_load_spdx_shacl_model()
629 
630  data_graph = rdflib.Graph()
631  data_graph.parse(file_name, format=data_format)
632 
633  conforms, results_graph, results_text = pyshacl.validate(
634  data_graph=data_graph,
635  shacl_graph=shacl_graph,
636  )
637  except ImportError as e:
638  logging.warning("SHACL validation skipped (missing dependency: %s)", e)
639  return
640  except Exception as e:
641  logging.warning("SHACL validation could not run: %s", e)
642  return
643 
644  if conforms:
645  logging.info("SPDX 3.0 report conforms to SHACL schema.")
646  else:
647  lines = results_text.strip().split('\n')
648  violation_count = len(
649  [l for l in lines if 'Violation' in l]
650  )
651  logging.warning(
652  "SHACL validation reported %d violation(s).",
653  violation_count,
654  )
655  for line in lines:
656  logging.debug("SHACL: %s", line)
657  logging.info("Report saved despite SHACL warnings (non-blocking).")
def _fixup_rdf_graph(self, g, rdflib)
Definition: Spdx3Report.py:314
def _deduplicate_creation_info(self, g, rdflib)
Definition: Spdx3Report.py:279
None _validate_report(self, str file_name)
Definition: Spdx3Report.py:615
str _rdf_format_for_file(str file_name)
Definition: Spdx3Report.py:604
Package _get_or_create_dep_package(self, dict component)
Definition: Spdx3Report.py:549
def _process_component(self, dict component, str parent_pkg_id)
Definition: Spdx3Report.py:415
str _make_base_uri(str doc_name)
Definition: Spdx3Report.py:573
def __init__(self, CliOptions cli_options, Scanners scanner)
Definition: Spdx3Report.py:82
def _type_satisfies(existing_types, expected_class, subclass_map)
Definition: Spdx3Report.py:403
def write_report(self, str file_name)
Definition: Spdx3Report.py:217
def _resolve_licenses(self, list[str] licenses)
Definition: Spdx3Report.py:495
def _get_license_object(self, str lic_id)
Definition: Spdx3Report.py:516
list_t type structure used to keep various lists. (e.g. there are multiple lists).
Definition: nomos.h:308