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,418 @@
|
|
|
1
|
+
"""Core data models for graph rendering."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
from pydantic import Field
|
|
7
|
+
from typing_extensions import Self
|
|
8
|
+
|
|
9
|
+
from metaxy.models.bases import FrozenBaseModel
|
|
10
|
+
from metaxy.models.types import FeatureKey, FieldKey
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from metaxy.models.feature import FeatureGraph
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NodeStatus(str, Enum):
|
|
17
|
+
"""Status of a node in a diff view."""
|
|
18
|
+
|
|
19
|
+
NORMAL = "normal" # Normal node (not in diff mode)
|
|
20
|
+
UNCHANGED = "unchanged" # Unchanged in diff
|
|
21
|
+
ADDED = "added" # Added in diff
|
|
22
|
+
REMOVED = "removed" # Removed in diff
|
|
23
|
+
CHANGED = "changed" # Changed in diff
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FieldNode(FrozenBaseModel):
|
|
27
|
+
"""Represents a field within a feature node.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
key: Field key
|
|
31
|
+
version: Current field version hash
|
|
32
|
+
old_version: Previous field version hash (for diffs)
|
|
33
|
+
code_version: Code version (if available)
|
|
34
|
+
status: Field status (for diff rendering)
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
key: FieldKey
|
|
38
|
+
version: str | None = None # None if field was removed
|
|
39
|
+
old_version: str | None = None # For diff mode
|
|
40
|
+
code_version: int | None = None
|
|
41
|
+
status: NodeStatus = NodeStatus.NORMAL
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class GraphNode(FrozenBaseModel):
|
|
45
|
+
"""Represents a feature node in the graph.
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
key: Feature key
|
|
49
|
+
version: Current feature version hash
|
|
50
|
+
old_version: Previous feature version hash (for diffs)
|
|
51
|
+
code_version: Code version (if available)
|
|
52
|
+
fields: List of field nodes
|
|
53
|
+
dependencies: List of feature keys this node depends on
|
|
54
|
+
status: Node status (for diff rendering)
|
|
55
|
+
metadata: Additional custom metadata
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
key: FeatureKey
|
|
59
|
+
version: str | None = None # None if feature was removed
|
|
60
|
+
old_version: str | None = None # For diff mode
|
|
61
|
+
code_version: int | None = None
|
|
62
|
+
fields: list[FieldNode] = Field(default_factory=list)
|
|
63
|
+
dependencies: list[FeatureKey] = Field(default_factory=list)
|
|
64
|
+
status: NodeStatus = NodeStatus.NORMAL
|
|
65
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class EdgeData(FrozenBaseModel):
|
|
69
|
+
"""Represents an edge between two nodes.
|
|
70
|
+
|
|
71
|
+
Attributes:
|
|
72
|
+
from_key: Source feature key (dependency)
|
|
73
|
+
to_key: Target feature key (dependent)
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
from_key: FeatureKey
|
|
77
|
+
to_key: FeatureKey
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class GraphData(FrozenBaseModel):
|
|
81
|
+
"""Container for complete graph structure.
|
|
82
|
+
|
|
83
|
+
This is the unified data model used by all renderers.
|
|
84
|
+
|
|
85
|
+
Attributes:
|
|
86
|
+
nodes: Map from feature key string to GraphNode
|
|
87
|
+
edges: List of edges
|
|
88
|
+
snapshot_version: Optional snapshot version
|
|
89
|
+
old_snapshot_version: Optional old snapshot version (for diffs)
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
nodes: dict[str, GraphNode] # Key is feature_key.to_string()
|
|
93
|
+
edges: list[EdgeData] = Field(default_factory=list)
|
|
94
|
+
snapshot_version: str | None = None
|
|
95
|
+
old_snapshot_version: str | None = None # For diff mode
|
|
96
|
+
|
|
97
|
+
def get_node(self, key: FeatureKey) -> GraphNode | None:
|
|
98
|
+
"""Get node by feature key.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
key: Feature key to lookup
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
GraphNode if found, None otherwise
|
|
105
|
+
"""
|
|
106
|
+
return self.nodes.get(key.to_string())
|
|
107
|
+
|
|
108
|
+
def get_nodes_by_status(self, status: NodeStatus) -> list[GraphNode]:
|
|
109
|
+
"""Get all nodes with a specific status.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
status: Status to filter by
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
List of nodes with matching status
|
|
116
|
+
"""
|
|
117
|
+
return [node for node in self.nodes.values() if node.status == status]
|
|
118
|
+
|
|
119
|
+
def to_struct(self) -> dict[str, Any]:
|
|
120
|
+
"""Serialize to struct (native Python types for storage).
|
|
121
|
+
|
|
122
|
+
Note: This uses custom serialization instead of Pydantic's model_dump() because:
|
|
123
|
+
1. Polars struct columns require specific type conversions (e.g., None → "" for strings, None → 0 for ints)
|
|
124
|
+
2. Custom types (FeatureKey, FieldKey) need explicit string conversion for storage
|
|
125
|
+
3. The storage schema is a separate concern from the domain model's Python representation
|
|
126
|
+
4. Different storage backends may need different serialization formats in the future
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Dict with structure compatible with Polars struct type
|
|
130
|
+
"""
|
|
131
|
+
nodes_list = []
|
|
132
|
+
for node in self.nodes.values():
|
|
133
|
+
fields_list = []
|
|
134
|
+
for field in node.fields:
|
|
135
|
+
fields_list.append(
|
|
136
|
+
{
|
|
137
|
+
"key": field.key.to_string(),
|
|
138
|
+
"version": field.version if field.version is not None else "",
|
|
139
|
+
"code_version": field.code_version
|
|
140
|
+
if field.code_version is not None
|
|
141
|
+
else 0,
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
nodes_list.append(
|
|
146
|
+
{
|
|
147
|
+
"key": node.key.to_string(),
|
|
148
|
+
"version": node.version if node.version is not None else "",
|
|
149
|
+
"code_version": node.code_version
|
|
150
|
+
if node.code_version is not None
|
|
151
|
+
else 0,
|
|
152
|
+
"fields": fields_list,
|
|
153
|
+
"dependencies": [dep.to_string() for dep in node.dependencies],
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
edges_list = []
|
|
158
|
+
for edge in self.edges:
|
|
159
|
+
edges_list.append(
|
|
160
|
+
{
|
|
161
|
+
"from_key": edge.from_key.to_string(),
|
|
162
|
+
"to_key": edge.to_key.to_string(),
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
result: dict[str, Any] = {
|
|
167
|
+
"nodes": nodes_list,
|
|
168
|
+
"edges": edges_list,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
# Include snapshot_version if present
|
|
172
|
+
if self.snapshot_version is not None:
|
|
173
|
+
result["snapshot_version"] = self.snapshot_version
|
|
174
|
+
|
|
175
|
+
# Include old_snapshot_version if present (for diffs)
|
|
176
|
+
if self.old_snapshot_version is not None:
|
|
177
|
+
result["old_snapshot_version"] = self.old_snapshot_version
|
|
178
|
+
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
@classmethod
|
|
182
|
+
def from_struct(cls, struct_data: dict[str, Any]) -> Self:
|
|
183
|
+
"""Deserialize from struct.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
struct_data: Dict with structure from to_struct()
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
GraphData instance
|
|
190
|
+
"""
|
|
191
|
+
nodes = {}
|
|
192
|
+
for node_data in struct_data["nodes"]:
|
|
193
|
+
fields = []
|
|
194
|
+
for field_data in node_data["fields"]:
|
|
195
|
+
fields.append(
|
|
196
|
+
FieldNode(
|
|
197
|
+
key=FieldKey(field_data["key"].split("/")),
|
|
198
|
+
version=field_data["version"]
|
|
199
|
+
if field_data["version"]
|
|
200
|
+
else None,
|
|
201
|
+
code_version=field_data["code_version"]
|
|
202
|
+
if field_data["code_version"] != 0
|
|
203
|
+
else None,
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
node = GraphNode(
|
|
208
|
+
key=FeatureKey(node_data["key"].split("/")),
|
|
209
|
+
version=node_data["version"] if node_data["version"] else None,
|
|
210
|
+
code_version=node_data["code_version"]
|
|
211
|
+
if node_data["code_version"] != 0
|
|
212
|
+
else None,
|
|
213
|
+
fields=fields,
|
|
214
|
+
dependencies=[
|
|
215
|
+
FeatureKey(dep.split("/")) for dep in node_data["dependencies"]
|
|
216
|
+
],
|
|
217
|
+
)
|
|
218
|
+
nodes[node_data["key"]] = node
|
|
219
|
+
|
|
220
|
+
edges = []
|
|
221
|
+
for edge_data in struct_data["edges"]:
|
|
222
|
+
edges.append(
|
|
223
|
+
EdgeData(
|
|
224
|
+
from_key=FeatureKey(edge_data["from_key"].split("/")),
|
|
225
|
+
to_key=FeatureKey(edge_data["to_key"].split("/")),
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Extract snapshot_version if present
|
|
230
|
+
snapshot_version = struct_data.get("snapshot_version")
|
|
231
|
+
|
|
232
|
+
# Extract old_snapshot_version if present (for diffs)
|
|
233
|
+
old_snapshot_version = struct_data.get("old_snapshot_version")
|
|
234
|
+
|
|
235
|
+
return cls(
|
|
236
|
+
nodes=nodes,
|
|
237
|
+
edges=edges,
|
|
238
|
+
snapshot_version=snapshot_version,
|
|
239
|
+
old_snapshot_version=old_snapshot_version,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
@classmethod
|
|
243
|
+
def from_feature_graph(cls, graph: "FeatureGraph") -> "GraphData":
|
|
244
|
+
"""Convert a FeatureGraph to GraphData.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
graph: FeatureGraph instance
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
GraphData with all nodes and edges
|
|
251
|
+
"""
|
|
252
|
+
from metaxy.models.plan import FQFieldKey
|
|
253
|
+
|
|
254
|
+
nodes: dict[str, GraphNode] = {}
|
|
255
|
+
edges: list[EdgeData] = []
|
|
256
|
+
|
|
257
|
+
# Convert each feature to a GraphNode
|
|
258
|
+
for feature_key, feature_cls in graph.features_by_key.items():
|
|
259
|
+
feature_key_str = feature_key.to_string()
|
|
260
|
+
spec = feature_cls.spec
|
|
261
|
+
|
|
262
|
+
# Get feature version
|
|
263
|
+
feature_version = graph.get_feature_version(feature_key)
|
|
264
|
+
|
|
265
|
+
# Convert fields
|
|
266
|
+
field_nodes: list[FieldNode] = []
|
|
267
|
+
if spec.fields:
|
|
268
|
+
for field_spec in spec.fields:
|
|
269
|
+
# Compute field version
|
|
270
|
+
fq_field_key = FQFieldKey(feature=feature_key, field=field_spec.key)
|
|
271
|
+
field_version = graph.get_field_version(fq_field_key)
|
|
272
|
+
|
|
273
|
+
field_node = FieldNode(
|
|
274
|
+
key=field_spec.key,
|
|
275
|
+
version=field_version,
|
|
276
|
+
code_version=field_spec.code_version,
|
|
277
|
+
status=NodeStatus.NORMAL,
|
|
278
|
+
)
|
|
279
|
+
field_nodes.append(field_node)
|
|
280
|
+
|
|
281
|
+
# Extract dependencies
|
|
282
|
+
dependencies: list[FeatureKey] = []
|
|
283
|
+
if spec.deps:
|
|
284
|
+
dependencies = [dep.key for dep in spec.deps]
|
|
285
|
+
|
|
286
|
+
# Create node
|
|
287
|
+
node = GraphNode(
|
|
288
|
+
key=feature_key,
|
|
289
|
+
version=feature_version,
|
|
290
|
+
code_version=spec.code_version,
|
|
291
|
+
fields=field_nodes,
|
|
292
|
+
dependencies=dependencies,
|
|
293
|
+
status=NodeStatus.NORMAL,
|
|
294
|
+
)
|
|
295
|
+
nodes[feature_key_str] = node
|
|
296
|
+
|
|
297
|
+
# Create edges
|
|
298
|
+
for dep_key in dependencies:
|
|
299
|
+
edges.append(EdgeData(from_key=dep_key, to_key=feature_key))
|
|
300
|
+
|
|
301
|
+
return cls(
|
|
302
|
+
nodes=nodes,
|
|
303
|
+
edges=edges,
|
|
304
|
+
snapshot_version=graph.snapshot_version,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
@classmethod
|
|
308
|
+
def from_merged_diff(cls, merged_data: dict[str, Any]) -> "GraphData":
|
|
309
|
+
"""Convert merged diff data to GraphData.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
merged_data: Merged diff data from GraphDiffer.create_merged_graph_data()
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
GraphData with status annotations
|
|
316
|
+
"""
|
|
317
|
+
from metaxy.graph.diff.diff_models import FieldChange
|
|
318
|
+
|
|
319
|
+
nodes: dict[str, GraphNode] = {}
|
|
320
|
+
edges: list[EdgeData] = []
|
|
321
|
+
|
|
322
|
+
# Convert nodes
|
|
323
|
+
for feature_key_str, node_data in merged_data["nodes"].items():
|
|
324
|
+
# Parse feature key
|
|
325
|
+
feature_key = FeatureKey(feature_key_str.split("/"))
|
|
326
|
+
|
|
327
|
+
# Map status strings to NodeStatus enum
|
|
328
|
+
status_str = node_data["status"]
|
|
329
|
+
if status_str == "added":
|
|
330
|
+
status = NodeStatus.ADDED
|
|
331
|
+
elif status_str == "removed":
|
|
332
|
+
status = NodeStatus.REMOVED
|
|
333
|
+
elif status_str == "changed":
|
|
334
|
+
status = NodeStatus.CHANGED
|
|
335
|
+
elif status_str == "unchanged":
|
|
336
|
+
status = NodeStatus.UNCHANGED
|
|
337
|
+
else:
|
|
338
|
+
status = NodeStatus.NORMAL
|
|
339
|
+
|
|
340
|
+
# Convert fields
|
|
341
|
+
fields_dict = node_data.get("fields", {})
|
|
342
|
+
field_changes_list = node_data.get("field_changes", [])
|
|
343
|
+
|
|
344
|
+
# Build field change map for quick lookup
|
|
345
|
+
field_change_map: dict[str, FieldChange] = {}
|
|
346
|
+
for fc in field_changes_list:
|
|
347
|
+
if isinstance(fc, FieldChange):
|
|
348
|
+
field_change_map[fc.field_key.to_string()] = fc
|
|
349
|
+
|
|
350
|
+
# Get all field keys (from both current fields and removed fields in changes)
|
|
351
|
+
all_field_keys = set(fields_dict.keys())
|
|
352
|
+
all_field_keys.update(field_change_map.keys())
|
|
353
|
+
|
|
354
|
+
field_nodes: list[FieldNode] = []
|
|
355
|
+
for field_key_str in all_field_keys:
|
|
356
|
+
# Parse field key
|
|
357
|
+
field_key = FieldKey(field_key_str.split("/"))
|
|
358
|
+
|
|
359
|
+
# Determine field status and versions
|
|
360
|
+
if field_key_str in field_change_map:
|
|
361
|
+
fc = field_change_map[field_key_str]
|
|
362
|
+
if fc.is_added:
|
|
363
|
+
field_status = NodeStatus.ADDED
|
|
364
|
+
field_version = fc.new_version
|
|
365
|
+
old_field_version = None
|
|
366
|
+
elif fc.is_removed:
|
|
367
|
+
field_status = NodeStatus.REMOVED
|
|
368
|
+
field_version = None
|
|
369
|
+
old_field_version = fc.old_version
|
|
370
|
+
elif fc.is_changed:
|
|
371
|
+
field_status = NodeStatus.CHANGED
|
|
372
|
+
field_version = fc.new_version
|
|
373
|
+
old_field_version = fc.old_version
|
|
374
|
+
else:
|
|
375
|
+
field_status = NodeStatus.UNCHANGED
|
|
376
|
+
field_version = fc.new_version or fc.old_version
|
|
377
|
+
old_field_version = None
|
|
378
|
+
else:
|
|
379
|
+
# Unchanged field
|
|
380
|
+
field_status = NodeStatus.UNCHANGED
|
|
381
|
+
field_version = fields_dict.get(field_key_str)
|
|
382
|
+
old_field_version = None
|
|
383
|
+
|
|
384
|
+
field_node = FieldNode(
|
|
385
|
+
key=field_key,
|
|
386
|
+
version=field_version,
|
|
387
|
+
old_version=old_field_version,
|
|
388
|
+
status=field_status,
|
|
389
|
+
)
|
|
390
|
+
field_nodes.append(field_node)
|
|
391
|
+
|
|
392
|
+
# Parse dependencies
|
|
393
|
+
dependencies = [
|
|
394
|
+
FeatureKey(dep_str.split("/"))
|
|
395
|
+
for dep_str in node_data.get("dependencies", [])
|
|
396
|
+
]
|
|
397
|
+
|
|
398
|
+
# Create node
|
|
399
|
+
node = GraphNode(
|
|
400
|
+
key=feature_key,
|
|
401
|
+
version=node_data.get("new_version"),
|
|
402
|
+
old_version=node_data.get("old_version"),
|
|
403
|
+
fields=field_nodes,
|
|
404
|
+
dependencies=dependencies,
|
|
405
|
+
status=status,
|
|
406
|
+
)
|
|
407
|
+
nodes[feature_key_str] = node
|
|
408
|
+
|
|
409
|
+
# Convert edges
|
|
410
|
+
for edge_dict in merged_data["edges"]:
|
|
411
|
+
from_key = FeatureKey(edge_dict["from"].split("/"))
|
|
412
|
+
to_key = FeatureKey(edge_dict["to"].split("/"))
|
|
413
|
+
edges.append(EdgeData(from_key=from_key, to_key=to_key))
|
|
414
|
+
|
|
415
|
+
return cls(
|
|
416
|
+
nodes=nodes,
|
|
417
|
+
edges=edges,
|
|
418
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Graph rendering - visualization backends for graphs and diffs."""
|
|
2
|
+
|
|
3
|
+
from metaxy.graph.diff.rendering.base import BaseRenderer, RenderConfig
|
|
4
|
+
from metaxy.graph.diff.rendering.cards import CardsRenderer
|
|
5
|
+
from metaxy.graph.diff.rendering.graphviz import GraphvizRenderer
|
|
6
|
+
from metaxy.graph.diff.rendering.mermaid import MermaidRenderer
|
|
7
|
+
from metaxy.graph.diff.rendering.rich import TerminalRenderer
|
|
8
|
+
from metaxy.graph.diff.rendering.theme import Theme
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"BaseRenderer",
|
|
12
|
+
"RenderConfig",
|
|
13
|
+
"TerminalRenderer",
|
|
14
|
+
"CardsRenderer",
|
|
15
|
+
"MermaidRenderer",
|
|
16
|
+
"GraphvizRenderer",
|
|
17
|
+
"Theme",
|
|
18
|
+
]
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Base classes and configuration for graph rendering."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from metaxy.graph.diff.models import GraphData, NodeStatus
|
|
6
|
+
from metaxy.graph.diff.rendering.theme import Theme
|
|
7
|
+
from metaxy.graph.diff.traversal import GraphWalker
|
|
8
|
+
from metaxy.graph.utils import format_feature_key, format_field_key, format_hash
|
|
9
|
+
from metaxy.models.feature import FeatureGraph
|
|
10
|
+
from metaxy.models.types import FeatureKey, FieldKey
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class RenderConfig:
|
|
15
|
+
"""Configuration for graph rendering.
|
|
16
|
+
|
|
17
|
+
Controls what information is displayed and how it's formatted.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
# What to show
|
|
21
|
+
show_fields: bool = field(
|
|
22
|
+
default=True,
|
|
23
|
+
metadata={"help": "Show field-level details within features"},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
show_feature_versions: bool = field(
|
|
27
|
+
default=True,
|
|
28
|
+
metadata={"help": "Show feature version hashes"},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
show_field_versions: bool = field(
|
|
32
|
+
default=True,
|
|
33
|
+
metadata={"help": "Show field version hashes (requires --show-fields)"},
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
show_code_versions: bool = field(
|
|
37
|
+
default=False,
|
|
38
|
+
metadata={"help": "Show feature and field code versions"},
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
show_snapshot_version: bool = field(
|
|
42
|
+
default=True,
|
|
43
|
+
metadata={"help": "Show graph snapshot version in output"},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Display options
|
|
47
|
+
hash_length: int = field(
|
|
48
|
+
default=8,
|
|
49
|
+
metadata={
|
|
50
|
+
"help": "Number of characters to show for version hashes (0 for full)"
|
|
51
|
+
},
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
direction: str = field(
|
|
55
|
+
default="TB",
|
|
56
|
+
metadata={"help": "Graph layout direction: TB (top-bottom) or LR (left-right)"},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Filtering options
|
|
60
|
+
feature: str | None = field(
|
|
61
|
+
default=None,
|
|
62
|
+
metadata={
|
|
63
|
+
"help": "Focus on a specific feature (e.g., 'video/files' or 'video__files')"
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
up: int | None = field(
|
|
68
|
+
default=None,
|
|
69
|
+
metadata={
|
|
70
|
+
"help": "Number of dependency levels to render upstream (default: all)"
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
down: int | None = field(
|
|
75
|
+
default=None,
|
|
76
|
+
metadata={
|
|
77
|
+
"help": "Number of dependency levels to render downstream (default: all)"
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def get_feature_key(self) -> FeatureKey | None:
|
|
82
|
+
"""Parse feature string into FeatureKey.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
FeatureKey if feature is set, None otherwise
|
|
86
|
+
"""
|
|
87
|
+
if self.feature is None:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
# Support both formats: "video__files" or "video/files"
|
|
91
|
+
if "/" in self.feature:
|
|
92
|
+
return FeatureKey(self.feature.split("/"))
|
|
93
|
+
else:
|
|
94
|
+
return FeatureKey(self.feature.split("__"))
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def minimal(cls) -> "RenderConfig":
|
|
98
|
+
"""Preset: minimal information (structure only)."""
|
|
99
|
+
return cls(
|
|
100
|
+
show_fields=False,
|
|
101
|
+
show_feature_versions=False,
|
|
102
|
+
show_field_versions=False,
|
|
103
|
+
show_code_versions=False,
|
|
104
|
+
show_snapshot_version=False,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def default(cls) -> "RenderConfig":
|
|
109
|
+
"""Preset: default information level (balanced)."""
|
|
110
|
+
return cls(
|
|
111
|
+
show_fields=True,
|
|
112
|
+
show_feature_versions=True,
|
|
113
|
+
show_field_versions=True,
|
|
114
|
+
show_code_versions=False,
|
|
115
|
+
show_snapshot_version=True,
|
|
116
|
+
hash_length=8,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def verbose(cls) -> "RenderConfig":
|
|
121
|
+
"""Preset: maximum information (everything)."""
|
|
122
|
+
return cls(
|
|
123
|
+
show_fields=True,
|
|
124
|
+
show_feature_versions=True,
|
|
125
|
+
show_field_versions=True,
|
|
126
|
+
show_code_versions=True,
|
|
127
|
+
show_snapshot_version=True,
|
|
128
|
+
hash_length=0, # Full hashes
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class BaseRenderer:
|
|
133
|
+
"""Base class for graph renderers.
|
|
134
|
+
|
|
135
|
+
Provides common utilities for formatting keys and hashes.
|
|
136
|
+
Uses unified GraphData model and Theme system.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
graph: FeatureGraph | None = None,
|
|
142
|
+
config: RenderConfig | None = None,
|
|
143
|
+
graph_data: GraphData | None = None,
|
|
144
|
+
theme: Theme | None = None,
|
|
145
|
+
):
|
|
146
|
+
"""Initialize renderer.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
graph: FeatureGraph (converted to GraphData automatically)
|
|
150
|
+
config: Render configuration
|
|
151
|
+
graph_data: GraphData (alternative to feature graph)
|
|
152
|
+
theme: Color theme (uses default if None)
|
|
153
|
+
|
|
154
|
+
Note:
|
|
155
|
+
Either graph or graph_data must be provided.
|
|
156
|
+
If both are provided, an error is raised.
|
|
157
|
+
"""
|
|
158
|
+
if graph_data is None and graph is None:
|
|
159
|
+
raise ValueError("Either graph or graph_data must be provided")
|
|
160
|
+
|
|
161
|
+
# Prefer graph_data if provided, otherwise convert from graph
|
|
162
|
+
if graph_data is not None:
|
|
163
|
+
self.graph_data: GraphData = graph_data
|
|
164
|
+
else:
|
|
165
|
+
# graph is not None (validated above)
|
|
166
|
+
assert graph is not None
|
|
167
|
+
self.graph_data = GraphData.from_feature_graph(graph)
|
|
168
|
+
|
|
169
|
+
self.config = config or RenderConfig()
|
|
170
|
+
self.theme = theme or Theme.default()
|
|
171
|
+
self.walker = GraphWalker(self.graph_data)
|
|
172
|
+
|
|
173
|
+
def _format_hash(self, hash_str: str | None) -> str:
|
|
174
|
+
"""Format hash according to config.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
hash_str: Full hash string (or None for removed nodes)
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Truncated hash if hash_length > 0, otherwise full hash
|
|
181
|
+
"""
|
|
182
|
+
if hash_str is None:
|
|
183
|
+
return "none"
|
|
184
|
+
return format_hash(hash_str, length=self.config.hash_length)
|
|
185
|
+
|
|
186
|
+
def _get_status_color(self, status: NodeStatus) -> str:
|
|
187
|
+
"""Get color for a given status.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
status: Node or field status
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Color string for Rich markup
|
|
194
|
+
"""
|
|
195
|
+
if status == NodeStatus.ADDED:
|
|
196
|
+
return self.theme.added_color
|
|
197
|
+
elif status == NodeStatus.REMOVED:
|
|
198
|
+
return self.theme.removed_color
|
|
199
|
+
elif status == NodeStatus.CHANGED:
|
|
200
|
+
return self.theme.changed_color
|
|
201
|
+
elif status == NodeStatus.UNCHANGED:
|
|
202
|
+
return self.theme.unchanged_color
|
|
203
|
+
else:
|
|
204
|
+
return self.theme.feature_color
|
|
205
|
+
|
|
206
|
+
def _format_version_transition(
|
|
207
|
+
self, old_version: str | None, new_version: str | None
|
|
208
|
+
) -> str:
|
|
209
|
+
"""Format version transition for diff display.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
old_version: Old version hash
|
|
213
|
+
new_version: New version hash
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Formatted string like "old... → new..."
|
|
217
|
+
"""
|
|
218
|
+
old_str = self._format_hash(old_version)
|
|
219
|
+
new_str = self._format_hash(new_version)
|
|
220
|
+
return (
|
|
221
|
+
f"[{self.theme.old_version_color}]{old_str}[/{self.theme.old_version_color}]... → "
|
|
222
|
+
f"[{self.theme.new_version_color}]{new_str}[/{self.theme.new_version_color}]..."
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def _format_feature_key(self, key: FeatureKey) -> str:
|
|
226
|
+
"""Format feature key for display.
|
|
227
|
+
|
|
228
|
+
Uses / separator instead of __ for better readability.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
key: Feature key
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
Formatted string like "my/feature/key"
|
|
235
|
+
"""
|
|
236
|
+
return format_feature_key(key)
|
|
237
|
+
|
|
238
|
+
def _format_field_key(self, key: FieldKey) -> str:
|
|
239
|
+
"""Format field key for display.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
key: Field key
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Formatted string like "field_name"
|
|
246
|
+
"""
|
|
247
|
+
return format_field_key(key)
|
|
248
|
+
|
|
249
|
+
def _get_filtered_graph_data(self) -> GraphData:
|
|
250
|
+
"""Get filtered graph data based on config filters.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
GraphData with only filtered nodes and edges
|
|
254
|
+
"""
|
|
255
|
+
focus_key = self.config.get_feature_key()
|
|
256
|
+
|
|
257
|
+
# If no focus feature specified, return full graph
|
|
258
|
+
if focus_key is None:
|
|
259
|
+
return self.graph_data
|
|
260
|
+
|
|
261
|
+
# Use walker to extract subgraph
|
|
262
|
+
return self.walker.extract_subgraph(
|
|
263
|
+
focus_key=focus_key,
|
|
264
|
+
up=self.config.up,
|
|
265
|
+
down=self.config.down,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def render(self) -> str:
|
|
269
|
+
"""Render the graph and return string output.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
Rendered graph as string
|
|
273
|
+
"""
|
|
274
|
+
raise NotImplementedError
|