from dataclasses import dataclass
from functools import cached_property
from typing import TYPE_CHECKING, List, Optional
import rdflib
import rdflib.query
import rfc3987
from buildingmotif import get_building_motif
from buildingmotif.dataclasses.shape_collection import ShapeCollection
from buildingmotif.dataclasses.validation import ValidationContext
from buildingmotif.utils import Triple, copy_graph, shacl_inference, skolemize_shapes
if TYPE_CHECKING:
from buildingmotif import BuildingMOTIF
from buildingmotif.dataclasses.compiled_model import CompiledModel
def _validate_uri(uri: str):
parsed = rfc3987.parse(uri)
if not parsed["scheme"]:
raise ValueError(
f"{uri} does not look like a valid URI, trying to serialize this will break."
)
[docs]@dataclass
class Model:
"""This class mirrors :py:class:`database.tables.DBModel`."""
_id: int
_name: str
_description: str
_graph: rdflib.Graph
_bm: "BuildingMOTIF"
_manifest_id: int
[docs] @classmethod
def create(cls, name: str, description: str = "") -> "Model":
"""Create a new model.
:param name: new model name
:type name: str
:param description: new model description
:type description: str
:return: new model
:rtype: Model
"""
_validate_uri(name)
g = rdflib.Graph()
g.add((rdflib.URIRef(name), rdflib.RDF.type, rdflib.OWL.Ontology))
if description:
g.add(
(rdflib.URIRef(name), rdflib.RDFS.comment, rdflib.Literal(description))
)
return cls.from_graph(g)
[docs] @classmethod
def from_graph(cls, graph: rdflib.Graph) -> "Model":
"""Create a new model from a graph. The name of the model is taken from the
ontology declaration in the graph (subject of rdf:type owl:Ontology triple).
The description of the model can be set through an RDFS comment on the ontology
:param graph: graph to create model from
:type graph: rdflib.Graph
:return: new model
:rtype: Model
"""
bm = get_building_motif()
name = graph.value(predicate=rdflib.RDF.type, object=rdflib.OWL.Ontology)
if name is None:
raise ValueError("Graph does not contain an ontology declaration")
_validate_uri(name)
# the 'description' is the rdfs:comment of the ontology
description = graph.value(name, rdflib.RDFS.comment)
description = str(description) if description is not None else ""
db_model = bm.table_connection.create_db_model(name, description)
graph = bm.graph_connection.create_graph(db_model.graph_id, graph)
# below, we normalize the name to a string so it matches the database type
return cls(
_id=db_model.id,
_name=str(db_model.name),
_description=db_model.description,
_graph=graph,
_bm=bm,
_manifest_id=db_model.manifest_id,
)
[docs] @classmethod
def from_file(cls, url_or_path: str) -> "Model":
"""Create a new model from a file.
:param url_or_path: url or path to file
:type url_or_path: str
:return: new model
:rtype: Model
"""
graph = rdflib.Graph()
# if guess_format doesn't match anything, it will return None,
# which tells graph.parse to guess 'turtle'
# if graph parsing fails, it will raise an exception
graph.parse(url_or_path, format=rdflib.util.guess_format(url_or_path))
return cls.from_graph(graph)
[docs] @classmethod
def load(cls, id: Optional[int] = None, name: Optional[str] = None) -> "Model":
"""Get model from database by id or name.
:param id: model id, defaults to None
:type id: Optional[int], optional
:param name: model name, defaults to None
:type name: Optional[str], optional
:raises Exception: if neither id nor name provided
:return: model
:rtype: Model
"""
bm = get_building_motif()
if id is not None:
db_model = bm.table_connection.get_db_model(id)
elif name is not None:
db_model = bm.table_connection.get_db_model_by_name(name)
else:
raise Exception("Neither id nor name provided to load Model")
graph = bm.graph_connection.get_graph(db_model.graph_id)
return cls(
_id=db_model.id,
_name=db_model.name,
_description=db_model.description,
_graph=graph,
_bm=bm,
_manifest_id=db_model.manifest_id,
)
@property
def id(self) -> Optional[int]:
return self._id
@id.setter
def id(self, new_id):
raise AttributeError("Cannot modify db id")
@cached_property
def graph(self) -> rdflib.Graph:
return self._graph
@property
def name(self):
return self._name
@name.setter
def name(self, new_name: str):
self._bm.table_connection.update_db_model_name(self._id, new_name)
self._name = new_name
@property
def description(self):
return self._description
@description.setter
def description(self, new_description: str):
self._bm.table_connection.update_db_model_description(self._id, new_description)
self._description = new_description
[docs] def add_triples(self, *triples: Triple) -> None:
"""Add the given triples to the model.
:param triples: a sequence of triples to add to the graph
:type triples: Triple
"""
for triple in triples:
self.graph.add(triple)
[docs] def add_graph(self, graph: rdflib.Graph) -> None:
"""Add the given graph to the model.
:param graph: the graph to add to the model
:type graph: rdflib.Graph
"""
self.graph += graph
[docs] def validate(
self,
shape_collections: Optional[List[ShapeCollection]] = None,
error_on_missing_imports: bool = True,
shacl_engine: Optional[str] = None,
) -> "ValidationContext":
"""Validates this model against the given list of ShapeCollections.
If no list is provided, the model will be validated against the model's "manifest".
If a list of shape collections is provided, the manifest will *not* be automatically
included in the set of shape collections.
Loads all of the ShapeCollections into a single graph.
:param shape_collections: a list of ShapeCollections against which the
graph should be validated. If an empty list or None is provided, the
model will be validated against the model's manifest.
:type shape_collections: List[ShapeCollection]
:param error_on_missing_imports: if True, raises an error if any of the dependency
ontologies are missing (i.e. they need to be loaded into BuildingMOTIF), defaults
to True
:type error_on_missing_imports: bool, optional
:return: An object containing useful properties/methods to deal with
the validation results
:param shacl_engine: the SHACL engine to use for validation, defaults to whatever
is set in the BuildingMOTIF object
:type shacl_engine: str, optional
:rtype: ValidationContext
"""
compiled_model = self.compile(shape_collections or [self.get_manifest()])
return compiled_model.validate(error_on_missing_imports)
[docs] def compile(
self, shape_collections: Optional[List["ShapeCollection"]] = None
) -> "CompiledModel":
"""Compile the graph of a model against a set of ShapeCollections.
:param shape_collections: list of ShapeCollections to compile the model
against. Defaults to the model's manifest.
:type shape_collections: List[ShapeCollection], optional
:param shacl_engine: the SHACL engine to use for validation, defaults to whatever
is set in the BuildingMOTIF object
:type shacl_engine: str, optional
:return: copy of model's graph that has been compiled against the
ShapeCollections
:rtype: Graph
"""
from buildingmotif.dataclasses.compiled_model import CompiledModel
ontology_graph = rdflib.Graph()
if shape_collections is None:
shape_collections = [self.get_manifest()]
for shape_collection in shape_collections:
ontology_graph += shape_collection.graph
ontology_graph = skolemize_shapes(ontology_graph)
model_graph = copy_graph(self.graph).skolemize()
compiled_graph = shacl_inference(
model_graph, ontology_graph, engine=self._bm.shacl_engine
)
return CompiledModel(self, shape_collections, compiled_graph)
[docs] def get_manifest(self) -> ShapeCollection:
"""Get ShapeCollection from model.
:return: model's shape collection
:rtype: ShapeCollection
"""
return ShapeCollection.load(self._manifest_id)
[docs] def update_manifest(self, manifest: ShapeCollection):
"""Updates the manifest for this model by adding in the contents
of the shape graph inside the provided SHapeCollection
:param manifest: the ShapeCollection containing additional shapes against which to validate this model
:type manifest: ShapeCollection
"""
self.get_manifest().graph += manifest.graph