iolanta 2.1.11__py3-none-any.whl → 2.1.13__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/cli/main.py +200 -80
- 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/facets/query/__init__.py +0 -0
- iolanta/facets/query/ask_result_csv.py +23 -0
- iolanta/facets/query/ask_result_json.py +24 -0
- iolanta/facets/query/ask_result_table.py +23 -0
- iolanta/facets/query/construct_result_csv.py +34 -0
- iolanta/facets/query/construct_result_json.py +32 -0
- iolanta/facets/query/construct_result_table.py +55 -0
- iolanta/facets/query/data/query_result.yamlld +102 -0
- iolanta/facets/query/select_result_csv.py +36 -0
- iolanta/facets/query/select_result_json.py +24 -0
- iolanta/facets/query/select_result_table.py +48 -0
- iolanta/iolanta.py +146 -55
- iolanta/mcp/cli.py +16 -3
- iolanta/mermaid/models.py +74 -40
- iolanta/sparqlspace/processor.py +232 -179
- {iolanta-2.1.11.dist-info → iolanta-2.1.13.dist-info}/METADATA +2 -2
- {iolanta-2.1.11.dist-info → iolanta-2.1.13.dist-info}/RECORD +30 -10
- {iolanta-2.1.11.dist-info → iolanta-2.1.13.dist-info}/WHEEL +1 -1
- {iolanta-2.1.11.dist-info → iolanta-2.1.13.dist-info}/entry_points.txt +10 -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,6 +1,9 @@
|
|
|
1
|
+
from pathlib import Path
|
|
1
2
|
from typing import Annotated
|
|
2
3
|
|
|
3
4
|
from fastmcp import FastMCP
|
|
5
|
+
from rdflib import URIRef
|
|
6
|
+
from yarl import URL
|
|
4
7
|
|
|
5
8
|
from iolanta.cli.main import render_and_return
|
|
6
9
|
|
|
@@ -9,11 +12,21 @@ mcp = FastMCP("Iolanta MCP Server")
|
|
|
9
12
|
|
|
10
13
|
@mcp.tool()
|
|
11
14
|
def render_uri(
|
|
12
|
-
uri: Annotated[str,
|
|
13
|
-
as_format: Annotated[
|
|
15
|
+
uri: Annotated[str, "URL, or file system path, to render"],
|
|
16
|
+
as_format: Annotated[
|
|
17
|
+
str, "Format to render as. Examples: `labeled-triple-set`, `mermaid`"
|
|
18
|
+
],
|
|
14
19
|
) -> str:
|
|
15
20
|
"""Render a URI."""
|
|
16
|
-
|
|
21
|
+
# Parse string URL to URIRef
|
|
22
|
+
node_url = URL(uri)
|
|
23
|
+
if node_url.scheme and node_url.scheme != "file":
|
|
24
|
+
node = URIRef(uri)
|
|
25
|
+
else:
|
|
26
|
+
path = Path(node_url.path).absolute()
|
|
27
|
+
node = URIRef(f"file://{path}")
|
|
28
|
+
|
|
29
|
+
return str(render_and_return(node=node, as_datatype=as_format))
|
|
17
30
|
|
|
18
31
|
|
|
19
32
|
def app():
|
iolanta/mermaid/models.py
CHANGED
|
@@ -2,29 +2,55 @@ 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 and return it wrapped in appropriate quotes.
|
|
16
|
+
|
|
17
|
+
Returns the label with URLs stripped, quotes escaped, and wrapped in quotes.
|
|
18
|
+
Uses single quotes if label contains double quotes to avoid escaping issues.
|
|
19
|
+
|
|
20
|
+
Escapes quotes in labels that will be wrapped in quotes in Mermaid syntax.
|
|
21
|
+
"""
|
|
22
|
+
# Remove https://, http://, and www. prefixes to prevent markdown link parsing
|
|
23
|
+
safe_label = (
|
|
24
|
+
label.replace("https://", "").replace("http://", "").replace("www.", "")
|
|
25
|
+
)
|
|
26
|
+
# Use single quotes if label contains double quotes to avoid escaping issues
|
|
27
|
+
use_single = '"' in safe_label
|
|
28
|
+
quote_char = "'" if use_single else '"'
|
|
29
|
+
# Escape the quote character that will be used for wrapping
|
|
30
|
+
if use_single:
|
|
31
|
+
escaped_label = safe_label.replace("'", r"\'")
|
|
32
|
+
else:
|
|
33
|
+
escaped_label = safe_label.replace('"', r"\"")
|
|
34
|
+
return f"{quote_char}{escaped_label}{quote_char}"
|
|
18
35
|
|
|
19
36
|
|
|
20
37
|
class Direction(enum.StrEnum):
|
|
21
38
|
"""Mermaid diagram direction."""
|
|
22
39
|
|
|
23
|
-
TB =
|
|
24
|
-
LR =
|
|
40
|
+
TB = "TB"
|
|
41
|
+
LR = "LR"
|
|
25
42
|
|
|
26
43
|
|
|
27
|
-
class
|
|
44
|
+
class MermaidScalar(Documented, BaseModel, arbitrary_types_allowed=True):
|
|
45
|
+
"""Base class for Mermaid scalar elements (nodes and edges)."""
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def id(self) -> str:
|
|
49
|
+
"""Get the unique identifier for this Mermaid element."""
|
|
50
|
+
raise NotImplementedError()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class MermaidURINode(MermaidScalar, frozen=True):
|
|
28
54
|
"""
|
|
29
55
|
{self.id}{self.maybe_title}
|
|
30
56
|
click {self.id} "{self.url}"
|
|
@@ -32,29 +58,30 @@ class MermaidURINode(Documented, BaseModel, arbitrary_types_allowed=True, frozen
|
|
|
32
58
|
|
|
33
59
|
uri: URIRef
|
|
34
60
|
url: AnyUrl
|
|
35
|
-
title: str =
|
|
61
|
+
title: str = ""
|
|
36
62
|
|
|
37
63
|
@property
|
|
38
64
|
def maybe_title(self):
|
|
39
65
|
if not self.title:
|
|
40
|
-
return
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return f'("{safe_title}")'
|
|
66
|
+
return ""
|
|
67
|
+
quoted_title = escape_label(self.title)
|
|
68
|
+
return f"({quoted_title})"
|
|
44
69
|
|
|
45
70
|
@property
|
|
46
71
|
def id(self):
|
|
47
|
-
return re.sub(
|
|
72
|
+
return re.sub(
|
|
73
|
+
r"[:\/\.#()?=&+]", "_", urllib_parse.unquote(str(self.url)).strip("/")
|
|
74
|
+
)
|
|
48
75
|
|
|
49
76
|
|
|
50
|
-
class MermaidLiteral(
|
|
77
|
+
class MermaidLiteral(MermaidScalar, frozen=True):
|
|
51
78
|
"""{self.id}[["{self.title}"]]"""
|
|
52
79
|
|
|
53
80
|
literal: Literal
|
|
54
81
|
|
|
55
82
|
@property
|
|
56
83
|
def title(self) -> str:
|
|
57
|
-
raw_title = str(self.literal) or
|
|
84
|
+
raw_title = str(self.literal) or "EMPTY"
|
|
58
85
|
# Replace quotes with safer characters for Mermaid
|
|
59
86
|
return raw_title.replace('"', '"').replace("'", "'")
|
|
60
87
|
|
|
@@ -63,10 +90,10 @@ class MermaidLiteral(Documented, BaseModel, arbitrary_types_allowed=True, frozen
|
|
|
63
90
|
# Use the lexical form of the literal, not rdflib's .value (which may be empty for typed literals),
|
|
64
91
|
# to ensure different texts get distinct node IDs in Mermaid.
|
|
65
92
|
value_hash = hashlib.md5(str(self.literal).encode()).hexdigest()
|
|
66
|
-
return f
|
|
93
|
+
return f"Literal-{value_hash}"
|
|
67
94
|
|
|
68
95
|
|
|
69
|
-
class MermaidBlankNode(
|
|
96
|
+
class MermaidBlankNode(MermaidScalar):
|
|
70
97
|
"""{self.id}({self.escaped_title})"""
|
|
71
98
|
|
|
72
99
|
node: BNode
|
|
@@ -74,49 +101,50 @@ class MermaidBlankNode(Documented, BaseModel, arbitrary_types_allowed=True):
|
|
|
74
101
|
|
|
75
102
|
@property
|
|
76
103
|
def id(self) -> str:
|
|
77
|
-
return self.node.replace(
|
|
78
|
-
|
|
104
|
+
return self.node.replace("_:", "")
|
|
105
|
+
|
|
79
106
|
@property
|
|
80
107
|
def escaped_title(self) -> str:
|
|
81
|
-
|
|
108
|
+
"""Escape the title to prevent Mermaid parsing issues."""
|
|
109
|
+
return escape_label(self.title)
|
|
82
110
|
|
|
83
111
|
|
|
84
|
-
class MermaidEdge(
|
|
112
|
+
class MermaidEdge(MermaidScalar):
|
|
85
113
|
"""
|
|
86
|
-
{self.source.id} --- {self.id}([
|
|
114
|
+
{self.source.id} --- {self.id}([{self.escaped_title}])--> {self.target.id}
|
|
87
115
|
click {self.id} "{self.predicate}"
|
|
88
116
|
class {self.id} predicate
|
|
89
117
|
"""
|
|
90
118
|
|
|
91
|
-
source:
|
|
92
|
-
target:
|
|
119
|
+
source: "MermaidURINode | MermaidBlankNode | MermaidSubgraph"
|
|
120
|
+
target: "MermaidURINode | MermaidLiteral | MermaidBlankNode | MermaidSubgraph"
|
|
93
121
|
predicate: URIRef
|
|
94
122
|
title: str
|
|
95
123
|
|
|
96
124
|
@property
|
|
97
125
|
def id(self) -> str:
|
|
98
|
-
return hashlib.md5(
|
|
126
|
+
return hashlib.md5(
|
|
127
|
+
f"{self.source.id}{self.predicate}{self.target.id}".encode() # noqa: WPS237
|
|
128
|
+
).hexdigest()
|
|
99
129
|
|
|
100
130
|
@property
|
|
101
131
|
def nodes(self):
|
|
102
132
|
return [self.source, self.target]
|
|
103
|
-
|
|
133
|
+
|
|
104
134
|
@property
|
|
105
135
|
def escaped_title(self) -> str:
|
|
106
136
|
# Escape URLs to prevent Mermaid from interpreting them as markdown links
|
|
107
137
|
return escape_label(self.title)
|
|
108
138
|
|
|
109
139
|
|
|
110
|
-
MermaidScalar = MermaidLiteral | MermaidBlankNode | MermaidURINode | MermaidEdge
|
|
111
|
-
|
|
112
|
-
|
|
113
140
|
class MermaidSubgraph(Documented, BaseModel, arbitrary_types_allowed=True, frozen=True):
|
|
114
141
|
"""
|
|
115
|
-
subgraph {self.id}[
|
|
142
|
+
subgraph {self.id}[{self.escaped_title}]
|
|
116
143
|
direction {self.direction}
|
|
117
144
|
{self.formatted_body}
|
|
118
145
|
end
|
|
119
146
|
"""
|
|
147
|
+
|
|
120
148
|
children: list[MermaidScalar]
|
|
121
149
|
uri: NotLiteralNode
|
|
122
150
|
title: str
|
|
@@ -125,8 +153,8 @@ class MermaidSubgraph(Documented, BaseModel, arbitrary_types_allowed=True, froze
|
|
|
125
153
|
@property
|
|
126
154
|
def id(self):
|
|
127
155
|
uri_hash = hashlib.md5(str(self.uri).encode()).hexdigest()
|
|
128
|
-
return f
|
|
129
|
-
|
|
156
|
+
return f"subgraph_{uri_hash}"
|
|
157
|
+
|
|
130
158
|
@property
|
|
131
159
|
def escaped_title(self) -> str:
|
|
132
160
|
"""Escape the subgraph title to prevent markdown link parsing."""
|
|
@@ -135,8 +163,8 @@ class MermaidSubgraph(Documented, BaseModel, arbitrary_types_allowed=True, froze
|
|
|
135
163
|
@property
|
|
136
164
|
def formatted_body(self):
|
|
137
165
|
return textwrap.indent(
|
|
138
|
-
|
|
139
|
-
prefix=
|
|
166
|
+
"\n".join(map(str, self.children)),
|
|
167
|
+
prefix=" ",
|
|
140
168
|
)
|
|
141
169
|
|
|
142
170
|
|
|
@@ -145,14 +173,20 @@ class Diagram(Documented, BaseModel):
|
|
|
145
173
|
graph {self.direction}
|
|
146
174
|
{self.formatted_body}
|
|
147
175
|
classDef predicate fill:transparent,stroke:transparent,stroke-width:0px;
|
|
176
|
+
{self.formatted_tail}
|
|
148
177
|
"""
|
|
149
178
|
|
|
150
179
|
children: list[MermaidScalar | MermaidSubgraph]
|
|
151
180
|
direction: Direction = Direction.LR
|
|
181
|
+
tail: str | None = None
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def formatted_tail(self) -> str:
|
|
185
|
+
return self.tail or ""
|
|
152
186
|
|
|
153
187
|
@property
|
|
154
188
|
def formatted_body(self):
|
|
155
189
|
return textwrap.indent(
|
|
156
|
-
|
|
157
|
-
prefix=
|
|
190
|
+
"\n".join(map(str, self.children)),
|
|
191
|
+
prefix=" ",
|
|
158
192
|
)
|