Repository navigation
Saída polars: campo dict em texto JSON e coluna sem valor com o tipo da anotação - #232
Conversation
…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.
Evidência do autorOs mesmos casos rodados na Caso da issue, na O mesmo caso, no branch: Casos que o PR afirma cobrir, e os que afirma não mudar:
Testes no mesmo ambiente (extras Testes da
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.
Revisão independente de
|
| 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
# Conflicts: # CHANGELOG.md # tests/test_entrada_e_saida.py
O quê
Dois comportamentos da saída polars que a saída pandas não tem.
Campo
dict. Comcampo: dict[str, int]e respostas{"a": 1},{"b": 2}e{}, a coluna saíaStruct({"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íaInt64quando havia algum inteiro eStringquando todas as respostas eram nulas; umlist[str]só com listas vazias saíaList(Null). Duas execuções do mesmo modelo devolviam schemas diferentes.Como
dictsai como texto JSON, numa colunaString, com o mesmo texto que o checkpoint grava; o valor que o JSON não representa, como uma data, sai nele como texto. Vale paradict,dict[str, int],Optional[dict[...]]edictdedict. O dict com chave que o JSON não aceita, como umEnum, continua saindoObjectcom aviso.str,int,float,bool,date,datetimeou lista de um deles. Com qualquer outra anotação, e sempre que há algum valor, o tipo continua vindo das respostas.from_pandaspassa 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_defaultmuda decore.pyparautils.py, sem alteração, para servir ao checkpoint e à saída polars.docs/reference/api.mde 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 campodicttroca 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 viramObject, 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 saindoStructcom a união das chaves, como antes. A entrada polars sem linhas também fica como namain: a coluna do modelo que a entrada já traz mantém o tipo dela, e a que ela não traz saiString.Testes
Enumcomo 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 emfrom_pandas, a coluna dict sem nenhuma resposta, com oNaNque o checkpoint CSV relido traz.Struct.StructeDatetimecom fuso sai com o schema da entrada, como namain.maine neste ramo._json_defaultsemtolistcai na suíte inteira, por um teste de checkpoint, e a busca do tipo por==no lugar deissobrevive à suíte inteira. A da entrada vazia e as duas doEnumem_json_defaulttambém caem em polars 1.0.0.unit, 1175 no job docodex, cobertura de ramos combinada em 100%, e 1219 nas versões mínimas (polars 1.0.0).ruff,ty,vulture,deptryemkdocs buildpassam.Closes #219