Capítulo 23, Intermediário
Configuração e variáveis de ambiente
O código é o mesmo em todos os ambientes. O que muda (o banco, a chave, as origens) vem de fora dele. Isso mantém os segredos fora do Git e permite rodar a mesma aplicação em desenvolvimento, teste e produção.
Por que fora do código
Um segredo escrito no código vai para o repositório, e um repositório vaza (um colaborador novo, um fork, um backup). E configurações como a URL do banco mudam de um ambiente para outro: em vez de editar o código para cada um, o ambiente fornece os valores. É a regra da aplicação de "12 fatores": configuração no ambiente.
Os valores moram em variáveis de ambiente, e em desenvolvimento costumam ser lidos de um arquivo .env, que nunca vai para o Git:
APP_CHAVE_SECRETA=troque-por-uma-chave-aleatoria-com-32-bytes-ou-mais
APP_BANCO_URL=sqlite:///./app.db
APP_EXPIRA_EM_MINUTOS=30
APP_ORIGENS_PERMITIDAS=["http://localhost:5173"]
.env
É boa prática versionar um .env.example com os nomes das variáveis e valores de mentira, para quem chega saber o que configurar.
`pydantic-settings`: configuração tipada
Ler os.getenv("X") devolve sempre texto (ou None), sem validação. O pydantic-settings lê as variáveis, converte e valida os tipos, e recusa iniciar se faltar algo obrigatório, o que é muito melhor do que descobrir em produção, no meio de uma requisição:
uv add pydantic-settings
import os
from functools import lru_cache
from pathlib import Path
from pydantic import Field, SecretStr, ValidationError
from pydantic_settings import BaseSettings, SettingsConfigDict
class Configuracoes(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_prefix="APP_", extra="ignore")
chave_secreta: SecretStr
algoritmo: str = "HS256"
expira_em_minutos: int = Field(default=30, gt=0)
banco_url: str = "sqlite:///./app.db"
origens_permitidas: list[str] = ["http://localhost:5173"]
try:
Configuracoes(_env_file=None)
except ValidationError as erro:
print(erro.errors()[0]["type"], erro.errors()[0]["loc"])
missing ('chave_secreta',)
Sem a variável APP_CHAVE_SECRETA (que não tem padrão), a aplicação nem constrói as configurações. O env_prefix evita colisões com outras variáveis do sistema.
Os tipos são convertidos
As variáveis de ambiente são sempre texto. O Pydantic converte: "45" vira int, e valores complexos (listas) são lidos como JSON. E o SecretStr esconde o valor ao imprimir, o que evita vazar a chave em um log:
os.environ["APP_CHAVE_SECRETA"] = "uma-chave-bem-comprida-com-mais-de-32-bytes!"
os.environ["APP_EXPIRA_EM_MINUTOS"] = "45"
os.environ["APP_ORIGENS_PERMITIDAS"] = '["https://app.exemplo.com", "http://localhost:5173"]'
config = Configuracoes(_env_file=None)
print(config.expira_em_minutos, type(config.expira_em_minutos).__name__)
print(config.origens_permitidas)
print(config.chave_secreta)
print(len(config.chave_secreta.get_secret_value()))
45 int
['https://app.exemplo.com', 'http://localhost:5173']
**********
44
Para obter o valor de verdade, você pede explicitamente (get_secret_value()), e isso deixa visível, no código, cada lugar onde o segredo é usado.
Ordem de prioridade e o arquivo `.env`
Quando o mesmo valor aparece em vários lugares, o vencedor é, do mais forte ao mais fraco: argumentos passados ao construtor, variáveis de ambiente, arquivo .env, padrão do modelo. Por isso um valor definido no ambiente de produção prevalece sobre o .env:
Path("exemplo.env").write_text(
"APP_CHAVE_SECRETA=chave-do-arquivo-com-mais-de-32-bytes-0123456\n"
"APP_EXPIRA_EM_MINUTOS=10\n"
"APP_BANCO_URL=sqlite:///./do-arquivo.db\n",
encoding="utf-8",
)
combinada = Configuracoes(_env_file="exemplo.env")
print(combinada.expira_em_minutos, combinada.banco_url)
print(Configuracoes(_env_file="exemplo.env", expira_em_minutos=5).expira_em_minutos)
45 sqlite:///./do-arquivo.db
5
O expira_em_minutos veio do ambiente (45), e não do arquivo (10), porque o ambiente vence. O banco_url, que não está no ambiente, veio do arquivo. E o argumento direto (5) vence tudo.
Entregar à aplicação como uma dependência
Criar as configurações é um trabalho que não precisa se repetir a cada requisição. O lru_cache guarda a instância, e uma função de dependência a entrega às rotas. A vantagem aparece nos testes: você pode trocar a dependência por outra configuração, sem mexer em variáveis de ambiente:
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
@lru_cache
def obter_configuracoes() -> Configuracoes:
return Configuracoes(_env_file=None)
app = FastAPI()
@app.get("/info")
def info(cfg: Annotated[Configuracoes, Depends(obter_configuracoes)]):
return {"algoritmo": cfg.algoritmo, "expira_em_minutos": cfg.expira_em_minutos}
cliente = TestClient(app)
print(cliente.get("/info").json())
app.dependency_overrides[obter_configuracoes] = lambda: Configuracoes(
_env_file=None, chave_secreta="chave-de-teste-bem-comprida-0123456789", expira_em_minutos=1
)
print(cliente.get("/info").json())
app.dependency_overrides.clear()
{'algoritmo': 'HS256', 'expira_em_minutos': 45}
{'algoritmo': 'HS256', 'expira_em_minutos': 1}
| Onde fica | O que vai lá |
|---|---|
| Código | O que não muda entre ambientes (nomes de campos, regras) |
.env (fora do Git) | Os valores de desenvolvimento |
| Variáveis do ambiente de produção | Os segredos reais, definidos na plataforma de deploy |
.env.example (no Git) | Os nomes das variáveis, sem segredos |
Exercício 1
Um ambiente restrito a valores conhecidos
Crie ConfigAmbiente (com env_prefix="AMB_") com ambiente: Literal["dev", "prod"] e debug: bool = False. Confira que AMB_AMBIENTE=prod e AMB_DEBUG=true funcionam, e que AMB_AMBIENTE=staging é recusado.