syncwave 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.
syncwave-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Lucas Iannitello
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.3
2
+ Name: syncwave
3
+ Version: 0.1.0
4
+ Summary: Make your code reactive; Turn plain JSONs into a live data store, two-way synced with Python objects.
5
+ Keywords:
6
+ Author: Lucas Iannitello
7
+ Author-email: Lucas Iannitello <lucas.iannitello@protonmail.com>
8
+ License: MIT License
9
+
10
+ Copyright (c) 2025 Lucas Iannitello
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ Requires-Dist: pydantic>=2.11.7
30
+ Requires-Dist: watchdog>=6.0.0
31
+ Requires-Python: >=3.9
32
+ Project-URL: Documentation, https://github.com/iannitello/syncwave
33
+ Project-URL: Homepage, https://github.com/iannitello/syncwave
34
+ Project-URL: Issues, https://github.com/iannitello/syncwave/issues
35
+ Project-URL: Repository, https://github.com/iannitello/syncwave
36
+ Description-Content-Type: text/markdown
37
+
38
+ # Syncwave
39
+
40
+ Make your code reactive; Turn plain JSONs into a live data store, two-way synced with Python objects.
41
+
42
+ > [!WARNING]
43
+ > Syncwave is under active development. Until version **1.0**, any minor release (`0.x`) may introduce breaking API changes. Pin the exact version to use in production.
44
+ >
45
+ > Version **1.0** will be released when the library is stable, feature-complete, and tested.
46
+
47
+ ## Getting Started
48
+
49
+ Install from [PyPI](https://pypi.org):
50
+
51
+ ```shell
52
+ # pip
53
+ pip install syncwave
54
+
55
+ # uv
56
+ uv add syncwave
57
+ ```
58
+
59
+ Bind a Pydantic model to a JSON file with `@syncwave.register`. Syncwave automatically synchronizes the file with your in-memory objects.
60
+
61
+ ```python
62
+ from pydantic import BaseModel
63
+
64
+ from syncwave import Syncwave
65
+
66
+ # 1. Initialise Syncwave with a directory to hold the JSON files.
67
+ syncwave = Syncwave(data_dir="data")
68
+
69
+
70
+ # 2. Define and register a model
71
+ # This also creates a JSON file at `data/customers.json` if it doesn't exist.
72
+ # Otherwise, the data is loaded into the `syncwave` instance.
73
+ @syncwave.register(name="customers", key="id")
74
+ class Customer(BaseModel):
75
+ id: int
76
+ name: str
77
+ age: int
78
+
79
+
80
+ # syncwave acts as a read-only dict with:
81
+ # keys -> collection name (e.g. "customers")
82
+ # values -> corresponding SyncStore
83
+ # A SyncStore also acts as a read-only dict:
84
+ # keys -> model's key field (e.g. `id`)
85
+ # values -> live Pydantic model instances
86
+
87
+
88
+ # 3. Create and mutate instances like you would with normal Pydantic objects.
89
+ john = Customer(id=1, name="John Doe", age=30) # Automatically inserted in JSON
90
+ john.age = 31 # Automatically updated in JSON
91
+
92
+
93
+ # 4. Delete instances with `SyncStore.delete(key)`.
94
+ syncwave["customers"].delete(1) # Removed from syncwave and JSON
95
+
96
+ # 5. External edits propagate automatically.
97
+ # First, let's add a new customer to populate the JSON file.
98
+ Customer(id=2, name="Jane Doe", age=25)
99
+
100
+ input("Now edit 'data/customers.json' and press Enter…")
101
+ print(syncwave)
102
+ ```
103
+
104
+ ## License
105
+
106
+ Syncwave is licensed under the MIT License.
@@ -0,0 +1,69 @@
1
+ # Syncwave
2
+
3
+ Make your code reactive; Turn plain JSONs into a live data store, two-way synced with Python objects.
4
+
5
+ > [!WARNING]
6
+ > Syncwave is under active development. Until version **1.0**, any minor release (`0.x`) may introduce breaking API changes. Pin the exact version to use in production.
7
+ >
8
+ > Version **1.0** will be released when the library is stable, feature-complete, and tested.
9
+
10
+ ## Getting Started
11
+
12
+ Install from [PyPI](https://pypi.org):
13
+
14
+ ```shell
15
+ # pip
16
+ pip install syncwave
17
+
18
+ # uv
19
+ uv add syncwave
20
+ ```
21
+
22
+ Bind a Pydantic model to a JSON file with `@syncwave.register`. Syncwave automatically synchronizes the file with your in-memory objects.
23
+
24
+ ```python
25
+ from pydantic import BaseModel
26
+
27
+ from syncwave import Syncwave
28
+
29
+ # 1. Initialise Syncwave with a directory to hold the JSON files.
30
+ syncwave = Syncwave(data_dir="data")
31
+
32
+
33
+ # 2. Define and register a model
34
+ # This also creates a JSON file at `data/customers.json` if it doesn't exist.
35
+ # Otherwise, the data is loaded into the `syncwave` instance.
36
+ @syncwave.register(name="customers", key="id")
37
+ class Customer(BaseModel):
38
+ id: int
39
+ name: str
40
+ age: int
41
+
42
+
43
+ # syncwave acts as a read-only dict with:
44
+ # keys -> collection name (e.g. "customers")
45
+ # values -> corresponding SyncStore
46
+ # A SyncStore also acts as a read-only dict:
47
+ # keys -> model's key field (e.g. `id`)
48
+ # values -> live Pydantic model instances
49
+
50
+
51
+ # 3. Create and mutate instances like you would with normal Pydantic objects.
52
+ john = Customer(id=1, name="John Doe", age=30) # Automatically inserted in JSON
53
+ john.age = 31 # Automatically updated in JSON
54
+
55
+
56
+ # 4. Delete instances with `SyncStore.delete(key)`.
57
+ syncwave["customers"].delete(1) # Removed from syncwave and JSON
58
+
59
+ # 5. External edits propagate automatically.
60
+ # First, let's add a new customer to populate the JSON file.
61
+ Customer(id=2, name="Jane Doe", age=25)
62
+
63
+ input("Now edit 'data/customers.json' and press Enter…")
64
+ print(syncwave)
65
+ ```
66
+
67
+ ## License
68
+
69
+ Syncwave is licensed under the MIT License.
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "syncwave"
3
+ version = "0.1.0"
4
+ description = "Make your code reactive; Turn plain JSONs into a live data store, two-way synced with Python objects."
5
+ readme = "README.md"
6
+ authors = [{ name = "Lucas Iannitello", email = "lucas.iannitello@protonmail.com" }]
7
+ requires-python = ">=3.9"
8
+ dependencies = [
9
+ "pydantic>=2.11.7",
10
+ "watchdog>=6.0.0",
11
+ ]
12
+ license = { file = "LICENSE" }
13
+ keywords = []
14
+ classifiers = []
15
+
16
+ [project.urls]
17
+ Homepage = "https://github.com/iannitello/syncwave"
18
+ Documentation = "https://github.com/iannitello/syncwave"
19
+ Repository = "https://github.com/iannitello/syncwave"
20
+ Issues = "https://github.com/iannitello/syncwave/issues"
21
+
22
+ [build-system]
23
+ requires = ["uv_build>=0.8.4,<0.9.0"]
24
+ build-backend = "uv_build"
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "ruff>=0.12.7",
29
+ ]
30
+
31
+ [tool.ruff.lint]
32
+ extend-select = ["E501"]
@@ -0,0 +1,6 @@
1
+ __version__ = "0.1.0"
2
+
3
+
4
+ from .syncwave import SyncStore, Syncwave
5
+
6
+ __all__ = ["SyncStore", "Syncwave"]
@@ -0,0 +1,98 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+ from tempfile import mkstemp
5
+ from threading import Lock, Timer
6
+ from typing import Any, Callable
7
+
8
+ from .watcher import watcher
9
+
10
+ JSONData = Any
11
+ DataProvider = Callable[[], JSONData]
12
+
13
+
14
+ class _IO:
15
+ ENCODING = "utf-8"
16
+ DEFAULT_JSON_CONTENT = {}
17
+ DUMPS_CONFIG = {"indent": 2, "ensure_ascii": False}
18
+ DEBOUNCE_WINDOW = 0.05
19
+
20
+ def __init__(self) -> None:
21
+ self._lock = Lock()
22
+ self._debounce_timers: dict[Path, Timer] = {}
23
+ self._pending_data_providers: dict[Path, DataProvider] = {}
24
+
25
+ def json_dumps(self, data: JSONData) -> str:
26
+ return json.dumps(data, **self.DUMPS_CONFIG)
27
+
28
+ def init_json_file(self, path: Path) -> None:
29
+ path.parent.mkdir(parents=True, exist_ok=True)
30
+
31
+ if path.is_dir():
32
+ raise IsADirectoryError(f"Path '{path}' is a directory, not a file.")
33
+ if not path.exists() or path.stat().st_size == 0:
34
+ self._atomic_write(path, self.DEFAULT_JSON_CONTENT)
35
+ return
36
+ try:
37
+ with path.open(encoding=self.ENCODING) as f:
38
+ json.load(f)
39
+ except json.JSONDecodeError as e:
40
+ raise IOError(f"File '{path}' exists but is not a valid JSON file.") from e
41
+
42
+ def read_json(self, path: Path) -> JSONData:
43
+ with self._lock:
44
+ if path in self._pending_data_providers:
45
+ return self._pending_data_providers[path]()
46
+ with path.open(encoding=self.ENCODING) as f:
47
+ return json.load(f)
48
+
49
+ def write_json(self, path: Path, data_provider: DataProvider) -> None:
50
+ with self._lock:
51
+ if path in self._debounce_timers:
52
+ self._debounce_timers[path].cancel()
53
+
54
+ self._pending_data_providers[path] = data_provider
55
+
56
+ timer = Timer(
57
+ self.DEBOUNCE_WINDOW,
58
+ self._scheduled_write,
59
+ args=(path, data_provider),
60
+ )
61
+ self._debounce_timers[path] = timer
62
+ timer.start()
63
+
64
+ def _scheduled_write(self, path: Path, data_provider: DataProvider) -> None:
65
+ try:
66
+ self._atomic_write(path, data_provider())
67
+ finally:
68
+ with self._lock:
69
+ self._debounce_timers.pop(path, None)
70
+ self._pending_data_providers.pop(path, None)
71
+
72
+ def _atomic_write(self, path: Path, data: JSONData) -> None:
73
+ fd, tmp_path = mkstemp(prefix=".tmp_", suffix=".json", dir=path.parent)
74
+ try:
75
+ with os.fdopen(fd, "w", encoding=self.ENCODING) as tmp_file:
76
+ json.dump(data, tmp_file, **self.DUMPS_CONFIG)
77
+ tmp_file.write("\n")
78
+ tmp_file.flush()
79
+ os.fsync(tmp_file.fileno())
80
+
81
+ # Atomically replace the target file
82
+ watcher.mark_self_write(path)
83
+ os.replace(tmp_path, path)
84
+
85
+ except Exception as e:
86
+ # Attempt to remove temporary file on error
87
+ try:
88
+ os.remove(tmp_path)
89
+ except OSError:
90
+ pass
91
+ # Raise a clear error message including context
92
+ raise IOError(
93
+ f"Failed to write JSON to '{path}'. "
94
+ f"Temporary file '{tmp_path}' removed. Original error: {e}"
95
+ ) from e
96
+
97
+
98
+ io = _IO()
File without changes
@@ -0,0 +1,223 @@
1
+ import json
2
+ import threading
3
+ from collections.abc import Mapping
4
+ from inspect import isclass
5
+ from pathlib import Path
6
+ from typing import Any, Callable, Iterator, Optional, Type, Union, get_args
7
+
8
+ from pydantic import BaseModel, ValidationError
9
+
10
+ from .io import io
11
+ from .watcher import watcher
12
+
13
+ # Only acceptable types for the key field to be used as a JSON key
14
+ JSONKey = Union[str, int, float, bool, None]
15
+
16
+
17
+ def _validate_key_type(cls: Type[BaseModel], key: str) -> None:
18
+ # This will work if the key field is a basic type (str, int, float, bool, or None),
19
+ # but will fail for more complex types (Union[str, int], Optional[str], etc.).
20
+ # In that case skip the validation.
21
+ key_type = cls.model_fields[key].annotation
22
+ if key_type in get_args(JSONKey):
23
+ return
24
+ raise TypeError(
25
+ f"The key field '{key}' in class '{cls.__module__}.{cls.__qualname__}' will be "
26
+ "used as a JSON key. It must be a basic type (str, int, float, bool, or None), "
27
+ f"not {key_type.__name__ if hasattr(key_type, '__name__') else str(key_type)}."
28
+ )
29
+
30
+
31
+ class SyncStore(Mapping[JSONKey, BaseModel]):
32
+ def __init__(
33
+ self,
34
+ syncwave: "Syncwave",
35
+ cls: Type[BaseModel],
36
+ *,
37
+ name: Optional[str] = None,
38
+ key: str,
39
+ skip_key_validation: bool = False,
40
+ file_name: Optional[str] = None,
41
+ sub_dir: Optional[Union[Path, str]] = None,
42
+ file_path: Optional[Union[Path, str]] = None,
43
+ # in_memory: bool = True, # TODO: implement in_memory
44
+ ) -> None:
45
+ if not isinstance(syncwave, Syncwave):
46
+ raise ValueError("The syncwave argument must be an instance of Syncwave.")
47
+ if not (isclass(cls) and issubclass(cls, BaseModel)):
48
+ raise ValueError("Only subclasses of BaseModel can be used.")
49
+ if (name_ := name or cls.__name__.lower()) in syncwave:
50
+ raise ValueError(f"The name '{name_}' is already registered.")
51
+ if cls in [store.cls for store in syncwave.values()]:
52
+ raise ValueError(f"The class '{cls.__name__}' is already registered.")
53
+ if key not in cls.model_fields:
54
+ raise ValueError(f"'{cls.__name__}' does not have a field '{key}'.")
55
+ if not skip_key_validation:
56
+ _validate_key_type(cls, key)
57
+
58
+ self.syncwave = syncwave
59
+ self.cls = cls
60
+ self.name = name_
61
+ self.key = key
62
+ self.path = self._get_path(file_name or name_, sub_dir, file_path)
63
+
64
+ self._ssd: dict[JSONKey, BaseModel] = {} # actual mapping, ssd = SyncStoreDict
65
+ self._lock = threading.RLock()
66
+ self._patch_cls()
67
+ io.init_json_file(self.path)
68
+ self.load()
69
+ watcher.watch(self.path, self.load)
70
+ self.syncwave._swd[self.name] = self
71
+
72
+ def __getitem__(self, key: JSONKey) -> BaseModel:
73
+ with self._lock:
74
+ return self._ssd[key]
75
+
76
+ def __iter__(self) -> Iterator[JSONKey]:
77
+ with self._lock:
78
+ # first, convert to a list so the iterator is over a frozen object
79
+ return iter(list(self._ssd))
80
+
81
+ def __len__(self) -> int:
82
+ with self._lock:
83
+ return len(self._ssd)
84
+
85
+ def __repr__(self) -> str:
86
+ with self._lock:
87
+ return repr(self._ssd)
88
+
89
+ def __str__(self) -> str:
90
+ return io.json_dumps(self.to_dict())
91
+
92
+ def to_dict(self) -> dict[JSONKey, dict[str, Any]]:
93
+ with self._lock:
94
+ return {
95
+ key: model_instance.model_dump()
96
+ for key, model_instance in self._ssd.items()
97
+ }
98
+
99
+ def delete(self, key: JSONKey) -> None:
100
+ with self._lock:
101
+ del self._ssd[key]
102
+ io.write_json(self.path, self.to_dict)
103
+
104
+ def load(self) -> None:
105
+ try:
106
+ tmp_ssd: dict[JSONKey, BaseModel] = {}
107
+ for value in io.read_json(self.path).values():
108
+ model = self.cls.model_validate(value)
109
+ tmp_ssd[getattr(model, self.key)] = model
110
+ with self._lock:
111
+ # no JSONDecodeError or ValidationError at this point, safe to clear
112
+ self._ssd.clear()
113
+ self._ssd.update(tmp_ssd)
114
+ except (json.JSONDecodeError, ValidationError):
115
+ return
116
+ else:
117
+ io.write_json(self.path, self.to_dict)
118
+
119
+ def _get_path(
120
+ self,
121
+ file_name: str,
122
+ sub_dir: Union[Path, str, None],
123
+ file_path: Union[Path, str, None],
124
+ ) -> Path:
125
+ if file_path:
126
+ return Path(file_path).resolve()
127
+ file_name = file_name if file_name.endswith(".json") else f"{file_name}.json"
128
+ if sub_dir:
129
+ return (self.syncwave.data_dir / sub_dir / file_name).resolve()
130
+ return (self.syncwave.data_dir / file_name).resolve()
131
+
132
+ def _patch_cls(self) -> None:
133
+ # preserve original methods
134
+ original_init = self.cls.__init__
135
+ original_setattr = self.cls.__setattr__
136
+ original_delattr = self.cls.__delattr__
137
+
138
+ def __init__(model_instance: BaseModel, *args: Any, **kwargs: Any) -> None:
139
+ original_init(model_instance, *args, **kwargs)
140
+ key_value = getattr(model_instance, self.key)
141
+ with self._lock:
142
+ self._ssd[key_value] = model_instance
143
+ io.write_json(self.path, self.to_dict)
144
+
145
+ def __setattr__(model_instance: BaseModel, attr: str, value: Any) -> None:
146
+ # TODO check for edge cases
147
+ old_value = getattr(model_instance, attr)
148
+ original_setattr(model_instance, attr, value)
149
+ with self._lock:
150
+ if attr == self.key:
151
+ del self._ssd[old_value]
152
+ self._ssd[value] = model_instance
153
+ io.write_json(self.path, self.to_dict)
154
+
155
+ def __delattr__(model_instance: BaseModel, attr: str) -> None:
156
+ old_value = getattr(model_instance, attr)
157
+ original_delattr(model_instance, attr)
158
+ with self._lock:
159
+ if attr == self.key:
160
+ del self._ssd[old_value]
161
+ io.write_json(self.path, self.to_dict)
162
+
163
+ # override original methods
164
+ self.cls.__init__ = __init__
165
+ self.cls.__setattr__ = __setattr__
166
+ self.cls.__delattr__ = __delattr__
167
+
168
+
169
+ class Syncwave(Mapping[str, SyncStore]):
170
+ def __init__(self, data_dir: Union[str, Path]) -> None:
171
+ self.data_dir = Path(data_dir).resolve()
172
+ self._swd: dict[str, SyncStore] = {} # actual mapping, swd = SyncWaveDict
173
+
174
+ def __getitem__(self, key: str) -> SyncStore:
175
+ return self._swd[key]
176
+
177
+ def __iter__(self) -> Iterator[str]:
178
+ return iter(self._swd)
179
+
180
+ def __len__(self) -> int:
181
+ return len(self._swd)
182
+
183
+ def __repr__(self) -> str:
184
+ return repr(self._swd)
185
+
186
+ def __str__(self) -> str:
187
+ return io.json_dumps(self.to_dict())
188
+
189
+ def to_dict(self) -> dict[str, dict[JSONKey, dict[str, Any]]]:
190
+ return {name: syncstore.to_dict() for name, syncstore in self._swd.items()}
191
+
192
+ def load(self) -> None:
193
+ for syncstore in self._swd.values():
194
+ syncstore.load()
195
+
196
+ def stop(self) -> None:
197
+ watcher.stop()
198
+
199
+ def register(
200
+ self,
201
+ *,
202
+ name: Optional[str] = None,
203
+ key: str,
204
+ skip_key_validation: bool = False,
205
+ file_name: Optional[str] = None,
206
+ sub_dir: Optional[Union[Path, str]] = None,
207
+ file_path: Optional[Union[Path, str]] = None,
208
+ # in_memory: bool = True, # TODO: implement in_memory
209
+ ) -> Callable[[Type[BaseModel]], Type[BaseModel]]:
210
+ def decorator(cls: Type[BaseModel]) -> Type[BaseModel]:
211
+ SyncStore(
212
+ self,
213
+ cls,
214
+ name=name,
215
+ key=key,
216
+ skip_key_validation=skip_key_validation,
217
+ file_name=file_name,
218
+ sub_dir=sub_dir,
219
+ file_path=file_path,
220
+ )
221
+ return cls
222
+
223
+ return decorator
@@ -0,0 +1,139 @@
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()