В свободное время я работаю над своим небольшим проектом. Написан на 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) дальше.

Жду ваших комментариев. Спасибо за то, что уделили внимание моей статье.

Комментарии (23)


  1. worldmind
    29.10.2019 18:05

    ээ, я ожидал презентацию декоратора, кишки это уже дело второе


    1. ivanuzzo Автор
      29.10.2019 18:32

      как говорил один в меру известный мастер презентации декораторов, «если то, что выше — не презентация декоратора, то я не знаю, что такое презентация декоратора». Скажите хотя бы, чего вы ожидали, может я смогу извлечь какие-нибудь выводы на будущее относительно оформления?


      1. worldmind
        29.10.2019 18:56

        А какую проблему он решает?


        1. ivanuzzo Автор
          29.10.2019 19:05

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


          1. worldmind
            30.10.2019 10:25

            Так нигде и не надо писать try..except — подавляющее большинство исключений должно обрабатываться на самом верхнем уровне, на то они и исключения, и декоратор там не поможет ничем.
            А для типовых задач вроде повторения запроса к внешней системе есть типовые модули.


            1. ivanuzzo Автор
              30.10.2019 13:38

              А как такой подход применить, если есть, например, сервер, который все запросы отдает в хэндлеры, а хэндлер может вернуть разный результат? Например, мы отправляем запрос на создание персонажа, а введенное имя уже занято. Получается, мне нужно добавить доп. запрос в бд на проверку имени, чтоб не использовать локально try except? Имхо, немного накладно. А с декоратором и локальным try...except мы в случае IntegrityError ('name' is duplicated) не делаем экстра запросов. Запрос всегда один.


              1. mayorovp
                30.10.2019 13:44

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


                1. ivanuzzo Автор
                  30.10.2019 13:58

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


                  1. mayorovp
                    30.10.2019 14:02

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


                    1. ivanuzzo Автор
                      30.10.2019 14:04

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


                      1. mayorovp
                        30.10.2019 15:12

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


                        1. ivanuzzo Автор
                          30.10.2019 15:25

                          Да, и для этого в декоратор можно будет передавать опциональное свойство custom_handlers. Для каждого отдельного хэндлера, где будет такая ошибка.


              1. worldmind
                30.10.2019 14:25

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


  1. resetme
    30.10.2019 06:26

    айти-Маяковский всегда должен укладывается в 80 символов в строке… А то в печать не примут.


    1. ivanuzzo Автор
      30.10.2019 14:02

      А у меня в PyCharm стандартные 120. По некоторым данным, это читабельнее. Так что, может, есть шанс попасть в печать?


      1. eumorozov
        30.10.2019 17:10

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


      1. resetme
        30.10.2019 19:31

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


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


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


        1. ivanuzzo Автор
          01.11.2019 16:43

          Справедливости ради, в 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. resetme
            01.11.2019 17:39
            -1

            Умеющий писать код в 80 символов строки легко уместит и в 99.

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

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

            Вообще при работе с профессиональными и сильными разработчиками никогда не встает вопрос о том какую длину строки выбрать в команде. Все легко умещают в 80 строк.


            1. ivanuzzo Автор
              01.11.2019 18:18

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


              1. resetme
                01.11.2019 18:32
                -1

                В этом случае процесс был более-менее отлажен, этого человека просто терпели, так как понимали что если уволят, то его работа ляжет на плечи других, а нового программиста вводить в курс дела это месяца на 2-3 растянется.

                В нормальной команде никто не будет обсуждать длину строки. Есть более важные темы для обсуждения, а 80 символов всех устраивает.


  1. Mogost
    30.10.2019 13:23
    +1

    Нужно только помнить что такой декоратор даст довольно серьезный overhead при исполнении. На примере класса Math() из текста.

    Сравнивая с реализацией try внутри метода
    class MathWithTry(object):
        def divide(self, a, b):
            try:
                return a // b
            except ZeroDivisionError:
                return 'Делить на ноль нельзя, но можно умножить'


    1. ivanuzzo Автор
      30.10.2019 13:51

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