python-library-configlib 0.1.0__py3-none-any.whl

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.
configlib/__init__.py ADDED
@@ -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
+ ]
configlib/json.py ADDED
@@ -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']
configlib/loader.py ADDED
@@ -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"]
configlib/resolver.py ADDED
@@ -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
+ ]
configlib/toml.py ADDED
@@ -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']
configlib/yaml.py ADDED
@@ -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,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,9 @@
1
+ configlib/__init__.py,sha256=nHRPxw4VSMahSttIrTI0r5yphgzKgk_mGZnPyckNh9s,1139
2
+ configlib/json.py,sha256=niF-J3FoHRsQKIZo4GUmkaEXpwLriRjF2Mpj1-F77rc,562
3
+ configlib/loader.py,sha256=IJX-50m_7WWa7vgDaiDA-8L8PbbiTlK09ImftCph2WY,3518
4
+ configlib/resolver.py,sha256=CoTOzgxvQvkAOS1qjHf3ZaLWZY7Yc2Y6iGfszX2KDrg,7524
5
+ configlib/toml.py,sha256=TwBvnLi055OqVd2qcSA8kh0VRx4MIA6DfhRHXX0LF_w,578
6
+ configlib/yaml.py,sha256=ngUjqAiL5kyoC3lciLX1TH5yIeNlAAWAU2lIdK0Dp8I,2092
7
+ python_library_configlib-0.1.0.dist-info/METADATA,sha256=XiN2pIWRZHs_ZHBGuYXmcsEfXAR2FTBgE8_odMBML-Y,233
8
+ python_library_configlib-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ python_library_configlib-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any