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.
- cmem_plugin_shapes/__init__.py +1 -0
- cmem_plugin_shapes/doc/__init__.py +6 -0
- cmem_plugin_shapes/doc/shapes_doc.md +24 -0
- cmem_plugin_shapes/plugin_shapes.py +330 -0
- cmem_plugin_shapes/prefix_cc.json +3374 -0
- cmem_plugin_shapes/shacl.jpg +0 -0
- cmem_plugin_shapes-1.0.0.dist-info/LICENSE +201 -0
- cmem_plugin_shapes-1.0.0.dist-info/METADATA +54 -0
- cmem_plugin_shapes-1.0.0.dist-info/RECORD +10 -0
- cmem_plugin_shapes-1.0.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""cmem-plugin-shapes"""
|
|
@@ -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()
|