syncwave 0.1.1__tar.gz → 0.1.3__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.
- {syncwave-0.1.1 → syncwave-0.1.3}/PKG-INFO +1 -1
- {syncwave-0.1.1 → syncwave-0.1.3}/pyproject.toml +7 -3
- {syncwave-0.1.1 → syncwave-0.1.3}/src/syncwave/__init__.py +1 -1
- {syncwave-0.1.1 → syncwave-0.1.3}/src/syncwave/io.py +6 -7
- {syncwave-0.1.1 → syncwave-0.1.3}/src/syncwave/syncwave.py +87 -52
- syncwave-0.1.3/src/syncwave/watcher.py +177 -0
- syncwave-0.1.1/src/syncwave/watcher.py +0 -139
- {syncwave-0.1.1 → syncwave-0.1.3}/LICENSE +0 -0
- {syncwave-0.1.1 → syncwave-0.1.3}/README.md +0 -0
- {syncwave-0.1.1 → syncwave-0.1.3}/src/syncwave/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "syncwave"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.3"
|
|
4
4
|
description = "Make your code reactive; Turn plain JSONs into a live data store, two-way synced with Python objects."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{ name = "Lucas Iannitello", email = "lucas.iannitello@protonmail.com" }]
|
|
@@ -24,8 +24,12 @@ build-backend = "uv_build"
|
|
|
24
24
|
|
|
25
25
|
[dependency-groups]
|
|
26
26
|
dev = [
|
|
27
|
-
"ruff>=0.12.
|
|
27
|
+
"ruff>=0.12.9",
|
|
28
28
|
]
|
|
29
29
|
|
|
30
30
|
[tool.ruff.lint]
|
|
31
|
-
|
|
31
|
+
select = ["E", "F"]
|
|
32
|
+
|
|
33
|
+
unfixable = [
|
|
34
|
+
"F401" # disable fix for unused imports
|
|
35
|
+
]
|
|
@@ -62,15 +62,13 @@ class _IO:
|
|
|
62
62
|
timer.start()
|
|
63
63
|
|
|
64
64
|
def _scheduled_write(self, path: Path, data_provider: DataProvider) -> None:
|
|
65
|
-
|
|
66
|
-
self.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
self._debounce_timers.pop(path, None)
|
|
70
|
-
self._pending_data_providers.pop(path, None)
|
|
65
|
+
with self._lock:
|
|
66
|
+
self._debounce_timers.pop(path, None)
|
|
67
|
+
self._pending_data_providers.pop(path, None)
|
|
68
|
+
self._atomic_write(path, data_provider())
|
|
71
69
|
|
|
72
70
|
def _atomic_write(self, path: Path, data: JSONData) -> None:
|
|
73
|
-
fd, tmp_path = mkstemp(prefix=
|
|
71
|
+
fd, tmp_path = mkstemp(prefix=watcher.TMP_FILE_PREFIX, dir=path.parent)
|
|
74
72
|
try:
|
|
75
73
|
with os.fdopen(fd, "w", encoding=self.ENCODING) as tmp_file:
|
|
76
74
|
json.dump(data, tmp_file, **self.DUMPS_CONFIG)
|
|
@@ -81,6 +79,7 @@ class _IO:
|
|
|
81
79
|
# Atomically replace the target file
|
|
82
80
|
watcher.mark_self_write(path)
|
|
83
81
|
os.replace(tmp_path, path)
|
|
82
|
+
# print(f"Wrote JSON to {path}")
|
|
84
83
|
|
|
85
84
|
except Exception as e:
|
|
86
85
|
# Attempt to remove temporary file on error
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import threading
|
|
3
1
|
from collections.abc import Mapping
|
|
4
2
|
from inspect import isclass
|
|
3
|
+
from json import JSONDecodeError
|
|
5
4
|
from pathlib import Path
|
|
6
|
-
from
|
|
5
|
+
from threading import RLock
|
|
6
|
+
from typing import Any, Callable, Iterator, Optional, Type, Union, get_args, overload
|
|
7
7
|
|
|
8
|
-
from pydantic import BaseModel, ValidationError
|
|
8
|
+
from pydantic import BaseModel, ValidationError, create_model, model_validator
|
|
9
9
|
|
|
10
10
|
from .io import io
|
|
11
11
|
from .watcher import watcher
|
|
@@ -33,37 +33,44 @@ class SyncStore(Mapping[JSONKey, BaseModel]):
|
|
|
33
33
|
self,
|
|
34
34
|
syncwave: "Syncwave",
|
|
35
35
|
cls: Type[BaseModel],
|
|
36
|
-
|
|
36
|
+
/,
|
|
37
37
|
name: Optional[str] = None,
|
|
38
|
-
key: str,
|
|
38
|
+
key: Optional[str] = None,
|
|
39
|
+
*,
|
|
39
40
|
skip_key_validation: bool = False,
|
|
40
41
|
file_name: Optional[str] = None,
|
|
41
42
|
sub_dir: Optional[Union[Path, str]] = None,
|
|
42
43
|
file_path: Optional[Union[Path, str]] = None,
|
|
44
|
+
new_cls_name: Optional[str] = None,
|
|
43
45
|
# in_memory: bool = True, # TODO: implement in_memory
|
|
44
46
|
) -> None:
|
|
45
47
|
if not isinstance(syncwave, Syncwave):
|
|
46
|
-
raise
|
|
48
|
+
raise TypeError("The first argument must be an instance of Syncwave.")
|
|
47
49
|
if not (isclass(cls) and issubclass(cls, BaseModel)):
|
|
48
|
-
raise
|
|
50
|
+
raise TypeError("Only subclasses of Pydantic's BaseModel can be used.")
|
|
51
|
+
# `store.cls` is created with `create_model`, with `cls` as its only base class
|
|
52
|
+
# in other words, this checks that `cls` has not been registered already
|
|
53
|
+
if any(store.cls.__bases__[0] is cls for store in syncwave.values()):
|
|
54
|
+
raise ValueError(f"The class '{cls.__name__}' has already been registered.")
|
|
55
|
+
# `name` defaults to the lowercased class name if not provided
|
|
49
56
|
if (name_ := name or cls.__name__.lower()) in syncwave:
|
|
50
|
-
raise ValueError(f"The name '{name_}'
|
|
51
|
-
if
|
|
52
|
-
raise ValueError(f"
|
|
53
|
-
|
|
54
|
-
|
|
57
|
+
raise ValueError(f"The name '{name_}' has already been registered.")
|
|
58
|
+
if not cls.model_fields:
|
|
59
|
+
raise ValueError(f"Class '{cls.__name__}' has no fields defined.")
|
|
60
|
+
# `key` defaults to the first field in the model if not provided
|
|
61
|
+
if (key_ := key or next(iter(cls.model_fields))) not in cls.model_fields:
|
|
62
|
+
raise ValueError(f"'{cls.__name__}' does not have a field named '{key_}'.")
|
|
55
63
|
if not skip_key_validation:
|
|
56
|
-
_validate_key_type(cls,
|
|
64
|
+
_validate_key_type(cls, key_)
|
|
57
65
|
|
|
58
66
|
self.syncwave = syncwave
|
|
59
|
-
self.cls = cls
|
|
67
|
+
self.cls = self._patch_cls(cls, new_cls_name or f"Syncwave{cls.__name__}")
|
|
60
68
|
self.name = name_
|
|
61
|
-
self.key =
|
|
69
|
+
self.key = key_
|
|
62
70
|
self.path = self._get_path(file_name or name_, sub_dir, file_path)
|
|
63
71
|
|
|
64
72
|
self._ssd: dict[JSONKey, BaseModel] = {} # actual mapping, ssd = SyncStoreDict
|
|
65
|
-
self._lock =
|
|
66
|
-
self._patch_cls()
|
|
73
|
+
self._lock = RLock()
|
|
67
74
|
io.init_json_file(self.path)
|
|
68
75
|
self.load()
|
|
69
76
|
watcher.watch(self.path, self.load)
|
|
@@ -75,7 +82,7 @@ class SyncStore(Mapping[JSONKey, BaseModel]):
|
|
|
75
82
|
|
|
76
83
|
def __iter__(self) -> Iterator[JSONKey]:
|
|
77
84
|
with self._lock:
|
|
78
|
-
# first
|
|
85
|
+
# first convert to a list so the iterator is over a frozen object
|
|
79
86
|
return iter(list(self._ssd))
|
|
80
87
|
|
|
81
88
|
def __len__(self) -> int:
|
|
@@ -91,10 +98,7 @@ class SyncStore(Mapping[JSONKey, BaseModel]):
|
|
|
91
98
|
|
|
92
99
|
def to_dict(self) -> dict[JSONKey, dict[str, Any]]:
|
|
93
100
|
with self._lock:
|
|
94
|
-
return {
|
|
95
|
-
key: model_instance.model_dump()
|
|
96
|
-
for key, model_instance in self._ssd.items()
|
|
97
|
-
}
|
|
101
|
+
return {key: instance.model_dump() for key, instance in self._ssd.items()}
|
|
98
102
|
|
|
99
103
|
def delete(self, key: JSONKey) -> None:
|
|
100
104
|
with self._lock:
|
|
@@ -111,9 +115,9 @@ class SyncStore(Mapping[JSONKey, BaseModel]):
|
|
|
111
115
|
# no JSONDecodeError or ValidationError at this point, safe to clear
|
|
112
116
|
self._ssd.clear()
|
|
113
117
|
self._ssd.update(tmp_ssd)
|
|
114
|
-
except (
|
|
115
|
-
|
|
116
|
-
|
|
118
|
+
except (FileNotFoundError, JSONDecodeError, ValidationError):
|
|
119
|
+
pass
|
|
120
|
+
finally:
|
|
117
121
|
io.write_json(self.path, self.to_dict)
|
|
118
122
|
|
|
119
123
|
def _get_path(
|
|
@@ -129,41 +133,53 @@ class SyncStore(Mapping[JSONKey, BaseModel]):
|
|
|
129
133
|
return (self.syncwave.data_dir / sub_dir / file_name).resolve()
|
|
130
134
|
return (self.syncwave.data_dir / file_name).resolve()
|
|
131
135
|
|
|
132
|
-
def _patch_cls(self) ->
|
|
133
|
-
#
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
original_init(model_instance, *args, **kwargs)
|
|
140
|
-
key_value = getattr(model_instance, self.key)
|
|
136
|
+
def _patch_cls(self, cls: Type[BaseModel], new_cls_name: str) -> Type[BaseModel]:
|
|
137
|
+
# This is a validator that acts as a post-initialization hook.
|
|
138
|
+
# Using a validator instead of `__init__` ensures every instance is tracked.
|
|
139
|
+
# Libraries such as FastAPI may create instances indirectly with methods like
|
|
140
|
+
# `validate_python` or `model_validate` and `__init__` may never be called.
|
|
141
|
+
def init(self_model: BaseModel) -> BaseModel:
|
|
142
|
+
key_value = getattr(self_model, self.key)
|
|
141
143
|
with self._lock:
|
|
142
|
-
self._ssd[key_value]
|
|
144
|
+
if key_value in self._ssd and self._ssd[key_value] == self_model:
|
|
145
|
+
return self_model # no changes, no need to write
|
|
146
|
+
self._ssd[key_value] = self_model
|
|
143
147
|
io.write_json(self.path, self.to_dict)
|
|
148
|
+
return self_model
|
|
149
|
+
|
|
150
|
+
new_cls = create_model(
|
|
151
|
+
new_cls_name,
|
|
152
|
+
__base__=cls,
|
|
153
|
+
__validators__={"__syncwave_init__": model_validator(mode="after")(init)},
|
|
154
|
+
)
|
|
144
155
|
|
|
145
|
-
|
|
156
|
+
# preserve original methods
|
|
157
|
+
original_setattr = cls.__setattr__
|
|
158
|
+
original_delattr = cls.__delattr__
|
|
159
|
+
|
|
160
|
+
def __setattr__(self_model: BaseModel, attr: str, value: Any) -> None:
|
|
146
161
|
# TODO check for edge cases
|
|
147
|
-
old_value = getattr(
|
|
148
|
-
original_setattr(
|
|
162
|
+
old_value = getattr(self_model, attr)
|
|
163
|
+
original_setattr(self_model, attr, value)
|
|
149
164
|
with self._lock:
|
|
150
165
|
if attr == self.key:
|
|
151
166
|
del self._ssd[old_value]
|
|
152
|
-
self._ssd[value] =
|
|
167
|
+
self._ssd[value] = self_model
|
|
153
168
|
io.write_json(self.path, self.to_dict)
|
|
154
169
|
|
|
155
|
-
def __delattr__(
|
|
156
|
-
old_value = getattr(
|
|
157
|
-
original_delattr(
|
|
170
|
+
def __delattr__(self_model: BaseModel, attr: str) -> None:
|
|
171
|
+
old_value = getattr(self_model, attr)
|
|
172
|
+
original_delattr(self_model, attr)
|
|
158
173
|
with self._lock:
|
|
159
174
|
if attr == self.key:
|
|
160
175
|
del self._ssd[old_value]
|
|
161
176
|
io.write_json(self.path, self.to_dict)
|
|
162
177
|
|
|
163
|
-
#
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
178
|
+
# assign the new methods
|
|
179
|
+
new_cls.__setattr__ = __setattr__
|
|
180
|
+
new_cls.__delattr__ = __delattr__
|
|
181
|
+
|
|
182
|
+
return new_cls
|
|
167
183
|
|
|
168
184
|
|
|
169
185
|
class Syncwave(Mapping[str, SyncStore]):
|
|
@@ -196,19 +212,33 @@ class Syncwave(Mapping[str, SyncStore]):
|
|
|
196
212
|
def stop(self) -> None:
|
|
197
213
|
watcher.stop()
|
|
198
214
|
|
|
215
|
+
@overload
|
|
216
|
+
def register(
|
|
217
|
+
self,
|
|
218
|
+
*,
|
|
219
|
+
name: Optional[str] = None,
|
|
220
|
+
key: Optional[str] = None,
|
|
221
|
+
skip_key_validation: bool = False,
|
|
222
|
+
file_name: Optional[str] = None,
|
|
223
|
+
sub_dir: Optional[Union[Path, str]] = None,
|
|
224
|
+
file_path: Optional[Union[Path, str]] = None,
|
|
225
|
+
) -> Callable[[Type[BaseModel]], Type[BaseModel]]: ...
|
|
226
|
+
|
|
199
227
|
def register(
|
|
200
228
|
self,
|
|
229
|
+
_cls: Optional[Type[BaseModel]] = None,
|
|
230
|
+
/,
|
|
201
231
|
*,
|
|
202
232
|
name: Optional[str] = None,
|
|
203
|
-
key: str,
|
|
233
|
+
key: Optional[str] = None,
|
|
204
234
|
skip_key_validation: bool = False,
|
|
205
235
|
file_name: Optional[str] = None,
|
|
206
236
|
sub_dir: Optional[Union[Path, str]] = None,
|
|
207
237
|
file_path: Optional[Union[Path, str]] = None,
|
|
208
238
|
# in_memory: bool = True, # TODO: implement in_memory
|
|
209
|
-
) -> Callable[[Type[BaseModel]], Type[BaseModel]]:
|
|
239
|
+
) -> Union[Type[BaseModel], Callable[[Type[BaseModel]], Type[BaseModel]]]:
|
|
210
240
|
def decorator(cls: Type[BaseModel]) -> Type[BaseModel]:
|
|
211
|
-
SyncStore(
|
|
241
|
+
store = SyncStore(
|
|
212
242
|
self,
|
|
213
243
|
cls,
|
|
214
244
|
name=name,
|
|
@@ -217,7 +247,12 @@ class Syncwave(Mapping[str, SyncStore]):
|
|
|
217
247
|
file_name=file_name,
|
|
218
248
|
sub_dir=sub_dir,
|
|
219
249
|
file_path=file_path,
|
|
250
|
+
new_cls_name=cls.__name__,
|
|
220
251
|
)
|
|
221
|
-
return cls
|
|
252
|
+
return store.cls
|
|
222
253
|
|
|
223
|
-
|
|
254
|
+
if _cls is None:
|
|
255
|
+
# called with parenthesis: @syncwave.register(...)
|
|
256
|
+
return decorator
|
|
257
|
+
# called without parenthesis: @syncwave.register
|
|
258
|
+
return decorator(_cls)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from threading import Lock, Timer
|
|
3
|
+
from time import monotonic, sleep
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from watchdog.events import (
|
|
7
|
+
DirDeletedEvent,
|
|
8
|
+
DirMovedEvent,
|
|
9
|
+
FileSystemEvent,
|
|
10
|
+
FileSystemEventHandler,
|
|
11
|
+
)
|
|
12
|
+
from watchdog.observers import Observer
|
|
13
|
+
from watchdog.observers.api import ObservedWatch
|
|
14
|
+
|
|
15
|
+
DirPath = Path
|
|
16
|
+
FilePath = Path
|
|
17
|
+
Callback = Callable[[], None]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _Watcher:
|
|
21
|
+
TMP_FILE_PREFIX = ".tmp_"
|
|
22
|
+
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
self._lock = Lock()
|
|
25
|
+
# dir_path -> (watch object, set of file_paths)
|
|
26
|
+
self._watched_dirs: dict[DirPath, tuple[ObservedWatch, set[FilePath]]] = {}
|
|
27
|
+
|
|
28
|
+
self._observer = Observer()
|
|
29
|
+
self._event_handler = _EventHandler(self)
|
|
30
|
+
self._observer.start()
|
|
31
|
+
|
|
32
|
+
def watch(self, file_path: FilePath, callback: Callback) -> None:
|
|
33
|
+
if not file_path.is_file():
|
|
34
|
+
raise ValueError(f"Path '{file_path}' is not a file.")
|
|
35
|
+
|
|
36
|
+
dir_path = file_path.parent
|
|
37
|
+
with self._lock:
|
|
38
|
+
# if the directory is already being watched, just add the file to the set
|
|
39
|
+
if dir_path in self._watched_dirs:
|
|
40
|
+
_, watched_files = self._watched_dirs[dir_path]
|
|
41
|
+
watched_files.add(file_path)
|
|
42
|
+
# otherwise, schedule the directory and create the set of watched files
|
|
43
|
+
else:
|
|
44
|
+
self._watched_dirs[dir_path] = (
|
|
45
|
+
self._observer.schedule(self._event_handler, str(dir_path)),
|
|
46
|
+
{file_path},
|
|
47
|
+
)
|
|
48
|
+
# set or reset the callback in all cases
|
|
49
|
+
self._event_handler.set_callback(file_path, callback)
|
|
50
|
+
|
|
51
|
+
def unwatch(self, file_path: FilePath) -> None:
|
|
52
|
+
# remove the callback
|
|
53
|
+
self._event_handler.unset_callback(file_path)
|
|
54
|
+
|
|
55
|
+
dir_path = file_path.parent
|
|
56
|
+
with self._lock:
|
|
57
|
+
# if the directory is not being watched, do nothing
|
|
58
|
+
if dir_path not in self._watched_dirs:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
# if the file is in the set, remove it
|
|
62
|
+
watch, watched_files = self._watched_dirs[dir_path]
|
|
63
|
+
if file_path in watched_files:
|
|
64
|
+
watched_files.remove(file_path)
|
|
65
|
+
|
|
66
|
+
# if the set is empty, unschedule the directory
|
|
67
|
+
if not watched_files:
|
|
68
|
+
self._observer.unschedule(watch)
|
|
69
|
+
del self._watched_dirs[dir_path]
|
|
70
|
+
|
|
71
|
+
def mark_self_write(self, file_path: FilePath) -> None:
|
|
72
|
+
self._event_handler.mark_self_write(file_path)
|
|
73
|
+
|
|
74
|
+
def get_watched_dirs(self) -> dict[DirPath, set[FilePath]]:
|
|
75
|
+
with self._lock:
|
|
76
|
+
return {
|
|
77
|
+
dir_path: watched_files.copy()
|
|
78
|
+
for dir_path, (_, watched_files) in self._watched_dirs.items()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
def restore_watched_dir(self, dir_path: DirPath) -> None:
|
|
82
|
+
with self._lock:
|
|
83
|
+
if dir_path not in self._watched_dirs:
|
|
84
|
+
return
|
|
85
|
+
dir_path.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
watch, watched_files = self._watched_dirs[dir_path]
|
|
87
|
+
self._observer.unschedule(watch)
|
|
88
|
+
self._watched_dirs[dir_path] = (
|
|
89
|
+
self._observer.schedule(self._event_handler, str(dir_path)),
|
|
90
|
+
watched_files,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def stop(self) -> None:
|
|
94
|
+
self._observer.stop()
|
|
95
|
+
self._observer.join()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class _EventHandler(FileSystemEventHandler):
|
|
99
|
+
DEBOUNCE_WINDOW = 0.05
|
|
100
|
+
SELF_WRITE_WINDOW = 0.5
|
|
101
|
+
|
|
102
|
+
def __init__(self, watcher: _Watcher) -> None:
|
|
103
|
+
self._watcher = watcher
|
|
104
|
+
self._lock = Lock()
|
|
105
|
+
self._callbacks: dict[FilePath, Callback] = {}
|
|
106
|
+
self._debounce_timers: dict[FilePath, Timer] = {}
|
|
107
|
+
self._self_writes_ts: dict[FilePath, float] = {}
|
|
108
|
+
|
|
109
|
+
def set_callback(self, file_path: FilePath, callback: Callback) -> None:
|
|
110
|
+
with self._lock:
|
|
111
|
+
self._callbacks[file_path] = callback
|
|
112
|
+
|
|
113
|
+
def unset_callback(self, file_path: FilePath) -> None:
|
|
114
|
+
with self._lock:
|
|
115
|
+
self._callbacks.pop(file_path, None)
|
|
116
|
+
|
|
117
|
+
def mark_self_write(self, file_path: FilePath) -> None:
|
|
118
|
+
with self._lock:
|
|
119
|
+
self._self_writes_ts[file_path] = monotonic()
|
|
120
|
+
|
|
121
|
+
def _is_self_write(self, file_path: FilePath) -> bool:
|
|
122
|
+
# not protected by the lock because it's only called internally
|
|
123
|
+
ts = self._self_writes_ts.get(file_path)
|
|
124
|
+
if ts is not None and monotonic() - ts < self.SELF_WRITE_WINDOW:
|
|
125
|
+
return True
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
def dispatch(self, event: FileSystemEvent) -> None:
|
|
129
|
+
if event.is_directory:
|
|
130
|
+
if not isinstance(event, (DirDeletedEvent, DirMovedEvent)):
|
|
131
|
+
return
|
|
132
|
+
watched_dirs = self._watcher.get_watched_dirs()
|
|
133
|
+
for dir_path in self._paths_from_event(event):
|
|
134
|
+
if dir_path in watched_dirs:
|
|
135
|
+
self._on_dir_deleted(dir_path, watched_dirs[dir_path])
|
|
136
|
+
else:
|
|
137
|
+
for file_path in self._paths_from_event(event):
|
|
138
|
+
self._on_file_modified(file_path)
|
|
139
|
+
|
|
140
|
+
def _on_dir_deleted(self, dir_path: DirPath, watched_files: set[FilePath]) -> None:
|
|
141
|
+
sleep(self.DEBOUNCE_WINDOW) # low-tech debounce
|
|
142
|
+
self._watcher.restore_watched_dir(dir_path)
|
|
143
|
+
for file_path in watched_files:
|
|
144
|
+
self._on_file_modified(file_path)
|
|
145
|
+
|
|
146
|
+
def _on_file_modified(self, file_path: FilePath) -> None:
|
|
147
|
+
with self._lock:
|
|
148
|
+
if file_path not in self._callbacks or self._is_self_write(file_path):
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
if file_path in self._debounce_timers:
|
|
152
|
+
self._debounce_timers[file_path].cancel()
|
|
153
|
+
|
|
154
|
+
timer = Timer(
|
|
155
|
+
self.DEBOUNCE_WINDOW,
|
|
156
|
+
self._scheduled_callback,
|
|
157
|
+
args=(file_path, self._callbacks[file_path]),
|
|
158
|
+
)
|
|
159
|
+
self._debounce_timers[file_path] = timer
|
|
160
|
+
timer.start()
|
|
161
|
+
|
|
162
|
+
def _scheduled_callback(self, file_path: FilePath, callback: Callback) -> None:
|
|
163
|
+
with self._lock:
|
|
164
|
+
self._debounce_timers.pop(file_path, None)
|
|
165
|
+
callback()
|
|
166
|
+
|
|
167
|
+
def _paths_from_event(self, event: FileSystemEvent) -> list[Path]:
|
|
168
|
+
paths = []
|
|
169
|
+
if self._watcher.TMP_FILE_PREFIX not in event.src_path:
|
|
170
|
+
paths.append(Path(event.src_path).resolve())
|
|
171
|
+
dest_path = getattr(event, "dest_path", "")
|
|
172
|
+
if dest_path and self._watcher.TMP_FILE_PREFIX not in dest_path:
|
|
173
|
+
paths.append(Path(dest_path).resolve())
|
|
174
|
+
return paths
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
watcher = _Watcher()
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
from pathlib import Path
|
|
2
|
-
from threading import Lock, Timer
|
|
3
|
-
from time import monotonic
|
|
4
|
-
from typing import Callable, Union
|
|
5
|
-
|
|
6
|
-
from watchdog.events import (
|
|
7
|
-
DirModifiedEvent,
|
|
8
|
-
FileModifiedEvent,
|
|
9
|
-
FileSystemEvent,
|
|
10
|
-
FileSystemEventHandler,
|
|
11
|
-
)
|
|
12
|
-
from watchdog.observers import Observer
|
|
13
|
-
from watchdog.observers.api import ObservedWatch
|
|
14
|
-
|
|
15
|
-
DirPath = Path
|
|
16
|
-
FilePath = Path
|
|
17
|
-
Callback = Callable[[], None]
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class _Watcher:
|
|
21
|
-
SELF_WRITE_WINDOW = 0.5
|
|
22
|
-
|
|
23
|
-
def __init__(self) -> None:
|
|
24
|
-
self._lock = Lock()
|
|
25
|
-
# mapping dir_path -> (watch object, set of file_paths)
|
|
26
|
-
self._watched_dirs: dict[DirPath, tuple[ObservedWatch, set[FilePath]]] = {}
|
|
27
|
-
self._self_writes_ts: dict[FilePath, float] = {}
|
|
28
|
-
self._callbacks: dict[FilePath, Callback] = {}
|
|
29
|
-
|
|
30
|
-
self._observer = Observer()
|
|
31
|
-
self._event_handler = _EventHandler(self)
|
|
32
|
-
self._observer.start()
|
|
33
|
-
|
|
34
|
-
def stop(self) -> None:
|
|
35
|
-
self._observer.stop()
|
|
36
|
-
self._observer.join()
|
|
37
|
-
|
|
38
|
-
def watch(self, file_path: FilePath, callback: Callback) -> None:
|
|
39
|
-
dir_path = file_path.parent
|
|
40
|
-
|
|
41
|
-
if not file_path.is_file():
|
|
42
|
-
raise ValueError(f"Path {file_path} is not a file.")
|
|
43
|
-
|
|
44
|
-
with self._lock:
|
|
45
|
-
# if the directory is already being watched...
|
|
46
|
-
if dir_path in self._watched_dirs:
|
|
47
|
-
_, watched_files = self._watched_dirs[dir_path]
|
|
48
|
-
|
|
49
|
-
# ...but the file is not in the set, add it
|
|
50
|
-
if file_path not in watched_files:
|
|
51
|
-
watched_files.add(file_path)
|
|
52
|
-
|
|
53
|
-
# and in any case, update the callback before returning
|
|
54
|
-
self._callbacks[file_path] = callback
|
|
55
|
-
return
|
|
56
|
-
|
|
57
|
-
# otherwise, schedule the directory and register the callback
|
|
58
|
-
self._watched_dirs[dir_path] = (
|
|
59
|
-
self._observer.schedule(self._event_handler, str(dir_path)),
|
|
60
|
-
{file_path},
|
|
61
|
-
)
|
|
62
|
-
self._callbacks[file_path] = callback
|
|
63
|
-
|
|
64
|
-
def unwatch(self, file_path: FilePath) -> None:
|
|
65
|
-
dir_path = file_path.parent
|
|
66
|
-
|
|
67
|
-
with self._lock:
|
|
68
|
-
# remove the callback
|
|
69
|
-
self._callbacks.pop(file_path, None)
|
|
70
|
-
|
|
71
|
-
# if the directory is not being watched, do nothing
|
|
72
|
-
if dir_path not in self._watched_dirs:
|
|
73
|
-
return
|
|
74
|
-
|
|
75
|
-
watch, watched_files = self._watched_dirs[dir_path]
|
|
76
|
-
|
|
77
|
-
# if the file is in the set, remove it
|
|
78
|
-
if file_path in watched_files:
|
|
79
|
-
watched_files.remove(file_path)
|
|
80
|
-
|
|
81
|
-
# if the set is empty, unschedule the directory
|
|
82
|
-
if not watched_files:
|
|
83
|
-
self._observer.unschedule(watch)
|
|
84
|
-
del self._watched_dirs[dir_path]
|
|
85
|
-
|
|
86
|
-
def mark_self_write(self, file_path: FilePath) -> None:
|
|
87
|
-
with self._lock:
|
|
88
|
-
self._self_writes_ts[file_path] = monotonic()
|
|
89
|
-
|
|
90
|
-
def _is_self_write(self, file_path: FilePath) -> bool:
|
|
91
|
-
with self._lock:
|
|
92
|
-
ts = self._self_writes_ts.get(file_path)
|
|
93
|
-
if ts is not None and monotonic() - ts < self.SELF_WRITE_WINDOW:
|
|
94
|
-
return True
|
|
95
|
-
return False
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
class _EventHandler(FileSystemEventHandler):
|
|
99
|
-
DEBOUNCE_WINDOW = 0.05
|
|
100
|
-
|
|
101
|
-
def __init__(self, watcher: _Watcher) -> None:
|
|
102
|
-
self.lock = Lock()
|
|
103
|
-
self.watcher = watcher
|
|
104
|
-
self.debounce_timers: dict[FileSystemEvent, Timer] = {}
|
|
105
|
-
|
|
106
|
-
def on_modified(self, event: Union[FileModifiedEvent, DirModifiedEvent]) -> None:
|
|
107
|
-
if event.is_directory or isinstance(event, DirModifiedEvent):
|
|
108
|
-
return
|
|
109
|
-
|
|
110
|
-
file_path = Path(event.src_path).resolve()
|
|
111
|
-
|
|
112
|
-
if self.watcher._is_self_write(file_path):
|
|
113
|
-
return
|
|
114
|
-
|
|
115
|
-
callback = self.watcher._callbacks.get(file_path)
|
|
116
|
-
if callback is None:
|
|
117
|
-
return
|
|
118
|
-
|
|
119
|
-
with self.lock:
|
|
120
|
-
if event in self.debounce_timers:
|
|
121
|
-
self.debounce_timers[event].cancel()
|
|
122
|
-
|
|
123
|
-
timer = Timer(
|
|
124
|
-
self.DEBOUNCE_WINDOW,
|
|
125
|
-
self.schedule_callback,
|
|
126
|
-
args=(event, callback),
|
|
127
|
-
)
|
|
128
|
-
self.debounce_timers[event] = timer
|
|
129
|
-
timer.start()
|
|
130
|
-
|
|
131
|
-
def schedule_callback(self, event: FileSystemEvent, callback: Callback) -> None:
|
|
132
|
-
try:
|
|
133
|
-
callback()
|
|
134
|
-
finally:
|
|
135
|
-
with self.lock:
|
|
136
|
-
self.debounce_timers.pop(event, None)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
watcher = _Watcher()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|