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.
- sortmeout/__init__.py +23 -0
- sortmeout/app.py +618 -0
- sortmeout/cli.py +550 -0
- sortmeout/config/__init__.py +11 -0
- sortmeout/config/manager.py +313 -0
- sortmeout/config/settings.py +201 -0
- sortmeout/core/__init__.py +21 -0
- sortmeout/core/action.py +889 -0
- sortmeout/core/condition.py +672 -0
- sortmeout/core/engine.py +421 -0
- sortmeout/core/rule.py +254 -0
- sortmeout/core/watcher.py +471 -0
- sortmeout/gui/__init__.py +10 -0
- sortmeout/gui/app.py +325 -0
- sortmeout/macos/__init__.py +19 -0
- sortmeout/macos/spotlight.py +337 -0
- sortmeout/macos/tags.py +308 -0
- sortmeout/macos/trash.py +449 -0
- sortmeout/utils/__init__.py +12 -0
- sortmeout/utils/file_info.py +363 -0
- sortmeout/utils/logger.py +214 -0
- sortmeout-1.0.0.dist-info/METADATA +302 -0
- sortmeout-1.0.0.dist-info/RECORD +27 -0
- sortmeout-1.0.0.dist-info/WHEEL +5 -0
- sortmeout-1.0.0.dist-info/entry_points.txt +3 -0
- sortmeout-1.0.0.dist-info/licenses/LICENSE +21 -0
- sortmeout-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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
|