metaxy 0.0.1.dev3__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.
- metaxy/__init__.py +170 -0
- metaxy/_packaging.py +96 -0
- metaxy/_testing/__init__.py +55 -0
- metaxy/_testing/config.py +43 -0
- metaxy/_testing/metaxy_project.py +780 -0
- metaxy/_testing/models.py +111 -0
- metaxy/_testing/parametric/__init__.py +13 -0
- metaxy/_testing/parametric/metadata.py +664 -0
- metaxy/_testing/pytest_helpers.py +74 -0
- metaxy/_testing/runbook.py +533 -0
- metaxy/_utils.py +35 -0
- metaxy/_version.py +1 -0
- metaxy/cli/app.py +97 -0
- metaxy/cli/console.py +13 -0
- metaxy/cli/context.py +167 -0
- metaxy/cli/graph.py +610 -0
- metaxy/cli/graph_diff.py +290 -0
- metaxy/cli/list.py +46 -0
- metaxy/cli/metadata.py +317 -0
- metaxy/cli/migrations.py +999 -0
- metaxy/cli/utils.py +268 -0
- metaxy/config.py +680 -0
- metaxy/entrypoints.py +296 -0
- metaxy/ext/__init__.py +1 -0
- metaxy/ext/dagster/__init__.py +54 -0
- metaxy/ext/dagster/constants.py +10 -0
- metaxy/ext/dagster/dagster_type.py +156 -0
- metaxy/ext/dagster/io_manager.py +200 -0
- metaxy/ext/dagster/metaxify.py +512 -0
- metaxy/ext/dagster/observable.py +115 -0
- metaxy/ext/dagster/resources.py +27 -0
- metaxy/ext/dagster/selection.py +73 -0
- metaxy/ext/dagster/table_metadata.py +417 -0
- metaxy/ext/dagster/utils.py +462 -0
- metaxy/ext/sqlalchemy/__init__.py +23 -0
- metaxy/ext/sqlalchemy/config.py +29 -0
- metaxy/ext/sqlalchemy/plugin.py +353 -0
- metaxy/ext/sqlmodel/__init__.py +13 -0
- metaxy/ext/sqlmodel/config.py +29 -0
- metaxy/ext/sqlmodel/plugin.py +499 -0
- metaxy/graph/__init__.py +29 -0
- metaxy/graph/describe.py +325 -0
- metaxy/graph/diff/__init__.py +21 -0
- metaxy/graph/diff/diff_models.py +446 -0
- metaxy/graph/diff/differ.py +769 -0
- metaxy/graph/diff/models.py +443 -0
- metaxy/graph/diff/rendering/__init__.py +18 -0
- metaxy/graph/diff/rendering/base.py +323 -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 +326 -0
- metaxy/graph/diff/rendering/rich.py +169 -0
- metaxy/graph/diff/rendering/theme.py +48 -0
- metaxy/graph/diff/traversal.py +247 -0
- metaxy/graph/status.py +329 -0
- metaxy/graph/utils.py +58 -0
- metaxy/metadata_store/__init__.py +32 -0
- metaxy/metadata_store/_ducklake_support.py +419 -0
- metaxy/metadata_store/base.py +1792 -0
- metaxy/metadata_store/bigquery.py +354 -0
- metaxy/metadata_store/clickhouse.py +184 -0
- metaxy/metadata_store/delta.py +371 -0
- metaxy/metadata_store/duckdb.py +446 -0
- metaxy/metadata_store/exceptions.py +61 -0
- metaxy/metadata_store/ibis.py +542 -0
- metaxy/metadata_store/lancedb.py +391 -0
- metaxy/metadata_store/memory.py +292 -0
- metaxy/metadata_store/system/__init__.py +57 -0
- metaxy/metadata_store/system/events.py +264 -0
- metaxy/metadata_store/system/keys.py +9 -0
- metaxy/metadata_store/system/models.py +129 -0
- metaxy/metadata_store/system/storage.py +957 -0
- metaxy/metadata_store/types.py +10 -0
- metaxy/metadata_store/utils.py +104 -0
- metaxy/metadata_store/warnings.py +36 -0
- metaxy/migrations/__init__.py +32 -0
- metaxy/migrations/detector.py +291 -0
- metaxy/migrations/executor.py +516 -0
- metaxy/migrations/generator.py +319 -0
- metaxy/migrations/loader.py +231 -0
- metaxy/migrations/models.py +528 -0
- metaxy/migrations/ops.py +447 -0
- metaxy/models/__init__.py +0 -0
- metaxy/models/bases.py +12 -0
- metaxy/models/constants.py +139 -0
- metaxy/models/feature.py +1335 -0
- metaxy/models/feature_spec.py +338 -0
- metaxy/models/field.py +263 -0
- metaxy/models/fields_mapping.py +307 -0
- metaxy/models/filter_expression.py +297 -0
- metaxy/models/lineage.py +285 -0
- metaxy/models/plan.py +232 -0
- metaxy/models/types.py +475 -0
- metaxy/py.typed +0 -0
- metaxy/utils/__init__.py +1 -0
- metaxy/utils/constants.py +2 -0
- metaxy/utils/exceptions.py +23 -0
- metaxy/utils/hashing.py +230 -0
- metaxy/versioning/__init__.py +31 -0
- metaxy/versioning/engine.py +656 -0
- metaxy/versioning/feature_dep_transformer.py +151 -0
- metaxy/versioning/ibis.py +249 -0
- metaxy/versioning/lineage_handler.py +205 -0
- metaxy/versioning/polars.py +189 -0
- metaxy/versioning/renamed_df.py +35 -0
- metaxy/versioning/types.py +63 -0
- metaxy-0.0.1.dev3.dist-info/METADATA +96 -0
- metaxy-0.0.1.dev3.dist-info/RECORD +111 -0
- metaxy-0.0.1.dev3.dist-info/WHEEL +4 -0
- metaxy-0.0.1.dev3.dist-info/entry_points.txt +4 -0
metaxy/cli/graph.py
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
"""Graph management commands for Metaxy CLI."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Literal
|
|
4
|
+
|
|
5
|
+
import cyclopts
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from metaxy.cli.console import console, data_console, error_console
|
|
9
|
+
from metaxy.graph import RenderConfig
|
|
10
|
+
|
|
11
|
+
# Graph subcommand app
|
|
12
|
+
app = cyclopts.App(
|
|
13
|
+
name="graph", # pyrefly: ignore[unexpected-keyword]
|
|
14
|
+
help="Manage feature graphs", # pyrefly: ignore[unexpected-keyword]
|
|
15
|
+
console=console, # pyrefly: ignore[unexpected-keyword]
|
|
16
|
+
error_console=error_console, # pyrefly: ignore[unexpected-keyword]
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command()
|
|
21
|
+
def push(
|
|
22
|
+
store: Annotated[
|
|
23
|
+
str | None,
|
|
24
|
+
cyclopts.Parameter(
|
|
25
|
+
name=["--store"],
|
|
26
|
+
help="Metadata store to use (defaults to configured default store)",
|
|
27
|
+
),
|
|
28
|
+
] = None,
|
|
29
|
+
*,
|
|
30
|
+
tags: Annotated[
|
|
31
|
+
dict[str, str] | None,
|
|
32
|
+
cyclopts.Parameter(
|
|
33
|
+
name=["--tags", "-t"],
|
|
34
|
+
help="Arbitrary key-value pairs to attach to the pushed snapshot. Example: `--tags.git_commit abc123def`.",
|
|
35
|
+
),
|
|
36
|
+
] = None,
|
|
37
|
+
):
|
|
38
|
+
"""Serialize all Metaxy features to the metadata store.
|
|
39
|
+
|
|
40
|
+
This is intended to be invoked in a CD pipeline **before** running Metaxy code in production.
|
|
41
|
+
"""
|
|
42
|
+
from metaxy.cli.context import AppContext
|
|
43
|
+
from metaxy.metadata_store.system.models import METAXY_TAG
|
|
44
|
+
from metaxy.metadata_store.system.storage import SystemTableStorage
|
|
45
|
+
|
|
46
|
+
context = AppContext.get()
|
|
47
|
+
context.raise_command_cannot_override_project()
|
|
48
|
+
|
|
49
|
+
metadata_store = context.get_store(store)
|
|
50
|
+
|
|
51
|
+
tags = tags or {}
|
|
52
|
+
|
|
53
|
+
assert METAXY_TAG not in tags, "`metaxy` tag is reserved for internal use"
|
|
54
|
+
|
|
55
|
+
with metadata_store.open("write"):
|
|
56
|
+
result = SystemTableStorage(metadata_store).push_graph_snapshot(tags=tags)
|
|
57
|
+
|
|
58
|
+
# Log store metadata for the system table
|
|
59
|
+
from metaxy.metadata_store.system import FEATURE_VERSIONS_KEY
|
|
60
|
+
|
|
61
|
+
store_metadata = metadata_store.get_store_metadata(FEATURE_VERSIONS_KEY)
|
|
62
|
+
if store_metadata:
|
|
63
|
+
console.print(f"[dim]Recorded at: {store_metadata}[/dim]")
|
|
64
|
+
|
|
65
|
+
# Scenario 1: New snapshot (computational changes)
|
|
66
|
+
if not result.already_pushed:
|
|
67
|
+
console.print("[green]✓[/green] Recorded feature graph")
|
|
68
|
+
|
|
69
|
+
# Scenario 2: Feature info updates to existing snapshot
|
|
70
|
+
elif result.updated_features:
|
|
71
|
+
console.print(
|
|
72
|
+
"[blue]ℹ[/blue] [cyan]Updated feature information[/cyan] (no topological changes)"
|
|
73
|
+
)
|
|
74
|
+
console.print(" [dim]Updated features:[/dim]")
|
|
75
|
+
for feature_key in result.updated_features:
|
|
76
|
+
console.print(f" [yellow]- {feature_key}[/yellow]")
|
|
77
|
+
|
|
78
|
+
# Scenario 3: No changes
|
|
79
|
+
else:
|
|
80
|
+
console.print(
|
|
81
|
+
"[green]✓[/green] [green]Snapshot already recorded[/green] [dim](no changes)[/dim]"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Always output the snapshot version to stdout (for scripting)
|
|
85
|
+
# Note: snapshot_version is "empty" when graph has no features
|
|
86
|
+
data_console.print(result.snapshot_version)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@app.command()
|
|
90
|
+
def history(
|
|
91
|
+
store: Annotated[
|
|
92
|
+
str | None,
|
|
93
|
+
cyclopts.Parameter(
|
|
94
|
+
name=["--store"],
|
|
95
|
+
help="Metadata store to use (defaults to configured default store)",
|
|
96
|
+
),
|
|
97
|
+
] = None,
|
|
98
|
+
limit: Annotated[
|
|
99
|
+
int | None,
|
|
100
|
+
cyclopts.Parameter(
|
|
101
|
+
name=["--limit"],
|
|
102
|
+
help="Limit number of snapshots to show (defaults to all)",
|
|
103
|
+
),
|
|
104
|
+
] = None,
|
|
105
|
+
):
|
|
106
|
+
"""Show history of recorded graph snapshots.
|
|
107
|
+
|
|
108
|
+
Displays all recorded graph snapshots from the metadata store,
|
|
109
|
+
showing snapshot versions, when they were recorded, and feature counts.
|
|
110
|
+
|
|
111
|
+
Example:
|
|
112
|
+
$ metaxy graph history
|
|
113
|
+
|
|
114
|
+
Graph Snapshot History
|
|
115
|
+
┌──────────────┬─────────────────────┬───────────────┐
|
|
116
|
+
│ Snapshot version │ Recorded At │ Feature Count │
|
|
117
|
+
├──────────────┼─────────────────────┼───────────────┤
|
|
118
|
+
│ abc123... │ 2025-01-15 10:30:00 │ 42 │
|
|
119
|
+
│ def456... │ 2025-01-14 09:15:00 │ 40 │
|
|
120
|
+
└──────────────┴─────────────────────┴───────────────┘
|
|
121
|
+
"""
|
|
122
|
+
from metaxy.cli.context import AppContext
|
|
123
|
+
|
|
124
|
+
context = AppContext.get()
|
|
125
|
+
metadata_store = context.get_store(store)
|
|
126
|
+
|
|
127
|
+
from metaxy.metadata_store.system.storage import SystemTableStorage
|
|
128
|
+
|
|
129
|
+
with metadata_store:
|
|
130
|
+
# Read snapshot history
|
|
131
|
+
storage = SystemTableStorage(metadata_store)
|
|
132
|
+
snapshots_df = storage.read_graph_snapshots(project=context.project)
|
|
133
|
+
|
|
134
|
+
if snapshots_df.height == 0:
|
|
135
|
+
console.print("[yellow]No graph snapshots recorded yet[/yellow]")
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
# Limit results if requested
|
|
139
|
+
if limit is not None:
|
|
140
|
+
snapshots_df = snapshots_df.head(limit)
|
|
141
|
+
|
|
142
|
+
# Create table
|
|
143
|
+
table = Table(title="Graph Snapshot History")
|
|
144
|
+
table.add_column(
|
|
145
|
+
"Snapshot version", style="cyan", no_wrap=False, overflow="fold"
|
|
146
|
+
)
|
|
147
|
+
table.add_column("Recorded At", style="green", no_wrap=False)
|
|
148
|
+
table.add_column(
|
|
149
|
+
"Feature Count", style="yellow", justify="right", no_wrap=False
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Add rows
|
|
153
|
+
for row in snapshots_df.iter_rows(named=True):
|
|
154
|
+
snapshot_version = row["metaxy_snapshot_version"]
|
|
155
|
+
recorded_at = row["recorded_at"].strftime("%Y-%m-%d %H:%M:%S")
|
|
156
|
+
feature_count = str(row["feature_count"])
|
|
157
|
+
|
|
158
|
+
table.add_row(snapshot_version, recorded_at, feature_count)
|
|
159
|
+
|
|
160
|
+
console.print(table)
|
|
161
|
+
console.print(f"\nTotal snapshots: {snapshots_df.height}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@app.command()
|
|
165
|
+
def describe(
|
|
166
|
+
snapshot: Annotated[
|
|
167
|
+
str | None,
|
|
168
|
+
cyclopts.Parameter(
|
|
169
|
+
name=["--snapshot"],
|
|
170
|
+
help="Snapshot version to describe (defaults to current graph from code)",
|
|
171
|
+
),
|
|
172
|
+
] = None,
|
|
173
|
+
store: Annotated[
|
|
174
|
+
str | None,
|
|
175
|
+
cyclopts.Parameter(
|
|
176
|
+
name=["--store"],
|
|
177
|
+
help="Metadata store to use (defaults to configured default store)",
|
|
178
|
+
),
|
|
179
|
+
] = None,
|
|
180
|
+
):
|
|
181
|
+
"""Describe a graph snapshot.
|
|
182
|
+
|
|
183
|
+
Shows detailed information about a graph snapshot including:
|
|
184
|
+
- Feature count (optionally filtered by project)
|
|
185
|
+
- Graph depth (longest dependency chain)
|
|
186
|
+
- Root features (features with no dependencies)
|
|
187
|
+
- Leaf features (features with no dependents)
|
|
188
|
+
- Project breakdown (if multi-project)
|
|
189
|
+
|
|
190
|
+
Example:
|
|
191
|
+
$ metaxy graph describe
|
|
192
|
+
|
|
193
|
+
Graph Snapshot: abc123def456...
|
|
194
|
+
┌─────────────────────┬────────┐
|
|
195
|
+
│ Metric │ Value │
|
|
196
|
+
├─────────────────────┼────────┤
|
|
197
|
+
│ Feature Count │ 42 │
|
|
198
|
+
│ Graph Depth │ 5 │
|
|
199
|
+
│ Root Features │ 8 │
|
|
200
|
+
│ Leaf Features │ 12 │
|
|
201
|
+
└─────────────────────┴────────┘
|
|
202
|
+
|
|
203
|
+
Root Features:
|
|
204
|
+
• user__profile
|
|
205
|
+
• transaction__history
|
|
206
|
+
...
|
|
207
|
+
|
|
208
|
+
$ metaxy graph describe --project my_project
|
|
209
|
+
Shows metrics filtered to my_project features
|
|
210
|
+
"""
|
|
211
|
+
from metaxy.cli.context import AppContext
|
|
212
|
+
from metaxy.graph.describe import describe_graph
|
|
213
|
+
from metaxy.models.feature import FeatureGraph
|
|
214
|
+
|
|
215
|
+
context = AppContext.get()
|
|
216
|
+
metadata_store = context.get_store(store)
|
|
217
|
+
|
|
218
|
+
with metadata_store:
|
|
219
|
+
# Determine which snapshot to describe
|
|
220
|
+
if snapshot is None:
|
|
221
|
+
# Use current graph from code
|
|
222
|
+
graph = FeatureGraph.get_active()
|
|
223
|
+
snapshot_version = graph.snapshot_version
|
|
224
|
+
console.print("[cyan]Describing current graph from code[/cyan]")
|
|
225
|
+
else:
|
|
226
|
+
# Use specified snapshot
|
|
227
|
+
snapshot_version = snapshot
|
|
228
|
+
console.print(f"[cyan]Describing snapshot: {snapshot_version}[/cyan]")
|
|
229
|
+
|
|
230
|
+
# Load graph from snapshot
|
|
231
|
+
from metaxy.metadata_store.system.storage import SystemTableStorage
|
|
232
|
+
|
|
233
|
+
storage = SystemTableStorage(metadata_store)
|
|
234
|
+
features_df = storage.read_features(
|
|
235
|
+
current=False,
|
|
236
|
+
snapshot_version=snapshot_version,
|
|
237
|
+
project=context.project,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
if features_df.height == 0:
|
|
241
|
+
console.print(
|
|
242
|
+
f"[red]✗[/red] No features found for snapshot {snapshot_version}"
|
|
243
|
+
)
|
|
244
|
+
if context.project:
|
|
245
|
+
console.print(f" (filtered by project: {context.project})")
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
# For historical snapshots, we'll use the current graph structure
|
|
249
|
+
# but report on the features that were in that snapshot
|
|
250
|
+
graph = FeatureGraph.get_active()
|
|
251
|
+
|
|
252
|
+
# Get graph description with optional project filter
|
|
253
|
+
info = describe_graph(graph, project=context.project)
|
|
254
|
+
|
|
255
|
+
# Display summary table
|
|
256
|
+
console.print()
|
|
257
|
+
table_title = f"Graph Snapshot: {info['metaxy_snapshot_version']}"
|
|
258
|
+
if context.project:
|
|
259
|
+
table_title += f" (Project: {context.project})"
|
|
260
|
+
|
|
261
|
+
summary_table = Table(title=table_title)
|
|
262
|
+
summary_table.add_column("Metric", style="cyan", no_wrap=False)
|
|
263
|
+
summary_table.add_column(
|
|
264
|
+
"Value", style="yellow", justify="right", no_wrap=False
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Only show filtered view if filtering actually reduces the feature count
|
|
268
|
+
if (
|
|
269
|
+
"filtered_features" in info
|
|
270
|
+
and info["filtered_features"] < info["total_features"]
|
|
271
|
+
):
|
|
272
|
+
# Show both total and filtered counts when there's actual filtering
|
|
273
|
+
summary_table.add_row("Total Features", str(info["total_features"]))
|
|
274
|
+
summary_table.add_row(
|
|
275
|
+
f"Features in {info['filter_project']}", str(info["filtered_features"])
|
|
276
|
+
)
|
|
277
|
+
else:
|
|
278
|
+
# Show simple count when no filtering or all features are in the project
|
|
279
|
+
if "filtered_features" in info:
|
|
280
|
+
# Use filtered count if available (all features are in the project)
|
|
281
|
+
summary_table.add_row("Total Features", str(info["filtered_features"]))
|
|
282
|
+
else:
|
|
283
|
+
# Use total count
|
|
284
|
+
summary_table.add_row("Total Features", str(info["total_features"]))
|
|
285
|
+
|
|
286
|
+
summary_table.add_row("Graph Depth", str(info["graph_depth"]))
|
|
287
|
+
summary_table.add_row("Root Features", str(len(info["root_features"])))
|
|
288
|
+
summary_table.add_row("Leaf Features", str(len(info["leaf_features"])))
|
|
289
|
+
|
|
290
|
+
console.print(summary_table)
|
|
291
|
+
|
|
292
|
+
# Display project breakdown if multi-project
|
|
293
|
+
if len(info["projects"]) > 1:
|
|
294
|
+
console.print("\n[bold]Features by Project:[/bold]")
|
|
295
|
+
for proj, count in sorted(info["projects"].items()):
|
|
296
|
+
console.print(f" • {proj}: {count} features")
|
|
297
|
+
|
|
298
|
+
# Display root features
|
|
299
|
+
if info["root_features"]:
|
|
300
|
+
console.print("\n[bold]Root Features:[/bold]")
|
|
301
|
+
for feature_key_str in info["root_features"][:10]: # Limit to 10
|
|
302
|
+
console.print(f" • {feature_key_str}")
|
|
303
|
+
if len(info["root_features"]) > 10:
|
|
304
|
+
console.print(f" ... and {len(info['root_features']) - 10} more")
|
|
305
|
+
|
|
306
|
+
# Display leaf features
|
|
307
|
+
if info["leaf_features"]:
|
|
308
|
+
console.print("\n[bold]Leaf Features:[/bold]")
|
|
309
|
+
for feature_key_str in info["leaf_features"][:10]: # Limit to 10
|
|
310
|
+
console.print(f" • {feature_key_str}")
|
|
311
|
+
if len(info["leaf_features"]) > 10:
|
|
312
|
+
console.print(f" ... and {len(info['leaf_features']) - 10} more")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@app.command()
|
|
316
|
+
def render(
|
|
317
|
+
render_config: Annotated[
|
|
318
|
+
RenderConfig | None, cyclopts.Parameter(name="*", help="Render configuration")
|
|
319
|
+
] = None,
|
|
320
|
+
format: Annotated[
|
|
321
|
+
str,
|
|
322
|
+
cyclopts.Parameter(
|
|
323
|
+
name=["--format", "-f"],
|
|
324
|
+
help="Output format: terminal, mermaid, or graphviz",
|
|
325
|
+
),
|
|
326
|
+
] = "terminal",
|
|
327
|
+
type: Annotated[
|
|
328
|
+
Literal["graph", "cards"],
|
|
329
|
+
cyclopts.Parameter(
|
|
330
|
+
name=["--type", "-t"],
|
|
331
|
+
help="Terminal rendering type: graph or cards (only for --format terminal)",
|
|
332
|
+
),
|
|
333
|
+
] = "graph",
|
|
334
|
+
output: Annotated[
|
|
335
|
+
str | None,
|
|
336
|
+
cyclopts.Parameter(
|
|
337
|
+
name=["--output", "-o"],
|
|
338
|
+
help="Output file path (default: stdout)",
|
|
339
|
+
),
|
|
340
|
+
] = None,
|
|
341
|
+
snapshot: Annotated[
|
|
342
|
+
str | None,
|
|
343
|
+
cyclopts.Parameter(
|
|
344
|
+
name=["--snapshot"],
|
|
345
|
+
help="Snapshot version to render (default: current graph from code)",
|
|
346
|
+
),
|
|
347
|
+
] = None,
|
|
348
|
+
store: Annotated[
|
|
349
|
+
str | None,
|
|
350
|
+
cyclopts.Parameter(
|
|
351
|
+
name=["--store"],
|
|
352
|
+
help="Metadata store to use (for loading historical snapshots)",
|
|
353
|
+
),
|
|
354
|
+
] = None,
|
|
355
|
+
# Preset modes
|
|
356
|
+
minimal: Annotated[
|
|
357
|
+
bool,
|
|
358
|
+
cyclopts.Parameter(
|
|
359
|
+
name=["--minimal"],
|
|
360
|
+
help="Minimal output: only feature keys and dependencies",
|
|
361
|
+
),
|
|
362
|
+
] = False,
|
|
363
|
+
verbose: Annotated[
|
|
364
|
+
bool,
|
|
365
|
+
cyclopts.Parameter(
|
|
366
|
+
name=["--verbose"],
|
|
367
|
+
help="Verbose output: show all available information",
|
|
368
|
+
),
|
|
369
|
+
] = False,
|
|
370
|
+
):
|
|
371
|
+
"""Render feature graph visualization.
|
|
372
|
+
|
|
373
|
+
Visualize the feature graph in different formats:
|
|
374
|
+
- terminal: Terminal rendering with two types:
|
|
375
|
+
- graph (default): Hierarchical tree view
|
|
376
|
+
- cards: Panel/card-based view with dependency edges
|
|
377
|
+
- mermaid: Mermaid flowchart markup
|
|
378
|
+
- graphviz: Graphviz DOT format
|
|
379
|
+
|
|
380
|
+
Examples:
|
|
381
|
+
# Render to terminal (default graph view)
|
|
382
|
+
$ metaxy graph render
|
|
383
|
+
|
|
384
|
+
# Render as cards with dependency edges
|
|
385
|
+
$ metaxy graph render --type cards
|
|
386
|
+
|
|
387
|
+
# Minimal view
|
|
388
|
+
$ metaxy graph render --minimal
|
|
389
|
+
|
|
390
|
+
# Everything
|
|
391
|
+
$ metaxy graph render --verbose
|
|
392
|
+
|
|
393
|
+
# Save Mermaid diagram to file
|
|
394
|
+
$ metaxy graph render --format mermaid --output graph.mmd
|
|
395
|
+
|
|
396
|
+
# Graphviz DOT format (pipe to dot command)
|
|
397
|
+
$ metaxy graph render --format graphviz | dot -Tpng -o graph.png
|
|
398
|
+
|
|
399
|
+
# Custom: show only structure with short hashes
|
|
400
|
+
$ metaxy graph render --no-show-fields --hash-length 6
|
|
401
|
+
|
|
402
|
+
# Focus on a specific feature and its dependencies
|
|
403
|
+
$ metaxy graph render --feature video/processing --up 2
|
|
404
|
+
|
|
405
|
+
# Show a feature and its downstream dependents
|
|
406
|
+
$ metaxy graph render --feature video/files --down 1
|
|
407
|
+
|
|
408
|
+
# Render historical snapshot
|
|
409
|
+
$ metaxy graph render --snapshot abc123... --store prod
|
|
410
|
+
"""
|
|
411
|
+
from metaxy.graph import (
|
|
412
|
+
CardsRenderer,
|
|
413
|
+
GraphvizRenderer,
|
|
414
|
+
MermaidRenderer,
|
|
415
|
+
TerminalRenderer,
|
|
416
|
+
)
|
|
417
|
+
from metaxy.models.feature import FeatureGraph
|
|
418
|
+
|
|
419
|
+
# Validate format
|
|
420
|
+
valid_formats = ["terminal", "mermaid", "graphviz"]
|
|
421
|
+
if format not in valid_formats:
|
|
422
|
+
console.print(
|
|
423
|
+
f"[red]Error:[/red] Invalid format '{format}'. Must be one of: {', '.join(valid_formats)}"
|
|
424
|
+
)
|
|
425
|
+
raise SystemExit(1)
|
|
426
|
+
|
|
427
|
+
# Validate type (only applies to terminal format)
|
|
428
|
+
valid_types = ["graph", "cards"]
|
|
429
|
+
if type not in valid_types:
|
|
430
|
+
console.print(
|
|
431
|
+
f"[red]Error:[/red] Invalid type '{type}'. Must be one of: {', '.join(valid_types)}"
|
|
432
|
+
)
|
|
433
|
+
raise SystemExit(1)
|
|
434
|
+
|
|
435
|
+
# Validate type is only used with terminal format
|
|
436
|
+
if type != "graph" and format != "terminal":
|
|
437
|
+
console.print(
|
|
438
|
+
"[red]Error:[/red] --type can only be used with --format terminal"
|
|
439
|
+
)
|
|
440
|
+
raise SystemExit(1)
|
|
441
|
+
|
|
442
|
+
# Resolve configuration from presets
|
|
443
|
+
if minimal and verbose:
|
|
444
|
+
console.print("[red]Error:[/red] Cannot specify both --minimal and --verbose")
|
|
445
|
+
raise SystemExit(1)
|
|
446
|
+
|
|
447
|
+
# If config is None, create a default instance
|
|
448
|
+
if render_config is None:
|
|
449
|
+
render_config = RenderConfig()
|
|
450
|
+
|
|
451
|
+
# Apply presets if specified (overrides display settings but preserves filtering)
|
|
452
|
+
if minimal:
|
|
453
|
+
preset = RenderConfig.minimal(show_projects=render_config.show_projects)
|
|
454
|
+
# Preserve filtering parameters from original config
|
|
455
|
+
preset.feature = render_config.feature
|
|
456
|
+
preset.up = render_config.up
|
|
457
|
+
preset.down = render_config.down
|
|
458
|
+
render_config = preset
|
|
459
|
+
elif verbose:
|
|
460
|
+
preset = RenderConfig.verbose(show_projects=render_config.show_projects)
|
|
461
|
+
# Preserve filtering parameters from original config
|
|
462
|
+
preset.feature = render_config.feature
|
|
463
|
+
preset.up = render_config.up
|
|
464
|
+
preset.down = render_config.down
|
|
465
|
+
render_config = preset
|
|
466
|
+
|
|
467
|
+
# Validate direction
|
|
468
|
+
if render_config.direction not in ["TB", "LR"]:
|
|
469
|
+
console.print(
|
|
470
|
+
f"[red]Error:[/red] Invalid direction '{render_config.direction}'. Must be TB or LR."
|
|
471
|
+
)
|
|
472
|
+
raise SystemExit(1)
|
|
473
|
+
|
|
474
|
+
# Validate filtering options
|
|
475
|
+
if (
|
|
476
|
+
render_config.up is not None or render_config.down is not None
|
|
477
|
+
) and render_config.feature is None:
|
|
478
|
+
console.print(
|
|
479
|
+
"[red]Error:[/red] --up and --down require --feature to be specified"
|
|
480
|
+
)
|
|
481
|
+
raise SystemExit(1)
|
|
482
|
+
|
|
483
|
+
# Auto-disable field versions if fields are disabled
|
|
484
|
+
if not render_config.show_fields and render_config.show_field_versions:
|
|
485
|
+
render_config.show_field_versions = False
|
|
486
|
+
|
|
487
|
+
from metaxy.cli.context import AppContext
|
|
488
|
+
|
|
489
|
+
context = AppContext.get()
|
|
490
|
+
|
|
491
|
+
# Apply project filter from context if not specified in config
|
|
492
|
+
if render_config.project is None and context.project is not None:
|
|
493
|
+
render_config.project = context.project
|
|
494
|
+
|
|
495
|
+
# Determine which graph to render
|
|
496
|
+
# Initialize to satisfy type checker - will be assigned in all code paths
|
|
497
|
+
graph = FeatureGraph.get_active() # Default initialization
|
|
498
|
+
|
|
499
|
+
if snapshot is None:
|
|
500
|
+
# Use current graph from code
|
|
501
|
+
graph = FeatureGraph.get_active()
|
|
502
|
+
|
|
503
|
+
# Validate feature exists if specified
|
|
504
|
+
if render_config.feature is not None:
|
|
505
|
+
focus_key = render_config.get_feature_key()
|
|
506
|
+
if focus_key not in graph.features_by_key:
|
|
507
|
+
console.print(
|
|
508
|
+
f"[red]Error:[/red] Feature '{render_config.feature}' not found in graph"
|
|
509
|
+
)
|
|
510
|
+
console.print("\nAvailable features:")
|
|
511
|
+
for key in sorted(
|
|
512
|
+
graph.features_by_key.keys(), key=lambda k: k.to_string()
|
|
513
|
+
):
|
|
514
|
+
console.print(f" • {key.to_string()}")
|
|
515
|
+
raise SystemExit(1)
|
|
516
|
+
|
|
517
|
+
if len(graph.features_by_key) == 0:
|
|
518
|
+
console.print(
|
|
519
|
+
"[yellow]Warning:[/yellow] Graph is empty (no features found)"
|
|
520
|
+
)
|
|
521
|
+
if output:
|
|
522
|
+
# Write empty output to file
|
|
523
|
+
with open(output, "w") as f:
|
|
524
|
+
f.write("")
|
|
525
|
+
return
|
|
526
|
+
else:
|
|
527
|
+
# Load historical snapshot from store
|
|
528
|
+
metadata_store = context.get_store(store)
|
|
529
|
+
|
|
530
|
+
from metaxy.metadata_store.system.storage import SystemTableStorage
|
|
531
|
+
|
|
532
|
+
with metadata_store:
|
|
533
|
+
storage = SystemTableStorage(metadata_store)
|
|
534
|
+
try:
|
|
535
|
+
graph = storage.load_graph_from_snapshot(snapshot_version=snapshot)
|
|
536
|
+
except ValueError as e:
|
|
537
|
+
from metaxy.cli.utils import print_error
|
|
538
|
+
|
|
539
|
+
print_error(console, "Snapshot error", e)
|
|
540
|
+
raise SystemExit(1)
|
|
541
|
+
except ImportError as e:
|
|
542
|
+
from metaxy.cli.utils import print_error
|
|
543
|
+
|
|
544
|
+
print_error(console, "Failed to load snapshot", e)
|
|
545
|
+
console.print(
|
|
546
|
+
"[yellow]Hint:[/yellow] Feature classes may have been moved or deleted."
|
|
547
|
+
)
|
|
548
|
+
raise SystemExit(1) from e
|
|
549
|
+
except Exception as e:
|
|
550
|
+
from metaxy.cli.utils import print_error
|
|
551
|
+
|
|
552
|
+
print_error(console, "Failed to load snapshot", e)
|
|
553
|
+
raise SystemExit(1) from e
|
|
554
|
+
|
|
555
|
+
console.print(
|
|
556
|
+
f"[green]✓[/green] Loaded {len(graph.features_by_key)} features from snapshot {snapshot}"
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
# Instantiate renderer based on format and type
|
|
560
|
+
# (graph is guaranteed to be assigned by this point - either from get_active() or from_snapshot())
|
|
561
|
+
assert "graph" in locals(), "graph must be assigned"
|
|
562
|
+
if format == "terminal":
|
|
563
|
+
if type == "graph":
|
|
564
|
+
renderer = TerminalRenderer(graph, render_config)
|
|
565
|
+
elif type == "cards":
|
|
566
|
+
renderer = CardsRenderer(graph, render_config)
|
|
567
|
+
else:
|
|
568
|
+
# Should not reach here due to validation above
|
|
569
|
+
console.print(f"[red]Error:[/red] Unknown type: {type}")
|
|
570
|
+
raise SystemExit(1)
|
|
571
|
+
elif format == "mermaid":
|
|
572
|
+
renderer = MermaidRenderer(graph, render_config)
|
|
573
|
+
elif format == "graphviz":
|
|
574
|
+
try:
|
|
575
|
+
renderer = GraphvizRenderer(graph, render_config)
|
|
576
|
+
except ImportError as e:
|
|
577
|
+
console.print(f"[red]✗[/red] {e}")
|
|
578
|
+
raise SystemExit(1)
|
|
579
|
+
else:
|
|
580
|
+
# Should not reach here due to validation above
|
|
581
|
+
console.print(f"[red]Error:[/red] Unknown format: {format}")
|
|
582
|
+
raise SystemExit(1)
|
|
583
|
+
|
|
584
|
+
# Render graph
|
|
585
|
+
try:
|
|
586
|
+
rendered = renderer.render()
|
|
587
|
+
except Exception as e:
|
|
588
|
+
from metaxy.cli.utils import print_error
|
|
589
|
+
|
|
590
|
+
print_error(console, "Rendering failed", e)
|
|
591
|
+
import traceback
|
|
592
|
+
|
|
593
|
+
traceback.print_exc()
|
|
594
|
+
raise SystemExit(1)
|
|
595
|
+
|
|
596
|
+
# Output to stdout or file
|
|
597
|
+
if output:
|
|
598
|
+
try:
|
|
599
|
+
with open(output, "w") as f:
|
|
600
|
+
f.write(rendered)
|
|
601
|
+
console.print(f"[green]✓[/green] Rendered graph saved to: {output}")
|
|
602
|
+
except Exception as e:
|
|
603
|
+
from metaxy.cli.utils import print_error
|
|
604
|
+
|
|
605
|
+
print_error(console, "Failed to write to file", e)
|
|
606
|
+
raise SystemExit(1)
|
|
607
|
+
else:
|
|
608
|
+
# Print to stdout using data_console
|
|
609
|
+
# Rendered graph output is data that users might pipe/redirect
|
|
610
|
+
data_console.print(rendered)
|