O que é schematics?

Schematics é uma lib que auxilia no design, conversão e validação de estruturas de dados em python. Essa biblioteca elimina a necessidade de criar e manipular diretamente dados de payload para o contexto da aplicação.

Para começar, usamos um pip install assim:

pip install schematics

Neste exemplo, vamos ingerir um esquema de dados complexo usando Schematics. Os dados chegam em um formato json, conforme exemplificado abaixo:

{
    "_id": "641505586f0763093fe5de82",
    "index": 0,
    "guid": "3e591c0d-5812-4c1e-9062-91d67db36326",
    "isActive": True,
    "balance": "$3,657.06",
    "picture": "https://picsum.photos/200",
    "age": 22,
    "eyeColor": "brown",
    "name": "Whitehead Navarro",
    "gender": "male",
    "company": "ROUGHIES",
    "email": "whiteheadnavarro@roughies.com",
    "phone": "(843) 429-2875",
    "address": "934 River Street, Sparkill, New Mexico, 4866",
    "about": "Mollit enim aute sint enim ut eiusmod dolore dolore veniam. Esse ad consequat pariatur "
                "cupidatat qui deserunt proident minim irure. Proident labore minim ex voluptate ea ut excepteur "
                "duis ad minim quis incididunt labore. Laborum sit aliqua aliqua et ad qui qui quis ullamco. "
                "Voluptate voluptate consectetur nostrud amet enim. In Lorem voluptate fugiat duis. Ut proident "
                "ipsum minim do fugiat sunt laboris voluptate tempor aliqua aliquip deserunt sit.\r\n",
    "registered": "2018-08-26T04:14:49",
    "coordinates": [
        -0.642558,
        -154.849655
    ],
    "tags": [
        "pariatur",
        "consequat",
        "et",
        "amet",
        "fugiat",
        "non",
        "deserunt"
    ],
    "friends": [
        {
            "id": 0,
            "name": "Alejandra Kinney"
        },
        {
            "id": 1,
            "name": "Holmes Graves"
        },
        {
            "id": 2,
            "name": "Barbra Dominguez"
        }
    ],
    "greeting": "Hello, Whitehead Navarro! You have 7 unread messages.",
    "favoriteFruit": "strawberry",
    "favoriteMedia": {
        "name": "Better Call Saul",
        "year": "2022",
        "network": "AMC"
    }
}

1. Recebendo e Convertendo Dados

Para converter os dados usando schematics, a primeira coisa a fazer é criar um modelo que represente uma pessoa:

import datetime
from schematics import Model
from schematics.transforms import blacklist, whitelist
from schematics.types import ModelType, StringType, IntType, UUIDType, BooleanType, URLType, EmailType, DateTimeType, \
    GeoPointType, ListType, serializable, PolyModelType

from model.friend import Friend
from model.game import Game
from model.movie import Movie
from model.schematics_types.currency_type import CurrencyType
from model.tv_show import TVShow
from model.validators import is_uppercase, is_email_valid, is_over_18


class Person(Model):
    id = StringType(deserialize_from='_id')
    index = IntType()
    guid = UUIDType()
    is_active = BooleanType(deserialize_from='isActive')
    balance = CurrencyType()
    picture = URLType()
    age = IntType(validators=[is_over_18])
    eye_color = StringType(deserialize_from='eyeColor')
    name = StringType(required=True)
    gender = StringType()
    company = StringType(validators=[is_uppercase])
    email = EmailType(validators=[is_email_valid], required=True)
    phone = StringType(required=True)
    address = StringType()
    about = StringType()
    registered = DateTimeType()
    coordinates = GeoPointType()
    tags = ListType(StringType)
    friends = ListType(ModelType(Friend))
    greeting = StringType()
    favorite_fruit = StringType(deserialize_from='favoriteFruit')
    favorite_media = PolyModelType([
        Movie,
        TVShow,
        Game
    ], deserialize_from='favoriteMedia')
    created_at = DateTimeType(default=datetime.datetime.now)

    @serializable
    def external_id(self):
        return u'%s-%s' % (self.index, self.id)

    class Options:
        serialize_when_none = False
        roles = {
            'public_person': blacklist('id', 'index', 'guid', 'is_active', 'balance'),
            'profile_info': whitelist('name', 'greeting', 'gender', 'picture', 'about', 'age')
        }

Muita informação aqui! Vamos entender passo a passo.

Atribuindo tipos a campos

Criamos uma classe Person que estende a classe Model do Schematics. Nesta classe, todas as variáveis são declaradas com um tipo específico já existente no Schematics ou um tipo customizado criados por nós mesmos. O Schematics fornece tipos prontos para uso (como StringType, UUIDType, IntType; verifique todos os tipos disponíveis usando o as documentações do Schematics).

Esses tipos são muito úteis para coagir e converter dados para corresponder ao nosso esquema de classe desejado. Além disso, com um tipo, a validação de campo se torna muito fácil (falaremos mais sobre isso já já). Como visto acima, é possível criar tipos personalizados para atender às nossas necessidades específicas, como a classe CurrencyType:

from schematics.types import FloatType


class CurrencyType(FloatType):
    def convert(self, value, context=None):
        if not isinstance(value, str):
            return value
        number = value.replace('$', '')
        return float(number.replace(',', ''))

Neste exemplo, CurrencyType estende a classe FloatType do Schematics, o método de conversão recebe um valor - e se tratando de uma string, remove o cifrão e a vírgula para aderir ao formato de moeda desejado.

Lista de Modelos

Para representar uma lista/array de itens de um modelo, usamos ListType(ModelType()) e o nome do modelo que compõem essa lista - como no exemplo acima: ListType(ModelType(Friend)). A classe Friend representa um modelo da seguinte forma:

from schematics import Model
from schematics.transforms import wholelist
from schematics.types import IntType, StringType


class Friend(Model):
    id = IntType()
    name = StringType()

Se a lista consistir em um tipo simples, como o parâmetro tags (uma lista composta apenas de strings), declaramos um ListType() com o tipo desejado, como ListType(StringType).

Renomeando Campos

Schematics faz atribuições automáticas para atribuir as variáveis baseadas no nome do campo recebido com o campo do nosso modelo; quando ambos compartilham o mesmo nome, esse processo acontece por baixo dos panos. Mas o que acontece quando recebemos campos que em nosso contexto possuem nomes diferentes?

Para resolver isso, é bastante fácil: basta adicionar deserialize_from= seguido do nome do campo original recebido para realizar a correspondência. No exemplo acima, os campos id, eye_color e favorite_fruit são desserializados usando este atributo:

favorite_fruit = StringType(deserialize_from='favoriteFruit')

Esta é uma ótima maneira de normalizar facilmente payloads com nomenclatura diferente do snake_case, que é usado pelo python.

Campos Padrão

Para definir um valor padrão para um campo, use o atributo default= dentro do tipo de campo desejado. No exemplo acima, o campo created_at terá como padrão a data/hora em que o modelo foi instanciado.

    created_at = DateTimeType(default=datetime.datetime.now)

Campos Compostos

Para criar um campo composto de campos existentes na classe, crie uma função com o nome do campo desejado anotado com @serializable como no campo external_id do model acima, que é criado concatenando os campos index e id. Isso permite que o valor seja acessado como qualquer outro campo da classe.

    @serializable
    def external_id(self):
        return u'%s-%s' % (self.index, self.id)

Definindo validadores Personalizados

Podemos criar um validador personalizado para verificar se os dados recebidos respeitam regras de negócio. Para isso, crie uma função com a validação desejada que receba um atributo value como parâmetro e gere uma exceção ValidationError em um cenário negativo, como este:

import re

from schematics.exceptions import ValidationError


def is_uppercase(value):
    if value.upper() != value:
        raise ValidationError('Field should be uppercase.')
    return value


def is_email_valid(value):
    regex = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b'
    if not re.fullmatch(regex, value):
        raise ValidationError('E-mail address invalid.')
    return value


def is_over_18(value):
    if value < 18:
        raise ValidationError('User cannot be underage.')
    return value

Para validar os elementos do modelo, use validators=[] e o nome da função entre colchetes, assim: validators=[is_email_valid].

    email = EmailType(validators=[is_email_valid], required=True)

Campos Obrigatórios

Para definir um campo como obrigatório, use required=True na declaração do tipo. Se o valor não estiver presente no momento da validação, a aplicação lançará uma exceção.


2. Acessando e Manipulando Dados

Com nosso modelo criado, é hora de gerar alguns dados carregando o json recebido como um dict e passando como parâmetro ao instanciar classe Person:

person = Person(json_input)

Validando Dados

Para validar se os dados recebidos estão em corretos, usamos o seguinte método:

person.validate()

Em nosso modelo, criamos um validador que especifica que o valor do campo empresa deve ser maiúsculo. Deixar de informar uma string em letras maiúsculas resulta na seguinte saída quando validado:

schematics.exceptions.DataError: {"company": ["Field should be uppercase."]}

Process finished with exit code 1

Ignorando Campos Não Mapeados

Para ignorar campos não declarados no modelo e presentes na entrada, usamos o seguinte atributo de validação:

person.validate(strict=False)

Se este parâmetro não for especificado, passar um campo na entrada que não está contido no modelo resulta na seguinte saída ao validar:

schematics.exceptions.DataError: {"newField": "Rogue field"}

Process finished with exit code 1

Omitindo Campos Sem Valor

Para omitir campos sem valor (atribuídos com None) ao exportar um modelo, use serialize_when_none = False dentro da classe interna Options:

    class Options:
        serialize_when_none = False

Acessando Campos

Com o objeto modelo criado, fica fácil acessar os campos:

print(person.name, 'is', person.age, 'old.')

que gera como resposta:

Whitehead Navarro is 22 old.

Process finished with exit code 0

Exportando para Json

Para exportar o objeto para json, utilizamos o seguinte método:

json.dumps(person.to_primitive())

que gera como resposta o json:

{"id": "641505586f0763093fe5de82", "index": 0, "guid": "3e591c0d-5812-4c1e-9062-91d67db36326", "is_active": true, "balance": 3657.06, "picture": "https://picsum.photos/200", "age": 22, "eye_color": "brown", "name": "Whitehead Navarro", "gender": "male", "company": "ROUGHIES", "email": "whiteheadnavarro@roughies.com", "phone": "(843) 429-2875", "address": "934 River Street, Sparkill, New Mexico, 4866", "about": "Mollit enim aute sint enim ut eiusmod dolore dolore veniam. Esse ad consequat pariatur cupidatat qui deserunt proident minim irure. Proident labore minim ex voluptate ea ut excepteur duis ad minim quis incididunt labore. Laborum sit aliqua aliqua et ad qui qui quis ullamco. Voluptate voluptate consectetur nostrud amet enim. In Lorem voluptate fugiat duis. Ut proident ipsum minim do fugiat sunt laboris voluptate tempor aliqua aliquip deserunt sit.\r\n", "registered": "2018-08-26T04:14:49.000000", "coordinates": [-0.642558, -154.849655], "tags": ["pariatur", "consequat", "et", "amet", "fugiat", "non", "deserunt"], "friends": [{"id": 0, "name": "Alejandra Kinney"}, {"id": 1, "name": "Holmes Graves"}, {"id": 2, "name": "Barbra Dominguez"}], "greeting": "Hello, Whitehead Navarro! You have 7 unread messages.", "favorite_fruit": "strawberry", "created_at": "2023-06-22T22:09:15.939875", "external_id": "0-641505586f0763093fe5de82"}

Process finished with exit code 0

Roles e Opções de Classe

Uma role funciona como um filtro ao exportar dados usando as opções whitelist e blacklist. Como no modelo Person acima, declaramos uma role dentro da subclasse Option e listamos os valores da whitelist (valores para exportar) ou uma blacklist (valores para omitir).

    class Options:
        roles = {
            'public_person': blacklist('id', 'index', 'guid', 'is_active', 'balance'),
            'profile_info': whitelist('name', 'greeting', 'gender', 'picture', 'about', 'age')
        }

Este é o comando para imprimir apenas os valores da whitelist:

print(person.to_primitive(role='profile_info'))

que imprime os valores de pictures, age, name, gender, about e greeting:

{'picture': 'https://picsum.photos/200', 'age': 22, 'name': 'Whitehead Navarro', 'gender': 'male', 'about': 'Mollit enim aute sint enim ut eiusmod dolore dolore veniam. Esse ad consequat pariatur cupidatat qui deserunt proident minim irure. Proident labore minim ex voluptate ea ut excepteur duis ad minim quis incididunt labore. Laborum sit aliqua aliqua et ad qui qui quis ullamco. Voluptate voluptate consectetur nostrud amet enim. In Lorem voluptate fugiat duis. Ut proident ipsum minim do fugiat sunt laboris voluptate tempor aliqua aliquip deserunt sit.\r\n', 'greeting': 'Hello, Whitehead Navarro! You have 7 unread messages.'}

Process finished with exit code 0

Ao imprimir todos os valores exceto os que estão na blacklist, usamos:

print(person.to_primitive(role='public_person'))

que omite os campos id, index, guid, is_active e balance:

{'picture': 'https://picsum.photos/200', 'age': 22, 'eye_color': 'brown', 'name': 'Whitehead Navarro', 'gender': 'male', 'company': 'ROUGHIES', 'email': 'whiteheadnavarro@roughies.com', 'phone': '(843) 429-2875', 'address': '934 River Street, Sparkill, New Mexico, 4866', 'about': 'Mollit enim aute sint enim ut eiusmod dolore dolore veniam. Esse ad consequat pariatur cupidatat qui deserunt proident minim irure. Proident labore minim ex voluptate ea ut excepteur duis ad minim quis incididunt labore. Laborum sit aliqua aliqua et ad qui qui quis ullamco. Voluptate voluptate consectetur nostrud amet enim. In Lorem voluptate fugiat duis. Ut proident ipsum minim do fugiat sunt laboris voluptate tempor aliqua aliquip deserunt sit.\r\n', 'registered': '2018-08-26T04:14:49.000000', 'coordinates': [-0.642558, -154.849655], 'tags': ['pariatur', 'consequat', 'et', 'amet', 'fugiat', 'non', 'deserunt'], 'friends': [{'id': 0, 'name': 'Alejandra Kinney'}, {'id': 1, 'name': 'Holmes Graves'}, {'id': 2, 'name': 'Barbra Dominguez'}], 'greeting': 'Hello, Whitehead Navarro! You have 7 unread messages.', 'favorite_fruit': 'strawberry', 'created_at': '2023-06-22T22:17:12.004692', 'external_id': '0-641505586f0763093fe5de82'}

Process finished with exit code 0

Mockando a Resposta

Para testar nosso código, podemos usar Schematics para mockar os valores do modelo:

print(Person.get_mock_object().to_primitive())

O mock tem valores aleatórios baseados nos tipos das variáveis:

{'index': 14, 'picture': 'http://aiV9Q.ZZ', 'age': 12, 'eye_color': 'N', 'name': 'hgqn8YPvDqLbf', 'company': 'JQ', 'email': 'ER@example.com', 'phone': 'DQVbv9q', 'address': '61sOKCSGGOkV', 'coordinates': (-83, 53), 'greeting': 'fsxF', 'created_at': '2127-03-09T03:39:51.357227+1130', 'external_id': '14-None'}

Process finished with exit code 0

3. Modelagem Avançada: Polimorfismo

Também é possível realizar polimorfismo de tipos usando Schematics! Quando temos um campo que pode ter vários tipos, podemos declará-lo assim:

favorite_media = PolyModelType([
    Movie,
    TVShow,
    Game
], deserialize_from='favoriteMedia')

As classes Movie, TVShow e Game representam os tipos aceitos do campo favorite_media:

from schematics import Model
from schematics.types import StringType, IntType


class Movie(Model):
    name = StringType()
    year = IntType()
from schematics import Model
from schematics.types import StringType, IntType


class TVShow(Model):
    name = StringType()
    year = IntType()
    network = StringType()

    @classmethod
    def _claim_polymorphic(cls, data):
        return data.get('network')
from schematics import Model
from schematics.types import StringType, IntType


class Game(Model):
    name = StringType()
    year = IntType()
    console = StringType()

    @classmethod
    def _claim_polymorphic(cls, data):
        return data.get('console')

Nossos modelos são muito parecidos. Neste caso, devemos implementar o método _claim_polymorphic que ajuda o Schematics a diferenciar entre os modelos com base no payload recebido. Neste caso, retornamos um data.get com um atributo que seja único entre todas as classes.

Para verificar o tipo de mídia favorito escolhido pelos Schematics após a ingestão dos dados recebidos, usamos:

print(person.favorite_media)

que gera como resposta:

<TVShow instance>

Process finished with exit code 0

A saída é definida como uma instância de TvShow porque informamos um atributo network em nosso esquema recebido.

Para acessar o nome da mídia, podemos acessar diretamente o campo de nome:

print(person.favorite_media.name)

que gera como resposta:

Better Call Saul

Process finished with exit code 0

Se alterarmos a entrada para representar um filme, por exemplo, com uma entrada como esta:

  "favoriteMedia": {
      "name": "Everything Everywhere All At Once",
      "year": "2022"
  }

Ao enviar o tipo de mídia favorito, vamos ter:

<Movie instance>

Process finished with exit code 0

É isso! Para acessar esta implementação, confira o código-fonte no meu Github.

Referências

Documentação Schematics Python