diff --git a/CHANGELOG.md b/CHANGELOG.md index 3971c966..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,6 +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. 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 7c72f100..095e6ba1 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 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 | Column | When it exists | Content | diff --git a/docs/reference/api.md b/docs/reference/api.md index 54ce5d4d..d1153f7a 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 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 | Coluna | Quando existe | Conteúdo | 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 87f52c92..ef6e5189 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 @@ -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,13 +243,76 @@ 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]) + # As chamadas ao provedor já foram feitas, e a coluna que o polars ou o + # 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) + # 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)) + 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 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, + ) + 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], + name=values.name, + dtype=object, + ) + + 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 fb754841..5ddc4bda 100644 --- a/tests/test_entrada_e_saida.py +++ b/tests/test_entrada_e_saida.py @@ -1,16 +1,18 @@ """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 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 +53,27 @@ class ModeloFlagEstrita(BaseModel): relevante: bool +class Cor(Enum): + AZUL = "azul" + 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 + + +class ModeloMisto(BaseModel): + x: str + campo: Union[int, str] + + def _responde(**campos): def call_langchain(text, *args, **kwargs): return { @@ -696,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"]) @@ -858,3 +881,208 @@ 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 +# ============================================================================= + +_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"]), + "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]), + "datetime_com_fuso_de_deslocamento": ( + datetime, + [ + datetime(2024, 1, 2, tzinfo=_FUSO_DE_DESLOCAMENTO), + datetime(2024, 1, 3, tzinfo=_FUSO_DE_DESLOCAMENTO), + ], + ), +} + + +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_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_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_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] + + +@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") + + 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..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 @@ -31,6 +32,7 @@ ORIGINAL_TYPE_POLARS_DF, ORIGINAL_TYPE_POLARS_SERIES, ConversionInfo, + _enums_by_value, from_pandas, to_pandas, ) @@ -334,6 +336,49 @@ 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"] + + @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]) + + 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]}) df, info = to_pandas(data)