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

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

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

основная репафласк-гейт (код всех экспериментов из статьи находится здесь)

© Habrahabr.ru