rdf-construct 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. rdf_construct/__init__.py +12 -0
  2. rdf_construct/__main__.py +0 -0
  3. rdf_construct/cli.py +3429 -0
  4. rdf_construct/core/__init__.py +33 -0
  5. rdf_construct/core/config.py +116 -0
  6. rdf_construct/core/ordering.py +219 -0
  7. rdf_construct/core/predicate_order.py +212 -0
  8. rdf_construct/core/profile.py +157 -0
  9. rdf_construct/core/selector.py +64 -0
  10. rdf_construct/core/serialiser.py +232 -0
  11. rdf_construct/core/utils.py +89 -0
  12. rdf_construct/cq/__init__.py +77 -0
  13. rdf_construct/cq/expectations.py +365 -0
  14. rdf_construct/cq/formatters/__init__.py +45 -0
  15. rdf_construct/cq/formatters/json.py +104 -0
  16. rdf_construct/cq/formatters/junit.py +104 -0
  17. rdf_construct/cq/formatters/text.py +146 -0
  18. rdf_construct/cq/loader.py +300 -0
  19. rdf_construct/cq/runner.py +321 -0
  20. rdf_construct/diff/__init__.py +59 -0
  21. rdf_construct/diff/change_types.py +214 -0
  22. rdf_construct/diff/comparator.py +338 -0
  23. rdf_construct/diff/filters.py +133 -0
  24. rdf_construct/diff/formatters/__init__.py +71 -0
  25. rdf_construct/diff/formatters/json.py +192 -0
  26. rdf_construct/diff/formatters/markdown.py +210 -0
  27. rdf_construct/diff/formatters/text.py +195 -0
  28. rdf_construct/docs/__init__.py +60 -0
  29. rdf_construct/docs/config.py +238 -0
  30. rdf_construct/docs/extractors.py +603 -0
  31. rdf_construct/docs/generator.py +360 -0
  32. rdf_construct/docs/renderers/__init__.py +7 -0
  33. rdf_construct/docs/renderers/html.py +803 -0
  34. rdf_construct/docs/renderers/json.py +390 -0
  35. rdf_construct/docs/renderers/markdown.py +628 -0
  36. rdf_construct/docs/search.py +278 -0
  37. rdf_construct/docs/templates/html/base.html.jinja +44 -0
  38. rdf_construct/docs/templates/html/class.html.jinja +152 -0
  39. rdf_construct/docs/templates/html/hierarchy.html.jinja +28 -0
  40. rdf_construct/docs/templates/html/index.html.jinja +110 -0
  41. rdf_construct/docs/templates/html/instance.html.jinja +90 -0
  42. rdf_construct/docs/templates/html/namespaces.html.jinja +37 -0
  43. rdf_construct/docs/templates/html/property.html.jinja +124 -0
  44. rdf_construct/docs/templates/html/single_page.html.jinja +169 -0
  45. rdf_construct/lint/__init__.py +75 -0
  46. rdf_construct/lint/config.py +214 -0
  47. rdf_construct/lint/engine.py +396 -0
  48. rdf_construct/lint/formatters.py +327 -0
  49. rdf_construct/lint/rules.py +692 -0
  50. rdf_construct/localise/__init__.py +114 -0
  51. rdf_construct/localise/config.py +508 -0
  52. rdf_construct/localise/extractor.py +427 -0
  53. rdf_construct/localise/formatters/__init__.py +36 -0
  54. rdf_construct/localise/formatters/markdown.py +229 -0
  55. rdf_construct/localise/formatters/text.py +224 -0
  56. rdf_construct/localise/merger.py +346 -0
  57. rdf_construct/localise/reporter.py +356 -0
  58. rdf_construct/main.py +6 -0
  59. rdf_construct/merge/__init__.py +165 -0
  60. rdf_construct/merge/config.py +354 -0
  61. rdf_construct/merge/conflicts.py +281 -0
  62. rdf_construct/merge/formatters.py +426 -0
  63. rdf_construct/merge/merger.py +425 -0
  64. rdf_construct/merge/migrator.py +339 -0
  65. rdf_construct/merge/rules.py +377 -0
  66. rdf_construct/merge/splitter.py +1102 -0
  67. rdf_construct/puml2rdf/__init__.py +103 -0
  68. rdf_construct/puml2rdf/config.py +230 -0
  69. rdf_construct/puml2rdf/converter.py +420 -0
  70. rdf_construct/puml2rdf/merger.py +200 -0
  71. rdf_construct/puml2rdf/model.py +202 -0
  72. rdf_construct/puml2rdf/parser.py +565 -0
  73. rdf_construct/puml2rdf/validators.py +451 -0
  74. rdf_construct/refactor/__init__.py +72 -0
  75. rdf_construct/refactor/config.py +362 -0
  76. rdf_construct/refactor/deprecator.py +328 -0
  77. rdf_construct/refactor/formatters/__init__.py +8 -0
  78. rdf_construct/refactor/formatters/text.py +311 -0
  79. rdf_construct/refactor/renamer.py +294 -0
  80. rdf_construct/shacl/__init__.py +56 -0
  81. rdf_construct/shacl/config.py +166 -0
  82. rdf_construct/shacl/converters.py +520 -0
  83. rdf_construct/shacl/generator.py +364 -0
  84. rdf_construct/shacl/namespaces.py +93 -0
  85. rdf_construct/stats/__init__.py +29 -0
  86. rdf_construct/stats/collector.py +178 -0
  87. rdf_construct/stats/comparator.py +298 -0
  88. rdf_construct/stats/formatters/__init__.py +83 -0
  89. rdf_construct/stats/formatters/json.py +38 -0
  90. rdf_construct/stats/formatters/markdown.py +153 -0
  91. rdf_construct/stats/formatters/text.py +186 -0
  92. rdf_construct/stats/metrics/__init__.py +26 -0
  93. rdf_construct/stats/metrics/basic.py +147 -0
  94. rdf_construct/stats/metrics/complexity.py +137 -0
  95. rdf_construct/stats/metrics/connectivity.py +130 -0
  96. rdf_construct/stats/metrics/documentation.py +128 -0
  97. rdf_construct/stats/metrics/hierarchy.py +207 -0
  98. rdf_construct/stats/metrics/properties.py +88 -0
  99. rdf_construct/uml/__init__.py +22 -0
  100. rdf_construct/uml/context.py +194 -0
  101. rdf_construct/uml/mapper.py +371 -0
  102. rdf_construct/uml/odm_renderer.py +789 -0
  103. rdf_construct/uml/renderer.py +684 -0
  104. rdf_construct/uml/uml_layout.py +393 -0
  105. rdf_construct/uml/uml_style.py +613 -0
  106. rdf_construct-0.3.0.dist-info/METADATA +496 -0
  107. rdf_construct-0.3.0.dist-info/RECORD +110 -0
  108. rdf_construct-0.3.0.dist-info/WHEEL +4 -0
  109. rdf_construct-0.3.0.dist-info/entry_points.txt +3 -0
  110. rdf_construct-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,338 @@
1
+ """Core comparison logic for semantic RDF graph diffing.
2
+
3
+ The algorithm:
4
+ 1. Parse both graphs into rdflib Graph objects
5
+ 2. Compute triple sets: added = new - old, removed = old - new
6
+ 3. Group changes by subject
7
+ 4. Classify entities as added/removed/modified
8
+ 5. Determine entity types and extract metadata
9
+ """
10
+
11
+ from collections import defaultdict
12
+ from pathlib import Path
13
+
14
+ from rdflib import Graph, RDF, RDFS, URIRef, BNode, Literal
15
+ from rdflib.namespace import OWL
16
+ from rdflib.term import Node
17
+
18
+ from rdf_construct.diff.change_types import (
19
+ ChangeType,
20
+ EntityChange,
21
+ EntityType,
22
+ GraphDiff,
23
+ TripleChange,
24
+ )
25
+
26
+
27
+ def compare_graphs(
28
+ old_graph: Graph,
29
+ new_graph: Graph,
30
+ old_path: str = "old",
31
+ new_path: str = "new",
32
+ ignore_predicates: set[URIRef] | None = None,
33
+ ) -> GraphDiff:
34
+ """Compare two RDF graphs and return semantic differences.
35
+
36
+ Uses set operations on triples to find changes, then groups by subject
37
+ and classifies each entity as added, removed, or modified.
38
+
39
+ Args:
40
+ old_graph: The baseline/original graph.
41
+ new_graph: The new/updated graph.
42
+ old_path: Display name for the old graph.
43
+ new_path: Display name for the new graph.
44
+ ignore_predicates: Set of predicates to ignore in comparison.
45
+
46
+ Returns:
47
+ GraphDiff containing all semantic changes.
48
+ """
49
+ ignore_predicates = ignore_predicates or set()
50
+
51
+ # Compute triple-level differences using set operations
52
+ old_triples = set(old_graph)
53
+ new_triples = set(new_graph)
54
+
55
+ added_triples = new_triples - old_triples
56
+ removed_triples = old_triples - new_triples
57
+
58
+ # Filter out ignored predicates
59
+ if ignore_predicates:
60
+ added_triples = {
61
+ (s, p, o) for s, p, o in added_triples if p not in ignore_predicates
62
+ }
63
+ removed_triples = {
64
+ (s, p, o) for s, p, o in removed_triples if p not in ignore_predicates
65
+ }
66
+
67
+ # Group changes by subject
68
+ changes_by_subject: dict[Node, dict[str, list[tuple]]] = defaultdict(
69
+ lambda: {"added": [], "removed": []}
70
+ )
71
+
72
+ for s, p, o in added_triples:
73
+ changes_by_subject[s]["added"].append((p, o))
74
+
75
+ for s, p, o in removed_triples:
76
+ changes_by_subject[s]["removed"].append((p, o))
77
+
78
+ # Track if we encountered blank nodes
79
+ has_blank_nodes = any(isinstance(s, BNode) for s in changes_by_subject)
80
+
81
+ # All subjects in each graph (for determining new vs removed entities)
82
+ old_subjects = {s for s, _, _ in old_graph}
83
+ new_subjects = {s for s, _, _ in new_graph}
84
+
85
+ # Classify each changed subject
86
+ added_entities: list[EntityChange] = []
87
+ removed_entities: list[EntityChange] = []
88
+ modified_entities: list[EntityChange] = []
89
+
90
+ for subject, changes in changes_by_subject.items():
91
+ # Skip blank nodes for deep analysis (just flag them)
92
+ if isinstance(subject, BNode):
93
+ continue
94
+
95
+ subject_in_old = subject in old_subjects
96
+ subject_in_new = subject in new_subjects
97
+
98
+ # Determine entity type from the appropriate graph
99
+ if subject_in_new:
100
+ entity_type = _determine_entity_type(new_graph, subject)
101
+ label = _get_label(new_graph, subject)
102
+ superclasses = _get_superclasses(new_graph, subject)
103
+ else:
104
+ entity_type = _determine_entity_type(old_graph, subject)
105
+ label = _get_label(old_graph, subject)
106
+ superclasses = _get_superclasses(old_graph, subject)
107
+
108
+ # Create triple changes
109
+ added_triple_changes = [
110
+ TripleChange(predicate=p, object=o, is_addition=True)
111
+ for p, o in changes["added"]
112
+ ]
113
+ removed_triple_changes = [
114
+ TripleChange(predicate=p, object=o, is_addition=False)
115
+ for p, o in changes["removed"]
116
+ ]
117
+
118
+ # Classify the entity change
119
+ if not subject_in_old and subject_in_new:
120
+ # New entity (all its triples are in added)
121
+ entity = EntityChange(
122
+ uri=subject,
123
+ entity_type=entity_type,
124
+ change_type=ChangeType.ADDED,
125
+ label=label,
126
+ added_triples=added_triple_changes,
127
+ removed_triples=[],
128
+ superclasses=superclasses,
129
+ )
130
+ added_entities.append(entity)
131
+
132
+ elif subject_in_old and not subject_in_new:
133
+ # Removed entity (all its triples are in removed)
134
+ entity = EntityChange(
135
+ uri=subject,
136
+ entity_type=entity_type,
137
+ change_type=ChangeType.REMOVED,
138
+ label=label,
139
+ added_triples=[],
140
+ removed_triples=removed_triple_changes,
141
+ superclasses=superclasses,
142
+ )
143
+ removed_entities.append(entity)
144
+
145
+ else:
146
+ # Modified entity (exists in both, but has changes)
147
+ entity = EntityChange(
148
+ uri=subject,
149
+ entity_type=entity_type,
150
+ change_type=ChangeType.MODIFIED,
151
+ label=label,
152
+ added_triples=added_triple_changes,
153
+ removed_triples=removed_triple_changes,
154
+ superclasses=superclasses,
155
+ )
156
+ modified_entities.append(entity)
157
+
158
+ # Sort entities by label/URI for consistent output
159
+ added_entities.sort(key=lambda e: e.label or str(e.uri))
160
+ removed_entities.sort(key=lambda e: e.label or str(e.uri))
161
+ modified_entities.sort(key=lambda e: e.label or str(e.uri))
162
+
163
+ return GraphDiff(
164
+ old_path=old_path,
165
+ new_path=new_path,
166
+ added=added_entities,
167
+ removed=removed_entities,
168
+ modified=modified_entities,
169
+ blank_node_warning=has_blank_nodes,
170
+ )
171
+
172
+
173
+ def compare_files(
174
+ old_path: Path,
175
+ new_path: Path,
176
+ ignore_predicates: set[URIRef] | None = None,
177
+ ) -> GraphDiff:
178
+ """Compare two RDF files and return semantic differences.
179
+
180
+ Convenience wrapper that handles file loading.
181
+
182
+ Args:
183
+ old_path: Path to the baseline/original file.
184
+ new_path: Path to the new/updated file.
185
+ ignore_predicates: Set of predicates to ignore in comparison.
186
+
187
+ Returns:
188
+ GraphDiff containing all semantic changes.
189
+
190
+ Raises:
191
+ FileNotFoundError: If either file doesn't exist.
192
+ ValueError: If files can't be parsed as RDF.
193
+ """
194
+ old_graph = _load_graph(old_path)
195
+ new_graph = _load_graph(new_path)
196
+
197
+ return compare_graphs(
198
+ old_graph,
199
+ new_graph,
200
+ old_path=old_path.name,
201
+ new_path=new_path.name,
202
+ ignore_predicates=ignore_predicates,
203
+ )
204
+
205
+
206
+ def _load_graph(path: Path) -> Graph:
207
+ """Load an RDF file into a graph, guessing format from extension.
208
+
209
+ Args:
210
+ path: Path to the RDF file.
211
+
212
+ Returns:
213
+ Loaded rdflib Graph.
214
+
215
+ Raises:
216
+ FileNotFoundError: If file doesn't exist.
217
+ ValueError: If file can't be parsed.
218
+ """
219
+ if not path.exists():
220
+ raise FileNotFoundError(f"File not found: {path}")
221
+
222
+ graph = Graph()
223
+
224
+ # Guess format from extension
225
+ suffix = path.suffix.lower()
226
+ format_map = {
227
+ ".ttl": "turtle",
228
+ ".turtle": "turtle",
229
+ ".rdf": "xml",
230
+ ".xml": "xml",
231
+ ".owl": "xml",
232
+ ".nt": "nt",
233
+ ".ntriples": "nt",
234
+ ".n3": "n3",
235
+ ".jsonld": "json-ld",
236
+ ".json": "json-ld",
237
+ }
238
+ fmt = format_map.get(suffix, "turtle")
239
+
240
+ try:
241
+ graph.parse(str(path), format=fmt)
242
+ except Exception as e:
243
+ raise ValueError(f"Failed to parse {path}: {e}")
244
+
245
+ return graph
246
+
247
+
248
+ def _determine_entity_type(graph: Graph, subject: URIRef) -> EntityType:
249
+ """Determine the semantic type of an entity.
250
+
251
+ Args:
252
+ graph: The RDF graph containing the entity.
253
+ subject: The entity URI.
254
+
255
+ Returns:
256
+ The EntityType classification.
257
+ """
258
+ types = set(graph.objects(subject, RDF.type))
259
+
260
+ # Check for ontology
261
+ if OWL.Ontology in types:
262
+ return EntityType.ONTOLOGY
263
+
264
+ # Check for classes
265
+ if OWL.Class in types or RDFS.Class in types:
266
+ return EntityType.CLASS
267
+
268
+ # Check for property types
269
+ if OWL.ObjectProperty in types:
270
+ return EntityType.OBJECT_PROPERTY
271
+ if OWL.DatatypeProperty in types:
272
+ return EntityType.DATATYPE_PROPERTY
273
+ if OWL.AnnotationProperty in types:
274
+ return EntityType.ANNOTATION_PROPERTY
275
+ if RDF.Property in types:
276
+ return EntityType.OBJECT_PROPERTY # Default property type
277
+
278
+ # If it has any type assertions (but not class/property), it's an individual
279
+ if types:
280
+ return EntityType.INDIVIDUAL
281
+
282
+ return EntityType.OTHER
283
+
284
+
285
+ def _get_label(graph: Graph, subject: URIRef) -> str | None:
286
+ """Get a human-readable label for an entity.
287
+
288
+ Tries rdfs:label first, then skos:prefLabel, then falls back to
289
+ the local name from the URI.
290
+
291
+ Args:
292
+ graph: The RDF graph containing the entity.
293
+ subject: The entity URI.
294
+
295
+ Returns:
296
+ Label string or None.
297
+ """
298
+ # Try rdfs:label
299
+ for label in graph.objects(subject, RDFS.label):
300
+ if isinstance(label, Literal):
301
+ return str(label)
302
+
303
+ # Try skos:prefLabel
304
+ SKOS = URIRef("http://www.w3.org/2004/02/skos/core#")
305
+ for label in graph.objects(subject, SKOS + "prefLabel"):
306
+ if isinstance(label, Literal):
307
+ return str(label)
308
+
309
+ # Fall back to local name from URI
310
+ if isinstance(subject, URIRef):
311
+ uri_str = str(subject)
312
+ if "#" in uri_str:
313
+ return uri_str.split("#")[-1]
314
+ elif "/" in uri_str:
315
+ return uri_str.split("/")[-1]
316
+
317
+ return None
318
+
319
+
320
+ def _get_superclasses(graph: Graph, subject: URIRef) -> list[URIRef] | None:
321
+ """Get direct superclasses of a class.
322
+
323
+ Args:
324
+ graph: The RDF graph containing the entity.
325
+ subject: The entity URI.
326
+
327
+ Returns:
328
+ List of superclass URIs, or None if not a class.
329
+ """
330
+ types = set(graph.objects(subject, RDF.type))
331
+ if OWL.Class not in types and RDFS.Class not in types:
332
+ return None
333
+
334
+ superclasses = [
335
+ sc for sc in graph.objects(subject, RDFS.subClassOf) if isinstance(sc, URIRef)
336
+ ]
337
+
338
+ return superclasses if superclasses else None
@@ -0,0 +1,133 @@
1
+ """Filtering logic for diff results.
2
+
3
+ Supports filtering by:
4
+ - Change type (added, removed, modified)
5
+ - Entity type (classes, properties, instances)
6
+ """
7
+
8
+ from rdf_construct.diff.change_types import ChangeType, EntityChange, EntityType, GraphDiff
9
+
10
+
11
+ # Maps CLI filter strings to internal types
12
+ CHANGE_TYPE_MAP = {
13
+ "added": ChangeType.ADDED,
14
+ "removed": ChangeType.REMOVED,
15
+ "modified": ChangeType.MODIFIED,
16
+ }
17
+
18
+ ENTITY_TYPE_MAP = {
19
+ "classes": EntityType.CLASS,
20
+ "class": EntityType.CLASS,
21
+ "object_properties": EntityType.OBJECT_PROPERTY,
22
+ "object-properties": EntityType.OBJECT_PROPERTY,
23
+ "objprops": EntityType.OBJECT_PROPERTY,
24
+ "datatype_properties": EntityType.DATATYPE_PROPERTY,
25
+ "datatype-properties": EntityType.DATATYPE_PROPERTY,
26
+ "dataprops": EntityType.DATATYPE_PROPERTY,
27
+ "annotation_properties": EntityType.ANNOTATION_PROPERTY,
28
+ "annotation-properties": EntityType.ANNOTATION_PROPERTY,
29
+ "annprops": EntityType.ANNOTATION_PROPERTY,
30
+ "properties": None, # Special: matches all property types
31
+ "individuals": EntityType.INDIVIDUAL,
32
+ "instances": EntityType.INDIVIDUAL,
33
+ }
34
+
35
+ # Property types for the "properties" filter
36
+ PROPERTY_TYPES = {
37
+ EntityType.OBJECT_PROPERTY,
38
+ EntityType.DATATYPE_PROPERTY,
39
+ EntityType.ANNOTATION_PROPERTY,
40
+ }
41
+
42
+
43
+ def filter_diff(
44
+ diff: GraphDiff,
45
+ show_types: set[str] | None = None,
46
+ hide_types: set[str] | None = None,
47
+ entity_types: set[str] | None = None,
48
+ ) -> GraphDiff:
49
+ """Filter a GraphDiff by change types and entity types.
50
+
51
+ Args:
52
+ diff: The diff to filter.
53
+ show_types: If provided, only include these change types.
54
+ hide_types: If provided, exclude these change types.
55
+ entity_types: If provided, only include these entity types.
56
+
57
+ Returns:
58
+ A new GraphDiff with filtered results.
59
+
60
+ Note:
61
+ show_types and hide_types are mutually exclusive in practice,
62
+ but if both are provided, show_types is applied first.
63
+ """
64
+ # Determine which change types to include
65
+ include_change_types = set(ChangeType)
66
+
67
+ if show_types:
68
+ include_change_types = {
69
+ CHANGE_TYPE_MAP[t.lower()]
70
+ for t in show_types
71
+ if t.lower() in CHANGE_TYPE_MAP
72
+ }
73
+
74
+ if hide_types:
75
+ for t in hide_types:
76
+ if t.lower() in CHANGE_TYPE_MAP:
77
+ include_change_types.discard(CHANGE_TYPE_MAP[t.lower()])
78
+
79
+ # Determine which entity types to include
80
+ include_entity_types: set[EntityType] | None = None
81
+ if entity_types:
82
+ include_entity_types = set()
83
+ for et in entity_types:
84
+ et_lower = et.lower()
85
+ if et_lower in ENTITY_TYPE_MAP:
86
+ mapped = ENTITY_TYPE_MAP[et_lower]
87
+ if mapped is None: # "properties" special case
88
+ include_entity_types.update(PROPERTY_TYPES)
89
+ else:
90
+ include_entity_types.add(mapped)
91
+
92
+ # Filter the entities
93
+ def should_include(entity: EntityChange) -> bool:
94
+ if include_entity_types is not None:
95
+ if entity.entity_type not in include_entity_types:
96
+ return False
97
+ return True
98
+
99
+ filtered_added = []
100
+ filtered_removed = []
101
+ filtered_modified = []
102
+
103
+ if ChangeType.ADDED in include_change_types:
104
+ filtered_added = [e for e in diff.added if should_include(e)]
105
+
106
+ if ChangeType.REMOVED in include_change_types:
107
+ filtered_removed = [e for e in diff.removed if should_include(e)]
108
+
109
+ if ChangeType.MODIFIED in include_change_types:
110
+ filtered_modified = [e for e in diff.modified if should_include(e)]
111
+
112
+ return GraphDiff(
113
+ old_path=diff.old_path,
114
+ new_path=diff.new_path,
115
+ added=filtered_added,
116
+ removed=filtered_removed,
117
+ modified=filtered_modified,
118
+ blank_node_warning=diff.blank_node_warning,
119
+ )
120
+
121
+
122
+ def parse_filter_string(filter_str: str) -> set[str]:
123
+ """Parse a comma-separated filter string into a set.
124
+
125
+ Args:
126
+ filter_str: Comma-separated list (e.g., "added,removed")
127
+
128
+ Returns:
129
+ Set of individual filter terms.
130
+ """
131
+ if not filter_str:
132
+ return set()
133
+ return {f.strip() for f in filter_str.split(",") if f.strip()}
@@ -0,0 +1,71 @@
1
+ """Diff output formatters.
2
+
3
+ Available formatters:
4
+ - text: Plain text for terminal output
5
+ - markdown: Markdown for release notes
6
+ - json: JSON for programmatic use
7
+ """
8
+
9
+ from rdflib import Graph
10
+
11
+ from rdf_construct.diff.change_types import GraphDiff
12
+ from rdf_construct.diff.formatters.text import format_text
13
+ from rdf_construct.diff.formatters.markdown import format_markdown
14
+ from rdf_construct.diff.formatters.json import format_json
15
+
16
+
17
+ # Format name to formatter function mapping
18
+ FORMATTERS = {
19
+ "text": format_text,
20
+ "markdown": format_markdown,
21
+ "md": format_markdown,
22
+ "json": format_json,
23
+ }
24
+
25
+
26
+ def get_formatter(format_name: str):
27
+ """Get a formatter function by name.
28
+
29
+ Args:
30
+ format_name: One of 'text', 'markdown', 'md', 'json'
31
+
32
+ Returns:
33
+ Formatter function.
34
+
35
+ Raises:
36
+ KeyError: If format_name is not recognized.
37
+ """
38
+ name = format_name.lower()
39
+ if name not in FORMATTERS:
40
+ available = ", ".join(FORMATTERS.keys())
41
+ raise KeyError(f"Unknown format '{format_name}'. Available: {available}")
42
+ return FORMATTERS[name]
43
+
44
+
45
+ def format_diff(
46
+ diff: GraphDiff,
47
+ format_name: str = "text",
48
+ graph: Graph | None = None,
49
+ ) -> str:
50
+ """Format a diff result using the specified formatter.
51
+
52
+ Args:
53
+ diff: The diff result to format.
54
+ format_name: Output format ('text', 'markdown', 'json').
55
+ graph: Optional graph for CURIE formatting.
56
+
57
+ Returns:
58
+ Formatted string.
59
+ """
60
+ formatter = get_formatter(format_name)
61
+ return formatter(diff, graph)
62
+
63
+
64
+ __all__ = [
65
+ "format_text",
66
+ "format_markdown",
67
+ "format_json",
68
+ "format_diff",
69
+ "get_formatter",
70
+ "FORMATTERS",
71
+ ]