Ultimately, all non-exit exceptions in Python are subclasses of the builtin Exception class.

#The following examples shows that all exceptions are derived from the Exception class
print(issubclass(ValueError, Exception))
print(issubclass(IndexError, Exception))
print(issubclass(TypeError, Exception))
print(issubclass(KeyError, Exception))

We can define our own custom exceptions by creating a class derived from the Exception class.

#Define a custom exception
class customClass(Exception):
     #statements

After a class inherits from the Exception class, it immediately  becomes an Exception just like the builtin ones. You can raise it normally using the raise statement  and you can also handle it using the try-except blocks,.

class ExampleException(Exception):
    pass

raise ExampleException

Customizing User-defined Exceptions

We can customize the custom exceptions to suit our needs. However, you will need a basic understanding of the fundamental concepts of object oriented programming in order to follow through.

By defining the __init__() method for our custom exception class, we can be able to pass custom arguments to the exception.As shown below:

class NotEligibleException(Exception):

    #Define the class c
    def __init__(self, age, *args, **kwargs):
        self.age = age
        super().__init__(*args, **kwargs)
        self.message = f"age {age} is not eligible for voting"

    def __str__(self):
         return self.message

age = 10
if age < 18:
    raise NotEligibleException(age)

In the above example, we defined the NotEligibleException class which is meant to be raised if the target age is not eligible for voting. The exception takes an age parameter which is used in customizing the display message.  

We can literally override any valid magic/dunder method for the custom exception. When we do this we can effectively define the behavior of our custom class to suit the program's need.

Inheriting from standard Exceptions

Custom exception can inherit from other standard exception classes rather than inheriting directly from the base Exception class. This enables custom exceptions to take advantage of existing behavior from other standard exceptions.

class NetworkError(RuntimeError):
      def __init__(self, message, *args, **kwargs):
           super().__init__(*args, **kwargs)
           self.message = message
      def __str__(self):
           return self.message

try:
    raise NetworkError("An error accurred.")
except NetworkError as e:
      print(e.message)