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.
- metaxy/__init__.py +61 -0
- metaxy/_testing.py +542 -0
- metaxy/_utils.py +16 -0
- metaxy/_version.py +1 -0
- metaxy/cli/app.py +76 -0
- metaxy/cli/context.py +71 -0
- metaxy/cli/graph.py +576 -0
- metaxy/cli/graph_diff.py +290 -0
- metaxy/cli/list.py +42 -0
- metaxy/cli/metadata.py +271 -0
- metaxy/cli/migrations.py +862 -0
- metaxy/cli/push.py +55 -0
- metaxy/config.py +450 -0
- metaxy/data_versioning/__init__.py +24 -0
- metaxy/data_versioning/calculators/__init__.py +13 -0
- metaxy/data_versioning/calculators/base.py +97 -0
- metaxy/data_versioning/calculators/duckdb.py +186 -0
- metaxy/data_versioning/calculators/ibis.py +225 -0
- metaxy/data_versioning/calculators/polars.py +135 -0
- metaxy/data_versioning/diff/__init__.py +15 -0
- metaxy/data_versioning/diff/base.py +150 -0
- metaxy/data_versioning/diff/narwhals.py +108 -0
- metaxy/data_versioning/hash_algorithms.py +19 -0
- metaxy/data_versioning/joiners/__init__.py +9 -0
- metaxy/data_versioning/joiners/base.py +70 -0
- metaxy/data_versioning/joiners/narwhals.py +235 -0
- metaxy/entrypoints.py +309 -0
- metaxy/ext/__init__.py +1 -0
- metaxy/ext/alembic.py +326 -0
- metaxy/ext/sqlmodel.py +172 -0
- metaxy/ext/sqlmodel_system_tables.py +139 -0
- metaxy/graph/__init__.py +21 -0
- metaxy/graph/diff/__init__.py +21 -0
- metaxy/graph/diff/diff_models.py +399 -0
- metaxy/graph/diff/differ.py +740 -0
- metaxy/graph/diff/models.py +418 -0
- metaxy/graph/diff/rendering/__init__.py +18 -0
- metaxy/graph/diff/rendering/base.py +274 -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 +320 -0
- metaxy/graph/diff/rendering/rich.py +165 -0
- metaxy/graph/diff/rendering/theme.py +48 -0
- metaxy/graph/diff/traversal.py +247 -0
- metaxy/graph/utils.py +58 -0
- metaxy/metadata_store/__init__.py +31 -0
- metaxy/metadata_store/_protocols.py +38 -0
- metaxy/metadata_store/base.py +1676 -0
- metaxy/metadata_store/clickhouse.py +161 -0
- metaxy/metadata_store/duckdb.py +167 -0
- metaxy/metadata_store/exceptions.py +43 -0
- metaxy/metadata_store/ibis.py +451 -0
- metaxy/metadata_store/memory.py +228 -0
- metaxy/metadata_store/sqlite.py +187 -0
- metaxy/metadata_store/system_tables.py +257 -0
- metaxy/migrations/__init__.py +34 -0
- metaxy/migrations/detector.py +153 -0
- metaxy/migrations/executor.py +208 -0
- metaxy/migrations/loader.py +260 -0
- metaxy/migrations/models.py +718 -0
- metaxy/migrations/ops.py +390 -0
- metaxy/models/__init__.py +0 -0
- metaxy/models/bases.py +6 -0
- metaxy/models/constants.py +24 -0
- metaxy/models/feature.py +665 -0
- metaxy/models/feature_spec.py +105 -0
- metaxy/models/field.py +25 -0
- metaxy/models/plan.py +155 -0
- metaxy/models/types.py +157 -0
- metaxy/py.typed +0 -0
- metaxy-0.0.0.dist-info/METADATA +247 -0
- metaxy-0.0.0.dist-info/RECORD +75 -0
- metaxy-0.0.0.dist-info/WHEEL +4 -0
- metaxy-0.0.0.dist-info/entry_points.txt +3 -0
metaxy/entrypoints.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Entrypoint discovery and loading for Metaxy features.
|
|
2
|
+
|
|
3
|
+
This module provides functionality to automatically discover and load Feature
|
|
4
|
+
classes from modules, supporting both:
|
|
5
|
+
- Config-based entrypoints (list of module paths)
|
|
6
|
+
- Environment-based entrypoints (environment variables starting with METAXY_ENTRYPOINT)
|
|
7
|
+
- Package-based entrypoints (via importlib.metadata)
|
|
8
|
+
|
|
9
|
+
Features are automatically registered to the active FeatureGraph when their
|
|
10
|
+
containing modules are imported (via the Feature metaclass).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import importlib
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from metaxy.models.feature import FeatureGraph
|
|
20
|
+
|
|
21
|
+
# Conditional import for Python 3.10+ compatibility
|
|
22
|
+
from importlib.metadata import entry_points # type: ignore[import-not-found]
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# Default entry point group name for package-based discovery
|
|
27
|
+
DEFAULT_ENTRY_POINT_GROUP = "metaxy.features"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EntrypointLoadError(Exception):
|
|
31
|
+
"""Raised when an entrypoint fails to load."""
|
|
32
|
+
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_module_entrypoint(
|
|
37
|
+
module_path: str,
|
|
38
|
+
*,
|
|
39
|
+
graph: "FeatureGraph | None" = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Load a single module entrypoint.
|
|
42
|
+
|
|
43
|
+
Imports the specified module, which should contain Feature class definitions.
|
|
44
|
+
Features will be automatically registered to the active graph via the
|
|
45
|
+
Feature metaclass.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
module_path: Fully qualified module path (e.g., "myapp.features.video")
|
|
49
|
+
graph: Target graph. If None, uses FeatureGraph.get_active()
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
EntrypointLoadError: If module import fails
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
>>> from metaxy.entrypoints import load_module_entrypoint
|
|
56
|
+
>>> load_module_entrypoint("myapp.features.core")
|
|
57
|
+
>>> # Features from myapp.features.core are now registered
|
|
58
|
+
"""
|
|
59
|
+
from metaxy.models.feature import FeatureGraph
|
|
60
|
+
|
|
61
|
+
target_graph = graph or FeatureGraph.get_active()
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
# Set graph as active during import so Features register to it
|
|
65
|
+
with target_graph.use():
|
|
66
|
+
logger.debug(f"Loading entrypoint module: {module_path}")
|
|
67
|
+
importlib.import_module(module_path)
|
|
68
|
+
logger.info(f"Successfully loaded entrypoint: {module_path}")
|
|
69
|
+
except ImportError as e:
|
|
70
|
+
raise EntrypointLoadError(
|
|
71
|
+
f"Failed to import entrypoint module '{module_path}': {e}"
|
|
72
|
+
) from e
|
|
73
|
+
except Exception as e:
|
|
74
|
+
raise EntrypointLoadError(
|
|
75
|
+
f"Error loading entrypoint module '{module_path}': {e}"
|
|
76
|
+
) from e
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_entrypoints(
|
|
80
|
+
entrypoints: list[str],
|
|
81
|
+
*,
|
|
82
|
+
graph: "FeatureGraph | None" = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Load multiple module entrypoints from a list.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
entrypoints: List of module paths to import
|
|
88
|
+
graph: Target graph. If None, uses FeatureGraph.get_active()
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
EntrypointLoadError: If any module import fails
|
|
92
|
+
|
|
93
|
+
Example:
|
|
94
|
+
>>> from metaxy.entrypoints import load_config_entrypoints
|
|
95
|
+
>>> load_config_entrypoints([
|
|
96
|
+
... "myapp.features.video",
|
|
97
|
+
... "myapp.features.audio",
|
|
98
|
+
... "myapp.features.text"
|
|
99
|
+
... ])
|
|
100
|
+
"""
|
|
101
|
+
from metaxy.models.feature import FeatureGraph
|
|
102
|
+
|
|
103
|
+
target_graph = graph or FeatureGraph.get_active()
|
|
104
|
+
|
|
105
|
+
logger.info(f"Loading {len(entrypoints)} entrypoints")
|
|
106
|
+
|
|
107
|
+
for module_path in entrypoints:
|
|
108
|
+
load_module_entrypoint(module_path, graph=target_graph)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def load_package_entrypoints(
|
|
112
|
+
group: str = DEFAULT_ENTRY_POINT_GROUP,
|
|
113
|
+
*,
|
|
114
|
+
graph: "FeatureGraph | None" = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Load entrypoints from installed packages using importlib.metadata.
|
|
117
|
+
|
|
118
|
+
Discovers and loads all entry points registered in the specified group.
|
|
119
|
+
This is the package-based entrypoint mechanism using standard Python
|
|
120
|
+
packaging infrastructure.
|
|
121
|
+
|
|
122
|
+
Packages declare entrypoints in their pyproject.toml:
|
|
123
|
+
[project.entry-points."metaxy.features"]
|
|
124
|
+
myfeature = "mypackage.features.module"
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
group: Entry point group name (default: "metaxy.features")
|
|
128
|
+
graph: Target graph. If None, uses FeatureGraph.get_active()
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
EntrypointLoadError: If any entrypoint fails to load
|
|
132
|
+
|
|
133
|
+
Example:
|
|
134
|
+
>>> from metaxy.entrypoints import load_package_entrypoints
|
|
135
|
+
>>> # Discover and load all installed plugins
|
|
136
|
+
>>> load_package_entrypoints()
|
|
137
|
+
"""
|
|
138
|
+
from metaxy.models.feature import FeatureGraph
|
|
139
|
+
|
|
140
|
+
target_graph = graph or FeatureGraph.get_active()
|
|
141
|
+
|
|
142
|
+
logger.info(f"Discovering package entrypoints in group: {group}")
|
|
143
|
+
|
|
144
|
+
# Discover entry points
|
|
145
|
+
# Note: Python 3.10+ returns SelectableGroups, 3.9 returns dict
|
|
146
|
+
discovered = entry_points()
|
|
147
|
+
|
|
148
|
+
# Handle different return types across Python versions
|
|
149
|
+
if hasattr(discovered, "select"):
|
|
150
|
+
# Python 3.10+: SelectableGroups with select() method
|
|
151
|
+
eps = discovered.select(group=group)
|
|
152
|
+
else:
|
|
153
|
+
# Python 3.9: dict-like interface
|
|
154
|
+
eps = discovered.get(group, [])
|
|
155
|
+
|
|
156
|
+
eps_list = list(eps)
|
|
157
|
+
|
|
158
|
+
if not eps_list:
|
|
159
|
+
logger.debug(f"No package entrypoints found in group: {group}")
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
logger.info(f"Found {len(eps_list)} package entrypoints in group: {group}")
|
|
163
|
+
|
|
164
|
+
for ep in eps_list:
|
|
165
|
+
try:
|
|
166
|
+
logger.debug(f"Loading package entrypoint: {ep.name} = {ep.value}")
|
|
167
|
+
# Load the entry point (imports the module)
|
|
168
|
+
with target_graph.use():
|
|
169
|
+
ep.load()
|
|
170
|
+
logger.info(f"Successfully loaded package entrypoint: {ep.name}")
|
|
171
|
+
except Exception as e:
|
|
172
|
+
raise EntrypointLoadError(
|
|
173
|
+
f"Failed to load package entrypoint '{ep.name}' ({ep.value}): {e}"
|
|
174
|
+
) from e
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def load_env_entrypoints() -> None:
|
|
178
|
+
"""Load entrypoints from environment variables.
|
|
179
|
+
|
|
180
|
+
Discovers and loads all entry points from environment variables matching
|
|
181
|
+
the pattern METAXY_ENTRYPOINT*. Each variable should contain a
|
|
182
|
+
comma-separated list of module paths.
|
|
183
|
+
|
|
184
|
+
Environment variables:
|
|
185
|
+
METAXY_ENTRYPOINT="myapp.features.core,myapp.features.extra"
|
|
186
|
+
METAXY_ENTRYPOINT_PLUGINS="plugin1.features,plugin2.features"
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
graph: Target graph. If None, uses FeatureGraph.get_active()
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
EntrypointLoadError: If any entrypoint fails to load
|
|
193
|
+
|
|
194
|
+
Example:
|
|
195
|
+
>>> import os
|
|
196
|
+
>>> os.environ["METAXY_ENTRYPOINT"] = "myapp.features.core"
|
|
197
|
+
>>> from metaxy.entrypoints import load_env_entrypoints
|
|
198
|
+
>>> load_env_entrypoints()
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
logger.info("Discovering environment-based entrypoints (METAXY_ENTRYPOINT*)")
|
|
202
|
+
|
|
203
|
+
# Find all environment variables matching METAXY_ENTRYPOINT*
|
|
204
|
+
env_vars = {
|
|
205
|
+
key: value
|
|
206
|
+
for key, value in os.environ.items()
|
|
207
|
+
if key.startswith("METAXY_ENTRYPOINT")
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if not env_vars:
|
|
211
|
+
logger.debug("No environment entrypoints found (METAXY_ENTRYPOINT* not set)")
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
logger.info(f"Found {len(env_vars)} METAXY_ENTRYPOINT* environment variable(s)")
|
|
215
|
+
|
|
216
|
+
# Collect all module paths from all matching env vars
|
|
217
|
+
all_module_paths = []
|
|
218
|
+
for env_var, value in sorted(env_vars.items()):
|
|
219
|
+
logger.debug(f"Processing {env_var}={value}")
|
|
220
|
+
# Split by comma and strip whitespace
|
|
221
|
+
module_paths = [path.strip() for path in value.split(",") if path.strip()]
|
|
222
|
+
all_module_paths.extend(module_paths)
|
|
223
|
+
|
|
224
|
+
if not all_module_paths:
|
|
225
|
+
logger.debug("No module paths found in METAXY_ENTRYPOINT* variables")
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
logger.info(
|
|
229
|
+
f"Loading {len(all_module_paths)} module(s) from environment entrypoints"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Load each module path
|
|
233
|
+
for module_path in all_module_paths:
|
|
234
|
+
load_module_entrypoint(module_path)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def load_features(
|
|
238
|
+
entrypoints: list[str] | None = None,
|
|
239
|
+
package_entrypoint_group: str = DEFAULT_ENTRY_POINT_GROUP,
|
|
240
|
+
*,
|
|
241
|
+
load_config: bool = True,
|
|
242
|
+
load_packages: bool = True,
|
|
243
|
+
load_env: bool = True,
|
|
244
|
+
) -> "FeatureGraph":
|
|
245
|
+
"""Discover and load all entrypoints from config, packages, and environment.
|
|
246
|
+
|
|
247
|
+
This is the main entry point for loading features. It combines config-based,
|
|
248
|
+
package-based, and environment-based entrypoint discovery.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
entrypoints: List of module paths (optional)
|
|
252
|
+
package_entrypoint_group: Entry point group for package discovery
|
|
253
|
+
load_config: Whether to load config-based entrypoints (default: True)
|
|
254
|
+
load_packages: Whether to load package-based entrypoints (default: True)
|
|
255
|
+
load_env: Whether to load environment-based entrypoints (default: True)
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
The graph that was populated (useful for testing/inspection)
|
|
259
|
+
|
|
260
|
+
Raises:
|
|
261
|
+
EntrypointLoadError: If any entrypoint fails to load
|
|
262
|
+
|
|
263
|
+
Example:
|
|
264
|
+
>>> from metaxy.entrypoints import load_features
|
|
265
|
+
>>>
|
|
266
|
+
>>> # Load from all sources
|
|
267
|
+
>>> graph = load_features(
|
|
268
|
+
... entrypoints=["myapp.features.core"],
|
|
269
|
+
... load_packages=True,
|
|
270
|
+
... load_env=True
|
|
271
|
+
... )
|
|
272
|
+
>>>
|
|
273
|
+
>>> # Load only from config
|
|
274
|
+
>>> graph = load_features(
|
|
275
|
+
... entrypoints=["myapp.features.core"],
|
|
276
|
+
... load_packages=False,
|
|
277
|
+
... load_env=False
|
|
278
|
+
... )
|
|
279
|
+
"""
|
|
280
|
+
from metaxy.config import MetaxyConfig
|
|
281
|
+
from metaxy.models.feature import FeatureGraph
|
|
282
|
+
|
|
283
|
+
target_graph = FeatureGraph.get_active()
|
|
284
|
+
|
|
285
|
+
logger.info("Starting entrypoint discovery and loading")
|
|
286
|
+
|
|
287
|
+
# Load explicit entrypoints
|
|
288
|
+
if entrypoints:
|
|
289
|
+
load_entrypoints(entrypoints)
|
|
290
|
+
|
|
291
|
+
# Load config-based entrypoints
|
|
292
|
+
if load_config:
|
|
293
|
+
config = MetaxyConfig.load(search_parents=True)
|
|
294
|
+
load_entrypoints(config.entrypoints)
|
|
295
|
+
|
|
296
|
+
# Load package-based entrypoints
|
|
297
|
+
if load_packages:
|
|
298
|
+
load_package_entrypoints(package_entrypoint_group)
|
|
299
|
+
|
|
300
|
+
# Load environment-based entrypoints
|
|
301
|
+
if load_env:
|
|
302
|
+
load_env_entrypoints()
|
|
303
|
+
|
|
304
|
+
num_features = len(target_graph.features_by_key)
|
|
305
|
+
logger.info(
|
|
306
|
+
f"Entrypoint loading complete. Registry now contains {num_features} features."
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
return target_graph
|
metaxy/ext/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Integrations with third-party software."""
|
metaxy/ext/alembic.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Alembic integration helpers for metaxy.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for including metaxy system tables
|
|
4
|
+
in Alembic migrations when using SQLModel integration.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from sqlalchemy import MetaData
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_metaxy_metadata() -> MetaData:
|
|
16
|
+
"""Get SQLAlchemy metadata containing metaxy system tables.
|
|
17
|
+
|
|
18
|
+
This function returns the metadata object that should be included
|
|
19
|
+
in your Alembic configuration to manage metaxy system tables.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
SQLAlchemy MetaData containing metaxy system table definitions
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
ImportError: If SQLModel is not installed
|
|
26
|
+
|
|
27
|
+
Example:
|
|
28
|
+
>>> # In your alembic/env.py:
|
|
29
|
+
>>> from metaxy.ext.alembic import get_metaxy_metadata
|
|
30
|
+
>>> from sqlalchemy import MetaData
|
|
31
|
+
>>>
|
|
32
|
+
>>> # Combine your app metadata with metaxy metadata
|
|
33
|
+
>>> target_metadata = MetaData()
|
|
34
|
+
>>> target_metadata.reflect(get_metaxy_metadata())
|
|
35
|
+
>>> target_metadata.reflect(my_app.metadata)
|
|
36
|
+
"""
|
|
37
|
+
from metaxy.ext.sqlmodel_system_tables import get_system_metadata
|
|
38
|
+
|
|
39
|
+
return get_system_metadata()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def include_metaxy_tables(target_metadata: MetaData) -> MetaData:
|
|
43
|
+
"""Include metaxy system tables in existing metadata.
|
|
44
|
+
|
|
45
|
+
This is a convenience function that adds metaxy system tables
|
|
46
|
+
to your existing SQLAlchemy metadata object.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
target_metadata: Your application's metadata object
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The same metadata object with metaxy tables added
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
>>> # In your alembic/env.py:
|
|
56
|
+
>>> from metaxy.ext.alembic import include_metaxy_tables
|
|
57
|
+
>>> from myapp.models import metadata
|
|
58
|
+
>>>
|
|
59
|
+
>>> # Add metaxy tables to your metadata
|
|
60
|
+
>>> target_metadata = include_metaxy_tables(metadata)
|
|
61
|
+
"""
|
|
62
|
+
metaxy_metadata = get_metaxy_metadata()
|
|
63
|
+
|
|
64
|
+
# Copy tables from metaxy metadata to target metadata
|
|
65
|
+
for table_name, table in metaxy_metadata.tables.items():
|
|
66
|
+
if table_name not in target_metadata.tables:
|
|
67
|
+
# Create a new table with the same definition in target metadata
|
|
68
|
+
table.to_metadata(target_metadata)
|
|
69
|
+
|
|
70
|
+
return target_metadata
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def check_sqlmodel_enabled() -> bool:
|
|
74
|
+
"""Check if SQLModel integration is enabled in the configuration.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
True if SQLModel integration is enabled, False otherwise
|
|
78
|
+
|
|
79
|
+
Example:
|
|
80
|
+
>>> from metaxy.ext.alembic import check_sqlmodel_enabled
|
|
81
|
+
>>> if check_sqlmodel_enabled():
|
|
82
|
+
... # SQLModel is enabled, include metaxy tables
|
|
83
|
+
... include_metaxy_tables(metadata)
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
from metaxy.config import MetaxyConfig
|
|
87
|
+
|
|
88
|
+
config = MetaxyConfig.get()
|
|
89
|
+
# Check if SQLModel configuration exists and is enabled
|
|
90
|
+
# The infer_db_table_names setting indicates SQLModel is being used
|
|
91
|
+
return hasattr(config.ext, "sqlmodel")
|
|
92
|
+
except Exception:
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def generate_alembic_env_template() -> str:
|
|
97
|
+
"""Generate a template for Alembic env.py with metaxy integration.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
String containing example Alembic env.py configuration
|
|
101
|
+
|
|
102
|
+
Example:
|
|
103
|
+
>>> from metaxy.ext.alembic import generate_alembic_env_template
|
|
104
|
+
>>> template = generate_alembic_env_template()
|
|
105
|
+
>>> print(template) # Shows example configuration
|
|
106
|
+
"""
|
|
107
|
+
return '''"""Alembic env.py template with metaxy integration.
|
|
108
|
+
|
|
109
|
+
This template shows how to include metaxy system tables in your
|
|
110
|
+
Alembic migrations when using SQLModel integration.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
from logging.config import fileConfig
|
|
114
|
+
|
|
115
|
+
from sqlalchemy import engine_from_config
|
|
116
|
+
from sqlalchemy import pool
|
|
117
|
+
from alembic import context
|
|
118
|
+
|
|
119
|
+
# Import your models here
|
|
120
|
+
# from myapp import models
|
|
121
|
+
|
|
122
|
+
# Metaxy integration
|
|
123
|
+
from metaxy.ext.alembic import get_metaxy_metadata, include_metaxy_tables
|
|
124
|
+
from metaxy.config import MetaxyConfig
|
|
125
|
+
|
|
126
|
+
# this is the Alembic Config object
|
|
127
|
+
config = context.config
|
|
128
|
+
|
|
129
|
+
# Interpret the config file for Python logging
|
|
130
|
+
if config.config_file_name is not None:
|
|
131
|
+
fileConfig(config.config_file_name)
|
|
132
|
+
|
|
133
|
+
# Load metaxy configuration
|
|
134
|
+
metaxy_config = MetaxyConfig.load()
|
|
135
|
+
|
|
136
|
+
# Add your model's MetaData object here for 'autogenerate' support
|
|
137
|
+
# from myapp import mymodel
|
|
138
|
+
# target_metadata = mymodel.Base.metadata
|
|
139
|
+
|
|
140
|
+
# Option 1: Include metaxy tables in your existing metadata
|
|
141
|
+
# from myapp.models import metadata
|
|
142
|
+
# target_metadata = include_metaxy_tables(metadata)
|
|
143
|
+
|
|
144
|
+
# Option 2: Combine multiple metadata objects
|
|
145
|
+
from sqlalchemy import MetaData
|
|
146
|
+
target_metadata = MetaData()
|
|
147
|
+
|
|
148
|
+
# Add metaxy system tables (if using SQLModel integration)
|
|
149
|
+
if metaxy_config.ext.sqlmodel:
|
|
150
|
+
metaxy_metadata = get_metaxy_metadata()
|
|
151
|
+
for table_name, table in metaxy_metadata.tables.items():
|
|
152
|
+
table.to_metadata(target_metadata)
|
|
153
|
+
|
|
154
|
+
# Add your application tables
|
|
155
|
+
# for table_name, table in myapp_metadata.tables.items():
|
|
156
|
+
# table.to_metadata(target_metadata)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def run_migrations_offline() -> None:
|
|
160
|
+
"""Run migrations in 'offline' mode.
|
|
161
|
+
|
|
162
|
+
This configures the context with just a URL
|
|
163
|
+
and not an Engine, though an Engine is acceptable
|
|
164
|
+
here as well. By skipping the Engine creation
|
|
165
|
+
we don't even need a DBAPI to be available.
|
|
166
|
+
|
|
167
|
+
Calls to context.execute() here emit the given string to the
|
|
168
|
+
script output.
|
|
169
|
+
"""
|
|
170
|
+
url = config.get_main_option("sqlalchemy.url")
|
|
171
|
+
context.configure(
|
|
172
|
+
url=url,
|
|
173
|
+
target_metadata=target_metadata,
|
|
174
|
+
literal_binds=True,
|
|
175
|
+
dialect_opts={"paramstyle": "named"},
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
with context.begin_transaction():
|
|
179
|
+
context.run_migrations()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def run_migrations_online() -> None:
|
|
183
|
+
"""Run migrations in 'online' mode.
|
|
184
|
+
|
|
185
|
+
In this scenario we need to create an Engine
|
|
186
|
+
and associate a connection with the context.
|
|
187
|
+
"""
|
|
188
|
+
connectable = engine_from_config(
|
|
189
|
+
config.get_section(config.config_ini_section, {}),
|
|
190
|
+
prefix="sqlalchemy.",
|
|
191
|
+
poolclass=pool.NullPool,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
with connectable.connect() as connection:
|
|
195
|
+
context.configure(connection=connection, target_metadata=target_metadata)
|
|
196
|
+
|
|
197
|
+
with context.begin_transaction():
|
|
198
|
+
context.run_migrations()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
if context.is_offline_mode():
|
|
202
|
+
run_migrations_offline()
|
|
203
|
+
else:
|
|
204
|
+
run_migrations_online()
|
|
205
|
+
'''
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def create_initial_migration_script() -> str:
|
|
209
|
+
"""Generate an Alembic migration script for creating metaxy system tables.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
String containing example migration script
|
|
213
|
+
|
|
214
|
+
Example:
|
|
215
|
+
>>> from metaxy.ext.alembic import create_initial_migration_script
|
|
216
|
+
>>> script = create_initial_migration_script()
|
|
217
|
+
>>> # Save to alembic/versions/001_create_metaxy_tables.py
|
|
218
|
+
"""
|
|
219
|
+
return '''"""Create metaxy system tables.
|
|
220
|
+
|
|
221
|
+
Revision ID: create_metaxy_system_tables
|
|
222
|
+
Revises:
|
|
223
|
+
Create Date: 2024-01-01 00:00:00.000000
|
|
224
|
+
|
|
225
|
+
"""
|
|
226
|
+
from typing import Sequence, Union
|
|
227
|
+
|
|
228
|
+
from alembic import op
|
|
229
|
+
import sqlalchemy as sa
|
|
230
|
+
from sqlalchemy.dialects import postgresql
|
|
231
|
+
|
|
232
|
+
# revision identifiers, used by Alembic.
|
|
233
|
+
revision: str = 'create_metaxy_system_tables'
|
|
234
|
+
down_revision: Union[str, None] = None
|
|
235
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
|
236
|
+
depends_on: Union[str, Sequence[str], None] = None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def upgrade() -> None:
|
|
240
|
+
"""Create metaxy system tables."""
|
|
241
|
+
|
|
242
|
+
# Create feature_versions table
|
|
243
|
+
op.create_table(
|
|
244
|
+
'metaxy-system__feature_versions',
|
|
245
|
+
sa.Column('feature_key', sa.String(), nullable=False),
|
|
246
|
+
sa.Column('snapshot_version', sa.String(), nullable=False),
|
|
247
|
+
sa.Column('feature_version', sa.String(), nullable=False),
|
|
248
|
+
sa.Column('recorded_at', sa.DateTime(), nullable=False),
|
|
249
|
+
sa.Column('feature_spec', sa.String(), nullable=False),
|
|
250
|
+
sa.Column('feature_class_path', sa.String(), nullable=False),
|
|
251
|
+
sa.PrimaryKeyConstraint('feature_key', 'snapshot_version')
|
|
252
|
+
)
|
|
253
|
+
op.create_index('idx_feature_versions_recorded', 'metaxy-system__feature_versions', ['recorded_at'])
|
|
254
|
+
op.create_index('idx_feature_versions_lookup', 'metaxy-system__feature_versions', ['feature_key', 'feature_version'])
|
|
255
|
+
|
|
256
|
+
# Create migration_events table
|
|
257
|
+
op.create_table(
|
|
258
|
+
'metaxy-system__migration_events',
|
|
259
|
+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
260
|
+
sa.Column('migration_id', sa.String(), nullable=False),
|
|
261
|
+
sa.Column('event_type', sa.String(), nullable=False),
|
|
262
|
+
sa.Column('timestamp', sa.DateTime(), nullable=False),
|
|
263
|
+
sa.Column('feature_key', sa.String(), nullable=False),
|
|
264
|
+
sa.Column('rows_affected', sa.Integer(), nullable=False),
|
|
265
|
+
sa.Column('error_message', sa.String(), nullable=False),
|
|
266
|
+
sa.PrimaryKeyConstraint('id')
|
|
267
|
+
)
|
|
268
|
+
op.create_index('idx_migration_events_lookup', 'metaxy-system__migration_events', ['migration_id', 'event_type'])
|
|
269
|
+
op.create_index('idx_migration_events_feature', 'metaxy-system__migration_events', ['migration_id', 'feature_key'])
|
|
270
|
+
op.create_index(op.f('ix_metaxy-system__migration_events_migration_id'), 'metaxy-system__migration_events', ['migration_id'])
|
|
271
|
+
op.create_index(op.f('ix_metaxy-system__migration_events_timestamp'), 'metaxy-system__migration_events', ['timestamp'])
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def downgrade() -> None:
|
|
275
|
+
"""Drop metaxy system tables."""
|
|
276
|
+
op.drop_index(op.f('ix_metaxy-system__migration_events_timestamp'), table_name='metaxy-system__migration_events')
|
|
277
|
+
op.drop_index(op.f('ix_metaxy-system__migration_events_migration_id'), table_name='metaxy-system__migration_events')
|
|
278
|
+
op.drop_index('idx_migration_events_feature', table_name='metaxy-system__migration_events')
|
|
279
|
+
op.drop_index('idx_migration_events_lookup', table_name='metaxy-system__migration_events')
|
|
280
|
+
op.drop_table('metaxy-system__migration_events')
|
|
281
|
+
|
|
282
|
+
op.drop_index('idx_feature_versions_lookup', table_name='metaxy-system__feature_versions')
|
|
283
|
+
op.drop_index('idx_feature_versions_recorded', table_name='metaxy-system__feature_versions')
|
|
284
|
+
op.drop_table('metaxy-system__feature_versions')
|
|
285
|
+
'''
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def get_table_creation_sql(dialect: str = "postgresql") -> dict[str, str]:
|
|
289
|
+
"""Get SQL statements for creating metaxy system tables.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
dialect: SQL dialect ('postgresql', 'mysql', 'sqlite', etc.)
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
Dictionary mapping table name to CREATE TABLE SQL statement
|
|
296
|
+
|
|
297
|
+
Example:
|
|
298
|
+
>>> from metaxy.ext.alembic import get_table_creation_sql
|
|
299
|
+
>>> sql_statements = get_table_creation_sql("postgresql")
|
|
300
|
+
>>> print(sql_statements["feature_versions"])
|
|
301
|
+
"""
|
|
302
|
+
from sqlalchemy import create_engine
|
|
303
|
+
from sqlalchemy.schema import CreateTable
|
|
304
|
+
|
|
305
|
+
# Get metaxy metadata
|
|
306
|
+
metadata = get_metaxy_metadata()
|
|
307
|
+
|
|
308
|
+
# Create a mock engine for the specified dialect
|
|
309
|
+
if dialect == "postgresql":
|
|
310
|
+
engine_url = "postgresql:///"
|
|
311
|
+
elif dialect == "mysql":
|
|
312
|
+
engine_url = "mysql:///"
|
|
313
|
+
elif dialect == "sqlite":
|
|
314
|
+
engine_url = "sqlite:///"
|
|
315
|
+
else:
|
|
316
|
+
engine_url = f"{dialect}:///"
|
|
317
|
+
|
|
318
|
+
engine = create_engine(engine_url)
|
|
319
|
+
|
|
320
|
+
# Generate CREATE TABLE statements
|
|
321
|
+
sql_statements = {}
|
|
322
|
+
for table_name, table in metadata.tables.items():
|
|
323
|
+
create_statement = CreateTable(table).compile(engine)
|
|
324
|
+
sql_statements[table_name] = str(create_statement)
|
|
325
|
+
|
|
326
|
+
return sql_statements
|