syncwave 0.1.1__tar.gz → 0.1.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: syncwave
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Make your code reactive; Turn plain JSONs into a live data store, two-way synced with Python objects.
5
5
  Keywords:
6
6
  Author: Lucas Iannitello
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "syncwave"
3
- version = "0.1.1"
3
+ version = "0.1.2"
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.7",
27
+ "ruff>=0.12.9",
28
28
  ]
29
29
 
30
30
  [tool.ruff.lint]
31
- extend-select = ["E501"]
31
+ select = ["E", "F"]
32
+
33
+ unfixable = [
34
+ "F401" # disable fix for unused imports
35
+ ]
@@ -1,4 +1,4 @@
1
- __version__ = "0.1.0"
1
+ __version__ = "0.1.2"
2
2
 
3
3
 
4
4
  from .syncwave import SyncStore, Syncwave
@@ -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
- 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)
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=".tmp_", suffix=".json", dir=path.parent)
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)
@@ -1,8 +1,8 @@
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
5
+ from threading import RLock
6
6
  from typing import Any, Callable, Iterator, Optional, Type, Union, get_args
7
7
 
8
8
  from pydantic import BaseModel, ValidationError
@@ -62,7 +62,7 @@ class SyncStore(Mapping[JSONKey, BaseModel]):
62
62
  self.path = self._get_path(file_name or name_, sub_dir, file_path)
63
63
 
64
64
  self._ssd: dict[JSONKey, BaseModel] = {} # actual mapping, ssd = SyncStoreDict
65
- self._lock = threading.RLock()
65
+ self._lock = RLock()
66
66
  self._patch_cls()
67
67
  io.init_json_file(self.path)
68
68
  self.load()
@@ -111,9 +111,9 @@ class SyncStore(Mapping[JSONKey, BaseModel]):
111
111
  # no JSONDecodeError or ValidationError at this point, safe to clear
112
112
  self._ssd.clear()
113
113
  self._ssd.update(tmp_ssd)
114
- except (json.JSONDecodeError, ValidationError):
115
- return
116
- else:
114
+ except (FileNotFoundError, JSONDecodeError, ValidationError):
115
+ pass
116
+ finally:
117
117
  io.write_json(self.path, self.to_dict)
118
118
 
119
119
  def _get_path(
@@ -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