Author: Saim Khalid

  •  Multithreading

    In Python, multithreading allows you to run multiple threads concurrently within a single process, which is also known as thread-based parallelism. This means a program can perform multiple tasks at the same time, enhancing its efficiency and responsiveness.

    Multithreading in Python is especially useful for multiple I/O-bound operations, rather than for tasks that require heavy computation.

    Generally, a computer program sequentially executes the instructions, from start to the end. Whereas, Multithreading divides the main task into more than one sub-task and executes them in an overlapping manner.

    Comparison with Processes

    An operating system is capable of handling multiple processes concurrently. It allocates a separate memory space to each process so that one process cannot access or write anything in other’s space.

    On the other hand, a thread can be considered a lightweight sub-process in a single program that shares the memory space allocated to it, facilitating easier communication and data sharing. As they are lightweight and do not require much memory overhead; they are cheaper than processes.

    multithreading

    A process always starts with a single thread (main thread). As and when required, a new thread can be started and sub task is delegated to it. Now the two threads are working in an overlapping manner. When the task assigned to the secondary thread is over, it merges with the main thread.

    A thread has a beginning, an execution sequence, and a conclusion. It has an instruction pointer that keeps track of where it is currently running within its context.

    • It can be pre-empted (interrupted)
    • It can temporarily be put on hold (also known as sleeping) while other threads are running – this is called yielding.

    Thread Handling Modules in Python

    Python’s standard library provides two main modules for managing threads: _thread and threading.

    The _thread Module

    The _thread module, also known as the low-level thread module, has been a part of Python’s standard library since version 2. It offers a basic API for thread management, supporting concurrent execution of threads within a shared global data space. The module includes simple locks (mutexes) for synchronization purposes.

    The threading Module

    The threading module, introduced in Python 2.4, builds upon _thread to provide a higher-level and more comprehensive threading API. It offers powerful tools for managing threads, making it easier to work with threads in Python applications.

    Key Features of the threading Module

    The threading module exposes all the methods of the thread module and provides some additional methods −

    • threading.activeCount() Returns the number of thread objects that are active.
    • threading.currentThread() Returns the number of thread objects in the caller’s thread control.
    • threading.enumerate() Returns a list of all thread objects that are currently active.

    In addition to the methods, the threading module has the Thread class that implements threading. The methods provided by the Thread class are as follows −

    • run() The run() method is the entry point for a thread.
    • start() The start() method starts a thread by calling the run method.
    • join([time]) The join() waits for threads to terminate.
    • isAlive() The isAlive() method checks whether a thread is still executing.
    • getName() The getName() method returns the name of a thread.
    • setName() The setName() method sets the name of a thread.

    Starting a New Thread

    To create and start a new thread in Python, you can use either the low-level _thread module or the higher-level threading module. The threading module is generally recommended due to its additional features and ease of use. Below, you can see both approaches.

    Starting a New Thread Using the _thread Module

    The start_new_thread() method of the _thread module provides a basic way to create and start new threads. This method provides a fast and efficient way to create new threads in both Linux and Windows. Following is the syntax of the method −

    thread.start_new_thread(function, args[, kwargs])

    This method call returns immediately, and the new thread starts executing the specified function with the given arguments. When the function returns, the thread terminates.

    Example

    This example demonstrates how to use the _thread module to create and run threads. Each thread runs the print_name function with different arguments. The time.sleep(0.5) call ensures that the main program waits for the threads to complete their execution before exiting.

    import _thread
    import time
    
    defprint_name(name,*arg):print(name,*arg)
    
    name="Tutorialspoint..."
    _thread.start_new_thread(print_name,(name,1))
    _thread.start_new_thread(print_name,(name,1,2))
    
    time.sleep(0.5)

    When the above code is executed, it produces the following result −

    Tutorialspoint... 1
    Tutorialspoint... 1 2
    

    Although it is very effective for low-level threading, but the _thread module is limited compared to the threading module, which offers more features and higher-level thread management.

    Starting a New Thread Using the Threading Module

    The threading module provides the Thread class, which is used to create and manage threads.

    Here are a few steps to start a new thread using the threading module −

    • Create a function that you want the thread to execute.
    • Then create a Thread object using the Thread class by passing the target function and its arguments.
    • Call the start method on the Thread object to begin execution.
    • Optionally, call the join method to wait for the thread to complete before proceeding.

    Example

    The following example demonstrates how to create and start threads using the threading module. It runs a function print_name that prints a name along with some arguments. This example creates two threads, starts them using the start() method, and waits for them to complete using the join method.

    import threading
    import time
    
    defprint_name(name,*args):print(name,*args)
    
    name ="Tutorialspoint..."# Create and start threads
    thread1 = threading.Thread(target=print_name, args=(name,1))
    thread2 = threading.Thread(target=print_name, args=(name,1,2))
    
    thread1.start()
    thread2.start()# Wait for threads to complete
    thread1.join()
    thread2.join()print("Threads are finished...exiting")

    When the above code is executed, it produces the following result −

    Tutorialspoint... 1
    Tutorialspoint... 1 2
    Threads are finished...exiting
    

    Synchronizing Threads

    The threading module provided with Python includes a simple-to-implement locking mechanism that allows you to synchronize threads. A new lock is created by calling the Lock() method, which returns the new lock.

    The acquire(blocking) method of the new lock object is used to force threads to run synchronously. The optional blocking parameter enables you to control whether the thread waits to acquire the lock.

    If blocking is set to 0, the thread returns immediately with a 0 value if the lock cannot be acquired and with a 1 if the lock was acquired. If blocking is set to 1, the thread blocks and wait for the lock to be released.

    The release() method of the new lock object is used to release the lock when it is no longer required.

    Example

    import threading
    import time
    
    classmyThread(threading.Thread):def__init__(self, threadID, name, counter):
    
      threading.Thread.__init__(self)
      self.threadID = threadID
      self.name = name
      self.counter = counter
    defrun(self):print("Starting "+ self.name)# Get lock to synchronize threads
      threadLock.acquire()
      print_time(self.name, self.counter,3)# Free lock to release next thread
      threadLock.release()defprint_time(threadName, delay, counter):while counter:
      time.sleep(delay)print("%s: %s"%(threadName, time.ctime(time.time())))
      counter -=1
    threadLock = threading.Lock() threads =[]# Create new threads thread1 = myThread(1,"Thread-1",1) thread2 = myThread(2,"Thread-2",2)# Start new Threads thread1.start() thread2.start()# Add threads to thread list threads.append(thread1) threads.append(thread2)# Wait for all threads to completefor t in threads:
    t.join()print("Exiting Main Thread")</pre>

    When the above code is executed, it produces the following result −

    Starting Thread-1
    Starting Thread-2
    Thread-1: Thu Mar 21 09:11:28 2013
    Thread-1: Thu Mar 21 09:11:29 2013
    Thread-1: Thu Mar 21 09:11:30 2013
    Thread-2: Thu Mar 21 09:11:32 2013
    Thread-2: Thu Mar 21 09:11:34 2013
    Thread-2: Thu Mar 21 09:11:36 2013
    Exiting Main Thread
    

    Multithreaded Priority Queue

    The Queue module allows you to create a new queue object that can hold a specific number of items. There are following methods to control the Queue −

    • get() − The get() removes and returns an item from the queue.
    • put() − The put adds item to a queue.
    • qsize() − The qsize() returns the number of items that are currently in the queue.
    • empty() − The empty( ) returns True if queue is empty; otherwise, False.
    • full() − the full() returns True if queue is full; otherwise, False.

    Example

    import queue
    import threading
    import time
    
    exitFlag =0classmyThread(threading.Thread):def__init__(self, threadID, name, q):
    
      threading.Thread.__init__(self)
      self.threadID = threadID
      self.name = name
      self.q = q
    defrun(self):print("Starting "+ self.name)
      process_data(self.name, self.q)print("Exiting "+ self.name)defprocess_data(threadName, q):whilenot exitFlag:
      queueLock.acquire()ifnot workQueue.empty():
         data = q.get()
         queueLock.release()print("%s processing %s"%(threadName, data))else:
         queueLock.release()
         time.sleep(1)
    threadList =["Thread-1","Thread-2","Thread-3"] nameList =["One","Two","Three","Four","Five"] queueLock = threading.Lock() workQueue = queue.Queue(10) threads =[] threadID =1# Create new threadsfor tName in threadList: thread = myThread(threadID, tName, workQueue) thread.start() threads.append(thread) threadID +=1# Fill the queue queueLock.acquire()for word in nameList: workQueue.put(word) queueLock.release()# Wait for queue to emptywhilenot workQueue.empty():pass# Notify threads it's time to exit exitFlag =1# Wait for all threads to completefor t in threads: t.join()print("Exiting Main Thread")

    When the above code is executed, it produces the following result −

    Starting Thread-1
    Starting Thread-2
    Starting Thread-3
    Thread-1 processing One
    Thread-2 processing Two
    Thread-3 processing Three
    Thread-1 processing Four
    Thread-2 processing Five
    Exiting Thread-3
    Exiting Thread-1
    Exiting Thread-2
    Exiting Main Thread
  • Built in Exceptions

    Built-in exceptions are pre-defined error classes in Python that handle errors and exceptional conditions in programs. They are derived from the base class “BaseException” and are part of the standard library.

    Standard Built-in Exceptions in Python

    Here is a list of Standard Exceptions available in Python −

    Sr.No.Exception Name & Description
    1ExceptionBase class for all exceptions
    2StopIterationRaised when the next() method of an iterator does not point to any object.
    3SystemExitRaised by the sys.exit() function.
    4StandardErrorBase class for all built-in exceptions except StopIteration and SystemExit.
    5ArithmeticErrorBase class for all errors that occur for numeric calculation.
    6OverflowErrorRaised when a calculation exceeds maximum limit for a numeric type.
    7FloatingPointErrorRaised when a floating point calculation fails.
    8ZeroDivisonErrorRaised when division or modulo by zero takes place for all numeric types.
    9AssertionErrorRaised in case of failure of the Assert statement.
    10AttributeErrorRaised in case of failure of attribute reference or assignment.
    11EOFErrorRaised when there is no input from either the raw_input() or input() function and the end of file is reached.
    12ImportErrorRaised when an import statement fails.
    13KeyboardInterruptRaised when the user interrupts program execution, usually by pressing Ctrl+C.
    14LookupErrorBase class for all lookup errors.
    15IndexErrorRaised when an index is not found in a sequence.
    16KeyErrorRaised when the specified key is not found in the dictionary.
    17NameErrorRaised when an identifier is not found in the local or global namespace.
    18UnboundLocalErrorRaised when trying to access a local variable in a function or method but no value has been assigned to it.
    19EnvironmentErrorBase class for all exceptions that occur outside the Python environment.
    20IOErrorRaised when an input/ output operation fails, such as the print statement or the open() function when trying to open a file that does not exist.
    21OSErrorRaised for operating system-related errors.
    22SyntaxErrorRaised when there is an error in Python syntax.
    23IndentationErrorRaised when indentation is not specified properly.
    24SystemErrorRaised when the interpreter finds an internal problem, but when this error is encountered the Python interpreter does not exit.
    25SystemExitRaised when Python interpreter is quit by using the sys.exit() function. If not handled in the code, causes the interpreter to exit.
    26TypeErrorRaised when an operation or function is attempted that is invalid for the specified data type.
    27ValueErrorRaised when the built-in function for a data type has the valid type of arguments, but the arguments have invalid values specified.
    28RuntimeErrorRaised when a generated error does not fall into any category.
    29NotImplementedErrorRaised when an abstract method that needs to be implemented in an inherited class is not actually implemented.

    Here are some examples of standard exceptions −

    IndexError

    It is shown when trying to access item at invalid index.

    numbers=[10,20,30,40]for n inrange(5):print(numbers[n])

    It will produce the following output −

    10
    20
    30
    40
    Traceback (most recent call last):
    
       print (numbers[n])
    IndexError: list index out of range
    

    ModuleNotFoundError

    This is displayed when module could not be found.

    import notamodule
    Traceback (most recent call last):import notamodule
    ModuleNotFoundError: No module named 'notamodule'

    KeyError

    It occurs as dictionary key is not found.

    D1={'1':"aa",'2':"bb",'3':"cc"}print( D1['4'])
    Traceback (most recent call last):
    
       D1['4']
    KeyError:'4'

    ImportError

    It is shown when specified function is not available for import.

    from math import cube
    Traceback (most recent call last):from math import cube
    ImportError: cannot import name 'cube'

    StopIteration

    This error appears when next() function is called after iterator stream exhausts.

    .it=iter([1,2,3])next(it)next(it)next(it)next(it)
    Traceback (most recent call last):next(it)
    StopIteration
    

    TypeError

    This is shown when operator or function is applied to an object of inappropriate type.

    print('2'+2)
    Traceback (most recent call last):'2'+2
    TypeError: must be str,notint

    ValueError

    It is displayed when function’s argument is of inappropriate type.

    print(int('xyz'))
    Traceback (most recent call last):int('xyz')
    ValueError: invalid literal forint()with base 10:'xyz'

    NameError

    This is encountered when object could not be found.

    print(age)
    Traceback (most recent call last):
    
       age
    NameError: name 'age'isnot defined
    

    ZeroDivisionError

    It is shown when second operator in division is zero.

    x=100/0
    Traceback (most recent call last):
    
       x=100/0
    ZeroDivisionError: division by zero
    

    KeyboardInterrupt

    When user hits the interrupt key normally Control-C during execution of program.

    name=input('enter your name')
    enter your name^c
    Traceback (most recent call last):
    
       name=input('enter your name')
    KeyboardInterrupt

    Hierarchy of Built-in Exceptions

    The exceptions in Python are organized in a hierarchical structure, with “BaseException” at the top. Here is a simplified hierarchy −

    • BaseException
      • SystemExit
      • KeyboardInterrupt
    • Exception
      • ArithmeticError
        • FloatingPointError
        • OverflowError
        • ZeroDivisionError
      • AttributeError
      • EOFError
      • ImportError
      • LookupError
        • IndexError
        • KeyError
      • MemoryError
      • NameError
        • UnboundLocalError
      • OSError
        • FileNotFoundError
      • TypeError
      • ValueError
      • —(Many others)— 

    How to Use Built-in Exceptions

    As we already know that built-in exceptions in Python are pre-defined classes that handle specific error conditions. Now, here is a detailed guide on how to use them effectively in your Python programs −

    Handling Exceptions with try-except Blocks

    The primary way to handle exceptions in Python is using “try-except” blocks. This allows you to catch and respond to exceptions that may occur during the execution of your code.

    Example

    In the following example, the code that may raise an exception is placed inside the “try” block. The “except” block catches the specified exception “ZeroDivisionError” and handles it

    try:
       result =1/0except ZeroDivisionError as e:print(f"Caught an exception: {e}")

    Following is the output obtained −

    Caught an exception: division by zero
    

    Handling Multiple Exceptions

    You can handle multiple exceptions by specifying them in a tuple within the “except” block as shown in the example below −

    try:
       result =int('abc')except(ValueError, TypeError)as e:print(f"Caught a ValueError or TypeError: {e}")

    Output of the above code is as shown below −

    Caught a ValueError or TypeError: invalid literal for int() with base 10: 'abc'
    

    Using “else” and “finally” Blocks

    The “else” block is executed if the code block in the “try” clause does not raise an exception −

    try:
       number =int(input("Enter a number: "))except ValueError as e:print(f"Invalid input: {e}")else:print(f"You entered: {number}")

    Output of the above code varies as per the input given −

    Enter a number: bn
    Invalid input: invalid literal for int() with base 10: 'bn'
    

    The “finally” block is always executed, regardless of whether an exception occurred or not. It’s typically used for clean-up actions, such as closing files or releasing resources −

    try:file=open('example.txt','r')
       content =file.read()except FileNotFoundError as e:print(f"File not found: {e}")finally:file.close()print("File closed.")

    Following is the output of the above code −

    File closed.
    

    Explicitly Raising Built-in Exceptions

    In Python, you can raise built-in exceptions to indicate errors or exceptional conditions in your code. This allows you to handle specific error scenarios and provide informative error messages to users or developers debugging your application.

    Syntax

    Following is the basic syntax for raising built-in exception −

    raise ExceptionClassName("Error message")

    Example

    In this example, the “divide” function attempts to divide two numbers “a” and “b”. If “b” is zero, it raises a “ZeroDivisionError” with a custom message −

    defdivide(a, b):if b ==0:raise ZeroDivisionError("Cannot divide by zero")return a / b
    
    try:
       result = divide(10,0)except ZeroDivisionError as e:print(f"Error: {e}")

    The output obtained is as shown below −

    Error: Cannot divide by zero
  • Assertions

    Assertions in Python

    Assertions in Python are statements that assert or assume a condition to be true. If the condition turns out to be false, Python raises an AssertionError exception. They are used to detect programming errors that should never occur if the code is correct.

    • The easiest way to think of an assertion is to liken it to a raise-if statement (or to be more accurate, a raise-if-not statement). An expression is tested, and if the result comes up false, an exception is raised.
    • Assertions are carried out by the assert statement, the newest keyword to Python, introduced in version 1.5.
    • Programmers often place assertions at the start of a function to check for valid input, and after a function call to check for valid output.

    The assert Statement

    In Python, assertions use the assert keyword followed by an expression. If the expression evaluates to False, an AssertionError is raised. Following is the syntax of assertion −

    assert condition, message
    

    Where,

    • condition − A boolean expression that should be true.
    • message (optional) − An optional message to be displayed if the assertion fails.

    Using Assertions

    Assertions are generally used during development and testing phases to check conditions that should always hold true.

    Example

    In the following example, we are using assertions to ensure that the variable “num” falls within the valid range of “0” to “100”. If the assertion fails, Python raises an “AssertionError”, preventing further execution of the subsequent print statement −

    print('Enter marks out of 100:')
    num =75assert num >=0and num <=100print('Marks obtained:', num)
    
    num =125assert num >=0and num <=100print('Marks obtained:', num)# This line won't be reached if assertion fails

    Following is the output of the above code −

    Enter marks out of 100:
    Marks obtained: 75
    Traceback (most recent call last):
      File "/home/cg/root/66723bd115007/main.py", line 7, in <module>
    
    assert num &gt;= 0 and num &lt;= 100
    AssertionError

    Custom Error Messages

    To display a custom error message when an assertion fails, include a string after the expression in the assert statement −

    assert num >=0and num <=100,"Only numbers in the range 0-100 are accepted"

    Handling AssertionError

    Assertions can be caught and handled like any other exception using a try-except block. If they are not handled, they will terminate the program and produce a traceback −

    try:
       num =int(input('Enter a number: '))assert num >=0,"Only non-negative numbers are accepted"print(num)except AssertionError as msg:print(msg)

    It will produce the following output −

    Enter a number: -87
    Only non-negative numbers are accepted
    

    Assertions vs. Exceptions

    Assertions are used to check internal state and invariants that should always be true. Whereas, exceptions helps in handling runtime errors and exceptional conditions that may occur during normal execution.

    Assertions are disabled by default in Python’s optimized mode (-O or python -O script.py). Therefore, they should not be used to enforce constraints that are required for the correct functioning of the program in production environments.

  • Logging

    Logging in Python

    Logging is the process of recording messages during the execution of a program to provide runtime information that can be useful for monitoring, debugging, and auditing.

    In Python, logging is achieved through the built-in logging module, which provides a flexible framework for generating log messages.

    Benefits of Logging

    Following are the benefits of using logging in Python −

    • Debugging − Helps identify and diagnose issues by capturing relevant information during program execution.
    • Monitoring − Provides insights into the application’s behavior and performance.
    • Auditing − Keeps a record of important events and actions for security purposes.
    • Troubleshooting − Facilitates tracking of program flow and variable values to understand unexpected behavior.

    Components of Python Logging

    Python logging consists of several key components that work together to manage and output log messages effectively −

    • Logger − It is the main entry point that you use to emit log messages. Each logger instance is named and can be configured independently.
    • Handler − It determines where log messages are sent. Handlers send log messages to different destinations such as the console, files, sockets, etc.
    • Formatter − It specifies the layout of log messages. Formatters define the structure of log records by specifying which information to include (e.g., timestamp, log level, message).
    • Logger Level − It defines the severity level of log messages. Messages below this level are ignored. Common levels include DEBUG, INFO, WARNING, ERROR, and CRITICAL.
    • Filter − It is the optional components that provide finer control over which log records are processed and emitted by a handler.

    Logging Levels

    Logging levels in Python define the severity of log messages, allowing developers to categorize and filter messages based on their importance. Each logging level has a specific purpose and helps in understanding the significance of the logged information −

    • DEBUG − Detailed information, typically useful only for debugging purposes. These messages are used to trace the flow of the program and are usually not seen in production environments.
    • INFO − Confirmation that things are working as expected. These messages provide general information about the progress of the application.
    • WARNING − Indicates potential issues that do not prevent the program from running but might require attention. These messages can be used to alert developers about unexpected situations.
    • ERROR − Indicates a more serious problem that prevents a specific function or operation from completing successfully. These messages highlight errors that need immediate attention but do not necessarily terminate the application.
    • CRITICAL − The most severe level, indicating a critical error that may lead to the termination of the program. These messages are reserved for critical failures that require immediate intervention.

    Usage

    Following are the usage scenarios for each logging level in Python applications −

    Choosing the Right Level − Selecting the appropriate logging level ensures that log messages provide relevant information without cluttering the logs.

    Setting Levels − Loggers, handlers, and specific log messages can be configured with different levels to control which messages are recorded and where they are outputted.

    Hierarchy − Logging levels are hierarchical, meaning that setting a level on a logger also affects the handlers and log messages associated with it.

    Basic Logging Example

    Following is a basic logging example in Python to demonstrate its usage and functionality −

    import logging
    
    # Configure logging
    logging.basicConfig(level=logging.DEBUG,format='%(asctime)s - %(levelname)s - %(message)s')# Example usagedefcalculate_sum(a, b):
       logging.debug(f"Calculating sum of {a} and {b}")
       result = a + b
       logging.info(f"Sum calculated successfully: {result}")return result
    
    # Main programif __name__ =="__main__":
       logging.info("Starting the program")
       result = calculate_sum(10,20)
       logging.info("Program completed")

    Output

    Following is the output of the above code −

    2024-06-19 09:00:06,774 - INFO - Starting the program
    2024-06-19 09:00:06,774 - DEBUG - Calculating sum of 10 and 20
    2024-06-19 09:00:06,774 - INFO - Sum calculated successfully: 30
    2024-06-19 09:00:06,775 - INFO - Program completed
    

    Configuring Logging

    Configuring logging in Python refers to setting up various components such as loggers, handlers, and formatters to control how and where log messages are stored and displayed. This configuration allows developers to customize logging behavior according to their application’s requirements and deployment environment.

    Example

    In the following example, the getLogger() function retrieves or creates a named logger. Loggers are organized hierarchically based on their names. Then, handlers like “StreamHandler” (console handler) are created to define where log messages go. They can be configured with specific log levels and formatters.

    The formatters specify the layout of log records, determining how log messages appear when printed or stored −

    import logging
    
    # Create logger
    logger = logging.getLogger('my_app')
    logger.setLevel(logging.DEBUG)# Set global log level# Create console handler and set level to debug
    console_handler = logging.StreamHandler()
    console_handler.setLevel(logging.DEBUG)# Create formatter
    formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
    console_handler.setFormatter(formatter)# Add console handler to logger
    logger.addHandler(console_handler)# Example usage
    logger.debug('This is a debug message')
    logger.info('This is an info message')
    logger.warning('This is a warning message')
    logger.error('This is an error message')
    logger.critical('This is a critical message')

    The result produced is as shown below −

    2024-06-19 09:05:20,852 - my_app - DEBUG - This is a debug message
    2024-06-19 09:05:20,852 - my_app - INFO - This is an info message
    2024-06-19 09:05:20,852 - my_app - WARNING - This is a warning message
    2024-06-19 09:05:20,852 - my_app - ERROR - This is an error message
    2024-06-19 09:05:20,852 - my_app - CRITICAL - This is a critical message
    

    Logging Handlers

    Logging handlers in Python determine where and how log messages are processed and outputted. They play an important role in directing log messages to specific destinations such as the console, files, email, databases, or even remote servers.

    Each handler can be configured independently to control the format, log level, and other properties of the messages it processes.

    Types of Logging Handlers

    Following are the various types of logging handlers in Python −

    • StreamHandler − Sends log messages to streams such as sys.stdout or sys.stderr. Useful for displaying log messages in the console or command line interface.
    • FileHandler − Writes log messages to a specified file on the file system. Useful for persistent logging and archiving of log data.
    • RotatingFileHandler − Similar to FileHandler but automatically rotates log files based on size or time intervals. Helps manage log file sizes and prevent them from growing too large.
    • SMTPHandler − Sends log messages as emails to designated recipients via SMTP. Useful for alerting administrators or developers about critical issues.
    • SysLogHandler − Sends log messages to the system log on Unix-like systems (e.g., syslog). Allows integration with system-wide logging facilities.
    • MemoryHandler − Buffers log messages in memory and sends them to a target handler after reaching a certain buffer size or timeout. Useful for batching and managing bursts of log messages.
    • HTTPHandler − Sends log messages to a web server via HTTP or HTTPS. Enables logging messages to a remote server or logging service.
  • User Defined Exceptions

    User-Defined Exceptions in Python

    User-defined exceptions in Python are custom error classes that you create to handle specific error conditions in your code. They are derived from the built-in Exception class or any of its sub classes.

    User-defined exceptions provide more precise control over error handling in your application −

    • Clarity − They provide specific error messages that make it clear what went wrong.
    • Granularity − They allow you to handle different error conditions separately.
    • Maintainability − They centralize error handling logic, making your code easier to maintain.

    How to Create a User-Defined Exception

    To create a user-defined exception, follow these steps −

    Step 1 − Define the Exception Class

    Create a new class that inherits from the built-in “Exception” class or any other appropriate base class. This new class will serve as your custom exception.

    classMyCustomError(Exception):pass

    Explanation

    • Inheritance − By inheriting from “Exception”, your custom exception will have the same behaviour and attributes as the built-in exceptions.
    • Class Definition − The class is defined using the standard Python class syntax. For simple custom exceptions, you can define an empty class body using the “pass” statement.

    Step 2 − Initialize the Exception

    Implement the “__init__” method to initialize any attributes or provide custom error messages. This allows you to pass specific information about the error when raising the exception.

    classInvalidAgeError(Exception):def__init__(self, age, message="Age must be between 18 and 100"):
    
      self.age = age
      self.message = message
      super().__init__(self.message)</pre>

    Explanation

    • Attributes − Define attributes such as "age" and "message" to store information about the error.
    • Initialization − The "__init__" method initializes these attributes. The "super().__init__(self.message)" call ensures that the base "Exception" class is properly initialized with the error message.
    • Default Message − A default message is provided, but you can override it when raising the exception.

    Step 3 − Optionally Override "__str__" or "__repr__"

    Override the "__str__" or "__repr__" method to provide a custom string representation of the exception. This is useful for printing or logging the exception.

    classInvalidAgeError(Exception):def__init__(self, age, message="Age must be between 18 and 100"):
    
      self.age = age
      self.message = message
      super().__init__(self.message)def__str__(self):returnf"{self.message}. Provided age: {self.age}"</pre>

    Explanation

    • __str__ Method − The "__str__" method returns a string representation of the exception. This is what will be displayed when the exception is printed.
    • Custom Message − Customize the message to include relevant information, such as the provided age in this example.

    Raising User-Defined Exceptions

    Once you have defined a custom exception, you can raise it in your code to signify specific error conditions. Raising user-defined exceptions involves using the raisestatement, which can be done with or without custom messages and attributes.

    Syntax

    Following is the basic syntax for raising an exception −

    raise ExceptionType(args)

    Example

    In this example, the "set_age" function raises an "InvalidAgeError" if the age is outside the valid range −

    defset_age(age):if age <18or age >100:raise InvalidAgeError(age)print(f"Age is set to {age}")

    Handling User-Defined Exceptions

    Handling user-defined exceptions in Python refers to using "try-except" blocks to catch and respond to the specific conditions that your custom exceptions represent. This allows your program to handle errors gracefully and continue running or to take specific actions based on the type of exception raised.

    Syntax

    Following is the basic syntax for handling exceptions −

    try:# Code that may raise an exceptionexcept ExceptionType as e:# Code to handle the exception

    Example

    In the below example, the "try" block calls "set_age" with an invalid age. The "except" block catches the "InvalidAgeError" and prints the custom error message −

    try:
       set_age(150)except InvalidAgeError as e:print(f"Invalid age: {e.age}. {e.message}")

    Complete Example

    Combining all the steps, here is a complete example of creating and using a user-defined exception −

    classInvalidAgeError(Exception):def__init__(self, age, message="Age must be between 18 and 100"):
    
      self.age = age
      self.message = message
      super().__init__(self.message)def__str__(self):returnf"{self.message}. Provided age: {self.age}"defset_age(age):if age &lt;18or age &gt;100:raise InvalidAgeError(age)print(f"Age is set to {age}")try:
    set_age(150)except InvalidAgeError as e:print(f"Invalid age: {e.age}. {e.message}")

    Following is the output of the above code −

    Invalid age: 150. Age must be between 18 and 100
    

  • Nested try Block

    Nested try Block in Python

    In a Python program, if there is another try-except construct either inside either a try block or inside its except block, it is known as a nested-try block. This is needed when different blocks like outer and inner may cause different errors. To handle them, we need nested try blocks.

    We start with an example having a single “try − except − finally” construct. If the statements inside try encounter exception, it is handled by except block. With or without exception occurred, the finally block is always executed.

    Example 1

    Here, the try block has “division by 0” situation, hence the except block comes into play. It is equipped to handle the generic exception with Exception class.

    a=10
    b=0try:print(a/b)except Exception:print("General Exception")finally:print("inside outer finally block")

    It will produce the following output −

    General Exception
    inside outer finally block
    

    Example 2

    Let us now see how to nest the try constructs. We put another “try − except − finally” blocks inside the existing try block. The except keyword for inner try now handles generic Exception, while we ask the except block of outer try to handle ZeroDivisionError.

    Since exception doesn’t occur in the inner try block, its corresponding generic Except isn’t called. The division by 0 situation is handled by outer except clause.

    a=10
    b=0try:print(a/b)try:print("This is inner try block")except Exception:print("General exception")finally:print("inside inner finally block")except ZeroDivisionError:print("Division by 0")finally:print("inside outer finally block")

    It will produce the following output −

    Division by 0
    inside outer finally block
    

    Example 3

    Now we reverse the situation. Out of the nested try blocks, the outer one doesn’t have any exception raised, but the statement causing division by 0 is inside inner try, and hence the exception handled by inner except block. Obviously, the except part corresponding to outer try: will not be called upon.

    a=10
    b=0try:print("This is outer try block")try:print(a/b)except ZeroDivisionError:print("Division by 0")finally:print("inside inner finally block")except Exception:print("General Exception")finally:print("inside outer finally block")

    It will produce the following output −

    This is outer try block
    Division by 0
    inside inner finally block
    inside outer finally block
    

    In the end, let us discuss another situation which may occur in case of nested blocks. While there isn’t any exception in the outer try:, there isn’t a suitable except block to handle the one inside the inner try: block.

    Example 4

    In the following example, the inner try: faces “division by 0”, but its corresponding except: is looking for KeyError instead of ZeroDivisionError. Hence, the exception object is passed on to the except: block of the subsequent except statement matching with outer try: statement. There, the zeroDivisionError exception is trapped and handled.

    a=10
    b=0try:print("This is outer try block")try:print(a/b)except KeyError:print("Key Error")finally:print("inside inner finally block")except ZeroDivisionError:print("Division by 0")finally:print("inside outer finally block")

    It will produce the following output −

    This is outer try block
    inside inner finally block
    Division by 0
    inside outer finally block
  • Exception Chaining

    Exception Chaining

    Exception chaining is a technique of handling exceptions by re-throwing a caught exception after wrapping it inside a new exception. The original exception is saved as a property (such as cause) of the new exception.

    During the handling of one exception ‘A’, it is possible that another exception ‘B’ may occur. It is useful to know about both exceptions in order to debug the problem. Sometimes it is useful for an exception handler to deliberately re-raise an exception, either to provide extra information or to translate an exception to another type.

    In Python 3.x, it is possible to implement exception chaining. If there is any unhandled exception inside an except section, it will have the exception being handled attached to it and included in the error message.

    Example

    In the following code snippet, trying to open a non-existent file raises FileNotFoundError. It is detected by the except block. While handling another exception is raised.

    try:open("nofile.txt")except OSError:raise RuntimeError("unable to handle error")

    It will produce the following output −

    Traceback (most recent call last):
      File "/home/cg/root/64afcad39c651/main.py", line 2, in <module>
    open("nofile.txt")
    FileNotFoundError: [Errno 2] No such file or directory: 'nofile.txt'
    
    During handling of the above exception, another exception occurred:
    
    Traceback (most recent call last):
      File "/home/cg/root/64afcad39c651/main.py", line 4, in <module>
    
    raise RuntimeError("unable to handle error")
    RuntimeError: unable to handle error

    The raise . . from Statement

    If you use an optional from clause in the raise statement, it indicates that an exception is a direct consequence of another. This can be useful when you are transforming exceptions. The token after from keyword should be the exception object.

    try:open("nofile.txt")except OSError as exc:raise RuntimeError from exc
    

    It will produce the following output −

    Traceback (most recent call last):
      File "/home/cg/root/64afcad39c651/main.py", line 2, in <module>
    
    open("nofile.txt")
    FileNotFoundError: [Errno 2] No such file or directory: 'nofile.txt' The above exception was the direct cause of the following exception: Traceback (most recent call last): File "/home/cg/root/64afcad39c651/main.py", line 4, in <module>
    raise RuntimeError from exc
    RuntimeError

    The raise . . from None Statement

    If we use None in from clause instead of exception object, the automatic exception chaining that was found in the earlier example is disabled.

    try:open("nofile.txt")except OSError as exc:raise RuntimeError fromNone

    It will produce the following output −

    Traceback (most recent call last):
     File "C:\Python311\hello.py", line 4, in <module>
      raise RuntimeError from None
    RuntimeError
    

    The __context__ and __cause__ Expression

    Raising an exception in the except block will automatically add the captured exception to the __context__ attribute of the new exception. Similarly, you can also add __cause__ to any exception using the expression raise … from syntax.

    try:try:raise ValueError("ValueError")except ValueError as e1:raise TypeError("TypeError")from e1
    except TypeError as e2:print("The exception was",repr(e2))print("Its __context__ was",repr(e2.__context__))print("Its __cause__ was",repr(e2.__cause__))

    It will produce the following output −

    The exception was TypeError('TypeError')
    Its __context__ was ValueError('ValueError')
    Its __cause__ was ValueError('ValueError')
  • Raising Exceptions

    Raising Exceptions in Python

    In Python, you can raise exceptions explicitly using the raise statement. Raising exceptions allows you to indicate that an error has occurred and to control the flow of your program by handling these exceptions appropriately.

    Raising an exception refers to explicitly trigger an error condition in your program. This can be useful for handling situations where the normal flow of your program cannot continue due to an error or an unexpected condition.

    In Python, you can raise built-in exceptions like ValueError or TypeError to indicate common error conditions. Additionally, you can create and raise custom exceptions.

    Raising Built-in Exceptions

    You can raise any built-in exception by creating an instance of the exception class and using the raise statement. Following is the syntax −

    raise Exception("This is a general exception")

    Example

    Here is an example where we raise a ValueError when a function receives an invalid argument −

    defdivide(a, b):if b ==0:raise ValueError("Cannot divide by zero")return a / b
    
    try:
       result = divide(10,0)except ValueError as e:print(e)

    Following is the output of the above code −

    Cannot divide by zero
    

    Raising Custom Exceptions

    In addition to built-in exceptions, you can define and raise your own custom exceptions by creating a new exception class that inherits from the base Exception class or any of its subclasses −

    classMyCustomError(Exception):passdefrisky_function():raise MyCustomError("Something went wrong in risky_function")try:
       risky_function()except MyCustomError as e:print(e)

    Output of the above code is as shown below −

    Something went wrong in risky_function
    

    Creating Custom Exceptions

    Custom exceptions is useful for handling specific error conditions that are unique to your application, providing more precise error reporting and control.

    To create a custom exception in Python, you define a new class that inherits from the built-in Exception class or any other appropriate built-in exception class. This custom exception class can have additional attributes and methods to provide more detailed context about the error condition.

    Example

    In this example −

    • We define a custom exception class “InvalidAgeError” that inherits from “Exception”.
    • The __init__() method initializes the exception with the invalid age and a default error message.
    • The set_age() function raises “InvalidAgeError” if the provided age is outside the valid range.
    classInvalidAgeError(Exception):def__init__(self, age, message="Age must be between 18 and 100"):
    
      self.age = age
      self.message = message
      super().__init__(self.message)defset_age(age):if age &lt;18or age &gt;100:raise InvalidAgeError(age)print(f"Age is set to {age}")try:
    set_age(150)except InvalidAgeError as e:print(f"Invalid age: {e.age}. {e.message}")

    The result obtained is as shown below −

    Invalid age: 150. Age must be between 18 and 100
    

    Re-Raising Exceptions

    Sometimes, you may need to catch an exception, perform specific actions (such as logging, cleanup, or providing additional context), and then re-raise the same exception to be handled further up the call stack

    This is useful when you want to ensure certain actions are taken when an exception occurs, but still allow the exception to propagate for higher-level handling.

    To re-raise an exception in Python, you use the “raise” statement without specifying an exception, which will re-raise the last exception that was active in the current scope.

    Example

    In the following example −

    • The process_file() function attempts to open and read a file.
    • If the file is not found, it prints an error message and re-raises the “FileNotFoundError” exception.
    • The exception is then caught and handled at a higher level in the call stack.
    defprocess_file(filename):try:withopen(filename,"r")asfile:
    
         data =file.read()# Process dataexcept FileNotFoundError as e:print(f"File not found: {filename}")# Re-raise the exceptionraisetry:
    process_file("nonexistentfile.txt")except FileNotFoundError as e:print("Handling the exception at a higher level")

    After executing the above code, we get the following output −

    File not found: nonexistentfile.txt
    Handling the exception at a higher level
  •  The tryfinally Block

    Python Try-Finally Block

    In Python, the try-finally block is used to ensure that certain code executes, regardless of whether an exception is raised or not. Unlike the try-except block, which handles exceptions, the try-finally block focuses on cleanup operations that must occur, ensuring resources are properly released and critical tasks are completed.

    Syntax

    The syntax of the try-finally statement is as follows −

    try:# Code that might raise exceptions
       risky_code()finally:# Code that always runs, regardless of exceptions
       cleanup_code()

    In Python, when using exception handling with try blocks, you have the option to include either except clauses to catch specific exceptions or a finally clause to ensure certain cleanup operations are executed, but not both together.

    Example

    Let us consider an example where we want to open a file in write mode (“w”), writes some content to it, and ensures the file is closed regardless of success or failure using a finally block −

    try:
       fh =open("testfile","w")
       fh.write("This is my test file for exception handling!!")finally:print("Error: can\'t find file or read data")
       fh.close()

    If you do not have permission to open the file in writing mode, then it will produce the following output −

    Error: can't find file or read data
    

    The same example can be written more cleanly as follows −

    try:
       fh =open("testfile","w")try:
    
      fh.write("This is my test file for exception handling!!")finally:print("Going to close the file")
      fh.close()except IOError:print("Error: can\'t find file or read data")</pre>

    When an exception is thrown in the try block, the execution immediately passes to the finally block. After all the statements in the finally block are executed, the exception is raised again and is handled in the except statements if present in the next higher layer of the try-except statement.

    Exception with Arguments

    An exception can have an argument, which is a value that gives additional information about the problem. The contents of the argument vary by exception. You capture an exception's argument by supplying a variable in the except clause as follows −

    try:
       You do your operations here
       ......................except ExceptionType as Argument:
       You can print value of Argument here...

    If you write the code to handle a single exception, you can have a variable follow the name of the exception in the except statement. If you are trapping multiple exceptions, you can have a variable follow the tuple of the exception.

    This variable receives the value of the exception mostly containing the cause of the exception. The variable can receive a single value or multiple values in the form of a tuple. This tuple usually contains the error string, the error number, and an error location.

    Example

    Following is an example for a single exception −

    # Define a function here.deftemp_convert(var):try:returnint(var)except ValueError as Argument:print("The argument does not contain numbers\n",Argument)# Call above function here.
    temp_convert("xyz")

    It will produce the following output −

    The argument does not contain numbers
    invalid literal for int() with base 10: 'xyz'
  • The try-except Block

    Python Try-Except Block

    In Python, the try-except block is used to handle exceptions and errors gracefully, ensuring that your program can continue running even when something goes wrong. This tutorial will cover the basics of using the try-except block, its syntax, and best practices.

    Exception handling allows you to manage errors in your code by capturing exceptions and taking appropriate actions instead of letting the program crash. An exception is an error that occurs during the execution of a program, and handling these exceptions ensures your program can respond to unexpected situations.

    The try-except block in Python is used to catch and handle exceptions. The code that might cause an exception is placed inside the try block, and the code to handle the exception is placed inside the except block.

    Syntax

    Following is the basic syntax of the try-except block in Python −

    try:# Code that might cause an exception
       risky_code()except SomeException as e:# Code that runs if an exception occurs
       handle_exception(e)

    Example

    In this example, if you enter a non-numeric value, a ValueError will be raised. If you enter zero, a ZeroDivisionError will be raised. The except blocks handle these exceptions and prints appropriate error messages −

    try:
    number =int(input("Enter a number: "))
    result =10/ number
    print(f"Result: {result}")except ZeroDivisionError as e:print("Error: Cannot divide by zero.")except ValueError as e:print("Error: Invalid input. Please enter a valid number.")
    Handling Multiple Exceptions

    In Python, you can handle multiple types of exceptions using multiple except blocks within a single try-except statement. This allows your code to respond differently to different types of errors that may occur during execution.

    Syntax

    Following is the basic syntax for handling multiple exceptions in Python −

    try:
    # Code that might raise exceptions
    risky_code()
    except FirstExceptionType:
    # Handle the first type of exception
    handle_first_exception()
    except SecondExceptionType:
    # Handle the second type of exception
    handle_second_exception()
    # Add more except blocks as needed for other exception types
    Example

    In the following example −

    If you enter zero as the divisor, a "ZeroDivisionError" will be raised, and the corresponding except ZeroDivisionError block will handle it by printing an error message.
    If you enter a non-numeric input for either the dividend or the divisor, a "ValueError" will be raised, and the except ValueError block will handle it by printing a different error message.
    try:
    dividend = int(input("Enter the dividend: "))
    divisor = int(input("Enter the divisor: "))
    result = dividend / divisor
    print(f"Result of division: {result}")
    except ZeroDivisionError:
    print("Error: Cannot divide by zero.")
    except ValueError:
    print("Error: Invalid input. Please enter valid integers.")
    Using Else Clause with Try-Except Block

    In Python, the else clause can be used in conjunction with the try-except block to specify code that should run only if no exceptions occur in the try block. This provides a way to differentiate between the main code that may raise exceptions and additional code that should only execute under normal conditions.

    Syntax

    Following is the basic syntax of the else clause in Python −

    try:
    # Code that might raise exceptions
    risky_code()
    except SomeExceptionType:
    # Handle the exception
    handle_exception()
    else:
    # Code that runs if no exceptions occurred
    no_exceptions_code()
    Example

    In the following example −

    If you enter a non-integer input, a ValueError will be raised, and the corresponding except ValueError block will handle it.
    If you enter zero as the denominator, a ZeroDivisionError will be raised, and the corresponding except ZeroDivisionError block will handle it.
    If the division is successful (i.e., no exceptions are raised), the else block will execute and print the result of the division.
    try:
    numerator = int(input("Enter the numerator: "))
    denominator = int(input("Enter the denominator: "))
    result = numerator / denominator
    except ValueError:
    print("Error: Invalid input. Please enter valid integers.")
    except ZeroDivisionError:
    print("Error: Cannot divide by zero.")
    else:
    print(f"Result of division: {result}")
    The Finally Clause

    The finally clause provides a mechanism to guarantee that specific code will be executed, regardless of whether an exception is raised or not. This is useful for performing cleanup actions such as closing files or network connections, releasing locks, or freeing up resources.

    Syntax

    Following is the basic syntax of the finally clause in Python −

    try:
    # Code that might raise exceptions
    risky_code()
    except SomeExceptionType:
    # Handle the exception
    handle_exception()
    else:
    # Code that runs if no exceptions occurred
    no_exceptions_code()
    finally:
    # Code that always runs, regardless of exceptions
    cleanup_code()
    Example

    In this example −

    If the file "example.txt" exists, its content is read and printed, and the else block confirms the successful operation.
    If the file is not found (FileNotFoundError), an appropriate error message is printed in the except block.
    The finally block ensures that the file is closed (file.close()) regardless of whether the file operation succeeds or an exception occurs.
    try:
    file = open("example.txt", "r")
    content = file.read()
    print(content)
    except FileNotFoundError:
    print("Error: The file was not found.")
    else:
    print("File read operation successful.")
    finally:
    if 'file' in locals():
    file.close()
    print("File operation is complete.")