sortmeout 1.0.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.
@@ -0,0 +1,471 @@
1
+ """
2
+ Folder watching using macOS FSEvents.
3
+
4
+ This module provides file system monitoring for detecting changes in watched folders.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ import threading
12
+ from pathlib import Path
13
+ from typing import Callable, Dict, List, Optional, Set
14
+ from datetime import datetime
15
+ import time
16
+
17
+ from watchdog.observers import Observer
18
+ from watchdog.events import (
19
+ FileSystemEventHandler,
20
+ FileCreatedEvent,
21
+ FileModifiedEvent,
22
+ FileMovedEvent,
23
+ FileDeletedEvent,
24
+ DirCreatedEvent,
25
+ DirModifiedEvent,
26
+ DirMovedEvent,
27
+ DirDeletedEvent,
28
+ )
29
+
30
+ from sortmeout.utils.logger import get_logger
31
+
32
+ logger = get_logger(__name__)
33
+
34
+
35
+ class FileEventHandler(FileSystemEventHandler):
36
+ """
37
+ Handle file system events from watchdog.
38
+
39
+ Filters events and calls the appropriate callback with normalized event types.
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ callback: Callable[[str, str, str], None],
45
+ folder_path: str,
46
+ ignore_patterns: Optional[List[str]] = None,
47
+ include_directories: bool = False,
48
+ debounce_seconds: float = 0.5,
49
+ ):
50
+ """
51
+ Initialize the event handler.
52
+
53
+ Args:
54
+ callback: Function to call on file events (event_type, file_path, folder_path).
55
+ folder_path: Path to the watched folder.
56
+ ignore_patterns: File patterns to ignore.
57
+ include_directories: Whether to process directory events.
58
+ debounce_seconds: Time to wait before processing rapid events.
59
+ """
60
+ super().__init__()
61
+ self.callback = callback
62
+ self.folder_path = folder_path
63
+ self.ignore_patterns = ignore_patterns or [
64
+ ".*", # Hidden files
65
+ "*.tmp",
66
+ "*.temp",
67
+ "*.part",
68
+ "*.crdownload",
69
+ ".DS_Store",
70
+ "Thumbs.db",
71
+ ]
72
+ self.include_directories = include_directories
73
+ self.debounce_seconds = debounce_seconds
74
+
75
+ # Debouncing: track recent events
76
+ self._recent_events: Dict[str, datetime] = {}
77
+ self._lock = threading.Lock()
78
+
79
+ def _should_ignore(self, path: str) -> bool:
80
+ """Check if a path should be ignored."""
81
+ import fnmatch
82
+
83
+ name = os.path.basename(path)
84
+
85
+ for pattern in self.ignore_patterns:
86
+ if fnmatch.fnmatch(name, pattern):
87
+ return True
88
+
89
+ return False
90
+
91
+ def _debounce(self, path: str) -> bool:
92
+ """
93
+ Check if event should be processed (debouncing).
94
+
95
+ Returns True if event should be processed.
96
+ """
97
+ now = datetime.now()
98
+
99
+ with self._lock:
100
+ last_event = self._recent_events.get(path)
101
+
102
+ if last_event:
103
+ delta = (now - last_event).total_seconds()
104
+ if delta < self.debounce_seconds:
105
+ return False
106
+
107
+ self._recent_events[path] = now
108
+
109
+ # Clean up old entries
110
+ cutoff = now.timestamp() - 60 # Keep last minute
111
+ self._recent_events = {
112
+ p: t for p, t in self._recent_events.items()
113
+ if t.timestamp() > cutoff
114
+ }
115
+
116
+ return True
117
+
118
+ def _process_event(self, event_type: str, src_path: str, is_directory: bool) -> None:
119
+ """Process a file system event."""
120
+ if is_directory and not self.include_directories:
121
+ return
122
+
123
+ if self._should_ignore(src_path):
124
+ return
125
+
126
+ if not self._debounce(src_path):
127
+ return
128
+
129
+ logger.debug("Processing event: %s - %s", event_type, src_path)
130
+
131
+ try:
132
+ self.callback(event_type, src_path, self.folder_path)
133
+ except Exception as e:
134
+ logger.error("Error in callback for %s: %s", src_path, e)
135
+
136
+ def on_created(self, event) -> None:
137
+ """Handle file/directory created event."""
138
+ # Wait a bit for file to be fully written
139
+ time.sleep(0.1)
140
+ self._process_event("created", event.src_path, event.is_directory)
141
+
142
+ def on_modified(self, event) -> None:
143
+ """Handle file/directory modified event."""
144
+ self._process_event("modified", event.src_path, event.is_directory)
145
+
146
+ def on_moved(self, event) -> None:
147
+ """Handle file/directory moved event."""
148
+ # Treat destination as created
149
+ self._process_event("created", event.dest_path, event.is_directory)
150
+
151
+ def on_deleted(self, event) -> None:
152
+ """Handle file/directory deleted event."""
153
+ self._process_event("deleted", event.src_path, event.is_directory)
154
+
155
+
156
+ class FolderWatcher:
157
+ """
158
+ Watch a single folder for file changes.
159
+
160
+ Attributes:
161
+ path: Path to the watched folder.
162
+ recursive: Whether to watch subdirectories.
163
+ enabled: Whether watching is active.
164
+ """
165
+
166
+ def __init__(
167
+ self,
168
+ path: str,
169
+ callback: Callable[[str, str, str], None],
170
+ recursive: bool = False,
171
+ ignore_patterns: Optional[List[str]] = None,
172
+ ):
173
+ """
174
+ Initialize a folder watcher.
175
+
176
+ Args:
177
+ path: Path to watch.
178
+ callback: Function to call on file events.
179
+ recursive: Watch subdirectories.
180
+ ignore_patterns: Patterns to ignore.
181
+ """
182
+ self.path = os.path.expanduser(path)
183
+ self.callback = callback
184
+ self.recursive = recursive
185
+ self.ignore_patterns = ignore_patterns
186
+ self.enabled = True
187
+
188
+ self._observer: Optional[Observer] = None
189
+ self._handler: Optional[FileEventHandler] = None
190
+
191
+ # Validate path
192
+ if not os.path.isdir(self.path):
193
+ raise ValueError(f"Path is not a directory: {self.path}")
194
+
195
+ def start(self) -> None:
196
+ """Start watching the folder."""
197
+ if self._observer is not None:
198
+ return
199
+
200
+ self._handler = FileEventHandler(
201
+ callback=self.callback,
202
+ folder_path=self.path,
203
+ ignore_patterns=self.ignore_patterns,
204
+ )
205
+
206
+ self._observer = Observer()
207
+ self._observer.schedule(
208
+ self._handler,
209
+ self.path,
210
+ recursive=self.recursive,
211
+ )
212
+ self._observer.start()
213
+
214
+ logger.info("Started watching: %s (recursive=%s)", self.path, self.recursive)
215
+
216
+ def stop(self) -> None:
217
+ """Stop watching the folder."""
218
+ if self._observer is None:
219
+ return
220
+
221
+ self._observer.stop()
222
+ self._observer.join(timeout=5)
223
+ self._observer = None
224
+ self._handler = None
225
+
226
+ logger.info("Stopped watching: %s", self.path)
227
+
228
+ def is_running(self) -> bool:
229
+ """Check if watcher is running."""
230
+ return self._observer is not None and self._observer.is_alive()
231
+
232
+ def __repr__(self) -> str:
233
+ status = "running" if self.is_running() else "stopped"
234
+ return f"FolderWatcher({self.path!r}, status={status})"
235
+
236
+
237
+ class WatcherManager:
238
+ """
239
+ Manage multiple folder watchers.
240
+
241
+ Provides centralized control over all folder watchers and their lifecycle.
242
+ """
243
+
244
+ def __init__(self):
245
+ """Initialize the watcher manager."""
246
+ self._watchers: Dict[str, FolderWatcher] = {}
247
+ self._running = False
248
+ self._lock = threading.RLock()
249
+
250
+ def add_watch(
251
+ self,
252
+ path: str,
253
+ callback: Callable[[str, str, str], None],
254
+ recursive: bool = False,
255
+ ignore_patterns: Optional[List[str]] = None,
256
+ ) -> bool:
257
+ """
258
+ Add a folder to watch.
259
+
260
+ Args:
261
+ path: Path to the folder.
262
+ callback: Function to call on file events.
263
+ recursive: Watch subdirectories.
264
+ ignore_patterns: Patterns to ignore.
265
+
266
+ Returns:
267
+ True if watch was added successfully.
268
+ """
269
+ resolved_path = str(Path(os.path.expanduser(path)).resolve())
270
+
271
+ with self._lock:
272
+ if resolved_path in self._watchers:
273
+ logger.warning("Already watching: %s", resolved_path)
274
+ return False
275
+
276
+ try:
277
+ watcher = FolderWatcher(
278
+ path=resolved_path,
279
+ callback=callback,
280
+ recursive=recursive,
281
+ ignore_patterns=ignore_patterns,
282
+ )
283
+
284
+ if self._running:
285
+ watcher.start()
286
+
287
+ self._watchers[resolved_path] = watcher
288
+ logger.info("Added watch: %s", resolved_path)
289
+ return True
290
+
291
+ except Exception as e:
292
+ logger.error("Failed to add watch for %s: %s", resolved_path, e)
293
+ return False
294
+
295
+ def remove_watch(self, path: str) -> bool:
296
+ """
297
+ Remove a folder watch.
298
+
299
+ Args:
300
+ path: Path to the folder.
301
+
302
+ Returns:
303
+ True if watch was removed successfully.
304
+ """
305
+ resolved_path = str(Path(os.path.expanduser(path)).resolve())
306
+
307
+ with self._lock:
308
+ if resolved_path not in self._watchers:
309
+ return False
310
+
311
+ watcher = self._watchers.pop(resolved_path)
312
+ watcher.stop()
313
+
314
+ logger.info("Removed watch: %s", resolved_path)
315
+ return True
316
+
317
+ def get_watched_folders(self) -> List[str]:
318
+ """Get list of all watched folders."""
319
+ with self._lock:
320
+ return list(self._watchers.keys())
321
+
322
+ def start(self) -> None:
323
+ """Start all watchers."""
324
+ with self._lock:
325
+ if self._running:
326
+ return
327
+
328
+ self._running = True
329
+
330
+ for watcher in self._watchers.values():
331
+ if not watcher.is_running():
332
+ watcher.start()
333
+
334
+ logger.info("Started %d watchers", len(self._watchers))
335
+
336
+ def stop(self) -> None:
337
+ """Stop all watchers."""
338
+ with self._lock:
339
+ if not self._running:
340
+ return
341
+
342
+ self._running = False
343
+
344
+ for watcher in self._watchers.values():
345
+ watcher.stop()
346
+
347
+ logger.info("Stopped all watchers")
348
+
349
+ def is_running(self) -> bool:
350
+ """Check if manager is running."""
351
+ return self._running
352
+
353
+ def get_status(self) -> Dict[str, Dict]:
354
+ """Get status of all watchers."""
355
+ with self._lock:
356
+ return {
357
+ path: {
358
+ "running": watcher.is_running(),
359
+ "recursive": watcher.recursive,
360
+ "enabled": watcher.enabled,
361
+ }
362
+ for path, watcher in self._watchers.items()
363
+ }
364
+
365
+ def __len__(self) -> int:
366
+ """Number of watched folders."""
367
+ return len(self._watchers)
368
+
369
+ def __repr__(self) -> str:
370
+ status = "running" if self._running else "stopped"
371
+ return f"WatcherManager({len(self._watchers)} watchers, status={status})"
372
+
373
+
374
+ class FSEventsWatcher:
375
+ """
376
+ Native macOS FSEvents watcher using PyObjC.
377
+
378
+ This provides more efficient file system monitoring on macOS
379
+ compared to the generic watchdog implementation.
380
+ """
381
+
382
+ def __init__(
383
+ self,
384
+ path: str,
385
+ callback: Callable[[str, str, str], None],
386
+ latency: float = 0.5,
387
+ ):
388
+ """
389
+ Initialize FSEvents watcher.
390
+
391
+ Args:
392
+ path: Path to watch.
393
+ callback: Callback function.
394
+ latency: Latency in seconds before reporting events.
395
+ """
396
+ self.path = os.path.expanduser(path)
397
+ self.callback = callback
398
+ self.latency = latency
399
+ self._stream = None
400
+ self._running = False
401
+
402
+ def start(self) -> None:
403
+ """Start the FSEvents stream."""
404
+ try:
405
+ from FSEvents import (
406
+ FSEventStreamCreate,
407
+ FSEventStreamScheduleWithRunLoop,
408
+ FSEventStreamStart,
409
+ FSEventStreamStop,
410
+ FSEventStreamInvalidate,
411
+ FSEventStreamRelease,
412
+ CFRunLoopGetCurrent,
413
+ kCFRunLoopDefaultMode,
414
+ kFSEventStreamEventFlagNone,
415
+ )
416
+
417
+ def fsevents_callback(stream, info, num_events, paths, flags, ids):
418
+ for i in range(num_events):
419
+ path = paths[i].decode() if isinstance(paths[i], bytes) else paths[i]
420
+ self.callback("modified", path, self.path)
421
+
422
+ self._stream = FSEventStreamCreate(
423
+ None,
424
+ fsevents_callback,
425
+ None,
426
+ [self.path],
427
+ -1, # kFSEventStreamEventIdSinceNow
428
+ self.latency,
429
+ kFSEventStreamEventFlagNone,
430
+ )
431
+
432
+ FSEventStreamScheduleWithRunLoop(
433
+ self._stream,
434
+ CFRunLoopGetCurrent(),
435
+ kCFRunLoopDefaultMode,
436
+ )
437
+
438
+ FSEventStreamStart(self._stream)
439
+ self._running = True
440
+
441
+ logger.info("Started FSEvents watcher: %s", self.path)
442
+
443
+ except ImportError:
444
+ logger.warning("FSEvents not available, falling back to watchdog")
445
+ raise
446
+
447
+ def stop(self) -> None:
448
+ """Stop the FSEvents stream."""
449
+ if self._stream:
450
+ try:
451
+ from FSEvents import (
452
+ FSEventStreamStop,
453
+ FSEventStreamInvalidate,
454
+ FSEventStreamRelease,
455
+ )
456
+
457
+ FSEventStreamStop(self._stream)
458
+ FSEventStreamInvalidate(self._stream)
459
+ FSEventStreamRelease(self._stream)
460
+
461
+ except ImportError:
462
+ pass
463
+
464
+ self._stream = None
465
+ self._running = False
466
+
467
+ logger.info("Stopped FSEvents watcher: %s", self.path)
468
+
469
+ def is_running(self) -> bool:
470
+ """Check if watcher is running."""
471
+ return self._running
@@ -0,0 +1,10 @@
1
+ """
2
+ GUI module for SortMeOut.
3
+ """
4
+
5
+ from sortmeout.gui.app import main, MenuBarApp
6
+
7
+ __all__ = [
8
+ "main",
9
+ "MenuBarApp",
10
+ ]