http://habrahabr.ru/post/190088/
История созданияЧасть 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
. Соответственно, весь запрос должен выглядеть так:
http://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-форумы, что угодно. Мне плевать на банальность или что-либо подобное. Важно чтобы на примере данного кода можно было показать как можно больше аспектов моего «питомца». Кроме всего прочего, это позволит проверить его в деле, и, возможно, найти пару багов или архитектурных недочётов.
Спасибо за внимание. Как всегда, рад любой критике и любым пожеланиям.
основная репафласк-гейт (код всех экспериментов из статьи находится здесь)