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,313 @@
1
+ """
2
+ Configuration file management.
3
+
4
+ Handles reading and writing configuration files for SortMeOut.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import shutil
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import Any, Dict, Optional
15
+
16
+ import yaml
17
+ import appdirs
18
+
19
+ from sortmeout.config.settings import Settings
20
+ from sortmeout.utils.logger import get_logger
21
+
22
+ logger = get_logger(__name__)
23
+
24
+ # Application identifiers
25
+ APP_NAME = "SortMeOut"
26
+ APP_AUTHOR = "SortMeOut"
27
+
28
+
29
+ def get_config_directory() -> Path:
30
+ """Get the configuration directory path."""
31
+ config_dir = Path(appdirs.user_config_dir(APP_NAME, APP_AUTHOR))
32
+ config_dir.mkdir(parents=True, exist_ok=True)
33
+ return config_dir
34
+
35
+
36
+ def get_data_directory() -> Path:
37
+ """Get the data directory path."""
38
+ data_dir = Path(appdirs.user_data_dir(APP_NAME, APP_AUTHOR))
39
+ data_dir.mkdir(parents=True, exist_ok=True)
40
+ return data_dir
41
+
42
+
43
+ class ConfigManager:
44
+ """
45
+ Manages configuration file operations.
46
+
47
+ Handles loading, saving, and migrating configuration files.
48
+ Supports both YAML and JSON formats.
49
+ """
50
+
51
+ def __init__(self, config_path: Optional[str] = None):
52
+ """
53
+ Initialize configuration manager.
54
+
55
+ Args:
56
+ config_path: Path to configuration file. If None, uses default.
57
+ """
58
+ if config_path:
59
+ self.config_path = Path(config_path)
60
+ else:
61
+ self.config_path = get_config_directory() / "config.yaml"
62
+
63
+ self.config_dir = self.config_path.parent
64
+ self.config_dir.mkdir(parents=True, exist_ok=True)
65
+
66
+ # Backup directory
67
+ self.backup_dir = self.config_dir / "backups"
68
+ self.backup_dir.mkdir(exist_ok=True)
69
+
70
+ logger.debug("Config path: %s", self.config_path)
71
+
72
+ def load_config(self) -> Dict[str, Any]:
73
+ """
74
+ Load configuration from file.
75
+
76
+ Returns:
77
+ Configuration dictionary.
78
+ """
79
+ if not self.config_path.exists():
80
+ logger.info("No config file found, using defaults")
81
+ return self._get_default_config()
82
+
83
+ try:
84
+ with open(self.config_path, "r") as f:
85
+ if self.config_path.suffix in (".yaml", ".yml"):
86
+ config = yaml.safe_load(f) or {}
87
+ else:
88
+ config = json.load(f)
89
+
90
+ logger.info("Loaded config from: %s", self.config_path)
91
+ return config
92
+
93
+ except Exception as e:
94
+ logger.error("Failed to load config: %s", e)
95
+ return self._get_default_config()
96
+
97
+ def save_config(self, config: Dict[str, Any]) -> bool:
98
+ """
99
+ Save configuration to file.
100
+
101
+ Args:
102
+ config: Configuration dictionary to save.
103
+
104
+ Returns:
105
+ True if save was successful.
106
+ """
107
+ try:
108
+ # Create backup before saving
109
+ if self.config_path.exists():
110
+ self._create_backup()
111
+
112
+ # Add metadata
113
+ config["_metadata"] = {
114
+ "version": "1.0",
115
+ "updated_at": datetime.now().isoformat(),
116
+ "app_version": "0.1.0",
117
+ }
118
+
119
+ with open(self.config_path, "w") as f:
120
+ if self.config_path.suffix in (".yaml", ".yml"):
121
+ yaml.dump(config, f, default_flow_style=False, sort_keys=False)
122
+ else:
123
+ json.dump(config, f, indent=2)
124
+
125
+ logger.info("Saved config to: %s", self.config_path)
126
+ return True
127
+
128
+ except Exception as e:
129
+ logger.error("Failed to save config: %s", e)
130
+ return False
131
+
132
+ def load_settings(self) -> Settings:
133
+ """
134
+ Load application settings.
135
+
136
+ Returns:
137
+ Settings object.
138
+ """
139
+ settings_path = self.config_dir / "settings.yaml"
140
+
141
+ if settings_path.exists():
142
+ try:
143
+ with open(settings_path, "r") as f:
144
+ data = yaml.safe_load(f) or {}
145
+ return Settings.from_dict(data)
146
+ except Exception as e:
147
+ logger.error("Failed to load settings: %s", e)
148
+
149
+ return Settings()
150
+
151
+ def save_settings(self, settings: Settings) -> bool:
152
+ """
153
+ Save application settings.
154
+
155
+ Args:
156
+ settings: Settings object to save.
157
+
158
+ Returns:
159
+ True if save was successful.
160
+ """
161
+ settings_path = self.config_dir / "settings.yaml"
162
+
163
+ try:
164
+ with open(settings_path, "w") as f:
165
+ yaml.dump(settings.to_dict(), f, default_flow_style=False)
166
+ return True
167
+ except Exception as e:
168
+ logger.error("Failed to save settings: %s", e)
169
+ return False
170
+
171
+ def _get_default_config(self) -> Dict[str, Any]:
172
+ """Get default configuration."""
173
+ return {
174
+ "folders": [],
175
+ "_metadata": {
176
+ "version": "1.0",
177
+ "created_at": datetime.now().isoformat(),
178
+ "app_version": "0.1.0",
179
+ },
180
+ }
181
+
182
+ def _create_backup(self) -> None:
183
+ """Create a backup of the current config file."""
184
+ if not self.config_path.exists():
185
+ return
186
+
187
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
188
+ backup_name = f"config_backup_{timestamp}{self.config_path.suffix}"
189
+ backup_path = self.backup_dir / backup_name
190
+
191
+ shutil.copy2(self.config_path, backup_path)
192
+ logger.debug("Created config backup: %s", backup_path)
193
+
194
+ # Keep only last 10 backups
195
+ self._cleanup_old_backups()
196
+
197
+ def _cleanup_old_backups(self, keep: int = 10) -> None:
198
+ """Remove old backup files."""
199
+ backups = sorted(self.backup_dir.glob("config_backup_*"))
200
+
201
+ if len(backups) > keep:
202
+ for backup in backups[:-keep]:
203
+ backup.unlink()
204
+ logger.debug("Removed old backup: %s", backup)
205
+
206
+ def restore_backup(self, backup_name: Optional[str] = None) -> bool:
207
+ """
208
+ Restore configuration from a backup.
209
+
210
+ Args:
211
+ backup_name: Name of backup file. If None, uses most recent.
212
+
213
+ Returns:
214
+ True if restore was successful.
215
+ """
216
+ if backup_name:
217
+ backup_path = self.backup_dir / backup_name
218
+ else:
219
+ backups = sorted(self.backup_dir.glob("config_backup_*"))
220
+ if not backups:
221
+ logger.warning("No backups found")
222
+ return False
223
+ backup_path = backups[-1]
224
+
225
+ if not backup_path.exists():
226
+ logger.error("Backup not found: %s", backup_path)
227
+ return False
228
+
229
+ try:
230
+ shutil.copy2(backup_path, self.config_path)
231
+ logger.info("Restored config from: %s", backup_path)
232
+ return True
233
+ except Exception as e:
234
+ logger.error("Failed to restore backup: %s", e)
235
+ return False
236
+
237
+ def list_backups(self) -> list:
238
+ """List available backup files."""
239
+ return sorted([b.name for b in self.backup_dir.glob("config_backup_*")])
240
+
241
+ def export_config(self, output_path: str, format: str = "yaml") -> bool:
242
+ """
243
+ Export configuration to a file.
244
+
245
+ Args:
246
+ output_path: Path to export to.
247
+ format: Export format (yaml or json).
248
+
249
+ Returns:
250
+ True if export was successful.
251
+ """
252
+ config = self.load_config()
253
+
254
+ try:
255
+ with open(output_path, "w") as f:
256
+ if format == "yaml":
257
+ yaml.dump(config, f, default_flow_style=False)
258
+ else:
259
+ json.dump(config, f, indent=2)
260
+
261
+ logger.info("Exported config to: %s", output_path)
262
+ return True
263
+
264
+ except Exception as e:
265
+ logger.error("Failed to export config: %s", e)
266
+ return False
267
+
268
+ def import_config(self, input_path: str, merge: bool = False) -> bool:
269
+ """
270
+ Import configuration from a file.
271
+
272
+ Args:
273
+ input_path: Path to import from.
274
+ merge: Merge with existing config instead of replacing.
275
+
276
+ Returns:
277
+ True if import was successful.
278
+ """
279
+ try:
280
+ with open(input_path, "r") as f:
281
+ if input_path.endswith((".yaml", ".yml")):
282
+ imported = yaml.safe_load(f) or {}
283
+ else:
284
+ imported = json.load(f)
285
+
286
+ if merge:
287
+ current = self.load_config()
288
+ # Merge folders
289
+ current_folders = {f["path"]: f for f in current.get("folders", [])}
290
+ for folder in imported.get("folders", []):
291
+ current_folders[folder["path"]] = folder
292
+ current["folders"] = list(current_folders.values())
293
+ config = current
294
+ else:
295
+ config = imported
296
+
297
+ return self.save_config(config)
298
+
299
+ except Exception as e:
300
+ logger.error("Failed to import config: %s", e)
301
+ return False
302
+
303
+ def reset_config(self) -> bool:
304
+ """
305
+ Reset configuration to defaults.
306
+
307
+ Creates a backup before resetting.
308
+
309
+ Returns:
310
+ True if reset was successful.
311
+ """
312
+ self._create_backup()
313
+ return self.save_config(self._get_default_config())
@@ -0,0 +1,201 @@
1
+ """
2
+ Application settings.
3
+
4
+ User-configurable settings for SortMeOut behavior.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Dict, List, Optional
11
+
12
+
13
+ @dataclass
14
+ class TrashSettings:
15
+ """Settings for trash management."""
16
+ enabled: bool = False
17
+ max_age_days: int = 30
18
+ max_size_gb: float = 10.0
19
+ app_sweep_enabled: bool = True
20
+ app_sweep_prompt: bool = True # Ask before cleaning app files
21
+
22
+ def to_dict(self) -> Dict[str, Any]:
23
+ return {
24
+ "enabled": self.enabled,
25
+ "max_age_days": self.max_age_days,
26
+ "max_size_gb": self.max_size_gb,
27
+ "app_sweep_enabled": self.app_sweep_enabled,
28
+ "app_sweep_prompt": self.app_sweep_prompt,
29
+ }
30
+
31
+ @classmethod
32
+ def from_dict(cls, data: Dict[str, Any]) -> "TrashSettings":
33
+ return cls(
34
+ enabled=data.get("enabled", False),
35
+ max_age_days=data.get("max_age_days", 30),
36
+ max_size_gb=data.get("max_size_gb", 10.0),
37
+ app_sweep_enabled=data.get("app_sweep_enabled", True),
38
+ app_sweep_prompt=data.get("app_sweep_prompt", True),
39
+ )
40
+
41
+
42
+ @dataclass
43
+ class NotificationSettings:
44
+ """Settings for notifications."""
45
+ enabled: bool = True
46
+ show_rule_matches: bool = False
47
+ show_errors: bool = True
48
+ show_summary: bool = True
49
+ summary_interval_minutes: int = 60
50
+ sound_enabled: bool = True
51
+
52
+ def to_dict(self) -> Dict[str, Any]:
53
+ return {
54
+ "enabled": self.enabled,
55
+ "show_rule_matches": self.show_rule_matches,
56
+ "show_errors": self.show_errors,
57
+ "show_summary": self.show_summary,
58
+ "summary_interval_minutes": self.summary_interval_minutes,
59
+ "sound_enabled": self.sound_enabled,
60
+ }
61
+
62
+ @classmethod
63
+ def from_dict(cls, data: Dict[str, Any]) -> "NotificationSettings":
64
+ return cls(
65
+ enabled=data.get("enabled", True),
66
+ show_rule_matches=data.get("show_rule_matches", False),
67
+ show_errors=data.get("show_errors", True),
68
+ show_summary=data.get("show_summary", True),
69
+ summary_interval_minutes=data.get("summary_interval_minutes", 60),
70
+ sound_enabled=data.get("sound_enabled", True),
71
+ )
72
+
73
+
74
+ @dataclass
75
+ class LoggingSettings:
76
+ """Settings for logging."""
77
+ level: str = "INFO"
78
+ file_logging: bool = True
79
+ max_log_size_mb: int = 10
80
+ backup_count: int = 5
81
+ action_logging: bool = True
82
+
83
+ def to_dict(self) -> Dict[str, Any]:
84
+ return {
85
+ "level": self.level,
86
+ "file_logging": self.file_logging,
87
+ "max_log_size_mb": self.max_log_size_mb,
88
+ "backup_count": self.backup_count,
89
+ "action_logging": self.action_logging,
90
+ }
91
+
92
+ @classmethod
93
+ def from_dict(cls, data: Dict[str, Any]) -> "LoggingSettings":
94
+ return cls(
95
+ level=data.get("level", "INFO"),
96
+ file_logging=data.get("file_logging", True),
97
+ max_log_size_mb=data.get("max_log_size_mb", 10),
98
+ backup_count=data.get("backup_count", 5),
99
+ action_logging=data.get("action_logging", True),
100
+ )
101
+
102
+
103
+ @dataclass
104
+ class WatcherSettings:
105
+ """Settings for file watching."""
106
+ latency_seconds: float = 0.5
107
+ debounce_seconds: float = 0.5
108
+ ignore_hidden_files: bool = True
109
+ ignore_system_files: bool = True
110
+ custom_ignore_patterns: List[str] = field(default_factory=list)
111
+
112
+ def to_dict(self) -> Dict[str, Any]:
113
+ return {
114
+ "latency_seconds": self.latency_seconds,
115
+ "debounce_seconds": self.debounce_seconds,
116
+ "ignore_hidden_files": self.ignore_hidden_files,
117
+ "ignore_system_files": self.ignore_system_files,
118
+ "custom_ignore_patterns": self.custom_ignore_patterns,
119
+ }
120
+
121
+ @classmethod
122
+ def from_dict(cls, data: Dict[str, Any]) -> "WatcherSettings":
123
+ return cls(
124
+ latency_seconds=data.get("latency_seconds", 0.5),
125
+ debounce_seconds=data.get("debounce_seconds", 0.5),
126
+ ignore_hidden_files=data.get("ignore_hidden_files", True),
127
+ ignore_system_files=data.get("ignore_system_files", True),
128
+ custom_ignore_patterns=data.get("custom_ignore_patterns", []),
129
+ )
130
+
131
+
132
+ @dataclass
133
+ class Settings:
134
+ """
135
+ Main application settings.
136
+
137
+ Contains all user-configurable settings for SortMeOut.
138
+ """
139
+ # General
140
+ start_at_login: bool = False
141
+ show_menu_bar_icon: bool = True
142
+ preview_mode: bool = False
143
+ confirm_destructive_actions: bool = True
144
+
145
+ # Subsystems
146
+ trash: TrashSettings = field(default_factory=TrashSettings)
147
+ notifications: NotificationSettings = field(default_factory=NotificationSettings)
148
+ logging: LoggingSettings = field(default_factory=LoggingSettings)
149
+ watcher: WatcherSettings = field(default_factory=WatcherSettings)
150
+
151
+ # UI preferences
152
+ theme: str = "system" # system, light, dark
153
+ language: str = "en"
154
+ show_preview_on_hover: bool = True
155
+
156
+ # Advanced
157
+ max_concurrent_actions: int = 5
158
+ action_timeout_seconds: int = 300
159
+ retry_failed_actions: bool = False
160
+ retry_count: int = 3
161
+
162
+ def to_dict(self) -> Dict[str, Any]:
163
+ """Convert settings to dictionary."""
164
+ return {
165
+ "start_at_login": self.start_at_login,
166
+ "show_menu_bar_icon": self.show_menu_bar_icon,
167
+ "preview_mode": self.preview_mode,
168
+ "confirm_destructive_actions": self.confirm_destructive_actions,
169
+ "trash": self.trash.to_dict(),
170
+ "notifications": self.notifications.to_dict(),
171
+ "logging": self.logging.to_dict(),
172
+ "watcher": self.watcher.to_dict(),
173
+ "theme": self.theme,
174
+ "language": self.language,
175
+ "show_preview_on_hover": self.show_preview_on_hover,
176
+ "max_concurrent_actions": self.max_concurrent_actions,
177
+ "action_timeout_seconds": self.action_timeout_seconds,
178
+ "retry_failed_actions": self.retry_failed_actions,
179
+ "retry_count": self.retry_count,
180
+ }
181
+
182
+ @classmethod
183
+ def from_dict(cls, data: Dict[str, Any]) -> "Settings":
184
+ """Create settings from dictionary."""
185
+ return cls(
186
+ start_at_login=data.get("start_at_login", False),
187
+ show_menu_bar_icon=data.get("show_menu_bar_icon", True),
188
+ preview_mode=data.get("preview_mode", False),
189
+ confirm_destructive_actions=data.get("confirm_destructive_actions", True),
190
+ trash=TrashSettings.from_dict(data.get("trash", {})),
191
+ notifications=NotificationSettings.from_dict(data.get("notifications", {})),
192
+ logging=LoggingSettings.from_dict(data.get("logging", {})),
193
+ watcher=WatcherSettings.from_dict(data.get("watcher", {})),
194
+ theme=data.get("theme", "system"),
195
+ language=data.get("language", "en"),
196
+ show_preview_on_hover=data.get("show_preview_on_hover", True),
197
+ max_concurrent_actions=data.get("max_concurrent_actions", 5),
198
+ action_timeout_seconds=data.get("action_timeout_seconds", 300),
199
+ retry_failed_actions=data.get("retry_failed_actions", False),
200
+ retry_count=data.get("retry_count", 3),
201
+ )
@@ -0,0 +1,21 @@
1
+ """
2
+ Core module for SortMeOut.
3
+
4
+ Contains the fundamental building blocks of the file automation system.
5
+ """
6
+
7
+ from sortmeout.core.rule import Rule
8
+ from sortmeout.core.condition import Condition, ConditionGroup
9
+ from sortmeout.core.action import Action
10
+ from sortmeout.core.watcher import FolderWatcher, WatcherManager
11
+ from sortmeout.core.engine import RuleEngine
12
+
13
+ __all__ = [
14
+ "Rule",
15
+ "Condition",
16
+ "ConditionGroup",
17
+ "Action",
18
+ "FolderWatcher",
19
+ "WatcherManager",
20
+ "RuleEngine",
21
+ ]