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.
@@ -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)