echotools 1.0.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.
- echotools/__init__.py +149 -0
- echotools/cache/__init__.py +8 -0
- echotools/cache/list_cache.py +152 -0
- echotools/cache/memory_cache.py +72 -0
- echotools/config/__init__.py +23 -0
- echotools/config/base.py +144 -0
- echotools/config/center.py +443 -0
- echotools/config/loader.py +138 -0
- echotools/config/merge.py +29 -0
- echotools/dispatch/__init__.py +20 -0
- echotools/dispatch/candidate.py +51 -0
- echotools/dispatch/dispatcher.py +268 -0
- echotools/dispatch/selector.py +390 -0
- echotools/errors/__init__.py +29 -0
- echotools/errors/base.py +32 -0
- echotools/errors/classify.py +42 -0
- echotools/errors/common.py +74 -0
- echotools/events/__init__.py +8 -0
- echotools/events/bus.py +104 -0
- echotools/events/event.py +31 -0
- echotools/files/__init__.py +7 -0
- echotools/files/file_util.py +155 -0
- echotools/fncall/__init__.py +40 -0
- echotools/fncall/parsers/__init__.py +6 -0
- echotools/fncall/parsers/stream.py +156 -0
- echotools/fncall/parsers/xml_parser.py +320 -0
- echotools/fncall/prompt/__init__.py +5 -0
- echotools/fncall/prompt/history.py +383 -0
- echotools/fncall/prompt/inject.py +108 -0
- echotools/fncall/prompt/templates.py +159 -0
- echotools/fncall/protocols/__init__.py +28 -0
- echotools/fncall/protocols/antml.py +251 -0
- echotools/fncall/protocols/bracket.py +154 -0
- echotools/fncall/protocols/custom.py +50 -0
- echotools/fncall/protocols/nous.py +161 -0
- echotools/fncall/protocols/original.py +378 -0
- echotools/fncall/protocols/xml.py +249 -0
- echotools/fncall/registry.py +96 -0
- echotools/fncall/shared/__init__.py +25 -0
- echotools/fncall/shared/coercion.py +295 -0
- echotools/fncall/shared/loop_detect.py +97 -0
- echotools/fncall/shared/normalization.py +170 -0
- echotools/fncall/shared/xml_helpers.py +56 -0
- echotools/ids/__init__.py +7 -0
- echotools/ids/generator.py +62 -0
- echotools/io/__init__.py +11 -0
- echotools/io/io_utils.py +81 -0
- echotools/lifecycle/__init__.py +8 -0
- echotools/lifecycle/manager.py +72 -0
- echotools/lifecycle/updater.py +176 -0
- echotools/logger/__init__.py +12 -0
- echotools/logger/manager.py +164 -0
- echotools/network/__init__.py +11 -0
- echotools/network/http_utils.py +68 -0
- echotools/plugin/__init__.py +9 -0
- echotools/plugin/base.py +41 -0
- echotools/plugin/discovery.py +134 -0
- echotools/plugin/registry.py +253 -0
- echotools/process/__init__.py +10 -0
- echotools/process/port.py +183 -0
- echotools/protocol/__init__.py +19 -0
- echotools/protocol/base.py +123 -0
- echotools/proxy/__init__.py +7 -0
- echotools/proxy/manager.py +269 -0
- echotools/retry/__init__.py +11 -0
- echotools/retry/retry.py +127 -0
- echotools/runtime/__init__.py +7 -0
- echotools/runtime/collector.py +60 -0
- echotools/scheduler/__init__.py +7 -0
- echotools/scheduler/scheduler.py +69 -0
- echotools/sdk/__init__.py +7 -0
- echotools/sdk/facade.py +154 -0
- echotools/tracing/__init__.py +28 -0
- echotools/tracing/context.py +63 -0
- echotools/tracing/span.py +135 -0
- echotools/tracing/tracer.py +97 -0
- echotools/watcher/__init__.py +7 -0
- echotools/watcher/file_watcher.py +110 -0
- echotools/web/__init__.py +8 -0
- echotools/web/application.py +120 -0
- echotools/web/utils.py +186 -0
- echotools-1.0.0.dist-info/METADATA +80 -0
- echotools-1.0.0.dist-info/RECORD +85 -0
- echotools-1.0.0.dist-info/WHEEL +5 -0
- echotools-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""配置中心:通用点路径访问 + 类型化绑定 + 热重载 + 变更回调。
|
|
4
|
+
|
|
5
|
+
完全项目无关:不预设任何配置结构,由用户提供 schema 或直接点路径访问。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import copy
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import (
|
|
15
|
+
Any,
|
|
16
|
+
Callable,
|
|
17
|
+
Dict,
|
|
18
|
+
List,
|
|
19
|
+
Optional,
|
|
20
|
+
Type,
|
|
21
|
+
TypeVar,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from echotools.config.base import ConfigBase
|
|
25
|
+
from echotools.config.loader import find_config, find_template, load_file, write_toml
|
|
26
|
+
from echotools.config.merge import merge_dicts
|
|
27
|
+
from echotools.errors.common import ConfigError
|
|
28
|
+
from echotools.logger.manager import get_logger
|
|
29
|
+
|
|
30
|
+
__all__ = ["ConfigCenter"]
|
|
31
|
+
|
|
32
|
+
logger = get_logger(__name__)
|
|
33
|
+
|
|
34
|
+
C = TypeVar("C", bound=ConfigBase)
|
|
35
|
+
|
|
36
|
+
_MISSING = object()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ConfigCenter:
|
|
40
|
+
"""通用配置中心。
|
|
41
|
+
|
|
42
|
+
特性:
|
|
43
|
+
- 点路径访问:cfg.get("server.port")
|
|
44
|
+
- 类型化绑定:cfg.bind(MyConfig)
|
|
45
|
+
- 热重载与变更回调
|
|
46
|
+
- 完全不依赖具体配置结构
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self) -> None:
|
|
50
|
+
"""初始化配置中心。"""
|
|
51
|
+
self._raw: Dict[str, Any] = {}
|
|
52
|
+
self._path: Optional[Path] = None
|
|
53
|
+
self._callbacks: Dict[str, List[Callable[[Any, Any], Any]]] = {}
|
|
54
|
+
self._lock: Optional[asyncio.Lock] = None
|
|
55
|
+
self._bound: Optional[ConfigBase] = None
|
|
56
|
+
self._observer: Any = None
|
|
57
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
58
|
+
self._debounce_delay: float = 0.5
|
|
59
|
+
self._last_reload_trigger: float = 0.0
|
|
60
|
+
self._is_reloading: bool = False
|
|
61
|
+
|
|
62
|
+
def _get_lock(self) -> asyncio.Lock:
|
|
63
|
+
"""延迟初始化锁。"""
|
|
64
|
+
if self._lock is None:
|
|
65
|
+
self._lock = asyncio.Lock()
|
|
66
|
+
return self._lock
|
|
67
|
+
|
|
68
|
+
def load(
|
|
69
|
+
self,
|
|
70
|
+
path: Optional[str] = None,
|
|
71
|
+
*,
|
|
72
|
+
filename: str = "config.toml",
|
|
73
|
+
data: Optional[Dict[str, Any]] = None,
|
|
74
|
+
) -> Dict[str, Any]:
|
|
75
|
+
"""加载配置。
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
path: 配置文件路径。
|
|
79
|
+
filename: 自动查找时的文件名。
|
|
80
|
+
data: 直接提供字典(最高优先,跳过文件)。
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
原始配置字典。
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
ConfigError: 无法定位或解析配置。
|
|
87
|
+
"""
|
|
88
|
+
if data is not None:
|
|
89
|
+
self._raw = copy.deepcopy(data)
|
|
90
|
+
return dict(self._raw)
|
|
91
|
+
if path:
|
|
92
|
+
self._path = Path(path).resolve()
|
|
93
|
+
else:
|
|
94
|
+
self._path = find_config(filename)
|
|
95
|
+
if self._path is None:
|
|
96
|
+
raise ConfigError("未找到配置文件: {}".format(filename))
|
|
97
|
+
self._raw = load_file(self._path)
|
|
98
|
+
logger.info("配置已加载: %s", self._path)
|
|
99
|
+
return dict(self._raw)
|
|
100
|
+
|
|
101
|
+
def get(self, path: str, default: Any = None) -> Any:
|
|
102
|
+
"""按点路径获取配置值。
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
path: 形如 "server.port" 的点路径。
|
|
106
|
+
default: 缺省值。
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
配置值或默认值。
|
|
110
|
+
"""
|
|
111
|
+
node: Any = self._raw
|
|
112
|
+
for part in path.split("."):
|
|
113
|
+
if isinstance(node, dict) and part in node:
|
|
114
|
+
node = node[part]
|
|
115
|
+
else:
|
|
116
|
+
return default
|
|
117
|
+
return node
|
|
118
|
+
|
|
119
|
+
def set(self, path: str, value: Any) -> None:
|
|
120
|
+
"""按点路径设置配置值(内存中)。
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
path: 点路径。
|
|
124
|
+
value: 新值。
|
|
125
|
+
"""
|
|
126
|
+
parts = path.split(".")
|
|
127
|
+
node = self._raw
|
|
128
|
+
for part in parts[:-1]:
|
|
129
|
+
if part not in node or not isinstance(node[part], dict):
|
|
130
|
+
node[part] = {}
|
|
131
|
+
node = node[part]
|
|
132
|
+
node[parts[-1]] = value
|
|
133
|
+
|
|
134
|
+
def bind(self, schema: Type[C], section: str = "") -> C:
|
|
135
|
+
"""将配置绑定到类型化 ConfigBase 子类。
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
schema: ConfigBase 子类。
|
|
139
|
+
section: 子段点路径,空表示根。
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
类型化配置实例。
|
|
143
|
+
"""
|
|
144
|
+
data = self.get(section, {}) if section else self._raw
|
|
145
|
+
if not isinstance(data, dict):
|
|
146
|
+
raise ConfigError(
|
|
147
|
+
"配置段 '{}' 不是字典".format(section or "<root>")
|
|
148
|
+
)
|
|
149
|
+
return schema.from_dict(data)
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def raw(self) -> Dict[str, Any]:
|
|
153
|
+
"""原始配置字典副本。"""
|
|
154
|
+
return copy.deepcopy(self._raw)
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def path(self) -> Optional[Path]:
|
|
158
|
+
"""配置文件路径。"""
|
|
159
|
+
return self._path
|
|
160
|
+
|
|
161
|
+
def on_change(
|
|
162
|
+
self, path: str, callback: Callable[[Any, Any], Any]
|
|
163
|
+
) -> None:
|
|
164
|
+
"""注册配置变更回调。
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
path: 监听的点路径。
|
|
168
|
+
callback: 回调(old, new),支持同步/异步。
|
|
169
|
+
"""
|
|
170
|
+
self._callbacks.setdefault(path, []).append(callback)
|
|
171
|
+
|
|
172
|
+
async def reload(self) -> bool:
|
|
173
|
+
"""热重载配置文件并触发变更回调。
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
是否成功。
|
|
177
|
+
"""
|
|
178
|
+
if self._path is None or not self._path.exists():
|
|
179
|
+
return False
|
|
180
|
+
async with self._get_lock():
|
|
181
|
+
old = copy.deepcopy(self._raw)
|
|
182
|
+
try:
|
|
183
|
+
new = load_file(self._path)
|
|
184
|
+
except Exception as exc:
|
|
185
|
+
logger.error("配置重载失败: %s", exc, exc_info=True)
|
|
186
|
+
return False
|
|
187
|
+
self._raw = new
|
|
188
|
+
await self._notify(old, new)
|
|
189
|
+
logger.info("配置已重载: %s", self._path)
|
|
190
|
+
return True
|
|
191
|
+
|
|
192
|
+
async def _notify(
|
|
193
|
+
self, old: Dict[str, Any], new: Dict[str, Any]
|
|
194
|
+
) -> None:
|
|
195
|
+
"""触发变更回调。"""
|
|
196
|
+
for path, callbacks in self._callbacks.items():
|
|
197
|
+
old_val = self._dig(old, path)
|
|
198
|
+
new_val = self._dig(new, path)
|
|
199
|
+
if old_val != new_val:
|
|
200
|
+
logger.info("配置变更: %s", path)
|
|
201
|
+
for cb in callbacks:
|
|
202
|
+
try:
|
|
203
|
+
if asyncio.iscoroutinefunction(cb):
|
|
204
|
+
await cb(old_val, new_val)
|
|
205
|
+
else:
|
|
206
|
+
cb(old_val, new_val)
|
|
207
|
+
except Exception as exc:
|
|
208
|
+
logger.error(
|
|
209
|
+
"配置回调异常 [%s]: %s",
|
|
210
|
+
path,
|
|
211
|
+
exc,
|
|
212
|
+
exc_info=True,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
@staticmethod
|
|
216
|
+
def _dig(data: Dict[str, Any], path: str) -> Any:
|
|
217
|
+
"""从字典按点路径取值。"""
|
|
218
|
+
node: Any = data
|
|
219
|
+
for part in path.split("."):
|
|
220
|
+
if isinstance(node, dict) and part in node:
|
|
221
|
+
node = node[part]
|
|
222
|
+
else:
|
|
223
|
+
return _MISSING
|
|
224
|
+
return node
|
|
225
|
+
|
|
226
|
+
# ------------------------------------------------------------------
|
|
227
|
+
# 模板初始化
|
|
228
|
+
# ------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
def init_from_template(
|
|
231
|
+
self,
|
|
232
|
+
*,
|
|
233
|
+
filename: str = "config.toml",
|
|
234
|
+
template_dir: str = "template",
|
|
235
|
+
template_name: str = "template_config.toml",
|
|
236
|
+
version_path: str = "server.version",
|
|
237
|
+
exit_after_create: bool = True,
|
|
238
|
+
exit_after_merge: bool = True,
|
|
239
|
+
) -> Dict[str, Any]:
|
|
240
|
+
"""从模板初始化配置:不存在则创建,版本不同则合并新字段。
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
filename: 配置文件名。
|
|
244
|
+
template_dir: 模板目录。
|
|
245
|
+
template_name: 模板文件名。
|
|
246
|
+
version_path: 版本号点路径,用于判断是否需要合并。
|
|
247
|
+
exit_after_create: 新建配置后是否 SystemExit。
|
|
248
|
+
exit_after_merge: 合并后是否 SystemExit。
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
原始配置字典。
|
|
252
|
+
"""
|
|
253
|
+
self._path = find_config(filename)
|
|
254
|
+
|
|
255
|
+
if self._path is None:
|
|
256
|
+
tpl = find_template(template_dir, template_name)
|
|
257
|
+
if tpl is None:
|
|
258
|
+
raise ConfigError(
|
|
259
|
+
"未找到配置或模板: {}/{}".format(template_dir, template_name)
|
|
260
|
+
)
|
|
261
|
+
target = Path.cwd() / filename
|
|
262
|
+
shutil.copy2(str(tpl), str(target))
|
|
263
|
+
self._path = target
|
|
264
|
+
logger.info("从模板创建配置: %s", target)
|
|
265
|
+
if exit_after_create:
|
|
266
|
+
raise SystemExit(0)
|
|
267
|
+
|
|
268
|
+
self._raw = load_file(self._path)
|
|
269
|
+
self._try_merge_template(
|
|
270
|
+
template_dir, template_name, version_path, exit_after_merge
|
|
271
|
+
)
|
|
272
|
+
logger.info("配置已加载: %s", self._path)
|
|
273
|
+
return dict(self._raw)
|
|
274
|
+
|
|
275
|
+
def _try_merge_template(
|
|
276
|
+
self,
|
|
277
|
+
template_dir: str,
|
|
278
|
+
template_name: str,
|
|
279
|
+
version_path: str,
|
|
280
|
+
exit_after_merge: bool,
|
|
281
|
+
) -> None:
|
|
282
|
+
"""比较版本号,合并模板新增字段。"""
|
|
283
|
+
tpl_path = find_template(template_dir, template_name)
|
|
284
|
+
if tpl_path is None:
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
try:
|
|
288
|
+
tpl_raw = load_file(tpl_path)
|
|
289
|
+
except Exception as exc:
|
|
290
|
+
logger.debug("加载模板失败: %s", exc)
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
old_ver = self._dig(self._raw, version_path)
|
|
294
|
+
new_ver = self._dig(tpl_raw, version_path)
|
|
295
|
+
|
|
296
|
+
if old_ver is not _MISSING and new_ver is not _MISSING and old_ver == new_ver:
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
if self._path is None:
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
self.backup()
|
|
303
|
+
merge_dicts(self._raw, tpl_raw)
|
|
304
|
+
write_toml(self._path, self._raw)
|
|
305
|
+
logger.info("已合并模板新增字段到 %s", self._path)
|
|
306
|
+
if exit_after_merge:
|
|
307
|
+
raise SystemExit(0)
|
|
308
|
+
|
|
309
|
+
# ------------------------------------------------------------------
|
|
310
|
+
# 写入 & 备份
|
|
311
|
+
# ------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
def write(self, data: Optional[Dict[str, Any]] = None) -> None:
|
|
314
|
+
"""将配置写回文件(tomlkit 保留注释)。
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
data: 要写入的字典,None 则写入当前 _raw。
|
|
318
|
+
"""
|
|
319
|
+
if self._path is None:
|
|
320
|
+
raise ConfigError("配置路径未设置")
|
|
321
|
+
write_toml(self._path, data if data is not None else self._raw)
|
|
322
|
+
if data is not None:
|
|
323
|
+
self._raw = copy.deepcopy(data)
|
|
324
|
+
|
|
325
|
+
def backup(self, backup_dir: Optional[str] = None) -> Optional[Path]:
|
|
326
|
+
"""创建配置文件的带时间戳备份。
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
backup_dir: 备份目录,默认 <config_dir>/template/old。
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
备份文件路径,无配置路径时返回 None。
|
|
333
|
+
"""
|
|
334
|
+
if self._path is None or not self._path.exists():
|
|
335
|
+
return None
|
|
336
|
+
if backup_dir is None:
|
|
337
|
+
bdir = self._path.parent / "template" / "old"
|
|
338
|
+
else:
|
|
339
|
+
bdir = Path(backup_dir)
|
|
340
|
+
bdir.mkdir(parents=True, exist_ok=True)
|
|
341
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
342
|
+
backup_path = bdir / "{}.bak.{}".format(self._path.name, ts)
|
|
343
|
+
shutil.copy2(str(self._path), str(backup_path))
|
|
344
|
+
logger.info("已备份: %s", backup_path)
|
|
345
|
+
return backup_path
|
|
346
|
+
|
|
347
|
+
# ------------------------------------------------------------------
|
|
348
|
+
# __getattr__ 代理
|
|
349
|
+
# ------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
def bind_proxy(self, schema: Type[C], section: str = "") -> C:
|
|
352
|
+
"""绑定 schema 并启用 __getattr__ 代理。
|
|
353
|
+
|
|
354
|
+
调用后,cfg.server 等价于 cfg._bound.server。
|
|
355
|
+
"""
|
|
356
|
+
bound = self.bind(schema, section)
|
|
357
|
+
self._bound = bound
|
|
358
|
+
return bound
|
|
359
|
+
|
|
360
|
+
def __getattr__(self, name: str) -> Any:
|
|
361
|
+
if name.startswith("_"):
|
|
362
|
+
raise AttributeError(
|
|
363
|
+
"'{}' has no attribute '{}'".format(type(self).__name__, name)
|
|
364
|
+
)
|
|
365
|
+
if self._bound is None:
|
|
366
|
+
raise AttributeError(
|
|
367
|
+
"配置未绑定 schema,请先调用 bind_proxy()"
|
|
368
|
+
)
|
|
369
|
+
return getattr(self._bound, name)
|
|
370
|
+
|
|
371
|
+
# ------------------------------------------------------------------
|
|
372
|
+
# Watchdog 文件监控
|
|
373
|
+
# ------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
async def watch(self, debounce: float = 0.5) -> None:
|
|
376
|
+
"""启动 watchdog 文件监控,文件变更时自动重载。
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
debounce: 防抖延迟(秒)。
|
|
380
|
+
"""
|
|
381
|
+
if self._observer is not None:
|
|
382
|
+
return
|
|
383
|
+
if self._path is None:
|
|
384
|
+
raise ConfigError("配置路径未设置,无法启动监控")
|
|
385
|
+
|
|
386
|
+
self._debounce_delay = debounce
|
|
387
|
+
self._loop = asyncio.get_running_loop()
|
|
388
|
+
config_path = str(self._path)
|
|
389
|
+
mgr = self
|
|
390
|
+
|
|
391
|
+
try:
|
|
392
|
+
from watchdog.events import FileModifiedEvent, FileSystemEventHandler
|
|
393
|
+
from watchdog.observers import Observer
|
|
394
|
+
except ImportError:
|
|
395
|
+
raise ConfigError("watchdog 未安装: pip install watchdog")
|
|
396
|
+
|
|
397
|
+
class _Handler(FileSystemEventHandler):
|
|
398
|
+
def on_modified(handler_self, event: Any) -> None:
|
|
399
|
+
if not isinstance(event, FileModifiedEvent):
|
|
400
|
+
return
|
|
401
|
+
if os.path.abspath(event.src_path) != os.path.abspath(config_path):
|
|
402
|
+
return
|
|
403
|
+
if mgr._loop:
|
|
404
|
+
asyncio.run_coroutine_threadsafe(
|
|
405
|
+
mgr._debounced_reload(), mgr._loop
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
self._observer = Observer()
|
|
409
|
+
self._observer.schedule(_Handler(), str(self._path.parent), recursive=False)
|
|
410
|
+
self._observer.start()
|
|
411
|
+
logger.info("已启动配置监控: %s", self._path)
|
|
412
|
+
|
|
413
|
+
async def stop_watch(self) -> None:
|
|
414
|
+
"""停止文件监控。"""
|
|
415
|
+
if self._observer is None:
|
|
416
|
+
return
|
|
417
|
+
self._observer.stop()
|
|
418
|
+
self._observer.join(timeout=2)
|
|
419
|
+
self._observer = None
|
|
420
|
+
logger.info("配置监控已停止")
|
|
421
|
+
|
|
422
|
+
async def _debounced_reload(self) -> None:
|
|
423
|
+
"""防抖重载。"""
|
|
424
|
+
import time
|
|
425
|
+
|
|
426
|
+
trigger = time.time()
|
|
427
|
+
self._last_reload_trigger = trigger
|
|
428
|
+
await asyncio.sleep(self._debounce_delay)
|
|
429
|
+
if self._last_reload_trigger > trigger:
|
|
430
|
+
return
|
|
431
|
+
if self._is_reloading:
|
|
432
|
+
return
|
|
433
|
+
self._is_reloading = True
|
|
434
|
+
try:
|
|
435
|
+
ok = await self.reload()
|
|
436
|
+
if not ok:
|
|
437
|
+
logger.error("配置重载失败")
|
|
438
|
+
finally:
|
|
439
|
+
self._is_reloading = False
|
|
440
|
+
|
|
441
|
+
def __repr__(self) -> str:
|
|
442
|
+
watching = self._observer is not None
|
|
443
|
+
return "<ConfigCenter path={} watching={}>".format(self._path, watching)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""配置文件加载器:支持 TOML / JSON,跨版本兼容。"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict
|
|
8
|
+
|
|
9
|
+
from echotools.errors.common import ConfigError
|
|
10
|
+
|
|
11
|
+
__all__ = ["load_file", "find_config", "find_template", "write_toml"]
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import tomllib as _tomllib # type: ignore[import]
|
|
15
|
+
except ImportError:
|
|
16
|
+
try:
|
|
17
|
+
import tomli as _tomllib # type: ignore[import,no-redef]
|
|
18
|
+
except ImportError:
|
|
19
|
+
_tomllib = None # type: ignore[assignment]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_file(path: Path) -> Dict[str, Any]:
|
|
23
|
+
"""加载配置文件,按扩展名选择解析器。
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
path: 配置文件路径。
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
解析后的字典。
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
ConfigError: 文件不存在或解析失败。
|
|
33
|
+
"""
|
|
34
|
+
if not path.is_file():
|
|
35
|
+
raise ConfigError("配置文件不存在: {}".format(path))
|
|
36
|
+
suffix = path.suffix.lower()
|
|
37
|
+
if suffix in (".toml",):
|
|
38
|
+
if _tomllib is None:
|
|
39
|
+
raise ConfigError("缺少 tomllib/tomli,无法解析 TOML")
|
|
40
|
+
with open(str(path), "rb") as f:
|
|
41
|
+
return _tomllib.load(f)
|
|
42
|
+
if suffix in (".json",):
|
|
43
|
+
with open(str(path), "r", encoding="utf-8") as f:
|
|
44
|
+
return json.load(f)
|
|
45
|
+
raise ConfigError("不支持的配置格式: {}".format(suffix))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def find_config(
|
|
49
|
+
filename: str = "config.toml",
|
|
50
|
+
env_var: str = "CONFIG_PATH",
|
|
51
|
+
max_depth: int = 5,
|
|
52
|
+
) -> "Path | None":
|
|
53
|
+
"""向上查找配置文件。
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
filename: 配置文件名。
|
|
57
|
+
env_var: 环境变量名(优先)。
|
|
58
|
+
max_depth: 向上查找最大层数。
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
找到的路径或 None。
|
|
62
|
+
"""
|
|
63
|
+
import os
|
|
64
|
+
|
|
65
|
+
env = os.environ.get(env_var)
|
|
66
|
+
if env and Path(env).is_file():
|
|
67
|
+
return Path(env).resolve()
|
|
68
|
+
d = Path.cwd()
|
|
69
|
+
for _ in range(max_depth):
|
|
70
|
+
candidate = d / filename
|
|
71
|
+
if candidate.is_file():
|
|
72
|
+
return candidate.resolve()
|
|
73
|
+
if d.parent == d:
|
|
74
|
+
break
|
|
75
|
+
d = d.parent
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def find_template(
|
|
80
|
+
template_dir: str = "template",
|
|
81
|
+
template_name: str = "template_config.toml",
|
|
82
|
+
) -> "Path | None":
|
|
83
|
+
"""查找配置模板文件。
|
|
84
|
+
|
|
85
|
+
在当前目录和脚本所在目录的 template_dir 子目录中查找。
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
template_dir: 模板目录名。
|
|
89
|
+
template_name: 模板文件名。
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
找到的路径或 None。
|
|
93
|
+
"""
|
|
94
|
+
for base in [Path.cwd(), Path(__file__).parent.parent.parent.parent]:
|
|
95
|
+
tpl = base / template_dir / template_name
|
|
96
|
+
if tpl.is_file():
|
|
97
|
+
return tpl.resolve()
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def write_toml(path: Path, data: Dict[str, Any]) -> None:
|
|
102
|
+
"""用 tomlkit 将字典写入 TOML 文件(保留注释)。
|
|
103
|
+
|
|
104
|
+
若 tomlkit 不可用则退化为 tomli_w 或手动写入。
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
path: 目标文件路径。
|
|
108
|
+
data: 要写入的字典。
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
ConfigError: 写入失败。
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
import tomlkit
|
|
115
|
+
except ImportError:
|
|
116
|
+
raise ConfigError("缺少 tomlkit,无法写入 TOML: pip install tomlkit")
|
|
117
|
+
|
|
118
|
+
doc = tomlkit.document()
|
|
119
|
+
_dict_to_toml(doc, data, tomlkit)
|
|
120
|
+
with open(str(path), "w", encoding="utf-8") as f:
|
|
121
|
+
f.write(tomlkit.dumps(doc))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _dict_to_toml(doc: Any, data: Dict[str, Any], tomlkit: Any) -> None:
|
|
125
|
+
"""递归将字典写入 tomlkit document/table。"""
|
|
126
|
+
for key, value in data.items():
|
|
127
|
+
if isinstance(value, dict):
|
|
128
|
+
if key not in doc or not isinstance(doc.get(key), (dict,)):
|
|
129
|
+
try:
|
|
130
|
+
doc[key] = tomlkit.table()
|
|
131
|
+
except Exception:
|
|
132
|
+
doc[key] = {}
|
|
133
|
+
_dict_to_toml(doc[key], value, tomlkit)
|
|
134
|
+
else:
|
|
135
|
+
try:
|
|
136
|
+
doc[key] = tomlkit.item(value)
|
|
137
|
+
except (TypeError, ValueError):
|
|
138
|
+
doc[key] = value
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""递归字典合并工具。"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
__all__ = ["merge_dicts"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def merge_dicts(
|
|
11
|
+
target: Dict[str, Any],
|
|
12
|
+
source: Dict[str, Any],
|
|
13
|
+
*,
|
|
14
|
+
skip_keys: tuple = ("version",),
|
|
15
|
+
) -> None:
|
|
16
|
+
"""递归将 source 中 target 缺少的键补进 target(就地修改)。
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
target: 目标字典(就地修改)。
|
|
20
|
+
source: 源字典(只读)。
|
|
21
|
+
skip_keys: 不参与合并的键名。
|
|
22
|
+
"""
|
|
23
|
+
for key, value in source.items():
|
|
24
|
+
if key in skip_keys:
|
|
25
|
+
continue
|
|
26
|
+
if key not in target:
|
|
27
|
+
target[key] = value
|
|
28
|
+
elif isinstance(value, dict) and isinstance(target[key], dict):
|
|
29
|
+
merge_dicts(target[key], value, skip_keys=skip_keys)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""dispatch 模块导出。"""
|
|
4
|
+
|
|
5
|
+
from echotools.dispatch.candidate import TaskCandidate, make_id
|
|
6
|
+
from echotools.dispatch.dispatcher import TaskDispatcher
|
|
7
|
+
from echotools.dispatch.selector import (
|
|
8
|
+
AdaptiveSelector,
|
|
9
|
+
TASRecord,
|
|
10
|
+
TASWeights,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"TaskCandidate",
|
|
15
|
+
"make_id",
|
|
16
|
+
"AdaptiveSelector",
|
|
17
|
+
"TASRecord",
|
|
18
|
+
"TASWeights",
|
|
19
|
+
"TaskDispatcher",
|
|
20
|
+
]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""通用任务候选项。"""
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
__all__ = ["TaskCandidate", "make_id"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def make_id(group: str, resource_id: str = "") -> str:
|
|
14
|
+
"""生成候选项 ID。
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
group: 分组标识(如插件名)。
|
|
18
|
+
resource_id: 资源标识,提供时生成确定性 ID。
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
ID 字符串。
|
|
22
|
+
"""
|
|
23
|
+
if resource_id:
|
|
24
|
+
h = hashlib.sha256(
|
|
25
|
+
"{}:{}".format(group, resource_id).encode()
|
|
26
|
+
).hexdigest()[:12]
|
|
27
|
+
return "{}_{}".format(group, h)
|
|
28
|
+
return "{}_{}".format(group, uuid.uuid4().hex[:12])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class TaskCandidate:
|
|
33
|
+
"""通用任务候选项。
|
|
34
|
+
|
|
35
|
+
不预设任何 AI 语义,仅提供能力标记与可用性元数据。
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
id: str
|
|
39
|
+
group: str
|
|
40
|
+
resource_id: str = ""
|
|
41
|
+
available: bool = True
|
|
42
|
+
busy: bool = False
|
|
43
|
+
cooldown: float = 0.0
|
|
44
|
+
capabilities: Dict[str, bool] = field(default_factory=dict)
|
|
45
|
+
tags: List[str] = field(default_factory=list)
|
|
46
|
+
meta: Dict[str, Any] = field(default_factory=dict)
|
|
47
|
+
context_length: Optional[int] = None
|
|
48
|
+
|
|
49
|
+
def has_capability(self, cap: str) -> bool:
|
|
50
|
+
"""检查是否具备指定能力。"""
|
|
51
|
+
return bool(self.capabilities.get(cap, False))
|