mxm-config 0.2.5__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.
- mxm_config/__init__.py +50 -0
- mxm_config/api.py +66 -0
- mxm_config/examples/__init__.py +0 -0
- mxm_config/examples/demo_config/__init__.py +0 -0
- mxm_config/examples/demo_config/default.yaml +10 -0
- mxm_config/examples/demo_config/environment.yaml +9 -0
- mxm_config/examples/demo_config/local.yaml +2 -0
- mxm_config/examples/demo_config/machine.yaml +11 -0
- mxm_config/examples/demo_config/profile.yaml +8 -0
- mxm_config/init_resolvers.py +81 -0
- mxm_config/initializer.py +25 -0
- mxm_config/installer.py +66 -0
- mxm_config/loader.py +168 -0
- mxm_config/py.typed +0 -0
- mxm_config/resolver.py +77 -0
- mxm_config/types.py +34 -0
- mxm_config-0.2.5.dist-info/METADATA +167 -0
- mxm_config-0.2.5.dist-info/RECORD +20 -0
- mxm_config-0.2.5.dist-info/WHEEL +4 -0
- mxm_config-0.2.5.dist-info/licenses/LICENSE +21 -0
mxm_config/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Public API for mxm-config.
|
|
3
|
+
|
|
4
|
+
This package loads layered configuration for MXM apps and installs standard
|
|
5
|
+
OmegaConf resolvers (e.g., `${cwd:}`, `${env:VAR}`) on import.
|
|
6
|
+
|
|
7
|
+
Typical usage
|
|
8
|
+
-------------
|
|
9
|
+
from mxm_config import MXMConfig, load_config, install_all
|
|
10
|
+
|
|
11
|
+
# (Optional) Install package config files into ~/.config/mxm/<package>/
|
|
12
|
+
install_all()
|
|
13
|
+
|
|
14
|
+
# Load layered config for your app/package
|
|
15
|
+
cfg: MXMConfig = load_config(
|
|
16
|
+
package="mxm-datakraken",
|
|
17
|
+
env="dev",
|
|
18
|
+
profile="default",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Use ergonomic dot-notation
|
|
22
|
+
root = cfg.paths.sources.justetf.root
|
|
23
|
+
# Or mapping-style access
|
|
24
|
+
root2 = cfg["paths"]["sources"]["justetf"]["root"]
|
|
25
|
+
|
|
26
|
+
Notes
|
|
27
|
+
-----
|
|
28
|
+
- Downstream packages should import from `mxm_config` (this module) and type
|
|
29
|
+
against the `MXMConfig` protocol rather than importing OmegaConf directly.
|
|
30
|
+
- `load_config` returns an object that satisfies `MXMConfig` (backed by
|
|
31
|
+
OmegaConf DictConfig internally).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from mxm_config.api import make_subconfig
|
|
37
|
+
from mxm_config.init_resolvers import register_mxm_resolvers
|
|
38
|
+
from mxm_config.installer import install_all
|
|
39
|
+
from mxm_config.loader import load_config
|
|
40
|
+
from mxm_config.types import MXMConfig
|
|
41
|
+
|
|
42
|
+
# Register standard MXM resolvers at import time so `${...}` interpolations work globally.
|
|
43
|
+
register_mxm_resolvers()
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"MXMConfig",
|
|
47
|
+
"install_all",
|
|
48
|
+
"load_config",
|
|
49
|
+
"make_subconfig",
|
|
50
|
+
]
|
mxm_config/api.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Public helpers for working with MXM configuration objects.
|
|
3
|
+
|
|
4
|
+
This module exposes utilities that produce or transform objects conforming to
|
|
5
|
+
the `MXMConfig` protocol without requiring callers to import OmegaConf.
|
|
6
|
+
|
|
7
|
+
`make_subconfig` is a tiny factory that turns a plain mapping into an object
|
|
8
|
+
behaving like your app config (supports both attribute and item access), backed
|
|
9
|
+
by OmegaConf `DictConfig` under the hood and typed as `MXMConfig`.
|
|
10
|
+
|
|
11
|
+
Typical use cases:
|
|
12
|
+
- Build a minimal, self-contained config for a subsystem (e.g., DataIO).
|
|
13
|
+
- Construct tiny configs in unit tests without loading layered YAML.
|
|
14
|
+
- Provide a focused “view” of a larger config tree at a package boundary.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any, Mapping
|
|
20
|
+
|
|
21
|
+
from .types import MXMConfig
|
|
22
|
+
|
|
23
|
+
__all__ = ["make_subconfig"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def make_subconfig(
|
|
27
|
+
data: Mapping[str, Any],
|
|
28
|
+
*,
|
|
29
|
+
readonly: bool = True,
|
|
30
|
+
resolve: bool = False,
|
|
31
|
+
) -> MXMConfig:
|
|
32
|
+
"""
|
|
33
|
+
Create an `MXMConfig` from a plain mapping.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
data
|
|
38
|
+
Plain nested mapping to convert into a config-shaped object.
|
|
39
|
+
readonly
|
|
40
|
+
If True (default), the returned config is set read-only.
|
|
41
|
+
resolve
|
|
42
|
+
If True, resolve `${...}` interpolations immediately.
|
|
43
|
+
|
|
44
|
+
Returns
|
|
45
|
+
-------
|
|
46
|
+
MXMConfig
|
|
47
|
+
An object supporting both dot and item access. Internally an
|
|
48
|
+
OmegaConf `DictConfig`, but typed as the protocol to keep OmegaConf
|
|
49
|
+
out of consumer APIs.
|
|
50
|
+
|
|
51
|
+
Notes
|
|
52
|
+
-----
|
|
53
|
+
- OmegaConf is imported locally to avoid exposing it to consumers.
|
|
54
|
+
- Use `resolve=True` if your subconfig contains `${...}` expressions
|
|
55
|
+
that should be evaluated right away.
|
|
56
|
+
"""
|
|
57
|
+
# Local import to keep OmegaConf out of public type signatures for consumers.
|
|
58
|
+
from omegaconf import OmegaConf # type: ignore
|
|
59
|
+
|
|
60
|
+
cfg = OmegaConf.create(dict(data))
|
|
61
|
+
if resolve:
|
|
62
|
+
OmegaConf.resolve(cfg)
|
|
63
|
+
if readonly:
|
|
64
|
+
OmegaConf.set_readonly(cfg, True)
|
|
65
|
+
# The returned object satisfies MXMConfig structurally (attr + item access).
|
|
66
|
+
return cfg
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# mxm_config/init_resolvers.py
|
|
2
|
+
"""Registration of standard MXM resolvers for OmegaConf interpolation.
|
|
3
|
+
|
|
4
|
+
Resolvers provided:
|
|
5
|
+
- ${cwd:} -> current working directory
|
|
6
|
+
- ${home:} -> user's home directory
|
|
7
|
+
- ${env:VAR[,default]} -> environment variable lookup with optional default
|
|
8
|
+
- ${timestamp:} -> ISO timestamp (seconds precision)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import datetime as _dt
|
|
14
|
+
import os as _os
|
|
15
|
+
from typing import Any, Callable, cast
|
|
16
|
+
|
|
17
|
+
from omegaconf import OmegaConf
|
|
18
|
+
|
|
19
|
+
# --- Typed resolver functions -------------------------------------------------
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _cwd_resolver() -> str:
|
|
23
|
+
"""Return the current working directory."""
|
|
24
|
+
return _os.getcwd()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _home_resolver() -> str:
|
|
28
|
+
"""Return the user's home directory."""
|
|
29
|
+
return _os.path.expanduser("~")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _env_resolver(key: str, default: str | None = None) -> str | None:
|
|
33
|
+
"""Resolve an environment variable.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
key: Environment variable name.
|
|
37
|
+
default: Value to return if the variable is unset.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
The env var value if set, otherwise `default`.
|
|
41
|
+
"""
|
|
42
|
+
value = _os.getenv(key)
|
|
43
|
+
return value if value is not None else default
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _timestamp_resolver() -> str:
|
|
47
|
+
"""Return the current timestamp (ISO 8601, seconds precision)."""
|
|
48
|
+
return _dt.datetime.now().isoformat(timespec="seconds")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# --- Public API ---------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def register_mxm_resolvers() -> None:
|
|
55
|
+
"""Register MXM resolvers if not already present.
|
|
56
|
+
|
|
57
|
+
Notes:
|
|
58
|
+
We cast the typed callables to `Callable[..., Any]` at registration
|
|
59
|
+
time because OmegaConf's type hints are permissive and do not model
|
|
60
|
+
per-resolver signatures precisely.
|
|
61
|
+
"""
|
|
62
|
+
if not OmegaConf.has_resolver("cwd"):
|
|
63
|
+
OmegaConf.register_new_resolver(
|
|
64
|
+
"cwd",
|
|
65
|
+
cast(Callable[..., Any], _cwd_resolver),
|
|
66
|
+
)
|
|
67
|
+
if not OmegaConf.has_resolver("home"):
|
|
68
|
+
OmegaConf.register_new_resolver(
|
|
69
|
+
"home",
|
|
70
|
+
cast(Callable[..., Any], _home_resolver),
|
|
71
|
+
)
|
|
72
|
+
if not OmegaConf.has_resolver("env"):
|
|
73
|
+
OmegaConf.register_new_resolver(
|
|
74
|
+
"env",
|
|
75
|
+
cast(Callable[..., Any], _env_resolver),
|
|
76
|
+
)
|
|
77
|
+
if not OmegaConf.has_resolver("timestamp"):
|
|
78
|
+
OmegaConf.register_new_resolver(
|
|
79
|
+
"timestamp",
|
|
80
|
+
cast(Callable[..., Any], _timestamp_resolver),
|
|
81
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from mxm_config.resolver import get_config_root
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def initiate_mxm_configs(
|
|
7
|
+
config_root: Path | None = None,
|
|
8
|
+
create_if_missing: bool = True,
|
|
9
|
+
) -> Path:
|
|
10
|
+
"""
|
|
11
|
+
Resolve and optionally create the MXM config root directory.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
config_root: Optional explicit path to use.
|
|
15
|
+
create_if_missing: Whether to create the directory if it does not exist.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
The resolved config root Path.
|
|
19
|
+
"""
|
|
20
|
+
resolved_root = config_root or get_config_root()
|
|
21
|
+
|
|
22
|
+
if create_if_missing:
|
|
23
|
+
resolved_root.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
|
|
25
|
+
return resolved_root
|
mxm_config/installer.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
from importlib.resources import files
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List, Optional, cast
|
|
5
|
+
|
|
6
|
+
from mxm_config.resolver import get_config_root
|
|
7
|
+
|
|
8
|
+
_CORE_FILES: list[str] = [
|
|
9
|
+
"default.yaml",
|
|
10
|
+
"environment.yaml",
|
|
11
|
+
"machine.yaml",
|
|
12
|
+
"profile.yaml",
|
|
13
|
+
"local.yaml",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def install_all(
|
|
18
|
+
package: str,
|
|
19
|
+
target_root: Optional[Path] = None,
|
|
20
|
+
target_name: Optional[str] = None,
|
|
21
|
+
overwrite: bool = False,
|
|
22
|
+
) -> List[Path]:
|
|
23
|
+
"""Install all known config files from a package into ~/.config/mxm/<package>/.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
package: Import path to the package providing config files,
|
|
27
|
+
e.g. ``"mxm_config.examples.demo_config"``.
|
|
28
|
+
target_root: Optional override for the mxm config root.
|
|
29
|
+
Defaults to ``~/.config/mxm``.
|
|
30
|
+
target_name: Optional override for the subdirectory name under the config root.
|
|
31
|
+
By default, the last component of the package name is used.
|
|
32
|
+
overwrite: Whether to overwrite existing files if they already exist.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
A list of installed file paths.
|
|
36
|
+
"""
|
|
37
|
+
config_root: Path = target_root if target_root else get_config_root()
|
|
38
|
+
package_dir: str = target_name or package.split(".")[-1]
|
|
39
|
+
dst_root: Path = config_root / package_dir
|
|
40
|
+
dst_root.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
|
|
42
|
+
installed: List[Path] = []
|
|
43
|
+
|
|
44
|
+
for fname in _CORE_FILES:
|
|
45
|
+
src = files(package).joinpath(fname)
|
|
46
|
+
if src.is_file():
|
|
47
|
+
dst = dst_root / fname
|
|
48
|
+
if dst.exists() and not overwrite:
|
|
49
|
+
continue
|
|
50
|
+
shutil.copy(str(src), str(dst))
|
|
51
|
+
installed.append(dst)
|
|
52
|
+
|
|
53
|
+
src_templates = files(package).joinpath("templates")
|
|
54
|
+
if src_templates.is_dir():
|
|
55
|
+
tmpl_root = dst_root / "templates"
|
|
56
|
+
tmpl_root.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
for src in src_templates.iterdir():
|
|
58
|
+
src_path = cast(Path, src)
|
|
59
|
+
if src_path.suffix == ".yaml":
|
|
60
|
+
dst = tmpl_root / src_path.name
|
|
61
|
+
if dst.exists() and not overwrite:
|
|
62
|
+
continue
|
|
63
|
+
shutil.copy(str(src), str(dst))
|
|
64
|
+
installed.append(dst)
|
|
65
|
+
|
|
66
|
+
return installed
|
mxm_config/loader.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration loader for MXM apps (layered OmegaConf).
|
|
3
|
+
|
|
4
|
+
This module composes the final, read-only configuration object for a given
|
|
5
|
+
`package` by merging a standard set of YAML layers located under the MXM
|
|
6
|
+
config root (e.g., `~/.config/mxm/<package>`). Layering order is stable and
|
|
7
|
+
well-defined (low → high precedence):
|
|
8
|
+
|
|
9
|
+
1) default.yaml — always applied if present
|
|
10
|
+
2) environment.yaml[env] — the block matching the resolved environment
|
|
11
|
+
3) machine.yaml[machine] — the block matching the resolved machine/host
|
|
12
|
+
4) profile.yaml[profile] — the block matching the resolved profile
|
|
13
|
+
5) local.yaml — optional, for developer overrides
|
|
14
|
+
6) overrides (in-memory) — explicit dict passed to `load_config(...)`
|
|
15
|
+
|
|
16
|
+
Resolution helpers in `mxm_config.resolver` normalize `env`, `profile`, and
|
|
17
|
+
`machine` (e.g., deriving defaults from environment variables or hostname).
|
|
18
|
+
|
|
19
|
+
The resulting OmegaConf DictConfig is:
|
|
20
|
+
- fully resolved (interpolations evaluated),
|
|
21
|
+
- merged according to the order above,
|
|
22
|
+
- and set to read-only.
|
|
23
|
+
|
|
24
|
+
Downstream packages should generally import `load_config` via
|
|
25
|
+
`mxm_config.__init__` and type against the `MXMConfig` protocol instead of
|
|
26
|
+
depending on OmegaConf directly.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from collections.abc import Mapping
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any, Callable, Union, cast
|
|
32
|
+
|
|
33
|
+
from omegaconf import DictConfig, ListConfig, OmegaConf
|
|
34
|
+
|
|
35
|
+
from mxm_config.resolver import (
|
|
36
|
+
get_config_root,
|
|
37
|
+
resolve_environment,
|
|
38
|
+
resolve_machine,
|
|
39
|
+
resolve_profile,
|
|
40
|
+
)
|
|
41
|
+
from mxm_config.types import MXMConfig
|
|
42
|
+
|
|
43
|
+
Layer = Union[ListConfig, DictConfig]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_config(
|
|
47
|
+
package: str,
|
|
48
|
+
env: str,
|
|
49
|
+
profile: str,
|
|
50
|
+
machine: str | None = None,
|
|
51
|
+
root: Path | None = None,
|
|
52
|
+
overrides: Mapping[str, Any] | None = None,
|
|
53
|
+
) -> MXMConfig:
|
|
54
|
+
"""
|
|
55
|
+
Load the MXM configuration by composing layered YAML files.
|
|
56
|
+
|
|
57
|
+
The configuration directory is determined by combining the MXM config root
|
|
58
|
+
(``~/.config/mxm`` by default, or overridden by ``MXM_CONFIG_HOME``) and
|
|
59
|
+
the given ``package`` name (e.g. ``demo``). This directory is populated
|
|
60
|
+
when configs are installed using :func:`install_all`.
|
|
61
|
+
|
|
62
|
+
Layers are merged in the following order (lowest → highest precedence):
|
|
63
|
+
|
|
64
|
+
1. ``default.yaml`` — always applied if present
|
|
65
|
+
2. ``environment.yaml`` — only the block matching ``env`` is applied
|
|
66
|
+
3. ``machine.yaml`` — only the block matching current hostname
|
|
67
|
+
4. ``profile.yaml`` — only the block matching ``profile``
|
|
68
|
+
5. ``local.yaml`` — applied if present
|
|
69
|
+
6. explicit overrides dict — passed via ``overrides`` argument
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
package: Name of the config subdirectory under the MXM config root.
|
|
73
|
+
env: Environment selector (e.g. ``"dev"``, ``"prod"``).
|
|
74
|
+
profile: Profile selector (e.g. ``"default"``, ``"research"``).
|
|
75
|
+
machine: Optional explicit machine name override.
|
|
76
|
+
root: Optional config root path override.
|
|
77
|
+
overrides: Optional dictionary of overrides applied last.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
OmegaConf: A frozen OmegaConf config object with all layers
|
|
81
|
+
merged and interpolated.
|
|
82
|
+
"""
|
|
83
|
+
base_root = Path(root) if root is not None else get_config_root()
|
|
84
|
+
cfg_root = base_root / Path(str(package))
|
|
85
|
+
|
|
86
|
+
context_cfg = OmegaConf.create(
|
|
87
|
+
{
|
|
88
|
+
"mxm_env": resolve_environment(env),
|
|
89
|
+
"mxm_profile": resolve_profile(profile),
|
|
90
|
+
"mxm_machine": resolve_machine(machine),
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
layers: list[Layer] = [context_cfg]
|
|
94
|
+
|
|
95
|
+
default_cfg = _load_yaml_if_exists(cfg_root / "default.yaml")
|
|
96
|
+
if default_cfg:
|
|
97
|
+
layers.append(default_cfg)
|
|
98
|
+
|
|
99
|
+
env_cfg = _load_block(env, cfg_root / "environment.yaml", resolve_environment)
|
|
100
|
+
if env_cfg:
|
|
101
|
+
layers.append(env_cfg)
|
|
102
|
+
|
|
103
|
+
machine_cfg = _load_block(machine, cfg_root / "machine.yaml", resolve_machine)
|
|
104
|
+
if machine_cfg:
|
|
105
|
+
layers.append(machine_cfg)
|
|
106
|
+
|
|
107
|
+
profile_cfg = _load_block(
|
|
108
|
+
profile, cfg_root / "profile.yaml", resolve_profile, allow_default_skip=True
|
|
109
|
+
)
|
|
110
|
+
if profile_cfg:
|
|
111
|
+
layers.append(profile_cfg)
|
|
112
|
+
|
|
113
|
+
local_cfg = _load_yaml_if_exists(cfg_root / "local.yaml")
|
|
114
|
+
if local_cfg:
|
|
115
|
+
layers.append(local_cfg)
|
|
116
|
+
|
|
117
|
+
if overrides is not None:
|
|
118
|
+
overrides_cfg: DictConfig = OmegaConf.create(dict(overrides))
|
|
119
|
+
layers.append(overrides_cfg)
|
|
120
|
+
|
|
121
|
+
merged: DictConfig = OmegaConf.merge(*layers) # type: ignore[assignment]
|
|
122
|
+
OmegaConf.resolve(merged)
|
|
123
|
+
OmegaConf.set_readonly(merged, True)
|
|
124
|
+
return cast(MXMConfig, merged)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _load_block(
|
|
128
|
+
selector: str | None,
|
|
129
|
+
path: Path,
|
|
130
|
+
resolver: Callable[[str | None], str],
|
|
131
|
+
allow_default_skip: bool = False,
|
|
132
|
+
) -> DictConfig | None:
|
|
133
|
+
"""
|
|
134
|
+
Resolve and load a configuration block from a YAML file.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
selector: Raw selector value (e.g. env, profile, machine).
|
|
138
|
+
path: Path to the YAML file.
|
|
139
|
+
resolver: Function to normalize the selector.
|
|
140
|
+
allow_default_skip: If True, missing "default" selector will return None
|
|
141
|
+
instead of raising KeyError (used for profiles).
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
DictConfig block for the selector, or None if skipped.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
KeyError: If YAML exists but selector not defined.
|
|
148
|
+
"""
|
|
149
|
+
resolved = resolver(selector)
|
|
150
|
+
|
|
151
|
+
if not path.exists():
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
cfg: DictConfig = OmegaConf.load(path) # type: ignore[assignment]
|
|
155
|
+
|
|
156
|
+
if resolved in cfg:
|
|
157
|
+
return cfg[resolved] # type: ignore[index]
|
|
158
|
+
elif allow_default_skip and resolved == "default":
|
|
159
|
+
return None
|
|
160
|
+
else:
|
|
161
|
+
raise KeyError(
|
|
162
|
+
f"Selector '{resolved}' not found in {path}. Available: {list(cfg.keys())}"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _load_yaml_if_exists(path: Path):
|
|
167
|
+
"""Load a YAML file into an OmegaConf config if it exists, else return None."""
|
|
168
|
+
return OmegaConf.load(path) if path.exists() else None
|
mxm_config/py.typed
ADDED
|
File without changes
|
mxm_config/resolver.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import socket
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_config_root() -> Path:
|
|
7
|
+
"""
|
|
8
|
+
Resolve the MXM config root.
|
|
9
|
+
|
|
10
|
+
Precedence:
|
|
11
|
+
1) MXM_CONFIG_HOME -> <dir>
|
|
12
|
+
2) XDG_CONFIG_HOME -> <dir>/mxm
|
|
13
|
+
3) HOME -> <HOME>/.config/mxm
|
|
14
|
+
"""
|
|
15
|
+
override = os.getenv("MXM_CONFIG_HOME")
|
|
16
|
+
if override:
|
|
17
|
+
return Path(override).expanduser()
|
|
18
|
+
|
|
19
|
+
xdg = os.getenv("XDG_CONFIG_HOME")
|
|
20
|
+
if xdg:
|
|
21
|
+
return Path(xdg).expanduser() / "mxm"
|
|
22
|
+
|
|
23
|
+
return Path.home() / ".config" / "mxm"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def resolve_environment(env: str | None = None) -> str:
|
|
27
|
+
"""Resolve environment (must be explicitly provided).
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
env: The chosen environment (e.g. "dev", "prod").
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
The environment string.
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
ValueError: If env is not provided.
|
|
37
|
+
"""
|
|
38
|
+
if env is None:
|
|
39
|
+
raise ValueError("Environment must be specified (e.g. 'dev', 'prod').")
|
|
40
|
+
return env
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resolve_profile(profile: str | None = None) -> str:
|
|
44
|
+
"""Resolve profile (must be explicitly provided).
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
profile: The chosen profile (e.g. "research", "trading").
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
The profile string.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
ValueError: If profile is not provided.
|
|
54
|
+
"""
|
|
55
|
+
if profile is None:
|
|
56
|
+
raise ValueError("Profile must be specified (e.g. 'research', 'trading').")
|
|
57
|
+
return profile
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolve_machine(machine: str | None = None) -> str:
|
|
61
|
+
"""Resolve machine identifier.
|
|
62
|
+
|
|
63
|
+
Resolution order:
|
|
64
|
+
1. Explicit argument
|
|
65
|
+
2. Environment variable: MXM_MACHINE
|
|
66
|
+
3. Fallback to system hostname
|
|
67
|
+
"""
|
|
68
|
+
if machine is not None:
|
|
69
|
+
return machine
|
|
70
|
+
|
|
71
|
+
env_machine = os.getenv("MXM_MACHINE")
|
|
72
|
+
if env_machine:
|
|
73
|
+
return env_machine
|
|
74
|
+
hostname = socket.gethostname().lower()
|
|
75
|
+
if hostname.endswith(".local"):
|
|
76
|
+
hostname = hostname[:-6]
|
|
77
|
+
return hostname
|
mxm_config/types.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Lightweight protocol(s) for application configuration objects.
|
|
3
|
+
|
|
4
|
+
`MXMConfig` is a runtime-agnostic interface that downstream packages can
|
|
5
|
+
type against without importing OmegaConf. It models the two access styles
|
|
6
|
+
we support post-load:
|
|
7
|
+
|
|
8
|
+
- Attribute access (dot-notation): `cfg.paths.sources.justetf.root`
|
|
9
|
+
- Item access (mapping-style): `cfg["paths"]["sources"]["justetf"]["root"]`
|
|
10
|
+
|
|
11
|
+
Any object that implements these (e.g., OmegaConf DictConfig, SimpleNamespace
|
|
12
|
+
with __getitem__ shim, or a small wrapper) will satisfy this protocol.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any, Protocol, runtime_checkable
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@runtime_checkable
|
|
21
|
+
class MXMConfig(Protocol):
|
|
22
|
+
"""Opaque app config supporting attribute and item access.
|
|
23
|
+
|
|
24
|
+
Notes
|
|
25
|
+
-----
|
|
26
|
+
- This is intentionally minimal. Do not add OmegaConf-specific methods.
|
|
27
|
+
- Keep it broad so tests can pass lightweight stand-ins.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
# Attribute access: cfg.foo
|
|
31
|
+
def __getattr__(self, key: str) -> Any: ...
|
|
32
|
+
|
|
33
|
+
# Item access: cfg["foo"]
|
|
34
|
+
def __getitem__(self, key: str) -> Any: ...
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mxm-config
|
|
3
|
+
Version: 0.2.5
|
|
4
|
+
Summary: MXM configuration loader and context resolver
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: mxm,configuration,omegaconf,settings
|
|
8
|
+
Author: mxm
|
|
9
|
+
Author-email: contact@moneyexmachina.com
|
|
10
|
+
Requires-Python: >=3.12,<4.0
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Dist: omegaconf (>=2.3.0,<3.0.0)
|
|
20
|
+
Project-URL: Homepage, https://github.com/moneyexmachina/mxm-config
|
|
21
|
+
Project-URL: Issues, https://github.com/moneyexmachina/mxm-config/issues
|
|
22
|
+
Project-URL: Repository, https://github.com/moneyexmachina/mxm-config
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# mxm-config
|
|
26
|
+
|
|
27
|
+

|
|
28
|
+

|
|
29
|
+

|
|
30
|
+
[](https://microsoft.github.io/pyright/)
|
|
31
|
+
|
|
32
|
+
## Purpose
|
|
33
|
+
|
|
34
|
+
`mxm-config` provides a unified way to **install, load, layer, and resolve configuration** across all Money Ex Machina (MXM) packages and applications.
|
|
35
|
+
It separates configuration from secrets and runtime metadata, enforces deterministic layering, and ensures every run has a transparent, reproducible view of its operating context.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Design Principles
|
|
40
|
+
|
|
41
|
+
- **Separation of concerns**
|
|
42
|
+
- Configuration ≠ secrets ≠ runtime.
|
|
43
|
+
- Secrets are handled by [`mxm-secrets`](https://github.com/moneyexmachina/mxm-secrets).
|
|
44
|
+
- Runtime metadata will be handled by `mxm-runtime` (planned).
|
|
45
|
+
|
|
46
|
+
- **Determinism**
|
|
47
|
+
- Configuration is layered in a fixed, documented order.
|
|
48
|
+
- Reproducible runs: the same context always produces the same resolved config.
|
|
49
|
+
|
|
50
|
+
- **Transparency**
|
|
51
|
+
- Configs are plain YAML files, no hidden state.
|
|
52
|
+
- Merging order is explicit and testable.
|
|
53
|
+
|
|
54
|
+
- **Extensibility**
|
|
55
|
+
- Layers are minimal and orthogonal.
|
|
56
|
+
- New packages can register defaults without breaking existing ones.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Configuration Layers
|
|
61
|
+
|
|
62
|
+
At runtime, configuration is resolved by merging up to six layers in order of precedence (lowest → highest):
|
|
63
|
+
|
|
64
|
+
1. **`default.yaml`**
|
|
65
|
+
Baseline shipped with the package.
|
|
66
|
+
*Always present.*
|
|
67
|
+
|
|
68
|
+
2. **`environment.yaml`**
|
|
69
|
+
Deployment mode (`dev`, `prod`, …).
|
|
70
|
+
Each environment is a block inside this file.
|
|
71
|
+
|
|
72
|
+
3. **`machine.yaml`**
|
|
73
|
+
Host-specific overrides (paths, mounts, resources).
|
|
74
|
+
|
|
75
|
+
4. **`profile.yaml`**
|
|
76
|
+
Role or user context (`research`, `trading`, …).
|
|
77
|
+
|
|
78
|
+
5. **`local.yaml`**
|
|
79
|
+
Local scratchpad for ad-hoc tweaks.
|
|
80
|
+
*Ignored by version control.*
|
|
81
|
+
|
|
82
|
+
6. **Explicit overrides (dict)**
|
|
83
|
+
Passed directly in code, applied last.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Installing Configs
|
|
88
|
+
|
|
89
|
+
Use the installer to copy package-shipped configs into the user’s config root (`~/.config/mxm/` by default, override with `$MXM_CONFIG_HOME`).
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from mxm_config.installer import install_all
|
|
93
|
+
|
|
94
|
+
install_all("mxm_config.examples.demo_config", target_name="demo")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
This creates:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
~/.config/mxm/demo/default.yaml
|
|
101
|
+
~/.config/mxm/demo/environment.yaml
|
|
102
|
+
~/.config/mxm/demo/machine.yaml
|
|
103
|
+
~/.config/mxm/demo/profile.yaml
|
|
104
|
+
~/.config/mxm/demo/local.yaml
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Any `templates/*.yaml` files shipped with the package will also be installed under `~/.config/mxm/<package>/templates/`.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Loading Configs
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from mxm_config.loader import load_config
|
|
115
|
+
|
|
116
|
+
cfg = load_config("demo", env="dev", profile="research")
|
|
117
|
+
|
|
118
|
+
print(cfg.parameters.refresh_interval)
|
|
119
|
+
print(cfg.paths.output)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
- Context (`mxm_env`, `mxm_profile`, `mxm_machine`) is injected automatically.
|
|
123
|
+
- All `${...}` interpolations are resolved before returning.
|
|
124
|
+
- The returned config is read-only by default.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Example Package
|
|
129
|
+
|
|
130
|
+
The repo ships a minimal demo package: `mxm_config/examples/demo_config`
|
|
131
|
+
|
|
132
|
+
- `default.yaml` → valid baseline
|
|
133
|
+
- `environment.yaml` → defines `dev` and `prod`
|
|
134
|
+
- `machine.yaml` → overrides per host (`bridge`, `wildling`, `monolith`)
|
|
135
|
+
- `profile.yaml` → defines `research`, `trading`
|
|
136
|
+
- `local.yaml` → local overrides (optional, not versioned)
|
|
137
|
+
|
|
138
|
+
This serves as a test fixture for installers and loaders.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Testing
|
|
143
|
+
|
|
144
|
+
Tests use `pytest` with `monkeypatch` to isolate config roots and hostnames.
|
|
145
|
+
|
|
146
|
+
Run with:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
poetry run pytest
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Roadmap
|
|
155
|
+
|
|
156
|
+
- Config schema validation (via `omegaconf.structured` or pydantic)
|
|
157
|
+
- CLI tool (`mxm-config install demo`)
|
|
158
|
+
- Environment variable overrides → auto-mapped into overrides dict
|
|
159
|
+
- Integration with `mxm-runtime` for provenance tracking
|
|
160
|
+
- Config hashing for reproducibility and auditability
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## License
|
|
165
|
+
|
|
166
|
+
MIT License. See [LICENSE](LICENSE).
|
|
167
|
+
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
mxm_config/__init__.py,sha256=Ce7QRf0rC-_62Lw-IJdyA0TlnMx00TkP5qxq3Ltli-I,1441
|
|
2
|
+
mxm_config/api.py,sha256=GDB9OO_TK7n-Xrb9Sys8ucQrjb-FNDreV-wRp1hI1pc,2063
|
|
3
|
+
mxm_config/examples/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
mxm_config/examples/demo_config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
mxm_config/examples/demo_config/default.yaml,sha256=bf28aSqGdduVxHhIdWPuq8W91vhOrupdh5XRIzOmWCk,217
|
|
6
|
+
mxm_config/examples/demo_config/environment.yaml,sha256=Pvf6dCcF6N_nRNlddhblX6nnA39bE5_0s3y1o46qbxc,141
|
|
7
|
+
mxm_config/examples/demo_config/local.yaml,sha256=J7W8XlqBRLae4l4Q5_SEr8qUUAkHukInzuoPhmdwLrU,31
|
|
8
|
+
mxm_config/examples/demo_config/machine.yaml,sha256=CgTE614z8fB9LeGvxiWFCDeJex9ut1jKd0qn8J4wsX8,189
|
|
9
|
+
mxm_config/examples/demo_config/profile.yaml,sha256=BvTq9agU_pmEZTMj-xF9ijWVj7WdM0vgqtB8S4vC1zA,127
|
|
10
|
+
mxm_config/init_resolvers.py,sha256=anbc7xhN-h6ziHTn8W8vHGGMlpjnl5CWTZUQDq8W7lQ,2445
|
|
11
|
+
mxm_config/initializer.py,sha256=oiTG-GffvUsczq-cxWzhiQUzherbnkGk2IfwN4asp6U,627
|
|
12
|
+
mxm_config/installer.py,sha256=Zr_HCVY3HhDGkgFyqOARw_X7G6AecdmG0SF2mkkgETU,2182
|
|
13
|
+
mxm_config/loader.py,sha256=sn3S_Vzel9_NPbqbf81hu3ZFiVSgTGSjEmSC1Tw90Eg,5930
|
|
14
|
+
mxm_config/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
mxm_config/resolver.py,sha256=8ZaHojOsJnyzgMvW_zWAjSq5kDnUMgc9bLcQVtMwYoU,1866
|
|
16
|
+
mxm_config/types.py,sha256=9KvDspPjXyBD00qn4BBRkRuzf7pz8CVVvvkiKxOexvE,1066
|
|
17
|
+
mxm_config-0.2.5.dist-info/METADATA,sha256=H79A9IrB6HC-yX0hiaJy9UBoQtx5LgOTUk4DDPmXo8Q,4972
|
|
18
|
+
mxm_config-0.2.5.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
19
|
+
mxm_config-0.2.5.dist-info/licenses/LICENSE,sha256=xq8z6L9uQsxwFsDj_rw0gXHSqp5V--ukyKkts-q4Zsc,1101
|
|
20
|
+
mxm_config-0.2.5.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Money Ex Machina
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the “Software”), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|