python-library-configlib 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.
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ .env
9
+ .pytest_cache/
10
+ config.yaml
11
+ logs/
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-library-configlib
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.10
5
+ Requires-Dist: json5>=0.9.0
6
+ Requires-Dist: pydantic>=2.0.0
7
+ Requires-Dist: pyyaml>=6.0.0
8
+ Requires-Dist: tomli>=2.0.0; python_version < '3.11'
@@ -0,0 +1,3 @@
1
+ title: hello
2
+ nested:
3
+ value: 123
@@ -0,0 +1,6 @@
1
+ {
2
+ user: {
3
+ name: "alice",
4
+ age: 18,
5
+ },
6
+ }
@@ -0,0 +1,3 @@
1
+ [server]
2
+ host = "127.0.0.1"
3
+ port = 8080
@@ -0,0 +1,14 @@
1
+ app:
2
+ name: demo
3
+ version: "1.0"
4
+
5
+ base: !include base.yaml
6
+ json_data: !include data.json5
7
+ toml_data: !include data.toml
8
+
9
+ refs:
10
+ app_name: ${app.name}
11
+ base_title: ${base.title}
12
+ local:
13
+ name: child
14
+ parent_name: ${..name}
@@ -0,0 +1,38 @@
1
+ from pathlib import Path
2
+
3
+ from .loader import ConfigLoader
4
+ from .json import is_json, load_json, load_json_raw
5
+ from .toml import is_toml, load_toml, load_toml_raw
6
+ from .yaml import is_yaml, load_yaml, load_yaml_raw
7
+
8
+
9
+ def load_config_raw(file_path: str | Path) -> dict | list:
10
+ """加载配置文件(不解析变量)"""
11
+ file_path = str(file_path)
12
+ if is_json(file_path):
13
+ return load_json_raw(file_path)
14
+ elif is_toml(file_path):
15
+ return load_toml_raw(file_path)
16
+ elif is_yaml(file_path):
17
+ return load_yaml_raw(file_path)
18
+ else:
19
+ raise ValueError(f"不支持的文件格式: {file_path}")
20
+
21
+
22
+ def load_config(file_path: str | Path) -> dict | list:
23
+ """加载配置文件"""
24
+ file_path = str(file_path)
25
+ if is_json(file_path):
26
+ return load_json(file_path)
27
+ elif is_toml(file_path):
28
+ return load_toml(file_path)
29
+ elif is_yaml(file_path):
30
+ return load_yaml(file_path)
31
+ else:
32
+ raise ValueError(f"不支持的文件格式: {file_path}")
33
+
34
+ __all__ = [
35
+ "load_config",
36
+ "load_config_raw",
37
+ "ConfigLoader",
38
+ ]
@@ -0,0 +1,22 @@
1
+ import json5
2
+ import os
3
+
4
+ from .resolver import resolve_variables
5
+
6
+ SUFFIXES = {'.json', '.json5'}
7
+
8
+
9
+ def is_json(file_path: str) -> bool:
10
+ return os.path.splitext(file_path)[1] in SUFFIXES
11
+
12
+
13
+ def load_json(file_path: str) -> dict | list:
14
+ with open(file_path, 'r', encoding='utf-8') as f:
15
+ data = json5.load(f)
16
+ return resolve_variables(data)
17
+
18
+ def load_json_raw(file_path: str) -> dict | list:
19
+ with open(file_path, 'r', encoding='utf-8') as f:
20
+ return json5.load(f)
21
+
22
+ __all__ = ['is_json', 'load_json', 'load_json_raw']
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+ import inspect
3
+ from pathlib import Path
4
+ from typing import Callable
5
+ from pydantic import BaseModel, ConfigDict, PrivateAttr
6
+ from typing import TypeVar
7
+ T = TypeVar("T", bound="ConfigLoader")
8
+
9
+ ReloadCallback = Callable[..., None]
10
+
11
+
12
+ class ConfigLoader(BaseModel):
13
+ model_config = ConfigDict(validate_assignment=True)
14
+
15
+ _file_path: Path | None = PrivateAttr(default=None)
16
+ _on_update: ReloadCallback | None = PrivateAttr(default=None)
17
+ _file_state: tuple[int, int] | None = PrivateAttr(default=None)
18
+
19
+ @classmethod
20
+ def from_file(
21
+ cls: type[T],
22
+ file_path: str | Path,
23
+ on_update: ReloadCallback | None = None,
24
+ ) -> T:
25
+ path = Path(file_path).resolve()
26
+ if not path.exists():
27
+ raise FileNotFoundError(f"配置文件 {path} 不存在")
28
+
29
+ data = cls._load_dict(path)
30
+
31
+ obj = cls.model_validate(data)
32
+ obj._file_path = path
33
+ obj._on_update = on_update
34
+ obj._file_state = obj._get_file_state()
35
+ return obj
36
+
37
+ @staticmethod
38
+ def _load_dict(path: Path) -> dict:
39
+ from . import load_config
40
+ data = load_config(str(path))
41
+ if not isinstance(data, dict):
42
+ raise TypeError(f"配置顶层必须是 dict,实际得到 {type(data).__name__}")
43
+ return data
44
+
45
+ def _get_file_state(self) -> tuple[int, int]:
46
+ """获取文件状态"""
47
+ if self._file_path is None:
48
+ raise RuntimeError("此实例未绑定配置文件,请使用 from_file() 创建")
49
+ stat = self._file_path.stat()
50
+ return (stat.st_mtime_ns, stat.st_size)
51
+
52
+ def has_changed(self) -> bool:
53
+ """判断配置文件是否发生变化(纯查询,不更新内部状态)"""
54
+ if self._file_path is None:
55
+ return False
56
+ return self._file_state is None or self._file_state != self._get_file_state()
57
+
58
+ def reload(self) -> bool:
59
+ """重新加载配置文件"""
60
+ if not self.has_changed():
61
+ return False
62
+
63
+ data = self._load_dict(self._file_path)
64
+
65
+ old = self.model_copy(deep=True)
66
+ new_obj = self.__class__.model_validate(data)
67
+
68
+ for field_name in self.__class__.model_fields:
69
+ setattr(self, field_name, getattr(new_obj, field_name))
70
+
71
+ self._file_state = self._get_file_state()
72
+ self._call_update_callback(old)
73
+
74
+ return True
75
+
76
+ def _call_update_callback(self, old: "ConfigLoader") -> None:
77
+ """调用更新回调函数"""
78
+ if self._on_update is None:
79
+ return
80
+ callback = self._on_update
81
+ try:
82
+ sig = inspect.signature(callback)
83
+ except (ValueError, TypeError):
84
+ for args in [(self, old), (self,), ()]:
85
+ try:
86
+ callback(*args)
87
+ return
88
+ except TypeError:
89
+ continue
90
+ raise TypeError(f"无法调用回调函数 {callback!r},请检查其参数签名")
91
+
92
+ params = [
93
+ p for p in sig.parameters.values()
94
+ if p.default is inspect.Parameter.empty
95
+ and p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
96
+ ]
97
+ if len(params) >= 2:
98
+ callback(self, old)
99
+ elif len(params) == 1:
100
+ callback(self)
101
+ else:
102
+ callback()
103
+
104
+ __all__ = ["ConfigLoader"]
@@ -0,0 +1,218 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ from typing import Any
6
+
7
+ _VAR_PATTERN = re.compile(r"\$\{([^{}]+)\}")
8
+
9
+ _SENTINEL = object()
10
+
11
+
12
+ def resolve_variables(data: Any) -> Any:
13
+ """对已加载的配置数据进行 ${} 变量解析。"""
14
+ return _VariableResolver(data).resolve()
15
+
16
+
17
+ def _auto_convert(value: str) -> str | int | float | bool | None:
18
+ """将字符串自动转换为合适的 Python 类型。"""
19
+ lower = value.strip().lower()
20
+ if lower in ("true", "yes", "on"):
21
+ return True
22
+ if lower in ("false", "no", "off"):
23
+ return False
24
+ if lower in ("null", "none", "~"):
25
+ return None
26
+ try:
27
+ return int(value)
28
+ except ValueError:
29
+ pass
30
+ try:
31
+ return float(value)
32
+ except ValueError:
33
+ pass
34
+ return value
35
+
36
+
37
+ class _VariableResolver:
38
+ """
39
+ 解析三种变量语法:
40
+ - ${key.path} 绝对路径引用
41
+ - ${..key} 相对路径引用
42
+ - ${env:NAME} 显式环境变量
43
+ - ${env:NAME:默认值} 带默认值的环境变量
44
+ 以及隐式回退:单级 ${KEY} 在配置中找不到时,自动查找同名环境变量。
45
+ """
46
+
47
+ def __init__(self, root: Any):
48
+ self.root = root
49
+ self._cache: dict[tuple[Any, ...], Any] = {}
50
+ self._resolving: set[tuple[Any, ...]] = set()
51
+
52
+ def resolve(self) -> Any:
53
+ return self._resolve_at(())
54
+
55
+ def _resolve_at(self, path: tuple[Any, ...]) -> Any:
56
+ if path in self._cache:
57
+ return self._cache[path]
58
+ if path in self._resolving:
59
+ raise ValueError(f"检测到循环变量引用: {self._format_path(path)}")
60
+
61
+ self._resolving.add(path)
62
+ try:
63
+ node = self._get_node(path)
64
+ resolved = self._resolve_node(node, path)
65
+ self._cache[path] = resolved
66
+ return resolved
67
+ finally:
68
+ self._resolving.remove(path)
69
+
70
+ def _resolve_node(self, node: Any, path: tuple[Any, ...]) -> Any:
71
+ if isinstance(node, dict):
72
+ return {key: self._resolve_at(path + (key,)) for key in node}
73
+ if isinstance(node, list):
74
+ return [self._resolve_at(path + (index,)) for index, _ in enumerate(node)]
75
+ if isinstance(node, str):
76
+ return self._resolve_string(node, path)
77
+ return node
78
+
79
+ def _resolve_string(self, value: str, path: tuple[Any, ...]) -> Any:
80
+ full_match = _VAR_PATTERN.fullmatch(value)
81
+ if full_match:
82
+ expr = full_match.group(1)
83
+ env_result = self._try_resolve_env(expr)
84
+ if env_result is not _SENTINEL:
85
+ return env_result
86
+ target_path = self._resolve_reference(expr, path)
87
+ return self._resolve_at(target_path)
88
+
89
+ def replace(match: re.Match[str]) -> str:
90
+ expr = match.group(1)
91
+ env_result = self._try_resolve_env(expr)
92
+ if env_result is not _SENTINEL:
93
+ return str(env_result)
94
+ target_path = self._resolve_reference(expr, path)
95
+ return str(self._resolve_at(target_path))
96
+
97
+ return _VAR_PATTERN.sub(replace, value)
98
+
99
+ def _try_resolve_env(self, expr: str) -> Any:
100
+ """解析 env:VAR 或 env:VAR:default。不匹配 env: 前缀则返回 _SENTINEL。"""
101
+ expr = expr.strip()
102
+ if not expr.startswith("env:"):
103
+ return _SENTINEL
104
+
105
+ rest = expr[4:]
106
+ colon_pos = rest.find(":")
107
+ if colon_pos == -1:
108
+ env_name = rest.strip()
109
+ default = _SENTINEL
110
+ else:
111
+ env_name = rest[:colon_pos].strip()
112
+ default = rest[colon_pos + 1:]
113
+
114
+ if not env_name:
115
+ raise ValueError("环境变量名不能为空: ${env:}")
116
+
117
+ value = os.environ.get(env_name)
118
+ if value is not None:
119
+ return _auto_convert(value)
120
+ if default is not _SENTINEL:
121
+ return _auto_convert(default)
122
+ raise KeyError(f"环境变量不存在: {env_name}")
123
+
124
+ # ---- 配置内引用 ----
125
+
126
+ def _resolve_reference(self, expr: str, current_path: tuple[Any, ...]) -> tuple[Any, ...]:
127
+ expr = expr.strip()
128
+ if not expr:
129
+ raise ValueError("变量表达式不能为空")
130
+ if expr.startswith("."):
131
+ return self._resolve_relative_path(expr, current_path)
132
+ return self._split_path(expr)
133
+
134
+ def _resolve_relative_path(
135
+ self,
136
+ expr: str,
137
+ current_path: tuple[Any, ...],
138
+ ) -> tuple[Any, ...]:
139
+ dot_count = 0
140
+ while dot_count < len(expr) and expr[dot_count] == ".":
141
+ dot_count += 1
142
+
143
+ if dot_count < 2 or dot_count % 2 != 0:
144
+ raise ValueError(f"不支持的相对路径变量: {expr}")
145
+
146
+ remainder = expr[dot_count:]
147
+ if remainder.startswith(".") or not remainder:
148
+ raise ValueError(f"不支持的相对路径变量: {expr}")
149
+
150
+ up_levels = dot_count // 2
151
+ mapping_paths = self._get_mapping_paths(current_path)
152
+ if len(mapping_paths) < up_levels:
153
+ raise ValueError(f"相对路径越界: {expr},当前位置 {self._format_path(current_path)}")
154
+ base_path = mapping_paths[-up_levels]
155
+ return base_path + self._split_path(remainder)
156
+
157
+ def _get_mapping_paths(self, current_path: tuple[Any, ...]) -> list[tuple[Any, ...]]:
158
+ mapping_paths: list[tuple[Any, ...]] = []
159
+ node = self.root
160
+ walked: list[Any] = []
161
+
162
+ if isinstance(node, dict):
163
+ mapping_paths.append(())
164
+
165
+ for key in current_path[:-1]:
166
+ if isinstance(node, dict):
167
+ node = node[key]
168
+ elif isinstance(node, list):
169
+ node = node[key]
170
+ else:
171
+ break
172
+ walked.append(key)
173
+ if isinstance(node, dict):
174
+ mapping_paths.append(tuple(walked))
175
+
176
+ return mapping_paths
177
+
178
+ def _split_path(self, expr: str) -> tuple[Any, ...]:
179
+ parts = [part.strip() for part in expr.split(".")]
180
+ if any(not part for part in parts):
181
+ raise ValueError(f"无效的变量路径: {expr}")
182
+
183
+ result: list[Any] = []
184
+ for part in parts:
185
+ if part.isdigit():
186
+ result.append(int(part))
187
+ else:
188
+ result.append(part)
189
+ return tuple(result)
190
+
191
+ def _get_node(self, path: tuple[Any, ...]) -> Any:
192
+ node = self.root
193
+ for key in path:
194
+ try:
195
+ node = node[key]
196
+ except (KeyError, IndexError, TypeError):
197
+ if isinstance(key, int) and isinstance(node, dict):
198
+ try:
199
+ node = node[str(key)]
200
+ continue
201
+ except KeyError:
202
+ pass
203
+ if len(path) == 1 and isinstance(path[0], str):
204
+ env_val = os.environ.get(path[0])
205
+ if env_val is not None:
206
+ return _auto_convert(env_val)
207
+ raise KeyError(f"变量引用不存在: {self._format_path(path)}")
208
+ return node
209
+
210
+ def _format_path(self, path: tuple[Any, ...]) -> str:
211
+ if not path:
212
+ return "<root>"
213
+ return ".".join(str(part) for part in path)
214
+
215
+
216
+ __all__ = [
217
+ "resolve_variables",
218
+ ]
@@ -0,0 +1,25 @@
1
+ import os
2
+ try:
3
+ import tomllib
4
+ except ModuleNotFoundError:
5
+ import tomli as tomllib
6
+
7
+ from .resolver import resolve_variables
8
+
9
+ SUFFIXES = {'.toml'}
10
+
11
+
12
+ def is_toml(file_path: str) -> bool:
13
+ return os.path.splitext(file_path)[1] in SUFFIXES
14
+
15
+
16
+ def load_toml(file_path: str) -> dict:
17
+ with open(file_path, "rb") as f:
18
+ data = tomllib.load(f)
19
+ return resolve_variables(data)
20
+
21
+ def load_toml_raw(file_path: str) -> dict:
22
+ with open(file_path, "rb") as f:
23
+ return tomllib.load(f)
24
+
25
+ __all__ = ['is_toml', 'load_toml', 'load_toml_raw']
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+ import os
3
+ from contextlib import contextmanager
4
+ from contextvars import ContextVar
5
+ from pathlib import Path
6
+ from typing import Any
7
+ import yaml
8
+ from .resolver import resolve_variables
9
+
10
+ SUFFIXES = {".yaml", ".yml"}
11
+ _YAML_LOAD_STACK: ContextVar[list[Path] | None] = ContextVar("_YAML_LOAD_STACK", default=None)
12
+
13
+
14
+ @contextmanager
15
+ def _include_guard(path: Path):
16
+ """管理 include 循环检测栈,顶层调用结束后复位。"""
17
+ stack = _YAML_LOAD_STACK.get()
18
+ is_top_level = stack is None
19
+ if is_top_level:
20
+ stack = []
21
+ _YAML_LOAD_STACK.set(stack)
22
+
23
+ if path in stack:
24
+ chain = " -> ".join(str(item) for item in [*stack, path])
25
+ raise ValueError(f"检测到循环 include: {chain}")
26
+
27
+ stack.append(path)
28
+ try:
29
+ yield
30
+ finally:
31
+ stack.pop()
32
+ if is_top_level:
33
+ _YAML_LOAD_STACK.set(None)
34
+
35
+
36
+ def is_yaml(file_path: str) -> bool:
37
+ return os.path.splitext(file_path)[1] in SUFFIXES
38
+
39
+
40
+ def load_yaml(file_path: str) -> dict | list:
41
+ path = Path(file_path).resolve()
42
+ with _include_guard(path):
43
+ data = _load_yaml_raw(path)
44
+ return resolve_variables(data)
45
+
46
+
47
+ def load_yaml_raw(file_path: str) -> dict | list:
48
+ """加载 YAML 但不解析变量(供 include 使用)"""
49
+ path = Path(file_path).resolve()
50
+ with _include_guard(path):
51
+ return _load_yaml_raw(path)
52
+
53
+
54
+ def _load_yaml_raw(path: Path) -> Any:
55
+ class _YamlIncludeLoader(yaml.SafeLoader):
56
+ pass
57
+
58
+ def construct_include(loader: _YamlIncludeLoader, node: yaml.Node) -> Any:
59
+ relative_path = loader.construct_scalar(node)
60
+ target_path = (path.parent / relative_path).resolve()
61
+ from . import load_config_raw
62
+ return load_config_raw(str(target_path))
63
+
64
+ _YamlIncludeLoader.add_constructor("!include", construct_include)
65
+
66
+ with path.open("r", encoding="utf-8") as f:
67
+ return yaml.load(f, Loader=_YamlIncludeLoader)
68
+
69
+
70
+ __all__ = ["is_yaml", "load_yaml", "load_yaml_raw"]
@@ -0,0 +1,17 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "python-library-configlib"
7
+ version = "0.1.0"
8
+ requires-python = ">=3.10"
9
+ dependencies = [
10
+ "pydantic>=2.0.0",
11
+ "json5>=0.9.0",
12
+ "PyYAML>=6.0.0",
13
+ "tomli>=2.0.0; python_version < '3.11'",
14
+ ]
15
+
16
+ [tool.hatch.build.targets.wheel]
17
+ packages = ["configlib"]
@@ -0,0 +1,10 @@
1
+ @echo off
2
+ cd /d %~dp0
3
+
4
+ if not exist .venv (
5
+ python -m venv .venv
6
+ )
7
+
8
+ call .venv\Scripts\activate.bat
9
+ python -m pip install -e .
10
+ python -m unittest discover -s tests -p "test_*.py"
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import tempfile
4
+ import unittest
5
+ from pathlib import Path
6
+
7
+ from configlib.json import is_json, load_json, load_json_raw
8
+
9
+
10
+ class JsonHelpersTests(unittest.TestCase):
11
+ def test_is_json_suffixes(self) -> None:
12
+ self.assertTrue(is_json("app.json"))
13
+ self.assertTrue(is_json(r"C:\x\config.json5"))
14
+ self.assertFalse(is_json("x.yaml"))
15
+ self.assertFalse(is_json("noext"))
16
+
17
+ def test_load_json_resolves_full_placeholder(self) -> None:
18
+ with tempfile.TemporaryDirectory() as tmp:
19
+ path = Path(tmp) / "c.json5"
20
+ path.write_text(
21
+ '{"port": 8080, "label": "${port}"}',
22
+ encoding="utf-8",
23
+ )
24
+ data = load_json(str(path))
25
+ self.assertEqual(data["label"], 8080)
26
+
27
+ def test_load_json_raw_skips_resolution(self) -> None:
28
+ with tempfile.TemporaryDirectory() as tmp:
29
+ path = Path(tmp) / "c.json5"
30
+ path.write_text(
31
+ '{"x": "${y}", "y": 1}',
32
+ encoding="utf-8",
33
+ )
34
+ data = load_json_raw(str(path))
35
+ self.assertEqual(data["x"], "${y}")
36
+
37
+ def test_load_json_interpolates_in_string(self) -> None:
38
+ with tempfile.TemporaryDirectory() as tmp:
39
+ path = Path(tmp) / "c.json5"
40
+ path.write_text(
41
+ '{"host": "127.0.0.1", "dsn": "postgres://${host}/db"}',
42
+ encoding="utf-8",
43
+ )
44
+ data = load_json(str(path))
45
+ self.assertEqual(data["dsn"], "postgres://127.0.0.1/db")
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ import tempfile
4
+ import unittest
5
+ from pathlib import Path
6
+
7
+ from configlib import load_config, load_config_raw
8
+
9
+
10
+ class LoadConfigDispatchTests(unittest.TestCase):
11
+ def test_unsupported_extension_raises(self) -> None:
12
+ with tempfile.TemporaryDirectory() as tmp:
13
+ path = Path(tmp) / "cfg.txt"
14
+ path.write_text("{}", encoding="utf-8")
15
+ with self.assertRaisesRegex(ValueError, "不支持的文件格式"):
16
+ load_config(path)
17
+ with self.assertRaisesRegex(ValueError, "不支持的文件格式"):
18
+ load_config_raw(path)
19
+
20
+ def test_load_config_accepts_path_object(self) -> None:
21
+ base_dir = Path(__file__).resolve().parent.parent
22
+ path = base_dir / "assets" / "example.yaml"
23
+ self.assertIsInstance(load_config(path), dict)
24
+
25
+ def test_raw_does_not_resolve_variables(self) -> None:
26
+ base_dir = Path(__file__).resolve().parent.parent
27
+ path = base_dir / "assets" / "example.yaml"
28
+ data = load_config_raw(path)
29
+ self.assertEqual(data["refs"]["app_name"], "${app.name}")
30
+
31
+ def test_resolved_matches_full_tree(self) -> None:
32
+ base_dir = Path(__file__).resolve().parent.parent
33
+ resolved = load_config(base_dir / "assets" / "example.yaml")
34
+ raw = load_config_raw(base_dir / "assets" / "example.yaml")
35
+ self.assertNotEqual(resolved["refs"]["app_name"], raw["refs"]["app_name"])
36
+ self.assertEqual(resolved["refs"]["app_name"], "demo")
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ import tempfile
4
+ import time
5
+ import unittest
6
+ from pathlib import Path
7
+
8
+ from configlib import ConfigLoader
9
+
10
+
11
+ class AppConfig(ConfigLoader):
12
+ name: str = ""
13
+ port: int = 0
14
+ debug: bool = False
15
+
16
+
17
+ def _write_text(path: Path, text: str) -> None:
18
+ path.write_text(text, encoding="utf-8")
19
+
20
+
21
+ class ConfiglibLoaderTests(unittest.TestCase):
22
+ def test_reload_and_on_update(self) -> None:
23
+ with tempfile.TemporaryDirectory() as tmp:
24
+ root = Path(tmp)
25
+ config_path = root / "config.json5"
26
+
27
+ _write_text(
28
+ config_path,
29
+ """{
30
+ name: "demo",
31
+ port: 8080,
32
+ debug: false,
33
+ }""",
34
+ )
35
+
36
+ called: list[tuple[str, int, bool]] = []
37
+
38
+ def on_update(cfg: AppConfig) -> None:
39
+ called.append((cfg.name, cfg.port, cfg.debug))
40
+
41
+ cfg = AppConfig.from_file(
42
+ file_path=str(config_path),
43
+ on_update=on_update,
44
+ )
45
+
46
+ self.assertEqual(cfg.name, "demo")
47
+ self.assertEqual(cfg.port, 8080)
48
+ self.assertIs(cfg.debug, False)
49
+
50
+ self.assertIs(cfg.reload(), False)
51
+ self.assertEqual(called, [])
52
+
53
+ time.sleep(0.01)
54
+ _write_text(
55
+ config_path,
56
+ """{
57
+ name: "demo2",
58
+ port: 9090,
59
+ debug: true,
60
+ }""",
61
+ )
62
+
63
+ self.assertIs(cfg.reload(), True)
64
+ self.assertEqual(cfg.name, "demo2")
65
+ self.assertEqual(cfg.port, 9090)
66
+ self.assertIs(cfg.debug, True)
67
+ self.assertEqual(called, [("demo2", 9090, True)])
68
+
69
+ def test_from_file_missing_raises(self) -> None:
70
+ with tempfile.TemporaryDirectory() as tmp:
71
+ missing = Path(tmp) / "nope.json5"
72
+ with self.assertRaises(FileNotFoundError):
73
+ AppConfig.from_file(missing)
74
+
75
+ def test_from_file_rejects_non_dict_top_level(self) -> None:
76
+ with tempfile.TemporaryDirectory() as tmp:
77
+ path = Path(tmp) / "list.json5"
78
+ _write_text(path, "[1, 2, 3]")
79
+ with self.assertRaisesRegex(TypeError, "配置顶层必须是 dict"):
80
+ AppConfig.from_file(path)
81
+
82
+ def test_has_changed_detects_modification(self) -> None:
83
+ with tempfile.TemporaryDirectory() as tmp:
84
+ root = Path(tmp)
85
+ config_path = root / "config.json5"
86
+ _write_text(
87
+ config_path,
88
+ """{ name: "a", port: 1, debug: false }""",
89
+ )
90
+ cfg = AppConfig.from_file(config_path)
91
+ self.assertFalse(cfg.has_changed())
92
+ time.sleep(0.01)
93
+ _write_text(
94
+ config_path,
95
+ """{ name: "b", port: 2, debug: true }""",
96
+ )
97
+ self.assertTrue(cfg.has_changed())
98
+
99
+ def test_on_update_two_arg_receives_old_snapshot(self) -> None:
100
+ with tempfile.TemporaryDirectory() as tmp:
101
+ root = Path(tmp)
102
+ config_path = root / "config.json5"
103
+ _write_text(
104
+ config_path,
105
+ """{ name: "first", port: 1, debug: false }""",
106
+ )
107
+ events: list[tuple[str, str]] = []
108
+
109
+ def on_update(new: AppConfig, old: AppConfig) -> None:
110
+ events.append((new.name, old.name))
111
+
112
+ cfg = AppConfig.from_file(config_path, on_update=on_update)
113
+ time.sleep(0.01)
114
+ _write_text(
115
+ config_path,
116
+ """{ name: "second", port: 1, debug: false }""",
117
+ )
118
+ self.assertTrue(cfg.reload())
119
+ self.assertEqual(events, [("second", "first")])
120
+
121
+ def test_on_update_zero_arg(self) -> None:
122
+ with tempfile.TemporaryDirectory() as tmp:
123
+ root = Path(tmp)
124
+ config_path = root / "config.json5"
125
+ _write_text(config_path, """{ name: "x", port: 1, debug: false }""")
126
+ count = 0
127
+
128
+ def on_update() -> None:
129
+ nonlocal count
130
+ count += 1
131
+
132
+ cfg = AppConfig.from_file(config_path, on_update=on_update)
133
+ time.sleep(0.01)
134
+ _write_text(config_path, """{ name: "y", port: 1, debug: false }""")
135
+ self.assertTrue(cfg.reload())
136
+ self.assertEqual(count, 1)
137
+
138
+
139
+ if __name__ == "__main__":
140
+ unittest.main()
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import unittest
5
+ from unittest.mock import patch
6
+
7
+ from configlib.resolver import resolve_variables
8
+
9
+
10
+ class ResolverTests(unittest.TestCase):
11
+ def test_pass_through_non_string_nodes(self) -> None:
12
+ self.assertIsNone(resolve_variables(None))
13
+ self.assertEqual(resolve_variables(42), 42)
14
+ self.assertEqual(resolve_variables([1, {"a": 2}]), [1, {"a": 2}])
15
+
16
+ def test_full_placeholder_resolves_sibling(self) -> None:
17
+ data = {"a": 1, "b": "${a}"}
18
+ self.assertEqual(resolve_variables(data)["b"], 1)
19
+
20
+ def test_nested_path(self) -> None:
21
+ data = {"x": {"y": "ok"}, "z": "${x.y}"}
22
+ self.assertEqual(resolve_variables(data)["z"], "ok")
23
+
24
+ def test_list_index_in_path(self) -> None:
25
+ data = {"items": [{"id": 9}], "first": "${items.0.id}"}
26
+ self.assertEqual(resolve_variables(data)["first"], 9)
27
+
28
+ def test_relative_reference(self) -> None:
29
+ data = {
30
+ "block": {
31
+ "name": "inner",
32
+ "copy": "${..name}",
33
+ },
34
+ "name": "outer",
35
+ }
36
+ out = resolve_variables(data)
37
+ self.assertEqual(out["block"]["copy"], "inner")
38
+
39
+ def test_env_explicit(self) -> None:
40
+ data = {"p": "${env:CFG_TEST_LIB_PORT}"}
41
+ with patch.dict(os.environ, {"CFG_TEST_LIB_PORT": "3000"}, clear=False):
42
+ self.assertEqual(resolve_variables(data)["p"], 3000)
43
+
44
+ def test_env_with_default_when_missing(self) -> None:
45
+ env_name = "CFG_TEST_LIB_SHOULD_NOT_EXIST_XYZ"
46
+ self.assertIsNone(os.environ.get(env_name))
47
+ data = {"p": "${env:" + env_name + ":fallback}"}
48
+ self.assertEqual(resolve_variables(data)["p"], "fallback")
49
+
50
+ def test_env_missing_raises(self) -> None:
51
+ env_name = "CFG_TEST_LIB_ABSENT_VAR"
52
+ self.assertIsNone(os.environ.get(env_name))
53
+ data = {"p": "${env:" + env_name + "}"}
54
+ with self.assertRaises(KeyError):
55
+ resolve_variables(data)
56
+
57
+ def test_single_segment_falls_back_to_env(self) -> None:
58
+ name = "CFG_TEST_LIB_SINGLE"
59
+ data = {"p": "${" + name + "}"}
60
+ with patch.dict(os.environ, {name: "yes"}, clear=False):
61
+ self.assertIs(resolve_variables(data)["p"], True)
62
+
63
+ def test_partial_string_interpolation(self) -> None:
64
+ data = {"host": "h", "s": "x-${host}-y"}
65
+ self.assertEqual(resolve_variables(data)["s"], "x-h-y")
66
+
67
+ def test_circular_reference_raises(self) -> None:
68
+ data: dict[str, object] = {"a": "${b}", "b": "${a}"}
69
+ with self.assertRaisesRegex(ValueError, "循环变量引用"):
70
+ resolve_variables(data)
71
+
72
+ def test_invalid_relative_path_raises(self) -> None:
73
+ data = {"x": "${.bad}"}
74
+ with self.assertRaisesRegex(ValueError, "不支持的相对路径变量"):
75
+ resolve_variables(data)
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import tempfile
4
+ import unittest
5
+ from pathlib import Path
6
+
7
+ from configlib.toml import is_toml, load_toml, load_toml_raw
8
+
9
+
10
+ class TomlHelpersTests(unittest.TestCase):
11
+ def test_is_toml_suffix(self) -> None:
12
+ self.assertTrue(is_toml("cfg.toml"))
13
+ self.assertFalse(is_toml("x.json"))
14
+
15
+ def test_load_toml_resolves_variables(self) -> None:
16
+ with tempfile.TemporaryDirectory() as tmp:
17
+ path = Path(tmp) / "c.toml"
18
+ path.write_text(
19
+ '[app]\nname = "demo"\nlabel = "${app.name}"\n',
20
+ encoding="utf-8",
21
+ )
22
+ data = load_toml(str(path))
23
+ self.assertEqual(data["app"]["label"], "demo")
24
+
25
+ def test_load_toml_raw_no_resolution(self) -> None:
26
+ with tempfile.TemporaryDirectory() as tmp:
27
+ path = Path(tmp) / "c.toml"
28
+ path.write_text(
29
+ 'x = "${y}"\ny = 1\n',
30
+ encoding="utf-8",
31
+ )
32
+ data = load_toml_raw(str(path))
33
+ self.assertEqual(data["x"], "${y}")
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ import tempfile
4
+ import unittest
5
+ from pathlib import Path
6
+
7
+ from configlib import load_config, load_config_raw
8
+ from configlib.yaml import is_yaml, load_yaml_raw
9
+
10
+
11
+ class ConfiglibYamlTests(unittest.TestCase):
12
+ def test_load_example_yaml(self) -> None:
13
+ base_dir = Path(__file__).resolve().parent.parent
14
+ result = load_config(str(base_dir / "assets" / "example.yaml"))
15
+
16
+ expected = {
17
+ "app": {
18
+ "name": "demo",
19
+ "version": "1.0",
20
+ },
21
+ "base": {
22
+ "title": "hello",
23
+ "nested": {
24
+ "value": 123,
25
+ },
26
+ },
27
+ "json_data": {
28
+ "user": {
29
+ "name": "alice",
30
+ "age": 18,
31
+ },
32
+ },
33
+ "toml_data": {
34
+ "server": {
35
+ "host": "127.0.0.1",
36
+ "port": 8080,
37
+ },
38
+ },
39
+ "refs": {
40
+ "app_name": "demo",
41
+ "base_title": "hello",
42
+ "local": {
43
+ "name": "child",
44
+ "parent_name": "child",
45
+ },
46
+ },
47
+ }
48
+
49
+ self.assertEqual(result, expected)
50
+
51
+ def test_is_yaml_suffixes(self) -> None:
52
+ self.assertTrue(is_yaml("a.yaml"))
53
+ self.assertTrue(is_yaml("b.yml"))
54
+ self.assertFalse(is_yaml("c.json"))
55
+
56
+ def test_load_yaml_raw_keeps_placeholders(self) -> None:
57
+ base_dir = Path(__file__).resolve().parent.parent
58
+ path = base_dir / "assets" / "example.yaml"
59
+ data = load_yaml_raw(str(path))
60
+ self.assertEqual(data["refs"]["app_name"], "${app.name}")
61
+
62
+ def test_load_config_raw_yaml_matches_load_yaml_raw(self) -> None:
63
+ base_dir = Path(__file__).resolve().parent.parent
64
+ path = base_dir / "assets" / "example.yaml"
65
+ self.assertEqual(load_config_raw(path), load_yaml_raw(str(path)))
66
+
67
+ def test_include_cycle_raises(self) -> None:
68
+ with tempfile.TemporaryDirectory() as tmp:
69
+ root = Path(tmp)
70
+ a = root / "a.yaml"
71
+ b = root / "b.yaml"
72
+ a.write_text("v: !include b.yaml\n", encoding="utf-8")
73
+ b.write_text("v: !include a.yaml\n", encoding="utf-8")
74
+ with self.assertRaisesRegex(ValueError, "循环 include"):
75
+ load_config(str(a))
76
+
77
+
78
+ if __name__ == "__main__":
79
+ unittest.main()