diff --git a/CHANGELOG.md b/CHANGELOG.md index aa4c28b0..b2fb9df9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR ### Corrigido +- A retomada pelo checkpoint deixa de recusar ou trocar valores já gravados (#196). No CSV e no XLSX, que gravam `""` e `None` na mesma célula vazia, o texto vazio de um campo de texto que não admite `None` volta `""`, em vez de a linha ser acusada como incompatível e a retomada pedir `reprocess_columns` para a coluna inteira; no campo que admite `None`, a célula vazia continua voltando `None`. No XLSX, o texto que começa com `=` é gravado como texto, e não como fórmula, que voltava vazia. Os campos `Sequence`, `AbstractSet`, `set`, `frozenset`, `Mapping` e as versões mutáveis, `TypedDict` e `NamedTuple` passam a contar como estrutura em `get_complex_fields`: o checkpoint os grava em JSON, o conjunto em ordem fixa, e a retomada os devolve ao tipo do campo, em vez de acusá-los como incompatíveis. O `"NA"` de um `Literal["NA", "deferido", None]` é relido como texto, e não como ausente. O aviso do reprocessamento interrompido diz que repetir a chamada com `reprocess_columns` refaz todas as linhas, inclusive as já reprocessadas. - No provider `codex`, a thread daemon que pede o `thread/unsubscribe` da linha abortada pela morte do app-server deixa de ficar presa para sempre. O processo morto ainda aceita a escrita enquanto termina, e o SDK registra a espera da resposta depois de o leitor do stdout ter acordado os pedidos em voo; agora o fechamento do cliente acorda essa espera (#250). - `read_df` com argumento de leitura que faz o pandas devolver outra coisa que um DataFrame, como `chunksize`, `iterator`, `sheet_name=None` ou lista e o `chunksize` do JSON em linhas, devolve o `TextFileReader`, o `JsonReader` ou o dict de abas como o pandas o devolve, sem normalizar, com ou sem modelo. Antes, sem modelo, ou com modelo que tem campo de lista, dict ou modelo aninhado, e com `normalize=True`, o padrão, a função levantava `AttributeError` ao procurar as colunas no objeto do leitor (#249). - Com entrada polars, a saída deixa de trocar sem aviso o valor que o modelo devolveu quando o pyarrow tipa a coluna pelo primeiro valor e converte os demais. Sai `Object`, com o aviso das colunas que o polars não reúne, o campo cuja anotação junta `timedelta` a outro tipo, `float` a `bool` ou `bytes` a `str`, e a coluna cujos valores trazem essas combinações ou as de data e hora, também em `Any`, onde o escalar do numpy, como o `np.int64`, conta como o número do Python, nos itens de lista e no campo de modelo aninhado, além da coluna cujos dicts do mesmo campo têm chaves diferentes e da que traz `datetime` com e sem fuso, `time` com fuso ou `Decimal` NaN. O NaN de campo `float` sai NaN. Antes, em `Union[timedelta, int]` o 5 virava cinco microssegundos, em `Union[float, bool]` o `True` virava `1.0`, o `datetime` sem fuso ganhava o fuso do primeiro, o `time` perdia o fuso, o `Decimal` NaN e o NaN de campo `float` viravam nulo, `{}` ao lado de `{"a": 1}` virava `{"a": None}`, e em `Union[NewType sobre date, int]` o 5 virava `1970-01-06`. Com `resume=True`, a coluna do campo cuja anotação é `int`, um `Literal` com inteiro ou uma união com um deles é lida com os inteiros do polars, e o inteiro acima de 2**53 deixa de voltar arredondado em `Float64`. O `Decimal` NaN e o NaN de campo `float` de uma linha processada deixam de ser acusados como incompatíveis na retomada, também com entrada pandas, e o reprocessamento campo a campo os repassa às condições como valor; na coluna float64, como a do checkpoint relido, o NaN não se distingue do nulo e segue contando como ausente. O `NewType` e o apelido `type`, inclusive o apelido genérico usado com argumentos, como `Opcional[int]` de `type Opcional[T] = T | None`, passam a contar pelo tipo a que se referem na saída polars, na retomada e em `read_df`: o `NewType` sobre `dict` sai em texto JSON, como o campo `dict`, e é retomado, e o campo `NewType` sobre `str` ou sobre `int` é relido do CSV como texto cru ou como inteiro exato (#241). diff --git a/docs/en/guides/performance.md b/docs/en/guides/performance.md index 4394db93..24ece3fc 100644 --- a/docs/en/guides/performance.md +++ b/docs/en/guides/performance.md @@ -105,7 +105,7 @@ result = dataframeit( ) ``` -The format is inferred from the file extension (`.csv`, `.xlsx`, `.parquet`). In every format, the model's list, dict and tuple fields are saved as JSON text, and `Enum` values by their value; `read_df` turns the structures back, and resuming returns structures and date, time, `datetime`, `Enum`, `int` and `bool` fields with their declared type, also with `strict=True`: a date saved as text, an integer read back as a float and a tuple read back as a list come back as `date`, `int` and `tuple`. XLSX stores numbers as doubles, and an integer above 2**53 comes back rounded; for ids that large, use CSV or parquet. None of the three formats keeps the NaN of a `float` field apart from null: CSV and XLSX write both as an empty cell, and parquet as null. When resuming, the NaN comes back missing, and a required field that received it is flagged for reprocessing. In parquet, an integer above 2**63 - 1 next to a pending row and an integer above 2**53 next to a float in the same column are not saved, and the checkpoint save fails with a warning. If execution is interrupted, run the same call again with the same input: with `resume=True`, the default, and the file present, execution continues from the checkpoint. On every save, `.dataframeit.json` is written next to it with the run's signature (prompt, provider, model, `model_kwargs` except `timeout`, schema, search, and the text and status columns), a hash of each row's text, and a hash of the checkpoint itself. The checkpoint is resumed only when all three match; with another configuration, another input, or a file that is not the one the signature describes, the run warns, starts over, and overwrites it. In a row already concluded, successfully or with an error, the model fields come from the checkpoint, over whatever the input carried in them; in a successful row, that is what an uninterrupted run would give, because it writes the answer over them. To keep an output corrected by hand as it is, pass it with the status column, and the checkpoint is not read again. A complete output is returned without it, and the column comes back with `output["_dataframeit_status"] = "processed"` (or the name passed in `status_column`). With `resume=False`, the file is ignored and overwritten. +The format is inferred from the file extension (`.csv`, `.xlsx`, `.parquet`). In every format, the model's structure fields, that is, list, tuple, set, dict, `Sequence`, `AbstractSet`, `Mapping`, `TypedDict`, `NamedTuple` and nested model, are saved as JSON text, sets in a fixed order, and `Enum` values by their value; `read_df` turns the structures back, and resuming returns structures and date, time, `datetime`, `Enum`, `int` and `bool` fields with their declared type, also with `strict=True`: a date saved as text, an integer read back as a float and a tuple read back as a list come back as `date`, `int` and `tuple`. CSV and XLSX write an empty string and `None` as the same empty cell. When resuming, that cell comes back as `""` in a text field that does not accept `None`, such as `str` or `str = "default"`, and as `None` in a field that accepts it, such as `Optional[str]`; to keep the `""` of an optional field, use parquet. In XLSX, text that starts with `=` is saved as text, not as a formula. XLSX stores numbers as doubles, and an integer above 2**53 comes back rounded; for ids that large, use CSV or parquet. None of the three formats keeps the NaN of a `float` field apart from null: CSV and XLSX write both as an empty cell, and parquet as null. When resuming, the NaN comes back missing, and a required field that received it is flagged for reprocessing. In parquet, an integer above 2**63 - 1 next to a pending row and an integer above 2**53 next to a float in the same column are not saved, and the checkpoint save fails with a warning. If execution is interrupted, run the same call again with the same input: with `resume=True`, the default, and the file present, execution continues from the checkpoint. On every save, `.dataframeit.json` is written next to it with the run's signature (prompt, provider, model, `model_kwargs` except `timeout`, schema, search, and the text and status columns), a hash of each row's text, and a hash of the checkpoint itself. The checkpoint is resumed only when all three match; with another configuration, another input, or a file that is not the one the signature describes, the run warns, starts over, and overwrites it. In a row already concluded, successfully or with an error, the model fields come from the checkpoint, over whatever the input carried in them; in a successful row, that is what an uninterrupted run would give, because it writes the answer over them. To keep an output corrected by hand as it is, pass it with the status column, and the checkpoint is not read again. A complete output is returned without it, and the column comes back with `output["_dataframeit_status"] = "processed"` (or the name passed in `status_column`). With `resume=False`, the file is ignored and overwritten. To inspect what has already been processed, `read_df` loads the file with lists, dicts and text in the model's types. That DataFrame also works as input for resuming, and then the file is not read again: @@ -119,7 +119,7 @@ result = dataframeit( ) ``` -When the provider reports a failure that prevents any further row, such as an exhausted usage quota (`ProviderUsageLimitError`) or a terminated `codex` runtime (`ProviderAbortError`), execution stops dispatching rows, saves the checkpoint, and warns how many rows were left without status. The control columns stay in the output, and running again with `resume=True` processes only the pending rows. With `reprocess_columns`, rows the interruption left without reprocessing keep their previous values and are marked in `_error_details`. +When the provider reports a failure that prevents any further row, such as an exhausted usage quota (`ProviderUsageLimitError`) or a terminated `codex` runtime (`ProviderAbortError`), execution stops dispatching rows, saves the checkpoint, and warns how many rows were left without status. The control columns stay in the output, and running again with `resume=True` processes only the pending rows. With `reprocess_columns`, rows the interruption left without reprocessing keep their previous values and are marked in `_error_details`. Repeating the call with `reprocess_columns` redoes every row, including the ones already reprocessed. ## Token Tracking diff --git a/docs/en/reference/api.md b/docs/en/reference/api.md index bc9bcaa7..126667bf 100644 --- a/docs/en/reference/api.md +++ b/docs/en/reference/api.md @@ -184,7 +184,7 @@ def read_df(path: str, model=None, normalize: bool = True, **kwargs) -> pd.DataF | `normalize` | bool | If `False`, no column is converted | | `**kwargs` | | Arguments passed to pandas. With one that makes the reader return something other than a DataFrame, such as `chunksize`, `iterator` or `sheet_name=None` or a list, the function returns the pandas object, such as a `TextFileReader`, a `JsonReader`, a dict of sheets or the `Series` from `typ="series"`, without normalizing or rereading any column, even with `model` and `normalize=True` | -CSV and XLSX store `""` and missing values the same way. A required text field that was `""` comes back missing, and resuming flags it for reprocessing; use a `.parquet` checkpoint to keep that difference. +CSV and XLSX store `""` and missing values the same way, and `read_df` returns the empty cell as missing. Resuming through `checkpoint_path`, whose signature guarantees that the model is the same, gives `""` back to a text field that does not accept `None`. Passed as input, the file read back carries no such guarantee, and resuming flags the required field for reprocessing. In a field that accepts `None`, the empty cell comes back as `None`; use a `.parquet` checkpoint to keep that difference. Called with `model` and no extra read arguments, the `**kwargs` passed to pandas, the function rereads from `.csv` and `.parquet` the column of an `int` field, a `Literal` of integers only, or one of them with `None`, that pandas does not return as an integer dtype: it comes back as `object`, with the exact `int` and `None` in empty cells, instead of float64 with `NaN`, which rounds integers above 2**53. A cell the file stores as a float, such as `7.5`, stays as read. A nullable `Int64` parquet column stays `Int64`, and a column with only empty cells stays as read: float in CSV and, in the null column the checkpoint writes to parquet, `object` with `None`. An extra read argument turns this reread off; `normalize` does not. `.xlsx`, which stores numbers as doubles, is left out. @@ -246,7 +246,7 @@ Identifies the fields of a Pydantic model that hold a structure instead of a sim def get_complex_fields(pydantic_model) -> set ``` -Returns the set of names of the fields whose type is `list`, `dict`, `tuple` or a nested Pydantic model, including inside `Optional` and `Union`. +Returns the set of names of the fields whose type is a structure: `list`, `tuple`, `set`, `frozenset`, `dict`, `Sequence`, `AbstractSet` and `Mapping` and their mutable versions, `TypedDict`, `NamedTuple` or a nested Pydantic model, including inside `Optional` and `Union`. ```python from dataframeit import get_complex_fields, normalize_complex_columns, read_df diff --git a/docs/guides/performance.md b/docs/guides/performance.md index bc3a78d2..7eb3a9eb 100644 --- a/docs/guides/performance.md +++ b/docs/guides/performance.md @@ -107,7 +107,7 @@ resultado = dataframeit( ) ``` -Formato inferido pela extensão do arquivo (`.csv`, `.xlsx`, `.parquet`). Em qualquer formato, os campos de lista, dict e tupla do modelo são gravados como texto JSON, e o `Enum`, pelo valor; `read_df` devolve as estruturas, e a retomada devolve ao tipo declarado as estruturas e os campos de data, hora, `datetime`, `Enum`, `int` e `bool`, inclusive com `strict=True`: a data gravada como texto, o inteiro relido como float e a tupla relida como lista voltam a `date`, `int` e `tuple`. O XLSX guarda número como double, e um inteiro acima de 2**53 volta arredondado dele; para ids desse tamanho, use CSV ou parquet. Nenhum dos três formatos guarda o NaN de um campo `float` à parte do nulo: o CSV e o XLSX gravam os dois como célula vazia, e o parquet, como nulo. Na retomada, o NaN volta ausente, e o campo obrigatório que o recebeu é acusado para reprocessar. No parquet, o inteiro acima de 2**63 - 1 ao lado de uma linha pendente e o inteiro acima de 2**53 ao lado de um float na mesma coluna não são gravados, e a gravação do checkpoint falha com aviso. Em caso de interrupção, rode a mesma chamada de novo, com a mesma entrada: com `resume=True`, o padrão, e o arquivo presente, a execução continua do checkpoint. A cada gravação, `.dataframeit.json` é gravado ao lado dele com a assinatura da execução (prompt, provider, modelo, `model_kwargs` menos `timeout`, schema, busca e as colunas de texto e de status), o hash do texto de cada linha e o hash do próprio checkpoint. O checkpoint só é retomado quando os três batem; com outra configuração, outra entrada ou um arquivo que não é o que a assinatura descreve, a execução avisa, começa do zero e o sobrescreve. Numa linha já concluída, com sucesso ou com erro, os campos do modelo vêm do checkpoint, sobre o que a entrada trazia neles; na linha com sucesso, é o que uma execução sem interrupção daria, porque ela grava a resposta por cima. Para manter uma saída corrigida à mão como está, passe-a com a coluna de status, e o checkpoint não é relido. A saída completa sai sem ela, e a coluna volta com `saida["_dataframeit_status"] = "processed"` (ou o nome passado em `status_column`). Com `resume=False`, o arquivo é ignorado e sobrescrito. +Formato inferido pela extensão do arquivo (`.csv`, `.xlsx`, `.parquet`). Em qualquer formato, os campos de estrutura do modelo, isto é, lista, tupla, conjunto, dict, `Sequence`, `AbstractSet`, `Mapping`, `TypedDict`, `NamedTuple` e modelo aninhado, são gravados como texto JSON, o conjunto em ordem fixa, e o `Enum`, pelo valor; `read_df` devolve as estruturas, e a retomada devolve ao tipo declarado as estruturas e os campos de data, hora, `datetime`, `Enum`, `int` e `bool`, inclusive com `strict=True`: a data gravada como texto, o inteiro relido como float e a tupla relida como lista voltam a `date`, `int` e `tuple`. O CSV e o XLSX gravam o texto vazio e o `None` na mesma célula vazia. Na retomada, ela volta `""` no campo de texto que não admite `None`, como `str` ou `str = "padrão"`, e `None` no campo que o admite, como `Optional[str]`; para guardar o `""` de um campo opcional, use parquet. No XLSX, o texto que começa com `=` é gravado como texto, e não como fórmula. O XLSX guarda número como double, e um inteiro acima de 2**53 volta arredondado dele; para ids desse tamanho, use CSV ou parquet. Nenhum dos três formatos guarda o NaN de um campo `float` à parte do nulo: o CSV e o XLSX gravam os dois como célula vazia, e o parquet, como nulo. Na retomada, o NaN volta ausente, e o campo obrigatório que o recebeu é acusado para reprocessar. No parquet, o inteiro acima de 2**63 - 1 ao lado de uma linha pendente e o inteiro acima de 2**53 ao lado de um float na mesma coluna não são gravados, e a gravação do checkpoint falha com aviso. Em caso de interrupção, rode a mesma chamada de novo, com a mesma entrada: com `resume=True`, o padrão, e o arquivo presente, a execução continua do checkpoint. A cada gravação, `.dataframeit.json` é gravado ao lado dele com a assinatura da execução (prompt, provider, modelo, `model_kwargs` menos `timeout`, schema, busca e as colunas de texto e de status), o hash do texto de cada linha e o hash do próprio checkpoint. O checkpoint só é retomado quando os três batem; com outra configuração, outra entrada ou um arquivo que não é o que a assinatura descreve, a execução avisa, começa do zero e o sobrescreve. Numa linha já concluída, com sucesso ou com erro, os campos do modelo vêm do checkpoint, sobre o que a entrada trazia neles; na linha com sucesso, é o que uma execução sem interrupção daria, porque ela grava a resposta por cima. Para manter uma saída corrigida à mão como está, passe-a com a coluna de status, e o checkpoint não é relido. A saída completa sai sem ela, e a coluna volta com `saida["_dataframeit_status"] = "processed"` (ou o nome passado em `status_column`). Com `resume=False`, o arquivo é ignorado e sobrescrito. Para inspecionar o que já foi processado, `read_df` carrega o arquivo com listas, dicts e textos nos tipos do modelo. Esse DataFrame também serve de entrada para a retomada, e aí o arquivo não é lido de novo: @@ -121,7 +121,7 @@ resultado = dataframeit( ) ``` -Quando o provider informa uma falha que impede qualquer linha seguinte, como a cota de uso esgotada (`ProviderUsageLimitError`) ou o runtime do `codex` encerrado (`ProviderAbortError`), a execução para de despachar linhas, grava o checkpoint e avisa quantas ficaram sem status. As colunas de controle ficam na saída, e rodar de novo com `resume=True` processa só as pendentes. Com `reprocess_columns`, as linhas que a interrupção deixou sem reprocessar mantêm os valores anteriores e ficam marcadas em `_error_details`. +Quando o provider informa uma falha que impede qualquer linha seguinte, como a cota de uso esgotada (`ProviderUsageLimitError`) ou o runtime do `codex` encerrado (`ProviderAbortError`), a execução para de despachar linhas, grava o checkpoint e avisa quantas ficaram sem status. As colunas de controle ficam na saída, e rodar de novo com `resume=True` processa só as pendentes. Com `reprocess_columns`, as linhas que a interrupção deixou sem reprocessar mantêm os valores anteriores e ficam marcadas em `_error_details`. Repetir a chamada com `reprocess_columns` refaz todas as linhas, inclusive as que já tinham sido reprocessadas. ## Tracking de Tokens diff --git a/docs/reference/api.md b/docs/reference/api.md index 2f62c744..02a85845 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -184,7 +184,7 @@ def read_df(path: str, model=None, normalize: bool = True, **kwargs) -> pd.DataF | `normalize` | bool | Se `False`, não converte nenhuma coluna | | `**kwargs` | | Argumentos passados para pandas. Com o que faz o leitor devolver outra coisa que um DataFrame, como `chunksize`, `iterator` ou `sheet_name=None` ou lista, a função devolve o objeto do pandas, como `TextFileReader`, `JsonReader`, dict de abas ou a `Series` de `typ="series"`, sem normalizar nem reler nenhuma coluna, mesmo com `model` e `normalize=True` | -CSV e XLSX gravam `""` e ausência do mesmo jeito. Um campo obrigatório de texto que era `""` volta ausente, e a retomada o acusa para reprocessar; para guardar essa diferença, use checkpoint `.parquet`. +CSV e XLSX gravam `""` e ausência do mesmo jeito, e `read_df` devolve ausente a célula vazia. A retomada pelo `checkpoint_path`, cuja assinatura garante que o modelo é o mesmo, devolve `""` ao campo de texto que não admite `None`. Passado como entrada, o arquivo relido não traz essa garantia, e a retomada acusa o campo obrigatório para reprocessar. No campo que admite `None`, a célula vazia volta `None`; para guardar essa diferença, use checkpoint `.parquet`. Chamada com `model` e sem argumentos de leitura extras, os `**kwargs` que vão ao pandas, a função relê de `.csv` e `.parquet` a coluna de um campo `int`, `Literal` só de inteiros, ou um deles com `None`, que o pandas não devolve em tipo inteiro: ela volta como `object`, com o `int` exato e `None` na célula vazia, em vez de float64 com `NaN`, que arredonda o inteiro acima de 2**53. A célula que o arquivo guarda como float, como `7.5`, fica como lida. A coluna `Int64` anulável do parquet continua `Int64`, e a coluna só com células vazias fica como lida: float no CSV e, na coluna nula que o checkpoint grava no parquet, `object` com `None`. Argumento de leitura extra desliga essa releitura; `normalize` não a desliga. O `.xlsx`, que guarda número como double, fica de fora. @@ -246,7 +246,7 @@ Identifica os campos de um modelo Pydantic que guardam estrutura em vez de valor def get_complex_fields(pydantic_model) -> set ``` -Retorna o conjunto de nomes dos campos cujo tipo é `list`, `dict`, `tuple` ou modelo Pydantic aninhado, inclusive dentro de `Optional` e `Union`. +Retorna o conjunto de nomes dos campos cujo tipo é estrutura: `list`, `tuple`, `set`, `frozenset`, `dict`, `Sequence`, `AbstractSet` e `Mapping` e as versões mutáveis deles, `TypedDict`, `NamedTuple` ou modelo Pydantic aninhado, inclusive dentro de `Optional` e `Union`. ```python from dataframeit import get_complex_fields, normalize_complex_columns, read_df diff --git a/src/dataframeit/core.py b/src/dataframeit/core.py index 09019e12..5bd13fbd 100644 --- a/src/dataframeit/core.py +++ b/src/dataframeit/core.py @@ -61,8 +61,10 @@ ORIGINAL_TYPE_POLARS_DF, TOKEN_COLUMNS, ConversionInfo, + _admits_none, _json_default, _keeps_nan, + accepts_only_text, from_pandas, get_complex_fields, normalize_complex_columns, @@ -318,8 +320,15 @@ def _validate_processed_rows( # noqa: C901, PLR0912, PLR0915 (validação por l status_col: str, pydantic_model: type[BaseModel], complex_fields: set[str], + *, + empty_text_fields: Collection[str] = frozenset(), ) -> tuple[list[str], dict[tuple[int, str], Any]]: - """Valida linhas concluídas sem alterar o checkpoint recebido.""" + """Valida linhas concluídas sem alterar o checkpoint recebido. + + Em `empty_text_fields`, os campos cuja célula ausente é o texto vazio que o + CSV ou o XLSX do checkpoint gravou (ver `_resume_from_checkpoint`), salvo + quando a condição do campo é falsa na linha, que o deixa None. + """ incompatible_fields: set[str] = set() values_to_fill: dict[tuple[int, str], Any] = {} expected_columns = list(pydantic_model.model_fields) @@ -432,6 +441,13 @@ def model_skipping(skipped: frozenset) -> type[BaseModel]: for field_name, field in pydantic_model.model_fields.items(): value = row_values[field_name] if value is None and (field_name not in df.columns or pd.isna(row[field_name])): + if field_name in empty_text_fields and field_name not in skipped: + # Gravado também quando a linha é recusada por outro campo: + # com esse campo em reprocess_columns, a linha fica, e este + # mantém o valor que tinha. + projected[field_name] = "" + values_to_fill[(position, field_name)] = "" + continue missing_values.add(field_name) if not field.is_required() or field_name in skipped: continue @@ -1373,6 +1389,7 @@ def dataframeit( # noqa: C901, PLR0912, PLR0913, PLR0915, PLR0917 (API pública # é desta execução, ela continua dele. Quem carrega o checkpoint à mão e o # passa como entrada já traz a coluna, e o arquivo não é lido de novo. signature = None + empty_text_fields: frozenset[str] = frozenset() if checkpoint is not None: signature = _run_signature( questions, @@ -1391,7 +1408,7 @@ def dataframeit( # noqa: C901, PLR0912, PLR0913, PLR0915, PLR0917 (API pública structures=frozenset(complex_fields), ) if resume and status_col not in df_pandas.columns and Path(checkpoint.path).exists(): - _resume_from_checkpoint( + empty_text_fields = _resume_from_checkpoint( df_pandas, checkpoint, questions, @@ -1449,6 +1466,7 @@ def dataframeit( # noqa: C901, PLR0912, PLR0913, PLR0915, PLR0917 (API pública status_col, questions, complex_fields, + empty_text_fields=empty_text_fields, ) uncovered_columns = [ column for column in incompatible_columns if column not in reprocessed_columns @@ -1621,7 +1639,8 @@ def dataframeit( # noqa: C901, PLR0912, PLR0913, PLR0915, PLR0917 (API pública if kept: summary += ( f", e {kept} não foram reprocessadas e mantêm os valores anteriores " - "(marcadas em _error_details); para elas, repita a chamada com reprocess_columns" + "(marcadas em _error_details); repetir a chamada com reprocess_columns " + "refaz todas as linhas, inclusive as já reprocessadas" ) if checkpoint is not None: how = "rode de novo com o mesmo checkpoint_path, ou com resume=True sobre esta saída" @@ -2001,7 +2020,7 @@ def _resume_from_checkpoint( *, text_column: str, status_col: str, -) -> None: +) -> frozenset[str]: """Traz para a entrada, in-place, o que um checkpoint desta execução já processou. O checkpoint é retomado só quando a assinatura gravada ao lado dele é a @@ -2020,6 +2039,19 @@ def _resume_from_checkpoint( checkpoint não relê. As demais colunas de entrada ficam como o usuário as passou, porque o CSV e o XLSX não preservam o tipo delas. As colunas se casam pelo nome em texto, que é como o CSV as devolve. + + Devolve os campos cuja célula vazia, numa linha processada, é o texto + vazio, para a validação da retomada (`_validate_processed_rows`); sem + retomar, nenhum. O CSV e o XLSX gravam o texto vazio e o None na mesma + célula vazia, que o leitor devolve ausente. A assinatura garante que o + checkpoint foi gravado com este modelo, e nele o campo que só aceita texto, + não admite None e não tem None por padrão só fica None quando a condição + dele é falsa. O campo com exclude=True fica de fora, porque o model_dump + não o traz e a célula dele guarda o None que a execução pôs ao criar a + coluna. Sem essa garantia, como no checkpoint carregado à mão, a + célula vazia continua ausente, porque com outro modelo ela pode ser o None + de um campo que o admitia. No campo que admite None, ela volta None, e o + texto vazio só sobrevive no parquet. """ reason, saved = _checkpoint_mismatch(df, checkpoint, questions, status_col=status_col) if reason is not None or saved is None: @@ -2030,7 +2062,7 @@ def _resume_from_checkpoint( UserWarning, stacklevel=3, ) - return + return frozenset() concluded = saved[status_col].notna().to_numpy() by_name = {str(column): column for column in df.columns} @@ -2045,6 +2077,14 @@ def _resume_from_checkpoint( values = df[target].to_numpy(dtype=object).copy() values[concluded] = saved[column].to_numpy(dtype=object)[concluded] df[target] = values + return frozenset( + field_name + for field_name, field in questions.model_fields.items() + if accepts_only_text(field.annotation) + and not _admits_none(field.annotation) + and field.default is not None + and not field.exclude + ) def _validate_checkpoint_extension(path: str | Path) -> None: @@ -2076,7 +2116,7 @@ def _array_as_list(value: object) -> object: 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. + """Cópia com listas, dicts, tuplas, conjuntos e arrays do numpy serializados como JSON. CSV e XLSX gravariam o repr Python ("['a', 'b']"), que json.loads não lê de volta, e o de um array ("['a' 'b']") o literal_eval lê como ['ab']. O @@ -2100,7 +2140,7 @@ def enum_as_value(value: object) -> object: def to_json(value: object) -> object: value = _array_as_list(value) - if isinstance(value, (list, dict, tuple)): + if isinstance(value, (list, dict, tuple, set, frozenset)): return json.dumps(value, ensure_ascii=False, default=_json_default) return enum_as_value(value) @@ -2147,7 +2187,7 @@ def _save_checkpoint( if ext == ".csv": _structures_as_json(df).to_csv(tmp, index=False) elif ext == ".xlsx": - _structures_as_json(df).to_excel(tmp, index=False) + _to_excel_as_text(_structures_as_json(df), tmp) elif ext == ".parquet": _structures_as_json(df, structures).to_parquet(tmp, index=False) else: @@ -2161,6 +2201,23 @@ def _save_checkpoint( _write_checkpoint_signature(path, meta) +def _to_excel_as_text(df: pd.DataFrame, path: Path) -> None: + """Grava o XLSX com o texto que começa com "=" como texto, e não como fórmula. + + O openpyxl toma por fórmula todo texto que começa com "=", e o leitor do + pandas devolve vazia a célula de fórmula, que nunca foi calculada. O + checkpoint não grava fórmula: toda célula que o openpyxl marcou assim é + texto da execução ou da entrada, e volta a ser texto. O engine é fixado + porque a marca é do openpyxl, que o checkpoint .xlsx já exige. + """ + with pd.ExcelWriter(path, engine="openpyxl") as writer: + df.to_excel(writer, index=False) + for row in next(iter(writer.sheets.values())).iter_rows(): + for cell in row: + if cell.data_type == "f": + cell.data_type = "s" + + def _try_save_checkpoint(df: pd.DataFrame, checkpoint: _Checkpoint) -> bool: """Grava o checkpoint e devolve se deu certo; falha vira aviso. diff --git a/src/dataframeit/utils.py b/src/dataframeit/utils.py index ecc09117..3be120e0 100644 --- a/src/dataframeit/utils.py +++ b/src/dataframeit/utils.py @@ -11,6 +11,7 @@ from __future__ import annotations import ast +import collections.abc import datetime import functools import json @@ -406,12 +407,21 @@ 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 que json.dumps não serializa: conjunto como lista, numpy pelo tolist, Enum pelo valor. + + O conjunto vira lista em ordem fixa, a do texto JSON de cada item. A ordem + em que um set de textos é percorrido muda de um processo para outro, com o + hash dos textos, e a lista que a retomada relê do checkpoint só diz o mesmo + que o conjunto validado (`_same_json`) quando os dois saem na mesma ordem. + O texto JSON também ordena itens de tipos que não se comparam, como 1 e "a". 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. + O resto vai como texto. """ + if isinstance(value, (set, frozenset)): + return sorted(value, key=lambda item: json.dumps(item, default=_json_default)) if isinstance(value, Enum): return value.value tolist = getattr(value, "tolist", None) @@ -779,16 +789,35 @@ def _reorder_columns(df: pd.DataFrame, status_col: str = "_dataframeit_status") # ============================================================================= +# Origens abstratas cujo valor o model_dump devolve como lista, conjunto ou dict. +# A busca é por identidade: o str e o bytes também são Sequence para o +# collections.abc, e a célula guarda o valor inteiro deles. +_ABSTRACT_STRUCTURES = ( + collections.abc.Sequence, + collections.abc.MutableSequence, + collections.abc.Set, + collections.abc.MutableSet, + collections.abc.Mapping, + collections.abc.MutableMapping, +) +# Classes cujas instâncias a célula guarda como estrutura. O TypedDict e o +# NamedTuple contam pela subclasse, de dict e de tuple, e o modelo aninhado +# porque a célula guarda o seu model_dump, um dict. +_STRUCTURE_CLASSES = (list, tuple, set, frozenset, dict, BaseModel) + + def is_complex_type(field_type: object) -> bool: - """Verifica se um tipo é complexo (list, dict, tuple ou modelo Pydantic). + """Verifica se um tipo é estrutura: lista, tupla, conjunto, dict ou modelo Pydantic. Args: field_type: Tipo a verificar (pode ser tipo simples ou genérico). Returns: - True se o tipo for list, dict, tuple ou subclasse de BaseModel, direto - ou dentro de Optional/Union. Um modelo aninhado conta como complexo - porque a célula guarda o seu model_dump, um dict. + True se o tipo, direto ou dentro de Optional/Union, é uma das classes + de `_STRUCTURE_CLASSES` ou subclasse dela, como o TypedDict e o + NamedTuple, ou um genérico com uma delas ou com uma das origens de + `_ABSTRACT_STRUCTURES`. É o que decide quais campos o checkpoint grava + em JSON e a retomada normaliza de volta. """ field_type = _resolved(field_type) origin = get_origin(field_type) @@ -798,31 +827,19 @@ def is_complex_type(field_type: object) -> bool: if origin is typing.Annotated: return is_complex_type(get_args(field_type)[0]) - # Tipos genéricos: list[str], dict[str, int], tuple[int, str], etc. - if origin in (list, dict, tuple): - return True - - # Union types (Optional, Union) - verificar os argumentos internos - # typing.Union para sintaxe Union[X, Y] e Optional[X] - if origin is typing.Union: - args = get_args(field_type) - return any(is_complex_type(arg) for arg in args if arg is not type(None)) - - # types.UnionType para sintaxe X | Y - if isinstance(field_type, types.UnionType): + # Union[X, Y] e Optional[X] têm origem typing.Union; X | Y é types.UnionType. + if origin is typing.Union or isinstance(field_type, types.UnionType): args = get_args(field_type) return any(is_complex_type(arg) for arg in args if arg is not type(None)) - # Tipos diretos - if field_type in (list, dict, tuple): + container = field_type if origin is None else origin + if container in _ABSTRACT_STRUCTURES: return True - - # Modelo aninhado: a linha guarda o model_dump, um dict - return bool(isinstance(field_type, type) and issubclass(field_type, BaseModel)) + return isinstance(container, type) and issubclass(container, _STRUCTURE_CLASSES) def get_complex_fields(pydantic_model: type[BaseModel]) -> set: - """Retorna os nomes dos campos que são tipos complexos (list, dict, tuple). + """Retorna os nomes dos campos que são estruturas (ver `is_complex_type`). Args: pydantic_model: Modelo Pydantic a analisar. @@ -840,7 +857,11 @@ def get_complex_fields(pydantic_model: type[BaseModel]) -> set: def accepts_only_text(field_type: object) -> bool: - """True se o tipo só aceita texto: str, Literal ou Enum de textos, ou esses com None.""" + """True se o tipo só aceita texto: str, Literal ou Enum de textos, ou esses com None. + + O None vale também dentro do Literal, como em Literal["NA", None], que diz o + mesmo que Optional[Literal["NA"]]. + """ field_type = _resolved(field_type) if field_type is str: return True @@ -850,13 +871,29 @@ def accepts_only_text(field_type: object) -> bool: if origin is typing.Annotated: # dentro de Optional, como em is_complex_type return accepts_only_text(get_args(field_type)[0]) if origin is typing.Literal: - return all(isinstance(arg, str) for arg in get_args(field_type)) + args = [arg for arg in get_args(field_type) if arg is not None] + return bool(args) and all(isinstance(arg, str) for arg in args) if origin is typing.Union or isinstance(field_type, types.UnionType): args = [arg for arg in get_args(field_type) if arg is not type(None)] return bool(args) and all(accepts_only_text(arg) for arg in args) return False +def _admits_none(field_type: object) -> bool: + """Se o tipo aceita None: o próprio None, a união com ele ou o Literal com ele.""" + field_type = _resolved(field_type) + if field_type is type(None): + return True + origin = get_origin(field_type) + if origin is typing.Annotated: # dentro de Optional, como em is_complex_type + return _admits_none(get_args(field_type)[0]) + if origin is typing.Literal: + return None in get_args(field_type) + if origin is typing.Union or isinstance(field_type, types.UnionType): + return any(_admits_none(arg) for arg in get_args(field_type)) + return False + + def get_text_fields(pydantic_model: type[BaseModel]) -> set: """Nomes dos campos que só aceitam texto.""" return { diff --git a/tests/test_checkpoint_robusto.py b/tests/test_checkpoint_robusto.py index 60b19732..7a7a3859 100644 --- a/tests/test_checkpoint_robusto.py +++ b/tests/test_checkpoint_robusto.py @@ -14,7 +14,7 @@ from dataframeit import read_df from dataframeit.core import _save_checkpoint, _validate_processed_rows, dataframeit -from dataframeit.utils import accepts_only_text, normalize_value +from dataframeit.utils import _admits_none, accepts_only_text, normalize_value class ModeloSimples(BaseModel): @@ -223,7 +223,11 @@ class Pessoa(BaseModel): def test_texto_obrigatorio_vazio_em_csv_continua_acusado(tmp_path): - """CSV grava "" e None do mesmo jeito; a retomada não adivinha qual era.""" + """Passado como entrada, o CSV relido não traz a assinatura que diz que o modelo é o mesmo. + + O CSV grava "" e None do mesmo jeito, e com outro modelo a célula vazia pode + ser o None de um campo que o admitia; a retomada não adivinha qual era. + """ class ComObservacao(BaseModel): observacao: str @@ -304,6 +308,29 @@ class Pessoa(BaseModel): assert incompativeis == ["cpf"] +def test_texto_vazio_do_checkpoint_respeita_a_condicao_do_campo(): + """A célula vazia só vira texto vazio quando a condição do campo é verdadeira na linha.""" + + class Pessoa(BaseModel): + tipo: str + cpf: str = Field(json_schema_extra={"condition": {"field": "tipo", "equals": "pf"}}) + + df = pd.DataFrame( + { + "tipo": ["pf", "pj"], + "cpf": [None, None], + "_dataframeit_status": ["processed", "processed"], + } + ) + incompativeis, valores = _validate_processed_rows( + df, "_dataframeit_status", Pessoa, set(), empty_text_fields={"cpf"} + ) + + assert incompativeis == [] + assert valores[(0, "cpf")] == "" + assert valores[(1, "cpf")] is None + + @pytest.mark.parametrize("parallel_requests", [1, 3]) def test_falha_na_ultima_gravacao_intermediaria_e_coberta_pela_final(tmp_path, parallel_requests): gravacoes = [] @@ -368,6 +395,36 @@ class Cor(Enum): assert relido["interno"][0] == '{"itens": ["a"], "cor": "azul"}' +def test_checkpoint_textual_grava_conjunto_em_ordem_fixa(tmp_path): + """A ordem de um set muda de um processo para outro; o JSON dele sai sempre na mesma. + + O {8, 0} é percorrido como 8, 0 no CPython, porque os dois caem na mesma + posição da tabela e o 8 entra primeiro. + """ + ckpt = tmp_path / "ckpt.csv" + df = pd.DataFrame( + {"numeros": [{8, 0}], "textos": [frozenset({"b", "a"})], "dentro": [{"s": {8, 0}}]} + ) + _save_checkpoint(df, ckpt) + + relido = pd.read_csv(ckpt) + assert relido["numeros"][0] == "[0, 8]" + assert relido["textos"][0] == '["a", "b"]' + assert relido["dentro"][0] == '{"s": [0, 8]}' + + +def test_checkpoint_xlsx_grava_como_texto_o_que_comeca_com_igual(tmp_path): + """O openpyxl tomaria o texto por fórmula, que o pandas relê vazia.""" + pytest.importorskip("openpyxl") + ckpt = tmp_path / "ckpt.xlsx" + df = pd.DataFrame({"resposta": ["=1+1", "=SOMA(A1;A2)"], "_error_details": [None, "=x"]}) + _save_checkpoint(df, ckpt) + + relido = pd.read_excel(ckpt) + assert relido["resposta"].tolist() == ["=1+1", "=SOMA(A1;A2)"] + assert relido["_error_details"].tolist()[1] == "=x" + + @pytest.mark.parametrize("texto", ["{[1]: 2} petição", "{[1], [2]}", "[1, 2"]) def test_normalize_value_devolve_o_texto_quando_nao_e_estrutura(texto): @@ -400,6 +457,9 @@ def test_accepts_only_text_cobre_optional_e_literal(): assert accepts_only_text(Optional[str]) assert accepts_only_text(Literal["a", "b"]) assert accepts_only_text(Optional[Literal["a"]]) + # A forma que o lint recusa por estilo é a que o usuário escreve no modelo. + assert accepts_only_text(Literal["NA", "deferido", None]) # noqa: PYI061 + assert not accepts_only_text(Literal[None]) # noqa: PYI061 assert not accepts_only_text(Literal["a", 1]) assert not accepts_only_text(Optional[int]) assert not accepts_only_text(list[str]) @@ -416,6 +476,29 @@ class Numero(Enum): assert not accepts_only_text(Numero) +# O None dentro do Literal, que o lint recusa por estilo, é a forma que o usuário escreve. +_LITERAL_COM_NONE = Literal["NA", "deferido", None] # noqa: PYI061 +_ANOTADO_COM_NONE = Annotated[Literal["NA", None], Field(description="x")] # noqa: PYI061 +_APELIDO_COM_NONE = TypeAliasType("_APELIDO_COM_NONE", _ANOTADO_COM_NONE) + + +@pytest.mark.parametrize( + ("anotacao", "admite"), + [ + (str, False), + (Optional[str], True), + (_LITERAL_COM_NONE, True), + (Literal["NA"], False), + (_ANOTADO_COM_NONE | Literal["y"], True), + (Annotated[str, Field(min_length=1)] | Literal["y"], False), + (_APELIDO_COM_NONE, True), + ], +) +def test_admits_none_ve_o_none_na_uniao_no_literal_e_no_annotated(anotacao, admite): + """O pydantic tira só o Annotated de cima; o de dentro da união e o do apelido ficam.""" + assert _admits_none(anotacao) is admite + + class Inteiros(BaseModel): nota: Annotated[int, Field(ge=0)] | None = None misto: int | str | None = None diff --git a/tests/test_interrupcao.py b/tests/test_interrupcao.py index 5ce3d54d..4b39f2d8 100644 --- a/tests/test_interrupcao.py +++ b/tests/test_interrupcao.py @@ -115,6 +115,25 @@ def test_interrupcao_no_reprocessamento_marca_as_linhas_que_ficaram_com_valor_an assert detalhes[~antigas].isna().all() +def test_aviso_do_reprocessamento_interrompido_diz_que_a_repeticao_refaz_todas_as_linhas(): + """Numa execução de 4 linhas interrompida depois de 2, a repetição faz as 4 chamadas.""" + df = pd.DataFrame( + { + "texto": [f"t{i}" for i in range(4)], + "campo1": ["antigo"] * 4, + "_dataframeit_status": ["processed"] * 4, + } + ) + _, llm = _llm_que_esgota(2, ProviderUsageLimitError("usage limit")) + with pytest.warns(UserWarning, match="refaz todas as linhas, inclusive as já reprocessadas"): + parcial = _roda(df, llm, reprocess_columns=["campo1"]) + + chamadas, llm = _llm_que_esgota(100, ProviderUsageLimitError("nunca")) + _roda(parcial, llm, reprocess_columns=["campo1"]) + + assert len(chamadas) == 4 + + def test_linha_com_erro_que_ia_ser_refeita_volta_a_ficar_pendente(): df = pd.DataFrame( { diff --git a/tests/test_normalize_python_structures.py b/tests/test_normalize_python_structures.py index ebbd2cd0..e701bad3 100644 --- a/tests/test_normalize_python_structures.py +++ b/tests/test_normalize_python_structures.py @@ -6,12 +6,15 @@ import importlib.util import tempfile +from collections.abc import Mapping, MutableMapping, MutableSequence, MutableSet, Sequence +from collections.abc import Set as AbstractSet from pathlib import Path -from typing import Annotated, Literal, Optional +from typing import Annotated, Literal, NamedTuple, Optional import pandas as pd import pytest from pydantic import BaseModel, Field +from typing_extensions import TypedDict from dataframeit.utils import ( _normalize_all_json_columns, @@ -122,6 +125,46 @@ def test_is_complex_type_annotated_dentro_de_optional(): assert is_complex_type(Annotated[str, Field(min_length=1)] | None) is False +class Autoria(TypedDict): + autor: str + ano: int + + +class Ponto(NamedTuple): + x: int + y: int + + +@pytest.mark.parametrize( + "anotacao", + [ + Sequence[str], + MutableSequence[str], + set[str], + frozenset[str], + AbstractSet[str], + MutableSet[str], + Mapping[str, int], + MutableMapping[str, int], + set, + frozenset, + Autoria, + Ponto, + Optional[set[str]], + Annotated[Sequence[str], Field(min_length=1)] | None, + ], +) +def test_is_complex_type_colecao_que_o_model_dump_devolve_como_estrutura(anotacao): + """O model_dump devolve cada uma como lista, conjunto, dict ou tupla, que a célula guarda.""" + assert is_complex_type(anotacao) is True + + +@pytest.mark.parametrize("anotacao", [bytes, Literal["a", "b"], Optional[Literal["a"]]]) +def test_is_complex_type_texto_e_bytes_nao_sao_estrutura(anotacao): + """O str e o bytes são Sequence para o collections.abc, mas a célula guarda o valor inteiro.""" + assert is_complex_type(anotacao) is False + + # ============================================================================= # TESTES PARA get_complex_fields # ============================================================================= diff --git a/tests/test_retomada_assinada.py b/tests/test_retomada_assinada.py index 01bbe546..f845f35d 100644 --- a/tests/test_retomada_assinada.py +++ b/tests/test_retomada_assinada.py @@ -4,8 +4,11 @@ import csv import datetime import warnings +from collections.abc import Mapping, Sequence +from collections.abc import Set as AbstractSet from enum import Enum from pathlib import Path +from typing import Literal, NamedTuple from unittest.mock import patch import numpy as np @@ -19,6 +22,7 @@ field_validator, model_validator, ) +from typing_extensions import TypedDict from dataframeit import ProviderUsageLimitError, dataframeit, read_df from dataframeit.core import ( @@ -1281,3 +1285,185 @@ def test_inteiro_grande_do_checkpoint_xlsx_volta_arredondado_e_strict_o_recusa(t assert final["nota"][0] == _INTEIRO_GRANDE - 1 with pytest.raises(ValueError, match="campos incompatíveis"): _retoma(df, tmp_path / "NotaEstrita.xlsx", NotaEstrita) + + +# ============================================================================= +# Texto vazio, Literal com None e coleções voltam do checkpoint como gravados +# ============================================================================= + + +def _direta_e_retomada(tmp_path, formato, modelo, resposta): + """(saída sem interrupção, saída da retomada, linhas chamadas na retomada). + + A resposta passa pelo model_dump do modelo, como a dos providers. A primeira + execução com checkpoint responde a linha "a" e é interrompida na "b", e a + retomada pelo mesmo checkpoint só tem a "b" por responder. + """ + if formato == "xlsx": + pytest.importorskip("openpyxl") + if formato == "parquet": + pytest.importorskip("pyarrow") + ckpt = tmp_path / f"ckpt.{formato}" + df = pd.DataFrame({"texto": ["a", "b"]}) + + def dump(text): + return modelo.model_validate(resposta(text)).model_dump() + + chamadas = [] + + def responde(text, *args, **kwargs): + chamadas.append(text) + return {"data": dump(text), "usage": None} + + direta = _roda(df, responde, modelo=modelo) + with pytest.warns(UserWarning, match="interrompida"): + _roda(df, _uma_linha_por_execucao(dump), modelo=modelo, batch_size=1, checkpoint_path=ckpt) + chamadas.clear() + retomada = _roda(df, responde, modelo=modelo, batch_size=1, checkpoint_path=ckpt) + return direta, retomada, chamadas + + +class ComPadrao(BaseModel): + campo1: str = "padrão" + + +class ComPadraoNone(BaseModel): + # O pydantic não valida o default, e o campo str guarda o None dele. + campo1: str = None # ty: ignore[invalid-assignment] + + +class ComOcultoNoDump(BaseModel): + campo1: str = Field(default="padrão", exclude=True) + + +class Decisao(BaseModel): + # A forma do caso relatado, que o lint recusa por estilo. + campo1: Literal["NA", "deferido", None] # noqa: PYI061 + + +@pytest.mark.parametrize("formato", ["csv", "xlsx"]) +@pytest.mark.parametrize( + ("modelo", "resposta", "valor"), + [ + (Modelo, {"campo1": ""}, ""), + (Modelo, {"campo1": "=1+1"}, "=1+1"), + (ComPadrao, {"campo1": ""}, ""), + (ModeloOpcional, {"campo1": None}, None), + (ComPadraoNone, {}, None), + (ComOcultoNoDump, {"campo1": "x"}, None), + (Decisao, {"campo1": "NA"}, "NA"), + (Decisao, {"campo1": None}, None), + ], + ids=[ + "str-vazio", + "str-com-igual", + "str-com-padrao-vazio", + "opcional-none", + "str-com-padrao-none-none", + "str-fora-do-dump-none", + "literal-com-none-na", + "literal-com-none-none", + ], +) +def test_texto_do_checkpoint_textual_volta_como_a_execucao_o_gravou( + tmp_path, formato, modelo, resposta, valor +): + """O texto vazio do campo que não admite None volta vazio, e o None dos demais volta None. + + O CSV e o XLSX gravam "" e None na mesma célula vazia. O "NA" do Literal com + None é texto, e o "=1+1" vai ao XLSX como texto, e não como fórmula. O campo + com exclude=True não sai no model_dump, e a célula dele fica None. + """ + direta, retomada, chamadas = _direta_e_retomada( + tmp_path, + formato, + modelo, + lambda text: resposta if text == "a" else {"campo1": "deferido"}, + ) + + # A coluna do campo fora do dump volta NaN do checkpoint relido, e não None; + # o que o teste separa é o texto vazio do ausente. + def lidos(saida): + return [None if pd.isna(v) else v for v in saida["campo1"]] + + assert chamadas == ["b"] + assert lidos(retomada) == lidos(direta) + assert lidos(direta)[0] == valor + + +class Autoria(TypedDict): + autor: str + ano: int + + +class Ponto(NamedTuple): + x: int + rotulo: str + + +class ComSequencia(BaseModel): + itens: Sequence[str] + + +class ComConjunto(BaseModel): + itens: set[str] + + +class ComConjuntoAbstrato(BaseModel): + itens: AbstractSet[str] + + +class ComTypedDict(BaseModel): + itens: Autoria + + +class ComMapping(BaseModel): + itens: Mapping[str, int] + + +class ComNamedTuple(BaseModel): + itens: Ponto + + +class ComConjuntoNoDict(BaseModel): + itens: dict[str, set[str]] + + +_COLECOES = { + ComSequencia: lambda text: [text, "z"], + ComConjunto: lambda text: {text, "z"}, + ComConjuntoAbstrato: lambda text: {text, "z"}, + ComTypedDict: lambda text: {"autor": text, "ano": 2024}, + ComMapping: lambda text: {text: 1}, + ComNamedTuple: lambda text: (1, text), + ComConjuntoNoDict: lambda text: {"partes": {text, "z"}}, +} + + +def _com_tipos(valor): + """O valor com o tipo de cada parte, que a igualdade do Python não separa: 1 == 1.0.""" + if isinstance(valor, (set, frozenset)): + return type(valor).__name__, sorted(map(_com_tipos, valor), key=repr) + if isinstance(valor, dict): + return type(valor).__name__, [(_com_tipos(k), _com_tipos(v)) for k, v in valor.items()] + if isinstance(valor, (list, tuple)): + return type(valor).__name__, [_com_tipos(v) for v in valor] + return type(valor).__name__, valor + + +@pytest.mark.parametrize("formato", ["csv", "xlsx", "parquet"]) +@pytest.mark.parametrize("modelo", list(_COLECOES), ids=lambda modelo: modelo.__name__) +def test_colecao_volta_do_checkpoint_com_o_valor_e_o_tipo_gravados(tmp_path, formato, modelo): + """Sequence, conjunto, TypedDict, Mapping e NamedTuple são estruturas do checkpoint. + + O checkpoint as grava em JSON, e a retomada as devolve ao tipo do campo, + inclusive nas partes: o parquet gravaria o dict como struct e relê o int dele + como float. + """ + resposta = _COLECOES[modelo] + direta, retomada, chamadas = _direta_e_retomada( + tmp_path, formato, modelo, lambda text: {"itens": resposta(text)} + ) + + assert chamadas == ["b"] + assert [_com_tipos(v) for v in retomada["itens"]] == [_com_tipos(v) for v in direta["itens"]]