metaxy 0.0.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.

Potentially problematic release.


This version of metaxy might be problematic. Click here for more details.

Files changed (75) hide show
  1. metaxy/__init__.py +61 -0
  2. metaxy/_testing.py +542 -0
  3. metaxy/_utils.py +16 -0
  4. metaxy/_version.py +1 -0
  5. metaxy/cli/app.py +76 -0
  6. metaxy/cli/context.py +71 -0
  7. metaxy/cli/graph.py +576 -0
  8. metaxy/cli/graph_diff.py +290 -0
  9. metaxy/cli/list.py +42 -0
  10. metaxy/cli/metadata.py +271 -0
  11. metaxy/cli/migrations.py +862 -0
  12. metaxy/cli/push.py +55 -0
  13. metaxy/config.py +450 -0
  14. metaxy/data_versioning/__init__.py +24 -0
  15. metaxy/data_versioning/calculators/__init__.py +13 -0
  16. metaxy/data_versioning/calculators/base.py +97 -0
  17. metaxy/data_versioning/calculators/duckdb.py +186 -0
  18. metaxy/data_versioning/calculators/ibis.py +225 -0
  19. metaxy/data_versioning/calculators/polars.py +135 -0
  20. metaxy/data_versioning/diff/__init__.py +15 -0
  21. metaxy/data_versioning/diff/base.py +150 -0
  22. metaxy/data_versioning/diff/narwhals.py +108 -0
  23. metaxy/data_versioning/hash_algorithms.py +19 -0
  24. metaxy/data_versioning/joiners/__init__.py +9 -0
  25. metaxy/data_versioning/joiners/base.py +70 -0
  26. metaxy/data_versioning/joiners/narwhals.py +235 -0
  27. metaxy/entrypoints.py +309 -0
  28. metaxy/ext/__init__.py +1 -0
  29. metaxy/ext/alembic.py +326 -0
  30. metaxy/ext/sqlmodel.py +172 -0
  31. metaxy/ext/sqlmodel_system_tables.py +139 -0
  32. metaxy/graph/__init__.py +21 -0
  33. metaxy/graph/diff/__init__.py +21 -0
  34. metaxy/graph/diff/diff_models.py +399 -0
  35. metaxy/graph/diff/differ.py +740 -0
  36. metaxy/graph/diff/models.py +418 -0
  37. metaxy/graph/diff/rendering/__init__.py +18 -0
  38. metaxy/graph/diff/rendering/base.py +274 -0
  39. metaxy/graph/diff/rendering/cards.py +188 -0
  40. metaxy/graph/diff/rendering/formatter.py +805 -0
  41. metaxy/graph/diff/rendering/graphviz.py +246 -0
  42. metaxy/graph/diff/rendering/mermaid.py +320 -0
  43. metaxy/graph/diff/rendering/rich.py +165 -0
  44. metaxy/graph/diff/rendering/theme.py +48 -0
  45. metaxy/graph/diff/traversal.py +247 -0
  46. metaxy/graph/utils.py +58 -0
  47. metaxy/metadata_store/__init__.py +31 -0
  48. metaxy/metadata_store/_protocols.py +38 -0
  49. metaxy/metadata_store/base.py +1676 -0
  50. metaxy/metadata_store/clickhouse.py +161 -0
  51. metaxy/metadata_store/duckdb.py +167 -0
  52. metaxy/metadata_store/exceptions.py +43 -0
  53. metaxy/metadata_store/ibis.py +451 -0
  54. metaxy/metadata_store/memory.py +228 -0
  55. metaxy/metadata_store/sqlite.py +187 -0
  56. metaxy/metadata_store/system_tables.py +257 -0
  57. metaxy/migrations/__init__.py +34 -0
  58. metaxy/migrations/detector.py +153 -0
  59. metaxy/migrations/executor.py +208 -0
  60. metaxy/migrations/loader.py +260 -0
  61. metaxy/migrations/models.py +718 -0
  62. metaxy/migrations/ops.py +390 -0
  63. metaxy/models/__init__.py +0 -0
  64. metaxy/models/bases.py +6 -0
  65. metaxy/models/constants.py +24 -0
  66. metaxy/models/feature.py +665 -0
  67. metaxy/models/feature_spec.py +105 -0
  68. metaxy/models/field.py +25 -0
  69. metaxy/models/plan.py +155 -0
  70. metaxy/models/types.py +157 -0
  71. metaxy/py.typed +0 -0
  72. metaxy-0.0.0.dist-info/METADATA +247 -0
  73. metaxy-0.0.0.dist-info/RECORD +75 -0
  74. metaxy-0.0.0.dist-info/WHEEL +4 -0
  75. metaxy-0.0.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,805 @@
1
+ """Formatter for graph diff output."""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from rich.console import Console
7
+
8
+ from metaxy.graph import utils
9
+ from metaxy.graph.diff.diff_models import FieldChange, GraphDiff
10
+
11
+
12
+ class DiffFormatter:
13
+ """Formats GraphDiff for display with colored output."""
14
+
15
+ def __init__(self, console: Console | None = None):
16
+ """Initialize formatter.
17
+
18
+ Args:
19
+ console: Rich console for output (creates new one if None)
20
+ """
21
+ self.console = console or Console()
22
+
23
+ def format(
24
+ self,
25
+ diff: GraphDiff | None = None,
26
+ merged_data: dict[str, Any] | None = None,
27
+ format: str = "terminal",
28
+ verbose: bool = False,
29
+ diff_only: bool = False,
30
+ show_all_fields: bool = True,
31
+ ) -> str:
32
+ """Format a GraphDiff or merged graph data in the specified format.
33
+
34
+ Args:
35
+ diff: GraphDiff to format (required for diff_only mode)
36
+ merged_data: Merged graph data (required for merged mode)
37
+ format: Output format ("terminal", "json", "yaml", or "mermaid")
38
+ verbose: If True, show more details (dependencies, code versions)
39
+ diff_only: If True, show only diff list; otherwise show merged graph
40
+ show_all_fields: If True, show all fields; if False, show only changed fields
41
+
42
+ Returns:
43
+ Formatted string
44
+
45
+ Raises:
46
+ ValueError: If format is not recognized or required data is missing
47
+ """
48
+ if diff_only:
49
+ if diff is None:
50
+ raise ValueError("diff is required for diff_only mode")
51
+ return self._format_diff_only(diff, format, verbose)
52
+ else:
53
+ if merged_data is None:
54
+ raise ValueError("merged_data is required for merged mode")
55
+ return self._format_merged(merged_data, format, verbose, show_all_fields)
56
+
57
+ def _format_diff_only(self, diff: GraphDiff, format: str, verbose: bool) -> str:
58
+ """Format diff-only output."""
59
+ if format == "terminal":
60
+ return self.format_terminal_diff_only(diff, verbose)
61
+ elif format == "json":
62
+ return self.format_json_diff_only(diff)
63
+ elif format == "yaml":
64
+ return self.format_yaml_diff_only(diff)
65
+ elif format == "mermaid":
66
+ return self.format_mermaid_diff_only(diff, verbose)
67
+ else:
68
+ raise ValueError(
69
+ f"Unknown format: {format}. Must be one of: terminal, json, yaml, mermaid"
70
+ )
71
+
72
+ def _format_merged(
73
+ self,
74
+ merged_data: dict[str, Any],
75
+ format: str,
76
+ verbose: bool,
77
+ show_all_fields: bool,
78
+ ) -> str:
79
+ """Format merged graph output."""
80
+ if format == "terminal":
81
+ return self.format_terminal_merged(merged_data, verbose, show_all_fields)
82
+ elif format == "json":
83
+ return self.format_json_merged(merged_data)
84
+ elif format == "yaml":
85
+ return self.format_yaml_merged(merged_data)
86
+ elif format == "mermaid":
87
+ return self.format_mermaid_merged(merged_data, verbose, show_all_fields)
88
+ else:
89
+ raise ValueError(
90
+ f"Unknown format: {format}. Must be one of: terminal, json, yaml, mermaid"
91
+ )
92
+
93
+ def format_terminal_diff_only(self, diff: GraphDiff, verbose: bool = False) -> str:
94
+ """Format a GraphDiff as a human-readable string with colored markup.
95
+
96
+ Args:
97
+ diff: GraphDiff to format
98
+ verbose: If True, show more details (dependencies, code versions)
99
+
100
+ Returns:
101
+ Formatted string with Rich markup
102
+ """
103
+ if not diff.has_changes:
104
+ return self._format_no_changes(diff)
105
+
106
+ lines = []
107
+
108
+ # Header
109
+ lines.append(
110
+ f"Graph Diff: {utils.format_hash(diff.from_snapshot_version)}... → {utils.format_hash(diff.to_snapshot_version)}..."
111
+ )
112
+ lines.append("")
113
+
114
+ # Added nodes
115
+ if diff.added_nodes:
116
+ lines.append(f"[bold green]Added ({len(diff.added_nodes)}):[/bold green]")
117
+ for node in diff.added_nodes:
118
+ lines.append(
119
+ f" [green]+[/green] {utils.format_feature_key(node.feature_key)}"
120
+ )
121
+ lines.append("")
122
+
123
+ # Removed nodes
124
+ if diff.removed_nodes:
125
+ lines.append(f"[bold red]Removed ({len(diff.removed_nodes)}):[/bold red]")
126
+ for node in diff.removed_nodes:
127
+ lines.append(
128
+ f" [red]-[/red] {utils.format_feature_key(node.feature_key)}"
129
+ )
130
+ lines.append("")
131
+
132
+ # Changed nodes
133
+ if diff.changed_nodes:
134
+ lines.append(
135
+ f"[bold yellow]Changed ({len(diff.changed_nodes)}):[/bold yellow]"
136
+ )
137
+ for node_change in diff.changed_nodes:
138
+ # Show feature-level change
139
+ old_ver = (
140
+ utils.format_hash(node_change.old_version)
141
+ if node_change.old_version
142
+ else "none"
143
+ )
144
+ new_ver = (
145
+ utils.format_hash(node_change.new_version)
146
+ if node_change.new_version
147
+ else "none"
148
+ )
149
+ lines.append(
150
+ f" [yellow]~[/yellow] {utils.format_feature_key(node_change.feature_key)} "
151
+ f"({old_ver}... → {new_ver}...)"
152
+ )
153
+
154
+ # Show field changes if any
155
+ all_field_changes = (
156
+ node_change.added_fields
157
+ + node_change.removed_fields
158
+ + node_change.changed_fields
159
+ )
160
+ if all_field_changes:
161
+ lines.append(" fields:")
162
+ for field_change in all_field_changes:
163
+ field_key_str = utils.format_field_key(field_change.field_key)
164
+
165
+ if field_change.is_added:
166
+ new_ver = (
167
+ utils.format_hash(field_change.new_version)
168
+ if field_change.new_version
169
+ else "none"
170
+ )
171
+ lines.append(
172
+ f" [green]+[/green] {field_key_str} ({new_ver}...)"
173
+ )
174
+ elif field_change.is_removed:
175
+ old_ver = (
176
+ utils.format_hash(field_change.old_version)
177
+ if field_change.old_version
178
+ else "none"
179
+ )
180
+ lines.append(
181
+ f" [red]-[/red] {field_key_str} ({old_ver}...)"
182
+ )
183
+ elif field_change.is_changed:
184
+ old_ver = (
185
+ utils.format_hash(field_change.old_version)
186
+ if field_change.old_version
187
+ else "none"
188
+ )
189
+ new_ver = (
190
+ utils.format_hash(field_change.new_version)
191
+ if field_change.new_version
192
+ else "none"
193
+ )
194
+ lines.append(
195
+ f" [yellow]~[/yellow] {field_key_str} "
196
+ f"({old_ver}... → {new_ver}...)"
197
+ )
198
+ lines.append("")
199
+
200
+ # Summary
201
+ total_changes = (
202
+ len(diff.added_nodes) + len(diff.removed_nodes) + len(diff.changed_nodes)
203
+ )
204
+ lines.append(f"[dim]Total changes: {total_changes}[/dim]")
205
+
206
+ return "\n".join(lines)
207
+
208
+ def _format_no_changes(self, diff: GraphDiff) -> str:
209
+ """Format message when there are no changes."""
210
+ return (
211
+ f"[green]No changes between snapshots[/green]\n"
212
+ f" {utils.format_hash(diff.from_snapshot_version)}... → {utils.format_hash(diff.to_snapshot_version)}..."
213
+ )
214
+
215
+ def print(self, diff: GraphDiff, verbose: bool = False) -> None:
216
+ """Print formatted diff to console.
217
+
218
+ Args:
219
+ diff: GraphDiff to print
220
+ verbose: If True, show more details
221
+ """
222
+ formatted = self.format_terminal_diff_only(diff, verbose=verbose)
223
+ self.console.print(formatted)
224
+
225
+ def format_json_diff_only(self, diff: GraphDiff) -> str:
226
+ """Format GraphDiff as JSON.
227
+
228
+ Args:
229
+ diff: GraphDiff to format
230
+
231
+ Returns:
232
+ JSON string representation of the diff
233
+ """
234
+ data = {
235
+ "from_snapshot_version": diff.from_snapshot_version,
236
+ "to_snapshot_version": diff.to_snapshot_version,
237
+ "added_nodes": [
238
+ utils.format_feature_key(node.feature_key) for node in diff.added_nodes
239
+ ],
240
+ "removed_nodes": [
241
+ utils.format_feature_key(node.feature_key)
242
+ for node in diff.removed_nodes
243
+ ],
244
+ "changed_nodes": [
245
+ {
246
+ "feature_key": utils.format_feature_key(nc.feature_key),
247
+ "old_version": nc.old_version,
248
+ "new_version": nc.new_version,
249
+ "field_changes": [
250
+ {
251
+ "field_key": utils.format_field_key(field.field_key),
252
+ "old_version": field.old_version,
253
+ "new_version": field.new_version,
254
+ "is_added": field.is_added,
255
+ "is_removed": field.is_removed,
256
+ "is_changed": field.is_changed,
257
+ }
258
+ for field in (
259
+ nc.added_fields + nc.removed_fields + nc.changed_fields
260
+ )
261
+ ],
262
+ }
263
+ for nc in diff.changed_nodes
264
+ ],
265
+ }
266
+ return json.dumps(data, indent=2)
267
+
268
+ def format_yaml_diff_only(self, diff: GraphDiff) -> str:
269
+ """Format GraphDiff as YAML.
270
+
271
+ Args:
272
+ diff: GraphDiff to format
273
+
274
+ Returns:
275
+ YAML string representation of the diff
276
+ """
277
+ import yaml
278
+
279
+ data = {
280
+ "from_snapshot_version": diff.from_snapshot_version,
281
+ "to_snapshot_version": diff.to_snapshot_version,
282
+ "added_nodes": [node.feature_key.to_string() for node in diff.added_nodes],
283
+ "removed_nodes": [
284
+ node.feature_key.to_string() for node in diff.removed_nodes
285
+ ],
286
+ "changed_nodes": [
287
+ {
288
+ "feature_key": nc.feature_key.to_string(),
289
+ "old_version": nc.old_version,
290
+ "new_version": nc.new_version,
291
+ "field_changes": [
292
+ {
293
+ "field_key": field.field_key.to_string(),
294
+ "old_version": field.old_version,
295
+ "new_version": field.new_version,
296
+ "is_added": field.is_added,
297
+ "is_removed": field.is_removed,
298
+ "is_changed": field.is_changed,
299
+ }
300
+ for field in (
301
+ nc.added_fields + nc.removed_fields + nc.changed_fields
302
+ )
303
+ ],
304
+ }
305
+ for nc in diff.changed_nodes
306
+ ],
307
+ }
308
+ # Use width=999999 to prevent line wrapping for long hashes
309
+ return yaml.safe_dump(
310
+ data,
311
+ default_flow_style=False,
312
+ sort_keys=False,
313
+ width=999999,
314
+ allow_unicode=True,
315
+ )
316
+
317
+ def format_mermaid_diff_only(self, diff: GraphDiff, verbose: bool = False) -> str:
318
+ """Format GraphDiff as Mermaid flowchart.
319
+
320
+ Args:
321
+ diff: GraphDiff to format
322
+ verbose: If True, show more details
323
+
324
+ Returns:
325
+ Mermaid flowchart markup showing the diff
326
+ """
327
+ lines = []
328
+ lines.append("---")
329
+ lines.append("title: Graph Diff")
330
+ lines.append("---")
331
+ lines.append("flowchart TB")
332
+ lines.append(
333
+ " %%{init: {'flowchart': {'htmlLabels': true, 'curve': 'basis'}, 'themeVariables': {'fontSize': '14px'}}}%%"
334
+ )
335
+ lines.append("")
336
+
337
+ # Collect all features
338
+ all_features = set()
339
+ for node in diff.added_nodes:
340
+ all_features.add(node.feature_key.to_string())
341
+ for node in diff.removed_nodes:
342
+ all_features.add(node.feature_key.to_string())
343
+ for nc in diff.changed_nodes:
344
+ all_features.add(nc.feature_key.to_string())
345
+
346
+ if not all_features:
347
+ lines.append(" Empty[No changes]")
348
+ lines.append("")
349
+ return "\n".join(lines)
350
+
351
+ # Generate node IDs (sanitized for Mermaid)
352
+ def sanitize_id(s: str) -> str:
353
+ return s.replace("/", "_").replace("-", "_")
354
+
355
+ # Define nodes with styling (border only, no fill)
356
+ for node in diff.added_nodes:
357
+ node_id = sanitize_id(node.feature_key.to_string())
358
+ feature_str = node.feature_key.to_string()
359
+ lines.append(f' {node_id}["{feature_str}"]')
360
+ lines.append(f" style {node_id} stroke:#00FF00,stroke-width:2px")
361
+
362
+ for node in diff.removed_nodes:
363
+ node_id = sanitize_id(node.feature_key.to_string())
364
+ feature_str = node.feature_key.to_string()
365
+ lines.append(f' {node_id}["{feature_str}"]')
366
+ lines.append(f" style {node_id} stroke:#FF0000,stroke-width:2px")
367
+
368
+ for nc in diff.changed_nodes:
369
+ node_id = sanitize_id(nc.feature_key.to_string())
370
+ feature_str = nc.feature_key.to_string()
371
+
372
+ all_field_changes = nc.added_fields + nc.removed_fields + nc.changed_fields
373
+ if verbose and all_field_changes:
374
+ # Show field changes in verbose mode
375
+ field_changes_str = "<br/>".join(
376
+ [
377
+ f"{'+ ' if field.is_added else '- ' if field.is_removed else '~ '}{field.field_key.to_string()}"
378
+ for field in all_field_changes
379
+ ]
380
+ )
381
+ lines.append(f' {node_id}["{feature_str}<br/>{field_changes_str}"]')
382
+ else:
383
+ lines.append(f' {node_id}["{feature_str}"]')
384
+
385
+ lines.append(f" style {node_id} stroke:#FFAA00,stroke-width:2px")
386
+
387
+ lines.append("")
388
+
389
+ return "\n".join(lines)
390
+
391
+ # Merged graph format methods
392
+
393
+ def format_terminal_merged(
394
+ self,
395
+ merged_data: dict[str, Any],
396
+ verbose: bool = False,
397
+ show_all_fields: bool = True,
398
+ ) -> str:
399
+ """Format merged graph as terminal tree view with status annotations.
400
+
401
+ Args:
402
+ merged_data: Merged graph data with nodes and edges
403
+ verbose: If True, show more details
404
+ show_all_fields: If True, show all fields; if False, show only changed fields
405
+
406
+ Returns:
407
+ Formatted string with Rich markup
408
+ """
409
+ nodes = merged_data["nodes"]
410
+
411
+ if not nodes:
412
+ return "[yellow]Empty graph (no features)[/yellow]"
413
+
414
+ lines = []
415
+ lines.append("[bold]Feature Graph (merged view):[/bold]")
416
+ lines.append("")
417
+
418
+ # Build dependency graph for hierarchical display
419
+ # We'll sort features by status priority: unchanged, changed, added, removed
420
+ status_order = {"unchanged": 0, "changed": 1, "added": 2, "removed": 3}
421
+
422
+ sorted_features = sorted(
423
+ nodes.items(), key=lambda x: (status_order[x[1]["status"]], x[0])
424
+ )
425
+
426
+ for feature_key_str, node_data in sorted_features:
427
+ status = node_data["status"]
428
+ old_version = node_data["old_version"]
429
+ new_version = node_data["new_version"]
430
+ field_changes = node_data["field_changes"]
431
+ dependencies = node_data["dependencies"]
432
+
433
+ # Status symbols and colors
434
+ if status == "added":
435
+ symbol = "[green]+[/green]"
436
+ status_text = "[green](added)[/green]"
437
+ elif status == "removed":
438
+ symbol = "[red]-[/red]"
439
+ status_text = "[red](removed)[/red]"
440
+ elif status == "changed":
441
+ symbol = "[yellow]~[/yellow]"
442
+ status_text = "[yellow](changed)[/yellow]"
443
+ else:
444
+ symbol = " "
445
+ status_text = ""
446
+
447
+ # Feature line
448
+ lines.append(f"{symbol} [bold]{feature_key_str}[/bold] {status_text}")
449
+
450
+ # Version information
451
+ if status == "added":
452
+ lines.append(f" version: {utils.format_hash(new_version)}...")
453
+ elif status == "removed":
454
+ lines.append(f" version: {utils.format_hash(old_version)}...")
455
+ elif status == "changed":
456
+ old_ver_str = utils.format_hash(old_version) if old_version else "none"
457
+ new_ver_str = utils.format_hash(new_version) if new_version else "none"
458
+ lines.append(f" version: {old_ver_str}... → {new_ver_str}...")
459
+ else:
460
+ # Unchanged
461
+ lines.append(f" version: {utils.format_hash(new_version)}...")
462
+
463
+ # Dependencies
464
+ if dependencies:
465
+ lines.append(f" depends on: {', '.join(dependencies)}")
466
+
467
+ # Fields (show fields for all features by default)
468
+ fields = node_data["fields"]
469
+ if fields:
470
+ lines.append(" fields:")
471
+
472
+ # Build a map of field changes for quick lookup
473
+ field_change_map = {
474
+ fc.field_key.to_string(): fc
475
+ for fc in field_changes
476
+ if isinstance(fc, FieldChange)
477
+ }
478
+
479
+ # Collect field keys based on show_all_fields setting
480
+ if show_all_fields:
481
+ # Show all fields (from both fields dict and field_changes for removed fields)
482
+ all_field_keys = set(fields.keys())
483
+ all_field_keys.update(field_change_map.keys())
484
+ else:
485
+ # Show only changed fields
486
+ all_field_keys = set(field_change_map.keys())
487
+
488
+ # Show fields
489
+ for field_key_str_inner in sorted(all_field_keys):
490
+ if field_key_str_inner in field_change_map:
491
+ # This field has a change
492
+ field_change = field_change_map[field_key_str_inner]
493
+
494
+ if field_change.is_added:
495
+ new_ver = (
496
+ utils.format_hash(field_change.new_version)
497
+ if field_change.new_version
498
+ else "none"
499
+ )
500
+ lines.append(
501
+ f" [green]+[/green] {field_key_str_inner} ({new_ver}...)"
502
+ )
503
+ elif field_change.is_removed:
504
+ old_ver = (
505
+ utils.format_hash(field_change.old_version)
506
+ if field_change.old_version
507
+ else "none"
508
+ )
509
+ lines.append(
510
+ f" [red]-[/red] {field_key_str_inner} ({old_ver}...)"
511
+ )
512
+ elif field_change.is_changed:
513
+ old_ver = (
514
+ utils.format_hash(field_change.old_version)
515
+ if field_change.old_version
516
+ else "none"
517
+ )
518
+ new_ver = (
519
+ utils.format_hash(field_change.new_version)
520
+ if field_change.new_version
521
+ else "none"
522
+ )
523
+ lines.append(
524
+ f" [yellow]~[/yellow] {field_key_str_inner} "
525
+ f"([red]{old_ver}[/red]... → [green]{new_ver}[/green]...)"
526
+ )
527
+ else:
528
+ # Unchanged field - no color, but show with proper spacing
529
+ field_version = fields[field_key_str_inner]
530
+ ver = (
531
+ utils.format_hash(field_version)
532
+ if field_version
533
+ else "none"
534
+ )
535
+ lines.append(f" {field_key_str_inner} ({ver}...)")
536
+
537
+ lines.append("")
538
+
539
+ return "\n".join(lines)
540
+
541
+ def format_json_merged(self, merged_data: dict[str, Any]) -> str:
542
+ """Format merged graph as JSON.
543
+
544
+ Args:
545
+ merged_data: Merged graph data with nodes and edges
546
+
547
+ Returns:
548
+ JSON string representation of merged graph
549
+ """
550
+ # Convert to JSON-serializable format
551
+ nodes_json = {}
552
+ for feature_key, node_data in merged_data["nodes"].items():
553
+ field_changes_json = []
554
+ for field_change in node_data["field_changes"]:
555
+ if isinstance(field_change, FieldChange):
556
+ field_changes_json.append(
557
+ {
558
+ "field_key": field_change.field_key.to_string(),
559
+ "old_version": field_change.old_version,
560
+ "new_version": field_change.new_version,
561
+ "is_added": field_change.is_added,
562
+ "is_removed": field_change.is_removed,
563
+ "is_changed": field_change.is_changed,
564
+ }
565
+ )
566
+
567
+ nodes_json[feature_key] = {
568
+ "status": node_data["status"],
569
+ "old_version": node_data["old_version"],
570
+ "new_version": node_data["new_version"],
571
+ "dependencies": node_data["dependencies"],
572
+ "field_changes": field_changes_json,
573
+ }
574
+
575
+ data = {
576
+ "nodes": nodes_json,
577
+ "edges": merged_data["edges"],
578
+ }
579
+ return json.dumps(data, indent=2)
580
+
581
+ def format_yaml_merged(self, merged_data: dict[str, Any]) -> str:
582
+ """Format merged graph as YAML.
583
+
584
+ Args:
585
+ merged_data: Merged graph data with nodes and edges
586
+
587
+ Returns:
588
+ YAML string representation of merged graph
589
+ """
590
+ import yaml
591
+
592
+ # Convert to YAML-serializable format
593
+ nodes_yaml = {}
594
+ for feature_key, node_data in merged_data["nodes"].items():
595
+ field_changes_yaml = []
596
+ for field_change in node_data["field_changes"]:
597
+ if isinstance(field_change, FieldChange):
598
+ field_changes_yaml.append(
599
+ {
600
+ "field_key": field_change.field_key.to_string(),
601
+ "old_version": field_change.old_version,
602
+ "new_version": field_change.new_version,
603
+ "is_added": field_change.is_added,
604
+ "is_removed": field_change.is_removed,
605
+ "is_changed": field_change.is_changed,
606
+ }
607
+ )
608
+
609
+ nodes_yaml[feature_key] = {
610
+ "status": node_data["status"],
611
+ "old_version": node_data["old_version"],
612
+ "new_version": node_data["new_version"],
613
+ "dependencies": node_data["dependencies"],
614
+ "field_changes": field_changes_yaml,
615
+ }
616
+
617
+ data = {
618
+ "nodes": nodes_yaml,
619
+ "edges": merged_data["edges"],
620
+ }
621
+ # Use width=999999 to prevent line wrapping for long hashes
622
+ return yaml.safe_dump(
623
+ data,
624
+ default_flow_style=False,
625
+ sort_keys=False,
626
+ width=999999,
627
+ allow_unicode=True,
628
+ )
629
+
630
+ def format_mermaid_merged(
631
+ self,
632
+ merged_data: dict[str, Any],
633
+ verbose: bool = False,
634
+ show_all_fields: bool = True,
635
+ ) -> str:
636
+ """Format merged graph as Mermaid flowchart with status colors.
637
+
638
+ Args:
639
+ merged_data: Merged graph data with nodes and edges
640
+ verbose: If True, show field changes on changed nodes
641
+ show_all_fields: If True, show all fields; if False, show only changed fields
642
+
643
+ Returns:
644
+ Mermaid flowchart markup
645
+ """
646
+ nodes = merged_data["nodes"]
647
+ edges = merged_data["edges"]
648
+
649
+ lines = []
650
+ lines.append("---")
651
+ lines.append("title: Merged Graph Diff")
652
+ lines.append("---")
653
+ lines.append("flowchart TB")
654
+ lines.append(
655
+ " %%{init: {'flowchart': {'htmlLabels': true, 'curve': 'basis'}, 'themeVariables': {'fontSize': '14px'}}}%%"
656
+ )
657
+ lines.append("")
658
+
659
+ if not nodes:
660
+ lines.append(" Empty[No features]")
661
+ return "\n".join(lines)
662
+
663
+ def sanitize_id(s: str) -> str:
664
+ return s.replace("/", "_").replace("-", "_")
665
+
666
+ # Define nodes with styling based on status
667
+ for feature_key_str, node_data in nodes.items():
668
+ node_id = sanitize_id(feature_key_str)
669
+ status = node_data["status"]
670
+ old_version = node_data["old_version"]
671
+ new_version = node_data["new_version"]
672
+ field_changes = node_data["field_changes"]
673
+
674
+ # Build node label
675
+ # Feature key in bold
676
+ label_parts = [f"<b>{feature_key_str}</b>"]
677
+ fields = node_data["fields"]
678
+
679
+ # Add version info
680
+ if status == "changed":
681
+ old_ver = (
682
+ utils.format_hash(old_version, length=6) if old_version else "none"
683
+ )
684
+ new_ver = (
685
+ utils.format_hash(new_version, length=6) if new_version else "none"
686
+ )
687
+ # Red for old version, green for new version
688
+ label_parts.append(
689
+ f'<font color="#CC0000">{old_ver}</font> → '
690
+ f'<font color="#00AA00">{new_ver}</font>'
691
+ )
692
+ elif status == "added":
693
+ ver = (
694
+ utils.format_hash(new_version, length=6) if new_version else "none"
695
+ )
696
+ label_parts.append(f"{ver}")
697
+ elif status == "removed":
698
+ ver = (
699
+ utils.format_hash(old_version, length=6) if old_version else "none"
700
+ )
701
+ label_parts.append(f"{ver}")
702
+ else:
703
+ # Unchanged
704
+ ver = (
705
+ utils.format_hash(new_version, length=6) if new_version else "none"
706
+ )
707
+ label_parts.append(f"{ver}")
708
+
709
+ # Add separator line before fields
710
+ if fields:
711
+ label_parts.append('<font color="#999">---</font>')
712
+
713
+ # Show fields for all features (not just changed)
714
+ if fields:
715
+ # Build field change map (only for changed features)
716
+ field_change_map = {
717
+ fc.field_key.to_string(): fc
718
+ for fc in field_changes
719
+ if isinstance(fc, FieldChange)
720
+ }
721
+
722
+ # Collect field keys based on show_all_fields setting
723
+ if show_all_fields:
724
+ # Show all fields (from both fields dict and field_changes for removed fields)
725
+ all_field_keys = set(fields.keys())
726
+ all_field_keys.update(field_change_map.keys())
727
+ else:
728
+ # Show only changed fields (skip if no changes for this feature)
729
+ if status != "changed" or not field_change_map:
730
+ all_field_keys = set()
731
+ else:
732
+ all_field_keys = set(field_change_map.keys())
733
+
734
+ for field_key_str_inner in sorted(all_field_keys):
735
+ if field_key_str_inner in field_change_map:
736
+ fc = field_change_map[field_key_str_inner]
737
+ if fc.is_added:
738
+ # Green for added (with version)
739
+ new_ver = (
740
+ utils.format_hash(fc.new_version, length=6)
741
+ if fc.new_version
742
+ else "none"
743
+ )
744
+ label_parts.append(
745
+ f'<font color="#00AA00">- {field_key_str_inner} ({new_ver})</font>'
746
+ )
747
+ elif fc.is_removed:
748
+ # Red for removed (with version)
749
+ old_ver = (
750
+ utils.format_hash(fc.old_version, length=6)
751
+ if fc.old_version
752
+ else "none"
753
+ )
754
+ label_parts.append(
755
+ f'<font color="#CC0000">- {field_key_str_inner} ({old_ver})</font>'
756
+ )
757
+ elif fc.is_changed:
758
+ # Yellow field name, red old version, green new version
759
+ old_ver = (
760
+ utils.format_hash(fc.old_version, length=6)
761
+ if fc.old_version
762
+ else "none"
763
+ )
764
+ new_ver = (
765
+ utils.format_hash(fc.new_version, length=6)
766
+ if fc.new_version
767
+ else "none"
768
+ )
769
+ label_parts.append(
770
+ f'- <font color="#FFAA00">{field_key_str_inner}</font> '
771
+ f'(<font color="#CC0000">{old_ver}</font> → '
772
+ f'<font color="#00AA00">{new_ver}</font>)'
773
+ )
774
+ else:
775
+ # Unchanged field - no color, with dash prefix
776
+ field_version = fields.get(field_key_str_inner)
777
+ if field_version:
778
+ ver = utils.format_hash(field_version, length=6)
779
+ label_parts.append(f"- {field_key_str_inner} ({ver})")
780
+
781
+ # Wrap content in left-aligned div (like graph render does)
782
+ label = "<br/>".join(label_parts)
783
+ label = f'<div style="text-align:left">{label}</div>'
784
+ lines.append(f' {node_id}["{label}"]')
785
+
786
+ # Apply styling based on status (border only, no fill)
787
+ if status == "added":
788
+ lines.append(f" style {node_id} stroke:#00FF00,stroke-width:2px")
789
+ elif status == "removed":
790
+ lines.append(f" style {node_id} stroke:#FF0000,stroke-width:2px")
791
+ elif status == "changed":
792
+ lines.append(f" style {node_id} stroke:#FFAA00,stroke-width:2px")
793
+ # else: unchanged - no special styling
794
+
795
+ lines.append("")
796
+
797
+ # Add edges
798
+ for edge in edges:
799
+ from_id = sanitize_id(edge["from"])
800
+ to_id = sanitize_id(edge["to"])
801
+ lines.append(f" {from_id} --> {to_id}")
802
+
803
+ lines.append("")
804
+
805
+ return "\n".join(lines)