Skip to content

Saída polars: campo dict em texto JSON e coluna sem valor com o tipo da anotação - #232

Merged
bdcdo merged 3 commits into
mainfrom
fix/polars-tipo-pela-anotacao
Oct 14, 2026
Merged

bdcdo merged 3 commits into
mainfrom
fix/polars-tipo-pela-anotacao

Conversation

@bdcdo

@bdcdo bdcdo commented Oct 13, 2026 •

Copy link
Copy Markdown
Owner

O quê

Dois comportamentos da saída polars que a saída pandas não tem.

Campo dict. Com campo: dict[str, int] e respostas {"a": 1}, {"b": 2} e {}, a coluna saía Struct({"a": Int64, "b": Int64}) com {"a": 1, "b": None}, {"a": None, "b": 2} e {"a": None, "b": None}. A chave ausente ficava igual à chave com valor nulo, e o dict vazio deixava de ser vazio.

Tipo que depende dos dados. O tipo da coluna do modelo vinha só das respostas. Um Optional[int] saía Int64 quando havia algum inteiro e String quando todas as respostas eram nulas; um list[str] só com listas vazias saía List(Null). Duas execuções do mesmo modelo devolviam schemas diferentes.

Como

  • O campo anotado como dict sai como texto JSON, numa coluna String, com o mesmo texto que o checkpoint grava; o valor que o JSON não representa, como uma data, sai nele como texto. Vale para dict, dict[str, int], Optional[dict[...]] e dict de dict. O dict com chave que o JSON não aceita, como um Enum, continua saindo Object com aviso.
  • A coluna sem nenhum valor, toda nula ou só com listas vazias, recebe o tipo da anotação quando ela é str, int, float, bool, date, datetime ou lista de um deles. Com qualquer outra anotação, e sempre que há algum valor, o tipo continua vindo das respostas.
  • from_pandas passa a receber a anotação de cada campo no lugar da lista de nomes, e a conversão de cada coluna vai para _to_polars. A anotação entra na saída da execução e na da saída já concluída passada de novo.
  • _json_default muda de core.py para utils.py, sem alteração, para servir ao checkpoint e à saída polars.
  • A referência (docs/reference/api.md e a versão em inglês) descreve as duas regras e o tipo da coluna do modelo na entrada sem linhas. A entrada da Saída polars: campo dict vira Struct com as chaves de todas as linhas, e o tipo da coluna depende dos dados #219 no CHANGELOG fica em "Alterado", porque a coluna do campo dict troca de tipo. Na entrada da Entrada polars: a conversão final falha depois das chamadas quando o campo é Enum, tupla ou união mista #218, dict[str, Any] sai da lista de campos que viram Object, porque agora sai como texto JSON.

Fora do escopo: o dict dentro de lista (list[dict[str, int]]) e o campo dict de um submodelo continuam saindo Struct com a união das chaves, como antes. A entrada polars sem linhas também fica como na main: a coluna do modelo que a entrada já traz mantém o tipo dela, e a que ela não traz sai String.

Testes

  • O campo dict em cada forma de anotação, com texto acentuado, valor que o JSON não serializa direto, Enum como valor e linha nula; retomada em memória de uma saída polars com a coluna em texto JSON; saída já concluída passada de novo; e, direto em from_pandas, a coluna dict sem nenhuma resposta, com o NaN que o checkpoint CSV relido traz.
  • A coluna toda nula e a lista só de listas vazias em cada tipo da lista; a coluna com algum valor e a de anotação fora da lista ficam com o tipo inferido; o modelo aninhado continua Struct.
  • Entrada polars de 0 linhas com a coluna do modelo Struct e Datetime com fuso sai com o schema da entrada, como na main.
  • Entrada pandas: o roteiro de casos da revisão sai idêntico na main e neste ramo.
  • Mutações nos dois arquivos de teste do PR, em polars 2.0.0: 33 de 35 derrubam ao menos um teste. Das duas que sobrevivem, _json_default sem tolist cai na suíte inteira, por um teste de checkpoint, e a busca do tipo por == no lugar de is sobrevive à suíte inteira. A da entrada vazia e as duas do Enum em _json_default também caem em polars 1.0.0.
  • Suíte: 1220 testes em Python 3.13 e em 3.11 na configuração do job unit, 1175 no job do codex, cobertura de ramos combinada em 100%, e 1219 nas versões mínimas (polars 1.0.0). ruff, ty, vulture, deptry e mkdocs build passam.

Closes #219

bdcdo added 2 commits October 13, 2026 15:54
…da anotação

Na saída polars, um campo dict saía Struct com as chaves de todas as
linhas: a chave ausente ficava igual à chave com valor nulo, e o dict
vazio deixava de ser vazio. O campo anotado como dict, inclusive dentro
de Optional, passa a sair como texto JSON, serializado como no
checkpoint, com nulo na linha sem resposta. O dict com valores de tipos
diferentes, que saía Object com aviso, sai do mesmo jeito. A saída
pandas não muda.

O tipo da coluna de um campo vinha só das respostas, e duas execuções do
mesmo modelo podiam devolver schemas diferentes: um Optional[int] todo
nulo saía String, e um list[str] só com listas vazias, List(Null). A
coluna sem nenhum valor recebe o tipo da anotação quando ela é str, int,
float, bool, date, datetime ou lista de um deles. Com outra anotação, ou
com algum valor na coluna, fica o tipo inferido. A entrada polars vazia
sai com o schema da entrada com linhas.

from_pandas recebe a anotação de cada campo do modelo, e _json_default
vai para utils.py, de onde core.py o importa.

Closes #219
A coluna de texto JSON do campo dict segue a regra do campo str quando não tem
valor: sai String, e não o tipo que o polars infere de uma coluna só de nulos,
como a do checkpoint CSV relido sem nenhuma resposta.
@bdcdo

bdcdo commented Oct 14, 2026

Copy link
Copy Markdown
Owner Author

Evidência do autor

Os mesmos casos rodados na main (c0cc0ce8) e no branch (f9949e12), no mesmo ambiente (Python 3.13, polars 2.0.0, pandas 3.0.6, pydantic 2.14.1), com o provider trocado por respostas fixas e o pacote importado de cada árvore pelo PYTHONPATH (conferido pelo dataframeit.__file__).

Caso da issue, na main:

dict[str, int] com chaves diferentes | Struct({'a': Int64, 'b': Int64}) | [{'a': 1, 'b': None}, {'a': None, 'b': 2}, {'a': None, 'b': None}]
Optional[int] todo nulo              | String       | [None, None, None]
list[str] só listas vazias           | List(Null)   | [[], [], []]

O mesmo caso, no branch:

dict[str, int] com chaves diferentes | String       | ['{"a": 1}', '{"b": 2}', '{}']
Optional[int] todo nulo              | Int64        | [None, None, None]
list[str] só listas vazias           | List(String) | [[], [], []]

Casos que o PR afirma cobrir, e os que afirma não mudar:

Campo e respostas Esperado main Branch
dict[str, int]: {"a": 1}, {"b": 2}, {} texto JSON Struct com a união das chaves String ['{"a": 1}', '{"b": 2}', '{}']
Optional[dict[str, int]]: {"a": 1}, None, {} texto JSON, nulo onde não há resposta Struct [{'a': 1}, None, {'a': None}] String ['{"a": 1}', None, '{}']
dict[str, Any]: {"a": 1}, {"a": [1]}, {} texto JSON, sem aviso Object com aviso String, sem aviso
dict[Cor, int], chave que o JSON recusa Object com aviso, como antes Object com aviso Object com aviso
list[dict[str, int]] sem mudança (fora do escopo) List(Struct) List(Struct), igual
Optional[int] todo nulo Int64 String Int64
Optional[str] todo nulo String String String
Optional[bool] todo nulo Boolean String Boolean
Optional[date] todo nulo Date String Date
list[str] só listas vazias List(String) List(Null) List(String)
Optional[Decimal] todo nulo, anotação fora da lista sem mudança String String, igual
Optional[int] com um valor sem mudança, tipo das respostas Int64 Int64, igual
entrada pandas, dict[str, int] sem mudança object com os dicts object com os dicts, igual

Testes no mesmo ambiente (extras dev e all): main 1184 passam e 105 são pulados; branch 1219 passam e 105 são pulados. Os pulados são os mesmos testes de extras ausentes nos dois lados.

Testes da main que o PR altera. Nenhum teste é apagado. Dois pontos mudam porque o comportamento que eles fixavam mudou por decisão:

  • No teste parametrizado da Entrada polars: a conversão final falha depois das chamadas quando o campo é Enum, tupla ou união mista #218 (test_campo_que_o_arrow_nao_tipa_sai_como_object_com_os_valores_do_pandas), o caso dict_com_valores_de_tipos_diferentes (dict[str, Any]) saía Object e agora sai texto JSON. Ele foi trocado por dict_com_chave_que_o_json_nao_aceita (dict[Cor, int]), que mantém o ramo Object coberto para campo dict; o dict[str, Any] misto passou a ser conferido, com avisos tratados como erro, nos testes novos do campo dict.
  • Uma chamada de from_pandas com model_columns=["nota"] passa a usar model_columns={"nota": float}, porque o parâmetro agora leva a anotação de cada campo.

SHA revisado: f9949e1

O retorno de entrada vazia passava as anotações a from_pandas, e a
coluna do modelo que a entrada de 0 linhas já trazia era convertida de
novo do pandas: um Struct ou um Decimal saía String, e um Datetime com
fuso perdia o fuso. O retorno volta a ser o da main, e um teste prende
o tipo da entrada no Struct e no Datetime com fuso.

A referência deixa de dizer que json.loads devolve o dict de cada
linha: o texto é o que o checkpoint grava, e o valor que o JSON não
representa sai como texto. Ela também diz o tipo da coluna do modelo
na entrada sem linhas. A entrada da #219 no CHANGELOG passa para
"Alterado", porque troca o tipo de uma coluna que o código do usuário
lia.

_to_polars devolve a série antes do explode quando a anotação não dá
tipo à coluna. O caso do Enum como valor de dict entra no teste
parametrizado do campo dict.
@bdcdo

bdcdo commented Oct 14, 2026

Copy link
Copy Markdown
Owner Author

Revisão independente de f9949e12 e ajustes em d337bb64

Um subagente novo revisou f9949e12 tentando derrubar a prova: a tabela do autor bateu nas 13 linhas no polars 1.0.0, 1.30.0 e 2.0.0; os testes do branch contra o src da main dão 22 falhas; em 157 cenários de saída polars, toda diferença contra a main é dict em texto JSON ou coluna sem valor com o tipo da anotação, com uma exceção; com entrada pandas, nenhuma diferença em 142 cenários. Nenhum achado bloqueava o merge.

A exceção, corrigida aqui: o retorno de entrada vazia passava a mandar as anotações a from_pandas, e numa entrada polars de 0 linhas que já trazia a coluna do modelo a coluna perdia o tipo da entrada (Struct virava String, Datetime com fuso perdia o fuso). O retorno voltou a ser o da main.

Também corrigidos: a referência afirmava que json.loads devolve o dict de cada linha, o que não vale para chave int, date, Decimal e Enum; a entrada do CHANGELOG foi para "Alterado", porque o tipo da coluna de dict muda para quem a lia como Struct; _to_polars deixa de fazer explode() à toa; e o Enum como valor de dict ganhou caso de teste, que derruba a mutação do ramo Enum de _json_default.

A revisão mediu também três efeitos que o PR traz sem ter sido pedido: na main com polars 1.30.0, um dict[str, datetime] com fuso +05:30 derrubava a saída com pânico do polars; a retomada de um modelo strict com campo dict levantava erro de campo incompatível; e, no polars 1.0.0, a linha None de um Optional[dict] virava {'a': None}. Os três deixam de acontecer.

Evidência dos ajustes da revisão

Commit d337bb64 sobre f9949e12. Três árvores comparadas: main (c0cc0ce8), f9949e12 (antes) e d337bb64 (depois). Cada script roda com PYTHONPATH=<árvore>/src e confere dataframeit.__file__ antes de imprimir. O provider é trocado por um dublê que devolve respostas fixas, sem rede.

1. Entrada polars vazia

O retorno de entrada vazia voltou a ser o da main (from_pandas(df_pandas, conversion_info, status_col), sem as anotações). O teste test_entrada_polars_vazia_sai_com_o_schema_da_entrada_com_linhas saiu, e test_entrada_polars_vazia_mantem_o_tipo_da_coluna_do_modelo_que_ja_traz prende o comportamento da main: entrada polars de 0 linhas com a coluna do modelo Struct e Datetime("us", "UTC") sai com o schema da entrada.

O script roda o modelo numa entrada de duas linhas (cheia), pega cheia.head(0) e roda de novo com resume=True (vazia-de-novo).

vazia.py
casos = [
 ("sub", Optional[Sub], [{"x":"p"},{"x":"q","y":1}]),
 ("decimal", Optional[Decimal], ["1.5", "2"]),
 ("datetime_tz", Optional[datetime], ["2024-01-02T03:04Z", None]),
 ("date", Optional[date], ["2024-01-02", None]),
 ("list_sub", list[Sub], [[{"x":"p"}], []]),
 ("enum", Optional[Cor], ["azul", None]),
 ("int", Optional[int], [1, None]),
 ("list_int", list[int], [[1], []]),
]
for nome, tipo, resp in casos:
    m = create_model("M", campo=(tipo, None if "Optional" in str(tipo) else ...))
    cheia, _, _ = rodar(pl.DataFrame({"texto":["a","b"]}), m, responde("campo", resp, modelo=m))
    r, c, av = rodar(cheia.head(0), m, resume=True)
    print(f"{nome:12} cheia={cheia['campo'].dtype} | vazia-de-novo={r['campo'].dtype} | chamadas={c.call_count} avisos={len(av)}")

Tipo de vazia-de-novo, polars 2.0.0 (Python 3.13). As quatro linhas que a revisão apontou voltam a sair como na main; as outras quatro não mudam em nenhuma árvore.

caso cheia main f9949e12 d337bb64
sub Struct({'x': String, 'y': Int64}) Struct({'x': String, 'y': Int64}) String Struct({'x': String, 'y': Int64})
decimal Decimal(precision=2, scale=1) Decimal(precision=2, scale=1) String Decimal(precision=2, scale=1)
datetime_tz Datetime('us', 'UTC') Datetime('us', 'UTC') Datetime('us', None) Datetime('us', 'UTC')
list_sub List(Struct({'x': String, 'y': Null})) List(Struct(...)) String List(Struct(...))
date Date Date Date Date
enum String String String String
int Int64 Int64 Int64 Int64
list_int List(Int64) List(Int64) List(Int64) List(Int64)

Com polars 1.0.0 (Python 3.11, ambiente do lowest-direct), as oito linhas saem iguais às de cima nas três árvores. Em todas, chamadas=0 avisos=0.

As dez linhas de entrada vazia do roteiro de casos da revisão (8-vazia*, com e sem a coluna do modelo na entrada) saem idênticas na main e em d337bb64 (diff vazio).

O teste novo, contra cada árvore (-o pythonpath=<árvore>/src):

main      1 passed
f9949e12  FAILED test_entrada_polars_vazia_mantem_o_tipo_da_coluna_do_modelo_que_ja_traz
E         {'sub': String} != {'sub': Struct({'x': String})}
E         {'quando': Datetime(time_unit='us', time_zone=None)} != {'quando': Datetime(time_unit='us', time_zone='UTC')}
d337bb64  passed

2. Documentação do campo dict

Antes (f9949e12, docs/reference/api.md): "json.loads devolve o dict de cada linha." Depois: "O texto é o mesmo que o checkpoint grava, e os valores que o JSON não representa saem nele como texto." O mesmo em docs/en/reference/api.md.

O caso que derruba a frase antiga, igual nas duas árvores (diff vazio):

chave_int      String {"1": "a"}             json.loads==original: False | json_decode: ok {'1': 'a'}
valor_date     String {"a": "2024-01-02"}    json.loads==original: False | json_decode: ok {'a': '2024-01-02'}
valor_decimal  String {"a": "1.5"}           json.loads==original: False | json_decode: ok {'a': '1.5'}
valor_enum     String {"a": "azul"}          json.loads==original: False | json_decode: ok {'a': 'azul'}
valor_nan      String {"a": NaN}             json.loads==original: False | json_decode: EXC ComputeError
valor_inf      String {"a": Infinity}        json.loads==original: True  | json_decode: EXC ComputeError

(valor_nan dá False também porque nan != nan; o ponto ali é o ComputeError do str.json_decode().)

Com o retorno de entrada vazia de volta ao da main, a regra "a coluna sem nenhum valor sai com o tipo da anotação" deixou de valer para a entrada sem linhas: um Optional[int] numa entrada polars de 0 linhas sai String, como na main. A referência ganhou uma frase sobre isso: "Na entrada sem linhas, a coluna do modelo que a entrada já traz mantém o tipo dela, e a que ela não traz sai String." Medido com nove anotações (int, bool, float, date, datetime, list[int], Optional[Sub], Decimal, str), com e sem track_tokens e status_column: todas String, iguais na main e em d337bb64.

3. CHANGELOG

Seção da entrada da #219 no bloco [Unreleased]:

$ git show <commit>:CHANGELOG.md | awk '/^## \[/{n++} n==1 && /^### /{s=$0} n==1 && /\(#219\)/{print s}'
f9949e12  ### Corrigido
d337bb64  ### Alterado

O texto da entrada não mudou; ela foi para o fim da lista de "Alterado".

4. _to_polars sem explode() quando a anotação não dá tipo

_to_polars chamado direto com 25 pares de anotação e valores, contando as chamadas de pl.Series.explode:

                       f9949e12   d337bb64
total explode:            25         12

As 13 chamadas que saíram são as dos casos sem tipo pela anotação (Decimal, Enum, união, submodelo, tupla, list[Decimal], typing.List, lista de item sem hash, Any, sem anotação). O tipo e os valores de cada um dos 25 casos são os mesmos nas duas árvores (diff vazio, tirada a contagem).

5. Enum como valor de dict

_CAMPOS_DICT ganhou "enum_como_valor": (dict[str, Cor], [{"a": Cor.AZUL}, {}], ['{"a": "azul"}', "{}"]).

Mutação que tira o ramo Enum de _json_default, contra tests/test_entrada_e_saida.py tests/test_multiple_data_types.py:

f9949e12  sobrevive  168 passed
d337bb64  morta      1 failed, 168 passed  test_campo_dict_sai_como_texto_json_em_cada_forma_de_anotacao[enum_como_valor]

Na main o caso novo reprova, como esperado: o dict sai Object com aviso.

Mutações em d337bb64

Cópia de src, PYTHONDONTWRITEBYTECODE=1, -p no:cacheprovider, -o pythonpath=<cópia>/src, import da cópia conferido em cada uma. Contra os dois arquivos de teste do PR:

mutação polars 2.0.0 polars 1.0.0
sem mutação 169 passed 169 passed
recolocar annotations no retorno de entrada vazia morta: test_entrada_polars_vazia_mantem_o_tipo_da_coluna_do_modelo_que_ja_traz morta, mesmo teste
tirar o ramo Enum de _json_default morta: [enum_como_valor] morta, mesmo teste
Enum pelo nome em _json_default morta: [enum_como_valor] morta, mesmo teste

O conjunto da revisão (31 mutações, sem a da entrada vazia, que o ajuste desfaz), as duas primeiras da tabela acima e duas do retorno antecipado de _to_polars (retirá-lo; inverter a condição) rodaram em polars 2.0.0: 33 de 35 mortas pelos dois arquivos. As duas que sobrevivem são as mesmas que sobreviviam em f9949e12: _json_default sem tolist (morta pela suíte inteira, em test_checkpoint_robusto.py::test_checkpoint_textual_serializa_array_e_enum_como_json) e a busca do tipo por == em vez de is (sobrevive à suíte inteira, 1220 passed).

Suítes, ao lado da main no mesmo ambiente

job main d337bb64
unit, Python 3.13 1184 passed, 105 skipped 1220 passed, 105 skipped
unit, Python 3.11 1184 passed, 105 skipped 1220 passed, 105 skipped
codex-provider, Python 3.13 1175 passed, 135 skipped 1175 passed, 171 skipped
lowest-direct, all excel polars search-all (polars 1.0.0, Python 3.11) 1183 passed, 106 skipped 1219 passed, 106 skipped

Os 36 testes a mais são os dos dois arquivos do PR; no job do codex, sem polars, eles são pulados pelo importorskip. Em relação a f9949e12 (1219 no unit), saiu um teste e entraram dois: o da entrada vazia e o caso enum_como_valor.

Cobertura de ramos combinada de unit 3.13, unit 3.11 e codex-provider 3.13, como o job coverage calcula (coverage combine e coverage report com fail_under = 100): TOTAL 3057 0 1198 0 100%. O job do codex em 3.11 não rodou aqui.

ruff format --check src tests, ruff check src tests, ty check, vulture e deptry src, com os extras do job quality: todos com código de saída 0. mkdocs build: código 0, com os mesmos avisos da main (o do mkdocs_static_i18n sobre navigation.instant).

A entrada pandas não mudou: o roteiro de casos da revisão, com entrada pandas, sai idêntico na main e em d337bb64 (142 linhas, diff vazio).

SHA revisado: d337bb6

@bdcdo
bdcdo merged commit c6e99a1 into main Oct 14, 2026
10 checks passed
bdcdo added a commit that referenced this pull request Oct 14, 2026
# Conflicts:
#	CHANGELOG.md
#	tests/test_entrada_e_saida.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Saída polars: campo dict vira Struct com as chaves de todas as linhas, e o tipo da coluna depende dos dados

1 participant