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.
Files changed (111) hide show
  1. metaxy/__init__.py +170 -0
  2. metaxy/_packaging.py +96 -0
  3. metaxy/_testing/__init__.py +55 -0
  4. metaxy/_testing/config.py +43 -0
  5. metaxy/_testing/metaxy_project.py +780 -0
  6. metaxy/_testing/models.py +111 -0
  7. metaxy/_testing/parametric/__init__.py +13 -0
  8. metaxy/_testing/parametric/metadata.py +664 -0
  9. metaxy/_testing/pytest_helpers.py +74 -0
  10. metaxy/_testing/runbook.py +533 -0
  11. metaxy/_utils.py +35 -0
  12. metaxy/_version.py +1 -0
  13. metaxy/cli/app.py +97 -0
  14. metaxy/cli/console.py +13 -0
  15. metaxy/cli/context.py +167 -0
  16. metaxy/cli/graph.py +610 -0
  17. metaxy/cli/graph_diff.py +290 -0
  18. metaxy/cli/list.py +46 -0
  19. metaxy/cli/metadata.py +317 -0
  20. metaxy/cli/migrations.py +999 -0
  21. metaxy/cli/utils.py +268 -0
  22. metaxy/config.py +680 -0
  23. metaxy/entrypoints.py +296 -0
  24. metaxy/ext/__init__.py +1 -0
  25. metaxy/ext/dagster/__init__.py +54 -0
  26. metaxy/ext/dagster/constants.py +10 -0
  27. metaxy/ext/dagster/dagster_type.py +156 -0
  28. metaxy/ext/dagster/io_manager.py +200 -0
  29. metaxy/ext/dagster/metaxify.py +512 -0
  30. metaxy/ext/dagster/observable.py +115 -0
  31. metaxy/ext/dagster/resources.py +27 -0
  32. metaxy/ext/dagster/selection.py +73 -0
  33. metaxy/ext/dagster/table_metadata.py +417 -0
  34. metaxy/ext/dagster/utils.py +462 -0
  35. metaxy/ext/sqlalchemy/__init__.py +23 -0
  36. metaxy/ext/sqlalchemy/config.py +29 -0
  37. metaxy/ext/sqlalchemy/plugin.py +353 -0
  38. metaxy/ext/sqlmodel/__init__.py +13 -0
  39. metaxy/ext/sqlmodel/config.py +29 -0
  40. metaxy/ext/sqlmodel/plugin.py +499 -0
  41. metaxy/graph/__init__.py +29 -0
  42. metaxy/graph/describe.py +325 -0
  43. metaxy/graph/diff/__init__.py +21 -0
  44. metaxy/graph/diff/diff_models.py +446 -0
  45. metaxy/graph/diff/differ.py +769 -0
  46. metaxy/graph/diff/models.py +443 -0
  47. metaxy/graph/diff/rendering/__init__.py +18 -0
  48. metaxy/graph/diff/rendering/base.py +323 -0
  49. metaxy/graph/diff/rendering/cards.py +188 -0
  50. metaxy/graph/diff/rendering/formatter.py +805 -0
  51. metaxy/graph/diff/rendering/graphviz.py +246 -0
  52. metaxy/graph/diff/rendering/mermaid.py +326 -0
  53. metaxy/graph/diff/rendering/rich.py +169 -0
  54. metaxy/graph/diff/rendering/theme.py +48 -0
  55. metaxy/graph/diff/traversal.py +247 -0
  56. metaxy/graph/status.py +329 -0
  57. metaxy/graph/utils.py +58 -0
  58. metaxy/metadata_store/__init__.py +32 -0
  59. metaxy/metadata_store/_ducklake_support.py +419 -0
  60. metaxy/metadata_store/base.py +1792 -0
  61. metaxy/metadata_store/bigquery.py +354 -0
  62. metaxy/metadata_store/clickhouse.py +184 -0
  63. metaxy/metadata_store/delta.py +371 -0
  64. metaxy/metadata_store/duckdb.py +446 -0
  65. metaxy/metadata_store/exceptions.py +61 -0
  66. metaxy/metadata_store/ibis.py +542 -0
  67. metaxy/metadata_store/lancedb.py +391 -0
  68. metaxy/metadata_store/memory.py +292 -0
  69. metaxy/metadata_store/system/__init__.py +57 -0
  70. metaxy/metadata_store/system/events.py +264 -0
  71. metaxy/metadata_store/system/keys.py +9 -0
  72. metaxy/metadata_store/system/models.py +129 -0
  73. metaxy/metadata_store/system/storage.py +957 -0
  74. metaxy/metadata_store/types.py +10 -0
  75. metaxy/metadata_store/utils.py +104 -0
  76. metaxy/metadata_store/warnings.py +36 -0
  77. metaxy/migrations/__init__.py +32 -0
  78. metaxy/migrations/detector.py +291 -0
  79. metaxy/migrations/executor.py +516 -0
  80. metaxy/migrations/generator.py +319 -0
  81. metaxy/migrations/loader.py +231 -0
  82. metaxy/migrations/models.py +528 -0
  83. metaxy/migrations/ops.py +447 -0
  84. metaxy/models/__init__.py +0 -0
  85. metaxy/models/bases.py +12 -0
  86. metaxy/models/constants.py +139 -0
  87. metaxy/models/feature.py +1335 -0
  88. metaxy/models/feature_spec.py +338 -0
  89. metaxy/models/field.py +263 -0
  90. metaxy/models/fields_mapping.py +307 -0
  91. metaxy/models/filter_expression.py +297 -0
  92. metaxy/models/lineage.py +285 -0
  93. metaxy/models/plan.py +232 -0
  94. metaxy/models/types.py +475 -0
  95. metaxy/py.typed +0 -0
  96. metaxy/utils/__init__.py +1 -0
  97. metaxy/utils/constants.py +2 -0
  98. metaxy/utils/exceptions.py +23 -0
  99. metaxy/utils/hashing.py +230 -0
  100. metaxy/versioning/__init__.py +31 -0
  101. metaxy/versioning/engine.py +656 -0
  102. metaxy/versioning/feature_dep_transformer.py +151 -0
  103. metaxy/versioning/ibis.py +249 -0
  104. metaxy/versioning/lineage_handler.py +205 -0
  105. metaxy/versioning/polars.py +189 -0
  106. metaxy/versioning/renamed_df.py +35 -0
  107. metaxy/versioning/types.py +63 -0
  108. metaxy-0.0.1.dev3.dist-info/METADATA +96 -0
  109. metaxy-0.0.1.dev3.dist-info/RECORD +111 -0
  110. metaxy-0.0.1.dev3.dist-info/WHEEL +4 -0
  111. 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)