Trilha de aprendizado · Nível 15 · Tutorial 4

Carregar configurações externas com precedência explícita

Resolver a configuração da aplicação a partir de padrões, arquivo TOML, variáveis de ambiente e opções da CLI, com regras claras para conflitos e valores inválidos.

  • Nível: Intermediário
  • Duração: 20 min
  • 8 passos
Carregar configurações externas com precedência explícita

O que você vai percorrer

  1. Definir o contrato e a ordem das fontes Defina quais valores a aplicação aceita e qual fonte vence quando mais de uma fornece a mesma chave. 2 min
  2. Ler a estrutura de um arquivo TOML Leia um arquivo TOML com segurança estrutural e extraia a contribuição da tabela da aplicação. 2 min
  3. Estabelecer a política de arquivos e caminhos Defina regras previsíveis para localizar o arquivo de configuração e interpretar caminhos relativos conforme sua fonte. 2 min
  4. Normalizar variáveis de ambiente Converta variáveis de ambiente presentes em uma contribuição parcial com chaves internas e tipos corretos. 2 min
  5. Preservar opções omitidas na CLI Configure o parser para que a CLI contribua somente com valores realmente informados, sem perder 0 nem False. 2 min
  6. Combinar as fontes sem perder suas regras Organize o carregamento em etapas, normalize cada fonte antes da sobreposição e preserve erros de fontes presentes. 3 min
  7. Validar antes de montar a aplicação Valide o resultado consolidado antes de criar componentes da aplicação, garantindo que apenas uma configuração tipada e válida chegue à raiz de composição. 2 min
  8. Aplicar e verificar o contrato completo Monte um exemplo autocontido, execute testes locais e confirme o contrato de precedência, caminhos e falhas de configuração. 4 min

O que você vai aprender

  • Ler um arquivo de configuração TOML e consultar variáveis de ambiente.
  • Combinar fontes de configuração segundo uma ordem de precedência documentada.
  • Distinguir uma opção omitida de um valor explicitamente informado.
  • Produzir uma configuração validada antes de montar os componentes da aplicação.

Antes de começar

  • Criar comandos e opções com argparse
  • Ler e gravar arquivos binários
  • Converter dados externos em valores validados e tipados

Passo 1 de 8

Definir o contrato e a ordem das fontes

Defina quais valores a aplicação aceita e qual fonte vence quando mais de uma fornece a mesma chave.

O contrato da configuração

Três chaves, um significado claro

Neste exemplo, a camada de configuração entrega três valores para a aplicação:

  • limite: inteiro não negativo. O valor 0 significa sem limite.
  • detalhado: booleano que ativa ou desativa uma saída mais detalhada.
  • arquivo_dados: caminho do arquivo de dados usado pela aplicação.

Antes de decidir de onde vem cada valor, defina esse contrato. Todas as fontes devem contribuir apenas com essas mesmas chaves, nos tipos esperados. A conversão e a validação serão tratadas nas próximas etapas.

Exemplo

Padrões internos centralizados

A camada de configuração começa com um único conjunto de padrões da aplicação:

PADROES = {
    "limite": 100,
    "detalhado": False,
    "arquivo_dados": "dados.csv",
}

Esses padrões não pertencem ao arquivo, ao ambiente nem ao parser da CLI. Eles existem uma vez, na configuração, para que cada fonte externa possa informar somente o que deseja alterar.

Uma ordem explícita de prioridade

Da menor para a maior prioridade

Quando uma mesma chave aparece em mais de uma fonte, a fonte de maior prioridade vence. A ordem adotada é:

  1. padrões internos;
  2. arquivo TOML;
  3. variáveis de ambiente;
  4. opções explicitamente informadas na CLI.

A CLI fica no topo porque representa uma decisão específica daquela execução. Essa ordem deve ser documentada como parte do contrato da aplicação.

Sobreposição por chave

Leia o diagrama de baixo para cima: cada camada só altera as chaves que ela fornece.

Diagrama em quatro camadas de fontes de configuração. Os padrões fornecem limite 100, detalhado falso e dados.csv. O TOML altera limite para 50. O ambiente altera detalhado para verdadeiro. A CLI altera limite para zero. O resultado final é limite zero, detalhado verdadeiro e dados.csv.

A prioridade sobe dos padrões para a CLI; chaves ausentes continuam com o valor já resolvido.

Precedência não substitui tudo

Cada chave tem sua própria origem vencedora

Considere contribuições já normalizadas para o formato interno:

  • padrões: limite=100, detalhado=False, arquivo_dados="dados.csv"
  • TOML: limite=50
  • ambiente: detalhado=True
  • CLI: limite=0

O resultado é limite=0, detalhado=True e arquivo_dados="dados.csv".

A CLI não apaga detalhado nem arquivo_dados, porque ela forneceu somente limite. Em especial, 0 é um valor informado e válido no contrato: não significa ausência.

Dica

Regra de leitura

Pergunte chave por chave: “qual foi a fonte de maior prioridade que forneceu este valor?”. Não pergunte qual fonte venceu a configuração inteira.

Verifique a ordem e a origem

Ordene da menor para a maior prioridade

Organize as fontes na ordem em que seus valores devem ser sobrepostos.

  1. Arquivo TOML
  2. Padrões internos
  3. Variáveis de ambiente
  4. Opções explicitamente informadas na CLI

Qual é a configuração resolvida?

Com padrões {"limite": 100, "detalhado": False, "arquivo_dados": "dados.csv"}, TOML {"arquivo_dados": "local.csv"}, ambiente {"detalhado": True} e CLI {"limite": 0}, qual resultado respeita o contrato?

Passo 2 de 8

Ler a estrutura de um arquivo TOML

Leia um arquivo TOML com segurança estrutural e extraia a contribuição da tabela da aplicação.

Do arquivo TOML ao dicionário

TOML para configuração de uso

A partir do Python 3.11, use o módulo padrão tomllib para ler TOML. Ele é apropriado para a configuração de uso da aplicação — não confunda esse arquivo com o pyproject.toml, que terá outra finalidade.

Neste exemplo, a tabela [aplicacao] reúne as três chaves reconhecidas: limite, detalhado e arquivo_dados. TOML preserva tipos básicos: inteiros viram int, booleanos viram bool e textos viram str.

Estrutura produzida pela leitura

A tabela TOML se torna um dicionário aninhado; a chave aplicacao aponta para outro dicionário.

Diagrama mostrando uma seção de configuração com três valores conectada a um dicionário Python cuja chave aplicacao contém limite, detalhado e arquivo_dados.

[aplicacao] cria o nível aninhado dados["aplicacao"]; ainda não existe mesclagem entre fontes.

Exemplo

Exemplo de config.toml

[aplicacao]
limite = 50
detalhado = true
arquivo_dados = "dados/itens.csv"

Após a leitura, a estrutura relevante é equivalente a:

{
    "aplicacao": {
        "limite": 50,
        "detalhado": True,
        "arquivo_dados": "dados/itens.csv",
    }
}

Ler e extrair a contribuição

Contrato estrutural

A tabela [aplicacao] é opcional: se ela não aparecer, este arquivo contribui com {}. Se aparecer, precisa ser uma tabela e só pode conter as chaves reconhecidas.

Essa função apenas lê e verifica a estrutura. A conversão e a validação dos valores serão etapas posteriores.

Carregador da tabela [aplicacao]

O arquivo é aberto em modo binário ("rb"), exigência de tomllib.load.

python
from pathlib import Path
import tomllib

CHAVES_CONFIG = {"limite", "detalhado", "arquivo_dados"}


class EstruturaConfigInvalida(ValueError):
    pass


def carregar_toml(config_file: Path) -> dict[str, object]:
    with config_file.open("rb") as arquivo:
        dados = tomllib.load(arquivo)

    aplicacao = dados.get("aplicacao")
    if aplicacao is None:
        return {}

    if not isinstance(aplicacao, dict):
        raise EstruturaConfigInvalida(
            "A seção 'aplicacao' deve ser uma tabela TOML."
        )

    desconhecidas = set(aplicacao) - CHAVES_CONFIG
    if desconhecidas:
        raise EstruturaConfigInvalida(
            "A seção 'aplicacao' contém chaves não reconhecidas."
        )

    return dict(aplicacao)

Dica

Falhas diferentes, decisões diferentes

tomllib.TOMLDecodeError indica sintaxe TOML inválida, como um valor mal escrito. Já uma falha para abrir ou ler o arquivo é uma falha de acesso do sistema, e uma EstruturaConfigInvalida indica que o TOML foi lido, mas não segue o contrato da aplicação. Não esconda essas falhas retornando {}.

Reconheça cada camada

Associe a situação ao resultado

Relacione cada situação ao comportamento correto do carregador.

Toque em um item e depois no par correspondente.

Passo 3 de 8

Estabelecer a política de arquivos e caminhos

Defina regras previsíveis para localizar o arquivo de configuração e interpretar caminhos relativos conforme sua fonte.

Quando procurar e quando interromper

Uma convenção sem adivinhação

Quando nenhuma opção solicitar um arquivo específico, a aplicação procura config.toml no diretório de trabalho atual. Esse arquivo convencional é opcional: se não existir, sua contribuição é simplesmente {} e a resolução segue com as outras fontes.

Já um arquivo indicado explicitamente por --config é uma solicitação do usuário. Se ele não existir, o carregamento deve falhar; não é correto trocar silenciosamente esse pedido por padrões.

Exemplo

Decisões para ausência

Diretório de trabalho: /projetos/relatorios

  • Sem arquivo solicitado; /projetos/relatorios/config.toml não existe → continuar com {}.
  • Arquivo solicitado como --config perfis/producao.toml; o caminho não existe → falhar.
  • config.toml existe, mas não pode ser lido ou contém TOML inválido → falhar.

A tolerância vale somente para a ausência do arquivo convencional. Permissão negada, sintaxe inválida e estrutura inválida são erros de uma fonte que foi encontrada.

Atenção

Não esconda uma configuração quebrada

Não capture qualquer falha e retorne {}. Isso converteria um problema real — por exemplo, arquivo sem permissão ou TOML malformado — em uma execução com valores possivelmente inesperados.

A referência depende da fonte

Todo caminho relativo precisa de uma base

Não busque nem grave configuração de uso ao lado de __file__: o pacote instalado não é a referência do usuário.

No exemplo, caminhos relativos recebidos por padrões internos, ambiente e CLI usam o diretório de trabalho. Um caminho relativo passado a --config também parte dele. Em contraste, um caminho declarado dentro do TOML usa a pasta que contém aquele arquivo TOML. Caminhos absolutos são preservados.

Duas referências de caminho

Compare a base usada por um valor da CLI com a base usada por um valor escrito no TOML.

Diagrama de diretórios: o diretório de trabalho /projetos/app contém uma pasta perfis com config.toml; config.toml aponta para dados/entrada.csv dentro de perfis, enquanto uma opção de CLI aponta para saidas/resultado.csv a partir de /projetos/app.

Resolva o caminho vindo do TOML enquanto ainda conhece a pasta do próprio TOML; depois da mesclagem, essa origem pode não estar disponível.

Exemplo

Mesmo texto, resultados diferentes

Diretório de trabalho: /projetos/app

Arquivo selecionado: perfis/config.toml → /projetos/app/perfis/config.toml

No TOML: arquivo_dados = "dados/clientes.csv" → /projetos/app/perfis/dados/clientes.csv

Na CLI: --arquivo-dados dados/clientes.csv → /projetos/app/dados/clientes.csv

O texto é igual, mas a fonte define sua referência.

Confira a política

Arquivo convencional ausente

A aplicação foi iniciada em /trabalho, sem solicitar arquivo específico. O caminho /trabalho/config.toml não existe. Qual é a decisão correta?

Aplique a referência correta

Destino do caminho no TOML

O diretório de trabalho é /home/ana/app. O arquivo lido é /home/ana/config/perfil.toml e nele consta arquivo_dados = "dados/vendas.csv". Qual caminho deve resultar?

Passo 4 de 8

Normalizar variáveis de ambiente

Converta variáveis de ambiente presentes em uma contribuição parcial com chaves internas e tipos corretos.

Ambiente: texto externo, contribuição interna

Variáveis de ambiente são consultadas como um mapeamento, normalmente os.environ. Seus valores sempre chegam como strings: até "0", "false" e "42" são textos.

Mapeie somente as chaves previstas pelo contrato:

  • APP_LIMITE → limite (int)
  • APP_DETALHADO → detalhado (bool)
  • APP_ARQUIVO_DADOS → arquivo_dados (Path)

A contribuição do ambiente é parcial: se uma chave não existe no mapeamento, ela não entra no dicionário resultante.

Da variável ao valor interno

Diagrama mostrando três cartões de variáveis de ambiente com valores textuais sendo convertidos para um número inteiro, um interruptor booleano e um caminho; um quarto cartão ausente não produz valor de saída.

A presença da chave decide se ela contribui. A conversão decide o tipo do valor contribuído.

Dica

Ausente não é vazio

Use if "APP_LIMITE" in ambiente, e não um teste de verdade do valor. Uma chave presente com "" continua presente; ela deve passar pela normalização apropriada. Em especial, um caminho vazio deve ser rejeitado, pois Path("") poderia representar o diretório atual sem que essa fosse a intenção.

Função de normalização

Não use bool(texto) para interpretar a variável booleana: toda string não vazia é verdadeira em Python. Portanto, bool("false") resulta em True.

Normalize espaços e maiúsculas/minúsculas, depois aceite explicitamente apenas true e false. Caminhos recebidos do ambiente usam o diretório de trabalho como referência.

Normalizando um mapeamento de ambiente

python
import os
from collections.abc import Mapping
from pathlib import Path


def converter_booleano(texto: str) -> bool:
    normalizado = texto.strip().lower()
    if normalizado == "true":
        return True
    if normalizado == "false":
        return False
    raise ValueError("APP_DETALHADO deve ser true ou false")


def normalizar_ambiente(
    ambiente: Mapping[str, str],
    *,
    diretorio_trabalho: Path,
) -> dict[str, object]:
    contribuicao: dict[str, object] = {}

    if "APP_LIMITE" in ambiente:
        contribuicao["limite"] = int(ambiente["APP_LIMITE"])

    if "APP_DETALHADO" in ambiente:
        contribuicao["detalhado"] = converter_booleano(
            ambiente["APP_DETALHADO"]
        )

    if "APP_ARQUIVO_DADOS" in ambiente:
        texto_caminho = ambiente["APP_ARQUIVO_DADOS"].strip()
        if not texto_caminho:
            raise ValueError("APP_ARQUIVO_DADOS não pode ser vazio")

        caminho = Path(texto_caminho)
        contribuicao["arquivo_dados"] = (
            caminho if caminho.is_absolute() else diretorio_trabalho / caminho
        )

    return contribuicao


# Na execução normal:
config_do_ambiente = normalizar_ambiente(
    os.environ,
    diretorio_trabalho=Path.cwd(),
)

Cuidado com "false"

Interpretação de booleanos

A expressão bool("false") produz False em Python.

Reconheça a contribuição correta

Ambiente parcial

Considere diretorio_trabalho = Path("/projeto") e o mapeamento abaixo:

ambiente = {
    "APP_DETALHADO": "  FALSE ",
    "APP_ARQUIVO_DADOS": "dados/itens.csv",
}

Qual contribuição normalizada está correta?

Passo 5 de 8

Preservar opções omitidas na CLI

Configure o parser para que a CLI contribua somente com valores realmente informados, sem perder 0 nem False.

Omissão não é um padrão da aplicação

A CLI só deve contribuir quando recebeu uma opção

Na resolução por precedência, uma opção omitida na linha de comando não deve gerar um valor artificial. Caso contrário, esse valor poderia encobrir uma configuração vinda do arquivo ou do ambiente.

Use default=argparse.SUPPRESS nas opções que correspondem a chaves de configuração. Quando a opção não aparece, o atributo nem sequer é criado no Namespace.

Duas contribuições diferentes

Compare uma chamada sem opções configuráveis com outra que informa valores explícitos.

Comparação entre uma CLI vazia que gera um dicionário vazio e uma CLI com limite zero e sem detalhes que gera limite igual a zero e detalhado igual a falso.

A ausência produz {}; valores explícitos, inclusive 0 e False, permanecem na contribuição da CLI.

Declare apenas contribuições explícitas

Parser sem padrões de negócio

Cada opção configurável usa SUPPRESS. O sinalizador negativo cria False somente quando é usado.

python
import argparse


def criar_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser()

    parser.add_argument(
        "--limite",
        type=int,
        default=argparse.SUPPRESS,
    )
    parser.add_argument(
        "--detalhado",
        action="store_true",
        default=argparse.SUPPRESS,
    )
    parser.add_argument(
        "--sem-detalhes",
        dest="detalhado",
        action="store_false",
        default=argparse.SUPPRESS,
    )
    parser.add_argument("--arquivo-dados", default=argparse.SUPPRESS)

    # Seleciona o arquivo; não é entregue à configuração da aplicação.
    parser.add_argument("--config")
    return parser


parser = criar_parser()
vazio = parser.parse_args([])
explicito = parser.parse_args(["--limite", "0", "--sem-detalhes"])

print(vars(vazio))       # {}
print(vars(explicito))   # {'limite': 0, 'detalhado': False}

Dica

Não use `or` para selecionar contribuições

valor or outro_valor descartaria 0 e False. Ao combinar fontes, a regra é a presença da chave: se "limite" está no dicionário da CLI, seu 0 é uma contribuição válida.

Separe seleção de arquivo e configuração

`--config` tem outro papel

--config escolhe qual arquivo TOML será consultado. Por isso, ele pode existir no Namespace, mas não pertence ao dicionário de chaves entregue aos componentes da aplicação.

Depois de interpretar os argumentos, use vars() e selecione explicitamente as chaves configuráveis presentes. Assim, padrões de negócio continuam centralizados na camada de configuração, não no parser.

Extrair a contribuição da CLI

A seleção por chave preserva exatamente os atributos que o usuário informou.

python
CHAVES_CONFIG = {"limite", "detalhado", "arquivo_dados"}


def contribuicao_cli(args: argparse.Namespace) -> dict[str, object]:
    valores = vars(args)
    return {
        chave: valores[chave]
        for chave in CHAVES_CONFIG
        if chave in valores
    }

args = criar_parser().parse_args(
    ["--config", "minha-config.toml", "--limite", "0", "--sem-detalhes"]
)

print(args.config)              # minha-config.toml
print(contribuicao_cli(args))   # {'limite': 0, 'detalhado': False}

Pratique a preservação da omissão

Complete a opção

Complete o valor de default para que --limite só apareça no Namespace quando for informado:

parser.add_argument("--limite", type=int, default=____)

Preveja a contribuição

Após executar parse_args(["--limite", "0", "--sem-detalhes"]), qual dicionário contribuicao_cli(args) deve retornar?

Passo 6 de 8

Combinar as fontes sem perder suas regras

Organize o carregamento em etapas, normalize cada fonte antes da sobreposição e preserve erros de fontes presentes.

Resolver é mais do que escolher o último valor

Fluxo com fronteiras claras

A resolução segue uma ordem fixa: interpretar os argumentos, selecionar o arquivo, coletar as contribuições, normalizar cada uma e só então sobrepor os dicionários.

Cada coletor deve entregar as mesmas chaves internas e os mesmos tipos: limite como int, detalhado como bool e arquivo_dados como Path. Resolva um caminho relativo enquanto ainda conhece sua origem: o diretório do TOML para valores do arquivo; o diretório de trabalho para padrões, ambiente e CLI.

Assim, a etapa de mesclagem não precisa saber se um valor veio de texto, TOML ou argumento.

Da fonte ao valor vencedor

A imagem mostra que a normalização acontece antes da sobreposição e que cada chave pode ter uma fonte vencedora diferente.

Diagrama em camadas com padrões, TOML, ambiente e CLI sendo normalizados para as mesmas três chaves e sobrepostos em um dicionário final.

A prioridade é aplicada por chave, depois que cada fonte já foi interpretada.

Dica

Presença não é verdade lógica

Uma chave presente com 0 ou False deve substituir um valor inferior. Portanto, não use valor_da_cli or valor_do_ambiente: essa expressão descartaria justamente valores explícitos válidos.

Sobrepor contribuições normalizadas

Atualize uma cópia dos padrões

Depois que os coletores concluírem sem erro, use dict.update na ordem crescente de prioridade. Isso cria uma configuração em trabalho e mantém PADROES intacto para outras execuções.

Mesclagem rasa por chave

As funções coletoras abaixo já normalizaram e validaram a representação de cada fonte.

python
from pathlib import Path
from typing import Any

PADROES: dict[str, Any] = {
    "limite": 100,
    "detalhado": True,
    "arquivo_dados": Path("dados.csv"),
}


def combinar(
    arquivo: dict[str, Any],
    ambiente: dict[str, Any],
    cli: dict[str, Any],
) -> dict[str, Any]:
    """Sobrepõe contribuições já normalizadas, sem alterar PADROES."""
    configuracao = PADROES.copy()
    configuracao.update(arquivo)
    configuracao.update(ambiente)
    configuracao.update(cli)
    return configuracao

arquivo = {"limite": 20, "detalhado": False}
ambiente = {"limite": 0}
cli = {"arquivo_dados": Path("entrada.csv")}

resultado = combinar(arquivo, ambiente, cli)
# {"limite": 0, "detalhado": False,
#  "arquivo_dados": Path("entrada.csv")}

Erros não são encobertos pela precedência

Valide a representação na própria fonte

Uma fonte presente precisa ser lida, estruturada e convertida corretamente antes de poder contribuir. Se o TOML contém limite = true, há um erro de representação: pelo contrato, limite deve ser inteiro, e bool não é aceito mesmo sendo subclasse de int em Python.

Não continue esperando que ambiente ou CLI substituam esse valor. A política é interromper a resolução em falhas de leitura, sintaxe, estrutura ou conversão de uma fonte presente. Restrições semânticas, como verificar se o limite vencedor é não negativo, ficam para a próxima etapa.

Cheque `bool` antes de aceitar `int`

Esta verificação pertence ao normalizador do TOML, antes de chamar combinar.

python
def ler_limite_toml(valor: object) -> int:
    # bool é subclasse de int; por isso type(...) é int, não isinstance.
    if type(valor) is not int:
        raise ValueError("configuração: limite do TOML deve ser inteiro")
    return valor

ler_limite_toml(25)      # 25
# ler_limite_toml(True)  # ValueError

Explique a decisão da resolução

Conflito e erro de origem

Considere: padrões limite=100, detalhado=True; TOML limite=20, detalhado=False; ambiente APP_LIMITE=0; CLI --arquivo-dados entrada.csv.

Quais valores vencem? Em seguida, explique o que muda se o TOML presente contiver limite = true, mesmo com APP_LIMITE=0 no ambiente.

Escreva pelo menos 120 caracteres (0/120).

Passo 7 de 8

Validar antes de montar a aplicação

Valide o resultado consolidado antes de criar componentes da aplicação, garantindo que apenas uma configuração tipada e válida chegue à raiz de composição.

Validar o resultado vencedor

A validação acontece depois da precedência

As fontes já foram normalizadas e sobrepostas. Agora, valide o dicionário consolidado: ele precisa conter todas as chaves exigidas, com os tipos corretos e com regras semânticas atendidas.

Neste contrato, limite deve ser um inteiro maior ou igual a zero; 0 significa “sem limite”. detalhado deve ser booleano, e arquivo_dados deve ser um Path já resolvido conforme a referência de sua fonte.

Da resolução à composição

A ordem importa: primeiro resolva e valide; só depois construa os componentes.

Diagrama mostrando padrões, TOML, ambiente e CLI entrando em uma etapa de resolução; o dicionário consolidado passa por uma validação e gera Config, que então segue para a raiz de composição. Um valor inválido é interrompido antes dos componentes.

Um valor negativo no arquivo pode ser substituído por um valor válido de maior prioridade. Mas, se o valor vencedor for inválido, o fluxo para antes da composição.

Atenção

Não procure um valor antigo para salvar a execução

Se o valor final de limite for -1, a configuração falha. Não volte silenciosamente ao padrão e não procure um valor de prioridade menor que seja válido. A precedência já decidiu qual valor venceu; a validação decide se esse resultado pode ser usado.

Criar Config somente após validar

Validação e objeto tipado

Receba aqui o dicionário já normalizado e mesclado pelas fontes.

python
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping


class ErroConfiguracao(ValueError):
    """Indica uma configuração ausente, malformada ou inválida."""


@dataclass(frozen=True)
class Config:
    limite: int
    detalhado: bool
    arquivo_dados: Path


def validar_config(valores: Mapping[str, Any]) -> Config:
    obrigatorias = {"limite", "detalhado", "arquivo_dados"}
    ausentes = obrigatorias - valores.keys()
    if ausentes:
        chave = sorted(ausentes)[0]
        raise ErroConfiguracao(
            f"configuração inválida: chave '{chave}' está ausente"
        )

    limite = valores["limite"]
    detalhado = valores["detalhado"]
    arquivo_dados = valores["arquivo_dados"]

    # bool é subclasse de int; por isso, teste-o explicitamente.
    if isinstance(limite, bool) or not isinstance(limite, int):
        raise ErroConfiguracao(
            "configuração inválida: chave 'limite' deve ser inteira"
        )
    if not isinstance(detalhado, bool):
        raise ErroConfiguracao(
            "configuração inválida: chave 'detalhado' deve ser booleana"
        )
    if not isinstance(arquivo_dados, Path):
        raise ErroConfiguracao(
            "configuração inválida: chave 'arquivo_dados' deve ser um caminho"
        )
    if limite < 0:
        raise ErroConfiguracao(
            "configuração inválida: chave 'limite' deve ser maior ou igual a zero"
        )

    return Config(
        limite=limite,
        detalhado=detalhado,
        arquivo_dados=arquivo_dados,
    )

Dica

Diagnóstico seguro e específico

A exceção informa a chave, a etapa e o motivo seguro da falha. Evite incluir o valor bruto recebido: ele pode conter caminhos sensíveis ou outros dados que não devem aparecer em diagnósticos. A forma de apresentar essa exceção na CLI será definida depois.

Exemplo

Uso na raiz de composição

A raiz de composição recebe uma Config pronta:

valores = resolver_configuracao(argv, ambiente)
config = validar_config(valores)
repositorio = criar_repositorio(config.arquivo_dados)
servico = criar_servico(repositorio, detalhado=config.detalhado)

Se validar_config() lançar ErroConfiguracao, criar_repositorio() e criar_servico() não são chamados. Ler e validar configuração não é iniciar operações de negócio.

Cheque a ordem do fluxo

Organize as etapas

Coloque o fluxo na ordem correta para impedir que componentes sejam inicializados com configuração inválida.

  1. Resolver e normalizar as contribuições das fontes.
  2. Construir o objeto Config.
  3. Validar as chaves, os tipos e as restrições semânticas do resultado.
  4. Criar os componentes da aplicação na raiz de composição.

Passo 8 de 8

Aplicar e verificar o contrato completo

Monte um exemplo autocontido, execute testes locais e confirme o contrato de precedência, caminhos e falhas de configuração.

Monte um exemplo local autocontido

Uma pasta, um módulo e testes

Crie uma pasta vazia para este experimento. Nela, salve o primeiro bloco como config.py. O módulo recebe argumentos, ambiente e diretório de trabalho como dependências: isso permite executar normalmente e testar sem depender da configuração real do seu computador.

A ordem de aplicação é sempre: padrões → TOML → ambiente → CLI. Cada fonte já é normalizada antes da sobreposição; portanto, um valor inválido em uma fonte presente falha mesmo que uma fonte superior pudesse substituí-lo.

Fluxo que será verificado

Diagrama com quatro contribuições em ordem: padrões internos, arquivo TOML, variáveis de ambiente e CLI. As contribuições passam por normalização e convergem para uma configuração validada antes de chegar à composição da aplicação.

Os caminhos são resolvidos dentro de cada fonte, antes de os dicionários serem combinados.

config.py

Salve este módulo completo como config.py. Requer Python 3.11 ou superior, pois usa tomllib.

python
from __future__ import annotations

import argparse
import os
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Mapping


class ConfigError(Exception):
    """Falha segura ao carregar ou validar a configuração."""


@dataclass(frozen=True)
class Config:
    limite: int
    detalhado: bool
    arquivo_dados: Path


PADROES = {
    "limite": 10,
    "detalhado": False,
    "arquivo_dados": Path("dados.json"),
}
CHAVES = set(PADROES)


def caminho(valor: str | Path, base: Path) -> Path:
    if str(valor).strip() == "":
        raise ConfigError("arquivo_dados: caminho vazio")
    resultado = Path(valor)
    return resultado if resultado.is_absolute() else base / resultado


def normalizar_bruto(dados: Mapping[str, object], base: Path) -> dict[str, object]:
    desconhecidas = set(dados) - CHAVES
    if desconhecidas:
        raise ConfigError("estrutura: chave não reconhecida")

    resultado: dict[str, object] = {}
    if "limite" in dados:
        valor = dados["limite"]
        if type(valor) is not int:  # bool não é um limite aceito
            raise ConfigError("limite: inteiro esperado")
        resultado["limite"] = valor
    if "detalhado" in dados:
        valor = dados["detalhado"]
        if type(valor) is not bool:
            raise ConfigError("detalhado: booleano esperado")
        resultado["detalhado"] = valor
    if "arquivo_dados" in dados:
        valor = dados["arquivo_dados"]
        if not isinstance(valor, (str, Path)):
            raise ConfigError("arquivo_dados: caminho esperado")
        resultado["arquivo_dados"] = caminho(valor, base)
    return resultado


def carregar_toml(config: Path | None, cwd: Path) -> dict[str, object]:
    caminho_config = config if config is not None else cwd / "config.toml"
    if not caminho_config.exists():
        if config is None:
            return {}
        raise ConfigError("arquivo de configuração solicitado não foi encontrado")

    try:
        with caminho_config.open("rb") as arquivo:
            documento = tomllib.load(arquivo)
    except tomllib.TOMLDecodeError as erro:
        raise ConfigError("arquivo TOML: sintaxe inválida") from erro
    except OSError as erro:
        raise ConfigError("arquivo TOML: não foi possível ler") from erro

    tabela = documento.get("aplicacao", {})
    if not isinstance(tabela, dict):
        raise ConfigError("arquivo TOML: [aplicacao] deve ser uma tabela")
    return normalizar_bruto(tabela, caminho_config.parent)


def booleano(texto: str, chave: str) -> bool:
    normalizado = texto.strip().lower()
    if normalizado == "true":
        return True
    if normalizado == "false":
        return False
    raise ConfigError(f"{chave}: use true ou false")


def carregar_ambiente(ambiente: Mapping[str, str], cwd: Path) -> dict[str, object]:
    resultado: dict[str, object] = {}
    if "APP_LIMITE" in ambiente:
        try:
            resultado["limite"] = int(ambiente["APP_LIMITE"])
        except ValueError as erro:
            raise ConfigError("APP_LIMITE: inteiro esperado") from erro
    if "APP_DETALHADO" in ambiente:
        resultado["detalhado"] = booleano(ambiente["APP_DETALHADO"], "APP_DETALHADO")
    if "APP_ARQUIVO_DADOS" in ambiente:
        resultado["arquivo_dados"] = caminho(ambiente["APP_ARQUIVO_DADOS"], cwd)
    return resultado


def parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser()
    p.add_argument("--config", type=Path, default=argparse.SUPPRESS)
    p.add_argument("--limite", type=int, default=argparse.SUPPRESS)
    p.add_argument("--arquivo-dados", dest="arquivo_dados", default=argparse.SUPPRESS)
    grupo = p.add_mutually_exclusive_group()
    grupo.add_argument("--detalhado", action="store_true", default=argparse.SUPPRESS)
    grupo.add_argument("--sem-detalhes", dest="detalhado", action="store_false", default=argparse.SUPPRESS)
    return p


def resolver(argv: list[str], ambiente: Mapping[str, str] | None = None, cwd: Path | None = None) -> Config:
    diretorio = (cwd or Path.cwd()).resolve()
    argumentos = vars(parser().parse_args(argv))
    selecionado = argumentos.pop("config", None)
    arquivo = caminho(selecionado, diretorio) if selecionado is not None else None

    cli = normalizar_bruto(argumentos, diretorio)
    ambiente_normalizado = carregar_ambiente(os.environ if ambiente is None else ambiente, diretorio)
    toml = carregar_toml(arquivo, diretorio)

    combinada = dict(PADROES)
    combinada.update(toml)
    combinada.update(ambiente_normalizado)
    combinada.update(cli)

    if combinada["limite"] < 0:
        raise ConfigError("limite: deve ser maior ou igual a zero")
    return Config(**combinada)


def iniciar(argv: list[str], criar_componentes: Callable[[Config], object]) -> object:
    """A composição só ocorre depois de resolver e validar a configuração."""
    return criar_componentes(resolver(argv))

Registre o caso de uso e execute

Exemplo de config.toml

Para observar um caminho relativo ao arquivo, crie a pasta exemplo e salve este conteúdo em exemplo/config.toml. O caminho final de arquivo_dados será exemplo/armazenamento/registros.json, mesmo se você executar Python a partir da pasta acima.

toml
[aplicacao]
limite = 25
detalhado = true
arquivo_dados = "armazenamento/registros.json"

Exemplo

Uma execução com conflito deliberado

No diretório que contém a pasta exemplo, execute no terminal:

python -c "from config import resolver; print(resolver(['--config', 'exemplo/config.toml', '--limite', '0', '--sem-detalhes'], {'APP_LIMITE': '40', 'APP_DETALHADO': 'true'}))"

O resultado contém limite=0 e detalhado=False: os dois valores foram fornecidos explicitamente na CLI e vencem o ambiente e o TOML. Já arquivo_dados vem do TOML e fica ancorado na pasta exemplo.

Não há instalação nem empacotamento nesta prática: você está executando o módulo local diretamente.

Dica

O que observar

0 e False não podem ser removidos por testes de “verdade” como if valor ou por expressões com or. Aqui eles permanecem porque a sobreposição consulta a presença da chave no dicionário de argumentos.

Verifique a matriz essencial com pytest

Teste comportamentos, não detalhes internos

Salve o bloco a seguir como test_config.py, na mesma pasta de config.py. Os testes controlam o ambiente passando um dicionário e usam tmp_path para isolar arquivos. Eles cobrem padrões, conflito entre fontes, omissão versus valores falsos, ausência de arquivo, representação inválida, regra semântica e o bloqueio da composição.

test_config.py

python
from pathlib import Path

import pytest

from config import ConfigError, iniciar, resolver


def escrever_toml(pasta: Path, texto: str) -> Path:
    arquivo = pasta / "config.toml"
    arquivo.write_text(texto, encoding="utf-8")
    return arquivo


def test_usa_padroes_quando_config_convencional_nao_existe(tmp_path: Path) -> None:
    config = resolver([], {}, tmp_path)
    assert config.limite == 10
    assert config.detalhado is False
    assert config.arquivo_dados == tmp_path / "dados.json"


def test_cli_vence_arquivo_ambiente_e_padroes(tmp_path: Path) -> None:
    arquivo = escrever_toml(
        tmp_path,
        '[aplicacao]\nlimite = 20\ndetalhado = true\narquivo_dados = "toml.json"\n',
    )
    config = resolver(
        ["--config", str(arquivo), "--limite", "0", "--sem-detalhes", "--arquivo-dados", "cli.json"],
        {"APP_LIMITE": "30", "APP_DETALHADO": "true", "APP_ARQUIVO_DADOS": "ambiente.json"},
        tmp_path,
    )
    assert (config.limite, config.detalhado) == (0, False)
    assert config.arquivo_dados == tmp_path / "cli.json"


def test_caminho_do_toml_e_relativo_ao_proprio_arquivo(tmp_path: Path) -> None:
    pasta_config = tmp_path / "configs"
    pasta_config.mkdir()
    arquivo = escrever_toml(pasta_config, '[aplicacao]\narquivo_dados = "dados/base.json"\n')
    config = resolver(["--config", str(arquivo)], {}, tmp_path)
    assert config.arquivo_dados == pasta_config / "dados/base.json"


def test_arquivo_convencional_ausente_e_aceito_mas_solicitado_nao(tmp_path: Path) -> None:
    assert resolver([], {}, tmp_path).limite == 10
    with pytest.raises(ConfigError, match="solicitado"):
        resolver(["--config", "nao-existe.toml"], {}, tmp_path)


def test_erro_de_representacao_falha_mesmo_com_cli_superior(tmp_path: Path) -> None:
    with pytest.raises(ConfigError, match="APP_LIMITE"):
        resolver(["--limite", "3"], {"APP_LIMITE": "muitos"}, tmp_path)


def test_restricao_semantica_incide_sobre_o_valor_vencedor(tmp_path: Path) -> None:
    with pytest.raises(ConfigError, match="maior ou igual"):
        resolver(["--limite", "-1"], {}, tmp_path)
    assert resolver(["--limite", "2"], {"APP_LIMITE": "-1"}, tmp_path).limite == 2


def test_falha_impede_a_composicao(tmp_path: Path) -> None:
    chamadas: list[object] = []

    def sentinela(config: object) -> object:
        chamadas.append(config)
        return object()

    with pytest.raises(ConfigError):
        iniciar(["--limite", "-1"], sentinela)
    assert chamadas == []

Exemplo

Execute os testes

Com pytest disponível no seu ambiente, execute:

python -m pytest -q

A expectativa é ver sete testes aprovados. Se algum falhar, leia a asserção: ela aponta qual parte do contrato — precedência, referência de caminho, ausência ou validação — deixou de ser respeitada.

Revise o contrato e relate sua observação

Aplicação final

Execute os testes localmente ou analise o código com atenção. Em poucas frases, explique: (1) qual fonte vence em um conflito; (2) como o exemplo preserva 0 ou False explícitos; e (3) por que a sentinela não é chamada quando a configuração falha.

Escreva pelo menos 180 caracteres (0/180).

Resumo

Contrato consolidado

  • A configuração final nasce da sobreposição por chave: padrões, TOML, ambiente e CLI.
  • Um TOML convencional ausente é contribuição vazia; um arquivo escolhido com --config e inexistente é erro.
  • Caminhos do TOML são relativos à pasta do TOML; caminhos de padrões, ambiente e CLI são relativos ao diretório de trabalho.
  • Cada fonte presente precisa ter representação válida, mesmo se outra fonte tiver prioridade maior.
  • A regra semântica, como limite não negativo, é aplicada ao valor vencedor final.
  • A dataclass Config só é criada após a validação; portanto, a composição dos componentes não começa em caso de falha.

Contrato verificado

Parabéns! Você concluiu: Carregar configurações externas com precedência explícita

Você concluiu a verificação local do carregador. Agora você consegue entregar uma configuração validada à raiz de composição, com precedência e falhas previsíveis. No próximo tutorial, as falhas serão traduzidas na fronteira da CLI em mensagens e códigos de saída adequados.

Baixe o Aplicativo agora para ter acesso a + de 5000 cursos gratuitos, exercícios, certificado e muito conteúdo sem pagar nada!

  • Cursos online 100% gratuitos do início ao fim

    Milhares de cursos online em vídeo, ebooks e áudiobooks.

  • Mais de 60 mil exercícios gratuitos

    Para testar seus conhecimentos no decorrer dos cursos online

  • Certificado Digital gratuito válido em todo o Brasil

    Gerado diretamente na galeria de fotos do seu celular e enviado ao seu e-mail

Aplicativo Cursa na tela de ebook, na tela de curso em vídeo e na tela de exercícios do curso, mais o certificado de conclusão de curso