ptdu 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.
ptdu/errors.py ADDED
@@ -0,0 +1,627 @@
1
+ """Error handling and user feedback for PTDU."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tkinter as tk
7
+ from dataclasses import dataclass
8
+ from enum import Enum, auto
9
+ from pathlib import Path
10
+ from tkinter import messagebox
11
+ from typing import Callable, Optional
12
+
13
+
14
+ class ErrorSeverity(Enum):
15
+ """Error severity levels."""
16
+
17
+ INFO = auto()
18
+ WARNING = auto()
19
+ ERROR = auto()
20
+ CRITICAL = auto()
21
+
22
+
23
+ @dataclass
24
+ class PTDUError:
25
+ """Structured error information."""
26
+
27
+ message: str
28
+ severity: ErrorSeverity
29
+ path: Optional[Path] = None
30
+ suggestion: Optional[str] = None
31
+ recoverable: bool = True
32
+
33
+
34
+ class ErrorHandler:
35
+ """Centralized error handling with user-friendly dialogs."""
36
+
37
+ def __init__(self, parent: Optional[tk.Tk] = None) -> None:
38
+ """Initialize error handler.
39
+
40
+ Args:
41
+ parent: Parent Tk window for dialogs
42
+ """
43
+ self._parent: Optional[tk.Tk] = parent
44
+ self._error_history: list[PTDUError] = []
45
+ self._max_history: int = 100
46
+ self._silent_mode: bool = False
47
+
48
+ def set_parent(self, parent: tk.Tk) -> None:
49
+ """Set parent window for dialogs.
50
+
51
+ Args:
52
+ parent: Tk root window
53
+ """
54
+ self._parent = parent
55
+
56
+ def set_silent_mode(self, silent: bool) -> bool:
57
+ """Set silent mode (suppress dialogs).
58
+
59
+ Args:
60
+ silent: True to suppress dialogs
61
+
62
+ Returns:
63
+ Previous silent mode state
64
+ """
65
+ previous = self._silent_mode
66
+ self._silent_mode = silent
67
+ return previous
68
+
69
+ def handle_permission_denied(
70
+ self,
71
+ path: Path,
72
+ operation: str = "access",
73
+ callback: Optional[Callable[[], None]] = None,
74
+ ) -> bool:
75
+ """Handle permission denied error with user dialog.
76
+
77
+ Args:
78
+ path: Path that caused permission error
79
+ operation: Operation being attempted (access, read, delete, etc.)
80
+ callback: Optional callback for retry
81
+
82
+ Returns:
83
+ True if user wants to continue, False to abort
84
+ """
85
+ error = PTDUError(
86
+ message=f"Permission denied: Cannot {operation} {path.name}",
87
+ severity=ErrorSeverity.ERROR,
88
+ path=path,
89
+ suggestion="Try running with elevated permissions or check file permissions",
90
+ recoverable=True,
91
+ )
92
+
93
+ self._log_error(error)
94
+
95
+ if self._silent_mode:
96
+ return True
97
+
98
+ # Build dialog message
99
+ msg = f"Permission denied\n\n"
100
+ msg += f"Cannot {operation}:\n{path}\n\n"
101
+
102
+ # Add helpful context
103
+ if path.exists():
104
+ try:
105
+ stat = os.stat(path)
106
+ msg += f"Owner: {stat.st_uid}, Group: {stat.st_gid}\n"
107
+ msg += f"Permissions: {oct(stat.st_mode)[-3:]}\n\n"
108
+ except (OSError, PermissionError):
109
+ pass
110
+
111
+ msg += "You may need to run with sudo or check file permissions."
112
+
113
+ # Show dialog with retry option if callback provided
114
+ if callback is not None:
115
+ result = messagebox.askretrycancel(
116
+ "Permission Denied",
117
+ msg,
118
+ icon=messagebox.ERROR,
119
+ parent=self._parent,
120
+ )
121
+ if result:
122
+ callback()
123
+ return True
124
+ return False
125
+ else:
126
+ messagebox.showerror(
127
+ "Permission Denied",
128
+ msg,
129
+ parent=self._parent,
130
+ )
131
+ return True
132
+
133
+ def handle_path_too_long(self, path: Path) -> bool:
134
+ """Handle long path error.
135
+
136
+ Args:
137
+ path: Path that exceeds length limits
138
+
139
+ Returns:
140
+ True if handled, False if critical
141
+ """
142
+ # Check actual path length
143
+ path_str = str(path)
144
+ path_len = len(path_str)
145
+
146
+ error = PTDUError(
147
+ message=f"Path too long ({path_len} characters)",
148
+ severity=ErrorSeverity.WARNING,
149
+ path=path,
150
+ suggestion="Consider using a shorter path or enabling long path support",
151
+ recoverable=True,
152
+ )
153
+
154
+ self._log_error(error)
155
+
156
+ if self._silent_mode:
157
+ return True
158
+
159
+ msg = f"Path is very long ({path_len} characters)\n\n"
160
+ msg += f"{path_str[:100]}...\n\n"
161
+
162
+ if path_len > 4096:
163
+ msg += "This path exceeds typical filesystem limits and may cause issues."
164
+ severity = messagebox.ERROR
165
+ else:
166
+ msg += "This path may cause issues on some systems."
167
+ severity = messagebox.WARNING
168
+
169
+ messagebox.showwarning(
170
+ "Long Path Warning",
171
+ msg,
172
+ parent=self._parent,
173
+ )
174
+ return True
175
+
176
+ def handle_scan_error(
177
+ self,
178
+ path: Path,
179
+ error: Exception,
180
+ continue_scan: bool = True,
181
+ ) -> bool:
182
+ """Handle scanning error.
183
+
184
+ Args:
185
+ path: Path being scanned
186
+ error: Exception that occurred
187
+ continue_scan: Whether to offer continue option
188
+
189
+ Returns:
190
+ True to continue, False to abort
191
+ """
192
+ error_msg = str(error)
193
+ severity = ErrorSeverity.WARNING if continue_scan else ErrorSeverity.ERROR
194
+
195
+ # Categorize common errors
196
+ if "permission" in error_msg.lower():
197
+ return self.handle_permission_denied(path, "scan")
198
+ elif "too long" in error_msg.lower() or len(str(path)) > 4000:
199
+ return self.handle_path_too_long(path)
200
+
201
+ ptdu_error = PTDUError(
202
+ message=f"Scan error: {error_msg}",
203
+ severity=severity,
204
+ path=path,
205
+ suggestion="Check if the path exists and is accessible",
206
+ recoverable=continue_scan,
207
+ )
208
+
209
+ self._log_error(ptdu_error)
210
+
211
+ if self._silent_mode:
212
+ return continue_scan
213
+
214
+ msg = f"Error scanning:\n{path}\n\n"
215
+ msg += f"Error: {error_msg}\n\n"
216
+
217
+ if continue_scan:
218
+ msg += "Continue scanning other directories?"
219
+ result = messagebox.askyesno(
220
+ "Scan Error",
221
+ msg,
222
+ icon=messagebox.WARNING,
223
+ parent=self._parent,
224
+ )
225
+ return result
226
+ else:
227
+ messagebox.showerror(
228
+ "Scan Error",
229
+ msg,
230
+ parent=self._parent,
231
+ )
232
+ return False
233
+
234
+ def handle_delete_error(
235
+ self,
236
+ path: Path,
237
+ error: Exception,
238
+ ) -> tuple[bool, bool]:
239
+ """Handle delete operation error.
240
+
241
+ Args:
242
+ path: Path being deleted
243
+ error: Exception that occurred
244
+
245
+ Returns:
246
+ Tuple of (retry, abort_all)
247
+ """
248
+ error_msg = str(error)
249
+
250
+ if "permission" in error_msg.lower():
251
+ return self._handle_delete_permission_denied(path)
252
+ elif "not empty" in error_msg.lower():
253
+ return self._handle_delete_not_empty(path)
254
+
255
+ ptdu_error = PTDUError(
256
+ message=f"Delete error: {error_msg}",
257
+ severity=ErrorSeverity.ERROR,
258
+ path=path,
259
+ suggestion="Check file permissions and if file is in use",
260
+ recoverable=True,
261
+ )
262
+
263
+ self._log_error(ptdu_error)
264
+
265
+ if self._silent_mode:
266
+ return False, False
267
+
268
+ msg = f"Could not delete:\n{path}\n\n"
269
+ msg += f"Error: {error_msg}"
270
+
271
+ result = messagebox.askyesnocancel(
272
+ "Delete Error",
273
+ msg + "\n\nYes = Retry, No = Skip, Cancel = Abort all",
274
+ icon=messagebox.ERROR,
275
+ parent=self._parent,
276
+ )
277
+
278
+ if result is True: # Retry
279
+ return True, False
280
+ elif result is False: # Skip
281
+ return False, False
282
+ else: # Cancel
283
+ return False, True
284
+
285
+ def _handle_delete_permission_denied(
286
+ self,
287
+ path: Path,
288
+ ) -> tuple[bool, bool]:
289
+ """Handle delete permission error.
290
+
291
+ Args:
292
+ path: Path being deleted
293
+
294
+ Returns:
295
+ Tuple of (retry, abort_all)
296
+ """
297
+ if self._silent_mode:
298
+ return False, False
299
+
300
+ msg = f"Permission denied when deleting:\n{path}\n\n"
301
+ msg += "You may need elevated permissions to delete this item."
302
+
303
+ result = messagebox.askyesnocancel(
304
+ "Delete Permission Denied",
305
+ msg + "\n\nYes = Retry, No = Skip, Cancel = Abort all",
306
+ icon=messagebox.ERROR,
307
+ parent=self._parent,
308
+ )
309
+
310
+ if result is True:
311
+ return True, False
312
+ elif result is False:
313
+ return False, False
314
+ else:
315
+ return False, True
316
+
317
+ def _handle_delete_not_empty(self, path: Path) -> tuple[bool, bool]:
318
+ """Handle directory not empty error.
319
+
320
+ Args:
321
+ path: Path being deleted
322
+
323
+ Returns:
324
+ Tuple of (retry, abort_all)
325
+ """
326
+ if self._silent_mode:
327
+ return False, False
328
+
329
+ msg = f"Directory not empty:\n{path}\n\n"
330
+ msg += "The directory contains items that couldn't be removed."
331
+
332
+ result = messagebox.askyesnocancel(
333
+ "Directory Not Empty",
334
+ msg + "\n\nYes = Retry, No = Skip, Cancel = Abort all",
335
+ icon=messagebox.WARNING,
336
+ parent=self._parent,
337
+ )
338
+
339
+ if result is True:
340
+ return True, False
341
+ elif result is False:
342
+ return False, False
343
+ else:
344
+ return False, True
345
+
346
+ def handle_export_error(
347
+ self,
348
+ path: Path,
349
+ error: Exception,
350
+ ) -> None:
351
+ """Handle export operation error.
352
+
353
+ Args:
354
+ path: Export path
355
+ error: Exception that occurred
356
+ """
357
+ error_msg = str(error)
358
+
359
+ ptdu_error = PTDUError(
360
+ message=f"Export error: {error_msg}",
361
+ severity=ErrorSeverity.ERROR,
362
+ path=path,
363
+ suggestion="Check if destination is writable and has available space",
364
+ recoverable=True,
365
+ )
366
+
367
+ self._log_error(ptdu_error)
368
+
369
+ if self._silent_mode:
370
+ return
371
+
372
+ msg = f"Could not export to:\n{path}\n\n"
373
+ msg += f"Error: {error_msg}"
374
+
375
+ messagebox.showerror(
376
+ "Export Error",
377
+ msg,
378
+ parent=self._parent,
379
+ )
380
+
381
+ def handle_cache_error(
382
+ self,
383
+ error: Exception,
384
+ operation: str = "access",
385
+ ) -> None:
386
+ """Handle cache operation error (non-critical).
387
+
388
+ Args:
389
+ error: Exception that occurred
390
+ operation: Cache operation being performed
391
+ """
392
+ # Cache errors are non-critical, just log them
393
+ error_msg = str(error)
394
+
395
+ ptdu_error = PTDUError(
396
+ message=f"Cache {operation} error: {error_msg}",
397
+ severity=ErrorSeverity.WARNING,
398
+ suggestion="Cache will be disabled for this session",
399
+ recoverable=True,
400
+ )
401
+
402
+ self._log_error(ptdu_error)
403
+
404
+ # Only show in non-silent mode and if parent exists
405
+ if not self._silent_mode and self._parent is not None:
406
+ # Don't show dialog for cache errors, just log
407
+ pass
408
+
409
+ def show_info(self, title: str, message: str) -> None:
410
+ """Show info dialog.
411
+
412
+ Args:
413
+ title: Dialog title
414
+ message: Message to display
415
+ """
416
+ if self._silent_mode:
417
+ return
418
+
419
+ messagebox.showinfo(
420
+ title,
421
+ message,
422
+ parent=self._parent,
423
+ )
424
+
425
+ def show_warning(self, title: str, message: str) -> None:
426
+ """Show warning dialog.
427
+
428
+ Args:
429
+ title: Dialog title
430
+ message: Message to display
431
+ """
432
+ if self._silent_mode:
433
+ return
434
+
435
+ messagebox.showwarning(
436
+ title,
437
+ message,
438
+ parent=self._parent,
439
+ )
440
+
441
+ def show_error(self, title: str, message: str) -> None:
442
+ """Show error dialog.
443
+
444
+ Args:
445
+ title: Dialog title
446
+ message: Message to display
447
+ """
448
+ if self._silent_mode:
449
+ return
450
+
451
+ messagebox.showerror(
452
+ title,
453
+ message,
454
+ parent=self._parent,
455
+ )
456
+
457
+ def ask_yes_no(self, title: str, message: str) -> bool:
458
+ """Ask yes/no question.
459
+
460
+ Args:
461
+ title: Dialog title
462
+ message: Question to ask
463
+
464
+ Returns:
465
+ True if yes, False if no
466
+ """
467
+ if self._silent_mode:
468
+ return True
469
+
470
+ return messagebox.askyesno(
471
+ title,
472
+ message,
473
+ parent=self._parent,
474
+ )
475
+
476
+ def _log_error(self, error: PTDUError) -> None:
477
+ """Log error to history.
478
+
479
+ Args:
480
+ error: Error to log
481
+ """
482
+ self._error_history.append(error)
483
+
484
+ # Trim history if needed
485
+ while len(self._error_history) > self._max_history:
486
+ self._error_history.pop(0)
487
+
488
+ def get_error_history(
489
+ self,
490
+ severity: Optional[ErrorSeverity] = None,
491
+ ) -> list[PTDUError]:
492
+ """Get error history.
493
+
494
+ Args:
495
+ severity: Filter by severity (None for all)
496
+
497
+ Returns:
498
+ List of errors
499
+ """
500
+ if severity is None:
501
+ return self._error_history.copy()
502
+
503
+ return [e for e in self._error_history if e.severity == severity]
504
+
505
+ def clear_history(self) -> None:
506
+ """Clear error history."""
507
+ self._error_history.clear()
508
+
509
+ def get_error_count(self, severity: Optional[ErrorSeverity] = None) -> int:
510
+ """Get count of errors.
511
+
512
+ Args:
513
+ severity: Filter by severity (None for all)
514
+
515
+ Returns:
516
+ Error count
517
+ """
518
+ if severity is None:
519
+ return len(self._error_history)
520
+
521
+ return sum(1 for e in self._error_history if e.severity == severity)
522
+
523
+
524
+ class PathValidator:
525
+ """Utility for validating and handling paths."""
526
+
527
+ # Maximum safe path length
528
+ MAX_SAFE_PATH_LENGTH: int = 4000
529
+
530
+ @classmethod
531
+ def validate_path(cls, path: Path) -> tuple[bool, Optional[str]]:
532
+ """Validate a path for operations.
533
+
534
+ Args:
535
+ path: Path to validate
536
+
537
+ Returns:
538
+ Tuple of (is_valid, error_message)
539
+ """
540
+ path_str = str(path)
541
+
542
+ # Check path length
543
+ if len(path_str) > cls.MAX_SAFE_PATH_LENGTH:
544
+ return (
545
+ False,
546
+ f"Path exceeds maximum length ({len(path_str)} > {cls.MAX_SAFE_PATH_LENGTH})",
547
+ )
548
+
549
+ # Check if path exists
550
+ if not path.exists():
551
+ return False, f"Path does not exist: {path}"
552
+
553
+ # Check for null bytes
554
+ if "\x00" in path_str:
555
+ return False, "Path contains null bytes"
556
+
557
+ return True, None
558
+
559
+ @classmethod
560
+ def sanitize_filename(cls, name: str) -> str:
561
+ """Sanitize a filename for safe operations.
562
+
563
+ Args:
564
+ name: Filename to sanitize
565
+
566
+ Returns:
567
+ Sanitized filename
568
+ """
569
+ # Remove or replace unsafe characters
570
+ unsafe_chars = '<>:"/\\|?*\x00-\x1f'
571
+ for char in unsafe_chars:
572
+ name = name.replace(char, "_")
573
+
574
+ # Limit length
575
+ max_len = 255
576
+ if len(name) > max_len:
577
+ name = name[:max_len]
578
+
579
+ return name
580
+
581
+ @classmethod
582
+ def get_path_display_name(cls, path: Path, max_length: int = 60) -> str:
583
+ """Get display name for a path, truncated if necessary.
584
+
585
+ Args:
586
+ path: Path to display
587
+ max_length: Maximum length
588
+
589
+ Returns:
590
+ Display string
591
+ """
592
+ path_str = str(path)
593
+
594
+ if len(path_str) <= max_length:
595
+ return path_str
596
+
597
+ # Truncate middle
598
+ prefix_len = max_length // 3
599
+ suffix_len = max_length - prefix_len - 3
600
+
601
+ return f"{path_str[:prefix_len]}...{path_str[-suffix_len:]}"
602
+
603
+
604
+ # Global error handler instance
605
+ _global_error_handler: Optional[ErrorHandler] = None
606
+
607
+
608
+ def get_error_handler() -> ErrorHandler:
609
+ """Get global error handler instance.
610
+
611
+ Returns:
612
+ ErrorHandler instance
613
+ """
614
+ global _global_error_handler
615
+ if _global_error_handler is None:
616
+ _global_error_handler = ErrorHandler()
617
+ return _global_error_handler
618
+
619
+
620
+ def set_error_handler(handler: ErrorHandler) -> None:
621
+ """Set global error handler.
622
+
623
+ Args:
624
+ handler: ErrorHandler to use
625
+ """
626
+ global _global_error_handler
627
+ _global_error_handler = handler