Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,11 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
- Com `search_per_field=True`, o modo por grupo também devolve `search_provider` no uso, e o modo por campo devolve os campos na ordem do modelo, como o modo por grupo. O campo pulado por condição é registrado uma vez no log, e não duas.
- O Python mínimo sobe para 3.11. O 3.10 saiu de suporte em outubro de 2026, e as versões atuais do numpy e do pandas já não o aceitam (#229).
- O piso do pyarrow nos extras `polars` e `all` sobe para 10.0.1, a primeira versão com wheel para Python 3.11 (#229).
- Na saída polars, um campo `dict` sai como texto JSON, como no checkpoint. Antes, saía `Struct` com as chaves de todas as linhas: a chave ausente ficava igual à chave com valor nulo, e o dict vazio deixava de ser vazio. A coluna de um campo sem nenhum valor, toda nula ou só com listas vazias, sai com o tipo da anotação quando ela é `str`, `int`, `float`, `bool`, `date`, `datetime` ou lista de um deles. Antes, o tipo vinha só das respostas: um `Optional[int]` todo nulo saía `String`, e um `list[str]` só com listas vazias, `List(Null)` (#219).

### 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).
- 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` 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).
Expand Down
2 changes: 2 additions & 0 deletions docs/en/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ Returns data in the same format as input with extracted columns added.

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.

A field annotated as `dict`, such as `dict[str, int]` or `Optional[dict[str, Any]]`, comes out in polars output as JSON text, with null where the row has no answer. The text is the same the checkpoint writes, and values that JSON does not represent come out in it as text. The type of the other model columns comes from the answers. A column with no value at all, all null or holding only empty lists, comes out with the type of the annotation when it is `str`, `int`, `float`, `bool`, `date`, `datetime`, or a list of one of them: `String`, `Int64`, `Float64`, `Boolean`, `Date`, `Datetime`, or a `List` of one of these. With any other annotation, the column with no value keeps the type polars infers. With input that has no rows, a model column the input already has keeps its type, and one it does not have comes out as `String`.

### Added Columns

| Column | When it exists | Content |
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ Retorna dados no mesmo formato da entrada com colunas extraídas adicionadas.

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.

Um campo anotado como `dict`, como `dict[str, int]` ou `Optional[dict[str, Any]]`, sai na saída polars como texto JSON, com nulo onde a linha não tem resposta. O texto é o mesmo que o checkpoint grava, e os valores que o JSON não representa saem nele como texto. O tipo das demais colunas do modelo vem das respostas. A coluna sem nenhum valor, toda nula ou só com listas vazias, sai com o tipo da anotação quando ela é `str`, `int`, `float`, `bool`, `date`, `datetime` ou lista de um deles: `String`, `Int64`, `Float64`, `Boolean`, `Date`, `Datetime` ou `List` de um desses. Com outra anotação, a coluna sem valor fica com o tipo que o polars infere. Na entrada sem linhas, a coluna do modelo que a entrada já traz mantém o tipo dela, e a que ela não traz sai `String`.

### Colunas Adicionadas

| Coluna | Quando existe | Conteúdo |
Expand Down
19 changes: 4 additions & 15 deletions src/dataframeit/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
ORIGINAL_TYPE_POLARS_DF,
TOKEN_COLUMNS,
ConversionInfo,
_json_default,
from_pandas,
get_complex_fields,
normalize_complex_columns,
Expand Down Expand Up @@ -1275,6 +1276,7 @@ def dataframeit( # noqa: C901, PLR0912, PLR0913, PLR0915, PLR0917 (API pública
if not expected_columns:
msg = "Modelo Pydantic não pode estar vazio"
raise ValueError(msg)
annotations = {name: field.annotation for name, field in questions.model_fields.items()}

# Cada campo extraído é gravado numa coluna de mesmo nome, que não pode ser
# a do texto de entrada.
Expand Down Expand Up @@ -1425,7 +1427,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, expected_columns)
return from_pandas(df_pandas, conversion_info, status_col, annotations)

# 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
Expand Down Expand Up @@ -1600,7 +1602,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, expected_columns)
return from_pandas(df_pandas, conversion_info, status_col, annotations)


def _setup_columns( # noqa: C901, PLR0912, PLR0913 (uma família de colunas por recurso ligado)
Expand Down Expand Up @@ -2016,19 +2018,6 @@ def _array_as_list(value: object) -> object:
return value


def _json_default(value: object) -> object:
"""O que json.dumps não serializa: numpy pelo tolist, Enum pelo valor, o resto texto.

O tolist dá lista ao array e número ou bool do Python ao escalar do numpy,
que pelo texto voltaria "5" ou "True". O Enum vai pelo valor porque é o que
a validação do campo aceita de volta; o texto dele ("Cor.AZUL") não valida.
"""
if isinstance(value, Enum):
return value.value
tolist = getattr(value, "tolist", None)
return tolist() if callable(tolist) else str(value)


def _structures_as_json(df: pd.DataFrame, columns: Collection[str] | None = None) -> pd.DataFrame:
"""Cópia com listas, dicts, tuplas e arrays do numpy serializados como JSON.

Expand Down
91 changes: 86 additions & 5 deletions src/dataframeit/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from __future__ import annotations

import ast
import datetime
import functools
import json
import operator
Expand All @@ -29,7 +30,7 @@
from pydantic import BaseModel

if TYPE_CHECKING:
from collections.abc import Callable, Collection, Hashable
from collections.abc import Callable, Collection, Hashable, Mapping

from polars import DataFrame as PolarsDataFrame
from polars import Series as PolarsSeries
Expand Down Expand Up @@ -205,7 +206,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] = (),
model_columns: Mapping[str, object] = types.MappingProxyType({}),
) -> pd.DataFrame | PolarsDataFrame:
"""Converte DataFrame pandas de volta para o formato original.

Expand All @@ -217,8 +218,9 @@ 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.
model_columns: Anotação de cada campo do modelo que a execução gravou, pelo
nome. Na saída polars, só eles e as colunas de controle são
convertidos do pandas, e a anotação entra em `_to_polars`.

Returns:
Dados no formato original (DataFrame, Series, list ou dict).
Expand Down Expand Up @@ -255,7 +257,7 @@ def from_pandas(
# 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))
columns.append(_to_polars(values, model_columns.get(col)))
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
Expand Down Expand Up @@ -313,6 +315,85 @@ def _enums_by_value(values: pd.Series) -> pd.Series:
)


def _json_default(value: object) -> object:
"""O que json.dumps não serializa: numpy pelo tolist, Enum pelo valor, o resto texto.

O tolist dá lista ao array e número ou bool do Python ao escalar do numpy,
que pelo texto voltaria "5" ou "True". O Enum vai pelo valor porque é o que
a validação do campo aceita de volta; o texto dele ("Cor.AZUL") não valida.
"""
if isinstance(value, Enum):
return value.value
tolist = getattr(value, "tolist", None)
return tolist() if callable(tolist) else str(value)


def _without_optional(annotation: object) -> object:
"""A anotação sem o Optional e sem o Annotated de dentro dele.

A união de mais de um tipo além de None fica como está.
"""
args = [arg for arg in get_args(annotation) if arg is not type(None)]
if get_origin(annotation) in (typing.Union, types.UnionType) and len(args) == 1:
annotation = args[0]
if get_origin(annotation) is typing.Annotated:
annotation = get_args(annotation)[0]
return annotation


def _to_polars(values: pd.Series, annotation: object) -> PolarsSeries:
"""Converte a coluna para polars; `annotation` é a do campo do modelo, ou None.

O campo dict sai como texto JSON, como o checkpoint o grava. Como Struct, a
coluna teria em cada linha as chaves de todas, e a chave ausente não se
distinguiria da chave com valor nulo. O dict com chave que o JSON não
aceita, como um Enum, levanta TypeError, e `from_pandas` devolve a coluna
como Object.

A coluna sem valor, toda nula ou só com listas vazias, não dá ao polars de
onde inferir o tipo, e o schema da saída mudaria com as respostas. Ela
recebe o tipo da anotação quando esta é um dos tipos de `scalars` ou uma
lista de um deles. A busca em `scalars` é por identidade, e não por chave
de dict, porque a anotação pode não ter hash, como o item de
list[Annotated[int, {...}]].
"""
# Só é chamada com o polars instalado; o Any dispensa conferir o None a cada uso.
polars: Any = pl
scalars = (
(str, polars.String),
(int, polars.Int64),
(float, polars.Float64),
(bool, polars.Boolean),
(datetime.date, polars.Date),
(datetime.datetime, polars.Datetime),
)
annotation = _without_optional(annotation)
origin, args = get_origin(annotation), get_args(annotation)
if (origin or annotation) is dict:
values = pd.Series(
[
json.dumps(value, ensure_ascii=False, default=_json_default)
if isinstance(value, dict)
else value
for value in values
],
name=values.name,
)
# A coluna de texto JSON sem nenhuma resposta segue a regra do campo str.
annotation = str
series = polars.from_pandas(values)
item = args[0] if origin is list and args else annotation
dtype = next((dtype for python_type, dtype in scalars if item is python_type), None)
if dtype is None:
return series
# Da lista vazia o explode tira um nulo ou nada, conforme a versão do polars,
# e a coluna que não é lista ele devolve como está.
leaves = series.explode()
if leaves.null_count() < len(leaves):
return series
return series.cast(polars.List(dtype) if origin is list else dtype)


def _untouched_columns(
source: PolarsDataFrame | None,
status_col: str,
Expand Down
Loading
Loading