rdf-construct 0.3.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 (110) hide show
  1. rdf_construct/__init__.py +12 -0
  2. rdf_construct/__main__.py +0 -0
  3. rdf_construct/cli.py +3429 -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/localise/__init__.py +114 -0
  51. rdf_construct/localise/config.py +508 -0
  52. rdf_construct/localise/extractor.py +427 -0
  53. rdf_construct/localise/formatters/__init__.py +36 -0
  54. rdf_construct/localise/formatters/markdown.py +229 -0
  55. rdf_construct/localise/formatters/text.py +224 -0
  56. rdf_construct/localise/merger.py +346 -0
  57. rdf_construct/localise/reporter.py +356 -0
  58. rdf_construct/main.py +6 -0
  59. rdf_construct/merge/__init__.py +165 -0
  60. rdf_construct/merge/config.py +354 -0
  61. rdf_construct/merge/conflicts.py +281 -0
  62. rdf_construct/merge/formatters.py +426 -0
  63. rdf_construct/merge/merger.py +425 -0
  64. rdf_construct/merge/migrator.py +339 -0
  65. rdf_construct/merge/rules.py +377 -0
  66. rdf_construct/merge/splitter.py +1102 -0
  67. rdf_construct/puml2rdf/__init__.py +103 -0
  68. rdf_construct/puml2rdf/config.py +230 -0
  69. rdf_construct/puml2rdf/converter.py +420 -0
  70. rdf_construct/puml2rdf/merger.py +200 -0
  71. rdf_construct/puml2rdf/model.py +202 -0
  72. rdf_construct/puml2rdf/parser.py +565 -0
  73. rdf_construct/puml2rdf/validators.py +451 -0
  74. rdf_construct/refactor/__init__.py +72 -0
  75. rdf_construct/refactor/config.py +362 -0
  76. rdf_construct/refactor/deprecator.py +328 -0
  77. rdf_construct/refactor/formatters/__init__.py +8 -0
  78. rdf_construct/refactor/formatters/text.py +311 -0
  79. rdf_construct/refactor/renamer.py +294 -0
  80. rdf_construct/shacl/__init__.py +56 -0
  81. rdf_construct/shacl/config.py +166 -0
  82. rdf_construct/shacl/converters.py +520 -0
  83. rdf_construct/shacl/generator.py +364 -0
  84. rdf_construct/shacl/namespaces.py +93 -0
  85. rdf_construct/stats/__init__.py +29 -0
  86. rdf_construct/stats/collector.py +178 -0
  87. rdf_construct/stats/comparator.py +298 -0
  88. rdf_construct/stats/formatters/__init__.py +83 -0
  89. rdf_construct/stats/formatters/json.py +38 -0
  90. rdf_construct/stats/formatters/markdown.py +153 -0
  91. rdf_construct/stats/formatters/text.py +186 -0
  92. rdf_construct/stats/metrics/__init__.py +26 -0
  93. rdf_construct/stats/metrics/basic.py +147 -0
  94. rdf_construct/stats/metrics/complexity.py +137 -0
  95. rdf_construct/stats/metrics/connectivity.py +130 -0
  96. rdf_construct/stats/metrics/documentation.py +128 -0
  97. rdf_construct/stats/metrics/hierarchy.py +207 -0
  98. rdf_construct/stats/metrics/properties.py +88 -0
  99. rdf_construct/uml/__init__.py +22 -0
  100. rdf_construct/uml/context.py +194 -0
  101. rdf_construct/uml/mapper.py +371 -0
  102. rdf_construct/uml/odm_renderer.py +789 -0
  103. rdf_construct/uml/renderer.py +684 -0
  104. rdf_construct/uml/uml_layout.py +393 -0
  105. rdf_construct/uml/uml_style.py +613 -0
  106. rdf_construct-0.3.0.dist-info/METADATA +496 -0
  107. rdf_construct-0.3.0.dist-info/RECORD +110 -0
  108. rdf_construct-0.3.0.dist-info/WHEEL +4 -0
  109. rdf_construct-0.3.0.dist-info/entry_points.txt +3 -0
  110. rdf_construct-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,684 @@
1
+ """PlantUML renderer with everything-as-class approach.
2
+
3
+ Renders RDF entities as PlantUML class diagrams where:
4
+ - Classes, properties, and instances all render as UML classes
5
+ - Stereotypes indicate entity type
6
+ - Relationships use appropriately styled arrows
7
+ - Datatype property values render as notes with dotted connections
8
+ """
9
+
10
+ from pathlib import Path
11
+ from typing import Optional
12
+ from collections import defaultdict
13
+
14
+ from rdflib import Graph, URIRef, RDF, RDFS, Literal
15
+ from rdflib.namespace import OWL, XSD
16
+
17
+
18
+ def qname(graph: Graph, uri: URIRef) -> str:
19
+ """Get qualified name (prefix:local) for a URI.
20
+
21
+ Args:
22
+ graph: RDF graph with namespace bindings
23
+ uri: URI to convert to QName
24
+
25
+ Returns:
26
+ QName string (e.g., 'ex:Animal') or full URI if no prefix found
27
+ """
28
+ try:
29
+ return graph.namespace_manager.normalizeUri(uri)
30
+ except Exception:
31
+ return str(uri)
32
+
33
+
34
+ def safe_label(graph: Graph, uri: URIRef, camelcase: bool = False) -> str:
35
+ """Get a safe label for display in PlantUML.
36
+
37
+ Uses rdfs:label if available, otherwise falls back to QName.
38
+ Strips quotes and handles multi-line labels.
39
+
40
+ Args:
41
+ graph: RDF graph containing the entity
42
+ uri: URI to get label for
43
+ camelcase: Whether to convert spaces to camelCase (for property names)
44
+
45
+ Returns:
46
+ Safe string for use in PlantUML
47
+ """
48
+ # Try to get rdfs:label
49
+ labels = list(graph.objects(uri, RDFS.label))
50
+ if labels:
51
+ label = str(labels[0])
52
+ # Clean up label for PlantUML
53
+ label = label.replace('"', "'").replace("\n", " ").strip()
54
+
55
+ # Convert spaces to camelCase only if requested (for property names)
56
+ if camelcase:
57
+ words = label.split()
58
+ if len(words) > 1:
59
+ # Ensure first word is lowercase for camelCase properties
60
+ label = words[0].lower() + "".join(word.capitalize() for word in words[1:])
61
+
62
+ return label
63
+
64
+ # Fallback to QName
65
+ qn = qname(graph, uri)
66
+
67
+ # For camelCase, ensure first character is lowercase
68
+ if camelcase and qn:
69
+ # Only lowercase the local part after the namespace prefix
70
+ if ":" in qn:
71
+ prefix, local = qn.split(":", 1)
72
+ if local:
73
+ local = local[0].lower() + local[1:]
74
+ qn = f"{prefix}:{local}"
75
+ else:
76
+ qn = qn[0].lower() + qn[1:]
77
+
78
+ return qn
79
+
80
+
81
+ def escape_plantuml(text: str) -> str:
82
+ """Escape special characters for PlantUML.
83
+
84
+ Args:
85
+ text: Text to escape
86
+
87
+ Returns:
88
+ Escaped text safe for PlantUML
89
+ """
90
+ # PlantUML is generally forgiving, but we'll handle basic escaping
91
+ return text.replace('"', "'")
92
+
93
+
94
+ def plantuml_identifier(graph: Graph, uri: URIRef) -> str:
95
+ """Convert RDF URI to PlantUML identifier using dot notation.
96
+
97
+ PlantUML uses package.Class notation, not prefix:Class notation.
98
+ This function converts RDF QNames to proper PlantUML identifiers.
99
+
100
+ Examples:
101
+ "building:Building" → "building.Building"
102
+ "ies:Entity" → "ies.Entity"
103
+
104
+ Args:
105
+ graph: RDF graph with namespace bindings
106
+ uri: URI to convert to PlantUML identifier
107
+
108
+ Returns:
109
+ PlantUML identifier string with dot notation
110
+ """
111
+ qn = qname(graph, uri)
112
+
113
+ # Convert prefix:local to prefix.local for PlantUML
114
+ if ":" in qn:
115
+ prefix, local = qn.split(":", 1)
116
+ return f"{prefix}.{local}"
117
+
118
+ # No namespace prefix - return as is
119
+ return qn
120
+
121
+
122
+ class PlantUMLRenderer:
123
+ """Renders RDF entities as styled PlantUML class diagrams.
124
+
125
+ Everything renders as a UML class:
126
+ - OWL/RDFS classes with stereotypes like <<owl:Class>>
127
+ - Properties as classes with stereotypes like <<owl:ObjectProperty>>
128
+ - Instances as classes with stereotypes showing their types
129
+
130
+ Relationships:
131
+ - rdfs:subClassOf and rdfs:subPropertyOf: --|> with labels
132
+ - rdf:type: red --> with <<rdf:type>> [#red] label
133
+ - domain/range: black --> with <<rdfs:domain>>/<<rdfs:range>> labels
134
+ - Object properties between instances: black --> with property name
135
+ - Datatype properties: dotted line to note .. with property name
136
+
137
+ Attributes:
138
+ graph: RDF graph being rendered
139
+ entities: Dictionary of selected entities to render
140
+ style: Style scheme to apply (optional)
141
+ layout: Layout configuration to apply (optional)
142
+ """
143
+
144
+ def __init__(
145
+ self,
146
+ graph: Graph,
147
+ entities: dict[str, set[URIRef]],
148
+ style: Optional = None, # Will import StyleScheme type hint later
149
+ layout: Optional = None, # Will import LayoutConfig type hint later
150
+ ):
151
+ """Initialise renderer with graph, entities, and optional styling.
152
+
153
+ Args:
154
+ graph: RDF graph containing the entities
155
+ entities: Dictionary of entity sets (classes, properties, instances)
156
+ style: Optional style scheme to apply
157
+ layout: Optional layout configuration to apply
158
+ """
159
+ self.graph = graph
160
+ self.entities = entities
161
+ self.style = style
162
+ self.layout = layout
163
+ self._note_counter = 0 # For generating unique note IDs
164
+
165
+ def _get_arrow_direction(self) -> str:
166
+ """Get arrow direction hint from layout config.
167
+
168
+ Returns:
169
+ Direction string: 'u' (up), 'd' (down), 'l' (left), 'r' (right), or ''
170
+ """
171
+ if self.layout and self.layout.arrow_direction:
172
+ direction_map = {
173
+ "up": "u",
174
+ "down": "d",
175
+ "left": "l",
176
+ "right": "r",
177
+ }
178
+ return direction_map.get(self.layout.arrow_direction, "u")
179
+ return "u" # Default: up (for top-to-bottom layout)
180
+
181
+ def _get_class_stereotype(self, cls: URIRef) -> str:
182
+ """Get stereotype for a class entity.
183
+
184
+ Checks rdf:type to determine if it's owl:Class, rdfs:Class, etc.
185
+
186
+ Args:
187
+ cls: Class URI
188
+
189
+ Returns:
190
+ Stereotype string like <<owl:Class>> or <<rdfs:Class>>
191
+ """
192
+ # Check if it's typed as owl:Class or rdfs:Class
193
+ types = list(self.graph.objects(cls, RDF.type))
194
+
195
+ for t in types:
196
+ type_qn = qname(self.graph, t)
197
+ if type_qn in ("owl:Class", "rdfs:Class", "owl:Restriction"):
198
+ return f"<< (C, #FFFFFF) {type_qn} >>"
199
+
200
+ # Default to rdfs:Class if not explicitly typed
201
+ return "<< (C, #FFFFFF) rdfs:Class >>"
202
+
203
+ def _get_property_stereotype(self, prop: URIRef) -> str:
204
+ """Get stereotype for a property entity.
205
+
206
+ Args:
207
+ prop: Property URI
208
+
209
+ Returns:
210
+ Stereotype string like <<owl:ObjectProperty>>
211
+ """
212
+ types = list(self.graph.objects(prop, RDF.type))
213
+
214
+ for t in types:
215
+ type_qn = qname(self.graph, t)
216
+ if type_qn in (
217
+ "owl:ObjectProperty",
218
+ "owl:DatatypeProperty",
219
+ "owl:AnnotationProperty",
220
+ "rdf:Property",
221
+ ):
222
+ if type_qn == "owl:ObjectProperty":
223
+ type_symbol = "O"
224
+ elif type_qn == "owl:DatatypeProperty":
225
+ type_symbol = "D"
226
+ elif type_qn == "owl:AnnotationProperty":
227
+ type_symbol = "A"
228
+ else:
229
+ type_symbol = "P"
230
+ return f"<< ({type_symbol}, #FFFFFF) {type_qn} >>"
231
+
232
+ # Default
233
+ return "<< (P, #FFFFFF) rdf:Property >>"
234
+
235
+ def _get_instance_stereotype(self, instance: URIRef) -> str:
236
+ """Get stereotype for an instance showing all its types.
237
+
238
+ For instances with multiple types, create comma-separated stereotype.
239
+ Example: <<ies:Entity>> or <<building:Structure, ies:Asset>>
240
+
241
+ Args:
242
+ instance: Instance URI
243
+
244
+ Returns:
245
+ Stereotype string with all types
246
+ """
247
+ types = list(self.graph.objects(instance, RDF.type))
248
+
249
+ # Filter out property/class types (instances shouldn't have these)
250
+ type_qnames = []
251
+ for t in types:
252
+ type_qn = qname(self.graph, t)
253
+ # Skip metaclass types
254
+ if type_qn not in ("owl:Class", "rdfs:Class", "owl:ObjectProperty",
255
+ "owl:DatatypeProperty", "owl:AnnotationProperty"):
256
+ type_qnames.append(type_qn)
257
+
258
+ if type_qnames:
259
+ return f"<< (I, #FFFFFF) {', '.join(type_qnames)} >>"
260
+
261
+ # Fallback if no suitable types found
262
+ return " (I, #FFFFFF) <<owl:NamedIndividual>>"
263
+
264
+ def render_class(self, cls: URIRef) -> list[str]:
265
+ """Render a RDF concept as PlantUML class.
266
+
267
+ Classes render without attributes (datatype properties become
268
+ dotted-line relationships instead).
269
+
270
+ Args:
271
+ cls: Class URI to render
272
+
273
+ Returns:
274
+ List of PlantUML lines for this class
275
+ """
276
+ lines = []
277
+
278
+ class_name = plantuml_identifier(self.graph, cls)
279
+ stereotype = self._get_class_stereotype(cls)
280
+
281
+ # Get colour styling if configured
282
+ color_spec = ""
283
+ if self.style:
284
+ # Use style system if available
285
+ palette = self.style.get_class_style(self.graph, cls, is_instance=False)
286
+ if palette and hasattr(palette, 'to_plantuml'):
287
+ color_spec = f" {palette.to_plantuml()}"
288
+
289
+ # Render as empty class with stereotype
290
+ lines.append(f"class {class_name} {stereotype}{color_spec}")
291
+
292
+ return lines
293
+
294
+ def render_property(self, prop: URIRef) -> list[str]:
295
+ """Render a property as a class.
296
+
297
+ Properties render as gray classes with appropriate stereotypes.
298
+
299
+ Args:
300
+ prop: Property URI to render
301
+
302
+ Returns:
303
+ List of PlantUML lines for this property
304
+ """
305
+ lines = []
306
+
307
+ prop_name = plantuml_identifier(self.graph, prop)
308
+ stereotype = self._get_property_stereotype(prop)
309
+
310
+ # Properties are typically gray
311
+ # Use style system if available, otherwise default gray
312
+ color_spec = " #CCCCCC"
313
+ if self.style:
314
+ palette = self.style.get_property_style(self.graph, prop)
315
+ if palette and hasattr(palette, 'to_plantuml'):
316
+ color_spec = f" {palette.to_plantuml()}"
317
+
318
+ lines.append(f"class {prop_name} {stereotype}{color_spec}")
319
+
320
+ return lines
321
+
322
+ def render_instance(self, instance: URIRef) -> list[str]:
323
+ """Render an instance as a class with type stereotype.
324
+
325
+ Args:
326
+ instance: Instance URI to render
327
+
328
+ Returns:
329
+ List of PlantUML lines for this instance
330
+ """
331
+ lines = []
332
+
333
+ instance_name = plantuml_identifier(self.graph, instance)
334
+ instance_label = safe_label(self.graph, instance, camelcase=False)
335
+ stereotype = self._get_instance_stereotype(instance)
336
+
337
+ # Get colour styling for instances
338
+ color_spec = ""
339
+ if self.style:
340
+ palette = self.style.get_class_style(self.graph, instance, is_instance=True)
341
+ if palette and hasattr(palette, 'to_plantuml'):
342
+ color_spec = f" {palette.to_plantuml()}"
343
+
344
+ # Render as class with stereotype and optional custom label
345
+ if instance_label != qname(self.graph, instance):
346
+ lines.append(f'class "{instance_label}" as {instance_name} {stereotype}{color_spec}')
347
+ else:
348
+ lines.append(f"class {instance_name} {stereotype}{color_spec}")
349
+
350
+ return lines
351
+
352
+ def render_subclass_relationships(self) -> list[str]:
353
+ """Render rdfs:subClassOf relationships with labels.
354
+
355
+ Uses layout-configured arrow direction if available.
356
+
357
+ Returns:
358
+ List of PlantUML relationship lines
359
+ """
360
+ lines = []
361
+ direction = self._get_arrow_direction()
362
+
363
+ for cls in self.entities.get("classes", set()):
364
+ for parent in self.graph.objects(cls, RDFS.subClassOf):
365
+ if parent in self.entities.get("classes", set()):
366
+ child_name = plantuml_identifier(self.graph, cls)
367
+ parent_name = plantuml_identifier(self.graph, parent)
368
+
369
+ lines.append(
370
+ f"{child_name} -{direction}-|> {parent_name} : <<rdfs:subClassOf>>"
371
+ )
372
+
373
+ return lines
374
+
375
+ def render_subproperty_relationships(self) -> list[str]:
376
+ """Render rdfs:subPropertyOf relationships with labels.
377
+
378
+ Returns:
379
+ List of PlantUML relationship lines
380
+ """
381
+ lines = []
382
+ direction = self._get_arrow_direction()
383
+
384
+ # Collect all properties
385
+ all_props = (
386
+ self.entities.get("object_properties", set()) |
387
+ self.entities.get("datatype_properties", set()) |
388
+ self.entities.get("annotation_properties", set())
389
+ )
390
+
391
+ for prop in all_props:
392
+ for parent_prop in self.graph.objects(prop, RDFS.subPropertyOf):
393
+ if parent_prop in all_props:
394
+ child_name = plantuml_identifier(self.graph, prop)
395
+ parent_name = plantuml_identifier(self.graph, parent_prop)
396
+
397
+ lines.append(
398
+ f"{child_name} -{direction}-|> {parent_name} : <<rdfs:subPropertyOf>>"
399
+ )
400
+
401
+ return lines
402
+
403
+ def render_type_relationships(self) -> list[str]:
404
+ """Render rdf:type relationships as red arrows.
405
+
406
+ Red arrows from instances to their type classes.
407
+
408
+ Returns:
409
+ List of PlantUML relationship lines
410
+ """
411
+ lines = []
412
+ direction = self._get_arrow_direction()
413
+
414
+ for instance in self.entities.get("instances", set()):
415
+ instance_name = plantuml_identifier(self.graph, instance)
416
+
417
+ for cls in self.graph.objects(instance, RDF.type):
418
+ # Skip metaclass types
419
+ type_qn = qname(self.graph, cls)
420
+ if type_qn in ("owl:Class", "rdfs:Class", "owl:ObjectProperty",
421
+ "owl:DatatypeProperty", "owl:AnnotationProperty"):
422
+ continue
423
+
424
+ if cls in self.entities.get("classes", set()):
425
+ class_name = plantuml_identifier(self.graph, cls)
426
+
427
+ # Get arrow color from style
428
+ arrow_color = "#FF0000" # Default red
429
+ if self.style and hasattr(self.style, 'arrow_colors'):
430
+ arrow_color = self.style.arrow_colors.get_color("type")
431
+
432
+ lines.append(f"{instance_name} -{direction}[{arrow_color}]-> {class_name} : <<rdf:type>>")
433
+
434
+ return lines
435
+
436
+ def render_property_domain_range(self) -> list[str]:
437
+ """Render domain and range relationships from property classes.
438
+
439
+ Black arrows from property to domain/range classes.
440
+
441
+ Returns:
442
+ List of PlantUML relationship lines
443
+ """
444
+ lines = []
445
+ direction = self._get_arrow_direction()
446
+
447
+ # Collect all properties
448
+ all_props = (
449
+ self.entities.get("object_properties", set()) |
450
+ self.entities.get("datatype_properties", set()) |
451
+ self.entities.get("annotation_properties", set())
452
+ )
453
+
454
+ for prop in all_props:
455
+ prop_name = plantuml_identifier(self.graph, prop)
456
+
457
+ # Render domain relationships
458
+ for domain in self.graph.objects(prop, RDFS.domain):
459
+ if domain in self.entities.get("classes", set()):
460
+ domain_name = plantuml_identifier(self.graph, domain)
461
+ lines.append(
462
+ f"{prop_name} -{direction}-> {domain_name} : <<rdfs:domain>>"
463
+ )
464
+
465
+ # Render range relationships
466
+ for range_cls in self.graph.objects(prop, RDFS.range):
467
+ # Check if range is a class (for object properties)
468
+ if range_cls in self.entities.get("classes", set()):
469
+ range_name = plantuml_identifier(self.graph, range_cls)
470
+ lines.append(
471
+ f"{prop_name} -{direction}-> {range_name} : <<rdfs:range>>"
472
+ )
473
+ # For datatype properties, range might be XSD type - skip those
474
+
475
+ return lines
476
+
477
+ def render_instance_object_properties(self) -> list[str]:
478
+ """Render object property relationships between instances.
479
+
480
+ Black arrows labeled with property name (camelCase).
481
+
482
+ Returns:
483
+ List of PlantUML relationship lines
484
+ """
485
+ lines = []
486
+ direction = self._get_arrow_direction()
487
+
488
+ obj_props = self.entities.get("object_properties", set())
489
+ instances = self.entities.get("instances", set())
490
+
491
+ for subj in instances:
492
+ for prop in obj_props:
493
+ for obj in self.graph.objects(subj, prop):
494
+ if obj in instances:
495
+ subj_name = plantuml_identifier(self.graph, subj)
496
+ obj_name = plantuml_identifier(self.graph, obj)
497
+ prop_qname = qname(self.graph, prop)
498
+
499
+ # Ensure property name is camelCase
500
+ prop_label = safe_label(self.graph, prop, camelcase=True)
501
+
502
+ lines.append(
503
+ f"{subj_name} -{direction}-> {obj_name} : <<{prop_qname}>>"
504
+ )
505
+
506
+ return lines
507
+
508
+ def render_instance_datatype_properties(self) -> list[str]:
509
+ """Render datatype property values as notes with dotted connections.
510
+
511
+ Creates a note for each literal value and connects with dotted line.
512
+
513
+ Returns:
514
+ List of PlantUML lines for notes and connections
515
+ """
516
+ lines = []
517
+ direction = self._get_arrow_direction()
518
+
519
+ datatype_props = self.entities.get("datatype_properties", set())
520
+ instances = self.entities.get("instances", set())
521
+
522
+ for instance in instances:
523
+ instance_name = plantuml_identifier(self.graph, instance)
524
+
525
+ for prop in datatype_props:
526
+ for value in self.graph.objects(instance, prop):
527
+ if isinstance(value, Literal):
528
+ # Create unique note ID
529
+ self._note_counter += 1
530
+ note_id = f"N{self._note_counter}"
531
+
532
+ # Escape literal value for PlantUML
533
+ value_str = escape_plantuml(str(value))
534
+
535
+ # Create note
536
+ lines.append(f'note "{value_str}" as {note_id}')
537
+
538
+ # Connect with dotted line
539
+ prop_qname = qname(self.graph, prop)
540
+ lines.append(
541
+ f"{instance_name} .{direction}. {note_id} : <<{prop_qname}>>"
542
+ )
543
+
544
+ return lines
545
+
546
+ def render_class_datatype_properties(self) -> list[str]:
547
+ """Render datatype properties on classes as dotted lines to notes.
548
+
549
+ For classes with domain restrictions, show the range as a note.
550
+
551
+ Returns:
552
+ List of PlantUML lines for notes and connections
553
+ """
554
+ lines = []
555
+ direction = self._get_arrow_direction()
556
+
557
+ datatype_props = self.entities.get("datatype_properties", set())
558
+ classes = self.entities.get("classes", set())
559
+
560
+ for cls in classes:
561
+ cls_name = plantuml_identifier(self.graph, cls)
562
+
563
+ for prop in datatype_props:
564
+ # Check if this property has this class as domain
565
+ domains = list(self.graph.objects(prop, RDFS.domain))
566
+ if cls not in domains:
567
+ continue
568
+
569
+ # Get range to show type
570
+ ranges = list(self.graph.objects(prop, RDFS.range))
571
+ if ranges:
572
+ # Create note showing the property and its type
573
+ self._note_counter += 1
574
+ note_id = f"N{self._note_counter}"
575
+
576
+ range_type = qname(self.graph, ranges[0])
577
+ # Simplify XSD types
578
+ if range_type.startswith("xsd:"):
579
+ range_type = range_type[4:]
580
+
581
+ prop_label = safe_label(self.graph, prop, camelcase=True)
582
+
583
+ lines.append(f'note "{prop_label}: {range_type}" as {note_id}')
584
+
585
+ prop_qname = qname(self.graph, prop)
586
+ lines.append(
587
+ f"{cls_name} .{direction}. {note_id} : <<{prop_qname}>>"
588
+ )
589
+
590
+ return lines
591
+
592
+ def render(self) -> str:
593
+ """Render complete PlantUML diagram.
594
+
595
+ Returns:
596
+ Complete PlantUML diagram as string
597
+ """
598
+ lines = ["@startuml", ""]
599
+
600
+ # Add layout directives
601
+ if self.layout:
602
+ directives = getattr(self.layout, 'get_plantuml_directives', lambda: [])()
603
+ lines.extend(directives)
604
+ lines.append("")
605
+
606
+ # Add style directives
607
+ if self.style:
608
+ directives = getattr(self.style, 'get_plantuml_directives', lambda: [])()
609
+ if directives:
610
+ lines.extend(directives)
611
+ lines.append("")
612
+
613
+ # Render all classes
614
+ for cls in sorted(self.entities.get("classes", set()), key=lambda x: qname(self.graph, x)):
615
+ lines.extend(self.render_class(cls))
616
+
617
+ if self.entities.get("classes"):
618
+ lines.append("")
619
+
620
+ # Render all properties as classes
621
+ all_props = (
622
+ self.entities.get("object_properties", set()) |
623
+ self.entities.get("datatype_properties", set()) |
624
+ self.entities.get("annotation_properties", set())
625
+ )
626
+ for prop in sorted(all_props, key=lambda x: qname(self.graph, x)):
627
+ lines.extend(self.render_property(prop))
628
+
629
+ if all_props:
630
+ lines.append("")
631
+
632
+ # Render all instances as classes
633
+ for instance in sorted(self.entities.get("instances", set()), key=lambda x: qname(self.graph, x)):
634
+ lines.extend(self.render_instance(instance))
635
+
636
+ if self.entities.get("instances"):
637
+ lines.append("")
638
+
639
+ # Render relationships
640
+ lines.extend(self.render_subclass_relationships())
641
+ lines.extend(self.render_subproperty_relationships())
642
+ lines.extend(self.render_type_relationships())
643
+ lines.extend(self.render_property_domain_range())
644
+ lines.extend(self.render_instance_object_properties())
645
+
646
+ if any([self.entities.get("classes"), all_props, self.entities.get("instances")]):
647
+ lines.append("")
648
+
649
+ # Render datatype properties as notes
650
+ lines.extend(self.render_instance_datatype_properties())
651
+ lines.extend(self.render_class_datatype_properties())
652
+
653
+ lines.append("")
654
+ lines.append("@enduml")
655
+
656
+ return "\n".join(lines)
657
+
658
+
659
+ def render_plantuml(
660
+ graph: Graph,
661
+ entities: dict[str, set[URIRef]],
662
+ output_path: Optional[Path] = None,
663
+ style: Optional = None,
664
+ layout: Optional = None,
665
+ ) -> str:
666
+ """Render entities from RDF graph as PlantUML class diagram.
667
+
668
+ Args:
669
+ graph: RDF graph containing entities
670
+ entities: Dictionary of entity sets to render
671
+ output_path: Optional path to write PlantUML file
672
+ style: Optional style scheme
673
+ layout: Optional layout configuration
674
+
675
+ Returns:
676
+ PlantUML diagram text
677
+ """
678
+ renderer = PlantUMLRenderer(graph, entities, style=style, layout=layout)
679
+ diagram = renderer.render()
680
+
681
+ if output_path:
682
+ output_path.write_text(diagram)
683
+
684
+ return diagram