iolanta 2.1.11__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/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/mermaid/models.py CHANGED
@@ -2,29 +2,45 @@ 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 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 = 'TB'
24
- LR = 'LR'
30
+ TB = "TB"
31
+ LR = "LR"
25
32
 
26
33
 
27
- class MermaidURINode(Documented, BaseModel, arbitrary_types_allowed=True, frozen=True):
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(r'[:\/\.#()]', '_', urllib.parse.unquote(str(self.url)).strip('/'))
63
+ return re.sub(
64
+ r"[:\/\.#()]", "_", urllib_parse.unquote(str(self.url)).strip("/")
65
+ )
48
66
 
49
67
 
50
- class MermaidLiteral(Documented, BaseModel, arbitrary_types_allowed=True, frozen=True):
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 'EMPTY'
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'Literal-{value_hash}'
84
+ return f"Literal-{value_hash}"
67
85
 
68
86
 
69
- class MermaidBlankNode(Documented, BaseModel, arbitrary_types_allowed=True):
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
- return self.title
99
+ """Escape the title to prevent Mermaid parsing issues."""
100
+ return escape_label(self.title)
82
101
 
83
102
 
84
- class MermaidEdge(Documented, BaseModel, arbitrary_types_allowed=True):
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: 'MermaidURINode | MermaidBlankNode | MermaidSubgraph'
92
- target: 'MermaidURINode | MermaidLiteral | MermaidBlankNode | MermaidSubgraph'
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(f'{self.source.id}{self.predicate}{self.target.id}'.encode()).hexdigest()
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'subgraph_{uri_hash}'
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
- '\n'.join(map(str, self.children)),
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
- '\n'.join(map(str, self.children)),
157
- prefix=' ',
181
+ "\n".join(map(str, self.children)),
182
+ prefix=" ",
158
183
  )