- PVSM.RU - https://www.pvsm.ru -

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

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

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

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

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

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

Проблема

Когда ты хочешь получить аргументы из запроса, ты вынужден брать их сам. Каждый раз. Каждый аргумент. Более того, ты вынужден обрабатывать эти аргументы прямо в теле своего 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))

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

Ну погнали

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

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

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, который я хотел прикрутить, но, думаю, сейчас можно обойтись и без него.

Репа [4]

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

Автор: uthunderbird

Источник [5]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/python/59970

Ссылки в тексте:

[1] аннотации к функциям: http://legacy.python.org/dev/peps/pep-3107/

[2] получение данных из форм во Flask: http://flask.pocoo.org/docs/quickstart/#the-request-object

[3] вот отсюда: https://github.com/sloria/webargs/blob/dev/webargs/core.py

[4] Репа: https://bitbucket.org/dsupiev/thunderargs

[5] Источник: http://habrahabr.ru/post/223041/