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,421 @@
1
+ """
2
+ Rule execution engine.
3
+
4
+ The engine processes files against rules, evaluates conditions,
5
+ and executes actions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import mimetypes
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional
16
+ import logging
17
+
18
+ from sortmeout.core.rule import Rule
19
+ from sortmeout.core.action import Action, ActionResult
20
+ from sortmeout.utils.logger import get_logger
21
+ from sortmeout.utils.file_info import get_file_info
22
+
23
+ logger = get_logger(__name__)
24
+
25
+
26
+ @dataclass
27
+ class ProcessingResult:
28
+ """
29
+ Result of processing a file through rules.
30
+
31
+ Attributes:
32
+ file_path: Path to the processed file.
33
+ matched_rules: Names of rules that matched.
34
+ executed_actions: Results of executed actions.
35
+ errors: Any errors that occurred.
36
+ processing_time: Time taken to process.
37
+ """
38
+ file_path: str
39
+ matched_rules: List[str] = field(default_factory=list)
40
+ executed_actions: List[ActionResult] = field(default_factory=list)
41
+ errors: List[str] = field(default_factory=list)
42
+ processing_time: float = 0.0
43
+ stopped: bool = False # Processing was stopped by a rule
44
+
45
+ @property
46
+ def success(self) -> bool:
47
+ """Check if processing was successful (no errors)."""
48
+ return len(self.errors) == 0
49
+
50
+ def __str__(self) -> str:
51
+ return (
52
+ f"ProcessingResult(file={Path(self.file_path).name}, "
53
+ f"matched={len(self.matched_rules)}, "
54
+ f"actions={len(self.executed_actions)}, "
55
+ f"errors={len(self.errors)})"
56
+ )
57
+
58
+
59
+ class RuleEngine:
60
+ """
61
+ Engine for processing files against rules.
62
+
63
+ The engine:
64
+ 1. Gathers file information
65
+ 2. Evaluates each rule's conditions
66
+ 3. Executes actions for matching rules
67
+ 4. Handles errors and continues/stops as configured
68
+
69
+ Attributes:
70
+ preview_mode: If True, actions are not actually executed.
71
+ stop_on_error: If True, stop processing on first error.
72
+ """
73
+
74
+ def __init__(
75
+ self,
76
+ preview_mode: bool = False,
77
+ stop_on_error: bool = False,
78
+ max_rules_per_file: int = 100,
79
+ ):
80
+ """
81
+ Initialize the rule engine.
82
+
83
+ Args:
84
+ preview_mode: Don't actually execute actions.
85
+ stop_on_error: Stop on first error.
86
+ max_rules_per_file: Maximum rules to process per file (safety limit).
87
+ """
88
+ self.preview_mode = preview_mode
89
+ self.stop_on_error = stop_on_error
90
+ self.max_rules_per_file = max_rules_per_file
91
+
92
+ # Statistics
93
+ self._stats = {
94
+ "files_processed": 0,
95
+ "rules_evaluated": 0,
96
+ "rules_matched": 0,
97
+ "actions_executed": 0,
98
+ "errors": 0,
99
+ }
100
+
101
+ def process_file(
102
+ self,
103
+ file_path: str,
104
+ rules: List[Rule],
105
+ file_info: Optional[Dict[str, Any]] = None,
106
+ ) -> ProcessingResult:
107
+ """
108
+ Process a file against a list of rules.
109
+
110
+ Args:
111
+ file_path: Path to the file to process.
112
+ rules: List of rules to evaluate.
113
+ file_info: Pre-computed file information (optional).
114
+
115
+ Returns:
116
+ ProcessingResult with matched rules and action results.
117
+ """
118
+ start_time = datetime.now()
119
+ result = ProcessingResult(file_path=file_path)
120
+
121
+ # Check if file still exists
122
+ if not os.path.exists(file_path):
123
+ result.errors.append(f"File does not exist: {file_path}")
124
+ return result
125
+
126
+ # Get file information
127
+ if file_info is None:
128
+ try:
129
+ file_info = get_file_info(file_path)
130
+ except Exception as e:
131
+ result.errors.append(f"Failed to get file info: {e}")
132
+ return result
133
+
134
+ logger.debug("Processing file: %s", file_path)
135
+ logger.debug("File info: %s", file_info)
136
+
137
+ # Track current file path (may change during processing)
138
+ current_path = file_path
139
+ rules_processed = 0
140
+
141
+ # Process each rule
142
+ for rule in rules:
143
+ if rules_processed >= self.max_rules_per_file:
144
+ logger.warning("Max rules limit reached for file: %s", file_path)
145
+ break
146
+
147
+ rules_processed += 1
148
+ self._stats["rules_evaluated"] += 1
149
+
150
+ # Skip disabled rules
151
+ if not rule.enabled:
152
+ continue
153
+
154
+ # Check if rule matches
155
+ try:
156
+ matches = rule.matches(file_info)
157
+ except Exception as e:
158
+ logger.error("Error evaluating rule '%s': %s", rule.name, e)
159
+ result.errors.append(f"Rule '{rule.name}' evaluation error: {e}")
160
+ if self.stop_on_error:
161
+ break
162
+ continue
163
+
164
+ if not matches:
165
+ continue
166
+
167
+ # Rule matched!
168
+ logger.info("Rule '%s' matched file: %s", rule.name, current_path)
169
+ result.matched_rules.append(rule.name)
170
+ self._stats["rules_matched"] += 1
171
+
172
+ # Execute actions
173
+ action_results, new_path, stop = self._execute_actions(
174
+ rule.actions,
175
+ current_path,
176
+ file_info,
177
+ )
178
+
179
+ result.executed_actions.extend(action_results)
180
+
181
+ # Check for action errors
182
+ for ar in action_results:
183
+ if not ar.success:
184
+ result.errors.append(f"Action failed: {ar.error}")
185
+ self._stats["errors"] += 1
186
+
187
+ # Update current path if file was moved/renamed
188
+ if new_path and new_path != current_path:
189
+ current_path = new_path
190
+ # Update file_info for subsequent rules
191
+ try:
192
+ file_info = get_file_info(current_path)
193
+ except:
194
+ pass
195
+
196
+ # Check if we should stop processing
197
+ if stop or not rule.continue_processing:
198
+ result.stopped = True
199
+ break
200
+
201
+ # Stop on error if configured
202
+ if result.errors and self.stop_on_error:
203
+ break
204
+
205
+ # Calculate processing time
206
+ result.processing_time = (datetime.now() - start_time).total_seconds()
207
+ self._stats["files_processed"] += 1
208
+
209
+ logger.debug("Finished processing: %s", result)
210
+ return result
211
+
212
+ def _execute_actions(
213
+ self,
214
+ actions: List[Action],
215
+ file_path: str,
216
+ file_info: Dict[str, Any],
217
+ ) -> tuple[List[ActionResult], Optional[str], bool]:
218
+ """
219
+ Execute a list of actions on a file.
220
+
221
+ Args:
222
+ actions: Actions to execute.
223
+ file_path: Current file path.
224
+ file_info: File information.
225
+
226
+ Returns:
227
+ Tuple of (action_results, new_path, should_stop).
228
+ """
229
+ results = []
230
+ current_path = file_path
231
+ should_stop = False
232
+
233
+ for action in actions:
234
+ if not action.enabled:
235
+ continue
236
+
237
+ # Check if file still exists (may have been deleted by previous action)
238
+ if not os.path.exists(current_path):
239
+ logger.warning("File no longer exists, stopping actions: %s", current_path)
240
+ break
241
+
242
+ # Execute the action
243
+ try:
244
+ result = action.execute(
245
+ current_path,
246
+ file_info,
247
+ preview=self.preview_mode,
248
+ )
249
+ results.append(result)
250
+ self._stats["actions_executed"] += 1
251
+
252
+ # Update path if action moved/renamed the file
253
+ if result.success and result.destination_path:
254
+ current_path = result.destination_path
255
+
256
+ # Stop on error if action is configured to do so
257
+ if not result.success and action.stop_on_error:
258
+ logger.warning("Action failed, stopping: %s", result.error)
259
+ should_stop = True
260
+ break
261
+
262
+ except Exception as e:
263
+ logger.error("Action execution error: %s", e)
264
+ results.append(ActionResult(
265
+ success=False,
266
+ action_type=action.action_type,
267
+ source_path=current_path,
268
+ error=str(e),
269
+ ))
270
+
271
+ if action.stop_on_error:
272
+ should_stop = True
273
+ break
274
+
275
+ return results, current_path if current_path != file_path else None, should_stop
276
+
277
+ def evaluate_rule(
278
+ self,
279
+ rule: Rule,
280
+ file_path: str,
281
+ file_info: Optional[Dict[str, Any]] = None,
282
+ ) -> bool:
283
+ """
284
+ Evaluate a single rule against a file without executing actions.
285
+
286
+ Args:
287
+ rule: Rule to evaluate.
288
+ file_path: Path to the file.
289
+ file_info: Pre-computed file information.
290
+
291
+ Returns:
292
+ True if the rule matches.
293
+ """
294
+ if file_info is None:
295
+ file_info = get_file_info(file_path)
296
+
297
+ return rule.matches(file_info)
298
+
299
+ def preview_rule(
300
+ self,
301
+ rule: Rule,
302
+ file_path: str,
303
+ file_info: Optional[Dict[str, Any]] = None,
304
+ ) -> List[ActionResult]:
305
+ """
306
+ Preview what actions a rule would perform on a file.
307
+
308
+ Args:
309
+ rule: Rule to preview.
310
+ file_path: Path to the file.
311
+ file_info: Pre-computed file information.
312
+
313
+ Returns:
314
+ List of ActionResults (with preview=True).
315
+ """
316
+ if file_info is None:
317
+ file_info = get_file_info(file_path)
318
+
319
+ if not rule.matches(file_info):
320
+ return []
321
+
322
+ results = []
323
+ for action in rule.actions:
324
+ if action.enabled:
325
+ result = action.execute(file_path, file_info, preview=True)
326
+ results.append(result)
327
+
328
+ return results
329
+
330
+ def get_stats(self) -> Dict[str, int]:
331
+ """Get engine statistics."""
332
+ return self._stats.copy()
333
+
334
+ def reset_stats(self) -> None:
335
+ """Reset engine statistics."""
336
+ self._stats = {
337
+ "files_processed": 0,
338
+ "rules_evaluated": 0,
339
+ "rules_matched": 0,
340
+ "actions_executed": 0,
341
+ "errors": 0,
342
+ }
343
+
344
+
345
+ class BatchProcessor:
346
+ """
347
+ Process multiple files in batch.
348
+
349
+ Useful for processing existing files in a folder when rules are first added.
350
+ """
351
+
352
+ def __init__(self, engine: RuleEngine):
353
+ """
354
+ Initialize batch processor.
355
+
356
+ Args:
357
+ engine: Rule engine to use for processing.
358
+ """
359
+ self.engine = engine
360
+
361
+ def process_folder(
362
+ self,
363
+ folder_path: str,
364
+ rules: List[Rule],
365
+ recursive: bool = False,
366
+ file_filter: Optional[callable] = None,
367
+ ) -> List[ProcessingResult]:
368
+ """
369
+ Process all files in a folder.
370
+
371
+ Args:
372
+ folder_path: Path to the folder.
373
+ rules: Rules to apply.
374
+ recursive: Process subdirectories.
375
+ file_filter: Optional function to filter files.
376
+
377
+ Returns:
378
+ List of ProcessingResults.
379
+ """
380
+ results = []
381
+ folder = Path(folder_path)
382
+
383
+ if recursive:
384
+ files = folder.rglob("*")
385
+ else:
386
+ files = folder.glob("*")
387
+
388
+ for path in files:
389
+ if not path.is_file():
390
+ continue
391
+
392
+ if file_filter and not file_filter(str(path)):
393
+ continue
394
+
395
+ result = self.engine.process_file(str(path), rules)
396
+ results.append(result)
397
+
398
+ return results
399
+
400
+ def process_files(
401
+ self,
402
+ file_paths: List[str],
403
+ rules: List[Rule],
404
+ ) -> List[ProcessingResult]:
405
+ """
406
+ Process a list of specific files.
407
+
408
+ Args:
409
+ file_paths: Paths to files.
410
+ rules: Rules to apply.
411
+
412
+ Returns:
413
+ List of ProcessingResults.
414
+ """
415
+ results = []
416
+
417
+ for file_path in file_paths:
418
+ result = self.engine.process_file(file_path, rules)
419
+ results.append(result)
420
+
421
+ return results
sortmeout/core/rule.py ADDED
@@ -0,0 +1,254 @@
1
+ """
2
+ Rule definition and management.
3
+
4
+ Rules are the core concept in SortMeOut. Each rule consists of:
5
+ - Conditions: Criteria that a file must match
6
+ - Actions: Operations to perform on matching files
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import uuid
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime
14
+ from typing import Any, Dict, List, Optional
15
+ from enum import Enum
16
+
17
+ from sortmeout.core.condition import Condition, ConditionGroup
18
+ from sortmeout.core.action import Action
19
+
20
+
21
+ class RuleMatchMode(Enum):
22
+ """How conditions are combined when matching."""
23
+ ALL = "all" # All conditions must match (AND)
24
+ ANY = "any" # Any condition must match (OR)
25
+ NONE = "none" # No conditions must match (NOT)
26
+
27
+
28
+ @dataclass
29
+ class Rule:
30
+ """
31
+ A rule that defines conditions and actions for file processing.
32
+
33
+ Rules are evaluated in order. When a file matches a rule's conditions,
34
+ the rule's actions are executed. By default, processing stops after
35
+ the first matching rule, but this can be changed with continue_processing.
36
+
37
+ Attributes:
38
+ name: Human-readable name for the rule.
39
+ conditions: List of conditions that must be satisfied.
40
+ actions: List of actions to perform on matching files.
41
+ enabled: Whether the rule is active.
42
+ match_mode: How conditions are combined (all, any, none).
43
+ continue_processing: Continue with next rules after match.
44
+ run_on_folder_open: Run rule when folder is first opened.
45
+ id: Unique identifier for the rule.
46
+ created_at: When the rule was created.
47
+ updated_at: When the rule was last updated.
48
+ description: Optional description of the rule.
49
+
50
+ Example:
51
+ >>> rule = Rule(
52
+ ... name="Organize PDFs",
53
+ ... conditions=[
54
+ ... Condition("extension", "equals", "pdf"),
55
+ ... Condition("size", "greater_than", "1MB"),
56
+ ... ],
57
+ ... actions=[
58
+ ... Action("move", destination="~/Documents/PDFs"),
59
+ ... ]
60
+ ... )
61
+ """
62
+
63
+ name: str
64
+ conditions: List[Condition | ConditionGroup] = field(default_factory=list)
65
+ actions: List[Action] = field(default_factory=list)
66
+ enabled: bool = True
67
+ match_mode: RuleMatchMode = RuleMatchMode.ALL
68
+ continue_processing: bool = False
69
+ run_on_folder_open: bool = True
70
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
71
+ created_at: datetime = field(default_factory=datetime.now)
72
+ updated_at: datetime = field(default_factory=datetime.now)
73
+ description: Optional[str] = None
74
+
75
+ def __post_init__(self) -> None:
76
+ """Validate rule after initialization."""
77
+ if not self.name:
78
+ raise ValueError("Rule name cannot be empty")
79
+
80
+ if isinstance(self.match_mode, str):
81
+ self.match_mode = RuleMatchMode(self.match_mode)
82
+
83
+ def matches(self, file_info: Dict[str, Any]) -> bool:
84
+ """
85
+ Check if a file matches this rule's conditions.
86
+
87
+ Args:
88
+ file_info: Dictionary containing file attributes.
89
+
90
+ Returns:
91
+ True if the file matches the rule's conditions.
92
+ """
93
+ if not self.enabled:
94
+ return False
95
+
96
+ if not self.conditions:
97
+ # No conditions means always match
98
+ return True
99
+
100
+ results = [cond.evaluate(file_info) for cond in self.conditions]
101
+
102
+ if self.match_mode == RuleMatchMode.ALL:
103
+ return all(results)
104
+ elif self.match_mode == RuleMatchMode.ANY:
105
+ return any(results)
106
+ elif self.match_mode == RuleMatchMode.NONE:
107
+ return not any(results)
108
+
109
+ return False
110
+
111
+ def add_condition(self, condition: Condition | ConditionGroup) -> "Rule":
112
+ """
113
+ Add a condition to the rule.
114
+
115
+ Args:
116
+ condition: Condition to add.
117
+
118
+ Returns:
119
+ Self for chaining.
120
+ """
121
+ self.conditions.append(condition)
122
+ self.updated_at = datetime.now()
123
+ return self
124
+
125
+ def add_action(self, action: Action) -> "Rule":
126
+ """
127
+ Add an action to the rule.
128
+
129
+ Args:
130
+ action: Action to add.
131
+
132
+ Returns:
133
+ Self for chaining.
134
+ """
135
+ self.actions.append(action)
136
+ self.updated_at = datetime.now()
137
+ return self
138
+
139
+ def remove_condition(self, index: int) -> bool:
140
+ """
141
+ Remove a condition by index.
142
+
143
+ Args:
144
+ index: Index of condition to remove.
145
+
146
+ Returns:
147
+ True if condition was removed.
148
+ """
149
+ if 0 <= index < len(self.conditions):
150
+ del self.conditions[index]
151
+ self.updated_at = datetime.now()
152
+ return True
153
+ return False
154
+
155
+ def remove_action(self, index: int) -> bool:
156
+ """
157
+ Remove an action by index.
158
+
159
+ Args:
160
+ index: Index of action to remove.
161
+
162
+ Returns:
163
+ True if action was removed.
164
+ """
165
+ if 0 <= index < len(self.actions):
166
+ del self.actions[index]
167
+ self.updated_at = datetime.now()
168
+ return True
169
+ return False
170
+
171
+ def duplicate(self, new_name: Optional[str] = None) -> "Rule":
172
+ """
173
+ Create a copy of this rule.
174
+
175
+ Args:
176
+ new_name: Name for the new rule.
177
+
178
+ Returns:
179
+ A new Rule instance.
180
+ """
181
+ return Rule(
182
+ name=new_name or f"{self.name} (Copy)",
183
+ conditions=[c.duplicate() for c in self.conditions],
184
+ actions=[a.duplicate() for a in self.actions],
185
+ enabled=self.enabled,
186
+ match_mode=self.match_mode,
187
+ continue_processing=self.continue_processing,
188
+ run_on_folder_open=self.run_on_folder_open,
189
+ description=self.description,
190
+ )
191
+
192
+ def to_dict(self) -> Dict[str, Any]:
193
+ """
194
+ Convert rule to dictionary for serialization.
195
+
196
+ Returns:
197
+ Dictionary representation of the rule.
198
+ """
199
+ return {
200
+ "id": self.id,
201
+ "name": self.name,
202
+ "description": self.description,
203
+ "enabled": self.enabled,
204
+ "match_mode": self.match_mode.value,
205
+ "continue_processing": self.continue_processing,
206
+ "run_on_folder_open": self.run_on_folder_open,
207
+ "conditions": [c.to_dict() for c in self.conditions],
208
+ "actions": [a.to_dict() for a in self.actions],
209
+ "created_at": self.created_at.isoformat(),
210
+ "updated_at": self.updated_at.isoformat(),
211
+ }
212
+
213
+ @classmethod
214
+ def from_dict(cls, data: Dict[str, Any]) -> "Rule":
215
+ """
216
+ Create a rule from a dictionary.
217
+
218
+ Args:
219
+ data: Dictionary containing rule data.
220
+
221
+ Returns:
222
+ A new Rule instance.
223
+ """
224
+ conditions = []
225
+ for cond_data in data.get("conditions", []):
226
+ if "conditions" in cond_data: # It's a condition group
227
+ conditions.append(ConditionGroup.from_dict(cond_data))
228
+ else:
229
+ conditions.append(Condition.from_dict(cond_data))
230
+
231
+ actions = [Action.from_dict(a) for a in data.get("actions", [])]
232
+
233
+ return cls(
234
+ id=data.get("id", str(uuid.uuid4())),
235
+ name=data["name"],
236
+ description=data.get("description"),
237
+ enabled=data.get("enabled", True),
238
+ match_mode=RuleMatchMode(data.get("match_mode", "all")),
239
+ continue_processing=data.get("continue_processing", False),
240
+ run_on_folder_open=data.get("run_on_folder_open", True),
241
+ conditions=conditions,
242
+ actions=actions,
243
+ created_at=datetime.fromisoformat(data["created_at"]) if "created_at" in data else datetime.now(),
244
+ updated_at=datetime.fromisoformat(data["updated_at"]) if "updated_at" in data else datetime.now(),
245
+ )
246
+
247
+ def __str__(self) -> str:
248
+ """String representation of the rule."""
249
+ status = "✓" if self.enabled else "✗"
250
+ return f"[{status}] {self.name} ({len(self.conditions)} conditions, {len(self.actions)} actions)"
251
+
252
+ def __repr__(self) -> str:
253
+ """Detailed string representation."""
254
+ return f"Rule(name={self.name!r}, enabled={self.enabled}, conditions={len(self.conditions)}, actions={len(self.actions)})"