Enmilocalfunciona

Thoughts, stories and ideas.

Gestionando configuraciones en Python con Pydantic

Publicado por David Lorenzo el

libreriapydanticdataclasessPython

Una de las partes más importantes en toda aplicación o servicio es gestionar adecuadamente sus configuraciones. Ya sean parametrizaciones sobre cómo va a funcionar el propio servicio, o la configuración de alguna de sus dependencias (como la conexión con una base de datos), organizar adecuadamente en código la definición, carga y gestión de estas configuraciones marcará una clara diferencia en la legibilidad y mantenibilidad de nuestro código, y en la resiliencia a la hora de cargar e interpretar adecuadamente nuestros ajustes.

En el caso de Python, una librería que nos puede ayudar en esta tarea es Pydantic.

¿Qué es Pydantic?

Pydantic (GitHub, PyPI, documentación) es una librería que nos permite crear dataclasses en Python. Una dataclass (en el contexto de Python) es una clase donde el constructor (y otros métodos especiales) son "autogenerados", de forma que lo único que tenemos que especificar son los atributos que va a tener dicha clase. Python dispone de la librería estándar "dataclasses", que nos proporciona un decorador para poder crear este tipo de clases.

from dataclasses import dataclass

@dataclass
class Persona:
    nombre: str
    edad: int

Persona(nombre="Foo", edad=25)
# Persona(nombre='Foo', edad=25)

Por su parte, Pydantic ofrece una serie de prestaciones de las que carecen las dataclasses propias de Python. Las dos principales características, que además van a resultarnos especialmente útiles a la hora de gestionar configuraciones, son:

  1. Auto-conversión de tipos. Por defecto, Pydantic intentará convertir el valor asignado a un atributo al de su tipo. Así, si un atributo de tipo entero lo inicializásemos con un texto "123", Pydantic intentará realizar la conversión de dicho string al entero 123.
  2. Validación de tipos. Cada atributo de la clase tiene asignado un tipo (utilizando el tipado estándar de Python). Si el valor asignado a un atributo no es válido, la inicialización de la clase fallará (por ejemplo, intentar inicializar un campo numeral con un texto "123abc").

Antes de comenzar, es importante destacar que este artículo se basa en la versión 1.9 de Pydantic, pero está previsto que a finales de 2022 se publique la versión 2, donde cambiarán (y mejorarán) bastantes aspectos.

Definiendo nuestra clase con Pydantic

Supongamos que queremos configurar la conexión a una base de datos en nuestra aplicación, de forma que necesitamos los siguientes parámetros:

  • Host del servidor
  • Puerto del servidor
  • Nombre de la base de datos
  • Timeout para las conexiones con la base de datos

En primer lugar, es necesario instalar Pydantic en nuestro sistema (o entorno virtual, preferiblemente):

pip install "pydantic<2"

(Hemos forzado la versión de pydantic a la última versión 1.x.x)

Con esto podemos definir una clase Pydantic muy básica, donde se definan los atributos mencionados:

import pydantic

class DatabaseSettings(pydantic.BaseModel):
    host: str
    port: int
    name: str
    timeout: float

Paso por paso, lo que hemos definido ha sido:

  • Una clase para las configuraciones de la base de datos, que hereda de pydantic.BaseModel.
  • Definimos los atributos dentro de la clase, asignándole a cada uno su tipo correspondiente.

Con la clase definida, ya podemos probar a instanciarla desde Python:

d = DatabaseSettings(host="127.0.0.1", port="27017", name="mydb", timeout=0.5)
print(d)
# host='127.0.0.1' port=27017 name='mydb' timeout=0.5

También podemos probar las dos características principales de Pydantic que destacamos en la introducción: la conversión de tipos...

d = DatabaseSettings(host="127.0.0.1", port="27017", name=1, timeout="2")
print(d)
# host='127.0.0.1' port=27017 name='1' timeout=2.0

...y la validación de tipos

d = DatabaseSettings(host="127.0.0.1", port="asdf", name="mydb", timeout="lorem ipsum")
# Traceback (most recent call last):
#   File "<stdin>", line 1, in <module>
#   File "pydantic/main.py", line 341, in pydantic.main.BaseModel.__init__
# pydantic.error_wrappers.ValidationError: 2 validation errors for DatabaseSettings
# port
#   value is not a valid integer (type=type_error.integer)
# timeout
#   value is not a valid float (type=type_error.float)

En este último caso, como era de esperar, la validación de los dos campos numerales (port como int y timeout como float) ha fallado, al intentar inicializarlos con strings no convertibles a numerales. En la excepción que obtenemos podemos apreciar otra de las ventajas de Pydantic, que es el nivel de detalle con el que nos indica los errores de validación obtenidos. Además, se realiza una validación completa, ya que se nos indican todos los atributos cuya validación ha fallado, en lugar de parar ante el primer campo erróneo, lo que nos permite conocer todos los errores que hemos cometido.

Valores opcionales y por defecto

Algo muy habitual en las configuraciones es que algunos atributos sean opcionales, o que incluso tengan un valor por defecto. Esto es algo que también podemos definir de una forma muy sencilla desde Pydantic.

Supongamos que, en el ejemplo que hemos utilizado hasta ahora, queremos que el atributo name sea opcional, y que timeout tenga un valor por defecto de 10. Podemos modificar la clase para cumplir estas condiciones de la siguiente forma:

import pydantic
from typing import Optional

class DatabaseSettings(pydantic.BaseModel):
    host: str
    port: int
    name: Optional[str] = None
    timeout: float = 10

d = DatabaseSettings(host="localhost", port=3306)
print(d)
# DatabaseSettings(host='localhost', port=3306, name=None, timeout=10)

En el caso de name, hemos tipado el atributo con Optional, un tipo especial de Python que indica que un valor puede ser None (además del tipo especificado entre llaves). Así mismo, con el operador = indicamos que el valor por defecto de ese campo es, precisamente, None. Por su parte, con timeout hemos hecho lo propio, asignándole un valor por defecto.

Cargando nuestra configuración desde un archivo

Pasando a un caso de uso real, una de las formas que tenemos de cargar nuestras configuraciones es a partir de un archivo JSON o YAML. Existen diferentes vías de inicializar una clase Pydantic a través de un fichero:

a) parse_file (solo JSON)

El método de clase parse_file instanciará una clase, dada la ruta del fichero JSON de donde cargar sus datos. Este método es la forma más cómoda de realizar la carga, pero solo es compatible con archivos JSON.

// Contenido del archivo "settings.json"
{
    "host": "localhost",
    "port": 6379,
    "name": 0,
    "timeout": 2
}
d = DatabaseSettings.parse_file("settings.json")
print(d)
# host='localhost' port=6379 name='0' timeout=2.0

b) parse_obj (cualquier tipo)

Para cargar nuestras configuraciones desde un formato que no sea JSON, en todo caso, necesitaremos cargar el archivo pertinente obteniendo un diccionario Python en su lugar. Con este diccionario podemos instanciar nuestra clase Pydantic mediante el método parse_obj, o directamente como kwargs. Así lo haríamos con un archivo YAML:

# Contenido del archivo "settings.yaml"
host: localhost
port: 6379
name: 0
timeout: 2
# Necesitamos instalar el paquete pyyaml
import yaml

with open("settings.yaml", "r") as f:
    data = yaml.safe_load(f)

# Carga con parse_file()
d = DatabaseSettings.parse_obj(data)
print(d)
# host='localhost' port=6379 name='0' timeout=2.0

# Carga con kwargs
d = DatabaseSettings(**data)

Clases anidadas

Hasta ahora hemos trabajado con un único conjunto de configuraciones (la conexión con nuestra base de datos ficticia). Pero es habitual que nuestra aplicación o servicio necesite más conjuntos de  parametrizaciones. En este caso, es común recurrir a un modelado más complejo, con objetos anidados que hagan referencia a los distintos elementos que queremos configurar.

Supongamos que queremos expandir nuestra configuración para, además de la base de datos, configurar el host y puerto de un servidor HTTP. Para ello, definiremos una nueva clase para este nuevo grupo de ajustes, y otra clase que aunará todas las configuraciones de nuestra aplicación:

import pydantic

class DatabaseSettings(pydantic.BaseModel):
    host: str
    port: int
    name: str
    timeout: float

class ServerSettings(pydantic.BaseModel):
    host: str
    port: int

class MainSettings(pydantic.BaseModel):
    server: ServerSettings
    database: DatabaseSettings

Como podemos apreciar, la nueva clase MainSettings tiene dos atributos cuyo tipado hemos definido como las clases de nuestros dos grupos de configuraciones. Pydantic realizará las validaciones de forma anidada para todo el "árbol" de objetos. Podemos ponerlo a prueba inicializando la clase MainSettings con el siguiente diccionario:

d = {
    "server": {
        "host": "127.0.0.1",
        "port": "lorem ipsum"
    },
    "database": {
        "host": "127.0.0.1",
        "port": 27017,
        "name": "mydb",
        "timeout": "foo"
    }
}

MainSettings(**d)
# Traceback (most recent call last):
#   File "<stdin>", line 1, in <module>
#   File "pydantic/main.py", line 341, in pydantic.main.BaseModel.__init__
# pydantic.error_wrappers.ValidationError: 2 validation errors for MainSettings
# server -> port
#   value is not a valid integer (type=type_error.integer)
# database -> timeout
#   value is not a valid float (type=type_error.float)

Nuevamente, Pydantic nos detallará en su ValidationError aquellos campos cuyos valores son incorrectos. Además, al tratarse de clases anidadas, nos indicará la ruta completa de cada atributo fallido (server > port y database > timeout).

Cargando nuestra configuración desde variables de entorno

Otra forma habitual de cargar configuraciones es mediante variables de entorno. Las variables de entorno son simples pares clave-valor, donde el valor se interpreta como un string. Para este caso Pydantic es especialmente útil, tanto para gestionar las propias configuraciones, como para realizar la autoconversión de los valores (strings) a los tipos que necesitemos.

Comenzaremos con la misma clase DatabaseSettings que estuvimos utilizando, pero con la única diferencia de que, en este caso, nuestra clase heredará de pydantic.BaseSettings. Así, al instanciar nuestra clase, Pydantic intentará buscar los valores de sus atributos en las variables de entorno.

# Contenido del archivo "pydantic-basesettings.py"
import pydantic

class DatabaseSettings(pydantic.BaseSettings):
    host: str
    port: int
    name: str
    timeout: float

if __name__ == "__main__":
    d = DatabaseSettings()
    print(d)

En esta ocasión, vamos a guardar el código en un archivo Python y ejecutarlo desde terminal, definiendo los atributos como variables de entorno en tiempo de ejecución. Las claves de las variables de entorno son los nombres de los atributos en la clase, y no son sensibles a mayúsculas/minúsculas, como podemos apreciar en el siguiente ejemplo:

HOST=localhost PORT=3306 name=mydb TimeOut=0.5 python pydantic-basesettings.py
# host='localhost' port=3306 name='mydb' timeout=0.5

Archivos dotenv

Otra forma que tenemos de cargar nuestras configuraciones es utilizando archivos "dotenv". En este formato especificamos nuestras configuraciones de la misma forma que si fuesen variables de entorno, pero en un archivo, con una variable por línea. Un archivo "dotenv" para la configuración del anterior ejemplo sería así:

# Contenido del archivo "settings.env"
HOST=localhost
PORT=3306
NAME=mydb
TIMEOUT=5

Pydantic tiene integrada la carga de este tipo de archivos, pero para poder hacer uso de esta funcionalidad, necesitamos instalar el paquete python-dotenv:

pip install python-dotenv

En cuanto al código, necesitamos especificar en nuestras clases BaseSettings que deseamos poder cargar sus valores desde un archivo "dotenv":

# Contenido del archivo "pydantic-basesettings.py"
import os
import pydantic

ENV_FILE = os.getenv("ENV_FILE", "settings.env")

class DatabaseSettings(pydantic.BaseSettings):
    host: str
    port: int
    name: str
    timeout: float
    
    class Config:
    	env_file = ENV_FILE

if __name__ == "__main__":
    d = DatabaseSettings()
    print(d)

Para ello, hemos tenido que especificar la ruta del fichero "dotenv" en la subclase Config. Esta subclase permite configurar diferentes aspectos de los modelos Pydantic, como es el caso de esta funcionalidad.

Es importante destacar que, aunque habilitemos la posibilidad de cargar las configuraciones de una clase desde un archivo "dotenv", es posible especificar (o reemplazar) los valores especificados en el fichero con valores establecidos como variables de entorno canónicas (establecidas en el entorno de ejecución). Estas últimas tienen prioridad sobre los valores existentes en el fichero.

Clases anidadas y variables de entorno

Combinando los ejemplos anteriores, un caso más realista, estructurando las configuraciones en varias clases y compatible con variables de entorno y carga desde un archivo "dotenv", sería el siguiente:

# Contenido del archivo "settings.env"
DB_HOST=localhost
DB_PORT=3306
DB_NAME=db1
DB_TIMEOUT=0.75
SERVER_HOST=127.0.0.1
SERVER_PORT=8000
# Contenido del archivo "pydantic-basesettings.py"
import pydantic

ENV_FILE = "settings.env"

class DatabaseSettings(pydantic.BaseSettings):
    host: str
    port: int
    name: str
    timeout: float

    class Config:
        env_prefix = "DB_"
        env_file = ENV_FILE

class ServerSettings(pydantic.BaseSettings):
    host: str
    port: int

    class Config:
        env_prefix = "SERVER_"
        env_file = ENV_FILE

class MainSettings(pydantic.BaseModel):
    server: ServerSettings = pydantic.Field(default_factory=ServerSettings)
    database: DatabaseSettings = pydantic.Field(default_factory=DatabaseSettings)

if __name__ == "__main__":
    s = MainSettings()
    print(s)

En esta ocasión, hemos definido prefijos para cada grupo (clase) de configuraciones, mediante el parámetro env_prefix en la subclase Config. De esta forma, por ejemplo, el atributo host de la clase DatabaseSettings se definirá mediante la variable de entorno con clave DB_HOST; y a su vez, el atributo homónimo en la clase ServerSettings hará lo propio con la clave SERVER_HOST.

Por otra parte, en la clase raíz de configuraciones hemos indicado que nuestras dos clases internas (ServerSettings y DatabaseSettings) se instancien por defecto al inicializar la clase raíz (MainSettings): esto lo hacemos indicando, tras el operador =, a pydantic.Field como atributo "por defecto". Con pydantic.Field podemos personalizar campos en Pydantic; en esta ocasión lo utilizamos para definir el denominado default_factory.

La figura del default_factory se emplea para indicar un valor por defecto en un atributo que, en lugar de ser un valor constante, viene dado de forma dinámica por una función, o en este caso, instanciando la clase dada. Así pues, lo que conseguimos es que los atributos server y database se inicialicen por defecto con sus respectivas clases, que a su vez están configuradas para que carguen sus valores desde variables de entorno o el fichero "dotenv" indicado. Todo esto se hará automáticamente al instanciar la clase MainSettings.

Validaciones especiales

La mencionada utilidad pydantic.Field tiene varios usos, y entre ellos está la posibilidad de personalizar las validaciones de nuestros campos. Por ejemplo, podemos limitar el tamaño mínimo o máximo de strings, números o listas. En el siguiente ejemplo podemos apreciar cómo aplicar restricciones a todos estos tipos de datos, y qué sucede si no las cumplimos:

import pydantic
from typing import List

class MyModel(pydantic.BaseModel):
    text: str = pydantic.Field(min_length=1, max_length=5)
    number: float = pydantic.Field(ge=0, lt=10)
    array: List[str] = pydantic.Field(min_items=1, max_items=4)

MyModel(text="abcdefg", number=-1, array=["a", "b", "c", "d", "e", "f"])
# Traceback (most recent call last):
#   File "<stdin>", line 1, in <module>
#   File "pydantic/main.py", line 341, in pydantic.main.BaseModel.__init__
# pydantic.error_wrappers.ValidationError: 3 validation errors for MyModel
# text
#   ensure this value has at most 5 characters (type=value_error.any_str.max_length; limit_value=5)
# number
#   ensure this value is greater than or equal to 0 (type=value_error.number.not_ge; limit_value=0)
# array
#   ensure this value has at most 4 items (type=value_error.list.max_items; limit_value=4)

Por último, merece la pena señalar que existen tipos especiales en Pydantic que se pueden utilizar para aplicar ciertas validaciones y restricciones a campos, según el contexto. Algunos ejemplos, que pueden resultar útiles para configuraciones, son:

  • URLs para HTTP: pydantic.HttpUrl (se verifica que la URL sea válida)
  • URLs para DSN, como pydantic.PostgresDsn, pydantic.RedisDsn, pydantic.RabbitMqDsn...
  • IPs: pydantic.IPv4Address, pydantic.IPv6Address, pydantic.IPvAnyInterface...
  • Rutas a directorios y archivos: pydantic.FilePath, pydantic.DirectoryPath (respectivamente)
  • Credenciales: pydantic.SecretStr, pydantic.SecretBytes (evitan que los valores de estos campos se exporten o visualicen por defecto, teniendo que llamar a un método especial sobre ellos para poder acceder a su contenido)

Conclusiones

Con los ejemplos expuestos hemos resumido las principales funcionalidades que nos puede ofrecer Pydantic para facilitarnos la gestión de las configuraciones de nuestras aplicaciones y servicios. A través de su documentación se pueden ver en detalle estas y otras funcionalidades avanzadas que también pueden resultarnos de utilidad.

Es interesante destacar que Pydantic sirve para mucho más que gestionar configuraciones: es bastante probable que nos sirva para algo más en nuestros desarrollos. Un ejemplo de ello es el uso tan intensivo que se le da en FastAPI, uno de los frameworks web para Python que más ha crecido en los últimos años, donde Pydantic es empleado principalmente para validar datos de entrada y salida.