How do I declare custom exceptions in modern Python?
Categories:
Mastering Custom Exceptions in Modern Python
Learn how to declare, raise, and handle custom exceptions effectively in Python 3.x, enhancing code clarity and maintainability.
In robust applications, relying solely on built-in exception types can obscure the true nature of errors. Custom exceptions provide a clear, domain-specific way to signal exceptional conditions, making your code easier to understand, debug, and maintain. This article explores best practices for defining and utilizing custom exceptions in modern Python.
Why Custom Exceptions?
Custom exceptions offer several advantages over generic exceptions like ValueError
or TypeError
:
- Clarity: They explicitly state what went wrong in your application's domain.
- Specificity: Allows for more granular exception handling logic.
- Maintainability: Changes to error conditions are localized to the exception definition.
- Readability: Improves code comprehension for other developers.
Generic vs. Custom Exceptions: A Conceptual Comparison
Declaring Simple Custom Exceptions
The simplest way to declare a custom exception is by inheriting from Python's built-in Exception
class. This class is the base for all non-exit exceptions and is generally the recommended parent for your custom exceptions.
class CustomError(Exception):
"""Base class for custom exceptions in this module."""
pass
class InvalidInputError(CustomError):
"""Raised when the input provided is invalid."""
def __init__(self, message="Invalid input received", value=None):
self.message = message
self.value = value
super().__init__(self.message)
# Example usage
def process_data(data):
if not isinstance(data, str) or len(data) == 0:
raise InvalidInputError(f"Expected non-empty string, got {data!r}", value=data)
return f"Processed: {data.upper()}"
try:
process_data(123)
except InvalidInputError as e:
print(f"Error: {e.message} (Value: {e.value})")
# Output: Error: Expected non-empty string, got 123 (Value: 123)
try:
process_data("")
except InvalidInputError as e:
print(f"Error: {e.message} (Value: {e.value})")
# Output: Error: Expected non-empty string, got '' (Value: )
Defining and raising a basic custom exception
CustomError
above). This allows you to catch all exceptions specific to your application with a single except CustomError:
block.Leveraging dataclasses
for Richer Exceptions (Python 3.7+)
For more complex exceptions that need to carry additional data beyond a simple message, Python's dataclasses
module (introduced in Python 3.7) provides a concise way to define attributes. This aligns with PEP 352, which recommends that exceptions should be 'simple classes' that can carry arbitrary attributes.
from dataclasses import dataclass
@dataclass
class ApplicationError(Exception):
"""Base application error for structured exceptions."""
message: str = "An application error occurred"
code: int = 500
@dataclass
class UnauthorizedAccessError(ApplicationError):
"""Raised when a user attempts to access a protected resource without proper authentication/authorization."""
user_id: str
resource: str
message: str = "Unauthorized access attempt"
code: int = 401
# Example usage
def check_access(user_id, resource):
if user_id != "admin" and resource == "/admin-panel":
raise UnauthorizedAccessError(user_id=user_id, resource=resource)
return f"Access granted for {user_id} to {resource}"
try:
check_access("guest", "/admin-panel")
except UnauthorizedAccessError as e:
print(f"Error: {e.message} (User: {e.user_id}, Resource: {e.resource}, Code: {e.code})")
# Output: Error: Unauthorized access attempt (User: guest, Resource: /admin-panel, Code: 401)
try:
print(check_access("admin", "/admin-panel"))
# Output: Access granted for admin to /admin-panel
except UnauthorizedAccessError as e:
pass # Should not be reached
Using dataclasses
to create data-rich custom exceptions
dataclasses
with exceptions, remember that the __init__
method generated by the dataclass might override a custom __init__
you define. If you need custom initialization logic, you might need to manually call super().__init__(...)
or use __post_init__
.Handling Custom Exceptions
Handling custom exceptions is no different from handling built-in ones. You use try...except
blocks, catching your specific custom exception types. This allows you to implement specific recovery logic or provide tailored error messages to the user.
class NetworkError(Exception):
pass
class ConnectionTimeout(NetworkError):
def __init__(self, host, port, timeout):
super().__init__(f"Connection to {host}:{port} timed out after {timeout}s")
self.host = host
self.port = port
self.timeout = timeout
def connect_to_service(host, port, timeout):
# Simulate network operation
if host == "bad.example.com":
raise ConnectionTimeout(host, port, timeout)
print(f"Successfully connected to {host}:{port}")
try:
connect_to_service("good.example.com", 8080, 5)
except ConnectionTimeout as e:
print(f"Failed to connect: {e}")
print(f"Please check network to {e.host}:{e.port} or increase timeout.")
try:
connect_to_service("bad.example.com", 8080, 5)
except ConnectionTimeout as e:
print(f"Failed to connect: {e}")
print(f"Please check network to {e.host}:{e.port} or increase timeout.")
Handling specific custom exceptions
NetworkError
s) while still allowing for specific handling of subclasses (e.g., ConnectionTimeout
).Custom exceptions are a powerful tool for writing clean, robust, and understandable Python code. By defining specific error conditions within your application's domain, you enhance clarity, enable precise error handling, and improve overall maintainability. Embrace them to make your Python applications more resilient and user-friendly.