iolanta 2.1.10__py3-none-any.whl → 2.1.12__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.
- iolanta/facets/facet.py +15 -9
- iolanta/facets/mermaid_roadmap/__init__.py +0 -0
- iolanta/facets/mermaid_roadmap/facet.py +133 -0
- iolanta/facets/mermaid_roadmap/inference/blocks.sparql +13 -0
- iolanta/facets/mermaid_roadmap/inference/has-task-default-type.sparql +16 -0
- iolanta/facets/mermaid_roadmap/inference/task.sparql +26 -0
- iolanta/facets/mermaid_roadmap/inference/unblocked.sparql +21 -0
- iolanta/facets/mermaid_roadmap/mermaid_roadmap.yamlld +59 -0
- iolanta/facets/mermaid_roadmap/sparql/edges.sparql +25 -0
- iolanta/facets/mermaid_roadmap/sparql/nodes.sparql +17 -0
- iolanta/iolanta.py +146 -55
- iolanta/mcp/cli.py +1 -17
- iolanta/mermaid/models.py +61 -36
- iolanta/parse_quads.py +18 -15
- iolanta/sparqlspace/processor.py +250 -255
- iolanta/sparqlspace/redirects.py +79 -0
- {iolanta-2.1.10.dist-info → iolanta-2.1.12.dist-info}/METADATA +2 -2
- {iolanta-2.1.10.dist-info → iolanta-2.1.12.dist-info}/RECORD +20 -12
- {iolanta-2.1.10.dist-info → iolanta-2.1.12.dist-info}/entry_points.txt +1 -0
- iolanta/mcp/prompts/nanopublication_assertion_authoring_rules.md +0 -63
- iolanta/mcp/prompts/rules.md +0 -83
- {iolanta-2.1.10.dist-info → iolanta-2.1.12.dist-info}/WHEEL +0 -0
iolanta/iolanta.py
CHANGED
|
@@ -6,7 +6,6 @@ from typing import ( # noqa: WPS235
|
|
|
6
6
|
Any,
|
|
7
7
|
Iterable,
|
|
8
8
|
List,
|
|
9
|
-
Mapping,
|
|
10
9
|
Optional,
|
|
11
10
|
Protocol,
|
|
12
11
|
Set,
|
|
@@ -27,7 +26,6 @@ from iolanta import entry_points, namespaces
|
|
|
27
26
|
from iolanta.conversions import path_to_iri
|
|
28
27
|
from iolanta.errors import UnresolvedIRI
|
|
29
28
|
from iolanta.facets.errors import FacetError
|
|
30
|
-
from iolanta.facets.facet import Facet
|
|
31
29
|
from iolanta.facets.locator import FacetFinder
|
|
32
30
|
from iolanta.models import ComputedQName, LDContext, NotLiteralNode
|
|
33
31
|
from iolanta.node_to_qname import node_to_qname
|
|
@@ -43,13 +41,13 @@ from iolanta.resolvers.base import Resolver
|
|
|
43
41
|
from iolanta.resolvers.dispatch import SchemeDispatchResolver
|
|
44
42
|
from iolanta.resolvers.pypi import PyPIResolver
|
|
45
43
|
from iolanta.resolvers.python_import import PythonImportResolver
|
|
46
|
-
from iolanta.sparqlspace.processor import normalize_term
|
|
44
|
+
from iolanta.sparqlspace.processor import normalize_term # noqa: WPS201
|
|
47
45
|
|
|
48
46
|
|
|
49
47
|
class LoggerProtocol(Protocol):
|
|
50
48
|
"""Abstract Logger interface that unites `loguru` & standard `logging`."""
|
|
51
49
|
|
|
52
|
-
def info(self, message: str, *args: Any, **kwargs: Any) -> None:
|
|
50
|
+
def info(self, message: str, *args: Any, **kwargs: Any) -> None: # noqa: WPS110
|
|
53
51
|
"""Log an INFO message."""
|
|
54
52
|
|
|
55
53
|
def error(self, message: str, *args: Any, **kwargs: Any) -> None:
|
|
@@ -60,20 +58,20 @@ class LoggerProtocol(Protocol):
|
|
|
60
58
|
|
|
61
59
|
|
|
62
60
|
def _create_default_graph():
|
|
63
|
-
return ConjunctiveGraph(identifier=namespaces.LOCAL.term(
|
|
61
|
+
return ConjunctiveGraph(identifier=namespaces.LOCAL.term("_inference"))
|
|
64
62
|
|
|
65
63
|
|
|
66
64
|
@dataclass
|
|
67
|
-
class Iolanta:
|
|
65
|
+
class Iolanta: # noqa: WPS214, WPS338
|
|
68
66
|
"""Iolanta is a Semantic web browser."""
|
|
69
67
|
|
|
70
|
-
language: Literal = Literal(
|
|
68
|
+
language: Literal = Literal("en")
|
|
71
69
|
|
|
72
70
|
project_root: Annotated[
|
|
73
71
|
Path | None,
|
|
74
72
|
(
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
"File or directory the contents of which "
|
|
74
|
+
"Iolanta will automatically load into the graph."
|
|
77
75
|
),
|
|
78
76
|
] = None
|
|
79
77
|
graph: ConjunctiveGraph = field(default_factory=_create_default_graph)
|
|
@@ -94,18 +92,100 @@ class Iolanta: # noqa: WPS214
|
|
|
94
92
|
init=False,
|
|
95
93
|
)
|
|
96
94
|
|
|
95
|
+
_facet_inference_needed: bool = field(
|
|
96
|
+
default=False,
|
|
97
|
+
init=False,
|
|
98
|
+
)
|
|
99
|
+
|
|
97
100
|
@property
|
|
98
101
|
def plugin_classes(self) -> List[Type[Plugin]]:
|
|
99
102
|
"""Installed Iolanta plugins."""
|
|
100
|
-
return self.force_plugins or entry_points.plugins(
|
|
103
|
+
return self.force_plugins or entry_points.plugins("iolanta.plugins")
|
|
101
104
|
|
|
102
105
|
@functools.cached_property
|
|
103
106
|
def plugins(self) -> List[Plugin]:
|
|
104
107
|
"""Construct a list of installed plugin instances."""
|
|
105
|
-
return [
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
return [plugin_class(iolanta=self) for plugin_class in self.plugin_classes]
|
|
109
|
+
|
|
110
|
+
def _run_facet_inference(self):
|
|
111
|
+
"""Run inference queries from facet inference directories."""
|
|
112
|
+
for facet_class in self.facet_classes:
|
|
113
|
+
inference_path = facet_class.inference_path
|
|
114
|
+
|
|
115
|
+
if inference_path is not None:
|
|
116
|
+
facet_name = facet_class.__name__.lower()
|
|
117
|
+
self._run_inference_from_directory(
|
|
118
|
+
inference_path,
|
|
119
|
+
graph_prefix=f"facet-{facet_name}",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def _run_inference_from_directory( # noqa: WPS210, WPS231
|
|
123
|
+
self,
|
|
124
|
+
inference_dir: Path,
|
|
125
|
+
graph_prefix: str = "inference",
|
|
126
|
+
):
|
|
127
|
+
"""
|
|
128
|
+
Run inference queries from a given inference directory.
|
|
129
|
+
|
|
130
|
+
For each SPARQL file in the inference directory:
|
|
131
|
+
1. Truncate the named graph `local:{graph_prefix}-{filename}`
|
|
132
|
+
2. Execute the CONSTRUCT query
|
|
133
|
+
3. Insert the resulting triples into that graph
|
|
134
|
+
"""
|
|
135
|
+
if not inference_dir.exists():
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
inference_files = sorted(inference_dir.glob("*.sparql"))
|
|
139
|
+
|
|
140
|
+
for inference_file in inference_files:
|
|
141
|
+
filename = inference_file.stem # filename without .sparql extension
|
|
142
|
+
inference_graph = URIRef(f"{graph_prefix}:{filename}")
|
|
143
|
+
|
|
144
|
+
# Truncate the inference graph
|
|
145
|
+
context = self.graph.get_context(inference_graph)
|
|
146
|
+
context.remove((None, None, None))
|
|
147
|
+
|
|
148
|
+
# Read and execute the CONSTRUCT query
|
|
149
|
+
query_text = inference_file.read_text()
|
|
150
|
+
try:
|
|
151
|
+
query_result = self.graph.query(query_text) # noqa: WPS110
|
|
152
|
+
except (SyntaxError, ParseException) as err:
|
|
153
|
+
self.logger.error(
|
|
154
|
+
"Invalid SPARQL syntax in inference query {filename}: {error}",
|
|
155
|
+
filename=filename,
|
|
156
|
+
error=err,
|
|
157
|
+
)
|
|
158
|
+
raise SPARQLParseException(
|
|
159
|
+
error=err,
|
|
160
|
+
query=query_text,
|
|
161
|
+
) from err
|
|
162
|
+
|
|
163
|
+
# CONSTRUCT queries return a SPARQLResult with a graph attribute
|
|
164
|
+
result_graph = (
|
|
165
|
+
query_result.get("graph")
|
|
166
|
+
if isinstance(query_result, dict)
|
|
167
|
+
else query_result.graph
|
|
168
|
+
)
|
|
169
|
+
if result_graph is None:
|
|
170
|
+
raise ValueError(
|
|
171
|
+
f"CONSTRUCT query {filename} returned None result_graph. "
|
|
172
|
+
f"query_result type: {type(query_result)}, "
|
|
173
|
+
f"query_result: {query_result}"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
inferred_quads = [
|
|
177
|
+
(s, p, o, inference_graph) # noqa: WPS111
|
|
178
|
+
for s, p, o in result_graph # noqa: WPS111
|
|
179
|
+
]
|
|
180
|
+
self.logger.info(f"Inference {filename}: generated quads: {inferred_quads}")
|
|
181
|
+
|
|
182
|
+
if inferred_quads:
|
|
183
|
+
self.graph.addN(inferred_quads) # noqa: WPS220
|
|
184
|
+
self.logger.info( # noqa: WPS220
|
|
185
|
+
"Inference {filename}: added {count} triples",
|
|
186
|
+
filename=filename,
|
|
187
|
+
count=len(inferred_quads),
|
|
188
|
+
)
|
|
109
189
|
|
|
110
190
|
def query(
|
|
111
191
|
self,
|
|
@@ -116,7 +196,7 @@ class Iolanta: # noqa: WPS214
|
|
|
116
196
|
try:
|
|
117
197
|
sparql_result: SPARQLResult = self.graph.query(
|
|
118
198
|
query_text,
|
|
119
|
-
processor=
|
|
199
|
+
processor="sparqlspace",
|
|
120
200
|
initBindings=kwargs,
|
|
121
201
|
)
|
|
122
202
|
except SyntaxError as err:
|
|
@@ -125,6 +205,11 @@ class Iolanta: # noqa: WPS214
|
|
|
125
205
|
query=query_text,
|
|
126
206
|
) from err
|
|
127
207
|
|
|
208
|
+
# Run facet-specific inference if needed
|
|
209
|
+
if self._facet_inference_needed:
|
|
210
|
+
self._run_facet_inference()
|
|
211
|
+
self._facet_inference_needed = False
|
|
212
|
+
|
|
128
213
|
if sparql_result.askAnswer is not None:
|
|
129
214
|
return sparql_result.askAnswer
|
|
130
215
|
|
|
@@ -139,7 +224,8 @@ class Iolanta: # noqa: WPS214
|
|
|
139
224
|
|
|
140
225
|
def reset(self):
|
|
141
226
|
"""Reset Iolanta graph."""
|
|
142
|
-
self.graph = _create_default_graph()
|
|
227
|
+
self.graph = _create_default_graph() # noqa: WPS601
|
|
228
|
+
self._facet_inference_needed = False
|
|
143
229
|
self.__post_init__()
|
|
144
230
|
|
|
145
231
|
def add( # noqa: C901, WPS231, WPS210, WPS213
|
|
@@ -147,14 +233,14 @@ class Iolanta: # noqa: WPS214
|
|
|
147
233
|
source: Path,
|
|
148
234
|
context: Optional[LDContext] = None,
|
|
149
235
|
graph_iri: Optional[URIRef] = None,
|
|
150
|
-
) ->
|
|
236
|
+
) -> "Iolanta":
|
|
151
237
|
"""Parse & load information from given URL into the graph."""
|
|
152
|
-
self.logger.info(f
|
|
238
|
+
self.logger.info(f"Adding to graph: {source}")
|
|
153
239
|
|
|
154
240
|
if not isinstance(source, Path):
|
|
155
241
|
source = Path(source)
|
|
156
242
|
|
|
157
|
-
for source_file in list(source.rglob(
|
|
243
|
+
for source_file in list(source.rglob("*")) or [source]:
|
|
158
244
|
if source_file.is_dir():
|
|
159
245
|
continue
|
|
160
246
|
|
|
@@ -162,23 +248,23 @@ class Iolanta: # noqa: WPS214
|
|
|
162
248
|
ld_rdf = yaml_ld.to_rdf(source_file)
|
|
163
249
|
except ConnectionError as name_resolution_error:
|
|
164
250
|
self.logger.warning(
|
|
165
|
-
|
|
251
|
+
"%s | name resolution error: %s",
|
|
166
252
|
source_file,
|
|
167
253
|
str(name_resolution_error),
|
|
168
254
|
)
|
|
169
255
|
continue
|
|
170
256
|
except ParserNotFound as parser_not_found:
|
|
171
|
-
self.logger.error(f
|
|
257
|
+
self.logger.error(f"{source} | {parser_not_found}")
|
|
172
258
|
continue
|
|
173
259
|
except YAMLLDError as yaml_ld_error:
|
|
174
260
|
# .add() only processes local files, so errors should be raised
|
|
175
|
-
self.logger.error(f
|
|
261
|
+
self.logger.error(f"{source_file} | {yaml_ld_error}")
|
|
176
262
|
raise
|
|
177
263
|
except ValueError as value_error:
|
|
178
|
-
self.logger.error(f
|
|
264
|
+
self.logger.error(f"{source} | {value_error}")
|
|
179
265
|
continue
|
|
180
266
|
|
|
181
|
-
self.logger.info(f
|
|
267
|
+
self.logger.info(f"{source_file} is loaded.")
|
|
182
268
|
|
|
183
269
|
graph = path_to_iri(source_file)
|
|
184
270
|
try:
|
|
@@ -186,7 +272,7 @@ class Iolanta: # noqa: WPS214
|
|
|
186
272
|
parse_quads(
|
|
187
273
|
quads_document=ld_rdf,
|
|
188
274
|
graph=graph,
|
|
189
|
-
blank_node_prefix=str(
|
|
275
|
+
blank_node_prefix=str(source_file),
|
|
190
276
|
),
|
|
191
277
|
)
|
|
192
278
|
except UnresolvedIRI as err:
|
|
@@ -197,44 +283,45 @@ class Iolanta: # noqa: WPS214
|
|
|
197
283
|
)
|
|
198
284
|
|
|
199
285
|
if not quads:
|
|
200
|
-
self.logger.info(f
|
|
286
|
+
self.logger.info(f"{source_file} | No data found")
|
|
201
287
|
continue
|
|
202
288
|
|
|
203
289
|
self.graph.addN(quads)
|
|
290
|
+
self._facet_inference_needed = True
|
|
204
291
|
|
|
205
292
|
return self
|
|
206
293
|
|
|
207
|
-
def infer(self, closure_class=None) ->
|
|
294
|
+
def infer(self, closure_class=None) -> "Iolanta":
|
|
208
295
|
"""Apply inference."""
|
|
209
296
|
return self
|
|
210
297
|
|
|
211
|
-
def bind_namespaces(self):
|
|
298
|
+
def bind_namespaces(self): # noqa: WPS213
|
|
212
299
|
"""Bind namespaces."""
|
|
213
300
|
self.graph.namespace_manager = NamespaceManager(
|
|
214
301
|
self.graph,
|
|
215
|
-
bind_namespaces=
|
|
302
|
+
bind_namespaces="none",
|
|
216
303
|
)
|
|
217
|
-
self.graph.bind(prefix=
|
|
218
|
-
self.graph.bind(prefix=
|
|
219
|
-
self.graph.bind(prefix=
|
|
220
|
-
self.graph.bind(prefix=
|
|
221
|
-
self.graph.bind(prefix=
|
|
222
|
-
self.graph.bind(prefix=
|
|
223
|
-
self.graph.bind(prefix=
|
|
224
|
-
self.graph.bind(prefix=
|
|
225
|
-
self.graph.bind(prefix=
|
|
226
|
-
self.graph.bind(prefix=
|
|
227
|
-
self.graph.bind(prefix=
|
|
304
|
+
self.graph.bind(prefix="local", namespace=namespaces.LOCAL)
|
|
305
|
+
self.graph.bind(prefix="iolanta", namespace=namespaces.IOLANTA)
|
|
306
|
+
self.graph.bind(prefix="rdf", namespace=namespaces.RDF)
|
|
307
|
+
self.graph.bind(prefix="rdfs", namespace=namespaces.RDFS)
|
|
308
|
+
self.graph.bind(prefix="owl", namespace=namespaces.OWL)
|
|
309
|
+
self.graph.bind(prefix="foaf", namespace=namespaces.FOAF)
|
|
310
|
+
self.graph.bind(prefix="schema", namespace=namespaces.SDO)
|
|
311
|
+
self.graph.bind(prefix="vann", namespace=namespaces.VANN)
|
|
312
|
+
self.graph.bind(prefix="np", namespace=namespaces.NP)
|
|
313
|
+
self.graph.bind(prefix="dcterms", namespace=namespaces.DCTERMS)
|
|
314
|
+
self.graph.bind(prefix="rdfg", namespace=namespaces.RDFG)
|
|
228
315
|
|
|
229
316
|
@functools.cached_property
|
|
230
317
|
def context_paths(self) -> Iterable[Path]:
|
|
231
318
|
"""Compile list of context files."""
|
|
232
|
-
directory = Path(__file__).parent /
|
|
319
|
+
directory = Path(__file__).parent / "data"
|
|
233
320
|
|
|
234
|
-
yield directory /
|
|
321
|
+
yield directory / "context.yaml"
|
|
235
322
|
|
|
236
323
|
for plugin in self.plugins:
|
|
237
|
-
if path := plugin.context_path:
|
|
324
|
+
if path := plugin.context_path: # noqa: WPS332
|
|
238
325
|
yield path
|
|
239
326
|
|
|
240
327
|
def add_files_from_plugins(self):
|
|
@@ -244,13 +331,13 @@ class Iolanta: # noqa: WPS214
|
|
|
244
331
|
self.add(plugin.data_files)
|
|
245
332
|
except Exception as error:
|
|
246
333
|
self.logger.error(
|
|
247
|
-
f
|
|
334
|
+
f"Cannot load {plugin} plugin data files: {error}",
|
|
248
335
|
)
|
|
249
336
|
|
|
250
337
|
@property
|
|
251
338
|
def facet_classes(self):
|
|
252
339
|
"""Get all registered facet classes."""
|
|
253
|
-
return entry_points.plugins(
|
|
340
|
+
return entry_points.plugins("iolanta.facets")
|
|
254
341
|
|
|
255
342
|
def add_files_from_facets(self):
|
|
256
343
|
"""Add files from all registered facets to the graph."""
|
|
@@ -258,7 +345,7 @@ class Iolanta: # noqa: WPS214
|
|
|
258
345
|
try:
|
|
259
346
|
self.add(facet_class.META)
|
|
260
347
|
except AttributeError:
|
|
261
|
-
pass
|
|
348
|
+
pass # noqa: WPS420
|
|
262
349
|
|
|
263
350
|
def __post_init__(self):
|
|
264
351
|
"""Initialize after instance creation."""
|
|
@@ -278,11 +365,11 @@ class Iolanta: # noqa: WPS214
|
|
|
278
365
|
|
|
279
366
|
if not as_datatype:
|
|
280
367
|
raise ValueError(
|
|
281
|
-
f
|
|
368
|
+
f"Please provide the datatype to render {node} as.",
|
|
282
369
|
)
|
|
283
370
|
|
|
284
371
|
if isinstance(as_datatype, list):
|
|
285
|
-
raise NotImplementedError(
|
|
372
|
+
raise NotImplementedError("Got a list for as_datatype :(")
|
|
286
373
|
|
|
287
374
|
found = FacetFinder(
|
|
288
375
|
iolanta=self,
|
|
@@ -290,12 +377,12 @@ class Iolanta: # noqa: WPS214
|
|
|
290
377
|
as_datatype=as_datatype,
|
|
291
378
|
).facet_and_output_datatype
|
|
292
379
|
|
|
293
|
-
facet_class = self.facet_resolver.resolve(found[
|
|
380
|
+
facet_class = self.facet_resolver.resolve(found["facet"])
|
|
294
381
|
|
|
295
382
|
facet = facet_class(
|
|
296
383
|
this=node,
|
|
297
384
|
iolanta=self,
|
|
298
|
-
as_datatype=found[
|
|
385
|
+
as_datatype=found["output_datatype"],
|
|
299
386
|
)
|
|
300
387
|
|
|
301
388
|
try:
|
|
@@ -304,14 +391,18 @@ class Iolanta: # noqa: WPS214
|
|
|
304
391
|
except Exception as err:
|
|
305
392
|
raise FacetError(
|
|
306
393
|
node=node,
|
|
307
|
-
facet_iri=found[
|
|
394
|
+
facet_iri=found["facet"],
|
|
308
395
|
error=err,
|
|
309
396
|
) from err
|
|
310
397
|
|
|
311
398
|
def node_as_qname(self, node: Node):
|
|
312
399
|
"""Render node as a QName if possible."""
|
|
313
400
|
qname = node_to_qname(node, self.graph)
|
|
314
|
-
return
|
|
315
|
-
qname
|
|
316
|
-
|
|
317
|
-
|
|
401
|
+
return (
|
|
402
|
+
f"{qname.namespace_name}:{qname.term}"
|
|
403
|
+
if isinstance(
|
|
404
|
+
qname,
|
|
405
|
+
ComputedQName,
|
|
406
|
+
)
|
|
407
|
+
else node
|
|
408
|
+
)
|
iolanta/mcp/cli.py
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
from pathlib import Path
|
|
2
1
|
from typing import Annotated
|
|
3
2
|
|
|
4
3
|
from fastmcp import FastMCP
|
|
@@ -14,22 +13,7 @@ def render_uri(
|
|
|
14
13
|
as_format: Annotated[str, 'Format to render as. Examples: `labeled-triple-set`, `mermaid`'],
|
|
15
14
|
) -> str:
|
|
16
15
|
"""Render a URI."""
|
|
17
|
-
|
|
18
|
-
return str(result)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@mcp.prompt(description="How to author Linked Data with Iolanta")
|
|
22
|
-
def ld_authoring_rules() -> str:
|
|
23
|
-
"""How to author Linked Data with Iolanta."""
|
|
24
|
-
rules_path = Path(__file__).parent / 'prompts' / 'rules.md'
|
|
25
|
-
return rules_path.read_text()
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
@mcp.prompt(description="How to author nanopublication assertions with Iolanta")
|
|
29
|
-
def nanopublication_assertion_authoring_rules() -> str:
|
|
30
|
-
"""How to author nanopublication assertions with Iolanta."""
|
|
31
|
-
rules_path = Path(__file__).parent / 'prompts' / 'nanopublication_assertion_authoring_rules.md'
|
|
32
|
-
return rules_path.read_text()
|
|
16
|
+
return str(render_and_return(uri, as_format))
|
|
33
17
|
|
|
34
18
|
|
|
35
19
|
def app():
|
iolanta/mermaid/models.py
CHANGED
|
@@ -2,29 +2,45 @@ import enum
|
|
|
2
2
|
import hashlib
|
|
3
3
|
import re
|
|
4
4
|
import textwrap
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def escape_label(label: str) -> str:
|
|
9
|
-
"""Escape a label to prevent Mermaid from interpreting URLs as markdown links."""
|
|
10
|
-
# Remove https://, http://, and www. prefixes to prevent markdown link parsing
|
|
11
|
-
return label.replace('https://', '').replace('http://', '').replace('www.', '')
|
|
5
|
+
from urllib import parse as urllib_parse
|
|
12
6
|
|
|
13
7
|
from documented import Documented
|
|
14
8
|
from pydantic import AnyUrl, BaseModel
|
|
15
9
|
from rdflib import BNode, Literal, URIRef
|
|
16
10
|
|
|
17
|
-
from iolanta.models import NotLiteralNode
|
|
11
|
+
from iolanta.models import NotLiteralNode # noqa: WPS202
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def escape_label(label: str) -> str:
|
|
15
|
+
"""Escape a label to prevent Mermaid from interpreting URLs as markdown links and handle quotes.
|
|
16
|
+
|
|
17
|
+
Escapes quotes in labels that will be wrapped in quotes in Mermaid syntax.
|
|
18
|
+
"""
|
|
19
|
+
# Remove https://, http://, and www. prefixes to prevent markdown link parsing
|
|
20
|
+
safe_label = (
|
|
21
|
+
label.replace("https://", "").replace("http://", "").replace("www.", "")
|
|
22
|
+
)
|
|
23
|
+
# Escape quotes for use inside quoted strings in Mermaid
|
|
24
|
+
return safe_label.replace('"', r'\"')
|
|
18
25
|
|
|
19
26
|
|
|
20
27
|
class Direction(enum.StrEnum):
|
|
21
28
|
"""Mermaid diagram direction."""
|
|
22
29
|
|
|
23
|
-
TB =
|
|
24
|
-
LR =
|
|
30
|
+
TB = "TB"
|
|
31
|
+
LR = "LR"
|
|
25
32
|
|
|
26
33
|
|
|
27
|
-
class
|
|
34
|
+
class MermaidScalar(Documented, BaseModel, arbitrary_types_allowed=True):
|
|
35
|
+
"""Base class for Mermaid scalar elements (nodes and edges)."""
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def id(self) -> str:
|
|
39
|
+
"""Get the unique identifier for this Mermaid element."""
|
|
40
|
+
raise NotImplementedError()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MermaidURINode(MermaidScalar, frozen=True):
|
|
28
44
|
"""
|
|
29
45
|
{self.id}{self.maybe_title}
|
|
30
46
|
click {self.id} "{self.url}"
|
|
@@ -32,29 +48,31 @@ class MermaidURINode(Documented, BaseModel, arbitrary_types_allowed=True, frozen
|
|
|
32
48
|
|
|
33
49
|
uri: URIRef
|
|
34
50
|
url: AnyUrl
|
|
35
|
-
title: str =
|
|
51
|
+
title: str = ""
|
|
36
52
|
|
|
37
53
|
@property
|
|
38
54
|
def maybe_title(self):
|
|
39
55
|
if not self.title:
|
|
40
|
-
return
|
|
56
|
+
return ""
|
|
41
57
|
# Escape URLs to prevent Mermaid from interpreting them as markdown links
|
|
42
58
|
safe_title = escape_label(self.title)
|
|
43
59
|
return f'("{safe_title}")'
|
|
44
60
|
|
|
45
61
|
@property
|
|
46
62
|
def id(self):
|
|
47
|
-
return re.sub(
|
|
63
|
+
return re.sub(
|
|
64
|
+
r"[:\/\.#()]", "_", urllib_parse.unquote(str(self.url)).strip("/")
|
|
65
|
+
)
|
|
48
66
|
|
|
49
67
|
|
|
50
|
-
class MermaidLiteral(
|
|
68
|
+
class MermaidLiteral(MermaidScalar, frozen=True):
|
|
51
69
|
"""{self.id}[["{self.title}"]]"""
|
|
52
70
|
|
|
53
71
|
literal: Literal
|
|
54
72
|
|
|
55
73
|
@property
|
|
56
74
|
def title(self) -> str:
|
|
57
|
-
raw_title = str(self.literal) or
|
|
75
|
+
raw_title = str(self.literal) or "EMPTY"
|
|
58
76
|
# Replace quotes with safer characters for Mermaid
|
|
59
77
|
return raw_title.replace('"', '"').replace("'", "'")
|
|
60
78
|
|
|
@@ -63,53 +81,53 @@ class MermaidLiteral(Documented, BaseModel, arbitrary_types_allowed=True, frozen
|
|
|
63
81
|
# Use the lexical form of the literal, not rdflib's .value (which may be empty for typed literals),
|
|
64
82
|
# to ensure different texts get distinct node IDs in Mermaid.
|
|
65
83
|
value_hash = hashlib.md5(str(self.literal).encode()).hexdigest()
|
|
66
|
-
return f
|
|
84
|
+
return f"Literal-{value_hash}"
|
|
67
85
|
|
|
68
86
|
|
|
69
|
-
class MermaidBlankNode(
|
|
70
|
-
"""{self.id}({self.escaped_title})"""
|
|
87
|
+
class MermaidBlankNode(MermaidScalar):
|
|
88
|
+
"""{self.id}("{self.escaped_title}")"""
|
|
71
89
|
|
|
72
90
|
node: BNode
|
|
73
91
|
title: str
|
|
74
92
|
|
|
75
93
|
@property
|
|
76
94
|
def id(self) -> str:
|
|
77
|
-
return self.node.replace(
|
|
78
|
-
|
|
95
|
+
return self.node.replace("_:", "")
|
|
96
|
+
|
|
79
97
|
@property
|
|
80
98
|
def escaped_title(self) -> str:
|
|
81
|
-
|
|
99
|
+
"""Escape the title to prevent Mermaid parsing issues."""
|
|
100
|
+
return escape_label(self.title)
|
|
82
101
|
|
|
83
102
|
|
|
84
|
-
class MermaidEdge(
|
|
103
|
+
class MermaidEdge(MermaidScalar):
|
|
85
104
|
"""
|
|
86
105
|
{self.source.id} --- {self.id}(["{self.escaped_title}"])--> {self.target.id}
|
|
87
106
|
click {self.id} "{self.predicate}"
|
|
88
107
|
class {self.id} predicate
|
|
89
108
|
"""
|
|
90
109
|
|
|
91
|
-
source:
|
|
92
|
-
target:
|
|
110
|
+
source: "MermaidURINode | MermaidBlankNode | MermaidSubgraph"
|
|
111
|
+
target: "MermaidURINode | MermaidLiteral | MermaidBlankNode | MermaidSubgraph"
|
|
93
112
|
predicate: URIRef
|
|
94
113
|
title: str
|
|
95
114
|
|
|
96
115
|
@property
|
|
97
116
|
def id(self) -> str:
|
|
98
|
-
return hashlib.md5(
|
|
117
|
+
return hashlib.md5(
|
|
118
|
+
f"{self.source.id}{self.predicate}{self.target.id}".encode() # noqa: WPS237
|
|
119
|
+
).hexdigest()
|
|
99
120
|
|
|
100
121
|
@property
|
|
101
122
|
def nodes(self):
|
|
102
123
|
return [self.source, self.target]
|
|
103
|
-
|
|
124
|
+
|
|
104
125
|
@property
|
|
105
126
|
def escaped_title(self) -> str:
|
|
106
127
|
# Escape URLs to prevent Mermaid from interpreting them as markdown links
|
|
107
128
|
return escape_label(self.title)
|
|
108
129
|
|
|
109
130
|
|
|
110
|
-
MermaidScalar = MermaidLiteral | MermaidBlankNode | MermaidURINode | MermaidEdge
|
|
111
|
-
|
|
112
|
-
|
|
113
131
|
class MermaidSubgraph(Documented, BaseModel, arbitrary_types_allowed=True, frozen=True):
|
|
114
132
|
"""
|
|
115
133
|
subgraph {self.id}["{self.escaped_title}"]
|
|
@@ -117,6 +135,7 @@ class MermaidSubgraph(Documented, BaseModel, arbitrary_types_allowed=True, froze
|
|
|
117
135
|
{self.formatted_body}
|
|
118
136
|
end
|
|
119
137
|
"""
|
|
138
|
+
|
|
120
139
|
children: list[MermaidScalar]
|
|
121
140
|
uri: NotLiteralNode
|
|
122
141
|
title: str
|
|
@@ -125,8 +144,8 @@ class MermaidSubgraph(Documented, BaseModel, arbitrary_types_allowed=True, froze
|
|
|
125
144
|
@property
|
|
126
145
|
def id(self):
|
|
127
146
|
uri_hash = hashlib.md5(str(self.uri).encode()).hexdigest()
|
|
128
|
-
return f
|
|
129
|
-
|
|
147
|
+
return f"subgraph_{uri_hash}"
|
|
148
|
+
|
|
130
149
|
@property
|
|
131
150
|
def escaped_title(self) -> str:
|
|
132
151
|
"""Escape the subgraph title to prevent markdown link parsing."""
|
|
@@ -135,8 +154,8 @@ class MermaidSubgraph(Documented, BaseModel, arbitrary_types_allowed=True, froze
|
|
|
135
154
|
@property
|
|
136
155
|
def formatted_body(self):
|
|
137
156
|
return textwrap.indent(
|
|
138
|
-
|
|
139
|
-
prefix=
|
|
157
|
+
"\n".join(map(str, self.children)),
|
|
158
|
+
prefix=" ",
|
|
140
159
|
)
|
|
141
160
|
|
|
142
161
|
|
|
@@ -145,14 +164,20 @@ class Diagram(Documented, BaseModel):
|
|
|
145
164
|
graph {self.direction}
|
|
146
165
|
{self.formatted_body}
|
|
147
166
|
classDef predicate fill:transparent,stroke:transparent,stroke-width:0px;
|
|
167
|
+
{self.formatted_tail}
|
|
148
168
|
"""
|
|
149
169
|
|
|
150
170
|
children: list[MermaidScalar | MermaidSubgraph]
|
|
151
171
|
direction: Direction = Direction.LR
|
|
172
|
+
tail: str | None = None
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def formatted_tail(self) -> str:
|
|
176
|
+
return self.tail or ""
|
|
152
177
|
|
|
153
178
|
@property
|
|
154
179
|
def formatted_body(self):
|
|
155
180
|
return textwrap.indent(
|
|
156
|
-
|
|
157
|
-
prefix=
|
|
181
|
+
"\n".join(map(str, self.children)),
|
|
182
|
+
prefix=" ",
|
|
158
183
|
)
|