python-library-watch-config 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.
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-library-watch-config
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.10
5
+ Requires-Dist: python-library-configlib
@@ -0,0 +1,8 @@
1
+ watch_config/__init__.py,sha256=9Kr0T2jbL2Ic3dQMTMyHeAy3MeF0nhrSL3otsY6C-WQ,293
2
+ watch_config/changelog.py,sha256=InFO0Loj2RpFLFT43dps5nyveMJghp8gyRGQkiKnJ3I,1881
3
+ watch_config/diff.py,sha256=lFBJo8UR_v1FuTHr1uo10ze_JrhwjvHfUpWs5uM9hzE,2992
4
+ watch_config/renderer.py,sha256=RSxsUEP5NMzNBQCtFe3ZUUnz9HS-PJ2bqEBD1QqZDGA,2815
5
+ watch_config/watch_config.py,sha256=GgfE89RQ9yQrt2BsV12QZBvR4X3teQoPvlelKBxgfdk,6858
6
+ python_library_watch_config-0.1.0.dist-info/METADATA,sha256=nVQyV3KVT8n1Jy39cZN_WoSiCmtp7dD2NYP7z8NnIfc,135
7
+ python_library_watch_config-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
+ python_library_watch_config-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
@@ -0,0 +1,12 @@
1
+ from .changelog import ChangeEntry, ChangeLog, ChangeType
2
+ from .renderer import ChangeRenderer, DefaultRenderer
3
+ from .watch_config import WatchConfig
4
+
5
+ __all__ = [
6
+ "ChangeEntry",
7
+ "ChangeLog",
8
+ "ChangeType",
9
+ "ChangeRenderer",
10
+ "DefaultRenderer",
11
+ "WatchConfig",
12
+ ]
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import StrEnum
5
+ from typing import Any
6
+
7
+
8
+ class ChangeType(StrEnum):
9
+ ADDED = "added"
10
+ REMOVED = "removed"
11
+ UPDATED = "updated"
12
+ TYPE_CHANGED = "type_changed"
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class ChangeEntry:
17
+ type: ChangeType
18
+ path: str
19
+ old_value: Any = None
20
+ new_value: Any = None
21
+
22
+
23
+ @dataclass
24
+ class ChangeLog:
25
+ entries: list[ChangeEntry] = field(default_factory=list)
26
+
27
+ @property
28
+ def is_empty(self) -> bool:
29
+ return not self.entries
30
+
31
+ def added(self, path: str, value: Any) -> None:
32
+ self.entries.append(
33
+ ChangeEntry(
34
+ type=ChangeType.ADDED,
35
+ path=path,
36
+ old_value=None,
37
+ new_value=value,
38
+ )
39
+ )
40
+
41
+ def removed(self, path: str, value: Any) -> None:
42
+ self.entries.append(
43
+ ChangeEntry(
44
+ type=ChangeType.REMOVED,
45
+ path=path,
46
+ old_value=value,
47
+ new_value=None,
48
+ )
49
+ )
50
+
51
+ def updated(self, path: str, old_value: Any, new_value: Any) -> None:
52
+ self.entries.append(
53
+ ChangeEntry(
54
+ type=ChangeType.UPDATED,
55
+ path=path,
56
+ old_value=old_value,
57
+ new_value=new_value,
58
+ )
59
+ )
60
+
61
+ def type_changed(self, path: str, old_value: Any, new_value: Any) -> None:
62
+ self.entries.append(
63
+ ChangeEntry(
64
+ type=ChangeType.TYPE_CHANGED,
65
+ path=path,
66
+ old_value=old_value,
67
+ new_value=new_value,
68
+ )
69
+ )
70
+
71
+ def __len__(self) -> int:
72
+ return len(self.entries)
73
+
74
+ def __iter__(self):
75
+ return iter(self.entries)
watch_config/diff.py ADDED
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from dataclasses import is_dataclass
5
+ from typing import Any
6
+
7
+ from .changelog import ChangeLog
8
+
9
+
10
+ def diff_values(
11
+ old: Any,
12
+ new: Any,
13
+ path: str = "$",
14
+ out: ChangeLog | None = None,
15
+ ) -> ChangeLog:
16
+ if out is None:
17
+ out = ChangeLog()
18
+
19
+ if type(old) is not type(new):
20
+ out.type_changed(path, old, new)
21
+ return out
22
+
23
+ if isinstance(old, dict):
24
+ old_keys = set(old)
25
+ new_keys = set(new)
26
+
27
+ for key in sorted(old_keys - new_keys, key=str):
28
+ out.removed(_join_path(path, key), old[key])
29
+
30
+ for key in sorted(new_keys - old_keys, key=str):
31
+ out.added(_join_path(path, key), new[key])
32
+
33
+ for key in sorted(old_keys & new_keys, key=str):
34
+ diff_values(old[key], new[key], _join_path(path, key), out)
35
+
36
+ return out
37
+
38
+ if isinstance(old, (list, tuple)):
39
+ common = min(len(old), len(new))
40
+
41
+ for i in range(common):
42
+ diff_values(old[i], new[i], f"{path}[{i}]", out)
43
+
44
+ for i in range(common, len(old)):
45
+ out.removed(f"{path}[{i}]", old[i])
46
+
47
+ for i in range(common, len(new)):
48
+ out.added(f"{path}[{i}]", new[i])
49
+
50
+ return out
51
+
52
+ if isinstance(old, (set, frozenset)):
53
+ if old != new:
54
+ out.updated(path, old, new)
55
+ return out
56
+
57
+ if old != new:
58
+ out.updated(path, old, new)
59
+
60
+ return out
61
+
62
+
63
+ def build_object(model_type: type[Any], data: Any) -> Any:
64
+ payload = deepcopy(data)
65
+
66
+ if model_type is dict:
67
+ if not isinstance(payload, dict):
68
+ raise TypeError("model_type=dict 时,配置顶层必须是 dict")
69
+ return payload
70
+
71
+ if model_type is list:
72
+ if not isinstance(payload, list):
73
+ raise TypeError("model_type=list 时,配置顶层必须是 list")
74
+ return payload
75
+
76
+ if model_type is set:
77
+ if isinstance(payload, set):
78
+ return payload
79
+ if isinstance(payload, (list, tuple)):
80
+ return set(payload)
81
+ raise TypeError("model_type=set 时,配置顶层必须是 set/list/tuple")
82
+
83
+ if _is_pydantic_model_class(model_type):
84
+ return model_type.model_validate(payload)
85
+
86
+ if is_dataclass(model_type):
87
+ if not isinstance(payload, dict):
88
+ raise TypeError("dataclass 类型要求配置顶层为 dict")
89
+ return model_type(**payload)
90
+
91
+ if isinstance(payload, dict):
92
+ return model_type(**payload)
93
+
94
+ return model_type(payload)
95
+
96
+
97
+ def _join_path(base: str, key: Any) -> str:
98
+ if isinstance(key, int):
99
+ return f"{base}[{key}]"
100
+ key_text = str(key)
101
+ if key_text.isidentifier():
102
+ return f"{base}.{key_text}"
103
+ return f"{base}[{key_text!r}]"
104
+
105
+
106
+ def _is_pydantic_model_class(tp: Any) -> bool:
107
+ return hasattr(tp, "model_validate") and hasattr(tp, "model_fields")
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import logging
5
+ from pprint import pformat
6
+ from typing import Any
7
+ from .changelog import ChangeLog, ChangeType
8
+ from abc import ABC, abstractmethod
9
+
10
+ class ChangeRenderer(ABC):
11
+ @abstractmethod
12
+ def render(self, changelog: ChangeLog) -> str: ...
13
+
14
+ def emit(self, changelog: ChangeLog, logger: logging.Logger) -> None:
15
+ text = self.render(changelog)
16
+ if text:
17
+ logger.info(text)
18
+
19
+
20
+ class DefaultRenderer(ChangeRenderer):
21
+ _ICONS = {
22
+ ChangeType.ADDED: "+",
23
+ ChangeType.REMOVED: "-",
24
+ ChangeType.UPDATED: "~",
25
+ ChangeType.TYPE_CHANGED: "!",
26
+ }
27
+
28
+ _COLORS = {
29
+ ChangeType.ADDED: "\033[32m",
30
+ ChangeType.REMOVED: "\033[31m",
31
+ ChangeType.UPDATED: "\033[33m",
32
+ ChangeType.TYPE_CHANGED: "\033[35m",
33
+ }
34
+
35
+ _RESET = "\033[0m"
36
+ _BOLD = "\033[1m"
37
+ _DIM = "\033[2m"
38
+
39
+ def __init__(self, *, color: bool | None = None, max_value_length: int = 120) -> None:
40
+ if color is None:
41
+ color = sys.stderr.isatty()
42
+ self.color = color
43
+ self.max_value_length = max_value_length
44
+
45
+ def render(self, changelog: ChangeLog) -> str:
46
+ if changelog.is_empty:
47
+ return ""
48
+
49
+ title = self._style(
50
+ f"Config Changes ({len(changelog.entries)}):",
51
+ self._BOLD,
52
+ )
53
+ lines = [title]
54
+
55
+ for entry in changelog.entries:
56
+ icon = self._ICONS[entry.type]
57
+ color = self._COLORS[entry.type]
58
+ path = entry.path.removeprefix("$.").removeprefix("$")
59
+ lines.append(self._style(f"{icon} {path}", color))
60
+
61
+ if entry.type == ChangeType.ADDED:
62
+ lines.append(self._style(f" + {self._pretty(entry.new_value)}", self._COLORS[ChangeType.ADDED]))
63
+ elif entry.type == ChangeType.REMOVED:
64
+ lines.append(self._style(f" - {self._pretty(entry.old_value)}", self._COLORS[ChangeType.REMOVED]))
65
+ else:
66
+ lines.append(self._style(f" - {self._pretty(entry.old_value)}", self._COLORS[ChangeType.REMOVED]))
67
+ lines.append(self._style(f" + {self._pretty(entry.new_value)}", self._COLORS[ChangeType.ADDED]))
68
+
69
+ return "\n".join(lines)
70
+
71
+ def _pretty(self, value: Any) -> str:
72
+ text = pformat(value, compact=True, sort_dicts=False, width=88)
73
+ if len(text) <= self.max_value_length:
74
+ return text
75
+ return text[: self.max_value_length - 3] + "..."
76
+
77
+ def _style(self, text: str, code: str) -> str:
78
+ if not self.color:
79
+ return text
80
+ return f"{code}{text}{self._RESET}"
81
+
82
+ def _dim(self, text: str) -> str:
83
+ return self._style(text, self._DIM)
@@ -0,0 +1,229 @@
1
+ from __future__ import annotations
2
+ import sys
3
+ import inspect
4
+ import logging
5
+ import threading
6
+ from pathlib import Path
7
+ from typing import Any, Callable, Generic, TypeVar
8
+
9
+ from configlib import load_config
10
+
11
+ from .changelog import ChangeLog
12
+ from .diff import build_object, diff_values
13
+ from .renderer import ChangeRenderer, DefaultRenderer
14
+
15
+ if sys.platform == "win32":
16
+ import os
17
+ os.system("")
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ T = TypeVar("T")
22
+ F = TypeVar("F", bound=Callable)
23
+
24
+
25
+ class WatchConfig(Generic[T]):
26
+ """
27
+ 配置热更新器。
28
+
29
+ 实例化后作为装饰器注册回调函数,
30
+ 初次加载和每次文件变更时,回调收到一个完整的新配置对象。
31
+
32
+ 用法::
33
+
34
+ watcher = WatchConfig("config.yaml", AppConfig)
35
+
36
+ @watcher
37
+ def on_config(cfg: AppConfig):
38
+ ...
39
+
40
+ watcher.start()
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ file_path: str | Path,
46
+ model_type: type[T],
47
+ renderer: ChangeRenderer | None = None,
48
+ *,
49
+ interval: float = 1.0,
50
+ debounce: float = 0.3,
51
+ logger_: logging.Logger | None = None,
52
+ ) -> None:
53
+ self._file_path = Path(file_path).resolve()
54
+ self._model_type = model_type
55
+ self._renderer = renderer or DefaultRenderer()
56
+ self._interval = interval
57
+ self._debounce = debounce
58
+ self._logger = logger_ or logger
59
+
60
+ self._callbacks: list[Callable] = []
61
+ self._lock = threading.Lock()
62
+ self._stop_event = threading.Event()
63
+ self._thread: threading.Thread | None = None
64
+ self._file_state: tuple[int, int] | None = None
65
+ self._raw_data: Any = None
66
+ self._value: T | None = None
67
+
68
+ def __call__(self, fn: F) -> F:
69
+ """注册回调函数。支持作为装饰器使用。
70
+
71
+ 回调签名可以是:
72
+ fn()
73
+ fn(cfg)
74
+ fn(cfg, changelog)
75
+ """
76
+ self._callbacks.append(fn)
77
+ return fn
78
+
79
+ @property
80
+ def value(self) -> T | None:
81
+ """当前配置对象。start() 之前为 None。"""
82
+ return self._value
83
+
84
+ @property
85
+ def file_path(self) -> Path:
86
+ return self._file_path
87
+
88
+ def start(self) -> "WatchConfig[T]":
89
+ """加载配置 → 调用回调 → 启动文件监控线程。"""
90
+ if self._thread and self._thread.is_alive():
91
+ return self
92
+
93
+ self._load_and_notify()
94
+
95
+ self._stop_event.clear()
96
+ self._thread = threading.Thread(
97
+ target=self._watch_loop,
98
+ name=f"WatchConfig:{self._file_path.name}",
99
+ daemon=True,
100
+ )
101
+ self._thread.start()
102
+
103
+ self._logger.info("WatchConfig started: %s", self._file_path)
104
+ return self
105
+
106
+ def stop(self) -> None:
107
+ """停止文件监控。"""
108
+ self._stop_event.set()
109
+ if self._thread and self._thread.is_alive():
110
+ self._thread.join(timeout=5)
111
+ self._logger.info("WatchConfig stopped: %s", self._file_path)
112
+
113
+ def run(self) -> None:
114
+ """start + wait + stop 的快捷方式。Ctrl+C 可退出。"""
115
+ self.start()
116
+ try:
117
+ self.wait()
118
+ except KeyboardInterrupt:
119
+ pass
120
+ finally:
121
+ self.stop()
122
+
123
+ def wait(self) -> None:
124
+ """阻塞当前线程,直到 stop() 被调用。支持 Ctrl+C 中断。"""
125
+ while not self._stop_event.is_set():
126
+ self._stop_event.wait(timeout=0.1)
127
+
128
+ def reload(self) -> ChangeLog:
129
+ """手动触发一次重新加载。"""
130
+ return self._load_and_notify()
131
+
132
+ def set_path(self, file_path: str | Path) -> "WatchConfig[T]":
133
+ """修改配置文件路径并立即重新加载。"""
134
+ self._file_path = Path(file_path).resolve()
135
+ self._load_and_notify()
136
+ return self
137
+
138
+ def has_changed(self) -> bool:
139
+ """检查配置文件是否发生了变化。"""
140
+ try:
141
+ state = self._get_file_state()
142
+ except FileNotFoundError:
143
+ return False
144
+ return self._file_state is None or state != self._file_state
145
+
146
+ def _load_and_notify(self) -> ChangeLog:
147
+ new_data = self._read_config()
148
+ new_obj = build_object(self._model_type, new_data)
149
+ file_state = self._get_file_state()
150
+
151
+ with self._lock:
152
+ old_data = self._raw_data
153
+ self._raw_data = new_data
154
+ self._value = new_obj
155
+ self._file_state = file_state
156
+
157
+ if old_data is not None:
158
+ changelog = diff_values(old_data, new_data)
159
+ else:
160
+ changelog = ChangeLog()
161
+
162
+
163
+ if not changelog.is_empty:
164
+ try:
165
+ self._renderer.emit(changelog, self._logger)
166
+ except Exception:
167
+ self._logger.exception("Renderer failed")
168
+
169
+ for cb in self._callbacks:
170
+ try:
171
+ _call_flexible(cb, new_obj, changelog)
172
+ except Exception:
173
+ self._logger.exception("Callback %r failed", cb)
174
+
175
+ return changelog
176
+
177
+ def _watch_loop(self) -> None:
178
+ while not self._stop_event.is_set():
179
+ try:
180
+ if self.has_changed():
181
+ if self._stop_event.wait(self._debounce):
182
+ return
183
+
184
+ if self.has_changed():
185
+ try:
186
+ self._load_and_notify()
187
+ except Exception:
188
+ self._logger.exception(
189
+ "Config reload failed: %s",
190
+ self._file_path,
191
+ )
192
+ except Exception:
193
+ self._logger.exception("Watch loop error: %s", self._file_path)
194
+
195
+ if self._stop_event.wait(self._interval):
196
+ return
197
+
198
+ def _read_config(self) -> Any:
199
+ return load_config(str(self._file_path))
200
+
201
+ def _get_file_state(self) -> tuple[int, int]:
202
+ stat = self._file_path.stat()
203
+ return (stat.st_mtime_ns, stat.st_size)
204
+
205
+
206
+ def _call_flexible(fn: Callable, obj: Any, changelog: ChangeLog) -> Any:
207
+ try:
208
+ sig = inspect.signature(fn)
209
+ except (TypeError, ValueError):
210
+ return fn(obj, changelog)
211
+
212
+ params = [
213
+ p
214
+ for p in sig.parameters.values()
215
+ if p.default is inspect.Parameter.empty
216
+ and p.kind
217
+ not in (
218
+ inspect.Parameter.VAR_POSITIONAL,
219
+ inspect.Parameter.VAR_KEYWORD,
220
+ )
221
+ ]
222
+
223
+ count = len(params)
224
+ if count >= 2:
225
+ return fn(obj, changelog)
226
+ elif count == 1:
227
+ return fn(obj)
228
+ else:
229
+ return fn()