The with statement simplifies managing resources by ensuring that any resources that are allocated for an object are automatically released when the block of code exits.The statement is generally used in scenarios where setup and teardown operations are needed.

Some objects such as files and databases needs to be closed once operations on them are over. This helps in ensuring that the resources associated with the object are released.

The following example shows the basic way that files can be  opened and closed.

#open the file
file = open('demo.txt')

#do something with the file
for line in file.readlines():
    print(line)

#close the file
file.close()

In the case with files, we can use the with statement to automate the file closing operation. The with statement alleviates the need to explicitly close the file. It ensures that the file is automatically closed before exiting the block even if there are exceptions raised within the block.

The previous example can be written as shown below using the with statement.

with open('demo.txt') as file:
     for line in file.readlines():
         print(line)

#the file is closed

The syntax of the with statement is basically as shown below.

with <expression> as target:
    #do something with the object

The expression is evaluated and the resulting object is assigned to the target name, so that from there onwards the object will be accessible through that name.

Context manager protocol

The context manager protocol is composed of two methods,  __enter__() and __exit__(). An object  which has these methods defined is known as a context manager. Such an object becomes eligible for use in the with statement. A file object which is created when we use the open() function to open a file is a typical example of a context manager.

The __enter__() method is used to perform setup operations for the context (such as allocating resources, opening files or databases, etc). It sets up the context and prepares it for the body of the with statement. 

The __exit__() method is called when the control leaves the with statement, either by reaching the end of the statement’s block or by having an exception thrown inside the statement’s body. The method cleans up the context and releases any acquired resources.

We can make custom objects support the context manager protocol by implementing the two methods. 

#Context manager for timing code blocks. 
import time


class Timer:

    def __enter__(self): 
         self.start = time.perf_counter() 
         return self 

    def __exit__(self, *args): 
        self.end = time.perf_counter() 
        self.elapsed = self.end - self.start 

def delayed_add(a, b):
     time.sleep(1)
     result = f"{a} + {b} = {a + b}"
     return result

with Timer() as t:
     print(delayed_add(10, 20))

     
print("Elapsed time: {:.2f}s".format(t.elapsed))

The Timer class above defines a context manager which is used to measure the amount of time it takes to run a process. The __enter__() and __exit__() methods are used to start and stop the timer. When the with statement is used with Timer objects, the __enter__() method is called, which in this case sets the start time of the timer and returns the Timer instance. When the with statement exits, the __exit__() method is called, which sets the end time, calculates the elapsed time, and stores it in the elapsed attribute.

with statement execution process

  The execution of the with block proceeds as follows:

  1. The expression in the with statement is evaluated and the context object is obtained.
  2. A context manager object  is created which will be used to handle the context  in the body of the with statement.
  3. The  context manager's __enter__() method is invoked which in turn returns a reference to the context object created in step 1.
  4. The context object is attached to the variable specified in the as clause of the with statement.
  5. The statements in the body of the with block are executed.
  6. The __exit__() method of the context manager object is called if the execution is over or an exception is encountered.

Multiple context managers in one with statement

It is possible to use multiple context managers in order to manage multiple resources at once. This is done by separating the different context managers with commas, allowing them to be used in the same with statement.

with <expression> as a, <expression> as b, <expression> as c:
      #do something

Literally the above syntax is equal to nesting multiple with statements as shown in the following syntax.

​
with <expression> as a:
    with<expression> as b:
        #do something
​
import time

class Timer:

    def __enter__(self): 
         self.start = time.perf_counter() 
         return self 

    def __exit__(self, *args): 
        self.end = time.perf_counter() 
        self.elapsed = self.end - self.start 

with open('file1.txt', 'r') as f1, open('file2.txt', 'w') as f2, Timer() as t:
     for i in in f1.read():
          f2.write(i)

print("Elapsed time: {:.2f}s".format(t.elapsed))

The above example demonstrates how we can use multiple context managers in the same with block. Inside the block, the contents of the first file(f1) are copied to the second file(f2), the timer object computes the time taken to perform this operations.

Multi-line with statement header

When we have multiple context manager objects like in the previous example, we can write them in a multi-line fashion By sarroundoing them with parenthesis. The syntax is as follows.

with(
<expression1> as target1,
<expression2> as target2,
<expression3> as target3,
):
   #statements

This way, our earlier example can be written as follows:

import time

class Timer:

    def __enter__(self): 
         self.start = time.perf_counter() 
         return self 

    def __exit__(self, *args): 
        self.end = time.perf_counter() 
        self.elapsed = self.end - self.start 

with(
open('file1.txt', 'r') as f1,
open('file2.txt', 'w') as f2,
Timer() as t
):
     for i in f1.read():
          f2.write(i)

print("Elapsed time: {:.2f}s".format(t.elapsed))