Explain Codes LogoExplain Codes Logo

Proper way to declare custom exceptions in modern Python?

python
exception-handling
oop
python-exceptions
Alex KataevbyAlex Kataev·Nov 12, 2024
TLDR

Here's how to get started with custom exceptions: extend Python's built-in Exception class, like so:

class MyCustomError(Exception): pass # Hard pass, like a doorman at an exclusive club

For some added punch, whip up an __init__ method for personalized messages or attributes:

class MyValidationError(ValueError): def __init__(self, message, errors=None): super().__init__(message) # Calling Home(BaseClass), E.T. style! if errors is None: errors = [] self.errors = errors # Pack your baggage in errors suitcase.

That's your minimal, yet effective, custom exception blueprint! Fear no more, exceptions are under your control.

Nailing custom exceptions, step-by-step

Venturing into the world of custom exceptions? Here's your playbook to make your exceptions play nice with the Python gang.

Embrace the OOP way-of-life

Design your custom exceptions by strapping them with encapsulation and inheritance:

class DataValidationError(Exception): """Exception raised for errors in the input data, more irritating than a typo in your tweet! Attributes: message -- explanation of the error errors -- list of errors during validation. Yes, it can be more than one! """ def __init__(self, message, errors=None): super().__init__(message) if errors is None: errors = [] self.errors = errors

Here, DataValidationError inherits from Exception, adds a errors attribute which can be a list carrying multiple issues found during validation. Handle with care!

Build exceptions to be robust

Design your exceptions to tackle variable argument lengths, exactly like Python's own native exceptions:

class MultipleIssuesError(Exception): def __init__(self, *args): super().__init__(*args) self.issues = args # Gather all issues. More the merrier!

... or store your attributes in legacy args, master in disguise:

class ConfigError(Exception): def __init__(self, message, filename, line): super().__init__(message, filename, line) self.filename = filename # Hiding like Waldo in a puzzle self.line = line # This one's easy! No Waldo here!

Constructor trusts base exception to hold message while, with a smoke screen, assigns custom attributes.

Apply the Liskov Substitution Principle (LSP)

The base exception and its derived exceptions should be as replaceable as a pair of comfortable old shoes:

class NetworkError(IOError): pass class HostnameError(NetworkError): pass

Exception at module-level

Declare exceptions at module level, like a commander leading from the front, since it's a key part of your module's API:

# In network_exceptions.py class NetworkError(IOError): """Network down? Blame this guy.""" ... # In client_code.py from network_exceptions import NetworkError # Bring out the big guns

Make your doc speak

Use docstrings and comments to let your custom exceptions speak for themselves:

# In document_storage_exceptions.py class DocumentRetrievalError(Exception): """Raised when a document couldn't be retrieved, ex. when it's out playing hide and seek.""" def __init__(self, document_id, reason): super().__init__(f"Document {document_id} couldn't be retrieved: {reason}") self.document_id = document_id self.reason = reason

Keeping things explicit

Give a thumbs up to named attributes over generic dictionaries to store additional data. Clarity loves company!

class AppConfigurationError(Exception): def __init__(self, message, missing_keys): super().__init__(message) self.missing_keys = missing_keys # Keep keys safe, or they go missing!

Specificity is your friend

Be specific with exceptions, just like a suspect description in a detective story. Go for intuitive and clear naming, leave no room for doubt:

class InsufficientBalanceError(ValueError): """Raised when an account balance isn't enough for a transaction, like on a Black Friday shopping spree!."""

Reuse? Yes please!

Before playing Dr. Frankenstein, try resizing the shoes of an existing exception type to fit your needs.

Make __str__ meaningful

Override __str__ only for some extra sauce, like BBQ on your ribs.

class DetailedError(Exception): def __init__(self, detail, code): self.detail = detail self.code = code super().__init__(f"Error {code}: {detail}") def __str__(self): return f"[{self.code}] {self.detail}" # Now that's a detailed error message!

Create a logical exception hierarchy

A clear exception hierarchy is good manners. Be polite, have it make common sense:

class ConnectionError(NetworkError): pass class TimeoutError(ConnectionError): # 'cause connections are like conversations. Sometimes they time out! pass

Pickling considerations

Thinking serialization/pickling? Prepare default attributes and consider defining __reduce__().

The field manual: usage and concerns

Optimized error handling = golden parachutes

Exception handling can mean gracefully touching down or crash landing. Be graceful—provide examples:

try: process_data(data) except DataValidationError as e: handle_errors(e.errors) # Soft landing. Point. Smile. Wave!

What about legacy support?

Mindful code respects the elders. Make your custom exceptions play nice with older Python versions.

Think before creating new exceptions

Play architect, think if a new tower (read: custom exception) is even necessary to touch your skyline or is it just making a crowded scene. Keep that balance.

Exception-raising 101

Exceptions are not regular passengers. They signal there's a bump on the road, don't confuse it with traffic control instructions. Exceptions are not a mechanism for normal program flow, they are signaling system for abnormal conditions.