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
metaxy/cli/context.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""CLI application context for sharing state across commands."""
|
|
2
|
+
|
|
3
|
+
from contextvars import ContextVar
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from metaxy.config import MetaxyConfig
|
|
8
|
+
from metaxy.metadata_store.base import MetadataStore
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AppContext:
|
|
12
|
+
"""CLI application context.
|
|
13
|
+
|
|
14
|
+
Stores the config initialized by the meta app launcher.
|
|
15
|
+
Commands access it via get_config() and instantiate stores as needed.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, config: "MetaxyConfig"):
|
|
19
|
+
"""Initialize context.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
config: Metaxy configuration
|
|
23
|
+
"""
|
|
24
|
+
self.config = config
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Context variable for storing the app context
|
|
28
|
+
_app_context: ContextVar[AppContext | None] = ContextVar("_app_context", default=None)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def set_config(config: "MetaxyConfig") -> None:
|
|
32
|
+
"""Set the config in CLI context.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
config: Metaxy configuration
|
|
36
|
+
"""
|
|
37
|
+
_app_context.set(AppContext(config))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_config() -> "MetaxyConfig":
|
|
41
|
+
"""Get the Metaxy config from context.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
MetaxyConfig instance
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
RuntimeError: If context not initialized
|
|
48
|
+
"""
|
|
49
|
+
ctx = _app_context.get()
|
|
50
|
+
if ctx is None:
|
|
51
|
+
raise RuntimeError(
|
|
52
|
+
"CLI context not initialized. Config must be set by meta app."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return ctx.config
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_store(name: str | None = None) -> "MetadataStore":
|
|
59
|
+
"""Get and open a metadata store from config.
|
|
60
|
+
|
|
61
|
+
Opens the store for the duration of the command.
|
|
62
|
+
Store is retrieved from config context.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Opened metadata store instance (within context manager)
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
RuntimeError: If context not initialized
|
|
69
|
+
"""
|
|
70
|
+
config = get_config()
|
|
71
|
+
return config.get_store(name)
|
metaxy/cli/graph.py
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
"""Graph management commands for Metaxy CLI."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Literal
|
|
4
|
+
|
|
5
|
+
import cyclopts
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from metaxy.graph import RenderConfig
|
|
10
|
+
|
|
11
|
+
# Rich console for formatted output
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
# Graph subcommand app
|
|
15
|
+
app = cyclopts.App(
|
|
16
|
+
name="graph", # pyrefly: ignore[unexpected-keyword]
|
|
17
|
+
help="Manage feature graphs", # pyrefly: ignore[unexpected-keyword]
|
|
18
|
+
console=console, # pyrefly: ignore[unexpected-keyword]
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.command()
|
|
23
|
+
def push(
|
|
24
|
+
store: Annotated[
|
|
25
|
+
str | None,
|
|
26
|
+
cyclopts.Parameter(
|
|
27
|
+
name=["--store"],
|
|
28
|
+
help="Metadata store to use (defaults to configured default store)",
|
|
29
|
+
),
|
|
30
|
+
] = None,
|
|
31
|
+
):
|
|
32
|
+
"""Record all feature versions (push graph snapshot).
|
|
33
|
+
|
|
34
|
+
Records all features in the active graph to the metadata store
|
|
35
|
+
with a deterministic snapshot version. This should be run after deploying
|
|
36
|
+
new feature definitions.
|
|
37
|
+
|
|
38
|
+
Example:
|
|
39
|
+
$ metaxy graph push
|
|
40
|
+
|
|
41
|
+
✓ Recorded feature graph
|
|
42
|
+
Snapshot version: abc123def456...
|
|
43
|
+
|
|
44
|
+
# Or if already recorded:
|
|
45
|
+
ℹ Snapshot already recorded (skipped)
|
|
46
|
+
Snapshot version: abc123def456...
|
|
47
|
+
"""
|
|
48
|
+
from metaxy.cli.context import get_store
|
|
49
|
+
from metaxy.entrypoints import load_features
|
|
50
|
+
|
|
51
|
+
# Load features from entrypoints
|
|
52
|
+
load_features()
|
|
53
|
+
|
|
54
|
+
metadata_store = get_store(store)
|
|
55
|
+
|
|
56
|
+
with metadata_store:
|
|
57
|
+
snapshot_version, was_already_recorded = (
|
|
58
|
+
metadata_store.record_feature_graph_snapshot()
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if was_already_recorded:
|
|
62
|
+
console.print("[blue]ℹ[/blue] Snapshot already recorded (skipped)")
|
|
63
|
+
console.print(f" Snapshot version: {snapshot_version}")
|
|
64
|
+
else:
|
|
65
|
+
console.print("[green]✓[/green] Recorded feature graph")
|
|
66
|
+
console.print(f" Snapshot version: {snapshot_version}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command()
|
|
70
|
+
def history(
|
|
71
|
+
store: Annotated[
|
|
72
|
+
str | None,
|
|
73
|
+
cyclopts.Parameter(
|
|
74
|
+
name=["--store"],
|
|
75
|
+
help="Metadata store to use (defaults to configured default store)",
|
|
76
|
+
),
|
|
77
|
+
] = None,
|
|
78
|
+
limit: Annotated[
|
|
79
|
+
int | None,
|
|
80
|
+
cyclopts.Parameter(
|
|
81
|
+
name=["--limit"],
|
|
82
|
+
help="Limit number of snapshots to show (defaults to all)",
|
|
83
|
+
),
|
|
84
|
+
] = None,
|
|
85
|
+
):
|
|
86
|
+
"""Show history of recorded graph snapshots.
|
|
87
|
+
|
|
88
|
+
Displays all recorded graph snapshots from the metadata store,
|
|
89
|
+
showing snapshot versions, when they were recorded, and feature counts.
|
|
90
|
+
|
|
91
|
+
Example:
|
|
92
|
+
$ metaxy graph history
|
|
93
|
+
|
|
94
|
+
Graph Snapshot History
|
|
95
|
+
┌──────────────┬─────────────────────┬───────────────┐
|
|
96
|
+
│ Snapshot version │ Recorded At │ Feature Count │
|
|
97
|
+
├──────────────┼─────────────────────┼───────────────┤
|
|
98
|
+
│ abc123... │ 2025-01-15 10:30:00 │ 42 │
|
|
99
|
+
│ def456... │ 2025-01-14 09:15:00 │ 40 │
|
|
100
|
+
└──────────────┴─────────────────────┴───────────────┘
|
|
101
|
+
"""
|
|
102
|
+
from metaxy.cli.context import get_store
|
|
103
|
+
from metaxy.entrypoints import load_features
|
|
104
|
+
|
|
105
|
+
# Load features from entrypoints
|
|
106
|
+
load_features()
|
|
107
|
+
|
|
108
|
+
metadata_store = get_store(store)
|
|
109
|
+
|
|
110
|
+
with metadata_store:
|
|
111
|
+
# Read snapshot history
|
|
112
|
+
snapshots_df = metadata_store.read_graph_snapshots()
|
|
113
|
+
|
|
114
|
+
if snapshots_df.height == 0:
|
|
115
|
+
console.print("[yellow]No graph snapshots recorded yet[/yellow]")
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
# Limit results if requested
|
|
119
|
+
if limit is not None:
|
|
120
|
+
snapshots_df = snapshots_df.head(limit)
|
|
121
|
+
|
|
122
|
+
# Create table
|
|
123
|
+
table = Table(title="Graph Snapshot History")
|
|
124
|
+
table.add_column(
|
|
125
|
+
"Snapshot version", style="cyan", no_wrap=False, overflow="fold"
|
|
126
|
+
)
|
|
127
|
+
table.add_column("Recorded At", style="green", no_wrap=False)
|
|
128
|
+
table.add_column(
|
|
129
|
+
"Feature Count", style="yellow", justify="right", no_wrap=False
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Add rows
|
|
133
|
+
for row in snapshots_df.iter_rows(named=True):
|
|
134
|
+
snapshot_version = row["snapshot_version"]
|
|
135
|
+
recorded_at = row["recorded_at"].strftime("%Y-%m-%d %H:%M:%S")
|
|
136
|
+
feature_count = str(row["feature_count"])
|
|
137
|
+
|
|
138
|
+
table.add_row(snapshot_version, recorded_at, feature_count)
|
|
139
|
+
|
|
140
|
+
console.print(table)
|
|
141
|
+
console.print(f"\nTotal snapshots: {snapshots_df.height}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@app.command()
|
|
145
|
+
def describe(
|
|
146
|
+
snapshot: Annotated[
|
|
147
|
+
str | None,
|
|
148
|
+
cyclopts.Parameter(
|
|
149
|
+
name=["--snapshot"],
|
|
150
|
+
help="Snapshot version to describe (defaults to current graph from code)",
|
|
151
|
+
),
|
|
152
|
+
] = None,
|
|
153
|
+
store: Annotated[
|
|
154
|
+
str | None,
|
|
155
|
+
cyclopts.Parameter(
|
|
156
|
+
name=["--store"],
|
|
157
|
+
help="Metadata store to use (defaults to configured default store)",
|
|
158
|
+
),
|
|
159
|
+
] = None,
|
|
160
|
+
):
|
|
161
|
+
"""Describe a graph snapshot.
|
|
162
|
+
|
|
163
|
+
Shows detailed information about a graph snapshot including:
|
|
164
|
+
- Feature count
|
|
165
|
+
- Graph depth (longest dependency chain)
|
|
166
|
+
- Root features (features with no dependencies)
|
|
167
|
+
|
|
168
|
+
Example:
|
|
169
|
+
$ metaxy graph describe
|
|
170
|
+
|
|
171
|
+
Graph Snapshot: abc123def456...
|
|
172
|
+
┌─────────────────────┬────────┐
|
|
173
|
+
│ Metric │ Value │
|
|
174
|
+
├─────────────────────┼────────┤
|
|
175
|
+
│ Feature Count │ 42 │
|
|
176
|
+
│ Graph Depth │ 5 │
|
|
177
|
+
│ Root Features │ 8 │
|
|
178
|
+
└─────────────────────┴────────┘
|
|
179
|
+
|
|
180
|
+
Root Features:
|
|
181
|
+
• user__profile
|
|
182
|
+
• transaction__history
|
|
183
|
+
...
|
|
184
|
+
"""
|
|
185
|
+
from metaxy.cli.context import get_store
|
|
186
|
+
from metaxy.entrypoints import load_features
|
|
187
|
+
from metaxy.models.feature import FeatureGraph
|
|
188
|
+
|
|
189
|
+
# Load features from entrypoints
|
|
190
|
+
load_features()
|
|
191
|
+
|
|
192
|
+
metadata_store = get_store(store)
|
|
193
|
+
|
|
194
|
+
with metadata_store:
|
|
195
|
+
# Determine which snapshot to describe
|
|
196
|
+
if snapshot is None:
|
|
197
|
+
# Use current graph from code
|
|
198
|
+
graph = FeatureGraph.get_active()
|
|
199
|
+
snapshot_version = graph.snapshot_version
|
|
200
|
+
console.print("[cyan]Describing current graph from code[/cyan]")
|
|
201
|
+
else:
|
|
202
|
+
# Use specified snapshot
|
|
203
|
+
snapshot_version = snapshot
|
|
204
|
+
console.print(f"[cyan]Describing snapshot: {snapshot_version}[/cyan]")
|
|
205
|
+
|
|
206
|
+
# Load graph from snapshot
|
|
207
|
+
features_df = metadata_store.read_features(
|
|
208
|
+
current=False, snapshot_version=snapshot_version
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
if features_df.height == 0:
|
|
212
|
+
console.print(
|
|
213
|
+
f"[red]✗[/red] No features found for snapshot {snapshot_version}"
|
|
214
|
+
)
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
# For historical snapshots, we'll use the current graph structure
|
|
218
|
+
# but report on the features that were in that snapshot
|
|
219
|
+
graph = FeatureGraph.get_active()
|
|
220
|
+
|
|
221
|
+
# Get graph metrics
|
|
222
|
+
feature_count = len(graph.features_by_key)
|
|
223
|
+
|
|
224
|
+
# Calculate graph depth (longest dependency chain)
|
|
225
|
+
def get_feature_depth(feature_key, visited=None):
|
|
226
|
+
if visited is None:
|
|
227
|
+
visited = set()
|
|
228
|
+
|
|
229
|
+
if feature_key in visited:
|
|
230
|
+
return 0 # Avoid cycles
|
|
231
|
+
|
|
232
|
+
visited.add(feature_key)
|
|
233
|
+
|
|
234
|
+
feature_cls = graph.features_by_key.get(feature_key)
|
|
235
|
+
if feature_cls is None or not feature_cls.spec.deps:
|
|
236
|
+
return 1
|
|
237
|
+
|
|
238
|
+
max_dep_depth = 0
|
|
239
|
+
for dep in feature_cls.spec.deps:
|
|
240
|
+
dep_depth = get_feature_depth(dep.key, visited.copy())
|
|
241
|
+
max_dep_depth = max(max_dep_depth, dep_depth)
|
|
242
|
+
|
|
243
|
+
return max_dep_depth + 1
|
|
244
|
+
|
|
245
|
+
max_depth = 0
|
|
246
|
+
for feature_key in graph.features_by_key:
|
|
247
|
+
depth = get_feature_depth(feature_key)
|
|
248
|
+
max_depth = max(max_depth, depth)
|
|
249
|
+
|
|
250
|
+
# Find root features (no dependencies)
|
|
251
|
+
root_features = [
|
|
252
|
+
feature_key
|
|
253
|
+
for feature_key, feature_cls in graph.features_by_key.items()
|
|
254
|
+
if not feature_cls.spec.deps
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
# Display summary table
|
|
258
|
+
console.print()
|
|
259
|
+
summary_table = Table(title=f"Graph Snapshot: {snapshot_version}")
|
|
260
|
+
summary_table.add_column("Metric", style="cyan", no_wrap=False)
|
|
261
|
+
summary_table.add_column(
|
|
262
|
+
"Value", style="yellow", justify="right", no_wrap=False
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
summary_table.add_row("Feature Count", str(feature_count))
|
|
266
|
+
summary_table.add_row("Graph Depth", str(max_depth))
|
|
267
|
+
summary_table.add_row("Root Features", str(len(root_features)))
|
|
268
|
+
|
|
269
|
+
console.print(summary_table)
|
|
270
|
+
|
|
271
|
+
# Display root features
|
|
272
|
+
if root_features:
|
|
273
|
+
console.print("\n[bold]Root Features:[/bold]")
|
|
274
|
+
for feature_key in sorted(root_features, key=lambda k: k.to_string()):
|
|
275
|
+
console.print(f" • {feature_key.to_string()}")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@app.command()
|
|
279
|
+
def render(
|
|
280
|
+
config: Annotated[
|
|
281
|
+
RenderConfig | None, cyclopts.Parameter(name="*", help="Render configuration")
|
|
282
|
+
] = None,
|
|
283
|
+
format: Annotated[
|
|
284
|
+
str,
|
|
285
|
+
cyclopts.Parameter(
|
|
286
|
+
name=["--format", "-f"],
|
|
287
|
+
help="Output format: terminal, mermaid, or graphviz",
|
|
288
|
+
),
|
|
289
|
+
] = "terminal",
|
|
290
|
+
type: Annotated[
|
|
291
|
+
Literal["graph", "cards"],
|
|
292
|
+
cyclopts.Parameter(
|
|
293
|
+
name=["--type", "-t"],
|
|
294
|
+
help="Terminal rendering type: graph or cards (only for --format terminal)",
|
|
295
|
+
),
|
|
296
|
+
] = "graph",
|
|
297
|
+
output: Annotated[
|
|
298
|
+
str | None,
|
|
299
|
+
cyclopts.Parameter(
|
|
300
|
+
name=["--output", "-o"],
|
|
301
|
+
help="Output file path (default: stdout)",
|
|
302
|
+
),
|
|
303
|
+
] = None,
|
|
304
|
+
snapshot: Annotated[
|
|
305
|
+
str | None,
|
|
306
|
+
cyclopts.Parameter(
|
|
307
|
+
name=["--snapshot"],
|
|
308
|
+
help="Snapshot version to render (default: current graph from code)",
|
|
309
|
+
),
|
|
310
|
+
] = None,
|
|
311
|
+
store: Annotated[
|
|
312
|
+
str | None,
|
|
313
|
+
cyclopts.Parameter(
|
|
314
|
+
name=["--store"],
|
|
315
|
+
help="Metadata store to use (for loading historical snapshots)",
|
|
316
|
+
),
|
|
317
|
+
] = None,
|
|
318
|
+
# Preset modes
|
|
319
|
+
minimal: Annotated[
|
|
320
|
+
bool,
|
|
321
|
+
cyclopts.Parameter(
|
|
322
|
+
name=["--minimal"],
|
|
323
|
+
help="Minimal output: only feature keys and dependencies",
|
|
324
|
+
),
|
|
325
|
+
] = False,
|
|
326
|
+
verbose: Annotated[
|
|
327
|
+
bool,
|
|
328
|
+
cyclopts.Parameter(
|
|
329
|
+
name=["--verbose"],
|
|
330
|
+
help="Verbose output: show all available information",
|
|
331
|
+
),
|
|
332
|
+
] = False,
|
|
333
|
+
):
|
|
334
|
+
"""Render feature graph visualization.
|
|
335
|
+
|
|
336
|
+
Visualize the feature graph in different formats:
|
|
337
|
+
- terminal: Terminal rendering with two types:
|
|
338
|
+
- graph (default): Hierarchical tree view
|
|
339
|
+
- cards: Panel/card-based view with dependency edges
|
|
340
|
+
- mermaid: Mermaid flowchart markup
|
|
341
|
+
- graphviz: Graphviz DOT format
|
|
342
|
+
|
|
343
|
+
Examples:
|
|
344
|
+
# Render to terminal (default graph view)
|
|
345
|
+
$ metaxy graph render
|
|
346
|
+
|
|
347
|
+
# Render as cards with dependency edges
|
|
348
|
+
$ metaxy graph render --type cards
|
|
349
|
+
|
|
350
|
+
# Minimal view
|
|
351
|
+
$ metaxy graph render --minimal
|
|
352
|
+
|
|
353
|
+
# Everything
|
|
354
|
+
$ metaxy graph render --verbose
|
|
355
|
+
|
|
356
|
+
# Save Mermaid diagram to file
|
|
357
|
+
$ metaxy graph render --format mermaid --output graph.mmd
|
|
358
|
+
|
|
359
|
+
# Graphviz DOT format (pipe to dot command)
|
|
360
|
+
$ metaxy graph render --format graphviz | dot -Tpng -o graph.png
|
|
361
|
+
|
|
362
|
+
# Custom: show only structure with short hashes
|
|
363
|
+
$ metaxy graph render --no-show-fields --hash-length 6
|
|
364
|
+
|
|
365
|
+
# Focus on a specific feature and its dependencies
|
|
366
|
+
$ metaxy graph render --feature video/processing --up 2
|
|
367
|
+
|
|
368
|
+
# Show a feature and its downstream dependents
|
|
369
|
+
$ metaxy graph render --feature video/files --down 1
|
|
370
|
+
|
|
371
|
+
# Render historical snapshot
|
|
372
|
+
$ metaxy graph render --snapshot abc123... --store prod
|
|
373
|
+
"""
|
|
374
|
+
from metaxy.cli.context import get_store
|
|
375
|
+
from metaxy.entrypoints import load_features
|
|
376
|
+
from metaxy.graph import (
|
|
377
|
+
CardsRenderer,
|
|
378
|
+
GraphvizRenderer,
|
|
379
|
+
MermaidRenderer,
|
|
380
|
+
TerminalRenderer,
|
|
381
|
+
)
|
|
382
|
+
from metaxy.models.feature import FeatureGraph
|
|
383
|
+
|
|
384
|
+
# Validate format
|
|
385
|
+
valid_formats = ["terminal", "mermaid", "graphviz"]
|
|
386
|
+
if format not in valid_formats:
|
|
387
|
+
console.print(
|
|
388
|
+
f"[red]Error:[/red] Invalid format '{format}'. Must be one of: {', '.join(valid_formats)}"
|
|
389
|
+
)
|
|
390
|
+
raise SystemExit(1)
|
|
391
|
+
|
|
392
|
+
# Validate type (only applies to terminal format)
|
|
393
|
+
valid_types = ["graph", "cards"]
|
|
394
|
+
if type not in valid_types:
|
|
395
|
+
console.print(
|
|
396
|
+
f"[red]Error:[/red] Invalid type '{type}'. Must be one of: {', '.join(valid_types)}"
|
|
397
|
+
)
|
|
398
|
+
raise SystemExit(1)
|
|
399
|
+
|
|
400
|
+
# Validate type is only used with terminal format
|
|
401
|
+
if type != "graph" and format != "terminal":
|
|
402
|
+
console.print(
|
|
403
|
+
"[red]Error:[/red] --type can only be used with --format terminal"
|
|
404
|
+
)
|
|
405
|
+
raise SystemExit(1)
|
|
406
|
+
|
|
407
|
+
# Resolve configuration from presets
|
|
408
|
+
if minimal and verbose:
|
|
409
|
+
console.print("[red]Error:[/red] Cannot specify both --minimal and --verbose")
|
|
410
|
+
raise SystemExit(1)
|
|
411
|
+
|
|
412
|
+
# If config is None, create a default instance
|
|
413
|
+
if config is None:
|
|
414
|
+
config = RenderConfig()
|
|
415
|
+
|
|
416
|
+
# Apply presets if specified (overrides display settings but preserves filtering)
|
|
417
|
+
if minimal:
|
|
418
|
+
preset = RenderConfig.minimal()
|
|
419
|
+
# Preserve filtering parameters from original config
|
|
420
|
+
preset.feature = config.feature
|
|
421
|
+
preset.up = config.up
|
|
422
|
+
preset.down = config.down
|
|
423
|
+
config = preset
|
|
424
|
+
elif verbose:
|
|
425
|
+
preset = RenderConfig.verbose()
|
|
426
|
+
# Preserve filtering parameters from original config
|
|
427
|
+
preset.feature = config.feature
|
|
428
|
+
preset.up = config.up
|
|
429
|
+
preset.down = config.down
|
|
430
|
+
config = preset
|
|
431
|
+
|
|
432
|
+
# Validate direction
|
|
433
|
+
if config.direction not in ["TB", "LR"]:
|
|
434
|
+
console.print(
|
|
435
|
+
f"[red]Error:[/red] Invalid direction '{config.direction}'. Must be TB or LR."
|
|
436
|
+
)
|
|
437
|
+
raise SystemExit(1)
|
|
438
|
+
|
|
439
|
+
# Validate filtering options
|
|
440
|
+
if (config.up is not None or config.down is not None) and config.feature is None:
|
|
441
|
+
console.print(
|
|
442
|
+
"[red]Error:[/red] --up and --down require --feature to be specified"
|
|
443
|
+
)
|
|
444
|
+
raise SystemExit(1)
|
|
445
|
+
|
|
446
|
+
# Auto-disable field versions if fields are disabled
|
|
447
|
+
if not config.show_fields and config.show_field_versions:
|
|
448
|
+
config.show_field_versions = False
|
|
449
|
+
|
|
450
|
+
# Load features from entrypoints
|
|
451
|
+
load_features()
|
|
452
|
+
|
|
453
|
+
# Validate feature exists if specified
|
|
454
|
+
if config.feature is not None:
|
|
455
|
+
focus_key = config.get_feature_key()
|
|
456
|
+
graph = FeatureGraph.get_active()
|
|
457
|
+
if focus_key not in graph.features_by_key:
|
|
458
|
+
console.print(
|
|
459
|
+
f"[red]Error:[/red] Feature '{config.feature}' not found in graph"
|
|
460
|
+
)
|
|
461
|
+
console.print("\nAvailable features:")
|
|
462
|
+
for key in sorted(
|
|
463
|
+
graph.features_by_key.keys(), key=lambda k: k.to_string()
|
|
464
|
+
):
|
|
465
|
+
console.print(f" • {key.to_string()}")
|
|
466
|
+
raise SystemExit(1)
|
|
467
|
+
|
|
468
|
+
# Determine which graph to render
|
|
469
|
+
if snapshot is None:
|
|
470
|
+
# Use current graph from code
|
|
471
|
+
graph = FeatureGraph.get_active()
|
|
472
|
+
|
|
473
|
+
if len(graph.features_by_key) == 0:
|
|
474
|
+
console.print(
|
|
475
|
+
"[yellow]Warning:[/yellow] Graph is empty (no features found)"
|
|
476
|
+
)
|
|
477
|
+
if output:
|
|
478
|
+
# Write empty output to file
|
|
479
|
+
with open(output, "w") as f:
|
|
480
|
+
f.write("")
|
|
481
|
+
return
|
|
482
|
+
else:
|
|
483
|
+
# Load historical snapshot from store
|
|
484
|
+
metadata_store = get_store(store)
|
|
485
|
+
|
|
486
|
+
with metadata_store:
|
|
487
|
+
# Read features for this snapshot
|
|
488
|
+
features_df = metadata_store.read_features(
|
|
489
|
+
current=False, snapshot_version=snapshot
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
if features_df.height == 0:
|
|
493
|
+
console.print(f"[red]✗[/red] No features found for snapshot {snapshot}")
|
|
494
|
+
raise SystemExit(1)
|
|
495
|
+
|
|
496
|
+
# Convert DataFrame to snapshot_data format expected by from_snapshot
|
|
497
|
+
import json
|
|
498
|
+
|
|
499
|
+
snapshot_data = {}
|
|
500
|
+
for row in features_df.iter_rows(named=True):
|
|
501
|
+
feature_key_str = row["feature_key"]
|
|
502
|
+
snapshot_data[feature_key_str] = {
|
|
503
|
+
"feature_spec": json.loads(row["feature_spec"]),
|
|
504
|
+
"feature_class_path": row["feature_class_path"],
|
|
505
|
+
"feature_version": row["feature_version"],
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
# Reconstruct graph from snapshot
|
|
509
|
+
try:
|
|
510
|
+
graph = FeatureGraph.from_snapshot(snapshot_data)
|
|
511
|
+
console.print(
|
|
512
|
+
f"[green]✓[/green] Loaded {len(graph.features_by_key)} features from snapshot {snapshot}"
|
|
513
|
+
)
|
|
514
|
+
except ImportError as e:
|
|
515
|
+
console.print(f"[red]✗[/red] Failed to load snapshot: {e}")
|
|
516
|
+
console.print(
|
|
517
|
+
"[yellow]Hint:[/yellow] Feature classes may have been moved or deleted."
|
|
518
|
+
)
|
|
519
|
+
console.print(
|
|
520
|
+
"[yellow]Hint:[/yellow] Use --store to ensure feature code is available at recorded paths."
|
|
521
|
+
)
|
|
522
|
+
raise SystemExit(1)
|
|
523
|
+
except Exception as e:
|
|
524
|
+
console.print(f"[red]✗[/red] Failed to load snapshot: {e}")
|
|
525
|
+
raise SystemExit(1)
|
|
526
|
+
|
|
527
|
+
# Instantiate renderer based on format and type
|
|
528
|
+
if format == "terminal":
|
|
529
|
+
if type == "graph":
|
|
530
|
+
renderer = TerminalRenderer(graph, config)
|
|
531
|
+
elif type == "cards":
|
|
532
|
+
renderer = CardsRenderer(graph, config)
|
|
533
|
+
else:
|
|
534
|
+
# Should not reach here due to validation above
|
|
535
|
+
console.print(f"[red]Error:[/red] Unknown type: {type}")
|
|
536
|
+
raise SystemExit(1)
|
|
537
|
+
elif format == "mermaid":
|
|
538
|
+
renderer = MermaidRenderer(graph, config)
|
|
539
|
+
elif format == "graphviz":
|
|
540
|
+
try:
|
|
541
|
+
renderer = GraphvizRenderer(graph, config)
|
|
542
|
+
except ImportError as e:
|
|
543
|
+
console.print(f"[red]✗[/red] {e}")
|
|
544
|
+
raise SystemExit(1)
|
|
545
|
+
else:
|
|
546
|
+
# Should not reach here due to validation above
|
|
547
|
+
console.print(f"[red]Error:[/red] Unknown format: {format}")
|
|
548
|
+
raise SystemExit(1)
|
|
549
|
+
|
|
550
|
+
# Render graph
|
|
551
|
+
try:
|
|
552
|
+
rendered = renderer.render()
|
|
553
|
+
except Exception as e:
|
|
554
|
+
console.print(f"[red]✗[/red] Rendering failed: {e}")
|
|
555
|
+
import traceback
|
|
556
|
+
|
|
557
|
+
traceback.print_exc()
|
|
558
|
+
raise SystemExit(1)
|
|
559
|
+
|
|
560
|
+
# Output to stdout or file
|
|
561
|
+
if output:
|
|
562
|
+
try:
|
|
563
|
+
with open(output, "w") as f:
|
|
564
|
+
f.write(rendered)
|
|
565
|
+
console.print(f"[green]✓[/green] Rendered graph saved to: {output}")
|
|
566
|
+
except Exception as e:
|
|
567
|
+
console.print(f"[red]✗[/red] Failed to write to file: {e}")
|
|
568
|
+
raise SystemExit(1)
|
|
569
|
+
else:
|
|
570
|
+
# Print to stdout
|
|
571
|
+
# For terminal/dag formats, the output already contains ANSI codes from Rich
|
|
572
|
+
# so we print directly to avoid double-escaping
|
|
573
|
+
if format in ("terminal", "dag"):
|
|
574
|
+
print(rendered)
|
|
575
|
+
else:
|
|
576
|
+
console.print(rendered)
|