shacl.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. """
  2. Utilities for interacting with SHACL Shapes Graphs more easily.
  3. """
  4. from __future__ import annotations
  5. from typing import TYPE_CHECKING, Optional, Union
  6. from rdflib import BNode, Graph, Literal, URIRef, paths
  7. from rdflib.collection import Collection
  8. from rdflib.namespace import RDF, SH
  9. from rdflib.paths import Path
  10. from rdflib.term import Node
  11. if TYPE_CHECKING:
  12. from rdflib.term import IdentifiedNode
  13. class SHACLPathError(Exception):
  14. pass
  15. # Map the variable length path operators to the corresponding SHACL path predicates
  16. _PATH_MOD_TO_PRED = {
  17. paths.ZeroOrMore: SH.zeroOrMorePath,
  18. paths.OneOrMore: SH.oneOrMorePath,
  19. paths.ZeroOrOne: SH.zeroOrOnePath,
  20. }
  21. # This implementation is roughly based on
  22. # pyshacl.helper.sparql_query_helper::SPARQLQueryHelper._shacl_path_to_sparql_path
  23. def parse_shacl_path(
  24. shapes_graph: Graph,
  25. path_identifier: Node,
  26. ) -> Union[URIRef, Path]:
  27. """
  28. Parse a valid SHACL path (e.g. the object of a triple with predicate sh:path)
  29. from a [`Graph`][rdflib.graph.Graph] as a [`URIRef`][rdflib.term.URIRef] if the path
  30. is simply a predicate or a [`Path`][rdflib.paths.Path] otherwise.
  31. Args:
  32. shapes_graph: A [`Graph`][rdflib.graph.Graph] containing the path to be parsed
  33. path_identifier: A [`Node`][rdflib.term.Node] of the path
  34. Returns:
  35. A [`URIRef`][rdflib.term.URIRef] or a [`Path`][rdflib.paths.Path]
  36. """
  37. path: Optional[Union[URIRef, Path]] = None
  38. # Literals are not allowed.
  39. if isinstance(path_identifier, Literal):
  40. raise TypeError("Literals are not a valid SHACL path.")
  41. # If a path is a URI, that's the whole path.
  42. elif isinstance(path_identifier, URIRef):
  43. if path_identifier == RDF.nil:
  44. raise SHACLPathError(
  45. "A list of SHACL Paths must contain at least two path items."
  46. )
  47. path = path_identifier
  48. # Handle Sequence Paths
  49. elif shapes_graph.value(path_identifier, RDF.first) is not None:
  50. sequence = list(shapes_graph.items(path_identifier))
  51. if len(sequence) < 2:
  52. raise SHACLPathError(
  53. "A list of SHACL Sequence Paths must contain at least two path items."
  54. )
  55. path = paths.SequencePath(
  56. *(parse_shacl_path(shapes_graph, path) for path in sequence)
  57. )
  58. # Handle sh:inversePath
  59. elif inverse_path := shapes_graph.value(path_identifier, SH.inversePath):
  60. path = paths.InvPath(parse_shacl_path(shapes_graph, inverse_path))
  61. # Handle sh:alternativePath
  62. elif alternative_path := shapes_graph.value(path_identifier, SH.alternativePath):
  63. alternatives = list(shapes_graph.items(alternative_path))
  64. if len(alternatives) < 2:
  65. raise SHACLPathError(
  66. "List of SHACL alternate paths must have at least two path items."
  67. )
  68. path = paths.AlternativePath(
  69. *(
  70. parse_shacl_path(shapes_graph, alternative)
  71. for alternative in alternatives
  72. )
  73. )
  74. # Handle sh:zeroOrMorePath
  75. elif zero_or_more_path := shapes_graph.value(path_identifier, SH.zeroOrMorePath):
  76. path = paths.MulPath(parse_shacl_path(shapes_graph, zero_or_more_path), "*")
  77. # Handle sh:oneOrMorePath
  78. elif one_or_more_path := shapes_graph.value(path_identifier, SH.oneOrMorePath):
  79. path = paths.MulPath(parse_shacl_path(shapes_graph, one_or_more_path), "+")
  80. # Handle sh:zeroOrOnePath
  81. elif zero_or_one_path := shapes_graph.value(path_identifier, SH.zeroOrOnePath):
  82. path = paths.MulPath(parse_shacl_path(shapes_graph, zero_or_one_path), "?")
  83. # Raise error if none of the above options were found
  84. elif path is None:
  85. raise SHACLPathError(f"Cannot parse {repr(path_identifier)} as a SHACL Path.")
  86. return path
  87. def _build_path_component(
  88. graph: Graph, path_component: URIRef | Path
  89. ) -> IdentifiedNode:
  90. """
  91. Helper method that implements the recursive component of SHACL path
  92. triple construction.
  93. Args:
  94. graph: A [`Graph`][rdflib.graph.Graph] into which to insert triples
  95. graph_component: A [`URIRef`][rdflib.term.URIRef] or
  96. [`Path`][rdflib.paths.Path] that is part of a path expression
  97. Returns:
  98. The [`IdentifiedNode`][rdflib.term.IdentifiedNode] of the resource in the
  99. graph that corresponds to the provided path_component
  100. """
  101. # Literals or other types are not allowed
  102. if not isinstance(path_component, (URIRef, Path)):
  103. raise TypeError(
  104. f"Objects of type {type(path_component)} are not valid "
  105. + "components of a SHACL path."
  106. )
  107. # If the path component is a URI, return it
  108. elif isinstance(path_component, URIRef):
  109. return path_component
  110. # Otherwise, the path component is represented as a blank node
  111. bnode = BNode()
  112. # Handle Sequence Paths
  113. if isinstance(path_component, paths.SequencePath):
  114. # Sequence paths are a Collection directly with at least two items
  115. if len(path_component.args) < 2:
  116. raise SHACLPathError(
  117. "A list of SHACL Sequence Paths must contain at least two path items."
  118. )
  119. Collection(
  120. graph,
  121. bnode,
  122. [_build_path_component(graph, arg) for arg in path_component.args],
  123. )
  124. # Handle Inverse Paths
  125. elif isinstance(path_component, paths.InvPath):
  126. graph.add(
  127. (bnode, SH.inversePath, _build_path_component(graph, path_component.arg))
  128. )
  129. # Handle Alternative Paths
  130. elif isinstance(path_component, paths.AlternativePath):
  131. # Alternative paths are a Collection but referenced by sh:alternativePath
  132. # with at least two items
  133. if len(path_component.args) < 2:
  134. raise SHACLPathError(
  135. "List of SHACL alternate paths must have at least two path items."
  136. )
  137. coll = Collection(
  138. graph,
  139. BNode(),
  140. [_build_path_component(graph, arg) for arg in path_component.args],
  141. )
  142. graph.add((bnode, SH.alternativePath, coll.uri))
  143. # Handle Variable Length Paths
  144. elif isinstance(path_component, paths.MulPath):
  145. # Get the predicate corresponding to the path modifiier
  146. pred = _PATH_MOD_TO_PRED.get(path_component.mod)
  147. if pred is None:
  148. raise SHACLPathError(f"Unknown path modifier {path_component.mod}")
  149. graph.add((bnode, pred, _build_path_component(graph, path_component.path)))
  150. # Return the blank node created for the provided path_component
  151. return bnode
  152. def build_shacl_path(
  153. path: URIRef | Path, target_graph: Graph | None = None
  154. ) -> tuple[IdentifiedNode, Graph | None]:
  155. """
  156. Build the SHACL Path triples for a path given by a [`URIRef`][rdflib.term.URIRef] for
  157. simple paths or a [`Path`][rdflib.paths.Path] for complex paths.
  158. Returns an [`IdentifiedNode`][rdflib.term.IdentifiedNode] for the path (which should be
  159. the object of a triple with predicate `sh:path`) and the graph into which any
  160. new triples were added.
  161. Args:
  162. path: A [`URIRef`][rdflib.term.URIRef] or a [`Path`][rdflib.paths.Path]
  163. target_graph: Optionally, a [`Graph`][rdflib.graph.Graph] into which to put
  164. constructed triples. If not provided, a new graph will be created
  165. Returns:
  166. A (path_identifier, graph) tuple where:
  167. - path_identifier: If path is a [`URIRef`][rdflib.term.URIRef], this is simply
  168. the provided path. If path is a [`Path`][rdflib.paths.Path], this is
  169. the [`BNode`][rdflib.term.BNode] corresponding to the root of the SHACL
  170. path expression added to the graph.
  171. - graph: None if path is a [`URIRef`][rdflib.term.URIRef] (as no new triples
  172. are constructed). If path is a [`Path`][rdflib.paths.Path], this is either the
  173. target_graph provided or a new graph into which the path triples were added.
  174. """
  175. # If a path is a URI, that's the whole path. No graph needs to be constructed.
  176. if isinstance(path, URIRef):
  177. return path, None
  178. # Create a graph if one was not provided
  179. if target_graph is None:
  180. target_graph = Graph()
  181. # Recurse through the path to build the graph representation
  182. return _build_path_component(target_graph, path), target_graph