memowatch 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- memowatch/__init__.py +99 -0
- memowatch/backends/__init__.py +3 -0
- memowatch/backends/base.py +78 -0
- memowatch/backends/linux.py +259 -0
- memowatch/backends/polling.py +156 -0
- memowatch/backends/win32.py +377 -0
- memowatch/cli/__init__.py +3 -0
- memowatch/cli/main.py +218 -0
- memowatch/core/__init__.py +3 -0
- memowatch/core/ast_diff.py +108 -0
- memowatch/core/diff.py +125 -0
- memowatch/core/events.py +259 -0
- memowatch/core/observer.py +264 -0
- memowatch/core/pipeline.py +296 -0
- memowatch/core/predict.py +74 -0
- memowatch/filters/__init__.py +3 -0
- memowatch/filters/gitignore.py +255 -0
- memowatch/filters/pattern.py +66 -0
- memowatch/filters/size_mime.py +61 -0
- memowatch/fluent.py +133 -0
- memowatch/utils/__init__.py +3 -0
- memowatch/utils/hasher.py +61 -0
- memowatch/utils/lock_check.py +52 -0
- memowatch-0.1.0.dist-info/METADATA +163 -0
- memowatch-0.1.0.dist-info/RECORD +28 -0
- memowatch-0.1.0.dist-info/WHEEL +5 -0
- memowatch-0.1.0.dist-info/entry_points.txt +2 -0
- memowatch-0.1.0.dist-info/top_level.txt +1 -0
memowatch/__init__.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
memowatch - A modern, high-performance filesystem monitoring library for Python.
|
|
3
|
+
|
|
4
|
+
Features beyond standard watchdog:
|
|
5
|
+
- First-class async/await support (async iterators)
|
|
6
|
+
- Built-in event debouncing & coalescing
|
|
7
|
+
- File settling / lock detection (FileSettledEvent)
|
|
8
|
+
- Content-aware SHA-256 diffing
|
|
9
|
+
- Native .gitignore parser + glob/regex/size filters
|
|
10
|
+
- Pure-Python Windows backend via ctypes (no pywin32)
|
|
11
|
+
- CLI with command runner and JSON streaming
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
# Sync / callback API
|
|
15
|
+
import memowatch
|
|
16
|
+
|
|
17
|
+
class MyHandler(memowatch.FileSystemEventHandler):
|
|
18
|
+
def on_created(self, event):
|
|
19
|
+
print(f"Created: {event.src_path}")
|
|
20
|
+
|
|
21
|
+
observer = memowatch.Observer()
|
|
22
|
+
observer.schedule(MyHandler(), path="./src", recursive=True)
|
|
23
|
+
observer.start()
|
|
24
|
+
|
|
25
|
+
# Async API
|
|
26
|
+
import asyncio
|
|
27
|
+
|
|
28
|
+
async def main():
|
|
29
|
+
async with memowatch.async_watch("./src") as stream:
|
|
30
|
+
async for event in stream:
|
|
31
|
+
print(event)
|
|
32
|
+
|
|
33
|
+
asyncio.run(main())
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
37
|
+
__author__ = "Yogesh Gokul <yogeshgokul372@gmail.com>"
|
|
38
|
+
|
|
39
|
+
# Display author information on import as requested
|
|
40
|
+
print(f"Loaded memowatch v{__version__} - Built with ❤️ by {__author__}")
|
|
41
|
+
|
|
42
|
+
from memowatch.core.events import (
|
|
43
|
+
FileSystemEvent,
|
|
44
|
+
FileCreatedEvent,
|
|
45
|
+
FileModifiedEvent,
|
|
46
|
+
FileDeletedEvent,
|
|
47
|
+
FileMovedEvent,
|
|
48
|
+
FileSettledEvent,
|
|
49
|
+
DirCreatedEvent,
|
|
50
|
+
DirModifiedEvent,
|
|
51
|
+
DirDeletedEvent,
|
|
52
|
+
DirMovedEvent,
|
|
53
|
+
EVENT_TYPE_CREATED,
|
|
54
|
+
EVENT_TYPE_MODIFIED,
|
|
55
|
+
EVENT_TYPE_DELETED,
|
|
56
|
+
EVENT_TYPE_MOVED,
|
|
57
|
+
EVENT_TYPE_SETTLED,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
from memowatch.core.observer import (
|
|
61
|
+
FileSystemEventHandler,
|
|
62
|
+
Observer,
|
|
63
|
+
async_watch,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
from memowatch.fluent import observe
|
|
67
|
+
|
|
68
|
+
__all__ = [
|
|
69
|
+
# Version
|
|
70
|
+
"__version__",
|
|
71
|
+
# Events
|
|
72
|
+
"FileSystemEvent",
|
|
73
|
+
"FileCreatedEvent",
|
|
74
|
+
"FileModifiedEvent",
|
|
75
|
+
"FileDeletedEvent",
|
|
76
|
+
"FileMovedEvent",
|
|
77
|
+
"FileSettledEvent",
|
|
78
|
+
"CodeChangedEvent",
|
|
79
|
+
"FilePredictedEvent",
|
|
80
|
+
"FileDiffEvent",
|
|
81
|
+
"DirCreatedEvent",
|
|
82
|
+
"DirModifiedEvent",
|
|
83
|
+
"DirDeletedEvent",
|
|
84
|
+
"DirMovedEvent",
|
|
85
|
+
"EVENT_TYPE_CREATED",
|
|
86
|
+
"EVENT_TYPE_MODIFIED",
|
|
87
|
+
"EVENT_TYPE_DELETED",
|
|
88
|
+
"EVENT_TYPE_MOVED",
|
|
89
|
+
"EVENT_TYPE_SETTLED",
|
|
90
|
+
"EVENT_TYPE_DIFF",
|
|
91
|
+
"EVENT_TYPE_CODE_CHANGED",
|
|
92
|
+
"EVENT_TYPE_PREDICTED",
|
|
93
|
+
# Observer
|
|
94
|
+
"FileSystemEventHandler",
|
|
95
|
+
"Observer",
|
|
96
|
+
"async_watch",
|
|
97
|
+
# Fluent API
|
|
98
|
+
"observe",
|
|
99
|
+
]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
memowatch.backends.base
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
Abstract base class for all platform backends.
|
|
6
|
+
|
|
7
|
+
A backend is responsible for:
|
|
8
|
+
1. Watching a single directory (optionally recursively).
|
|
9
|
+
2. Emitting raw ``FileSystemEvent`` objects via a callback.
|
|
10
|
+
3. Running in a background thread managed by the ``Observer``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import abc
|
|
16
|
+
import threading
|
|
17
|
+
from typing import Callable, Optional
|
|
18
|
+
|
|
19
|
+
from memowatch.core.events import FileSystemEvent
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BaseBackend(abc.ABC):
|
|
23
|
+
"""Abstract base class that every platform backend must implement."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
path: str,
|
|
28
|
+
recursive: bool = False,
|
|
29
|
+
callback: Optional[Callable[[FileSystemEvent], None]] = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.path = path
|
|
32
|
+
self.recursive = recursive
|
|
33
|
+
self.callback = callback
|
|
34
|
+
self._stop_event = threading.Event()
|
|
35
|
+
self._thread: Optional[threading.Thread] = None
|
|
36
|
+
|
|
37
|
+
# ── Public API ────────────────────────────
|
|
38
|
+
|
|
39
|
+
def start(self) -> None:
|
|
40
|
+
"""Start watching in a background thread."""
|
|
41
|
+
self._stop_event.clear()
|
|
42
|
+
self._thread = threading.Thread(
|
|
43
|
+
target=self._run, daemon=True, name=f"memowatch-{self.name}"
|
|
44
|
+
)
|
|
45
|
+
self._thread.start()
|
|
46
|
+
|
|
47
|
+
def stop(self) -> None:
|
|
48
|
+
"""Signal the backend to stop and wait for its thread to finish."""
|
|
49
|
+
self._stop_event.set()
|
|
50
|
+
if self._thread and self._thread.is_alive():
|
|
51
|
+
self._thread.join(timeout=5.0)
|
|
52
|
+
|
|
53
|
+
def is_alive(self) -> bool:
|
|
54
|
+
return self._thread is not None and self._thread.is_alive()
|
|
55
|
+
|
|
56
|
+
# ── Emit helper ───────────────────────────
|
|
57
|
+
|
|
58
|
+
def emit(self, event: FileSystemEvent) -> None:
|
|
59
|
+
"""Forward an event to the registered callback."""
|
|
60
|
+
if self.callback is not None:
|
|
61
|
+
self.callback(event)
|
|
62
|
+
|
|
63
|
+
# ── Abstract interface ────────────────────
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
@abc.abstractmethod
|
|
67
|
+
def name(self) -> str:
|
|
68
|
+
"""Short human-readable name for this backend (e.g. 'win32', 'polling')."""
|
|
69
|
+
|
|
70
|
+
@abc.abstractmethod
|
|
71
|
+
def _run(self) -> None:
|
|
72
|
+
"""Main loop executed in the background thread.
|
|
73
|
+
|
|
74
|
+
Implementations should watch ``self.path``, detect changes, and call
|
|
75
|
+
``self.emit(event)`` for each one. The loop must check
|
|
76
|
+
``self._stop_event.is_set()`` periodically and return when it becomes
|
|
77
|
+
``True``.
|
|
78
|
+
"""
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""
|
|
2
|
+
memowatch.backends.linux
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
Native Linux backend using ``inotify`` via ``ctypes``.
|
|
6
|
+
|
|
7
|
+
Uses the inotify system calls (inotify_init1, inotify_add_watch, read)
|
|
8
|
+
directly via ctypes — no external C-extension compilation required.
|
|
9
|
+
|
|
10
|
+
Supported inotify events mapped to memowatch events:
|
|
11
|
+
- IN_CREATE → FileCreatedEvent / DirCreatedEvent
|
|
12
|
+
- IN_DELETE → FileDeletedEvent / DirDeletedEvent
|
|
13
|
+
- IN_MODIFY → FileModifiedEvent
|
|
14
|
+
- IN_MOVED_FROM/TO → FileMovedEvent / DirMovedEvent
|
|
15
|
+
- IN_ATTRIB → FileModifiedEvent (metadata change)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import ctypes
|
|
21
|
+
import ctypes.util
|
|
22
|
+
import os
|
|
23
|
+
import struct
|
|
24
|
+
import sys
|
|
25
|
+
from typing import Callable, Dict, List, Optional
|
|
26
|
+
|
|
27
|
+
from memowatch.backends.base import BaseBackend
|
|
28
|
+
from memowatch.core.events import (
|
|
29
|
+
DirCreatedEvent,
|
|
30
|
+
DirDeletedEvent,
|
|
31
|
+
DirModifiedEvent,
|
|
32
|
+
DirMovedEvent,
|
|
33
|
+
FileCreatedEvent,
|
|
34
|
+
FileDeletedEvent,
|
|
35
|
+
FileModifiedEvent,
|
|
36
|
+
FileMovedEvent,
|
|
37
|
+
FileSystemEvent,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# ──────────────────────────────────────────────────
|
|
41
|
+
# inotify constants
|
|
42
|
+
# ──────────────────────────────────────────────────
|
|
43
|
+
IN_ACCESS = 0x00000001
|
|
44
|
+
IN_MODIFY = 0x00000002
|
|
45
|
+
IN_ATTRIB = 0x00000004
|
|
46
|
+
IN_CLOSE_WRITE = 0x00000008
|
|
47
|
+
IN_CLOSE_NOWRITE = 0x00000010
|
|
48
|
+
IN_OPEN = 0x00000020
|
|
49
|
+
IN_MOVED_FROM = 0x00000040
|
|
50
|
+
IN_MOVED_TO = 0x00000080
|
|
51
|
+
IN_CREATE = 0x00000100
|
|
52
|
+
IN_DELETE = 0x00000200
|
|
53
|
+
IN_DELETE_SELF = 0x00000400
|
|
54
|
+
IN_MOVE_SELF = 0x00000800
|
|
55
|
+
IN_ISDIR = 0x40000000
|
|
56
|
+
|
|
57
|
+
IN_NONBLOCK = 0x00000800
|
|
58
|
+
IN_CLOEXEC = 0x00080000
|
|
59
|
+
|
|
60
|
+
# Watch mask
|
|
61
|
+
_WATCH_MASK = (
|
|
62
|
+
IN_MODIFY
|
|
63
|
+
| IN_ATTRIB
|
|
64
|
+
| IN_CLOSE_WRITE
|
|
65
|
+
| IN_MOVED_FROM
|
|
66
|
+
| IN_MOVED_TO
|
|
67
|
+
| IN_CREATE
|
|
68
|
+
| IN_DELETE
|
|
69
|
+
| IN_DELETE_SELF
|
|
70
|
+
| IN_MOVE_SELF
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# inotify_event struct: int wd, uint32_t mask, uint32_t cookie, uint32_t len
|
|
74
|
+
_EVENT_HEADER_SIZE = struct.calcsize("iIII")
|
|
75
|
+
_EVENT_HEADER_FORMAT = "iIII"
|
|
76
|
+
|
|
77
|
+
# Read buffer
|
|
78
|
+
_READ_BUF_SIZE = 65536
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class InotifyBackend(BaseBackend):
|
|
82
|
+
"""Native Linux filesystem watcher using inotify via ctypes.
|
|
83
|
+
|
|
84
|
+
Parameters
|
|
85
|
+
----------
|
|
86
|
+
path : str
|
|
87
|
+
Root directory to watch.
|
|
88
|
+
recursive : bool
|
|
89
|
+
Whether to watch subdirectories.
|
|
90
|
+
callback : callable
|
|
91
|
+
Called with each detected ``FileSystemEvent``.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
path: str,
|
|
97
|
+
recursive: bool = False,
|
|
98
|
+
callback: Optional[Callable[[FileSystemEvent], None]] = None,
|
|
99
|
+
) -> None:
|
|
100
|
+
if sys.platform != "linux":
|
|
101
|
+
raise RuntimeError("InotifyBackend is only available on Linux")
|
|
102
|
+
super().__init__(path=path, recursive=recursive, callback=callback)
|
|
103
|
+
self._inotify_fd: int = -1
|
|
104
|
+
self._wd_to_path: Dict[int, str] = {}
|
|
105
|
+
self._cookie_map: Dict[int, str] = {} # for rename pairing
|
|
106
|
+
self._libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def name(self) -> str:
|
|
110
|
+
return "inotify"
|
|
111
|
+
|
|
112
|
+
def stop(self) -> None:
|
|
113
|
+
"""Signal stop and close the inotify fd to unblock read()."""
|
|
114
|
+
self._stop_event.set()
|
|
115
|
+
# Closing the fd will cause the blocking read() to return with an error
|
|
116
|
+
if self._inotify_fd >= 0:
|
|
117
|
+
try:
|
|
118
|
+
os.close(self._inotify_fd)
|
|
119
|
+
except OSError:
|
|
120
|
+
pass
|
|
121
|
+
self._inotify_fd = -1
|
|
122
|
+
if self._thread and self._thread.is_alive():
|
|
123
|
+
self._thread.join(timeout=5.0)
|
|
124
|
+
|
|
125
|
+
def _run(self) -> None:
|
|
126
|
+
abs_path = os.path.abspath(self.path)
|
|
127
|
+
|
|
128
|
+
# inotify_init1(IN_NONBLOCK | IN_CLOEXEC)
|
|
129
|
+
self._inotify_fd = self._libc.inotify_init1(IN_NONBLOCK | IN_CLOEXEC)
|
|
130
|
+
if self._inotify_fd < 0:
|
|
131
|
+
raise OSError("inotify_init1 failed")
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
self._add_watch(abs_path)
|
|
135
|
+
if self.recursive:
|
|
136
|
+
self._add_watches_recursive(abs_path)
|
|
137
|
+
self._read_loop()
|
|
138
|
+
finally:
|
|
139
|
+
# Clean up watches
|
|
140
|
+
for wd in list(self._wd_to_path):
|
|
141
|
+
try:
|
|
142
|
+
self._libc.inotify_rm_watch(self._inotify_fd, wd)
|
|
143
|
+
except OSError:
|
|
144
|
+
pass
|
|
145
|
+
if self._inotify_fd >= 0:
|
|
146
|
+
try:
|
|
147
|
+
os.close(self._inotify_fd)
|
|
148
|
+
except OSError:
|
|
149
|
+
pass
|
|
150
|
+
self._inotify_fd = -1
|
|
151
|
+
|
|
152
|
+
def _add_watch(self, path: str) -> int:
|
|
153
|
+
wd = self._libc.inotify_add_watch(
|
|
154
|
+
self._inotify_fd,
|
|
155
|
+
path.encode("utf-8"),
|
|
156
|
+
_WATCH_MASK,
|
|
157
|
+
)
|
|
158
|
+
if wd < 0:
|
|
159
|
+
errno = ctypes.get_errno()
|
|
160
|
+
raise OSError(f"inotify_add_watch failed for {path} (errno={errno})")
|
|
161
|
+
self._wd_to_path[wd] = path
|
|
162
|
+
return wd
|
|
163
|
+
|
|
164
|
+
def _add_watches_recursive(self, root: str) -> None:
|
|
165
|
+
for dirpath, dirnames, _ in os.walk(root):
|
|
166
|
+
for d in dirnames:
|
|
167
|
+
full = os.path.join(dirpath, d)
|
|
168
|
+
try:
|
|
169
|
+
self._add_watch(full)
|
|
170
|
+
except OSError:
|
|
171
|
+
pass # Permission denied, etc.
|
|
172
|
+
|
|
173
|
+
def _read_loop(self) -> None:
|
|
174
|
+
import select
|
|
175
|
+
|
|
176
|
+
while not self._stop_event.is_set():
|
|
177
|
+
# Use select with timeout so we can check stop_event periodically
|
|
178
|
+
try:
|
|
179
|
+
readable, _, _ = select.select(
|
|
180
|
+
[self._inotify_fd], [], [], 0.5
|
|
181
|
+
)
|
|
182
|
+
except (ValueError, OSError):
|
|
183
|
+
break # fd closed
|
|
184
|
+
|
|
185
|
+
if not readable:
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
data = os.read(self._inotify_fd, _READ_BUF_SIZE)
|
|
190
|
+
except OSError:
|
|
191
|
+
break
|
|
192
|
+
|
|
193
|
+
if not data:
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
self._parse_events(data)
|
|
197
|
+
|
|
198
|
+
def _parse_events(self, data: bytes) -> None:
|
|
199
|
+
offset = 0
|
|
200
|
+
while offset < len(data):
|
|
201
|
+
if offset + _EVENT_HEADER_SIZE > len(data):
|
|
202
|
+
break
|
|
203
|
+
|
|
204
|
+
wd, mask, cookie, name_len = struct.unpack_from(
|
|
205
|
+
_EVENT_HEADER_FORMAT, data, offset
|
|
206
|
+
)
|
|
207
|
+
offset += _EVENT_HEADER_SIZE
|
|
208
|
+
|
|
209
|
+
name_bytes = data[offset : offset + name_len]
|
|
210
|
+
offset += name_len
|
|
211
|
+
|
|
212
|
+
# Trim null bytes
|
|
213
|
+
name = name_bytes.rstrip(b"\x00").decode("utf-8", errors="replace")
|
|
214
|
+
|
|
215
|
+
watch_path = self._wd_to_path.get(wd, "")
|
|
216
|
+
full_path = os.path.join(watch_path, name) if name else watch_path
|
|
217
|
+
is_dir = bool(mask & IN_ISDIR)
|
|
218
|
+
|
|
219
|
+
if mask & IN_CREATE:
|
|
220
|
+
if is_dir:
|
|
221
|
+
self.emit(DirCreatedEvent(src_path=full_path))
|
|
222
|
+
if self.recursive:
|
|
223
|
+
try:
|
|
224
|
+
self._add_watch(full_path)
|
|
225
|
+
except OSError:
|
|
226
|
+
pass
|
|
227
|
+
else:
|
|
228
|
+
self.emit(FileCreatedEvent(src_path=full_path))
|
|
229
|
+
|
|
230
|
+
elif mask & IN_DELETE:
|
|
231
|
+
if is_dir:
|
|
232
|
+
self.emit(DirDeletedEvent(src_path=full_path))
|
|
233
|
+
else:
|
|
234
|
+
self.emit(FileDeletedEvent(src_path=full_path))
|
|
235
|
+
|
|
236
|
+
elif mask & (IN_MODIFY | IN_ATTRIB | IN_CLOSE_WRITE):
|
|
237
|
+
if is_dir:
|
|
238
|
+
self.emit(DirModifiedEvent(src_path=full_path))
|
|
239
|
+
else:
|
|
240
|
+
self.emit(FileModifiedEvent(src_path=full_path))
|
|
241
|
+
|
|
242
|
+
elif mask & IN_MOVED_FROM:
|
|
243
|
+
self._cookie_map[cookie] = full_path
|
|
244
|
+
|
|
245
|
+
elif mask & IN_MOVED_TO:
|
|
246
|
+
old_path = self._cookie_map.pop(cookie, None)
|
|
247
|
+
if old_path is not None:
|
|
248
|
+
if is_dir:
|
|
249
|
+
self.emit(DirMovedEvent(src_path=old_path, dest_path=full_path))
|
|
250
|
+
else:
|
|
251
|
+
self.emit(FileMovedEvent(src_path=old_path, dest_path=full_path))
|
|
252
|
+
else:
|
|
253
|
+
if is_dir:
|
|
254
|
+
self.emit(DirCreatedEvent(src_path=full_path))
|
|
255
|
+
else:
|
|
256
|
+
self.emit(FileCreatedEvent(src_path=full_path))
|
|
257
|
+
|
|
258
|
+
elif mask & IN_DELETE_SELF:
|
|
259
|
+
self._wd_to_path.pop(wd, None)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""
|
|
2
|
+
memowatch.backends.polling
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
Cross-platform polling backend. Works everywhere but is less efficient
|
|
6
|
+
than native backends. Periodically scans the directory tree, detects
|
|
7
|
+
changes in file metadata (existence, mtime, size), and emits events.
|
|
8
|
+
|
|
9
|
+
This is the universal fallback and is used on platforms without a native
|
|
10
|
+
backend or when the native backend fails to initialize.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import stat
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Callable, Dict, Optional, Set
|
|
20
|
+
|
|
21
|
+
from memowatch.backends.base import BaseBackend
|
|
22
|
+
from memowatch.core.events import (
|
|
23
|
+
DirCreatedEvent,
|
|
24
|
+
DirDeletedEvent,
|
|
25
|
+
DirModifiedEvent,
|
|
26
|
+
FileCreatedEvent,
|
|
27
|
+
FileDeletedEvent,
|
|
28
|
+
FileModifiedEvent,
|
|
29
|
+
FileMovedEvent,
|
|
30
|
+
FileSystemEvent,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class _Snapshot:
|
|
36
|
+
"""Metadata snapshot for a single file or directory."""
|
|
37
|
+
is_dir: bool
|
|
38
|
+
size: int
|
|
39
|
+
mtime_ns: int
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PollingBackend(BaseBackend):
|
|
43
|
+
"""Polling-based filesystem watcher.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
path : str
|
|
48
|
+
Root directory to watch.
|
|
49
|
+
recursive : bool
|
|
50
|
+
Whether to recursively watch subdirectories.
|
|
51
|
+
callback : callable
|
|
52
|
+
Called with each detected ``FileSystemEvent``.
|
|
53
|
+
interval : float
|
|
54
|
+
Polling interval in seconds (default 1.0).
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
path: str,
|
|
60
|
+
recursive: bool = False,
|
|
61
|
+
callback: Optional[Callable[[FileSystemEvent], None]] = None,
|
|
62
|
+
interval: float = 1.0,
|
|
63
|
+
) -> None:
|
|
64
|
+
super().__init__(path=path, recursive=recursive, callback=callback)
|
|
65
|
+
self.interval = interval
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def name(self) -> str:
|
|
69
|
+
return "polling"
|
|
70
|
+
|
|
71
|
+
# ── Scanning ──────────────────────────────
|
|
72
|
+
|
|
73
|
+
def _take_snapshot(self) -> Dict[str, _Snapshot]:
|
|
74
|
+
"""Walk the directory tree and capture file metadata."""
|
|
75
|
+
entries: Dict[str, _Snapshot] = {}
|
|
76
|
+
try:
|
|
77
|
+
if self.recursive:
|
|
78
|
+
for dirpath, dirnames, filenames in os.walk(self.path):
|
|
79
|
+
# Record dirs
|
|
80
|
+
for d in dirnames:
|
|
81
|
+
full = os.path.join(dirpath, d)
|
|
82
|
+
entries[full] = self._stat_entry(full, is_dir=True)
|
|
83
|
+
# Record files
|
|
84
|
+
for f in filenames:
|
|
85
|
+
full = os.path.join(dirpath, f)
|
|
86
|
+
entries[full] = self._stat_entry(full, is_dir=False)
|
|
87
|
+
else:
|
|
88
|
+
for entry in os.scandir(self.path):
|
|
89
|
+
entries[entry.path] = self._stat_entry(
|
|
90
|
+
entry.path, is_dir=entry.is_dir(follow_symlinks=False)
|
|
91
|
+
)
|
|
92
|
+
except OSError:
|
|
93
|
+
pass # directory may have been deleted
|
|
94
|
+
return entries
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _stat_entry(path: str, is_dir: bool) -> _Snapshot:
|
|
98
|
+
try:
|
|
99
|
+
st = os.stat(path)
|
|
100
|
+
return _Snapshot(
|
|
101
|
+
is_dir=is_dir,
|
|
102
|
+
size=st.st_size if not is_dir else 0,
|
|
103
|
+
mtime_ns=st.st_mtime_ns,
|
|
104
|
+
)
|
|
105
|
+
except OSError:
|
|
106
|
+
return _Snapshot(is_dir=is_dir, size=-1, mtime_ns=0)
|
|
107
|
+
|
|
108
|
+
# ── Diffing ───────────────────────────────
|
|
109
|
+
|
|
110
|
+
def _diff(
|
|
111
|
+
self,
|
|
112
|
+
old: Dict[str, _Snapshot],
|
|
113
|
+
new: Dict[str, _Snapshot],
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Compare two snapshots and emit events for any differences."""
|
|
116
|
+
old_paths = set(old.keys())
|
|
117
|
+
new_paths = set(new.keys())
|
|
118
|
+
|
|
119
|
+
# Created
|
|
120
|
+
for p in new_paths - old_paths:
|
|
121
|
+
snap = new[p]
|
|
122
|
+
if snap.is_dir:
|
|
123
|
+
self.emit(DirCreatedEvent(src_path=p))
|
|
124
|
+
else:
|
|
125
|
+
self.emit(FileCreatedEvent(src_path=p))
|
|
126
|
+
|
|
127
|
+
# Deleted
|
|
128
|
+
for p in old_paths - new_paths:
|
|
129
|
+
snap = old[p]
|
|
130
|
+
if snap.is_dir:
|
|
131
|
+
self.emit(DirDeletedEvent(src_path=p))
|
|
132
|
+
else:
|
|
133
|
+
self.emit(FileDeletedEvent(src_path=p))
|
|
134
|
+
|
|
135
|
+
# Modified (size or mtime changed)
|
|
136
|
+
for p in old_paths & new_paths:
|
|
137
|
+
o, n = old[p], new[p]
|
|
138
|
+
if o.size != n.size or o.mtime_ns != n.mtime_ns:
|
|
139
|
+
if n.is_dir:
|
|
140
|
+
self.emit(DirModifiedEvent(src_path=p))
|
|
141
|
+
else:
|
|
142
|
+
self.emit(FileModifiedEvent(src_path=p))
|
|
143
|
+
|
|
144
|
+
# ── Main loop ─────────────────────────────
|
|
145
|
+
|
|
146
|
+
def _run(self) -> None:
|
|
147
|
+
previous = self._take_snapshot()
|
|
148
|
+
|
|
149
|
+
while not self._stop_event.is_set():
|
|
150
|
+
self._stop_event.wait(self.interval)
|
|
151
|
+
if self._stop_event.is_set():
|
|
152
|
+
break
|
|
153
|
+
|
|
154
|
+
current = self._take_snapshot()
|
|
155
|
+
self._diff(previous, current)
|
|
156
|
+
previous = current
|