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.
- python_library_watch_config-0.1.0.dist-info/METADATA +5 -0
- python_library_watch_config-0.1.0.dist-info/RECORD +8 -0
- python_library_watch_config-0.1.0.dist-info/WHEEL +4 -0
- watch_config/__init__.py +12 -0
- watch_config/changelog.py +75 -0
- watch_config/diff.py +107 -0
- watch_config/renderer.py +83 -0
- watch_config/watch_config.py +229 -0
|
@@ -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,,
|
watch_config/__init__.py
ADDED
|
@@ -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")
|
watch_config/renderer.py
ADDED
|
@@ -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()
|