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 ADDED
@@ -0,0 +1,23 @@
1
+ """
2
+ SortMeOut - Open-source file automation and organization tool for macOS.
3
+
4
+ A powerful, rule-based file organization system inspired by Noodlesoft Hazel.
5
+ """
6
+
7
+ __version__ = "1.0.0"
8
+ __author__ = "SortMeOut Contributors"
9
+ __license__ = "MIT"
10
+
11
+ from sortmeout.app import SortMeOut
12
+ from sortmeout.core.rule import Rule
13
+ from sortmeout.core.condition import Condition, ConditionGroup
14
+ from sortmeout.core.action import Action
15
+
16
+ __all__ = [
17
+ "SortMeOut",
18
+ "Rule",
19
+ "Condition",
20
+ "ConditionGroup",
21
+ "Action",
22
+ "__version__",
23
+ ]
sortmeout/app.py ADDED
@@ -0,0 +1,618 @@
1
+ """
2
+ Main SortMeOut application class.
3
+
4
+ This module provides the main entry point for the SortMeOut application,
5
+ managing folder watches, rules, and the overall lifecycle of the app.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import os
12
+ import signal
13
+ import sys
14
+ import threading
15
+ from pathlib import Path
16
+ from typing import Dict, List, Optional, Callable, Any
17
+ from datetime import datetime
18
+
19
+ from sortmeout.core.watcher import FolderWatcher, WatcherManager
20
+ from sortmeout.core.rule import Rule
21
+ from sortmeout.core.engine import RuleEngine
22
+ from sortmeout.config.manager import ConfigManager
23
+ from sortmeout.config.settings import Settings
24
+ from sortmeout.utils.logger import setup_logging, get_logger
25
+
26
+ logger = get_logger(__name__)
27
+
28
+
29
+ class SortMeOut:
30
+ """
31
+ Main application class for SortMeOut file automation.
32
+
33
+ This class manages:
34
+ - Folder watchers for monitoring file system changes
35
+ - Rules and their associations with folders
36
+ - Configuration persistence
37
+ - Application lifecycle
38
+
39
+ Example:
40
+ >>> app = SortMeOut()
41
+ >>> app.add_folder("~/Downloads")
42
+ >>> rule = Rule(name="PDF Organizer", ...)
43
+ >>> app.add_rule("~/Downloads", rule)
44
+ >>> app.start()
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ config_path: Optional[str] = None,
50
+ preview_mode: bool = False,
51
+ verbose: bool = False,
52
+ ):
53
+ """
54
+ Initialize SortMeOut application.
55
+
56
+ Args:
57
+ config_path: Path to configuration file. If None, uses default location.
58
+ preview_mode: If True, rules are evaluated but actions are not executed.
59
+ verbose: Enable verbose logging.
60
+ """
61
+ self._preview_mode = preview_mode
62
+ self._verbose = verbose
63
+ self._running = False
64
+ self._lock = threading.RLock()
65
+
66
+ # Set up logging
67
+ log_level = logging.DEBUG if verbose else logging.INFO
68
+ setup_logging(level=log_level)
69
+
70
+ # Initialize components
71
+ self._config_manager = ConfigManager(config_path)
72
+ self._settings = self._config_manager.load_settings()
73
+ self._watcher_manager = WatcherManager()
74
+ self._rule_engine = RuleEngine(preview_mode=preview_mode)
75
+
76
+ # Folder -> Rules mapping
77
+ self._folder_rules: Dict[str, List[Rule]] = {}
78
+
79
+ # Event callbacks
80
+ self._callbacks: Dict[str, List[Callable[..., Any]]] = {
81
+ "rule_matched": [],
82
+ "action_executed": [],
83
+ "error": [],
84
+ "file_processed": [],
85
+ }
86
+
87
+ # Statistics
88
+ self._stats = {
89
+ "files_processed": 0,
90
+ "rules_matched": 0,
91
+ "actions_executed": 0,
92
+ "errors": 0,
93
+ "start_time": None,
94
+ }
95
+
96
+ # Load saved configuration
97
+ self._load_saved_config()
98
+
99
+ logger.info("SortMeOut initialized (preview_mode=%s)", preview_mode)
100
+
101
+ def _load_saved_config(self) -> None:
102
+ """Load folders and rules from saved configuration."""
103
+ config = self._config_manager.load_config()
104
+
105
+ for folder_config in config.get("folders", []):
106
+ folder_path = folder_config["path"]
107
+ self._folder_rules[folder_path] = []
108
+
109
+ for rule_dict in folder_config.get("rules", []):
110
+ rule = Rule.from_dict(rule_dict)
111
+ self._folder_rules[folder_path].append(rule)
112
+
113
+ logger.debug("Loaded folder: %s with %d rules",
114
+ folder_path, len(self._folder_rules[folder_path]))
115
+
116
+ def add_folder(
117
+ self,
118
+ path: str,
119
+ recursive: bool = False,
120
+ enabled: bool = True,
121
+ ) -> bool:
122
+ """
123
+ Add a folder to watch for file changes.
124
+
125
+ Args:
126
+ path: Path to the folder to watch.
127
+ recursive: Watch subdirectories as well.
128
+ enabled: Whether watching is enabled.
129
+
130
+ Returns:
131
+ True if folder was added successfully.
132
+
133
+ Raises:
134
+ ValueError: If path is not a valid directory.
135
+ """
136
+ # Expand user path
137
+ expanded_path = os.path.expanduser(path)
138
+ resolved_path = str(Path(expanded_path).resolve())
139
+
140
+ if not os.path.isdir(resolved_path):
141
+ raise ValueError(f"Path is not a valid directory: {path}")
142
+
143
+ with self._lock:
144
+ if resolved_path in self._folder_rules:
145
+ logger.warning("Folder already being watched: %s", resolved_path)
146
+ return False
147
+
148
+ self._folder_rules[resolved_path] = []
149
+
150
+ if self._running and enabled:
151
+ self._watcher_manager.add_watch(
152
+ resolved_path,
153
+ callback=self._on_file_event,
154
+ recursive=recursive,
155
+ )
156
+
157
+ logger.info("Added folder: %s (recursive=%s)", resolved_path, recursive)
158
+ self._save_config()
159
+ return True
160
+
161
+ def remove_folder(self, path: str) -> bool:
162
+ """
163
+ Remove a folder from watching.
164
+
165
+ Args:
166
+ path: Path to the folder to remove.
167
+
168
+ Returns:
169
+ True if folder was removed successfully.
170
+ """
171
+ expanded_path = os.path.expanduser(path)
172
+ resolved_path = str(Path(expanded_path).resolve())
173
+
174
+ with self._lock:
175
+ if resolved_path not in self._folder_rules:
176
+ logger.warning("Folder not found: %s", resolved_path)
177
+ return False
178
+
179
+ del self._folder_rules[resolved_path]
180
+
181
+ if self._running:
182
+ self._watcher_manager.remove_watch(resolved_path)
183
+
184
+ logger.info("Removed folder: %s", resolved_path)
185
+ self._save_config()
186
+ return True
187
+
188
+ def get_folders(self) -> List[str]:
189
+ """Get list of all watched folders."""
190
+ with self._lock:
191
+ return list(self._folder_rules.keys())
192
+
193
+ def add_rule(self, folder_path: str, rule: Rule) -> bool:
194
+ """
195
+ Add a rule to a watched folder.
196
+
197
+ Args:
198
+ folder_path: Path to the folder.
199
+ rule: Rule to add.
200
+
201
+ Returns:
202
+ True if rule was added successfully.
203
+ """
204
+ expanded_path = os.path.expanduser(folder_path)
205
+ resolved_path = str(Path(expanded_path).resolve())
206
+
207
+ with self._lock:
208
+ if resolved_path not in self._folder_rules:
209
+ raise ValueError(f"Folder not being watched: {folder_path}")
210
+
211
+ # Check for duplicate rule names
212
+ existing_names = {r.name for r in self._folder_rules[resolved_path]}
213
+ if rule.name in existing_names:
214
+ logger.warning("Rule with name '%s' already exists", rule.name)
215
+ return False
216
+
217
+ self._folder_rules[resolved_path].append(rule)
218
+ logger.info("Added rule '%s' to folder: %s", rule.name, resolved_path)
219
+ self._save_config()
220
+ return True
221
+
222
+ def remove_rule(self, folder_path: str, rule_name: str) -> bool:
223
+ """
224
+ Remove a rule from a folder.
225
+
226
+ Args:
227
+ folder_path: Path to the folder.
228
+ rule_name: Name of the rule to remove.
229
+
230
+ Returns:
231
+ True if rule was removed successfully.
232
+ """
233
+ expanded_path = os.path.expanduser(folder_path)
234
+ resolved_path = str(Path(expanded_path).resolve())
235
+
236
+ with self._lock:
237
+ if resolved_path not in self._folder_rules:
238
+ return False
239
+
240
+ rules = self._folder_rules[resolved_path]
241
+ for i, rule in enumerate(rules):
242
+ if rule.name == rule_name:
243
+ del rules[i]
244
+ logger.info("Removed rule '%s' from folder: %s", rule_name, resolved_path)
245
+ self._save_config()
246
+ return True
247
+
248
+ return False
249
+
250
+ def get_rules(self, folder_path: str) -> List[Rule]:
251
+ """
252
+ Get all rules for a folder.
253
+
254
+ Args:
255
+ folder_path: Path to the folder.
256
+
257
+ Returns:
258
+ List of rules for the folder.
259
+ """
260
+ expanded_path = os.path.expanduser(folder_path)
261
+ resolved_path = str(Path(expanded_path).resolve())
262
+
263
+ with self._lock:
264
+ return list(self._folder_rules.get(resolved_path, []))
265
+
266
+ def update_rule(self, folder_path: str, rule_name: str, updated_rule: Rule) -> bool:
267
+ """
268
+ Update an existing rule.
269
+
270
+ Args:
271
+ folder_path: Path to the folder.
272
+ rule_name: Name of the rule to update.
273
+ updated_rule: The updated rule.
274
+
275
+ Returns:
276
+ True if rule was updated successfully.
277
+ """
278
+ expanded_path = os.path.expanduser(folder_path)
279
+ resolved_path = str(Path(expanded_path).resolve())
280
+
281
+ with self._lock:
282
+ if resolved_path not in self._folder_rules:
283
+ return False
284
+
285
+ rules = self._folder_rules[resolved_path]
286
+ for i, rule in enumerate(rules):
287
+ if rule.name == rule_name:
288
+ rules[i] = updated_rule
289
+ logger.info("Updated rule '%s' in folder: %s", rule_name, resolved_path)
290
+ self._save_config()
291
+ return True
292
+
293
+ return False
294
+
295
+ def reorder_rules(self, folder_path: str, rule_names: List[str]) -> bool:
296
+ """
297
+ Reorder rules for a folder.
298
+
299
+ Args:
300
+ folder_path: Path to the folder.
301
+ rule_names: List of rule names in desired order.
302
+
303
+ Returns:
304
+ True if rules were reordered successfully.
305
+ """
306
+ expanded_path = os.path.expanduser(folder_path)
307
+ resolved_path = str(Path(expanded_path).resolve())
308
+
309
+ with self._lock:
310
+ if resolved_path not in self._folder_rules:
311
+ return False
312
+
313
+ rules = self._folder_rules[resolved_path]
314
+ rule_map = {r.name: r for r in rules}
315
+
316
+ if set(rule_names) != set(rule_map.keys()):
317
+ logger.error("Rule names don't match existing rules")
318
+ return False
319
+
320
+ self._folder_rules[resolved_path] = [rule_map[name] for name in rule_names]
321
+ logger.info("Reordered rules in folder: %s", resolved_path)
322
+ self._save_config()
323
+ return True
324
+
325
+ def _on_file_event(self, event_type: str, file_path: str, folder_path: str) -> None:
326
+ """
327
+ Handle file system events.
328
+
329
+ Args:
330
+ event_type: Type of event (created, modified, moved, deleted).
331
+ file_path: Path to the affected file.
332
+ folder_path: Path to the watched folder.
333
+ """
334
+ # Skip certain files
335
+ if self._should_skip_file(file_path):
336
+ return
337
+
338
+ logger.debug("File event: %s - %s", event_type, file_path)
339
+
340
+ # Process file with rules
341
+ if event_type in ("created", "modified"):
342
+ self._process_file(file_path, folder_path)
343
+
344
+ def _should_skip_file(self, file_path: str) -> bool:
345
+ """Check if a file should be skipped from processing."""
346
+ path = Path(file_path)
347
+
348
+ # Skip hidden files (starting with .)
349
+ if path.name.startswith("."):
350
+ return True
351
+
352
+ # Skip temporary files
353
+ temp_extensions = {".tmp", ".temp", ".part", ".crdownload", ".download"}
354
+ if path.suffix.lower() in temp_extensions:
355
+ return True
356
+
357
+ # Skip directories
358
+ if path.is_dir():
359
+ return True
360
+
361
+ return False
362
+
363
+ def _process_file(self, file_path: str, folder_path: str) -> None:
364
+ """
365
+ Process a file against all rules for its folder.
366
+
367
+ Args:
368
+ file_path: Path to the file to process.
369
+ folder_path: Path to the watched folder.
370
+ """
371
+ with self._lock:
372
+ rules = self._folder_rules.get(folder_path, [])
373
+
374
+ if not rules:
375
+ return
376
+
377
+ # Process through rule engine
378
+ result = self._rule_engine.process_file(file_path, rules)
379
+
380
+ # Update statistics
381
+ self._stats["files_processed"] += 1
382
+ if result.matched_rules:
383
+ self._stats["rules_matched"] += len(result.matched_rules)
384
+ if result.executed_actions:
385
+ self._stats["actions_executed"] += len(result.executed_actions)
386
+ if result.errors:
387
+ self._stats["errors"] += len(result.errors)
388
+
389
+ # Fire callbacks
390
+ for rule_name in result.matched_rules:
391
+ self._fire_callback("rule_matched", file_path, rule_name)
392
+
393
+ for action_result in result.executed_actions:
394
+ self._fire_callback("action_executed", file_path, action_result)
395
+
396
+ for error in result.errors:
397
+ self._fire_callback("error", file_path, error)
398
+
399
+ self._fire_callback("file_processed", file_path, result)
400
+
401
+ def process_folder(self, folder_path: str, force: bool = False) -> Dict[str, Any]:
402
+ """
403
+ Process all existing files in a folder.
404
+
405
+ Args:
406
+ folder_path: Path to the folder to process.
407
+ force: Process files even if already processed.
408
+
409
+ Returns:
410
+ Dictionary with processing results.
411
+ """
412
+ expanded_path = os.path.expanduser(folder_path)
413
+ resolved_path = str(Path(expanded_path).resolve())
414
+
415
+ if resolved_path not in self._folder_rules:
416
+ raise ValueError(f"Folder not being watched: {folder_path}")
417
+
418
+ results = {
419
+ "processed": 0,
420
+ "matched": 0,
421
+ "errors": 0,
422
+ "files": [],
423
+ }
424
+
425
+ for entry in os.scandir(resolved_path):
426
+ if entry.is_file() and not self._should_skip_file(entry.path):
427
+ self._process_file(entry.path, resolved_path)
428
+ results["processed"] += 1
429
+ results["files"].append(entry.path)
430
+
431
+ logger.info("Processed %d files in folder: %s", results["processed"], resolved_path)
432
+ return results
433
+
434
+ def on(self, event: str, callback: Callable[..., Any]) -> None:
435
+ """
436
+ Register a callback for an event.
437
+
438
+ Args:
439
+ event: Event name (rule_matched, action_executed, error, file_processed).
440
+ callback: Callback function.
441
+ """
442
+ if event not in self._callbacks:
443
+ raise ValueError(f"Unknown event: {event}")
444
+
445
+ self._callbacks[event].append(callback)
446
+
447
+ def off(self, event: str, callback: Callable[..., Any]) -> None:
448
+ """
449
+ Unregister a callback for an event.
450
+
451
+ Args:
452
+ event: Event name.
453
+ callback: Callback function to remove.
454
+ """
455
+ if event in self._callbacks and callback in self._callbacks[event]:
456
+ self._callbacks[event].remove(callback)
457
+
458
+ def _fire_callback(self, event: str, *args: Any) -> None:
459
+ """Fire all callbacks for an event."""
460
+ for callback in self._callbacks.get(event, []):
461
+ try:
462
+ callback(*args)
463
+ except Exception as e:
464
+ logger.error("Callback error for event '%s': %s", event, e)
465
+
466
+ def _save_config(self) -> None:
467
+ """Save current configuration to file."""
468
+ config = {
469
+ "folders": [
470
+ {
471
+ "path": folder_path,
472
+ "rules": [rule.to_dict() for rule in rules],
473
+ }
474
+ for folder_path, rules in self._folder_rules.items()
475
+ ]
476
+ }
477
+ self._config_manager.save_config(config)
478
+
479
+ def start(self) -> None:
480
+ """
481
+ Start watching all folders.
482
+
483
+ This method starts the file system watchers for all configured folders.
484
+ It blocks until stop() is called or a signal is received.
485
+ """
486
+ if self._running:
487
+ logger.warning("Already running")
488
+ return
489
+
490
+ self._running = True
491
+ self._stats["start_time"] = datetime.now()
492
+
493
+ # Set up signal handlers
494
+ signal.signal(signal.SIGINT, self._signal_handler)
495
+ signal.signal(signal.SIGTERM, self._signal_handler)
496
+
497
+ # Start watchers for all folders
498
+ for folder_path in self._folder_rules:
499
+ self._watcher_manager.add_watch(
500
+ folder_path,
501
+ callback=self._on_file_event,
502
+ recursive=False,
503
+ )
504
+
505
+ logger.info("SortMeOut started, watching %d folders", len(self._folder_rules))
506
+
507
+ # Start the watcher manager
508
+ self._watcher_manager.start()
509
+
510
+ def start_background(self) -> threading.Thread:
511
+ """
512
+ Start watching in a background thread.
513
+
514
+ Returns:
515
+ The background thread.
516
+ """
517
+ thread = threading.Thread(target=self.start, daemon=True)
518
+ thread.start()
519
+ return thread
520
+
521
+ def stop(self) -> None:
522
+ """Stop watching all folders."""
523
+ if not self._running:
524
+ return
525
+
526
+ self._running = False
527
+ self._watcher_manager.stop()
528
+
529
+ logger.info("SortMeOut stopped")
530
+
531
+ def _signal_handler(self, signum: int, frame: Any) -> None:
532
+ """Handle shutdown signals."""
533
+ logger.info("Received signal %d, shutting down...", signum)
534
+ self.stop()
535
+ sys.exit(0)
536
+
537
+ def get_stats(self) -> Dict[str, Any]:
538
+ """Get current statistics."""
539
+ stats = self._stats.copy()
540
+ if stats["start_time"]:
541
+ stats["uptime"] = str(datetime.now() - stats["start_time"])
542
+ return stats
543
+
544
+ @property
545
+ def is_running(self) -> bool:
546
+ """Check if the application is running."""
547
+ return self._running
548
+
549
+ @property
550
+ def preview_mode(self) -> bool:
551
+ """Check if preview mode is enabled."""
552
+ return self._preview_mode
553
+
554
+ @preview_mode.setter
555
+ def preview_mode(self, value: bool) -> None:
556
+ """Set preview mode."""
557
+ self._preview_mode = value
558
+ self._rule_engine.preview_mode = value
559
+
560
+ def export_rules(self, folder_path: str, output_path: str) -> bool:
561
+ """
562
+ Export rules for a folder to a file.
563
+
564
+ Args:
565
+ folder_path: Path to the folder.
566
+ output_path: Path to export to.
567
+
568
+ Returns:
569
+ True if export was successful.
570
+ """
571
+ import json
572
+
573
+ rules = self.get_rules(folder_path)
574
+ if not rules:
575
+ return False
576
+
577
+ export_data = {
578
+ "version": "1.0",
579
+ "folder": folder_path,
580
+ "rules": [rule.to_dict() for rule in rules],
581
+ }
582
+
583
+ with open(output_path, "w") as f:
584
+ json.dump(export_data, f, indent=2)
585
+
586
+ logger.info("Exported %d rules to: %s", len(rules), output_path)
587
+ return True
588
+
589
+ def import_rules(self, folder_path: str, input_path: str) -> int:
590
+ """
591
+ Import rules from a file to a folder.
592
+
593
+ Args:
594
+ folder_path: Path to the folder.
595
+ input_path: Path to import from.
596
+
597
+ Returns:
598
+ Number of rules imported.
599
+ """
600
+ import json
601
+
602
+ expanded_path = os.path.expanduser(folder_path)
603
+ resolved_path = str(Path(expanded_path).resolve())
604
+
605
+ if resolved_path not in self._folder_rules:
606
+ raise ValueError(f"Folder not being watched: {folder_path}")
607
+
608
+ with open(input_path, "r") as f:
609
+ import_data = json.load(f)
610
+
611
+ imported = 0
612
+ for rule_dict in import_data.get("rules", []):
613
+ rule = Rule.from_dict(rule_dict)
614
+ if self.add_rule(folder_path, rule):
615
+ imported += 1
616
+
617
+ logger.info("Imported %d rules from: %s", imported, input_path)
618
+ return imported