Пользователь
0,0
рейтинг
16 мая 2014 в 12:50

Разработка → Один вариант использования аннотаций

Сразу хочу объявить, что здесь под аннотациями подразумеваются НЕ декораторы. И я не знаю по какой причине декораторы иногда именуют аннотациями.

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

Вот каноничный пример из PEP:

def compile(source: "something compilable",
            filename: "where the compilable thing comes from",
            mode: "is this a single statement or a suite?"):
    ...


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



Проблема



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

Вот пример:

@app.route('/ugly_calc')
def ugly_calc():

    x, y = int(request.args['x']), int(request.args['y'])

    op = OPERATION[request.args['op']]

    # Только тут начинается реальное тело функции. Всё, что выше — получение аргументов и их обработка (кстати, очень хреновая обработка)
    return str(op(x, y))


Было бы намного логичней получать в контроллер уже очищенные, валидированные и проверенные аргументы:

@app.route('/calc')
def calc(x:Arg(int), y:Arg(int), op:Arg(_from=OPERATION)):
    return str(op(x, y))


Код приобрёл в читаемости и логичности, и размер контроллера уменьшился до фактического количества операций.

Ну погнали



Первым делом, нам нужно накидать класс аргумента.

Основу для него мы возьмём вот отсюда. Выкинем то, что нам сейчас не нужно, и вуаля!

class Arg(object):

    """
    A request argument.
    """

    def __init__(self, p_type=str, default=None):

        self.type = p_type
        self.default = default


    def _validate(self, value):

        """Perform conversion and validation on ``value``."""

        return self.type(value)


    def validated(self, value):
        """
        Convert and validate the given value according to the ``p_type``
        Sets default if value is None
        """

        if value is None:
            return self.default or self.type()

        return self._validate(value)


Да, класс аргумента у нас пока что будет очень минималистичным. В конце-концов, расширить его всякими required и передаваемыми валидаторами мы сможем его в любой момент.

Теперь нам нужно сделать штуку, которая будет получать словарь из «грязных» аргументов, и возвращать «чистые».

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

>>> def lol(yep, foo: "woof", bar: 32*2):
	pass

>>> lol.__annotations__
{'foo': 'woof', 'bar': 64}


Итак, как мы видим, у нас есть словарь со всеми элементами, которые нужно обрабатывать. Но про существование других аргументов тоже забывать не следует. Будет не очень хорошо, если функция lol не получит свой yep.

Что-то я отступил от повествования. Продолжаем:

class Parser(object):

    def __call__(self, dct):

        """
        Just for simplify
        """

        return self.validated(dct)


    def __init__(self, structure):
        self.structure = structure


    def validated(self, dct):

        for key, arg_instatce in self.structure.items():
            dct[key] = arg_instatce(dct.get(key, None))

        return dct


Этот класс простой как три рубля. Его инстансы валидируют каждую полученный параметр, название которого есть и в полученном словаре, и в структуре параметров, а потом возвращают изменённый словарь. В общем-то, особого смысла его возвращать и нет, это просто привычка :)

Мы достаточно активно используем дополнительный параметр __annotations__ и декораторы. Поэтому будет лучше дополнить стандартный wraps дабы избежать проблем.

from functools import wraps as orig_wraps, WRAPPER_ASSIGNMENTS


WRAPPER_ASSIGNMENTS += ('__annotations__',)

wraps = lambda x: orig_wraps(x, WRAPPER_ASSIGNMENTS)


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

class Endpoint(object):

    """
    Класс для оборачивания целевых функций и передачи им
    уже обработанных аргументов вместо сырых

    >>> plus = Endpoint(plus)
    >>> plus(5.0, "4")
    9
    """

    def __call__(self, *args, **kwargs):
        return self.callable(*args, **kwargs)


    def __init__(self, func):

        self.__annotations__ = func.__annotations__
        self.__name__ = func.__name__

        self.set_func(func)


    def set_func(self, func):

        if func.__annotations__:

            # Создаём парсер для данной структуры данных
            self.parser = Parser(func.__annotations__)

            # Делаем инстансы данного класса вызываемыми.
            # Целевая функция оборачивается в декоратор, который
            # описн ниже
            self.callable = self._wrap_callable(func)

        else:
            self.callable = func


    def _wrap_callable(self, func):

        @wraps(func)
        def wrapper(*args, **kwargs):
            # Обертка принимает все аргументы, предназначенные
            # для целефой функции, и передаёт в парсер именованные.
            # Только именованные - потому что сюда все данные из
            # форм будут приходить именованными
            return func(*args, **self.parser(kwargs))

        return wrapper


Ну что же, всё готово. Пришло время прикручивать эту штуку к Flask.
Кстати, всё, что мы пилили до этого момента, написано достаточно абстрактно для того, чтобы использовать эти же фрагменты кода и на других фреймворках. И даже без фреймворков :)

Начнём:
class Flask(OrigFlask):

    # Для чистого роутинга. Вдруг кому пригодится
    froute = OrigFlask.route


    def route(self, rule, **options):

        """
        Роутим прям как во фласке.
        """

        def registrator(func):

            # У нас будет правило: 1 метод - 1 эндпоинт.
            if 'methods' in options:
                method = options['methods'][0]

            else:
                method = 'GET'

            wrapped = self.register_endpoint(rule, func, options.get('name'), method)

            return wrapped

        return registrator


    def register_endpoint(self, rule, func, endpoint_name=None, method='GET'):

        endpoint_name = endpoint_name or func.__name__

        endpoint = Endpoint(func)

        wrapped = self._arg_taker(endpoint)

        self.add_url_rule(rule,
                          "%s.%s" % (endpoint_name, method),
                          wrapped, methods=[method])

        return wrapped


    def _arg_taker(self, func):

        """
        Эта функция будет забирать аргументы из формы. Такие дела.
        """

        @wraps(func)
        def wrapper(*args, **kwargs):

            for key_name in func.__annotations__.keys():
                    kwargs[key_name] = request.args.get(key_name)

            return func(*args, **kwargs)

        return wrapper


Отлично, базовый функционал работает. Пока что без _from, но, думаю, сейчас можно обойтись и без него.

Репа

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

UPD


Написал краткий мануал по использованию этой штуковины.
Данияр Супиев @uthunderbird
карма
9,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

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

  • 0
    Оригинально. Вообще, аннотациям я пока не нашел особенно применения (аннотирую типы аргументов и функций), так что интересно видеть такие юзкейсы.

    Во Фласке, наверно, пригодится.

    В других фреймворках такая проблема необязательно есть
    В ЧерриПае аргументы парсятся автоматически (вроде, даже типы приводятся). Т.е., там ваш ugly_calc будет совсем не такой ugly :-)
    • 0
      В ЧерриПае аргументы парсятся автоматически (вроде, даже типы приводятся). Т.е., там ваш ugly_calc будет совсем не такой ugly :-)

      Во фласке тоже с использованием конверторов.
      • 0
        Path variables, ты имеешь в виду? Да, но их использование не всегда возможно. Особенно если речь идёт о POST-запросах, которые по конвенции HTTP не должны передавать данные в URL.

        Кстати, эта штука совместима с path variables. Проверил так:
        @app.route('/calc/<int:x>/')
        def pathed_calc(x, y:Arg(int), op:Arg(str, default='+')):
            return str(OPERATION[op](x, y))
        
        • 0
          Прошу прощения, не сразу понял, что насчет более консерватиного WTForms?
          • 0
            WTForms вынуждает тебя описывать форму отдельной сущностью (отдельным классом), что не очень-то способствует читабельности и компактности кода. Кроме того, если речь идёт о REST-интерфейсе (для которого я это изначально и пилил), то WTForms тебе не помощник.
            • 0
              Когда-то юзал wtforms для rest, всего-то написать обёртку для дампа ошибок в json, а валидаторы прекрасно себе работают. Потом появился schematics: github.com/j2labs/schematics
  • 0
    Питоновские декораторы обзывают аннотациями, потому что @-синтаксис, например, в Java и PHP используется для механизма «аннотаций» (которые не имеют ничего общего ни с декораторами питона, ни его же аннотациями, хотя выглядит очень похоже). Как раз «at» (значок и идентификатор перед объявлением функции) и путает людей.

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