Python v3.x: обработчик исключений для корутин и синхронных функций. Вобщем, для всего

    В свободное время я работаю над своим небольшим проектом. Написан на Python v3.x + SQLAlchemy. Возможно, я когда-нибудь напишу и о нем, но сегодня хочу рассказать о своем декораторе для обработки исключений. Его можно применять как для функций, так и для методов. Синхронных и асинхронных. Также можно подключать кастомные хэндлеры исключений.

    Декоратор на текущий момент выглядит так:
    import asyncio
    
    from asyncio import QueueEmpty, QueueFull
    from concurrent.futures import TimeoutError
    
    
    class ProcessException(object):
    
        __slots__ = ('func', 'custom_handlers', 'exclude')
    
        def __init__(self, custom_handlers=None):
            self.func = None
            self.custom_handlers: dict = custom_handlers
            self.exclude = [QueueEmpty, QueueFull, TimeoutError]
    
        def __call__(self, func, *a):
            self.func = func
    
            def wrapper(*args, **kwargs):
                if self.custom_handlers:
                    if isinstance(self.custom_handlers, property):
                        self.custom_handlers = self.custom_handlers.__get__(self, self.__class__)
    
                if asyncio.iscoroutinefunction(self.func):
                    return self._coroutine_exception_handler(*args, **kwargs)
                else:
                    return self._sync_exception_handler(*args, **kwargs)
    
            return wrapper
    
        async def _coroutine_exception_handler(self, *args, **kwargs):
            try:
                return await self.func(*args, **kwargs)
            except Exception as e:
                if self.custom_handlers and e.__class__ in self.custom_handlers:
                    return self.custom_handlers[e.__class__]()
    
                if e.__class__ not in self.exclude:
                    raise e
    
        def _sync_exception_handler(self, *args, **kwargs):
            try:
                return self.func(*args, **kwargs)
            except Exception as e:
                if self.custom_handlers and e.__class__ in self.custom_handlers:
                    return self.custom_handlers[e.__class__]()
    
                if e.__class__ not in self.exclude:
                    raise e
    
    

    Разберем по порядку. __slots__ я использую для небольшой экономии памяти. Бывает полезно, если объект используется ну ооочень часто.

    На этапе инициализации в __init__ мы сохраняем custom_handlers (в случае, если понадобилось их передать). На всякий случай обозначил, что мы ожидаем там увидеть словарь, хотя, возможно, в будущем, есть смысл добавить пару жестких проверок. В свойстве self.exclude лежит список исключений, которые обрабатывать не нужно. В случае такого исключения функция с декоратором вернет None. На текущий момент список заточен под мой проект и возможно есть смысл его вынести в отдельный конфиг.

    Самое главное происходит в __call__. Поэтому при использовании декоратора его нужно вызывать. Даже без параметров:

    @ProcessException()
    def some_function(*args):
        return None
    

    Т.е. вот так уже будет неправильно и будет вызвана ошибка:

    @ProcessException
    def some_function(*args):
        return None
    

    В этом случае мы получим текущую функцию, которую, в зависимости от степени ее асинхронности обработаем либо как обычную синхронную, либо как корутин.

    На что здесь можно обратить внимание. Первое, это проверка на проперти:

    if self.custom_handlers:
        if isinstance(self.custom_handlers, property):
            self.custom_handlers = self.custom_handlers.__get__(self, self.__class__)
    

    Зачем я это делаю.

    Конечно же 
             не потому, что 
                          я айти-Маяковский 
                                       и мне платят построчно.
    

    Два if здесь для улучшения читабельности (да-да, ведь код может саппортить человек с садистскими наклонностями), а self.custom_handlers.__get__(self, self.__class__) мы делаем для того, чтобы не терять класс, в случае, если мы решили хэндлеры хранить в @property класса.

    Например, так:

    class Math(object):
        @property
        def exception_handlers(self):
            return {
                ZeroDivisionError: lambda: 'Делить на ноль нельзя, но можно умножить'
            }
        
        @ProcessException(exception_handlers)
        def divide(self, a, b):
            return a // b
    

    Если не сделать self.custom_handlers.__get__(...), то вместо содержимого @property мы будем получать что-то типа <property object at 0x7f78d844f9b0>.

    Собственно, в примере выше показан способ подключения кастомных хэндлеров. В общем случае это делается так:

    @ProcessException({ZeroDivisionError: lambda: 'Делить на ноль можно, но с ошибкой'})
    def divide(a, b):
        return a // b
    

    В случае с классом (если мы собираемся передавать свойства/методы) нужно учесть, что на этапе инициализации декоратора класса как такового еще нету и методы/свойства суть простые функции. Поэтому мы можем передать только то, что объявлено выше. Поэтому вариант с @property — это возможность применять через self все функции, которые ниже по коду. Ну либо можно использовать лямбды, если self не нужен.

    Для асинхронного кода справедливы все вышеописанные примеры.

    Напоследок хочу обратить внимание, что если исключение на своем пути не встретило кастомных хэндлеров, то оно просто рейзится (raise) дальше.

    Жду ваших комментариев. Спасибо за то, что уделили внимание моей статье.
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 24

      0
      ээ, я ожидал презентацию декоратора, кишки это уже дело второе
        0
        как говорил один в меру известный мастер презентации декораторов, «если то, что выше — не презентация декоратора, то я не знаю, что такое презентация декоратора». Скажите хотя бы, чего вы ожидали, может я смогу извлечь какие-нибудь выводы на будущее относительно оформления?
          0
          А какую проблему он решает?
            0
            избавляет от необходимости везде писать try...except, что само по себе достаточно громоздко. Делает код компактным и избавляет от необходимости его дублирования.
              0
              Так нигде и не надо писать try..except — подавляющее большинство исключений должно обрабатываться на самом верхнем уровне, на то они и исключения, и декоратор там не поможет ничем.
              А для типовых задач вроде повторения запроса к внешней системе есть типовые модули.
                0
                А как такой подход применить, если есть, например, сервер, который все запросы отдает в хэндлеры, а хэндлер может вернуть разный результат? Например, мы отправляем запрос на создание персонажа, а введенное имя уже занято. Получается, мне нужно добавить доп. запрос в бд на проверку имени, чтоб не использовать локально try except? Имхо, немного накладно. А с декоратором и локальным try...except мы в случае IntegrityError ('name' is duplicated) не делаем экстра запросов. Запрос всегда один.
                  0

                  В данном случае тот код, который занимается обработкой непосредственно запроса и передачей его в хендлер, и есть тот самый "верхний уровень".

                    0
                    правильно. И вот если не использовать локальный try except, то придется каждый раз делать еще один запрос в бд на уникальность. Полагаю, это не лучший вариант.
                      0

                      А, вот вы о чём. Но зачем вам тогда декоратор?

                        0
                        Затем, что таких try except очень много накапливается, они получаются более громоздкими, чем декоратор.
                          0

                          Так ведь "один" декоратор в любом случае не получится, ошибка IntegrityError может требовать разной реакции в разных случаях.

                            0
                            Да, и для этого в декоратор можно будет передавать опциональное свойство custom_handlers. Для каждого отдельного хэндлера, где будет такая ошибка.
                    0
                    вот этот пример уже ближе к делу, хотя тоже не факт что хендлер должен возвращать исключение, и не факт что таких случаев настолько много что для них нужен декоратор, но это хоть немного показывает о какой проблеме речь и с этого описания надо было начинать.
          +1
          айти-Маяковский всегда должен укладывается в 80 символов в строке… А то в печать не примут.
            0
            А у меня в PyCharm стандартные 120. По некоторым данным, это читабельнее. Так что, может, есть шанс попасть в печать?
              0

              Для 3-way merge понадобится уже 360, это ни на какой монитор не влезет. Правда, инструменты у всех разные, есть наверное такие, которые делят окно по горизонтали, но мне удобно пользоваться другими, и в них такие длинные строки очень неудобно мержить.

                0

                Если бы было так, тот PEP-8 бы объявили устаревшим и выпустили новый.


                Источник изложил свое субъективное мнение, а мое субъективное мнение в том что надо выгонять из команд людей не умеющих писать читаемый код в 80 символов. Больше 80 символов — это проявление неуважения к коллегам.


                И конечно же трехколоночный мердж с длиной в 120 эта адская боль. Кто мерджил, тот поймет.

                  0
                  Справедливости ради, в PEP-8 также прописано следующее:
                  Some teams strongly prefer a longer line length. For code maintained exclusively or primarily by a team that can reach agreement on this issue, it is okay to increase the line length limit up to 99 characters, provided that comments and docstrings are still wrapped at 72 characters.
                    –1
                    Умеющий писать код в 80 символов строки легко уместит и в 99.

                    Я помню случай, когда один программист попросил сделать в команде 100 длину строки, через неделю он уже попросил 120, а еще через неделю и этого стало мало. Он давал названия функций в 86 символов, а перевод строки вообще не считал нужным делать. При этом он считал себя чертовки хорошим программистом.

                    После двух десятков китайских предупреждений, его уволили, а строку вернули к 80 символам. Пока я переформатировал и переименовывал его функции, я нашёл с более десятка ошибок и некоторые критичные, которые были совершены при записи в одну строку. Он просто скрывал в длинных строках проблемы в алгоритмах, никто толком не мог разобрать его криво оформленный код и ему это сходило с рук.

                    Вообще при работе с профессиональными и сильными разработчиками никогда не встает вопрос о том какую длину строки выбрать в команде. Все легко умещают в 80 строк.
                      0
                      Вообще, как правило, любые правки в линтерах, общих конфигах и т.д. выносятся на обсуждение в команде. Если у всех с текущими настройками все ок (например, длина строки 80 символов всех устраивает), а одному почему-то не ок — значит надо разбираться в самом начале с этим специалистом, почему так. Ситуация, которую вы описали, может возникать в компаниях с высокой текучкой и в аутсорсе иногда и говорит она о том, что рабочий процесс не отлажен.
                        –1
                        В этом случае процесс был более-менее отлажен, этого человека просто терпели, так как понимали что если уволят, то его работа ляжет на плечи других, а нового программиста вводить в курс дела это месяца на 2-3 растянется.

                        В нормальной команде никто не будет обсуждать длину строки. Есть более важные темы для обсуждения, а 80 символов всех устраивает.
              +1
              Нужно только помнить что такой декоратор даст довольно серьезный overhead при исполнении. На примере класса Math() из текста.
              Сравнивая с реализацией try внутри метода
              class MathWithTry(object):
                  def divide(self, a, b):
                      try:
                          return a // b
                      except ZeroDivisionError:
                          return 'Делить на ноль нельзя, но можно умножить'


              %timeit m_with_try.divide(1, 0)
              406 ns ± 32.4 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
              
              %timeit m_with_decorator.divide(1, 0)
              3 µs ± 394 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

              Рост в 7 раз.
                0
                Есть такой момент. Я попробую поэкспериментировать, возможно, смогу оптимизировать. Благодарю за замечание.
                  0

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое