python-library-configlib 0.1.0__tar.gz → 0.1.1__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.
Files changed (22) hide show
  1. {python_library_configlib-0.1.0 → python_library_configlib-0.1.1}/.gitignore +3 -1
  2. {python_library_configlib-0.1.0 → python_library_configlib-0.1.1}/PKG-INFO +1 -1
  3. {python_library_configlib-0.1.0 → python_library_configlib-0.1.1}/configlib/yaml.py +6 -3
  4. python_library_configlib-0.1.1/configlib/yaml_compose.py +217 -0
  5. {python_library_configlib-0.1.0 → python_library_configlib-0.1.1}/pyproject.toml +1 -1
  6. python_library_configlib-0.1.1/tests/test_yaml_compose.py +206 -0
  7. {python_library_configlib-0.1.0 → python_library_configlib-0.1.1}/assets/base.yaml +0 -0
  8. {python_library_configlib-0.1.0 → python_library_configlib-0.1.1}/assets/data.json5 +0 -0
  9. {python_library_configlib-0.1.0 → python_library_configlib-0.1.1}/assets/data.toml +0 -0
  10. {python_library_configlib-0.1.0 → python_library_configlib-0.1.1}/assets/example.yaml +0 -0
  11. {python_library_configlib-0.1.0 → python_library_configlib-0.1.1}/configlib/__init__.py +0 -0
  12. {python_library_configlib-0.1.0 → python_library_configlib-0.1.1}/configlib/json.py +0 -0
  13. {python_library_configlib-0.1.0 → python_library_configlib-0.1.1}/configlib/loader.py +0 -0
  14. {python_library_configlib-0.1.0 → python_library_configlib-0.1.1}/configlib/resolver.py +0 -0
  15. {python_library_configlib-0.1.0 → python_library_configlib-0.1.1}/configlib/toml.py +0 -0
  16. {python_library_configlib-0.1.0 → python_library_configlib-0.1.1}/test.bat +0 -0
  17. {python_library_configlib-0.1.0 → python_library_configlib-0.1.1}/tests/test_json.py +0 -0
  18. {python_library_configlib-0.1.0 → python_library_configlib-0.1.1}/tests/test_load_config.py +0 -0
  19. {python_library_configlib-0.1.0 → python_library_configlib-0.1.1}/tests/test_loader.py +0 -0
  20. {python_library_configlib-0.1.0 → python_library_configlib-0.1.1}/tests/test_resolver.py +0 -0
  21. {python_library_configlib-0.1.0 → python_library_configlib-0.1.1}/tests/test_toml.py +0 -0
  22. {python_library_configlib-0.1.0 → python_library_configlib-0.1.1}/tests/test_yaml.py +0 -0
@@ -8,4 +8,6 @@ build/
8
8
  .env
9
9
  .pytest_cache/
10
10
  config.yaml
11
- logs/
11
+ logs/
12
+ .cursor/
13
+ uv.lock
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-library-configlib
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Requires-Python: >=3.10
5
5
  Requires-Dist: json5>=0.9.0
6
6
  Requires-Dist: pydantic>=2.0.0
@@ -6,6 +6,7 @@ from pathlib import Path
6
6
  from typing import Any
7
7
  import yaml
8
8
  from .resolver import resolve_variables
9
+ from .yaml_compose import apply_composition, preprocess_yaml_compose
9
10
 
10
11
  SUFFIXES = {".yaml", ".yml"}
11
12
  _YAML_LOAD_STACK: ContextVar[list[Path] | None] = ContextVar("_YAML_LOAD_STACK", default=None)
@@ -41,7 +42,8 @@ def load_yaml(file_path: str) -> dict | list:
41
42
  path = Path(file_path).resolve()
42
43
  with _include_guard(path):
43
44
  data = _load_yaml_raw(path)
44
- return resolve_variables(data)
45
+ resolved = resolve_variables(data)
46
+ return apply_composition(resolved)
45
47
 
46
48
 
47
49
  def load_yaml_raw(file_path: str) -> dict | list:
@@ -63,8 +65,9 @@ def _load_yaml_raw(path: Path) -> Any:
63
65
 
64
66
  _YamlIncludeLoader.add_constructor("!include", construct_include)
65
67
 
66
- with path.open("r", encoding="utf-8") as f:
67
- return yaml.load(f, Loader=_YamlIncludeLoader)
68
+ text = path.read_text(encoding="utf-8")
69
+ text = preprocess_yaml_compose(text)
70
+ return yaml.load(text, Loader=_YamlIncludeLoader)
68
71
 
69
72
 
70
73
  __all__ = ["is_yaml", "load_yaml", "load_yaml_raw"]
@@ -0,0 +1,217 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import re
5
+ from typing import Any, Literal
6
+
7
+ SPREAD_KEY = "__configlib_spread__"
8
+ MERGES_KEY = "__configlib_merges__"
9
+
10
+ _BARE_VAR_LINE = re.compile(r"^(\s*)(\$\{[^{}]+\})\s*(#.*)?$")
11
+ _RESERVED_KEYS = frozenset({SPREAD_KEY, MERGES_KEY})
12
+
13
+
14
+ def preprocess_yaml_compose(source: str) -> str:
15
+ """将块式 YAML 中独占一行的 ${} 改写成可解析的占位结构。"""
16
+ if not source:
17
+ return source
18
+ lines = source.splitlines(keepends=True)
19
+ if not lines:
20
+ return source
21
+
22
+ infos = [_line_info(line) for line in lines]
23
+ out: list[str] = []
24
+ i = 0
25
+ while i < len(lines):
26
+ info = infos[i]
27
+ if not info.is_bare_var:
28
+ out.append(lines[i])
29
+ i += 1
30
+ continue
31
+
32
+ parent_indent = _parent_indent(infos, i)
33
+ block_end = _block_end_index(infos, i, parent_indent)
34
+ block = infos[i:block_end]
35
+ siblings = _siblings_at_indent(infos, i, parent_indent)
36
+ mode = _infer_block_mode(siblings)
37
+
38
+ if mode == "seq":
39
+ for entry in block:
40
+ if entry.is_bare_var:
41
+ out.append(
42
+ f"{entry.indent_str}- {SPREAD_KEY}: {entry.bare_expr}{entry.comment_suffix}\n"
43
+ )
44
+ i = block_end
45
+ continue
46
+
47
+ merge_exprs = [entry.bare_expr for entry in block if entry.is_bare_var]
48
+ if merge_exprs:
49
+ base_indent = block[0].indent_str
50
+ out.append(f"{base_indent}{MERGES_KEY}:\n")
51
+ for expr in merge_exprs:
52
+ out.append(f"{base_indent} - {expr}\n")
53
+ i = block_end
54
+
55
+ if source.endswith("\n") and out and not out[-1].endswith("\n"):
56
+ out[-1] = out[-1] + "\n"
57
+ return "".join(out)
58
+
59
+
60
+ def apply_composition(data: Any) -> Any:
61
+ """在变量解析完成后展开列表拼接与字典合并占位。"""
62
+ if isinstance(data, dict):
63
+ return _compose_mapping(data)
64
+ if isinstance(data, list):
65
+ return _compose_sequence(data)
66
+ return data
67
+
68
+
69
+ class _LineInfo:
70
+ __slots__ = (
71
+ "indent",
72
+ "indent_str",
73
+ "is_bare_var",
74
+ "bare_expr",
75
+ "comment_suffix",
76
+ "is_dash_item",
77
+ "is_mapping_entry",
78
+ "is_blank",
79
+ )
80
+
81
+ def __init__(self, line: str) -> None:
82
+ stripped = line.lstrip(" \t")
83
+ self.indent = len(line) - len(stripped)
84
+ self.indent_str = line[: self.indent]
85
+ self.is_blank = not stripped or stripped.startswith("#")
86
+ self.is_dash_item = stripped.startswith("- ")
87
+ self.is_mapping_entry = (
88
+ not self.is_blank
89
+ and not self.is_dash_item
90
+ and ":" in stripped
91
+ and not stripped.startswith("${")
92
+ )
93
+ match = _BARE_VAR_LINE.match(stripped)
94
+ self.is_bare_var = match is not None and not self.is_blank
95
+ self.bare_expr = match.group(2) if match else ""
96
+ self.comment_suffix = match.group(3) or "" if match else ""
97
+
98
+
99
+ def _line_info(line: str) -> _LineInfo:
100
+ return _LineInfo(line)
101
+
102
+
103
+ def _parent_indent(infos: list[_LineInfo], index: int) -> int:
104
+ indent = infos[index].indent
105
+ for pos in range(index - 1, -1, -1):
106
+ info = infos[pos]
107
+ if info.is_blank:
108
+ continue
109
+ if info.indent < indent:
110
+ return info.indent
111
+ return -1
112
+
113
+
114
+ def _siblings_at_indent(
115
+ infos: list[_LineInfo],
116
+ index: int,
117
+ parent_indent: int,
118
+ ) -> list[_LineInfo]:
119
+ indent = infos[index].indent
120
+ siblings: list[_LineInfo] = []
121
+ for pos, info in enumerate(infos):
122
+ if info.is_blank:
123
+ continue
124
+ if info.indent != indent:
125
+ continue
126
+ if _parent_indent(infos, pos) != parent_indent:
127
+ continue
128
+ siblings.append(info)
129
+ return siblings
130
+
131
+
132
+ def _block_end_index(infos: list[_LineInfo], start: int, parent_indent: int) -> int:
133
+ indent = infos[start].indent
134
+ pos = start
135
+ while pos < len(infos):
136
+ info = infos[pos]
137
+ if not info.is_blank and info.indent < indent and pos > start:
138
+ break
139
+ if not info.is_blank and info.indent == indent:
140
+ if pos > start and not info.is_bare_var:
141
+ break
142
+ if not info.is_blank and info.indent > indent:
143
+ pos += 1
144
+ continue
145
+ pos += 1
146
+ return pos
147
+
148
+
149
+ def _infer_block_mode(block: list[_LineInfo]) -> Literal["seq", "map"]:
150
+ for info in block:
151
+ if info.is_dash_item:
152
+ return "seq"
153
+ if info.is_mapping_entry:
154
+ return "map"
155
+ return "map"
156
+
157
+
158
+ def _deep_merge_dict(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
159
+ """递归合并 mapping;双方均为 dict 的同名键继续下钻,否则由 overlay 覆盖。"""
160
+ result = copy.deepcopy(base)
161
+ for key, value in overlay.items():
162
+ if (
163
+ key in result
164
+ and isinstance(result[key], dict)
165
+ and isinstance(value, dict)
166
+ ):
167
+ result[key] = _deep_merge_dict(result[key], value)
168
+ else:
169
+ result[key] = copy.deepcopy(value)
170
+ return result
171
+
172
+
173
+ def _compose_mapping(data: dict[str, Any]) -> dict[str, Any]:
174
+ merged: dict[str, Any] = {}
175
+ merge_sources: list[dict[str, Any]] = []
176
+
177
+ for key, value in data.items():
178
+ if key == MERGES_KEY:
179
+ resolved = _compose_sequence(value) if isinstance(value, list) else value
180
+ if not isinstance(resolved, list):
181
+ raise TypeError(f"{MERGES_KEY} 必须是列表")
182
+ for item in resolved:
183
+ if not isinstance(item, dict):
184
+ raise TypeError(f"{MERGES_KEY} 每一项必须是字典")
185
+ merge_sources.append(apply_composition(item))
186
+ continue
187
+ if key in _RESERVED_KEYS:
188
+ raise ValueError(f"保留键 {key!r} 只能由预处理生成")
189
+ merged[key] = apply_composition(value)
190
+
191
+ result: dict[str, Any] = {}
192
+ for source in merge_sources:
193
+ result = _deep_merge_dict(result, source)
194
+ return _deep_merge_dict(result, merged)
195
+
196
+
197
+ def _compose_sequence(data: list[Any]) -> list[Any]:
198
+ result: list[Any] = []
199
+ for item in data:
200
+ if isinstance(item, dict) and SPREAD_KEY in item and len(item) == 1:
201
+ spread_value = apply_composition(item[SPREAD_KEY])
202
+ if not isinstance(spread_value, list):
203
+ raise TypeError(f"{SPREAD_KEY} 必须是列表")
204
+ result.extend(spread_value)
205
+ continue
206
+ if isinstance(item, dict) and SPREAD_KEY in item:
207
+ raise ValueError(f"{SPREAD_KEY} 不能与其他键混用")
208
+ result.append(apply_composition(item))
209
+ return result
210
+
211
+
212
+ __all__ = [
213
+ "SPREAD_KEY",
214
+ "MERGES_KEY",
215
+ "preprocess_yaml_compose",
216
+ "apply_composition",
217
+ ]
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-library-configlib"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  requires-python = ">=3.10"
9
9
  dependencies = [
10
10
  "pydantic>=2.0.0",
@@ -0,0 +1,206 @@
1
+ from __future__ import annotations
2
+
3
+ import tempfile
4
+ import unittest
5
+ from pathlib import Path
6
+ from textwrap import dedent
7
+
8
+ from configlib import load_config, load_config_raw
9
+ from configlib.yaml_compose import apply_composition, preprocess_yaml_compose
10
+ from configlib.resolver import resolve_variables
11
+
12
+
13
+ class PreprocessTests(unittest.TestCase):
14
+ def test_list_spread_line_gets_dash_placeholder(self) -> None:
15
+ source = dedent("""
16
+ items:
17
+ - a
18
+ ${extra}
19
+ - b
20
+ """)
21
+ out = preprocess_yaml_compose(source)
22
+ self.assertIn("- __configlib_spread__: ${extra}", out)
23
+
24
+ def test_dict_merge_lines_collapsed(self) -> None:
25
+ source = dedent("""
26
+ cfg:
27
+ ${base}
28
+ ${more}
29
+ flag: true
30
+ """)
31
+ out = preprocess_yaml_compose(source)
32
+ self.assertIn("__configlib_merges__:", out)
33
+ self.assertIn("- ${base}", out)
34
+ self.assertIn("- ${more}", out)
35
+
36
+
37
+ class ComposeIntegrationTests(unittest.TestCase):
38
+ def test_list_spread_and_nested_item(self) -> None:
39
+ with tempfile.TemporaryDirectory() as tmp:
40
+ path = Path(tmp) / "cfg.yaml"
41
+ path.write_text(
42
+ dedent("""
43
+ shared: [s1, s2]
44
+ items:
45
+ - head
46
+ ${shared}
47
+ - tail
48
+ """),
49
+ encoding="utf-8",
50
+ )
51
+ data = load_config(path)
52
+ self.assertEqual(data["items"], ["head", "s1", "s2", "tail"])
53
+
54
+ def test_list_nested_variable_stays_one_element(self) -> None:
55
+ with tempfile.TemporaryDirectory() as tmp:
56
+ path = Path(tmp) / "cfg.yaml"
57
+ path.write_text(
58
+ dedent("""
59
+ shared: [1, 2]
60
+ items:
61
+ - ${shared}
62
+ """),
63
+ encoding="utf-8",
64
+ )
65
+ data = load_config(path)
66
+ self.assertEqual(data["items"], [[1, 2]])
67
+
68
+ def test_dict_deep_merge_nested_override(self) -> None:
69
+ with tempfile.TemporaryDirectory() as tmp:
70
+ path = Path(tmp) / "cfg.yaml"
71
+ path.write_text(
72
+ dedent("""
73
+ base:
74
+ x2:
75
+ x3: v1
76
+ x4: v2
77
+ x5: v3
78
+ x1:
79
+ ${base}
80
+ x2:
81
+ x4: vvvv
82
+ """),
83
+ encoding="utf-8",
84
+ )
85
+ data = load_config(path)
86
+ self.assertEqual(
87
+ data["x1"],
88
+ {"x2": {"x3": "v1", "x4": "vvvv"}, "x5": "v3"},
89
+ )
90
+
91
+ def test_cfg_merge_base_then_nested_local_keys(self) -> None:
92
+ with tempfile.TemporaryDirectory() as tmp:
93
+ path = Path(tmp) / "cfg.yaml"
94
+ path.write_text(
95
+ dedent("""
96
+ base:
97
+ a: 1
98
+ b:
99
+ c: old
100
+ d: keep
101
+ cfg:
102
+ ${base}
103
+ b:
104
+ c: v1
105
+ """),
106
+ encoding="utf-8",
107
+ )
108
+ data = load_config(path)
109
+ self.assertEqual(
110
+ data["cfg"],
111
+ {"a": 1, "b": {"c": "v1", "d": "keep"}},
112
+ )
113
+
114
+ def test_include_then_deep_merge_via_variable(self) -> None:
115
+ with tempfile.TemporaryDirectory() as tmp:
116
+ root = Path(tmp)
117
+ (root / "a.yaml").write_text(
118
+ dedent("""
119
+ x1:
120
+ x2:
121
+ x3: v1
122
+ x4: v2
123
+ x5: v3
124
+ """),
125
+ encoding="utf-8",
126
+ )
127
+ (root / "b.yaml").write_text(
128
+ dedent("""
129
+ from_a: !include a.yaml
130
+ x1:
131
+ ${from_a.x1}
132
+ x2:
133
+ x4: vvvv
134
+ """),
135
+ encoding="utf-8",
136
+ )
137
+ data = load_config(root / "b.yaml")
138
+ self.assertEqual(
139
+ data["x1"],
140
+ {"x2": {"x3": "v1", "x4": "vvvv"}, "x5": "v3"},
141
+ )
142
+
143
+ def test_dict_merge_then_local_override(self) -> None:
144
+ with tempfile.TemporaryDirectory() as tmp:
145
+ path = Path(tmp) / "cfg.yaml"
146
+ path.write_text(
147
+ dedent("""
148
+ base:
149
+ a: 1
150
+ b: old
151
+ cfg:
152
+ ${base}
153
+ b: new
154
+ c: 3
155
+ """),
156
+ encoding="utf-8",
157
+ )
158
+ data = load_config(path)
159
+ self.assertEqual(data["cfg"], {"a": 1, "b": "new", "c": 3})
160
+
161
+ def test_dict_nested_key_keeps_subtree(self) -> None:
162
+ with tempfile.TemporaryDirectory() as tmp:
163
+ path = Path(tmp) / "cfg.yaml"
164
+ path.write_text(
165
+ dedent("""
166
+ base:
167
+ x: 1
168
+ root:
169
+ nested: ${base}
170
+ """),
171
+ encoding="utf-8",
172
+ )
173
+ data = load_config(path)
174
+ self.assertEqual(data["root"]["nested"], {"x": 1})
175
+
176
+ def test_raw_keeps_placeholders_without_resolve(self) -> None:
177
+ with tempfile.TemporaryDirectory() as tmp:
178
+ path = Path(tmp) / "cfg.yaml"
179
+ path.write_text(
180
+ dedent("""
181
+ items:
182
+ - head
183
+ ${shared}
184
+ """),
185
+ encoding="utf-8",
186
+ )
187
+ raw = load_config_raw(path)
188
+ self.assertIn("__configlib_spread__", str(raw))
189
+
190
+ def test_spread_non_list_raises(self) -> None:
191
+ tree = resolve_variables({"n": 1, "items": [{"__configlib_spread__": "${n}"}]})
192
+ with self.assertRaises(TypeError):
193
+ apply_composition(tree)
194
+
195
+
196
+ class ComposeUnitTests(unittest.TestCase):
197
+ def test_apply_list_spread(self) -> None:
198
+ data = [{"__configlib_spread__": [1, 2]}, 3]
199
+ self.assertEqual(apply_composition(data), [1, 2, 3])
200
+
201
+ def test_apply_dict_merges(self) -> None:
202
+ data = {
203
+ "__configlib_merges__": [{"a": 1}, {"b": 2}],
204
+ "b": 9,
205
+ }
206
+ self.assertEqual(apply_composition(data), {"a": 1, "b": 9})