Pull to refresh

Trafaret — библиотека для проверки и преобразования данных

Python *API *
Исходные данные — вы строите некий сервис, и узнаете, что будете получать данные из вне в определенном формате.
Предположим что это будет JSON, структуру его определяете не вы, и вообще вот она:

sample_data = {
        'userNameFirst': 'Adam',
        'userNameSecond': 'Smith',
        'userPassword': 'supersecretpassword',
        'userEmail': 'adam@smith.math.edu',
        'userRoles': 'teacher, worker, admin',
    }


Внутри проекта конечно хотелось бы чтобы структура была другой:

import hashlib

desired_data = {
        'name': 'Adam',
        'second_name': 'Smith',
        'password': hashlib.md5('supersecretpassword').hexdigest(),
        'email': 'adam@smith.math.edu',
        'roles': ['teacher', 'worker', 'admin'],
    }


Видимо придется преобразовывать к внутреннему формату, попробуем так:

new_data = {
        'name': sample_data['userNameFirst'],
        'second_name': sample_data['userNameSecond'],
        'password': hashlib.md5(sample_data['userPassword']).hexdigest(),
        'email': sample_data['userEmail'],
        'roles': [s.strip() for s in sample_data['userRoles'].split(',')]
    }
assert new_data == desired_data, 'Uh oh'


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

FIELDS = {
        'userNameFirst': 'name',
        'userNameSecond': 'second_name',
        'userEmail': 'email',
    }
new_data = dict((n2, sample_data[n1]) for n1, n2 in FIELDS.items())
new_data['roles'] = [s.strip() for s in sample_data['userRoles'].split(',')]
new_data['password'] = hashlib.md5(sample_data['userPassword']).hexdigest()

assert new_data == desired_data, 'Uh oh'


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

И тут вдруг приходит небольшое обновление — 'userEmail' оказывается необязательное поле. Плюс добавляется поле userTitle, которое по умолчанию, если не передается, должно быть 'Bachelor'.
Наши руки не для скуки, в ожидании когда же наконец придет полная информация учтем возможность опциональных полей и значений по умолчанию.

desired_data['title'] = 'Bachelor' # Добавим поле в проверочные данные

FIELDS = {
        'userNameFirst': 'name',
        'userNameSecond': 'second_name',
        'userEmail': ('email', '__optional'),
        'userTitle': ('title', 'Bachelor'),
    }

new_data = {}
for old, new in FIELDS.items():
    if isinstance(new, tuple):
        new, default = new
    if old not in sample_data:
        if default == '__optional':
            continue
        new_data[new] = default
    else:
        new_data[new] = sample_data[old]

new_data['roles'] = [s.strip() for s in sample_data['userRoles'].split(',')]
new_data['password'] = hashlib.md5(sample_data['userPassword']).hexdigest()

assert new_data == desired_data, 'Uh oh'



Черт, так мало полей и так много кода. Было проще, лучше решим пока в лоб.

new_data = {
        'name': sample_data['userNameFirst'],
        'second_name': sample_data['userNameSecond'],
        'password': hashlib.md5(sample_data['userPassword']).hexdigest(),
        'roles': [s.strip() for s in sample_data['userRoles'].split(',')]
    }
if 'userEmail' in sample_data:
    new_data['email'] = sample_data['userEmail']
new_data['title'] = sample_data.get('userTitle', 'Bachelor')

assert new_data == desired_data, 'Uh oh'


Ах, хороший знакомый код без излишней сложности, прекрасно. Но что будет когда придет полная спецификация? Видимо вернемся ко второму варианту, добавим к нему проверку данных, хорошие сообщения об ошибках, упакуем его в библиотеку и будем использовать.
Хм, но уже есть такая библиотека, смотрите:

import trafaret as t

hash_md5 = lambda d: hashlib.md5(d).hexdigest()
comma_to_list = lambda d: [s.strip() for s in d.split(',')]

converter = t.Dict({
    t.Key('userNameFirst') >> 'name': t.String,
    t.Key('userNameSecond') >> 'second_name': t.String,
    t.Key('userPassword') >> 'password': hash_md5,
    t.Key('userEmail', optional=True) >> 'email': t.Email,
    t.Key('userTitle', default='Bachelor') >> 'title': t.String,
    t.Key('userRoles') >> 'roles': comma_to_list,
})

assert converter.check(sample_data) == desired_data


Брать здесь github.com/Deepwalker/trafaret

Полный код этого топика в виде скрипта здесь gist.github.com/2023370

Небольшое дополнение по просьбе от nimnull об ошибках.

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

В приведенных выше примерах ошибки нормально будут сгенерированны только в последнем, потому что в остальных я не усложнял код. Я решил что ошибки это все таки про валидацию, а в trafaret это только база, trafaret о преобразовании данных.

Точнее github.com/barbuza/contract, из которого trafaret был сделан, и был именно о валидации и ничего больше. Хорошая вещь, четко выполняет поставленную задачу. Но у меня была немного другая задача, и впечатление от '>>' из funcparserlib. Собственно в funcparserlib '>>' делает ровным счетом тоже, что и в трафарете — передает собранные данные в пользовательскую функцию на обработку.

Вернемся к ошибкам. Ошибки в трафарете это экземпляры trafaret.DataError. У каждого DataError есть аттрибут error. Для простых типов, как то Float, Int, String и тп, это строка с описанием ошибки на английском. Для Dict, Mapping и List это словарь. Для Dict и Mapping очевидно — элементы словаря это ошибки собранные из проверки полей. В случае List ключами будут числа — номера позиций элементов. Остальные варианты организации смотрятся неподходящими.

То есть пример:

>>> import trafaret as t
>>> c = t.Dict({'a': t.List(t.Int)})
>>> c.check({'a': [4, 5]})
{'a': [4, 5]}
>>> c.check({'a': [4, 'a', 6]})
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "trafaret/__init__.py", line 110, in check
    return self._convert(self._check_val(value))
  File "trafaret/__init__.py", line 804, in _check_val
    raise DataError(error=errors)
trafaret.DataError: {'a': DataError({1: DataError(value cant be converted to int)})}


Да, как видно из примера ошибку надо ловить, но сейчас не об этом — мы получили вложенные ошибки, и благодаря этому можем точно определить где и какие ошибки имеем.

Есть небольшой хелпер чтобы ошибку перевести в более удобный вид:

>>> t.extract_error(c, {'a': [4, 'a', 6]})
{'a': {1: 'value cant be converted to int'}}


И в еще более удобный, для некоторых целей:

>>> from trafaret.utils import unfold
>>> unfold(t.extract_error(c, {'a': [4, 'a', 6]}), prefix='form')
{'form__a__1': 'value cant be converted to int'}


Про последний пример — нет, трафарет с формами не работает. То есть он не содержит в себе ни одного виджета, и не строит формы по мапперам/таблицам алхимии или одно чудного орма. Но проверять данные пришедшие с HTML формы вполне может.

На этом наверное все, задавайте вопросы, могу дополнить.

В заключение подкину хардкорный пример использования и гибкости:

>>> todt = lambda  m: datetime(*[int(i) for i in m.groups()])
>>> (t.String(regex='^year=(\d+),month=(\d+),day=(\d+)$') >> todt).check('year=2011,month=07,day=23')
datetime.datetime(2011, 7, 23, 0, 0)
Tags:
Hubs:
Total votes 25: ↑22 and ↓3 +19
Views 4.9K
Comments Comments 8