Saltar al contenido

Settings y Variables de Entorno

En muchos casos tu aplicación podría necesitar algunas configuraciones o parámetros externos, por ejemplo claves secretas, credenciales de base de datos, credenciales para servicios de email, etc.

La mayoría de estas configuraciones son variables (pueden cambiar), como las URLs de base 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, y 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.

Settings de Pydantic

Afortunadamente, Pydantic proporciona una gran utilidad para manejar estas configuraciones que vienen de variables de entorno con Pydantic: Gestión de Settings.

Instalar pydantic-settings

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

fast →pip install pydantic-settings
restart ↻

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

fast →pip install "fastapi[all]"
restart ↻

Crear el objeto Settings

Importa BaseSettings de Pydantic y crea una sub-clase, muy parecido a como lo harías con un modelo de Pydantic.

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

Puedes usar todas las mismas funcionalidades y herramientas de validación 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,
    }

Consejo

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

Luego, cuando creas una instancia de esa clase Settings (en este caso, en el objeto settings), Pydantic leerá las variables de entorno de manera insensible a mayúsculas y minúsculas, así, una variable en mayúsculas APP_NAME seguirá siendo leída para el atributo app_name.

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

Usar los 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:

fast →ADMIN_EMAIL="deadpool@example.com" APP_NAME="ChimichangApp" fastapi run main.py
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

restart ↻

Consejo

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

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

El app_name sería "ChimichangApp".

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

Settings 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.

Settings 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 usa 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

Continuando con el ejemplo anterior, tu archivo config.py podría verse así:

from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    app_name: str = "Awesome API"
    admin_email: str
    items_per_user: int = 50
🤓 Otras versiones y variantes

Consejo

Preferible usar la versión con Annotated si es posible.

from pydantic_settings import BaseSettings


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

Ten en cuenta que ahora no creamos una instancia por defecto settings = Settings().

El archivo principal de la aplicación

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,
    }
🤓 Otras versiones y variantes

Consejo

Preferible usar la versión con Annotated si es posible.

from functools import lru_cache

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: Settings = Depends(get_settings)):
    return {
        "app_name": settings.app_name,
        "admin_email": settings.admin_email,
        "items_per_user": settings.items_per_user,
    }

Consejo

Hablaremos sobre @lru_cache en un momento.

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

Y luego podemos requerirlo desde la función path operation 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,
    }
🤓 Otras versiones y variantes

Consejo

Preferible usar la versión con Annotated si es posible.

from functools import lru_cache

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: Settings = Depends(get_settings)):
    return {
        "app_name": settings.app_name,
        "admin_email": settings.admin_email,
        "items_per_user": settings.items_per_user,
    }

Settings y testing

Luego sería muy fácil proporcionar un objeto de configuración diferente durante las pruebas creando una sobrescritura de 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,
    }
🤓 Otras versiones y variantes

Consejo

Preferible usar la versión con Annotated si es posible.

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 sobrescritura de dependencia establecemos un nuevo valor para admin_email al crear el nuevo objeto Settings, y luego devolvemos ese nuevo objeto.

Luego podemos probar que se usa.

Leer un archivo .env

Si tienes muchas configuraciones que posiblemente cambian mucho, quizás en diferentes entornos, podría ser útil ponerlas en un archivo y luego leerlas desde él como si fueran variables de entorno.

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

Consejo

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

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

Pydantic tiene soporte para leer de estos tipos de archivos usando una librería externa. Puedes leer más en Pydantic Settings: Soporte para Dotenv (.env).

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 settings desde .env

Y luego actualiza 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")
🤓 Otras versiones y variantes

Consejo

Preferible usar la versión con Annotated si es posible.

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 la configuración de Pydantic. Puedes leer más en Pydantic: Conceptos: Configuración.

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

Crear los Settings solo una vez con lru_cache

Leer un archivo del disco es normalmente una operación costosa (lenta), así que probablemente quieras hacerlo solo una vez y luego reutilizar el mismo objeto de configuración, en lugar de leerlo para cada petición.

Pero cada vez que hacemos:

Settings()

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

Si la función de dependencia fuera simplemente:

def get_settings():
    return Settings()

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

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 typing import Annotated

from fastapi import Depends, FastAPI

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,
    }
🤓 Otras versiones y variantes

Consejo

Preferible usar la versión con Annotated si es posible.

from functools import lru_cache

from fastapi import Depends, FastAPI

from . import config

app = FastAPI()


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


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

Luego para cualquier llamada subsiguiente de get_settings() en las dependencias para las siguientes peticiones, 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 fue devuelto la primera vez, en lugar de calcularlo de nuevo, ejecutando el código de la función cada vez.

Así, 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 usarán una y otra vez siempre que se llame a la función 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í:

En el caso de nuestra dependencia get_settings(), la función ni siquiera toma ningún argumento, así que siempre devuelve el mismo valor.

De esa manera, se comporta casi como si fuera 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 de la librería estándar de Python, puedes leer más sobre esto en la documentación de Python para @lru_cache.

Resumen

Puedes usar Pydantic Settings para manejar las configuraciones o parámetros 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 petición, mientras te permite sobrescribirlo durante las pruebas.