Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/adr/0005-leitor-de-planilhas.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@ Opções avaliadas:

## Decisão

- O módulo `namescommons.spreadsheet` lê XLSX com a biblioteca padrão (`zipfile` e `iterparse`), em fluxo.
- O módulo `namescommons.spreadsheet` lê XLSX com a biblioteca padrão (`zipfile` e `xml.parsers.expat` com `TreeBuilder`), em fluxo.
- XLS é lido com `xlrd`, fixado em `==2.0.2` em `pyproject.toml`. É a única dependência de runtime acrescentada.
- O formato é reconhecido pelos primeiros bytes do arquivo, e os dois caminhos devolvem valores do mesmo tipo (texto, inteiro, fração, booleano, vazio).
- Adaptadores não importam `xlrd` nem leem XML de planilha: usam só `namescommons.spreadsheet`.

## Consequências

- XLSX não depende de terceiros, e o pico de memória é o de uma linha mais os textos compartilhados.
- O `ElementTree` da biblioteca padrão não busca entidades externas, e o expat do Python 3.12 limita a expansão de entidades; por isso `defusedxml` não entra. Como defesa em profundidade, o leitor recusa com `FormatError` qualquer membro XML cujo prólogo contenha `<!DOCTYPE` ou `<!ENTITY`, inspecionando só os primeiros 64 KiB antes de repassar o fluxo ao parser.
- O `ElementTree` da biblioteca padrão não busca entidades externas, e o expat do Python 3.12 limita a expansão de entidades; por isso `defusedxml` não entra. Como defesa em profundidade, o leitor recusa com `FormatError` qualquer membro XML com DTD, em duas camadas independentes. (1) Pré-checagem: lê só os primeiros 64 KiB do prólogo, que precisam ser UTF-8 ou UTF-16 válidos (por BOM ou bytes nulos; UTF-32 e prefixos inválidos como EBCDIC são recusados), e a declaração `encoding` do XML, se existir, só pode ser UTF-8 ou UTF-16; assim nenhuma codificação aceita pelo expat esconde um `<!DOCTYPE` ou `<!ENTITY` da pré-checagem (diferencial entre o analisador e o parser). (2) O parser: o `ET.XMLParser` em C não expõe o expat, então o leitor usa `xml.parsers.expat` direto com `ET.TreeBuilder` e instala `StartDoctypeDeclHandler` e `EntityDeclHandler` que levantam `FormatError`; vale mesmo que a pré-checagem falhe, em qualquer codificação, e mantém a leitura em fluxo.
- `xlrd` carrega o XLS inteiro na memória. Para o maior arquivo do INE (14,9 MB) o pico medido foi de 67 MB, muito abaixo do limite de 4 GB da spec 002 (R8).
- `xlrd` está em manutenção mínima: a 2.0.2 saiu quatro anos e meio depois da 2.0.1. A versão fixada evita mudança de comportamento silenciosa; se o projeto for abandonado, o XLS é um formato congelado, e trocar a implementação fica restrito a `namescommons.spreadsheet`.
- Só XLS gravado em documento composto (Excel 5 em diante) é reconhecido; XLS mais antigo, ODS e XLSB aparecem como erro de formato.
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/003b-fontes-planilhas/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Módulo `namescommons.spreadsheet`, usado por adaptadores de fontes em planilha.
- **R1.1 Interface.** `sheet_names(path) -> list[str]` devolve as abas na ordem do arquivo. `rows(path, sheet) -> Iterator[tuple[int, list]]` devolve `(número da linha, valores)` de cada linha não vazia, com o número da linha como o Excel mostra (a partir de 1) e os valores na posição da coluna (A = 0). Uma aba fora de `sheet_names` levanta `FormatError` com o nome pedido e a lista das abas.
- **R1.2 Formato pelo conteúdo.** O formato é reconhecido pelos primeiros bytes, não pela extensão: `PK\x03\x04` é XLSX (ZIP do Office Open XML) e `D0 CF 11 E0` é XLS (documento composto do Excel, da versão 5 em diante). Outro conteúdo, inclusive uma página HTML de erro gravada no lugar do arquivo, levanta `FormatError`.
- **R1.3 Valores iguais nos dois formatos.** Texto vira `str` sem alteração, número inteiro vira `int`, número com fração vira `float`, booleano vira `bool` e célula vazia vira `None`. Valores `None` no fim da linha são cortados, e linhas sem nenhum valor são puladas. Datas chegam como o número de série do Excel (nenhuma fonte de nomes usa datas em célula); a célula XLSX de tipo `d` (data ISO 8601, rara) não é aceita e levanta `FormatError` com o tipo. Em células mescladas, o valor fica só na célula do canto superior esquerdo, e as demais chegam vazias (`None`), como estão gravadas no arquivo. Célula com erro de fórmula (`#N/A`, `#REF!`) levanta `FormatError` com a aba e a referência da célula.
- **R1.4 XLSX com a biblioteca padrão.** O XLSX é lido com `zipfile` e `xml.etree.ElementTree.iterparse`, linha a linha, sem carregar a aba inteira na memória. São aceitos os tipos de célula `s` (texto compartilhado, inclusive com trechos de formatação; o texto fonético `rPh` é ignorado), `inlineStr`, `str`, `n` (ou sem tipo), `b` e `e`. A aba é localizada pelo nome em `xl/workbook.xml` e pelo alvo em `xl/_rels/workbook.xml.rels`. Cada linha lida sai da árvore XML, e a memória fica plana na aba inteira. Viram `FormatError`, com o arquivo e a aba ou a célula: membro XML com DTD no prólogo (`<!DOCTYPE` ou `<!ENTITY` antes do elemento raiz, conferido nos primeiros 64 KiB sem perder a leitura em fluxo; prólogo maior que isso também é recusado), membro do ZIP ausente (inclusive a aba apontada pelas relações), membro que descompacta mais de 1 GiB ou mais de 1 MiB com taxa de compressão acima de 100:1, referência de célula malformada ou além da coluna `XFD` (16.384 colunas), número de linha não numérico e índice de texto compartilhado que não é inteiro ou está fora da tabela.
- **R1.4 XLSX com a biblioteca padrão.** O XLSX é lido com `zipfile` e `xml.parsers.expat` com `xml.etree.ElementTree.TreeBuilder`, em eventos como o `iterparse`, linha a linha, sem carregar a aba inteira na memória. São aceitos os tipos de célula `s` (texto compartilhado, inclusive com trechos de formatação; o texto fonético `rPh` é ignorado), `inlineStr`, `str`, `n` (ou sem tipo), `b` e `e`. A aba é localizada pelo nome em `xl/workbook.xml` e pelo alvo em `xl/_rels/workbook.xml.rels`. Cada linha lida sai da árvore XML, e a memória fica plana na aba inteira. Viram `FormatError`, com o arquivo e a aba ou a célula: membro XML com DTD no prólogo (`<!DOCTYPE` ou `<!ENTITY` antes do elemento raiz, conferido nos primeiros 64 KiB sem perder a leitura em fluxo; prólogo maior que isso também é recusado; o próprio parser expat também recusa DTD, em qualquer codificação), membro cujo prefixo não é UTF-8 nem UTF-16 válido (inclui UTF-32 e EBCDIC) ou cuja declaração XML pede codificação diferente de UTF-8 e UTF-16, membro do ZIP ausente (inclusive a aba apontada pelas relações), membro que descompacta mais de 1 GiB ou mais de 1 MiB com taxa de compressão acima de 100:1, referência de célula malformada ou além da coluna `XFD` (16.384 colunas), número de linha não numérico e índice de texto compartilhado que não é inteiro ou está fora da tabela.
- **R1.5 XLS com `xlrd` fixado.** O XLS é lido com `xlrd` 2.0.2 ([ADR 0005](../../adr/0005-leitor-de-planilhas.md)), única dependência nova de runtime. Arquivo XLS que `xlrd` não consegue abrir levanta `FormatError` com a mensagem original.
- **R1.6 Contagem em célula.** `cell_count(value, missing=(), field="contagem") -> int | None` converte uma célula numa contagem pelo mesmo critério de `parse_count` (spec 003, R2.2): `int` não negativo passa, `float` com fração, negativo ou `bool` é `FormatError`, texto passa por `parse_count` com os marcadores de `missing`, e célula vazia só é aceita se `""` estiver em `missing`.

Expand Down
121 changes: 110 additions & 11 deletions src/namescommons/spreadsheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
XLS, com xlrd. Os dois devolvem valores do mesmo tipo: str, int, float, bool ou None.
"""

import codecs
import io
import posixpath
import re
import zipfile
import xml.etree.ElementTree as ET
import xml.parsers.expat as expat
from collections.abc import Iterable, Iterator
from pathlib import Path

Expand All @@ -35,6 +37,10 @@
# Defesa em profundidade (ADR 0005): membro XML com DTD é recusado antes de ir ao parser.
_MAX_PROLOGO = 1 << 16
_ESPACO = re.compile(r"[\s\ufeff]*")
_DECLARACAO = re.compile(r"<\?xml(?=\s)(.*?)\?>", re.DOTALL)
_ENCODING = re.compile(r"""\sencoding\s*=\s*(?:"([^"]*)"|'([^']*)')""")
_ENCODING_OK = re.compile(r"utf-?(?:8|16)", re.IGNORECASE)
_TAMANHO_BLOCO = 1 << 16


def _formato(caminho: Path) -> str:
Expand Down Expand Up @@ -135,7 +141,11 @@ def __init__(self, fh, nome: str, caminho: Path):
while True:
parte = fh.read(4096)
prefixo += parte
decisao = _prologo(prefixo)
try:
decisao = _prologo(prefixo, f"{caminho.name}: membro {nome}")
except FormatError:
fh.close()
raise
if decisao == "dtd":
fh.close()
raise FormatError(f"{caminho.name}: membro {nome} tem DTD (DOCTYPE ou ENTITY); recusado")
Expand All @@ -162,15 +172,47 @@ def close(self) -> None:
super().close()


def _prologo(dados: bytes) -> str:
"""'dtd' se há DOCTYPE/ENTITY antes do elemento raiz, 'raiz' se a raiz já começou, senão 'mais'."""
def _texto_do_prefixo(dados: bytes, onde: str) -> str:
"""Decodifica o prefixo como UTF-8 ou UTF-16 (por BOM ou NUL); outra coisa é FormatError.

O expat aceita qualquer codificação declarada (EBCDIC, UTF-32, ISO-8859-x...) e um DOCTYPE
escrito assim passaria despercebido por uma leitura só em UTF-8; por isso o resto é recusado.
"""
if dados[:4] in (b"\xff\xfe\x00\x00", b"\x00\x00\xfe\xff") or dados[:4] in (b"<\x00\x00\x00", b"\x00\x00\x00<"):
raise FormatError(f"{onde} está em UTF-32; só UTF-8 e UTF-16 são aceitos")
if dados[:2] in (b"\xff\xfe", b"\xfe\xff"):
texto = dados.decode("utf-16", errors="replace")
codec = "utf-16"
elif dados[:4] == b"<\x00?\x00":
codec = "utf-16-le"
elif dados[:4] == b"\x00<\x00?":
codec = "utf-16-be"
elif b"\x00" in dados[:4]:
texto = dados.decode("utf-16-le" if dados[1:2] == b"\x00" else "utf-16-be", errors="replace")
raise FormatError(f"{onde} começa com bytes nulos que não são UTF-16; só UTF-8 e UTF-16 são aceitos")
else:
# UTF-8 (BOM opcional). UTF-32 e EBCDIC não são inspecionados e falham no expat.
texto = dados.removeprefix(b"\xef\xbb\xbf").decode("utf-8", errors="replace")
codec = "utf-8-sig"
try:
# final=False: o prefixo pode terminar no meio de um caractere de vários bytes.
return codecs.getincrementaldecoder(codec)("strict").decode(dados, final=False)
except UnicodeDecodeError:
raise FormatError(f"{onde} não é UTF-8 nem UTF-16 válido; só essas codificações são aceitas") from None


def _confere_declaracao(texto: str, onde: str) -> None:
"""A declaração `<?xml ... encoding=...?>` só pode dizer UTF-8 ou UTF-16 (ou não dizer nada)."""
m = _DECLARACAO.match(texto)
if m and (e := _ENCODING.search(m.group(0))):
nome = e.group(1) if e.group(1) is not None else e.group(2)
if not _ENCODING_OK.fullmatch(nome):
raise FormatError(f"{onde} declara a codificação {nome!r}; só UTF-8 e UTF-16 são aceitos")


def _prologo(dados: bytes, onde: str = "membro XML") -> str:
"""'dtd' se há DOCTYPE/ENTITY antes do elemento raiz, 'raiz' se a raiz já começou, senão 'mais'.

FormatError se o prefixo não é UTF-8 nem UTF-16 ou se a declaração XML pede outra codificação.
"""
texto = _texto_do_prefixo(dados, onde)
_confere_declaracao(texto.lstrip("\ufeff"), onde)
i = 0
while True:
i = _ESPACO.match(texto, i).end()
Expand All @@ -191,13 +233,70 @@ def _prologo(dados: bytes) -> str:
i = fim + salto


def _eventos(fh, nome: str, caminho: Path, events=("end",)) -> Iterator[tuple[str, ET.Element]]:
"""Eventos ("start"/"end", elemento) do XML de um membro, em fluxo, num expat que recusa DTD.

Defesa em profundidade, independente da pré-checagem do prólogo (ADR 0005): o próprio expat
levanta FormatError ao ver DOCTYPE ou ENTITY, em qualquer codificação que ele aceite. O
`ET.XMLParser` em C não expõe o expat; por isso o expat é usado direto, com `ET.TreeBuilder`.
Erro de sintaxe vira `ET.ParseError`, como no `iterparse`.
"""
def recusa(*_args):
raise FormatError(f"{caminho.name}: membro {nome} tem DTD (DOCTYPE ou ENTITY); recusado")

construtor = ET.TreeBuilder()
pendentes: list[tuple[str, ET.Element]] = []
quer_start, quer_end = "start" in events, "end" in events

def inicio(tag, atributos):
el = construtor.start(tag, atributos)
if quer_start:
pendentes.append(("start", el))

def fim(tag):
el = construtor.end(tag)
if quer_end:
pendentes.append(("end", el))

parser = expat.ParserCreate(namespace_separator="}")
parser.buffer_text = True
parser.StartDoctypeDeclHandler = recusa
parser.EntityDeclHandler = recusa
parser.StartElementHandler = lambda tag, atr: inicio(_tag(tag), {_tag(k): v for k, v in atr.items()})
parser.EndElementHandler = lambda tag: fim(_tag(tag))
parser.CharacterDataHandler = construtor.data
try:
while True:
bloco = fh.read(_TAMANHO_BLOCO)
parser.Parse(bloco, not bloco)
yield from pendentes
pendentes.clear()
if not bloco:
break
except expat.ExpatError as e:
raise ET.ParseError(str(e)) from None


def _tag(nome: str) -> str:
"""'uri}local' do expat vira '{uri}local' (nome qualificado do ElementTree)."""
return "{" + nome if "}" in nome else nome


def _raiz(fh, nome: str, caminho: Path) -> ET.Element:
"""Elemento raiz do XML de um membro (o `ET.parse(...).getroot()` com DTD recusado)."""
raiz = None
for _, el in _eventos(fh, nome, caminho):
raiz = el
return raiz


def _xlsx_abas(z: zipfile.ZipFile, caminho: Path) -> list[tuple[str, str]]:
"""(nome, membro do ZIP) de cada aba, na ordem de xl/workbook.xml."""
try:
with _membro(z, "xl/workbook.xml", caminho) as fh:
livro = ET.parse(fh).getroot()
livro = _raiz(fh, "xl/workbook.xml", caminho)
with _membro(z, "xl/_rels/workbook.xml.rels", caminho) as fh:
rels = ET.parse(fh).getroot()
rels = _raiz(fh, "xl/_rels/workbook.xml.rels", caminho)
except ET.ParseError as e:
raise FormatError(f"{caminho.name}: workbook do XLSX ilegível: {e}") from None
alvos = {r.get("Id"): r.get("Target", "") for r in rels.iter(_REL_PKG + "Relationship")}
Expand Down Expand Up @@ -228,7 +327,7 @@ def _textos_compartilhados(z: zipfile.ZipFile, caminho: Path) -> list[str]:
textos = []
with _membro(z, "xl/sharedStrings.xml", caminho) as fh:
try:
for _, el in ET.iterparse(fh):
for _, el in _eventos(fh, "xl/sharedStrings.xml", caminho):
if el.tag == _SI:
textos.append(_texto(el))
el.clear()
Expand Down Expand Up @@ -301,7 +400,7 @@ def _xlsx_rows(caminho: Path, sheet: str) -> Iterator[tuple[int, list]]:
with _membro(z, membro, caminho) as fh:
numero, pai = 0, None
try:
for evento, el in ET.iterparse(fh, events=("start", "end")):
for evento, el in _eventos(fh, membro, caminho, ("start", "end")):
if evento == "start":
if el.tag == _SHEET_DATA:
pai = el
Expand Down
Loading
Loading