""" ================================================================================ File: 02_custom_exceptions.py Topic: Creating and Using Custom Exceptions ================================================================================ This file demonstrates how to create your own exception classes in Python. Custom exceptions make your code more expressive and allow you to handle specific error conditions in a meaningful way. Key Concepts: - Why use custom exceptions - Creating exception classes - Exception with custom attributes - Exception chaining - Best practices ================================================================================ """ # ----------------------------------------------------------------------------- # 1. Why Custom Exceptions? # ----------------------------------------------------------------------------- print("--- Why Custom Exceptions? ---") # Built-in exceptions are generic # Custom exceptions: # - Are more descriptive # - Can carry additional data # - Allow specific handling # - Document error conditions print("Benefits of custom exceptions:") print(" - More meaningful error messages") print(" - Carry context-specific data") print(" - Enable targeted exception handling") print(" - Self-documenting code") # ----------------------------------------------------------------------------- # 2. Basic Custom Exception # ----------------------------------------------------------------------------- print("\n--- Basic Custom Exception ---") # Inherit from Exception (or a more specific built-in) class InvalidAgeError(Exception): """Raised when an age value is invalid.""" pass # Using the custom exception def set_age(age): """Set age with validation.""" if age < 0: raise InvalidAgeError("Age cannot be negative") if age > 150: raise InvalidAgeError("Age seems unrealistically high") return age # Catching custom exception try: set_age(-5) except InvalidAgeError as e: print(f"Caught InvalidAgeError: {e}") try: set_age(200) except InvalidAgeError as e: print(f"Caught InvalidAgeError: {e}") # ----------------------------------------------------------------------------- # 3. Custom Exception with Attributes # ----------------------------------------------------------------------------- print("\n--- Exception with Custom Attributes ---") class ValidationError(Exception): """Raised when validation fails.""" def __init__(self, field, value, message): self.field = field self.value = value self.message = message # Call parent constructor with full message super().__init__(f"{field}: {message} (got: {value})") def to_dict(self): """Convert error to dictionary (useful for API responses).""" return { "field": self.field, "value": self.value, "message": self.message } # Using the enhanced exception def validate_email(email): """Validate email format.""" if not email: raise ValidationError("email", email, "Email is required") if "@" not in email: raise ValidationError("email", email, "Invalid email format") return True try: validate_email("invalid-email") except ValidationError as e: print(f"Error: {e}") print(f"Field: {e.field}") print(f"Value: {e.value}") print(f"As dict: {e.to_dict()}") # ----------------------------------------------------------------------------- # 4. Exception Hierarchy # ----------------------------------------------------------------------------- print("\n--- Exception Hierarchy ---") # Create a base exception for your module/application class AppError(Exception): """Base exception for the application.""" pass class DatabaseError(AppError): """Database-related errors.""" pass class ConnectionError(DatabaseError): """Database connection errors.""" pass class QueryError(DatabaseError): """Query execution errors.""" pass class AuthenticationError(AppError): """Authentication-related errors.""" pass class PermissionError(AppError): """Permission-related errors.""" pass # Now you can catch broadly or specifically def database_operation(): raise QueryError("Invalid SQL syntax") try: database_operation() except DatabaseError as e: # Catches ConnectionError and QueryError print(f"Database error: {e}") except AppError as e: # Catches all app errors print(f"App error: {e}") # ----------------------------------------------------------------------------- # 5. Exception Chaining # ----------------------------------------------------------------------------- print("\n--- Exception Chaining ---") class ProcessingError(Exception): """Error during data processing.""" pass def parse_config(data): """Parse configuration data.""" try: # Simulating a parsing error if not data: raise ValueError("Empty data") return {"parsed": data} except ValueError as e: # Chain the original exception raise ProcessingError("Failed to parse config") from e try: parse_config("") except ProcessingError as e: print(f"Processing error: {e}") print(f"Caused by: {e.__cause__}") # Using 'from None' to suppress chaining def simple_error(): try: raise ValueError("original") except ValueError: raise RuntimeError("new error") from None # Hides original # ----------------------------------------------------------------------------- # 6. Practical Example: User Registration # ----------------------------------------------------------------------------- print("\n--- Practical Example: User Registration ---") class RegistrationError(Exception): """Base class for registration errors.""" pass class UsernameError(RegistrationError): """Username-related errors.""" def __init__(self, username, reason): self.username = username self.reason = reason super().__init__(f"Username '{username}': {reason}") class PasswordError(RegistrationError): """Password-related errors.""" def __init__(self, issues): self.issues = issues super().__init__(f"Password issues: {', '.join(issues)}") class EmailError(RegistrationError): """Email-related errors.""" pass def validate_username(username): """Validate username.""" if len(username) < 3: raise UsernameError(username, "Too short (min 3 chars)") if not username.isalnum(): raise UsernameError(username, "Must be alphanumeric") # Check if username exists (simulated) existing = ["admin", "root", "user"] if username.lower() in existing: raise UsernameError(username, "Already taken") return True def validate_password(password): """Validate password strength.""" issues = [] if len(password) < 8: issues.append("too short") if not any(c.isupper() for c in password): issues.append("needs uppercase") if not any(c.islower() for c in password): issues.append("needs lowercase") if not any(c.isdigit() for c in password): issues.append("needs digit") if issues: raise PasswordError(issues) return True def register_user(username, password, email): """Register a new user.""" try: validate_username(username) validate_password(password) # validate_email(email) - already defined above print(f" ✓ User '{username}' registered successfully!") return True except RegistrationError as e: print(f" ✗ Registration failed: {e}") return False # Test registration print("Registration attempts:") register_user("ab", "password123", "test@example.com") register_user("admin", "Password1", "test@example.com") register_user("newuser", "weak", "test@example.com") register_user("newuser", "StrongPass123", "test@example.com") # ----------------------------------------------------------------------------- # 7. Context Manager with Exceptions # ----------------------------------------------------------------------------- print("\n--- Exception in Context Manager ---") class ManagedResource: """Resource with cleanup that handles exceptions.""" def __init__(self, name): self.name = name def __enter__(self): print(f" Acquiring resource: {self.name}") return self def __exit__(self, exc_type, exc_val, exc_tb): print(f" Releasing resource: {self.name}") if exc_type is not None: print(f" Exception occurred: {exc_type.__name__}: {exc_val}") # Return True to suppress the exception # Return False to propagate it return False # Don't suppress exceptions # Using the context manager print("Context manager with exception:") try: with ManagedResource("database") as resource: print(f" Using {resource.name}") raise RuntimeError("Something went wrong!") except RuntimeError: print(" Caught exception outside") # ----------------------------------------------------------------------------- # 8. Re-raising Exceptions # ----------------------------------------------------------------------------- print("\n--- Re-raising Exceptions ---") def process_data(data): """Process data with logging and re-raise.""" try: # Processing that might fail if not data: raise ValueError("Empty data") return data.upper() except ValueError as e: print(f" Logging error: {e}") raise # Re-raise the same exception try: process_data("") except ValueError as e: print(f"Caught re-raised exception: {e}") # ----------------------------------------------------------------------------- # 9. Exception Documentation # ----------------------------------------------------------------------------- print("\n--- Exception Documentation ---") class APIError(Exception): """ Base exception for API errors. Attributes: status_code: HTTP status code message: Human-readable error message error_code: Application-specific error code Example: >>> raise APIError(404, "Resource not found", "NOT_FOUND") """ def __init__(self, status_code: int, message: str, error_code: str = None): """ Initialize API error. Args: status_code: HTTP status code (e.g., 400, 404, 500) message: Human-readable error description error_code: Optional application error code """ self.status_code = status_code self.message = message self.error_code = error_code super().__init__(message) def to_response(self): """Convert to API response format.""" return { "error": { "code": self.error_code, "message": self.message, "status": self.status_code } } # Using documented exception try: raise APIError(404, "User not found", "USER_NOT_FOUND") except APIError as e: print(f"API Error Response: {e.to_response()}") # ----------------------------------------------------------------------------- # 10. Best Practices # ----------------------------------------------------------------------------- print("\n--- Best Practices ---") best_practices = """ 1. Inherit from Exception, not BaseException - BaseException includes SystemExit, KeyboardInterrupt 2. Create a hierarchy for related exceptions - Base exception for your module/app - Specific exceptions inherit from base 3. Add useful attributes - Include context that helps debugging - Provide methods for serialization (API responses) 4. Document your exceptions - What conditions trigger them - What attributes they have - How to handle them 5. Use meaningful names - End with 'Error' or 'Exception' - Be specific: UserNotFoundError, not JustError 6. Don't over-catch - except Exception: catches too much - Be specific about what you expect 7. Re-raise when appropriate - Log and re-raise for debugging - Transform to appropriate exception type 8. Use exception chaining - Use 'from' to preserve original exception - Helps with debugging complex systems """ print(best_practices)