Python es un lenguaje que es multiparadigma, se puede trabajar funcional y orientado a objetos sin ningún problema, por esto mismo dentro de su librería estándar python tiene algunas herramientas que soportan estos paradigmas, podríamos pensar herramientas como los decoradores para trabajar la programación funcional, dataclasses para trabajar la programación orientada a objetos y si hablamos de asincronismo asyncio para soportar programación asíncrona, todo esto potencia los diferentes arquitecturas y formas de desarrollo, sin embargo como todo en tecnologia la mejor opción a tu problema depende de muchas variables, hoy en este artículo me tomare la tarea de analizar un enfoque pensado en la serialización de los datos, podría llamarse conversión o parseo de estructuras, este es un concepto muy utilizado en la comunicación entre APIS, ya que este proceso requiere transmitir la información entre diferentes componentes en cierto formato diferente al que se representa en memoria, el cual se puede apreciar en la siguiente Figura

Figura 1: Como funciona un proceso de comunicacion hacia una API obtenido de https://www.altexsoft.com/blog/rest-api-design/

Validación nativa

Generalmente los serializadores 'pythonicos' son instancias creados a partir de ciertas clases, esto se podría hacer en un proceso rudimentario de serialización y descrizalicion usando solamente clases, para ello veamos cómo sería un ejemplo


class Employer:

    def __get_email(self, name:str, last_name:str):
        return f"{name}.{last_name}@mycompany.com"

    def __init__(self, name:str, last_name:str):
        self.name = name
        self.last_name = last_name
        self.email = self.__get_email(self.name, self.last_name)

    def serialize(self):
        return self.__dict__


if __name__ == '__main__':

    try:
        body = {
            "name":"Jairo",
            "last_name":"Castañeda"
        }
        employer = Employer(**body)
        print("Python Object")
        print(employer)
        print("Json object")
        print(employer.serialize())


    except TypeError as error:
        print("The body is invalid")

Ejecutamos y obtenemos el siguiente resultado

Python object
<__main__.Employer object at 0x7f8ec8d78e50>
Json object
{'name': 'Jairo', 'last_name': 'Castañeda', 'email': 'Jairo.Castañeda@mycompany.com'}

en el código anterior ya tendríamos una base para aceptar objetos JSON, y utilizando el desempaquetamiento en python crear objetos de clase, además utilizando el atributo __dict__ convertimos el objeto de clase a un json object que sería lo que se retorna en una respuesta, podríamos ver varios problemas, a pesar de que el ejemplo anterior funciona, es bastante simple y se podría mejorar un poco de la siguiente forma

import json


class Departament:
    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return f"{self.__class__.__name__}(name={self.name})"


class Employer:

    def __get_email(self, name:str, last_name:str):
        return f"{name}.{last_name}@mycompany.com"

    def __init__(self, name:str, last_name:str, departament:Departament):
        self.name = name
        self.last_name = last_name
        self.email = self.__get_email(self.name, self.last_name)
        self.departament = departament
        if isinstance(departament, dict):
            self.departament = Departament(**departament)


    def __repr__(self) -> str:
        return  f"{self.__class__.__name__}(name={self.name},last_name={self.last_name}, " \
                f"email={self.email}, departament={self.departament})"
    
    def serialize(self):
        return json.loads(json.dumps(self, default=lambda value: value.__dict__))


if __name__ == '__main__':

    try:
        body = {
            "name":"Jairo",
            "last_name":"Castañeda",
            "departament":{
                "name":"Cloud"
            }
        }
        employer = Employer(**body)
        print("Python object")
        print(employer)
        print("Json object")
        print(employer.serialize())


    except TypeError as error:
        print("The body is invalid")

Ejecutamos y obtenemos el siguiente resultado

Python object
Employer(name=Jairo,last_name=Andres, email=Jairo.Andres@mycompany.com, departament=Departament(name=Cloud))
Json object
{'name': 'Jairo', 'last_name': 'Castañeda', 'email': 'Jairo.Castañeda@mycompany.com', 'departament': {'name': 'Cloud'}}

logramos manejar objeto relacionales o anidados y modificamos como se representa el método, igualmente tenemos una gran cantidad de métodos que podemos utilizar, pero si ven la parte de serialización se vuelve muy compleja y esto realmente se vuelve insostenible, por ello debemos mirar otras opciones, entre ellas tenemos los dataclasses

Validación con dataclass

Los dataclass son un decorador en python que nos facilita trabajar con clases y nos permite de forma pythonica generar una gran cantidad de código por nosotros esto gracias a sus banderas o parámetros, los cuales logran gestionar diferentes comportamientos, veamos cómo quedaría el mismo ejemplo que teníamos anteriormente, pero ahora utilizando dataclass

from dataclasses import dataclass, asdict, field
from dacite import from_dict


@dataclass
class Departament:
    name:str

@dataclass
class Employer:
    name:str
    last_name:str
    departament:Departament
    email:str = field(init=False)

    def __post_init__(self):
        self.email = f"{self.name}.{self.last_name}@mycompany.com"


if __name__ == '__main__':
    body = {
        "name": "Jairo",
        "last_name": "Castañeda",
        "departament": {
            "name": "Cloud"
        }
    }
    employer:Employer = from_dict(Employer, body)
    print("Python Object")
    print(employer)
    print("Json Object")
    print(asdict(employer))

Ejecuto y vemos el resultado

Python Object
Employer(name='Jairo', last_name='Andres', departament=Departament(name='Cloud'), email='Jairo.Andres@mycompany.com')
Json Object
{'name': 'Jairo', 'last_name': 'Castañeda', 'departament': {'name': 'Cloud'}, 'email': 'Jairo.Castañeda@mycompany.com'}

Como se puede apreciar en el código, dataclass redujo una gran cantidad de trabajo, agregue una librería  llamada dacite en poetry

poetry add dacite

esta librería se encarga de la conversión de json de objeto de python, es decir recibe una clase y un objeto JSON y realiza la conversión. Los dataclass lo que hacen por debajo de todo, es sobreescribir métodos que me evitan a escribir más cantidad de código, para confirmarlo ejecutamos el siguiente linea de código sobre el objeto de python

print(dir(employer))

y obtenemos el siguiente resultado

Methods
['__annotations__', '__class__', '__dataclass_fields__', '__dataclass_params__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__post_init__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', 'departament', 'email', 'last_name', 'name']

todos esos métodos los escribe dataclass por mi, por ello tuve que agregar un método __post_init__ que lo que hace es invocarse después de la inicialización para que se puede crear el campo email, ahora hasta aquí todo aparenta estar bien, pero qué pasa si actualizo el atributo name o last_name? el correo no se cambiara, y que pasa si necesito más validación sobre los datos, que el name tenga solo 10 caracteres o que el departamento de trabajo reciba solo unos valores predefinidos? pues existe algo llamado los property pero no se llevan muy bien con los dataclass podríamos tratar de implementar la validación de esta forma

from dataclasses import dataclass, asdict, field, InitVar
from dacite import from_dict


@dataclass
class Departament:
    name:str

@dataclass
class Employer:
    last_name:str
    departament:Departament
    name: InitVar[str]
    __name:str = None
    email:str = field(init=False)

    def __post_init__(self, name):
        self.__name = name
        self.email = f"{self.name}.{self.last_name}@mycompany.com"


    @property
    def name(self)->str:
        return self.__name

    @name.setter
    def name(self, name:str):
        if len(name)>11:
            raise ValueError
        self.__name = name



if __name__ == '__main__':
    body = {
        "name": "Jairo",
        "last_name": "Castañeda",
        "departament": {
            "name": "Cloud"
        }
    }
    employer:Employer = from_dict(Employer, body)
    print("Python Object")
    print(employer)
    print("Json Object")
    print(asdict(employer))

Ejecutamos y obtenemos el siguiente resultado

Python Object
Employer(last_name='Andres', departament=Departament(name='Cloud'), _Employer__name='Jairo', email='Jairo.Andres@mycompany.com')
Json Object
{'last_name': 'Castañeda', 'departament': {'name': 'Cloud'}, '_Employer__name': 'Jairo', 'email': 'Jairo.Castañeda@mycompany.com'}

debido a los métodos que se sobreescriben, el truco de la variable privada para utilizar el property nos genera un error en la serialización, por ello debemos pensar en una librería  completa cuyo trabajo es el de trabajar con objetos python, se enfoca en la serialización y validación de datos, esta librería es pydantic

Validación con pydantic

Ya en otros post he hablado de pydantic, hoy trataré de enfocarme en lo relacionado a validaciones, pydantic es básicamente una librería para gestionar todo el proceso de validación de datos forzando la validación incluso en runtime, garantizando a si que los type hints se cumplen no solo en desarrollo, veamos el código

from pydantic import BaseModel, Field, validator
from typing_extensions import Annotated


class Departament(BaseModel):
    name: str


class Employer(BaseModel):
    last_name: str
    departament: Departament
    name: str
    email: Annotated[str, Field(default_factory=lambda: "")]

    def __init__(self, **data):
        super().__init__(**data)
        self.email = f"{self.name}.{self.last_name}@mycompany.com"


if __name__ == '__main__':
    body = {
        "name": "Jairo",
        "last_name": "Castañeda",
        "departament": {
            "name": "Cloud"
        }
    }
    employer = Employer(**body)
    print("Python Object")
    print(employer)
    print("Json Object")
    print(employer.dict())

Ejecutamos y obtenemos el siguiente código

Python Object
last_name='Castañeda' departament=Departament(name='Cloud') name='Jairo' email='Jairo.Castañeda@mycompany.com'
Json Object
{'last_name': 'Castañeda', 'departament': {'name': 'Cloud'}, 'name': 'Jairo', 'email': 'Jairo.Castañeda@mycompany.com'}

como se puede apreciar la serialización y deserialización viene incluida en toda la herramienta y funciona bastante sencilla,  ahora para agregar los validadores usamos el siguiente código

from pydantic import BaseModel, Field, validator
from typing_extensions import Annotated


class Departament(BaseModel):
    name: str


class Employer(BaseModel):
    last_name: str
    departament: Departament
    name: str
    email: Annotated[str, Field(default_factory=lambda: "")]

    def __init__(self, **data):
        super().__init__(**data)
        self.email = f"{self.name}.{self.last_name}@mycompany.com"

    @validator('name')
    def name_must_contain_space(cls, v):
        if ' ' not in v:
            raise ValueError('must contain a space')
        return v.title()


if __name__ == '__main__':
    body = {
        "name": "Jairo Andres",
        "last_name": "Castañeda",
        "departament": {
            "name": "Cloud"
        }
    }
    employer = Employer(**body)
    print("Python Object")
    print(employer)
    print("Json Object")
    print(employer.dict())

los validadores en pydantic se ponen a nombre de los atributos, puedo recibir solo el atributo a modificar o todos los atributos si lo que deseo es modificar el atributo en función de los demás, con esta sencilla linea de codigo ya tengo la validación si ejecuto el código incumpliendo la regla obtengo este error

Traceback (most recent call last):
  File "main_pydantic.py", line 34, in <module>
    employer = Employer(**body)
  File "main_pydantic.py", line 16, in __init__
    super().__init__(**data)
  File "pydantic/main.py", line 331, in pydantic.main.BaseModel.__init__
pydantic.error_wrappers.ValidationError: 1 validation error for Employer
name
  must contain a space (type=value_error)

Conclusiones

Las librerías que nos permiten serialización sin contar las que son propias de los frameworks (como django rest framework) son librerías livianas e independientes del framework, incluso podría modelar el dominio usandolas por que son librerías realizadas para eso, construccion de modelo de datos, cual es la mejor? esa respuesta no la puedo dar, pero el proceso comparativo tiene que tener en cuenta por lo menos variables como rendimiento, peso de la librería y arquitectura sobre la cual se esta desarrollando el proyecto, con estas variables y una calificación basada en la teoría y la prueba podríamos obtener la que mejor se adapta a nuestro contexto

Te gusto el post y quisieras poder aplicar lo aprendido?

Únete al equipo de Simetrik AQUI