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.
- metaxy/__init__.py +61 -0
- metaxy/_testing.py +542 -0
- metaxy/_utils.py +16 -0
- metaxy/_version.py +1 -0
- metaxy/cli/app.py +76 -0
- metaxy/cli/context.py +71 -0
- metaxy/cli/graph.py +576 -0
- metaxy/cli/graph_diff.py +290 -0
- metaxy/cli/list.py +42 -0
- metaxy/cli/metadata.py +271 -0
- metaxy/cli/migrations.py +862 -0
- metaxy/cli/push.py +55 -0
- metaxy/config.py +450 -0
- metaxy/data_versioning/__init__.py +24 -0
- metaxy/data_versioning/calculators/__init__.py +13 -0
- metaxy/data_versioning/calculators/base.py +97 -0
- metaxy/data_versioning/calculators/duckdb.py +186 -0
- metaxy/data_versioning/calculators/ibis.py +225 -0
- metaxy/data_versioning/calculators/polars.py +135 -0
- metaxy/data_versioning/diff/__init__.py +15 -0
- metaxy/data_versioning/diff/base.py +150 -0
- metaxy/data_versioning/diff/narwhals.py +108 -0
- metaxy/data_versioning/hash_algorithms.py +19 -0
- metaxy/data_versioning/joiners/__init__.py +9 -0
- metaxy/data_versioning/joiners/base.py +70 -0
- metaxy/data_versioning/joiners/narwhals.py +235 -0
- metaxy/entrypoints.py +309 -0
- metaxy/ext/__init__.py +1 -0
- metaxy/ext/alembic.py +326 -0
- metaxy/ext/sqlmodel.py +172 -0
- metaxy/ext/sqlmodel_system_tables.py +139 -0
- metaxy/graph/__init__.py +21 -0
- metaxy/graph/diff/__init__.py +21 -0
- metaxy/graph/diff/diff_models.py +399 -0
- metaxy/graph/diff/differ.py +740 -0
- metaxy/graph/diff/models.py +418 -0
- metaxy/graph/diff/rendering/__init__.py +18 -0
- metaxy/graph/diff/rendering/base.py +274 -0
- metaxy/graph/diff/rendering/cards.py +188 -0
- metaxy/graph/diff/rendering/formatter.py +805 -0
- metaxy/graph/diff/rendering/graphviz.py +246 -0
- metaxy/graph/diff/rendering/mermaid.py +320 -0
- metaxy/graph/diff/rendering/rich.py +165 -0
- metaxy/graph/diff/rendering/theme.py +48 -0
- metaxy/graph/diff/traversal.py +247 -0
- metaxy/graph/utils.py +58 -0
- metaxy/metadata_store/__init__.py +31 -0
- metaxy/metadata_store/_protocols.py +38 -0
- metaxy/metadata_store/base.py +1676 -0
- metaxy/metadata_store/clickhouse.py +161 -0
- metaxy/metadata_store/duckdb.py +167 -0
- metaxy/metadata_store/exceptions.py +43 -0
- metaxy/metadata_store/ibis.py +451 -0
- metaxy/metadata_store/memory.py +228 -0
- metaxy/metadata_store/sqlite.py +187 -0
- metaxy/metadata_store/system_tables.py +257 -0
- metaxy/migrations/__init__.py +34 -0
- metaxy/migrations/detector.py +153 -0
- metaxy/migrations/executor.py +208 -0
- metaxy/migrations/loader.py +260 -0
- metaxy/migrations/models.py +718 -0
- metaxy/migrations/ops.py +390 -0
- metaxy/models/__init__.py +0 -0
- metaxy/models/bases.py +6 -0
- metaxy/models/constants.py +24 -0
- metaxy/models/feature.py +665 -0
- metaxy/models/feature_spec.py +105 -0
- metaxy/models/field.py +25 -0
- metaxy/models/plan.py +155 -0
- metaxy/models/types.py +157 -0
- metaxy/py.typed +0 -0
- metaxy-0.0.0.dist-info/METADATA +247 -0
- metaxy-0.0.0.dist-info/RECORD +75 -0
- metaxy-0.0.0.dist-info/WHEEL +4 -0
- 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)
|