بیاید context manager های پایتون را دریابیم

سلام خدمت شما. توی این پست قراره راجع به context manager ها و کتابخانه contextlib پایتون یاد بگیریم. من خودم به شخصه قبلا خیلی کم از اینها استفاده می کردم اما الان که فکر می کنم، سناریو های زیادی رو می بینم که می تونستم تو اونها از context manager ها استفاده کنم.

خوب context manager چی هست؟

به طور کلی context manager ها به شما امکان مدیریت منابع برنامه تون رو میدن. مثلا ممکنه بخواهید یه سری اطلاعات رو تو برنامه load کنید و پس از پردازش اونها، پاکشون کنید (یا عملیات clean-up انجام بدید).

خوب شاید با خودتون بگید: "مگه چیه، خودمون این عملیات رو انجام میدیم و نیازی به context manager نیست" ولی باید بگم که در موارد پیچیده ای که برای clean-up مجبورید 10 خط کد بنویسید خیلی هم نیازه. (چون بدون اونها هربار باید اون 10 خط کد رو تکرار کنید)

عبارت های with

یک context manager در یک عبارت with می تونه استفاده بشه که syntax اش به این صورته:

with <context_manager>(<args>) as <returned_resource>:
     <block_of_code> # deal with <returned_resource>
# <returned_resource> is disposed automatically after the `with` block

یک مثال

یکی از بارز ترین مثال هایی که در این مورد می تونم بزنم، استفاده از file ها در پایتون است. شما در پایتون می تونید اینطوری به فایل ها بنویسید:

file = open("path/to/file.txt", "w")
file.write("content")
file.close()

به نظر خیلی هم ساده میاد اما به هر حال بهتره که از context manager ها استفاده کنید، اونطوری اصلا لازم نیست خودتون فایل رو ببندید (خودش براتون می بنده و احتمال اینکه شما یادتون بره 0 درصد میشه).

with open("path/to/file.txt", "w") as file:
    file.write("content")

خوب این یه مثال ساده است اما شما می تونید تو خیلی از موقعیت های دیگه هم ازش استفاده کنید. مثلا وقتی چندین اتصال سوکت رو برقرار کردید که باید در آخر کار بسته بشن و ...

خوب دقیقا چطور کار می کنه؟

بهترین راه برای فهمیدنش اینه که خودمون یه context manager برای خودمون بسازیم. وقتی بفهمیم که context manager ها چطور ساخته میشن، شیوه کارشون رو هم به راحتی متوجه میشیم. شما می تونید با دو روش context manager خودتون رو پیاده سازی کنید:

  1. پیاده سازی با استفاده از class ها
  2. پیاده سازی با استفاده از generator ها

پیاده سازی با class ها

بزارید برای شروع یه context manager برای بستن اتوماتیک فایل ها بسازیم. برای اینکه یک class بتونه به عنوان یک context manager استفاده بشه، باید دو متد __enter__ و __exit__ رو داشته باشه. پایین نحوه تعریف کردنشون رو می بینید:

class ContextManager:
     def __init__(self, filepath, mode):
         # initialize the context manager
         self.file = open(filepath, mode)

     def __enter__(self):
         # return a value here. this value can be used in `as` clause of a `with` statement
         return self.file

     def __exit__(self, type, value, traceback):
         # dispose the acquired resources here. 
         print("cleaning up")
         self.file.close()
  • تابع __enter__ می تونه یه مقدار برگردونه که بعدا می تونیم ازش استفاده کنیم. این تابع هیچ آرگومانی نداره به غیر از self که همه متد ها باید اون رو داشته باشن
  • تابع __exit__ یه سری آرگومان های اضافی علاوه بر self داره. در صورتی که موقع اجرای دستورات درون عبارت with اروری رخ بده، اون سه آرگومان اضافی شامل اطلاعاتی درباره اون ارور خواهند بود که به شما این اجازه رو میده که اون ارور رو یه جوری handle کنید.
  • در مثال بالا تابع __init__ اختیاریه اما اگه می خواهید موقع ساخت instance از کاربر کدتون آرگومان بگیرید باید این تابع رو تعریف کنید.
اگه manager ما asynchronous باشه باید به جای __enter__ و __exit__ متد های __aenter__ و __aexit__ رو داشته باشه که فعلا باهاشون تو این پست کاری نداریم.

نحوه استفاده از classـی که بالا ساختیم به این صورته:

with ContextManager("path/to/file.txt", "r") as file:
       # do something with file
  • متغیر file در اصل همون فایلی هستش که در متد __enter__ اون رو برگردوندیم.
  • بعد از اونکه تمام دستورات داخل بلاک with اجرا شدند، پایتون خود به خود تابع __exit__ رو صدا میزنه و طبق کدی که ما در بدنه اون تابع نوشتیم، فایل همون جا بسته میشه و اینطوری اطمینان پیدا می کنیم که دیگه هیچ فایلی باز نمونده

کمی بیشتر راجع به __exit__

همونطور که تا الان متوجه شدید، متد __exit__ بعد از پایان اجرای دستورات بلاک with فراخوانی میشه. برای همین محل خوبی برای انجام عملیات clean--up هستش. حالا بزارید کمی بیشتر راجع به تک تک آرگومان های اون حرف بزنیم

  • آرگومان اول که self هستش و با اون کاری نداریم

اگر در زمان اجرای دستورات بلاک with اروری رخ بده ...

  • آرگومان type شامل نوع اروری که رخ داده خواهد بود (مثلا FileNotFoundError)
  • آرگومان value یک instance از روی type خواهد داشت که اطلاعاتی راجع به ارور پیش اومده به ما میده.
  • آرگومان traceback هم مثل value به ما اطلاعاتی راجع به ارور میده اما این اطلاعات مرتب شده و خواناتر هستند. می تونید این آرگومان رو به تابع print بدید تا اطلاعات کامل از جمله محل اتفاق افتادن ارور، نوع ارور و علتش رو با ظاهری خیلی تمیز در cmd/termianl ببینید.
اگر در زمان اجرای دستورات هیچ اروری رخ نده، هر سه آرگومان های بالا مقدار None رو خواهند داشت.

اگر اروری رخ بده، قبل از اینکه برنامه متوقف بشه و متن ارور نمایش داده بشه، تابع __exit__ همراه با تمام اطلاعات مربوط به ارور پیش آمده صدا زده می شه.

اگر بخواهید ارور پیش آمده رو خودتون handle کنید، تابع __exit__ باید مقدار True رو برگردونه، اینطوری پایتون متوجه میشه که چیز خاصی نیست و طبق معمول به کار خودش ادامه میده (انگار نه انگار که اروری پیش اومده)

در این مورد نمی تونیم از try و except استفاده کنیم برای همین باید از عبارت های شرطی استفاده کنیم. مثل مثال زیر:

class ContextManager:
    ... 
    def __exit__(self, type, value, traceback):
        if type == FileNotFoundError:
            print ("couldn't find the specified file")
            return True # means there's no problem with that error

همونطور که می بینید در این مثال، ارور مربوط به "پیدا نشدن فایل" یا همون FileNotFoundError با عبارت شرطی handle شده. عبارت return True باعث میشه که پایتون بفهمه که ارور توسط ما handle شده و ایراد خاصی نیست.

اگه می خواهید کل ارور ها رو pass کنید. کافیه فقط یه عبارت return True توی __exit__ بزارید

پیاده سازی با generator ها

شما می تونید یک generator بسازید و تبدیلش کنید به context manager. برای اینکار شما به دکوریتور contextmanager نیاز دارید. این دکوریتور کاری می کنه که وقتی شما تابع رو فراخوانی می کنید، یک instance از نوع GeneratorContextManager از تابع برگشت داده بشه. اینطوری generator شما تبدیل به یک context manager میشه.

from contextlib import contextmanager

@contextmanager
def suppress_all_errors():
    try:
        yield optional_value
    except: pass

نکته: این generator باید فقط و فقط یک عبارت yield داشته باشه نه کمتر نه بیشتر که البته دلیل این رو در ادامه بهتون خواهم گفت.

حالا می تونیم از suppress_all_errors مثل هر context manager دیگه ای استفاده کنیم:

with suppress_all_errors() as optional_value_returned_from_yield_statement:
      # do stuff here

نوشتن کد بالا برابره با نوشتن کد زیر:

try:
    # do stuff
except: pass

تا الان ممکنه یه سری سوالات ذهنتون رو مشغول کرده باشه. در ادامه به یه سری سوالات معمول که برای خودم هم پیش اومده بود جواب میدم ...

اون عبارت yield دیگه برای چیه؟

قبل اینکه بریم سر اصل مطلب بزارید کمی از اصل مطلب دور بشیم و درمورد generator ها یادبگیریم

پرانتز باز (

برای فهم این مطلب باید از قبل با generator ها آشنا باشید. generator ها نوعی تابع هستند که شبیه به iterator ها عمل میکنن. هربار که یه مقداری رو از طریق عبارت های yield بر می گردونن متوقف میشن تا زمانی که دوباره شما بهشون بگید تا اجرا بشن. generator ها همینطور به برگردوندن مقادیر مختلف ادامه میدن تا زمانی که دیگه هیچ عبارت yield دیگه ای نباشه و اجرای دستورات تابع به آخرش برسه. از اونجایی که توضیح دادن generator ها به زبان آدمیزاد کار سختیه، اجازه بدید با زبان پایتون باهاتون صحبت کنم:

def gen():
    number  = 1
    yield number

    number += 1
    yield number

    number += 1
    yield number

generatorObj= gen()
print (repr(generatorObj)) # -> '<generator object gen at 0x00A08A00>'

value = next(generatorObj) # value  = 1
value = next(generatorObj) # value = 2
value = next(generatorObj) # value = 3
 # causes a StopIteration error since there's no yield statements anymore
value = next(generatorObj) # StopIteration error

در پایتون. هر تابعی که حداقل یک عبارت yield داشته باشه یک generator محسوب میشه. توجه داشته باشید که generator یک تابع معمولی نیست و نمی تونید با فراخوانی اون دستوراتش رو اجرا کنید. وقتی که شما generator رو فراخوانی می کنید، یک object از نوع generator بهتون داده میشه که باید اون رو در یک متغیر ذخیره کنید. همونطور که توی مثال بالا می بینید من اون object رو در متغیری به نام generatorObj ذخیره کرده ام. حالا برای اجرای دستورات باید تابع next رو روی generatorObj صدا بزنیم.

وقتی که تابع next روی generatorObj فراخوانی میشه، اجرای کد ها شروع میشه و تا زمانی که به یک عبارت yield برسیم، ادامه پیدا می کنه. بعد از اینکه به عبارت yield رسیدیم، مقداری که عبارت yield داره برگردونده میشه و تابع به حالت suspend (تعویق) درمیاد (موقتا متوقف میشه) و کنترل thread به caller (=تابعی یا قسمتی از کد که generator رو فراخوانی کرده) داده میشه.

حالا caller می تونه تصمیم بگیره که کی generator کارش رو دوباره از سر بگیره.

اگه دیگه هیچ عبارت yieldـی وجود نداشته باشه، صدا زدن تابع next رو generator باعث ارور StopIteration میشه. حال در صورتی که تابع عبارت return هم داشته باشه، مقدار برگشتی از عبارت return به صورت StopIterationInstance.value قابل دسترسی است.

از generator ها می تونید به عنوان iterator استفاده کنید تا یک مجموعه نامتناهی از آیتم ها رو ایجاد کنید. مثل generator زیر که ساخت عدد های فرد رو تا بینهایت ادامه میده:

def gen():
    n = 1
    while True: # for ever
         yield 2 * n - 1
         number += 1

for number in gen(): print (number)
# 1
# 3
# 5
# ... All odd numbers

قطعا بدون داشتن generator ها انجام دادن کار هایی از این قبیل ممکن نیست. اگه generator ها نبودن باید یه عالمه عدد رو در یک list خیلی بزرگ ذخیره می کردید و دونه دونه میگرفتید و print می کردید اون هم در صورتی که ممکنه فقط به 5 تا از اونها نیاز داشته باشید. انجام این کار با list ها برنامه رو کند میکنه و کلی از حجم حافظه رو اشغال می کنه. پس کلا در این سناریو ها بهتره از generator ها استفاده کنید:

  • وقتی که خروجی iterator شما الگویی متناوب و مشخص داشته باشه (مثل مثال بالا که الگوی ما 2n-1 بود (فرمول اعداد مفرد))
  • وقتی که مجموعه ای نامتناهی از آیتم ها رو داشته باشید.

) پرانتز بسته

خوب دیگه حاشیه بسه. بریم سراغ اصل مطلب. اگه یادتون باشه بهتون گفتم که یک generator بعد از رسیدن به عبارت yield متوقف میشه. یک context manager که با generator ها پیاده سازی شده اینطوری کار می کنه:

  • اول از همه کد های داخل generator اجرا میشن.
  • وقتی که به عبارت yield میرسیم generator متوقف میشه و کنترل thread به caller داده میشه، پس این فرصت به وجود میاد که کد های داخل بلاک with در این میان اجرا بشن. - (اگه هم عبارت yield مقدار برگشتی داشته باشه در قسمت `as` عبارت with مقدارش به یک متغیر اختصاص داده میشه)
  • بعد از اینکه کل کدها اجرا شدند، تابع next روی generator صدا زده میشه تا generator کارش رو ادامه بده (کار های مربوط به clean-up در اینجا انجام میشه). البته اونطوری که من توی سورس کد پایتون دیدم، اگه این generator بیشتر از یک عبارت yield داشته باشه (یعنی اگه بعد از فراخوانی دوباره next ارور StopIteration رخ نده) پایتون خودش یک ارور نشون میده مبنی بر اینکه generator کارش رو متوقف نکرده. پس حواستون باشه که generator فقط باید یک yield داشته باشه.

اگه اروری در بلاک with پیش بیاد چطور handle میشه؟

این یک سوال بزرگ بود که همش ذهنم رو به خودش مشغول میکرد. بالاخره دل رو زدم به دریا و رفتم سراغ سورس کد کتابخونه contextlib پایتون

اولش فکر میکردم که با یه مشت کد عجیب و غریب که به طور وحشتناکی پیچیده اند رو به رو میشم اما نمی دونید کد ها چقدر تر و تمیز و خوشگل نوشته شده بودن!!!

بعد از کمی این ور و اون ور کردن سورس کد پایتون و به کمک VS Code بالاخره فهمیدم چطور خود generator ارور هایی که بیرون از خودش رخ داده رو handle میکنه.

هر generator object یک متد به نام throw داره که با استفاده از اون می تونید اتفاق افتادن یه ارور رو شبیه سازی کنید. وقتی که این تابع رو صدا میزنید انگار دارید کاری می کنید که به نظر برسه دقیقا همونجایی که generator متوقف شده اروری رخ داده. حالا اگه جایی که generator در اون متوقف شده داخل بلاک های try و except قرار گرفته باشه به راحتی ارور handle میشه.

پس به طور کلی وقتی داخل بلاک with اروری رخ بده پایتون اینطوری حلّش میکنه:

  1. اولا خودش اون ارور رو handle میکنه
  2. بعد اون رو از طریق تابع throw به generator انتقال میده.
  3. حالا اگه generator یه بلاک try و except داشته باشه ارور handle میشه وگرنه ارور باعث متوقف شدن برنامه میشه.
برای کسب اطلاعات بیشتر راجع به generator ها به این مطلب از سایت realpython مراجعه کنید. مقاله بسیار عالی هستش.

معرفی به کتابخانه contextlib

خوب همونطور که می دونید پایتون کتابخانه استاندارد (standard library) خیلی غنی داره. اونقدر غنی که حتی یه سری context manager آماده داره که کار های معمول رو انجام بدن. در زیر چند تا از جالب ترین هاشون رو ذکر می کنم:

  • contextlib.contextmanager

با این مورد قبلا آشنا شدید. بهتون قابلیت تبدیل generator ها به contextmanager رو میده

from contextlib import contextmanager

@contextmanager
def file_writer():
     file = open("path/file.txt", "w")
     yield file
     file.close()

with file_writer() as write_stream:
    write_stream.write("content")
  • contextlib.suppress(exception_type)

این contextmanager کاری می کنه که اگه اروری از نوع `exception_type` در بلاک with پیش اومد، اون ارور خنثی بشه و باعث توقف برنامه نشه.

from contextlib import suppress

with suppress(FileNotFoundError):
     file = open("path/to/file.txt", "r")
     buffer = file.read(1024)
  • contextlib.nullcontext(enter_result)

این context manager هیچ کاری نمی کنه فقط enter_result رو در __enter__ برمیگردونه ?. خوب شاید بگید به چه دردی می خوره!؟ برای اینکه بفهمید به چه دردی می خوره به مثال زیر نگاهی بندازید

from contextlib import nullcontext, contextmanager, suppress

def try_to_establish_a_connection(ignore_exceptions = False):
     cm = suppress(Exception)
     if !ignore_exception: cm = nullcontext()
     with cm:
          # try to establish a connection here

همونطور که در مثال بالا می بینید، تابع `try_to_establish_a_connection` پارامتری به نام ignore_exceptions داره. اگه مقدار این متغیر برابر با True باشه، اون موقع cm همه ارور های پیش اومده رو خنثی خواهد کرد اما اگه مقدار این متغیر برابر با False باشه cm برابر خواهد بود با contextmanager ای از نوع nullcontext که اصولا هیچ کاری نمی کنه (=هیچ اروری هم خنثی نمیشه). تنها کاری که انجام میده اینه که مثل یک interface عمل می کنه و باعث میشه پایتون به خاطر اینکه از cm به عنوان context manager استفاده کردیم ایراد نگیره.

یعنی در اصل فقط به خاطر اینکه اون بلاک with رو برنداریم از nullcontext استفاده می کنیم.
  • contextlib.closing(thing)

این context manager پارامتری به نام thing داره. این thing هرچیزی می تونه باشه. تنها شرطش اینه که متدی به نام close رو داشته باشه. مثلا یک فایل در پایتون از متد close برخورداره پس می تونه به عنوان thing استفاده بشه.

from contextlib import closing
class ClosingClass:
     def __init__(self, name): self.name = name
     def close(self): print (f"closed {self.name}")

with closing(ClosingClass("something")) as instance:
     print (f"dealing with {instance.name}")

# expected output:
# dealing with something
# closed something

خوب تا همینجا کافیه. اگه دوست دارید اطلاعات بیشتری به دست بیارید به اینجا مراجعه کنید

چند تا سناریو جالب برای استفاده از contextmanager ها

خوب الان که بر میگردم و به پشت سرم نگاهی میندازم، تو بعضی جا ها می تونستم از context manager ها استفاده کنم که نکردم. (ولی از این به بعد سعی می کنم که این کار رو بکنم)

استفاده برای load و unload کردن پلاگین ها

توی یکی از مقاله های قبلیم راجع به ساخت یک سیستم پلاگین برای پایتون توضیح داده ام که اگه وقت کردید حتما سری بهش بزنید. توی اون مقاله برای load و unload پلاگین ها از شیوه ای به این صورت استفاده کردم:

for plugin in plugins:
     plugin_instance = plugin.Plugin()
     plugin_instance.load(program_instance)
     plugin_list_to_unload.append(plugin_instance)

program_instance.run() # run the main program

for plugin_to_unload in plugin_list_to_unload:
     plugin_to_unload.unload()

راستش دقیقا یادم نیست که اون زمان چی نوشتم و اسم متغیر هام چی بود. ولی یادمه از همچین روشی استفاده کرده بودم.

شاید اگه در اون زمان کمی بیشتر راجع به context manager ها بلد بودم از یه راه حل تمیز تر می رفتم:

class PluginLoader:
    def __init__(self, plugins, program_instance):
        self.plugin_instances = [p.Plugin() for p in plugins]
        for plugin in self.plugin_instances: plugin.load(program_instance)
    def close(self):
         for plugin in self.plugin_instances: plugin.unload()

with closing(PluginLoader(plugins, program_instance)): 
     program_instance.run() # run the program here

خوب شاید بعضی از شما بگید که این از روش معمول هم طولانی تره. ولی باید بگم که اگه بخواهید در جاهای مختلف پلاگین ها رو چندین بار load و unload کنید دیگه لازم نیست کلی کد بزنید، با دو سه خط کد کارتون راه می افته. مثلا در مثال بالا اگه دوباره دلم بخواد پلاگین ها رو load و unload کنم می تونم به راحتی فقط این رو بنویسم:

with closing(PluginLoader(plugins, program_instance)): 
    # do stuff

ذخیره اطلاعات در بافر و پردازش یکجا

مثلا می تونید با یه حلقه بینهایت و با دستور input از کاربر هرچقدر خواستید ورودی بگیرید و ذخیره کنید بعد همه رو توی یه فایل بنویسید، یا اینکه از طریق یه سوکت برفرستیدش برای یکی دیگه، یا اینکه به سادگی چاپش کنید تو ترمینال و ... (کار هایی از این قبیل)

class UserInputProcessor:
    def __init__(self): self.buffer = []
    def __enter__(self): return self.buffer
    def __exit__(self):
         accumulated_str = "\n".join(self.buffer)
         with open("path/file.txt", "w") as file: file.write(accumulated_str)

with UserInputProcessor() as buffer:
    while (content:=input()) != "__exit__": buffer.append(content)

خوب دیگه خلاقیتم ته کشید دوستان. ولی مطمئنم استفاده های بیشتری از همین context manager ها میشه کرد. امیدوارم براتون مفید بوده باشه. خدانگهدار.