cmem-plugin-shapes 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """cmem-plugin-shapes"""
@@ -0,0 +1,6 @@
1
+ """doc"""
2
+
3
+ from pathlib import Path
4
+
5
+ with (Path(__path__[0]) / "shapes_doc.md").open("r", encoding="utf-8") as f:
6
+ SHAPES_DOC = f.read()
@@ -0,0 +1,24 @@
1
+ A task generating SHACL node and property shapes from a data graph.
2
+
3
+ ## Parameters
4
+
5
+ ### Data graph
6
+
7
+ The input data graph to be analyzed for the SHACL shapes generation.
8
+
9
+ ### Output SHACL shapes graph
10
+
11
+ The output SHACL shapes graph.
12
+
13
+ ### Overwrite shapes graph if it exists
14
+
15
+ Overwrite the output SHACL shapes graph if it exists. If disabled and the graph exists, the plugin execution fails.
16
+
17
+ ### Import shapes graph in CMEM Shapes Catalog
18
+
19
+ Import the SHACL shapes graph in the CMEM Shapes catalog by adding an `owl:imports` statement to the CMEM Shapes Catalog.
20
+
21
+ ### Use prefixes
22
+
23
+ Attempt to fetch namespace prefixes from http://prefix.cc instead of from the local database. If this fails, fall back
24
+ on local database. Prefixes defined in the CMEM project override prefixes defined in the external database.
@@ -0,0 +1,330 @@
1
+ """Generate SHACL node and property shapes from a data graph"""
2
+
3
+ import json
4
+ from collections.abc import Sequence
5
+ from http import HTTPStatus
6
+ from io import BytesIO
7
+ from pathlib import Path
8
+ from urllib.parse import quote_plus
9
+ from urllib.request import urlopen
10
+ from uuid import NAMESPACE_URL, uuid5
11
+
12
+ from cmem.cmempy.api import send_request
13
+ from cmem.cmempy.config import get_dp_api_endpoint
14
+ from cmem.cmempy.dp.proxy.graph import get_graphs_list, post_streamed
15
+ from cmem.cmempy.dp.proxy.sparql import get
16
+ from cmem.cmempy.dp.proxy.update import post
17
+ from cmem.cmempy.workspace.projects.project import get_prefixes
18
+ from cmem_plugin_base.dataintegration.context import ExecutionContext
19
+ from cmem_plugin_base.dataintegration.description import Icon, Plugin, PluginParameter
20
+ from cmem_plugin_base.dataintegration.entity import Entities
21
+ from cmem_plugin_base.dataintegration.parameter.graph import GraphParameterType
22
+ from cmem_plugin_base.dataintegration.plugins import WorkflowPlugin
23
+ from cmem_plugin_base.dataintegration.ports import FixedNumberOfInputs
24
+ from cmem_plugin_base.dataintegration.types import BoolParameterType
25
+ from cmem_plugin_base.dataintegration.utils import setup_cmempy_user_access
26
+ from rdflib import RDF, RDFS, SH, XSD, Graph, Literal, Namespace, URIRef
27
+ from rdflib.namespace import split_uri
28
+ from validators import url
29
+
30
+ from cmem_plugin_shapes.doc import SHAPES_DOC
31
+
32
+ from . import __path__
33
+
34
+ SHUI = Namespace("https://vocab.eccenca.com/shui/")
35
+ PREFIX_CC = "http://prefix.cc/popular/all.file.json"
36
+ TRUE_SET = {"yes", "true", "t", "y", "1"}
37
+ FALSE_SET = {"no", "false", "f", "n", "0"}
38
+
39
+
40
+ def format_namespace(iri: str) -> str:
41
+ """Ensure namespace ends with '/' or '#'"""
42
+ return iri if iri.endswith(("/", "#")) else iri + "/"
43
+
44
+
45
+ def str2bool(value: str) -> bool:
46
+ """Convert string to boolean"""
47
+ value = value.lower()
48
+ if value in TRUE_SET:
49
+ return True
50
+ if value in FALSE_SET:
51
+ return False
52
+ allowed_values = '", "'.join(TRUE_SET | FALSE_SET)
53
+ raise ValueError(f'Expected one of: "{allowed_values}"')
54
+
55
+
56
+ @Plugin(
57
+ label="Generate SHACL shapes from data",
58
+ icon=Icon(file_name="shacl.jpg", package=__package__),
59
+ description="Generate SHACL node and property shapes from a data graph",
60
+ documentation=SHAPES_DOC,
61
+ parameters=[
62
+ PluginParameter(
63
+ param_type=GraphParameterType(),
64
+ name="data_graph_iri",
65
+ label="Input data graph.",
66
+ description="The input data graph to be analyzed for the SHACL shapes generation.",
67
+ ),
68
+ PluginParameter(
69
+ param_type=GraphParameterType(
70
+ classes=["https://vocab.eccenca.com/shui/ShapeCatalog"],
71
+ allow_only_autocompleted_values=False,
72
+ ),
73
+ name="shapes_graph_iri",
74
+ label="Output SHACL shapes graph.",
75
+ description="The output SHACL shapes graph.",
76
+ ),
77
+ PluginParameter(
78
+ param_type=BoolParameterType(),
79
+ name="overwrite",
80
+ label="Overwrite output graph.",
81
+ description="""Overwrite the output SHACL shapes graph if it exists. If disabled and
82
+ the graph exists, the plugin execution fails.""",
83
+ default_value=False,
84
+ ),
85
+ PluginParameter(
86
+ param_type=BoolParameterType(),
87
+ name="import_shapes",
88
+ label="Import SHACL shapes graph in CMEM Shapes Catalog.",
89
+ description="""Import the SHACL shapes graph in the CMEM Shapes catalog by adding an
90
+ `owl:imports` triple to the CMEM Shapes Catalog.""",
91
+ default_value=False,
92
+ ),
93
+ PluginParameter(
94
+ param_type=BoolParameterType(),
95
+ name="prefix_cc",
96
+ label="Fetch namespace prefixes from prefix.cc",
97
+ description="""Attempt to fetch namespace prefixes from http://prefix.cc instead of
98
+ from the local database. If this fails, fall back on local database. Prefixes defined in
99
+ the CMEM project override prefixes defined in the external database.""",
100
+ default_value=True,
101
+ advanced=True,
102
+ ),
103
+ ],
104
+ )
105
+ class ShapesPlugin(WorkflowPlugin):
106
+ """SHACL shapes generation plugin"""
107
+
108
+ def __init__(
109
+ self,
110
+ data_graph_iri: str = "",
111
+ shapes_graph_iri: str = "",
112
+ overwrite: bool = False,
113
+ import_shapes: bool = False,
114
+ prefix_cc: bool = True,
115
+ ) -> None:
116
+ if not url(data_graph_iri):
117
+ raise ValueError("Data graph IRI parameter is invalid.")
118
+ if not url(shapes_graph_iri):
119
+ raise ValueError("Shapes graph IRI parameter is invalid.")
120
+ self.shapes_graph_iri = shapes_graph_iri
121
+ self.data_graph_iri = data_graph_iri
122
+ self.overwrite = overwrite
123
+ self.import_shapes = import_shapes
124
+ self.prefix_cc = prefix_cc
125
+
126
+ self.input_ports = FixedNumberOfInputs([])
127
+ self.output_port = None
128
+
129
+ def format_prefixes(self, prefixes: dict, formatted_prefixes: dict | None = None) -> dict:
130
+ """Format prefix dictionary for consistency"""
131
+ if formatted_prefixes is None:
132
+ formatted_prefixes = {}
133
+ for prefix, namespace in prefixes.items():
134
+ formatted_prefixes.setdefault(namespace, []).append(prefix + ":")
135
+ return formatted_prefixes
136
+
137
+ def get_prefixes(self) -> dict:
138
+ """Fetch namespace prefixes"""
139
+ prefixes_project = get_prefixes(self.context.task.project_id())
140
+ prefixes = self.format_prefixes(prefixes_project)
141
+
142
+ prefixes_cc = None
143
+ if self.prefix_cc:
144
+ try:
145
+ res = urlopen(PREFIX_CC) # noqa: S310
146
+ self.log.info("prefixes fetched from http://prefix.cc")
147
+ prefixes_cc = self.format_prefixes(json.loads(res.read()), prefixes)
148
+ except Exception as exc: # noqa: BLE001
149
+ self.log.warning(
150
+ f"failed to fetch prefixes from http://prefix.cc ({exc}) - using local file"
151
+ )
152
+ if not prefixes_cc or not self.prefix_cc:
153
+ with (Path(__path__[0]) / "prefix_cc.json").open("r", encoding="utf-8") as json_file:
154
+ prefixes_cc = self.format_prefixes(json.load(json_file), prefixes)
155
+
156
+ return {k: tuple(set(v)) for k, v in prefixes.items()}
157
+
158
+ def get_name(self, iri: str) -> str:
159
+ """Generate shape name from IRI"""
160
+ response = send_request(
161
+ uri=f"{self.dp_api_endpoint}/api/explore/title?resource={quote_plus(iri)}",
162
+ method="GET",
163
+ headers={"Content-Type": "application/json", "Accept": "application/json"},
164
+ )
165
+ title_json = json.loads(response)
166
+ title: str = title_json["title"]
167
+ try:
168
+ namespace, _ = split_uri(iri)
169
+ except ValueError as exc:
170
+ raise ValueError(f"Invalid class or property ({iri}).") from exc
171
+
172
+ if namespace in self.prefixes:
173
+ prefixes = self.prefixes[namespace]
174
+ prefix = prefixes[0]
175
+ if title_json["fromIri"]:
176
+ if title.startswith(prefixes):
177
+ if len(prefixes) > 1:
178
+ prefix = title.split(":", 1)[0] + ":"
179
+ title = title[len(prefix) :]
180
+ else:
181
+ try:
182
+ title = title.split("_", 1)[1]
183
+ except IndexError as exc:
184
+ raise IndexError(f"{title_json['title']} {prefixes}") from exc
185
+ title += f" ({prefix})"
186
+
187
+ return title
188
+
189
+ def init_shapes_graph(self) -> Graph:
190
+ """Initialize SHACL shapes graph"""
191
+ shapes_graph = Graph()
192
+ shapes_graph.add((URIRef(self.shapes_graph_iri), RDF.type, SHUI.ShapeCatalog))
193
+ shapes_graph.add(
194
+ (
195
+ URIRef(self.shapes_graph_iri),
196
+ RDFS.label,
197
+ Literal(f"Shapes for {self.data_graph_iri}"),
198
+ )
199
+ )
200
+ return shapes_graph
201
+
202
+ def get_class_dict(self) -> dict:
203
+ """Retrieve classes and associated properties"""
204
+ setup_cmempy_user_access(self.context.user)
205
+ query = f"""
206
+ SELECT DISTINCT ?class ?property ?data ?inverse
207
+ FROM <{self.data_graph_iri}> {{
208
+ {{
209
+ ?subject a ?class .
210
+ ?subject ?property ?object
211
+ BIND(isLiteral(?object) AS ?data)
212
+ BIND("false" AS ?inverse)
213
+ }}
214
+ UNION
215
+ {{
216
+ ?object a ?class .
217
+ ?subject ?property ?object
218
+ BIND("false" AS ?data)
219
+ BIND("true" AS ?inverse)
220
+ }}
221
+ }}
222
+ """ # noqa: S608
223
+ results = json.loads(get(query))
224
+
225
+ class_dict: dict = {}
226
+ for binding in results["results"]["bindings"]:
227
+ class_iri = binding["class"]["value"]
228
+ if class_iri not in class_dict:
229
+ class_dict[class_iri] = []
230
+ class_dict[class_iri].append(
231
+ {
232
+ "property": binding["property"]["value"],
233
+ "data": str2bool(binding["data"]["value"]),
234
+ "inverse": str2bool(binding["inverse"]["value"]),
235
+ }
236
+ )
237
+ return class_dict
238
+
239
+ def create_shapes(self, shapes_graph: Graph) -> Graph:
240
+ """Create SHACL node and property shapes"""
241
+ class_uuids = set()
242
+ prop_uuids = set()
243
+
244
+ for cls, properties in self.get_class_dict().items():
245
+ class_uuid = uuid5(NAMESPACE_URL, cls)
246
+ node_shape_uri = URIRef(f"{format_namespace(self.shapes_graph_iri)}{class_uuid}")
247
+
248
+ if class_uuid not in class_uuids:
249
+ shapes_graph.add((node_shape_uri, RDF.type, SH.NodeShape))
250
+ shapes_graph.add((node_shape_uri, SH.targetClass, URIRef(cls)))
251
+ name = self.get_name(cls)
252
+ shapes_graph.add((node_shape_uri, SH.name, Literal(name, lang="en")))
253
+ shapes_graph.add((node_shape_uri, RDFS.label, Literal(name, lang="en")))
254
+ class_uuids.add(class_uuid)
255
+
256
+ for prop in properties:
257
+ prop_uuid = uuid5(
258
+ NAMESPACE_URL, f'{prop["property"]}{"inverse" if prop["inverse"] else ""}'
259
+ )
260
+ property_shape_uri = URIRef(f"{format_namespace(self.shapes_graph_iri)}{prop_uuid}")
261
+
262
+ if prop_uuid not in prop_uuids:
263
+ name = self.get_name(prop["property"])
264
+ shapes_graph.add((property_shape_uri, RDF.type, SH.PropertyShape))
265
+ shapes_graph.add((property_shape_uri, SH.path, URIRef(prop["property"])))
266
+ shapes_graph.add(
267
+ (property_shape_uri, SH.nodeKind, SH.Literal if prop["data"] else SH.IRI)
268
+ )
269
+ if prop["inverse"]:
270
+ shapes_graph.add(
271
+ (
272
+ property_shape_uri,
273
+ SHUI.inversePath,
274
+ Literal("true", datatype=XSD.boolean),
275
+ )
276
+ )
277
+ name = "← " + name
278
+ shapes_graph.add((property_shape_uri, SH.name, Literal(name, lang="en")))
279
+ shapes_graph.add((property_shape_uri, RDFS.label, Literal(name, lang="en")))
280
+ prop_uuids.add(prop_uuid)
281
+
282
+ shapes_graph.add((node_shape_uri, SH.property, property_shape_uri))
283
+
284
+ return shapes_graph
285
+
286
+ def import_shapes_graph(self) -> None:
287
+ """Import SHACL shapes graph to catalog"""
288
+ query = f"""
289
+ INSERT DATA {{
290
+ GRAPH <https://vocab.eccenca.com/shacl/> {{
291
+ <https://vocab.eccenca.com/shacl/> <http://www.w3.org/2002/07/owl#imports>
292
+ <{self.shapes_graph_iri}> .
293
+ }}
294
+ }}
295
+ """
296
+ setup_cmempy_user_access(self.context.user)
297
+ post(query)
298
+
299
+ def execute(self, inputs: Sequence[Entities], context: ExecutionContext) -> None:
300
+ """Execute plugin"""
301
+ _ = inputs
302
+ setup_cmempy_user_access(context.user)
303
+
304
+ if not self.overwrite and self.shapes_graph_iri in [
305
+ graph["iri"] for graph in get_graphs_list()
306
+ ]:
307
+ raise ValueError(f"Graph <{self.shapes_graph_iri}> already exists.")
308
+
309
+ self.context = context
310
+ self.dp_api_endpoint = get_dp_api_endpoint()
311
+ self.prefixes = self.get_prefixes()
312
+
313
+ shapes_graph = self.init_shapes_graph()
314
+ shapes_graph = self.create_shapes(shapes_graph)
315
+
316
+ nt_file = BytesIO(shapes_graph.serialize(format="nt", encoding="utf-8"))
317
+ response = post_streamed(
318
+ self.shapes_graph_iri,
319
+ nt_file,
320
+ replace=self.overwrite,
321
+ content_type="application/n-triples",
322
+ )
323
+
324
+ if response.status_code != HTTPStatus.NO_CONTENT:
325
+ raise OSError(
326
+ f"Error posting SHACL validation graph (status code {response.status_code})."
327
+ )
328
+
329
+ if self.import_shapes:
330
+ self.import_shapes_graph()