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,449 @@
1
+ """
2
+ macOS Trash management.
3
+
4
+ Provides functions for managing the Trash and implementing App Sweep functionality.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import subprocess
11
+ from dataclasses import dataclass
12
+ from datetime import datetime, timedelta
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ from sortmeout.utils.logger import get_logger
17
+
18
+ logger = get_logger(__name__)
19
+
20
+
21
+ @dataclass
22
+ class TrashItem:
23
+ """Information about an item in the Trash."""
24
+ path: str
25
+ original_path: str
26
+ name: str
27
+ size: int
28
+ deleted_date: datetime
29
+ kind: str
30
+
31
+ @property
32
+ def age_days(self) -> int:
33
+ """Days since item was deleted."""
34
+ return (datetime.now() - self.deleted_date).days
35
+
36
+
37
+ @dataclass
38
+ class TrashInfo:
39
+ """Overall Trash statistics."""
40
+ item_count: int
41
+ total_size: int
42
+ oldest_item_date: Optional[datetime]
43
+ newest_item_date: Optional[datetime]
44
+ items: List[TrashItem]
45
+
46
+ @property
47
+ def size_human(self) -> str:
48
+ """Human-readable size."""
49
+ size = self.total_size
50
+ for unit in ["B", "KB", "MB", "GB", "TB"]:
51
+ if size < 1024:
52
+ return f"{size:.1f} {unit}"
53
+ size /= 1024
54
+ return f"{size:.1f} PB"
55
+
56
+
57
+ def get_trash_path() -> Path:
58
+ """Get the user's Trash directory path."""
59
+ return Path.home() / ".Trash"
60
+
61
+
62
+ def get_trash_info() -> TrashInfo:
63
+ """
64
+ Get information about the Trash contents.
65
+
66
+ Returns:
67
+ TrashInfo with statistics and item list.
68
+ """
69
+ trash_path = get_trash_path()
70
+ items = []
71
+ total_size = 0
72
+ oldest_date = None
73
+ newest_date = None
74
+
75
+ if not trash_path.exists():
76
+ return TrashInfo(
77
+ item_count=0,
78
+ total_size=0,
79
+ oldest_item_date=None,
80
+ newest_item_date=None,
81
+ items=[],
82
+ )
83
+
84
+ for entry in trash_path.iterdir():
85
+ try:
86
+ stat = entry.stat()
87
+
88
+ # Get size (recursively for directories)
89
+ if entry.is_dir():
90
+ size = sum(f.stat().st_size for f in entry.rglob("*") if f.is_file())
91
+ else:
92
+ size = stat.st_size
93
+
94
+ total_size += size
95
+
96
+ # Get deletion date from .DS_Store or use modification time
97
+ deleted_date = datetime.fromtimestamp(stat.st_mtime)
98
+
99
+ # Update date range
100
+ if oldest_date is None or deleted_date < oldest_date:
101
+ oldest_date = deleted_date
102
+ if newest_date is None or deleted_date > newest_date:
103
+ newest_date = deleted_date
104
+
105
+ # Determine kind
106
+ kind = "Folder" if entry.is_dir() else "File"
107
+
108
+ items.append(TrashItem(
109
+ path=str(entry),
110
+ original_path="", # Would need .Trashes metadata
111
+ name=entry.name,
112
+ size=size,
113
+ deleted_date=deleted_date,
114
+ kind=kind,
115
+ ))
116
+
117
+ except (PermissionError, OSError) as e:
118
+ logger.debug("Error reading trash item %s: %s", entry, e)
119
+ continue
120
+
121
+ return TrashInfo(
122
+ item_count=len(items),
123
+ total_size=total_size,
124
+ oldest_item_date=oldest_date,
125
+ newest_item_date=newest_date,
126
+ items=items,
127
+ )
128
+
129
+
130
+ def empty_trash(secure: bool = False) -> bool:
131
+ """
132
+ Empty the Trash.
133
+
134
+ Args:
135
+ secure: If True, securely delete files (slower).
136
+
137
+ Returns:
138
+ True if successful.
139
+ """
140
+ try:
141
+ if secure:
142
+ script = '''
143
+ tell application "Finder"
144
+ empty trash with security
145
+ end tell
146
+ '''
147
+ else:
148
+ script = '''
149
+ tell application "Finder"
150
+ empty trash
151
+ end tell
152
+ '''
153
+
154
+ result = subprocess.run(
155
+ ["osascript", "-e", script],
156
+ capture_output=True,
157
+ timeout=60,
158
+ )
159
+
160
+ if result.returncode == 0:
161
+ logger.info("Trash emptied")
162
+ return True
163
+ else:
164
+ logger.error("Failed to empty trash: %s", result.stderr)
165
+ return False
166
+
167
+ except Exception as e:
168
+ logger.error("Error emptying trash: %s", e)
169
+ return False
170
+
171
+
172
+ def delete_old_trash_items(max_age_days: int) -> List[str]:
173
+ """
174
+ Delete items from Trash older than specified days.
175
+
176
+ Args:
177
+ max_age_days: Maximum age in days.
178
+
179
+ Returns:
180
+ List of deleted item names.
181
+ """
182
+ trash_info = get_trash_info()
183
+ cutoff = datetime.now() - timedelta(days=max_age_days)
184
+ deleted = []
185
+
186
+ for item in trash_info.items:
187
+ if item.deleted_date < cutoff:
188
+ try:
189
+ path = Path(item.path)
190
+ if path.is_dir():
191
+ import shutil
192
+ shutil.rmtree(path)
193
+ else:
194
+ path.unlink()
195
+ deleted.append(item.name)
196
+ logger.info("Deleted old trash item: %s", item.name)
197
+ except Exception as e:
198
+ logger.error("Failed to delete %s: %s", item.name, e)
199
+
200
+ return deleted
201
+
202
+
203
+ def trim_trash_to_size(max_size_bytes: int) -> List[str]:
204
+ """
205
+ Trim Trash to maximum size by removing oldest items first.
206
+
207
+ Args:
208
+ max_size_bytes: Maximum total size in bytes.
209
+
210
+ Returns:
211
+ List of deleted item names.
212
+ """
213
+ trash_info = get_trash_info()
214
+
215
+ if trash_info.total_size <= max_size_bytes:
216
+ return []
217
+
218
+ # Sort by date (oldest first)
219
+ items_by_date = sorted(trash_info.items, key=lambda x: x.deleted_date)
220
+
221
+ deleted = []
222
+ current_size = trash_info.total_size
223
+
224
+ for item in items_by_date:
225
+ if current_size <= max_size_bytes:
226
+ break
227
+
228
+ try:
229
+ path = Path(item.path)
230
+ if path.is_dir():
231
+ import shutil
232
+ shutil.rmtree(path)
233
+ else:
234
+ path.unlink()
235
+
236
+ current_size -= item.size
237
+ deleted.append(item.name)
238
+ logger.info("Deleted trash item to free space: %s", item.name)
239
+
240
+ except Exception as e:
241
+ logger.error("Failed to delete %s: %s", item.name, e)
242
+
243
+ return deleted
244
+
245
+
246
+ class TrashManager:
247
+ """
248
+ Automatic trash management.
249
+
250
+ Monitors and manages the Trash based on configured policies.
251
+ """
252
+
253
+ def __init__(
254
+ self,
255
+ max_age_days: int = 30,
256
+ max_size_gb: float = 10.0,
257
+ enabled: bool = True,
258
+ ):
259
+ """
260
+ Initialize trash manager.
261
+
262
+ Args:
263
+ max_age_days: Maximum age for items.
264
+ max_size_gb: Maximum total size in GB.
265
+ enabled: Whether management is enabled.
266
+ """
267
+ self.max_age_days = max_age_days
268
+ self.max_size_gb = max_size_gb
269
+ self.max_size_bytes = int(max_size_gb * 1024 * 1024 * 1024)
270
+ self.enabled = enabled
271
+
272
+ def run_cleanup(self) -> Dict[str, Any]:
273
+ """
274
+ Run trash cleanup based on policies.
275
+
276
+ Returns:
277
+ Cleanup results.
278
+ """
279
+ if not self.enabled:
280
+ return {"enabled": False, "deleted": []}
281
+
282
+ results = {
283
+ "deleted_by_age": [],
284
+ "deleted_by_size": [],
285
+ "before_size": 0,
286
+ "after_size": 0,
287
+ }
288
+
289
+ # Get initial state
290
+ before = get_trash_info()
291
+ results["before_size"] = before.total_size
292
+
293
+ # Delete old items
294
+ if self.max_age_days > 0:
295
+ results["deleted_by_age"] = delete_old_trash_items(self.max_age_days)
296
+
297
+ # Trim to size
298
+ if self.max_size_bytes > 0:
299
+ results["deleted_by_size"] = trim_trash_to_size(self.max_size_bytes)
300
+
301
+ # Get final state
302
+ after = get_trash_info()
303
+ results["after_size"] = after.total_size
304
+
305
+ return results
306
+
307
+ def get_status(self) -> Dict[str, Any]:
308
+ """
309
+ Get current trash status.
310
+
311
+ Returns:
312
+ Status information.
313
+ """
314
+ info = get_trash_info()
315
+
316
+ return {
317
+ "item_count": info.item_count,
318
+ "total_size": info.total_size,
319
+ "size_human": info.size_human,
320
+ "oldest_item_age_days": (datetime.now() - info.oldest_item_date).days if info.oldest_item_date else 0,
321
+ "over_size_limit": info.total_size > self.max_size_bytes,
322
+ "has_old_items": info.oldest_item_date and (datetime.now() - info.oldest_item_date).days > self.max_age_days,
323
+ }
324
+
325
+
326
+ # App Sweep functionality
327
+
328
+ def find_app_support_files(app_name: str) -> List[str]:
329
+ """
330
+ Find support files for an application.
331
+
332
+ Args:
333
+ app_name: Application name or bundle identifier.
334
+
335
+ Returns:
336
+ List of support file paths.
337
+ """
338
+ support_dirs = [
339
+ Path.home() / "Library" / "Application Support",
340
+ Path.home() / "Library" / "Preferences",
341
+ Path.home() / "Library" / "Caches",
342
+ Path.home() / "Library" / "Containers",
343
+ Path.home() / "Library" / "Logs",
344
+ Path.home() / "Library" / "Saved Application State",
345
+ Path("/Library/Application Support"),
346
+ Path("/Library/Preferences"),
347
+ ]
348
+
349
+ found_files = []
350
+
351
+ # Normalize app name for matching
352
+ app_name_lower = app_name.lower()
353
+ # Remove .app extension if present
354
+ if app_name_lower.endswith(".app"):
355
+ app_name_lower = app_name_lower[:-4]
356
+
357
+ for support_dir in support_dirs:
358
+ if not support_dir.exists():
359
+ continue
360
+
361
+ try:
362
+ for entry in support_dir.iterdir():
363
+ entry_name = entry.name.lower()
364
+
365
+ # Match by name
366
+ if app_name_lower in entry_name:
367
+ found_files.append(str(entry))
368
+ continue
369
+
370
+ # Match common patterns
371
+ if any(pattern in entry_name for pattern in [
372
+ app_name_lower.replace(" ", ""),
373
+ app_name_lower.replace(" ", "-"),
374
+ app_name_lower.replace(" ", "_"),
375
+ ]):
376
+ found_files.append(str(entry))
377
+
378
+ except PermissionError:
379
+ continue
380
+
381
+ return found_files
382
+
383
+
384
+ def get_app_support_size(app_name: str) -> int:
385
+ """
386
+ Get total size of support files for an application.
387
+
388
+ Args:
389
+ app_name: Application name.
390
+
391
+ Returns:
392
+ Total size in bytes.
393
+ """
394
+ files = find_app_support_files(app_name)
395
+ total = 0
396
+
397
+ for file_path in files:
398
+ path = Path(file_path)
399
+ try:
400
+ if path.is_dir():
401
+ total += sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
402
+ else:
403
+ total += path.stat().st_size
404
+ except (PermissionError, OSError):
405
+ continue
406
+
407
+ return total
408
+
409
+
410
+ def clean_app_support_files(app_name: str, to_trash: bool = True) -> List[str]:
411
+ """
412
+ Clean up support files for an application.
413
+
414
+ Args:
415
+ app_name: Application name.
416
+ to_trash: Move to trash instead of deleting permanently.
417
+
418
+ Returns:
419
+ List of cleaned files.
420
+ """
421
+ files = find_app_support_files(app_name)
422
+ cleaned = []
423
+
424
+ for file_path in files:
425
+ try:
426
+ if to_trash:
427
+ # Move to trash using Finder
428
+ script = f'''
429
+ tell application "Finder"
430
+ delete POSIX file "{file_path}"
431
+ end tell
432
+ '''
433
+ subprocess.run(["osascript", "-e", script], capture_output=True, timeout=10)
434
+ else:
435
+ # Delete directly
436
+ path = Path(file_path)
437
+ if path.is_dir():
438
+ import shutil
439
+ shutil.rmtree(path)
440
+ else:
441
+ path.unlink()
442
+
443
+ cleaned.append(file_path)
444
+ logger.info("Cleaned app support file: %s", file_path)
445
+
446
+ except Exception as e:
447
+ logger.error("Failed to clean %s: %s", file_path, e)
448
+
449
+ return cleaned
@@ -0,0 +1,12 @@
1
+ """
2
+ Utility functions for SortMeOut.
3
+ """
4
+
5
+ from sortmeout.utils.logger import setup_logging, get_logger
6
+ from sortmeout.utils.file_info import get_file_info
7
+
8
+ __all__ = [
9
+ "setup_logging",
10
+ "get_logger",
11
+ "get_file_info",
12
+ ]