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
30 changes: 17 additions & 13 deletions pyathena/polars/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import logging
from collections.abc import Callable
from copy import deepcopy
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, TypeVar

from pyathena.converter import (
_TIMESTAMP_TEXT_LENGTHS,
Expand All @@ -32,6 +32,8 @@

_logger = logging.getLogger(__name__)

_FrameT = TypeVar("_FrameT", "pl.DataFrame", "pl.LazyFrame")


_DEFAULT_POLARS_CONVERTERS: dict[str, Callable[[str | None], Any | None]] = {
"date": _to_date,
Expand All @@ -43,36 +45,38 @@
}


def _to_datetimes(df: pl.DataFrame, dtypes: dict[str, Any]) -> pl.DataFrame:
def _to_datetimes(frame: _FrameT, dtypes: dict[str, Any], strict: bool = True) -> _FrameT:
"""Convert timestamp text columns to Datetime dtypes, truncating finer fractions.

Athena writes up to 12 fractional digits, which Polars does not parse into a
Datetime whose time unit holds fewer.
Athena writes up to 12 fractional digits, and Polars does not parse more than

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 (claims, callers, operations): FINDINGS, repaired

Base 9e2bf6eebfa96f7a1f747aae51b30637b1bd8296, head 7d5584ef (round one's head after rewording), repaired at 3276a3f472bd3996c154f42f2142c0c6d2eb74a2.

Claims checked (PR body, docstrings, comments, commit messages, issue premise):

  • "Polars does not parse more fractional digits than the time unit" (docstrings, PR body "such as TIMESTAMP(9)", commit messages, Chunked PolarsCursor CSV reads fail on timestamps with more fractional digits than the time unit #1191's title): false. On Polars 1.39.0 and 2.0.0, read_csv/scan_csv with Datetime(\"us\") or (\"ns\") parse 3, 6, and 9 digits (9 truncated to us) and raise ComputeError only at 12; the live test_temporal_types_chunked failure on master names ts_p12, not ts_p9. Repaired: docstrings in 3276a3f, commit messages rewritten (same trees; force-pushed with lease), PR body corrected to "more than nine (TIMESTAMP(10) to (12))".
  • "The whole read is unchanged": stale after round one's repair; PR body now says it is unchanged without ignore_errors and describes the ignore_errors path, including its peak-RSS implication.
  • scan_csv() has no columns parameter (1.39.0, 2.0.0): confirmed by signature.
  • Polars ignores schema_overrides when given schema: confirmed (schema of String wins over schema_overrides Int64).
  • Fixed-length cut correct for years 1-9999: Athena writes four-digit years in range (Define and enforce the contract for Athena temporal types across cursors #1212 facts); outside is out of scope (contract item 3).
  • Benchmark table: re-measured at 9fb2b4a (same read path as the head), local, noisy machine; ranges stated as measured. Per-batch numbers and the whole-read RSS figures are cited from Read the timestamp columns of chunked Polars CSV results as text #1194.

Callers: _scan_csv_chunks()/_get_csv_schema_overrides() are private with no callers outside result_set.py; AsyncPolarsCursor uses the same result set (aio tests pass). No AWS request count changes: the same single result file read per query; the live test adds one SELECT.

Evidence at 3276a3f: just lint passed; pytest -n 2 tests/pyathena/polars tests/pyathena/aio/polars with AWS (Py 3.15, Polars 2.0.0): 219 passed, 1 xfailed (#1214); unit tests on Py 3.13 + Polars 1.39.0: 82 passed. Not run: SQLAlchemy suites and other cursors (untouched).

nine into a Datetime, so the text is cut to the digits that the time unit holds.

Args:
df: The DataFrame with the timestamp text columns, with NULL as null or as
an empty string.
frame: The DataFrame or LazyFrame with the timestamp text columns, with NULL
as null or as an empty string. A column that it does not have, such as
one that the read options leave out, is skipped.
dtypes: The Datetime dtypes keyed by column name.
strict: Whether text that is not a timestamp raises an error, rather than
becoming null.

Returns:
The DataFrame with the columns converted.
The frame with the columns converted.
"""
import polars as pl
import polars.selectors as cs

exprs = []
for name, dtype in dtypes.items():
if name not in df.columns:
# Not read, such as a column that the read options leave out.
continue
time_unit = (dtype() if isinstance(dtype, type) else dtype).time_unit
text = pl.col(name)
# A selector, not pl.col(), so that a LazyFrame need not resolve its schema.
text = cs.by_name(name, require_all=False).as_expr()
exprs.append(
pl.when(text != "")
.then(text.str.slice(0, _TIMESTAMP_TEXT_LENGTHS[time_unit]))

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, model gpt-6-astra, sandbox read-only, reasoning effort high, session 01a14803-ee7c-7a33-9d78-0d8bb2f56ec4. Static review of git diff 9e2bf6eebfa96f7a1f747aae51b30637b1bd8296..3276a3f472bd3996c154f42f2142c0c6d2eb74a2 in a detached snapshot (unchanged afterwards); no PR text, commit messages, or prior findings given. It ran small Polars probes under /tmp on 1.39.0 and 2.0.0; no repository tests, AWS, or GitHub access.

Covered (reviewer): whole-read native parsing and fallback; GetQueryResults CSV serialization; lazy chunk conversion and exception wrapping; NULLs and empty/header-only results; duplicate names and renaming; reader kwargs; typing, docstrings, and regression assertions. Valid timestamps with 0-12 fractional digits matched between whole and chunked reads across years and time units. Result: FINDINGS (2, both P2).

Finding 1 (P2), here: "Truncation hides malformed timestamp suffixes: 2020-01-02 03:04:05.123456garbage is cut to a valid timestamp before validation, so strict and ignore_errors accept it."

Author verdict: rejected. The text of a timestamp column comes from Athena, which writes only YYYY-MM-DD HH:MM:SS[.f{1,12}] for years 1-9999 (#1212 facts); a suffix after the fraction cannot occur. The cut predates this PR (master's whole-read fallback), and dropping it was #1201, closed not planned.

.str.to_datetime("%Y-%m-%d %H:%M:%S%.f", time_unit=time_unit)
.str.to_datetime("%Y-%m-%d %H:%M:%S%.f", time_unit=time_unit, strict=strict)
.cast(dtype)
)
return df.with_columns(exprs) if exprs else df
return frame.with_columns(exprs) if exprs else frame


class DefaultPolarsTypeConverter(Converter):
Expand Down
72 changes: 48 additions & 24 deletions pyathena/polars/result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,29 +547,42 @@ def _get_converters(
for name, d in zip(column_names, description, strict=True)
}

def _get_timestamp_dtypes(self, has_header: bool) -> dict[str, Any]:
"""Get the Datetime dtypes of the timestamp columns, which are read as text.
def _get_csv_schema_overrides(self, has_header: bool) -> tuple[dict[str, Any], dict[str, Any]]:
"""Get the Polars data types for reading CSV data, with the timestamp columns as text.

Athena writes up to 12 fractional digits, and Polars does not parse more than
nine into a Datetime. A chunked read always reads the timestamp
columns as text and converts them with ``_to_datetimes()``. A whole read does
so when parsing them fails, and with ``ignore_errors`` given to ``execute()``,
with which Polars reads the timestamps that it does not parse as null.

Args:
has_header: Whether the CSV data has a header. Without one, or with
``schema_overrides`` or ``with_column_names`` given to ``execute()``,
Polars does not name the columns by the header, and they are not read
as text.
Polars does not name the columns by the header, and with ``schema``,
Polars ignores ``schema_overrides``, so the columns are not read as text.

Returns:
The Datetime dtypes keyed by the header of a CSV file.
Tuple of (schema_overrides, timestamp_dtypes): the data types to read the
columns with, and the Datetime dtypes of the timestamp columns read as text,
both keyed by the header of a CSV file.
"""
import polars as pl

if not has_header or self._kwargs.keys() & {"schema_overrides", "with_column_names"}:
return {}
dtypes = self._csv_dtypes
return {
if not has_header or self._kwargs.keys() & {

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), finding 2 (P2), here: "Eligibility ignores the effective has_header kwarg: with execute(has_header=False, skip_rows=1, ignore_errors=True) on a .csv result whose timestamp column is named column_1 (Polars 1.39) / column_0 (2.0), the native whole read returns null for a 12-digit value while this change returns a truncated datetime, violating the headerless exception."

Author verdict: rejected. _get_csv_schema_overrides() only replaces the Datetime entries of the schema_overrides that the readers already pass with String, and _to_datetimes() converts the same names. With has_header=False, Polars applies those overrides by its default names, so the columns read as text are exactly the ones it would otherwise parse as Datetime, and a column the frame does not have is skipped. In the scenario, the truncated value is what #1212 contract items 2 and 8 require; the null was the ignore_errors defect fixed in 7d5584e. Honoring the execute() has_header here would make that case raise OperationalError (without ignore_errors) or return null instead. The docstring's "Without one" refers to the has_header argument, which comes from the result file type.

No repair, so no follow-up review is needed.

"schema",
"schema_overrides",
"with_column_names",
}:
return dtypes, {}
timestamp_dtypes = {
name: dtype
for name, d in zip(self._get_column_names(), self.description or [], strict=True)
if d[1] == "timestamp"
and ((dtype := dtypes.get(name)) is pl.Datetime or isinstance(dtype, pl.Datetime))
}
return {**dtypes, **dict.fromkeys(timestamp_dtypes, pl.String)}, timestamp_dtypes

def _get_column_names(self) -> list[str]:
"""Get the names of the result columns in a DataFrame.
Expand Down Expand Up @@ -728,20 +741,21 @@ def _read_csv(self) -> pl.DataFrame:
if self.output_location:
with fsspec.open(self.output_location, "rb", **(storage_options or {})) as file:
source = file.read()
try:
df = pl.read_csv(source, **kwargs)
except pl.exceptions.ComputeError:
timestamp_dtypes = self._get_timestamp_dtypes(has_header)
if not timestamp_dtypes:
raise
# Athena writes up to 12 fractional digits, which Polars does not parse
# into a Datetime whose time unit holds fewer, so the data is read again
# with the timestamp columns as text.
kwargs["schema_overrides"] = {
**self._csv_dtypes,
**dict.fromkeys(timestamp_dtypes, pl.String),
}
df = _to_datetimes(pl.read_csv(source, **kwargs), timestamp_dtypes)
schema_overrides, timestamp_dtypes = self._get_csv_schema_overrides(has_header)
strict = not kwargs.get("ignore_errors", False)

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 (behavior and implementation): FINDINGS, repaired

Base 9e2bf6eebfa96f7a1f747aae51b30637b1bd8296, head 9fb2b4a6fca154e7f493f2e2e6ec709cf5a3f349.

Covered: _to_datetimes() (DataFrame/LazyFrame, missing columns, strict), _get_csv_schema_overrides() (header, schema/schema_overrides/with_column_names, custom converters with non-Datetime dtypes), _read_csv() (result file and GetQueryResults rows), _iter_csv_chunks()/_scan_csv_chunks() (new_columns, ignore_errors, error wrapping into OperationalError), callers (only these two readers; AsyncPolarsCursor uses the same result set), fetch methods over chunks (unchanged; they read the converted DataFrames), and the tests.

Finding (pre-existing on master, exposed by this PR): with ignore_errors=True, pl.read_csv() reads a timestamp with more fractional digits than the time unit as null instead of raising ComputeError (Polars 1.39.0 and 2.0.0: "2020-01-02 03:04:05.123456789012" → None), so the whole read never reached the text fallback and returned null, while the chunked read now truncates it. That contradicts #1191's expected behavior and #1212 contract item 8.

Repair in 2a2a61c: with ignore_errors, _read_csv() reads the timestamp columns as text at once and converts them with strict=False. test_read_csv_ignore_errors now covers both readers; both cases fail on master 9e2bf6e. Unit tests pass on Polars 2.0.0 and 1.39.0 (82 passed each).

No other findings.

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.

The branch was rewritten after round two to correct the commit messages (same trees): 9fb2b4a → e2006b3, 2a2a61c → 7d5584e. git diff 2a2a61c2 7d5584ef is empty.

df: pl.DataFrame | None = None
if strict or not timestamp_dtypes:
try:
df = pl.read_csv(source, **kwargs)
except pl.exceptions.ComputeError:
if not timestamp_dtypes:
raise
if df is None:
# The timestamp columns are read as text, again after parsing them
# failed, or at once with ignore_errors, with which Polars reads the
# timestamps that it does not parse as null instead of failing.
kwargs["schema_overrides"] = schema_overrides
df = _to_datetimes(pl.read_csv(source, **kwargs), timestamp_dtypes, strict=strict)
if new_columns:
df.columns = [*new_columns, *df.columns[len(new_columns) :]]
return df
Expand Down Expand Up @@ -908,14 +922,16 @@ def _iter_csv_chunks(self) -> Iterator[pl.DataFrame]:
raise ProgrammingError("output_location is not available.")

separator, has_header, new_columns = self._get_csv_params()
schema_overrides, timestamp_dtypes = self._get_csv_schema_overrides(has_header)
return self._scan_csv_chunks(
self.output_location,
self.connection,
{
"separator": separator,
"has_header": has_header,
"schema_overrides": self._csv_dtypes,
"schema_overrides": schema_overrides,
},
timestamp_dtypes,
new_columns,
)

Expand All @@ -924,6 +940,7 @@ def _scan_csv_chunks(
location: str,
connection: Connection[Any],
defaults: dict[str, Any],
timestamp_dtypes: dict[str, Any],
new_columns: list[str] | None,
) -> Iterator[pl.DataFrame]:
"""Scan a CSV result file in chunks with Polars.
Expand All @@ -934,6 +951,8 @@ def _scan_csv_chunks(
frozen when the first chunk is read.
defaults: The arguments of ``polars.scan_csv()`` that the result set
chooses; see ``_read_kwargs()``.
timestamp_dtypes: The Datetime dtypes of the timestamp columns read as
text; see ``_get_csv_schema_overrides()``.
new_columns: The names of the first columns, set after reading; see
``_get_csv_params()``.

Expand All @@ -954,7 +973,12 @@ def _scan_csv_chunks(
if "schema_overrides" not in self._kwargs:
# Renamed after reading, so that Polars matches the types to the header.
read_kwargs.pop("new_columns", None)
lazy_df = pl.scan_csv(location, **read_kwargs)
lazy_df = _to_datetimes(
pl.scan_csv(location, **read_kwargs),
timestamp_dtypes,
# As Polars reads a value it cannot parse as null with ignore_errors.
strict=not read_kwargs.get("ignore_errors", False),
)
for batch in lazy_df.collect_batches(chunk_size=self._chunksize):
if new_columns:
batch.columns = [*new_columns, *batch.columns[len(new_columns) :]]
Expand Down
20 changes: 17 additions & 3 deletions tests/pyathena/polars/test_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def test_convert_delegates_to_default(self):
assert converter.convert("varchar", "hello") == "hello"


@pytest.mark.parametrize("lazy", [False, True], ids=["dataframe", "lazyframe"])
@pytest.mark.parametrize(
("dtype", "microseconds"),
[
Expand All @@ -28,11 +29,12 @@ def test_convert_delegates_to_default(self):
(pl.Datetime("us"), [0, 123000, 123456, 123456, 123456]),
],
)
def test_to_datetimes(dtype, microseconds):
def test_to_datetimes(dtype, microseconds, lazy):
"""Timestamp text with up to 12 fractional digits is truncated to the time unit.

NULL can be null or an empty string, depending on the read options. A column that
the DataFrame does not have, such as one not selected, is skipped.
the frame does not have, such as one not selected, is skipped. A LazyFrame is
converted in its query plan.
"""
df = pl.DataFrame(
{
Expand All @@ -48,11 +50,23 @@ def test_to_datetimes(dtype, microseconds):
]
}
)
result = _to_datetimes(df, {"t": dtype, "missing": dtype})
result = _to_datetimes(df.lazy() if lazy else df, {"t": dtype, "missing": dtype})
if lazy:
assert isinstance(result, pl.LazyFrame)
result = result.collect()
assert result.schema["t"] == dtype
assert result["t"].to_list() == [
*(datetime(2020, 1, 2, 3, 4, 5, us) for us in microseconds),
datetime(1, 1, 1),
None,
None,
]


def test_to_datetimes_not_strict():
"""Text that is not a timestamp becomes null when the conversion is not strict."""
df = pl.DataFrame({"t": ["2020-01-02 03:04:05.123456789012", "invalid"]})
with pytest.raises(pl.exceptions.InvalidOperationError):
_to_datetimes(df, {"t": pl.Datetime})
result = _to_datetimes(df, {"t": pl.Datetime}, strict=False)
assert result["t"].to_list() == [datetime(2020, 1, 2, 3, 4, 5, 123456), None]
16 changes: 16 additions & 0 deletions tests/pyathena/polars/test_cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,22 @@ def test_temporal_types(self, polars_cursor, unload):
assert row == temporal.fetched_row(unload)
assert temporal.zones(row) == temporal.zones(temporal.fetched_row(unload))

@pytest.mark.parametrize("polars_cursor", [{"cursor_kwargs": {"chunksize": 1}}], indirect=True)
def test_temporal_types_chunked(self, polars_cursor):
"""Chunks of a CSV result have the temporal types and values of a whole read (#1191).

Timestamps with up to 12 fractional digits are truncated to microseconds.
"""
types = {"timestamp": pl.Datetime("us"), "date": pl.Date()}
columns = temporal.columns()
chunks = list(polars_cursor.execute(temporal.query()).iter_chunks())
assert len(chunks) == 1
df = chunks[0]
assert dict(df.schema) == {c.name: types.get(c.athena_type, pl.String()) for c in columns}
assert [df[c.name][0] for c in columns] == [
c.value if c.athena_type in types else c.text for c in columns
]

def test_complex(self, polars_cursor):
polars_cursor.execute(
"""
Expand Down
Loading
Loading