Post
Exercício de ETL: converter coleções de um JSON em arquivos CSV
Gist ID: 7e934be8ddfbe7642a9ce43182f34684
Este exercício introdutório parte de um arquivo JSON com duas coleções e gera um CSV para cada uma. O objetivo não é apenas converter formatos, mas tornar explícitas as três decisões de um pequeno processo de ETL:
- extração: como o JSON será lido;
- transformação: quais estruturas são aceitas e como as colunas serão definidas;
- carga: onde os CSVs serão gravados e como evitar sobrescritas.
Use este conteúdo em dados.json:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
{
"pessoas": [
{
"nome": "João",
"idade": 25,
"cidade": "São Paulo"
},
{
"nome": "Maria",
"idade": 30,
"cidade": "Rio de Janeiro"
},
{
"nome": "Carlos",
"idade": 35,
"cidade": "Belo Horizonte"
}
],
"veiculos": [
{
"marca": "Toyota",
"modelo": "Corolla",
"ano": 2018
},
{
"marca": "Honda",
"modelo": "Civic",
"ano": 2020
},
{
"marca": "Volkswagen",
"modelo": "Golf",
"ano": 2017
}
]
}
Antes de programar
Defina o contrato do exercício. Neste caso:
- a raiz do JSON deve ser um objeto;
- cada chave da raiz identifica uma coleção;
- cada coleção deve ser uma lista de objetos;
- os valores de cada registro devem ser escalares ou nulos;
- uma coleção vazia gera um arquivo vazio, pois não há colunas a inferir;
- um arquivo existente não deve ser substituído silenciosamente.
Essas regras são escolhas desta implementação. Outro pipeline poderia receber um esquema separado, serializar objetos aninhados ou permitir sobrescrita.
Implementação
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
from __future__ import annotations
import argparse
import csv
import json
import re
from pathlib import Path
from typing import Any
SAFE_DATASET_NAME = re.compile(r"^[A-Za-z0-9_-]+$")
SCALAR_TYPES = (str, int, float, bool, type(None))
def extract(path: Path) -> dict[str, Any]:
with path.open(encoding="utf-8") as source:
data = json.load(source)
if not isinstance(data, dict):
raise ValueError("A raiz do JSON deve ser um objeto")
return data
def validate_records(dataset_name: str, value: Any) -> list[dict[str, Any]]:
if not SAFE_DATASET_NAME.fullmatch(dataset_name):
raise ValueError(f"Nome de coleção inválido: {dataset_name!r}")
if not isinstance(value, list):
raise ValueError(f"{dataset_name!r} deve conter uma lista")
records: list[dict[str, Any]] = []
for index, record in enumerate(value):
if not isinstance(record, dict):
raise ValueError(
f"{dataset_name}[{index}] deve ser um objeto"
)
for field, field_value in record.items():
if not isinstance(field, str):
raise ValueError("Todo nome de campo deve ser uma string")
if not isinstance(field_value, SCALAR_TYPES):
raise ValueError(
f"{dataset_name}[{index}].{field} contém um valor aninhado"
)
records.append(record)
return records
def collect_headers(records: list[dict[str, Any]]) -> list[str]:
"""Preserva a ordem da primeira ocorrência de cada campo."""
headers: list[str] = []
seen: set[str] = set()
for record in records:
for field in record:
if field not in seen:
seen.add(field)
headers.append(field)
return headers
def load_csv(
output_path: Path,
records: list[dict[str, Any]],
) -> None:
headers = collect_headers(records)
# O modo "x" interrompe a execução se o arquivo já existir.
with output_path.open("x", encoding="utf-8", newline="") as target:
if not headers:
return
writer = csv.DictWriter(
target,
fieldnames=headers,
extrasaction="raise",
)
writer.writeheader()
writer.writerows(records)
def run(input_path: Path, output_directory: Path) -> None:
data = extract(input_path)
output_directory.mkdir(parents=True, exist_ok=True)
for dataset_name, value in data.items():
records = validate_records(dataset_name, value)
output_path = output_directory / f"{dataset_name}.csv"
load_csv(output_path, records)
print(f"{output_path}: {len(records)} registro(s)")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Converte coleções de um JSON em arquivos CSV."
)
parser.add_argument("input", type=Path)
parser.add_argument("output_directory", type=Path)
return parser.parse_args()
if __name__ == "__main__":
arguments = parse_args()
run(arguments.input, arguments.output_directory)
Salve o código como json_to_csv.py e execute:
1
python json_to_csv.py dados.json saida
O diretório saida receberá pessoas.csv e veiculos.csv.
Por que usar DictWriter
O primeiro exemplo deste exercício obtinha o cabeçalho com as chaves do primeiro
registro e depois gravava item.values(). Isso pressupunha que todos os objetos
tinham exatamente os mesmos campos, na mesma ordem. Um campo ausente, adicional
ou reordenado podia deslocar valores para a coluna errada.
csv.DictWriter associa cada valor ao nome do campo. A função
collect_headers percorre todos os registros para incluir campos que só
aparecem depois do primeiro. Quando um registro não contém uma dessas chaves, o
módulo grava uma célula vazia.
Casos que o exercício deve testar
Depois do exemplo principal, experimente deliberadamente:
- trocar a ordem dos campos em um registro;
- remover
cidadede uma pessoa; - acrescentar um campo apenas ao último veículo;
- inserir uma lista no lugar de um valor escalar;
- deixar uma coleção vazia;
- executar o programa duas vezes no mesmo diretório.
Os três primeiros casos verificam a transformação das colunas. Os demais verificam se o contrato falha de forma visível ou preserva a decisão definida.
Limitações
CSV é tabular; JSON pode representar árvores. Este exemplo rejeita listas e objetos dentro dos registros para não decidir silenciosamente como achatá-los. Em um pipeline real, o esquema de saída, os tipos, a codificação de datas, o tratamento de erros e a política de reprocessamento deveriam ser definidos antes da carga.
As APIs utilizadas estão documentadas nos módulos
json e
csv do Python.