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

в 8:50, , рубрики: flask, python, python3, метки: ,

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

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

Вот каноничный пример из 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 ugly_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

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

Репа

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

Автор: uthunderbird

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js