diff --git a/CHANGELOG.md b/CHANGELOG.md index 63a6411c..5ab235f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR - Nos providers `claude_code` e `codex`, o status HTTP 408 ou 409 dá `ProviderTransientError`, e a linha é repetida com backoff, como no erro de SDK que chega pelo LangChain; no 408, o aviso sai com a caixa de timeout. Antes, os dois status davam `ProviderError` definitivo, e a linha ficava com erro na primeira falha (#274). - A caixa genérica de erro (`ERRO NO PROCESSAMENTO`) mostra a mensagem em até três linhas, terminada em reticências quando passa disso, e a moldura fica alinhada nas linhas do tipo e dos detalhes. Antes, a mensagem era cortada em 66 caracteres sem aviso, e no `claude_code` o corte caía dentro do prefixo `Claude Code SDK retornou erro (subtipo, status N): `, sem mostrar nada do texto do SDK (#275). - No provider `claude_code`, o `api_error_status` que o SDK entrega como texto de dígitos ou float inteiro, como `"401"` ou `401.0`, é classificado como o status inteiro: `"401"` dá a caixa de autenticação sem nova tentativa, e `"429"` reduz o paralelismo. `True`, `False` e os demais valores contam como status ausente; antes, `True` passava como status e virava `ProviderError` definitivo com `status_code=True` (#276). +- O campo `deque`, de `collections` ou `typing.Deque`, passa a contar como estrutura em `get_complex_fields`: o checkpoint o grava em JSON, também dentro de outra estrutura, como `dict[str, deque[str]]`, e a retomada o devolve como `deque`. Antes, a retomada do CSV e do XLSX recusava a linha concluída e pedia `reprocess_columns`, e a do parquet, cuja gravação falhava, pagava de novo as linhas concluídas. A estrutura passa a ser reconhecida pelas ABCs `Sequence`, `Set` e `Mapping`, menos texto e bytes (#287). ## [0.11.1] - 2026-10-29 diff --git a/docs/en/guides/performance.md b/docs/en/guides/performance.md index 4e0cb118..6b089c06 100644 --- a/docs/en/guides/performance.md +++ b/docs/en/guides/performance.md @@ -129,7 +129,7 @@ result = dataframeit( ) ``` -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 a `str` field whose default is `None`, given directly or by a factory, it also comes back as `None`. The empty string of a field whose factory reads the other fields is not restored: the empty cell counts as missing, and resuming fills it with the factory's value, even where the run saved `""`. A validator that turns an answer into `None` in a `str` field gives a value that the field's type rejects. In parquet, resuming flags that `None` for reprocessing. In CSV and XLSX, it flags the field when the validator turns the empty string itself into `None`, and returns `""` when it turns another answer, such as `"N/A"`, into `None`, because the empty cell does not say which of the two was saved. To keep that `None`, declare the field as `Optional[str]`. This holds for a run that applies the model's validators; in per-field mode (`search_per_field`), they do not run during execution, which saves the empty string as it came, and resuming from CSV and XLSX flags the field whose validator turns the empty string into `None`, while resuming from parquet returns the saved `""`. Parquet stores the empty string and `None` apart, and resuming returns the empty string as it was saved. 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. 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, `deque`, 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 a `str` field whose default is `None`, given directly or by a factory, it also comes back as `None`. The empty string of a field whose factory reads the other fields is not restored: the empty cell counts as missing, and resuming fills it with the factory's value, even where the run saved `""`. A validator that turns an answer into `None` in a `str` field gives a value that the field's type rejects. In parquet, resuming flags that `None` for reprocessing. In CSV and XLSX, it flags the field when the validator turns the empty string itself into `None`, and returns `""` when it turns another answer, such as `"N/A"`, into `None`, because the empty cell does not say which of the two was saved. To keep that `None`, declare the field as `Optional[str]`. This holds for a run that applies the model's validators; in per-field mode (`search_per_field`), they do not run during execution, which saves the empty string as it came, and resuming from CSV and XLSX flags the field whose validator turns the empty string into `None`, while resuming from parquet returns the saved `""`. Parquet stores the empty string and `None` apart, and resuming returns the empty string as it was saved. 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. 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. In the signature, the schema is the model's JSON Schema. When a field's `json_schema_extra`, nested fields included, holds a function, such as the condition from `field_condition`, or a set with a value that is not an integer, such as `{"in": {"pf", "mei"}}` in a `condition`, the attributes of each field take its place, with the set sorted and the function entered by its qualified name and its code: bytecode, constants and the names it reads. A `functools.partial` enters by the function it wraps and its fixed arguments; an object with `__call__`, by the code of `__call__` and the instance attributes, `__slots__` included; and a bound method, by the method's code and its owner: an object by its attributes, and the class of a `classmethod` by its name only. Editing the function, the `__call__`, the method or an argument of the `partial`, or changing an attribute of the object, changes the signature, and the run starts over. What the function reads from outside its own code does not enter: with another value in a global variable or in an enclosing scope, another default argument value or another body in a function it calls, the signature is the same, and resuming accepts the rows already concluded, in which the field was asked or skipped by the previous condition. To change the condition through one of these paths, pass `resume=False` or another `checkpoint_path`. The bytecode changes from one minor Python version to another, such as from 3.11 to 3.12, and the checkpoint of a model with a function is not resumed after that change. diff --git a/docs/en/reference/api.md b/docs/en/reference/api.md index a0977e95..f311e936 100644 --- a/docs/en/reference/api.md +++ b/docs/en/reference/api.md @@ -277,7 +277,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 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`. +Returns the set of names of the fields whose type is a structure: `list`, `tuple`, `deque`, `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 1806917d..431c1470 100644 --- a/docs/guides/performance.md +++ b/docs/guides/performance.md @@ -131,7 +131,7 @@ resultado = dataframeit( ) ``` -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 campo `str` cujo default é `None`, dado direto ou pela fábrica, ela também volta `None`. O texto vazio do campo cuja fábrica lê os outros campos não é restaurado: a célula vazia conta como ausente, e a retomada a preenche com o valor da fábrica, mesmo onde a execução gravou `""`. O validador que troca uma resposta por `None` num campo `str` dá um valor que o tipo do campo recusa. No parquet, a retomada acusa esse `None` para reprocessar. No CSV e no XLSX, ela acusa o campo quando o validador troca o próprio texto vazio por `None`, e devolve `""` quando ele troca outra resposta, como `"N/A"`, porque a célula vazia não diz qual das duas foi gravada. Para guardar esse `None`, declare o campo como `Optional[str]`. Isso vale para a execução que aplica os validadores do modelo; no modo por campo (`search_per_field`), eles não rodam na execução, que grava o texto vazio como veio, e a retomada do CSV e do XLSX acusa o campo cujo validador troca o texto vazio por `None`, enquanto a do parquet devolve o `""` gravado. O parquet grava o texto vazio e o `None` à parte, e a retomada devolve o texto vazio como foi gravado. 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. 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, `deque`, 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 campo `str` cujo default é `None`, dado direto ou pela fábrica, ela também volta `None`. O texto vazio do campo cuja fábrica lê os outros campos não é restaurado: a célula vazia conta como ausente, e a retomada a preenche com o valor da fábrica, mesmo onde a execução gravou `""`. O validador que troca uma resposta por `None` num campo `str` dá um valor que o tipo do campo recusa. No parquet, a retomada acusa esse `None` para reprocessar. No CSV e no XLSX, ela acusa o campo quando o validador troca o próprio texto vazio por `None`, e devolve `""` quando ele troca outra resposta, como `"N/A"`, porque a célula vazia não diz qual das duas foi gravada. Para guardar esse `None`, declare o campo como `Optional[str]`. Isso vale para a execução que aplica os validadores do modelo; no modo por campo (`search_per_field`), eles não rodam na execução, que grava o texto vazio como veio, e a retomada do CSV e do XLSX acusa o campo cujo validador troca o texto vazio por `None`, enquanto a do parquet devolve o `""` gravado. O parquet grava o texto vazio e o `None` à parte, e a retomada devolve o texto vazio como foi gravado. 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. 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. Na assinatura, o schema é o JSON Schema do modelo. Quando o `json_schema_extra` de um campo, inclusive aninhado, tem uma função, como a condição de `field_condition`, ou um conjunto com valor que não é inteiro, como `{"in": {"pf", "mei"}}` numa `condition`, entram no lugar dele os atributos de cada campo, com o conjunto em ordem e a função pelo nome qualificado e pelo código: bytecode, constantes e nomes que ela lê. O `functools.partial` entra pela função que embrulha e pelos argumentos fixados; o objeto com `__call__`, pelo código do `__call__` e pelos atributos da instância, inclusive os de `__slots__`; e o método ligado, pelo código do método e pelo dono: o objeto entra pelos atributos, e a classe do `classmethod`, só pelo nome. Editar a função, o `__call__`, o método ou um argumento do `partial`, ou mudar um atributo do objeto, muda a assinatura, e a execução recomeça. O que ela lê de fora do próprio código não entra: com outro valor numa variável global ou do escopo de fora, outro valor padrão de argumento ou outro corpo numa função que ela chama, a assinatura é a mesma, e a retomada aceita as linhas já concluídas, em que o campo foi pedido ou pulado pela condição anterior. Para mudar a condição por um desses caminhos, passe `resume=False` ou outro `checkpoint_path`. O bytecode muda de uma versão menor do Python para outra, como da 3.11 para a 3.12, e o checkpoint de um modelo com função não é retomado depois dessa troca. diff --git a/docs/reference/api.md b/docs/reference/api.md index bdfced85..40157fc6 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -277,7 +277,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 é 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`. +Retorna o conjunto de nomes dos campos cujo tipo é estrutura: `list`, `tuple`, `deque`, `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 029e5ff3..541d0362 100644 --- a/src/dataframeit/core.py +++ b/src/dataframeit/core.py @@ -66,6 +66,7 @@ ConversionInfo, _admits_none, _column_rank, + _is_structure_class, _json_default, _keeps_nan, accepts_only_text, @@ -2641,7 +2642,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, conjuntos e arrays do numpy serializados como JSON. + """Cópia com as coleções de `_is_structure_class` e os 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 @@ -2665,7 +2666,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, set, frozenset)): + if _is_structure_class(type(value)): return json.dumps(value, ensure_ascii=False, default=_json_default) return enum_as_value(value) diff --git a/src/dataframeit/utils.py b/src/dataframeit/utils.py index 5c393f94..fa9db39e 100644 --- a/src/dataframeit/utils.py +++ b/src/dataframeit/utils.py @@ -405,7 +405,7 @@ def _enums_by_value(values: pd.Series) -> pd.Series: def _json_default(value: object) -> object: - """O que json.dumps não serializa: conjunto como lista, numpy pelo tolist, Enum pelo valor. + """O que json.dumps não serializa: conjunto e deque 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 @@ -414,7 +414,14 @@ def _json_default(value: object) -> object: 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 + que pelo texto voltaria "5" ou "True". Ele vem antes do ramo de sequência + porque sabe montar a lista aninhada de um buffer de mais de uma dimensão, + como a memoryview, que o list() não percorre. + + A sequência que não é lista nem tupla, como o deque, vira lista na ordem + dela. O texto não é coleção para a célula (`_TEXT_CLASSES`): a UserString + é Sequence, e cada item que o list() tira dela é outra UserString, então + descer nela não termina. 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. """ @@ -423,7 +430,11 @@ def _json_default(value: object) -> object: if isinstance(value, Enum): return value.value tolist = getattr(value, "tolist", None) - return tolist() if callable(tolist) else str(value) + if callable(tolist): + return tolist() + if isinstance(value, collections.abc.Sequence) and not isinstance(value, _TEXT_CLASSES): + return list(value) + return str(value) def _resolved(annotation: object) -> object: @@ -787,21 +798,19 @@ 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) +# Coleções que a célula guarda como estrutura, reconhecidas pela ABC: a lista, +# a tupla, o NamedTuple, o deque, o set, o frozenset, o dict, o TypedDict e as +# subclasses de dict, além das próprias origens abstratas e das versões +# mutáveis delas. O texto e os bytes também são Sequence para o +# collections.abc e ficam de fora pela subclasse, inclusive o Enum de texto: a +# célula guarda o valor inteiro deles. +_STRUCTURE_ABCS = (collections.abc.Sequence, collections.abc.Set, collections.abc.Mapping) +_TEXT_CLASSES = (str, bytes, bytearray, collections.UserString) + + +def _is_structure_class(cls: type) -> bool: + """Se a classe é coleção que a célula guarda como estrutura (`_STRUCTURE_ABCS`).""" + return issubclass(cls, _STRUCTURE_ABCS) and not issubclass(cls, _TEXT_CLASSES) def is_complex_type(field_type: object) -> bool: @@ -811,11 +820,11 @@ def is_complex_type(field_type: object) -> bool: field_type: Tipo a verificar (pode ser tipo simples ou genérico). Returns: - 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. + True se o tipo, direto ou dentro de Optional/Union, ou a origem dele, + quando genérico, é coleção de `_is_structure_class` ou modelo + Pydantic, que a célula guarda pelo model_dump, um dict. É 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) @@ -833,9 +842,9 @@ def is_complex_type(field_type: object) -> bool: return any(is_complex_type(arg) for arg in args if arg is not type(None)) container = field_type if origin is None else origin - if container in _ABSTRACT_STRUCTURES: - return True - return isinstance(container, type) and issubclass(container, _STRUCTURE_CLASSES) + return isinstance(container, type) and ( + issubclass(container, BaseModel) or _is_structure_class(container) + ) def get_complex_fields(pydantic_model: type[BaseModel]) -> set: diff --git a/tests/test_checkpoint_robusto.py b/tests/test_checkpoint_robusto.py index a733b404..12f8e191 100644 --- a/tests/test_checkpoint_robusto.py +++ b/tests/test_checkpoint_robusto.py @@ -2,6 +2,7 @@ import datetime import warnings +from collections import UserString from enum import Enum from typing import Annotated, Literal, NewType, Optional, TypeVar from unittest.mock import patch @@ -12,7 +13,7 @@ from pydantic import BaseModel, Field, model_validator from typing_extensions import TypeAliasType -from dataframeit import read_df +from dataframeit import ProviderUsageLimitError, read_df from dataframeit.core import _save_checkpoint, _validate_processed_rows, dataframeit from dataframeit.utils import _admits_none, accepts_only_text, normalize_value @@ -1118,3 +1119,54 @@ def test_retomada_de_checkpoint_csv_com_newtype_e_apelido(tmp_path): assert pd.isna(linha[campo]) else: assert linha[campo] == valor + + +# ============================================================================= +# Coluna do usuário com texto que é Sequence +# ============================================================================= + + +@pytest.mark.parametrize("formato", ["csv", "xlsx"]) +@pytest.mark.parametrize( + "valores", + [ + [UserString("ola"), UserString("oi")], + [{"k": UserString("x")}, {"k": UserString("y")}], + ], + ids=["userstring", "userstring-num-dict"], +) +def test_coluna_do_usuario_com_userstring_vai_ao_checkpoint(tmp_path, formato, valores): + """A UserString é Sequence, mas é texto: o checkpoint a grava, e a retomada paga só a pendente.""" + caminho = tmp_path / f"ck.{formato}" + chamadas = [] + + def call_langchain(text, *args, **kwargs): + chamadas.append(text) + if text == "b" and len(chamadas) == 2: + mensagem = "cota" + raise ProviderUsageLimitError(mensagem) + return {"data": {"campo": f"v-{text}"}, "usage": None} + + def roda(): + df = pd.DataFrame({"texto": ["a", "b"], "extra": valores}) + with ( + patch("dataframeit.core.call_langchain", side_effect=call_langchain), + patch("dataframeit.core.validate_provider_dependencies"), + warnings.catch_warnings(record=True) as avisos, + ): + warnings.simplefilter("always") + dataframeit( + df, + ModeloSimples, + "{texto}", + checkpoint_path=caminho, + batch_size=1, + track_tokens=False, + ) + return [str(a.message) for a in avisos if "Falha ao gravar" in str(a.message)] + + assert roda() == [] + assert caminho.exists() + chamadas.clear() + assert roda() == [] + assert chamadas == ["b"] diff --git a/tests/test_normalize_python_structures.py b/tests/test_normalize_python_structures.py index e701bad3..53849c50 100644 --- a/tests/test_normalize_python_structures.py +++ b/tests/test_normalize_python_structures.py @@ -5,11 +5,21 @@ """ import importlib.util +import json import tempfile +from collections import Counter, OrderedDict, UserString, defaultdict, deque from collections.abc import Mapping, MutableMapping, MutableSequence, MutableSet, Sequence from collections.abc import Set as AbstractSet +from enum import StrEnum from pathlib import Path -from typing import Annotated, Literal, NamedTuple, Optional +from typing import ( # noqa: UP035 (os apelidos do typing são casos testados) + Annotated, + DefaultDict, + Deque, + Literal, + NamedTuple, + Optional, +) import pandas as pd import pytest @@ -17,6 +27,7 @@ from typing_extensions import TypedDict from dataframeit.utils import ( + _json_default, _normalize_all_json_columns, get_complex_fields, is_complex_type, @@ -152,6 +163,13 @@ class Ponto(NamedTuple): Ponto, Optional[set[str]], Annotated[Sequence[str], Field(min_length=1)] | None, + deque[str], + Deque[str], # noqa: UP006 (o apelido do typing é caso testado) + deque, + Counter[str], + OrderedDict[str, int], + defaultdict[str, int], + DefaultDict[str, int], # noqa: UP006 (o apelido do typing é caso testado) ], ) def test_is_complex_type_colecao_que_o_model_dump_devolve_como_estrutura(anotacao): @@ -159,7 +177,14 @@ def test_is_complex_type_colecao_que_o_model_dump_devolve_como_estrutura(anotaca assert is_complex_type(anotacao) is True -@pytest.mark.parametrize("anotacao", [bytes, Literal["a", "b"], Optional[Literal["a"]]]) +class CorTexto(StrEnum): + AZUL = "azul" + + +@pytest.mark.parametrize( + "anotacao", + [bytes, bytearray, UserString, CorTexto, 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 @@ -551,3 +576,14 @@ def test_numero_em_coluna_de_objeto_nao_quebra_a_deteccao_de_json(): assert list(sem_json["misto"]) == [1, "x"] assert list(com_json["misto"]) == [1, ["a"]] + + +def test_json_default_monta_a_lista_aninhada_da_memoryview_de_duas_dimensoes(): + """O tolist vem antes do ramo de sequência: o list() não percorre buffer de mais de uma dimensão.""" + visao = memoryview(bytearray(4)).cast("b", (2, 2)) + + assert json.loads(json.dumps([visao], default=_json_default)) == [[[0, 0], [0, 0]]] + + +def test_json_default_grava_a_userstring_como_texto(): + assert json.dumps([UserString("ola")], default=_json_default) == '["ola"]' diff --git a/tests/test_retomada_assinada.py b/tests/test_retomada_assinada.py index 9f7adbdc..2583b087 100644 --- a/tests/test_retomada_assinada.py +++ b/tests/test_retomada_assinada.py @@ -4,11 +4,17 @@ import csv import datetime import warnings +from collections import Counter, OrderedDict, defaultdict, deque from collections.abc import Mapping, Sequence from collections.abc import Set as AbstractSet from enum import Enum from pathlib import Path -from typing import Any, Literal, NamedTuple +from typing import ( # noqa: UP035 (o apelido do typing é caso testado) + Any, + Deque, + Literal, + NamedTuple, +) from unittest.mock import patch import numpy as np @@ -1638,6 +1644,30 @@ class ComConjuntoNoDict(BaseModel): itens: dict[str, set[str]] +class ComDeque(BaseModel): + itens: deque[str] + + +class ComDequeDoTyping(BaseModel): + itens: Deque[str] # noqa: UP006 (o apelido do typing é caso testado) + + +class ComDequeNoDict(BaseModel): + itens: dict[str, deque[str]] + + +class ComCounter(BaseModel): + itens: Counter[str] + + +class ComOrderedDict(BaseModel): + itens: OrderedDict[str, int] + + +class ComDefaultdict(BaseModel): + itens: defaultdict[str, int] + + _COLECOES = { ComSequencia: lambda text: [text, "z"], ComConjunto: lambda text: {text, "z"}, @@ -1646,6 +1676,12 @@ class ComConjuntoNoDict(BaseModel): ComMapping: lambda text: {text: 1}, ComNamedTuple: lambda text: (1, text), ComConjuntoNoDict: lambda text: {"partes": {text, "z"}}, + ComDeque: lambda text: [text, "z"], + ComDequeDoTyping: lambda text: [text, "z"], + ComDequeNoDict: lambda text: {"partes": [text, "z"]}, + ComCounter: lambda text: {text: 2, "z": 1}, + ComOrderedDict: lambda text: {text: 2, "z": 1}, + ComDefaultdict: lambda text: {text: 2, "z": 1}, } @@ -1655,7 +1691,7 @@ def _com_tipos(valor): 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)): + if isinstance(valor, (list, tuple, deque)): return type(valor).__name__, [_com_tipos(v) for v in valor] return type(valor).__name__, valor @@ -1663,7 +1699,7 @@ def _com_tipos(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. + """Sequence, conjunto, deque, TypedDict, Mapping, NamedTuple e as subclasses de dict 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