metaxy 0.0.1.dev3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. metaxy/__init__.py +170 -0
  2. metaxy/_packaging.py +96 -0
  3. metaxy/_testing/__init__.py +55 -0
  4. metaxy/_testing/config.py +43 -0
  5. metaxy/_testing/metaxy_project.py +780 -0
  6. metaxy/_testing/models.py +111 -0
  7. metaxy/_testing/parametric/__init__.py +13 -0
  8. metaxy/_testing/parametric/metadata.py +664 -0
  9. metaxy/_testing/pytest_helpers.py +74 -0
  10. metaxy/_testing/runbook.py +533 -0
  11. metaxy/_utils.py +35 -0
  12. metaxy/_version.py +1 -0
  13. metaxy/cli/app.py +97 -0
  14. metaxy/cli/console.py +13 -0
  15. metaxy/cli/context.py +167 -0
  16. metaxy/cli/graph.py +610 -0
  17. metaxy/cli/graph_diff.py +290 -0
  18. metaxy/cli/list.py +46 -0
  19. metaxy/cli/metadata.py +317 -0
  20. metaxy/cli/migrations.py +999 -0
  21. metaxy/cli/utils.py +268 -0
  22. metaxy/config.py +680 -0
  23. metaxy/entrypoints.py +296 -0
  24. metaxy/ext/__init__.py +1 -0
  25. metaxy/ext/dagster/__init__.py +54 -0
  26. metaxy/ext/dagster/constants.py +10 -0
  27. metaxy/ext/dagster/dagster_type.py +156 -0
  28. metaxy/ext/dagster/io_manager.py +200 -0
  29. metaxy/ext/dagster/metaxify.py +512 -0
  30. metaxy/ext/dagster/observable.py +115 -0
  31. metaxy/ext/dagster/resources.py +27 -0
  32. metaxy/ext/dagster/selection.py +73 -0
  33. metaxy/ext/dagster/table_metadata.py +417 -0
  34. metaxy/ext/dagster/utils.py +462 -0
  35. metaxy/ext/sqlalchemy/__init__.py +23 -0
  36. metaxy/ext/sqlalchemy/config.py +29 -0
  37. metaxy/ext/sqlalchemy/plugin.py +353 -0
  38. metaxy/ext/sqlmodel/__init__.py +13 -0
  39. metaxy/ext/sqlmodel/config.py +29 -0
  40. metaxy/ext/sqlmodel/plugin.py +499 -0
  41. metaxy/graph/__init__.py +29 -0
  42. metaxy/graph/describe.py +325 -0
  43. metaxy/graph/diff/__init__.py +21 -0
  44. metaxy/graph/diff/diff_models.py +446 -0
  45. metaxy/graph/diff/differ.py +769 -0
  46. metaxy/graph/diff/models.py +443 -0
  47. metaxy/graph/diff/rendering/__init__.py +18 -0
  48. metaxy/graph/diff/rendering/base.py +323 -0
  49. metaxy/graph/diff/rendering/cards.py +188 -0
  50. metaxy/graph/diff/rendering/formatter.py +805 -0
  51. metaxy/graph/diff/rendering/graphviz.py +246 -0
  52. metaxy/graph/diff/rendering/mermaid.py +326 -0
  53. metaxy/graph/diff/rendering/rich.py +169 -0
  54. metaxy/graph/diff/rendering/theme.py +48 -0
  55. metaxy/graph/diff/traversal.py +247 -0
  56. metaxy/graph/status.py +329 -0
  57. metaxy/graph/utils.py +58 -0
  58. metaxy/metadata_store/__init__.py +32 -0
  59. metaxy/metadata_store/_ducklake_support.py +419 -0
  60. metaxy/metadata_store/base.py +1792 -0
  61. metaxy/metadata_store/bigquery.py +354 -0
  62. metaxy/metadata_store/clickhouse.py +184 -0
  63. metaxy/metadata_store/delta.py +371 -0
  64. metaxy/metadata_store/duckdb.py +446 -0
  65. metaxy/metadata_store/exceptions.py +61 -0
  66. metaxy/metadata_store/ibis.py +542 -0
  67. metaxy/metadata_store/lancedb.py +391 -0
  68. metaxy/metadata_store/memory.py +292 -0
  69. metaxy/metadata_store/system/__init__.py +57 -0
  70. metaxy/metadata_store/system/events.py +264 -0
  71. metaxy/metadata_store/system/keys.py +9 -0
  72. metaxy/metadata_store/system/models.py +129 -0
  73. metaxy/metadata_store/system/storage.py +957 -0
  74. metaxy/metadata_store/types.py +10 -0
  75. metaxy/metadata_store/utils.py +104 -0
  76. metaxy/metadata_store/warnings.py +36 -0
  77. metaxy/migrations/__init__.py +32 -0
  78. metaxy/migrations/detector.py +291 -0
  79. metaxy/migrations/executor.py +516 -0
  80. metaxy/migrations/generator.py +319 -0
  81. metaxy/migrations/loader.py +231 -0
  82. metaxy/migrations/models.py +528 -0
  83. metaxy/migrations/ops.py +447 -0
  84. metaxy/models/__init__.py +0 -0
  85. metaxy/models/bases.py +12 -0
  86. metaxy/models/constants.py +139 -0
  87. metaxy/models/feature.py +1335 -0
  88. metaxy/models/feature_spec.py +338 -0
  89. metaxy/models/field.py +263 -0
  90. metaxy/models/fields_mapping.py +307 -0
  91. metaxy/models/filter_expression.py +297 -0
  92. metaxy/models/lineage.py +285 -0
  93. metaxy/models/plan.py +232 -0
  94. metaxy/models/types.py +475 -0
  95. metaxy/py.typed +0 -0
  96. metaxy/utils/__init__.py +1 -0
  97. metaxy/utils/constants.py +2 -0
  98. metaxy/utils/exceptions.py +23 -0
  99. metaxy/utils/hashing.py +230 -0
  100. metaxy/versioning/__init__.py +31 -0
  101. metaxy/versioning/engine.py +656 -0
  102. metaxy/versioning/feature_dep_transformer.py +151 -0
  103. metaxy/versioning/ibis.py +249 -0
  104. metaxy/versioning/lineage_handler.py +205 -0
  105. metaxy/versioning/polars.py +189 -0
  106. metaxy/versioning/renamed_df.py +35 -0
  107. metaxy/versioning/types.py +63 -0
  108. metaxy-0.0.1.dev3.dist-info/METADATA +96 -0
  109. metaxy-0.0.1.dev3.dist-info/RECORD +111 -0
  110. metaxy-0.0.1.dev3.dist-info/WHEEL +4 -0
  111. metaxy-0.0.1.dev3.dist-info/entry_points.txt +4 -0
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