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
@@ -0,0 +1,290 @@
1
+ """Graph diff commands for Metaxy CLI."""
2
+
3
+ from typing import Annotated, Literal
4
+
5
+ import cyclopts
6
+
7
+ from metaxy.cli.console import console, data_console, error_console
8
+ from metaxy.graph import RenderConfig
9
+
10
+ # Graph-diff subcommand app
11
+ app = cyclopts.App(
12
+ name="graph-diff", # pyrefly: ignore[unexpected-keyword]
13
+ help="Compare and visualize graph snapshots", # pyrefly: ignore[unexpected-keyword]
14
+ console=console, # pyrefly: ignore[unexpected-keyword]
15
+ error_console=error_console, # pyrefly: ignore[unexpected-keyword]
16
+ )
17
+
18
+
19
+ @app.command()
20
+ def render(
21
+ from_snapshot: Annotated[
22
+ str,
23
+ cyclopts.Parameter(
24
+ help='First snapshot to compare (can be "latest", "current", or snapshot hash)',
25
+ ),
26
+ ],
27
+ to_snapshot: Annotated[
28
+ str,
29
+ cyclopts.Parameter(
30
+ help='Second snapshot to compare (can be "latest", "current", or snapshot hash)',
31
+ ),
32
+ ] = "current",
33
+ store: Annotated[
34
+ str | None,
35
+ cyclopts.Parameter(
36
+ name=["--store"],
37
+ help="Metadata store to use (defaults to configured default store)",
38
+ ),
39
+ ] = None,
40
+ format: Annotated[
41
+ Literal["terminal", "cards", "mermaid", "graphviz", "json", "yaml"],
42
+ cyclopts.Parameter(
43
+ name=["--format", "-f"],
44
+ help="Output format: terminal, cards, mermaid, graphviz, json, or yaml",
45
+ ),
46
+ ] = "terminal",
47
+ output: Annotated[
48
+ str | None,
49
+ cyclopts.Parameter(
50
+ name=["--output", "-o"],
51
+ help="Output file path (default: stdout)",
52
+ ),
53
+ ] = None,
54
+ config: Annotated[
55
+ RenderConfig | None,
56
+ cyclopts.Parameter(name="*", help="Render configuration"),
57
+ ] = None,
58
+ # Preset modes
59
+ minimal: Annotated[
60
+ bool,
61
+ cyclopts.Parameter(
62
+ name=["--minimal"],
63
+ help="Minimal output: only feature keys and dependencies",
64
+ ),
65
+ ] = False,
66
+ verbose: Annotated[
67
+ bool,
68
+ cyclopts.Parameter(
69
+ name=["--verbose"],
70
+ help="Verbose output: show all available information",
71
+ ),
72
+ ] = False,
73
+ ):
74
+ """Render merged graph visualization comparing two snapshots.
75
+
76
+ Shows all features color-coded by status (added/removed/changed/unchanged).
77
+ Uses the unified rendering system - same renderers as 'metaxy graph render'.
78
+
79
+ Special snapshot literals:
80
+ - "latest": Most recent snapshot in the store
81
+ - "current": Current graph state from code
82
+
83
+ Output formats:
84
+ - terminal: Hierarchical tree view (default)
85
+ - cards: Panel/card-based view
86
+ - mermaid: Mermaid flowchart diagram
87
+ - graphviz: Graphviz DOT format
88
+
89
+ Examples:
90
+ # Show merged graph with default terminal renderer
91
+ $ metaxy graph-diff render latest current
92
+
93
+ # Cards view
94
+ $ metaxy graph-diff render latest current --format cards
95
+
96
+ # Focus on specific feature with 2 levels up and 1 level down
97
+ $ metaxy graph-diff render latest current --feature user/profile --up 2 --down 1
98
+
99
+ # Show only changed fields (hide unchanged)
100
+ $ metaxy graph-diff render latest current --show-changed-fields-only
101
+
102
+ # Save Mermaid diagram to file
103
+ $ metaxy graph-diff render latest current --format mermaid --output diff.mmd
104
+
105
+ # Graphviz DOT format
106
+ $ metaxy graph-diff render latest current --format graphviz --output diff.dot
107
+
108
+ # Minimal view
109
+ $ metaxy graph-diff render latest current --minimal
110
+
111
+ # Everything
112
+ $ metaxy graph-diff render latest current --verbose
113
+ """
114
+ from metaxy.graph import (
115
+ CardsRenderer,
116
+ GraphData,
117
+ GraphvizRenderer,
118
+ )
119
+ from metaxy.graph.diff.differ import GraphDiffer, SnapshotResolver
120
+
121
+ # Validate format
122
+ valid_formats = ["terminal", "cards", "mermaid", "graphviz", "json", "yaml"]
123
+ if format not in valid_formats:
124
+ console.print(
125
+ f"[red]Error:[/red] Invalid format '{format}'. Must be one of: {', '.join(valid_formats)}"
126
+ )
127
+ raise SystemExit(1)
128
+
129
+ # Resolve configuration from presets
130
+ if minimal and verbose:
131
+ console.print("[red]Error:[/red] Cannot specify both --minimal and --verbose")
132
+ raise SystemExit(1)
133
+
134
+ # If config is None, create a default instance
135
+ if config is None:
136
+ config = RenderConfig()
137
+
138
+ # Apply presets if specified (overrides display settings but preserves filtering)
139
+ if minimal:
140
+ preset = RenderConfig.minimal()
141
+ # Preserve filtering parameters from original config
142
+ preset.feature = config.feature
143
+ preset.up = config.up
144
+ preset.down = config.down
145
+ config = preset
146
+ elif verbose:
147
+ preset = RenderConfig.verbose()
148
+ # Preserve filtering parameters from original config
149
+ preset.feature = config.feature
150
+ preset.up = config.up
151
+ preset.down = config.down
152
+ config = preset
153
+
154
+ # Validate filtering options
155
+ if (config.up is not None or config.down is not None) and config.feature is None:
156
+ console.print(
157
+ "[red]Error:[/red] --up and --down require --feature to be specified"
158
+ )
159
+ raise SystemExit(1)
160
+
161
+ from metaxy.cli.context import AppContext
162
+
163
+ context = AppContext.get()
164
+ metadata_store = context.get_store(store)
165
+ graph = context.graph
166
+ project = context.get_required_project() # This command needs a specific project
167
+
168
+ with metadata_store:
169
+ # Resolve snapshot versions
170
+ resolver = SnapshotResolver()
171
+ try:
172
+ from_snapshot_version = resolver.resolve_snapshot(
173
+ from_snapshot, metadata_store, graph
174
+ )
175
+ to_snapshot_version = resolver.resolve_snapshot(
176
+ to_snapshot, metadata_store, graph
177
+ )
178
+ except ValueError as e:
179
+ console.print(f"[red]Error:[/red] {e}")
180
+ raise SystemExit(1)
181
+
182
+ # Load snapshot data
183
+ differ = GraphDiffer()
184
+ try:
185
+ from_snapshot_data = differ.load_snapshot_data(
186
+ metadata_store, from_snapshot_version, project
187
+ )
188
+ to_snapshot_data = differ.load_snapshot_data(
189
+ metadata_store, to_snapshot_version, project
190
+ )
191
+ except ValueError as e:
192
+ console.print(f"[red]Error:[/red] {e}")
193
+ raise SystemExit(1)
194
+
195
+ # Compute diff
196
+ graph_diff = differ.diff(from_snapshot_data, to_snapshot_data)
197
+
198
+ # Create merged graph data
199
+ merged_data = differ.create_merged_graph_data(
200
+ from_snapshot_data, to_snapshot_data, graph_diff
201
+ )
202
+
203
+ # Apply graph slicing if requested
204
+ if config.feature is not None:
205
+ try:
206
+ merged_data = differ.filter_merged_graph(
207
+ merged_data,
208
+ focus_feature=config.feature,
209
+ up=config.up,
210
+ down=config.down,
211
+ )
212
+ except ValueError as e:
213
+ console.print(f"[red]Error:[/red] {e}")
214
+ raise SystemExit(1)
215
+
216
+ # Render the diff
217
+ # Use DiffFormatter for terminal/json/yaml/mermaid (has proper diff visualization)
218
+ # Use unified renderers for cards/graphviz (DiffFormatter doesn't support these)
219
+ if format in ("terminal", "mermaid", "json", "yaml"):
220
+ from metaxy.graph.diff.rendering.formatter import DiffFormatter
221
+
222
+ formatter = DiffFormatter(console)
223
+
224
+ # Determine show_all_fields based on config
225
+ # TODO: add show_changed_fields_only to config
226
+ show_all_fields = True # Default: show all fields
227
+
228
+ try:
229
+ rendered = formatter.format(
230
+ merged_data=merged_data,
231
+ format=format,
232
+ verbose=verbose,
233
+ diff_only=False, # Always use merged view for graph-diff render
234
+ show_all_fields=show_all_fields,
235
+ )
236
+ except Exception as e:
237
+ from metaxy.cli.utils import print_error
238
+
239
+ print_error(console, "Rendering failed", e, prefix="[red]Error:[/red]")
240
+ import traceback
241
+
242
+ traceback.print_exc()
243
+ raise SystemExit(1)
244
+ else:
245
+ # Use unified renderers for cards/graphviz formats
246
+ from metaxy.graph.diff.rendering.theme import Theme
247
+
248
+ theme = Theme.default()
249
+ graph_data = GraphData.from_merged_diff(merged_data)
250
+
251
+ if format == "cards":
252
+ renderer = CardsRenderer(
253
+ graph_data=graph_data, config=config, theme=theme
254
+ )
255
+ elif format == "graphviz":
256
+ renderer = GraphvizRenderer(
257
+ graph_data=graph_data, config=config, theme=theme
258
+ )
259
+ else:
260
+ console.print(f"[red]Error:[/red] Unknown format: {format}")
261
+ raise SystemExit(1)
262
+
263
+ try:
264
+ rendered = renderer.render()
265
+ except Exception as e:
266
+ from metaxy.cli.utils import print_error
267
+
268
+ print_error(console, "Rendering failed", e, prefix="[red]Error:[/red]")
269
+ import traceback
270
+
271
+ traceback.print_exc()
272
+ raise SystemExit(1)
273
+
274
+ # Output to file or stdout
275
+ if output:
276
+ try:
277
+ with open(output, "w") as f:
278
+ f.write(rendered)
279
+ console.print(f"[green]Success:[/green] Diff rendered to: {output}")
280
+ except Exception as e:
281
+ from metaxy.cli.utils import print_error
282
+
283
+ print_error(
284
+ console, "Failed to write to file", e, prefix="[red]Error:[/red]"
285
+ )
286
+ raise SystemExit(1)
287
+ else:
288
+ # Print to stdout using data_console
289
+ # Rendered diff output is data that users might pipe/redirect
290
+ data_console.print(rendered)
metaxy/cli/list.py ADDED
@@ -0,0 +1,46 @@
1
+ import cyclopts
2
+
3
+ from metaxy.cli.console import console, data_console, error_console
4
+
5
+ # List subcommand app
6
+ app = cyclopts.App(
7
+ name="list", # pyrefly: ignore[unexpected-keyword]
8
+ help="List Metaxy entities", # pyrefly: ignore[unexpected-keyword]
9
+ console=console, # pyrefly: ignore[unexpected-keyword]
10
+ error_console=error_console, # pyrefly: ignore[unexpected-keyword]
11
+ )
12
+
13
+
14
+ @app.command()
15
+ def features():
16
+ """
17
+ List Metaxy features.
18
+ """
19
+ from metaxy import get_feature_by_key
20
+ from metaxy.cli.context import AppContext
21
+ from metaxy.models.plan import FQFieldKey
22
+
23
+ context = AppContext.get()
24
+ graph = context.graph
25
+
26
+ for feature_key, feature_spec in graph.feature_specs_by_key.items():
27
+ if (
28
+ context.project
29
+ and get_feature_by_key(feature_key).project != context.project
30
+ ):
31
+ continue
32
+ data_console.print("---")
33
+ version = graph.get_feature_version(feature_key)
34
+ data_console.print(f"{feature_key} (version\n{version})")
35
+ if feature_spec.deps:
36
+ data_console.print(" Feature Dependencies:")
37
+ for dep in feature_spec.deps:
38
+ data_console.print(f" {dep}")
39
+ data_console.print(" Fields:")
40
+ for field_key, field_spec in feature_spec.fields_by_key.items():
41
+ field_version = graph.get_field_version(
42
+ FQFieldKey(feature=feature_key, field=field_key)
43
+ )
44
+ data_console.print(
45
+ f" {field_spec.key.to_string()} (code_version {field_spec.code_version}, version\n{field_version})"
46
+ )
metaxy/cli/metadata.py ADDED
@@ -0,0 +1,317 @@
1
+ """Metadata management commands for Metaxy CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING, Annotated, Any
7
+
8
+ import cyclopts
9
+ from pydantic import TypeAdapter
10
+
11
+ from metaxy.cli.console import console, data_console, error_console
12
+ from metaxy.cli.utils import (
13
+ CLIError,
14
+ FeatureSelector,
15
+ OutputFormat,
16
+ exit_with_error,
17
+ load_graph_for_command,
18
+ )
19
+ from metaxy.graph.status import FullFeatureMetadataRepresentation
20
+
21
+ if TYPE_CHECKING:
22
+ pass
23
+
24
+ # Metadata subcommand app
25
+ app = cyclopts.App(
26
+ name="metadata", # pyrefly: ignore[unexpected-keyword]
27
+ help="Manage Metaxy metadata", # pyrefly: ignore[unexpected-keyword]
28
+ console=console, # pyrefly: ignore[unexpected-keyword]
29
+ error_console=error_console, # pyrefly: ignore[unexpected-keyword]
30
+ )
31
+
32
+
33
+ @app.command()
34
+ def status(
35
+ *,
36
+ selector: FeatureSelector = FeatureSelector(),
37
+ store: Annotated[
38
+ str | None,
39
+ cyclopts.Parameter(
40
+ name=["--store"],
41
+ help="Metadata store name (defaults to configured default store).",
42
+ ),
43
+ ] = None,
44
+ snapshot_version: Annotated[
45
+ str | None,
46
+ cyclopts.Parameter(
47
+ name=["--snapshot-id"],
48
+ help="Check metadata against a specific snapshot version.",
49
+ ),
50
+ ] = None,
51
+ assert_in_sync: Annotated[
52
+ bool,
53
+ cyclopts.Parameter(
54
+ name=["--assert-in-sync"],
55
+ help="Exit with error if any feature needs updates or metadata is missing.",
56
+ ),
57
+ ] = False,
58
+ verbose: Annotated[
59
+ bool,
60
+ cyclopts.Parameter(
61
+ name=["--verbose"],
62
+ help="Show additional details about samples needing updates.",
63
+ ),
64
+ ] = False,
65
+ format: Annotated[
66
+ OutputFormat,
67
+ cyclopts.Parameter(
68
+ name=["--format"],
69
+ ),
70
+ ] = "plain",
71
+ ) -> None:
72
+ """Check metadata completeness and freshness for specified features.
73
+
74
+ Examples:
75
+ $ metaxy metadata status --feature user_features
76
+ $ metaxy metadata status --feature feat1 --feature feat2
77
+ $ metaxy metadata status --all-features
78
+ $ metaxy metadata status --store dev --all-features
79
+ """
80
+ from metaxy.cli.context import AppContext
81
+ from metaxy.graph.status import get_feature_metadata_status
82
+
83
+ # Validate feature selection
84
+ selector.validate(format)
85
+
86
+ context = AppContext.get()
87
+ metadata_store = context.get_store(store)
88
+
89
+ with metadata_store:
90
+ # Load graph (from snapshot or current)
91
+ graph = load_graph_for_command(
92
+ context, snapshot_version, metadata_store, format
93
+ )
94
+
95
+ # Resolve feature keys
96
+ valid_keys, missing_keys = selector.resolve_keys(graph, format)
97
+
98
+ # Handle empty result for --all-features
99
+ if selector.all_features and not valid_keys:
100
+ _output_no_features_warning(format, snapshot_version)
101
+ return
102
+
103
+ # Handle missing features
104
+ if missing_keys:
105
+ if assert_in_sync:
106
+ exit_with_error(
107
+ CLIError(
108
+ code="FEATURES_NOT_FOUND",
109
+ message="Feature(s) not found in graph",
110
+ details={"features": [k.to_string() for k in missing_keys]},
111
+ ),
112
+ format,
113
+ )
114
+ elif format == "plain":
115
+ formatted = ", ".join(k.to_string() for k in missing_keys)
116
+ data_console.print(
117
+ f"[yellow]Warning:[/yellow] Feature(s) not found in graph: {formatted}"
118
+ )
119
+
120
+ # If no valid features remain
121
+ if not valid_keys:
122
+ _output_no_features_warning(format, snapshot_version)
123
+ return
124
+
125
+ # Print header for plain format
126
+ if format == "plain":
127
+ header = (
128
+ f"Metadata status (snapshot {snapshot_version})"
129
+ if snapshot_version
130
+ else "Metadata status"
131
+ )
132
+ data_console.print(f"\n[bold]{header}[/bold]")
133
+
134
+ # Collect status for all features
135
+ needs_update = False
136
+ feature_reps: dict[str, FullFeatureMetadataRepresentation] = {}
137
+
138
+ for feature_key in valid_keys:
139
+ feature_cls = graph.features_by_key[feature_key]
140
+ status_with_increment = get_feature_metadata_status(
141
+ feature_cls, metadata_store
142
+ )
143
+
144
+ if status_with_increment.status.needs_update:
145
+ needs_update = True
146
+
147
+ if format == "json":
148
+ feature_reps[feature_key.to_string()] = (
149
+ status_with_increment.to_representation(
150
+ feature_cls=feature_cls, verbose=verbose
151
+ )
152
+ )
153
+ else:
154
+ data_console.print(status_with_increment.status.format_status_line())
155
+ if verbose:
156
+ for line in status_with_increment.sample_details(feature_cls):
157
+ data_console.print(line)
158
+
159
+ # Output JSON result
160
+ if format == "json":
161
+ adapter = TypeAdapter(dict[str, FullFeatureMetadataRepresentation])
162
+ output: dict[str, Any] = {
163
+ "snapshot_version": snapshot_version,
164
+ "features": json.loads(
165
+ adapter.dump_json(feature_reps, exclude_none=True)
166
+ ),
167
+ "needs_update": needs_update,
168
+ }
169
+ if missing_keys:
170
+ output["warnings"] = {
171
+ "missing_in_graph": [k.to_string() for k in missing_keys]
172
+ }
173
+ print(json.dumps(output, indent=2))
174
+
175
+ # Exit with error if assert_in_sync and updates needed
176
+ if assert_in_sync and needs_update:
177
+ raise SystemExit(1)
178
+
179
+
180
+ def _output_no_features_warning(
181
+ format: OutputFormat, snapshot_version: str | None
182
+ ) -> None:
183
+ """Output warning when no features are found to check."""
184
+ if format == "json":
185
+ print(
186
+ json.dumps(
187
+ {
188
+ "warning": "No valid features to check",
189
+ "features": {},
190
+ "snapshot_version": snapshot_version,
191
+ "needs_update": False,
192
+ },
193
+ indent=2,
194
+ )
195
+ )
196
+ else:
197
+ data_console.print("[yellow]Warning:[/yellow] No valid features to check.")
198
+
199
+
200
+ @app.command()
201
+ def drop(
202
+ *,
203
+ selector: FeatureSelector = FeatureSelector(),
204
+ store: Annotated[
205
+ str | None,
206
+ cyclopts.Parameter(
207
+ name=["--store"],
208
+ help="Store name to drop metadata from (defaults to configured default store).",
209
+ ),
210
+ ] = None,
211
+ confirm: Annotated[
212
+ bool,
213
+ cyclopts.Parameter(
214
+ name=["--confirm"],
215
+ help="Confirm the drop operation (required to prevent accidental deletion).",
216
+ ),
217
+ ] = False,
218
+ format: Annotated[
219
+ OutputFormat,
220
+ cyclopts.Parameter(
221
+ name=["--format"],
222
+ help="Output format: 'plain' (default) or 'json'.",
223
+ ),
224
+ ] = "plain",
225
+ ) -> None:
226
+ """Drop metadata from a store.
227
+
228
+ Removes metadata for specified features. This is destructive and requires --confirm.
229
+
230
+ Examples:
231
+ $ metaxy metadata drop --feature user_features --confirm
232
+ $ metaxy metadata drop --feature feat1 --feature feat2 --confirm
233
+ $ metaxy metadata drop --store dev --all-features --confirm
234
+ """
235
+ from metaxy.cli.context import AppContext
236
+
237
+ # Validate feature selection
238
+ selector.validate(format)
239
+
240
+ # Require confirmation
241
+ if not confirm:
242
+ exit_with_error(
243
+ CLIError(
244
+ code="MISSING_CONFIRMATION",
245
+ message="This is a destructive operation. Must specify --confirm flag.",
246
+ details={"required_flag": "--confirm"},
247
+ ),
248
+ format,
249
+ )
250
+
251
+ context = AppContext.get()
252
+ context.raise_command_cannot_override_project()
253
+ metadata_store = context.get_store(store)
254
+
255
+ with metadata_store.open("write"):
256
+ graph = context.graph
257
+
258
+ # Resolve feature keys
259
+ valid_keys, _ = selector.resolve_keys(graph, format)
260
+
261
+ # Handle no features
262
+ if not valid_keys:
263
+ if format == "json":
264
+ print(
265
+ json.dumps(
266
+ {
267
+ "warning": "NO_FEATURES_FOUND",
268
+ "message": "No features found in active graph.",
269
+ "features_dropped": 0,
270
+ }
271
+ )
272
+ )
273
+ else:
274
+ console.print(
275
+ "[yellow]Warning:[/yellow] No features found in active graph."
276
+ )
277
+ return
278
+
279
+ if format == "plain":
280
+ console.print(
281
+ f"\n[bold]Dropping metadata for {len(valid_keys)} feature(s)...[/bold]\n"
282
+ )
283
+
284
+ # Drop each feature
285
+ dropped: list[str] = []
286
+ failed: list[dict[str, str]] = []
287
+
288
+ for feature_key in valid_keys:
289
+ key_str = feature_key.to_string()
290
+ try:
291
+ metadata_store.drop_feature_metadata(feature_key)
292
+ dropped.append(key_str)
293
+ if format == "plain":
294
+ console.print(f"[green]✓[/green] Dropped: {key_str}")
295
+ except Exception as e:
296
+ failed.append({"feature": key_str, "error": str(e)})
297
+ if format == "plain":
298
+ from metaxy.cli.utils import print_error_item
299
+
300
+ print_error_item(
301
+ console, key_str, e, prefix="[red]✗[/red] Failed to drop"
302
+ )
303
+
304
+ # Output result
305
+ if format == "json":
306
+ result: dict[str, Any] = {
307
+ "success": True,
308
+ "features_dropped": len(dropped),
309
+ "dropped": dropped,
310
+ }
311
+ if failed:
312
+ result["failed"] = failed
313
+ print(json.dumps(result, indent=2))
314
+ else:
315
+ console.print(
316
+ f"\n[green]✓[/green] Drop complete: {len(dropped)} feature(s) dropped"
317
+ )