Context Managers

Context managers allow for the setup and teardown of resources. They enable you to manage resources such as files, network connections, and locks in a clean and efficient manner.

This tutorial will guide you through the basics of context managers, including how to use them with the `with` statement, how to create your own context managers, and practical examples of their use.

### Using Context Managers with the `with` Statement

The primary way to use a context manager is through the `with` statement. This allows for automatic setup and cleanup of resources.

#### Example: Managing Files

The most common use case for context managers is file handling. Here's how you can use a context manager to open and read a file:

# Using the with statement to open and write a file
with open('sample.txt', 'w') as file:
  file.write('Hello world')

# Using the with statement to open and read a file
with open('sample.txt', 'r') as file:
    content = file.read()
    print(content)
Hello world
- **`with open('sample.txt', 'w') as file`**: Opens the file `sample.txt` in write mode. The `with` statement ensures the file is properly closed after the code block finishes.
- **`with open('sample.txt', 'r') as file`**: Opens the file `sample.txt` in read mode. The `with` statement ensures the file is properly closed after the code block finishes.
- **`file.read()`**: Reads the contents of the file.

### Creating Custom Context Managers

You can create your own context managers in Python. There are two primary ways to do this: using the `contextlib` module and creating a class with `__enter__` and `__exit__` methods.

#### Using `contextlib`

The `contextlib` module makes it easy to create context managers. Here's an example using the `contextmanager` decorator:

from contextlib import contextmanager

@contextmanager
def managed_file(filename):
    file = open(filename, 'w')
    try:
        yield file
    finally:
        file.close()

# Using the custom context manager
with managed_file('sample2.txt') as file:
    file.write('Hello, World!')
- **`@contextmanager`**: This decorator is used to define a generator-based context manager.
- **`yield`**: The context manager setup code runs up to the `yield` statement, and the code within the `with` block is executed. After this, execution resumes and the cleanup code runs.

#### Using Classes with `__enter__` and `__exit__`

You can also define a context manager by implementing the `__enter__` and `__exit__` methods in a class.

class ManagedFile:
    def __init__(self, filename):
        self.filename = filename

    def __enter__(self):
        self.file = open(self.filename, 'w')
        return self.file

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.file.close()
        if exc_type is not None:
            print(f"An exception occurred: {exc_type}, {exc_val}")

# Using the class-based context manager
with ManagedFile('sample3.txt') as file:
    file.write('Hello, again!')
- **`__enter__`**: Sets up the resource and returns it.
- **`__exit__`**: Handles cleanup, closing the file in this case. It also handles exceptions that may occur within the `with` block.

### Practical Examples

#### Example: Handling Database Connections

Context managers can be extremely useful for managing database connections. Here's a simple example with SQLite.

import sqlite3
from contextlib import contextmanager

@contextmanager
def open_database(db_name):
    connection = sqlite3.connect(db_name)
    cursor = connection.cursor()
    try:
        yield cursor
    finally:
        connection.commit()
        connection.close()

# Using the database context manager
with open_database('example.db') as cursor:
    cursor.execute('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)')
    cursor.execute('INSERT INTO users (name) VALUES (?)', ('Alice',))
- **`sqlite3.connect(db_name)`**: Connects to the SQLite database.
- **`yield cursor`**: Provides the cursor to the `with` block for executing SQL commands.
- **`connection.commit()`**: Commits the transaction when done.
- **`connection.close()`**: Closes the connection to the database.

#### Example: Timer Context Manager

A context manager can also be used to measure the execution time of a code block.

import time
from contextlib import contextmanager

@contextmanager
def timer():
    start = time.time()
    try:
        yield
    finally:
        end = time.time()
        print(f"Elapsed time: {end - start} seconds")

# Using the timer context manager
with timer():
    time.sleep(2)  # Simulate a time-consuming task
Elapsed time: 2.004432201385498 seconds
- **`time.time()`**: Records the current time.
- **`yield`**: Executes the code within the `with` block.
- **`Elapsed time`**: Prints the time taken to execute the block.

### Conclusion

Context managers are incredibly useful for managing resources efficiently in your Python code. They ensure clean setup and teardown, handle exceptions gracefully, and keep your code concise and readable.