0,0
рейтинг
14 марта 2012 в 14:47

Разработка → 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)
Кривушин Михаил @Deepwalker
карма
18,7
рейтинг 0,0
Программист
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (8)

  • 0
    Миш, я бы еще добавил чуть про ошибки валидации — это чуть ли не самая интересная тема после самой декларации структуры данных.
    • +1
      Вернусь с прогулки — допишу.
  • 0
    Какие преимущества перед FormEncode?
    • 0
      Они все таки о разном. Trafaret предлагает легкий путь разобрать некие данные прилетевшие извне, и частично декларативно перегнать их во внутреннюю форму.

      А FormEncode это хорошие валидаторы плюс формы. У нее круг задач другой, и шире, и там все сложнее. Поэтому в своем классе задач каждая из библиотек хороша.
      • 0
        Мне кажется, что примерно одинаково. API шире, да. Но никто не заставляет лезть глубоко вовнутрь.

        from formencode import Schema, validators as v
        from pprint import pprint
        import hashlib
        
        
        
        MD5Password = v.Wrapper(
            not_empty=True,
            to_python=lambda d: hashlib.md5(d).hexdigest()
        )
        
        RolesList = v.Wrapper(
            to_python=lambda d: [s.strip() for s in d.split(',')]
        )
        
        
        class RequiredString(v.UnicodeString):
            not_empty = True
        
        
        class MapFields(v.FormValidator):
            _fields_mapping = {
                'userNameFirst': 'name',
                'userNameSecond': 'second_name',
                'userPassword': 'password',
                'userEmail': 'email',
                'userTitle': 'title',
                'userRoles':'roles'
            }
        
            def _to_python(self, fields, state):
            	result = {}
            	for key, value in self._fields_mapping.iteritems():
                    result[value] = fields[key]
            	return result
        
        
        class Converter(Schema):
            # FormEncode's Declarative API attributes
            allow_extra_fields = True
            filter_extra_fields = True
        
            # Our own attributes and fields
            userNameFirst = RequiredString
            userNameSecond = RequiredString
            userPassword = MD5Password
            userEmail = v.Email
            userTitle = v.UnicodeString(if_missing='Bachelor')
            userRoles = RolesList
        
            chained_validators = [MapFields]
        
        
        
        # Test the module
        if __name__ == '__main__':
            sample_data = {
                'userNameFirst': 'Adam',
                'userNameSecond': 'Smith',
                'userPassword': 'supersecretpassword',
                'userEmail': 'adam@smith.math.edu',
                'userRoles': 'teacher, worker, admin',
            }
        
            desired_data = {
                'name': 'Adam',
                'second_name': 'Smith',
                'password': hashlib.md5('supersecretpassword').hexdigest(),
                'email': 'adam@smith.math.edu',
                'title': 'Bachelor',
                'roles': ['teacher', 'worker', 'admin'],
            }
            data = Converter.to_python(sample_data)
            assert data == desired_data
            pprint(data)
        
        


        Мне нравится, что в вашем примере библиотека избавляет вас от необходимости дублировать названия полей в нескольких местах. Также, нравится наличие fold/unfold утилит.
        Но мне кажется, что вы не совсем удачно выбрали Contract в качестве базовой библиотеки. Сейчас большая функциональность Trafaret, связанная с проверкой и конвертацией, дублирует то, что есть в схемах FormEncode. Однако я не могу использовать схемы FormEncode для тех утилит, которые есть в Trafaret помимо этого. В итоге — плохая интероперабельность, связанная с выбором Contract, которая взамен не предоставляет разработчику никаких плюсов.
        • 0
          Вы можете взять идею и применить ее к FormEncode, я думаю. Я бы посмотрел на результат.
  • 0
    Библиотечка очень понравилась. Предложу ещё добавить:
    import itertools
    import trafaret as t
    
    class Tuple(t.Trafaret):
    
        def __init__(self, *args):
            self._trafarets = map(self._trafaret, args)
            self._traf_cnt = len(self._trafarets)
    
        def _check(self, value):
            try:
                value = tuple(value)
            except TypeError:
                self._failure('value must be convertable to tuple')
    
            if len(value) != self._traf_cnt:
                self._failure('value must contain exact %s items' % self._traf_cnt)
    
            result = []
            errors = {}
            for idx, item, traf in itertools.izip(itertools.count(), value, self._trafarets):
                try:
                    result.append(traf.check(item))
                except t.DataError as err:
                    errors[idx] = err
    
            if errors:
                self._failure(errors)
            return tuple(result)
    
    
    if __name__ == '__main__':
        pair = t.Or(Tuple(int, str), Tuple(str, str)) >> (lambda x: '%s%s' % x)
        print pair.check((1, 'a'))
        print pair.check(('2', 'b'))
    
    
    • 0
      Черт, а почему я не видел этот комментарий раньше?
      Добавлю в мастер.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.