Exportar CSV e XLSX (tempestweb.export)¶
O que você vai aprender
A transformar as linhas que já estão na sua tela num arquivo que o usuário baixa — CSV ou XLSX — sem escrever encoder na mão e sem instalar nada. 🚀
Sua app mostra uma DataTable. O usuário quer um botão "Exportar". Você já
tem native.file.save, que entrega bytes ao usuário —
mas quem produz esses bytes?
Até aqui, você. E encoder escrito na mão erra sempre nos mesmos lugares.
O problema, em quatro linhas¶
# ❌ Não faça isso
linhas = ["id,nome,cidade"]
for row in rows:
linhas.append(f"{row['id']},{row['name']},{row['city']}")
csv = "\n".join(linhas).encode("utf-8")
Funciona até a primeira linha de dado real:
| O dado | O que quebra |
|---|---|
Recife, PE |
a vírgula vira separador: a linha ganha uma coluna |
Ana "A" Silva |
as aspas quebram o parser de quem for ler |
João |
sem BOM, o Excel abre como João |
27/08/2026 num XLSX |
vira o número 46265, porque Excel não tem tipo data |
O tempestweb.export embrulha exatamente esses quatro.
O primeiro export¶
Um programa completo, que roda:
from tempestweb import native
from tempestweb.export import CSV_MIME_TYPE, Column, to_csv
COLUMNS = [
Column("id", "ID"),
Column("name", "Nome"),
Column("city", "Cidade"),
]
ROWS = [
{"id": 1, "name": 'Ana "A" Silva', "city": "Recife, PE"},
{"id": 2, "name": "João", "city": "Olinda"},
]
async def exportar() -> None:
"""Gera o CSV e entrega ao usuário."""
await native.file.save(
"usuarios.csv",
to_csv(ROWS, COLUMNS),
mime_type=CSV_MIME_TYPE,
)
O arquivo que sai, byte a byte:
Repare no que você não escreveu: a vírgula de Recife, PE foi para dentro
de aspas, as aspas de "A" foram dobradas, e o na frente é o BOM que
faz o Excel ler João certo.
Modo A e Modo B — o Modo C recusa
Gerar bytes é Python puro: nada aqui toca o browser, e to_csv/to_xlsx
funcionam igual no Modo A (WASM) e no Modo B (servidor). Só a entrega, o
file.save, é capacidade nativa.
O Modo C é outra história. Ele transcreve o Python da sua app para
JavaScript e serve um conjunto fechado de módulos — tempest_core,
tempestweb.components e tempestweb.native. Importar este pacote numa app
Modo C é recusado no build, com erro nomeado:
app.py:5: import from 'tempestweb.export' is not supported
(only tempest_core, `tempestweb.components` and `tempestweb.native`)
App Modo C que precisa exportar pede o arquivo ao servidor, que gera com este mesmo módulo e responde os bytes.
Column: de onde vem, o que diz, como aparece¶
Uma coluna carrega três coisas:
Column("created_at", "Criado em", format=lambda v: v.strftime("%d/%m/%Y"))
# ^ o campo ^ o cabeçalho ^ como o valor vira texto (opcional)
O campo é lido da linha seja ela um dict ou um objeto — a mesma lista de
colunas serve para o dict que veio da API e para a @dataclass do seu estado:
from dataclasses import dataclass
from tempestweb.export import Column, to_csv
@dataclass(frozen=True)
class Usuario:
"""Uma linha da tabela."""
id: int
name: str
COLUMNS = [Column("id", "ID"), Column("name", "Nome")]
print(to_csv([{"id": 1, "name": "Ana"}], COLUMNS, bom=False))
print(to_csv([Usuario(1, "Ana")], COLUMNS, bom=False))
# b'ID,Nome\r\n1,Ana\r\n' nos dois casos
Um campo que não existe levanta, não exporta em branco
Column("nmae", "Nome") não gera uma coluna vazia: levanta
ColumnFieldError dizendo qual campo faltou e quais existem. Uma coluna de
brancos só é descoberta por quem abre a planilha — muito depois, e muito
mais caro.
O separador do Excel em pt-BR¶
Vírgula + Excel pt-BR = tudo numa coluna só
O Excel usa o separador de lista do sistema. Num Windows configurado em
português, isso é ; — e um arquivo separado por vírgula abre com todas as
colunas empilhadas numa só. O BOM não resolve isso: são problemas
diferentes.
Se o destino é outro sistema (um import, um script, um pandas.read_csv),
mantenha a vírgula — que é o default e o que a RFC 4180 diz.
XLSX: a data é o detalhe que importa¶
from datetime import date
from tempestweb import native
from tempestweb.export import XLSX_MIME_TYPE, Column, to_xlsx
COLUMNS = [
Column("name", "Nome"),
Column("created_at", "Criado em"),
]
ROWS = [{"name": "Ana", "created_at": date(2026, 8, 27)}]
async def exportar() -> None:
"""Gera a planilha e entrega ao usuário."""
await native.file.save(
"usuarios.xlsx",
to_xlsx(ROWS, COLUMNS, sheet="Usuários"),
mime_type=XLSX_MIME_TYPE,
)
Abra o arquivo: a coluna Criado em mostra 27/08/2026, e a célula é uma
data de verdade — dá para ordenar, filtrar por período e somar dias.
Detalhes técnicos — por que isso é difícil
O Excel não tem tipo data. Uma célula de data é um número — dias
desde 1899-12-30 — que ganha aparência de data por um number format
guardado à parte, no styles.xml.
Encoder escrito na mão acerta o número e esquece o formato. O resultado é
uma planilha válida que mostra 46265 onde o leitor esperava a data, e o
bug só aparece quando alguém abre o arquivo.
Por isso o tempestweb.export carrega um styles.xml com dois numFmt
(dd/mm/yyyy e dd/mm/yyyy hh:mm) e por isso o teste do repo abre a
planilha de volta — descompacta o zip, resolve os relacionamentos e
confere que a célula é data, não número parecido com data.
O epoch de 1899-12-30, e não 1900-01-01, absorve o bug deliberado do Excel que considera 1900 um ano bissexto — mantido desde o Lotus 1-2-3.
Os tipos que a planilha entende, sem você pedir:
| Valor em Python | Célula no Excel |
|---|---|
str |
texto |
int, float, Decimal |
número |
bool |
booleano (VERDADEIRO/FALSO), não o número 1 |
date |
data, formatada dd/mm/yyyy |
datetime |
data e hora, formatada dd/mm/yyyy hh:mm |
None |
célula vazia |
Passar format= transforma a célula em texto
Column("created_at", "Criado em", format=lambda v: v.strftime("%d/%m/%Y"))
produz uma string, e strings viram células de texto — que não ordenam
como data. Para XLSX, deixe a data passar crua e deixe o formato numérico
fazer o trabalho. O format= é para o CSV, ou para quando você quer texto
mesmo.
Nomes de aba que o Excel recusa¶
O Excel limita o nome da aba a 31 caracteres e proíbe [ ] : * ? / \,
apóstrofo no começo ou no fim, e o nome reservado History. Uma pasta com nome
inválido abre como "conteúdo ilegível", sem dizer qual parte está errada — então
o to_xlsx recusa antes, nomeando o motivo.
Zero dependência¶
O XLSX é um zip de XMLs, e o subconjunto que uma exportação precisa é pequeno:
uma planilha, um cabeçalho, e células de texto, número, booleano e data.
zipfile e xml.etree da biblioteca padrão cobrem tudo isso.
Por que não openpyxl
A superfície do openpyxl é enorme perto do punhado de partes usadas aqui,
e num pacote publicado os bounds dele propagam para todo consumidor do
tempestweb. A regra do projeto é implementar antes de depender quando o
valor da lib é pequeno em relação ao que ela restringe — e este é o caso.
Recap¶
Column(campo, cabeçalho, format=...)diz de onde vem, o que aparece e como.to_csv(rows, columns)devolve bytes com BOM ligado por default; usedelimiter=";"quando o destino é Excel em pt-BR.to_xlsx(rows, columns, sheet=...)devolve bytes de uma planilha real, com data que é data.- Os dois são Python puro: rodam no Modo A e no Modo B, e não instalam nada. O Modo C recusa o import no build — nele, o servidor gera e responde.
- Entregue com
native.file.save(nome, dados, mime_type=...). - Campo inexistente e nome de aba inválido levantam — em vez de exportar algo silenciosamente errado.