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.
- rdf_construct/__init__.py +12 -0
- rdf_construct/__main__.py +0 -0
- rdf_construct/cli.py +1762 -0
- rdf_construct/core/__init__.py +33 -0
- rdf_construct/core/config.py +116 -0
- rdf_construct/core/ordering.py +219 -0
- rdf_construct/core/predicate_order.py +212 -0
- rdf_construct/core/profile.py +157 -0
- rdf_construct/core/selector.py +64 -0
- rdf_construct/core/serialiser.py +232 -0
- rdf_construct/core/utils.py +89 -0
- rdf_construct/cq/__init__.py +77 -0
- rdf_construct/cq/expectations.py +365 -0
- rdf_construct/cq/formatters/__init__.py +45 -0
- rdf_construct/cq/formatters/json.py +104 -0
- rdf_construct/cq/formatters/junit.py +104 -0
- rdf_construct/cq/formatters/text.py +146 -0
- rdf_construct/cq/loader.py +300 -0
- rdf_construct/cq/runner.py +321 -0
- rdf_construct/diff/__init__.py +59 -0
- rdf_construct/diff/change_types.py +214 -0
- rdf_construct/diff/comparator.py +338 -0
- rdf_construct/diff/filters.py +133 -0
- rdf_construct/diff/formatters/__init__.py +71 -0
- rdf_construct/diff/formatters/json.py +192 -0
- rdf_construct/diff/formatters/markdown.py +210 -0
- rdf_construct/diff/formatters/text.py +195 -0
- rdf_construct/docs/__init__.py +60 -0
- rdf_construct/docs/config.py +238 -0
- rdf_construct/docs/extractors.py +603 -0
- rdf_construct/docs/generator.py +360 -0
- rdf_construct/docs/renderers/__init__.py +7 -0
- rdf_construct/docs/renderers/html.py +803 -0
- rdf_construct/docs/renderers/json.py +390 -0
- rdf_construct/docs/renderers/markdown.py +628 -0
- rdf_construct/docs/search.py +278 -0
- rdf_construct/docs/templates/html/base.html.jinja +44 -0
- rdf_construct/docs/templates/html/class.html.jinja +152 -0
- rdf_construct/docs/templates/html/hierarchy.html.jinja +28 -0
- rdf_construct/docs/templates/html/index.html.jinja +110 -0
- rdf_construct/docs/templates/html/instance.html.jinja +90 -0
- rdf_construct/docs/templates/html/namespaces.html.jinja +37 -0
- rdf_construct/docs/templates/html/property.html.jinja +124 -0
- rdf_construct/docs/templates/html/single_page.html.jinja +169 -0
- rdf_construct/lint/__init__.py +75 -0
- rdf_construct/lint/config.py +214 -0
- rdf_construct/lint/engine.py +396 -0
- rdf_construct/lint/formatters.py +327 -0
- rdf_construct/lint/rules.py +692 -0
- rdf_construct/main.py +6 -0
- rdf_construct/puml2rdf/__init__.py +103 -0
- rdf_construct/puml2rdf/config.py +230 -0
- rdf_construct/puml2rdf/converter.py +420 -0
- rdf_construct/puml2rdf/merger.py +200 -0
- rdf_construct/puml2rdf/model.py +202 -0
- rdf_construct/puml2rdf/parser.py +565 -0
- rdf_construct/puml2rdf/validators.py +451 -0
- rdf_construct/shacl/__init__.py +56 -0
- rdf_construct/shacl/config.py +166 -0
- rdf_construct/shacl/converters.py +520 -0
- rdf_construct/shacl/generator.py +364 -0
- rdf_construct/shacl/namespaces.py +93 -0
- rdf_construct/stats/__init__.py +29 -0
- rdf_construct/stats/collector.py +178 -0
- rdf_construct/stats/comparator.py +298 -0
- rdf_construct/stats/formatters/__init__.py +83 -0
- rdf_construct/stats/formatters/json.py +38 -0
- rdf_construct/stats/formatters/markdown.py +153 -0
- rdf_construct/stats/formatters/text.py +186 -0
- rdf_construct/stats/metrics/__init__.py +26 -0
- rdf_construct/stats/metrics/basic.py +147 -0
- rdf_construct/stats/metrics/complexity.py +137 -0
- rdf_construct/stats/metrics/connectivity.py +130 -0
- rdf_construct/stats/metrics/documentation.py +128 -0
- rdf_construct/stats/metrics/hierarchy.py +207 -0
- rdf_construct/stats/metrics/properties.py +88 -0
- rdf_construct/uml/__init__.py +22 -0
- rdf_construct/uml/context.py +194 -0
- rdf_construct/uml/mapper.py +371 -0
- rdf_construct/uml/odm_renderer.py +789 -0
- rdf_construct/uml/renderer.py +684 -0
- rdf_construct/uml/uml_layout.py +393 -0
- rdf_construct/uml/uml_style.py +613 -0
- rdf_construct-0.2.0.dist-info/METADATA +431 -0
- rdf_construct-0.2.0.dist-info/RECORD +88 -0
- rdf_construct-0.2.0.dist-info/WHEEL +4 -0
- rdf_construct-0.2.0.dist-info/entry_points.txt +3 -0
- 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
|