Saltar a contenido

Configuraciones y Variables de Entorno

En muchos casos, tu aplicación podría necesitar algunas configuraciones o ajustes externos, por ejemplo, claves secretas, credenciales de base de datos, credenciales para servicios de correo electrónico, etc.

La mayoría de estas configuraciones son variables (pueden cambiar), como las URLs de bases de datos. Y muchas podrían ser sensibles, como los secretos.

Por esta razón, es común proporcionarlas en variables de entorno que son leídas por la aplicación.

Consejo

Para entender las variables de entorno, puedes leer Variables de Entorno.

Tipos y validación

Estas variables de entorno solo pueden manejar strings de texto, ya que son externas a Python y tienen que ser compatibles con otros programas y el resto del sistema (e incluso con diferentes sistemas operativos, como Linux, Windows, macOS).

Eso significa que cualquier valor leído en Python desde una variable de entorno será un str, y cualquier conversión a un tipo diferente o cualquier validación tiene que hacerse en código.

Pydantic Settings

Afortunadamente, Pydantic proporciona una gran utilidad para manejar estas configuraciones provenientes de variables de entorno con Pydantic: Settings management.

Instalar pydantic-settings

Primero, asegúrate de crear tu entorno virtual, actívalo y luego instala el paquete pydantic-settings:

$ pip install pydantic-settings
---> 100%

También viene incluido cuando instalas los extras all con:

$ pip install "fastapi[all]"
---> 100%

Información

En Pydantic v1 venía incluido con el paquete principal. Ahora se distribuye como este paquete independiente para que puedas elegir si instalarlo o no si no necesitas esa funcionalidad.

Crear el objeto Settings

Importa BaseSettings de Pydantic y crea una sub-clase, muy similar a un modelo de Pydantic.

De la misma forma que con los modelos de Pydantic, declaras atributos de clase con anotaciones de tipos, y posiblemente, valores por defecto.

Puedes usar todas las mismas funcionalidades de validación y herramientas que usas para los modelos de Pydantic, como diferentes tipos de datos y validaciones adicionales con Field().

from fastapi import FastAPI
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    app_name: str = "Awesome API"
    admin_email: str
    items_per_user: int = 50


settings = Settings()
app = FastAPI()


@app.get("/info")
async def info():
    return {
        "app_name": settings.app_name,
        "admin_email": settings.admin_email,
        "items_per_user": settings.items_per_user,
    }

Información

En Pydantic v1 importarías BaseSettings directamente desde pydantic en lugar de desde pydantic_settings.

from fastapi import FastAPI
from pydantic import BaseSettings


class Settings(BaseSettings):
    app_name: str = "Awesome API"
    admin_email: str
    items_per_user: int = 50


settings = Settings()
app = FastAPI()


@app.get("/info")
async def info():
    return {
        "app_name": settings.app_name,
        "admin_email": settings.admin_email,
        "items_per_user": settings.items_per_user,
    }

Consejo

Si quieres algo rápido para copiar y pegar, no uses este ejemplo, usa el último más abajo.

Luego, cuando creas una instance de esa clase Settings (en este caso, en el objeto settings), Pydantic leerá las variables de entorno de una manera indiferente a mayúsculas y minúsculas, por lo que una variable en mayúsculas APP_NAME aún será leída para el atributo app_name.

Luego convertirá y validará los datos. Así que, cuando uses ese objeto settings, tendrás datos de los tipos que declaraste (por ejemplo, items_per_user será un int).

Usar el settings

Luego puedes usar el nuevo objeto settings en tu aplicación:

from fastapi import FastAPI
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    app_name: str = "Awesome API"
    admin_email: str
    items_per_user: int = 50


settings = Settings()
app = FastAPI()


@app.get("/info")
async def info():
    return {
        "app_name": settings.app_name,
        "admin_email": settings.admin_email,
        "items_per_user": settings.items_per_user,
    }

Ejecutar el servidor

Luego, ejecutarías el servidor pasando las configuraciones como variables de entorno, por ejemplo, podrías establecer un ADMIN_EMAIL y APP_NAME con:

$ ADMIN_EMAIL="deadpool@example.com" APP_NAME="ChimichangApp" fastapi run main.py

<span style="color: green;">INFO</span>:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

Consejo

Para establecer múltiples variables de entorno para un solo comando, simplemente sepáralas con un espacio y ponlas todas antes del comando.

Y luego la configuración admin_email se establecería en "deadpool@example.com".

El app_name sería "ChimichangApp".

Y el items_per_user mantendría su valor por defecto de 50.

Configuraciones en otro módulo

Podrías poner esas configuraciones en otro archivo de módulo como viste en Aplicaciones Más Grandes - Múltiples Archivos.

Por ejemplo, podrías tener un archivo config.py con:

from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    app_name: str = "Awesome API"
    admin_email: str
    items_per_user: int = 50


settings = Settings()

Y luego usarlo en un archivo main.py:

from fastapi import FastAPI

from .config import settings

app = FastAPI()


@app.get("/info")
async def info():
    return {
        "app_name": settings.app_name,
        "admin_email": settings.admin_email,
        "items_per_user": settings.items_per_user,
    }

Consejo

También necesitarías un archivo __init__.py como viste en Aplicaciones Más Grandes - Múltiples Archivos.

Configuraciones en una dependencia

En algunas ocasiones podría ser útil proporcionar las configuraciones desde una dependencia, en lugar de tener un objeto global con settings que se use en todas partes.

Esto podría ser especialmente útil durante las pruebas, ya que es muy fácil sobrescribir una dependencia con tus propias configuraciones personalizadas.

El archivo de configuración

Proveniente del ejemplo anterior, tu archivo config.py podría verse como:

from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    app_name: str = "Awesome API"
    admin_email: str
    items_per_user: int = 50

Nota que ahora no creamos una instance por defecto settings = Settings().

El archivo principal de la app

Ahora creamos una dependencia que devuelve un nuevo config.Settings().

from functools import lru_cache
from typing import Annotated

from fastapi import Depends, FastAPI

from .config import Settings

app = FastAPI()


@lru_cache
def get_settings():
    return Settings()


@app.get("/info")
async def info(settings: Annotated[Settings, Depends(get_settings)]):
    return {
        "app_name": settings.app_name,
        "admin_email": settings.admin_email,
        "items_per_user": settings.items_per_user,
    }

Consejo

Hablaremos del @lru_cache en un momento.

Por ahora puedes asumir que get_settings() es una función normal.

Y luego podemos requerirlo desde la path operation function como una dependencia y usarlo donde lo necesitemos.

from functools import lru_cache
from typing import Annotated

from fastapi import Depends, FastAPI

from .config import Settings

app = FastAPI()


@lru_cache
def get_settings():
    return Settings()


@app.get("/info")
async def info(settings: Annotated[Settings, Depends(get_settings)]):
    return {
        "app_name": settings.app_name,
        "admin_email": settings.admin_email,
        "items_per_user": settings.items_per_user,
    }

Configuraciones y pruebas

Luego sería muy fácil proporcionar un objeto de configuraciones diferente durante las pruebas al sobrescribir una dependencia para get_settings:

from fastapi.testclient import TestClient

from .config import Settings
from .main import app, get_settings

client = TestClient(app)


def get_settings_override():
    return Settings(admin_email="testing_admin@example.com")


app.dependency_overrides[get_settings] = get_settings_override


def test_app():
    response = client.get("/info")
    data = response.json()
    assert data == {
        "app_name": "Awesome API",
        "admin_email": "testing_admin@example.com",
        "items_per_user": 50,
    }

En la dependencia sobreescrita establecemos un nuevo valor para el admin_email al crear el nuevo objeto Settings, y luego devolvemos ese nuevo objeto.

Luego podemos probar que se está usando.

Leer un archivo .env

Si tienes muchas configuraciones que posiblemente cambien mucho, tal vez en diferentes entornos, podría ser útil ponerlos en un archivo y luego leerlos desde allí como si fueran variables de entorno.

Esta práctica es lo suficientemente común que tiene un nombre, estas variables de entorno generalmente se colocan en un archivo .env, y el archivo se llama un "dotenv".

Consejo

Un archivo que comienza con un punto (.) es un archivo oculto en sistemas tipo Unix, como Linux y macOS.

Pero un archivo dotenv realmente no tiene que tener ese nombre exacto.

Pydantic tiene soporte para leer desde estos tipos de archivos usando un paquete externo. Puedes leer más en Pydantic Settings: Dotenv (.env) support.

Consejo

Para que esto funcione, necesitas pip install python-dotenv.

El archivo .env

Podrías tener un archivo .env con:

ADMIN_EMAIL="deadpool@example.com"
APP_NAME="ChimichangApp"

Leer configuraciones desde .env

Y luego actualizar tu config.py con:

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    app_name: str = "Awesome API"
    admin_email: str
    items_per_user: int = 50

    model_config = SettingsConfigDict(env_file=".env")

Consejo

El atributo model_config se usa solo para configuración de Pydantic. Puedes leer más en Pydantic: Concepts: Configuration.

from pydantic import BaseSettings


class Settings(BaseSettings):
    app_name: str = "Awesome API"
    admin_email: str
    items_per_user: int = 50

    class Config:
        env_file = ".env"

Consejo

La clase Config se usa solo para configuración de Pydantic. Puedes leer más en Pydantic Model Config.

Información

En la versión 1 de Pydantic la configuración se hacía en una clase interna Config, en la versión 2 de Pydantic se hace en un atributo model_config. Este atributo toma un dict, y para obtener autocompletado y errores en línea, puedes importar y usar SettingsConfigDict para definir ese dict.

Aquí definimos la configuración env_file dentro de tu clase Pydantic Settings, y establecemos el valor en el nombre del archivo con el archivo dotenv que queremos usar.

Creando el Settings solo una vez con lru_cache

Leer un archivo desde el disco es normalmente una operación costosa (lenta), por lo que probablemente quieras hacerlo solo una vez y luego reutilizar el mismo objeto de configuraciones, en lugar de leerlo para cada request.

Pero cada vez que hacemos:

Settings()

se crearía un nuevo objeto Settings, y al crearse leería el archivo .env nuevamente.

Si la función de dependencia fuera simplemente así:

def get_settings():
    return Settings()

crearíamos ese objeto para cada request, y estaríamos leyendo el archivo .env para cada request. ⚠️

Pero como estamos usando el decorador @lru_cache encima, el objeto Settings se creará solo una vez, la primera vez que se llame. ✔️

from functools import lru_cache

from fastapi import Depends, FastAPI
from typing_extensions import Annotated

from . import config

app = FastAPI()


@lru_cache
def get_settings():
    return config.Settings()


@app.get("/info")
async def info(settings: Annotated[config.Settings, Depends(get_settings)]):
    return {
        "app_name": settings.app_name,
        "admin_email": settings.admin_email,
        "items_per_user": settings.items_per_user,
    }

Entonces, para cualquier llamada subsiguiente de get_settings() en las dependencias de los próximos requests, en lugar de ejecutar el código interno de get_settings() y crear un nuevo objeto Settings, devolverá el mismo objeto que fue devuelto en la primera llamada, una y otra vez.

Detalles Técnicos de lru_cache

@lru_cache modifica la función que decora para devolver el mismo valor que se devolvió la primera vez, en lugar de calcularlo nuevamente, ejecutando el código de la función cada vez.

Así que la función debajo se ejecutará una vez por cada combinación de argumentos. Y luego, los valores devueltos por cada una de esas combinaciones de argumentos se utilizarán una y otra vez cada vez que la función sea llamada con exactamente la misma combinación de argumentos.

Por ejemplo, si tienes una función:

@lru_cache
def say_hi(name: str, salutation: str = "Ms."):
    return f"Hello {salutation} {name}"

tu programa podría ejecutarse así:

sequenceDiagram

participant code as Código
participant function as say_hi()
participant execute as Ejecutar función

    rect rgba(0, 255, 0, .1)
        code ->> function: say_hi(name="Camila")
        function ->> execute: ejecutar código de la función
        execute ->> code: devolver el resultado
    end

    rect rgba(0, 255, 255, .1)
        code ->> function: say_hi(name="Camila")
        function ->> code: devolver resultado almacenado
    end

    rect rgba(0, 255, 0, .1)
        code ->> function: say_hi(name="Rick")
        function ->> execute: ejecutar código de la función
        execute ->> code: devolver el resultado
    end

    rect rgba(0, 255, 0, .1)
        code ->> function: say_hi(name="Rick", salutation="Mr.")
        function ->> execute: ejecutar código de la función
        execute ->> code: devolver el resultado
    end

    rect rgba(0, 255, 255, .1)
        code ->> function: say_hi(name="Rick")
        function ->> code: devolver resultado almacenado
    end

    rect rgba(0, 255, 255, .1)
        code ->> function: say_hi(name="Camila")
        function ->> code: devolver resultado almacenado
    end

En el caso de nuestra dependencia get_settings(), la función ni siquiera toma argumentos, por lo que siempre devolverá el mismo valor.

De esa manera, se comporta casi como si fuera solo una variable global. Pero como usa una función de dependencia, entonces podemos sobrescribirla fácilmente para las pruebas.

@lru_cache es parte de functools, que es parte del library estándar de Python, puedes leer más sobre él en las docs de Python para @lru_cache.

Resumen

Puedes usar Pydantic Settings para manejar las configuraciones o ajustes de tu aplicación, con todo el poder de los modelos de Pydantic.

  • Al usar una dependencia, puedes simplificar las pruebas.
  • Puedes usar archivos .env con él.
  • Usar @lru_cache te permite evitar leer el archivo dotenv una y otra vez para cada request, mientras te permite sobrescribirlo durante las pruebas.