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 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
+ [![PyPI version](https://img.shields.io/pypi/v/cfgx.svg)](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
+ [![PyPI version](https://img.shields.io/pypi/v/cfgx.svg)](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,19 @@
1
+ from .config import (
2
+ Delete,
3
+ Replace,
4
+ apply_overrides,
5
+ dump,
6
+ format,
7
+ load,
8
+ merge,
9
+ )
10
+
11
+ __all__ = [
12
+ "load",
13
+ "apply_overrides",
14
+ "merge",
15
+ "Delete",
16
+ "Replace",
17
+ "dump",
18
+ "format",
19
+ ]
@@ -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