From fe6275033ee836e00610361c3300df90301285f2 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 13 Oct 2026 08:23:02 -0300 Subject: [PATCH 1/4] =?UTF-8?q?Campo=20do=20modelo=20que=20o=20Arrow=20n?= =?UTF-8?q?=C3=A3o=20tipa=20sai=20como=20Object=20na=20sa=C3=ADda=20polars?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Com entrada polars, a conversão final dos campos do modelo levantava ArrowInvalid, TypeError ou OverflowError depois de todas as chamadas quando as respostas não cabiam numa coluna tipada: Union[int, str] com número numa linha e texto em outra, tuple[int, str], Any e dict[str, Any] com tipos diferentes, lista de Enum, inteiro acima de 2**53 ao lado de float e inteiro acima do Int64. Sem checkpoint_path, as respostas se perdiam. A conversão passa a ser coluna a coluna. O Enum vai pelo valor, como no checkpoint. A coluna que o Arrow recusa sai com o tipo Object, com os objetos Python que a entrada pandas devolveria, e um aviso a nomeia. Na ida, a coluna Object é lida pelos objetos, para que a saída com uma coluna dessas possa ser retomada: no polars 0.20, o to_pandas() devolve bytes no lugar deles. A remoção das colunas de status ociosas sai de from_pandas para uma função própria. Closes #218 --- CHANGELOG.md | 1 + docs/en/reference/api.md | 2 + docs/reference/api.md | 2 + src/dataframeit/utils.py | 94 ++++++++++++++++---- tests/test_entrada_e_saida.py | 137 +++++++++++++++++++++++++++++- tests/test_multiple_data_types.py | 10 +++ 6 files changed, 226 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3971c966..1ad94655 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR ### Corrigido +- Com entrada polars, a saída deixa de falhar depois de todas as chamadas quando um campo do modelo guarda valores que o Arrow não reúne numa coluna, como `Union[int, str]` com número numa linha e texto em outra, `tuple[int, str]`, `Any` e `dict[str, Any]` com tipos diferentes, lista de `Enum`, inteiro acima de 2**53 ao lado de float e inteiro acima do `Int64`. A coluna sai com o tipo `Object`, com os objetos Python que a entrada pandas devolveria, e um aviso a nomeia. O campo `Enum` sai pelo valor do membro (#218). - A coluna `_trace` do agente único fica com as demais colunas de trace na ordem da saída, depois dos campos do modelo. Antes, numa entrada que já a trazia, os campos novos saíam depois dela. - Com entrada polars, as colunas que a execução não escreve saem do DataFrame de entrada, com o tipo e os valores de lá. Antes, a saída inteira era convertida de volta do pandas: um `Int64` com nulo voltava `Float64`, o que arredondava ids acima de 2**53, um `Date` voltava `Datetime`, e `Enum`, `Decimal`, `Array`, `Null` e o inteiro dentro de `Struct` e `List` também trocavam de tipo; uma coluna `Object` também trocava de tipo ou, com objetos que o Arrow não infere, levantava `ArrowInvalid` depois das chamadas (#195). - O operando de `in` e `not_in` em `condition` é conferido antes de processar, com `ValueError` quando não é lista, tupla, set ou frozenset. Antes, um operando que não era coleção, como `{"in": 5}`, levantava `TypeError` em cada linha depois das chamadas dos campos anteriores, e um texto, como `{"in": "pfj"}`, fazia busca de substring e aceitava `"pf"` (#182). diff --git a/docs/en/reference/api.md b/docs/en/reference/api.md index 7c72f100..d912ab3d 100644 --- a/docs/en/reference/api.md +++ b/docs/en/reference/api.md @@ -113,6 +113,8 @@ Returns data in the same format as input with extracted columns added. | `list` | `pd.DataFrame` with numeric index | | `dict` | `pd.DataFrame` with keys as index | +With polars input, the input columns that are not model fields come back as they went in, with the same type and the same values. In polars output, an `Enum` field comes out as the member's value. When the answers of a field do not fit a typed polars column, such as a `Union[int, str]` that received a number in one row and text in another, the column comes out with the `Object` type, holding the Python objects as the model returned them, and a warning names the columns; convert them before writing to parquet. + ### Added Columns | Column | When it exists | Content | diff --git a/docs/reference/api.md b/docs/reference/api.md index 54ce5d4d..8ecd13cf 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -113,6 +113,8 @@ Retorna dados no mesmo formato da entrada com colunas extraídas adicionadas. | `list` | `pd.DataFrame` com índice numérico | | `dict` | `pd.DataFrame` com chaves como índice | +Com entrada polars, as colunas de entrada que não são campo do modelo voltam como entraram, com o mesmo tipo e os mesmos valores. Na saída polars, um campo `Enum` sai pelo valor do membro. Quando as respostas de um campo não cabem numa coluna tipada do polars, como um `Union[int, str]` que recebeu número numa linha e texto em outra, a coluna sai com o tipo `Object`, com os objetos Python como o modelo os devolveu, e um aviso diz quais colunas são; converta-as antes de gravar em parquet. + ### Colunas Adicionadas | Coluna | Quando existe | Conteúdo | diff --git a/src/dataframeit/utils.py b/src/dataframeit/utils.py index 87f52c92..58214f8f 100644 --- a/src/dataframeit/utils.py +++ b/src/dataframeit/utils.py @@ -18,6 +18,7 @@ import sys import types import typing +import warnings from dataclasses import dataclass from enum import Enum from pathlib import Path @@ -155,7 +156,7 @@ def to_pandas(data: object) -> tuple[pd.DataFrame, ConversionInfo]: # polars DataFrame if pl is not None and isinstance(data, pl.DataFrame): - return data.to_pandas(), ConversionInfo( + return _polars_to_pandas(data), ConversionInfo( original_type=ORIGINAL_TYPE_POLARS_DF, polars_frame=data ) @@ -229,19 +230,7 @@ def from_pandas( original_type=ORIGINAL_TYPE_POLARS_DF if was_polars else ORIGINAL_TYPE_PANDAS_DF ) - error_col = "_error_details" - - # Remover colunas de status/erro se não houver erros nem linha pendente. A - # linha sem status é a que uma interrupção deixou para `resume=True`, e sem a - # coluna ela não se distingue das processadas. - if status_col in df.columns: - has_errors = (df[status_col] == "error").any() - has_pending = df[status_col].isna().any() - has_error_details = error_col in df.columns and df[error_col].notna().any() - - if not has_errors and not has_pending and not has_error_details: - cols_to_drop = [c for c in [status_col, error_col] if c in df.columns] - df = df.drop(columns=cols_to_drop) + df = _drop_idle_control_columns(df, status_col) # Series, list e dict viram DataFrame sem a coluna interna de texto; a # pandas.Series recupera o índice original. @@ -254,10 +243,79 @@ def from_pandas( df = _reorder_columns(df, status_col) if original_type in _POLARS_INPUTS and pl is not None: untouched = _untouched_columns(conversion_info.polars_frame, status_col, model_columns) - converted = pl.from_pandas(df[[col for col in df.columns if col not in untouched]]) - return pl.DataFrame( - [untouched[col] if col in untouched else converted[col] for col in df.columns] - ) + columns = [] + as_object = [] + for col in df.columns: + if col in untouched: + columns.append(untouched[col]) + continue + values = _enums_by_value(df[col]) + # A coluna com valores de tipos diferentes, como int e str, é recusada + # com ValueError (o ArrowInvalid do pyarrow) ou com TypeError, conforme + # a ordem dos valores, e o inteiro acima do Int64, com OverflowError. + try: + columns.append(pl.from_pandas(values)) + except (ValueError, TypeError, OverflowError): + columns.append(pl.Series(col, values.tolist(), dtype=pl.Object)) + as_object.append(col) + if as_object: + warnings.warn( + f"As colunas {as_object} têm valores de tipos que o polars não reúne numa " + "coluna tipada e saem com o tipo Object, com os objetos Python como o modelo " + "os devolveu. Converta-as antes de gravar em parquet.", + UserWarning, + stacklevel=3, + ) + return pl.DataFrame(columns) + return df + + +def _drop_idle_control_columns(df: pd.DataFrame, status_col: str) -> pd.DataFrame: + """Remove as colunas de status e de erro quando não há erro nem linha pendente. + + A linha sem status é a que uma interrupção deixou para `resume=True`, e sem + a coluna ela não se distingue das processadas. + """ + error_col = "_error_details" + if status_col not in df.columns: + return df + has_errors = (df[status_col] == "error").any() + has_pending = df[status_col].isna().any() + has_error_details = error_col in df.columns and df[error_col].notna().any() + if has_errors or has_pending or has_error_details: + return df + return df.drop(columns=[c for c in [status_col, error_col] if c in df.columns]) + + +def _enums_by_value(values: pd.Series) -> pd.Series: + """Troca o Enum pelo valor numa coluna de objetos. + + O Arrow não tipa o membro de um Enum, e é pelo valor que o checkpoint o + grava. O Enum dentro de lista ou dict fica como está, e a coluna sai Object. + A Series é montada com dtype=object porque `Series.map` voltaria a inferir + o tipo e levaria o inteiro com None a float. + """ + if values.dtype != object: + return values + return pd.Series( + [value.value if isinstance(value, Enum) else value for value in values], + index=values.index, + name=values.name, + dtype=object, + ) + + +def _polars_to_pandas(data: PolarsDataFrame) -> pd.DataFrame: + """Converte para pandas lendo a coluna Object pelos objetos que ela guarda. + + É a coluna que a saída traz quando o Arrow não tipa um campo do modelo, e + ela volta como entrada na retomada. No polars 0.20, o `to_pandas()` devolve + bytes sem sentido no lugar dos objetos. + """ + df = data.to_pandas() + for name, dtype in data.schema.items(): + if str(dtype) == "Object": + df[name] = pd.Series(data[name].to_list(), index=df.index, dtype=object) return df diff --git a/tests/test_entrada_e_saida.py b/tests/test_entrada_e_saida.py index fb754841..ddaa7b55 100644 --- a/tests/test_entrada_e_saida.py +++ b/tests/test_entrada_e_saida.py @@ -4,13 +4,14 @@ import warnings from datetime import date from decimal import Decimal -from typing import Optional, Union +from enum import Enum +from typing import Any, Optional, Union from unittest.mock import patch import numpy as np import pandas as pd import pytest -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, create_model from dataframeit import ProviderAbortError, ProviderError, core, dataframeit from dataframeit.core import _format_token_stats @@ -51,6 +52,20 @@ class ModeloFlagEstrita(BaseModel): relevante: bool +class Cor(Enum): + AZUL = "azul" + VERDE = "verde" + + +class ModeloCor(BaseModel): + cor: Cor + + +class ModeloMisto(BaseModel): + x: str + campo: Union[int, str] + + def _responde(**campos): def call_langchain(text, *args, **kwargs): return { @@ -858,3 +873,121 @@ def test_retomada_polars_de_campo_bool_gravado_como_inteiro(modelo): assert llm.call_count == 1 assert resultado["relevante"].dtype == pl.Boolean assert resultado["relevante"].to_list() == [True, False, True] + + +# ============================================================================= +# Entrada polars: campo do modelo que o Arrow não tipa +# ============================================================================= + +# Tipo do campo e as respostas de duas linhas que o Arrow não põe na mesma coluna. +_CAMPOS_QUE_O_ARROW_NAO_TIPA = { + "tupla_heterogenea": (tuple[int, str], [(1, "a"), (2, "b")]), + "uniao_com_numero_primeiro": (Union[int, str], [1, "dois"]), + "uniao_com_texto_primeiro": (Union[int, str], ["dois", 1]), + "any_com_tipos_diferentes": (Any, [1, "x"]), + "dict_com_valores_de_tipos_diferentes": (dict[str, Any], [{"a": 1}, {"a": [1]}]), + "lista_de_uniao": (list[Union[int, str]], [[1, "a"], [2]]), + "lista_de_enum": (list[Cor], [[Cor.AZUL], [Cor.VERDE]]), + "inteiro_que_o_float_arredonda_com_float": (Union[int, float], [_ID_GRANDE, 2.5]), + "inteiro_acima_do_int64": (int, [2**64 - 1, 1]), +} + + +def _responde_por_linha(campo, valores): + fila = iter(valores) + return lambda text, *args, **kwargs: {"data": {campo: next(fila)}, "usage": None} + + +@pytest.mark.parametrize("caso", _CAMPOS_QUE_O_ARROW_NAO_TIPA) +def test_campo_que_o_arrow_nao_tipa_sai_como_object_com_os_valores_do_pandas(caso): + pl = pytest.importorskip("polars") + tipo, valores = _CAMPOS_QUE_O_ARROW_NAO_TIPA[caso] + modelo = create_model("ModeloDoCaso", campo=(tipo, ...)) + pandas, _ = _rodar( + pd.DataFrame({"texto": ["a", "b"]}), + questions=modelo, + llm=_responde_por_linha("campo", valores), + ) + + with pytest.warns(UserWarning, match=r"\['campo'\].*Object"): + resultado, _ = _rodar( + pl.DataFrame({"texto": ["a", "b"]}), + questions=modelo, + llm=_responde_por_linha("campo", valores), + ) + + assert resultado["campo"].dtype == pl.Object + assert resultado["campo"].to_list() == pandas["campo"].tolist() == valores + + +def test_aviso_de_object_nomeia_so_a_coluna_que_o_arrow_nao_tipa(): + pl = pytest.importorskip("polars") + respostas = iter([{"x": "p", "campo": 1}, {"x": "q", "campo": "dois"}]) + + with pytest.warns(UserWarning, match="Object") as avisos: + resultado, _ = _rodar( + pl.DataFrame({"texto": ["a", "b"]}), + questions=ModeloMisto, + llm=lambda text, *args, **kwargs: {"data": next(respostas), "usage": None}, + ) + + assert resultado["x"].to_list() == ["p", "q"] + assert resultado["x"].dtype != pl.Object + (aviso,) = [a for a in avisos if "Object" in str(a.message)] + assert "['campo']" in str(aviso.message) + # O aviso aponta para quem chamou dataframeit(), que aqui é _rodar. + assert aviso.filename == __file__ + + +def test_enum_sai_pelo_valor_na_saida_polars(): + pl = pytest.importorskip("polars") + + with warnings.catch_warnings(): + warnings.simplefilter("error") + resultado, _ = _rodar( + pl.DataFrame({"texto": ["a", "b"]}), + questions=ModeloCor, + llm=_responde_por_linha("cor", [Cor.AZUL, Cor.VERDE]), + ) + + assert resultado["cor"].to_list() == ["azul", "verde"] + assert resultado["cor"].dtype != pl.Object + + +def test_campo_que_o_arrow_nao_tipa_com_entrada_polars_series(): + pl = pytest.importorskip("polars") + + with pytest.warns(UserWarning, match=r"\['campo'\].*Object"): + resultado, _ = _rodar( + pl.Series(["a", "b"]), + questions=create_model("ModeloDoCaso", campo=(Union[int, str], ...)), + llm=_responde_por_linha("campo", [1, "dois"]), + ) + + assert resultado["campo"].to_list() == [1, "dois"] + + +def test_saida_polars_com_coluna_object_e_retomada(): + pl = pytest.importorskip("polars") + modelo = create_model("ModeloDoCaso", campo=(Union[int, str], ...)) + with warnings.catch_warnings(record=True) as avisos: + warnings.simplefilter("always") + interrompida, _ = _rodar( + pl.DataFrame({"texto": ["a", "b", "c"]}), + questions=modelo, + llm=_responde_em_sequencia( + {"campo": 1}, {"campo": "dois"}, ProviderAbortError("limite") + ), + ) + mensagens = [str(a.message) for a in avisos] + assert any("interrompida" in mensagem for mensagem in mensagens) + assert any("Object" in mensagem for mensagem in mensagens) + assert interrompida["campo"].dtype == pl.Object + + with pytest.warns(UserWarning, match="Object"): + retomada, llm = _rodar( + interrompida, questions=modelo, llm=_responde_em_sequencia({"campo": 3}), resume=True + ) + + assert llm.call_count == 1 + assert retomada["campo"].to_list() == [1, "dois", 3] diff --git a/tests/test_multiple_data_types.py b/tests/test_multiple_data_types.py index 38411975..94c30e3e 100644 --- a/tests/test_multiple_data_types.py +++ b/tests/test_multiple_data_types.py @@ -334,6 +334,16 @@ def test_coluna_de_controle_que_estava_na_entrada_sai_do_pandas(self, coluna): assert result[coluna].to_list() == ["depois", "error"] + def test_coluna_que_o_arrow_nao_tipa_sai_como_object_com_aviso(self): + df = pd.DataFrame({"a": [1, "dois"], "b": ["x", "y"]}) + + with pytest.warns(UserWarning, match=r"\['a'\].*Object"): + result = from_pandas(df, True) + + assert result["a"].dtype == pl.Object + assert result["a"].to_list() == [1, "dois"] + assert result["b"].to_list() == ["x", "y"] + def test_coluna_de_status_personalizada_que_estava_na_entrada_sai_do_pandas(self): data = pl.DataFrame({"texto": ["a", "b"], "st": ["error", None]}) df, info = to_pandas(data) From 5d3d4c6cc218c8c9f306e9cf01048c63d62400d9 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 13 Oct 2026 13:22:30 -0300 Subject: [PATCH 2/4] =?UTF-8?q?Coluna=20que=20o=20polars=20n=C3=A3o=20tipa?= =?UTF-8?q?=20sai=20Object=20sem=20perder=20valor=20nem=20derrubar=20a=20s?= =?UTF-8?q?a=C3=ADda?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Com entrada polars, três casos ainda escapavam da degradação para Object: - O polars 0.20.23 a 0.20.31 converte a coluna de texto com um número e põe nulo no lugar dele, sem levantar erro. A coluna que volta com mais nulos do que tinha no pandas passa a sair Object. A Struct fica fora da comparação, porque o polars 0.20 e o 1.0 contam como nula a linha com todos os campos nulos. - O ComputeError do polars, como o do datetime com fuso +05:30, não é ValueError nem TypeError e derrubava a saída depois de todas as chamadas. Qualquer exceção da conversão leva a coluna a Object. - A coluna Object é montada por map_elements. O pl.Series com dtype=pl.Object olha o primeiro valor e levanta erro com range e dataclass no polars 0.20.0, e o array numpy de objetos que começa por texto não sai Object do 0.20.22 em diante. O Enum sai pelo valor também na coluna Object. O aviso e a referência falam em gravar em arquivo, porque CSV, IPC e JSON também recusam a coluna Object, e a referência diz que a coluna de entrada com nome de coluna adicionada não volta como entrou. Os testes novos conferem a linha para a qual o aviso aponta, o aviso único para duas colunas, o Enum em coluna Object, o nulo a mais (simulado, para não depender da versão instalada) e as colunas com nulo que continuam tipadas. --- CHANGELOG.md | 2 +- docs/en/reference/api.md | 2 +- docs/reference/api.md | 2 +- src/dataframeit/utils.py | 29 +++++++--- tests/test_entrada_e_saida.py | 91 ++++++++++++++++++++++++++++++- tests/test_multiple_data_types.py | 50 +++++++++++++++++ 6 files changed, 165 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ad94655..83f6b9c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,7 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR ### Corrigido -- Com entrada polars, a saída deixa de falhar depois de todas as chamadas quando um campo do modelo guarda valores que o Arrow não reúne numa coluna, como `Union[int, str]` com número numa linha e texto em outra, `tuple[int, str]`, `Any` e `dict[str, Any]` com tipos diferentes, lista de `Enum`, inteiro acima de 2**53 ao lado de float e inteiro acima do `Int64`. A coluna sai com o tipo `Object`, com os objetos Python que a entrada pandas devolveria, e um aviso a nomeia. O campo `Enum` sai pelo valor do membro (#218). +- Com entrada polars, a saída deixa de falhar depois de todas as chamadas quando um campo do modelo guarda valores que o polars não reúne numa coluna tipada, como `Union[int, str]` com número numa linha e texto em outra, `tuple[int, str]`, `Any` e `dict[str, Any]` com tipos diferentes, lista de `Enum`, inteiro acima de 2**53 ao lado de float, inteiro acima do `Int64` e `datetime` com fuso de deslocamento, como `+05:30`. A coluna sai com o tipo `Object`, com os objetos Python que a entrada pandas devolveria, e um aviso a nomeia. Nas versões do polars que convertem a coluna de texto com um número e põem nulo no lugar dele, a coluna também sai `Object`, com o número. O campo `Enum` sai pelo valor do membro, inclusive na coluna `Object` (#218). - A coluna `_trace` do agente único fica com as demais colunas de trace na ordem da saída, depois dos campos do modelo. Antes, numa entrada que já a trazia, os campos novos saíam depois dela. - Com entrada polars, as colunas que a execução não escreve saem do DataFrame de entrada, com o tipo e os valores de lá. Antes, a saída inteira era convertida de volta do pandas: um `Int64` com nulo voltava `Float64`, o que arredondava ids acima de 2**53, um `Date` voltava `Datetime`, e `Enum`, `Decimal`, `Array`, `Null` e o inteiro dentro de `Struct` e `List` também trocavam de tipo; uma coluna `Object` também trocava de tipo ou, com objetos que o Arrow não infere, levantava `ArrowInvalid` depois das chamadas (#195). - O operando de `in` e `not_in` em `condition` é conferido antes de processar, com `ValueError` quando não é lista, tupla, set ou frozenset. Antes, um operando que não era coleção, como `{"in": 5}`, levantava `TypeError` em cada linha depois das chamadas dos campos anteriores, e um texto, como `{"in": "pfj"}`, fazia busca de substring e aceitava `"pf"` (#182). diff --git a/docs/en/reference/api.md b/docs/en/reference/api.md index d912ab3d..c62bbbab 100644 --- a/docs/en/reference/api.md +++ b/docs/en/reference/api.md @@ -113,7 +113,7 @@ Returns data in the same format as input with extracted columns added. | `list` | `pd.DataFrame` with numeric index | | `dict` | `pd.DataFrame` with keys as index | -With polars input, the input columns that are not model fields come back as they went in, with the same type and the same values. In polars output, an `Enum` field comes out as the member's value. When the answers of a field do not fit a typed polars column, such as a `Union[int, str]` that received a number in one row and text in another, the column comes out with the `Object` type, holding the Python objects as the model returned them, and a warning names the columns; convert them before writing to parquet. +With polars input, the input columns that are not model fields and do not have the name of one of the [added columns](#added-columns) come back as they went in, with the same type and the same values. In polars output, an `Enum` field comes out as the member's value. When the answers of a field do not fit a typed polars column, such as a `Union[int, str]` that received a number in one row and text in another, the column comes out with the `Object` type, holding the Python objects as the model returned them, and a warning names the columns; convert them before writing to a file. ### Added Columns diff --git a/docs/reference/api.md b/docs/reference/api.md index 8ecd13cf..4d3f4394 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -113,7 +113,7 @@ Retorna dados no mesmo formato da entrada com colunas extraídas adicionadas. | `list` | `pd.DataFrame` com índice numérico | | `dict` | `pd.DataFrame` com chaves como índice | -Com entrada polars, as colunas de entrada que não são campo do modelo voltam como entraram, com o mesmo tipo e os mesmos valores. Na saída polars, um campo `Enum` sai pelo valor do membro. Quando as respostas de um campo não cabem numa coluna tipada do polars, como um `Union[int, str]` que recebeu número numa linha e texto em outra, a coluna sai com o tipo `Object`, com os objetos Python como o modelo os devolveu, e um aviso diz quais colunas são; converta-as antes de gravar em parquet. +Com entrada polars, as colunas de entrada que não são campo do modelo nem têm nome de uma das [colunas adicionadas](#colunas-adicionadas) voltam como entraram, com o mesmo tipo e os mesmos valores. Na saída polars, um campo `Enum` sai pelo valor do membro. Quando as respostas de um campo não cabem numa coluna tipada do polars, como um `Union[int, str]` que recebeu número numa linha e texto em outra, a coluna sai com o tipo `Object`, com os objetos Python como o modelo os devolveu, e um aviso diz quais colunas são; converta-as antes de gravar em arquivo. ### Colunas Adicionadas diff --git a/src/dataframeit/utils.py b/src/dataframeit/utils.py index 58214f8f..17e0143f 100644 --- a/src/dataframeit/utils.py +++ b/src/dataframeit/utils.py @@ -250,19 +250,34 @@ def from_pandas( columns.append(untouched[col]) continue values = _enums_by_value(df[col]) - # A coluna com valores de tipos diferentes, como int e str, é recusada - # com ValueError (o ArrowInvalid do pyarrow) ou com TypeError, conforme - # a ordem dos valores, e o inteiro acima do Int64, com OverflowError. + # As chamadas ao provedor já foram feitas, e a coluna que o polars ou o + # pyarrow recusam, seja qual for o erro (ValueError, TypeError, + # OverflowError, ComputeError), sai Object em vez de derrubar a saída. try: - columns.append(pl.from_pandas(values)) - except (ValueError, TypeError, OverflowError): - columns.append(pl.Series(col, values.tolist(), dtype=pl.Object)) + column = pl.from_pandas(values) + except Exception: # noqa: BLE001 (a recusa muda de tipo com os valores e a versão) + column = None + # Há versões do polars que, em vez de recusar a coluna de texto com um + # valor de outro tipo, a convertem com nulo no lugar dele: o nulo que o + # pandas não tinha é esse valor perdido. A Struct fica fora da comparação + # porque há versões que contam como nula a linha com todos os campos nulos. + if column is None or ( + column.dtype != pl.Struct and column.null_count() > values.isna().sum() + ): + # O construtor de Series olha o primeiro valor mesmo com dtype=pl.Object + # e, conforme a versão, levanta erro com range ou dataclass; do array + # numpy de objetos, a coluna que começa por texto não sai Object. O + # map_elements guarda o que a função devolve, sem inspecionar. + items = values.tolist() + positions = pl.Series(col, range(len(items)), dtype=pl.Int64) + column = positions.map_elements(items.__getitem__, return_dtype=pl.Object) as_object.append(col) + columns.append(column) if as_object: warnings.warn( f"As colunas {as_object} têm valores de tipos que o polars não reúne numa " "coluna tipada e saem com o tipo Object, com os objetos Python como o modelo " - "os devolveu. Converta-as antes de gravar em parquet.", + "os devolveu. Converta-as antes de gravar em arquivo.", UserWarning, stacklevel=3, ) diff --git a/tests/test_entrada_e_saida.py b/tests/test_entrada_e_saida.py index ddaa7b55..eeb2f797 100644 --- a/tests/test_entrada_e_saida.py +++ b/tests/test_entrada_e_saida.py @@ -1,8 +1,9 @@ """Entrada e saída de dataframeit(): colunas de controle, texto ausente, índice e avisos.""" +import linecache import logging import warnings -from datetime import date +from datetime import date, datetime, timedelta, timezone from decimal import Decimal from enum import Enum from typing import Any, Optional, Union @@ -879,7 +880,11 @@ def test_retomada_polars_de_campo_bool_gravado_como_inteiro(modelo): # Entrada polars: campo do modelo que o Arrow não tipa # ============================================================================= +_FUSO_DE_DESLOCAMENTO = timezone(timedelta(hours=5, minutes=30)) + # Tipo do campo e as respostas de duas linhas que o Arrow não põe na mesma coluna. +# O datetime com fuso de deslocamento é recusado pelo polars, com ComputeError, +# que não é ValueError nem TypeError. _CAMPOS_QUE_O_ARROW_NAO_TIPA = { "tupla_heterogenea": (tuple[int, str], [(1, "a"), (2, "b")]), "uniao_com_numero_primeiro": (Union[int, str], [1, "dois"]), @@ -890,6 +895,13 @@ def test_retomada_polars_de_campo_bool_gravado_como_inteiro(modelo): "lista_de_enum": (list[Cor], [[Cor.AZUL], [Cor.VERDE]]), "inteiro_que_o_float_arredonda_com_float": (Union[int, float], [_ID_GRANDE, 2.5]), "inteiro_acima_do_int64": (int, [2**64 - 1, 1]), + "datetime_com_fuso_de_deslocamento": ( + datetime, + [ + datetime(2024, 1, 2, tzinfo=_FUSO_DE_DESLOCAMENTO), + datetime(2024, 1, 3, tzinfo=_FUSO_DE_DESLOCAMENTO), + ], + ), } @@ -939,6 +951,69 @@ def test_aviso_de_object_nomeia_so_a_coluna_que_o_arrow_nao_tipa(): assert aviso.filename == __file__ +def test_aviso_de_object_aponta_para_a_linha_que_chamou_dataframeit(): + pl = pytest.importorskip("polars") + entrada = pl.DataFrame({"texto": ["a", "b"]}) + modelo = create_model("ModeloDoCaso", campo=(Union[int, str], ...)) + + # Sem _rodar no meio: com ele, o quadro seguinte da pilha também fica neste + # arquivo, e o stacklevel um nível acima do certo passaria. + with ( + patch( + "dataframeit.core.call_langchain", side_effect=_responde_por_linha("campo", [1, "x"]) + ), + patch("dataframeit.core.validate_provider_dependencies"), + warnings.catch_warnings(record=True) as avisos, + ): + warnings.simplefilter("always") + dataframeit(entrada, questions=modelo, prompt="Analise {texto}", track_tokens=False) + + (aviso,) = [a for a in avisos if "Object" in str(a.message)] + assert aviso.filename == __file__ + assert "dataframeit(entrada" in linecache.getline(aviso.filename, aviso.lineno) + + +def test_um_so_aviso_nomeia_todas_as_colunas_que_saem_object(): + pl = pytest.importorskip("polars") + modelo = create_model("ModeloDoCaso", a=(Union[int, str], ...), b=(Union[int, str], ...)) + + with warnings.catch_warnings(record=True) as avisos: + warnings.simplefilter("always") + resultado, _ = _rodar( + pl.DataFrame({"texto": ["a", "b"]}), + questions=modelo, + llm=_responde_em_sequencia({"a": 1, "b": "x"}, {"a": "dois", "b": 2}), + ) + + assert resultado["a"].dtype == resultado["b"].dtype == pl.Object + (aviso,) = [a for a in avisos if "Object" in str(a.message)] + assert "['a', 'b']" in str(aviso.message) + + +def test_coluna_que_a_conversao_devolve_com_nulo_a_mais_sai_como_object(monkeypatch): + """Simula o polars que converte o texto ao lado de número e anula o número.""" + pl = pytest.importorskip("polars") + converte = pl.from_pandas + + def converte_e_anula(valores, *args, **kwargs): + if valores.name == "campo": + return pl.Series("campo", ["dois", None], dtype=pl.Utf8) + return converte(valores, *args, **kwargs) + + monkeypatch.setattr(pl, "from_pandas", converte_e_anula) + + with pytest.warns(UserWarning, match=r"\['campo'\].*Object"): + resultado, _ = _rodar( + pl.DataFrame({"texto": ["a", "b"]}), + questions=ModeloMisto, + llm=_responde_em_sequencia({"x": "p", "campo": "dois"}, {"x": "q", "campo": 1}), + ) + + assert resultado["campo"].dtype == pl.Object + assert resultado["campo"].to_list() == ["dois", 1] + assert resultado["x"].dtype != pl.Object + + def test_enum_sai_pelo_valor_na_saida_polars(): pl = pytest.importorskip("polars") @@ -954,6 +1029,20 @@ def test_enum_sai_pelo_valor_na_saida_polars(): assert resultado["cor"].dtype != pl.Object +def test_enum_sai_pelo_valor_tambem_na_coluna_object(): + pl = pytest.importorskip("polars") + + with pytest.warns(UserWarning, match=r"\['campo'\].*Object"): + resultado, _ = _rodar( + pl.DataFrame({"texto": ["a", "b", "c"]}), + questions=create_model("ModeloDoCaso", campo=(Optional[Union[Cor, int]], ...)), + llm=_responde_por_linha("campo", [Cor.AZUL, None, 2]), + ) + + assert resultado["campo"].dtype == pl.Object + assert resultado["campo"].to_list() == ["azul", None, 2] + + def test_campo_que_o_arrow_nao_tipa_com_entrada_polars_series(): pl = pytest.importorskip("polars") diff --git a/tests/test_multiple_data_types.py b/tests/test_multiple_data_types.py index 94c30e3e..8cd914b1 100644 --- a/tests/test_multiple_data_types.py +++ b/tests/test_multiple_data_types.py @@ -7,9 +7,11 @@ - dict """ +import dataclasses import datetime import importlib.util import sys +import warnings import pandas as pd import pytest @@ -273,6 +275,11 @@ def test_dict_roundtrip(self): assert "resumo" in result.columns +@dataclasses.dataclass +class Ponto: + x: int + + @pytest.mark.skipif(not HAS_POLARS, reason="polars não instalado") class TestEntradaPolars: """A entrada polars guarda o frame original e não arredonda inteiro com nulo.""" @@ -344,6 +351,49 @@ def test_coluna_que_o_arrow_nao_tipa_sai_como_object_com_aviso(self): assert result["a"].to_list() == [1, "dois"] assert result["b"].to_list() == ["x", "y"] + @pytest.mark.parametrize( + "valores", + [["dois", 1], [None, "dois", 1], [b"dois", 1], [range(3), 1], [Ponto(1), 2]], + ids=["texto", "texto_depois_de_nulo", "bytes", "range", "dataclass"], + ) + def test_coluna_object_guarda_os_valores_sem_o_polars_os_inspecionar(self, valores): + """O primeiro valor é de um tipo pelo qual o construtor de Series escolhe caminho.""" + df = pd.DataFrame({"a": pd.Series(valores, dtype=object)}) + + with pytest.warns(UserWarning, match=r"\['a'\].*Object"): + result = from_pandas(df, True) + + assert result["a"].dtype == pl.Object + assert result["a"].to_list() == valores + + @pytest.mark.parametrize( + "valores", + [ + pd.Series([1.5, float("nan")]), + pd.Series([1, pd.NA], dtype="Int64"), + pd.Series([pd.Timestamp("2024-01-02"), pd.NaT]), + pd.Series(["x", None], dtype=object), + pd.Series([[1, None], None], dtype=object), + pd.Series([None, None], dtype=object), + pd.Series([{"n": None}, {"n": 1}], dtype=object), + ], + ids=[ + "float_com_nan", + "inteiro_com_na", + "datetime_com_nat", + "texto_com_none", + "lista_com_none_dentro", + "toda_nula", + "dict_com_todos_os_campos_nulos", + ], + ) + def test_coluna_com_nulo_continua_tipada_e_sem_aviso(self, valores): + with warnings.catch_warnings(): + warnings.simplefilter("error") + result = from_pandas(pd.DataFrame({"a": valores}), True) + + assert result["a"].dtype != pl.Object + def test_coluna_de_status_personalizada_que_estava_na_entrada_sai_do_pandas(self): data = pl.DataFrame({"texto": ["a", "b"], "st": ["error", None]}) df, info = to_pandas(data) From 5d5a656bf898b1f44eb7228512bb119bcca711d7 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 13 Oct 2026 14:25:36 -0300 Subject: [PATCH 3/4] =?UTF-8?q?Piso=20do=20polars=20sobe=20para=201.0=20e?= =?UTF-8?q?=20a=20coluna=20Object=20sai=20s=C3=B3=20da=20convers=C3=A3o=20?= =?UTF-8?q?recusada?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Os extras polars e all passam a exigir polars>=1.0. Nesse piso, o pl.from_pandas recusa com erro a coluna de texto com valor de outro tipo, e o to_pandas() devolve os objetos de uma coluna Object. A coluna de um campo do modelo vai para Object só quando a conversão levanta erro, e a retomada lê a coluna Object pelo to_pandas() do polars. A coluna Object continua montada por map_elements: o construtor de Series troca o membro de Enum pelo valor mesmo com dtype=pl.Object. O caso que chega lá com um membro de Enum é o Enum cujo valor é membro de outro Enum, agora coberto por teste. O aviso e a referência da API dizem que o Enum fora de lista ou dict sai pelo valor também na coluna Object. --- CHANGELOG.md | 3 +- docs/en/reference/api.md | 2 +- docs/reference/api.md | 2 +- pyproject.toml | 4 +-- src/dataframeit/utils.py | 43 ++++++------------------ tests/test_entrada_e_saida.py | 54 +++++++++++++++++-------------- tests/test_multiple_data_types.py | 52 +++-------------------------- 7 files changed, 50 insertions(+), 110 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83f6b9c2..8657978d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR - O resumo de uso do fim da execução deixa de sair por `print` no stdout e passa ao logger `dataframeit.stats`, em nível INFO. Sem logging configurado, ele sai em stderr; `logging.getLogger("dataframeit.stats").setLevel(logging.WARNING)` o silencia e mantém as colunas de tokens. O aviso de workers reduzidos por rate limit vira `UserWarning`, e a mensagem amigável do provider passa a fazer parte do aviso de falha da linha, ambos filtráveis por `warnings` e `pytest -W` (#181). - `dataframeit()` declara o tipo de retorno pelo tipo da entrada: DataFrame ou Series do polars devolve DataFrame do polars, e DataFrame ou Series do pandas, `list` e `dict` devolvem DataFrame do pandas. Antes, o retorno era a união dos dois, e o checker de tipos recusava `resultado["coluna"].tolist()` (#155). - O piso do pydantic sobe para 2.12. Na 2.11, uma `default_factory` que lê outro campo é chamada com os dados validados vazios quando esse campo falhou, e a retomada levantava o `KeyError` dela em vez do erro de campo incompatível (#154). +- Os extras `polars` e `all` passam a exigir `polars>=1.0`. No 0.20, há versões que convertem a coluna de texto com um valor de outro tipo e põem nulo no lugar dele, sem erro, e versões em que o `to_pandas()` de uma coluna `Object`, pelo qual ela volta na retomada, devolve bytes no lugar dos objetos ou falha (#218). - O extra `codex` passa a exigir `openai-codex==0.159.2`, que traz o runtime 0.159.2. Com o runtime 0.137, que o SDK anterior fixava, a conta do ChatGPT recebia HTTP 400 nos modelos lançados depois dele, como o `gpt-6-luna`, embora o catálogo da conta os anunciasse. O `effort` é conferido contra os valores que o SDK declara, agora com `max` e `ultra`, porque o enum do SDK novo aceita qualquer texto. - No provider `codex`, os códigos de erro `rateLimitExceeded` e `flexUnavailable` do runtime levantam `ProviderOverloadedError`, e a linha é repetida como no HTTP 429. - No provider `codex`, as flags `sleep_tool` e `view_image` do runtime ficam desligadas, e as ferramentas de pausa e de leitura de imagem local deixam de ser oferecidas ao modelo. @@ -35,7 +36,7 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR ### Corrigido -- Com entrada polars, a saída deixa de falhar depois de todas as chamadas quando um campo do modelo guarda valores que o polars não reúne numa coluna tipada, como `Union[int, str]` com número numa linha e texto em outra, `tuple[int, str]`, `Any` e `dict[str, Any]` com tipos diferentes, lista de `Enum`, inteiro acima de 2**53 ao lado de float, inteiro acima do `Int64` e `datetime` com fuso de deslocamento, como `+05:30`. A coluna sai com o tipo `Object`, com os objetos Python que a entrada pandas devolveria, e um aviso a nomeia. Nas versões do polars que convertem a coluna de texto com um número e põem nulo no lugar dele, a coluna também sai `Object`, com o número. O campo `Enum` sai pelo valor do membro, inclusive na coluna `Object` (#218). +- Com entrada polars, a saída deixa de falhar depois de todas as chamadas quando um campo do modelo guarda valores que o polars não reúne numa coluna tipada, como `Union[int, str]` com número numa linha e texto em outra, `tuple[int, str]`, `Any` e `dict[str, Any]` com tipos diferentes, lista de `Enum`, inteiro acima de 2**53 ao lado de float, inteiro acima do `Int64` e `datetime` com fuso de deslocamento, como `+05:30`. A coluna sai com o tipo `Object`, com os objetos Python que a entrada pandas devolveria, e um aviso a nomeia. O campo `Enum` sai pelo valor do membro, inclusive na coluna `Object` (#218). - A coluna `_trace` do agente único fica com as demais colunas de trace na ordem da saída, depois dos campos do modelo. Antes, numa entrada que já a trazia, os campos novos saíam depois dela. - Com entrada polars, as colunas que a execução não escreve saem do DataFrame de entrada, com o tipo e os valores de lá. Antes, a saída inteira era convertida de volta do pandas: um `Int64` com nulo voltava `Float64`, o que arredondava ids acima de 2**53, um `Date` voltava `Datetime`, e `Enum`, `Decimal`, `Array`, `Null` e o inteiro dentro de `Struct` e `List` também trocavam de tipo; uma coluna `Object` também trocava de tipo ou, com objetos que o Arrow não infere, levantava `ArrowInvalid` depois das chamadas (#195). - O operando de `in` e `not_in` em `condition` é conferido antes de processar, com `ValueError` quando não é lista, tupla, set ou frozenset. Antes, um operando que não era coleção, como `{"in": 5}`, levantava `TypeError` em cada linha depois das chamadas dos campos anteriores, e um texto, como `{"in": "pfj"}`, fazia busca de substring e aceitava `"pf"` (#182). diff --git a/docs/en/reference/api.md b/docs/en/reference/api.md index c62bbbab..095e6ba1 100644 --- a/docs/en/reference/api.md +++ b/docs/en/reference/api.md @@ -113,7 +113,7 @@ Returns data in the same format as input with extracted columns added. | `list` | `pd.DataFrame` with numeric index | | `dict` | `pd.DataFrame` with keys as index | -With polars input, the input columns that are not model fields and do not have the name of one of the [added columns](#added-columns) come back as they went in, with the same type and the same values. In polars output, an `Enum` field comes out as the member's value. When the answers of a field do not fit a typed polars column, such as a `Union[int, str]` that received a number in one row and text in another, the column comes out with the `Object` type, holding the Python objects as the model returned them, and a warning names the columns; convert them before writing to a file. +With polars input, the input columns that are not model fields and do not have the name of one of the [added columns](#added-columns) come back as they went in, with the same type and the same values. In polars output, an `Enum` field comes out as the member's value. When the answers of a field do not fit a typed polars column, such as a `Union[int, str]` that received a number in one row and text in another, the column comes out with the `Object` type, holding the Python objects the model returned, except an `Enum` member outside a list or dict, which comes out as its value there too, and a warning names the columns; convert them before writing to a file. ### Added Columns diff --git a/docs/reference/api.md b/docs/reference/api.md index 4d3f4394..d1153f7a 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -113,7 +113,7 @@ Retorna dados no mesmo formato da entrada com colunas extraídas adicionadas. | `list` | `pd.DataFrame` com índice numérico | | `dict` | `pd.DataFrame` com chaves como índice | -Com entrada polars, as colunas de entrada que não são campo do modelo nem têm nome de uma das [colunas adicionadas](#colunas-adicionadas) voltam como entraram, com o mesmo tipo e os mesmos valores. Na saída polars, um campo `Enum` sai pelo valor do membro. Quando as respostas de um campo não cabem numa coluna tipada do polars, como um `Union[int, str]` que recebeu número numa linha e texto em outra, a coluna sai com o tipo `Object`, com os objetos Python como o modelo os devolveu, e um aviso diz quais colunas são; converta-as antes de gravar em arquivo. +Com entrada polars, as colunas de entrada que não são campo do modelo nem têm nome de uma das [colunas adicionadas](#colunas-adicionadas) voltam como entraram, com o mesmo tipo e os mesmos valores. Na saída polars, um campo `Enum` sai pelo valor do membro. Quando as respostas de um campo não cabem numa coluna tipada do polars, como um `Union[int, str]` que recebeu número numa linha e texto em outra, a coluna sai com o tipo `Object`, com os objetos Python que o modelo devolveu, exceto o membro de `Enum` fora de lista ou dict, que também nela sai pelo valor, e um aviso diz quais colunas são; converta-as antes de gravar em arquivo. ### Colunas Adicionadas diff --git a/pyproject.toml b/pyproject.toml index a45ada53..1f3ff27f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,7 +86,7 @@ codex = [ "openai-codex==0.160.1", ] polars = [ - "polars>=0.20", + "polars>=1.0", "pyarrow>=10", ] excel = [ @@ -110,7 +110,7 @@ all = [ "langchain-tavily>=0.2.12", "langchain-exa>=1.0.0", "claude-agent-sdk>=0.1.48", - "polars>=0.20", + "polars>=1.0", "pyarrow>=10", "openpyxl>=3.1", ] diff --git a/src/dataframeit/utils.py b/src/dataframeit/utils.py index 17e0143f..3d6462af 100644 --- a/src/dataframeit/utils.py +++ b/src/dataframeit/utils.py @@ -156,7 +156,7 @@ def to_pandas(data: object) -> tuple[pd.DataFrame, ConversionInfo]: # polars DataFrame if pl is not None and isinstance(data, pl.DataFrame): - return _polars_to_pandas(data), ConversionInfo( + return data.to_pandas(), ConversionInfo( original_type=ORIGINAL_TYPE_POLARS_DF, polars_frame=data ) @@ -254,30 +254,22 @@ def from_pandas( # pyarrow recusam, seja qual for o erro (ValueError, TypeError, # OverflowError, ComputeError), sai Object em vez de derrubar a saída. try: - column = pl.from_pandas(values) - except Exception: # noqa: BLE001 (a recusa muda de tipo com os valores e a versão) - column = None - # Há versões do polars que, em vez de recusar a coluna de texto com um - # valor de outro tipo, a convertem com nulo no lugar dele: o nulo que o - # pandas não tinha é esse valor perdido. A Struct fica fora da comparação - # porque há versões que contam como nula a linha com todos os campos nulos. - if column is None or ( - column.dtype != pl.Struct and column.null_count() > values.isna().sum() - ): - # O construtor de Series olha o primeiro valor mesmo com dtype=pl.Object - # e, conforme a versão, levanta erro com range ou dataclass; do array - # numpy de objetos, a coluna que começa por texto não sai Object. O + columns.append(pl.from_pandas(values)) + except Exception: # noqa: BLE001 (a recusa muda de tipo com os valores) + # O construtor de Series inspeciona os valores mesmo com dtype=pl.Object: + # quando o primeiro valor não nulo é membro de Enum, troca cada valor + # pelo `.value`, e levanta erro no que não o tem, como o None. O # map_elements guarda o que a função devolve, sem inspecionar. items = values.tolist() positions = pl.Series(col, range(len(items)), dtype=pl.Int64) - column = positions.map_elements(items.__getitem__, return_dtype=pl.Object) + columns.append(positions.map_elements(items.__getitem__, return_dtype=pl.Object)) as_object.append(col) - columns.append(column) if as_object: warnings.warn( f"As colunas {as_object} têm valores de tipos que o polars não reúne numa " - "coluna tipada e saem com o tipo Object, com os objetos Python como o modelo " - "os devolveu. Converta-as antes de gravar em arquivo.", + "coluna tipada e saem com o tipo Object, com os objetos Python que o modelo " + "devolveu, exceto o Enum fora de lista ou dict, que sai pelo valor. Converta-as " + "antes de gravar em arquivo.", UserWarning, stacklevel=3, ) @@ -314,26 +306,11 @@ def _enums_by_value(values: pd.Series) -> pd.Series: return values return pd.Series( [value.value if isinstance(value, Enum) else value for value in values], - index=values.index, name=values.name, dtype=object, ) -def _polars_to_pandas(data: PolarsDataFrame) -> pd.DataFrame: - """Converte para pandas lendo a coluna Object pelos objetos que ela guarda. - - É a coluna que a saída traz quando o Arrow não tipa um campo do modelo, e - ela volta como entrada na retomada. No polars 0.20, o `to_pandas()` devolve - bytes sem sentido no lugar dos objetos. - """ - df = data.to_pandas() - for name, dtype in data.schema.items(): - if str(dtype) == "Object": - df[name] = pd.Series(data[name].to_list(), index=df.index, dtype=object) - return df - - def _untouched_columns( source: PolarsDataFrame | None, status_col: str, diff --git a/tests/test_entrada_e_saida.py b/tests/test_entrada_e_saida.py index eeb2f797..5b6b1e94 100644 --- a/tests/test_entrada_e_saida.py +++ b/tests/test_entrada_e_saida.py @@ -58,6 +58,13 @@ class Cor(Enum): VERDE = "verde" +class Tinta(Enum): + """O valor de cada membro é membro de outro Enum.""" + + CLARA = Cor.AZUL + ESCURA = Cor.VERDE + + class ModeloCor(BaseModel): cor: Cor @@ -990,30 +997,6 @@ def test_um_so_aviso_nomeia_todas_as_colunas_que_saem_object(): assert "['a', 'b']" in str(aviso.message) -def test_coluna_que_a_conversao_devolve_com_nulo_a_mais_sai_como_object(monkeypatch): - """Simula o polars que converte o texto ao lado de número e anula o número.""" - pl = pytest.importorskip("polars") - converte = pl.from_pandas - - def converte_e_anula(valores, *args, **kwargs): - if valores.name == "campo": - return pl.Series("campo", ["dois", None], dtype=pl.Utf8) - return converte(valores, *args, **kwargs) - - monkeypatch.setattr(pl, "from_pandas", converte_e_anula) - - with pytest.warns(UserWarning, match=r"\['campo'\].*Object"): - resultado, _ = _rodar( - pl.DataFrame({"texto": ["a", "b"]}), - questions=ModeloMisto, - llm=_responde_em_sequencia({"x": "p", "campo": "dois"}, {"x": "q", "campo": 1}), - ) - - assert resultado["campo"].dtype == pl.Object - assert resultado["campo"].to_list() == ["dois", 1] - assert resultado["x"].dtype != pl.Object - - def test_enum_sai_pelo_valor_na_saida_polars(): pl = pytest.importorskip("polars") @@ -1043,6 +1026,29 @@ def test_enum_sai_pelo_valor_tambem_na_coluna_object(): assert resultado["campo"].to_list() == ["azul", None, 2] +@pytest.mark.parametrize( + ("respostas", "valores"), + [ + ([Tinta.CLARA, Tinta.ESCURA], [Cor.AZUL, Cor.VERDE]), + ([Tinta.CLARA, None], [Cor.AZUL, None]), + ], + ids=["so_membros", "membro_e_nulo"], +) +def test_valor_de_enum_que_e_membro_de_outro_enum_fica_inteiro_na_coluna_object(respostas, valores): + """Sobra um membro de Enum na coluna Object, e o polars não o troca pelo valor dele.""" + pl = pytest.importorskip("polars") + + with pytest.warns(UserWarning, match=r"\['campo'\].*Object"): + resultado, _ = _rodar( + pl.DataFrame({"texto": ["a", "b"]}), + questions=create_model("ModeloDoCaso", campo=(Optional[Tinta], ...)), + llm=_responde_por_linha("campo", respostas), + ) + + assert resultado["campo"].dtype == pl.Object + assert resultado["campo"].to_list() == valores + + def test_campo_que_o_arrow_nao_tipa_com_entrada_polars_series(): pl = pytest.importorskip("polars") diff --git a/tests/test_multiple_data_types.py b/tests/test_multiple_data_types.py index 8cd914b1..bd68bb0f 100644 --- a/tests/test_multiple_data_types.py +++ b/tests/test_multiple_data_types.py @@ -7,11 +7,9 @@ - dict """ -import dataclasses import datetime import importlib.util import sys -import warnings import pandas as pd import pytest @@ -33,6 +31,7 @@ ORIGINAL_TYPE_POLARS_DF, ORIGINAL_TYPE_POLARS_SERIES, ConversionInfo, + _enums_by_value, from_pandas, to_pandas, ) @@ -275,11 +274,6 @@ def test_dict_roundtrip(self): assert "resumo" in result.columns -@dataclasses.dataclass -class Ponto: - x: int - - @pytest.mark.skipif(not HAS_POLARS, reason="polars não instalado") class TestEntradaPolars: """A entrada polars guarda o frame original e não arredonda inteiro com nulo.""" @@ -351,48 +345,10 @@ def test_coluna_que_o_arrow_nao_tipa_sai_como_object_com_aviso(self): assert result["a"].to_list() == [1, "dois"] assert result["b"].to_list() == ["x", "y"] - @pytest.mark.parametrize( - "valores", - [["dois", 1], [None, "dois", 1], [b"dois", 1], [range(3), 1], [Ponto(1), 2]], - ids=["texto", "texto_depois_de_nulo", "bytes", "range", "dataclass"], - ) - def test_coluna_object_guarda_os_valores_sem_o_polars_os_inspecionar(self, valores): - """O primeiro valor é de um tipo pelo qual o construtor de Series escolhe caminho.""" - df = pd.DataFrame({"a": pd.Series(valores, dtype=object)}) - - with pytest.warns(UserWarning, match=r"\['a'\].*Object"): - result = from_pandas(df, True) - - assert result["a"].dtype == pl.Object - assert result["a"].to_list() == valores - - @pytest.mark.parametrize( - "valores", - [ - pd.Series([1.5, float("nan")]), - pd.Series([1, pd.NA], dtype="Int64"), - pd.Series([pd.Timestamp("2024-01-02"), pd.NaT]), - pd.Series(["x", None], dtype=object), - pd.Series([[1, None], None], dtype=object), - pd.Series([None, None], dtype=object), - pd.Series([{"n": None}, {"n": 1}], dtype=object), - ], - ids=[ - "float_com_nan", - "inteiro_com_na", - "datetime_com_nat", - "texto_com_none", - "lista_com_none_dentro", - "toda_nula", - "dict_com_todos_os_campos_nulos", - ], - ) - def test_coluna_com_nulo_continua_tipada_e_sem_aviso(self, valores): - with warnings.catch_warnings(): - warnings.simplefilter("error") - result = from_pandas(pd.DataFrame({"a": valores}), True) + def test_troca_de_enum_devolve_a_mesma_coluna_quando_ela_nao_e_de_objetos(self): + valores = pd.Series([1.5, 2.5]) - assert result["a"].dtype != pl.Object + assert _enums_by_value(valores) is valores def test_coluna_de_status_personalizada_que_estava_na_entrada_sai_do_pandas(self): data = pl.DataFrame({"texto": ["a", "b"], "st": ["error", None]}) From 53d1986aec5cd8548c5618d7de34a68d9f5e1774 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 13 Oct 2026 15:16:20 -0300 Subject: [PATCH 4/4] =?UTF-8?q?Coluna=20com=20nulo=20continua=20tipada=20n?= =?UTF-8?q?a=20sa=C3=ADda=20polars:=20teste=20de=20volta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sem o teste, mandar para Object toda coluna com nulo (float com NaN, inteiro com NA, datetime com NaT, texto com None, lista com None, coluna toda nula, dict com campo nulo) passava na suíte inteira. Os comentários de from_pandas dizem que o pânico do polars não é capturado e que só algumas versões do construtor de Series inspecionam os valores. O teste das colunas de entrada deixa de tratar Decimal à parte, que Series.equals compara desde o piso atual. --- src/dataframeit/utils.py | 12 +++++++----- tests/test_entrada_e_saida.py | 4 ++-- tests/test_multiple_data_types.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/dataframeit/utils.py b/src/dataframeit/utils.py index 3d6462af..ef6e5189 100644 --- a/src/dataframeit/utils.py +++ b/src/dataframeit/utils.py @@ -251,15 +251,17 @@ def from_pandas( continue values = _enums_by_value(df[col]) # As chamadas ao provedor já foram feitas, e a coluna que o polars ou o - # pyarrow recusam, seja qual for o erro (ValueError, TypeError, + # pyarrow recusam, seja qual for a exceção (ValueError, TypeError, # OverflowError, ComputeError), sai Object em vez de derrubar a saída. + # O pânico do polars não é Exception e passa por aqui. try: columns.append(pl.from_pandas(values)) except Exception: # noqa: BLE001 (a recusa muda de tipo com os valores) - # O construtor de Series inspeciona os valores mesmo com dtype=pl.Object: - # quando o primeiro valor não nulo é membro de Enum, troca cada valor - # pelo `.value`, e levanta erro no que não o tem, como o None. O - # map_elements guarda o que a função devolve, sem inspecionar. + # Há versões do polars em que o construtor de Series inspeciona os + # valores mesmo com dtype=pl.Object: quando o primeiro valor não nulo + # é membro de Enum, troca cada valor pelo `.value`, e levanta erro no + # que não o tem, como o None. O map_elements guarda o que a função + # devolve, sem inspecionar. items = values.tolist() positions = pl.Series(col, range(len(items)), dtype=pl.Int64) columns.append(positions.map_elements(items.__getitem__, return_dtype=pl.Object)) diff --git a/tests/test_entrada_e_saida.py b/tests/test_entrada_e_saida.py index 5b6b1e94..5ddc4bda 100644 --- a/tests/test_entrada_e_saida.py +++ b/tests/test_entrada_e_saida.py @@ -719,8 +719,8 @@ def test_coluna_polars_que_a_execucao_nao_escreve_volta_como_entrou(tipo): assert resultado.columns == ["texto", "c", "x"] assert resultado["c"].dtype == entrada["c"].dtype - # Series.equals não compara Object, nem Decimal no polars 0.20. - if entrada["c"].dtype in (pl.Object, pl.Decimal): + # Series.equals não compara Object. + if entrada["c"].dtype == pl.Object: assert resultado["c"].to_list() == entrada["c"].to_list() else: assert resultado["c"].equals(entrada["c"]) diff --git a/tests/test_multiple_data_types.py b/tests/test_multiple_data_types.py index bd68bb0f..ac0439f1 100644 --- a/tests/test_multiple_data_types.py +++ b/tests/test_multiple_data_types.py @@ -10,6 +10,7 @@ import datetime import importlib.util import sys +import warnings import pandas as pd import pytest @@ -345,6 +346,34 @@ def test_coluna_que_o_arrow_nao_tipa_sai_como_object_com_aviso(self): assert result["a"].to_list() == [1, "dois"] assert result["b"].to_list() == ["x", "y"] + @pytest.mark.parametrize( + "valores", + [ + pd.Series([1.5, float("nan")]), + pd.Series([1, pd.NA], dtype="Int64"), + pd.Series([pd.Timestamp("2024-01-02"), pd.NaT]), + pd.Series(["x", None], dtype=object), + pd.Series([[1, None], None], dtype=object), + pd.Series([None, None], dtype=object), + pd.Series([{"n": None}, {"n": 1}], dtype=object), + ], + ids=[ + "float_com_nan", + "inteiro_com_na", + "datetime_com_nat", + "texto_com_none", + "lista_com_none_dentro", + "toda_nula", + "dict_com_todos_os_campos_nulos", + ], + ) + def test_coluna_com_nulo_continua_tipada_e_sem_aviso(self, valores): + with warnings.catch_warnings(): + warnings.simplefilter("error") + result = from_pandas(pd.DataFrame({"a": valores}), True) + + assert result["a"].dtype != pl.Object + def test_troca_de_enum_devolve_a_mesma_coluna_quando_ela_nao_e_de_objetos(self): valores = pd.Series([1.5, 2.5])