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/config.py
ADDED
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
"""Configuration system for Metaxy using pydantic-settings."""
|
|
2
|
+
# pyright: reportImportCycles=false
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import warnings
|
|
7
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from contextvars import ContextVar
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
|
|
12
|
+
|
|
13
|
+
import tomli
|
|
14
|
+
from pydantic import Field as PydanticField
|
|
15
|
+
from pydantic import field_validator
|
|
16
|
+
from pydantic.types import ImportString
|
|
17
|
+
from pydantic_settings import (
|
|
18
|
+
BaseSettings,
|
|
19
|
+
PydanticBaseSettingsSource,
|
|
20
|
+
SettingsConfigDict,
|
|
21
|
+
)
|
|
22
|
+
from typing_extensions import Self
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from metaxy.metadata_store.base import (
|
|
26
|
+
MetadataStore, # pyright: ignore[reportImportCycles]
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
T = TypeVar("T")
|
|
30
|
+
|
|
31
|
+
# Pattern for ${VAR} or ${VAR:-default} syntax
|
|
32
|
+
_ENV_VAR_PATTERN = re.compile(r"\$\{([^}:]+)(?::-([^}]*))?\}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _expand_env_vars(value: Any) -> Any:
|
|
36
|
+
"""Recursively expand environment variables in config values.
|
|
37
|
+
|
|
38
|
+
Supports:
|
|
39
|
+
- ${VAR} - substitutes with environment variable value, empty string if not set
|
|
40
|
+
- ${VAR:-default} - substitutes with environment variable value, or default if not set
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
value: The value to expand (can be string, dict, list, or other)
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The value with environment variables expanded
|
|
47
|
+
"""
|
|
48
|
+
if isinstance(value, str):
|
|
49
|
+
|
|
50
|
+
def replace_match(match: re.Match[str]) -> str:
|
|
51
|
+
var_name = match.group(1)
|
|
52
|
+
default = match.group(2) # None if no default specified
|
|
53
|
+
env_value = os.environ.get(var_name)
|
|
54
|
+
if env_value is not None:
|
|
55
|
+
return env_value
|
|
56
|
+
elif default is not None:
|
|
57
|
+
return default
|
|
58
|
+
else:
|
|
59
|
+
return ""
|
|
60
|
+
|
|
61
|
+
return _ENV_VAR_PATTERN.sub(replace_match, value)
|
|
62
|
+
elif isinstance(value, Mapping):
|
|
63
|
+
return {k: _expand_env_vars(v) for k, v in value.items()}
|
|
64
|
+
elif isinstance(value, Sequence):
|
|
65
|
+
return [_expand_env_vars(item) for item in value]
|
|
66
|
+
else:
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TomlConfigSettingsSource(PydanticBaseSettingsSource):
|
|
71
|
+
"""Custom settings source for TOML configuration files.
|
|
72
|
+
|
|
73
|
+
Auto-discovers configuration in this order:
|
|
74
|
+
1. Explicit file path if provided
|
|
75
|
+
2. metaxy.toml in current directory (preferred)
|
|
76
|
+
3. pyproject.toml [tool.metaxy] section (fallback)
|
|
77
|
+
4. No config (returns empty dict)
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, settings_cls: type[BaseSettings], toml_file: Path | None = None):
|
|
81
|
+
super().__init__(settings_cls)
|
|
82
|
+
self.toml_file = toml_file or self._discover_config_file()
|
|
83
|
+
self.toml_data = self._load_toml()
|
|
84
|
+
|
|
85
|
+
def _discover_config_file(self) -> Path | None:
|
|
86
|
+
"""Auto-discover config file."""
|
|
87
|
+
# Prefer metaxy.toml
|
|
88
|
+
if Path("metaxy.toml").exists():
|
|
89
|
+
return Path("metaxy.toml")
|
|
90
|
+
|
|
91
|
+
# Fallback to pyproject.toml
|
|
92
|
+
if Path("pyproject.toml").exists():
|
|
93
|
+
return Path("pyproject.toml")
|
|
94
|
+
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
def _load_toml(self) -> dict[str, Any]:
|
|
98
|
+
"""Load TOML file and extract metaxy config.
|
|
99
|
+
|
|
100
|
+
Environment variables in the format ${VAR} or ${VAR:-default} are
|
|
101
|
+
expanded in string values.
|
|
102
|
+
"""
|
|
103
|
+
if self.toml_file is None:
|
|
104
|
+
return {}
|
|
105
|
+
|
|
106
|
+
with open(self.toml_file, "rb") as f:
|
|
107
|
+
data = tomli.load(f)
|
|
108
|
+
|
|
109
|
+
# Extract [tool.metaxy] from pyproject.toml or root from metaxy.toml
|
|
110
|
+
if self.toml_file.name == "pyproject.toml":
|
|
111
|
+
config = data.get("tool", {}).get("metaxy", {})
|
|
112
|
+
else:
|
|
113
|
+
config = data
|
|
114
|
+
|
|
115
|
+
# Expand environment variables in config values
|
|
116
|
+
return _expand_env_vars(config)
|
|
117
|
+
|
|
118
|
+
def get_field_value(self, field: Any, field_name: str) -> tuple[Any, str, bool]:
|
|
119
|
+
"""Get field value from TOML data."""
|
|
120
|
+
field_value = self.toml_data.get(field_name)
|
|
121
|
+
return field_value, field_name, False
|
|
122
|
+
|
|
123
|
+
def __call__(self) -> dict[str, Any]:
|
|
124
|
+
"""Return all settings from TOML."""
|
|
125
|
+
return self.toml_data
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class StoreConfig(BaseSettings):
|
|
129
|
+
"""Configuration for a single metadata store.
|
|
130
|
+
|
|
131
|
+
Example:
|
|
132
|
+
```py
|
|
133
|
+
config = StoreConfig(
|
|
134
|
+
type="metaxy_delta.DeltaMetadataStore",
|
|
135
|
+
config={
|
|
136
|
+
"root_path": "s3://bucket/metadata",
|
|
137
|
+
"region": "us-west-2",
|
|
138
|
+
"fallback_stores": ["prod"],
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
```
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
model_config = SettingsConfigDict(
|
|
145
|
+
extra="forbid", # Only type and config fields allowed
|
|
146
|
+
frozen=True,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
type: ImportString[Any] = PydanticField(
|
|
150
|
+
description="Full import path to metadata store class (e.g., 'metaxy.metadata_store.duckdb.DuckDBMetadataStore')",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
config: dict[str, Any] = PydanticField(
|
|
154
|
+
default_factory=dict,
|
|
155
|
+
description="Store-specific configuration parameters (kwargs for __init__). Includes fallback_stores, database paths, connection parameters, etc.",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class PluginConfig(BaseSettings):
|
|
160
|
+
"""Configuration for Metaxy plugins"""
|
|
161
|
+
|
|
162
|
+
model_config = SettingsConfigDict(frozen=True, extra="allow")
|
|
163
|
+
|
|
164
|
+
enable: bool = PydanticField(
|
|
165
|
+
default=False,
|
|
166
|
+
description="Whether to enable the plugin.",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
PluginConfigT = TypeVar("PluginConfigT", bound=PluginConfig)
|
|
171
|
+
|
|
172
|
+
# Context variable for storing the app context
|
|
173
|
+
_metaxy_config: ContextVar["MetaxyConfig | None"] = ContextVar(
|
|
174
|
+
"_metaxy_config", default=None
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
BUILTIN_PLUGINS = {
|
|
179
|
+
"sqlmodel": "metaxy.ext.sqlmodel",
|
|
180
|
+
"alembic": "metaxy.ext.alembic",
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
StoreTypeT = TypeVar("StoreTypeT", bound="MetadataStore")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class MetaxyConfig(BaseSettings):
|
|
188
|
+
"""Main Metaxy configuration.
|
|
189
|
+
|
|
190
|
+
Loads from (in order of precedence):
|
|
191
|
+
|
|
192
|
+
1. Init arguments
|
|
193
|
+
|
|
194
|
+
2. Environment variables (METAXY_*)
|
|
195
|
+
|
|
196
|
+
3. Config file (`metaxy.toml` or `[tool.metaxy]` in `pyproject.toml` )
|
|
197
|
+
|
|
198
|
+
Environment variables can be templated with `${MY_VAR:-default}` syntax.
|
|
199
|
+
|
|
200
|
+
Example: Accessing current configuration
|
|
201
|
+
```py
|
|
202
|
+
config = MetaxyConfig.load()
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
Example: Getting a configured metadata store
|
|
207
|
+
```py
|
|
208
|
+
store = config.get_store("prod")
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Example: Templating environment variables
|
|
212
|
+
```toml {title="metaxy.toml"}
|
|
213
|
+
[stores.branch.config]
|
|
214
|
+
root_path = "s3://my-bucket/${BRANCH_NAME}"
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
The default store is `"dev"`; `METAXY_STORE` can be used to override it.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
model_config = SettingsConfigDict(
|
|
221
|
+
env_prefix="METAXY_",
|
|
222
|
+
env_nested_delimiter="__",
|
|
223
|
+
frozen=True, # Make the config immutable
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
store: str = PydanticField(
|
|
227
|
+
default="dev",
|
|
228
|
+
description="Default metadata store to use",
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
stores: dict[str, StoreConfig] = PydanticField(
|
|
232
|
+
default_factory=dict,
|
|
233
|
+
description="Named store configurations",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
migrations_dir: str = PydanticField(
|
|
237
|
+
default=".metaxy/migrations",
|
|
238
|
+
description="Directory where migration files are stored",
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
entrypoints: list[str] = PydanticField(
|
|
242
|
+
default_factory=list,
|
|
243
|
+
description="List of Python module paths to load for feature discovery",
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
theme: str = PydanticField(
|
|
247
|
+
default="default",
|
|
248
|
+
description="Graph rendering theme for CLI visualization",
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
ext: dict[str, PluginConfig] = PydanticField(
|
|
252
|
+
default_factory=dict,
|
|
253
|
+
description="Configuration for Metaxy integrations with third-party tools",
|
|
254
|
+
frozen=False,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
hash_truncation_length: int | None = PydanticField(
|
|
258
|
+
default=None,
|
|
259
|
+
description="Truncate hash values to this length (minimum 8 characters).",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
auto_create_tables: bool = PydanticField(
|
|
263
|
+
default=False,
|
|
264
|
+
description="Auto-create tables when opening stores (development/testing only). WARNING: Do not use in production. Use proper database migration tools like Alembic.",
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
project: str = PydanticField(
|
|
268
|
+
default="default",
|
|
269
|
+
description="Project name for metadata isolation. Used to scope operations to enable multiple independent projects in a shared metadata store. Does not modify feature keys or table names. Project names must be valid alphanumeric strings with dashes, underscores, and cannot contain forward slashes (`/`) or double underscores (`__`)",
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def _load_plugins(self) -> None:
|
|
273
|
+
"""Load enabled plugins. Must be called after config is set."""
|
|
274
|
+
for name, module in BUILTIN_PLUGINS.items():
|
|
275
|
+
if name in self.ext and self.ext[name].enable:
|
|
276
|
+
try:
|
|
277
|
+
__import__(module)
|
|
278
|
+
except Exception as e:
|
|
279
|
+
raise ValueError(
|
|
280
|
+
f"Failed to load Metaxy plugin '{name}' (defined in \"ext\" config field): {e}"
|
|
281
|
+
) from e
|
|
282
|
+
|
|
283
|
+
@field_validator("project")
|
|
284
|
+
@classmethod
|
|
285
|
+
def validate_project(cls, v: str) -> str:
|
|
286
|
+
"""Validate project name follows naming rules."""
|
|
287
|
+
if not v:
|
|
288
|
+
raise ValueError("project name cannot be empty")
|
|
289
|
+
if "/" in v:
|
|
290
|
+
raise ValueError(
|
|
291
|
+
f"project name '{v}' cannot contain forward slashes (/). "
|
|
292
|
+
f"Forward slashes are reserved for FeatureKey separation"
|
|
293
|
+
)
|
|
294
|
+
if "__" in v:
|
|
295
|
+
raise ValueError(
|
|
296
|
+
f"project name '{v}' cannot contain double underscores (__). "
|
|
297
|
+
f"Double underscores are reserved for table name generation"
|
|
298
|
+
)
|
|
299
|
+
import re
|
|
300
|
+
|
|
301
|
+
if not re.match(r"^[a-zA-Z0-9_-]+$", v):
|
|
302
|
+
raise ValueError(
|
|
303
|
+
f"project name '{v}' must contain only alphanumeric characters, underscores, and hyphens"
|
|
304
|
+
)
|
|
305
|
+
return v
|
|
306
|
+
|
|
307
|
+
@property
|
|
308
|
+
def plugins(self) -> list[str]:
|
|
309
|
+
"""Returns all enabled plugin names from ext configuration."""
|
|
310
|
+
return [name for name, plugin in self.ext.items() if plugin.enable]
|
|
311
|
+
|
|
312
|
+
@classmethod
|
|
313
|
+
def get_plugin(cls, name: str, plugin_cls: type[PluginConfigT]) -> PluginConfigT:
|
|
314
|
+
"""Get the plugin config from the global Metaxy config.
|
|
315
|
+
|
|
316
|
+
Unlike `get()`, this method does not warn when the global config is not
|
|
317
|
+
initialized. This is intentional because plugins may call this at import
|
|
318
|
+
time to read their configuration, and returning default plugin config
|
|
319
|
+
is always safe.
|
|
320
|
+
"""
|
|
321
|
+
ext = cls.get(_allow_default_config=True).ext
|
|
322
|
+
if name in ext:
|
|
323
|
+
existing = ext[name]
|
|
324
|
+
if isinstance(existing, plugin_cls):
|
|
325
|
+
# Already the correct type
|
|
326
|
+
plugin = existing
|
|
327
|
+
else:
|
|
328
|
+
# Convert from generic PluginConfig or dict to specific plugin class
|
|
329
|
+
plugin = plugin_cls.model_validate(existing.model_dump())
|
|
330
|
+
else:
|
|
331
|
+
# Return default config if plugin not configured
|
|
332
|
+
plugin = plugin_cls()
|
|
333
|
+
return plugin
|
|
334
|
+
|
|
335
|
+
@field_validator("hash_truncation_length")
|
|
336
|
+
@classmethod
|
|
337
|
+
def validate_hash_truncation_length(cls, v: int | None) -> int | None:
|
|
338
|
+
"""Validate hash truncation length is at least 8 if set."""
|
|
339
|
+
if v is not None and v < 8:
|
|
340
|
+
raise ValueError(
|
|
341
|
+
f"hash_truncation_length must be at least 8 characters, got {v}"
|
|
342
|
+
)
|
|
343
|
+
return v
|
|
344
|
+
|
|
345
|
+
@classmethod
|
|
346
|
+
def settings_customise_sources(
|
|
347
|
+
cls,
|
|
348
|
+
settings_cls: type[BaseSettings],
|
|
349
|
+
init_settings: PydanticBaseSettingsSource,
|
|
350
|
+
env_settings: PydanticBaseSettingsSource,
|
|
351
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
352
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
353
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
354
|
+
"""Customize settings sources: init → env → TOML.
|
|
355
|
+
|
|
356
|
+
Priority (first wins):
|
|
357
|
+
1. Init arguments
|
|
358
|
+
2. Environment variables
|
|
359
|
+
3. TOML file
|
|
360
|
+
"""
|
|
361
|
+
toml_settings = TomlConfigSettingsSource(settings_cls)
|
|
362
|
+
return (init_settings, env_settings, toml_settings)
|
|
363
|
+
|
|
364
|
+
@classmethod
|
|
365
|
+
def get(cls, *, _allow_default_config: bool = False) -> "MetaxyConfig":
|
|
366
|
+
"""Get the current Metaxy configuration.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
_allow_default_config: Internal parameter. When True, returns default
|
|
370
|
+
config without warning if global config is not set. Used by methods
|
|
371
|
+
like `get_plugin` that may be called at import time.
|
|
372
|
+
"""
|
|
373
|
+
cfg = _metaxy_config.get()
|
|
374
|
+
if cfg is None:
|
|
375
|
+
if not _allow_default_config:
|
|
376
|
+
warnings.warn(
|
|
377
|
+
UserWarning(
|
|
378
|
+
"Global Metaxy configuration not initialized. It can be set with MetaxyConfig.set(config) typically after loading it from a toml file. Returning default configuration (with environment variables and other pydantic settings sources resolved, project='default')."
|
|
379
|
+
),
|
|
380
|
+
stacklevel=2,
|
|
381
|
+
)
|
|
382
|
+
return cls(project="default")
|
|
383
|
+
else:
|
|
384
|
+
return cfg
|
|
385
|
+
|
|
386
|
+
@classmethod
|
|
387
|
+
def set(cls, config: Self | None) -> None:
|
|
388
|
+
"""Set the current Metaxy configuration."""
|
|
389
|
+
_metaxy_config.set(config)
|
|
390
|
+
|
|
391
|
+
@classmethod
|
|
392
|
+
def is_set(cls) -> bool:
|
|
393
|
+
"""Check if the current Metaxy configuration is set."""
|
|
394
|
+
return _metaxy_config.get() is not None
|
|
395
|
+
|
|
396
|
+
@classmethod
|
|
397
|
+
def reset(cls) -> None:
|
|
398
|
+
"""Reset the current Metaxy configuration to None."""
|
|
399
|
+
_metaxy_config.set(None)
|
|
400
|
+
|
|
401
|
+
@contextmanager
|
|
402
|
+
def use(self) -> Iterator[Self]:
|
|
403
|
+
"""Use this configuration temporarily, restoring previous config on exit.
|
|
404
|
+
|
|
405
|
+
Example:
|
|
406
|
+
```py
|
|
407
|
+
config = MetaxyConfig(project="test")
|
|
408
|
+
with config.use():
|
|
409
|
+
# Code here uses test config
|
|
410
|
+
assert MetaxyConfig.get().project == "test"
|
|
411
|
+
# Previous config restored
|
|
412
|
+
```
|
|
413
|
+
"""
|
|
414
|
+
previous = _metaxy_config.get()
|
|
415
|
+
_metaxy_config.set(self)
|
|
416
|
+
try:
|
|
417
|
+
yield self
|
|
418
|
+
finally:
|
|
419
|
+
_metaxy_config.set(previous)
|
|
420
|
+
|
|
421
|
+
@classmethod
|
|
422
|
+
def load(
|
|
423
|
+
cls,
|
|
424
|
+
config_file: str | Path | None = None,
|
|
425
|
+
*,
|
|
426
|
+
search_parents: bool = True,
|
|
427
|
+
auto_discovery_start: Path | None = None,
|
|
428
|
+
) -> "MetaxyConfig":
|
|
429
|
+
"""Load config with auto-discovery and parent directory search.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
config_file: Optional config file path.
|
|
433
|
+
|
|
434
|
+
!!! tip
|
|
435
|
+
`METAXY_CONFIG` environment variable can be used to set this parameter
|
|
436
|
+
|
|
437
|
+
search_parents: Search parent directories for config file
|
|
438
|
+
auto_discovery_start: Directory to start search from.
|
|
439
|
+
Defaults to current working directory.
|
|
440
|
+
|
|
441
|
+
Returns:
|
|
442
|
+
Loaded config (TOML + env vars merged)
|
|
443
|
+
|
|
444
|
+
Example:
|
|
445
|
+
```py
|
|
446
|
+
# Auto-discover with parent search
|
|
447
|
+
config = MetaxyConfig.load()
|
|
448
|
+
|
|
449
|
+
# Explicit file
|
|
450
|
+
config = MetaxyConfig.load("custom.toml")
|
|
451
|
+
|
|
452
|
+
# Auto-discover without parent search
|
|
453
|
+
config = MetaxyConfig.load(search_parents=False)
|
|
454
|
+
|
|
455
|
+
# Auto-discover from a specific directory
|
|
456
|
+
config = MetaxyConfig.load(auto_discovery_start=Path("/path/to/project"))
|
|
457
|
+
```
|
|
458
|
+
"""
|
|
459
|
+
# Search for config file if not explicitly provided
|
|
460
|
+
|
|
461
|
+
if config_from_env := os.getenv("METAXY_CONFIG"):
|
|
462
|
+
config_file = Path(config_from_env)
|
|
463
|
+
|
|
464
|
+
if config_file is None and search_parents:
|
|
465
|
+
config_file = cls._discover_config_with_parents(auto_discovery_start)
|
|
466
|
+
|
|
467
|
+
# For explicit file, temporarily patch the TomlConfigSettingsSource
|
|
468
|
+
# to use that file, then use normal instantiation
|
|
469
|
+
# This ensures env vars still work
|
|
470
|
+
|
|
471
|
+
if config_file:
|
|
472
|
+
# Create a custom settings source class for this file
|
|
473
|
+
toml_path = Path(config_file)
|
|
474
|
+
|
|
475
|
+
class CustomTomlSource(TomlConfigSettingsSource):
|
|
476
|
+
def __init__(self, settings_cls: type[BaseSettings]):
|
|
477
|
+
# Skip auto-discovery, use explicit file
|
|
478
|
+
super(TomlConfigSettingsSource, self).__init__(settings_cls)
|
|
479
|
+
self.toml_file = toml_path
|
|
480
|
+
self.toml_data = self._load_toml()
|
|
481
|
+
|
|
482
|
+
# Customize sources to use custom TOML file
|
|
483
|
+
original_method = cls.settings_customise_sources
|
|
484
|
+
|
|
485
|
+
@classmethod # type: ignore[misc]
|
|
486
|
+
def custom_sources(
|
|
487
|
+
cls_inner,
|
|
488
|
+
settings_cls,
|
|
489
|
+
init_settings,
|
|
490
|
+
env_settings,
|
|
491
|
+
dotenv_settings,
|
|
492
|
+
file_secret_settings,
|
|
493
|
+
):
|
|
494
|
+
toml_settings = CustomTomlSource(settings_cls)
|
|
495
|
+
return (init_settings, env_settings, toml_settings)
|
|
496
|
+
|
|
497
|
+
# Temporarily replace method
|
|
498
|
+
cls.settings_customise_sources = custom_sources # type: ignore[assignment]
|
|
499
|
+
config = cls()
|
|
500
|
+
cls.settings_customise_sources = original_method # type: ignore[method-assign]
|
|
501
|
+
else:
|
|
502
|
+
# Use default sources (auto-discovery + env vars)
|
|
503
|
+
config = cls()
|
|
504
|
+
|
|
505
|
+
cls.set(config)
|
|
506
|
+
|
|
507
|
+
# Load plugins after config is set (plugins may access MetaxyConfig.get())
|
|
508
|
+
config._load_plugins()
|
|
509
|
+
|
|
510
|
+
return config
|
|
511
|
+
|
|
512
|
+
@staticmethod
|
|
513
|
+
def _discover_config_with_parents(start_dir: Path | None = None) -> Path | None:
|
|
514
|
+
"""Discover config file by searching current and parent directories.
|
|
515
|
+
|
|
516
|
+
Searches for metaxy.toml or pyproject.toml in start directory,
|
|
517
|
+
then iteratively searches parent directories.
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
start_dir: Directory to start search from (defaults to cwd)
|
|
521
|
+
|
|
522
|
+
Returns:
|
|
523
|
+
Path to config file if found, None otherwise
|
|
524
|
+
"""
|
|
525
|
+
current = start_dir or Path.cwd()
|
|
526
|
+
|
|
527
|
+
while True:
|
|
528
|
+
# Check for metaxy.toml (preferred)
|
|
529
|
+
metaxy_toml = current / "metaxy.toml"
|
|
530
|
+
if metaxy_toml.exists():
|
|
531
|
+
return metaxy_toml
|
|
532
|
+
|
|
533
|
+
# Check for pyproject.toml
|
|
534
|
+
pyproject_toml = current / "pyproject.toml"
|
|
535
|
+
if pyproject_toml.exists():
|
|
536
|
+
return pyproject_toml
|
|
537
|
+
|
|
538
|
+
# Move to parent
|
|
539
|
+
parent = current.parent
|
|
540
|
+
if parent == current:
|
|
541
|
+
# Reached roothash_tru
|
|
542
|
+
break
|
|
543
|
+
current = parent
|
|
544
|
+
|
|
545
|
+
return None
|
|
546
|
+
|
|
547
|
+
@overload
|
|
548
|
+
def get_store(
|
|
549
|
+
self,
|
|
550
|
+
name: str | None = None,
|
|
551
|
+
*,
|
|
552
|
+
expected_type: Literal[None] = None,
|
|
553
|
+
**kwargs: Any,
|
|
554
|
+
) -> "MetadataStore": ...
|
|
555
|
+
|
|
556
|
+
@overload
|
|
557
|
+
def get_store(
|
|
558
|
+
self,
|
|
559
|
+
name: str | None = None,
|
|
560
|
+
*,
|
|
561
|
+
expected_type: type[StoreTypeT],
|
|
562
|
+
**kwargs: Any,
|
|
563
|
+
) -> StoreTypeT: ...
|
|
564
|
+
|
|
565
|
+
def get_store(
|
|
566
|
+
self,
|
|
567
|
+
name: str | None = None,
|
|
568
|
+
*,
|
|
569
|
+
expected_type: type[StoreTypeT] | None = None,
|
|
570
|
+
**kwargs: Any,
|
|
571
|
+
) -> "MetadataStore | StoreTypeT":
|
|
572
|
+
"""Instantiate metadata store by name.
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
name: Store name (uses config.store if None)
|
|
576
|
+
expected_type: Expected type of the store.
|
|
577
|
+
If the actual store type does not match the expected type, a `TypeError` is raised.
|
|
578
|
+
**kwargs: Additional keyword arguments to pass to the store constructor.
|
|
579
|
+
|
|
580
|
+
Returns:
|
|
581
|
+
Instantiated metadata store
|
|
582
|
+
|
|
583
|
+
Raises:
|
|
584
|
+
ValueError: If store name not found in config, or if fallback stores
|
|
585
|
+
have different hash algorithms than the parent store
|
|
586
|
+
ImportError: If store class cannot be imported
|
|
587
|
+
TypeError: If the actual store type does not match the expected type
|
|
588
|
+
|
|
589
|
+
Example:
|
|
590
|
+
```py
|
|
591
|
+
config = MetaxyConfig.load()
|
|
592
|
+
store = config.get_store("prod")
|
|
593
|
+
|
|
594
|
+
# Use default store
|
|
595
|
+
store = config.get_store()
|
|
596
|
+
```
|
|
597
|
+
"""
|
|
598
|
+
from metaxy.versioning.types import HashAlgorithm
|
|
599
|
+
|
|
600
|
+
if len(self.stores) == 0:
|
|
601
|
+
raise ValueError(
|
|
602
|
+
"No Metaxy stores available. They should be configured in metaxy.toml|pyproject.toml or via environment variables."
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
name = name or self.store
|
|
606
|
+
|
|
607
|
+
if name not in self.stores:
|
|
608
|
+
raise ValueError(
|
|
609
|
+
f"Store '{name}' not found in config. "
|
|
610
|
+
f"Available stores: {list(self.stores.keys())}"
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
store_config = self.stores[name]
|
|
614
|
+
|
|
615
|
+
# Get store class (already imported by Pydantic's ImportString)
|
|
616
|
+
store_class = store_config.type
|
|
617
|
+
|
|
618
|
+
if expected_type is not None and not issubclass(store_class, expected_type):
|
|
619
|
+
raise TypeError(f"Store '{name}' is not of type '{expected_type.__name__}'")
|
|
620
|
+
|
|
621
|
+
# Extract configuration and prepare for typed config model
|
|
622
|
+
config_copy = store_config.config.copy()
|
|
623
|
+
|
|
624
|
+
# Get hash_algorithm from config (if specified) and convert to enum
|
|
625
|
+
configured_hash_algorithm = config_copy.get("hash_algorithm")
|
|
626
|
+
if configured_hash_algorithm is not None:
|
|
627
|
+
# Convert string to enum if needed
|
|
628
|
+
if isinstance(configured_hash_algorithm, str):
|
|
629
|
+
configured_hash_algorithm = HashAlgorithm(configured_hash_algorithm)
|
|
630
|
+
config_copy["hash_algorithm"] = configured_hash_algorithm
|
|
631
|
+
else:
|
|
632
|
+
# Don't set a default here - let the store choose its own default
|
|
633
|
+
configured_hash_algorithm = None
|
|
634
|
+
|
|
635
|
+
# Get the store's config model class and create typed config
|
|
636
|
+
config_model_cls = store_class.config_model()
|
|
637
|
+
|
|
638
|
+
# Get auto_create_tables from global config only if the config model supports it
|
|
639
|
+
if (
|
|
640
|
+
"auto_create_tables" not in config_copy
|
|
641
|
+
and self.auto_create_tables is not None
|
|
642
|
+
and "auto_create_tables" in config_model_cls.model_fields
|
|
643
|
+
):
|
|
644
|
+
# Use global setting from MetaxyConfig if not specified per-store
|
|
645
|
+
config_copy["auto_create_tables"] = self.auto_create_tables
|
|
646
|
+
|
|
647
|
+
# Separate kwargs into config fields and extra constructor args
|
|
648
|
+
config_fields = set(config_model_cls.model_fields.keys())
|
|
649
|
+
extra_kwargs = {}
|
|
650
|
+
for key, value in kwargs.items():
|
|
651
|
+
if key in config_fields:
|
|
652
|
+
config_copy[key] = value
|
|
653
|
+
else:
|
|
654
|
+
extra_kwargs[key] = value
|
|
655
|
+
|
|
656
|
+
typed_config = config_model_cls.model_validate(config_copy)
|
|
657
|
+
|
|
658
|
+
# Instantiate using from_config() - fallback stores are resolved via MetaxyConfig.get()
|
|
659
|
+
# Use self.use() to ensure this config is available for fallback resolution
|
|
660
|
+
with self.use():
|
|
661
|
+
store = store_class.from_config(typed_config, **extra_kwargs)
|
|
662
|
+
|
|
663
|
+
# Verify the store actually uses the hash algorithm we configured
|
|
664
|
+
# (in case a store subclass overrides the default or ignores the parameter)
|
|
665
|
+
# Only check if we explicitly configured a hash algorithm
|
|
666
|
+
if (
|
|
667
|
+
configured_hash_algorithm is not None
|
|
668
|
+
and store.hash_algorithm != configured_hash_algorithm
|
|
669
|
+
):
|
|
670
|
+
raise ValueError(
|
|
671
|
+
f"Store '{name}' ({store_class.__name__}) was configured with "
|
|
672
|
+
f"hash_algorithm='{configured_hash_algorithm.value}' but is using "
|
|
673
|
+
f"'{store.hash_algorithm.value}'. The store class may have overridden "
|
|
674
|
+
f"the hash algorithm. All stores must use the same hash algorithm."
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
if expected_type is not None and not isinstance(store, expected_type):
|
|
678
|
+
raise TypeError(f"Store '{name}' is not of type '{expected_type.__name__}'")
|
|
679
|
+
|
|
680
|
+
return store
|