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.
Files changed (30) hide show
  1. iolanta/cli/main.py +200 -80
  2. iolanta/facets/facet.py +15 -9
  3. iolanta/facets/mermaid_roadmap/__init__.py +0 -0
  4. iolanta/facets/mermaid_roadmap/facet.py +133 -0
  5. iolanta/facets/mermaid_roadmap/inference/blocks.sparql +13 -0
  6. iolanta/facets/mermaid_roadmap/inference/has-task-default-type.sparql +16 -0
  7. iolanta/facets/mermaid_roadmap/inference/task.sparql +26 -0
  8. iolanta/facets/mermaid_roadmap/inference/unblocked.sparql +21 -0
  9. iolanta/facets/mermaid_roadmap/mermaid_roadmap.yamlld +59 -0
  10. iolanta/facets/mermaid_roadmap/sparql/edges.sparql +25 -0
  11. iolanta/facets/mermaid_roadmap/sparql/nodes.sparql +17 -0
  12. iolanta/facets/query/__init__.py +0 -0
  13. iolanta/facets/query/ask_result_csv.py +23 -0
  14. iolanta/facets/query/ask_result_json.py +24 -0
  15. iolanta/facets/query/ask_result_table.py +23 -0
  16. iolanta/facets/query/construct_result_csv.py +34 -0
  17. iolanta/facets/query/construct_result_json.py +32 -0
  18. iolanta/facets/query/construct_result_table.py +55 -0
  19. iolanta/facets/query/data/query_result.yamlld +102 -0
  20. iolanta/facets/query/select_result_csv.py +36 -0
  21. iolanta/facets/query/select_result_json.py +24 -0
  22. iolanta/facets/query/select_result_table.py +48 -0
  23. iolanta/iolanta.py +146 -55
  24. iolanta/mcp/cli.py +16 -3
  25. iolanta/mermaid/models.py +74 -40
  26. iolanta/sparqlspace/processor.py +232 -179
  27. {iolanta-2.1.11.dist-info → iolanta-2.1.13.dist-info}/METADATA +2 -2
  28. {iolanta-2.1.11.dist-info → iolanta-2.1.13.dist-info}/RECORD +30 -10
  29. {iolanta-2.1.11.dist-info → iolanta-2.1.13.dist-info}/WHEEL +1 -1
  30. {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('_inference'))
61
+ return ConjunctiveGraph(identifier=namespaces.LOCAL.term("_inference"))
64
62
 
65
63
 
66
64
  @dataclass
67
- class Iolanta: # noqa: WPS214
65
+ class Iolanta: # noqa: WPS214, WPS338
68
66
  """Iolanta is a Semantic web browser."""
69
67
 
70
- language: Literal = Literal('en')
68
+ language: Literal = Literal("en")
71
69
 
72
70
  project_root: Annotated[
73
71
  Path | None,
74
72
  (
75
- 'File or directory the contents of which '
76
- 'Iolanta will automatically load into the graph.'
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('iolanta.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
- plugin_class(iolanta=self)
107
- for plugin_class in self.plugin_classes
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='sparqlspace',
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() # noqa: WPS601
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
- ) -> 'Iolanta':
236
+ ) -> "Iolanta":
151
237
  """Parse & load information from given URL into the graph."""
152
- self.logger.info(f'Adding to graph: {source}')
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('*')) or [source]:
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
- '%s | name resolution error: %s',
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'{source} | {parser_not_found}')
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'{source_file} | {yaml_ld_error}')
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'{source} | {value_error}')
264
+ self.logger.error(f"{source} | {value_error}")
179
265
  continue
180
266
 
181
- self.logger.info(f'{source_file} is loaded.')
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(source),
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'{source_file} | No data found')
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) -> 'Iolanta':
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='none',
302
+ bind_namespaces="none",
216
303
  )
217
- self.graph.bind(prefix='local', namespace=namespaces.LOCAL)
218
- self.graph.bind(prefix='iolanta', namespace=namespaces.IOLANTA)
219
- self.graph.bind(prefix='rdf', namespace=namespaces.RDF)
220
- self.graph.bind(prefix='rdfs', namespace=namespaces.RDFS)
221
- self.graph.bind(prefix='owl', namespace=namespaces.OWL)
222
- self.graph.bind(prefix='foaf', namespace=namespaces.FOAF)
223
- self.graph.bind(prefix='schema', namespace=namespaces.SDO)
224
- self.graph.bind(prefix='vann', namespace=namespaces.VANN)
225
- self.graph.bind(prefix='np', namespace=namespaces.NP)
226
- self.graph.bind(prefix='dcterms', namespace=namespaces.DCTERMS)
227
- self.graph.bind(prefix='rdfg', namespace=namespaces.RDFG)
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 / 'data'
319
+ directory = Path(__file__).parent / "data"
233
320
 
234
- yield directory / 'context.yaml'
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'Cannot load {plugin} plugin data files: {error}',
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('iolanta.facets')
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'Please provide the datatype to render {node} as.',
368
+ f"Please provide the datatype to render {node} as.",
282
369
  )
283
370
 
284
371
  if isinstance(as_datatype, list):
285
- raise NotImplementedError('Got a list for as_datatype :(')
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['facet'])
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['output_datatype'],
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['facet'],
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 f'{qname.namespace_name}:{qname.term}' if isinstance(
315
- qname,
316
- ComputedQName,
317
- ) else node
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, 'URL, or file system path, to render'],
13
- as_format: Annotated[str, 'Format to render as. Examples: `labeled-triple-set`, `mermaid`'],
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
- return str(render_and_return(uri, as_format))
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 urllib.parse
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 = 'TB'
24
- LR = 'LR'
40
+ TB = "TB"
41
+ LR = "LR"
25
42
 
26
43
 
27
- class MermaidURINode(Documented, BaseModel, arbitrary_types_allowed=True, frozen=True):
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
- # Escape URLs to prevent Mermaid from interpreting them as markdown links
42
- safe_title = escape_label(self.title)
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(r'[:\/\.#()]', '_', urllib.parse.unquote(str(self.url)).strip('/'))
72
+ return re.sub(
73
+ r"[:\/\.#()?=&+]", "_", urllib_parse.unquote(str(self.url)).strip("/")
74
+ )
48
75
 
49
76
 
50
- class MermaidLiteral(Documented, BaseModel, arbitrary_types_allowed=True, frozen=True):
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 'EMPTY'
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'Literal-{value_hash}'
93
+ return f"Literal-{value_hash}"
67
94
 
68
95
 
69
- class MermaidBlankNode(Documented, BaseModel, arbitrary_types_allowed=True):
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
- return self.title
108
+ """Escape the title to prevent Mermaid parsing issues."""
109
+ return escape_label(self.title)
82
110
 
83
111
 
84
- class MermaidEdge(Documented, BaseModel, arbitrary_types_allowed=True):
112
+ class MermaidEdge(MermaidScalar):
85
113
  """
86
- {self.source.id} --- {self.id}(["{self.escaped_title}"])--> {self.target.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: 'MermaidURINode | MermaidBlankNode | MermaidSubgraph'
92
- target: 'MermaidURINode | MermaidLiteral | MermaidBlankNode | MermaidSubgraph'
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(f'{self.source.id}{self.predicate}{self.target.id}'.encode()).hexdigest()
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}["{self.escaped_title}"]
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'subgraph_{uri_hash}'
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
- '\n'.join(map(str, self.children)),
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
- '\n'.join(map(str, self.children)),
157
- prefix=' ',
190
+ "\n".join(map(str, self.children)),
191
+ prefix=" ",
158
192
  )