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.
- python_library_configlib-0.1.0/.gitignore +11 -0
- python_library_configlib-0.1.0/PKG-INFO +8 -0
- python_library_configlib-0.1.0/assets/base.yaml +3 -0
- python_library_configlib-0.1.0/assets/data.json5 +6 -0
- python_library_configlib-0.1.0/assets/data.toml +3 -0
- python_library_configlib-0.1.0/assets/example.yaml +14 -0
- python_library_configlib-0.1.0/configlib/__init__.py +38 -0
- python_library_configlib-0.1.0/configlib/json.py +22 -0
- python_library_configlib-0.1.0/configlib/loader.py +104 -0
- python_library_configlib-0.1.0/configlib/resolver.py +218 -0
- python_library_configlib-0.1.0/configlib/toml.py +25 -0
- python_library_configlib-0.1.0/configlib/yaml.py +70 -0
- python_library_configlib-0.1.0/pyproject.toml +17 -0
- python_library_configlib-0.1.0/test.bat +10 -0
- python_library_configlib-0.1.0/tests/test_json.py +45 -0
- python_library_configlib-0.1.0/tests/test_load_config.py +36 -0
- python_library_configlib-0.1.0/tests/test_loader.py +140 -0
- python_library_configlib-0.1.0/tests/test_resolver.py +75 -0
- python_library_configlib-0.1.0/tests/test_toml.py +33 -0
- python_library_configlib-0.1.0/tests/test_yaml.py +79 -0
|
@@ -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,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()
|