Pull to refresh

Thunderargs: практика использования. Часть 2

Reading time 12 min
Views 3K
История создания
Часть 1

Добрый день. Вкратце напомню, что thunderargs — библиотека, которая даёт использовать аннотации для обработки входящих аргументов.

Кроме того, она даёт возможность достаточно просто накидать гейт, который будет эти самые аргументы для функции вытаскивать откуда-нибудь ещё. Например, из объекта request во фласке. И в итоге мы вместо

@app.route('/ugly_calc', dont_wrap=True)
def ugly_calc():
    x, y = int(request.args['x']), int(request.args['y'])
    op_key = request.args.get('op')
    if not op_key:
        op_key = '+'
    op = OPERATION.get(op_key)
    return str(op(x, y))

делаем
@app.route('/calc')
def calc(x:Arg(int), y:Arg(int), op:Arg(str, default='+')):
    return str(OPERATION[op](x, y))


Думаю, все хотя бы примерно поняли о чём будет речь в статье. Всё, что в ней описано — размышления о будущем проекта и примерная расстановка «майлстоунов» по нему. Ну и, разумеется, первые наброски всяких-разных фич.

В этой части


  • Рассмотрим структурные изменения в проекте и пару критических ошибок в изначальной структуре
  • Разберёмся как работают валидаторы и как можно кастомизировать выдаваемые ими ошибки
  • Создадим зачатки специализированных аргументов (IntArg, StrArg, ListArg и так далее)
  • Подготовим класс, который будет автоматически вытаскивать объект из базы по id, поступившему в запросе
  • Будем генерировать точки входа по классу модели
  • Реализуем листенеры и посмотрим как можно сделать валидатор для нескольких аргументов
  • Убедимся, что информацию о структуре аргументов можно смело переносить в БД, и ничего нам за это не будет
  • И, наконец, порассуждаем о мелких интересностях, так и не реализованных в рамках этих экспериментов



Структурные изменения, или почему меня надо бить ногами


Ну а теперь коротко о важных событиях в судьбе проекта. Во-первых, я наконец-то почитал как Армин Ронашер рекомендует делать модули к фласку, и привёл своего «пэта» к нужному виду. Для этого я целиком и полностью отделил основной функционал библиотеки (эта либа и репа остались под названием thunderargs) от функционала, который позволяет использовать её в качестве дополнения к Flask (теперь эту хрень можно поставить под именем flask-thunderargs, как несложно догадаться). Да, по сути это всего-навсего отделение интерфейса от ядра, которое жизнеспособно и без этого интерфейса. И так следовало поступить с самого начала. За свою непредусмотрительность я поплатился почти пятью часами, потраченными на реорганизацию.
В общем, кратко опишу что именно изменилось и что это значит:

Теперь у нас есть две либы — ядро и интерфейс к фласку

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

flask-thunderargs теперь является полноценным flask-модулем

Единственная беда — сам по себе интерфейс просто крошечный. По сути, весь он заключён в этом файле. Если кто решит написать свой собственный интерфейс к другой либе, можете смело ориентироваться на него.
А ещё изменился процесс инициализации endpoint'ов, разумеется. Теперь минималистичное приложение выглядит примерно так:
from flask import Flask
from flask.ext.thunderargs import ThunderargsProxy
from thunderargs import Arg

app = Flask(__name__)
ThunderargsProxy(app)

@app.route('/max')
def find_max(x: Arg(int, multiple=True)):
    return str(max(x))

if __name__ == '__main__':
    app.run()


Такие дела.

Делаем ошибки



В прошлой части мы уже разбирались как создавать свои валидаторы. И убедились, что это довольно просто. Напомню:

def less_than_21(x):
    return x < 21

@app.route('/step5_alt')
def step5_1(offset: Arg(int, default=0, validators=[lambda x: x >= 0 and
                                                            x < len(elements)]),
          limit: Arg(int, default=20, validators=[less_than_21])):
    return str(elements[offset:offset+limit])


Как мы видим, здесь есть два варианта их создания. Один — инлайновый, с помощью лямбд. Второй — полновесный. Сейчас я хочу показать почему полновесный вариант предпочтительней.

Человек, щупавший эксперименты прошлой части, мог заметить, что валидаторы, созданные фабрикой, кидают довольно красивые и понятные ошибки:
thunderargs.errors.ValidationError: Value of `limit` must be less than 21


Но наш пример выдаёт непонятные и ни о чём говорящие ошибки:
thunderargs.errors.ValidationError: Argument limit failed at validator #0.Given value: 23


Справиться с этим довольно просто. Более того, наша ошибка будет даже лучше оригинальной:
experiments.custom_error.LimitError: limit must be less than 21 and more than 0. Given: 23


Для такого результата нам нужен такой код:

class LimitError(ValidationError):
    pass


from thunderargs.errors import customize_error
from experiments.custom_error import LimitError

message = "{arg_name} must be less than 21 and more than 0. Given: {value}"
@customize_error(message=message, error_class=LimitError)
def limit_validator(x):
    return x < 21 and x>0

@app.route('/step5_alt2')
def step5_2(offset: Arg(int, default=0, validators=[lambda x: x >= 0 and
                                                            x < len(elements)]),
          limit: Arg(int, default=20, validators=[limit_validator])):
    return str(elements[offset:offset+limit])


В общем, для кастомизации ошибки нужно просто навесить декоратор customize_error на функцию-валидатор. В текст ошибки всегда передаются следующие переменные:
  • error_code — номер ошибки для отображения; внутрисистемная хрень для любителей систематизации;
  • arg_name — имя аргумента, которое соответствует присваиваемому в объявлении функции аргументу названию; В нашем случае это, например, limit;
  • value — значение, полученное валидатором; в случае с flask-thunderargs это чаще всего string, поскольку все, кроме reques.json и reques.files отдают именно его;
  • validator_no — порядковый номер валидатора; сильно сомневаюсь, что он пригодится в правильно составленных валидаторах;


Кроме того, можно передавать в customize_error любые именованные параметры, которые класс ошибки сожрёт под соответствующими именами. Это удобно, допустим, если нам нужно передать какие-то прописанные в конфиге данные в качестве уведомления для конечного пользователя. А ещё это применимо если вы пишете генератор ошибок. В качестве примера рассмотрим классическую фабрику декораторов из validfarm:
def val_in(x):
    @customize_error("Value of `{arg_name}` must be in {possible_values}", possible_values=x)
    def validator(value):
        return value in x
    return validator

possible_values в данном примере берётся из x, переменной, которая будет передана фабрике программистом, и будет получена ещё во время запуска приложения.
Предположительная версия: 0.4

Наследованные классы переменных


Очевидно, что уменьшение уровня абстракции полезно для конечного пользователя библиотеки. И первым шагом в этом направлении будут специализированные классы. Вот пример:
class IntArg(Arg):

    def __init__(self, max_val=None, min_val=None, **kwargs):
        kwargs['p_type'] = int
        if not 'validators' in kwargs or kwargs['validators'] is None:
            kwargs['validators'] = []

        if min_val is not None:
            if not isinstance(min_val, int):
                raise TypeError("Minimal value must be int")

            kwargs['validators'].append(val_gt(min_val-1))

        if max_val is not None:
            if not isinstance(max_val, int):
                raise TypeError("Maximal value must be int")

            kwargs['validators'].append(val_lt(max_val+1))

        if min_val is not None and max_val is not None:
            if max_val < min_val:
                raise ValueError("max_val is greater than min_val")

        super().__init__(**kwargs)


А вот применение данного класса:
from experiments.inherited_args import IntArg

@app.route('/step7')
def step7(x: IntArg(default=0, max_val=100, min_val=0)):
    return str(x)


Основная фишка таких классов в том, что отпадает необходимость вручную описывать некоторые параметры входящего аргумента. Кроме того, отпадает необходимость описывать некоторые валидаторы вручную. И появляется возможность конкретизировать их смысл в коде, что очень важно для читабельности.
Предположительная версия: 0.4

Наследованные классы для ORM


Допустим, что у нас есть класс документов, сделанный через mongoengine:
class Note(Document):
    title = StringField(max_length=40)
    text = StringField(min_length=3, required=True)
    created = DateTimeField(default=datetime.now)


У нас должен быть геттер, который должен вернуть конкретный документ. Давайте сделаем под эту задачу самостоятельный класс:
class ItemArg(Arg):
    def __init__(self, collection, **kwargs):
        kwargs['p_type'] = kwargs.get('p_type') or ObjectId
        kwargs['expander'] = lambda x: collection.objects.get(pk=x)
        super().__init__(**kwargs)


Всё, что он делает — меняет входные аргументы. Просто расширяет их до необходимого набора. И даже такой минималистичный вариант позволяет нам делать так:
@app.route('/step9/get')
def step9_2(note: ItemArg(Note)):
    return str(note.text)


Довольно няшно, правда?

Предположительная версия: есть смысл вынести в самостоятельную библиотеку

Генерируем фласковые геттеры


Представим себе, что у нас есть какой-то класс в модели, геттеры которого не совершают никаких особых действий. Нужно написать геттер, который будет выдвать пользователю информацию в таком же виде, в каком она хранится в БД. В этом случае нам не помешает генератор геттеров. Давайте сделаем его:
def make_default_serializable_getlist(cls, name="default_getter_name"):
    @Endpoint
    def get(offset: IntArg(min_val=0, default=0),
            limit: IntArg(min_val=1, max_val=50, default=20)):
        return list(map(lambda x: x.get_serializable_dict(), cls.objects.skip(offset).limit(limit)))
    get.__name__ = name
    return get

Эта функция должна создать геттер для коллекции MongoEngine. Единственное дополнительное условие — у класса коллекции должен быть определён метод get_serializable_dict. Но, думаю, с этим ни у кого особых проблем не возникнет. А вот один из вариантов применения этой штуки:

getter = make_default_serializable_getlist(Note, name='step11_getter')
app.route('/step11_alt3')(json_resp(getter))


Здесь используется вспомогательная функция json_resp, но на самом деле она не делает ничего интересного, просто оборачивает ответ контроллера в flask.jsonify (если может). Кроме того, в этом примере я использовал декоратор без применения классического синтаксиса. На мой взгляд, это оправдано, иначе пришлось бы делать не совершающую никакой полезной деятельности обёртку-транспорт.

Предположительная версия: аналогично предыдущему

Логгирование вызовов и кое-что ещё


Давайте логгировать каждое телодвижение пользователя, вписывающееся в описанные нами правила. Для этого накидаем простецкий декоратор, который будет принимать в себя функцию-коллбэк:
def listen_with(listener):
    def decorator(victim):
        @wraps(victim)
        def wrapper(**kwargs):
            listener(func=victim, **kwargs)
            return victim(**kwargs)
        return wrapper
    return decorator

и сам коллбэк:
def logger(func, **kwargs):
    print(func.__name__)
    print(kwargs)


Этот коллбэк просто выводит все полученные аргументы на экран. А теперь рассмотрим более полезный пример:
def denied_for_john_doe(func, firstname, lastname):
    if firstname == 'John' and lastname == 'Doe':
        raise ValueError("Sorry, John, but you are banned")


@app.route('/step13')
@listen_with(denied_for_john_doe)
def step13(firstname: Arg(str, required=True),
           lastname: Arg(str, required=True)):
    return "greeting you, {} {}".format(firstname, lastname)


Здесь, как мы видим, идёт проверка возможности использования комбинации значений. Вообще, чисто формально, такая конструкция не является лисетенером, и должна быть от них, листенеров, отделена. Но пока, в рамках эксперимента, оставим это так. Вот более корректный с архитектурной точки зрения пример:
def mail_sender(func, email):
    if func.__name__ == 'step14':
        # Здесь был код, отправлявший приветственное письмо
        # зарегистрированному пользователю, но его облил супом дедушка :(
        pass


@app.route('/step14')
@listen_with(mail_sender)
def step14(email: Arg(str, required=True)):
    """
    Здесь был код, регистрирующий юзера в базе, но его съела собака :(
    """
    return "ok"


Ладно, не пример, а его заготовка.

Предположительная версия: 0.5

Структура аргументов в БД


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

Для начала нам нужно составить таблицу соответствий объектов исполняемой в текущий момент программы с данными, импортируемыми из БД. В примере мы будем использовать только один тип, уже описанный нами выше. Поэтому пока что здесь будет только он:
TYPES = {'IntArg': IntArg}


Теперь нам нужно описать модель, которая, собственно, и будет хранить и выдавать информацию о входящих аргументах точек входа.
class DBArg(Document):

    name = StringField(max_length=30, min_length=1, required=True)
    arg_type = StringField(default="IntArg")
    params = DictField()

    def get_arg(self):

        arg = TYPES[self.arg_type](**self.params)
        arg.db_entity = self

        return arg


Здесь, как мы видим, указано имя аргумента, её тип и дополнительные параметры, которые будут передаваться в конструктор данного типа. В нашем случае это IntArg, а параметрами у нас могут быть max_val, min_val, required, default и все прочие, которые правильно обрабатываются ОРМ-кой.
Функция get_arg предназначена для получения инстанса Arg с хранящейся в БД конфигурацией. Теперь нам нужна такая же балалайка для структур, которые мы обычно присобачиваем к функциям, описывая отдельные аргументы посредством аннотаций. Да-да, всё это сливается в специфичную конструкцию, которая потом и скармливается парсеру аргументов.
class DBStruct(Document):

    args = ListField(ReferenceField(DBArg))

    def get_structure(self):
        return {x.name: x.get_arg() for x in self.args}

Она намного проще, и вряд ли её стоит описывать отдельно. Пожалуй, стоит уточнить для людей, не «общавшихся» с mongoengine, что конструкция ListField(ReferenceField(DBArg)) значит всего лишь что в БД в этом поле у нас будет храниться список из элементов класса DBArg.

А ещё нам нужна штука, которая будет компоновать приведённое выше во что-то цельное и конкретное. Скажем так, применять это всё к живым задачам. И такая задача есть. Давайте предположим, что у нас с вами есть магазин или аукцион. Иногда бывает так, что по тех. заданию в админке, кроме всего прочего, должна быть возможность создавать категории товаров, в каждой из которых будут свои параметры, присущие только ей. Вот к этой задаче и приложимся.
class Category(Document):

    name = StringField(primary_key=True)
    label = StringField()
    parent = ReferenceField('self')

    arg_structure = ReferenceField(DBStruct)

    def get_creator(self):

        @Endpoint
        @annotate(**self.arg_structure.get_structure())
        def creator(**kwargs):
            return Item(data=kwargs).save()

        creator.__name__ = "create_" + self.name

        return creator

    def get_getter(self):
        pass

Здесь у нас описана модель категории. У неё будет системное имя, необходимое для именования функций и эндпоинтов, отображаемое имя, которое для нас пока вообще ничего не значит, и родитель (ага, сделаем заранее заготовку для inheritance). Кроме того, указана используемая для данной категории структура данных. И, наконец, описана функция, которая автоматически создаст функцию-создатель для данной категории. Сюда бы неплохо прикрутить кэш и прочие вкусности, но пока что, в рамках эксперимента, проигнорируем это.

И, наконец, нам нужна модель для хранения пользовательских данных, через которую конечные пользователи и будут заливать инфу о товарах. У нас, как и во всех предыдущих примерах, это будет представлено в упрощённом виде:
class Item(Document):

    data = DictField()
    category = ReferenceField(Category)


Думаю, тут особых разъяснений не требуется вовсе.

Ну а теперь давайте создадим первую категорию товаров:
>>> weight = DBArg(name="weight", params={'max_val': 500, 'min_val':0, 'required': True}).save()
>>> height = DBArg(name="height", params={'max_val': 290}).save()
>>> human_argstructure = DBStruct(args=[weight, height]).save()
>>> human = Category(name="human", arg_structure=human_argstructure).save()


Да, я в курсе что продавать людей не очень этично, но так уж вышло :)

Теперь нам нужна обёртка, при помощи которой мы и будем создавать наименования товаров:
@app.route('/step15_abstract')
def abstract_add_item(category: ItemArg(Category, required=True, p_type=str)):
    creator = category.get_creator()
    wrapped_creator = app._arg_taker(creator)
    return str(wrapped_creator().id)


Сейчас это выглядит очень уродливо. Связано это с ещё одной ошибкой в архитектуре. Впрочем, куда менее значительной, чем предыдущая. Ну да ладно. Сейчас объясню что тут происходит.

Сначала мы получаем инстанс категории способом, который уже был описан выше (см. пример с моделью Note). Соответственно, если пользователь попробует добавить товар в несуществующую категорию, он получит DoesNotExist. primary key в этой категории — её системное наименование, и именно его пользователь должен передвать в качестве идентификатора. В нашем случае это human. Соответственно, весь запрос должен выглядеть так:
localhost:5000/step15_abstract?category=human&weight=100&height=200
Остальная часть предназначена для того, чтобы вызываемый конструктор получил другие параметры. app._arg_taker — декоратор, который позволяет эндпоинту «добрать» недостающие аргументы из source. В нашем случае это request.args, но, в принципе, источник может быть любым. Собственно, в этом фрагменте моя архитектурная ошибка и заключается. По-хорошему, нужды оборачивать вложенные эндпоинты в такой декоратор возникать не должно.

Предположительная версия: никогда, это просто опыт

Заключение и будущее


Ну, пожалуй, на этом сегодня и закончим. Теперь можно порассуждать на пространные темы. В первую очередь я бы хотел выразить благодарность всем откликнувшимся на первые посты. Даже несмотря на то, что никто так и не сделал ни одного конструктивного предложения, вы мне очень помогли в моральном плане :)

А теперь коротко о намерениях и желаниях.
Главным направлением ближайших месяцев будет комментирование кода, рефакторинг и покрытие тестами. Да, я и сам знаю что в этой области мой код просто отвратителен, глупо было бы это отрицать.
Кроме того, хотелось бы написать ещё парочку гейтов, вроде фласкового, к другим фреймворкам. В общем, я бы хотел найти такие места, где моя библиотека была бы полезна. Пока на примете только tornado и argparse.

Что же касается самой библиотеки, здесь я считаю важным сосредоточиться на обратном информировании. Допустим, мы используем thunderargs для написания restful-интерфейса. Было бы круто, если б он мог дать информацию конечной библиотеке, которая бы позволила сформировать какое-то подобие json-rpc, чтобы клиент по запросу OPTIONS мог узнать какие параметры какой из методов принимает и какие в их эндпоинтах могут произойти ошибки.

Позже я напишу ещё одну, заключительную статью. Она будет уже жёстко привязана к «реальной жизни». Полагаю, что там будет описание процесса кодинга какого-нибудь сервиса. Сейчас у меня только одна идея, и она связана с системой тегов на одном интересном сайте (с грустной пандой). Но я буду рад послушать и другие предложения. Микроблоги, Q&A-форумы, что угодно. Мне плевать на банальность или что-либо подобное. Важно чтобы на примере данного кода можно было показать как можно больше аспектов моего «питомца». Кроме всего прочего, это позволит проверить его в деле, и, возможно, найти пару багов или архитектурных недочётов.

Спасибо за внимание. Как всегда, рад любой критике и любым пожеланиям.

основная репа
фласк-гейт (код всех экспериментов из статьи находится здесь)
Tags:
Hubs:
+10
Comments 4
Comments Comments 4

Articles