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
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)