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

    Исходные данные — вы строите некий сервис, и узнаете, что будете получать данные из вне в определенном формате.
    Предположим что это будет 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)
    
    • +19
    • 2,4k
    • 8
    Поделиться публикацией
    Похожие публикации
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 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
            Черт, а почему я не видел этот комментарий раньше?
            Добавлю в мастер.

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