metaxy 0.0.1.dev3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- metaxy/__init__.py +170 -0
- metaxy/_packaging.py +96 -0
- metaxy/_testing/__init__.py +55 -0
- metaxy/_testing/config.py +43 -0
- metaxy/_testing/metaxy_project.py +780 -0
- metaxy/_testing/models.py +111 -0
- metaxy/_testing/parametric/__init__.py +13 -0
- metaxy/_testing/parametric/metadata.py +664 -0
- metaxy/_testing/pytest_helpers.py +74 -0
- metaxy/_testing/runbook.py +533 -0
- metaxy/_utils.py +35 -0
- metaxy/_version.py +1 -0
- metaxy/cli/app.py +97 -0
- metaxy/cli/console.py +13 -0
- metaxy/cli/context.py +167 -0
- metaxy/cli/graph.py +610 -0
- metaxy/cli/graph_diff.py +290 -0
- metaxy/cli/list.py +46 -0
- metaxy/cli/metadata.py +317 -0
- metaxy/cli/migrations.py +999 -0
- metaxy/cli/utils.py +268 -0
- metaxy/config.py +680 -0
- metaxy/entrypoints.py +296 -0
- metaxy/ext/__init__.py +1 -0
- metaxy/ext/dagster/__init__.py +54 -0
- metaxy/ext/dagster/constants.py +10 -0
- metaxy/ext/dagster/dagster_type.py +156 -0
- metaxy/ext/dagster/io_manager.py +200 -0
- metaxy/ext/dagster/metaxify.py +512 -0
- metaxy/ext/dagster/observable.py +115 -0
- metaxy/ext/dagster/resources.py +27 -0
- metaxy/ext/dagster/selection.py +73 -0
- metaxy/ext/dagster/table_metadata.py +417 -0
- metaxy/ext/dagster/utils.py +462 -0
- metaxy/ext/sqlalchemy/__init__.py +23 -0
- metaxy/ext/sqlalchemy/config.py +29 -0
- metaxy/ext/sqlalchemy/plugin.py +353 -0
- metaxy/ext/sqlmodel/__init__.py +13 -0
- metaxy/ext/sqlmodel/config.py +29 -0
- metaxy/ext/sqlmodel/plugin.py +499 -0
- metaxy/graph/__init__.py +29 -0
- metaxy/graph/describe.py +325 -0
- metaxy/graph/diff/__init__.py +21 -0
- metaxy/graph/diff/diff_models.py +446 -0
- metaxy/graph/diff/differ.py +769 -0
- metaxy/graph/diff/models.py +443 -0
- metaxy/graph/diff/rendering/__init__.py +18 -0
- metaxy/graph/diff/rendering/base.py +323 -0
- metaxy/graph/diff/rendering/cards.py +188 -0
- metaxy/graph/diff/rendering/formatter.py +805 -0
- metaxy/graph/diff/rendering/graphviz.py +246 -0
- metaxy/graph/diff/rendering/mermaid.py +326 -0
- metaxy/graph/diff/rendering/rich.py +169 -0
- metaxy/graph/diff/rendering/theme.py +48 -0
- metaxy/graph/diff/traversal.py +247 -0
- metaxy/graph/status.py +329 -0
- metaxy/graph/utils.py +58 -0
- metaxy/metadata_store/__init__.py +32 -0
- metaxy/metadata_store/_ducklake_support.py +419 -0
- metaxy/metadata_store/base.py +1792 -0
- metaxy/metadata_store/bigquery.py +354 -0
- metaxy/metadata_store/clickhouse.py +184 -0
- metaxy/metadata_store/delta.py +371 -0
- metaxy/metadata_store/duckdb.py +446 -0
- metaxy/metadata_store/exceptions.py +61 -0
- metaxy/metadata_store/ibis.py +542 -0
- metaxy/metadata_store/lancedb.py +391 -0
- metaxy/metadata_store/memory.py +292 -0
- metaxy/metadata_store/system/__init__.py +57 -0
- metaxy/metadata_store/system/events.py +264 -0
- metaxy/metadata_store/system/keys.py +9 -0
- metaxy/metadata_store/system/models.py +129 -0
- metaxy/metadata_store/system/storage.py +957 -0
- metaxy/metadata_store/types.py +10 -0
- metaxy/metadata_store/utils.py +104 -0
- metaxy/metadata_store/warnings.py +36 -0
- metaxy/migrations/__init__.py +32 -0
- metaxy/migrations/detector.py +291 -0
- metaxy/migrations/executor.py +516 -0
- metaxy/migrations/generator.py +319 -0
- metaxy/migrations/loader.py +231 -0
- metaxy/migrations/models.py +528 -0
- metaxy/migrations/ops.py +447 -0
- metaxy/models/__init__.py +0 -0
- metaxy/models/bases.py +12 -0
- metaxy/models/constants.py +139 -0
- metaxy/models/feature.py +1335 -0
- metaxy/models/feature_spec.py +338 -0
- metaxy/models/field.py +263 -0
- metaxy/models/fields_mapping.py +307 -0
- metaxy/models/filter_expression.py +297 -0
- metaxy/models/lineage.py +285 -0
- metaxy/models/plan.py +232 -0
- metaxy/models/types.py +475 -0
- metaxy/py.typed +0 -0
- metaxy/utils/__init__.py +1 -0
- metaxy/utils/constants.py +2 -0
- metaxy/utils/exceptions.py +23 -0
- metaxy/utils/hashing.py +230 -0
- metaxy/versioning/__init__.py +31 -0
- metaxy/versioning/engine.py +656 -0
- metaxy/versioning/feature_dep_transformer.py +151 -0
- metaxy/versioning/ibis.py +249 -0
- metaxy/versioning/lineage_handler.py +205 -0
- metaxy/versioning/polars.py +189 -0
- metaxy/versioning/renamed_df.py +35 -0
- metaxy/versioning/types.py +63 -0
- metaxy-0.0.1.dev3.dist-info/METADATA +96 -0
- metaxy-0.0.1.dev3.dist-info/RECORD +111 -0
- metaxy-0.0.1.dev3.dist-info/WHEEL +4 -0
- metaxy-0.0.1.dev3.dist-info/entry_points.txt +4 -0
metaxy/cli/graph_diff.py
ADDED
|
@@ -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
|
+
)
|