diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d6b09ba..3971c966 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR ### Corrigido +- A coluna `_trace` do agente único fica com as demais colunas de trace na ordem da saída, depois dos campos do modelo. Antes, numa entrada que já a trazia, os campos novos saíam depois dela. +- Com entrada polars, as colunas que a execução não escreve saem do DataFrame de entrada, com o tipo e os valores de lá. Antes, a saída inteira era convertida de volta do pandas: um `Int64` com nulo voltava `Float64`, o que arredondava ids acima de 2**53, um `Date` voltava `Datetime`, e `Enum`, `Decimal`, `Array`, `Null` e o inteiro dentro de `Struct` e `List` também trocavam de tipo; uma coluna `Object` também trocava de tipo ou, com objetos que o Arrow não infere, levantava `ArrowInvalid` depois das chamadas (#195). - O operando de `in` e `not_in` em `condition` é conferido antes de processar, com `ValueError` quando não é lista, tupla, set ou frozenset. Antes, um operando que não era coleção, como `{"in": 5}`, levantava `TypeError` em cada linha depois das chamadas dos campos anteriores, e um texto, como `{"in": "pfj"}`, fazia busca de substring e aceitava `"pf"` (#182). - A retomada de um modelo com `strict=True` deixa de acusar como incompatíveis os campos que o checkpoint grava em outra forma, como a data em texto, a tupla como lista, o `Enum` pelo valor e o inteiro que a coluna com linha pendente relê como float. A linha recusada pela validação configurada é validada de novo sem strict, e cada campo recusado só é aceito quando o valor validado diz o mesmo que o relido; `"3"` num campo `int` strict continua acusado. A volta ao tipo declarado passa a valer também para os campos escalares: data, `datetime`, `Enum`, `int` e `bool` relidos do CSV, do XLSX ou do parquet deixam de misturar o tipo da execução com o do arquivo. Os campos de `Enum` de valores em texto são relidos como texto do CSV e do XLSX, como os de `Literal`, e um valor como `"1"` deixa de voltar como número. O checkpoint grava o `Enum` escalar pelo valor; antes, o CSV e o XLSX gravavam `"Cor.AZUL"`, que a retomada recusava, e o parquet falhava ao gravar (#178). - A forma de `condition` em `json_schema_extra` é conferida antes de processar, com `ValueError` que diz o que é aceito: dict com `field` em texto e um operador (`equals`, `not_equals`, `in`, `not_in` ou `exists`), ou callable. Antes, `condition={}` e os demais valores falsos, como `False` ou `[]`, rodavam o campo sem aviso, embora `evaluate_condition` trate o dict vazio como condição falsa; `field` que não era texto levantava `AttributeError`; e as demais formas eram avisadas a cada linha, com o campo pulado (#163). diff --git a/src/dataframeit/core.py b/src/dataframeit/core.py index b7df9f39..bae66793 100644 --- a/src/dataframeit/core.py +++ b/src/dataframeit/core.py @@ -1425,7 +1425,7 @@ def dataframeit( # noqa: C901, PLR0912, PLR0913, PLR0915, PLR0917 (API pública _apply_processed_values(df_pandas, processed_values) if complex_fields: normalize_complex_columns(df_pandas, complex_fields) - return from_pandas(df_pandas, conversion_info, status_col) + return from_pandas(df_pandas, conversion_info, status_col, expected_columns) # Os resultados são gravados por rótulo (df.at); com rótulo repetido, o # resultado de uma linha cairia em outra. Um checkpoint já concluído, que @@ -1600,7 +1600,7 @@ def dataframeit( # noqa: C901, PLR0912, PLR0913, PLR0915, PLR0917 (API pública ) # Retornar no formato original (remove colunas de status/erro se não houver erros) - return from_pandas(df_pandas, conversion_info, status_col) + return from_pandas(df_pandas, conversion_info, status_col, expected_columns) def _setup_columns( # noqa: C901, PLR0912, PLR0913 (uma família de colunas por recurso ligado) diff --git a/src/dataframeit/utils.py b/src/dataframeit/utils.py index 93bce6ee..87f52c92 100644 --- a/src/dataframeit/utils.py +++ b/src/dataframeit/utils.py @@ -28,9 +28,10 @@ from pydantic import BaseModel if TYPE_CHECKING: - from collections.abc import Callable, Hashable + from collections.abc import Callable, Collection, Hashable from polars import DataFrame as PolarsDataFrame + from polars import Series as PolarsSeries # Import opcional de Polars try: @@ -90,6 +91,8 @@ class ConversionInfo: original_type: str original_index: pd.Index | list | None = None # Índice/chaves originais para reconversão series_name: Hashable | None = None # Nome original da Series (se aplicável) + # DataFrame polars de entrada, de onde a saída tira as colunas que a execução não escreve + polars_frame: PolarsDataFrame | None = None def parse_json(resposta: str) -> dict: @@ -152,7 +155,9 @@ def to_pandas(data: object) -> tuple[pd.DataFrame, ConversionInfo]: # polars DataFrame if pl is not None and isinstance(data, pl.DataFrame): - return data.to_pandas(), ConversionInfo(original_type=ORIGINAL_TYPE_POLARS_DF) + return data.to_pandas(), ConversionInfo( + original_type=ORIGINAL_TYPE_POLARS_DF, polars_frame=data + ) # pandas Series if isinstance(data, pd.Series): @@ -199,6 +204,7 @@ def from_pandas( df: pd.DataFrame, conversion_info: ConversionInfo | bool, # noqa: FBT001 (bool aceito por retrocompatibilidade) status_col: str = "_dataframeit_status", + model_columns: Collection[str] = (), ) -> pd.DataFrame | PolarsDataFrame: """Converte DataFrame pandas de volta para o formato original. @@ -210,6 +216,8 @@ def from_pandas( conversion_info: ConversionInfo com metadados da conversão original, ou bool para retrocompatibilidade (True = era polars). status_col: Nome da coluna de status (o `status_column` do usuário). + model_columns: Campos do modelo que a execução gravou. Na saída polars, + só eles e as colunas de controle são convertidos do pandas. Returns: Dados no formato original (DataFrame, Series, list ou dict). @@ -245,51 +253,73 @@ def from_pandas( df = _reorder_columns(df, status_col) if original_type in _POLARS_INPUTS and pl is not None: - return pl.from_pandas(df) + untouched = _untouched_columns(conversion_info.polars_frame, status_col, model_columns) + converted = pl.from_pandas(df[[col for col in df.columns if col not in untouched]]) + return pl.DataFrame( + [untouched[col] if col in untouched else converted[col] for col in df.columns] + ) return df -def _reorder_columns(df: pd.DataFrame, status_col: str = "_dataframeit_status") -> pd.DataFrame: - """Reordena colunas para que internas fiquem no final. +def _untouched_columns( + source: PolarsDataFrame | None, + status_col: str, + model_columns: Collection[str], +) -> dict[str, PolarsSeries]: + """Colunas da entrada polars que a execução não escreve, pelo nome. - Ordem final das colunas: - 1. Colunas do usuário (originais + campos do modelo) - 2. Colunas de trace (_trace_*) - 3. Colunas de busca (_search_credits) - 4. Colunas de tokens (_input_tokens, _cached_input_tokens, _output_tokens, - _reasoning_tokens) - 5. Colunas de controle (_dataframeit_status, _error_details) + São as colunas do usuário que não são campo do modelo. A saída as tira de + `source`, o DataFrame polars de entrada: convertidas de volta do pandas, + elas trocariam de tipo (Date por Datetime, Enum por Categorical) e trariam + arredondado o inteiro acima de 2**53 que tem nulo na coluna ou está dentro + de Struct e List, porque o `to_pandas()` do polars o leva a float. As linhas da saída estão na ordem das de `source`, porque a + execução grava por rótulo e não remove nem reordena linhas. - Args: - df: DataFrame a reordenar. - status_col: Nome da coluna de status. + Sem `source` (entrada polars.Series, que só traz o texto), todas as colunas + são da execução. + """ + if source is None: + return {} + return { + col: source[col] + for col in source.columns + if col not in model_columns and _column_rank(col, status_col) == _USER_COLUMN + } - Returns: - DataFrame com colunas reordenadas. + +# Posição da coluna do usuário em _column_rank; as demais são as que a execução acrescenta. +_USER_COLUMN = (0, 0) + + +def _column_rank(col: Hashable, status_col: str) -> tuple[int, int]: + """Família da coluna e posição dentro dela, na ordem em que a saída as traz. + + 1. Colunas do usuário (originais + campos do modelo) + 2. Colunas de trace (_trace e _trace_*) + 3. Colunas de busca (_search_credits) + 4. Colunas de tokens, na ordem de TOKEN_COLUMNS + 5. Colunas de controle (status e _error_details) """ - # Separar colunas por categoria - user_cols = [] - trace_cols = [] - search_cols = [] - token_cols = [col for col in TOKEN_COLUMNS if col in df.columns] - status_cols = [] + if col == "_trace" or str(col).startswith("_trace_"): + return (1, 0) + if col == "_search_credits": + return (2, 0) + if col in TOKEN_COLUMNS: + return (3, TOKEN_COLUMNS.index(col)) + if col in (status_col, "_error_details"): + return (4, 0) + return _USER_COLUMN - for col in df.columns: - if str(col).startswith("_trace_"): - trace_cols.append(col) - elif col == "_search_credits": - search_cols.append(col) - elif col in TOKEN_COLUMNS: - continue - elif col in (status_col, "_error_details"): - status_cols.append(col) - else: - user_cols.append(col) - # Ordem final: user -> trace -> search -> tokens -> status - new_order = user_cols + trace_cols + search_cols + token_cols + status_cols +def _reorder_columns(df: pd.DataFrame, status_col: str = "_dataframeit_status") -> pd.DataFrame: + """Reordena as colunas para que as da execução fiquem no final. - return df[new_order] + A ordem é a de `_column_rank`; dentro de cada família, menos a de tokens, + as colunas ficam na ordem em que estavam. A seleção é por posição, para que + um rótulo repetido não traga a coluna mais de uma vez. + """ + order = sorted(range(df.shape[1]), key=lambda i: _column_rank(df.columns[i], status_col)) + return df.iloc[:, order] # ============================================================================= diff --git a/tests/test_entrada_e_saida.py b/tests/test_entrada_e_saida.py index 324fd9c0..fb754841 100644 --- a/tests/test_entrada_e_saida.py +++ b/tests/test_entrada_e_saida.py @@ -2,14 +2,17 @@ import logging import warnings +from datetime import date +from decimal import Decimal +from typing import Optional, Union from unittest.mock import patch import numpy as np import pandas as pd import pytest -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict -from dataframeit import ProviderError, core, dataframeit +from dataframeit import ProviderAbortError, ProviderError, core, dataframeit from dataframeit.core import _format_token_stats @@ -21,6 +24,33 @@ class ModeloLista(BaseModel): tags: list[str] +class ModeloNotaInteira(BaseModel): + nota: int + + +class ModeloNotaDecimal(BaseModel): + nota: float + + +class ModeloNotaMista(BaseModel): + nota: Union[int, float] + + +class ModeloContagem(BaseModel): + x: str + n: Optional[int] = None + + +class ModeloFlag(BaseModel): + relevante: bool + + +class ModeloFlagEstrita(BaseModel): + model_config = ConfigDict(strict=True) + + relevante: bool + + def _responde(**campos): def call_langchain(text, *args, **kwargs): return { @@ -31,6 +61,24 @@ def call_langchain(text, *args, **kwargs): return call_langchain +def _responde_em_sequencia(*respostas): + """Uma resposta por chamada; a que for exceção é levantada.""" + fila = iter(respostas) + + def call_langchain(text, *args, **kwargs): + resposta = next(fila) + if isinstance(resposta, Exception): + raise resposta + return {"data": resposta, "usage": None} + + return call_langchain + + +def _responde_em_sequencia_com_uso(uso, *respostas): + sem_uso = _responde_em_sequencia(*respostas) + return lambda text, *args, **kwargs: {**sem_uso(text), "usage": uso} + + def _rodar(df, questions=Modelo, llm=None, **opcoes): llm = llm or _responde(x=lambda t: f"x-{t}") with ( @@ -610,3 +658,203 @@ def test_resumo_sem_requisicao_concluida_omite_a_taxa(): assert "METRICAS DE THROUGHPUT" in saida assert "TPM (tokens/min): 0" in saida assert "Requisicoes" not in saida + + +# ============================================================================= +# Entrada polars: colunas que a execução não escreve +# ============================================================================= + +# 2**53 + 1, o primeiro inteiro que o float64 arredonda. +_ID_GRANDE = 9007199254740993 + +# Um tipo por coluna que a ida e volta pelo pandas trocaria ou arredondaria. +_COLUNAS_POLARS = { + "int64_com_nulo": lambda pl: pl.Series([_ID_GRANDE, None], dtype=pl.Int64), + "uint64_com_nulo": lambda pl: pl.Series([2**64 - 1, None], dtype=pl.UInt64), + "float_com_nan_e_nulo": lambda pl: pl.Series([float("nan"), None], dtype=pl.Float64), + "date": lambda pl: pl.Series([date(2024, 1, 2), None], dtype=pl.Date), + "enum": lambda pl: pl.Series(["a", None], dtype=pl.Enum(["a", "b"])), + "decimal": lambda pl: pl.Series([Decimal("1.10"), None], dtype=pl.Decimal(10, 2)), + "array": lambda pl: pl.Series([[1, 2], [3, 4]], dtype=pl.Array(pl.Int64, 2)), + "struct_com_inteiro_grande": lambda pl: pl.Series( + [{"id": _ID_GRANDE}, {"id": None}], dtype=pl.Struct({"id": pl.Int64}) + ), + "lista_com_inteiro_grande": lambda pl: pl.Series( + [[_ID_GRANDE, None], [1]], dtype=pl.List(pl.Int64) + ), + "null": lambda pl: pl.Series([None, None], dtype=pl.Null), + "object": lambda pl: pl.Series([{1}, None], dtype=pl.Object), +} + + +@pytest.mark.parametrize("tipo", _COLUNAS_POLARS) +def test_coluna_polars_que_a_execucao_nao_escreve_volta_como_entrou(tipo): + pl = pytest.importorskip("polars") + entrada = pl.DataFrame({"texto": ["a", "b"], "c": _COLUNAS_POLARS[tipo](pl)}) + + resultado, _ = _rodar(entrada) + + assert resultado.columns == ["texto", "c", "x"] + assert resultado["c"].dtype == entrada["c"].dtype + # Series.equals não compara Object, nem Decimal no polars 0.20. + if entrada["c"].dtype in (pl.Object, pl.Decimal): + assert resultado["c"].to_list() == entrada["c"].to_list() + else: + assert resultado["c"].equals(entrada["c"]) + + +def test_saida_polars_mantem_cada_linha_com_o_seu_resultado(): + pl = pytest.importorskip("polars") + entrada = pl.DataFrame( + {"texto": ["a", "b", "c"], "id": pl.Series([_ID_GRANDE, None, 1], dtype=pl.Int64)} + ) + + def llm(text, *args, **kwargs): + if text.endswith("b"): + msg = "falhou" + raise ValueError(msg) + return {"data": {"x": text[-1]}, "usage": None} + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + resultado, _ = _rodar(entrada, llm=llm, max_retries=1) + + assert resultado["id"].to_list() == [_ID_GRANDE, None, 1] + assert resultado["x"].to_list() == ["a", None, "c"] + assert resultado["_dataframeit_status"].to_list() == ["processed", "error", "processed"] + + +def test_campo_reprocessado_em_saida_polars_sai_com_o_tipo_da_resposta_nova(): + pl = pytest.importorskip("polars") + primeira, _ = _rodar( + pl.DataFrame({"texto": ["a", "b"]}), questions=ModeloNotaInteira, llm=_responde(nota=7) + ) + assert primeira["nota"].dtype == pl.Int64 + + segunda, _ = _rodar( + primeira, questions=ModeloNotaDecimal, llm=_responde(nota=7.5), reprocess_columns=["nota"] + ) + + assert segunda["nota"].to_list() == [7.5, 7.5] + + +def test_retomada_de_saida_polars_nao_trunca_a_resposta_nova(): + pl = pytest.importorskip("polars") + entrada = pl.DataFrame({"texto": ["a", "b", "c"]}) + with pytest.warns(UserWarning, match="interrompida"): + interrompida, _ = _rodar( + entrada, + questions=ModeloNotaMista, + llm=_responde_em_sequencia({"nota": 7}, ProviderAbortError("limite")), + ) + assert interrompida["nota"].dtype == pl.Int64 + + retomada, _ = _rodar( + interrompida, + questions=ModeloNotaMista, + llm=_responde_em_sequencia({"nota": 8.5}, {"nota": 9.9}), + resume=True, + ) + + assert retomada["nota"].to_list() == [7.0, 8.5, 9.9] + + +def test_retomada_de_saida_polars_sem_resposta_tipa_o_campo_pelas_respostas(): + pl = pytest.importorskip("polars") + entrada = pl.DataFrame({"texto": ["a", "b", "c"]}) + with pytest.warns(UserWarning, match="interrompida"): + interrompida, _ = _rodar( + entrada, + questions=ModeloContagem, + llm=_responde_em_sequencia(ProviderAbortError("limite")), + ) + assert interrompida["n"].null_count() == 3 + + retomada, _ = _rodar( + interrompida, + questions=ModeloContagem, + llm=_responde_em_sequencia({"x": "p", "n": 1}, {"x": "q"}, {"x": "r", "n": 3}), + resume=True, + ) + + assert retomada["n"].dtype == pl.Int64 + assert retomada["n"].to_list() == [1, None, 3] + + +def test_saida_polars_ja_concluida_devolve_o_campo_de_lista_normalizado(): + pl = pytest.importorskip("polars") + entrada = pl.DataFrame( + {"texto": ["a"], "tags": ['["p", "q"]'], "_dataframeit_status": ["processed"]} + ) + + resultado, llm = _rodar(entrada, questions=ModeloLista, resume=True) + + llm.assert_not_called() + assert resultado["tags"].to_list() == [["p", "q"]] + + +def test_entrada_polars_com_colunas_existentes_volta_igual(): + pl = pytest.importorskip("polars") + entrada = pl.DataFrame( + {"texto": ["a"], "x": [date(2024, 1, 2)], "id": pl.Series([None], dtype=pl.Int64)} + ) + + with pytest.warns(UserWarning, match="já existem"): + resultado, llm = _rodar(entrada, resume=False) + + llm.assert_not_called() + assert resultado.schema == entrada.schema + assert resultado.equals(entrada) + + +def test_entrada_polars_vazia_mantem_o_tipo_das_colunas(): + pl = pytest.importorskip("polars") + entrada = pl.DataFrame( + {"texto": pl.Series([], dtype=pl.Utf8), "dia": pl.Series([], dtype=pl.Date)} + ) + + resultado, _ = _rodar(entrada) + + assert resultado.columns == ["texto", "dia", "x"] + assert resultado["dia"].dtype == pl.Date + + +def test_retomada_de_saida_polars_traz_os_tokens_das_linhas_novas(): + pl = pytest.importorskip("polars") + uso = {"input_tokens": 3, "output_tokens": 2, "total_tokens": 5} + with pytest.warns(UserWarning, match="interrompida"): + interrompida, _ = _rodar( + pl.DataFrame({"texto": ["a", "b"]}), + llm=_responde_em_sequencia_com_uso(uso, {"x": "p"}, ProviderAbortError("limite")), + track_tokens=True, + ) + assert interrompida["_input_tokens"].to_list() == [3, None] + + retomada, _ = _rodar( + interrompida, + llm=_responde_em_sequencia_com_uso(uso, {"x": "q"}), + track_tokens=True, + resume=True, + ) + + assert retomada["_input_tokens"].to_list() == [3, 3] + assert retomada["_output_tokens"].to_list() == [2, 2] + + +@pytest.mark.parametrize("modelo", [ModeloFlag, ModeloFlagEstrita]) +def test_retomada_polars_de_campo_bool_gravado_como_inteiro(modelo): + """Um bool lido de volta de SQLite pelo polars chega como Int64 0/1.""" + pl = pytest.importorskip("polars") + entrada = pl.DataFrame( + { + "texto": ["a", "b", "c"], + "relevante": pl.Series([1, 0, None], dtype=pl.Int64), + "_dataframeit_status": ["processed", "processed", None], + } + ) + + resultado, llm = _rodar(entrada, questions=modelo, llm=_responde(relevante=True), resume=True) + + assert llm.call_count == 1 + assert resultado["relevante"].dtype == pl.Boolean + assert resultado["relevante"].to_list() == [True, False, True] diff --git a/tests/test_multiple_data_types.py b/tests/test_multiple_data_types.py index 06201395..38411975 100644 --- a/tests/test_multiple_data_types.py +++ b/tests/test_multiple_data_types.py @@ -7,6 +7,7 @@ - dict """ +import datetime import importlib.util import sys @@ -272,6 +273,93 @@ def test_dict_roundtrip(self): assert "resumo" in result.columns +@pytest.mark.skipif(not HAS_POLARS, reason="polars não instalado") +class TestEntradaPolars: + """A entrada polars guarda o frame original e não arredonda inteiro com nulo.""" + + def test_inteiro_com_nulo_mantem_precisao_e_tipo(self): + ids = [9007199254740993, None, 1234567890123456789] + data = pl.DataFrame({"texto": ["a", "b", "c"], "id": pl.Series(ids, dtype=pl.Int64)}) + + df, info = to_pandas(data) + df["resultado"] = ["x", "y", "z"] + result = from_pandas(df, info) + + assert result["id"].dtype == pl.Int64 + assert result["id"].to_list() == ids + assert result["resultado"].to_list() == ["x", "y", "z"] + + def test_inteiro_sem_nulo_fica_como_estava(self): + data = pl.DataFrame({"id": pl.Series([1, 2], dtype=pl.UInt8)}) + + df, info = to_pandas(data) + + assert df["id"].dtype == "uint8" + assert from_pandas(df, info)["id"].dtype == pl.UInt8 + + def test_coluna_de_data_continua_data(self): + data = pl.DataFrame({"texto": ["a"], "dia": [datetime.date(2024, 1, 2)]}) + + df, info = to_pandas(data) + result = from_pandas(df, info) + + assert result["dia"].dtype == pl.Date + assert result["dia"].to_list() == [datetime.date(2024, 1, 2)] + + def test_campo_do_modelo_que_estava_na_entrada_sai_do_pandas(self): + data = pl.DataFrame({"texto": ["a"], "nota": [7]}) + df, info = to_pandas(data) + df["nota"] = [7.5] + + assert from_pandas(df, info)["nota"].to_list() == [7] + assert from_pandas(df, info, model_columns=["nota"])["nota"].to_list() == [7.5] + + @pytest.mark.parametrize( + "coluna", + [ + "_trace", + "_trace_campo", + "_search_credits", + "_input_tokens", + "_dataframeit_status", + "_error_details", + ], + ) + def test_coluna_de_controle_que_estava_na_entrada_sai_do_pandas(self, coluna): + data = pl.DataFrame({"texto": ["a", "b"], coluna: ["antes", None]}) + df, info = to_pandas(data) + df[coluna] = ["depois", "error"] + + result = from_pandas(df, info) + + assert result[coluna].to_list() == ["depois", "error"] + + def test_coluna_de_status_personalizada_que_estava_na_entrada_sai_do_pandas(self): + data = pl.DataFrame({"texto": ["a", "b"], "st": ["error", None]}) + df, info = to_pandas(data) + df["st"] = ["processed", "error"] + + assert from_pandas(df, info)["st"].to_list() == ["error", None] + assert from_pandas(df, info, "st")["st"].to_list() == ["processed", "error"] + + +def test_rotulo_repetido_nao_duplica_coluna_na_reordenacao(): + df = pd.DataFrame([[1, 2, 3, 4]], columns=["_input_tokens", "a", "_input_tokens", "a"]) + + resultado = from_pandas(df, ConversionInfo(original_type=ORIGINAL_TYPE_PANDAS_DF)) + + assert list(resultado.columns) == ["a", "a", "_input_tokens", "_input_tokens"] + assert resultado.to_numpy().tolist() == [[2, 4, 1, 3]] + + +def test_coluna_trace_sem_sufixo_fica_com_as_de_trace(): + df = pd.DataFrame([[1, 2, 3]], columns=["_trace", "texto", "_search_credits"]) + + resultado = from_pandas(df, ConversionInfo(original_type=ORIGINAL_TYPE_PANDAS_DF)) + + assert list(resultado.columns) == ["texto", "_trace", "_search_credits"] + + def test_dict_com_chaves_tupla_mantem_indice_simples(): """Chaves-tupla viram rótulos de um Index simples, sem MultiIndex.""" original = {("sp", 2020): "texto a", ("rj", 2021): "texto b"}