Skip to content

Commit e2179d9

Browse files
committed
feat: add pyshifty shacl engine support
1 parent 64ae7a0 commit e2179d9

File tree

10 files changed

+2653
-1893
lines changed

10 files changed

+2653
-1893
lines changed

buildingmotif/api/app.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,9 @@ def create_app(DB_URI, shacl_engine: Optional[str] = "pyshacl"):
4949
5050
:param db_uri: database URI
5151
:type db_uri: str
52-
:param shacl_engine: the name of the engine to use for validation: "pyshacl" or "topquadrant". Using topquadrant
53-
requires Java to be installed on this machine, and the "topquadrant" feature on BuildingMOTIF,
52+
:param shacl_engine: the name of the engine to use for validation: "pyshacl",
53+
"topquadrant", or "pyshifty". Using topquadrant requires Java to be
54+
installed on this machine, and the "topquadrant" feature on BuildingMOTIF,
5455
defaults to "pyshacl"
5556
:type shacl_engine: str, optional
5657
:return: flask app

buildingmotif/building_motif/building_motif.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,10 @@ def __init__(
3535
3636
:param db_uri: database URI
3737
:type db_uri: str
38-
:param shacl_engine: the name of the engine to use for validation: "pyshacl" or "topquadrant". Using topquadrant
39-
requires Java to be installed on this machine, and the "topquadrant" feature on BuildingMOTIF,
40-
defaults to "pyshacl"
38+
:param shacl_engine: the name of the engine to use for validation: "pyshacl",
39+
"topquadrant", or "pyshifty". Using topquadrant requires Java to be
40+
installed on this machine, and the "topquadrant" feature on
41+
BuildingMOTIF, defaults to "pyshacl"
4142
:type shacl_engine: str, optional
4243
:param log_level: logging level of detail
4344
:type log_level: int

buildingmotif/dataclasses/compiled_model.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,3 +222,139 @@ def shape_to_df(self, shape: rdflib.URIRef) -> pd.DataFrame:
222222
metadata.columns = [str(col) for col in metadata.columns]
223223
# convert the rdflib terms to Python types
224224
return metadata.map(lambda x: x.toPython())
225+
226+
227+
class PyshiftyCompiledModel(CompiledModel):
228+
"""
229+
Specialized CompiledModel for the pyshifty SHACL engine.
230+
Keeps data and shape graphs separate and avoids shape skolemization.
231+
"""
232+
233+
def __init__(
234+
self,
235+
model: Model,
236+
shape_collections: List[ShapeCollection],
237+
compiled_graph: rdflib.Graph,
238+
shacl_engine: str = "default",
239+
):
240+
self.model = model
241+
self.shape_collections = shape_collections
242+
ontology_graph = rdflib.Graph()
243+
for shape_collection in shape_collections:
244+
ontology_graph += shape_collection.graph
245+
246+
ontology_graph = skolemize_shapes(ontology_graph)
247+
248+
shacl_engine = (
249+
self.model._bm.shacl_engine
250+
if (shacl_engine == "default" or not shacl_engine)
251+
else shacl_engine
252+
)
253+
254+
self._compiled_graph = shacl_inference(
255+
compiled_graph, ontology_graph, shacl_engine
256+
)
257+
258+
def _build_shape_graph(self, error_on_missing_imports: bool = True) -> rdflib.Graph:
259+
shape_graph = rdflib.Graph()
260+
for sc in self.shape_collections:
261+
shape_graph += sc.resolve_imports(
262+
error_on_missing_imports=error_on_missing_imports
263+
).graph
264+
shape_graph = rewrite_shape_graph(shape_graph)
265+
shape_graph.remove((None, OWL.imports, None))
266+
return skolemize_shapes(shape_graph)
267+
268+
def validate_model_against_shapes(
269+
self,
270+
shapes_to_test: List[rdflib.URIRef],
271+
target_class: rdflib.URIRef,
272+
) -> Dict[rdflib.URIRef, "ValidationContext"]:
273+
"""Validates the model against a list of shapes and generates a
274+
validation report for each.
275+
276+
:param shapes_to_test: list of shape URIs to validate the model against
277+
:type shapes_to_test: List[URIRef]
278+
:param target_class: the class upon which to run the selected shapes
279+
:type target_class: URIRef
280+
:return: a dictionary that relates each shape to test URIRef to a
281+
ValidationContext
282+
:rtype: Dict[URIRef, ValidationContext]
283+
"""
284+
model_graph = copy_graph(self._compiled_graph)
285+
286+
results = {}
287+
288+
targets = model_graph.query(
289+
f"""
290+
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
291+
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
292+
SELECT ?target
293+
WHERE {{
294+
?target rdf:type/rdfs:subClassOf* <{target_class}>
295+
296+
}}
297+
"""
298+
)
299+
300+
shape_graph = rdflib.Graph()
301+
for shape_collection in self.shape_collections:
302+
shape_graph += shape_collection.graph
303+
shape_graph = skolemize_shapes(shape_graph)
304+
305+
for shape_uri in shapes_to_test:
306+
temp_model_graph = copy_graph(model_graph)
307+
for (s,) in targets:
308+
temp_model_graph.add((URIRef(s), A, shape_uri))
309+
310+
valid, report_g, report_str = shacl_validate(
311+
temp_model_graph, shape_graph, engine=self.model._bm.shacl_engine
312+
)
313+
results[shape_uri] = ValidationContext(
314+
self.shape_collections,
315+
shape_graph,
316+
valid,
317+
report_g,
318+
report_str,
319+
self.model,
320+
)
321+
322+
return results
323+
324+
def validate(
325+
self,
326+
error_on_missing_imports: bool = True,
327+
) -> "ValidationContext":
328+
"""Validates this model against the given list of ShapeCollections.
329+
If no list is provided, the model will be validated against the model's "manifest".
330+
If a list of shape collections is provided, the manifest will *not* be automatically
331+
included in the set of shape collections.
332+
333+
Loads all of the ShapeCollections into a single graph.
334+
335+
:param error_on_missing_imports: if True, raises an error if any of the dependency
336+
ontologies are missing (i.e. they need to be loaded into BuildingMOTIF), defaults
337+
to True
338+
:type error_on_missing_imports: bool, optional
339+
:return: An object containing useful properties/methods to deal with
340+
the validation results
341+
:rtype: ValidationContext
342+
"""
343+
data_graph = copy_graph(self._compiled_graph)
344+
data_graph.remove((None, OWL.imports, None))
345+
346+
shape_graph = self._build_shape_graph(
347+
error_on_missing_imports=error_on_missing_imports
348+
)
349+
350+
valid, report_g, report_str = shacl_validate(
351+
data_graph, shape_graph, engine=self.model._bm.shacl_engine
352+
)
353+
return ValidationContext(
354+
self.shape_collections,
355+
shape_graph,
356+
valid,
357+
report_g,
358+
report_str,
359+
self.model,
360+
)

buildingmotif/dataclasses/model.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -232,20 +232,30 @@ def compile(
232232
ShapeCollections
233233
:rtype: Graph
234234
"""
235-
from buildingmotif.dataclasses.compiled_model import CompiledModel
235+
from buildingmotif.dataclasses.compiled_model import (
236+
CompiledModel,
237+
PyshiftyCompiledModel,
238+
)
236239

237-
ontology_graph = rdflib.Graph()
238240
if shape_collections is None:
239241
shape_collections = [self.get_manifest()]
242+
243+
model_graph = copy_graph(self.graph).skolemize()
244+
shacl_engine = self._bm.shacl_engine
245+
246+
if shacl_engine == "pyshifty":
247+
return PyshiftyCompiledModel(
248+
self, shape_collections, model_graph, shacl_engine=shacl_engine
249+
)
250+
251+
ontology_graph = rdflib.Graph()
240252
for shape_collection in shape_collections:
241253
ontology_graph += shape_collection.graph
242254

243255
ontology_graph = skolemize_shapes(ontology_graph)
244256

245-
model_graph = copy_graph(self.graph).skolemize()
246-
247257
compiled_graph = shacl_inference(
248-
model_graph, ontology_graph, engine=self._bm.shacl_engine
258+
model_graph, ontology_graph, engine=shacl_engine
249259
)
250260
return CompiledModel(self, shape_collections, compiled_graph)
251261

buildingmotif/utils.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -635,7 +635,8 @@ def shacl_validate(
635635
"""
636636
Validate the data graph against the shape graph.
637637
Uses the fastest validation method available. Use the 'topquadrant' feature
638-
to use TopQuadrant's SHACL engine. Defaults to using PySHACL.
638+
to use TopQuadrant's SHACL engine. Use 'pyshifty' to use the pyshifty engine.
639+
Defaults to using PySHACL.
639640
640641
:param data_graph: the graph to validate
641642
:type data_graph: Graph
@@ -659,6 +660,18 @@ def shacl_validate(
659660
"TopQuadrant SHACL engine not available. Using PySHACL instead."
660661
)
661662
pass
663+
elif engine == "pyshifty":
664+
try:
665+
import shifty # type: ignore
666+
667+
return shifty.validate(
668+
data_graph,
669+
shape_graph or Graph(),
670+
run_inference=True,
671+
)
672+
except ImportError:
673+
logging.info("PyShifty SHACL engine not available. Using PySHACL instead.")
674+
pass
662675

663676
data_graph = data_graph + (shape_graph or Graph())
664677
return pyshacl.validate(
@@ -679,8 +692,8 @@ def shacl_inference(
679692
"""
680693
Infer new triples in the data graph using the shape graph.
681694
Edits the data graph in place. Uses the fastest inference method available.
682-
Use the 'topquadrant' feature to use TopQuadrant's SHACL engine. Defaults to
683-
using PySHACL.
695+
Use the 'topquadrant' feature to use TopQuadrant's SHACL engine. Use
696+
'pyshifty' to use the pyshifty engine. Defaults to using PySHACL.
684697
685698
:param data_graph: the graph to infer new triples in
686699
:type data_graph: Graph
@@ -701,6 +714,19 @@ def shacl_inference(
701714
"TopQuadrant SHACL engine not available. Using PySHACL instead."
702715
)
703716
pass
717+
elif engine == "pyshifty":
718+
try:
719+
import shifty # type: ignore
720+
721+
o = shifty.infer(
722+
data_graph,
723+
shape_graph or Graph(),
724+
union=True,
725+
)
726+
return o
727+
except ImportError:
728+
logging.info("PyShifty SHACL engine not available. Using PySHACL instead.")
729+
pass
704730

705731
# We use a fixed-point computation approach to 'compiling' RDF models.
706732
# We accomlish this by keeping track of the size of the graph before and after

0 commit comments

Comments
 (0)