rdf-construct 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. rdf_construct/__init__.py +12 -0
  2. rdf_construct/__main__.py +0 -0
  3. rdf_construct/cli.py +1762 -0
  4. rdf_construct/core/__init__.py +33 -0
  5. rdf_construct/core/config.py +116 -0
  6. rdf_construct/core/ordering.py +219 -0
  7. rdf_construct/core/predicate_order.py +212 -0
  8. rdf_construct/core/profile.py +157 -0
  9. rdf_construct/core/selector.py +64 -0
  10. rdf_construct/core/serialiser.py +232 -0
  11. rdf_construct/core/utils.py +89 -0
  12. rdf_construct/cq/__init__.py +77 -0
  13. rdf_construct/cq/expectations.py +365 -0
  14. rdf_construct/cq/formatters/__init__.py +45 -0
  15. rdf_construct/cq/formatters/json.py +104 -0
  16. rdf_construct/cq/formatters/junit.py +104 -0
  17. rdf_construct/cq/formatters/text.py +146 -0
  18. rdf_construct/cq/loader.py +300 -0
  19. rdf_construct/cq/runner.py +321 -0
  20. rdf_construct/diff/__init__.py +59 -0
  21. rdf_construct/diff/change_types.py +214 -0
  22. rdf_construct/diff/comparator.py +338 -0
  23. rdf_construct/diff/filters.py +133 -0
  24. rdf_construct/diff/formatters/__init__.py +71 -0
  25. rdf_construct/diff/formatters/json.py +192 -0
  26. rdf_construct/diff/formatters/markdown.py +210 -0
  27. rdf_construct/diff/formatters/text.py +195 -0
  28. rdf_construct/docs/__init__.py +60 -0
  29. rdf_construct/docs/config.py +238 -0
  30. rdf_construct/docs/extractors.py +603 -0
  31. rdf_construct/docs/generator.py +360 -0
  32. rdf_construct/docs/renderers/__init__.py +7 -0
  33. rdf_construct/docs/renderers/html.py +803 -0
  34. rdf_construct/docs/renderers/json.py +390 -0
  35. rdf_construct/docs/renderers/markdown.py +628 -0
  36. rdf_construct/docs/search.py +278 -0
  37. rdf_construct/docs/templates/html/base.html.jinja +44 -0
  38. rdf_construct/docs/templates/html/class.html.jinja +152 -0
  39. rdf_construct/docs/templates/html/hierarchy.html.jinja +28 -0
  40. rdf_construct/docs/templates/html/index.html.jinja +110 -0
  41. rdf_construct/docs/templates/html/instance.html.jinja +90 -0
  42. rdf_construct/docs/templates/html/namespaces.html.jinja +37 -0
  43. rdf_construct/docs/templates/html/property.html.jinja +124 -0
  44. rdf_construct/docs/templates/html/single_page.html.jinja +169 -0
  45. rdf_construct/lint/__init__.py +75 -0
  46. rdf_construct/lint/config.py +214 -0
  47. rdf_construct/lint/engine.py +396 -0
  48. rdf_construct/lint/formatters.py +327 -0
  49. rdf_construct/lint/rules.py +692 -0
  50. rdf_construct/main.py +6 -0
  51. rdf_construct/puml2rdf/__init__.py +103 -0
  52. rdf_construct/puml2rdf/config.py +230 -0
  53. rdf_construct/puml2rdf/converter.py +420 -0
  54. rdf_construct/puml2rdf/merger.py +200 -0
  55. rdf_construct/puml2rdf/model.py +202 -0
  56. rdf_construct/puml2rdf/parser.py +565 -0
  57. rdf_construct/puml2rdf/validators.py +451 -0
  58. rdf_construct/shacl/__init__.py +56 -0
  59. rdf_construct/shacl/config.py +166 -0
  60. rdf_construct/shacl/converters.py +520 -0
  61. rdf_construct/shacl/generator.py +364 -0
  62. rdf_construct/shacl/namespaces.py +93 -0
  63. rdf_construct/stats/__init__.py +29 -0
  64. rdf_construct/stats/collector.py +178 -0
  65. rdf_construct/stats/comparator.py +298 -0
  66. rdf_construct/stats/formatters/__init__.py +83 -0
  67. rdf_construct/stats/formatters/json.py +38 -0
  68. rdf_construct/stats/formatters/markdown.py +153 -0
  69. rdf_construct/stats/formatters/text.py +186 -0
  70. rdf_construct/stats/metrics/__init__.py +26 -0
  71. rdf_construct/stats/metrics/basic.py +147 -0
  72. rdf_construct/stats/metrics/complexity.py +137 -0
  73. rdf_construct/stats/metrics/connectivity.py +130 -0
  74. rdf_construct/stats/metrics/documentation.py +128 -0
  75. rdf_construct/stats/metrics/hierarchy.py +207 -0
  76. rdf_construct/stats/metrics/properties.py +88 -0
  77. rdf_construct/uml/__init__.py +22 -0
  78. rdf_construct/uml/context.py +194 -0
  79. rdf_construct/uml/mapper.py +371 -0
  80. rdf_construct/uml/odm_renderer.py +789 -0
  81. rdf_construct/uml/renderer.py +684 -0
  82. rdf_construct/uml/uml_layout.py +393 -0
  83. rdf_construct/uml/uml_style.py +613 -0
  84. rdf_construct-0.2.0.dist-info/METADATA +431 -0
  85. rdf_construct-0.2.0.dist-info/RECORD +88 -0
  86. rdf_construct-0.2.0.dist-info/WHEEL +4 -0
  87. rdf_construct-0.2.0.dist-info/entry_points.txt +3 -0
  88. rdf_construct-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,789 @@
1
+ """ODM RDF Profile renderer for PlantUML diagrams.
2
+
3
+ This module provides ODM (Ontology Definition Metamodel) compliant rendering
4
+ of RDF/OWL ontologies as PlantUML class diagrams. The ODM is an OMG standard
5
+ that defines UML profiles for RDF and OWL modelling.
6
+
7
+ Key differences from the default renderer:
8
+ - Uses standard ODM stereotype names (<<owlClass>>, <<objectProperty>>, etc.)
9
+ - Supports rendering properties as UML associations (not just classes)
10
+ - Uses <<individual>> stereotype for instances
11
+ - Follows OMG ODM 1.1 specification conventions
12
+
13
+ References:
14
+ - OMG ODM 1.1: https://www.omg.org/spec/ODM/1.1/
15
+ - W3C OWL UML Concrete Syntax: https://www.w3.org/2007/OWL/wiki/UML_Concrete_Syntax
16
+ """
17
+
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ from rdflib import Graph, URIRef, RDF, RDFS, Literal
22
+ from rdflib.namespace import OWL, XSD
23
+
24
+
25
+ # ODM stereotype mappings for RDF/OWL concepts
26
+ # These follow the OMG ODM 1.1 specification naming conventions
27
+ ODM_CLASS_STEREOTYPES = {
28
+ str(OWL.Class): "owlClass",
29
+ str(RDFS.Class): "rdfsClass",
30
+ str(OWL.Restriction): "restriction",
31
+ str(RDFS.Datatype): "rdfsDatatype",
32
+ str(OWL.Ontology): "owlOntology",
33
+ }
34
+
35
+ ODM_PROPERTY_STEREOTYPES = {
36
+ str(OWL.ObjectProperty): "objectProperty",
37
+ str(OWL.DatatypeProperty): "datatypeProperty",
38
+ str(OWL.AnnotationProperty): "annotationProperty",
39
+ str(OWL.OntologyProperty): "ontologyProperty",
40
+ str(OWL.FunctionalProperty): "functionalProperty",
41
+ str(OWL.InverseFunctionalProperty): "inverseFunctionalProperty",
42
+ str(OWL.SymmetricProperty): "symmetricProperty",
43
+ str(OWL.TransitiveProperty): "transitiveProperty",
44
+ str(RDF.Property): "rdfProperty",
45
+ }
46
+
47
+ ODM_INDIVIDUAL_STEREOTYPE = "individual"
48
+
49
+ # Relationship stereotypes
50
+ ODM_RELATIONSHIP_STEREOTYPES = {
51
+ "subclass": "rdfsSubClassOf",
52
+ "subproperty": "rdfsSubPropertyOf",
53
+ "type": "rdfType",
54
+ "domain": "rdfsDomain",
55
+ "range": "rdfsRange",
56
+ "equivalent_class": "owlEquivalentClass",
57
+ "disjoint_with": "owlDisjointWith",
58
+ "inverse_of": "owlInverseOf",
59
+ }
60
+
61
+
62
+ def qname(graph: Graph, uri: URIRef) -> str:
63
+ """Get qualified name (prefix:local) for a URI.
64
+
65
+ Args:
66
+ graph: RDF graph with namespace bindings
67
+ uri: URI to convert to QName
68
+
69
+ Returns:
70
+ QName string (e.g., 'ex:Animal') or full URI if no prefix found
71
+ """
72
+ try:
73
+ return graph.namespace_manager.normalizeUri(uri)
74
+ except Exception:
75
+ return str(uri)
76
+
77
+
78
+ def local_name(graph: Graph, uri: URIRef) -> str:
79
+ """Get local name only (without prefix) for a URI.
80
+
81
+ Args:
82
+ graph: RDF graph with namespace bindings
83
+ uri: URI to extract local name from
84
+
85
+ Returns:
86
+ Local name string (e.g., 'Animal' from 'ex:Animal')
87
+ """
88
+ qn = qname(graph, uri)
89
+ if ":" in qn:
90
+ return qn.split(":", 1)[1]
91
+ # Try to extract from full URI
92
+ uri_str = str(uri)
93
+ if "#" in uri_str:
94
+ return uri_str.split("#")[-1]
95
+ if "/" in uri_str:
96
+ return uri_str.split("/")[-1]
97
+ return uri_str
98
+
99
+
100
+ def plantuml_identifier(graph: Graph, uri: URIRef) -> str:
101
+ """Convert RDF URI to PlantUML identifier using dot notation.
102
+
103
+ PlantUML uses package.Class notation. This converts RDF QNames
104
+ to proper PlantUML identifiers.
105
+
106
+ Args:
107
+ graph: RDF graph with namespace bindings
108
+ uri: URI to convert
109
+
110
+ Returns:
111
+ PlantUML identifier string (e.g., 'ex.Animal')
112
+ """
113
+ qn = qname(graph, uri)
114
+ if ":" in qn:
115
+ prefix, local = qn.split(":", 1)
116
+ return f"{prefix}.{local}"
117
+ return qn
118
+
119
+
120
+ def escape_plantuml(text: str) -> str:
121
+ """Escape special characters for PlantUML.
122
+
123
+ Args:
124
+ text: Text to escape
125
+
126
+ Returns:
127
+ Escaped text safe for PlantUML
128
+ """
129
+ return text.replace('"', "'").replace("\n", " ").strip()
130
+
131
+
132
+ def safe_label(graph: Graph, uri: URIRef) -> str:
133
+ """Get a safe display label for an entity.
134
+
135
+ Uses rdfs:label if available, otherwise falls back to QName.
136
+
137
+ Args:
138
+ graph: RDF graph containing the entity
139
+ uri: URI to get label for
140
+
141
+ Returns:
142
+ Safe string for use in PlantUML
143
+ """
144
+ labels = list(graph.objects(uri, RDFS.label))
145
+ if labels:
146
+ return escape_plantuml(str(labels[0]))
147
+ return qname(graph, uri)
148
+
149
+
150
+ class ODMRenderer:
151
+ """Renders RDF ontologies as ODM-compliant PlantUML class diagrams.
152
+
153
+ This renderer follows the OMG Ontology Definition Metamodel (ODM) 1.1
154
+ specification for representing RDF and OWL constructs in UML.
155
+
156
+ Key features:
157
+ - Standard ODM stereotype names (<<owlClass>>, <<objectProperty>>, etc.)
158
+ - Properties can render as associations or classes
159
+ - Individuals use <<individual>> stereotype
160
+ - Domain/range shown with <<rdfsDomain>>/<<rdfsRange>> stereotypes
161
+
162
+ Attributes:
163
+ graph: RDF graph being rendered
164
+ entities: Dictionary of selected entities to render
165
+ style: Optional style scheme
166
+ layout: Optional layout configuration
167
+ property_style: How to render properties ('class' or 'association')
168
+ """
169
+
170
+ def __init__(
171
+ self,
172
+ graph: Graph,
173
+ entities: dict[str, set[URIRef]],
174
+ style: Optional[object] = None,
175
+ layout: Optional[object] = None,
176
+ property_style: str = "class",
177
+ ):
178
+ """Initialise ODM renderer.
179
+
180
+ Args:
181
+ graph: RDF graph containing the entities
182
+ entities: Dictionary of entity sets (classes, properties, instances)
183
+ style: Optional style scheme to apply
184
+ layout: Optional layout configuration
185
+ property_style: How to render properties - 'class' (as UML classes)
186
+ or 'association' (as UML associations)
187
+ """
188
+ self.graph = graph
189
+ self.entities = entities
190
+ self.style = style
191
+ self.layout = layout
192
+ self.property_style = property_style
193
+ self._note_counter = 0
194
+
195
+ def _get_arrow_direction(self) -> str:
196
+ """Get arrow direction hint from layout config.
197
+
198
+ Returns:
199
+ Direction string: 'u', 'd', 'l', 'r', or ''
200
+ """
201
+ if self.layout and hasattr(self.layout, 'arrow_direction'):
202
+ direction_map = {
203
+ "up": "u",
204
+ "down": "d",
205
+ "left": "l",
206
+ "right": "r",
207
+ }
208
+ return direction_map.get(self.layout.arrow_direction, "u")
209
+ return "u"
210
+
211
+ def _get_class_stereotype(self, cls: URIRef) -> str:
212
+ """Get ODM stereotype for a class entity.
213
+
214
+ Args:
215
+ cls: Class URI
216
+
217
+ Returns:
218
+ ODM stereotype string like <<owlClass>> or <<rdfsClass>>
219
+ """
220
+ types = list(self.graph.objects(cls, RDF.type))
221
+
222
+ for t in types:
223
+ type_str = str(t)
224
+ if type_str in ODM_CLASS_STEREOTYPES:
225
+ return f"<<{ODM_CLASS_STEREOTYPES[type_str]}>>"
226
+
227
+ # Default to rdfsClass
228
+ return "<<rdfsClass>>"
229
+
230
+ def _get_property_stereotype(self, prop: URIRef) -> str:
231
+ """Get ODM stereotype for a property entity.
232
+
233
+ Includes all applicable property characteristics as stereotypes.
234
+
235
+ Args:
236
+ prop: Property URI
237
+
238
+ Returns:
239
+ ODM stereotype string, possibly with multiple stereotypes
240
+ """
241
+ types = list(self.graph.objects(prop, RDF.type))
242
+ stereotypes = []
243
+
244
+ # Primary type stereotype
245
+ for t in types:
246
+ type_str = str(t)
247
+ if type_str in ODM_PROPERTY_STEREOTYPES:
248
+ stereotypes.append(ODM_PROPERTY_STEREOTYPES[type_str])
249
+
250
+ if not stereotypes:
251
+ stereotypes.append("rdfProperty")
252
+
253
+ # Check for property characteristics
254
+ characteristic_types = [
255
+ (OWL.FunctionalProperty, "functional"),
256
+ (OWL.InverseFunctionalProperty, "inverseFunctional"),
257
+ (OWL.SymmetricProperty, "symmetric"),
258
+ (OWL.TransitiveProperty, "transitive"),
259
+ (OWL.ReflexiveProperty, "reflexive"),
260
+ (OWL.IrreflexiveProperty, "irreflexive"),
261
+ (OWL.AsymmetricProperty, "asymmetric"),
262
+ ]
263
+
264
+ for prop_type, short_name in characteristic_types:
265
+ if prop_type in types and short_name not in stereotypes:
266
+ # Only add if not already primary type
267
+ if short_name not in [s.replace("Property", "") for s in stereotypes]:
268
+ stereotypes.append(short_name)
269
+
270
+ return f"<<{', '.join(stereotypes)}>>"
271
+
272
+ def _get_instance_stereotype(self, instance: URIRef) -> str:
273
+ """Get ODM stereotype for an individual.
274
+
275
+ For ODM compliance, individuals use <<individual>> stereotype,
276
+ optionally with their class types shown.
277
+
278
+ Args:
279
+ instance: Instance URI
280
+
281
+ Returns:
282
+ ODM stereotype string
283
+ """
284
+ types = list(self.graph.objects(instance, RDF.type))
285
+
286
+ # Filter out metaclass types
287
+ metaclass_uris = {
288
+ OWL.Class, RDFS.Class, OWL.ObjectProperty,
289
+ OWL.DatatypeProperty, OWL.AnnotationProperty,
290
+ RDF.Property, OWL.NamedIndividual
291
+ }
292
+
293
+ type_names = []
294
+ for t in types:
295
+ if t not in metaclass_uris:
296
+ type_names.append(qname(self.graph, t))
297
+
298
+ if type_names:
299
+ # Show individual stereotype with type information
300
+ return f"<<{ODM_INDIVIDUAL_STEREOTYPE}: {', '.join(sorted(type_names)[:3])}>>"
301
+
302
+ return f"<<{ODM_INDIVIDUAL_STEREOTYPE}>>"
303
+
304
+ def _get_colour_spec(self, entity: URIRef, is_instance: bool = False) -> str:
305
+ """Get PlantUML colour specification for an entity.
306
+
307
+ Uses the style system to look up colours based on:
308
+ - Explicit type mappings (by_type)
309
+ - Inheritance through rdfs:subClassOf hierarchy
310
+ - Namespace-based defaults (by_namespace)
311
+ - Global defaults
312
+
313
+ For instances, uses get_instance_style() which supports:
314
+ - Instance-specific type mappings
315
+ - inherit_class_text for text colour from class hierarchy
316
+ - Black fill with coloured text/border
317
+
318
+ Args:
319
+ entity: Entity URI
320
+ is_instance: Whether this is an instance (uses instance styling)
321
+
322
+ Returns:
323
+ PlantUML colour specification string or empty string
324
+ """
325
+ if not self.style:
326
+ return ""
327
+
328
+ try:
329
+ if hasattr(self.style, 'get_class_style'):
330
+ palette = self.style.get_class_style(self.graph, entity, is_instance=is_instance)
331
+ if palette and hasattr(palette, 'to_plantuml'):
332
+ colour_spec = palette.to_plantuml()
333
+ if colour_spec:
334
+ return f" {colour_spec}"
335
+ except Exception:
336
+ # If style lookup fails, continue without styling
337
+ pass
338
+
339
+ return ""
340
+
341
+ def _get_property_colour_spec(self, prop: URIRef) -> str:
342
+ """Get PlantUML colour specification for a property.
343
+
344
+ Args:
345
+ prop: Property URI
346
+
347
+ Returns:
348
+ PlantUML colour specification string
349
+ """
350
+ if self.style and hasattr(self.style, 'get_property_style'):
351
+ try:
352
+ palette = self.style.get_property_style(self.graph, prop)
353
+ if palette and hasattr(palette, 'to_plantuml'):
354
+ colour_spec = palette.to_plantuml()
355
+ if colour_spec:
356
+ return f" {colour_spec}"
357
+ except Exception:
358
+ pass
359
+
360
+ # Default grey for properties
361
+ return " #CCCCCC"
362
+
363
+ def _curie_to_plantuml_id(self, curie: str) -> str:
364
+ """Convert a CURIE (e.g., 'building:Building') to PlantUML identifier.
365
+
366
+ This is used by layout together/hints which use CURIEs in config.
367
+
368
+ Args:
369
+ curie: CURIE string like 'building:Building'
370
+
371
+ Returns:
372
+ PlantUML identifier like 'building.Building'
373
+ """
374
+ if ":" in curie:
375
+ prefix, local = curie.split(":", 1)
376
+ return f"{prefix}.{local}"
377
+ return curie
378
+
379
+ def render_class(self, cls: URIRef) -> list[str]:
380
+ """Render a class as an ODM-compliant PlantUML class.
381
+
382
+ Args:
383
+ cls: Class URI to render
384
+
385
+ Returns:
386
+ List of PlantUML lines
387
+ """
388
+ lines = []
389
+
390
+ class_id = plantuml_identifier(self.graph, cls)
391
+ stereotype = self._get_class_stereotype(cls)
392
+ colour_spec = self._get_colour_spec(cls)
393
+
394
+ # Get display name - use rdfs:label if different from QName
395
+ display_name = safe_label(self.graph, cls)
396
+ class_qname = qname(self.graph, cls)
397
+
398
+ if display_name != class_qname:
399
+ lines.append(f'class "{display_name}" as {class_id} {stereotype}{colour_spec}')
400
+ else:
401
+ lines.append(f"class {class_id} {stereotype}{colour_spec}")
402
+
403
+ return lines
404
+
405
+ def render_property_as_class(self, prop: URIRef) -> list[str]:
406
+ """Render a property as an ODM-compliant PlantUML class.
407
+
408
+ Args:
409
+ prop: Property URI to render
410
+
411
+ Returns:
412
+ List of PlantUML lines
413
+ """
414
+ lines = []
415
+
416
+ prop_id = plantuml_identifier(self.graph, prop)
417
+ stereotype = self._get_property_stereotype(prop)
418
+ colour_spec = self._get_property_colour_spec(prop)
419
+
420
+ # Get display name - use rdfs:label if different from QName
421
+ display_name = safe_label(self.graph, prop)
422
+ prop_qname = qname(self.graph, prop)
423
+
424
+ if display_name != prop_qname:
425
+ lines.append(f'class "{display_name}" as {prop_id} {stereotype}{colour_spec}')
426
+ else:
427
+ lines.append(f"class {prop_id} {stereotype}{colour_spec}")
428
+
429
+ return lines
430
+
431
+ def render_individual(self, instance: URIRef) -> list[str]:
432
+ """Render an individual as an ODM-compliant PlantUML class.
433
+
434
+ Instances are styled using the instance styling rules which typically
435
+ include black fill with text colour inherited from their class hierarchy.
436
+
437
+ Args:
438
+ instance: Instance URI to render
439
+
440
+ Returns:
441
+ List of PlantUML lines
442
+ """
443
+ lines = []
444
+
445
+ instance_id = plantuml_identifier(self.graph, instance)
446
+ stereotype = self._get_instance_stereotype(instance)
447
+ # Pass is_instance=True to get instance-specific styling
448
+ colour_spec = self._get_colour_spec(instance, is_instance=True)
449
+
450
+ # Get display name - use rdfs:label if different from QName
451
+ display_name = safe_label(self.graph, instance)
452
+ instance_qname = qname(self.graph, instance)
453
+
454
+ if display_name != instance_qname:
455
+ lines.append(f'class "{display_name}" as {instance_id} {stereotype}{colour_spec}')
456
+ else:
457
+ lines.append(f"class {instance_id} {stereotype}{colour_spec}")
458
+
459
+ return lines
460
+
461
+ def render_subclass_relationships(self) -> list[str]:
462
+ """Render rdfs:subClassOf as UML generalisations.
463
+
464
+ Returns:
465
+ List of PlantUML relationship lines
466
+ """
467
+ lines = []
468
+ direction = self._get_arrow_direction()
469
+
470
+ for cls in self.entities.get("classes", set()):
471
+ for parent in self.graph.objects(cls, RDFS.subClassOf):
472
+ if parent in self.entities.get("classes", set()):
473
+ child_id = plantuml_identifier(self.graph, cls)
474
+ parent_id = plantuml_identifier(self.graph, parent)
475
+ lines.append(
476
+ f"{child_id} -{direction}-|> {parent_id}"
477
+ )
478
+
479
+ return lines
480
+
481
+ def render_subproperty_relationships(self) -> list[str]:
482
+ """Render rdfs:subPropertyOf as UML generalisations.
483
+
484
+ Returns:
485
+ List of PlantUML relationship lines
486
+ """
487
+ lines = []
488
+ direction = self._get_arrow_direction()
489
+
490
+ all_props = (
491
+ self.entities.get("object_properties", set()) |
492
+ self.entities.get("datatype_properties", set()) |
493
+ self.entities.get("annotation_properties", set())
494
+ )
495
+
496
+ for prop in all_props:
497
+ for parent_prop in self.graph.objects(prop, RDFS.subPropertyOf):
498
+ if parent_prop in all_props:
499
+ child_id = plantuml_identifier(self.graph, prop)
500
+ parent_id = plantuml_identifier(self.graph, parent_prop)
501
+ lines.append(
502
+ f"{child_id} -{direction}-|> {parent_id}"
503
+ )
504
+
505
+ return lines
506
+
507
+ def render_type_relationships(self) -> list[str]:
508
+ """Render rdf:type relationships with <<rdfType>> stereotype.
509
+
510
+ Uses arrow colour from style config if available.
511
+
512
+ Returns:
513
+ List of PlantUML relationship lines
514
+ """
515
+ lines = []
516
+ direction = self._get_arrow_direction()
517
+
518
+ metaclass_uris = {
519
+ OWL.Class, RDFS.Class, OWL.ObjectProperty,
520
+ OWL.DatatypeProperty, OWL.AnnotationProperty
521
+ }
522
+
523
+ # Get arrow colour from style config
524
+ arrow_color = "#FF0000" # Default red
525
+ if self.style and hasattr(self.style, 'arrow_colors'):
526
+ arrow_color = self.style.arrow_colors.get_color("type")
527
+
528
+ for instance in self.entities.get("instances", set()):
529
+ instance_id = plantuml_identifier(self.graph, instance)
530
+
531
+ for cls in self.graph.objects(instance, RDF.type):
532
+ if cls in metaclass_uris:
533
+ continue
534
+
535
+ if cls in self.entities.get("classes", set()):
536
+ class_id = plantuml_identifier(self.graph, cls)
537
+ lines.append(
538
+ f"{instance_id} -{direction}-[{arrow_color}]-> {class_id} : <<rdfType>>"
539
+ )
540
+
541
+ return lines
542
+
543
+ def render_domain_range_relationships(self) -> list[str]:
544
+ """Render domain and range with ODM stereotypes.
545
+
546
+ Returns:
547
+ List of PlantUML relationship lines
548
+ """
549
+ lines = []
550
+ direction = self._get_arrow_direction()
551
+
552
+ all_props = (
553
+ self.entities.get("object_properties", set()) |
554
+ self.entities.get("datatype_properties", set()) |
555
+ self.entities.get("annotation_properties", set())
556
+ )
557
+
558
+ for prop in all_props:
559
+ prop_id = plantuml_identifier(self.graph, prop)
560
+
561
+ # Render domain relationships
562
+ for domain in self.graph.objects(prop, RDFS.domain):
563
+ if domain in self.entities.get("classes", set()):
564
+ domain_id = plantuml_identifier(self.graph, domain)
565
+ lines.append(
566
+ f"{prop_id} -{direction}-> {domain_id} : <<rdfsDomain>>"
567
+ )
568
+
569
+ # Render range relationships
570
+ for range_cls in self.graph.objects(prop, RDFS.range):
571
+ if range_cls in self.entities.get("classes", set()):
572
+ range_id = plantuml_identifier(self.graph, range_cls)
573
+ lines.append(
574
+ f"{prop_id} -{direction}-> {range_id} : <<rdfsRange>>"
575
+ )
576
+
577
+ return lines
578
+
579
+ def render_instance_properties(self) -> list[str]:
580
+ """Render object property relationships between individuals.
581
+
582
+ Returns:
583
+ List of PlantUML relationship lines
584
+ """
585
+ lines = []
586
+ direction = self._get_arrow_direction()
587
+
588
+ obj_props = self.entities.get("object_properties", set())
589
+ instances = self.entities.get("instances", set())
590
+
591
+ for subj in instances:
592
+ for prop in obj_props:
593
+ for obj in self.graph.objects(subj, prop):
594
+ if obj in instances:
595
+ subj_id = plantuml_identifier(self.graph, subj)
596
+ obj_id = plantuml_identifier(self.graph, obj)
597
+ prop_qn = qname(self.graph, prop)
598
+ lines.append(
599
+ f"{subj_id} -{direction}-> {obj_id} : {prop_qn}"
600
+ )
601
+
602
+ return lines
603
+
604
+ def render_together_blocks(self) -> list[str]:
605
+ """Render PlantUML 'together' blocks from layout config.
606
+
607
+ Together blocks group classes so they are placed adjacent
608
+ in the diagram.
609
+
610
+ Returns:
611
+ List of PlantUML lines for together blocks
612
+ """
613
+ if not self.layout or not hasattr(self.layout, 'get_together_blocks'):
614
+ return []
615
+
616
+ return self.layout.get_together_blocks(
617
+ id_resolver=self._curie_to_plantuml_id
618
+ )
619
+
620
+ def render_layout_hints(self) -> list[str]:
621
+ """Render hidden links from layout hints.
622
+
623
+ Hidden links influence PlantUML's layout engine without
624
+ being visible in the diagram.
625
+
626
+ Returns:
627
+ List of PlantUML hidden link lines
628
+ """
629
+ if not self.layout or not hasattr(self.layout, 'get_hidden_links'):
630
+ return []
631
+
632
+ return self.layout.get_hidden_links(
633
+ id_resolver=self._curie_to_plantuml_id
634
+ )
635
+
636
+ def render_datatype_property_notes(self) -> list[str]:
637
+ """Render datatype property values as notes.
638
+
639
+ Returns:
640
+ List of PlantUML lines for notes
641
+ """
642
+ lines = []
643
+ direction = self._get_arrow_direction()
644
+
645
+ datatype_props = self.entities.get("datatype_properties", set())
646
+ instances = self.entities.get("instances", set())
647
+
648
+ for instance in instances:
649
+ instance_id = plantuml_identifier(self.graph, instance)
650
+
651
+ for prop in datatype_props:
652
+ for value in self.graph.objects(instance, prop):
653
+ if isinstance(value, Literal):
654
+ self._note_counter += 1
655
+ note_id = f"N{self._note_counter}"
656
+ value_str = escape_plantuml(str(value))
657
+ prop_local = local_name(self.graph, prop)
658
+
659
+ lines.append(f'note "{prop_local}: {value_str}" as {note_id}')
660
+ lines.append(f"{instance_id} .{direction}. {note_id}")
661
+
662
+ return lines
663
+
664
+ def render(self) -> str:
665
+ """Render complete ODM-compliant PlantUML diagram.
666
+
667
+ Returns:
668
+ Complete PlantUML diagram as string
669
+ """
670
+ lines = ["@startuml", ""]
671
+
672
+ # Add title comment
673
+ lines.append("' ODM RDF Profile compliant diagram")
674
+ lines.append("' Generated by rdf-construct")
675
+ lines.append("")
676
+
677
+ # Add layout directives
678
+ if self.layout and hasattr(self.layout, 'get_plantuml_directives'):
679
+ directives = self.layout.get_plantuml_directives()
680
+ lines.extend(directives)
681
+ lines.append("")
682
+
683
+ # Add style directives
684
+ if self.style and hasattr(self.style, 'get_plantuml_directives'):
685
+ directives = self.style.get_plantuml_directives()
686
+ if directives:
687
+ lines.extend(directives)
688
+ lines.append("")
689
+
690
+ # Add together blocks (grouping hints - must come before class definitions)
691
+ together_blocks = self.render_together_blocks()
692
+ if together_blocks:
693
+ lines.append("' Layout groupings")
694
+ lines.extend(together_blocks)
695
+
696
+ # Render classes
697
+ classes = sorted(
698
+ self.entities.get("classes", set()),
699
+ key=lambda x: qname(self.graph, x)
700
+ )
701
+ for cls in classes:
702
+ lines.extend(self.render_class(cls))
703
+
704
+ if classes:
705
+ lines.append("")
706
+
707
+ # Render properties (as classes in this mode)
708
+ all_props = sorted(
709
+ self.entities.get("object_properties", set()) |
710
+ self.entities.get("datatype_properties", set()) |
711
+ self.entities.get("annotation_properties", set()),
712
+ key=lambda x: qname(self.graph, x)
713
+ )
714
+ for prop in all_props:
715
+ lines.extend(self.render_property_as_class(prop))
716
+
717
+ if all_props:
718
+ lines.append("")
719
+
720
+ # Render individuals
721
+ instances = sorted(
722
+ self.entities.get("instances", set()),
723
+ key=lambda x: qname(self.graph, x)
724
+ )
725
+ for instance in instances:
726
+ lines.extend(self.render_individual(instance))
727
+
728
+ if instances:
729
+ lines.append("")
730
+
731
+ # Render relationships
732
+ lines.extend(self.render_subclass_relationships())
733
+ lines.extend(self.render_subproperty_relationships())
734
+ lines.extend(self.render_type_relationships())
735
+ lines.extend(self.render_domain_range_relationships())
736
+ lines.extend(self.render_instance_properties())
737
+
738
+ # Add hidden layout hints (after visible relationships)
739
+ layout_hints = self.render_layout_hints()
740
+ if layout_hints:
741
+ lines.append("")
742
+ lines.append("' Hidden layout hints")
743
+ lines.extend(layout_hints)
744
+
745
+ if any([classes, all_props, instances]):
746
+ lines.append("")
747
+
748
+ # Render datatype property values as notes
749
+ lines.extend(self.render_datatype_property_notes())
750
+
751
+ lines.append("")
752
+ lines.append("@enduml")
753
+
754
+ return "\n".join(lines)
755
+
756
+
757
+ def render_odm_plantuml(
758
+ graph: Graph,
759
+ entities: dict[str, set[URIRef]],
760
+ output_path: Optional[Path] = None,
761
+ style: Optional[object] = None,
762
+ layout: Optional[object] = None,
763
+ property_style: str = "class",
764
+ ) -> str:
765
+ """Render entities as ODM-compliant PlantUML class diagram.
766
+
767
+ Args:
768
+ graph: RDF graph containing entities
769
+ entities: Dictionary of entity sets to render
770
+ output_path: Optional path to write PlantUML file
771
+ style: Optional style scheme
772
+ layout: Optional layout configuration
773
+ property_style: How to render properties ('class' or 'association')
774
+
775
+ Returns:
776
+ PlantUML diagram text
777
+ """
778
+ renderer = ODMRenderer(
779
+ graph, entities,
780
+ style=style,
781
+ layout=layout,
782
+ property_style=property_style
783
+ )
784
+ diagram = renderer.render()
785
+
786
+ if output_path:
787
+ output_path.write_text(diagram, encoding="utf-8")
788
+
789
+ return diagram