cfgx 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.
- cfgx-0.1.0/PKG-INFO +46 -0
- cfgx-0.1.0/README.md +36 -0
- cfgx-0.1.0/pyproject.toml +24 -0
- cfgx-0.1.0/src/cfgx/__init__.py +19 -0
- cfgx-0.1.0/src/cfgx/config.py +250 -0
- cfgx-0.1.0/src/cfgx/py.typed +0 -0
cfgx-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: cfgx
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python-first config loader with composition and CLI overrides.
|
|
5
|
+
Author: Karim Abou Zeid
|
|
6
|
+
Author-email: Karim Abou Zeid <contact@ka.codes>
|
|
7
|
+
Requires-Dist: ruff>=0.14.3
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# cfgx
|
|
12
|
+
|
|
13
|
+
[](https://pypi.org/project/cfgx/)
|
|
14
|
+
|
|
15
|
+
Python-first config loader with parent chaining, parameterized templates, and CLI-style overrides.
|
|
16
|
+
|
|
17
|
+
Docs: https://kabouzeid.github.io/cfgx/
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install cfgx
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
Example config file:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
# configs/model.py
|
|
31
|
+
config = {
|
|
32
|
+
"data": {"dataset": "imagenet", "batch_size": 128},
|
|
33
|
+
"model": {"depth": 8, "width": 512, "dropout": 0.1},
|
|
34
|
+
"optimizer": {"lr": 3e-4, "weight_decay": 0.01},
|
|
35
|
+
"trainer": {"max_steps": 50_000, "mixed_precision": "bf16"},
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from cfgx import apply_overrides, load
|
|
41
|
+
|
|
42
|
+
cfg = load("configs/model.py")
|
|
43
|
+
cfg = apply_overrides(cfg, ["optimizer.lr=1e-3"]) # update nested keys
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Works well with [`specbuild`](https://github.com/kabouzeid/specbuild) when you want to build your model and other classes from config dictionaries.
|
cfgx-0.1.0/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# cfgx
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/cfgx/)
|
|
4
|
+
|
|
5
|
+
Python-first config loader with parent chaining, parameterized templates, and CLI-style overrides.
|
|
6
|
+
|
|
7
|
+
Docs: https://kabouzeid.github.io/cfgx/
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install cfgx
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
Example config file:
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
# configs/model.py
|
|
21
|
+
config = {
|
|
22
|
+
"data": {"dataset": "imagenet", "batch_size": 128},
|
|
23
|
+
"model": {"depth": 8, "width": 512, "dropout": 0.1},
|
|
24
|
+
"optimizer": {"lr": 3e-4, "weight_decay": 0.01},
|
|
25
|
+
"trainer": {"max_steps": 50_000, "mixed_precision": "bf16"},
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from cfgx import apply_overrides, load
|
|
31
|
+
|
|
32
|
+
cfg = load("configs/model.py")
|
|
33
|
+
cfg = apply_overrides(cfg, ["optimizer.lr=1e-3"]) # update nested keys
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Works well with [`specbuild`](https://github.com/kabouzeid/specbuild) when you want to build your model and other classes from config dictionaries.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "cfgx"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python-first config loader with composition and CLI overrides."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Karim Abou Zeid", email = "contact@ka.codes" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"ruff>=0.14.3",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["uv_build>=0.9.0,<0.10.0"]
|
|
16
|
+
build-backend = "uv_build"
|
|
17
|
+
|
|
18
|
+
[dependency-groups]
|
|
19
|
+
dev = [
|
|
20
|
+
"pytest>=8.4.0",
|
|
21
|
+
"ruff>=0.11.13",
|
|
22
|
+
"zensical>=0.0.10",
|
|
23
|
+
"mkdocstrings-python>=2.0.1",
|
|
24
|
+
]
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import inspect
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import runpy
|
|
6
|
+
import subprocess
|
|
7
|
+
from functools import reduce
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable, Sequence
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Delete:
|
|
13
|
+
"""Sentinel that removes a key from a merged config."""
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Replace:
|
|
18
|
+
"""Sentinel that forces a value to replace a mapping during merge."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, value, /):
|
|
21
|
+
self.value = value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load(path: os.PathLike | Sequence[os.PathLike], params: dict | None = None):
|
|
25
|
+
"""
|
|
26
|
+
Load config modules from one or more paths, apply params, and merge the results.
|
|
27
|
+
|
|
28
|
+
Parent configs (via `parents`) are resolved first, then later paths override
|
|
29
|
+
earlier ones. Callable configs receive params (defaults come from signatures);
|
|
30
|
+
plain dict configs are merged directly.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
paths = [path] if isinstance(path, (str, os.PathLike)) else path
|
|
34
|
+
specs = [spec for p in paths for spec in _collect_config_specs(Path(p))]
|
|
35
|
+
|
|
36
|
+
# last assignment wins. we could also deep merge, but it feels less natural here
|
|
37
|
+
params = {k: v for _, d in specs for k, v in d.items()} | (params or {})
|
|
38
|
+
|
|
39
|
+
return reduce(
|
|
40
|
+
merge,
|
|
41
|
+
(_build_config(config, params) for config in [cfg for cfg, _ in specs]),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _build_config(config: dict | Callable, params: dict):
|
|
46
|
+
return config(**params) if callable(config) else config
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _collect_config_specs(path: os.PathLike) -> list[tuple[dict, dict]]:
|
|
50
|
+
"""
|
|
51
|
+
Return the flattened inheritance chain for the config at `path`. Ordered from the farthest parent first.
|
|
52
|
+
"""
|
|
53
|
+
path = Path(path).resolve()
|
|
54
|
+
config_module_globs = runpy.run_path(str(path), run_name="__config__")
|
|
55
|
+
|
|
56
|
+
config = config_module_globs.get("config", {})
|
|
57
|
+
params = _defaults_args(config) if callable(config) else {}
|
|
58
|
+
|
|
59
|
+
parents = config_module_globs.get("parents", None)
|
|
60
|
+
if isinstance(parents, str):
|
|
61
|
+
parents = [parents]
|
|
62
|
+
|
|
63
|
+
return [
|
|
64
|
+
parent_cfg_specs
|
|
65
|
+
for parent in parents or []
|
|
66
|
+
for parent_cfg_specs in _collect_config_specs(path.parent / Path(parent))
|
|
67
|
+
] + [(config, params)]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _defaults_args(f: Callable) -> dict:
|
|
71
|
+
return {
|
|
72
|
+
name: param.default
|
|
73
|
+
for name, param in inspect.signature(f).parameters.items()
|
|
74
|
+
if param.default is not inspect.Parameter.empty
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def dump(config: dict, path: os.PathLike):
|
|
79
|
+
"""
|
|
80
|
+
Persist a config dictionary to a ruff-formatted Python file.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
config_str = _ruff_format(
|
|
84
|
+
"# Auto-generated config snapshot\nconfig = " + repr(config)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
with open(path, "w") as f:
|
|
88
|
+
f.write("# fmt: off\n") # prevent auto-formatting
|
|
89
|
+
f.write(config_str)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def format(config: dict) -> str:
|
|
93
|
+
"""Return a ruff-formatted string representation of the config dictionary."""
|
|
94
|
+
return _ruff_format(repr(config))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _ruff_format(source: str) -> str:
|
|
98
|
+
from ruff.__main__ import find_ruff_bin
|
|
99
|
+
|
|
100
|
+
result = subprocess.run(
|
|
101
|
+
[find_ruff_bin(), "format", "--isolated", "--stdin-filename=config.py", "-"],
|
|
102
|
+
input=source,
|
|
103
|
+
text=True,
|
|
104
|
+
capture_output=True,
|
|
105
|
+
check=True,
|
|
106
|
+
cwd=Path.cwd(),
|
|
107
|
+
)
|
|
108
|
+
result.check_returncode()
|
|
109
|
+
return result.stdout
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def merge(base: dict, override: dict):
|
|
113
|
+
"""
|
|
114
|
+
Recursively merge two dictionaries, honoring Delete/Replace sentinels.
|
|
115
|
+
|
|
116
|
+
If both sides contain dicts, merge continues down the tree. Delete removes a key
|
|
117
|
+
from the base config, Replace overwrites without further deep merging, and other
|
|
118
|
+
values simply override. Returns a new dictionary without mutating the inputs.
|
|
119
|
+
"""
|
|
120
|
+
base = base.copy()
|
|
121
|
+
for k, v in override.items():
|
|
122
|
+
if k in base and isinstance(base[k], dict) and isinstance(v, dict):
|
|
123
|
+
base[k] = merge(base[k], v)
|
|
124
|
+
elif isinstance(v, Delete):
|
|
125
|
+
base.pop(k, None)
|
|
126
|
+
elif isinstance(v, Replace):
|
|
127
|
+
base[k] = v.value
|
|
128
|
+
else:
|
|
129
|
+
base[k] = v
|
|
130
|
+
return base
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def apply_overrides(cfg: dict, overrides: Sequence[str]):
|
|
134
|
+
"""
|
|
135
|
+
Apply CLI-style override strings to a config dictionary.
|
|
136
|
+
|
|
137
|
+
Supports assignment (`=`), append (`+=`), delete (`!=`), and removal from list
|
|
138
|
+
(`-=`) using dotted/indexed key paths like ``model.layers[0].units``. Returns a
|
|
139
|
+
shallow copy with overrides applied.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
cfg = cfg.copy()
|
|
143
|
+
for override in overrides:
|
|
144
|
+
if "+=" in override:
|
|
145
|
+
key, value = override.split("+=", 1)
|
|
146
|
+
keys = parse_key_path(key)
|
|
147
|
+
append_to_nested(cfg, keys, infer_type(value))
|
|
148
|
+
elif "!=" in override:
|
|
149
|
+
key, _ = override.split("!=", 1)
|
|
150
|
+
keys = parse_key_path(key)
|
|
151
|
+
delete_nested(cfg, keys)
|
|
152
|
+
elif "-=" in override:
|
|
153
|
+
key, value = override.split("-=", 1)
|
|
154
|
+
keys = parse_key_path(key)
|
|
155
|
+
remove_value_from_list(cfg, keys, infer_type(value))
|
|
156
|
+
else:
|
|
157
|
+
key, value = override.split("=", 1)
|
|
158
|
+
keys = parse_key_path(key)
|
|
159
|
+
set_nested(cfg, keys, infer_type(value))
|
|
160
|
+
return cfg
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def parse_key_path(path: str):
|
|
164
|
+
"""Parse 'a.b[0].c' → ['a', 'b', 0, 'c']"""
|
|
165
|
+
tokens = []
|
|
166
|
+
parts = re.split(r"(\[-?\d+\]|\.)", path)
|
|
167
|
+
for part in parts:
|
|
168
|
+
if not part or part == ".":
|
|
169
|
+
continue
|
|
170
|
+
if part.startswith("[") and part.endswith("]"):
|
|
171
|
+
tokens.append(int(part[1:-1]))
|
|
172
|
+
else:
|
|
173
|
+
tokens.append(part)
|
|
174
|
+
return tokens
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def set_nested(d: dict, keys, value):
|
|
178
|
+
for i, key in enumerate(keys):
|
|
179
|
+
is_last = i == len(keys) - 1
|
|
180
|
+
if isinstance(key, int):
|
|
181
|
+
while len(d) <= key:
|
|
182
|
+
d.append(None)
|
|
183
|
+
if is_last:
|
|
184
|
+
d[key] = value
|
|
185
|
+
else:
|
|
186
|
+
if d[key] is None:
|
|
187
|
+
d[key] = {} if isinstance(keys[i + 1], str) else []
|
|
188
|
+
d = d[key]
|
|
189
|
+
else:
|
|
190
|
+
if is_last:
|
|
191
|
+
d[key] = value
|
|
192
|
+
else:
|
|
193
|
+
if key not in d or d[key] is None:
|
|
194
|
+
d[key] = {} if isinstance(keys[i + 1], str) else []
|
|
195
|
+
d = d[key]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def append_to_nested(d: dict, keys, value):
|
|
199
|
+
for i, key in enumerate(keys):
|
|
200
|
+
is_last = i == len(keys) - 1
|
|
201
|
+
next_key_type = type(keys[i + 1]) if not is_last else None
|
|
202
|
+
|
|
203
|
+
if isinstance(key, int):
|
|
204
|
+
while len(d) <= key:
|
|
205
|
+
d.append(None)
|
|
206
|
+
if is_last:
|
|
207
|
+
if d[key] is None:
|
|
208
|
+
d[key] = []
|
|
209
|
+
if not isinstance(d[key], list):
|
|
210
|
+
raise ValueError(f"Target at index {key} is not a list")
|
|
211
|
+
d[key].append(value)
|
|
212
|
+
else:
|
|
213
|
+
if d[key] is None:
|
|
214
|
+
d[key] = {} if next_key_type is str else []
|
|
215
|
+
d = d[key]
|
|
216
|
+
else:
|
|
217
|
+
if is_last:
|
|
218
|
+
if key not in d or not isinstance(d[key], list):
|
|
219
|
+
d[key] = []
|
|
220
|
+
d[key].append(value)
|
|
221
|
+
else:
|
|
222
|
+
if key not in d or d[key] is None:
|
|
223
|
+
d[key] = {} if next_key_type is str else []
|
|
224
|
+
d = d[key]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def delete_nested(d: dict, keys):
|
|
228
|
+
for i, key in enumerate(keys[:-1]):
|
|
229
|
+
d = d[key]
|
|
230
|
+
last_key = keys[-1]
|
|
231
|
+
if isinstance(last_key, int):
|
|
232
|
+
if isinstance(d, list) and 0 <= last_key < len(d):
|
|
233
|
+
del d[last_key]
|
|
234
|
+
else:
|
|
235
|
+
d.pop(last_key, None)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def remove_value_from_list(d: dict, keys, value):
|
|
239
|
+
for key in keys:
|
|
240
|
+
d = d[key]
|
|
241
|
+
if isinstance(d, list) and value in d:
|
|
242
|
+
d.remove(value)
|
|
243
|
+
# TODO: this should probably raise if d is not a list
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def infer_type(val: str):
|
|
247
|
+
try:
|
|
248
|
+
return ast.literal_eval(val)
|
|
249
|
+
except (ValueError, SyntaxError):
|
|
250
|
+
return val
|
|
File without changes
|