Skip to content
36 changes: 18 additions & 18 deletions pyathena/arrow/result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from pyathena import OperationalError
from pyathena.arrow.util import to_column_info
from pyathena.converter import Converter, _text_value_converter, _to_default
from pyathena.converter import _TEXT_VALUE_TYPES, Converter, _text_value_converter, _to_default
from pyathena.error import ProgrammingError
from pyathena.model import AthenaQueryExecution
from pyathena.result_set import AthenaResultSet
Expand Down Expand Up @@ -254,23 +254,23 @@ def _fetch(self) -> None:
except StopIteration:
return
else:
dict_rows = rows.to_pydict()
converters = (
self.converters
if self._convert_rows
else self._text_value_converters(self.converters)
)
if converters:
column_names = dict_rows.keys()
# Read the columns and their converters by position; to_pydict() and the
# converters property keep one column per name.
columns = [column.to_pylist() for column in rows.columns]
description = self.description if self.description else []
converters = [
self._converter.get(d[1])
if self._convert_rows or d[1] in _TEXT_VALUE_TYPES
else _to_default
for d in description
]
if any(convert is not _to_default for convert in converters):
processed_rows = [
tuple(
converters.get(k, _to_default)(v)
for k, v in zip(column_names, row, strict=False)
)
for row in zip(*dict_rows.values(), strict=False)
tuple(convert(v) for convert, v in zip(converters, row, strict=False))
for row in zip(*columns, strict=False)
]
else:
processed_rows = list(zip(*dict_rows.values(), strict=False))
processed_rows = list(zip(*columns, strict=False))
self._rows.extend(processed_rows)

@override
Expand Down Expand Up @@ -403,8 +403,8 @@ def _as_arrow_from_api(self, converter: Converter | None = None) -> Table:
if not rows:
return pa.Table.from_pydict({})
description = self.description if self.description else []
columns = [d[0] for d in description]
return pa.table(self._rows_to_columnar(rows, columns))
columns = [list(column) for column in zip(*rows, strict=True)]
return pa.table(columns, names=[d[0] for d in description])

def as_arrow(self) -> Table:
"""Return the query results as an Apache Arrow Table.
Expand Down Expand Up @@ -447,4 +447,4 @@ def close(self) -> None:

super().close()
self._table = pa.Table.from_pydict({})
self._batches = []
self._batches = iter([])
196 changes: 146 additions & 50 deletions pyathena/pandas/result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -411,9 +411,10 @@ def __init__(
# The converters that pandas.read_csv() applies, keyed by column name.
self._csv_converters: dict[Any, Callable[[str | None], Any]] = {}

# Cache time column names for efficient _trunc_date processing
# Cache time column names for efficient _trunc_date processing. _read_csv()
# replaces them with the labels of the columns that it reads.
description = self.description if self.description else []
self._time_columns: list[str] = [d[0] for d in description if d[1] == "time"]
self._time_columns: list[Any] = [d[0] for d in description if d[1] == "time"]

import pandas as pd

Expand Down Expand Up @@ -475,6 +476,12 @@ def _get_csv_engine(
effective_chunksize is None
and self._quoting == 1
and not self.converters
# The pyarrow engine does not rename columns with the same name, and
# the column labels cannot be resolved for it (see
# _get_csv_column_labels()).
and not self._needs_csv_column_name_resolution(
[d[0] for d in self.description or []]
)
and (file_size_bytes is None or file_size_bytes >= self.PYARROW_MIN_FILE_SIZE_BYTES)
)
if is_compatible:
Expand Down Expand Up @@ -588,6 +595,23 @@ def parse_dates(self) -> list[Any | None]:
description = self.description if self.description else []
return [d[0] for d in description if d[1] in self._PARSE_DATES]

def _get_column_names(self) -> list[Any]:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review round one: implementation behavior: CLEAN

Scope: git diff 28ec68d9f70b5bc2fb447f6a4fb745ee0a467683..8e5dda66ad09576e7f19aa941c50651ab72e38ee, all 9 files.

Checked:

  • Callers. The pandas, Arrow, and Polars result sets are shared by the sync, AsyncCursor, and aio cursors (pyathena/{pandas,arrow,polars}/async_cursor.py, pyathena/aio/{pandas,arrow,polars}/cursor.py). _fetch_all_rows() is also used by AthenaS3FSResultSet (pyathena/s3fs/result_set.py:125), which reads positionally and gets the first-page labels fix. Nothing else referenced _rows_to_columnar(). Only Polars passes column_names to _json_converters(); Arrow keeps the description names, where strict=True holds.
  • Name resolution. _get_column_names() returns the description names unchanged unless a name repeats. In that case it uses each library's header parser, so a collision such as x, x, x.1 gets pandas' own x.2. Read paths keyed by these names: dtypes, converters, parse_dates, _time_columns, fetchone(), the .txt names= (pandas) / new_columns (Polars), the chunked readers, and the GetQueryResults fallback. The binary-NULL path (_configure_binary_csv_read) already resolves duplicates with the same parser, so its converter keys match. Measured live: duplicate json/time/date/varbinary/decimal columns convert correctly on the S3 and managed paths, and with chunksize=2.
  • pandas engine="pyarrow". It does not rename duplicates (pandas 3.0.6: columns x, x, and a dtype keyed by x.1 raises AttributeError), so it falls back to c only when names repeat.
  • Fetch/close. Arrow close() now leaves an exhausted iterator, so fetchone()/fetchmany()/fetchall() return None/[], as pandas (enumerate([])) and Polars (iter([])) do.
  • Fallback pages. first_page is computed before the request, so only the request without a NextToken skips the labels row, matching _pre_fetch().
  • Tests. Both offline tests fail on the merge-base source (checked by reverting pyathena/ only). The live duplicate tests include a non-duplicate column (y), so they hit the managed-path length mismatch, and they include json and time, which exercise converters and parse_dates.

Limitations, recorded rather than fixed:

  • Duplicates with different types remain out of scope by agreement (to be filed separately).
  • A Polars name collision such as x, x, x_duplicated_0 raises Polars' DuplicateError. The S3 path raised it before this PR (wrapped in OperationalError), and the managed path raised ShapeError before. Both still fail.
  • No live test covers the .txt paths with duplicates; Athena writes .txt only for DDL-style output.
  • Keep integer and JSON values exact in PandasCursor results with NULL #1044 also edits pandas' CSV converters, so whichever PR merges second needs a rebase.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebase repair, round one (implementation): CLEAN. The branch was rebased from 8e5dda6 (merge-base 28ec68d) to e87840a (merge-base ec5323e) to resolve conflicts with #1033. Reviewed with git range-diff 28ec68d9f70b5bc2fb447f6a4fb745ee0a467683..8e5dda66ad09576e7f19aa941c50651ab72e38ee ec5323ea30e3fc2da1aca536d9cbdf8b51c7b3e2..e87840ac4fe02887f43981d8a4009a0797a14513; both old objects exist.

The resolution keeps both changes:

Upstream contracts checked: #1033 adds no other name-keyed maps in the result set files. Its new pandas/Polars/Arrow converters are reached through the converters/dtypes properties, which are keyed by the renamed names.

Validation on e87840a: just lint; offline tests 5 passed; live -k "duplicate_column_names or fetch_all_rows or time" 18 passed. Live duplicate time with time zone/json columns return aware times and decoded JSON for all three cursors, on S3 and managed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI repair (6d073ae), round one: CLEAN. AWS CI on e87840a failed only tests/pyathena/pandas/test_cursor.py::TestPandasCursor::test_get_csv_engine_explicit_specification (AttributeError: ... no attribute '_metadata'; 2798 passed). The test builds AthenaPandasResultSet.__new__ without __init__, and the new duplicate-name condition in _get_csv_engine() reads description. That was my local miss, because I selected tests with -k.

  • Production callers are unaffected: _get_csv_engine() is called only from _read_csv(), after __init__ has set _metadata.
  • The repair sets result_set._metadata = None, which gives the same description as a real result set without metadata.
  • It adds a case where description repeats a name with the pyarrow engine requested and available, and asserts the c engine. Before this, the new branch had no test.
  • The new case fails with pyathena/ reverted to the merge-base (assert 'pyarrow' == 'c') and passes on 6d073ae. Lint passed.

"""Get the names of the result columns in the DataFrame.

Columns with the same name are renamed as pandas renames them when it reads
the header of a CSV file, such as ``x`` and ``x.1``.

Returns:
List of column names.
"""
import pandas as pd

description = self.description if self.description else []
names = [d[0] for d in description]
if len(set(names)) == len(names):
return names
return self._resolve_csv_column_names(names, {}, pd.read_csv)[0]

def _finish_csv_frame(self, df: DataFrame) -> DataFrame:
"""Finish a DataFrame read from the CSV result file.

Expand Down Expand Up @@ -641,8 +665,8 @@ def fetchone(
return None
else:
self._rownumber = row[0] + 1
description = self.description if self.description else []
return tuple([row[1][d[0]] for d in description])
# By position, so that columns with the same name keep their own values.
return tuple(row[1].values())

def _read_csv(self) -> TextFileReader | DataFrame:
import pandas as pd
Expand Down Expand Up @@ -676,11 +700,14 @@ def _read_csv(self) -> TextFileReader | DataFrame:

csv_engine = self._get_csv_engine(length, effective_chunksize)
read_csv_kwargs = self._get_csv_read_options(csv_engine, effective_chunksize)
labels = self._get_csv_column_labels(csv_engine, read_csv_kwargs)
if labels is not None:
self._key_csv_columns_by_labels(read_csv_kwargs, labels)

try:
with ExitStack() as stack:
source: str | IOBase = self.output_location
binary_columns = self._configure_binary_csv_read(read_csv_kwargs, pd.read_csv)
binary_columns = self._configure_binary_csv_read(read_csv_kwargs, labels)
self._csv_converters = read_csv_kwargs.get("converters") or {}
if binary_columns:
# Given storage_options, even None, open the file through fsspec
Expand Down Expand Up @@ -721,7 +748,7 @@ def _get_csv_read_options(self, csv_engine: str, chunksize: int | None) -> dict[
if self.output_location and self.output_location.endswith(".txt"):
sep = "\t"
header = None
names = [d[0] for d in self.description or []]
names = self._get_column_names()
else:
sep = ","
header = 0
Expand Down Expand Up @@ -796,12 +823,17 @@ def _resolve_csv_column_names(
)
return column_names, selected_names

def _can_preserve_binary_csv_nulls(self, read_csv_kwargs: dict[str, Any]) -> bool:
"""Whether CSV settings support distinguishing binary NULL from empty values."""
def _is_standard_csv_parsing(self, read_csv_kwargs: dict[str, Any]) -> bool:
"""Whether the options read a CSV result file as Athena writes it.

Args:
read_csv_kwargs: The options for ``pandas.read_csv()``.

Returns:
True if pandas reads the header and quoted fields of the file as written.
"""
return not (
"varbinary" not in self._converter.mappings
or "converters" in self._kwargs
or not self.output_location
not self.output_location
or not self.output_location.endswith(".csv")
or read_csv_kwargs.get("header") != 0
or read_csv_kwargs.get("skiprows") is not None
Expand All @@ -810,6 +842,14 @@ def _can_preserve_binary_csv_nulls(self, read_csv_kwargs: dict[str, Any]) -> boo
or read_csv_kwargs.get("quotechar", '"') != '"'
)

def _can_preserve_binary_csv_nulls(self, read_csv_kwargs: dict[str, Any]) -> bool:
"""Whether CSV settings support distinguishing binary NULL from empty values."""
return (
"varbinary" in self._converter.mappings
and "converters" not in self._kwargs
and self._is_standard_csv_parsing(read_csv_kwargs)
)

def _needs_csv_column_name_resolution(self, column_names: list[Any]) -> bool:
"""Whether pandas must resolve column names instead of using Athena metadata."""
return (
Expand All @@ -829,38 +869,98 @@ def _needs_csv_column_name_resolution(self, column_names: list[Any]) -> bool:
)
)

def _get_csv_column_labels(
self, csv_engine: str, read_csv_kwargs: dict[str, Any]
) -> list[Any] | None:
"""Get the labels that pandas gives the result columns when it reads the CSV file.

Args:
csv_engine: The CSV engine that reads the file.
read_csv_kwargs: The options for ``pandas.read_csv()``.

Returns:
The label of each result column in the description order, with None for a
column that ``usecols`` leaves out. None if the options do not read the file
as Athena writes it, or if the labels do not match the result columns one
to one, such as with fewer ``names``.
"""
import pandas as pd

# The pyarrow engine runs only when no labels need resolving (see
# _get_csv_engine()), and does not support reading only the header.
if csv_engine == "pyarrow" or not self._is_standard_csv_parsing(read_csv_kwargs):
return None
column_names = [d[0] for d in self.description or []]
if not self._needs_csv_column_name_resolution(column_names):
return column_names
labels, selected_labels = self._resolve_csv_column_names(
column_names, read_csv_kwargs, pd.read_csv
)
if len(labels) != len(column_names):
return None
return [label if label in selected_labels else None for label in labels]

def _key_csv_columns_by_labels(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review round one, redesign (implementation): CLEAN

Scope: full pass over git diff 72c95e321d13b93c3a263e65239ef22f5d41fb87..f0a0ba2861418c5a11ce5349c41b800d68a5e482, all 9 files. The patch was rebased onto 72c95e3, over #1054 (pandas time NULLs) and #1045 (Arrow CSV blocks); neither changes name keying.

Checked:

  • pandas key equivalence. Without renaming options or duplicates, the labels are the description names, so the rebuilt dtype, converters, and parse_dates equal master's, minus unselected columns. Options given to execute() stay (not in self._kwargs, so explicit {} and None are kept too).
  • Non-standard parsing. labels is None under custom quoting, dialect, skiprows, or header, under the pyarrow engine, for .txt, and when the labels are not one-to-one with the columns. In all of these the master keys and master time columns stay. The 5 test_binary_custom_* / test_binary_dataframe_column_names cases that the positional attempt broke pass.
  • Binary NULLs. _can_preserve_binary_csv_nulls is master's predicate with the parsing part extracted into _is_standard_csv_parsing. The converters it wraps are the label-keyed ones, which match master's rebuilt dict for renamed or selected columns.
  • Fetch. pandas reads rows by position in the DataFrame order. With usecols/index_col that returns the read columns, where master raised KeyError. Polars uses _get_frame_column_names() for eager reads, chunked reads (scan_csv), iter_chunks(), and fetchone(). The managed path uses _get_column_names() without new_columns, so CSV options stay out of the fallback naming. UNLOAD ignores new_columns.
  • Public properties. pandas and Polars dtypes/converters/parse_dates return master's description-name keys.
  • Tests. The pandas and Polars duplicate tests now mix types (integer/varchar, time/integer), so they exercise the per-label dtype and the time truncation. The read-option tests fail on master (measured: OperationalError, KeyError, wrong values).

Limitations, recorded:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repair 4585ef7, round one (implementation): CLEAN. Reviewed git range-diff 72c95e32..f0a0ba28 72c95e32..4585ef74; one added commit.

  • Polars. _csv_dtypes (header names, duplicates renamed) is the only reader schema. The user's new_columns is popped from the read kwargs and applied after reading to the eager DataFrame and to each scan_csv batch, with Polars' own _update_columns rule. .txt keeps applying the full frame names. Fetch and converters keep using _get_frame_column_names(), which equals the names after renaming. UNLOAD and the managed path are unaffected.
  • Arrow. Converters are taken by description position: every column for result files, _TEXT_VALUE_TYPES only for GetQueryResults, as before. The description is aligned with the table for CSV, Parquet (metadata replaced from the schema), and the fallback. The fast path without conversion is kept.
  • pandas engine. pyarrow is disallowed exactly when label resolution would be needed, so _get_csv_column_labels() returning None for pyarrow now only covers cases where the labels equal the description names.
  • Validation. pytest -n 4 tests/pyathena/pandas tests/pyathena/polars tests/pyathena/arrow tests/pyathena/test_result_set.py: 511 passed. Reverting the source to f0a0ba2 fails the new Arrow tests ([default] and [managed]), the pandas engine test, and the eager partial new_columns test.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repair a21bb92, round one: CLEAN. In Polars, _get_csv_params() returns the user's new_columns for .csv only when execute() did not give schema_overrides. In that case the readers pop it and rename after reading. Otherwise both options reach Polars unchanged, as on master, and the result set's types are not used. _get_frame_column_names() still applies new_columns, which matches the DataFrame either way. .txt is unchanged. Tests: tests/pyathena/polars 113 passed, and the new test fails on 4585ef7.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repair f66d765, rounds one and two: CLEAN. Both Polars readers pop new_columns only when execute() gave no schema_overrides. .txt still sets the full frame names after reading, so the final names are the same either way. The docstring and the commit message match. Tests on f66d765: tests/pyathena/polars 115 passed. The new offline test fails on a21bb92.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebase over #1044 (e783222), rounds one and two: CLEAN. Reviewed git range-diff 72c95e32..f66d765a a57325b1..e7832221; both old objects exist. Conflicts were only in pandas:

Validation on e783222:

PR body updated with the tested commit and the #1044 interaction.

self, read_csv_kwargs: dict[str, Any], labels: list[Any]
) -> None:
"""Key the column options of ``pandas.read_csv()`` by the labels of the columns.

Columns with the same name keep their own converters and date parsing, and
options that rename or select columns get the types of the columns they
read. The ``dtype``, ``converters``, and ``parse_dates`` given to
``execute()`` are kept as they are.

Args:
read_csv_kwargs: The options for ``pandas.read_csv()``, updated in place.
labels: The labels from ``_get_csv_column_labels()``.
"""
description = self.description or []
columns = [
(label, d) for label, d in zip(labels, description, strict=True) if label is not None
]
if "dtype" not in self._kwargs:
read_csv_kwargs["dtype"] = {
label: dtype
for label, d in columns
if (dtype := self._converter.get_dtype(d[1], d[4], d[5])) is not None
}
if "converters" not in self._kwargs:
read_csv_kwargs["converters"] = {
label: self._get_csv_converter(d[1])
for label, d in columns
if d[1] in self._converter.mappings
}
if "parse_dates" not in self._kwargs:
read_csv_kwargs["parse_dates"] = [
label for label, d in columns if d[1] in self._PARSE_DATES
]
self._time_columns = [label for label, d in columns if d[1] == "time"]

def _configure_binary_csv_read(
self, read_csv_kwargs: dict[str, Any], read_csv: Callable[..., DataFrame]
self, read_csv_kwargs: dict[str, Any], labels: list[Any] | None
) -> set[int]:
"""Wrap binary converters and return column positions needing NULL preservation."""
if not self._can_preserve_binary_csv_nulls(read_csv_kwargs):
return set()
"""Wrap binary converters and return column positions needing NULL preservation.

description = self.description or []
binary_columns = {i for i, d in enumerate(description) if d[1] == "varbinary"}
if not binary_columns:
return set()
Args:
read_csv_kwargs: The options for ``pandas.read_csv()``, whose converters
are keyed by the column labels.
labels: The labels from ``_get_csv_column_labels()``.

column_names = [d[0] for d in description]
converters = read_csv_kwargs["converters"]
if self._needs_csv_column_name_resolution(column_names):
column_names, selected_names = self._resolve_csv_column_names(
column_names, read_csv_kwargs, read_csv
)
if len(column_names) != len(description):
return set()
converters = {
name: self._get_csv_converter(d[1])
for name, d in zip(column_names, description, strict=True)
if d[1] in self._converter.mappings and name in selected_names
}
binary_columns = {i for i in binary_columns if column_names[i] in selected_names}
Returns:
The positions of the binary columns whose NULL fields the stream preserves.
"""
if labels is None or not self._can_preserve_binary_csv_nulls(read_csv_kwargs):
return set()

description = self.description or []
binary_columns = {
i for i, d in enumerate(description) if d[1] == "varbinary" and labels[i] is not None
}
if binary_columns:
converters = read_csv_kwargs["converters"]
for index in binary_columns:
name = column_names[index]
converters[name] = partial(_convert_binary_csv, converters[name])
read_csv_kwargs["converters"] = converters
label = labels[index]
converters[label] = partial(_convert_binary_csv, converters[label])
return binary_columns

def _open_binary_csv_stream(
Expand Down Expand Up @@ -957,24 +1057,20 @@ def _as_pandas_from_api(self, converter: Converter | None = None) -> DataFrame:
if not rows:
return pd.DataFrame()
description = self.description if self.description else []
columns = [d[0] for d in description]
columnar = self._rows_to_columnar(rows, columns)
# Positional, so that columns with the same name keep their own values.
columns = [list(column) for column in zip(*rows, strict=True)]
# Integer columns get the dtype that the CSV result file reads them with,
# and json columns with NULL stay objects as there, so that NULL does not
# make their values floats.
dtypes: dict[str, Any] = {}
for d in description:
data: dict[Any, Any] = {}
for name, values, d in zip(self._get_column_names(), columns, description, strict=True):
dtype = None
if d[1] in self._INTEGER_TYPES:
if (dtype := self._converter.get_dtype(d[1], d[4], d[5])) is not None:
dtypes[d[0]] = dtype
elif d[1] == "json" and None in columnar[d[0]]:
dtypes[d[0]] = object
return pd.DataFrame(
{
name: values if name not in dtypes else pd.array(values, dtype=dtypes[name])
for name, values in columnar.items()
}
)
dtype = self._converter.get_dtype(d[1], d[4], d[5])
elif d[1] == "json" and None in values:
dtype = object
data[name] = values if dtype is None else pd.array(values, dtype=dtype)
return pd.DataFrame(data)

def as_pandas(self) -> PandasDataFrameIterator | DataFrame:
"""Return the query results as a DataFrame or an iterator of DataFrame chunks.
Expand Down
Loading
Loading