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,451 @@
|
|
|
1
|
+
"""Validation for PlantUML import.
|
|
2
|
+
|
|
3
|
+
This module provides validation of parsed PlantUML models and
|
|
4
|
+
generated RDF, ensuring consistency and flagging potential issues.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from rdflib import Graph, URIRef, RDF, RDFS
|
|
12
|
+
from rdflib.namespace import OWL
|
|
13
|
+
|
|
14
|
+
from rdf_construct.puml2rdf.model import PumlModel, PumlRelationship, RelationshipType
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Severity(Enum):
|
|
18
|
+
"""Severity level for validation issues."""
|
|
19
|
+
|
|
20
|
+
ERROR = "error" # Must be fixed
|
|
21
|
+
WARNING = "warning" # Should be reviewed
|
|
22
|
+
INFO = "info" # Informational
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ValidationIssue:
|
|
27
|
+
"""A validation issue found during checking.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
severity: How serious the issue is
|
|
31
|
+
code: Machine-readable issue code
|
|
32
|
+
message: Human-readable description
|
|
33
|
+
entity: The entity this issue relates to
|
|
34
|
+
suggestion: Optional fix suggestion
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
severity: Severity
|
|
38
|
+
code: str
|
|
39
|
+
message: str
|
|
40
|
+
entity: Optional[str] = None
|
|
41
|
+
suggestion: Optional[str] = None
|
|
42
|
+
|
|
43
|
+
def __str__(self) -> str:
|
|
44
|
+
prefix = f"[{self.severity.value.upper()}]"
|
|
45
|
+
entity_str = f" ({self.entity})" if self.entity else ""
|
|
46
|
+
return f"{prefix} {self.code}: {self.message}{entity_str}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class ValidationResult:
|
|
51
|
+
"""Result of validation.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
issues: List of validation issues found
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
issues: list[ValidationIssue]
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def has_errors(self) -> bool:
|
|
61
|
+
"""Return True if any errors were found."""
|
|
62
|
+
return any(i.severity == Severity.ERROR for i in self.issues)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def has_warnings(self) -> bool:
|
|
66
|
+
"""Return True if any warnings were found."""
|
|
67
|
+
return any(i.severity == Severity.WARNING for i in self.issues)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def error_count(self) -> int:
|
|
71
|
+
"""Count of errors."""
|
|
72
|
+
return sum(1 for i in self.issues if i.severity == Severity.ERROR)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def warning_count(self) -> int:
|
|
76
|
+
"""Count of warnings."""
|
|
77
|
+
return sum(1 for i in self.issues if i.severity == Severity.WARNING)
|
|
78
|
+
|
|
79
|
+
def errors(self) -> list[ValidationIssue]:
|
|
80
|
+
"""Return only error-level issues."""
|
|
81
|
+
return [i for i in self.issues if i.severity == Severity.ERROR]
|
|
82
|
+
|
|
83
|
+
def warnings(self) -> list[ValidationIssue]:
|
|
84
|
+
"""Return only warning-level issues."""
|
|
85
|
+
return [i for i in self.issues if i.severity == Severity.WARNING]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class PumlModelValidator:
|
|
89
|
+
"""Validates parsed PlantUML models for consistency.
|
|
90
|
+
|
|
91
|
+
Checks include:
|
|
92
|
+
- Relationships reference existing classes
|
|
93
|
+
- No duplicate class names
|
|
94
|
+
- Attributes have valid types
|
|
95
|
+
- Inheritance doesn't create cycles
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def validate(self, model: PumlModel) -> ValidationResult:
|
|
99
|
+
"""Validate a parsed PlantUML model.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
model: The parsed model to validate
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
ValidationResult with any issues found
|
|
106
|
+
"""
|
|
107
|
+
issues: list[ValidationIssue] = []
|
|
108
|
+
|
|
109
|
+
# Check for duplicate class names
|
|
110
|
+
issues.extend(self._check_duplicate_classes(model))
|
|
111
|
+
|
|
112
|
+
# Check relationship references
|
|
113
|
+
issues.extend(self._check_relationship_references(model))
|
|
114
|
+
|
|
115
|
+
# Check for inheritance cycles
|
|
116
|
+
issues.extend(self._check_inheritance_cycles(model))
|
|
117
|
+
|
|
118
|
+
# Check attribute types
|
|
119
|
+
issues.extend(self._check_attribute_types(model))
|
|
120
|
+
|
|
121
|
+
# Check for classes without any relationships
|
|
122
|
+
issues.extend(self._check_orphan_classes(model))
|
|
123
|
+
|
|
124
|
+
return ValidationResult(issues=issues)
|
|
125
|
+
|
|
126
|
+
def _check_duplicate_classes(self, model: PumlModel) -> list[ValidationIssue]:
|
|
127
|
+
"""Check for duplicate class names."""
|
|
128
|
+
issues = []
|
|
129
|
+
seen = set()
|
|
130
|
+
|
|
131
|
+
for cls in model.classes:
|
|
132
|
+
if cls.name in seen:
|
|
133
|
+
issues.append(
|
|
134
|
+
ValidationIssue(
|
|
135
|
+
severity=Severity.ERROR,
|
|
136
|
+
code="DUPLICATE_CLASS",
|
|
137
|
+
message=f"Duplicate class name: {cls.name}",
|
|
138
|
+
entity=cls.name,
|
|
139
|
+
suggestion="Rename one of the classes or use packages to distinguish them",
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
seen.add(cls.name)
|
|
143
|
+
|
|
144
|
+
return issues
|
|
145
|
+
|
|
146
|
+
def _check_relationship_references(self, model: PumlModel) -> list[ValidationIssue]:
|
|
147
|
+
"""Check that relationships reference existing classes."""
|
|
148
|
+
issues = []
|
|
149
|
+
|
|
150
|
+
# Include both local and qualified names for lookup
|
|
151
|
+
class_names = set()
|
|
152
|
+
for cls in model.classes:
|
|
153
|
+
class_names.add(cls.name) # Local name: "Building"
|
|
154
|
+
class_names.add(cls.qualified_name) # Qualified: "building.Building"
|
|
155
|
+
|
|
156
|
+
for rel in model.relationships:
|
|
157
|
+
if rel.source not in class_names:
|
|
158
|
+
issues.append(
|
|
159
|
+
ValidationIssue(
|
|
160
|
+
severity=Severity.ERROR,
|
|
161
|
+
code="UNKNOWN_CLASS",
|
|
162
|
+
message=f"Relationship references unknown source class: {rel.source}",
|
|
163
|
+
entity=rel.source,
|
|
164
|
+
suggestion=f"Add class declaration for '{rel.source}'",
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
if rel.target not in class_names:
|
|
168
|
+
issues.append(
|
|
169
|
+
ValidationIssue(
|
|
170
|
+
severity=Severity.ERROR,
|
|
171
|
+
code="UNKNOWN_CLASS",
|
|
172
|
+
message=f"Relationship references unknown target class: {rel.target}",
|
|
173
|
+
entity=rel.target,
|
|
174
|
+
suggestion=f"Add class declaration for '{rel.target}'",
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return issues
|
|
179
|
+
|
|
180
|
+
def _check_inheritance_cycles(self, model: PumlModel) -> list[ValidationIssue]:
|
|
181
|
+
"""Check for cycles in inheritance hierarchy."""
|
|
182
|
+
issues = []
|
|
183
|
+
|
|
184
|
+
# Build inheritance graph
|
|
185
|
+
inheritance: dict[str, set[str]] = {}
|
|
186
|
+
for rel in model.inheritance_relationships():
|
|
187
|
+
if rel.source not in inheritance:
|
|
188
|
+
inheritance[rel.source] = set()
|
|
189
|
+
inheritance[rel.source].add(rel.target)
|
|
190
|
+
|
|
191
|
+
# Check for cycles using DFS
|
|
192
|
+
def has_cycle(start: str, visited: set[str], path: list[str]) -> Optional[list[str]]:
|
|
193
|
+
if start in path:
|
|
194
|
+
cycle_start = path.index(start)
|
|
195
|
+
return path[cycle_start:] + [start]
|
|
196
|
+
|
|
197
|
+
if start in visited:
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
visited.add(start)
|
|
201
|
+
path.append(start)
|
|
202
|
+
|
|
203
|
+
for parent in inheritance.get(start, set()):
|
|
204
|
+
cycle = has_cycle(parent, visited, path)
|
|
205
|
+
if cycle:
|
|
206
|
+
return cycle
|
|
207
|
+
|
|
208
|
+
path.pop()
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
for cls in model.classes:
|
|
212
|
+
cycle = has_cycle(cls.name, set(), [])
|
|
213
|
+
if cycle:
|
|
214
|
+
issues.append(
|
|
215
|
+
ValidationIssue(
|
|
216
|
+
severity=Severity.ERROR,
|
|
217
|
+
code="INHERITANCE_CYCLE",
|
|
218
|
+
message=f"Inheritance cycle detected: {' -> '.join(cycle)}",
|
|
219
|
+
entity=cls.name,
|
|
220
|
+
suggestion="Break the cycle by removing one of the inheritance relationships",
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
break # Only report once
|
|
224
|
+
|
|
225
|
+
return issues
|
|
226
|
+
|
|
227
|
+
def _check_attribute_types(self, model: PumlModel) -> list[ValidationIssue]:
|
|
228
|
+
"""Check that attribute types are recognized."""
|
|
229
|
+
issues = []
|
|
230
|
+
|
|
231
|
+
known_types = {
|
|
232
|
+
"string", "str", "text",
|
|
233
|
+
"integer", "int",
|
|
234
|
+
"decimal", "float", "double", "number",
|
|
235
|
+
"boolean", "bool",
|
|
236
|
+
"date", "datetime", "time",
|
|
237
|
+
"gYear", "gyear", "gYearMonth",
|
|
238
|
+
"duration",
|
|
239
|
+
"uri", "anyURI", "anyuri", "url",
|
|
240
|
+
"base64", "hexBinary",
|
|
241
|
+
"language", "token",
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
for cls in model.classes:
|
|
245
|
+
for attr in cls.attributes:
|
|
246
|
+
if attr.datatype and attr.datatype.lower() not in known_types:
|
|
247
|
+
if not attr.datatype.startswith("xsd:"):
|
|
248
|
+
issues.append(
|
|
249
|
+
ValidationIssue(
|
|
250
|
+
severity=Severity.WARNING,
|
|
251
|
+
code="UNKNOWN_DATATYPE",
|
|
252
|
+
message=f"Unknown datatype '{attr.datatype}' for attribute '{attr.name}'",
|
|
253
|
+
entity=f"{cls.name}.{attr.name}",
|
|
254
|
+
suggestion="Use standard XSD type or add custom mapping in config",
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
return issues
|
|
259
|
+
|
|
260
|
+
def _check_orphan_classes(self, model: PumlModel) -> list[ValidationIssue]:
|
|
261
|
+
"""Check for classes with no relationships."""
|
|
262
|
+
issues = []
|
|
263
|
+
|
|
264
|
+
# Get all classes involved in relationships
|
|
265
|
+
related_classes = set()
|
|
266
|
+
for rel in model.relationships:
|
|
267
|
+
related_classes.add(rel.source)
|
|
268
|
+
related_classes.add(rel.target)
|
|
269
|
+
|
|
270
|
+
for cls in model.classes:
|
|
271
|
+
if cls.name not in related_classes and not cls.attributes:
|
|
272
|
+
issues.append(
|
|
273
|
+
ValidationIssue(
|
|
274
|
+
severity=Severity.INFO,
|
|
275
|
+
code="ISOLATED_CLASS",
|
|
276
|
+
message=f"Class '{cls.name}' has no relationships or attributes",
|
|
277
|
+
entity=cls.name,
|
|
278
|
+
suggestion="Consider adding relationships or attributes",
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
return issues
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class RdfValidator:
|
|
286
|
+
"""Validates generated RDF for OWL/RDFS consistency.
|
|
287
|
+
|
|
288
|
+
Checks include:
|
|
289
|
+
- Classes are typed as owl:Class
|
|
290
|
+
- Properties have domain and range
|
|
291
|
+
- No dangling references
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
def validate(self, graph: Graph) -> ValidationResult:
|
|
295
|
+
"""Validate an RDF graph.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
graph: The graph to validate
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
ValidationResult with any issues found
|
|
302
|
+
"""
|
|
303
|
+
issues: list[ValidationIssue] = []
|
|
304
|
+
|
|
305
|
+
# Check class typing
|
|
306
|
+
issues.extend(self._check_class_typing(graph))
|
|
307
|
+
|
|
308
|
+
# Check property completeness
|
|
309
|
+
issues.extend(self._check_property_completeness(graph))
|
|
310
|
+
|
|
311
|
+
# Check for dangling references
|
|
312
|
+
issues.extend(self._check_dangling_references(graph))
|
|
313
|
+
|
|
314
|
+
return ValidationResult(issues=issues)
|
|
315
|
+
|
|
316
|
+
def _check_class_typing(self, graph: Graph) -> list[ValidationIssue]:
|
|
317
|
+
"""Check that classes are properly typed."""
|
|
318
|
+
issues = []
|
|
319
|
+
|
|
320
|
+
# Find subjects of rdfs:subClassOf that aren't typed as classes
|
|
321
|
+
for s in graph.subjects(RDFS.subClassOf, None):
|
|
322
|
+
if not isinstance(s, URIRef):
|
|
323
|
+
continue
|
|
324
|
+
|
|
325
|
+
is_class = (
|
|
326
|
+
(s, RDF.type, OWL.Class) in graph
|
|
327
|
+
or (s, RDF.type, RDFS.Class) in graph
|
|
328
|
+
)
|
|
329
|
+
if not is_class:
|
|
330
|
+
issues.append(
|
|
331
|
+
ValidationIssue(
|
|
332
|
+
severity=Severity.WARNING,
|
|
333
|
+
code="UNTYPED_CLASS",
|
|
334
|
+
message=f"Subject of rdfs:subClassOf not typed as class",
|
|
335
|
+
entity=str(s),
|
|
336
|
+
suggestion="Add rdf:type owl:Class triple",
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
return issues
|
|
341
|
+
|
|
342
|
+
def _check_property_completeness(self, graph: Graph) -> list[ValidationIssue]:
|
|
343
|
+
"""Check that properties have domain and range."""
|
|
344
|
+
issues = []
|
|
345
|
+
|
|
346
|
+
for prop in graph.subjects(RDF.type, OWL.ObjectProperty):
|
|
347
|
+
if not isinstance(prop, URIRef):
|
|
348
|
+
continue
|
|
349
|
+
|
|
350
|
+
has_domain = any(graph.objects(prop, RDFS.domain))
|
|
351
|
+
has_range = any(graph.objects(prop, RDFS.range))
|
|
352
|
+
|
|
353
|
+
if not has_domain:
|
|
354
|
+
issues.append(
|
|
355
|
+
ValidationIssue(
|
|
356
|
+
severity=Severity.INFO,
|
|
357
|
+
code="MISSING_DOMAIN",
|
|
358
|
+
message="Object property has no domain",
|
|
359
|
+
entity=str(prop),
|
|
360
|
+
)
|
|
361
|
+
)
|
|
362
|
+
if not has_range:
|
|
363
|
+
issues.append(
|
|
364
|
+
ValidationIssue(
|
|
365
|
+
severity=Severity.INFO,
|
|
366
|
+
code="MISSING_RANGE",
|
|
367
|
+
message="Object property has no range",
|
|
368
|
+
entity=str(prop),
|
|
369
|
+
)
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
for prop in graph.subjects(RDF.type, OWL.DatatypeProperty):
|
|
373
|
+
if not isinstance(prop, URIRef):
|
|
374
|
+
continue
|
|
375
|
+
|
|
376
|
+
has_range = any(graph.objects(prop, RDFS.range))
|
|
377
|
+
if not has_range:
|
|
378
|
+
issues.append(
|
|
379
|
+
ValidationIssue(
|
|
380
|
+
severity=Severity.INFO,
|
|
381
|
+
code="MISSING_RANGE",
|
|
382
|
+
message="Datatype property has no range (XSD type)",
|
|
383
|
+
entity=str(prop),
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
return issues
|
|
388
|
+
|
|
389
|
+
def _check_dangling_references(self, graph: Graph) -> list[ValidationIssue]:
|
|
390
|
+
"""Check for references to undefined entities."""
|
|
391
|
+
issues = []
|
|
392
|
+
|
|
393
|
+
# Get all defined classes
|
|
394
|
+
defined_classes = set()
|
|
395
|
+
for cls in graph.subjects(RDF.type, OWL.Class):
|
|
396
|
+
defined_classes.add(cls)
|
|
397
|
+
for cls in graph.subjects(RDF.type, RDFS.Class):
|
|
398
|
+
defined_classes.add(cls)
|
|
399
|
+
|
|
400
|
+
# Check domain and range references
|
|
401
|
+
for prop in graph.subjects(RDF.type, OWL.ObjectProperty):
|
|
402
|
+
for domain in graph.objects(prop, RDFS.domain):
|
|
403
|
+
if isinstance(domain, URIRef) and domain not in defined_classes:
|
|
404
|
+
issues.append(
|
|
405
|
+
ValidationIssue(
|
|
406
|
+
severity=Severity.WARNING,
|
|
407
|
+
code="UNDEFINED_DOMAIN",
|
|
408
|
+
message=f"Property domain references undefined class",
|
|
409
|
+
entity=f"{prop} -> {domain}",
|
|
410
|
+
)
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
for rng in graph.objects(prop, RDFS.range):
|
|
414
|
+
if isinstance(rng, URIRef) and rng not in defined_classes:
|
|
415
|
+
# Could be external class - just info
|
|
416
|
+
issues.append(
|
|
417
|
+
ValidationIssue(
|
|
418
|
+
severity=Severity.INFO,
|
|
419
|
+
code="EXTERNAL_RANGE",
|
|
420
|
+
message=f"Property range references class not in this graph",
|
|
421
|
+
entity=f"{prop} -> {rng}",
|
|
422
|
+
)
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
return issues
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def validate_puml(model: PumlModel) -> ValidationResult:
|
|
429
|
+
"""Convenience function to validate a PlantUML model.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
model: The parsed model to validate
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
ValidationResult with any issues found
|
|
436
|
+
"""
|
|
437
|
+
validator = PumlModelValidator()
|
|
438
|
+
return validator.validate(model)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def validate_rdf(graph: Graph) -> ValidationResult:
|
|
442
|
+
"""Convenience function to validate an RDF graph.
|
|
443
|
+
|
|
444
|
+
Args:
|
|
445
|
+
graph: The graph to validate
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
ValidationResult with any issues found
|
|
449
|
+
"""
|
|
450
|
+
validator = RdfValidator()
|
|
451
|
+
return validator.validate(graph)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""SHACL shape generation from OWL ontologies.
|
|
2
|
+
|
|
3
|
+
This module provides tools for generating SHACL validation shapes from
|
|
4
|
+
OWL ontology definitions, converting domain/range, cardinality restrictions,
|
|
5
|
+
and other OWL patterns to equivalent SHACL constraints.
|
|
6
|
+
|
|
7
|
+
Basic usage:
|
|
8
|
+
|
|
9
|
+
from rdf_construct.shacl import generate_shapes, ShaclConfig, StrictnessLevel
|
|
10
|
+
|
|
11
|
+
# Generate shapes with default settings
|
|
12
|
+
graph, turtle = generate_shapes(Path("ontology.ttl"))
|
|
13
|
+
|
|
14
|
+
# Generate with strict level
|
|
15
|
+
config = ShaclConfig(level=StrictnessLevel.STRICT, closed=True)
|
|
16
|
+
graph, turtle = generate_shapes(Path("ontology.ttl"), config)
|
|
17
|
+
|
|
18
|
+
# Generate and write to file
|
|
19
|
+
from rdf_construct.shacl import generate_shapes_to_file
|
|
20
|
+
generate_shapes_to_file(
|
|
21
|
+
Path("ontology.ttl"),
|
|
22
|
+
Path("shapes.ttl"),
|
|
23
|
+
config,
|
|
24
|
+
)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from rdf_construct.shacl.config import (
|
|
28
|
+
ShaclConfig,
|
|
29
|
+
Severity,
|
|
30
|
+
StrictnessLevel,
|
|
31
|
+
load_shacl_config,
|
|
32
|
+
)
|
|
33
|
+
from rdf_construct.shacl.converters import PropertyConstraint
|
|
34
|
+
from rdf_construct.shacl.generator import (
|
|
35
|
+
ShapeGenerator,
|
|
36
|
+
generate_shapes,
|
|
37
|
+
generate_shapes_to_file,
|
|
38
|
+
)
|
|
39
|
+
from rdf_construct.shacl.namespaces import SH, SHACL_PREFIXES
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
# Configuration
|
|
43
|
+
"ShaclConfig",
|
|
44
|
+
"StrictnessLevel",
|
|
45
|
+
"Severity",
|
|
46
|
+
"load_shacl_config",
|
|
47
|
+
# Generator
|
|
48
|
+
"ShapeGenerator",
|
|
49
|
+
"generate_shapes",
|
|
50
|
+
"generate_shapes_to_file",
|
|
51
|
+
# Converters
|
|
52
|
+
"PropertyConstraint",
|
|
53
|
+
# Namespaces
|
|
54
|
+
"SH",
|
|
55
|
+
"SHACL_PREFIXES",
|
|
56
|
+
]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Configuration handling for SHACL shape generation."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from rdflib import URIRef
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StrictnessLevel(Enum):
|
|
13
|
+
"""Strictness levels for SHACL generation.
|
|
14
|
+
|
|
15
|
+
Controls how many OWL patterns are converted to SHACL constraints.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
MINIMAL: Only basic type constraints (sh:class, sh:datatype).
|
|
19
|
+
STANDARD: Adds cardinality, functional properties, and common patterns.
|
|
20
|
+
STRICT: Maximum constraints including sh:closed, all cardinalities.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
MINIMAL = "minimal"
|
|
24
|
+
STANDARD = "standard"
|
|
25
|
+
STRICT = "strict"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Severity(Enum):
|
|
29
|
+
"""SHACL constraint severity levels.
|
|
30
|
+
|
|
31
|
+
Maps to sh:Violation, sh:Warning, sh:Info.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
VIOLATION = "violation"
|
|
35
|
+
WARNING = "warning"
|
|
36
|
+
INFO = "info"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ShaclConfig:
|
|
41
|
+
"""Configuration for SHACL shape generation.
|
|
42
|
+
|
|
43
|
+
Controls which OWL patterns are converted and how shapes are structured.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
level: Strictness level for conversion.
|
|
47
|
+
default_severity: Default severity for generated constraints.
|
|
48
|
+
closed: Generate closed shapes (no extra properties allowed).
|
|
49
|
+
shape_namespace: Namespace suffix for generated shapes.
|
|
50
|
+
target_classes: Optional list of classes to generate shapes for.
|
|
51
|
+
exclude_classes: Classes to skip during generation.
|
|
52
|
+
include_labels: Include rdfs:label as sh:name.
|
|
53
|
+
include_descriptions: Include rdfs:comment as sh:description.
|
|
54
|
+
inherit_constraints: Inherit property constraints from superclasses.
|
|
55
|
+
generate_property_shapes: Generate top-level PropertyShapes.
|
|
56
|
+
ignored_properties: Properties to ignore in closed shapes.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
level: StrictnessLevel = StrictnessLevel.STANDARD
|
|
60
|
+
default_severity: Severity = Severity.VIOLATION
|
|
61
|
+
closed: bool = False
|
|
62
|
+
shape_namespace: str = "shapes#"
|
|
63
|
+
target_classes: list[str] = field(default_factory=list)
|
|
64
|
+
exclude_classes: list[str] = field(default_factory=list)
|
|
65
|
+
include_labels: bool = True
|
|
66
|
+
include_descriptions: bool = True
|
|
67
|
+
inherit_constraints: bool = True
|
|
68
|
+
generate_property_shapes: bool = False
|
|
69
|
+
ignored_properties: list[str] = field(default_factory=list)
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_yaml(cls, path: Path) -> "ShaclConfig":
|
|
73
|
+
"""Load configuration from YAML file.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
path: Path to YAML configuration file.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Populated ShaclConfig instance.
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
FileNotFoundError: If configuration file doesn't exist.
|
|
83
|
+
ValueError: If configuration is invalid.
|
|
84
|
+
"""
|
|
85
|
+
with open(path) as f:
|
|
86
|
+
data = yaml.safe_load(f) or {}
|
|
87
|
+
|
|
88
|
+
return cls.from_dict(data)
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_dict(cls, data: dict[str, Any]) -> "ShaclConfig":
|
|
92
|
+
"""Create configuration from dictionary.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
data: Configuration dictionary.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Populated ShaclConfig instance.
|
|
99
|
+
"""
|
|
100
|
+
# Handle enum conversions
|
|
101
|
+
level_str = data.get("level", "standard").lower()
|
|
102
|
+
try:
|
|
103
|
+
level = StrictnessLevel(level_str)
|
|
104
|
+
except ValueError:
|
|
105
|
+
valid = ", ".join(s.value for s in StrictnessLevel)
|
|
106
|
+
raise ValueError(f"Invalid strictness level '{level_str}'. Valid: {valid}")
|
|
107
|
+
|
|
108
|
+
severity_str = data.get("default_severity", "violation").lower()
|
|
109
|
+
try:
|
|
110
|
+
severity = Severity(severity_str)
|
|
111
|
+
except ValueError:
|
|
112
|
+
valid = ", ".join(s.value for s in Severity)
|
|
113
|
+
raise ValueError(f"Invalid severity '{severity_str}'. Valid: {valid}")
|
|
114
|
+
|
|
115
|
+
return cls(
|
|
116
|
+
level=level,
|
|
117
|
+
default_severity=severity,
|
|
118
|
+
closed=data.get("closed", False),
|
|
119
|
+
shape_namespace=data.get("shape_namespace", "shapes#"),
|
|
120
|
+
target_classes=data.get("target_classes", []),
|
|
121
|
+
exclude_classes=data.get("exclude_classes", []),
|
|
122
|
+
include_labels=data.get("include_labels", True),
|
|
123
|
+
include_descriptions=data.get("include_descriptions", True),
|
|
124
|
+
inherit_constraints=data.get("inherit_constraints", True),
|
|
125
|
+
generate_property_shapes=data.get("generate_property_shapes", False),
|
|
126
|
+
ignored_properties=data.get("ignored_properties", []),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def should_generate_for(self, class_uri: URIRef, graph: "Graph") -> bool:
|
|
130
|
+
"""Check if a shape should be generated for this class.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
class_uri: The class URI to check.
|
|
134
|
+
graph: The source graph (for CURIE expansion).
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
True if shape should be generated.
|
|
138
|
+
"""
|
|
139
|
+
class_str = str(class_uri)
|
|
140
|
+
|
|
141
|
+
# If explicit targets specified, only generate for those
|
|
142
|
+
if self.target_classes:
|
|
143
|
+
return any(class_str.endswith(t) or t in class_str for t in self.target_classes)
|
|
144
|
+
|
|
145
|
+
# Check exclusions
|
|
146
|
+
if self.exclude_classes:
|
|
147
|
+
return not any(
|
|
148
|
+
class_str.endswith(e) or e in class_str for e in self.exclude_classes
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return True
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def load_shacl_config(path: Path | None) -> ShaclConfig:
|
|
155
|
+
"""Load SHACL configuration from file or return defaults.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
path: Optional path to configuration file.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
ShaclConfig instance (defaults if no path provided).
|
|
162
|
+
"""
|
|
163
|
+
if path is None:
|
|
164
|
+
return ShaclConfig()
|
|
165
|
+
|
|
166
|
+
return ShaclConfig.from_yaml(path)
|