ezsort 0.1.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.
ezsort/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """EzSort - Keep Your Files Sorted, Effortlessly.
2
+
3
+ A safe, lightweight Python CLI that automatically organizes messy folders
4
+ into categories based on file types.
5
+
6
+ Created by Kritagya Dubey
7
+ """
8
+
9
+ __version__ = "0.1.0"
10
+ __author__ = "Kritagya Dubey"
ezsort/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Allow running EzSort as a module: python -m ezsort"""
2
+
3
+ import sys
4
+ from ezsort.cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
ezsort/classifier.py ADDED
@@ -0,0 +1,103 @@
1
+ """File classifier - determines file categories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ from typing import Any
8
+
9
+ from ezsort.config import get_categories
10
+ from ezsort.models import FileItem
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def classify_file(
16
+ file_item: FileItem,
17
+ config: dict[str, Any] | None = None,
18
+ ) -> tuple[str, str]:
19
+ """Classify a file into a category.
20
+
21
+ Args:
22
+ file_item: The file to classify.
23
+ config: Optional configuration dictionary.
24
+
25
+ Returns:
26
+ Tuple of (category, reason).
27
+ """
28
+ categories = get_categories(config)
29
+ custom_rules = (config or {}).get("custom_rules", [])
30
+
31
+ # Check custom rules first (highest priority)
32
+ for rule in custom_rules:
33
+ match_result = _match_custom_rule(file_item, rule)
34
+ if match_result:
35
+ reason = f'Custom rule matched: {rule.get("pattern", "")}'
36
+ return match_result, reason
37
+
38
+ # Check by extension
39
+ ext = file_item.extension.lower()
40
+ for category, extensions in categories.items():
41
+ if ext in extensions:
42
+ return category, f'Extension "{ext}" matched {category}'
43
+
44
+ # Fallback
45
+ return "Others", f'No category matched for extension "{ext}"'
46
+
47
+
48
+ def _match_custom_rule(file_item: FileItem, rule: dict[str, Any]) -> str | None:
49
+ """Check if a file matches a custom rule.
50
+
51
+ Returns the target category + subfolder path, or None.
52
+ """
53
+ rule_type = rule.get("type", "")
54
+ pattern = rule.get("pattern", "")
55
+ category = rule.get("category", "Others")
56
+ subfolder = rule.get("subfolder", "")
57
+
58
+ if not pattern or not category:
59
+ return None
60
+
61
+ filename = file_item.path.name.lower()
62
+
63
+ if rule_type == "extension":
64
+ if file_item.extension.lower() == pattern.lower():
65
+ return f"{category}/{subfolder}" if subfolder else category
66
+
67
+ elif rule_type == "filename_contains":
68
+ if pattern.lower() in filename:
69
+ return f"{category}/{subfolder}" if subfolder else category
70
+
71
+ elif rule_type == "filename_matches":
72
+ try:
73
+ if re.search(pattern, filename, re.IGNORECASE):
74
+ return f"{category}/{subfolder}" if subfolder else category
75
+ except re.error:
76
+ logger.warning("Invalid regex pattern in custom rule: %s", pattern)
77
+
78
+ elif rule_type == "filename_starts":
79
+ if filename.startswith(pattern.lower()):
80
+ return f"{category}/{subfolder}" if subfolder else category
81
+
82
+ elif rule_type == "filename_ends":
83
+ if filename.endswith(pattern.lower()):
84
+ return f"{category}/{subfolder}" if subfolder else category
85
+
86
+ return None
87
+
88
+
89
+ def classify_batch(
90
+ files: list[FileItem],
91
+ config: dict[str, Any] | None = None,
92
+ ) -> list[tuple[FileItem, str, str]]:
93
+ """Classify a batch of files.
94
+
95
+ Returns:
96
+ List of (file_item, category, reason) tuples.
97
+ """
98
+ results = []
99
+ for item in files:
100
+ category, reason = classify_file(item, config)
101
+ results.append((item, category, reason))
102
+ logger.debug("Classified %s as %s: %s", item.path.name, category, reason)
103
+ return results
ezsort/cli.py ADDED
@@ -0,0 +1,429 @@
1
+ """CLI entry point for EzSort."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import sys
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Sequence
11
+
12
+ from ezsort import __version__
13
+ from ezsort.constants import BANNER, CATEGORY_ORDER, VERSION_STRING
14
+ from ezsort.scanner import scan_directory
15
+ from ezsort.classifier import classify_batch
16
+ from ezsort.planner import create_plan
17
+ from ezsort.organizer import execute_operations
18
+ from ezsort.history import record_operations, undo_last, get_history, clear_history
19
+ from ezsort.duplicate import find_duplicates
20
+ from ezsort.config import load_config
21
+ from ezsort.utils import green, red, yellow, cyan, bold
22
+
23
+
24
+ ABOUT_TEXT = f"""
25
+ {bold('EzSort')} v{__version__}
26
+ Keep Your Files Sorted, Effortlessly.
27
+
28
+ Made by Kritagya Dubey
29
+ GitHub: https://github.com/kritagyadubey/EzSort
30
+ License: MIT
31
+ """
32
+
33
+
34
+ def main(argv: Sequence[str] | None = None) -> int:
35
+ """Main CLI entry point."""
36
+ parser = argparse.ArgumentParser(
37
+ prog="ezsort",
38
+ description=(
39
+ "EzSort - A smart file organizer that sorts your messy folders\n"
40
+ "into clean categories with a single command. Safe, fast, and\n"
41
+ "completely offline. Never deletes, never overwrites."
42
+ ),
43
+ formatter_class=argparse.RawDescriptionHelpFormatter,
44
+ epilog=(
45
+ "Examples:\n"
46
+ " ezsort organize ~/Downloads --dry-run See what would happen\n"
47
+ " ezsort organize ~/Downloads --yes Sort it for real\n"
48
+ " ezsort scan ~/Downloads Check folder stats\n"
49
+ " ezsort undo Reverse last sort\n"
50
+ " ezsort watch ~/Downloads Auto-sort new files\n"
51
+ ),
52
+ )
53
+ parser.add_argument("--version", action="version", version=VERSION_STRING)
54
+ parser.add_argument("--about", action="store_true", help="Show info about EzSort")
55
+ parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose output")
56
+ parser.add_argument("--quiet", "-q", action="store_true", help="Suppress non-essential output")
57
+
58
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
59
+
60
+ # organize command
61
+ org_parser = subparsers.add_parser(
62
+ "organize",
63
+ help="Sort a messy folder into neat categories",
64
+ description="Scan a folder, classify files by type, and move them into organized subfolders.",
65
+ )
66
+ org_parser.add_argument("folder", help="Path to the folder to organize")
67
+ org_parser.add_argument("--dry-run", action="store_true", help="Preview changes without touching files")
68
+ org_parser.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt")
69
+ org_parser.add_argument("--recursive", "-r", action="store_true", help="Include subdirectories")
70
+
71
+ # scan command
72
+ scan_parser = subparsers.add_parser(
73
+ "scan",
74
+ help="Analyze a folder without moving anything",
75
+ description="Scan a folder and show a breakdown of file types, duplicates, and what could be organized.",
76
+ )
77
+ scan_parser.add_argument("folder", help="Path to scan")
78
+ scan_parser.add_argument("--recursive", "-r", action="store_true", help="Scan subdirectories too")
79
+
80
+ # watch command
81
+ watch_parser = subparsers.add_parser(
82
+ "watch",
83
+ help="Auto-sort new files as they appear",
84
+ description="Monitor a folder and automatically organize new files that show up.",
85
+ )
86
+ watch_parser.add_argument("folder", help="Path to watch")
87
+ watch_parser.add_argument("--interval", type=int, default=2, help="Seconds between scans (default: 2)")
88
+
89
+ # undo command
90
+ subparsers.add_parser(
91
+ "undo",
92
+ help="Reverse the last sort operation",
93
+ description="Undo the most recent organization and put every file back where it was.",
94
+ )
95
+
96
+ # history command
97
+ hist_parser = subparsers.add_parser(
98
+ "history",
99
+ help="See what EzSort has done",
100
+ description="View a log of all past organize operations, including what was moved and when.",
101
+ )
102
+ hist_parser.add_argument("--details", action="store_true", help="Show detailed operation info")
103
+ hist_parser.add_argument("--clear", action="store_true", help="Clear all history")
104
+
105
+ # stats command
106
+ subparsers.add_parser(
107
+ "stats",
108
+ help="See your sorting stats",
109
+ description="View cumulative statistics about all your past organize operations.",
110
+ )
111
+
112
+ # config command
113
+ subparsers.add_parser(
114
+ "config",
115
+ help="View current settings",
116
+ description="Show the active configuration: categories, rules, excluded dirs, and preferences.",
117
+ )
118
+
119
+ # version command
120
+ subparsers.add_parser(
121
+ "version",
122
+ help="Show version info",
123
+ description="Print the current EzSort version.",
124
+ )
125
+
126
+ args = parser.parse_args(argv)
127
+
128
+ # Handle --about flag
129
+ if args.about:
130
+ print(ABOUT_TEXT)
131
+ return 0
132
+
133
+ # Set up logging
134
+ log_level = logging.WARNING
135
+ if args.verbose:
136
+ log_level = logging.DEBUG
137
+ elif not hasattr(args, "quiet") or not getattr(args, "quiet", False):
138
+ log_level = logging.INFO
139
+
140
+ logging.basicConfig(
141
+ level=log_level,
142
+ format="[%(levelname)s] %(message)s",
143
+ stream=sys.stderr,
144
+ )
145
+
146
+ if not args.command:
147
+ print(BANNER)
148
+ parser.print_help()
149
+ return 0
150
+
151
+ # Dispatch to command handlers
152
+ try:
153
+ if args.command == "organize":
154
+ return cmd_organize(args)
155
+ elif args.command == "scan":
156
+ return cmd_scan(args)
157
+ elif args.command == "watch":
158
+ return cmd_watch(args)
159
+ elif args.command == "undo":
160
+ return cmd_undo()
161
+ elif args.command == "history":
162
+ return cmd_history(args)
163
+ elif args.command == "stats":
164
+ return cmd_stats()
165
+ elif args.command == "config":
166
+ return cmd_config()
167
+ elif args.command == "version":
168
+ print(VERSION_STRING)
169
+ return 0
170
+ else:
171
+ parser.print_help()
172
+ return 0
173
+ except KeyboardInterrupt:
174
+ print("\n Operation cancelled.")
175
+ return 130
176
+ except Exception as e:
177
+ logging.error("Unexpected error: %s", e)
178
+ return 1
179
+
180
+
181
+ def cmd_organize(args: argparse.Namespace) -> int:
182
+ """Handle the organize command."""
183
+ target = Path(args.folder).expanduser().resolve()
184
+
185
+ if not target.exists():
186
+ print(red(f" Error: Folder not found: {target}"))
187
+ return 1
188
+ if not target.is_dir():
189
+ print(red(f" Error: Not a directory: {target}"))
190
+ return 1
191
+
192
+ config = load_config()
193
+
194
+ print(f"\n Scanning {target} ...")
195
+ files = scan_directory(target, recursive=args.recursive)
196
+
197
+ if not files:
198
+ print(yellow(" Nothing to sort here. Folder is clean!"))
199
+ return 0
200
+
201
+ print(f" Found {len(files)} file(s).")
202
+
203
+ # Classify
204
+ classified = classify_batch(files, config)
205
+
206
+ # Plan
207
+ plan = create_plan(classified, target)
208
+
209
+ if not plan:
210
+ print(yellow(" Everything is already in its place. Nice!"))
211
+ return 0
212
+
213
+ # Dry run
214
+ if args.dry_run:
215
+ print(f"\n {bold('DRY RUN')} -- nothing gets moved\n")
216
+ print(f" {'File':<30} {'->':<5} {'Destination'}")
217
+ print(f" {'-'*30} {'-'*5} {'-'*30}")
218
+ for op in plan:
219
+ try:
220
+ rel_source = op.source.relative_to(target)
221
+ except ValueError:
222
+ rel_source = op.source.name
223
+ try:
224
+ rel_dest = op.destination.relative_to(target)
225
+ except ValueError:
226
+ rel_dest = op.destination
227
+ print(f" {str(rel_source):<30} {'->':<5} {rel_dest}")
228
+ print(f"\n {len(plan)} file(s) would be moved.")
229
+ print(f" Run without --dry-run to apply.\n")
230
+ return 0
231
+
232
+ # Confirmation
233
+ if not args.yes and config.get("confirm_before_move", True):
234
+ print(f"\n About to move {len(plan)} file(s):")
235
+ for op in plan[:10]:
236
+ try:
237
+ rel_dest = op.destination.relative_to(target)
238
+ except ValueError:
239
+ rel_dest = op.destination
240
+ print(f" {op.source.name} -> {rel_dest}")
241
+ if len(plan) > 10:
242
+ print(f" ... and {len(plan) - 10} more")
243
+ print()
244
+ answer = input(" Proceed? [y/N]: ").strip().lower()
245
+ if answer not in ("y", "yes"):
246
+ print(" Cancelled. Nothing was changed.")
247
+ return 0
248
+
249
+ # Execute
250
+ print(f"\n Sorting {len(plan)} file(s) ...")
251
+ start_time = time.time()
252
+ results = execute_operations(plan, dry_run=False)
253
+ elapsed = time.time() - start_time
254
+
255
+ # Record history
256
+ batch_id = record_operations(results)
257
+
258
+ # Summary
259
+ moved = sum(1 for r in results if r.success)
260
+ failed = sum(1 for r in results if not r.success)
261
+
262
+ print(f"\n {green('Done!')} {moved} file(s) sorted in {elapsed:.2f}s")
263
+ if failed:
264
+ print(f" {red(f'{failed} file(s) failed to move.')}")
265
+ print(f" Batch ID: {batch_id}")
266
+ print(f" Undo anytime with: ezsort undo\n")
267
+
268
+ return 0 if failed == 0 else 1
269
+
270
+
271
+ def cmd_scan(args: argparse.Namespace) -> int:
272
+ """Handle the scan command."""
273
+ target = Path(args.folder).expanduser().resolve()
274
+
275
+ if not target.exists():
276
+ print(red(f" Error: Folder not found: {target}"))
277
+ return 1
278
+ if not target.is_dir():
279
+ print(red(f" Error: Not a directory: {target}"))
280
+ return 1
281
+
282
+ config = load_config()
283
+ files = scan_directory(target, recursive=args.recursive)
284
+
285
+ if not files:
286
+ print(yellow(" Folder is empty. Nothing to scan."))
287
+ return 0
288
+
289
+ # Classify for category counts
290
+ classified = classify_batch(files, config)
291
+ category_counts: dict[str, int] = {}
292
+ for _, category, _ in classified:
293
+ top = category.split("/")[0]
294
+ category_counts[top] = category_counts.get(top, 0) + 1
295
+
296
+ # Duplicate check
297
+ file_paths = [f.path for f in files if f.path.is_file()]
298
+ duplicates = find_duplicates(file_paths)
299
+ duplicate_count = sum(len(paths) - 1 for paths in duplicates.values())
300
+
301
+ # Count organizable
302
+ plan = create_plan(classified, target)
303
+ organizable = len(plan)
304
+
305
+ print(f"\n {bold('Scan Report')}")
306
+ print(f" {'='*30}")
307
+ print(f" Files found: {len(files)}")
308
+ print(f" Directories: {sum(1 for f in target.iterdir() if f.is_dir())}")
309
+ print()
310
+ for cat in CATEGORY_ORDER:
311
+ count = category_counts.get(cat, 0)
312
+ if count > 0:
313
+ print(f" {cat + ':':<20} {count}")
314
+ others = category_counts.get("Others", 0)
315
+ if others:
316
+ print(f" {'Others:':<20} {others}")
317
+ print()
318
+ print(f" Potential duplicates: {duplicate_count}")
319
+ print(f" Organizable files: {organizable}")
320
+ print()
321
+
322
+ return 0
323
+
324
+
325
+ def cmd_watch(args: argparse.Namespace) -> int:
326
+ """Handle the watch command."""
327
+ from ezsort.watcher import watch_directory
328
+
329
+ target = Path(args.folder).expanduser().resolve()
330
+
331
+ if not target.exists():
332
+ print(red(f" Error: Folder not found: {target}"))
333
+ return 1
334
+ if not target.is_dir():
335
+ print(red(f" Error: Not a directory: {target}"))
336
+ return 1
337
+
338
+ config = load_config()
339
+ watch_directory(target, interval=args.interval, config=config)
340
+ return 0
341
+
342
+
343
+ def cmd_undo() -> int:
344
+ """Handle the undo command."""
345
+ success, message = undo_last()
346
+ if success:
347
+ print(green(f" {message}"))
348
+ else:
349
+ print(red(f" {message}"))
350
+ return 0 if success else 1
351
+
352
+
353
+ def cmd_history(args: argparse.Namespace) -> int:
354
+ """Handle the history command."""
355
+ if args.clear:
356
+ clear_history()
357
+ print(green(" History cleared."))
358
+ return 0
359
+
360
+ history = get_history()
361
+
362
+ if not history:
363
+ print(yellow(" No history yet. Run 'ezsort organize' first."))
364
+ return 0
365
+
366
+ if args.details:
367
+ for batch in history:
368
+ print(f"\n Batch {batch['batch_id']} - {batch['timestamp']}")
369
+ print(f" Moved: {batch['total_moved']} file(s)")
370
+ for op in batch.get("operations", []):
371
+ print(f" {Path(op['source']).name} -> {op['category']}/")
372
+ else:
373
+ print(f"\n {'ID':<6} {'Date':<25} {'Action'}")
374
+ print(f" {'-'*6} {'-'*25} {'-'*30}")
375
+ for batch in history:
376
+ ts = batch["timestamp"][:19].replace("T", " ")
377
+ print(f" {batch['batch_id']:<6} {ts:<25} Organized {batch['total_moved']} file(s)")
378
+ print()
379
+ return 0
380
+
381
+
382
+ def cmd_stats() -> int:
383
+ """Handle the stats command."""
384
+ history = get_history()
385
+
386
+ if not history:
387
+ print(yellow(" No stats yet. Run 'ezsort organize' first."))
388
+ return 0
389
+
390
+ total = 0
391
+ category_totals: dict[str, int] = {}
392
+
393
+ for batch in history:
394
+ total += batch.get("total_moved", 0)
395
+ for op in batch.get("operations", []):
396
+ cat = op.get("category", "Others").split("/")[0]
397
+ category_totals[cat] = category_totals.get(cat, 0) + 1
398
+
399
+ print(f"\n {bold('Your Sorting Stats')}")
400
+ print(f" {'='*30}")
401
+ print(f" Total files organized: {total:,}")
402
+ print()
403
+ for cat in CATEGORY_ORDER:
404
+ count = category_totals.get(cat, 0)
405
+ if count > 0:
406
+ print(f" {cat + ':':<20} {count:,}")
407
+ print()
408
+ return 0
409
+
410
+
411
+ def cmd_config() -> int:
412
+ """Handle the config command."""
413
+ config = load_config()
414
+
415
+ print(f"\n {bold('Configuration')}")
416
+ print(f" {'='*30}")
417
+ print(f" Categories:")
418
+ for cat, exts in config.get("categories", {}).items():
419
+ print(f" {cat}: {len(exts)} extensions")
420
+ print(f"\n Custom rules: {len(config.get('custom_rules', []))}")
421
+ print(f" Excluded dirs: {config.get('excluded_dirs', [])}")
422
+ print(f" Confirm before move: {config.get('confirm_before_move', True)}")
423
+ print(f" Duplicate strategy: {config.get('duplicate_strategy', 'rename')}")
424
+ print()
425
+ return 0
426
+
427
+
428
+ if __name__ == "__main__":
429
+ sys.exit(main())
ezsort/config.py ADDED
@@ -0,0 +1,98 @@
1
+ """Configuration management for EzSort."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import platform
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from ezsort.constants import DEFAULT_CATEGORIES, DEFAULT_EXCLUDED_DIRS
11
+
12
+
13
+ def get_config_dir() -> Path:
14
+ """Return the platform-appropriate configuration directory."""
15
+ if platform.system() == "Windows":
16
+ base = Path.home() / "AppData" / "Roaming"
17
+ elif platform.system() == "Darwin":
18
+ base = Path.home() / "Library" / "Application Support"
19
+ else:
20
+ base = Path.home() / ".config"
21
+ return base / "ezsort"
22
+
23
+
24
+ def get_config_path() -> Path:
25
+ """Return the path to the configuration file."""
26
+ return get_config_dir() / "config.json"
27
+
28
+
29
+ def get_history_path() -> Path:
30
+ """Return the path to the history file."""
31
+ return get_config_dir() / "history.json"
32
+
33
+
34
+ def load_config() -> dict[str, Any]:
35
+ """Load configuration from disk, returning defaults if missing."""
36
+ config_path = get_config_path()
37
+ if config_path.exists():
38
+ try:
39
+ with open(config_path, "r", encoding="utf-8") as f:
40
+ return json.load(f)
41
+ except (json.JSONDecodeError, OSError):
42
+ pass
43
+ return get_default_config()
44
+
45
+
46
+ def save_config(config: dict[str, Any]) -> None:
47
+ """Save configuration to disk."""
48
+ config_path = get_config_path()
49
+ config_path.parent.mkdir(parents=True, exist_ok=True)
50
+ with open(config_path, "w", encoding="utf-8") as f:
51
+ json.dump(config, f, indent=2, ensure_ascii=False)
52
+
53
+
54
+ def get_default_config() -> dict[str, Any]:
55
+ """Return the default configuration dictionary."""
56
+ return {
57
+ "categories": DEFAULT_CATEGORIES,
58
+ "excluded_dirs": DEFAULT_EXCLUDED_DIRS,
59
+ "excluded_extensions": [],
60
+ "duplicate_strategy": "rename",
61
+ "confirm_before_move": True,
62
+ "watch_interval": 2,
63
+ "custom_rules": [],
64
+ "recursive": False,
65
+ }
66
+
67
+
68
+ def get_categories(config: dict[str, Any] | None = None) -> dict[str, list[str]]:
69
+ """Get category mappings from config or defaults."""
70
+ if config and "categories" in config:
71
+ return config["categories"]
72
+ return DEFAULT_CATEGORIES.copy()
73
+
74
+
75
+ def get_excluded_dirs(config: dict[str, Any] | None = None) -> list[str]:
76
+ """Get excluded directory names from config."""
77
+ if config and "excluded_dirs" in config:
78
+ return config["excluded_dirs"]
79
+ return DEFAULT_EXCLUDED_DIRS.copy()
80
+
81
+
82
+ def add_custom_rule(
83
+ config: dict[str, Any],
84
+ rule_type: str,
85
+ pattern: str,
86
+ category: str,
87
+ subfolder: str = "",
88
+ ) -> dict[str, Any]:
89
+ """Add a custom classification rule to config."""
90
+ if "custom_rules" not in config:
91
+ config["custom_rules"] = []
92
+ config["custom_rules"].append({
93
+ "type": rule_type,
94
+ "pattern": pattern,
95
+ "category": category,
96
+ "subfolder": subfolder,
97
+ })
98
+ return config