mxm-config 0.2.5__tar.gz

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.
@@ -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.
@@ -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
+ ![Version](https://img.shields.io/github/v/release/moneyexmachina/mxm-config)
28
+ ![License](https://img.shields.io/github/license/moneyexmachina/mxm-config)
29
+ ![Python](https://img.shields.io/badge/python-3.12+-blue)
30
+ [![Checked with pyright](https://microsoft.github.io/pyright/img/pyright_badge.svg)](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,142 @@
1
+ # mxm-config
2
+
3
+ ![Version](https://img.shields.io/github/v/release/moneyexmachina/mxm-config)
4
+ ![License](https://img.shields.io/github/license/moneyexmachina/mxm-config)
5
+ ![Python](https://img.shields.io/badge/python-3.12+-blue)
6
+ [![Checked with pyright](https://microsoft.github.io/pyright/img/pyright_badge.svg)](https://microsoft.github.io/pyright/)
7
+
8
+ ## Purpose
9
+
10
+ `mxm-config` provides a unified way to **install, load, layer, and resolve configuration** across all Money Ex Machina (MXM) packages and applications.
11
+ It separates configuration from secrets and runtime metadata, enforces deterministic layering, and ensures every run has a transparent, reproducible view of its operating context.
12
+
13
+ ---
14
+
15
+ ## Design Principles
16
+
17
+ - **Separation of concerns**
18
+ - Configuration ≠ secrets ≠ runtime.
19
+ - Secrets are handled by [`mxm-secrets`](https://github.com/moneyexmachina/mxm-secrets).
20
+ - Runtime metadata will be handled by `mxm-runtime` (planned).
21
+
22
+ - **Determinism**
23
+ - Configuration is layered in a fixed, documented order.
24
+ - Reproducible runs: the same context always produces the same resolved config.
25
+
26
+ - **Transparency**
27
+ - Configs are plain YAML files, no hidden state.
28
+ - Merging order is explicit and testable.
29
+
30
+ - **Extensibility**
31
+ - Layers are minimal and orthogonal.
32
+ - New packages can register defaults without breaking existing ones.
33
+
34
+ ---
35
+
36
+ ## Configuration Layers
37
+
38
+ At runtime, configuration is resolved by merging up to six layers in order of precedence (lowest → highest):
39
+
40
+ 1. **`default.yaml`**
41
+ Baseline shipped with the package.
42
+ *Always present.*
43
+
44
+ 2. **`environment.yaml`**
45
+ Deployment mode (`dev`, `prod`, …).
46
+ Each environment is a block inside this file.
47
+
48
+ 3. **`machine.yaml`**
49
+ Host-specific overrides (paths, mounts, resources).
50
+
51
+ 4. **`profile.yaml`**
52
+ Role or user context (`research`, `trading`, …).
53
+
54
+ 5. **`local.yaml`**
55
+ Local scratchpad for ad-hoc tweaks.
56
+ *Ignored by version control.*
57
+
58
+ 6. **Explicit overrides (dict)**
59
+ Passed directly in code, applied last.
60
+
61
+ ---
62
+
63
+ ## Installing Configs
64
+
65
+ Use the installer to copy package-shipped configs into the user’s config root (`~/.config/mxm/` by default, override with `$MXM_CONFIG_HOME`).
66
+
67
+ ```python
68
+ from mxm_config.installer import install_all
69
+
70
+ install_all("mxm_config.examples.demo_config", target_name="demo")
71
+ ```
72
+
73
+ This creates:
74
+
75
+ ```
76
+ ~/.config/mxm/demo/default.yaml
77
+ ~/.config/mxm/demo/environment.yaml
78
+ ~/.config/mxm/demo/machine.yaml
79
+ ~/.config/mxm/demo/profile.yaml
80
+ ~/.config/mxm/demo/local.yaml
81
+ ```
82
+
83
+ Any `templates/*.yaml` files shipped with the package will also be installed under `~/.config/mxm/<package>/templates/`.
84
+
85
+ ---
86
+
87
+ ## Loading Configs
88
+
89
+ ```python
90
+ from mxm_config.loader import load_config
91
+
92
+ cfg = load_config("demo", env="dev", profile="research")
93
+
94
+ print(cfg.parameters.refresh_interval)
95
+ print(cfg.paths.output)
96
+ ```
97
+
98
+ - Context (`mxm_env`, `mxm_profile`, `mxm_machine`) is injected automatically.
99
+ - All `${...}` interpolations are resolved before returning.
100
+ - The returned config is read-only by default.
101
+
102
+ ---
103
+
104
+ ## Example Package
105
+
106
+ The repo ships a minimal demo package: `mxm_config/examples/demo_config`
107
+
108
+ - `default.yaml` → valid baseline
109
+ - `environment.yaml` → defines `dev` and `prod`
110
+ - `machine.yaml` → overrides per host (`bridge`, `wildling`, `monolith`)
111
+ - `profile.yaml` → defines `research`, `trading`
112
+ - `local.yaml` → local overrides (optional, not versioned)
113
+
114
+ This serves as a test fixture for installers and loaders.
115
+
116
+ ---
117
+
118
+ ## Testing
119
+
120
+ Tests use `pytest` with `monkeypatch` to isolate config roots and hostnames.
121
+
122
+ Run with:
123
+
124
+ ```bash
125
+ poetry run pytest
126
+ ```
127
+
128
+ ---
129
+
130
+ ## Roadmap
131
+
132
+ - Config schema validation (via `omegaconf.structured` or pydantic)
133
+ - CLI tool (`mxm-config install demo`)
134
+ - Environment variable overrides → auto-mapped into overrides dict
135
+ - Integration with `mxm-runtime` for provenance tracking
136
+ - Config hashing for reproducibility and auditability
137
+
138
+ ---
139
+
140
+ ## License
141
+
142
+ MIT License. See [LICENSE](LICENSE).
@@ -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
+ ]
@@ -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
@@ -0,0 +1,10 @@
1
+ project: "mxm-config demo"
2
+ version: "0.1.0"
3
+
4
+ paths:
5
+ output: "${paths.base_output}/${mxm_env}/${mxm_profile}"
6
+ logs: "${paths.base_output}/logs/${mxm_env}"
7
+
8
+ parameters:
9
+ refresh_interval: "5min"
10
+ sample_count: 10
@@ -0,0 +1,9 @@
1
+ dev:
2
+ parameters:
3
+ sample_count: 1
4
+ refresh_interval: "1min"
5
+
6
+ prod:
7
+ parameters:
8
+ sample_count: 100
9
+ refresh_interval: "10min"
@@ -0,0 +1,2 @@
1
+ parameters:
2
+ sample_count: 42
@@ -0,0 +1,11 @@
1
+ bridge:
2
+ paths:
3
+ base_output: "/Users/mxm/demo_output"
4
+
5
+ wildling:
6
+ paths:
7
+ base_output: "/mnt/wildling/demo_output"
8
+
9
+ monolith:
10
+ paths:
11
+ base_output: "/srv/monolith/demo_output"
@@ -0,0 +1,8 @@
1
+ research:
2
+ parameters:
3
+ refresh_interval: "30min"
4
+
5
+ trading:
6
+ parameters:
7
+ refresh_interval: "5s"
8
+ sample_count: 999
@@ -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
@@ -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
@@ -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
File without changes
@@ -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
@@ -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,68 @@
1
+ [tool.poetry]
2
+ name = "mxm-config"
3
+ version = "0.2.5"
4
+ description = "MXM configuration loader and context resolver"
5
+ authors = ["mxm <contact@moneyexmachina.com>"]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+ keywords = ["mxm", "configuration", "omegaconf", "settings"]
9
+ exclude = ["dist/*", "build/*", "*.egg-info/*", ".venv/*"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Programming Language :: Python :: 3 :: Only",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Programming Language :: Python :: 3.13",
16
+ "Typing :: Typed",
17
+ ]
18
+ packages = [{ include = "mxm_config" }]
19
+ include = [
20
+ "LICENSE",
21
+ "mxm_config/examples/demo_config/*.yaml",
22
+ "mxm_config/py.typed",
23
+ ]
24
+ [tool.poetry.urls]
25
+ "Homepage" = "https://github.com/moneyexmachina/mxm-config"
26
+ "Repository" = "https://github.com/moneyexmachina/mxm-config"
27
+ "Issues" = "https://github.com/moneyexmachina/mxm-config/issues"
28
+ [tool.poetry.dependencies]
29
+ python = ">=3.12,<4.0"
30
+ omegaconf = ">=2.3.0,<3.0.0"
31
+
32
+ [tool.poetry.group.dev.dependencies]
33
+ ruff = "^0.13.0"
34
+ black = "^25.1.0"
35
+ isort = "^6.0.1"
36
+ ipython = "^9.5.0"
37
+ pytest = "^8.4.2"
38
+
39
+ [build-system]
40
+ requires = ["poetry-core>=2.0.0"]
41
+ build-backend = "poetry.core.masonry.api"
42
+
43
+ [tool.ruff]
44
+ line-length = 88
45
+ target-version = "py312"
46
+ select = ["E", "F", "B", "ANN"]
47
+ ignore = ["ANN101", "ANN102"]
48
+ exclude = ["build", "dist", ".venv"]
49
+
50
+ [tool.black]
51
+ line-length = 88
52
+ target-version = ["py312"]
53
+
54
+ [tool.isort]
55
+ profile = "black"
56
+ line_length = 88
57
+ known_first_party = ["mxm_config"]
58
+ known_third_party = ["omegaconf"]
59
+ combine_as_imports = true
60
+
61
+ [tool.pyright]
62
+ typeCheckingMode = "strict"
63
+ include = ["mxm_config"]
64
+ exclude = ["build", "dist", "docs", ".venv"]
65
+ pythonVersion = "3.12"
66
+ pythonPlatform = "All"
67
+ reportMissingImports = "error"
68
+ reportMissingTypeStubs = "warning"