Ir para o conteúdo

Configurações e Variáveis de Ambiente

Em muitos casos a sua aplicação pode precisar de configurações externas, como chaves secretas, credenciais de banco de dados, credenciais para serviços de email, etc.

A maioria dessas configurações é variável (podem mudar), como URLs de bancos de dados. E muitas delas podem conter dados sensíveis, como tokens secretos.

Por isso é comum prover essas configurações como variáveis de ambiente que são utilizidas pela aplicação.

Variáveis de Ambiente

Dica

Se você já sabe o que são variáveis de ambiente e como utilizá-las, sinta-se livre para avançar para o próximo tópico.

Uma variável de ambiente (abreviada em inglês para "env var") é uma variável definida fora do código Python, no sistema operacional, e pode ser lida pelo seu código Python (ou por outros programas).

Você pode criar e utilizar variáveis de ambiente no terminal, sem precisar utilizar Python:

// Você pode criar uma env var MY_NAME usando
$ export MY_NAME="Wade Wilson"

// E utilizá-la em outros programas, como
$ echo "Hello $MY_NAME"

Hello Wade Wilson
// Criando env var MY_NAME
$ $Env:MY_NAME = "Wade Wilson"

// Usando em outros programas, como
$ echo "Hello $Env:MY_NAME"

Hello Wade Wilson

Lendo variáveis de ambiente com Python

Você também pode criar variáveis de ambiente fora do Python, no terminal (ou com qualquer outro método), e realizar a leitura delas no Python.

Por exemplo, você pode definir um arquivo main.py com o seguinte código:

import os

name = os.getenv("MY_NAME", "World")
print(f"Hello {name} from Python")

Dica

O segundo parâmetro em os.getenv() é o valor padrão para o retorno.

Se nenhum valor for informado, None é utilizado por padrão, aqui definimos "World" como o valor padrão a ser utilizado.

E depois você pode executar esse arquivo:

// Aqui ainda não definimos a env var
$ python main.py

// Por isso obtemos o valor padrão

Hello World from Python

// Mas se definirmos uma variável de ambiente primeiro
$ export MY_NAME="Wade Wilson"

// E executarmos o programa novamente
$ python main.py

// Agora ele pode ler a variável de ambiente

Hello Wade Wilson from Python

Como variáveis de ambiente podem ser definidas fora do código da aplicação, mas acessadas pela aplicação, e não precisam ser armazenadas (versionadas com git) junto dos outros arquivos, é comum utilizá-las para guardar configurações.

Você também pode criar uma variável de ambiente específica para uma invocação de um programa, que é acessível somente para esse programa, e somente enquanto ele estiver executando.

Para fazer isso, crie a variável imediatamente antes de iniciar o programa, na mesma linha:

// Criando uma env var MY_NAME na mesma linha da execução do programa
$ MY_NAME="Wade Wilson" python main.py

// Agora a aplicação consegue ler a variável de ambiente

Hello Wade Wilson from Python

// E a variável deixa de existir após isso
$ python main.py

Hello World from Python

Dica

Você pode ler mais sobre isso em: The Twelve-Factor App: Configurações.

Tipagem e Validação

Essas variáveis de ambiente suportam apenas strings, por serem externas ao Python e por que precisam ser compatíveis com outros programas e o resto do sistema (e até mesmo com outros sistemas operacionais, como Linux, Windows e macOS).

Isso significa que qualquer valor obtido de uma variável de ambiente em Python terá o tipo str, e qualquer conversão para um tipo diferente ou validação deve ser realizada no código.

Pydantic Settings

Por sorte, o Pydantic possui uma funcionalidade para lidar com essas configurações vindas de variáveis de ambiente utilizando Pydantic: Settings management.

Instalando pydantic-settings

Primeiro, instale o pacote pydantic-settings:

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

Ele também está incluído no fastapi quando você instala com a opção all:

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

Info

Na v1 do Pydantic ele estava incluído no pacote principal. Agora ele está distribuido como um pacote independente para que você possa optar por instalar ou não caso você não precise dessa funcionalidade.

Criando o objeto Settings

Importe a classe BaseSettings do Pydantic e crie uma nova subclasse, de forma parecida com um modelo do Pydantic.

Os atributos da classe são declarados com anotações de tipo, e possíveis valores padrão, da mesma maneira que os modelos do Pydantic.

Você pode utilizar todas as ferramentas e funcionalidades de validação que são utilizadas nos modelos do Pydantic, como tipos de dados diferentes e validações adicionei com 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,
    }

Info

Na versão 1 do Pydantic você importaria BaseSettings diretamente do módulo pydantic em vez do módulo 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,
    }

Dica

Se você quiser algo pronto para copiar e colar na sua aplicação, não use esse exemplo, mas sim o exemplo abaixo.

Portanto, quando você cria uma instância da classe Settings (nesse caso, o objeto settings), o Pydantic lê as variáveis de ambiente sem diferenciar maiúsculas e minúsculas, por isso, uma variável maiúscula APP_NAME será usada para o atributo app_name.

Depois ele irá converter e validar os dados. Assim, quando você utilizar aquele objeto settings, os dados terão o tipo que você declarou (e.g. items_per_user será do tipo int).

Usando o objeto settings

Depois, Você pode utilizar o novo objeto settings na sua aplicação:

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,
    }

Executando o servidor

No próximo passo, você pode inicializar o servidor passando as configurações em forma de variáveis de ambiente, por exemplo, você poderia definir ADMIN_EMAIL e APP_NAME da seguinte forma:

$ 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)

Dica

Para definir múltiplas variáveis de ambiente para um único comando basta separá-las utilizando espaços, e incluir todas elas antes do comando.

Assim, o atributo admin_email seria definido como "deadpool@example.com".

app_name seria "ChimichangApp".

E items_per_user manteria o valor padrão de 50.

Configurações em um módulo separado

Você também pode incluir essas configurações em um arquivo de um módulo separado como visto em Bigger Applications - Multiple Files.

Por exemplo, você pode adicionar um arquivo config.py com:

from pydantic_settings import BaseSettings


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


settings = Settings()

E utilizar essa configuração em 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,
    }

Dica

Você também precisa incluir um arquivo __init__.py como visto em Bigger Applications - Multiple Files.

Configurações em uma dependência

Em certas ocasiões, pode ser útil fornecer essas configurações a partir de uma dependência, em vez de definir um objeto global settings que é utilizado em toda a aplicação.

Isso é especialmente útil durante os testes, já que é bastante simples sobrescrever uma dependência com suas configurações personalizadas.

O arquivo de configuração

Baseando-se no exemplo anterior, seu arquivo config.py seria parecido com isso:

from pydantic_settings import BaseSettings


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

Perceba que dessa vez não criamos uma instância padrão settings = Settings().

O arquivo principal da aplicação

Agora criamos a dependência que retorna um novo objeto 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,
    }
from functools import lru_cache

from fastapi import Depends, FastAPI
from typing_extensions import Annotated

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,
    }

Dica

Utilize a versão com Annotated se possível.

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,
    }

Dica

Vamos discutir sobre @lru_cache logo mais.

Por enquanto, você pode considerar get_settings() como uma função normal.

E então podemos declarar essas configurações como uma dependência na função de operação da rota e utilizar onde for necessário.

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,
    }
from functools import lru_cache

from fastapi import Depends, FastAPI
from typing_extensions import Annotated

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,
    }

Dica

Utilize a versão com Annotated se possível.

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,
    }

Configurações e testes

Então seria muito fácil fornecer uma configuração diferente durante a execução dos testes sobrescrevendo a dependência de 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,
    }

Na sobrescrita da dependência, definimos um novo valor para admin_email quando instanciamos um novo objeto Settings, e então retornamos esse novo objeto.

Após isso, podemos testar se o valor está sendo utilizado.

Lendo um arquivo .env

Se você tiver muitas configurações que variem bastante, talvez em ambientes distintos, pode ser útil colocá-las em um arquivo e depois lê-las como se fossem variáveis de ambiente.

Essa prática é tão comum que possui um nome, essas variáveis de ambiente normalmente são colocadas em um arquivo .env, e esse arquivo é chamado de "dotenv".

Dica

Um arquivo iniciando com um ponto final (.) é um arquivo oculto em sistemas baseados em Unix, como Linux e MacOS.

Mas um arquivo dotenv não precisa ter esse nome exato.

Pydantic suporta a leitura desses tipos de arquivos utilizando uma biblioteca externa. Você pode ler mais em Pydantic Settings: Dotenv (.env) support.

Dica

Para que isso funcione você precisa executar pip install python-dotenv.

O arquivo .env

Você pode definir um arquivo .env com o seguinte conteúdo:

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

Obtendo configurações do .env

E então adicionar o seguinte código em config.py:

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")

Dica

O atributo model_config é usado apenas para configuração do Pydantic. Você pode ler mais em Pydantic Model Config.

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"

Dica

A classe Config é usada apenas para configuração do Pydantic. Você pode ler mais em Pydantic Model Config.

Info

Na versão 1 do Pydantic a configuração é realizada por uma classe interna Config, na versão 2 do Pydantic isso é feito com o atributo model_config. Esse atributo recebe um dict, para utilizar o autocomplete e checagem de erros do seu editor de texto você pode importar e utilizar SettingsConfigDict para definir esse dict.

Aqui definimos a configuração env_file dentro da classe Settings do Pydantic, e definimos o valor como o nome do arquivo dotenv que queremos utilizar.

Declarando Settings apenas uma vez com lru_cache

Ler o conteúdo de um arquivo em disco normalmente é uma operação custosa (lenta), então você provavelmente quer fazer isso apenas um vez e reutilizar o mesmo objeto settings depois, em vez de ler os valores a cada requisição.

Mas cada vez que fazemos:

Settings()

um novo objeto Settings é instanciado, e durante a instanciação, o arquivo .env é lido novamente.

Se a função da dependência fosse apenas:

def get_settings():
    return Settings()

Iriamos criar um novo objeto a cada requisição, e estaríamos lendo o arquivo .env a cada requisição. ⚠️

Mas como estamos utilizando o decorador @lru_cache acima, o objeto Settings é criado apenas uma vez, na primeira vez que a função é chamada. ✔️

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,
    }
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,
    }

Dica

Utilize a versão com Annotated se possível.

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,
    }

Dessa forma, todas as chamadas da função get_settings() nas dependências das próximas requisições, em vez de executar o código interno de get_settings() e instanciar um novo objeto Settings, irão retornar o mesmo objeto que foi retornado na primeira chamada, de novo e de novo.

Detalhes Técnicos de lru_cache

@lru_cache modifica a função decorada para retornar o mesmo valor que foi retornado na primeira vez, em vez de calculá-lo novamente, executando o código da função toda vez.

Assim, a função abaixo do decorador é executada uma única vez para cada combinação dos argumentos passados. E os valores retornados para cada combinação de argumentos são sempre reutilizados para cada nova chamada da função com a mesma combinação de argumentos.

Por exemplo, se você definir uma função:

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

Seu programa poderia executar dessa forma:

sequenceDiagram

participant code as Código
participant function as say_hi()
participant execute as Executar Função

    rect rgba(0, 255, 0, .1)
        code ->> function: say_hi(name="Camila")
        function ->> execute: executar código da função
        execute ->> code: retornar o resultado
    end

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

    rect rgba(0, 255, 0, .1)
        code ->> function: say_hi(name="Rick")
        function ->> execute: executar código da função
        execute ->> code: retornar o resultado
    end

    rect rgba(0, 255, 0, .1)
        code ->> function: say_hi(name="Rick", salutation="Mr.")
        function ->> execute: executar código da função
        execute ->> code: retornar o resultado
    end

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

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

No caso da nossa dependência get_settings(), a função não recebe nenhum argumento, então ela sempre retorna o mesmo valor.

Dessa forma, ela se comporta praticamente como uma variável global, mas ao ser utilizada como uma função de uma dependência, pode facilmente ser sobrescrita durante os testes.

@lru_cache é definido no módulo functools que faz parte da biblioteca padrão do Python, você pode ler mais sobre esse decorador no link Python Docs sobre @lru_cache.

Recapitulando

Você pode usar o módulo Pydantic Settings para gerenciar as configurações de sua aplicação, utilizando todo o poder dos modelos Pydantic.

  • Utilizar dependências simplifica os testes.
  • Você pode utilizar arquivos .env junto das configurações do Pydantic.
  • Utilizar o decorador @lru_cache evita que o arquivo .env seja lido de novo e de novo para cada requisição, enquanto permite que você sobrescreva durante os testes.