powerbi-ontology-extractor 0.1.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.
- cli/__init__.py +1 -0
- cli/pbi_ontology_cli.py +286 -0
- powerbi_ontology/__init__.py +38 -0
- powerbi_ontology/analyzer.py +420 -0
- powerbi_ontology/chat.py +303 -0
- powerbi_ontology/cli.py +530 -0
- powerbi_ontology/contract_builder.py +269 -0
- powerbi_ontology/dax_parser.py +305 -0
- powerbi_ontology/export/__init__.py +17 -0
- powerbi_ontology/export/contract_to_owl.py +408 -0
- powerbi_ontology/export/fabric_iq.py +243 -0
- powerbi_ontology/export/fabric_iq_to_owl.py +463 -0
- powerbi_ontology/export/json_schema.py +110 -0
- powerbi_ontology/export/ontoguard.py +177 -0
- powerbi_ontology/export/owl.py +522 -0
- powerbi_ontology/extractor.py +368 -0
- powerbi_ontology/mcp_config.py +237 -0
- powerbi_ontology/mcp_models.py +166 -0
- powerbi_ontology/mcp_server.py +1106 -0
- powerbi_ontology/ontology_diff.py +776 -0
- powerbi_ontology/ontology_generator.py +406 -0
- powerbi_ontology/review.py +556 -0
- powerbi_ontology/schema_mapper.py +369 -0
- powerbi_ontology/semantic_debt.py +584 -0
- powerbi_ontology/utils/__init__.py +13 -0
- powerbi_ontology/utils/pbix_reader.py +558 -0
- powerbi_ontology/utils/visualizer.py +332 -0
- powerbi_ontology_extractor-0.1.0.dist-info/METADATA +507 -0
- powerbi_ontology_extractor-0.1.0.dist-info/RECORD +33 -0
- powerbi_ontology_extractor-0.1.0.dist-info/WHEEL +5 -0
- powerbi_ontology_extractor-0.1.0.dist-info/entry_points.txt +4 -0
- powerbi_ontology_extractor-0.1.0.dist-info/licenses/LICENSE +21 -0
- powerbi_ontology_extractor-0.1.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ontology Diff Tool - Compare versions of ontologies.
|
|
3
|
+
|
|
4
|
+
Provides Git-like diff functionality for ontologies:
|
|
5
|
+
- Detect added, removed, modified elements
|
|
6
|
+
- Generate changelogs
|
|
7
|
+
- Support merge operations
|
|
8
|
+
|
|
9
|
+
Use case: Track changes between ontology versions or compare branches.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
16
|
+
from difflib import unified_diff
|
|
17
|
+
|
|
18
|
+
from powerbi_ontology.ontology_generator import (
|
|
19
|
+
Ontology,
|
|
20
|
+
OntologyEntity,
|
|
21
|
+
OntologyProperty,
|
|
22
|
+
OntologyRelationship,
|
|
23
|
+
BusinessRule,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ChangeType(Enum):
|
|
30
|
+
"""Types of changes between ontology versions."""
|
|
31
|
+
ADDED = "added"
|
|
32
|
+
REMOVED = "removed"
|
|
33
|
+
MODIFIED = "modified"
|
|
34
|
+
UNCHANGED = "unchanged"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ElementType(Enum):
|
|
38
|
+
"""Types of ontology elements."""
|
|
39
|
+
ENTITY = "entity"
|
|
40
|
+
PROPERTY = "property"
|
|
41
|
+
RELATIONSHIP = "relationship"
|
|
42
|
+
RULE = "rule"
|
|
43
|
+
METADATA = "metadata"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Change:
|
|
48
|
+
"""Represents a single change between ontology versions."""
|
|
49
|
+
change_type: ChangeType
|
|
50
|
+
element_type: ElementType
|
|
51
|
+
element_name: str
|
|
52
|
+
path: str # Full path like "Entity.Property"
|
|
53
|
+
old_value: Optional[Any] = None
|
|
54
|
+
new_value: Optional[Any] = None
|
|
55
|
+
details: str = ""
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict:
|
|
58
|
+
"""Convert to dictionary."""
|
|
59
|
+
return {
|
|
60
|
+
"change_type": self.change_type.value,
|
|
61
|
+
"element_type": self.element_type.value,
|
|
62
|
+
"element_name": self.element_name,
|
|
63
|
+
"path": self.path,
|
|
64
|
+
"old_value": str(self.old_value) if self.old_value else None,
|
|
65
|
+
"new_value": str(self.new_value) if self.new_value else None,
|
|
66
|
+
"details": self.details,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class DiffReport:
|
|
72
|
+
"""Report of differences between two ontology versions."""
|
|
73
|
+
source_name: str
|
|
74
|
+
target_name: str
|
|
75
|
+
source_version: str
|
|
76
|
+
target_version: str
|
|
77
|
+
changes: List[Change] = field(default_factory=list)
|
|
78
|
+
summary: Dict[str, Any] = field(default_factory=dict)
|
|
79
|
+
|
|
80
|
+
def add_change(self, change: Change):
|
|
81
|
+
"""Add a change to the report."""
|
|
82
|
+
self.changes.append(change)
|
|
83
|
+
|
|
84
|
+
def generate_summary(self):
|
|
85
|
+
"""Generate summary statistics."""
|
|
86
|
+
self.summary = {
|
|
87
|
+
"total_changes": len(self.changes),
|
|
88
|
+
"added": sum(1 for c in self.changes if c.change_type == ChangeType.ADDED),
|
|
89
|
+
"removed": sum(1 for c in self.changes if c.change_type == ChangeType.REMOVED),
|
|
90
|
+
"modified": sum(1 for c in self.changes if c.change_type == ChangeType.MODIFIED),
|
|
91
|
+
"by_element": {},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for element_type in ElementType:
|
|
95
|
+
count = sum(1 for c in self.changes if c.element_type == element_type)
|
|
96
|
+
if count > 0:
|
|
97
|
+
self.summary["by_element"][element_type.value] = count
|
|
98
|
+
|
|
99
|
+
def has_changes(self) -> bool:
|
|
100
|
+
"""Check if there are any changes."""
|
|
101
|
+
return len(self.changes) > 0
|
|
102
|
+
|
|
103
|
+
def to_dict(self) -> dict:
|
|
104
|
+
"""Convert to dictionary."""
|
|
105
|
+
self.generate_summary()
|
|
106
|
+
return {
|
|
107
|
+
"source": {"name": self.source_name, "version": self.source_version},
|
|
108
|
+
"target": {"name": self.target_name, "version": self.target_version},
|
|
109
|
+
"summary": self.summary,
|
|
110
|
+
"changes": [c.to_dict() for c in self.changes],
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
def to_changelog(self) -> str:
|
|
114
|
+
"""Generate changelog in markdown format."""
|
|
115
|
+
self.generate_summary()
|
|
116
|
+
|
|
117
|
+
lines = [
|
|
118
|
+
f"# Changelog: {self.source_name} → {self.target_name}",
|
|
119
|
+
"",
|
|
120
|
+
f"**From**: {self.source_name} v{self.source_version}",
|
|
121
|
+
f"**To**: {self.target_name} v{self.target_version}",
|
|
122
|
+
"",
|
|
123
|
+
"## Summary",
|
|
124
|
+
"",
|
|
125
|
+
f"- Total changes: {self.summary['total_changes']}",
|
|
126
|
+
f"- ➕ Added: {self.summary['added']}",
|
|
127
|
+
f"- ➖ Removed: {self.summary['removed']}",
|
|
128
|
+
f"- 📝 Modified: {self.summary['modified']}",
|
|
129
|
+
"",
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
# Group changes by element type
|
|
133
|
+
added = [c for c in self.changes if c.change_type == ChangeType.ADDED]
|
|
134
|
+
removed = [c for c in self.changes if c.change_type == ChangeType.REMOVED]
|
|
135
|
+
modified = [c for c in self.changes if c.change_type == ChangeType.MODIFIED]
|
|
136
|
+
|
|
137
|
+
if added:
|
|
138
|
+
lines.append("## ➕ Added")
|
|
139
|
+
lines.append("")
|
|
140
|
+
for c in added:
|
|
141
|
+
lines.append(f"- **{c.element_type.value}**: `{c.path}`")
|
|
142
|
+
if c.details:
|
|
143
|
+
lines.append(f" - {c.details}")
|
|
144
|
+
lines.append("")
|
|
145
|
+
|
|
146
|
+
if removed:
|
|
147
|
+
lines.append("## ➖ Removed")
|
|
148
|
+
lines.append("")
|
|
149
|
+
for c in removed:
|
|
150
|
+
lines.append(f"- **{c.element_type.value}**: `{c.path}`")
|
|
151
|
+
if c.details:
|
|
152
|
+
lines.append(f" - {c.details}")
|
|
153
|
+
lines.append("")
|
|
154
|
+
|
|
155
|
+
if modified:
|
|
156
|
+
lines.append("## 📝 Modified")
|
|
157
|
+
lines.append("")
|
|
158
|
+
for c in modified:
|
|
159
|
+
lines.append(f"- **{c.element_type.value}**: `{c.path}`")
|
|
160
|
+
if c.old_value and c.new_value:
|
|
161
|
+
lines.append(f" - Was: `{c.old_value}`")
|
|
162
|
+
lines.append(f" - Now: `{c.new_value}`")
|
|
163
|
+
if c.details:
|
|
164
|
+
lines.append(f" - {c.details}")
|
|
165
|
+
lines.append("")
|
|
166
|
+
|
|
167
|
+
return "\n".join(lines)
|
|
168
|
+
|
|
169
|
+
def to_unified_diff(self) -> str:
|
|
170
|
+
"""Generate unified diff format (like git diff)."""
|
|
171
|
+
source_lines = self._ontology_to_lines("source")
|
|
172
|
+
target_lines = self._ontology_to_lines("target")
|
|
173
|
+
|
|
174
|
+
diff = unified_diff(
|
|
175
|
+
source_lines,
|
|
176
|
+
target_lines,
|
|
177
|
+
fromfile=f"{self.source_name} v{self.source_version}",
|
|
178
|
+
tofile=f"{self.target_name} v{self.target_version}",
|
|
179
|
+
lineterm="",
|
|
180
|
+
)
|
|
181
|
+
return "\n".join(diff)
|
|
182
|
+
|
|
183
|
+
def _ontology_to_lines(self, which: str) -> List[str]:
|
|
184
|
+
"""Convert changes to text lines for diff."""
|
|
185
|
+
lines = []
|
|
186
|
+
for c in self.changes:
|
|
187
|
+
if which == "source" and c.old_value:
|
|
188
|
+
lines.append(f"{c.element_type.value}: {c.path} = {c.old_value}")
|
|
189
|
+
elif which == "target" and c.new_value:
|
|
190
|
+
lines.append(f"{c.element_type.value}: {c.path} = {c.new_value}")
|
|
191
|
+
return sorted(lines)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class OntologyDiff:
|
|
195
|
+
"""
|
|
196
|
+
Compares two ontology versions and generates diff reports.
|
|
197
|
+
|
|
198
|
+
Supports:
|
|
199
|
+
- Entity comparison (added/removed/modified)
|
|
200
|
+
- Property comparison within entities
|
|
201
|
+
- Relationship comparison
|
|
202
|
+
- Business rule comparison
|
|
203
|
+
- Metadata comparison
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
def __init__(self, source: Ontology, target: Ontology):
|
|
207
|
+
"""
|
|
208
|
+
Initialize diff tool.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
source: Original/old ontology version
|
|
212
|
+
target: New/updated ontology version
|
|
213
|
+
"""
|
|
214
|
+
self.source = source
|
|
215
|
+
self.target = target
|
|
216
|
+
|
|
217
|
+
def diff(self) -> DiffReport:
|
|
218
|
+
"""
|
|
219
|
+
Perform diff between source and target ontologies.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
DiffReport with all detected changes
|
|
223
|
+
"""
|
|
224
|
+
report = DiffReport(
|
|
225
|
+
source_name=self.source.name,
|
|
226
|
+
target_name=self.target.name,
|
|
227
|
+
source_version=self.source.version,
|
|
228
|
+
target_version=self.target.version,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# Compare all elements
|
|
232
|
+
self._diff_entities(report)
|
|
233
|
+
self._diff_relationships(report)
|
|
234
|
+
self._diff_business_rules(report)
|
|
235
|
+
self._diff_metadata(report)
|
|
236
|
+
|
|
237
|
+
report.generate_summary()
|
|
238
|
+
return report
|
|
239
|
+
|
|
240
|
+
def _diff_entities(self, report: DiffReport):
|
|
241
|
+
"""Compare entities between versions."""
|
|
242
|
+
source_entities = {e.name: e for e in self.source.entities}
|
|
243
|
+
target_entities = {e.name: e for e in self.target.entities}
|
|
244
|
+
|
|
245
|
+
source_names = set(source_entities.keys())
|
|
246
|
+
target_names = set(target_entities.keys())
|
|
247
|
+
|
|
248
|
+
# Added entities
|
|
249
|
+
for name in target_names - source_names:
|
|
250
|
+
entity = target_entities[name]
|
|
251
|
+
report.add_change(Change(
|
|
252
|
+
change_type=ChangeType.ADDED,
|
|
253
|
+
element_type=ElementType.ENTITY,
|
|
254
|
+
element_name=name,
|
|
255
|
+
path=name,
|
|
256
|
+
new_value=f"type={entity.entity_type}, properties={len(entity.properties)}",
|
|
257
|
+
details=entity.description or "",
|
|
258
|
+
))
|
|
259
|
+
|
|
260
|
+
# Removed entities
|
|
261
|
+
for name in source_names - target_names:
|
|
262
|
+
entity = source_entities[name]
|
|
263
|
+
report.add_change(Change(
|
|
264
|
+
change_type=ChangeType.REMOVED,
|
|
265
|
+
element_type=ElementType.ENTITY,
|
|
266
|
+
element_name=name,
|
|
267
|
+
path=name,
|
|
268
|
+
old_value=f"type={entity.entity_type}, properties={len(entity.properties)}",
|
|
269
|
+
details=entity.description or "",
|
|
270
|
+
))
|
|
271
|
+
|
|
272
|
+
# Modified entities
|
|
273
|
+
for name in source_names & target_names:
|
|
274
|
+
self._diff_entity(report, source_entities[name], target_entities[name])
|
|
275
|
+
|
|
276
|
+
def _diff_entity(self, report: DiffReport, source: OntologyEntity, target: OntologyEntity):
|
|
277
|
+
"""Compare a single entity between versions."""
|
|
278
|
+
# Check entity type change
|
|
279
|
+
if source.entity_type != target.entity_type:
|
|
280
|
+
report.add_change(Change(
|
|
281
|
+
change_type=ChangeType.MODIFIED,
|
|
282
|
+
element_type=ElementType.ENTITY,
|
|
283
|
+
element_name=source.name,
|
|
284
|
+
path=f"{source.name}.entity_type",
|
|
285
|
+
old_value=source.entity_type,
|
|
286
|
+
new_value=target.entity_type,
|
|
287
|
+
details="Entity type changed",
|
|
288
|
+
))
|
|
289
|
+
|
|
290
|
+
# Check description change
|
|
291
|
+
if source.description != target.description:
|
|
292
|
+
report.add_change(Change(
|
|
293
|
+
change_type=ChangeType.MODIFIED,
|
|
294
|
+
element_type=ElementType.ENTITY,
|
|
295
|
+
element_name=source.name,
|
|
296
|
+
path=f"{source.name}.description",
|
|
297
|
+
old_value=source.description,
|
|
298
|
+
new_value=target.description,
|
|
299
|
+
details="Description updated",
|
|
300
|
+
))
|
|
301
|
+
|
|
302
|
+
# Compare properties
|
|
303
|
+
self._diff_properties(report, source.name, source.properties, target.properties)
|
|
304
|
+
|
|
305
|
+
def _diff_properties(
|
|
306
|
+
self,
|
|
307
|
+
report: DiffReport,
|
|
308
|
+
entity_name: str,
|
|
309
|
+
source_props: List[OntologyProperty],
|
|
310
|
+
target_props: List[OntologyProperty],
|
|
311
|
+
):
|
|
312
|
+
"""Compare properties within an entity."""
|
|
313
|
+
source_map = {p.name: p for p in source_props}
|
|
314
|
+
target_map = {p.name: p for p in target_props}
|
|
315
|
+
|
|
316
|
+
source_names = set(source_map.keys())
|
|
317
|
+
target_names = set(target_map.keys())
|
|
318
|
+
|
|
319
|
+
# Added properties
|
|
320
|
+
for name in target_names - source_names:
|
|
321
|
+
prop = target_map[name]
|
|
322
|
+
report.add_change(Change(
|
|
323
|
+
change_type=ChangeType.ADDED,
|
|
324
|
+
element_type=ElementType.PROPERTY,
|
|
325
|
+
element_name=name,
|
|
326
|
+
path=f"{entity_name}.{name}",
|
|
327
|
+
new_value=f"type={prop.data_type}, required={prop.required}",
|
|
328
|
+
details=prop.description or "",
|
|
329
|
+
))
|
|
330
|
+
|
|
331
|
+
# Removed properties
|
|
332
|
+
for name in source_names - target_names:
|
|
333
|
+
prop = source_map[name]
|
|
334
|
+
report.add_change(Change(
|
|
335
|
+
change_type=ChangeType.REMOVED,
|
|
336
|
+
element_type=ElementType.PROPERTY,
|
|
337
|
+
element_name=name,
|
|
338
|
+
path=f"{entity_name}.{name}",
|
|
339
|
+
old_value=f"type={prop.data_type}, required={prop.required}",
|
|
340
|
+
details=prop.description or "",
|
|
341
|
+
))
|
|
342
|
+
|
|
343
|
+
# Modified properties
|
|
344
|
+
for name in source_names & target_names:
|
|
345
|
+
self._diff_property(report, entity_name, source_map[name], target_map[name])
|
|
346
|
+
|
|
347
|
+
def _diff_property(
|
|
348
|
+
self,
|
|
349
|
+
report: DiffReport,
|
|
350
|
+
entity_name: str,
|
|
351
|
+
source: OntologyProperty,
|
|
352
|
+
target: OntologyProperty,
|
|
353
|
+
):
|
|
354
|
+
"""Compare a single property between versions."""
|
|
355
|
+
path = f"{entity_name}.{source.name}"
|
|
356
|
+
|
|
357
|
+
# Check data type
|
|
358
|
+
if source.data_type != target.data_type:
|
|
359
|
+
report.add_change(Change(
|
|
360
|
+
change_type=ChangeType.MODIFIED,
|
|
361
|
+
element_type=ElementType.PROPERTY,
|
|
362
|
+
element_name=source.name,
|
|
363
|
+
path=f"{path}.data_type",
|
|
364
|
+
old_value=source.data_type,
|
|
365
|
+
new_value=target.data_type,
|
|
366
|
+
details="Data type changed",
|
|
367
|
+
))
|
|
368
|
+
|
|
369
|
+
# Check required
|
|
370
|
+
if source.required != target.required:
|
|
371
|
+
report.add_change(Change(
|
|
372
|
+
change_type=ChangeType.MODIFIED,
|
|
373
|
+
element_type=ElementType.PROPERTY,
|
|
374
|
+
element_name=source.name,
|
|
375
|
+
path=f"{path}.required",
|
|
376
|
+
old_value=str(source.required),
|
|
377
|
+
new_value=str(target.required),
|
|
378
|
+
details="Required flag changed",
|
|
379
|
+
))
|
|
380
|
+
|
|
381
|
+
# Check unique
|
|
382
|
+
if source.unique != target.unique:
|
|
383
|
+
report.add_change(Change(
|
|
384
|
+
change_type=ChangeType.MODIFIED,
|
|
385
|
+
element_type=ElementType.PROPERTY,
|
|
386
|
+
element_name=source.name,
|
|
387
|
+
path=f"{path}.unique",
|
|
388
|
+
old_value=str(source.unique),
|
|
389
|
+
new_value=str(target.unique),
|
|
390
|
+
details="Unique flag changed",
|
|
391
|
+
))
|
|
392
|
+
|
|
393
|
+
def _diff_relationships(self, report: DiffReport):
|
|
394
|
+
"""Compare relationships between versions."""
|
|
395
|
+
def rel_key(r: OntologyRelationship) -> str:
|
|
396
|
+
return f"{r.from_entity}→{r.to_entity}"
|
|
397
|
+
|
|
398
|
+
source_rels = {rel_key(r): r for r in self.source.relationships}
|
|
399
|
+
target_rels = {rel_key(r): r for r in self.target.relationships}
|
|
400
|
+
|
|
401
|
+
source_keys = set(source_rels.keys())
|
|
402
|
+
target_keys = set(target_rels.keys())
|
|
403
|
+
|
|
404
|
+
# Added relationships
|
|
405
|
+
for key in target_keys - source_keys:
|
|
406
|
+
rel = target_rels[key]
|
|
407
|
+
report.add_change(Change(
|
|
408
|
+
change_type=ChangeType.ADDED,
|
|
409
|
+
element_type=ElementType.RELATIONSHIP,
|
|
410
|
+
element_name=key,
|
|
411
|
+
path=key,
|
|
412
|
+
new_value=f"type={rel.relationship_type}, cardinality={rel.cardinality}",
|
|
413
|
+
details=rel.description or "",
|
|
414
|
+
))
|
|
415
|
+
|
|
416
|
+
# Removed relationships
|
|
417
|
+
for key in source_keys - target_keys:
|
|
418
|
+
rel = source_rels[key]
|
|
419
|
+
report.add_change(Change(
|
|
420
|
+
change_type=ChangeType.REMOVED,
|
|
421
|
+
element_type=ElementType.RELATIONSHIP,
|
|
422
|
+
element_name=key,
|
|
423
|
+
path=key,
|
|
424
|
+
old_value=f"type={rel.relationship_type}, cardinality={rel.cardinality}",
|
|
425
|
+
details=rel.description or "",
|
|
426
|
+
))
|
|
427
|
+
|
|
428
|
+
# Modified relationships
|
|
429
|
+
for key in source_keys & target_keys:
|
|
430
|
+
self._diff_relationship(report, source_rels[key], target_rels[key])
|
|
431
|
+
|
|
432
|
+
def _diff_relationship(
|
|
433
|
+
self,
|
|
434
|
+
report: DiffReport,
|
|
435
|
+
source: OntologyRelationship,
|
|
436
|
+
target: OntologyRelationship,
|
|
437
|
+
):
|
|
438
|
+
"""Compare a single relationship between versions."""
|
|
439
|
+
key = f"{source.from_entity}→{source.to_entity}"
|
|
440
|
+
|
|
441
|
+
if source.relationship_type != target.relationship_type:
|
|
442
|
+
report.add_change(Change(
|
|
443
|
+
change_type=ChangeType.MODIFIED,
|
|
444
|
+
element_type=ElementType.RELATIONSHIP,
|
|
445
|
+
element_name=key,
|
|
446
|
+
path=f"{key}.type",
|
|
447
|
+
old_value=source.relationship_type,
|
|
448
|
+
new_value=target.relationship_type,
|
|
449
|
+
details="Relationship type changed",
|
|
450
|
+
))
|
|
451
|
+
|
|
452
|
+
if source.cardinality != target.cardinality:
|
|
453
|
+
report.add_change(Change(
|
|
454
|
+
change_type=ChangeType.MODIFIED,
|
|
455
|
+
element_type=ElementType.RELATIONSHIP,
|
|
456
|
+
element_name=key,
|
|
457
|
+
path=f"{key}.cardinality",
|
|
458
|
+
old_value=source.cardinality,
|
|
459
|
+
new_value=target.cardinality,
|
|
460
|
+
details="Cardinality changed",
|
|
461
|
+
))
|
|
462
|
+
|
|
463
|
+
def _diff_business_rules(self, report: DiffReport):
|
|
464
|
+
"""Compare business rules between versions."""
|
|
465
|
+
source_rules = {r.name: r for r in self.source.business_rules}
|
|
466
|
+
target_rules = {r.name: r for r in self.target.business_rules}
|
|
467
|
+
|
|
468
|
+
source_names = set(source_rules.keys())
|
|
469
|
+
target_names = set(target_rules.keys())
|
|
470
|
+
|
|
471
|
+
# Added rules
|
|
472
|
+
for name in target_names - source_names:
|
|
473
|
+
rule = target_rules[name]
|
|
474
|
+
report.add_change(Change(
|
|
475
|
+
change_type=ChangeType.ADDED,
|
|
476
|
+
element_type=ElementType.RULE,
|
|
477
|
+
element_name=name,
|
|
478
|
+
path=f"rule:{name}",
|
|
479
|
+
new_value=f"condition={rule.condition}, action={rule.action}",
|
|
480
|
+
details=rule.description or "",
|
|
481
|
+
))
|
|
482
|
+
|
|
483
|
+
# Removed rules
|
|
484
|
+
for name in source_names - target_names:
|
|
485
|
+
rule = source_rules[name]
|
|
486
|
+
report.add_change(Change(
|
|
487
|
+
change_type=ChangeType.REMOVED,
|
|
488
|
+
element_type=ElementType.RULE,
|
|
489
|
+
element_name=name,
|
|
490
|
+
path=f"rule:{name}",
|
|
491
|
+
old_value=f"condition={rule.condition}, action={rule.action}",
|
|
492
|
+
details=rule.description or "",
|
|
493
|
+
))
|
|
494
|
+
|
|
495
|
+
# Modified rules
|
|
496
|
+
for name in source_names & target_names:
|
|
497
|
+
self._diff_rule(report, source_rules[name], target_rules[name])
|
|
498
|
+
|
|
499
|
+
def _diff_rule(self, report: DiffReport, source: BusinessRule, target: BusinessRule):
|
|
500
|
+
"""Compare a single business rule between versions."""
|
|
501
|
+
path = f"rule:{source.name}"
|
|
502
|
+
|
|
503
|
+
if source.condition != target.condition:
|
|
504
|
+
report.add_change(Change(
|
|
505
|
+
change_type=ChangeType.MODIFIED,
|
|
506
|
+
element_type=ElementType.RULE,
|
|
507
|
+
element_name=source.name,
|
|
508
|
+
path=f"{path}.condition",
|
|
509
|
+
old_value=source.condition,
|
|
510
|
+
new_value=target.condition,
|
|
511
|
+
details="Condition changed",
|
|
512
|
+
))
|
|
513
|
+
|
|
514
|
+
if source.action != target.action:
|
|
515
|
+
report.add_change(Change(
|
|
516
|
+
change_type=ChangeType.MODIFIED,
|
|
517
|
+
element_type=ElementType.RULE,
|
|
518
|
+
element_name=source.name,
|
|
519
|
+
path=f"{path}.action",
|
|
520
|
+
old_value=source.action,
|
|
521
|
+
new_value=target.action,
|
|
522
|
+
details="Action changed",
|
|
523
|
+
))
|
|
524
|
+
|
|
525
|
+
if source.classification != target.classification:
|
|
526
|
+
report.add_change(Change(
|
|
527
|
+
change_type=ChangeType.MODIFIED,
|
|
528
|
+
element_type=ElementType.RULE,
|
|
529
|
+
element_name=source.name,
|
|
530
|
+
path=f"{path}.classification",
|
|
531
|
+
old_value=source.classification,
|
|
532
|
+
new_value=target.classification,
|
|
533
|
+
details="Classification changed",
|
|
534
|
+
))
|
|
535
|
+
|
|
536
|
+
def _diff_metadata(self, report: DiffReport):
|
|
537
|
+
"""Compare metadata between versions."""
|
|
538
|
+
source_meta = self.source.metadata or {}
|
|
539
|
+
target_meta = self.target.metadata or {}
|
|
540
|
+
|
|
541
|
+
source_keys = set(source_meta.keys())
|
|
542
|
+
target_keys = set(target_meta.keys())
|
|
543
|
+
|
|
544
|
+
# Added metadata
|
|
545
|
+
for key in target_keys - source_keys:
|
|
546
|
+
report.add_change(Change(
|
|
547
|
+
change_type=ChangeType.ADDED,
|
|
548
|
+
element_type=ElementType.METADATA,
|
|
549
|
+
element_name=key,
|
|
550
|
+
path=f"metadata:{key}",
|
|
551
|
+
new_value=str(target_meta[key]),
|
|
552
|
+
))
|
|
553
|
+
|
|
554
|
+
# Removed metadata
|
|
555
|
+
for key in source_keys - target_keys:
|
|
556
|
+
report.add_change(Change(
|
|
557
|
+
change_type=ChangeType.REMOVED,
|
|
558
|
+
element_type=ElementType.METADATA,
|
|
559
|
+
element_name=key,
|
|
560
|
+
path=f"metadata:{key}",
|
|
561
|
+
old_value=str(source_meta[key]),
|
|
562
|
+
))
|
|
563
|
+
|
|
564
|
+
# Modified metadata
|
|
565
|
+
for key in source_keys & target_keys:
|
|
566
|
+
if source_meta[key] != target_meta[key]:
|
|
567
|
+
report.add_change(Change(
|
|
568
|
+
change_type=ChangeType.MODIFIED,
|
|
569
|
+
element_type=ElementType.METADATA,
|
|
570
|
+
element_name=key,
|
|
571
|
+
path=f"metadata:{key}",
|
|
572
|
+
old_value=str(source_meta[key]),
|
|
573
|
+
new_value=str(target_meta[key]),
|
|
574
|
+
))
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
class OntologyMerge:
|
|
578
|
+
"""
|
|
579
|
+
Merge two ontology versions.
|
|
580
|
+
|
|
581
|
+
Supports:
|
|
582
|
+
- Three-way merge (base, ours, theirs)
|
|
583
|
+
- Conflict detection
|
|
584
|
+
- Auto-resolution for non-conflicting changes
|
|
585
|
+
"""
|
|
586
|
+
|
|
587
|
+
def __init__(self, base: Ontology, ours: Ontology, theirs: Ontology):
|
|
588
|
+
"""
|
|
589
|
+
Initialize merge tool.
|
|
590
|
+
|
|
591
|
+
Args:
|
|
592
|
+
base: Common ancestor version
|
|
593
|
+
ours: Our modified version
|
|
594
|
+
theirs: Their modified version
|
|
595
|
+
"""
|
|
596
|
+
self.base = base
|
|
597
|
+
self.ours = ours
|
|
598
|
+
self.theirs = theirs
|
|
599
|
+
self.conflicts: List[Dict[str, Any]] = []
|
|
600
|
+
|
|
601
|
+
def merge(self, strategy: str = "ours") -> Tuple[Ontology, List[Dict[str, Any]]]:
|
|
602
|
+
"""
|
|
603
|
+
Perform three-way merge.
|
|
604
|
+
|
|
605
|
+
Args:
|
|
606
|
+
strategy: Conflict resolution strategy ("ours", "theirs", "manual")
|
|
607
|
+
|
|
608
|
+
Returns:
|
|
609
|
+
Tuple of (merged ontology, list of conflicts)
|
|
610
|
+
"""
|
|
611
|
+
# Diff both versions against base
|
|
612
|
+
our_diff = OntologyDiff(self.base, self.ours).diff()
|
|
613
|
+
their_diff = OntologyDiff(self.base, self.theirs).diff()
|
|
614
|
+
|
|
615
|
+
# Detect conflicts (same element changed in both)
|
|
616
|
+
our_paths = {c.path for c in our_diff.changes}
|
|
617
|
+
their_paths = {c.path for c in their_diff.changes}
|
|
618
|
+
conflict_paths = our_paths & their_paths
|
|
619
|
+
|
|
620
|
+
# Build merged ontology
|
|
621
|
+
merged_entities = self._merge_entities(our_diff, their_diff, conflict_paths, strategy)
|
|
622
|
+
merged_relationships = self._merge_relationships(our_diff, their_diff, conflict_paths, strategy)
|
|
623
|
+
merged_rules = self._merge_rules(our_diff, their_diff, conflict_paths, strategy)
|
|
624
|
+
|
|
625
|
+
merged = Ontology(
|
|
626
|
+
name=self.ours.name,
|
|
627
|
+
version=self._increment_version(self.ours.version),
|
|
628
|
+
source=self.ours.source,
|
|
629
|
+
entities=merged_entities,
|
|
630
|
+
relationships=merged_relationships,
|
|
631
|
+
business_rules=merged_rules,
|
|
632
|
+
metadata={
|
|
633
|
+
**self.base.metadata,
|
|
634
|
+
**self.theirs.metadata,
|
|
635
|
+
**self.ours.metadata,
|
|
636
|
+
"merged_from": [self.ours.name, self.theirs.name],
|
|
637
|
+
},
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
return merged, self.conflicts
|
|
641
|
+
|
|
642
|
+
def _merge_entities(
|
|
643
|
+
self,
|
|
644
|
+
our_diff: DiffReport,
|
|
645
|
+
their_diff: DiffReport,
|
|
646
|
+
conflict_paths: Set[str],
|
|
647
|
+
strategy: str,
|
|
648
|
+
) -> List[OntologyEntity]:
|
|
649
|
+
"""Merge entities from both versions."""
|
|
650
|
+
# Start with our entities
|
|
651
|
+
merged = {e.name: e for e in self.ours.entities}
|
|
652
|
+
|
|
653
|
+
# Add their new entities
|
|
654
|
+
for change in their_diff.changes:
|
|
655
|
+
if (
|
|
656
|
+
change.element_type == ElementType.ENTITY
|
|
657
|
+
and change.change_type == ChangeType.ADDED
|
|
658
|
+
):
|
|
659
|
+
# Check if we also added it
|
|
660
|
+
if change.path not in conflict_paths:
|
|
661
|
+
# Find the entity in theirs
|
|
662
|
+
for e in self.theirs.entities:
|
|
663
|
+
if e.name == change.element_name:
|
|
664
|
+
merged[e.name] = e
|
|
665
|
+
break
|
|
666
|
+
else:
|
|
667
|
+
self._record_conflict(change.path, "entity", strategy)
|
|
668
|
+
|
|
669
|
+
return list(merged.values())
|
|
670
|
+
|
|
671
|
+
def _merge_relationships(
|
|
672
|
+
self,
|
|
673
|
+
our_diff: DiffReport,
|
|
674
|
+
their_diff: DiffReport,
|
|
675
|
+
conflict_paths: Set[str],
|
|
676
|
+
strategy: str,
|
|
677
|
+
) -> List[OntologyRelationship]:
|
|
678
|
+
"""Merge relationships from both versions."""
|
|
679
|
+
merged = {f"{r.from_entity}→{r.to_entity}": r for r in self.ours.relationships}
|
|
680
|
+
|
|
681
|
+
for change in their_diff.changes:
|
|
682
|
+
if (
|
|
683
|
+
change.element_type == ElementType.RELATIONSHIP
|
|
684
|
+
and change.change_type == ChangeType.ADDED
|
|
685
|
+
):
|
|
686
|
+
if change.path not in conflict_paths:
|
|
687
|
+
for r in self.theirs.relationships:
|
|
688
|
+
key = f"{r.from_entity}→{r.to_entity}"
|
|
689
|
+
if key == change.element_name:
|
|
690
|
+
merged[key] = r
|
|
691
|
+
break
|
|
692
|
+
else:
|
|
693
|
+
self._record_conflict(change.path, "relationship", strategy)
|
|
694
|
+
|
|
695
|
+
return list(merged.values())
|
|
696
|
+
|
|
697
|
+
def _merge_rules(
|
|
698
|
+
self,
|
|
699
|
+
our_diff: DiffReport,
|
|
700
|
+
their_diff: DiffReport,
|
|
701
|
+
conflict_paths: Set[str],
|
|
702
|
+
strategy: str,
|
|
703
|
+
) -> List[BusinessRule]:
|
|
704
|
+
"""Merge business rules from both versions."""
|
|
705
|
+
merged = {r.name: r for r in self.ours.business_rules}
|
|
706
|
+
|
|
707
|
+
for change in their_diff.changes:
|
|
708
|
+
if (
|
|
709
|
+
change.element_type == ElementType.RULE
|
|
710
|
+
and change.change_type == ChangeType.ADDED
|
|
711
|
+
):
|
|
712
|
+
if change.path not in conflict_paths:
|
|
713
|
+
for r in self.theirs.business_rules:
|
|
714
|
+
if r.name == change.element_name:
|
|
715
|
+
merged[r.name] = r
|
|
716
|
+
break
|
|
717
|
+
else:
|
|
718
|
+
self._record_conflict(change.path, "rule", strategy)
|
|
719
|
+
|
|
720
|
+
return list(merged.values())
|
|
721
|
+
|
|
722
|
+
def _record_conflict(self, path: str, element_type: str, strategy: str):
|
|
723
|
+
"""Record a merge conflict."""
|
|
724
|
+
self.conflicts.append({
|
|
725
|
+
"path": path,
|
|
726
|
+
"element_type": element_type,
|
|
727
|
+
"resolution": strategy,
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
def _increment_version(self, version: str) -> str:
|
|
731
|
+
"""Increment version number."""
|
|
732
|
+
parts = version.split(".")
|
|
733
|
+
if len(parts) >= 2:
|
|
734
|
+
try:
|
|
735
|
+
parts[-1] = str(int(parts[-1]) + 1)
|
|
736
|
+
return ".".join(parts)
|
|
737
|
+
except ValueError:
|
|
738
|
+
pass
|
|
739
|
+
return f"{version}.1"
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def diff_ontologies(source: Ontology, target: Ontology) -> DiffReport:
|
|
743
|
+
"""
|
|
744
|
+
Convenience function to diff two ontologies.
|
|
745
|
+
|
|
746
|
+
Args:
|
|
747
|
+
source: Original ontology
|
|
748
|
+
target: Updated ontology
|
|
749
|
+
|
|
750
|
+
Returns:
|
|
751
|
+
DiffReport with all changes
|
|
752
|
+
"""
|
|
753
|
+
differ = OntologyDiff(source, target)
|
|
754
|
+
return differ.diff()
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def merge_ontologies(
|
|
758
|
+
base: Ontology,
|
|
759
|
+
ours: Ontology,
|
|
760
|
+
theirs: Ontology,
|
|
761
|
+
strategy: str = "ours",
|
|
762
|
+
) -> Tuple[Ontology, List[Dict[str, Any]]]:
|
|
763
|
+
"""
|
|
764
|
+
Convenience function to merge ontologies.
|
|
765
|
+
|
|
766
|
+
Args:
|
|
767
|
+
base: Common ancestor
|
|
768
|
+
ours: Our version
|
|
769
|
+
theirs: Their version
|
|
770
|
+
strategy: Conflict resolution strategy
|
|
771
|
+
|
|
772
|
+
Returns:
|
|
773
|
+
Tuple of (merged ontology, conflicts)
|
|
774
|
+
"""
|
|
775
|
+
merger = OntologyMerge(base, ours, theirs)
|
|
776
|
+
return merger.merge(strategy)
|