39.6 Adding Context to Exceptions: Arguments and Attributes
When an exception is raised, the most basic information it provides is its type. However, in nearly all real-world scenarios, this alone is insufficient for effective debugging and error handling. The true power of exceptions is unlocked by attaching contextual information—specific details about the state of the program, the invalid data, or the failed operation at the moment the error occurred. This context transforms a generic error into a precise, actionable diagnostic message.
Using Arguments with Built-in Exceptions
The most straightforward method for adding context is to pass arguments when raising a built-in exception. Most built-in exception classes accept a variable number of arguments, which are stored in the args tuple attribute of the exception object. The string representation of the exception (str(e)) is built from these arguments.
def calculate_square_root(x):
if x < 0:
# Pass the invalid value as an argument
raise ValueError("Input must be non-negative", x)
return x ** 0.5
try:
result = calculate_square_root(-9)
except ValueError as e:
print(f"Error type: {type(e).__name__}")
print(f"Error message: {e}") # This prints the tuple as a string
print(f"Arguments tuple: {e.args}")
# Output:
# Error type: ValueError
# Error message: ('Input must be non-negative', -9)
# Arguments tuple: ('Input must be non-negative', -9)
# You can access individual arguments
message, invalid_value = e.args
print(f"Failed with value: {invalid_value}")
This approach is simple and effective for small amounts of data. However, a common pitfall is relying on string manipulation to parse the error message later. Instead, always access the .args tuple or, even better, define specific attributes on a custom exception.
Storing Context in Custom Exception Attributes
For more complex exceptions, especially custom ones, the best practice is to store contextual information in explicitly named attributes. This creates a stable, intuitive interface for anyone handling your exception. They can access data directly by attribute name without unpacking a tuple or parsing a string, which makes the code more readable and robust.
class ConfigurationError(Exception):
"""Exception raised for errors in the application configuration."""
def __init__(self, message, config_file, section):
super().__init__(message)
self.config_file = config_file
self.section = section
# Optionally, override __str__ to include the context automatically
def __str__(self):
return f"{super().__str__()} (File: {self.config_file}, Section: {self.section})"
def load_config(config_file, section):
# Simulate an error finding the section
if section != 'database':
raise ConfigurationError("Missing required section", config_file, section)
try:
load_config('app.conf', 'cache')
except ConfigurationError as e:
print(f"Configuration failed: {e}")
# Handle the error using the specific attributes
print(f"Please check section '{e.section}' in file: {e.config_file}")
This method is superior because it is self-documenting. A developer catching a ConfigurationError knows immediately that they can access .config_file and .section. It avoids the ambiguity of a generic tuple and prevents breaking changes if you later decide to add more arguments.
The Importance of Calling super().init()
When creating a custom exception with attributes, it is crucial to call super().__init__(). The base Exception class is responsible for populating the .args tuple. If you override __init__ without calling the parent constructor, the .args tuple will be empty, and the default string representation of your exception will be broken. Always pass the main error message to the parent class.
class IncorrectException(Exception):
"""This implementation is broken."""
def __init__(self, important_value):
self.important_value = important_value
# BUG: Forgot to call super().__init__()
class CorrectException(Exception):
"""This implementation is correct."""
def __init__(self, message, important_value):
super().__init__(message) # Essential for proper behavior
self.important_value = important_value
# Using the broken version
e_incorrect = IncorrectException(42)
print(e_incorrect.args) # Output: ()
print(str(e_incorrect)) # Output: <__main__.IncorrectException object at 0x...>
# Using the correct version
e_correct = CorrectException("An error occurred", 42)
print(e_correct.args) # Output: ('An error occurred',)
print(str(e_correct)) # Output: An error occurred
Best Practices for Contextual Information
- Be Specific but Concise: Provide enough context to diagnose the problem without dumping large objects or sensitive data (like passwords) into the exception.
- Use Attributes for Stability: Prefer named attributes over positional arguments for anything beyond a simple message. This creates a formal API for your exception.
- Consider Overriding str: For custom exceptions, overriding the
__str__method can provide a highly detailed, human-readable error message automatically, incorporating all the relevant attributes. - Preserve Original Exceptions: When translating a low-level exception (e.g., an
OSErrorfrom a file operation) into a higher-level domain exception, ensure you include the original exception. This is best done with thefromkeyword (exception chaining) to preserve the full traceback.
try:
config = open('missing_file.conf').read()
except OSError as original_error:
raise ConfigurationError("Could not load config file", 'missing_file.conf', '') from original_error
# The traceback will show both the OSError and the ConfigurationError