Context Manager در پایتون روشی است برای اجرای قطعی کدهای راه‌اندازی و جمع‌بندی با دستور with؛ یعنی قبل از ورود به بلاک با __enter__ آماده‌سازی انجام می‌شود و در پایان بلاک (حتی در صورت خطا) __exit__ فراخوانی و منبع آزاد می‌شود. می‌توانید آن را با نوشتن کلاس دارای __enter__/__exit__ پیاده کنید یا با contextlib سریع‌تر بسازید.

Context Manager دقیقا چه مسئله‌ای را حل می‌کند؟

پاسخ کوتاه: مدیریت عمر منابع به شکل مطمئن. فایل، قفل، اتصال شبکه یا تراکنش باید حتما آزاد شوند؛ با with فراموش نمی‌کنید. حتی اگر استثنا رخ دهد، پاکسازی در __exit__ یا بلوک finally اتفاق می‌افتد.

دستور with چگونه کار می‌کند؟

  1. ابژه سمت راست with باید Context Manager باشد.
  2. پایتون __enter__ را صدا می‌زند و نتیجه‌اش را به as متغیر می‌دهد (اگر as داشته باشید).
  3. بدنه with اجرا می‌شود.
  4. در پایان، چه خطا رخ دهد چه نه، __exit__(exc_type, exc, tb) اجرا می‌شود.
# نمونه ساده فایل
with open("data.txt", "w", encoding="utf-8") as f:
    f.write("hello")
# اینجا فایل قطعا بسته شده است

نوشتن Context Manager با کلاس: __enter__ و __exit__

وقتی نیاز خاص دارید، یک کلاس با این دو متد بنویسید. __enter__ باید ابژه‌ای را که می‌خواهید با as دریافت شود برگرداند؛ __exit__ هم همیشه فراخوانی می‌شود و می‌تواند خطا را مدیریت کند.

import time

class Timer:
    def __enter__(self):
        self.start = time.perf_counter()
        return self  # در with ... as t: این شیء به t می‌رسد

    def __exit__(self, exc_type, exc, tb):
        self.elapsed = time.perf_counter() - self.start
        print(f"done in {self.elapsed:.3f}s")
        # با False (یا None) اجازه می‌دهیم استثناها عبور کنند
        return False

# استفاده
with Timer() as t:
    sum(i for i in range(1_000_000))
# در پایان زمان اجرا چاپ می‌شود

اگر __exit__ مقدار True برگرداند، استثنای رخ‌داده در بدنه with سرکوب می‌شود. این کار فقط وقتی موجه است که واقعا می‌خواهید خطا نادیده گرفته شود؛ در غیر این صورت، بهتر است False برگردانید تا اشکال‌زدایی سخت نشود.

ساخت سریع با contextlib.contextmanager

اگر فقط می‌خواهید قبل/بعد یک قطعه کد کاری انجام دهید، دکوریتور contextmanager نوشتن کلاس را بی‌نیاز می‌کند. قبل از yield آماده‌سازی، بعد از yield پاکسازی است. حتما پاکسازی را در finally بگذارید.

import sqlite3
from contextlib import contextmanager

@contextmanager
def open_db(path):
    conn = sqlite3.connect(path)
    try:
        yield conn  # بدنه with از این اتصال استفاده می‌کند
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()

# استفاده
with open_db("example.db") as db:
    db.execute("create table if not exists t(x)")
    db.execute("insert into t(x) values (?)", ("hi",))

ابزارهای کاربردی contextlib

  • suppress: استثناهای مشخصی را نادیده بگیر.
    from contextlib import suppress
    
    with suppress(FileNotFoundError):
        print(open("maybe.txt").read())  # اگر نبود، خطا نمی‌دهد
  • closing: برای ابژه‌هایی که فقط متد close دارند و Context Manager نیستند.
    from contextlib import closing
    
    class Resource:
        def close(self):
            print("closed")
    
    with closing(Resource()):
        # استفاده از منبع
        pass  # در پایان close صدا زده می‌شود
  • nullcontext: وقتی تنظیم اختیاری دارید؛ اگر منبعی ندارید، با nullcontext همان API حفظ می‌شود.
    from contextlib import nullcontext
    
    debug = False
    cm = open("log.txt", "a", encoding="utf-8") if debug else nullcontext()
    
    with cm as f:
        if debug:
            f.write("started\n")
        # ادامه کار
  • ExitStack: مدیریت پویا و ترکیب چند Context Manager که از قبل تعدادشان معلوم نیست.
    from contextlib import ExitStack
    
    filenames = ["a.txt", "b.txt", "c.txt"]
    with ExitStack() as stack:
        files = [stack.enter_context(open(name, "w", encoding="utf-8")) for name in filenames]
        for i, f in enumerate(files, 1):
            f.write(f"file {i}\n")
    # همه فایل‌ها خودکار بسته شدند

مدیریت چند منبع در یک with یا با ExitStack

وقتی تعداد ثابت است، می‌توانید چند Context Manager را در یک with بیاورید. ترتیب باز شدن از چپ به راست است و بستن به ترتیب معکوس.

with open("in.txt", encoding="utf-8") as src, open("out.txt", "w", encoding="utf-8") as dst:
    for line in src:
        dst.write(line.upper())

وقتی تعداد منابع در زمان اجرا معلوم می‌شود، از ExitStack استفاده کنید (نمونه بالا).

Context Manager ناهمگام: async with و __aenter__/__aexit__

وقتی آماده‌سازی و پاکسازی نیازمند عملیات ناهمگام است، از async with استفاده می‌شود و شیء باید __aenter__ و __aexit__ ناهمگام داشته باشد. یک نمونه رایج، کار با TaskGroup در asyncio است.

import asyncio

async def main():
    async with asyncio.TaskGroup() as tg:
        tg.create_task(asyncio.sleep(1))
        tg.create_task(asyncio.sleep(2))
    # در اینجا همه تسک‌ها تمام یا مدیریت خطا شده‌اند

asyncio.run(main())

برای یادگیری عمیق‌تر این الگو در برنامه‌های همزمان، آموزش مدیریت همزمان چند وظیفه با asyncio TaskGroup در پایتون مسیر خوبی است.

الگوی استفاده در برنامه‌های CLI

در ابزارهای خط فرمان، Context Managerها برای باز و بستن فایل‌های پیکربندی، قفل‌گذاری یا مدیریت لاگ بسیار مفیدند. اگر در حال ساخت CLI هستید، آشنایی با Typer کنار این الگوها کمک می‌کند کد تمیزتری بنویسید. نگاه کنید به آموزش Typer در پایتون: ساخت ابزار خط فرمان حرفه‌ای.

خطاهای رایج و نکات ایمنی

  • سرکوب ناخواسته استثنا: برگرداندن True از __exit__ خطا را قورت می‌دهد. مگر با دلیل روشن، False برگردانید. برای نادیده گرفتن هدفمند، از contextlib.suppress استفاده کنید.
  • فراموشی finally در generator-based: در contextmanager فقط یک بار yield مجاز است. پاکسازی را حتما در finally بگذارید تا حتی در صورت خطا اجرا شود.
  • اتکا به __del__ برای پاکسازی: زمان فراخوانی __del__ قطعی نیست. پاکسازی منابع را به Context Manager یا بلوک finally بسپارید.
  • ایجاد منبع در __enter__ بدون برنامه پشتیبان: اگر وسط __enter__ خطا رخ دهد، __exit__ اجرا نمی‌شود. منابع موقتی را یا قبل از __enter__/after yield مدیریت کنید یا در همان __enter__ با try/except خودتان جمع کنید.
  • استفاده بعد از خروج: پس از خروج از with، منبع ممکن است بسته/آزاد شده باشد. استفاده بعدی می‌تواند خطا یا رفتار نامشخص ایجاد کند.
  • ترتیب بسته‌شدن در چند Context Manager: بستن از راست به چپ است. اگر وابستگی زمانی دارید، ترتیب را با دقت بچینید یا از ExitStack کمک بگیرید.
  • بالا انداختن خطای جدید در __exit__: اگر هنگام لاگ‌کردن/پاکسازی خطای دیگری بالا بیاید، خطای اصلی پنهان می‌شود. استثناهای ثانویه را مدیریت کنید.

روش بررسی صحت و اشکال‌زدایی

  • اطمینان از بسته‌شدن فایل:
    f = open("tmp.txt", "w", encoding="utf-8")
    with f:
        f.write("ok")
    print(f.closed)  # True
  • بازبینی مسیر اجرای خطا: با ایجاد خطا در بدنه with بررسی کنید __exit__ یا بلاک finally اجرا می‌شود.
    class CM:
        def __enter__(self):
            print("enter")
            return self
        def __exit__(self, exc_type, exc, tb):
            print("exit", exc_type.__name__ if exc_type else None)
            return False  # نگذارید خطا مخفی شود
    
    try:
        with CM():
            1 / 0
    except ZeroDivisionError:
        print("caught!")
  • تست ترکیب پویا: برای چند منبع پویا از ExitStack استفاده کنید و با شمارش/پرینت اطمینان بگیرید همه آزاد می‌شوند.

چه زمانی کدام روش مناسب‌تر است؟

  • کلاس با __enter__/__exit__: وقتی حالت داخلی دارید یا می‌خواهید ابژه‌ای با عمر قابل مدیریت در اختیار کاربر بگذارید.
  • @contextmanager: برای پوشاندن یک API موجود با before/after سریع و بدون نیاز به نگه‌داشتن state پیچیده.
  • ExitStack / AsyncExitStack: وقتی تعداد/نوع contextها در زمان اجرا معلوم می‌شود یا نیاز به ترکیب انعطاف‌پذیر دارید.
  • async with: هر جا آماده‌سازی/پاکسازی ناهمگام است (سوکت‌های async، کلاینت‌های HTTP async، TaskGroup).

گام بعدی چیست؟

از امروز هر منبعی که باید قطعا آزاد شود را در with قرار دهید. اگر الگوی خاصی تکرار می‌شود، آن را به شکل یک Context Manager کوچک (با کلاس یا contextlib) تبدیل کنید. در پروژه‌های ناهمگام، حتما الگوی async with و مدیریت گروهی وظایف را تمرین کنید و برای تکمیل مسیر، سر بزنید به آموزش مدیریت همزمان چند وظیفه با asyncio TaskGroup در پایتون.