Как я упоролся по декораторам в питоне
 Публичный пост
7 июня 2021     582   

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

ДИСКЛЕЙМЕР
- я понимаю что далеко не всем это интересно, и у нас в клубе не так много чисто технических постов, но вроде как это не возбраняется
- я не хочу идти с этим на хабр (по крайней мере, пока): у меня нет опыта написания технических статей, и, откровенно говоря, хочется для начала попробовать в уютном клубе
- я прекрасно понимаю, что все что я сейчас буду описывать, либо уже придумано до меня, либо никому не нужно, но это не важно: я просто делаю удобный  мне инструмент, попутно осваивая технологии

Исходные данные

  • у меня есть несколько пет-проектов в виде ботов для телеги
  • все они написаны с использованием python + aiogram
  • я люблю упарываться по статистике (см. предыдущий пост) использования своих ботов

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

Что бы мне хотелось: один раз инициализировать библиотеку на старте приложения, и дальше кидать в нее ивенты.

Что у меня получилось? Да примерно то, что я и хотел:

  1. на старте приложения библиотека инициализируется (надо указать url для базы и название таблицы для хранения ивентов)
  2. любую функцию (и хендлер сообщений) я помечаю декоратором @track('/start command'). Можно также использовать отдельную функцию manual_track чтобы передать ивент напрямую
  3. благодяря asyncio.create_task() сохранение в базу будет происходить в фоне, и основной процесс не будет задерживаться для того чтобы сходить в базу
  4. благодаря тому, что aiogram позволяет достать из контекста потока практически все, ивент обогащается всякими полезными данными без необходимости пробрасывать их все как аргументы
  5. ???
  6. PROFIT

Все, первая версия библиотеки уже работает и все замечательно... почти

Идеальный API для библиотеки

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

То, что есть в первой версии, уже довольно универсально: этот декоратор можно вешать на любую функцию, в независимости от того, асинхронная она или нет. Даже под dependency injection aiogram'a она подстраивается.

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

Кратко про декораторы

Полагаю, что если вы дочитали досюда, вы и так знаете, что такое декораторы в питоне. Я только напомню, как выглядит декоратор, который может работать с параметрами и без:

...и его использование:

Проблема

заключается в том, что после того как в декораторе появились аргументы, уже не получится простыми методами однозначно понять, кто вызвал функцию — декоратор, или обычный вызов.
За пару вечеров попыток, я пришел к двум решениям:

Решение 1: красиво снаружи, мерзко внутри, медленно

С помощью inspect можно понять, как выглядит вызов функции, и тупо посмотреть, стоит ли там символ декоратора:


Это УСЛОВНО медленно:
На 10к тестовых запусках, декорированная функция отрабатывает в среднем за 0.000006с, а вызванная напрямую и проинспкетированная - за 0.002с. Разница в 300-400 раз.
С другой стороны — это не высоконагруженное приложение, а бот для телеги, и микро- и наносекунды здесь никто считать не будет. Зато какой красивый api получается!

Решение 2: некрасиво снаружи, красиво внутри, быстро

В принципе, это даже не очень спортивно:

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

Спасибо что дочитали!

10 комментариев 👇
Egor Suvorov, Программист/преподаватель C++ 7 июня в 21:17

Есть клёвая лекция про декораторы. Добавьте себе @functools.wraps в декоратор, чтобы всякие штуки для метапрограммирования с декорированной функцией продолжали работать.

А так мне кажется, что использовать одну и ту же штуку и для декораторов с логикой "оберни функцию в такую вот штуку" и для "запусти штуку прямо сейчас" может быть слишком магическим. Возможно, я бы скорее сделал отдельную функцию track и отдельный декоратор "запусти вот это в начале функции" и получил что-то вроде @at_start_async(track).

  Развернуть 1 комментарий

@yeputons, за лекцию огромное спасибо, обязательно посмотрю

wraps добавлю, да, просто хотел в примерах максимально упростить код

В целом я согласен про магические штуки. Но конкретно в данном случае мне от декоратора даже не нужно "оборачивание" - только привязать выполнение кода к событию "эту функцию вызвали"
Ну то есть конкретно здесь и сейчас мне кажется норм

  Развернуть 1 комментарий

@yeputons, я посмотрел лекцию, и это просто пушка, теперь посмотрю как минимум ещё несколько из этого курса

Огромное спасибо, лектор просто ТОП

  Развернуть 1 комментарий

Challenge accepted.

class track:
    __slots__ = ("message", "armed")

    def __new__(cls, arg):
        if callable(arg):
            return cls(None)(arg)
        return super().__new__(cls)

    def __init__(self, message):
        self.message = message
        self.armed = True

    def __call__(self, f):
        @functools.wraps(f)
        def wrapper(*args, **kwargs):
            print(self.message, args, kwargs)
            return f(*args, **kwargs)

        self.armed = False
        return wrapper

    def __del__(self):
        if self.armed:
            self.armed = False
            print(self.message)

Можно попытаться сделать что-то более красивое через weakref.finalize, но explicit is better than implicit, поэтому решение 2 или вариант с отдельными декоратором и функцией для сохранения события мне кажется более правильным.

  Развернуть 1 комментарий

@dizzy57, не хватило мозгов чтобы представить в голове как это будет работать, но вечером обязательно попробую, спасибо

По поводу explicit да, но это же норм практика - выводить сложную и непонятную штуку в библиотеку, чтобы у юзера все было просто и удобно, см. pydantic, например

  Развернуть 1 комментарий

@benyamin, одно дело выводить сложную и непонятную штуку в библиотеку, а другое дело — делать интерфейс библиотеки сложным и непонятным.

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

Вот, например, модуль snoop, который можно использовать в качестве декоратора:

import snoop

@snoop
def function(number):
    pass

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

Syntactic sugar causes cancer of the semicolons.

  Развернуть 1 комментарий

@dizzy57, да, справедливо, согласен

  Развернуть 1 комментарий

Вот вариант с weakref:

def track(arg):
    def decorator(f):
        if finalizer is not None:
            finalizer.detach()

        @functools.wraps(f)
        def wrapper(*args, **kwargs):
            callback()
            return f(*args, **kwargs)

        return wrapper

    if callable(arg):
        callback = functools.partial(print, None)
        finalizer = None
        return decorator(arg)
    else:
        callback = functools.partial(print, arg)
        finalizer = weakref.finalize(decorator, callback)
        return decorator
  Развернуть 1 комментарий
LexsZero, Embedded Software Engineer 7 июня в 21:18

Коммент не про имплементацию, а про изначальную задачу. Немного напомнило вот это: https://www.serverless.com/framework/docs/guides/sdk/python#tag_event - да, там не про декораторы, но возможно приглянется что-то из этих утилиток. Если понатыкать по коду достаточно этих трейсеров, то в дашборде потом вот такие картинки вылазят:

  Развернуть 1 комментарий

@LexsZero, Спасибо
Пока что я получил именно то, что хотел - а красивые дашборды мне metabase рисует по моим данным

  Развернуть 1 комментарий

😱 Комментарий удален его автором...

  Развернуть 1 комментарий

😎

Автор поста открыл его для большого интернета, но комментирование и движухи доступны только участникам Клуба

Что вообще здесь происходит?


Войти  или  Вступить в Клуб