megaconf 0.1.0__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.
megaconf-0.1.0/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Richard Wessels
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,230 @@
1
+ Metadata-Version: 2.4
2
+ Name: megaconf
3
+ Version: 0.1.0
4
+ Summary: Easily create many variations of a config.
5
+ Author: Richard Wessels
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Dist: numpy>=2.5.2
11
+ Requires-Dist: pydantic>=2.13.5
12
+ Requires-Dist: pyyaml>=6.0.3
13
+ Requires-Python: >=3.12
14
+ Project-URL: Homepage, https://github.com/RichardWessels/megaconf
15
+ Project-URL: Issues, https://github.com/RichardWessels/megaconf/issues
16
+ Description-Content-Type: text/markdown
17
+
18
+ # megaconf
19
+
20
+ megaconf is a tool to help with producing configs. This tool allows you to describe configuration combinations declaratively, instead of writing nested loops in code.
21
+
22
+ ## How it works
23
+
24
+ megaconf generates configurations from two inputs:
25
+
26
+ 1. Base config – a starting configuration.
27
+ 2. Override config – rules describing how to modify the base config to produce multiple variants.
28
+
29
+ The result is a set of generated configs.
30
+
31
+ ## Example
32
+
33
+ Suppose we start with a base config:
34
+
35
+ ```yaml
36
+ algo: NN
37
+ lr: 0.001
38
+ layer_count: 8
39
+ activation: ReLU
40
+ initializer: He
41
+ ```
42
+
43
+ We want to run experiments across different algorithms and hyperparameters.
44
+
45
+ This might look like:
46
+
47
+ ```python
48
+ for algo in ["XGBoost", "NN"]:
49
+ for lr in [0.1, 0.01, 0.001]:
50
+ if algo == "NN":
51
+ for layer_count in [4, 8, 12, 16]:
52
+ for activation in ["ReLU", "Tanh"]:
53
+ ...
54
+ ```
55
+
56
+ As the number of parameters and dependencies grows, this approach becomes hard to maintain.
57
+
58
+ megaconf allows expressing the same logic using override rules.
59
+
60
+ Override Config:
61
+ ```yaml
62
+ - fixed:
63
+ algo: XGBoost
64
+ product:
65
+ lr: [0.1, 0.01, 0.001]
66
+
67
+ - fixed:
68
+ algo: NN
69
+ joint:
70
+ activation: [ReLU, Tanh]
71
+ initializer: [He, Glorot]
72
+ product:
73
+ lr: [0.1, 0.01, 0.001]
74
+ layer_count: [4, 8, 12, 16]
75
+ ```
76
+
77
+ ## Override Methods
78
+
79
+ megaconf supports three override methods.
80
+
81
+ ---
82
+
83
+ ### `fixed`
84
+
85
+ Sets specific values in the config.
86
+
87
+ ```yaml
88
+ fixed:
89
+ algo: NN
90
+ ```
91
+
92
+ Equivalent to:
93
+
94
+ ```python
95
+ config["algo"] = "NN"
96
+ ```
97
+
98
+ If the key does not exist in the base config, it is added.
99
+
100
+ ---
101
+
102
+ ### `joint`
103
+
104
+ Iterates over multiple parameters together.
105
+
106
+ All lists must be the same length.
107
+
108
+ ```yaml
109
+ joint:
110
+ activation: [ReLU, Tanh]
111
+ initializer: [He, Glorot]
112
+ ```
113
+
114
+ Result:
115
+ ```
116
+ activation=ReLU initializer=He
117
+ activation=Tanh initializer=Glorot
118
+ ```
119
+
120
+ This is useful for paired parameters.
121
+
122
+ ---
123
+
124
+ ### `product`
125
+
126
+ Produces the cartesian product of parameter values.
127
+
128
+ ```yaml
129
+ product:
130
+ lr: [0.1, 0.01, 0.001]
131
+ layer_count: [4, 8, 12]
132
+ ```
133
+
134
+ This generates:
135
+
136
+ ```
137
+ lr=0.1 layer_count=4
138
+ lr=0.1 layer_count=8
139
+ lr=0.1 layer_count=12
140
+ lr=0.01 layer_count=4
141
+ ...
142
+ ```
143
+
144
+ This is equivalent to a grid search.
145
+
146
+ ## How Overrides Are Combined
147
+
148
+ Within an override block:
149
+
150
+ 1. fixed values are applied first.
151
+ 2. joint combinations are generated.
152
+ 3. product combinations are generated.
153
+
154
+ The final configs are the combination of these rules applied to the base config.
155
+
156
+ ## Interface
157
+
158
+ Create configurations with `generate_configs`. It accepts a base config and an
159
+ overrides config.
160
+
161
+ Both inputs can be provided in two ways:
162
+ - as a Python object (e.g., a dictionary or list).
163
+ - as a path to a configuration file (.yaml, .yml, or .json).
164
+
165
+ `generate_configs` returns an iterator that yields each generated config. The
166
+ number of generated configs depends on the combinations produced by the override
167
+ rules. Use `generate_configs_list` if you need all generated configs in a list.
168
+
169
+ Example usage:
170
+
171
+ ```python
172
+ from megaconf import generate_configs
173
+
174
+ base_config = {
175
+ "algo": "NN",
176
+ "lr": 0.001,
177
+ "layer_count": 8,
178
+ "activation": "ReLU",
179
+ "initializer": "He"
180
+ }
181
+
182
+ overrides = [
183
+ {
184
+ "fixed": {
185
+ "algo": "XGBoost"
186
+ },
187
+ "product": {
188
+ "lr": [0.1, 0.01, 0.001]
189
+ }
190
+ },
191
+ {
192
+ "fixed": {
193
+ "algo": "NN"
194
+ },
195
+ "joint": {
196
+ "activation": ["ReLU", "Tanh"],
197
+ "initializer": ["He", "Glorot"]
198
+ },
199
+ "product": {
200
+ "lr": [0.1, 0.01],
201
+ "layer_count": [4, 8]
202
+ }
203
+ }
204
+ ]
205
+
206
+ configs = generate_configs(base_config, overrides)
207
+ ```
208
+
209
+ Or if the base and overrides separate config files:
210
+ ```python
211
+ configs = generate_configs(
212
+ "base_config.yaml",
213
+ "overrides.yaml"
214
+ )
215
+ ```
216
+
217
+ ## Nesting
218
+ If a key is deeply nested in the config, a dot-key separator is used by default. This means that specifying a nested key is done as follows:
219
+ ```yaml
220
+ fixed:
221
+ key1.key2.key3: value
222
+ ```
223
+ However, if a dot conflicts with your key strings, you can specify a custom key separator as follows:
224
+ ```python
225
+ configs = generate_configs(
226
+ "base_config.yaml",
227
+ "overrides.yaml",
228
+ key_separator="::"
229
+ )
230
+ ```
@@ -0,0 +1,213 @@
1
+ # megaconf
2
+
3
+ megaconf is a tool to help with producing configs. This tool allows you to describe configuration combinations declaratively, instead of writing nested loops in code.
4
+
5
+ ## How it works
6
+
7
+ megaconf generates configurations from two inputs:
8
+
9
+ 1. Base config – a starting configuration.
10
+ 2. Override config – rules describing how to modify the base config to produce multiple variants.
11
+
12
+ The result is a set of generated configs.
13
+
14
+ ## Example
15
+
16
+ Suppose we start with a base config:
17
+
18
+ ```yaml
19
+ algo: NN
20
+ lr: 0.001
21
+ layer_count: 8
22
+ activation: ReLU
23
+ initializer: He
24
+ ```
25
+
26
+ We want to run experiments across different algorithms and hyperparameters.
27
+
28
+ This might look like:
29
+
30
+ ```python
31
+ for algo in ["XGBoost", "NN"]:
32
+ for lr in [0.1, 0.01, 0.001]:
33
+ if algo == "NN":
34
+ for layer_count in [4, 8, 12, 16]:
35
+ for activation in ["ReLU", "Tanh"]:
36
+ ...
37
+ ```
38
+
39
+ As the number of parameters and dependencies grows, this approach becomes hard to maintain.
40
+
41
+ megaconf allows expressing the same logic using override rules.
42
+
43
+ Override Config:
44
+ ```yaml
45
+ - fixed:
46
+ algo: XGBoost
47
+ product:
48
+ lr: [0.1, 0.01, 0.001]
49
+
50
+ - fixed:
51
+ algo: NN
52
+ joint:
53
+ activation: [ReLU, Tanh]
54
+ initializer: [He, Glorot]
55
+ product:
56
+ lr: [0.1, 0.01, 0.001]
57
+ layer_count: [4, 8, 12, 16]
58
+ ```
59
+
60
+ ## Override Methods
61
+
62
+ megaconf supports three override methods.
63
+
64
+ ---
65
+
66
+ ### `fixed`
67
+
68
+ Sets specific values in the config.
69
+
70
+ ```yaml
71
+ fixed:
72
+ algo: NN
73
+ ```
74
+
75
+ Equivalent to:
76
+
77
+ ```python
78
+ config["algo"] = "NN"
79
+ ```
80
+
81
+ If the key does not exist in the base config, it is added.
82
+
83
+ ---
84
+
85
+ ### `joint`
86
+
87
+ Iterates over multiple parameters together.
88
+
89
+ All lists must be the same length.
90
+
91
+ ```yaml
92
+ joint:
93
+ activation: [ReLU, Tanh]
94
+ initializer: [He, Glorot]
95
+ ```
96
+
97
+ Result:
98
+ ```
99
+ activation=ReLU initializer=He
100
+ activation=Tanh initializer=Glorot
101
+ ```
102
+
103
+ This is useful for paired parameters.
104
+
105
+ ---
106
+
107
+ ### `product`
108
+
109
+ Produces the cartesian product of parameter values.
110
+
111
+ ```yaml
112
+ product:
113
+ lr: [0.1, 0.01, 0.001]
114
+ layer_count: [4, 8, 12]
115
+ ```
116
+
117
+ This generates:
118
+
119
+ ```
120
+ lr=0.1 layer_count=4
121
+ lr=0.1 layer_count=8
122
+ lr=0.1 layer_count=12
123
+ lr=0.01 layer_count=4
124
+ ...
125
+ ```
126
+
127
+ This is equivalent to a grid search.
128
+
129
+ ## How Overrides Are Combined
130
+
131
+ Within an override block:
132
+
133
+ 1. fixed values are applied first.
134
+ 2. joint combinations are generated.
135
+ 3. product combinations are generated.
136
+
137
+ The final configs are the combination of these rules applied to the base config.
138
+
139
+ ## Interface
140
+
141
+ Create configurations with `generate_configs`. It accepts a base config and an
142
+ overrides config.
143
+
144
+ Both inputs can be provided in two ways:
145
+ - as a Python object (e.g., a dictionary or list).
146
+ - as a path to a configuration file (.yaml, .yml, or .json).
147
+
148
+ `generate_configs` returns an iterator that yields each generated config. The
149
+ number of generated configs depends on the combinations produced by the override
150
+ rules. Use `generate_configs_list` if you need all generated configs in a list.
151
+
152
+ Example usage:
153
+
154
+ ```python
155
+ from megaconf import generate_configs
156
+
157
+ base_config = {
158
+ "algo": "NN",
159
+ "lr": 0.001,
160
+ "layer_count": 8,
161
+ "activation": "ReLU",
162
+ "initializer": "He"
163
+ }
164
+
165
+ overrides = [
166
+ {
167
+ "fixed": {
168
+ "algo": "XGBoost"
169
+ },
170
+ "product": {
171
+ "lr": [0.1, 0.01, 0.001]
172
+ }
173
+ },
174
+ {
175
+ "fixed": {
176
+ "algo": "NN"
177
+ },
178
+ "joint": {
179
+ "activation": ["ReLU", "Tanh"],
180
+ "initializer": ["He", "Glorot"]
181
+ },
182
+ "product": {
183
+ "lr": [0.1, 0.01],
184
+ "layer_count": [4, 8]
185
+ }
186
+ }
187
+ ]
188
+
189
+ configs = generate_configs(base_config, overrides)
190
+ ```
191
+
192
+ Or if the base and overrides separate config files:
193
+ ```python
194
+ configs = generate_configs(
195
+ "base_config.yaml",
196
+ "overrides.yaml"
197
+ )
198
+ ```
199
+
200
+ ## Nesting
201
+ If a key is deeply nested in the config, a dot-key separator is used by default. This means that specifying a nested key is done as follows:
202
+ ```yaml
203
+ fixed:
204
+ key1.key2.key3: value
205
+ ```
206
+ However, if a dot conflicts with your key strings, you can specify a custom key separator as follows:
207
+ ```python
208
+ configs = generate_configs(
209
+ "base_config.yaml",
210
+ "overrides.yaml",
211
+ key_separator="::"
212
+ )
213
+ ```
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "megaconf"
3
+ version = "0.1.0"
4
+ description = "Easily create many variations of a config."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "numpy>=2.5.2",
9
+ "pydantic>=2.13.5",
10
+ "pyyaml>=6.0.3",
11
+ ]
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "Operating System :: OS Independent",
15
+ ]
16
+ license = "MIT"
17
+ license-files = ["LICENSE"]
18
+
19
+ [[project.authors]]
20
+ name = "Richard Wessels"
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/RichardWessels/megaconf"
24
+ Issues = "https://github.com/RichardWessels/megaconf/issues"
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "pytest>=9.1.1",
29
+ "ruff>=0.16.5",
30
+ ]
31
+
32
+ [build-system]
33
+ requires = ["uv_build>=0.12.5,<0.13.0"]
34
+ build-backend = "uv_build"
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "megaconf"
3
+ version = "0.1.0"
4
+ description = "Easily create many variations of a config."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Richard Wessels" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "numpy>=2.5.2",
12
+ "pydantic>=2.13.5",
13
+ "pyyaml>=6.0.3",
14
+ ]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Operating System :: OS Independent",
18
+ ]
19
+ license = "MIT"
20
+ license-files = ["LICENSE"]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/RichardWessels/megaconf"
24
+ Issues = "https://github.com/RichardWessels/megaconf/issues"
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "pytest>=9.1.1",
29
+ "ruff>=0.16.5",
30
+ ]
31
+
32
+ [build-system]
33
+ requires = ["uv_build>=0.12.5,<0.13.0"]
34
+ build-backend = "uv_build"
@@ -0,0 +1,3 @@
1
+ from .generate import generate_configs, generate_configs_list
2
+
3
+ __all__ = ["generate_configs", "generate_configs_list"]
@@ -0,0 +1,78 @@
1
+ import random
2
+ from collections.abc import Iterator
3
+ from typing import Literal
4
+
5
+ import numpy as np
6
+
7
+
8
+ def get_joint_generator(joint_config: dict[str, list]) -> Iterator[dict]:
9
+ """Yield joint overrides by zipping aligned list values.
10
+
11
+ Args:
12
+ joint_config: Mapping of keys to equally sized lists.
13
+
14
+ Yields:
15
+ Dictionaries containing one value per key at each shared index.
16
+ """
17
+ if not joint_config:
18
+ yield {}
19
+ return
20
+
21
+ list_length = len(next(iter(joint_config.values())))
22
+
23
+ for i in range(list_length):
24
+ yield {k: v[i] for k, v in joint_config.items()}
25
+
26
+
27
+ def get_product_generator(
28
+ product_config: dict[str, list],
29
+ sampling: Literal["without_replacement", "with_replacement"] | None = None,
30
+ n_samples: int | None = None,
31
+ ) -> Iterator[dict]:
32
+ """Yield product overrides from the Cartesian product of list values.
33
+
34
+ Args:
35
+ product_config: Mapping of keys to candidate values.
36
+ sampling: Optional sampling mode over product indices.
37
+ n_samples: Number of sampled combinations when sampling is enabled.
38
+
39
+ Yields:
40
+ Dictionaries containing one chosen value per product key.
41
+
42
+ Raises:
43
+ ValueError: If sampling is enabled without ``n_samples`` or mode is invalid.
44
+ """
45
+
46
+ if not product_config:
47
+ yield {}
48
+ return
49
+
50
+ prod_items = product_config.items()
51
+ prod_keys = [item[0] for item in prod_items]
52
+ prod_values = [item[1] for item in prod_items]
53
+
54
+ n = int(np.prod([len(it) for it in prod_values]))
55
+
56
+ if sampling is None:
57
+ loop_values = range(n)
58
+ else:
59
+ if n_samples is None:
60
+ raise ValueError("Argument `n_samples` required when using sampling.")
61
+ if sampling == "with_replacement":
62
+ loop_values = random.choices(range(n), k=n_samples)
63
+ elif sampling == "without_replacement":
64
+ loop_values = random.sample(range(n), k=n_samples)
65
+ else:
66
+ # illegal state
67
+ raise ValueError("Invalid value for `sampling` given.")
68
+
69
+ for num in loop_values:
70
+ i = num
71
+
72
+ res = []
73
+ for it in reversed(prod_values):
74
+ i, r = divmod(i, len(it))
75
+ res.append(it[r])
76
+ res = list(reversed(res))
77
+
78
+ yield {prod_keys[i]: res[i] for i in range(len(prod_keys))}
@@ -0,0 +1,122 @@
1
+ from collections.abc import Iterator
2
+ from copy import deepcopy
3
+ from pathlib import Path
4
+ from typing import Literal
5
+
6
+ from pydantic import TypeAdapter
7
+
8
+ from .expansion import get_joint_generator, get_product_generator
9
+ from .inputs import load_data_from_file
10
+ from .models import BaseConfigInput, Override, OverridesInput
11
+ from .utils import convert_flat_dict_to_nested_dict, deep_update_dict
12
+
13
+
14
+ def _generate_configs(
15
+ base_config: dict,
16
+ overrides: list[Override],
17
+ key_separator: str,
18
+ sampling: Literal["without_replacement", "with_replacement"] | None = None,
19
+ n_samples: int | None = None,
20
+ ) -> Iterator[dict]:
21
+ """Generate merged configs for all fixed, joint, and product combinations.
22
+
23
+ Args:
24
+ base_config: Base configuration to copy and update.
25
+ overrides: Validated override groups.
26
+ key_separator: Separator for flat keys that target nested fields.
27
+ sampling: Optional sampling mode for product combinations.
28
+ n_samples: Number of product combinations to sample.
29
+
30
+ Yields:
31
+ Fully merged configuration dictionaries.
32
+ """
33
+
34
+ for override in overrides:
35
+ fixed = override.fixed
36
+
37
+ for joint_conf in get_joint_generator(override.joint):
38
+ # NOTE: only product space is sampled
39
+ for prod_conf in get_product_generator(
40
+ override.product, sampling=sampling, n_samples=n_samples
41
+ ):
42
+ new_config = deepcopy(base_config)
43
+ override_dict = fixed | joint_conf | prod_conf
44
+
45
+ override_dict = convert_flat_dict_to_nested_dict(
46
+ override_dict, key_separator
47
+ )
48
+ new_config = deep_update_dict(new_config, override_dict)
49
+
50
+ yield new_config
51
+
52
+
53
+ def generate_configs(
54
+ base_config: BaseConfigInput,
55
+ overrides: OverridesInput | None,
56
+ key_separator=".",
57
+ sampling: Literal["without_replacement", "with_replacement"] | None = None,
58
+ n_samples: int | None = None,
59
+ ) -> Iterator[dict]:
60
+ """Generate configurations from a base config and override definitions.
61
+
62
+ Args:
63
+ base_config: Base config dictionary or path to YAML/JSON.
64
+ overrides: Override list or path to YAML/JSON override definitions.
65
+ key_separator: Separator used in flat override keys for nesting.
66
+ sampling: Optional sampling mode for product combinations.
67
+ n_samples: Number of product combinations to sample.
68
+
69
+ Returns:
70
+ An iterator of generated configuration dictionaries.
71
+
72
+ Raises:
73
+ ValueError: If base config does not resolve to a dictionary.
74
+ """
75
+
76
+ if not isinstance(base_config, dict):
77
+ path = Path(base_config)
78
+ base_config = load_data_from_file(path)
79
+
80
+ if isinstance(overrides, (str, Path)):
81
+ path = Path(overrides)
82
+ overrides = load_data_from_file(path)
83
+
84
+ # validation
85
+ if not isinstance(base_config, dict):
86
+ raise ValueError("Base config must be a dictionary.")
87
+ overrides_validated = TypeAdapter(list[Override]).validate_python(overrides)
88
+
89
+ if (
90
+ not overrides
91
+ ): # NOTE: need to confirm that this general falsy check is not a problem
92
+ yield base_config
93
+ return
94
+
95
+ yield from _generate_configs(
96
+ base_config, overrides_validated, key_separator, sampling, n_samples
97
+ )
98
+
99
+
100
+ def generate_configs_list(
101
+ base_config: BaseConfigInput,
102
+ overrides: OverridesInput | None,
103
+ key_separator=".",
104
+ sampling: Literal["without_replacement", "with_replacement"] | None = None,
105
+ n_samples: int | None = None,
106
+ ) -> list[dict]:
107
+ """Return generated configurations as a list.
108
+
109
+ Args:
110
+ base_config: Base config dictionary or path to YAML/JSON.
111
+ overrides: Override list or path to YAML/JSON override definitions.
112
+ key_separator: Separator used in flat override keys for nesting.
113
+ sampling: Optional sampling mode for product combinations.
114
+ n_samples: Number of product combinations to sample.
115
+
116
+ Returns:
117
+ All generated configurations materialized in a list.
118
+ """
119
+
120
+ return list(
121
+ generate_configs(base_config, overrides, key_separator, sampling, n_samples)
122
+ )
@@ -0,0 +1,27 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ from yaml import safe_load
6
+
7
+
8
+ def load_data_from_file(file_path: Path | str) -> Any:
9
+ """Load YAML or JSON data from disk.
10
+
11
+ Args:
12
+ file_path: Path to a ``.yaml``, ``.yml``, or ``.json`` file.
13
+
14
+ Returns:
15
+ Parsed file contents.
16
+
17
+ Raises:
18
+ ValueError: If the file extension is unsupported.
19
+ """
20
+ file_path = Path(file_path)
21
+
22
+ with open(file_path, encoding="utf-8") as f:
23
+ if file_path.suffix in [".yaml", ".yml"]:
24
+ return safe_load(f)
25
+ if file_path.suffix == ".json":
26
+ return json.load(f)
27
+ raise ValueError("File must end in `.yaml`, `.yml` or `.json`.")
@@ -0,0 +1,38 @@
1
+ from os import PathLike
2
+ from typing import Any
3
+
4
+ from pydantic import BaseModel, Field, field_validator
5
+
6
+
7
+ class Override(BaseModel):
8
+ fixed: dict[str, Any] = Field(default_factory=dict)
9
+ joint: dict[str, list] = Field(default_factory=dict)
10
+ product: dict[str, list] = Field(default_factory=dict)
11
+
12
+ @field_validator("joint")
13
+ @classmethod
14
+ def validate_joint_lengths(cls, joint: dict[str, list]):
15
+ """Validate that all lists in ``joint`` have equal length.
16
+
17
+ Args:
18
+ joint: Mapping of override keys to lists.
19
+
20
+ Returns:
21
+ The validated ``joint`` mapping.
22
+
23
+ Raises:
24
+ ValueError: If list lengths differ across ``joint`` keys.
25
+ """
26
+ lengths = {key: len(value) for key, value in joint.items()}
27
+
28
+ if len(set(lengths.values())) > 1:
29
+ raise ValueError(
30
+ f"All lists in 'joint' must have equal length. Got lengths: {lengths}"
31
+ )
32
+
33
+ return joint
34
+
35
+
36
+ type ConfigDict = dict[str, Any]
37
+ type BaseConfigInput = ConfigDict | str | PathLike[str]
38
+ type OverridesInput = list[dict[str, Any]] | str | PathLike[str]
File without changes
@@ -0,0 +1,54 @@
1
+ from copy import deepcopy
2
+ from typing import Any
3
+
4
+
5
+ def convert_flat_dict_to_nested_dict(
6
+ dictionary: dict[str, Any], key_separator=".", override_duplicates=False
7
+ ) -> dict:
8
+ """
9
+ Convert from format: {"k1.k2": value} -> {"k1": {"k2": value}}
10
+ """
11
+ output_dict = {}
12
+
13
+ for composite_key, value in dictionary.items():
14
+ current_dict = output_dict
15
+ key_list = composite_key.split(key_separator)
16
+
17
+ # step through dictionary
18
+ for key in key_list[:-1]:
19
+ if key in current_dict and not isinstance(current_dict[key], dict):
20
+ if override_duplicates:
21
+ current_dict[key] = {}
22
+ else:
23
+ raise RuntimeError(
24
+ f"Duplicate key found: '{composite_key}'. "
25
+ "Set `override_duplicates`=True to skip this error."
26
+ )
27
+ current_dict[key] = current_dict.get(key, {})
28
+ current_dict = current_dict[key]
29
+
30
+ # write value
31
+ key = key_list[-1]
32
+ if key in current_dict and not override_duplicates:
33
+ raise RuntimeError(
34
+ f"Duplicate key found: '{composite_key}'. "
35
+ "Set `override_duplicates`=True to skip this error."
36
+ )
37
+ current_dict[key] = value
38
+ return output_dict
39
+
40
+
41
+ def deep_update_dict(base: dict, override: dict) -> dict:
42
+ """
43
+ Recursively replaces each key/value in `base` that is also present in `override`
44
+ When key not in `base`, adds the key.
45
+ """
46
+ output = deepcopy(base)
47
+
48
+ for key, value in override.items():
49
+ if isinstance(value, dict) and isinstance(output.get(key), dict):
50
+ output[key] = deep_update_dict(output[key], value)
51
+ else:
52
+ output[key] = deepcopy(value)
53
+
54
+ return output