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,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 ""