From c6dfe74a8d55b1e44c54a4556c84b6c962443d2f Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 13 Oct 2026 08:12:58 -0300 Subject: [PATCH 1/4] Colunas de entrada polars saem do DataFrame original MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Com entrada polars, a saída inteira era convertida de volta do pandas, e as colunas que a execução não escreve voltavam com outro tipo: Int64 com nulo virava Float64 e arredondava ids acima de 2**53, Date virava Datetime, e Enum, Decimal, Array, Null e o inteiro dentro de Struct e List também mudavam. Uma coluna Object levantava ArrowInvalid depois das chamadas. A conversão guarda o DataFrame polars de entrada. Na saída, as colunas do usuário que não são campo do modelo saem dele, e só os campos do modelo e as colunas de controle são convertidos do pandas. A família de cada coluna vem de _column_rank, que _reorder_columns também usa. Na ida, o inteiro com nulo vira inteiro anulável do pandas, para que o campo inteiro do modelo numa saída interrompida não seja arredondado na retomada. Closes #195 Co-authored-by: mayuriphad --- CHANGELOG.md | 1 + src/dataframeit/core.py | 4 +- src/dataframeit/utils.py | 115 +++++++++++----- tests/test_entrada_e_saida.py | 215 +++++++++++++++++++++++++++++- tests/test_multiple_data_types.py | 62 +++++++++ 5 files changed, 358 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d6b09ba..e319f4cd 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, 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` levantava `ArrowInvalid` depois das chamadas. Na retomada de uma saída polars, o campo inteiro do modelo com linha pendente deixa de ser arredondado (#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). - A retomada de um modelo com `strict=True` deixa de acusar como incompatíveis os campos que o checkpoint grava em outra forma, como a data em texto, a tupla como lista, o `Enum` pelo valor e o inteiro que a coluna com linha pendente relê como float. A linha recusada pela validação configurada é validada de novo sem strict, e cada campo recusado só é aceito quando o valor validado diz o mesmo que o relido; `"3"` num campo `int` strict continua acusado. A volta ao tipo declarado passa a valer também para os campos escalares: data, `datetime`, `Enum`, `int` e `bool` relidos do CSV, do XLSX ou do parquet deixam de misturar o tipo da execução com o do arquivo. Os campos de `Enum` de valores em texto são relidos como texto do CSV e do XLSX, como os de `Literal`, e um valor como `"1"` deixa de voltar como número. O checkpoint grava o `Enum` escalar pelo valor; antes, o CSV e o XLSX gravavam `"Cor.AZUL"`, que a retomada recusava, e o parquet falhava ao gravar (#178). - A forma de `condition` em `json_schema_extra` é conferida antes de processar, com `ValueError` que diz o que é aceito: dict com `field` em texto e um operador (`equals`, `not_equals`, `in`, `not_in` ou `exists`), ou callable. Antes, `condition={}` e os demais valores falsos, como `False` ou `[]`, rodavam o campo sem aviso, embora `evaluate_condition` trate o dict vazio como condição falsa; `field` que não era texto levantava `AttributeError`; e as demais formas eram avisadas a cada linha, com o campo pulado (#163). diff --git a/src/dataframeit/core.py b/src/dataframeit/core.py index b7df9f39..bae66793 100644 --- a/src/dataframeit/core.py +++ b/src/dataframeit/core.py @@ -1425,7 +1425,7 @@ def dataframeit( # noqa: C901, PLR0912, PLR0913, PLR0915, PLR0917 (API pública _apply_processed_values(df_pandas, processed_values) if complex_fields: normalize_complex_columns(df_pandas, complex_fields) - return from_pandas(df_pandas, conversion_info, status_col) + return from_pandas(df_pandas, conversion_info, status_col, expected_columns) # Os resultados são gravados por rótulo (df.at); com rótulo repetido, o # resultado de uma linha cairia em outra. Um checkpoint já concluído, que @@ -1600,7 +1600,7 @@ def dataframeit( # noqa: C901, PLR0912, PLR0913, PLR0915, PLR0917 (API pública ) # Retornar no formato original (remove colunas de status/erro se não houver erros) - return from_pandas(df_pandas, conversion_info, status_col) + return from_pandas(df_pandas, conversion_info, status_col, expected_columns) def _setup_columns( # noqa: C901, PLR0912, PLR0913 (uma família de colunas por recurso ligado) diff --git a/src/dataframeit/utils.py b/src/dataframeit/utils.py index 93bce6ee..2a223893 100644 --- a/src/dataframeit/utils.py +++ b/src/dataframeit/utils.py @@ -28,9 +28,10 @@ from pydantic import BaseModel if TYPE_CHECKING: - from collections.abc import Callable, Hashable + from collections.abc import Callable, Collection, Hashable from polars import DataFrame as PolarsDataFrame + from polars import Series as PolarsSeries # Import opcional de Polars try: @@ -90,6 +91,8 @@ class ConversionInfo: original_type: str original_index: pd.Index | list | None = None # Índice/chaves originais para reconversão series_name: Hashable | None = None # Nome original da Series (se aplicável) + # DataFrame polars de entrada, de onde a saída tira as colunas que a execução não escreve + polars_frame: PolarsDataFrame | None = None def parse_json(resposta: str) -> dict: @@ -152,7 +155,9 @@ 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(original_type=ORIGINAL_TYPE_POLARS_DF) + return _polars_to_pandas(data), ConversionInfo( + original_type=ORIGINAL_TYPE_POLARS_DF, polars_frame=data + ) # pandas Series if isinstance(data, pd.Series): @@ -199,6 +204,7 @@ def from_pandas( df: pd.DataFrame, conversion_info: ConversionInfo | bool, # noqa: FBT001 (bool aceito por retrocompatibilidade) status_col: str = "_dataframeit_status", + model_columns: Collection[str] = (), ) -> pd.DataFrame | PolarsDataFrame: """Converte DataFrame pandas de volta para o formato original. @@ -210,6 +216,8 @@ def from_pandas( conversion_info: ConversionInfo com metadados da conversão original, ou bool para retrocompatibilidade (True = era polars). status_col: Nome da coluna de status (o `status_column` do usuário). + model_columns: Campos do modelo que a execução gravou. Na saída polars, + só eles e as colunas de controle são convertidos do pandas. Returns: Dados no formato original (DataFrame, Series, list ou dict). @@ -245,51 +253,86 @@ def from_pandas( df = _reorder_columns(df, status_col) if original_type in _POLARS_INPUTS and pl is not None: - return pl.from_pandas(df) + 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] + ) return df -def _reorder_columns(df: pd.DataFrame, status_col: str = "_dataframeit_status") -> pd.DataFrame: - """Reordena colunas para que internas fiquem no final. +def _polars_to_pandas(data: PolarsDataFrame) -> pd.DataFrame: + """Converte para pandas mantendo o inteiro com nulo como inteiro anulável. + + O `to_pandas()` do polars leva o inteiro com nulo a float64, que arredonda + valores acima de 2**53. É o caso do campo inteiro do modelo numa saída + interrompida, que volta como entrada na retomada com as linhas pendentes + nulas, e de toda coluna inteira com nulo que o checkpoint grava. + """ + df = data.to_pandas() + for name, dtype in data.schema.items(): + if dtype.is_integer() and data[name].null_count() > 0: + df[name] = pd.array(data[name].to_list(), dtype=str(dtype)) + return df + + +def _untouched_columns( + source: PolarsDataFrame | None, + status_col: str, + model_columns: Collection[str], +) -> dict[str, PolarsSeries]: + """Colunas da entrada polars que a execução não escreve, pelo nome. + + São as colunas do usuário que não são campo do modelo. A saída as tira de + `source`, o DataFrame polars de entrada: convertidas de volta do pandas, + elas trocariam de tipo (Date por Datetime, Enum por Categorical) e trariam + arredondado o inteiro acima de 2**53 dentro de Struct e List, que passa por + float na ida. As linhas da saída estão na ordem das de `source`, porque a + execução grava por rótulo e não remove nem reordena linhas. + + Sem `source` (entrada polars.Series, que só traz o texto), todas as colunas + são da execução. + """ + if source is None: + return {} + return { + col: source[col] + for col in source.columns + if col not in model_columns and _column_rank(col, status_col) == _USER_COLUMN + } + + +# Posição da coluna do usuário em _column_rank; as demais são as que a execução acrescenta. +_USER_COLUMN = (0, 0) + + +def _column_rank(col: Hashable, status_col: str) -> tuple[int, int]: + """Família da coluna e posição dentro dela, na ordem em que a saída as traz. - Ordem final das colunas: 1. Colunas do usuário (originais + campos do modelo) 2. Colunas de trace (_trace_*) 3. Colunas de busca (_search_credits) - 4. Colunas de tokens (_input_tokens, _cached_input_tokens, _output_tokens, - _reasoning_tokens) - 5. Colunas de controle (_dataframeit_status, _error_details) - - Args: - df: DataFrame a reordenar. - status_col: Nome da coluna de status. - - Returns: - DataFrame com colunas reordenadas. + 4. Colunas de tokens, na ordem de TOKEN_COLUMNS + 5. Colunas de controle (status e _error_details) """ - # Separar colunas por categoria - user_cols = [] - trace_cols = [] - search_cols = [] - token_cols = [col for col in TOKEN_COLUMNS if col in df.columns] - status_cols = [] + if str(col).startswith("_trace_"): + return (1, 0) + if col == "_search_credits": + return (2, 0) + if col in TOKEN_COLUMNS: + return (3, TOKEN_COLUMNS.index(col)) + if col in (status_col, "_error_details"): + return (4, 0) + return _USER_COLUMN - for col in df.columns: - if str(col).startswith("_trace_"): - trace_cols.append(col) - elif col == "_search_credits": - search_cols.append(col) - elif col in TOKEN_COLUMNS: - continue - elif col in (status_col, "_error_details"): - status_cols.append(col) - else: - user_cols.append(col) - # Ordem final: user -> trace -> search -> tokens -> status - new_order = user_cols + trace_cols + search_cols + token_cols + status_cols +def _reorder_columns(df: pd.DataFrame, status_col: str = "_dataframeit_status") -> pd.DataFrame: + """Reordena as colunas para que as da execução fiquem no final. - return df[new_order] + A ordem é a de `_column_rank`; dentro de cada família, menos a de tokens, + as colunas ficam na ordem em que estavam. + """ + return df[sorted(df.columns, key=lambda col: _column_rank(col, status_col))] # ============================================================================= diff --git a/tests/test_entrada_e_saida.py b/tests/test_entrada_e_saida.py index 324fd9c0..806b26a1 100644 --- a/tests/test_entrada_e_saida.py +++ b/tests/test_entrada_e_saida.py @@ -2,6 +2,9 @@ import logging import warnings +from datetime import date +from decimal import Decimal +from typing import Optional, Union from unittest.mock import patch import numpy as np @@ -9,7 +12,7 @@ import pytest from pydantic import BaseModel -from dataframeit import ProviderError, core, dataframeit +from dataframeit import ProviderAbortError, ProviderError, core, dataframeit from dataframeit.core import _format_token_stats @@ -21,6 +24,23 @@ class ModeloLista(BaseModel): tags: list[str] +class ModeloNotaInteira(BaseModel): + nota: int + + +class ModeloNotaDecimal(BaseModel): + nota: float + + +class ModeloNotaMista(BaseModel): + nota: Union[int, float] + + +class ModeloContagem(BaseModel): + x: str + n: Optional[int] = None + + def _responde(**campos): def call_langchain(text, *args, **kwargs): return { @@ -31,6 +51,19 @@ def call_langchain(text, *args, **kwargs): return call_langchain +def _responde_em_sequencia(*respostas): + """Uma resposta por chamada; a que for exceção é levantada.""" + fila = iter(respostas) + + def call_langchain(text, *args, **kwargs): + resposta = next(fila) + if isinstance(resposta, Exception): + raise resposta + return {"data": resposta, "usage": None} + + return call_langchain + + def _rodar(df, questions=Modelo, llm=None, **opcoes): llm = llm or _responde(x=lambda t: f"x-{t}") with ( @@ -610,3 +643,183 @@ def test_resumo_sem_requisicao_concluida_omite_a_taxa(): assert "METRICAS DE THROUGHPUT" in saida assert "TPM (tokens/min): 0" in saida assert "Requisicoes" not in saida + + +# ============================================================================= +# Entrada polars: colunas que a execução não escreve +# ============================================================================= + +# 2**53 + 1, o primeiro inteiro que o float64 arredonda. +_ID_GRANDE = 9007199254740993 + +# Um tipo por coluna que a ida e volta pelo pandas trocaria ou arredondaria. +_COLUNAS_POLARS = { + "int64_com_nulo": lambda pl: pl.Series([_ID_GRANDE, None], dtype=pl.Int64), + "uint64_com_nulo": lambda pl: pl.Series([2**64 - 1, None], dtype=pl.UInt64), + "float_com_nan_e_nulo": lambda pl: pl.Series([float("nan"), None], dtype=pl.Float64), + "date": lambda pl: pl.Series([date(2024, 1, 2), None], dtype=pl.Date), + "enum": lambda pl: pl.Series(["a", None], dtype=pl.Enum(["a", "b"])), + "decimal": lambda pl: pl.Series([Decimal("1.10"), None], dtype=pl.Decimal(10, 2)), + "array": lambda pl: pl.Series([[1, 2], [3, 4]], dtype=pl.Array(pl.Int64, 2)), + "struct_com_inteiro_grande": lambda pl: pl.Series( + [{"id": _ID_GRANDE}, {"id": None}], dtype=pl.Struct({"id": pl.Int64}) + ), + "lista_com_inteiro_grande": lambda pl: pl.Series( + [[_ID_GRANDE, None], [1]], dtype=pl.List(pl.Int64) + ), + "null": lambda pl: pl.Series([None, None], dtype=pl.Null), + "object": lambda pl: pl.Series([{1}, None], dtype=pl.Object), +} + + +@pytest.mark.parametrize("tipo", _COLUNAS_POLARS) +def test_coluna_polars_que_a_execucao_nao_escreve_volta_como_entrou(tipo): + pl = pytest.importorskip("polars") + entrada = pl.DataFrame({"texto": ["a", "b"], "c": _COLUNAS_POLARS[tipo](pl)}) + + resultado, _ = _rodar(entrada) + + 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): + assert resultado["c"].to_list() == entrada["c"].to_list() + else: + assert resultado["c"].equals(entrada["c"]) + + +def test_saida_polars_mantem_cada_linha_com_o_seu_resultado(): + pl = pytest.importorskip("polars") + entrada = pl.DataFrame( + {"texto": ["a", "b", "c"], "id": pl.Series([_ID_GRANDE, None, 1], dtype=pl.Int64)} + ) + + def llm(text, *args, **kwargs): + if text.endswith("b"): + msg = "falhou" + raise ValueError(msg) + return {"data": {"x": text[-1]}, "usage": None} + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + resultado, _ = _rodar(entrada, llm=llm, max_retries=1) + + assert resultado["id"].to_list() == [_ID_GRANDE, None, 1] + assert resultado["x"].to_list() == ["a", None, "c"] + assert resultado["_dataframeit_status"].to_list() == ["processed", "error", "processed"] + + +def test_campo_reprocessado_em_saida_polars_sai_com_o_tipo_da_resposta_nova(): + pl = pytest.importorskip("polars") + primeira, _ = _rodar( + pl.DataFrame({"texto": ["a", "b"]}), questions=ModeloNotaInteira, llm=_responde(nota=7) + ) + assert primeira["nota"].dtype == pl.Int64 + + segunda, _ = _rodar( + primeira, questions=ModeloNotaDecimal, llm=_responde(nota=7.5), reprocess_columns=["nota"] + ) + + assert segunda["nota"].to_list() == [7.5, 7.5] + + +def test_retomada_de_saida_polars_nao_trunca_a_resposta_nova(): + pl = pytest.importorskip("polars") + entrada = pl.DataFrame({"texto": ["a", "b", "c"]}) + with pytest.warns(UserWarning, match="interrompida"): + interrompida, _ = _rodar( + entrada, + questions=ModeloNotaMista, + llm=_responde_em_sequencia({"nota": 7}, ProviderAbortError("limite")), + ) + assert interrompida["nota"].dtype == pl.Int64 + + retomada, _ = _rodar( + interrompida, + questions=ModeloNotaMista, + llm=_responde_em_sequencia({"nota": 8.5}, {"nota": 9.9}), + resume=True, + ) + + assert retomada["nota"].to_list() == [7.0, 8.5, 9.9] + + +def test_retomada_de_saida_polars_sem_resposta_tipa_o_campo_pelas_respostas(): + pl = pytest.importorskip("polars") + entrada = pl.DataFrame({"texto": ["a", "b", "c"]}) + with pytest.warns(UserWarning, match="interrompida"): + interrompida, _ = _rodar( + entrada, + questions=ModeloContagem, + llm=_responde_em_sequencia(ProviderAbortError("limite")), + ) + assert interrompida["n"].null_count() == 3 + + retomada, _ = _rodar( + interrompida, + questions=ModeloContagem, + llm=_responde_em_sequencia({"x": "p", "n": 1}, {"x": "q"}, {"x": "r", "n": 3}), + resume=True, + ) + + assert retomada["n"].dtype == pl.Int64 + assert retomada["n"].to_list() == [1, None, 3] + + +def test_retomada_de_saida_polars_mantem_o_inteiro_grande_ja_respondido(): + pl = pytest.importorskip("polars") + entrada = pl.DataFrame({"texto": ["a", "b", "c"]}) + with pytest.warns(UserWarning, match="interrompida"): + interrompida, _ = _rodar( + entrada, + questions=ModeloContagem, + llm=_responde_em_sequencia({"x": "p", "n": _ID_GRANDE}, ProviderAbortError("limite")), + ) + + retomada, _ = _rodar( + interrompida, + questions=ModeloContagem, + llm=_responde_em_sequencia({"x": "q"}, {"x": "r", "n": 3}), + resume=True, + ) + + assert retomada["n"].dtype == pl.Int64 + assert retomada["n"].to_list() == [_ID_GRANDE, None, 3] + + +def test_saida_polars_ja_concluida_devolve_o_campo_de_lista_normalizado(): + pl = pytest.importorskip("polars") + entrada = pl.DataFrame( + {"texto": ["a"], "tags": ['["p", "q"]'], "_dataframeit_status": ["processed"]} + ) + + resultado, llm = _rodar(entrada, questions=ModeloLista, resume=True) + + llm.assert_not_called() + assert resultado["tags"].to_list() == [["p", "q"]] + + +def test_entrada_polars_com_colunas_existentes_volta_igual(): + pl = pytest.importorskip("polars") + entrada = pl.DataFrame( + {"texto": ["a"], "x": [date(2024, 1, 2)], "id": pl.Series([None], dtype=pl.Int64)} + ) + + with pytest.warns(UserWarning, match="já existem"): + resultado, llm = _rodar(entrada, resume=False) + + llm.assert_not_called() + assert resultado.schema == entrada.schema + assert resultado.equals(entrada) + + +def test_entrada_polars_vazia_mantem_o_tipo_das_colunas(): + pl = pytest.importorskip("polars") + entrada = pl.DataFrame( + {"texto": pl.Series([], dtype=pl.Utf8), "dia": pl.Series([], dtype=pl.Date)} + ) + + resultado, _ = _rodar(entrada) + + assert resultado.columns == ["texto", "dia", "x"] + assert resultado["dia"].dtype == pl.Date diff --git a/tests/test_multiple_data_types.py b/tests/test_multiple_data_types.py index 06201395..f06bb688 100644 --- a/tests/test_multiple_data_types.py +++ b/tests/test_multiple_data_types.py @@ -7,6 +7,7 @@ - dict """ +import datetime import importlib.util import sys @@ -272,6 +273,67 @@ def test_dict_roundtrip(self): assert "resumo" in result.columns +@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.""" + + def test_inteiro_com_nulo_mantem_precisao_e_tipo(self): + ids = [9007199254740993, None, 1234567890123456789] + data = pl.DataFrame({"texto": ["a", "b", "c"], "id": pl.Series(ids, dtype=pl.Int64)}) + + df, info = to_pandas(data) + df["resultado"] = ["x", "y", "z"] + result = from_pandas(df, info) + + assert df["id"].dtype == "Int64" + assert df["id"].tolist()[0] == ids[0] + assert result["id"].dtype == pl.Int64 + assert result["id"].to_list() == ids + assert result["resultado"].to_list() == ["x", "y", "z"] + + def test_inteiro_sem_nulo_fica_como_estava(self): + data = pl.DataFrame({"id": pl.Series([1, 2], dtype=pl.UInt8)}) + + df, info = to_pandas(data) + + assert df["id"].dtype == "uint8" + assert from_pandas(df, info)["id"].dtype == pl.UInt8 + + def test_coluna_de_data_continua_data(self): + data = pl.DataFrame({"texto": ["a"], "dia": [datetime.date(2024, 1, 2)]}) + + df, info = to_pandas(data) + result = from_pandas(df, info) + + assert result["dia"].dtype == pl.Date + assert result["dia"].to_list() == [datetime.date(2024, 1, 2)] + + def test_campo_do_modelo_que_estava_na_entrada_sai_do_pandas(self): + data = pl.DataFrame({"texto": ["a"], "nota": [7]}) + df, info = to_pandas(data) + df["nota"] = [7.5] + + assert from_pandas(df, info)["nota"].to_list() == [7] + assert from_pandas(df, info, model_columns=["nota"])["nota"].to_list() == [7.5] + + def test_coluna_de_controle_que_estava_na_entrada_sai_do_pandas(self): + data = pl.DataFrame({"texto": ["a", "b"], "_dataframeit_status": ["error", None]}) + df, info = to_pandas(data) + df["_dataframeit_status"] = ["processed", "error"] + + result = from_pandas(df, info) + + assert result["_dataframeit_status"].to_list() == ["processed", "error"] + + 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) + df["st"] = ["processed", "error"] + + assert from_pandas(df, info)["st"].to_list() == ["error", None] + assert from_pandas(df, info, "st")["st"].to_list() == ["processed", "error"] + + def test_dict_com_chaves_tupla_mantem_indice_simples(): """Chaves-tupla viram rótulos de um Index simples, sem MultiIndex.""" original = {("sp", 2020): "texto a", ("rj", 2021): "texto b"} From 2008e71b6fb4202470682c874c508e1d832645a8 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 13 Oct 2026 08:33:49 -0300 Subject: [PATCH 2/4] =?UTF-8?q?Achados=20da=20revis=C3=A3o:=20=5Ftrace,=20?= =?UTF-8?q?inteiro=20como=20objeto,=20sele=C3=A7=C3=A3o=20por=20posi=C3=A7?= =?UTF-8?q?=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A coluna _trace do agente único entra na família de trace. Classificada como coluna do usuário, ela saía do DataFrame polars de entrada, e a retomada ou o reprocessamento de uma saída com save_trace perdia o trace das linhas novas. - Na ida, o inteiro com nulo vem como int do Python numa coluna de objetos, e não como inteiro anulável do pandas. É a forma em que a execução guarda o campo do modelo: a validação strict aceita de volta o inteiro acima de 2**53, um validador before recebe int, e a coluna de tokens aceita o valor que o provider devolver. - _reorder_columns seleciona por posição, e um rótulo repetido no pandas não traz a coluna mais de uma vez. - O CHANGELOG diz que a coluna Object trocava de tipo ou levantava ArrowInvalid, conforme o objeto. Testes novos: cada família de coluna de controle presente na entrada polars, tokens na retomada, modelo strict com inteiro grande, rótulo repetido e a posição de _trace. --- CHANGELOG.md | 2 +- src/dataframeit/utils.py | 21 ++++++++------- tests/test_entrada_e_saida.py | 43 ++++++++++++++++++++++++++++--- tests/test_multiple_data_types.py | 40 +++++++++++++++++++++++----- 4 files changed, 86 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e319f4cd..125c48f8 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, 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` levantava `ArrowInvalid` depois das chamadas. Na retomada de uma saída polars, o campo inteiro do modelo com linha pendente deixa de ser arredondado (#195). +- 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. Na retomada de uma saída polars, o campo inteiro do modelo com linha pendente deixa de ser arredondado (#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). - A retomada de um modelo com `strict=True` deixa de acusar como incompatíveis os campos que o checkpoint grava em outra forma, como a data em texto, a tupla como lista, o `Enum` pelo valor e o inteiro que a coluna com linha pendente relê como float. A linha recusada pela validação configurada é validada de novo sem strict, e cada campo recusado só é aceito quando o valor validado diz o mesmo que o relido; `"3"` num campo `int` strict continua acusado. A volta ao tipo declarado passa a valer também para os campos escalares: data, `datetime`, `Enum`, `int` e `bool` relidos do CSV, do XLSX ou do parquet deixam de misturar o tipo da execução com o do arquivo. Os campos de `Enum` de valores em texto são relidos como texto do CSV e do XLSX, como os de `Literal`, e um valor como `"1"` deixa de voltar como número. O checkpoint grava o `Enum` escalar pelo valor; antes, o CSV e o XLSX gravavam `"Cor.AZUL"`, que a retomada recusava, e o parquet falhava ao gravar (#178). - A forma de `condition` em `json_schema_extra` é conferida antes de processar, com `ValueError` que diz o que é aceito: dict com `field` em texto e um operador (`equals`, `not_equals`, `in`, `not_in` ou `exists`), ou callable. Antes, `condition={}` e os demais valores falsos, como `False` ou `[]`, rodavam o campo sem aviso, embora `evaluate_condition` trate o dict vazio como condição falsa; `field` que não era texto levantava `AttributeError`; e as demais formas eram avisadas a cada linha, com o campo pulado (#163). diff --git a/src/dataframeit/utils.py b/src/dataframeit/utils.py index 2a223893..14d0320a 100644 --- a/src/dataframeit/utils.py +++ b/src/dataframeit/utils.py @@ -262,17 +262,18 @@ def from_pandas( def _polars_to_pandas(data: PolarsDataFrame) -> pd.DataFrame: - """Converte para pandas mantendo o inteiro com nulo como inteiro anulável. + """Converte para pandas sem passar o inteiro com nulo por float. O `to_pandas()` do polars leva o inteiro com nulo a float64, que arredonda - valores acima de 2**53. É o caso do campo inteiro do modelo numa saída - interrompida, que volta como entrada na retomada com as linhas pendentes - nulas, e de toda coluna inteira com nulo que o checkpoint grava. + valores acima de 2**53. A coluna vem como objetos, com int do Python e + None: é como a execução guarda o campo inteiro do modelo, que numa saída + interrompida volta como entrada na retomada com as linhas pendentes nulas, + e é o que a validação strict aceita de volta. """ df = data.to_pandas() for name, dtype in data.schema.items(): if dtype.is_integer() and data[name].null_count() > 0: - df[name] = pd.array(data[name].to_list(), dtype=str(dtype)) + df[name] = pd.Series(data[name].to_list(), index=df.index, dtype=object) return df @@ -310,12 +311,12 @@ def _column_rank(col: Hashable, status_col: str) -> tuple[int, int]: """Família da coluna e posição dentro dela, na ordem em que a saída as traz. 1. Colunas do usuário (originais + campos do modelo) - 2. Colunas de trace (_trace_*) + 2. Colunas de trace (_trace e _trace_*) 3. Colunas de busca (_search_credits) 4. Colunas de tokens, na ordem de TOKEN_COLUMNS 5. Colunas de controle (status e _error_details) """ - if str(col).startswith("_trace_"): + if col == "_trace" or str(col).startswith("_trace_"): return (1, 0) if col == "_search_credits": return (2, 0) @@ -330,9 +331,11 @@ def _reorder_columns(df: pd.DataFrame, status_col: str = "_dataframeit_status") """Reordena as colunas para que as da execução fiquem no final. A ordem é a de `_column_rank`; dentro de cada família, menos a de tokens, - as colunas ficam na ordem em que estavam. + as colunas ficam na ordem em que estavam. A seleção é por posição, para que + um rótulo repetido não traga a coluna mais de uma vez. """ - return df[sorted(df.columns, key=lambda col: _column_rank(col, status_col))] + order = sorted(range(df.shape[1]), key=lambda i: _column_rank(df.columns[i], status_col)) + return df.iloc[:, order] # ============================================================================= diff --git a/tests/test_entrada_e_saida.py b/tests/test_entrada_e_saida.py index 806b26a1..12771414 100644 --- a/tests/test_entrada_e_saida.py +++ b/tests/test_entrada_e_saida.py @@ -10,7 +10,7 @@ import numpy as np import pandas as pd import pytest -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from dataframeit import ProviderAbortError, ProviderError, core, dataframeit from dataframeit.core import _format_token_stats @@ -41,6 +41,13 @@ class ModeloContagem(BaseModel): n: Optional[int] = None +class ModeloContagemEstrita(BaseModel): + model_config = ConfigDict(strict=True) + + x: str + n: Optional[int] = None + + def _responde(**campos): def call_langchain(text, *args, **kwargs): return { @@ -64,6 +71,11 @@ def call_langchain(text, *args, **kwargs): return call_langchain +def _responde_em_sequencia_com_uso(uso, *respostas): + sem_uso = _responde_em_sequencia(*respostas) + return lambda text, *args, **kwargs: {**sem_uso(text), "usage": uso} + + def _rodar(df, questions=Modelo, llm=None, **opcoes): llm = llm or _responde(x=lambda t: f"x-{t}") with ( @@ -766,19 +778,20 @@ def test_retomada_de_saida_polars_sem_resposta_tipa_o_campo_pelas_respostas(): assert retomada["n"].to_list() == [1, None, 3] -def test_retomada_de_saida_polars_mantem_o_inteiro_grande_ja_respondido(): +@pytest.mark.parametrize("modelo", [ModeloContagem, ModeloContagemEstrita]) +def test_retomada_de_saida_polars_mantem_o_inteiro_grande_ja_respondido(modelo): pl = pytest.importorskip("polars") entrada = pl.DataFrame({"texto": ["a", "b", "c"]}) with pytest.warns(UserWarning, match="interrompida"): interrompida, _ = _rodar( entrada, - questions=ModeloContagem, + questions=modelo, llm=_responde_em_sequencia({"x": "p", "n": _ID_GRANDE}, ProviderAbortError("limite")), ) retomada, _ = _rodar( interrompida, - questions=ModeloContagem, + questions=modelo, llm=_responde_em_sequencia({"x": "q"}, {"x": "r", "n": 3}), resume=True, ) @@ -823,3 +836,25 @@ def test_entrada_polars_vazia_mantem_o_tipo_das_colunas(): assert resultado.columns == ["texto", "dia", "x"] assert resultado["dia"].dtype == pl.Date + + +def test_retomada_de_saida_polars_traz_os_tokens_das_linhas_novas(): + pl = pytest.importorskip("polars") + uso = {"input_tokens": 3, "output_tokens": 2, "total_tokens": 5} + with pytest.warns(UserWarning, match="interrompida"): + interrompida, _ = _rodar( + pl.DataFrame({"texto": ["a", "b"]}), + llm=_responde_em_sequencia_com_uso(uso, {"x": "p"}, ProviderAbortError("limite")), + track_tokens=True, + ) + assert interrompida["_input_tokens"].to_list() == [3, None] + + retomada, _ = _rodar( + interrompida, + llm=_responde_em_sequencia_com_uso(uso, {"x": "q"}), + track_tokens=True, + resume=True, + ) + + assert retomada["_input_tokens"].to_list() == [3, 3] + assert retomada["_output_tokens"].to_list() == [2, 2] diff --git a/tests/test_multiple_data_types.py b/tests/test_multiple_data_types.py index f06bb688..78724806 100644 --- a/tests/test_multiple_data_types.py +++ b/tests/test_multiple_data_types.py @@ -285,8 +285,8 @@ def test_inteiro_com_nulo_mantem_precisao_e_tipo(self): df["resultado"] = ["x", "y", "z"] result = from_pandas(df, info) - assert df["id"].dtype == "Int64" - assert df["id"].tolist()[0] == ids[0] + assert df["id"].tolist() == ids + assert type(df["id"].iloc[0]) is int assert result["id"].dtype == pl.Int64 assert result["id"].to_list() == ids assert result["resultado"].to_list() == ["x", "y", "z"] @@ -316,14 +316,25 @@ def test_campo_do_modelo_que_estava_na_entrada_sai_do_pandas(self): assert from_pandas(df, info)["nota"].to_list() == [7] assert from_pandas(df, info, model_columns=["nota"])["nota"].to_list() == [7.5] - def test_coluna_de_controle_que_estava_na_entrada_sai_do_pandas(self): - data = pl.DataFrame({"texto": ["a", "b"], "_dataframeit_status": ["error", None]}) + @pytest.mark.parametrize( + "coluna", + [ + "_trace", + "_trace_campo", + "_search_credits", + "_input_tokens", + "_dataframeit_status", + "_error_details", + ], + ) + def test_coluna_de_controle_que_estava_na_entrada_sai_do_pandas(self, coluna): + data = pl.DataFrame({"texto": ["a", "b"], coluna: ["antes", None]}) df, info = to_pandas(data) - df["_dataframeit_status"] = ["processed", "error"] + df[coluna] = ["depois", "error"] result = from_pandas(df, info) - assert result["_dataframeit_status"].to_list() == ["processed", "error"] + assert result[coluna].to_list() == ["depois", "error"] def test_coluna_de_status_personalizada_que_estava_na_entrada_sai_do_pandas(self): data = pl.DataFrame({"texto": ["a", "b"], "st": ["error", None]}) @@ -334,6 +345,23 @@ def test_coluna_de_status_personalizada_que_estava_na_entrada_sai_do_pandas(self assert from_pandas(df, info, "st")["st"].to_list() == ["processed", "error"] +def test_rotulo_repetido_nao_duplica_coluna_na_reordenacao(): + df = pd.DataFrame([[1, 2, 3, 4]], columns=["_input_tokens", "a", "_input_tokens", "a"]) + + resultado = from_pandas(df, ConversionInfo(original_type=ORIGINAL_TYPE_PANDAS_DF)) + + assert list(resultado.columns) == ["a", "a", "_input_tokens", "_input_tokens"] + assert resultado.to_numpy().tolist() == [[2, 4, 1, 3]] + + +def test_coluna_trace_sem_sufixo_fica_com_as_de_trace(): + df = pd.DataFrame([[1, 2, 3]], columns=["_trace", "texto", "_search_credits"]) + + resultado = from_pandas(df, ConversionInfo(original_type=ORIGINAL_TYPE_PANDAS_DF)) + + assert list(resultado.columns) == ["texto", "_trace", "_search_credits"] + + def test_dict_com_chaves_tupla_mantem_indice_simples(): """Chaves-tupla viram rótulos de um Index simples, sem MultiIndex.""" original = {("sp", 2020): "texto a", ("rj", 2021): "texto b"} From 05fb5edbb5182ade87f843bf900c2d1cf35dfa43 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 13 Oct 2026 09:00:15 -0300 Subject: [PATCH 3/4] =?UTF-8?q?Segunda=20revis=C3=A3o:=20float=20quando=20?= =?UTF-8?q?o=20inteiro=20cabe,=20objetos=20s=C3=B3=20acima=20de=202**53?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Com o inteiro com nulo sempre em objetos, um campo bool cuja coluna chega do polars como Int64 0/1, como a lida de SQLite, deixava de ser reconhecido na retomada: as linhas processadas ficavam 1 e 0, a nova vinha True, e a conversão final falhava depois da chamada. A coluna inteira com nulo fica em float64, como o to_pandas() a entrega, enquanto todo valor cabe exato no float. É a forma que o pandas daria ao mesmo dado lido de arquivo e a que a retomada reconhece em campo int e bool. Só a coluna com valor que o float arredondaria vem como objetos. O CHANGELOG registra que _trace passa a ficar com as colunas de trace na ordem da saída. --- CHANGELOG.md | 1 + src/dataframeit/utils.py | 23 ++++++++++++++--------- tests/test_entrada_e_saida.py | 29 +++++++++++++++++++++++++++++ tests/test_multiple_data_types.py | 8 ++++++++ 4 files changed, 52 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 125c48f8..19d18a92 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 +- 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. Na retomada de uma saída polars, o campo inteiro do modelo com linha pendente deixa de ser arredondado (#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). - A retomada de um modelo com `strict=True` deixa de acusar como incompatíveis os campos que o checkpoint grava em outra forma, como a data em texto, a tupla como lista, o `Enum` pelo valor e o inteiro que a coluna com linha pendente relê como float. A linha recusada pela validação configurada é validada de novo sem strict, e cada campo recusado só é aceito quando o valor validado diz o mesmo que o relido; `"3"` num campo `int` strict continua acusado. A volta ao tipo declarado passa a valer também para os campos escalares: data, `datetime`, `Enum`, `int` e `bool` relidos do CSV, do XLSX ou do parquet deixam de misturar o tipo da execução com o do arquivo. Os campos de `Enum` de valores em texto são relidos como texto do CSV e do XLSX, como os de `Literal`, e um valor como `"1"` deixa de voltar como número. O checkpoint grava o `Enum` escalar pelo valor; antes, o CSV e o XLSX gravavam `"Cor.AZUL"`, que a retomada recusava, e o parquet falhava ao gravar (#178). diff --git a/src/dataframeit/utils.py b/src/dataframeit/utils.py index 14d0320a..a4aecaa0 100644 --- a/src/dataframeit/utils.py +++ b/src/dataframeit/utils.py @@ -262,18 +262,23 @@ def from_pandas( def _polars_to_pandas(data: PolarsDataFrame) -> pd.DataFrame: - """Converte para pandas sem passar o inteiro com nulo por float. - - O `to_pandas()` do polars leva o inteiro com nulo a float64, que arredonda - valores acima de 2**53. A coluna vem como objetos, com int do Python e - None: é como a execução guarda o campo inteiro do modelo, que numa saída - interrompida volta como entrada na retomada com as linhas pendentes nulas, - e é o que a validação strict aceita de volta. + """Converte para pandas sem arredondar o inteiro com nulo. + + O `to_pandas()` do polars leva o inteiro com nulo a float64. Enquanto todo + valor cabe exato no float, a coluna fica assim: é como o pandas a leria de + um arquivo, e é a forma que a retomada reconhece num campo int ou bool. + Quando algum valor não cabe, acima de 2**53, a coluna vem como objetos, com + int do Python e None, que é como a execução guarda o campo inteiro do + modelo. É o caso do campo de uma saída interrompida, que volta como entrada + na retomada com as linhas pendentes nulas. """ df = data.to_pandas() for name, dtype in data.schema.items(): - if dtype.is_integer() and data[name].null_count() > 0: - df[name] = pd.Series(data[name].to_list(), index=df.index, dtype=object) + if not dtype.is_integer() or data[name].null_count() == 0: + continue + values = data[name].to_list() + if any(value is not None and int(float(value)) != value for value in values): + df[name] = pd.Series(values, index=df.index, dtype=object) return df diff --git a/tests/test_entrada_e_saida.py b/tests/test_entrada_e_saida.py index 12771414..704b7312 100644 --- a/tests/test_entrada_e_saida.py +++ b/tests/test_entrada_e_saida.py @@ -48,6 +48,16 @@ class ModeloContagemEstrita(BaseModel): n: Optional[int] = None +class ModeloFlag(BaseModel): + relevante: bool + + +class ModeloFlagEstrita(BaseModel): + model_config = ConfigDict(strict=True) + + relevante: bool + + def _responde(**campos): def call_langchain(text, *args, **kwargs): return { @@ -858,3 +868,22 @@ def test_retomada_de_saida_polars_traz_os_tokens_das_linhas_novas(): assert retomada["_input_tokens"].to_list() == [3, 3] assert retomada["_output_tokens"].to_list() == [2, 2] + + +@pytest.mark.parametrize("modelo", [ModeloFlag, ModeloFlagEstrita]) +def test_retomada_polars_de_campo_bool_gravado_como_inteiro(modelo): + """Um bool lido de volta de SQLite pelo polars chega como Int64 0/1.""" + pl = pytest.importorskip("polars") + entrada = pl.DataFrame( + { + "texto": ["a", "b", "c"], + "relevante": pl.Series([1, 0, None], dtype=pl.Int64), + "_dataframeit_status": ["processed", "processed", None], + } + ) + + resultado, llm = _rodar(entrada, questions=modelo, llm=_responde(relevante=True), resume=True) + + assert llm.call_count == 1 + assert resultado["relevante"].dtype == pl.Boolean + assert resultado["relevante"].to_list() == [True, False, True] diff --git a/tests/test_multiple_data_types.py b/tests/test_multiple_data_types.py index 78724806..d6f8d47e 100644 --- a/tests/test_multiple_data_types.py +++ b/tests/test_multiple_data_types.py @@ -291,6 +291,14 @@ def test_inteiro_com_nulo_mantem_precisao_e_tipo(self): assert result["id"].to_list() == ids assert result["resultado"].to_list() == ["x", "y", "z"] + def test_inteiro_com_nulo_que_cabe_no_float_fica_como_o_pandas_o_leria(self): + data = pl.DataFrame({"texto": ["a", "b"], "n": pl.Series([2**53, None], dtype=pl.Int64)}) + + df, info = to_pandas(data) + + assert df["n"].dtype == "float64" + assert from_pandas(df, info)["n"].to_list() == [2**53, None] + def test_inteiro_sem_nulo_fica_como_estava(self): data = pl.DataFrame({"id": pl.Series([1, 2], dtype=pl.UInt8)}) From 4e31a31b2e3fa5d1a073ac0eb7fd11df0ee33f2a Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 13 Oct 2026 09:26:10 -0300 Subject: [PATCH 4/4] =?UTF-8?q?Terceira=20revis=C3=A3o:=20a=20ida=20ao=20p?= =?UTF-8?q?andas=20volta=20a=20ser=20a=20do=20to=5Fpandas()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A conversão do inteiro com nulo na ida saía do escopo da issue e, nas três formas tentadas, piorava algum caso de retomada: campo bool sobre coluna Int64 0/1, campo float sobre coluna com inteiro acima de 2**53, e inteiro acima do Int64, que passavam a falhar depois das chamadas. A ida fica como na main, e o PR trata só da volta: as colunas que a execução não escreve saem do DataFrame polars de entrada. Numa matriz de 8.844 combinações de campo do modelo, coluna já presente e modo de retomada, a única diferença em relação à main é a coluna de entrada voltar com o tipo e os valores originais. --- CHANGELOG.md | 2 +- src/dataframeit/utils.py | 27 +++------------------------ tests/test_entrada_e_saida.py | 29 ----------------------------- tests/test_multiple_data_types.py | 10 ---------- 4 files changed, 4 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19d18a92..3971c966 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR ### Corrigido - 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. Na retomada de uma saída polars, o campo inteiro do modelo com linha pendente deixa de ser arredondado (#195). +- 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). - A retomada de um modelo com `strict=True` deixa de acusar como incompatíveis os campos que o checkpoint grava em outra forma, como a data em texto, a tupla como lista, o `Enum` pelo valor e o inteiro que a coluna com linha pendente relê como float. A linha recusada pela validação configurada é validada de novo sem strict, e cada campo recusado só é aceito quando o valor validado diz o mesmo que o relido; `"3"` num campo `int` strict continua acusado. A volta ao tipo declarado passa a valer também para os campos escalares: data, `datetime`, `Enum`, `int` e `bool` relidos do CSV, do XLSX ou do parquet deixam de misturar o tipo da execução com o do arquivo. Os campos de `Enum` de valores em texto são relidos como texto do CSV e do XLSX, como os de `Literal`, e um valor como `"1"` deixa de voltar como número. O checkpoint grava o `Enum` escalar pelo valor; antes, o CSV e o XLSX gravavam `"Cor.AZUL"`, que a retomada recusava, e o parquet falhava ao gravar (#178). - A forma de `condition` em `json_schema_extra` é conferida antes de processar, com `ValueError` que diz o que é aceito: dict com `field` em texto e um operador (`equals`, `not_equals`, `in`, `not_in` ou `exists`), ou callable. Antes, `condition={}` e os demais valores falsos, como `False` ou `[]`, rodavam o campo sem aviso, embora `evaluate_condition` trate o dict vazio como condição falsa; `field` que não era texto levantava `AttributeError`; e as demais formas eram avisadas a cada linha, com o campo pulado (#163). diff --git a/src/dataframeit/utils.py b/src/dataframeit/utils.py index a4aecaa0..87f52c92 100644 --- a/src/dataframeit/utils.py +++ b/src/dataframeit/utils.py @@ -155,7 +155,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 ) @@ -261,27 +261,6 @@ def from_pandas( return df -def _polars_to_pandas(data: PolarsDataFrame) -> pd.DataFrame: - """Converte para pandas sem arredondar o inteiro com nulo. - - O `to_pandas()` do polars leva o inteiro com nulo a float64. Enquanto todo - valor cabe exato no float, a coluna fica assim: é como o pandas a leria de - um arquivo, e é a forma que a retomada reconhece num campo int ou bool. - Quando algum valor não cabe, acima de 2**53, a coluna vem como objetos, com - int do Python e None, que é como a execução guarda o campo inteiro do - modelo. É o caso do campo de uma saída interrompida, que volta como entrada - na retomada com as linhas pendentes nulas. - """ - df = data.to_pandas() - for name, dtype in data.schema.items(): - if not dtype.is_integer() or data[name].null_count() == 0: - continue - values = data[name].to_list() - if any(value is not None and int(float(value)) != value for value in values): - df[name] = pd.Series(values, index=df.index, dtype=object) - return df - - def _untouched_columns( source: PolarsDataFrame | None, status_col: str, @@ -292,8 +271,8 @@ def _untouched_columns( São as colunas do usuário que não são campo do modelo. A saída as tira de `source`, o DataFrame polars de entrada: convertidas de volta do pandas, elas trocariam de tipo (Date por Datetime, Enum por Categorical) e trariam - arredondado o inteiro acima de 2**53 dentro de Struct e List, que passa por - float na ida. As linhas da saída estão na ordem das de `source`, porque a + arredondado o inteiro acima de 2**53 que tem nulo na coluna ou está dentro + de Struct e List, porque o `to_pandas()` do polars o leva a float. As linhas da saída estão na ordem das de `source`, porque a execução grava por rótulo e não remove nem reordena linhas. Sem `source` (entrada polars.Series, que só traz o texto), todas as colunas diff --git a/tests/test_entrada_e_saida.py b/tests/test_entrada_e_saida.py index 704b7312..fb754841 100644 --- a/tests/test_entrada_e_saida.py +++ b/tests/test_entrada_e_saida.py @@ -41,13 +41,6 @@ class ModeloContagem(BaseModel): n: Optional[int] = None -class ModeloContagemEstrita(BaseModel): - model_config = ConfigDict(strict=True) - - x: str - n: Optional[int] = None - - class ModeloFlag(BaseModel): relevante: bool @@ -788,28 +781,6 @@ def test_retomada_de_saida_polars_sem_resposta_tipa_o_campo_pelas_respostas(): assert retomada["n"].to_list() == [1, None, 3] -@pytest.mark.parametrize("modelo", [ModeloContagem, ModeloContagemEstrita]) -def test_retomada_de_saida_polars_mantem_o_inteiro_grande_ja_respondido(modelo): - pl = pytest.importorskip("polars") - entrada = pl.DataFrame({"texto": ["a", "b", "c"]}) - with pytest.warns(UserWarning, match="interrompida"): - interrompida, _ = _rodar( - entrada, - questions=modelo, - llm=_responde_em_sequencia({"x": "p", "n": _ID_GRANDE}, ProviderAbortError("limite")), - ) - - retomada, _ = _rodar( - interrompida, - questions=modelo, - llm=_responde_em_sequencia({"x": "q"}, {"x": "r", "n": 3}), - resume=True, - ) - - assert retomada["n"].dtype == pl.Int64 - assert retomada["n"].to_list() == [_ID_GRANDE, None, 3] - - def test_saida_polars_ja_concluida_devolve_o_campo_de_lista_normalizado(): pl = pytest.importorskip("polars") entrada = pl.DataFrame( diff --git a/tests/test_multiple_data_types.py b/tests/test_multiple_data_types.py index d6f8d47e..38411975 100644 --- a/tests/test_multiple_data_types.py +++ b/tests/test_multiple_data_types.py @@ -285,20 +285,10 @@ def test_inteiro_com_nulo_mantem_precisao_e_tipo(self): df["resultado"] = ["x", "y", "z"] result = from_pandas(df, info) - assert df["id"].tolist() == ids - assert type(df["id"].iloc[0]) is int assert result["id"].dtype == pl.Int64 assert result["id"].to_list() == ids assert result["resultado"].to_list() == ["x", "y", "z"] - def test_inteiro_com_nulo_que_cabe_no_float_fica_como_o_pandas_o_leria(self): - data = pl.DataFrame({"texto": ["a", "b"], "n": pl.Series([2**53, None], dtype=pl.Int64)}) - - df, info = to_pandas(data) - - assert df["n"].dtype == "float64" - assert from_pandas(df, info)["n"].to_list() == [2**53, None] - def test_inteiro_sem_nulo_fica_como_estava(self): data = pl.DataFrame({"id": pl.Series([1, 2], dtype=pl.UInt8)})