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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions pyathena/arrow/result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
from __future__ import annotations

import logging
from collections.abc import Callable
from collections.abc import Callable, Iterator
from typing import (
TYPE_CHECKING,
Any,
ClassVar,
)

from pyathena import OperationalError
from pyathena import DataError, OperationalError
from pyathena.arrow.converter import _to_null, _to_timestamp
from pyathena.arrow.util import to_column_info
from pyathena.converter import Converter, _to_default
Expand Down Expand Up @@ -201,6 +201,11 @@ def _fetch(self) -> None:

A table can hold empty record batches between others, such as one of
an empty result file, which would otherwise end the fetches early.

Raises:
DataError: If a value fails to convert, such as a ``timestamp with time
zone`` value with an unknown zone or a local time without one instant in
its zone. The message names the column.
"""
rows = next((batch for batch in self._batches if batch.num_rows), None)
if rows is None:
Expand All @@ -209,12 +214,22 @@ def _fetch(self) -> None:
# converters property keep one column per name.
columns = [column.to_pylist() for column in rows.columns]
description = self.description if self.description else []
names = [d[0] for d in description]
converters = [self._converter.get(d[1]) for d in description]
if any(convert is not _to_default for convert in converters):
processed_rows = [
tuple(convert(v) for convert, v in zip(converters, row, strict=False))
for row in zip(*columns, strict=False)
]

def convert(row: tuple[Any, ...]) -> Iterator[Any]:
# Unlike a loop that returns the values, a generator turns a StopIteration
# from the converter into a RuntimeError instead of ending an iteration of
# the rows.
for name, converter, value in zip(names, converters, row, strict=False):
try:
converted = converter(value)
except DataError as e:
raise DataError(f"Column {name!r}: {e}") from e

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.

Independent review (relayed): Codex CLI 0.163.0 (codex exec -s read-only, model gpt-6-astra, reasoning effort max), session 01a14db5-b194-7502-b3a3-1d170d2ddc3e. Static review only: no edits, builds, tests, or network. Snapshot: detached worktree at head 80b8f91d96c6c5401665f1fe49dcccb1d9ce1442, base b8cec9dfc98fba2bcfd8470e82e1800ba02d85d0; the prompt gave the diff, intended behavior, and test policy, without the PR text or prior findings.

Covered: Cursor/DictCursor, S3FS CSV and API fallback, Arrow batches, Polars eager and chunked iteration, Pandas conversion callers, async/aio delegation; hints, truncation, dict_type, duplicate names, empty/DML results, exception boundaries, hot-path structure.

Result: FINDINGS

  • P2 pyathena/arrow/result_set.py:224 (at 80b8f91): converting a whole column before the next changes the order in which converters see values. With two VARCHAR rows [("a", "b"), ("c", "d")] and a converter lambda v: next(counter) over iter([1, 2, 3, 4]), the base returns [(1, 2), (3, 4)] and the head [(1, 3), (2, 4)]; it can also change which failure is reported first.

Non-actionable observations:

  • No other regression or double wrapping found; hint omission, short-row truncation, dict construction, positional column attribution, and StopIteration → RuntimeError are preserved.
  • The tightened and added tests cover distinct entry points and fail on the base; their regexes were not anchored to the start of the message.
  • Pre-existing: PandasCursor with dtype={} (any given dtype, parse_dates, or converters) bypasses _TemporalConverter (pyathena/pandas/result_set.py:1233), so the error is an unprefixed OperationalError (:1418). This is outside the changed paths and is the Give PandasCursor's temporal columns one dtype per Athena type #1273 design noted on Name the column in DataError for time zone values in every cursor #1217.
  • Performance impact was not verified by the reviewer.

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 in f81e5af: the Arrow fetch converts each row in column order again, through a per-row generator that names the column of a DataError; the no-converter path keeps list(zip(*columns)). The tests anchor the prefix with ^.

Self-review of the repair (both perspectives, scope: git range-diff b8cec9df..80b8f91d b8cec9df..f81e5afa):

  • Behavior: the reviewer's scenario now gives [(1, 2), (3, 4)] on both b8cec9d and f81e5af (script). StopIteration from a converter is still RuntimeError. With no description (DML), no converter runs, as on the base. Names and converters come from the description by position, as the base's converters did.
  • Claims: the PR body no longer claims an Arrow speedup; re-measured Arrow 10,000 x 6: 41.2-42.2 ms vs 41.2-43.4 ms on the base. The commit message states the reason.
  • Validation on f81e5af: just lint passed; pytest -n 4 tests/pyathena/test_cursor.py tests/pyathena/s3fs/test_cursor.py tests/pyathena/arrow tests/pyathena/aio/arrow tests/pyathena/polars/test_cursor.py -k "ambiguous or arrow": 146 passed.

The Pandas dtype/parse_dates/converters case is deferred as pre-existing (#1273's design, noted on #1217). An independent follow-up of this repair follows.

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.

Independent follow-up (relayed): Codex CLI 0.163.0 (codex exec -s read-only), session 01a14dbe-118f-7ba3-bbac-194f984d8826. Static review only. Snapshot: detached worktree at f81e5afa60d63e2180a6054830078ea93fb1c086; scope: the repair diff 80b8f91..f81e5af and git range-diff b8cec9df..80b8f91d b8cec9df..f81e5afa.

Result: CLEAN. Covered the Arrow fetch against b8cec9d (fetch and error propagation, converter order, default and DML paths, buffering) and the five anchored test assertions. The repair restores row-by-row conversion in column order; only a converter's DataError is wrapped, with the original as the cause; StopIteration still becomes RuntimeError; the list(zip(*columns)) path is intact. The reviewer did not measure performance. The snapshot and the PR worktree stayed unchanged.

yield converted

if any(converter is not _to_default for converter in converters):
processed_rows = [tuple(convert(row)) for row in zip(*columns, strict=False)]
else:
processed_rows = list(zip(*columns, strict=False))
self._rows.extend(processed_rows)
Expand Down
73 changes: 67 additions & 6 deletions pyathena/pandas/result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,51 @@ def _convert_values(self, values: Series) -> Series:
return _to_timestamps(values, date=self._type == "date")


class _TimeZoneConverter:

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 two, scope expansion (claims, callers, evidence). Same range as round one.

Claims checked:

  • "OperationalError without the column name in a whole read, and DataError without it in a chunked read" (commit message, PR body): reproduced live on b8cec9d with dtype={}, parse_dates=[], engine="python", and chunksize=1; after the change all give DataError: Column 'col_timestamp_tz': ....
  • "even dtype={}": _get_csv_temporal_converters() treats any non-None option as given (pyathena/pandas/result_set.py, "None is the default of each option"). Holds.
  • "with a custom conversion function": _get_csv_temporal_converters() skips a timestamp with time zone column whose mapping is not _to_datetime_with_tz, so read_csv gets that function, wrapped. Holds by construction; not tested separately.
  • Documentation: docs/pandas.md already states that such a value raises DataError naming the column, without an exception for these options, and the section on parse_dates/dtype/converters says nothing about errors; it is now accurate as written.
  • Callers: DataError is a DatabaseError, like OperationalError, so except DatabaseError still catches it; except OperationalError does not, which is the intended contract change (Name the column in DataError for time zone values in every cursor #1217) and matches the default path since Give PandasCursor's temporal columns one dtype per Athena type #1273.

Result: CLEAN. Validation: just lint passed; pytest -n 4 tests/pyathena/pandas tests/pyathena/aio/pandas: 441 passed on 067a1ef (d555a2c changes a docstring only). The scope expansion goes to an independent review next.

"""A converter that ``pandas.read_csv()`` applies to each ``timestamp with time zone`` value.

With ``parse_dates`` or ``dtype`` given to ``execute()``, or with a conversion
function other than the default one, pandas applies the column's conversion
function to each value as it reads, unless ``converters`` given to ``execute()``
replaces the conversion functions. This names the column in the
``DataError`` of a value that fails to convert, as ``_TemporalConverter`` does.
"""

__slots__ = ("_converter", "_label", "error")

def __init__(self, converter: Callable[[str | None], Any], label: Any) -> None:
"""Wrap the conversion function of a column.

Args:
converter: The conversion function.
label: The label of the column.
"""
self._converter = converter
self._label = label
# The DataError that the converter raised, which a whole read raises as it is.
self.error: DataError | None = None

def __call__(self, value: str | None) -> Any:
"""Convert a value.

Args:
value: The text of the value.

Returns:
The converted value.

Raises:
DataError: If the value fails to convert, such as a value with an unknown
zone or a local time without one instant in its zone. The message names
the column.
"""
try:
return self._converter(value)
except DataError as e:
self.error = DataError(f"Column {self._label!r}: {e}")
raise self.error from e


def _to_timestamps(values: Series, date: bool = False) -> Series:
"""Convert timestamp or date text to ``datetime64[us]``.

Expand Down Expand Up @@ -1246,22 +1291,29 @@ def _get_csv_temporal_converters(
temporal_converters[label] = _TemporalConverter(type_)
return temporal_converters

def _get_csv_converter(self, type_: str) -> Callable[[str | None], Any] | _JSONConverter:
"""Get the converter for a column type of the CSV result file.
def _get_csv_converter(
self, type_: str, label: Any
) -> Callable[[str | None], Any] | _JSONConverter:
"""Get the converter for a column of the CSV result file.

json columns get a ``_JSONConverter``, which ``_read_json_columns_as_text()``
replaces in the converters for ``pandas.read_csv()``, and which converts the
columns after reading.
columns after reading. ``timestamp with time zone`` columns get a
``_TimeZoneConverter``, which names the column in a ``DataError``.

Args:
type_: The Athena type of the column.
label: The label of the column.

Returns:
The conversion function, or the ``_JSONConverter`` of a json column.
The conversion function, the ``_JSONConverter`` of a json column, or the
``_TimeZoneConverter`` of a ``timestamp with time zone`` column.
"""
converter = self._converter.get(type_)
if type_ == "json":
return _JSONConverter(converter)
if type_ == "timestamp with time zone":
return _TimeZoneConverter(converter, label)
return converter

def _trunc_date(self, df: DataFrame) -> DataFrame:
Expand Down Expand Up @@ -1317,6 +1369,8 @@ def _read_csv(self) -> TextFileReader | DataFrame:
The DataFrame of the results, or a reader of their chunks.

Raises:
DataError: If a ``timestamp with time zone`` value that pandas converts as it
reads fails to convert, naming the column.
OperationalError: If reading the results fails.
"""
import pandas as pd
Expand Down Expand Up @@ -1414,6 +1468,13 @@ def _read_csv(self) -> TextFileReader | DataFrame:
return result

except Exception as e:
# converters given to execute() can be of any type.
converters = read_csv_kwargs.get("converters")
if isinstance(converters, Mapping) and any(
isinstance(converter, _TimeZoneConverter) and converter.error is e
for converter in converters.values()
):
raise
_logger.exception(f"Failed to read {location}.")
raise OperationalError(*e.args) from e

Expand Down Expand Up @@ -1519,7 +1580,7 @@ def _get_csv_read_options(self, csv_engine: str, chunksize: int | None) -> dict[
"names": names,
"dtype": {**self.dtypes, **dict.fromkeys(temporal, text_dtype)},
"converters": {
d[0]: self._get_csv_converter(d[1])
d[0]: self._get_csv_converter(d[1], d[0])
for d in description
if d[1] in self._converter.mappings and d[0] not in self._csv_temporal_converters
},
Expand Down Expand Up @@ -1690,7 +1751,7 @@ def _key_csv_columns_by_labels(
}
if "converters" not in self._kwargs:
read_csv_kwargs["converters"] = {
label: self._get_csv_converter(d[1])
label: self._get_csv_converter(d[1], label)
for label, d in columns
if d[1] in self._converter.mappings and label not in self._csv_temporal_converters
}
Expand Down
33 changes: 24 additions & 9 deletions pyathena/polars/result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
from pyathena import OperationalError
from pyathena.arrow.reader import ParquetChunkReader
from pyathena.converter import Converter, _to_default
from pyathena.error import ProgrammingError
from pyathena.error import DataError, ProgrammingError
from pyathena.filesystem.s3 import S3FileSystem
from pyathena.model import AthenaQueryExecution
from pyathena.polars.converter import _to_datetimes
Expand Down Expand Up @@ -192,16 +192,25 @@ def _iter_row_blocks(self, df: pl.DataFrame) -> Iterator[Iterator[tuple[Any, ...

Yields:
An iterator of the rows of each block.

Raises:
DataError: While the rows are iterated, if a value fails to convert, such as
a ``timestamp with time zone`` value with an unknown zone or a local
time without one instant in its zone. The message names the column.
"""
import polars as pl

# Each column's Series, or None for a name the chunk does not have, and its
# converter, or None for the identity.
columns: list[tuple[pl.Series | None, Callable[[Any], Any] | None]] = []
# Each column's name, its Series, or None for a name the chunk does not have,
# and its converter, or None for the identity.
columns: list[tuple[str, pl.Series | None, Callable[[Any], Any] | None]] = []
for name in self._column_names:
converter = self._converters.get(name)
columns.append(
(df.get_column(name, default=None), None if converter is _to_default else converter)
(
name,
df.get_column(name, default=None),
None if converter is _to_default else converter,
)
)

def take_values(block: pl.Series) -> Iterator[Any]:
Expand All @@ -212,17 +221,23 @@ def take_values(block: pl.Series) -> Iterator[Any]:
for i in range(block.len()):
yield block.slice(i, 1).to_list()[0]

def convert(converter: Callable[[Any], Any], values: Iterable[Any]) -> Iterator[Any]:
def convert(
name: str, converter: Callable[[Any], Any], values: Iterable[Any]
) -> Iterator[Any]:
# Unlike map(), a generator turns a StopIteration from the converter into a
# RuntimeError instead of ending the rows early.
for value in values:
yield converter(value)
try:
converted = converter(value)
except DataError as e:
raise DataError(f"Column {name!r}: {e}") from e
yield converted

size = self._ROW_BLOCK_SIZE
for start in range(0, df.height, size):
length = min(size, df.height - start)
blocks: list[Iterable[Any]] = []
for series, converter in columns:
for name, series, converter in columns:
values: Iterable[Any]
if series is None:
values = [None] * length
Expand All @@ -232,7 +247,7 @@ def convert(converter: Callable[[Any], Any], values: Iterable[Any]) -> Iterator[
values = block.to_list()
except (Exception, pl.exceptions.PanicException):
values = take_values(block)
blocks.append(values if converter is None else convert(converter, values))
blocks.append(values if converter is None else convert(name, converter, values))
# Without column names, each row is empty, as iter_rows() gave it.
yield zip(*blocks, strict=True) if blocks else repeat((), length)

Expand Down
Loading
Loading