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
@@ -0,0 +1,290 @@
1
+ """Graph diff commands for Metaxy CLI."""
2
+
3
+ from typing import Annotated, Literal
4
+
5
+ import cyclopts
6
+ from rich.console import Console
7
+
8
+ from metaxy.graph import RenderConfig
9
+
10
+ # Rich console for formatted output
11
+ console = Console()
12
+
13
+ # Graph-diff subcommand app
14
+ app = cyclopts.App(
15
+ name="graph-diff", # pyrefly: ignore[unexpected-keyword]
16
+ help="Compare and visualize graph snapshots", # pyrefly: ignore[unexpected-keyword]
17
+ console=console, # pyrefly: ignore[unexpected-keyword]
18
+ )
19
+
20
+
21
+ @app.command()
22
+ def render(
23
+ from_snapshot: Annotated[
24
+ str,
25
+ cyclopts.Parameter(
26
+ help='First snapshot to compare (can be "latest", "current", or snapshot hash)',
27
+ ),
28
+ ],
29
+ to_snapshot: Annotated[
30
+ str,
31
+ cyclopts.Parameter(
32
+ help='Second snapshot to compare (can be "latest", "current", or snapshot hash)',
33
+ ),
34
+ ] = "current",
35
+ store: Annotated[
36
+ str | None,
37
+ cyclopts.Parameter(
38
+ name=["--store"],
39
+ help="Metadata store to use (defaults to configured default store)",
40
+ ),
41
+ ] = None,
42
+ format: Annotated[
43
+ Literal["terminal", "cards", "mermaid", "graphviz", "json", "yaml"],
44
+ cyclopts.Parameter(
45
+ name=["--format", "-f"],
46
+ help="Output format: terminal, cards, mermaid, graphviz, json, or yaml",
47
+ ),
48
+ ] = "terminal",
49
+ output: Annotated[
50
+ str | None,
51
+ cyclopts.Parameter(
52
+ name=["--output", "-o"],
53
+ help="Output file path (default: stdout)",
54
+ ),
55
+ ] = None,
56
+ config: Annotated[
57
+ RenderConfig | None,
58
+ cyclopts.Parameter(name="*", help="Render configuration"),
59
+ ] = None,
60
+ # Preset modes
61
+ minimal: Annotated[
62
+ bool,
63
+ cyclopts.Parameter(
64
+ name=["--minimal"],
65
+ help="Minimal output: only feature keys and dependencies",
66
+ ),
67
+ ] = False,
68
+ verbose: Annotated[
69
+ bool,
70
+ cyclopts.Parameter(
71
+ name=["--verbose"],
72
+ help="Verbose output: show all available information",
73
+ ),
74
+ ] = False,
75
+ ):
76
+ """Render merged graph visualization comparing two snapshots.
77
+
78
+ Shows all features color-coded by status (added/removed/changed/unchanged).
79
+ Uses the unified rendering system - same renderers as 'metaxy graph render'.
80
+
81
+ Special snapshot literals:
82
+ - "latest": Most recent snapshot in the store
83
+ - "current": Current graph state from code
84
+
85
+ Output formats:
86
+ - terminal: Hierarchical tree view (default)
87
+ - cards: Panel/card-based view
88
+ - mermaid: Mermaid flowchart diagram
89
+ - graphviz: Graphviz DOT format
90
+
91
+ Examples:
92
+ # Show merged graph with default terminal renderer
93
+ $ metaxy graph-diff render latest current
94
+
95
+ # Cards view
96
+ $ metaxy graph-diff render latest current --format cards
97
+
98
+ # Focus on specific feature with 2 levels up and 1 level down
99
+ $ metaxy graph-diff render latest current --feature user/profile --up 2 --down 1
100
+
101
+ # Show only changed fields (hide unchanged)
102
+ $ metaxy graph-diff render latest current --show-changed-fields-only
103
+
104
+ # Save Mermaid diagram to file
105
+ $ metaxy graph-diff render latest current --format mermaid --output diff.mmd
106
+
107
+ # Graphviz DOT format
108
+ $ metaxy graph-diff render latest current --format graphviz --output diff.dot
109
+
110
+ # Minimal view
111
+ $ metaxy graph-diff render latest current --minimal
112
+
113
+ # Everything
114
+ $ metaxy graph-diff render latest current --verbose
115
+ """
116
+ from metaxy.cli.context import get_store
117
+ from metaxy.entrypoints import load_features
118
+ from metaxy.graph import (
119
+ CardsRenderer,
120
+ GraphData,
121
+ GraphvizRenderer,
122
+ )
123
+ from metaxy.graph.diff.differ import GraphDiffer, SnapshotResolver
124
+ from metaxy.models.feature import FeatureGraph
125
+
126
+ # Validate format
127
+ valid_formats = ["terminal", "cards", "mermaid", "graphviz", "json", "yaml"]
128
+ if format not in valid_formats:
129
+ console.print(
130
+ f"[red]Error:[/red] Invalid format '{format}'. Must be one of: {', '.join(valid_formats)}"
131
+ )
132
+ raise SystemExit(1)
133
+
134
+ # Resolve configuration from presets
135
+ if minimal and verbose:
136
+ console.print("[red]Error:[/red] Cannot specify both --minimal and --verbose")
137
+ raise SystemExit(1)
138
+
139
+ # If config is None, create a default instance
140
+ if config is None:
141
+ config = RenderConfig()
142
+
143
+ # Apply presets if specified (overrides display settings but preserves filtering)
144
+ if minimal:
145
+ preset = RenderConfig.minimal()
146
+ # Preserve filtering parameters from original config
147
+ preset.feature = config.feature
148
+ preset.up = config.up
149
+ preset.down = config.down
150
+ config = preset
151
+ elif verbose:
152
+ preset = RenderConfig.verbose()
153
+ # Preserve filtering parameters from original config
154
+ preset.feature = config.feature
155
+ preset.up = config.up
156
+ preset.down = config.down
157
+ config = preset
158
+
159
+ # Validate filtering options
160
+ if (config.up is not None or config.down is not None) and config.feature is None:
161
+ console.print(
162
+ "[red]Error:[/red] --up and --down require --feature to be specified"
163
+ )
164
+ raise SystemExit(1)
165
+
166
+ # Load features from entrypoints (needed for "current" literal)
167
+ load_features()
168
+ graph = FeatureGraph.get_active()
169
+
170
+ metadata_store = get_store(store)
171
+
172
+ with metadata_store:
173
+ # Resolve snapshot versions
174
+ resolver = SnapshotResolver()
175
+ try:
176
+ from_snapshot_version = resolver.resolve_snapshot(
177
+ from_snapshot, metadata_store, graph
178
+ )
179
+ to_snapshot_version = resolver.resolve_snapshot(
180
+ to_snapshot, metadata_store, graph
181
+ )
182
+ except ValueError as e:
183
+ console.print(f"[red]Error:[/red] {e}")
184
+ raise SystemExit(1)
185
+
186
+ # Load snapshot data
187
+ differ = GraphDiffer()
188
+ try:
189
+ from_snapshot_data = differ.load_snapshot_data(
190
+ metadata_store, from_snapshot_version
191
+ )
192
+ to_snapshot_data = differ.load_snapshot_data(
193
+ metadata_store, to_snapshot_version
194
+ )
195
+ except ValueError as e:
196
+ console.print(f"[red]Error:[/red] {e}")
197
+ raise SystemExit(1)
198
+
199
+ # Compute diff
200
+ graph_diff = differ.diff(from_snapshot_data, to_snapshot_data)
201
+
202
+ # Create merged graph data
203
+ merged_data = differ.create_merged_graph_data(
204
+ from_snapshot_data, to_snapshot_data, graph_diff
205
+ )
206
+
207
+ # Apply graph slicing if requested
208
+ if config.feature is not None:
209
+ try:
210
+ merged_data = differ.filter_merged_graph(
211
+ merged_data,
212
+ focus_feature=config.feature,
213
+ up=config.up,
214
+ down=config.down,
215
+ )
216
+ except ValueError as e:
217
+ console.print(f"[red]Error:[/red] {e}")
218
+ raise SystemExit(1)
219
+
220
+ # Render the diff
221
+ # Use DiffFormatter for terminal/json/yaml/mermaid (has proper diff visualization)
222
+ # Use unified renderers for cards/graphviz (DiffFormatter doesn't support these)
223
+ if format in ("terminal", "mermaid", "json", "yaml"):
224
+ from metaxy.graph.diff.rendering.formatter import DiffFormatter
225
+
226
+ formatter = DiffFormatter(console)
227
+
228
+ # Determine show_all_fields based on config
229
+ # TODO: add show_changed_fields_only to config
230
+ show_all_fields = True # Default: show all fields
231
+
232
+ try:
233
+ rendered = formatter.format(
234
+ merged_data=merged_data,
235
+ format=format,
236
+ verbose=verbose,
237
+ diff_only=False, # Always use merged view for graph-diff render
238
+ show_all_fields=show_all_fields,
239
+ )
240
+ except Exception as e:
241
+ console.print(f"[red]Error:[/red] Rendering failed: {e}")
242
+ import traceback
243
+
244
+ traceback.print_exc()
245
+ raise SystemExit(1)
246
+ else:
247
+ # Use unified renderers for cards/graphviz formats
248
+ from metaxy.graph.diff.rendering.theme import Theme
249
+
250
+ theme = Theme.default()
251
+ graph_data = GraphData.from_merged_diff(merged_data)
252
+
253
+ if format == "cards":
254
+ renderer = CardsRenderer(
255
+ graph_data=graph_data, config=config, theme=theme
256
+ )
257
+ elif format == "graphviz":
258
+ renderer = GraphvizRenderer(
259
+ graph_data=graph_data, config=config, theme=theme
260
+ )
261
+ else:
262
+ console.print(f"[red]Error:[/red] Unknown format: {format}")
263
+ raise SystemExit(1)
264
+
265
+ try:
266
+ rendered = renderer.render()
267
+ except Exception as e:
268
+ console.print(f"[red]Error:[/red] Rendering failed: {e}")
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
+ console.print(f"[red]Error:[/red] Failed to write to file: {e}")
282
+ raise SystemExit(1)
283
+ else:
284
+ # Print to stdout
285
+ if format in ("terminal", "cards"):
286
+ # Use plain print for terminal formats (they have ANSI codes)
287
+ print(rendered)
288
+ else:
289
+ # Use Rich console for non-terminal formats
290
+ console.print(rendered)
metaxy/cli/list.py ADDED
@@ -0,0 +1,42 @@
1
+ import cyclopts
2
+ from rich.console import Console
3
+
4
+ # Rich console for formatted output
5
+ console = Console()
6
+
7
+ # Migrations subcommand app
8
+ app = cyclopts.App(
9
+ name="list", # pyrefly: ignore[unexpected-keyword]
10
+ help="List Metaxy entities", # pyrefly: ignore[unexpected-keyword]
11
+ console=console, # pyrefly: ignore[unexpected-keyword]
12
+ )
13
+
14
+
15
+ @app.command()
16
+ def features():
17
+ """List Metaxy features"""
18
+ from metaxy.cli.context import set_config
19
+ from metaxy.config import MetaxyConfig
20
+ from metaxy.entrypoints import load_features
21
+ from metaxy.models.plan import FQFieldKey
22
+
23
+ metaxy_config = MetaxyConfig.load(search_parents=True)
24
+
25
+ set_config(metaxy_config)
26
+
27
+ graph = load_features()
28
+
29
+ for feature_key, feature_spec in graph.feature_specs_by_key.items():
30
+ console.print("---")
31
+ console.print(
32
+ f"{feature_key} (version {graph.get_feature_version(feature_key)})"
33
+ )
34
+ if feature_spec.deps:
35
+ console.print(" Feature Dependencies:")
36
+ for dep in feature_spec.deps:
37
+ console.print(f" {dep}")
38
+ console.print(" Fields:")
39
+ for field_key, field_spec in feature_spec.fields_by_key.items():
40
+ console.print(
41
+ f" {field_spec.key.to_string()} (code_version {field_spec.code_version}, version {graph.get_field_version(FQFieldKey(feature=feature_key, field=field_key))})"
42
+ )
metaxy/cli/metadata.py ADDED
@@ -0,0 +1,271 @@
1
+ """Metadata management commands for Metaxy CLI."""
2
+
3
+ from typing import TYPE_CHECKING, Annotated
4
+
5
+ import cyclopts
6
+ from rich.console import Console
7
+
8
+ from metaxy.models.types import FeatureKey
9
+
10
+ if TYPE_CHECKING:
11
+ from metaxy.models.feature import Feature
12
+
13
+ # Rich console for formatted output
14
+ console = Console()
15
+
16
+ # Metadata subcommand app
17
+ app = cyclopts.App(
18
+ name="metadata", # pyrefly: ignore[unexpected-keyword]
19
+ help="Manage Metaxy metadata", # pyrefly: ignore[unexpected-keyword]
20
+ console=console, # pyrefly: ignore[unexpected-keyword]
21
+ )
22
+
23
+
24
+ @app.command()
25
+ def copy(
26
+ from_store: Annotated[
27
+ str,
28
+ cyclopts.Parameter(
29
+ name=["--from", "FROM"],
30
+ help="Source store name (must be configured in metaxy.toml)",
31
+ ),
32
+ ],
33
+ to_store: Annotated[
34
+ str,
35
+ cyclopts.Parameter(
36
+ name=["--to", "TO"],
37
+ help="Destination store name (must be configured in metaxy.toml)",
38
+ ),
39
+ ],
40
+ features: Annotated[
41
+ list[str] | None,
42
+ cyclopts.Parameter(
43
+ name=["--feature"],
44
+ help="Feature key to copy (e.g., 'my_feature' or 'group/my_feature'). Can be repeated multiple times. If not specified, uses --all-features.",
45
+ ),
46
+ ] = None,
47
+ all_features: Annotated[
48
+ bool,
49
+ cyclopts.Parameter(
50
+ name=["--all-features"],
51
+ help="Copy all features from source store",
52
+ ),
53
+ ] = False,
54
+ from_snapshot: Annotated[
55
+ str | None,
56
+ cyclopts.Parameter(
57
+ name=["--snapshot"],
58
+ help="Snapshot version to copy (defaults to latest in source store). The snapshot_version is preserved in the destination.",
59
+ ),
60
+ ] = None,
61
+ incremental: Annotated[
62
+ bool,
63
+ cyclopts.Parameter(
64
+ name=["--incremental"],
65
+ help="Use incremental copy (compare data_version to skip existing rows). Disable for better performance if destination is empty or uses deduplication.",
66
+ ),
67
+ ] = True,
68
+ ):
69
+ """Copy metadata between stores.
70
+
71
+ Copies metadata for specified features from one store to another,
72
+ optionally using a historical version. Useful for:
73
+ - Migrating data between environments
74
+ - Backfilling metadata
75
+ - Copying specific feature versions
76
+
77
+ Incremental Mode (default):
78
+ By default, performs an anti-join on sample_uid to skip rows that already exist
79
+ in the destination for the same snapshot_version. This prevents duplicate writes.
80
+
81
+ Disabling incremental (--no-incremental) may improve performance when:
82
+ - The destination store is empty or has no overlap with source
83
+ - The destination store has eventual deduplication
84
+
85
+ Examples:
86
+ # Copy all features from latest snapshot in dev to staging
87
+ $ metaxy metadata copy --from dev --to staging --all-features
88
+
89
+ # Copy specific features (repeatable flag)
90
+ $ metaxy metadata copy --from dev --to staging --feature user_features --feature customer_features
91
+
92
+ # Copy specific snapshot
93
+ $ metaxy metadata copy --from prod --to staging --all-features --snapshot abc123
94
+
95
+ # Non-incremental copy (faster, but may create duplicates)
96
+ $ metaxy metadata copy --from dev --to staging --all-features --no-incremental
97
+ """
98
+ import logging
99
+
100
+ from metaxy.cli.context import get_config
101
+
102
+ # Enable logging to show progress
103
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
104
+
105
+ config = get_config()
106
+
107
+ # Validate arguments
108
+ if not all_features and not features:
109
+ console.print(
110
+ "[red]Error:[/red] Must specify either --all-features or --feature"
111
+ )
112
+ raise SystemExit(1)
113
+
114
+ if all_features and features:
115
+ console.print(
116
+ "[red]Error:[/red] Cannot specify both --all-features and --feature"
117
+ )
118
+ raise SystemExit(1)
119
+
120
+ # Parse feature keys
121
+ feature_keys: list[FeatureKey | type[Feature]] | None = None
122
+ if features:
123
+ feature_keys = []
124
+ for feature_str in features:
125
+ # Parse feature key (supports both "feature" and "part1/part2/..." formats)
126
+ if "/" in feature_str:
127
+ parts = feature_str.split("/")
128
+ feature_keys.append(FeatureKey(parts))
129
+ else:
130
+ # Single-part key
131
+ feature_keys.append(FeatureKey([feature_str]))
132
+
133
+ # Get stores
134
+ console.print(f"[cyan]Source store:[/cyan] {from_store}")
135
+ console.print(f"[cyan]Destination store:[/cyan] {to_store}")
136
+
137
+ source_store = config.get_store(from_store)
138
+ dest_store = config.get_store(to_store)
139
+
140
+ # Open both stores and copy
141
+ with source_store, dest_store:
142
+ console.print("\n[bold]Starting copy operation...[/bold]\n")
143
+
144
+ try:
145
+ stats = dest_store.copy_metadata(
146
+ from_store=source_store,
147
+ features=feature_keys,
148
+ from_snapshot=from_snapshot,
149
+ incremental=incremental,
150
+ )
151
+
152
+ console.print(
153
+ f"\n[green]✓[/green] Copy complete: {stats['features_copied']} features, {stats['rows_copied']} rows"
154
+ )
155
+
156
+ except Exception as e:
157
+ console.print(f"\n[red]✗[/red] Copy failed:\n{e}")
158
+ raise SystemExit(1)
159
+
160
+
161
+ @app.command()
162
+ def drop(
163
+ store: Annotated[
164
+ str | None,
165
+ cyclopts.Parameter(
166
+ name=["--store"],
167
+ help="Store name to drop metadata from (defaults to configured default store)",
168
+ ),
169
+ ] = None,
170
+ features: Annotated[
171
+ list[str] | None,
172
+ cyclopts.Parameter(
173
+ name=["--feature"],
174
+ help="Feature key to drop (e.g., 'my_feature' or 'group/my_feature'). Can be repeated multiple times. If not specified, uses --all-features.",
175
+ ),
176
+ ] = None,
177
+ all_features: Annotated[
178
+ bool,
179
+ cyclopts.Parameter(
180
+ name=["--all-features"],
181
+ help="Drop metadata for all features in the store",
182
+ ),
183
+ ] = False,
184
+ confirm: Annotated[
185
+ bool,
186
+ cyclopts.Parameter(
187
+ name=["--confirm"],
188
+ help="Confirm the drop operation (required to prevent accidental deletion)",
189
+ ),
190
+ ] = False,
191
+ ):
192
+ """Drop metadata from a store.
193
+
194
+ Removes metadata for specified features from the store.
195
+ This is a destructive operation and requires --confirm flag.
196
+
197
+ Useful for:
198
+ - Cleaning up test data
199
+ - Re-computing feature metadata from scratch
200
+ - Removing obsolete features
201
+
202
+ Examples:
203
+ # Drop specific feature (requires confirmation)
204
+ $ metaxy metadata drop --feature user_features --confirm
205
+
206
+ # Drop multiple features
207
+ $ metaxy metadata drop --feature user_features --feature customer_features --confirm
208
+
209
+ # Drop all features from specific store
210
+ $ metaxy metadata drop --store dev --all-features --confirm
211
+ """
212
+ from metaxy.cli.context import get_store
213
+
214
+ # Validate arguments
215
+ if not all_features and not features:
216
+ console.print(
217
+ "[red]Error:[/red] Must specify either --all-features or --feature"
218
+ )
219
+ raise SystemExit(1)
220
+
221
+ if all_features and features:
222
+ console.print(
223
+ "[red]Error:[/red] Cannot specify both --all-features and --feature"
224
+ )
225
+ raise SystemExit(1)
226
+
227
+ if not confirm:
228
+ console.print(
229
+ "[red]Error:[/red] This is a destructive operation. Must specify --confirm flag."
230
+ )
231
+ raise SystemExit(1)
232
+
233
+ # Parse feature keys
234
+ feature_keys: list[FeatureKey] = []
235
+ if features:
236
+ for feature_str in features:
237
+ # Parse feature key (supports both "feature" and "part1/part2/..." formats)
238
+ if "/" in feature_str:
239
+ parts = feature_str.split("/")
240
+ feature_keys.append(FeatureKey(parts))
241
+ else:
242
+ # Single-part key
243
+ feature_keys.append(FeatureKey([feature_str]))
244
+
245
+ # Get store
246
+ metadata_store = get_store(store)
247
+
248
+ with metadata_store:
249
+ # If all_features, get all feature keys from store
250
+ if all_features:
251
+ # Get all features that have metadata in the store
252
+ feature_keys = metadata_store.list_features(include_fallback=False)
253
+
254
+ console.print(
255
+ f"\n[bold]Dropping metadata for {len(feature_keys)} feature(s)...[/bold]\n"
256
+ )
257
+
258
+ dropped_count = 0
259
+ for feature_key in feature_keys:
260
+ try:
261
+ metadata_store.drop_feature_metadata(feature_key)
262
+ console.print(f"[green]✓[/green] Dropped: {feature_key.to_string()}")
263
+ dropped_count += 1
264
+ except Exception as e:
265
+ console.print(
266
+ f"[red]✗[/red] Failed to drop {feature_key.to_string()}: {e}"
267
+ )
268
+
269
+ console.print(
270
+ f"\n[green]✓[/green] Drop complete: {dropped_count} feature(s) dropped"
271
+ )