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,246 @@
|
|
|
1
|
+
"""Graphviz renderer for DOT format generation.
|
|
2
|
+
|
|
3
|
+
Requires pygraphviz library to be installed.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from metaxy.graph.diff.models import GraphNode, NodeStatus
|
|
7
|
+
from metaxy.graph.diff.rendering.base import BaseRenderer
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GraphvizRenderer(BaseRenderer):
|
|
11
|
+
"""Renders graph using pygraphviz.
|
|
12
|
+
|
|
13
|
+
Creates DOT format output using pygraphviz library.
|
|
14
|
+
Requires pygraphviz to be installed as optional dependency.
|
|
15
|
+
Supports both normal and diff rendering via node status.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def render(self) -> str:
|
|
19
|
+
"""Render graph as Graphviz DOT format.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
DOT format as string
|
|
23
|
+
"""
|
|
24
|
+
lines = []
|
|
25
|
+
|
|
26
|
+
# Get filtered graph data
|
|
27
|
+
filtered_graph = self._get_filtered_graph_data()
|
|
28
|
+
|
|
29
|
+
# Graph header
|
|
30
|
+
rankdir = self.config.direction
|
|
31
|
+
lines.append("strict digraph {")
|
|
32
|
+
lines.append(f" rankdir={rankdir};")
|
|
33
|
+
|
|
34
|
+
# Graph attributes
|
|
35
|
+
if self.config.show_snapshot_version:
|
|
36
|
+
label = f"Graph (snapshot: {self._format_hash(filtered_graph.snapshot_version)})"
|
|
37
|
+
else:
|
|
38
|
+
label = "Graph"
|
|
39
|
+
lines.append(f' label="{label}";')
|
|
40
|
+
lines.append(" labelloc=t;")
|
|
41
|
+
lines.append(" fontsize=14;")
|
|
42
|
+
lines.append(" fontname=helvetica;")
|
|
43
|
+
lines.append("")
|
|
44
|
+
|
|
45
|
+
# Add nodes for features
|
|
46
|
+
from metaxy.graph.diff.traversal import GraphWalker
|
|
47
|
+
|
|
48
|
+
walker = GraphWalker(filtered_graph)
|
|
49
|
+
for node in walker.topological_sort():
|
|
50
|
+
node_id = node.key.to_string()
|
|
51
|
+
label = self._build_feature_label(node)
|
|
52
|
+
|
|
53
|
+
# Choose shape and color based on status
|
|
54
|
+
shape = self._get_node_shape(node)
|
|
55
|
+
color = self._get_node_color(node)
|
|
56
|
+
|
|
57
|
+
lines.append(
|
|
58
|
+
f' "{node_id}" [label="{label}", shape={shape}, color="{color}"];'
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
lines.append("")
|
|
62
|
+
|
|
63
|
+
# Add edges for feature dependencies
|
|
64
|
+
for node in filtered_graph.nodes.values():
|
|
65
|
+
if node.dependencies:
|
|
66
|
+
target_id = node.key.to_string()
|
|
67
|
+
for dep_key in node.dependencies:
|
|
68
|
+
source_id = dep_key.to_string()
|
|
69
|
+
lines.append(f' "{source_id}" -> "{target_id}";')
|
|
70
|
+
|
|
71
|
+
lines.append("")
|
|
72
|
+
|
|
73
|
+
# Add field nodes if configured
|
|
74
|
+
if self.config.show_fields:
|
|
75
|
+
for node in filtered_graph.nodes.values():
|
|
76
|
+
parent_id = node.key.to_string()
|
|
77
|
+
|
|
78
|
+
if not node.fields:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
for field_node in node.fields:
|
|
82
|
+
field_id = f"{parent_id}::{field_node.key.to_string()}"
|
|
83
|
+
label = self._build_field_label(field_node)
|
|
84
|
+
|
|
85
|
+
# Get field color based on status
|
|
86
|
+
color = self._get_field_color(field_node)
|
|
87
|
+
|
|
88
|
+
lines.append(
|
|
89
|
+
f' "{field_id}" [label="{label}", shape=ellipse, '
|
|
90
|
+
f'color="{color}", fontsize=10];'
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Connect field to feature with dashed line
|
|
94
|
+
lines.append(
|
|
95
|
+
f' "{parent_id}" -> "{field_id}" [style=dashed, arrowhead=none];'
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
lines.append("}")
|
|
99
|
+
|
|
100
|
+
return "\n".join(lines)
|
|
101
|
+
|
|
102
|
+
def _get_node_shape(self, node: GraphNode) -> str:
|
|
103
|
+
"""Get Graphviz shape based on node properties.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
node: GraphNode
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Graphviz shape name
|
|
110
|
+
"""
|
|
111
|
+
# Root features get special shape
|
|
112
|
+
if not node.dependencies:
|
|
113
|
+
return "doubleoctagon"
|
|
114
|
+
|
|
115
|
+
# Different shapes for diff mode
|
|
116
|
+
if node.status == NodeStatus.REMOVED:
|
|
117
|
+
return "octagon"
|
|
118
|
+
elif node.status == NodeStatus.ADDED:
|
|
119
|
+
return "oval"
|
|
120
|
+
else:
|
|
121
|
+
return "box"
|
|
122
|
+
|
|
123
|
+
def _get_node_color(self, node: GraphNode) -> str:
|
|
124
|
+
"""Get border color based on node status.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
node: GraphNode
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Graphviz color (hex)
|
|
131
|
+
"""
|
|
132
|
+
if node.status == NodeStatus.ADDED:
|
|
133
|
+
return self.theme.added_color
|
|
134
|
+
elif node.status == NodeStatus.REMOVED:
|
|
135
|
+
return self.theme.removed_color
|
|
136
|
+
elif node.status == NodeStatus.CHANGED:
|
|
137
|
+
return self.theme.changed_color
|
|
138
|
+
elif node.status == NodeStatus.UNCHANGED:
|
|
139
|
+
return self.theme.unchanged_color
|
|
140
|
+
else:
|
|
141
|
+
return self.theme.feature_color
|
|
142
|
+
|
|
143
|
+
def _get_field_color(self, field_node) -> str:
|
|
144
|
+
"""Get border color for field based on status.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
field_node: FieldNode
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Graphviz color (hex)
|
|
151
|
+
"""
|
|
152
|
+
if field_node.status == NodeStatus.ADDED:
|
|
153
|
+
return self.theme.added_color
|
|
154
|
+
elif field_node.status == NodeStatus.REMOVED:
|
|
155
|
+
return self.theme.removed_color
|
|
156
|
+
elif field_node.status == NodeStatus.CHANGED:
|
|
157
|
+
return self.theme.changed_color
|
|
158
|
+
elif field_node.status == NodeStatus.UNCHANGED:
|
|
159
|
+
return self.theme.unchanged_color
|
|
160
|
+
else:
|
|
161
|
+
return self.theme.field_color
|
|
162
|
+
|
|
163
|
+
def _build_feature_label(self, node: GraphNode) -> str:
|
|
164
|
+
"""Build label for feature node.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
node: GraphNode
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Formatted label with optional version info
|
|
171
|
+
"""
|
|
172
|
+
parts = [self._format_feature_key(node.key)]
|
|
173
|
+
|
|
174
|
+
# Add status badge
|
|
175
|
+
if node.status != NodeStatus.NORMAL:
|
|
176
|
+
badge = self._get_status_badge(node.status)
|
|
177
|
+
parts.append(f"\\n{badge}")
|
|
178
|
+
|
|
179
|
+
if self.config.show_feature_versions:
|
|
180
|
+
if node.status == NodeStatus.CHANGED and node.old_version is not None:
|
|
181
|
+
# Show version transition
|
|
182
|
+
old_v = self._format_hash(node.old_version)
|
|
183
|
+
new_v = self._format_hash(node.version)
|
|
184
|
+
parts.append(f"\\nv: {old_v} → {new_v}")
|
|
185
|
+
else:
|
|
186
|
+
version = self._format_hash(node.version)
|
|
187
|
+
parts.append(f"\\nv: {version}")
|
|
188
|
+
|
|
189
|
+
if self.config.show_code_versions and node.code_version is not None:
|
|
190
|
+
parts.append(f"\\ncv: {node.code_version}")
|
|
191
|
+
|
|
192
|
+
return "".join(parts)
|
|
193
|
+
|
|
194
|
+
def _build_field_label(self, field_node) -> str:
|
|
195
|
+
"""Build label for field node.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
field_node: FieldNode
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
Formatted label with optional version info
|
|
202
|
+
"""
|
|
203
|
+
parts = [self._format_field_key(field_node.key)]
|
|
204
|
+
|
|
205
|
+
# Add status badge
|
|
206
|
+
if field_node.status != NodeStatus.NORMAL:
|
|
207
|
+
badge = self._get_status_badge(field_node.status)
|
|
208
|
+
parts.append(f"\\n{badge}")
|
|
209
|
+
|
|
210
|
+
if self.config.show_field_versions:
|
|
211
|
+
if (
|
|
212
|
+
field_node.status == NodeStatus.CHANGED
|
|
213
|
+
and field_node.old_version is not None
|
|
214
|
+
):
|
|
215
|
+
# Show version transition
|
|
216
|
+
old_v = self._format_hash(field_node.old_version)
|
|
217
|
+
new_v = self._format_hash(field_node.version)
|
|
218
|
+
parts.append(f"\\nv: {old_v} → {new_v}")
|
|
219
|
+
else:
|
|
220
|
+
version = self._format_hash(field_node.version)
|
|
221
|
+
parts.append(f"\\nv: {version}")
|
|
222
|
+
|
|
223
|
+
if self.config.show_code_versions and field_node.code_version is not None:
|
|
224
|
+
parts.append(f"\\ncv: {field_node.code_version}")
|
|
225
|
+
|
|
226
|
+
return "".join(parts)
|
|
227
|
+
|
|
228
|
+
def _get_status_badge(self, status: NodeStatus) -> str:
|
|
229
|
+
"""Get status badge text.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
status: Node status
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Badge string
|
|
236
|
+
"""
|
|
237
|
+
if status == NodeStatus.ADDED:
|
|
238
|
+
return "[ADDED]"
|
|
239
|
+
elif status == NodeStatus.REMOVED:
|
|
240
|
+
return "[REMOVED]"
|
|
241
|
+
elif status == NodeStatus.CHANGED:
|
|
242
|
+
return "[CHANGED]"
|
|
243
|
+
elif status == NodeStatus.UNCHANGED:
|
|
244
|
+
return "[UNCHANGED]"
|
|
245
|
+
else:
|
|
246
|
+
return ""
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Mermaid renderer for flowchart generation.
|
|
2
|
+
|
|
3
|
+
Requires mermaid-py library to be installed.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from metaxy.graph.diff.models import GraphNode, NodeStatus
|
|
7
|
+
from metaxy.graph.diff.rendering.base import BaseRenderer
|
|
8
|
+
from metaxy.graph.utils import sanitize_mermaid_id
|
|
9
|
+
from metaxy.models.types import FeatureKey
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MermaidRenderer(BaseRenderer):
|
|
13
|
+
"""Generates Mermaid flowchart markup using mermaid-py.
|
|
14
|
+
|
|
15
|
+
Creates flowchart with type-safe API.
|
|
16
|
+
Supports both normal and diff rendering via node status.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def render(self) -> str:
|
|
20
|
+
"""Render graph as Mermaid flowchart.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Mermaid markup as string
|
|
24
|
+
"""
|
|
25
|
+
from mermaid.flowchart import FlowChart, Link, Node
|
|
26
|
+
|
|
27
|
+
# Get filtered graph data
|
|
28
|
+
filtered_graph = self._get_filtered_graph_data()
|
|
29
|
+
|
|
30
|
+
# Create nodes with fields as sub-items in the label
|
|
31
|
+
from metaxy.graph.diff.traversal import GraphWalker
|
|
32
|
+
|
|
33
|
+
walker = GraphWalker(filtered_graph)
|
|
34
|
+
nodes = []
|
|
35
|
+
node_map = {} # feature_key string -> Node
|
|
36
|
+
|
|
37
|
+
for graph_node in walker.topological_sort():
|
|
38
|
+
node_id = self._node_id_from_key(graph_node.key)
|
|
39
|
+
|
|
40
|
+
# Build label with fields inside
|
|
41
|
+
label = self._build_feature_label_with_fields(graph_node)
|
|
42
|
+
|
|
43
|
+
# Choose shape based on status
|
|
44
|
+
shape = self._get_node_shape(graph_node)
|
|
45
|
+
|
|
46
|
+
node = Node(id_=node_id, content=label, shape=shape)
|
|
47
|
+
nodes.append(node)
|
|
48
|
+
node_map[graph_node.key.to_string()] = node
|
|
49
|
+
|
|
50
|
+
# Create links for dependencies
|
|
51
|
+
links = []
|
|
52
|
+
for graph_node in filtered_graph.nodes.values():
|
|
53
|
+
if graph_node.dependencies:
|
|
54
|
+
target_node = node_map.get(graph_node.key.to_string())
|
|
55
|
+
if target_node:
|
|
56
|
+
for dep_key in graph_node.dependencies:
|
|
57
|
+
source_node = node_map.get(dep_key.to_string())
|
|
58
|
+
if source_node:
|
|
59
|
+
links.append(Link(origin=source_node, end=target_node))
|
|
60
|
+
|
|
61
|
+
# Create flowchart
|
|
62
|
+
title = "Feature Graph"
|
|
63
|
+
|
|
64
|
+
chart = FlowChart(
|
|
65
|
+
title=title,
|
|
66
|
+
nodes=nodes,
|
|
67
|
+
links=links,
|
|
68
|
+
orientation=self.config.direction,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
script = chart.script
|
|
72
|
+
|
|
73
|
+
# Modify script to add styling and snapshot version
|
|
74
|
+
lines = script.split("\n")
|
|
75
|
+
|
|
76
|
+
# Find the flowchart line
|
|
77
|
+
for i, line in enumerate(lines):
|
|
78
|
+
if line.startswith("flowchart "):
|
|
79
|
+
insertions = []
|
|
80
|
+
|
|
81
|
+
# Add snapshot version comment if needed
|
|
82
|
+
if self.config.show_snapshot_version:
|
|
83
|
+
snapshot_hash = self._format_hash(filtered_graph.snapshot_version)
|
|
84
|
+
insertions.append(f" %% Snapshot version: {snapshot_hash}")
|
|
85
|
+
|
|
86
|
+
# Add styling
|
|
87
|
+
insertions.append(
|
|
88
|
+
" %%{init: {'flowchart': {'htmlLabels': true, 'curve': 'basis'}, 'themeVariables': {'fontSize': '14px'}}}%%"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Insert all additions after the flowchart line
|
|
92
|
+
for j, insertion in enumerate(insertions):
|
|
93
|
+
lines.insert(i + 1 + j, insertion)
|
|
94
|
+
break
|
|
95
|
+
|
|
96
|
+
script = "\n".join(lines)
|
|
97
|
+
|
|
98
|
+
# Add color styling for diff nodes if in diff mode
|
|
99
|
+
if self._is_diff_mode(filtered_graph):
|
|
100
|
+
script = self._add_diff_styling(script, filtered_graph)
|
|
101
|
+
|
|
102
|
+
return script
|
|
103
|
+
|
|
104
|
+
def _node_id_from_key(self, key: FeatureKey) -> str:
|
|
105
|
+
"""Generate valid node ID from feature key.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
key: Feature key
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Valid node identifier (lowercase, no special chars)
|
|
112
|
+
"""
|
|
113
|
+
return sanitize_mermaid_id(key.to_string()).lower()
|
|
114
|
+
|
|
115
|
+
def _get_node_shape(self, node: GraphNode) -> str:
|
|
116
|
+
"""Get Mermaid node shape based on status.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
node: GraphNode
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Mermaid shape name
|
|
123
|
+
"""
|
|
124
|
+
# Use different shapes for diff mode
|
|
125
|
+
if node.status == NodeStatus.REMOVED:
|
|
126
|
+
return "stadium" # Rounded box for removed
|
|
127
|
+
elif node.status == NodeStatus.ADDED:
|
|
128
|
+
return "round" # Rounded corners for added
|
|
129
|
+
else:
|
|
130
|
+
return "normal" # Standard rectangle
|
|
131
|
+
|
|
132
|
+
def _is_diff_mode(self, graph_data) -> bool:
|
|
133
|
+
"""Check if rendering in diff mode.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
graph_data: GraphData
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
True if any node has non-NORMAL status
|
|
140
|
+
"""
|
|
141
|
+
return any(
|
|
142
|
+
node.status != NodeStatus.NORMAL for node in graph_data.nodes.values()
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def _add_diff_styling(self, script: str, graph_data) -> str:
|
|
146
|
+
"""Add color styling for diff nodes.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
script: Mermaid script
|
|
150
|
+
graph_data: GraphData with status information
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Modified script with style classes
|
|
154
|
+
"""
|
|
155
|
+
lines = script.split("\n")
|
|
156
|
+
|
|
157
|
+
# Find position to insert style classes (before closing line)
|
|
158
|
+
insert_idx = len(lines)
|
|
159
|
+
|
|
160
|
+
style_lines = []
|
|
161
|
+
|
|
162
|
+
# Add style classes for each node based on status
|
|
163
|
+
for node in graph_data.nodes.values():
|
|
164
|
+
if node.status == NodeStatus.NORMAL:
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
node_id = self._node_id_from_key(node.key)
|
|
168
|
+
|
|
169
|
+
if node.status == NodeStatus.ADDED:
|
|
170
|
+
# Only color the border, no fill
|
|
171
|
+
style_lines.append(
|
|
172
|
+
f" style {node_id} stroke:{self.theme.added_color},stroke-width:2px"
|
|
173
|
+
)
|
|
174
|
+
elif node.status == NodeStatus.REMOVED:
|
|
175
|
+
# Only color the border, no fill
|
|
176
|
+
style_lines.append(
|
|
177
|
+
f" style {node_id} stroke:{self.theme.removed_color},stroke-width:2px"
|
|
178
|
+
)
|
|
179
|
+
elif node.status == NodeStatus.CHANGED:
|
|
180
|
+
# Only color the border, no fill
|
|
181
|
+
style_lines.append(
|
|
182
|
+
f" style {node_id} stroke:{self.theme.changed_color},stroke-width:2px"
|
|
183
|
+
)
|
|
184
|
+
elif node.status == NodeStatus.UNCHANGED:
|
|
185
|
+
# Only color the border, no fill
|
|
186
|
+
style_lines.append(
|
|
187
|
+
f" style {node_id} stroke:{self.theme.unchanged_color}"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Insert style lines before the end
|
|
191
|
+
if style_lines:
|
|
192
|
+
lines = lines[:insert_idx] + [""] + style_lines + lines[insert_idx:]
|
|
193
|
+
|
|
194
|
+
return "\n".join(lines)
|
|
195
|
+
|
|
196
|
+
def _build_feature_label_with_fields(self, node: GraphNode) -> str:
|
|
197
|
+
"""Build label for feature node with fields displayed inside.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
node: GraphNode
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Formatted label with feature info and fields as sub-items
|
|
204
|
+
"""
|
|
205
|
+
lines = []
|
|
206
|
+
|
|
207
|
+
# Feature key (bold)
|
|
208
|
+
feature_name = self._format_feature_key(node.key)
|
|
209
|
+
lines.append(f"<b>{feature_name}</b>")
|
|
210
|
+
|
|
211
|
+
# Add status badge for diff mode
|
|
212
|
+
if node.status != NodeStatus.NORMAL:
|
|
213
|
+
badge = self._get_status_badge_html(node.status)
|
|
214
|
+
lines.append(badge)
|
|
215
|
+
|
|
216
|
+
# Feature version info
|
|
217
|
+
if self.config.show_feature_versions or self.config.show_code_versions:
|
|
218
|
+
version_parts = []
|
|
219
|
+
|
|
220
|
+
if self.config.show_feature_versions:
|
|
221
|
+
if node.status == NodeStatus.CHANGED and node.old_version is not None:
|
|
222
|
+
# Show version transition
|
|
223
|
+
old_v = self._format_hash(node.old_version)
|
|
224
|
+
new_v = self._format_hash(node.version)
|
|
225
|
+
version_parts.append(f"v: {old_v} → {new_v}")
|
|
226
|
+
else:
|
|
227
|
+
version = self._format_hash(node.version)
|
|
228
|
+
version_parts.append(f"v: {version}")
|
|
229
|
+
|
|
230
|
+
if self.config.show_code_versions and node.code_version is not None:
|
|
231
|
+
version_parts.append(f"cv: {node.code_version}")
|
|
232
|
+
|
|
233
|
+
lines.append(f"<small>({', '.join(version_parts)})</small>")
|
|
234
|
+
|
|
235
|
+
# Fields (if configured)
|
|
236
|
+
if self.config.show_fields and node.fields:
|
|
237
|
+
# Subtle separator line before fields
|
|
238
|
+
lines.append('<font color="#999">---</font>')
|
|
239
|
+
for field_node in node.fields:
|
|
240
|
+
field_line = self._build_field_line(field_node)
|
|
241
|
+
lines.append(field_line)
|
|
242
|
+
|
|
243
|
+
# Wrap content in a div with left alignment
|
|
244
|
+
content = "<br/>".join(lines)
|
|
245
|
+
return f'<div style="text-align:left">{content}</div>'
|
|
246
|
+
|
|
247
|
+
def _build_field_line(self, field_node) -> str:
|
|
248
|
+
"""Build single line for field display.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
field_node: FieldNode
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
Formatted field line
|
|
255
|
+
"""
|
|
256
|
+
parts = [f"• {self._format_field_key(field_node.key)}"]
|
|
257
|
+
|
|
258
|
+
# Add status badge
|
|
259
|
+
if field_node.status != NodeStatus.NORMAL:
|
|
260
|
+
badge = self._get_status_badge_text(field_node.status)
|
|
261
|
+
parts[0] = f"{parts[0]} {badge}"
|
|
262
|
+
|
|
263
|
+
if self.config.show_field_versions or self.config.show_code_versions:
|
|
264
|
+
version_parts = []
|
|
265
|
+
|
|
266
|
+
if self.config.show_field_versions:
|
|
267
|
+
if (
|
|
268
|
+
field_node.status == NodeStatus.CHANGED
|
|
269
|
+
and field_node.old_version is not None
|
|
270
|
+
):
|
|
271
|
+
# Show version transition
|
|
272
|
+
old_v = self._format_hash(field_node.old_version)
|
|
273
|
+
new_v = self._format_hash(field_node.version)
|
|
274
|
+
version_parts.append(f"v: {old_v} → {new_v}")
|
|
275
|
+
else:
|
|
276
|
+
version = self._format_hash(field_node.version)
|
|
277
|
+
version_parts.append(f"v: {version}")
|
|
278
|
+
|
|
279
|
+
if self.config.show_code_versions and field_node.code_version is not None:
|
|
280
|
+
version_parts.append(f"cv: {field_node.code_version}")
|
|
281
|
+
|
|
282
|
+
parts.append(f"<small>({', '.join(version_parts)})</small>")
|
|
283
|
+
|
|
284
|
+
return " ".join(parts)
|
|
285
|
+
|
|
286
|
+
def _get_status_badge_html(self, status: NodeStatus) -> str:
|
|
287
|
+
"""Get HTML status badge.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
status: Node status
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
HTML badge string
|
|
294
|
+
"""
|
|
295
|
+
if status == NodeStatus.ADDED:
|
|
296
|
+
return '<small><font color="green">[+]</font></small>'
|
|
297
|
+
elif status == NodeStatus.REMOVED:
|
|
298
|
+
return '<small><font color="red">[-]</font></small>'
|
|
299
|
+
elif status == NodeStatus.CHANGED:
|
|
300
|
+
return '<small><font color="orange">[~]</font></small>'
|
|
301
|
+
else:
|
|
302
|
+
return ""
|
|
303
|
+
|
|
304
|
+
def _get_status_badge_text(self, status: NodeStatus) -> str:
|
|
305
|
+
"""Get text-only status badge.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
status: Node status
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
Text badge string
|
|
312
|
+
"""
|
|
313
|
+
if status == NodeStatus.ADDED:
|
|
314
|
+
return "[+]"
|
|
315
|
+
elif status == NodeStatus.REMOVED:
|
|
316
|
+
return "[-]"
|
|
317
|
+
elif status == NodeStatus.CHANGED:
|
|
318
|
+
return "[~]"
|
|
319
|
+
else:
|
|
320
|
+
return ""
|