Skip to content

Context Managers

Resource management is a recurring challenge in programming: files must be closed, locks must be released, and database connections must be returned to the pool — even when an exception interrupts normal flow. Python's with statement and the context manager protocol guarantee that cleanup code runs no matter what. Any object that implements __enter__ and __exit__ can be used with with.

With Statement Support

1. The enter Method

The __enter__ method is called when execution enters the with block. It performs any setup work (opening a file, acquiring a lock) and returns the resource that will be bound to the variable after the as keyword. If no resource needs to be returned, it typically returns self.

2. The exit Method

The __exit__ method is called when execution leaves the with block, whether normally or because of an exception. It receives three arguments — exc_type, exc_val, and exc_tb — that describe the exception, or are all None if the block completed successfully. After performing cleanup, __exit__ can suppress the exception by returning True, or let it propagate by returning False (or None).

3. Usage

The following class wraps a filename and ensures the underlying file handle is always closed, even if an error occurs inside the with block.

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

    def __enter__(self):
        self.file = open(self.filename, "r")
        return self.file

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.file.close()
        return False  # Do not suppress exceptions

with ManagedFile("example.txt") as f:
    contents = f.read()
# f is guaranteed to be closed here

When the with block begins, __enter__ opens the file and returns the file handle. When the block ends — normally or via exception — __exit__ closes the file.

Summary

  • Context managers guarantee resource cleanup by pairing setup (__enter__) with teardown (__exit__).
  • The __exit__ method always runs, even when an exception occurs inside the with block.
  • Implement __enter__ and __exit__ on any class to make its instances usable with the with statement.

Runnable Example: context_manager_protocol.py

"""
TUTORIAL: Context Managers - The __enter__ and __exit__ Protocol

This tutorial teaches you how to implement the context manager protocol by
defining __enter__ and __exit__ methods. Context managers enable the 'with'
statement, which is Python's way of managing resource setup and cleanup
safely and reliably.

Real-world uses: file handling, database connections, locks, temporary
redirects, transaction management, and anything that needs setup/teardown.

Key Learning Goals:
  - Understand when and why context managers matter
  - Implement __enter__ and __exit__ properly
  - Handle exceptions in __exit__
  - See how the 'with' statement works under the hood
"""

import sys

if __name__ == "__main__":

    print("=" * 70)
    print("TUTORIAL: Context Managers - __enter__ and __exit__")
    print("=" * 70)

    # ============ EXAMPLE 1: The Problem Context Managers Solve ============
    print("\n# Example 1: Resource Cleanup Without Context Managers")
    print("=" * 70)

    print("""
    Imagine you have a resource that needs setup and teardown:

    WRONG WAY (without context manager):
        resource = acquire_resource()
        resource.do_work()
        resource.cleanup()  # Oops! What if do_work() raises an exception?
        # cleanup() never runs!

    SAFER WAY (manual try/finally):
        resource = acquire_resource()
        try:
            resource.do_work()
        finally:
            resource.cleanup()  # Always runs, even if exception occurs

    PYTHONIC WAY (context manager):
        with acquire_resource() as resource:
            resource.do_work()  # cleanup() automatically happens after

    The 'with' statement guarantees cleanup happens, even on exceptions.
    It's cleaner, safer, and more readable than try/finally.
    """)

    # ============ EXAMPLE 2: The Context Manager Protocol ============
    print("\n# Example 2: Understanding __enter__ and __exit__")
    print("=" * 70)

    print("""
    A context manager is any object with two dunder methods:

        class ContextManager:
            def __enter__(self):
                # Called when entering the with block
                # Setup goes here
                # Return a value (or None) to assign to 'as variable'
                return something

            def __exit__(self, exc_type, exc_value, traceback):
                # Called when exiting the with block (always!)
                # Cleanup goes here
                # If it returns True, the exception is suppressed
                # If it returns False or None, exception propagates
                return False  # Don't suppress exceptions

    When you write:
        with obj:
            # ... code ...

    Python does this:
        mgr = obj
        exit = type(mgr).__exit__
        value = type(mgr).__enter__(mgr)
        try:
            # ... code ...
        except:
            exc_info = sys.exc_info()
            if not exit(mgr, *exc_info):
                raise  # Exception propagates
        else:
            exit(mgr, None, None, None)  # Normal exit
    """)

    # ============ EXAMPLE 3: A Simple Example - Stdout Redirection ============
    print("\n# Example 3: The LookingGlass Context Manager")
    print("=" * 70)

    class LookingGlass:
        """
        A context manager that reverses stdout output (mirror effect).

        This is a playful example but demonstrates the protocol perfectly.
        While active, all print output is reversed - it appears backwards.
        """

        def __enter__(self):
            """
            Called when entering the with block.

            Tasks:
            1. Save the original sys.stdout.write method
            2. Replace it with our reverse_write method
            3. Return a value to be assigned to 'as variable'

            This is the setup phase.
            """
            # Save the original so we can restore it later
            self.original_write = sys.stdout.write

            # Replace with our custom function
            sys.stdout.write = self.reverse_write

            # Return a value for the 'as' variable
            return 'JABBERWOCKY'

        def reverse_write(self, text):
            """
            Our custom stdout.write that reverses text.

            This will be called by print() internally, so output appears
            backwards. We call the original write with reversed text.
            """
            self.original_write(text[::-1])

        def __exit__(self, exc_type, exc_value, traceback):
            """
            Called when exiting the with block (always!).

            Parameters:
                exc_type: Exception class if an exception occurred (None otherwise)
                exc_value: Exception instance if one occurred (None otherwise)
                traceback: Exception traceback if one occurred (None otherwise)

            Tasks:
            1. Restore the original stdout.write
            2. Optionally handle exceptions (return True to suppress)

            Return value:
                True: suppress the exception (don't re-raise)
                False/None: let exception propagate normally
            """
            # Always restore the original write method
            sys.stdout.write = self.original_write

            # Handle ZeroDivisionError specially
            if exc_type is ZeroDivisionError:
                print('Please DO NOT divide by zero!')
                return True  # Suppress this specific exception

            # All other exceptions propagate normally (implicit return None)


    # Demonstrate the context manager
    print("Before entering context manager:")
    print("This text is printed normally")
    print()

    print("Entering context manager with LookingGlass():")

    with LookingGlass() as what:
        print('Alice, Kitty and Snowdrop')
        print(f"The variable 'what' is: {what}")

    print()
    print("After exiting context manager:")
    print("Output is back to normal")

    print("""
    WHY: The context manager handles all the complexity for you.
    You don't have to remember try/finally or manual cleanup.
    The 'with' statement makes it impossible to forget.
    """)

    # ============ EXAMPLE 4: Understanding the Execution Order ============
    print("\n# Example 4: Step-by-Step Execution")
    print("=" * 70)

    class Tracer:
        """Context manager that logs when things happen."""

        def __enter__(self):
            print("  1. __enter__ called (setup phase)")
            return "Inside the with block"

        def __exit__(self, exc_type, exc_value, traceback):
            print("  3. __exit__ called (cleanup phase)")
            if exc_type is not None:
                print(f"     Exception occurred: {exc_type.__name__}")
            return False  # Don't suppress exceptions


    print("Normal execution:")
    with Tracer() as var:
        print(f"  2. Inside with block, var = {var}")

    print()
    print("Execution with exception:")
    try:
        with Tracer() as var:
            print(f"  2. Inside with block")
            x = 1 / 0  # Raise exception
    except ZeroDivisionError:
        print("  4. Exception propagated and caught by outer try/except")

    print("""
    KEY INSIGHT: __exit__ is ALWAYS called, whether the block completes
    normally or an exception occurs. This guarantee is what makes context
    managers so powerful for cleanup.
    """)

    # ============ EXAMPLE 5: Handling Exceptions ============
    print("\n# Example 5: Exception Handling in __exit__")
    print("=" * 70)

    class ErrorHandler:
        """
        Context manager that can suppress certain exceptions.

        Shows how returning True from __exit__ suppresses exceptions.
        """

        def __init__(self, exception_type):
            self.exception_type = exception_type

        def __enter__(self):
            print(f"  __enter__: Will suppress {self.exception_type.__name__}")
            return self

        def __exit__(self, exc_type, exc_value, traceback):
            if exc_type is None:
                print("  __exit__: Normal exit (no exception)")
                return False
            elif exc_type is self.exception_type:
                print(f"  __exit__: Caught {exc_type.__name__} - suppressing it")
                return True  # Suppress this exception
            else:
                print(f"  __exit__: Caught {exc_type.__name__} - NOT suppressing")
                return False  # Don't suppress


    print("Suppressing ValueError:")
    with ErrorHandler(ValueError):
        print("  Inside block, raising ValueError")
        raise ValueError("This will be suppressed")
    print("  Block exited - exception was suppressed!")
    print()

    print("NOT suppressing TypeError:")
    try:
        with ErrorHandler(ValueError):
            print("  Inside block, raising TypeError")
            raise TypeError("This will NOT be suppressed")
    except TypeError:
        print("  Block exited - exception propagated and caught!")

    print("""
    WHY: Some context managers (like pytest fixtures) suppress expected
    exceptions. Returning True says "I handled this, don't propagate."
    Returning False or None says "I didn't handle this, propagate it."

    Most context managers return None or False - they don't suppress.
    """)

    # ============ EXAMPLE 6: Real-World Example - File Handling ============
    print("\n# Example 6: File Handling (Real-World Context Manager)")
    print("=" * 70)

    class ManagedFile:
        """
        A context manager for file operations.

        This demonstrates why context managers matter in real code:
        files need to be opened and closed reliably.
        """

        def __init__(self, name, mode):
            self.name = name
            self.mode = mode
            self.file = None

        def __enter__(self):
            print(f"  __enter__: Opening file '{self.name}'")
            self.file = open(self.name, self.mode)
            return self.file

        def __exit__(self, exc_type, exc_value, traceback):
            if self.file:
                print(f"  __exit__: Closing file '{self.name}'")
                self.file.close()
            if exc_type is not None:
                print(f"  __exit__: Exception occurred: {exc_type.__name__}")
            return False


    # Demonstrate file handling
    print("Writing to a file:")
    with ManagedFile('/tmp/test.txt', 'w') as f:
        f.write("Hello, World!\n")
        f.write("This is a test file.\n")

    print()
    print("Reading from the file:")
    with ManagedFile('/tmp/test.txt', 'r') as f:
        for line in f:
            print(f"  Read: {line.rstrip()}")

    print()
    print("File closed automatically after each with block!")

    print("""
    WHY: Built-in file objects already implement the context manager protocol,
    so you can use:
        with open('file.txt') as f:
            # ...

    This is so common that it's the Pythonic way to open files. Files are
    automatically closed when exiting the with block, even if an exception
    occurs.
    """)

    # ============ EXAMPLE 7: Multiple Resources ============
    print("\n# Example 7: Managing Multiple Resources")
    print("=" * 70)

    class Resource:
        """Simulates a resource that needs cleanup."""

        def __init__(self, name):
            self.name = name

        def __enter__(self):
            print(f"  Acquired {self.name}")
            return self

        def __exit__(self, exc_type, exc_value, traceback):
            print(f"  Released {self.name}")
            return False

        def use(self):
            print(f"  Using {self.name}")


    print("Managing multiple resources with nested with statements:")
    with Resource("Resource A") as a:
        with Resource("Resource B") as b:
            with Resource("Resource C") as c:
                a.use()
                b.use()
                c.use()

    print()
    print("More readable: use comma syntax (Python 3.10+):")
    print("""
        with Resource("A") as a, \\
             Resource("B") as b, \\
             Resource("C") as c:
            a.use()
            b.use()
            c.use()

    Resources are acquired and released in the correct order (LIFO - Last In,
    First Out). C is released first, then B, then A.
    """)

    # ============ EXAMPLE 8: The Complete Pattern ============
    print("\n# Example 8: Context Manager Checklist")
    print("=" * 70)

    print("""
    When implementing a context manager, follow this pattern:

    class MyContextManager:
        def __init__(self, ...):
            # Store initialization parameters
            self.resource = None

        def __enter__(self):
            # 1. Acquire resource
            self.resource = acquire_resource()
            # 2. Perform any setup
            self.resource.setup()
            # 3. Return a value for 'as variable' (or self)
            return self.resource

        def __exit__(self, exc_type, exc_value, traceback):
            # 1. Always clean up resources
            if self.resource:
                self.resource.cleanup()

            # 2. Optional: handle specific exceptions
            if exc_type is SomeException:
                # Handle it
                return True  # Suppress

            # 3. Return False/None to propagate exceptions
            return False

    USAGE:
        with MyContextManager(...) as resource:
            resource.do_something()
        # Cleanup guaranteed to happen here

    KEY RULES:
        - __enter__ runs when entering the with block
        - __exit__ ALWAYS runs when exiting (normal or exception)
        - __exit__ receives exception info (or None, None, None if normal)
        - Return True to suppress exception, False/None to propagate
        - Use __exit__ for cleanup, not for normal work
    """)

    # ============ EXAMPLE 9: Common Pattern - Lock Management ============
    print("\n# Example 9: Lock Management (Concurrency Pattern)")
    print("=" * 70)

    class SimpleLock:
        """Context manager for thread synchronization."""

        def __init__(self, name):
            self.name = name
            self.acquired = False

        def __enter__(self):
            print(f"  Acquiring lock: {self.name}")
            self.acquired = True
            return self

        def __exit__(self, exc_type, exc_value, traceback):
            if self.acquired:
                print(f"  Releasing lock: {self.name}")
                self.acquired = False


    print("Lock acquisition and release:")
    with SimpleLock("data_lock"):
        print("  Critical section protected by lock")
        print("  Can safely access shared data")
    print("  Lock automatically released")

    print("""
    WHY: Context managers are essential for thread-safe code. Locks are
    acquired on __enter__ and released on __exit__, even if exceptions occur.
    This prevents deadlocks and data corruption.

    This pattern is used in threading.Lock, threading.RLock, and many
    concurrency libraries.
    """)

    # ============ EXAMPLE 10: Summary ============
    print("\n# Example 10: When to Use Context Managers")
    print("=" * 70)

    print("""
    USE CONTEXT MANAGERS FOR:
        1. File operations (open, read, write, close)
        2. Database transactions (begin, commit, rollback)
        3. Network connections (connect, disconnect)
        4. Thread locks (acquire, release)
        5. Temporary state changes (redirect stdout, change directory)
        6. Resource pooling (acquire from pool, return to pool)
        7. Timing operations (start timer, stop and report)
        8. Error suppression (catch specific exceptions)

    PATTERN CHECKLIST:
        ✓ __enter__ acquires resources and returns them
        ✓ __exit__ releases resources (cleanup)
        ✓ __exit__ always runs, even on exceptions
        ✓ Suppressing exceptions is optional (usually don't)
        ✓ Use 'with' statement (never call __enter__/__exit__ directly)
        ✓ Can stack multiple with statements for multiple resources

    BENEFITS:
        ✓ Guaranteed cleanup (no forgotten cleanup calls)
        ✓ Clean, readable code
        ✓ Exception-safe
        ✓ Clear intent (anyone sees 'with' knows resource is managed)
        ✓ Pythonic (idiomatic Python style)

    NEXT: If you find context managers verbose, learn about @contextmanager
    decorator in the next tutorial - it simplifies many common cases!
    """)

    print("\n" + "=" * 70)
    print("KEY TAKEAWAYS")
    print("=" * 70)
    print("""
    1. CONTEXT MANAGERS ARE FOR RESOURCE MANAGEMENT: Use them whenever
       something needs setup and cleanup (files, locks, transactions, etc).

    2. __enter__ AND __exit__ ARE THE PROTOCOL: Implement these two methods
       and any object becomes a context manager usable with 'with'.

    3. __exit__ IS GUARANTEED TO RUN: This is what makes context managers
       so powerful - cleanup is guaranteed even if exceptions occur.

    4. RETURN VALUE MATTERS: Return True to suppress exceptions, False/None
       to propagate them. Most context managers propagate.

    5. WITH STATEMENT IS CLEANER: 'with' is more readable and safer than
       manual try/finally. Always prefer 'with' when possible.

    6. USE THE PROTOCOL EVERYWHERE APPLICABLE: Any resource that needs
       cleanup should be managed through a context manager. It's a sign
       of professional Python code.

    7. YOU'RE PROBABLY ALREADY USING THEM: File objects, database
       connections, and many libraries use context managers. Now you can
       build your own!
    """)

Runnable Example: context_manager_decorator.py

"""
TUTORIAL: The @contextmanager Decorator - Simpler Context Managers

This tutorial teaches you how to use @contextmanager decorator from the
contextlib module. Instead of writing classes with __enter__ and __exit__
methods, you can write a simple generator function that's much more concise.

This is the Pythonic way to create context managers when you don't need
the full power of a class.

Key Learning Goals:
  - Understand how @contextmanager transforms generators into context managers
  - Write context managers as functions instead of classes
  - Learn when to use decorator vs class approach
  - Master the yield pattern for setup/teardown
"""

import contextlib
import sys

if __name__ == "__main__":

    print("=" * 70)
    print("TUTORIAL: @contextmanager Decorator - Simpler Context Managers")
    print("=" * 70)

    # ============ EXAMPLE 1: Why the Decorator Exists ============
    print("\n# Example 1: Class vs Function Approaches")
    print("=" * 70)

    print("""
    REMINDER: Context managers can be classes:

        class LookingGlass:
            def __enter__(self):
                self.original = sys.stdout.write
                sys.stdout.write = self.reverse_write
                return 'JABBERWOCKY'

            def reverse_write(self, text):
                self.original(text[::-1])

            def __exit__(self, exc_type, exc_value, traceback):
                sys.stdout.write = self.original
                return False

    This is verbose and has a lot of boilerplate. If your context manager
    just does setup (before), then cleanup (after), there's a simpler way.

    WITH @contextmanager:

        @contextlib.contextmanager
        def looking_glass():
            original = sys.stdout.write
            sys.stdout.write = lambda text: original(text[::-1])
            try:
                yield 'JABBERWOCKY'
            finally:
                sys.stdout.write = original

    Much more concise! The decorator handles __enter__ and __exit__ for you.
    """)

    # ============ EXAMPLE 2: Basic @contextmanager Usage ============
    print("\n# Example 2: Your First @contextmanager")
    print("=" * 70)

    @contextlib.contextmanager
    def simple_context():
        """
        A simple context manager using the decorator.

        The pattern:
        1. Do setup before yield
        2. Yield a value to return from __enter__
        3. Do cleanup after yield (in finally block)

        The decorator automatically:
        - Wraps the yield value in a context manager
        - Runs code before yield in __enter__
        - Runs code after yield in __exit__
        - Handles exceptions properly
        """
        print("  Entering context (setup)")
        yield "setup complete"
        print("  Exiting context (cleanup)")


    print("Using the context manager:")
    with simple_context() as value:
        print(f"  Inside with block, value = {value}")

    print()
    print("Notice how clean this is:")
    print("  - No class definition needed")
    print("  - No __enter__ and __exit__ methods")
    print("  - Setup and cleanup are adjacent in the code")
    print("""
    WHY: For simple setup/cleanup patterns, the decorator is more readable.
    The yield statement clearly marks what gets returned and when cleanup happens.
    """)

    # ============ EXAMPLE 3: The LookingGlass with Decorator ============
    print("\n# Example 3: LookingGlass Revisited (Much Simpler)")
    print("=" * 70)

    @contextlib.contextmanager
    def looking_glass():
        """
        Reverse stdout output using the decorator.

        Compare this to the class version in the previous tutorial.
        This is so much simpler!

        HOW IT WORKS:
        1. Save original sys.stdout.write
        2. Yield the magic word to be returned by __enter__
        3. In finally block, restore the original write
        4. Everything between yield and end of function runs in __exit__
        """
        original_write = sys.stdout.write

        def reverse_write(text):
            original_write(text[::-1])

        sys.stdout.write = reverse_write
        try:
            yield 'JABBERWOCKY'  # This value is returned by __enter__
        finally:
            sys.stdout.write = original_write  # Always runs in __exit__


    print("Before context:")
    print("Normal output here")
    print()

    with looking_glass() as word:
        print('Alice, Kitty and Snowdrop')
        print(f"The magic word: {word}")

    print()
    print("After context:")
    print("Back to normal output")

    print("""
    WHY: The try/finally ensures cleanup happens even if an exception occurs
    inside the with block. The decorator handles all the __enter__/__exit__
    machinery for you.
    """)

    # ============ EXAMPLE 4: Yielding None ============
    print("\n# Example 4: Yielding None (No Return Value)")
    print("=" * 70)

    @contextlib.contextmanager
    def timed_operation(operation_name):
        """
        Context manager that measures execution time.

        Sometimes you don't yield a value - you just yield None or nothing.
        The context manager still works; 'as variable' will be None.
        """
        import time
        start_time = time.time()
        print(f"  Starting {operation_name}")
        try:
            yield  # No value returned
        finally:
            elapsed = time.time() - start_time
            print(f"  Finished {operation_name} in {elapsed:.4f} seconds")


    print("Measuring operation time:")
    with timed_operation("file read"):
        # Simulate work
        data = sum(range(1000000))

    print()
    print("Another operation:")
    with timed_operation("file write"):
        # Simulate work
        temp = [x for x in range(100000)]

    print("""
    WHY: Not all context managers need to return a value. Sometimes they just
    manage state or timing. Yielding None (or nothing) is perfectly valid.
    """)

    # ============ EXAMPLE 5: Exception Handling ============
    print("\n# Example 5: Handling Exceptions in Decorated Generators")
    print("=" * 70)

    @contextlib.contextmanager
    def error_handler(exception_type):
        """
        A decorator-based context manager that can suppress exceptions.

        The key insight: exceptions raised in the with block propagate to
        the code after yield. You can catch them with try/except.
        """
        print(f"  Entering (will handle {exception_type.__name__})")
        try:
            yield
        except exception_type as e:
            print(f"  Caught and suppressed: {exception_type.__name__}")
            # Exception is suppressed by not re-raising
        except Exception as e:
            print(f"  Caught but re-raising: {type(e).__name__}")
            raise
        finally:
            print(f"  Always cleaning up")


    print("Suppressing ValueError:")
    with error_handler(ValueError):
        print("  Doing work...")
        raise ValueError("Something went wrong")
    print("  After with block (exception was suppressed)")
    print()

    print("Not suppressing TypeError:")
    try:
        with error_handler(ValueError):
            print("  Doing work...")
            raise TypeError("Wrong type!")
    except TypeError:
        print("  After with block (exception propagated)")

    print("""
    WHY: Decorated generators let you handle exceptions elegantly.
    - Don't re-raise to suppress
    - Re-raise to propagate
    - finally block always runs (cleanup guaranteed)
    """)

    # ============ EXAMPLE 6: Resource Management Pattern ============
    print("\n# Example 6: Database-Like Resource Management")
    print("=" * 70)

    class FakeDatabase:
        """Simulates a database connection."""

        def __init__(self, name):
            self.name = name
            self.is_open = False

        def open(self):
            self.is_open = True
            print(f"    Database '{self.name}' opened")

        def close(self):
            self.is_open = False
            print(f"    Database '{self.name}' closed")

        def query(self, sql):
            if not self.is_open:
                raise RuntimeError("Database is closed")
            return f"Results of: {sql}"


    @contextlib.contextmanager
    def database_session(db_name):
        """
        Context manager for database operations.

        Pattern: open connection, yield it, close on exit.
        This is how many real database libraries work.
        """
        db = FakeDatabase(db_name)
        db.open()
        try:
            yield db
        finally:
            db.close()


    print("Using database context manager:")
    with database_session("mydb") as db:
        print(f"  Database open: {db.is_open}")
        result = db.query("SELECT * FROM users")
        print(f"  Query result: {result}")

    print(f"  After with block: {db.is_open}")

    print()
    print("Exception handling in database context:")
    try:
        with database_session("tempdb") as db:
            print(f"  Database open: {db.is_open}")
            # Simulate error
            raise ValueError("Invalid query")
    except ValueError:
        print("  Exception propagated and caught")
        print(f"  Database closed during exception: {db.is_open}")

    print("""
    WHY: Real libraries (SQLAlchemy, psycopg2, etc.) use this pattern.
    The decorator makes it simple to write context managers that acquire
    and release resources safely.
    """)

    # ============ EXAMPLE 7: Complex Setup and Teardown ============
    print("\n# Example 7: Multi-Step Setup and Cleanup")
    print("=" * 70)

    @contextlib.contextmanager
    def multi_step_context():
        """
        Context manager with multiple setup/teardown steps.

        Even complex operations stay clean with the decorator.
        """
        print("  Step 1: Initialize resources")
        resource1 = "resource_1"

        print("  Step 2: Configure resource")
        resource2 = "resource_2"

        print("  Step 3: Open connections")
        print("  SETUP COMPLETE")

        try:
            yield (resource1, resource2)
        finally:
            print("  Step 1: Close connections")
            print("  Step 2: Save state")
            print("  Step 3: Release resources")
            print("  CLEANUP COMPLETE")


    print("Multi-step context manager:")
    with multi_step_context() as (r1, r2):
        print(f"  Working with {r1} and {r2}")

    print("""
    WHY: Complex initialization and cleanup is still readable with decorators.
    The setup code is grouped at the top, cleanup at the bottom (in finally).
    """)

    # ============ EXAMPLE 8: Nesting and Composition ============
    print("\n# Example 8: Combining Multiple Context Managers")
    print("=" * 70)

    @contextlib.contextmanager
    def lock_context(name):
        """Simulates acquiring and releasing a lock."""
        print(f"  Acquired lock: {name}")
        try:
            yield
        finally:
            print(f"  Released lock: {name}")


    @contextlib.contextmanager
    def transaction_context():
        """Simulates a database transaction."""
        print("  BEGIN TRANSACTION")
        try:
            yield
        finally:
            print("  COMMIT TRANSACTION")


    print("Combining context managers with nesting:")
    with lock_context("data_lock"):
        with transaction_context():
            print("  Executing operations under lock and transaction")

    print()
    print("Cleaner syntax with multiple contexts (Python 3.10+):")
    print("""
        with lock_context("lock"), transaction_context():
            # operations here
    """)

    print("""
    WHY: Context managers compose well. You can nest them when you need
    multiple resources, or use comma syntax for cleaner code.
    """)

    # ============ EXAMPLE 9: When to Use Decorator vs Class ============
    print("\n# Example 9: Decorator vs Class - When to Use Each")
    print("=" * 70)

    print("""
    USE @contextmanager (DECORATOR) WHEN:
        ✓ Simple setup/cleanup pattern
        ✓ Just need to yield a value once
        ✓ Logic is straightforward
        ✓ Less than ~50 lines of code

    EXAMPLE: File cleanup, timing, simple redirects, database transactions

        @contextlib.contextmanager
        def simple():
            setup()
            try:
                yield value
            finally:
                cleanup()


    USE A CLASS WHEN:
        ✓ Need complex initialization
        ✓ Need multiple methods
        ✓ State management is complex
        ✓ Need to support multiple protocols (__enter__, __exit__, and others)
        ✓ Plan to reuse in multiple contexts

    EXAMPLE: Database connection pool, transaction with savepoints, API wrapper

        class ComplexManager:
            def __init__(self, ...):
                # Complex init
            def __enter__(self):
                # Setup
            def __exit__(self, ...):
                # Cleanup
            def helper_method(self):
                # Additional methods

    RULE OF THUMB: Start with @contextmanager. If you find yourself adding
    complexity, convert to a class. The decorator is for simple cases.
    """)

    # ============ EXAMPLE 10: Real-World Example - Temporary Directory ============
    print("\n# Example 10: Real-World Example - Temporary File Management")
    print("=" * 70)

    import tempfile
    import os

    @contextlib.contextmanager
    def temporary_directory():
        """
        Context manager for temporary directory.

        This shows a realistic use case: manage a temporary resource
        that needs cleanup.
        """
        tmpdir = tempfile.mkdtemp()
        print(f"  Created temporary directory: {tmpdir}")
        try:
            yield tmpdir
        finally:
            # Clean up
            if os.path.exists(tmpdir):
                print(f"  Removing temporary directory: {tmpdir}")
                # In real code: shutil.rmtree(tmpdir)
                # Here we just print it


    print("Using temporary directory:")
    with temporary_directory() as tmpdir:
        print(f"  Working in {tmpdir}")
        # Create files, do work, etc.
        print(f"  Directory exists: {os.path.exists(tmpdir)}")

    print(f"  Directory cleaned up after with block")

    print("""
    WHY: Real libraries (tempfile, sqlite3, requests, etc.) use decorated
    context managers extensively. This pattern is idiomatic Python.

    REAL-WORLD USAGE:
        with tempfile.TemporaryDirectory() as tmpdir:
            # Create files, do work
        # tmpdir automatically cleaned up

        with requests.Session() as session:
            response = session.get(url)
        # Connection automatically closed

        with open('file.txt') as f:
            data = f.read()
        # File automatically closed
    """)

    # ============ EXAMPLE 11: Summary and Comparison ============
    print("\n# Example 11: Summary - Class vs Decorator")
    print("=" * 70)

    print("""
    CONTEXT MANAGER AS CLASS:

        class ManagedResource:
            def __init__(self, name):
                self.name = name

            def __enter__(self):
                print(f"Opening {self.name}")
                return self

            def __exit__(self, exc_type, exc_value, traceback):
                print(f"Closing {self.name}")
                return False

        with ManagedResource("file") as res:
            # use res

    SAME CONTEXT MANAGER WITH DECORATOR:

        @contextlib.contextmanager
        def managed_resource(name):
            print(f"Opening {name}")
            try:
                yield type('Resource', (), {'name': name})()
            finally:
                print(f"Closing {name}")

        with managed_resource("file") as res:
            # use res

    The decorator is simpler for straightforward cases. The class is better
    for complex logic.
    """)

    print("\n" + "=" * 70)
    print("KEY TAKEAWAYS")
    print("=" * 70)
    print("""
    1. @contextmanager TURNS GENERATORS INTO CONTEXT MANAGERS: Decorate a
       generator function and it becomes usable with 'with' statements.

    2. THE YIELD PATTERN IS CLEAR: Code before yield = __enter__,
       code after yield = __exit__. Very readable!

    3. try/finally ENSURES CLEANUP: Always use try/finally around yield
       to guarantee cleanup happens, even on exceptions.

    4. DECORATOR SIMPLIFIES COMMON CASE: For simple setup/cleanup patterns,
       the decorator is far less verbose than a class.

    5. EXCEPTION HANDLING WORKS NATURALLY: Exceptions in the with block
       propagate to the code after yield. Catch them with try/except.

    6. YIELD NOTHING FOR SIDE-EFFECT MANAGERS: Not all context managers
       return values. Some just manage state (timing, locks, transactions).

    7. CHOOSE BASED ON COMPLEXITY: Decorator for simple cases, class for
       complex cases. Start with the decorator, convert to class if needed.

    8. STANDARD LIBRARIES USE THIS: Many real libraries use @contextmanager.
       Understanding it helps you read and use library code confidently.

    NEXT: Explore advanced patterns like ExitStack for managing multiple
    contexts dynamically, or write your own context managers for domain
    -specific resources (database transactions, API sessions, etc.).
    """)