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 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,3 @@
1
+ """
2
+ memowatch.backends - Backend sub-package init.
3
+ """
@@ -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