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.
Files changed (85) hide show
  1. echotools/__init__.py +149 -0
  2. echotools/cache/__init__.py +8 -0
  3. echotools/cache/list_cache.py +152 -0
  4. echotools/cache/memory_cache.py +72 -0
  5. echotools/config/__init__.py +23 -0
  6. echotools/config/base.py +144 -0
  7. echotools/config/center.py +443 -0
  8. echotools/config/loader.py +138 -0
  9. echotools/config/merge.py +29 -0
  10. echotools/dispatch/__init__.py +20 -0
  11. echotools/dispatch/candidate.py +51 -0
  12. echotools/dispatch/dispatcher.py +268 -0
  13. echotools/dispatch/selector.py +390 -0
  14. echotools/errors/__init__.py +29 -0
  15. echotools/errors/base.py +32 -0
  16. echotools/errors/classify.py +42 -0
  17. echotools/errors/common.py +74 -0
  18. echotools/events/__init__.py +8 -0
  19. echotools/events/bus.py +104 -0
  20. echotools/events/event.py +31 -0
  21. echotools/files/__init__.py +7 -0
  22. echotools/files/file_util.py +155 -0
  23. echotools/fncall/__init__.py +40 -0
  24. echotools/fncall/parsers/__init__.py +6 -0
  25. echotools/fncall/parsers/stream.py +156 -0
  26. echotools/fncall/parsers/xml_parser.py +320 -0
  27. echotools/fncall/prompt/__init__.py +5 -0
  28. echotools/fncall/prompt/history.py +383 -0
  29. echotools/fncall/prompt/inject.py +108 -0
  30. echotools/fncall/prompt/templates.py +159 -0
  31. echotools/fncall/protocols/__init__.py +28 -0
  32. echotools/fncall/protocols/antml.py +251 -0
  33. echotools/fncall/protocols/bracket.py +154 -0
  34. echotools/fncall/protocols/custom.py +50 -0
  35. echotools/fncall/protocols/nous.py +161 -0
  36. echotools/fncall/protocols/original.py +378 -0
  37. echotools/fncall/protocols/xml.py +249 -0
  38. echotools/fncall/registry.py +96 -0
  39. echotools/fncall/shared/__init__.py +25 -0
  40. echotools/fncall/shared/coercion.py +295 -0
  41. echotools/fncall/shared/loop_detect.py +97 -0
  42. echotools/fncall/shared/normalization.py +170 -0
  43. echotools/fncall/shared/xml_helpers.py +56 -0
  44. echotools/ids/__init__.py +7 -0
  45. echotools/ids/generator.py +62 -0
  46. echotools/io/__init__.py +11 -0
  47. echotools/io/io_utils.py +81 -0
  48. echotools/lifecycle/__init__.py +8 -0
  49. echotools/lifecycle/manager.py +72 -0
  50. echotools/lifecycle/updater.py +176 -0
  51. echotools/logger/__init__.py +12 -0
  52. echotools/logger/manager.py +164 -0
  53. echotools/network/__init__.py +11 -0
  54. echotools/network/http_utils.py +68 -0
  55. echotools/plugin/__init__.py +9 -0
  56. echotools/plugin/base.py +41 -0
  57. echotools/plugin/discovery.py +134 -0
  58. echotools/plugin/registry.py +253 -0
  59. echotools/process/__init__.py +10 -0
  60. echotools/process/port.py +183 -0
  61. echotools/protocol/__init__.py +19 -0
  62. echotools/protocol/base.py +123 -0
  63. echotools/proxy/__init__.py +7 -0
  64. echotools/proxy/manager.py +269 -0
  65. echotools/retry/__init__.py +11 -0
  66. echotools/retry/retry.py +127 -0
  67. echotools/runtime/__init__.py +7 -0
  68. echotools/runtime/collector.py +60 -0
  69. echotools/scheduler/__init__.py +7 -0
  70. echotools/scheduler/scheduler.py +69 -0
  71. echotools/sdk/__init__.py +7 -0
  72. echotools/sdk/facade.py +154 -0
  73. echotools/tracing/__init__.py +28 -0
  74. echotools/tracing/context.py +63 -0
  75. echotools/tracing/span.py +135 -0
  76. echotools/tracing/tracer.py +97 -0
  77. echotools/watcher/__init__.py +7 -0
  78. echotools/watcher/file_watcher.py +110 -0
  79. echotools/web/__init__.py +8 -0
  80. echotools/web/application.py +120 -0
  81. echotools/web/utils.py +186 -0
  82. echotools-1.0.0.dist-info/METADATA +80 -0
  83. echotools-1.0.0.dist-info/RECORD +85 -0
  84. echotools-1.0.0.dist-info/WHEEL +5 -0
  85. 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))