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,363 @@
1
+ """
2
+ File information extraction.
3
+
4
+ This module provides functions for extracting various attributes from files,
5
+ including standard file system attributes and macOS-specific metadata.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import mimetypes
11
+ import os
12
+ import subprocess
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional
16
+
17
+
18
+ def get_file_info(file_path: str) -> Dict[str, Any]:
19
+ """
20
+ Get comprehensive information about a file.
21
+
22
+ Args:
23
+ file_path: Path to the file.
24
+
25
+ Returns:
26
+ Dictionary containing file attributes.
27
+ """
28
+ path = Path(file_path)
29
+ stat = path.stat()
30
+
31
+ info = {
32
+ # Basic attributes
33
+ "path": str(path.absolute()),
34
+ "name": path.stem,
35
+ "extension": path.suffix.lstrip(".").lower(),
36
+ "full_name": path.name,
37
+ "parent_folder": path.parent.name,
38
+ "parent_path": str(path.parent),
39
+
40
+ # Size
41
+ "size": stat.st_size,
42
+ "size_bytes": stat.st_size,
43
+ "size_human": _format_size(stat.st_size),
44
+
45
+ # Dates
46
+ "date_created": datetime.fromtimestamp(stat.st_birthtime) if hasattr(stat, "st_birthtime") else datetime.fromtimestamp(stat.st_ctime),
47
+ "date_modified": datetime.fromtimestamp(stat.st_mtime),
48
+ "date_accessed": datetime.fromtimestamp(stat.st_atime),
49
+
50
+ # Type
51
+ "is_file": path.is_file(),
52
+ "is_directory": path.is_dir(),
53
+ "is_symlink": path.is_symlink(),
54
+ "is_hidden": path.name.startswith("."),
55
+ }
56
+
57
+ # MIME type
58
+ mime_type, _ = mimetypes.guess_type(str(path))
59
+ info["mime_type"] = mime_type
60
+ info["file_type"] = mime_type
61
+
62
+ # Determine kind based on extension/mime
63
+ info["kind"] = _get_file_kind(path.suffix, mime_type)
64
+
65
+ # macOS-specific attributes
66
+ try:
67
+ macos_info = _get_macos_metadata(file_path)
68
+ info.update(macos_info)
69
+ except Exception:
70
+ pass
71
+
72
+ return info
73
+
74
+
75
+ def _format_size(size_bytes: int) -> str:
76
+ """Format size in bytes to human-readable string."""
77
+ for unit in ["B", "KB", "MB", "GB", "TB"]:
78
+ if size_bytes < 1024:
79
+ return f"{size_bytes:.1f} {unit}"
80
+ size_bytes /= 1024
81
+ return f"{size_bytes:.1f} PB"
82
+
83
+
84
+ def _get_file_kind(extension: str, mime_type: Optional[str]) -> str:
85
+ """Determine the kind of file based on extension and MIME type."""
86
+ extension = extension.lower().lstrip(".")
87
+
88
+ # Extension-based kinds
89
+ kinds = {
90
+ # Documents
91
+ "pdf": "PDF Document",
92
+ "doc": "Word Document",
93
+ "docx": "Word Document",
94
+ "xls": "Excel Spreadsheet",
95
+ "xlsx": "Excel Spreadsheet",
96
+ "ppt": "PowerPoint Presentation",
97
+ "pptx": "PowerPoint Presentation",
98
+ "txt": "Plain Text",
99
+ "rtf": "Rich Text Document",
100
+ "md": "Markdown Document",
101
+ "pages": "Pages Document",
102
+ "numbers": "Numbers Spreadsheet",
103
+ "key": "Keynote Presentation",
104
+
105
+ # Images
106
+ "jpg": "JPEG Image",
107
+ "jpeg": "JPEG Image",
108
+ "png": "PNG Image",
109
+ "gif": "GIF Image",
110
+ "bmp": "BMP Image",
111
+ "tiff": "TIFF Image",
112
+ "tif": "TIFF Image",
113
+ "webp": "WebP Image",
114
+ "svg": "SVG Image",
115
+ "ico": "Icon",
116
+ "heic": "HEIC Image",
117
+ "heif": "HEIF Image",
118
+ "raw": "RAW Image",
119
+ "psd": "Photoshop Document",
120
+ "ai": "Illustrator Document",
121
+
122
+ # Audio
123
+ "mp3": "MP3 Audio",
124
+ "wav": "WAV Audio",
125
+ "aac": "AAC Audio",
126
+ "flac": "FLAC Audio",
127
+ "m4a": "M4A Audio",
128
+ "ogg": "OGG Audio",
129
+ "wma": "WMA Audio",
130
+ "aiff": "AIFF Audio",
131
+
132
+ # Video
133
+ "mp4": "MP4 Video",
134
+ "mov": "QuickTime Movie",
135
+ "avi": "AVI Video",
136
+ "mkv": "MKV Video",
137
+ "wmv": "WMV Video",
138
+ "flv": "FLV Video",
139
+ "webm": "WebM Video",
140
+ "m4v": "M4V Video",
141
+
142
+ # Archives
143
+ "zip": "ZIP Archive",
144
+ "rar": "RAR Archive",
145
+ "7z": "7-Zip Archive",
146
+ "tar": "TAR Archive",
147
+ "gz": "Gzip Archive",
148
+ "bz2": "Bzip2 Archive",
149
+ "dmg": "Disk Image",
150
+ "iso": "ISO Image",
151
+
152
+ # Code
153
+ "py": "Python Script",
154
+ "js": "JavaScript",
155
+ "ts": "TypeScript",
156
+ "html": "HTML Document",
157
+ "css": "CSS Stylesheet",
158
+ "json": "JSON File",
159
+ "xml": "XML File",
160
+ "yaml": "YAML File",
161
+ "yml": "YAML File",
162
+ "sh": "Shell Script",
163
+ "java": "Java Source",
164
+ "cpp": "C++ Source",
165
+ "c": "C Source",
166
+ "h": "Header File",
167
+ "swift": "Swift Source",
168
+ "rb": "Ruby Script",
169
+ "go": "Go Source",
170
+ "rs": "Rust Source",
171
+
172
+ # Applications
173
+ "app": "Application",
174
+ "exe": "Windows Executable",
175
+ "pkg": "Installer Package",
176
+
177
+ # Other
178
+ "torrent": "Torrent File",
179
+ "ics": "Calendar Event",
180
+ "vcf": "Contact Card",
181
+ }
182
+
183
+ if extension in kinds:
184
+ return kinds[extension]
185
+
186
+ # MIME-based fallback
187
+ if mime_type:
188
+ if mime_type.startswith("image/"):
189
+ return "Image"
190
+ elif mime_type.startswith("video/"):
191
+ return "Video"
192
+ elif mime_type.startswith("audio/"):
193
+ return "Audio"
194
+ elif mime_type.startswith("text/"):
195
+ return "Text Document"
196
+ elif mime_type.startswith("application/"):
197
+ return "Application"
198
+
199
+ return "Document"
200
+
201
+
202
+ def _get_macos_metadata(file_path: str) -> Dict[str, Any]:
203
+ """
204
+ Get macOS-specific metadata using mdls command.
205
+
206
+ Args:
207
+ file_path: Path to the file.
208
+
209
+ Returns:
210
+ Dictionary of macOS metadata.
211
+ """
212
+ info = {}
213
+
214
+ try:
215
+ # Get Spotlight metadata
216
+ result = subprocess.run(
217
+ ["mdls", "-plist", "-", file_path],
218
+ capture_output=True,
219
+ text=True,
220
+ timeout=5,
221
+ )
222
+
223
+ if result.returncode == 0:
224
+ import plistlib
225
+ metadata = plistlib.loads(result.stdout.encode())
226
+
227
+ # Extract useful attributes
228
+ attr_mapping = {
229
+ "kMDItemWhereFroms": "where_from",
230
+ "kMDItemUserTags": "tags",
231
+ "kMDItemFinderComment": "finder_comment",
232
+ "kMDItemContentType": "uti",
233
+ "kMDItemKind": "kind_macos",
234
+ "kMDItemDateAdded": "date_added",
235
+ "kMDItemDownloadedDate": "date_downloaded",
236
+ "kMDItemPixelHeight": "pixel_height",
237
+ "kMDItemPixelWidth": "pixel_width",
238
+ "kMDItemDurationSeconds": "duration_seconds",
239
+ "kMDItemTitle": "title",
240
+ "kMDItemAuthors": "authors",
241
+ "kMDItemCreator": "creator",
242
+ "kMDItemDescription": "description",
243
+ }
244
+
245
+ for mdls_key, our_key in attr_mapping.items():
246
+ if mdls_key in metadata and metadata[mdls_key] is not None:
247
+ value = metadata[mdls_key]
248
+ # Handle lists
249
+ if isinstance(value, list) and len(value) == 1:
250
+ value = value[0]
251
+ info[our_key] = value
252
+
253
+ except (subprocess.TimeoutExpired, subprocess.SubprocessError):
254
+ pass
255
+ except Exception:
256
+ pass
257
+
258
+ # Get tags using xattr if not found via mdls
259
+ if "tags" not in info:
260
+ try:
261
+ tags = _get_finder_tags(file_path)
262
+ if tags:
263
+ info["tags"] = tags
264
+ except Exception:
265
+ pass
266
+
267
+ return info
268
+
269
+
270
+ def _get_finder_tags(file_path: str) -> List[str]:
271
+ """
272
+ Get Finder tags from a file using xattr.
273
+
274
+ Args:
275
+ file_path: Path to the file.
276
+
277
+ Returns:
278
+ List of tag names.
279
+ """
280
+ try:
281
+ import plistlib
282
+
283
+ result = subprocess.run(
284
+ ["xattr", "-p", "com.apple.metadata:_kMDItemUserTags", file_path],
285
+ capture_output=True,
286
+ timeout=5,
287
+ )
288
+
289
+ if result.returncode == 0 and result.stdout:
290
+ # Parse the binary plist
291
+ tags_data = plistlib.loads(result.stdout)
292
+ return [tag.split("\n")[0] for tag in tags_data if tag]
293
+ except Exception:
294
+ pass
295
+
296
+ return []
297
+
298
+
299
+ def get_file_contents(file_path: str, max_bytes: int = 1024 * 1024) -> Optional[str]:
300
+ """
301
+ Read file contents (for text files).
302
+
303
+ Args:
304
+ file_path: Path to the file.
305
+ max_bytes: Maximum bytes to read.
306
+
307
+ Returns:
308
+ File contents as string, or None if not readable.
309
+ """
310
+ try:
311
+ with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
312
+ return f.read(max_bytes)
313
+ except Exception:
314
+ return None
315
+
316
+
317
+ def get_download_source(file_path: str) -> Optional[str]:
318
+ """
319
+ Get the URL from which a file was downloaded.
320
+
321
+ Args:
322
+ file_path: Path to the file.
323
+
324
+ Returns:
325
+ Download URL or None.
326
+ """
327
+ info = _get_macos_metadata(file_path)
328
+ where_from = info.get("where_from")
329
+
330
+ if isinstance(where_from, list):
331
+ return where_from[0] if where_from else None
332
+ return where_from
333
+
334
+
335
+ def is_complete_download(file_path: str) -> bool:
336
+ """
337
+ Check if a file is a complete download (not still being downloaded).
338
+
339
+ Args:
340
+ file_path: Path to the file.
341
+
342
+ Returns:
343
+ True if download is complete.
344
+ """
345
+ path = Path(file_path)
346
+
347
+ # Check for common incomplete download extensions
348
+ incomplete_extensions = {
349
+ ".crdownload", # Chrome
350
+ ".part", # Firefox, wget
351
+ ".download", # Safari
352
+ ".tmp",
353
+ ".partial",
354
+ }
355
+
356
+ if path.suffix.lower() in incomplete_extensions:
357
+ return False
358
+
359
+ # Check for .download companion file (Safari)
360
+ if (path.parent / f"{path.name}.download").exists():
361
+ return False
362
+
363
+ return True
@@ -0,0 +1,214 @@
1
+ """
2
+ Logging configuration for SortMeOut.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ import os
9
+ import sys
10
+ from datetime import datetime
11
+ from logging.handlers import RotatingFileHandler
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ import appdirs
16
+
17
+ # Application name for directories
18
+ APP_NAME = "SortMeOut"
19
+ APP_AUTHOR = "SortMeOut"
20
+
21
+ # Default log format
22
+ DEFAULT_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
23
+ SIMPLE_FORMAT = "%(levelname)s: %(message)s"
24
+ DETAILED_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s"
25
+
26
+
27
+ def get_log_directory() -> Path:
28
+ """Get the log directory path."""
29
+ log_dir = Path(appdirs.user_log_dir(APP_NAME, APP_AUTHOR))
30
+ log_dir.mkdir(parents=True, exist_ok=True)
31
+ return log_dir
32
+
33
+
34
+ def setup_logging(
35
+ level: int = logging.INFO,
36
+ log_file: Optional[str] = None,
37
+ console: bool = True,
38
+ file_logging: bool = True,
39
+ max_file_size: int = 10 * 1024 * 1024, # 10 MB
40
+ backup_count: int = 5,
41
+ format_string: Optional[str] = None,
42
+ ) -> logging.Logger:
43
+ """
44
+ Set up logging for the application.
45
+
46
+ Args:
47
+ level: Logging level.
48
+ log_file: Path to log file. If None, uses default location.
49
+ console: Enable console logging.
50
+ file_logging: Enable file logging.
51
+ max_file_size: Maximum log file size before rotation.
52
+ backup_count: Number of backup files to keep.
53
+ format_string: Custom format string.
54
+
55
+ Returns:
56
+ Root logger instance.
57
+ """
58
+ # Get root logger for our package
59
+ root_logger = logging.getLogger("sortmeout")
60
+ root_logger.setLevel(level)
61
+
62
+ # Clear existing handlers
63
+ root_logger.handlers.clear()
64
+
65
+ # Determine format
66
+ if format_string is None:
67
+ format_string = DETAILED_FORMAT if level == logging.DEBUG else DEFAULT_FORMAT
68
+
69
+ formatter = logging.Formatter(format_string)
70
+
71
+ # Console handler
72
+ if console:
73
+ console_handler = logging.StreamHandler(sys.stdout)
74
+ console_handler.setLevel(level)
75
+ console_handler.setFormatter(logging.Formatter(SIMPLE_FORMAT))
76
+ root_logger.addHandler(console_handler)
77
+
78
+ # File handler
79
+ if file_logging:
80
+ if log_file is None:
81
+ log_dir = get_log_directory()
82
+ log_file = str(log_dir / "sortmeout.log")
83
+
84
+ file_handler = RotatingFileHandler(
85
+ log_file,
86
+ maxBytes=max_file_size,
87
+ backupCount=backup_count,
88
+ )
89
+ file_handler.setLevel(level)
90
+ file_handler.setFormatter(formatter)
91
+ root_logger.addHandler(file_handler)
92
+
93
+ return root_logger
94
+
95
+
96
+ def get_logger(name: str) -> logging.Logger:
97
+ """
98
+ Get a logger for a module.
99
+
100
+ Args:
101
+ name: Logger name (usually __name__).
102
+
103
+ Returns:
104
+ Logger instance.
105
+ """
106
+ # Ensure name is under our package namespace
107
+ if not name.startswith("sortmeout"):
108
+ name = f"sortmeout.{name}"
109
+
110
+ return logging.getLogger(name)
111
+
112
+
113
+ class ActionLogger:
114
+ """
115
+ Specialized logger for tracking file actions.
116
+
117
+ Logs actions to a separate file for easy auditing.
118
+ """
119
+
120
+ def __init__(self, log_file: Optional[str] = None):
121
+ """
122
+ Initialize action logger.
123
+
124
+ Args:
125
+ log_file: Path to action log file.
126
+ """
127
+ self.logger = logging.getLogger("sortmeout.actions")
128
+ self.logger.setLevel(logging.INFO)
129
+
130
+ if log_file is None:
131
+ log_dir = get_log_directory()
132
+ log_file = str(log_dir / "actions.log")
133
+
134
+ handler = RotatingFileHandler(
135
+ log_file,
136
+ maxBytes=10 * 1024 * 1024,
137
+ backupCount=10,
138
+ )
139
+ handler.setFormatter(logging.Formatter(
140
+ "%(asctime)s | %(message)s"
141
+ ))
142
+ self.logger.addHandler(handler)
143
+
144
+ def log_action(
145
+ self,
146
+ action_type: str,
147
+ source: str,
148
+ destination: Optional[str] = None,
149
+ success: bool = True,
150
+ details: Optional[str] = None,
151
+ ) -> None:
152
+ """
153
+ Log a file action.
154
+
155
+ Args:
156
+ action_type: Type of action performed.
157
+ source: Source file path.
158
+ destination: Destination path (if applicable).
159
+ success: Whether action succeeded.
160
+ details: Additional details.
161
+ """
162
+ status = "SUCCESS" if success else "FAILED"
163
+
164
+ message_parts = [
165
+ status,
166
+ action_type.upper(),
167
+ f"src={source}",
168
+ ]
169
+
170
+ if destination:
171
+ message_parts.append(f"dst={destination}")
172
+
173
+ if details:
174
+ message_parts.append(f"({details})")
175
+
176
+ message = " | ".join(message_parts)
177
+
178
+ if success:
179
+ self.logger.info(message)
180
+ else:
181
+ self.logger.error(message)
182
+
183
+ def get_recent_actions(self, count: int = 100) -> list:
184
+ """
185
+ Get recent logged actions.
186
+
187
+ Args:
188
+ count: Number of recent actions to retrieve.
189
+
190
+ Returns:
191
+ List of action log entries.
192
+ """
193
+ log_dir = get_log_directory()
194
+ log_file = log_dir / "actions.log"
195
+
196
+ if not log_file.exists():
197
+ return []
198
+
199
+ with open(log_file, "r") as f:
200
+ lines = f.readlines()
201
+
202
+ return lines[-count:]
203
+
204
+
205
+ # Global action logger instance
206
+ _action_logger: Optional[ActionLogger] = None
207
+
208
+
209
+ def get_action_logger() -> ActionLogger:
210
+ """Get the global action logger instance."""
211
+ global _action_logger
212
+ if _action_logger is None:
213
+ _action_logger = ActionLogger()
214
+ return _action_logger