python-library-watch-config 0.1.0__tar.gz
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/.gitignore +11 -0
- python_library_watch_config-0.1.0/PKG-INFO +5 -0
- python_library_watch_config-0.1.0/example/__main__.py +34 -0
- python_library_watch_config-0.1.0/example.bat +11 -0
- python_library_watch_config-0.1.0/pyproject.toml +17 -0
- python_library_watch_config-0.1.0/test.bat +10 -0
- python_library_watch_config-0.1.0/tests/__init__.py +0 -0
- python_library_watch_config-0.1.0/tests/test_changelog.py +57 -0
- python_library_watch_config-0.1.0/tests/test_diff.py +144 -0
- python_library_watch_config-0.1.0/tests/test_renderer.py +57 -0
- python_library_watch_config-0.1.0/tests/test_watch_config.py +179 -0
- python_library_watch_config-0.1.0/watch_config/__init__.py +12 -0
- python_library_watch_config-0.1.0/watch_config/changelog.py +75 -0
- python_library_watch_config-0.1.0/watch_config/diff.py +107 -0
- python_library_watch_config-0.1.0/watch_config/renderer.py +83 -0
- python_library_watch_config-0.1.0/watch_config/watch_config.py +229 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from watch_config import WatchConfig
|
|
6
|
+
|
|
7
|
+
logging.basicConfig(
|
|
8
|
+
level=logging.INFO,
|
|
9
|
+
format="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
|
|
10
|
+
datefmt="%H:%M:%S",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
CONFIG_FILEPATH = Path(__file__).parent / "config.yaml"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class AppConfig:
|
|
18
|
+
host: str
|
|
19
|
+
port: int
|
|
20
|
+
debug: bool = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
watcher = WatchConfig(CONFIG_FILEPATH, AppConfig)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@watcher
|
|
27
|
+
def on_config(cfg: AppConfig):
|
|
28
|
+
logging.getLogger(__name__).info(
|
|
29
|
+
"Config loaded: host=%s, port=%s, debug=%s",
|
|
30
|
+
cfg.host, cfg.port, cfg.debug,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
watcher.run()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "python-library-watch-config"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"python-library-configlib",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[tool.hatch.build.targets.wheel]
|
|
14
|
+
packages = ["watch_config"]
|
|
15
|
+
|
|
16
|
+
[tool.hatch.metadata]
|
|
17
|
+
allow-direct-references = true
|
|
File without changes
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
from watch_config import ChangeEntry, ChangeLog, ChangeType
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ChangeLogTests(unittest.TestCase):
|
|
9
|
+
def test_is_empty_true_when_no_entries(self) -> None:
|
|
10
|
+
log = ChangeLog()
|
|
11
|
+
self.assertTrue(log.is_empty)
|
|
12
|
+
self.assertEqual(len(log), 0)
|
|
13
|
+
|
|
14
|
+
def test_added_removed_updated_type_changed(self) -> None:
|
|
15
|
+
log = ChangeLog()
|
|
16
|
+
log.added("$.a", 1)
|
|
17
|
+
log.removed("$.b", 2)
|
|
18
|
+
log.updated("$.c", 0, 9)
|
|
19
|
+
log.type_changed("$.d", "x", 1)
|
|
20
|
+
|
|
21
|
+
self.assertFalse(log.is_empty)
|
|
22
|
+
self.assertEqual(len(log), 4)
|
|
23
|
+
|
|
24
|
+
types = [e.type for e in log.entries]
|
|
25
|
+
self.assertEqual(
|
|
26
|
+
types,
|
|
27
|
+
[
|
|
28
|
+
ChangeType.ADDED,
|
|
29
|
+
ChangeType.REMOVED,
|
|
30
|
+
ChangeType.UPDATED,
|
|
31
|
+
ChangeType.TYPE_CHANGED,
|
|
32
|
+
],
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
self.assertEqual(log.entries[0].path, "$.a")
|
|
36
|
+
self.assertIsNone(log.entries[0].old_value)
|
|
37
|
+
self.assertEqual(log.entries[0].new_value, 1)
|
|
38
|
+
|
|
39
|
+
self.assertEqual(log.entries[1].old_value, 2)
|
|
40
|
+
self.assertIsNone(log.entries[1].new_value)
|
|
41
|
+
|
|
42
|
+
self.assertEqual(log.entries[2].old_value, 0)
|
|
43
|
+
self.assertEqual(log.entries[2].new_value, 9)
|
|
44
|
+
|
|
45
|
+
self.assertEqual(log.entries[3].old_value, "x")
|
|
46
|
+
self.assertEqual(log.entries[3].new_value, 1)
|
|
47
|
+
|
|
48
|
+
def test_iter_yields_entries(self) -> None:
|
|
49
|
+
log = ChangeLog()
|
|
50
|
+
log.added("$.x", 1)
|
|
51
|
+
iterated = list(log)
|
|
52
|
+
self.assertEqual(len(iterated), 1)
|
|
53
|
+
self.assertIsInstance(iterated[0], ChangeEntry)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
unittest.main()
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from watch_config import ChangeType
|
|
9
|
+
from watch_config.diff import build_object, diff_values
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DiffValuesTests(unittest.TestCase):
|
|
13
|
+
def test_identical_primitives_empty(self) -> None:
|
|
14
|
+
log = diff_values(1, 1)
|
|
15
|
+
self.assertTrue(log.is_empty)
|
|
16
|
+
|
|
17
|
+
def test_primitive_updated(self) -> None:
|
|
18
|
+
log = diff_values(1, 2)
|
|
19
|
+
self.assertEqual(len(log), 1)
|
|
20
|
+
e = log.entries[0]
|
|
21
|
+
self.assertEqual(e.type, ChangeType.UPDATED)
|
|
22
|
+
self.assertEqual(e.path, "$")
|
|
23
|
+
self.assertEqual(e.old_value, 1)
|
|
24
|
+
self.assertEqual(e.new_value, 2)
|
|
25
|
+
|
|
26
|
+
def test_type_changed(self) -> None:
|
|
27
|
+
log = diff_values(1, "1")
|
|
28
|
+
self.assertEqual(len(log), 1)
|
|
29
|
+
self.assertEqual(log.entries[0].type, ChangeType.TYPE_CHANGED)
|
|
30
|
+
|
|
31
|
+
def test_dict_key_added_removed_updated(self) -> None:
|
|
32
|
+
old = {"a": 1, "b": 2}
|
|
33
|
+
new = {"a": 10, "c": 3}
|
|
34
|
+
log = diff_values(old, new)
|
|
35
|
+
paths = {(e.type, e.path) for e in log.entries}
|
|
36
|
+
self.assertIn((ChangeType.UPDATED, "$.a"), paths)
|
|
37
|
+
self.assertIn((ChangeType.REMOVED, "$.b"), paths)
|
|
38
|
+
self.assertIn((ChangeType.ADDED, "$.c"), paths)
|
|
39
|
+
|
|
40
|
+
def test_list_length_change(self) -> None:
|
|
41
|
+
log = diff_values([1, 2], [1])
|
|
42
|
+
types_paths = [(e.type, e.path) for e in log.entries]
|
|
43
|
+
self.assertIn((ChangeType.REMOVED, "$[1]"), types_paths)
|
|
44
|
+
|
|
45
|
+
def test_list_item_updated(self) -> None:
|
|
46
|
+
log = diff_values([1, 2], [1, 3])
|
|
47
|
+
self.assertEqual(len(log), 1)
|
|
48
|
+
self.assertEqual(log.entries[0].path, "$[1]")
|
|
49
|
+
self.assertEqual(log.entries[0].type, ChangeType.UPDATED)
|
|
50
|
+
|
|
51
|
+
def test_tuple_treated_as_sequence(self) -> None:
|
|
52
|
+
log = diff_values((1,), (1, 2))
|
|
53
|
+
added = [e for e in log.entries if e.type == ChangeType.ADDED]
|
|
54
|
+
self.assertEqual(len(added), 1)
|
|
55
|
+
self.assertEqual(added[0].path, "$[1]")
|
|
56
|
+
|
|
57
|
+
def test_set_unequal_single_updated(self) -> None:
|
|
58
|
+
log = diff_values({1, 2}, {1, 3})
|
|
59
|
+
self.assertEqual(len(log), 1)
|
|
60
|
+
self.assertEqual(log.entries[0].type, ChangeType.UPDATED)
|
|
61
|
+
|
|
62
|
+
def test_set_equal_empty(self) -> None:
|
|
63
|
+
log = diff_values({1}, {1})
|
|
64
|
+
self.assertTrue(log.is_empty)
|
|
65
|
+
|
|
66
|
+
def test_nested_dict_non_identifier_key_in_path(self) -> None:
|
|
67
|
+
old = {"a-b": 1}
|
|
68
|
+
new = {"a-b": 2}
|
|
69
|
+
log = diff_values(old, new)
|
|
70
|
+
self.assertTrue(any("a-b" in e.path for e in log.entries))
|
|
71
|
+
|
|
72
|
+
def test_reuses_out_parameter(self) -> None:
|
|
73
|
+
from watch_config import ChangeLog
|
|
74
|
+
|
|
75
|
+
out = ChangeLog()
|
|
76
|
+
diff_values({"x": 1}, {"x": 2}, out=out)
|
|
77
|
+
self.assertFalse(out.is_empty)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class SampleDC:
|
|
82
|
+
name: str
|
|
83
|
+
count: int
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class SampleModel(BaseModel):
|
|
87
|
+
name: str
|
|
88
|
+
count: int = 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class BuildObjectTests(unittest.TestCase):
|
|
92
|
+
def test_dict_model_requires_dict_payload(self) -> None:
|
|
93
|
+
self.assertEqual(build_object(dict, {"a": 1}), {"a": 1})
|
|
94
|
+
with self.assertRaises(TypeError):
|
|
95
|
+
build_object(dict, [])
|
|
96
|
+
|
|
97
|
+
def test_list_model(self) -> None:
|
|
98
|
+
self.assertEqual(build_object(list, [1, 2]), [1, 2])
|
|
99
|
+
with self.assertRaises(TypeError):
|
|
100
|
+
build_object(list, {})
|
|
101
|
+
|
|
102
|
+
def test_set_model_from_list(self) -> None:
|
|
103
|
+
self.assertEqual(build_object(set, [1, 2]), {1, 2})
|
|
104
|
+
|
|
105
|
+
def test_set_model_from_set(self) -> None:
|
|
106
|
+
self.assertEqual(build_object(set, {1}), {1})
|
|
107
|
+
|
|
108
|
+
def test_set_model_invalid_payload(self) -> None:
|
|
109
|
+
with self.assertRaises(TypeError):
|
|
110
|
+
build_object(set, "nope")
|
|
111
|
+
|
|
112
|
+
def test_dataclass_from_dict(self) -> None:
|
|
113
|
+
obj = build_object(SampleDC, {"name": "x", "count": 3})
|
|
114
|
+
self.assertIsInstance(obj, SampleDC)
|
|
115
|
+
self.assertEqual(obj.name, "x")
|
|
116
|
+
self.assertEqual(obj.count, 3)
|
|
117
|
+
|
|
118
|
+
def test_dataclass_requires_dict(self) -> None:
|
|
119
|
+
with self.assertRaises(TypeError):
|
|
120
|
+
build_object(SampleDC, [])
|
|
121
|
+
|
|
122
|
+
def test_pydantic_model_validate(self) -> None:
|
|
123
|
+
obj = build_object(SampleModel, {"name": "p", "count": 5})
|
|
124
|
+
self.assertIsInstance(obj, SampleModel)
|
|
125
|
+
self.assertEqual(obj.name, "p")
|
|
126
|
+
self.assertEqual(obj.count, 5)
|
|
127
|
+
|
|
128
|
+
def test_plain_class_kwargs_from_dict(self) -> None:
|
|
129
|
+
class Box:
|
|
130
|
+
def __init__(self, w: int, h: int) -> None:
|
|
131
|
+
self.w = w
|
|
132
|
+
self.h = h
|
|
133
|
+
|
|
134
|
+
obj = build_object(Box, {"w": 2, "h": 3})
|
|
135
|
+
self.assertEqual(obj.w, 2)
|
|
136
|
+
self.assertEqual(obj.h, 3)
|
|
137
|
+
|
|
138
|
+
def test_plain_callable_payload(self) -> None:
|
|
139
|
+
obj = build_object(int, "42")
|
|
140
|
+
self.assertEqual(obj, 42)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == "__main__":
|
|
144
|
+
unittest.main()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
from watch_config import ChangeLog, ChangeType, DefaultRenderer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DefaultRendererTests(unittest.TestCase):
|
|
10
|
+
def test_empty_changelog_returns_empty_string(self) -> None:
|
|
11
|
+
r = DefaultRenderer(color=False)
|
|
12
|
+
self.assertEqual(r.render(ChangeLog()), "")
|
|
13
|
+
|
|
14
|
+
def test_render_contains_paths_and_icons(self) -> None:
|
|
15
|
+
log = ChangeLog()
|
|
16
|
+
log.added("$.foo", {"k": 1})
|
|
17
|
+
log.removed("$.bar", 2)
|
|
18
|
+
log.updated("$.baz", 0, 1)
|
|
19
|
+
|
|
20
|
+
r = DefaultRenderer(color=False, max_value_length=200)
|
|
21
|
+
text = r.render(log)
|
|
22
|
+
|
|
23
|
+
self.assertIn("Config Changes (3):", text)
|
|
24
|
+
self.assertIn("+ foo", text)
|
|
25
|
+
self.assertIn("- bar", text)
|
|
26
|
+
self.assertIn("~ baz", text)
|
|
27
|
+
|
|
28
|
+
def test_emit_logs_when_non_empty(self) -> None:
|
|
29
|
+
log = ChangeLog()
|
|
30
|
+
log.added("$.x", 1)
|
|
31
|
+
r = DefaultRenderer(color=False)
|
|
32
|
+
logger = logging.getLogger("test_renderer_emit")
|
|
33
|
+
logger.setLevel(logging.INFO)
|
|
34
|
+
with self.assertLogs(logger, level="INFO") as cm:
|
|
35
|
+
r.emit(log, logger)
|
|
36
|
+
self.assertTrue(any("Config Changes" in rec.message for rec in cm.records))
|
|
37
|
+
|
|
38
|
+
def test_emit_skips_empty_string(self) -> None:
|
|
39
|
+
r = DefaultRenderer(color=False)
|
|
40
|
+
logger = logging.getLogger("test_renderer_emit_empty")
|
|
41
|
+
logger.setLevel(logging.INFO)
|
|
42
|
+
with self.assertLogs(logger, level="INFO") as cm:
|
|
43
|
+
logger.info("marker")
|
|
44
|
+
r.emit(ChangeLog(), logger)
|
|
45
|
+
self.assertEqual(len(cm.records), 1)
|
|
46
|
+
self.assertIn("marker", cm.records[0].getMessage())
|
|
47
|
+
|
|
48
|
+
def test_long_value_truncated(self) -> None:
|
|
49
|
+
log = ChangeLog()
|
|
50
|
+
log.added("$.v", "x" * 100)
|
|
51
|
+
r = DefaultRenderer(color=False, max_value_length=20)
|
|
52
|
+
text = r.render(log)
|
|
53
|
+
self.assertIn("...", text)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
unittest.main()
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
import unittest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from tempfile import TemporaryDirectory
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from watch_config import ChangeLog, ChangeRenderer, WatchConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BoomRenderer(ChangeRenderer):
|
|
14
|
+
def render(self, changelog: ChangeLog) -> str:
|
|
15
|
+
raise RuntimeError("render boom")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WatchConfigTests(unittest.TestCase):
|
|
19
|
+
def _write_yaml(self, path: Path, content: str) -> None:
|
|
20
|
+
path.write_text(content.strip() + "\n", encoding="utf-8")
|
|
21
|
+
|
|
22
|
+
def test_start_loads_file_and_sets_value(self) -> None:
|
|
23
|
+
with TemporaryDirectory() as tmp:
|
|
24
|
+
p = Path(tmp) / "cfg.yaml"
|
|
25
|
+
self._write_yaml(p, "x: 1\ny: two")
|
|
26
|
+
w = WatchConfig(p, dict, interval=0.05, debounce=0.05)
|
|
27
|
+
try:
|
|
28
|
+
w.start()
|
|
29
|
+
self.assertIsNotNone(w.value)
|
|
30
|
+
self.assertEqual(w.value, {"x": 1, "y": "two"})
|
|
31
|
+
self.assertEqual(w.file_path, p.resolve())
|
|
32
|
+
finally:
|
|
33
|
+
w.stop()
|
|
34
|
+
|
|
35
|
+
def test_decorator_registers_callbacks(self) -> None:
|
|
36
|
+
with TemporaryDirectory() as tmp:
|
|
37
|
+
p = Path(tmp) / "c.yaml"
|
|
38
|
+
self._write_yaml(p, "n: 0")
|
|
39
|
+
w = WatchConfig(p, dict, interval=0.05, debounce=0.05)
|
|
40
|
+
seen: list[tuple[Any, ChangeLog]] = []
|
|
41
|
+
|
|
42
|
+
@w
|
|
43
|
+
def cb(cfg: dict, changelog: ChangeLog) -> None:
|
|
44
|
+
seen.append((dict(cfg), changelog))
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
w.start()
|
|
48
|
+
self.assertEqual(len(seen), 1)
|
|
49
|
+
self.assertEqual(seen[0][0], {"n": 0})
|
|
50
|
+
self.assertTrue(seen[0][1].is_empty)
|
|
51
|
+
finally:
|
|
52
|
+
w.stop()
|
|
53
|
+
|
|
54
|
+
def test_callback_zero_or_one_arg(self) -> None:
|
|
55
|
+
with TemporaryDirectory() as tmp:
|
|
56
|
+
p = Path(tmp) / "c.yaml"
|
|
57
|
+
self._write_yaml(p, "a: 1")
|
|
58
|
+
w = WatchConfig(p, dict, interval=0.05, debounce=0.05)
|
|
59
|
+
calls: list[str] = []
|
|
60
|
+
|
|
61
|
+
@w
|
|
62
|
+
def cb0() -> None:
|
|
63
|
+
calls.append("0")
|
|
64
|
+
|
|
65
|
+
@w
|
|
66
|
+
def cb1(cfg: dict) -> None:
|
|
67
|
+
calls.append("1")
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
w.start()
|
|
71
|
+
self.assertEqual(sorted(calls), ["0", "1"])
|
|
72
|
+
finally:
|
|
73
|
+
w.stop()
|
|
74
|
+
|
|
75
|
+
def test_reload_after_file_change_non_empty_changelog(self) -> None:
|
|
76
|
+
with TemporaryDirectory() as tmp:
|
|
77
|
+
p = Path(tmp) / "c.yaml"
|
|
78
|
+
self._write_yaml(p, "k: 1")
|
|
79
|
+
w = WatchConfig(p, dict, interval=0.05, debounce=0.05)
|
|
80
|
+
try:
|
|
81
|
+
w.start()
|
|
82
|
+
self._write_yaml(p, "k: 2")
|
|
83
|
+
time.sleep(0.05)
|
|
84
|
+
log = w.reload()
|
|
85
|
+
self.assertFalse(log.is_empty)
|
|
86
|
+
self.assertEqual(w.value, {"k": 2})
|
|
87
|
+
finally:
|
|
88
|
+
w.stop()
|
|
89
|
+
|
|
90
|
+
def test_set_path_reloads(self) -> None:
|
|
91
|
+
with TemporaryDirectory() as tmp:
|
|
92
|
+
p1 = Path(tmp) / "a.yaml"
|
|
93
|
+
p2 = Path(tmp) / "b.yaml"
|
|
94
|
+
self._write_yaml(p1, "u: 1")
|
|
95
|
+
self._write_yaml(p2, "u: 2")
|
|
96
|
+
w = WatchConfig(p1, dict, interval=0.05, debounce=0.05)
|
|
97
|
+
try:
|
|
98
|
+
w.start()
|
|
99
|
+
self.assertEqual(w.value, {"u": 1})
|
|
100
|
+
w.set_path(p2)
|
|
101
|
+
self.assertEqual(w.file_path, p2.resolve())
|
|
102
|
+
self.assertEqual(w.value, {"u": 2})
|
|
103
|
+
finally:
|
|
104
|
+
w.stop()
|
|
105
|
+
|
|
106
|
+
def test_has_changed_false_when_missing(self) -> None:
|
|
107
|
+
with TemporaryDirectory() as tmp:
|
|
108
|
+
p = Path(tmp) / "missing.yaml"
|
|
109
|
+
w = WatchConfig(p, dict)
|
|
110
|
+
self.assertFalse(w.has_changed())
|
|
111
|
+
|
|
112
|
+
def test_has_changed_after_write(self) -> None:
|
|
113
|
+
with TemporaryDirectory() as tmp:
|
|
114
|
+
p = Path(tmp) / "c.yaml"
|
|
115
|
+
self._write_yaml(p, "a: 1")
|
|
116
|
+
w = WatchConfig(p, dict, interval=0.05, debounce=0.05)
|
|
117
|
+
try:
|
|
118
|
+
w.start()
|
|
119
|
+
self.assertFalse(w.has_changed())
|
|
120
|
+
self._write_yaml(p, "a: 2")
|
|
121
|
+
self.assertTrue(w.has_changed())
|
|
122
|
+
finally:
|
|
123
|
+
w.stop()
|
|
124
|
+
|
|
125
|
+
def test_double_start_is_idempotent(self) -> None:
|
|
126
|
+
with TemporaryDirectory() as tmp:
|
|
127
|
+
p = Path(tmp) / "c.yaml"
|
|
128
|
+
self._write_yaml(p, "z: 1")
|
|
129
|
+
w = WatchConfig(p, dict, interval=0.05, debounce=0.05)
|
|
130
|
+
try:
|
|
131
|
+
w.start()
|
|
132
|
+
t1 = w._thread
|
|
133
|
+
w.start()
|
|
134
|
+
self.assertIs(w._thread, t1)
|
|
135
|
+
finally:
|
|
136
|
+
w.stop()
|
|
137
|
+
|
|
138
|
+
def test_renderer_failure_still_invokes_callbacks(self) -> None:
|
|
139
|
+
with TemporaryDirectory() as tmp:
|
|
140
|
+
p = Path(tmp) / "c.yaml"
|
|
141
|
+
self._write_yaml(p, "q: 1")
|
|
142
|
+
logger = logging.getLogger("test_watch_renderer_fail")
|
|
143
|
+
logger.handlers.clear()
|
|
144
|
+
logger.addHandler(logging.NullHandler())
|
|
145
|
+
logger.propagate = False
|
|
146
|
+
logger.setLevel(logging.ERROR)
|
|
147
|
+
w = WatchConfig(
|
|
148
|
+
p,
|
|
149
|
+
dict,
|
|
150
|
+
renderer=BoomRenderer(),
|
|
151
|
+
interval=0.05,
|
|
152
|
+
debounce=0.05,
|
|
153
|
+
logger_=logger,
|
|
154
|
+
)
|
|
155
|
+
ok: list[bool] = []
|
|
156
|
+
|
|
157
|
+
@w
|
|
158
|
+
def cb(cfg: dict) -> None:
|
|
159
|
+
ok.append(True)
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
w.start()
|
|
163
|
+
self._write_yaml(p, "q: 2")
|
|
164
|
+
w.reload()
|
|
165
|
+
self.assertEqual(ok, [True, True])
|
|
166
|
+
finally:
|
|
167
|
+
w.stop()
|
|
168
|
+
|
|
169
|
+
def test_reload_raises_propagates_to_caller(self) -> None:
|
|
170
|
+
with TemporaryDirectory() as tmp:
|
|
171
|
+
p = Path(tmp) / "bad.yaml"
|
|
172
|
+
p.write_text("{ not yaml", encoding="utf-8")
|
|
173
|
+
w = WatchConfig(p, dict)
|
|
174
|
+
with self.assertRaises(Exception):
|
|
175
|
+
w.reload()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == "__main__":
|
|
179
|
+
unittest.main()
|
|
@@ -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)
|
|
@@ -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()
|