Skip to content
Closed
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
26 changes: 15 additions & 11 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.

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]))
.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
59 changes: 40 additions & 19 deletions pyathena/polars/result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -546,29 +546,41 @@ 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, which Polars does not parse into a
Datetime whose time unit holds fewer. A chunked read always reads the timestamp
columns as text and converts them with ``_to_datetimes()``, and a whole read
does so when parsing them fails.

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() & {
"schema",

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): FINDINGS, repaired.

Scope: merge-base 06224494788043a4887afcb112a96c94bbcb02f1..bc95db9a6441caf4e112754b16c0631106583d16; pyathena/polars/result_set.py (_get_csv_schema_overrides(), _read_csv(), _iter_csv_chunks(), _scan_csv_chunks()), tests/pyathena/polars/test_result_set.py, tests/pyathena/polars/test_cursor.py. Callers traced: the sync and async PolarsCursor share AthenaPolarsResultSet; the S3 result file and the managed GetQueryResults rows both reach _read_csv().

Finding: with schema given to execute(), Polars ignores schema_overrides (checked with read_csv() and scan_csv()), so a timestamp column keeps the dtype from that schema. bc95db9 still converted it with _to_datetimes(): a String column became Datetime("us"), and a Datetime("ms") column failed with OperationalError, for both readers. On master both kept the schema's dtype; the old fallback ran only after a ComputeError.

Repair (f5023e8): schema joins schema_overrides and with_column_names as a reason not to read the columns as text. test_read_csv_truncates_timestamps gains a schema case per reader; both fail without the repair.

Checked without findings: errors from _to_datetimes() are inside each reader's try and become OperationalError; NULL as an empty field or ""; columns, new_columns (renamed after the conversion), .txt results without a header, and tz-aware or ns Datetime dtypes from a custom converter.

Validation: just lint; pytest tests/pyathena/polars/test_result_set.py tests/pyathena/polars/test_converter.py: 76 passed. The AWS Polars suites ran at bc95db9 (209 passed) and are rerun before Ready.

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.

Round one, follow-up after the design change (80424b2): CLEAN for the changed scope (git range-diff-equivalent: f5023e8..80424b2 touches _read_csv(), _scan_csv_chunks(), _to_datetimes(), and their tests).

Round two measured that reading the timestamp columns of a whole read as text raised its peak RSS by about 35-40%. After consulting Codex and claude-fable-5-1, the maintainer chose to keep the whole read as on master (native parse, text re-read only on ComputeError) and to convert the chunked read's text columns in the scan_csv() query plan.

Checked: the whole read matches master except that a ComputeError with schema given re-raises without the futile re-read (the outcome is the same OperationalError); a conversion error in a chunk surfaces from collect_batches() inside the try as OperationalError; cs.by_name(require_all=False) matches names literally (not as a regex), skips a column the frame lacks without resolving a LazyFrame's schema, and keeps the column order; collect_batches() keeps maintain_order=True; a header-only file yields no batches as before; new_columns is applied after the conversion.

Validation at 80424b2: just lint; Polars 2.0.0 with AWS tests/pyathena/polars tests/pyathena/aio/polars: 214 passed; unit tests on Polars 1.39.0 and 1.44.2: 79 passed each.

"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 @@ -729,17 +741,16 @@ def _read_csv(self) -> pl.DataFrame:
try:
df = pl.read_csv(source, **kwargs)
except pl.exceptions.ComputeError:
timestamp_dtypes = self._get_timestamp_dtypes(has_header)
schema_overrides, timestamp_dtypes = self._get_csv_schema_overrides(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)
# The data is read again with the timestamp columns as text.
kwargs["schema_overrides"] = schema_overrides
df = _to_datetimes(
pl.read_csv(source, **kwargs),
timestamp_dtypes,
strict=not kwargs.get("ignore_errors", False),
)
if new_columns:
df.columns = [*new_columns, *df.columns[len(new_columns) :]]
return df
Expand Down Expand Up @@ -906,14 +917,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 @@ -922,6 +935,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 @@ -932,6 +946,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 @@ -952,7 +968,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),

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): FINDINGS.

Reviewer: Codex CLI 0.162.1, model gpt-6-astra, reasoning effort max, codex exec -s read-only, session 01a141e0-50a5-77d3-83a6-59fd6e533f3f. Static review of a detached snapshot at 80424b2eb029ce5a731dd41137b8c6b68155f3c9, diff from merge-base 06224494788043a4887afcb112a96c94bbcb02f1; the prompt held the intended behavior, conventions, and diff only (no PR text, commit messages, or prior findings). The reviewer ran in-memory Polars probes under /tmp with 1.39.0 and 2.0.0; no repository tests, builds, edits, AWS, or GitHub access. The snapshot remained unchanged.

Covered: whole-read parse and fallback; chunked lazy conversion and OperationalError translation; NULLs, empty/header-only results, duplicate names, projections, new_columns; schema, schema_overrides, with_column_names, null_values, ignore_errors; Polars 1.39.0/2.0.0 selectors, units, batch ordering, typing, and test assertions.

Finding (P2): _to_datetimes() stayed strict regardless of ignore_errors. With timestamp metadata and CSV t\n2020-01-02 03:04:05.123\ninvalid\n, a chunked read with ignore_errors=True used to return the datetime then NULL; at 80424b2 it raised InvalidOperationError → OperationalError, aborting iteration.

Verified and repaired in eee0346: _to_datetimes() takes strict, which _scan_csv_chunks() and the whole read's fallback set from ignore_errors. test_iter_csv_chunks_ignore_errors fails at 80424b2 and passes now; test_to_datetimes_not_strict covers the converter. Both self-review perspectives on the repair: strict only affects str.to_datetime (not the cast); the PR body's statement that non-timestamp text raises with ignore_errors was corrected. Validation at eee0346: just lint; Polars 2.0.0 with AWS tests/pyathena/polars tests/pyathena/aio/polars: 216 passed; unit tests on Polars 1.39.0: 81 passed. An independent follow-up review of the 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 review (relayed): CLEAN.

Reviewer: Codex CLI 0.162.1, gpt-6-astra, reasoning effort max, codex exec -s read-only, session 01a141ed-b626-7582-bb88-e0bfd275efa1. Static review of a detached snapshot at eee03465f89838bcb6c128009f8065916b7e3ff2, covering 80424b2e..eee03465 with the overall diff from merge-base 06224494788043a4887afcb112a96c94bbcb02f1 available; in-memory Polars 1.39.0 / 2.0.0 probes under /tmp only. The snapshot remained unchanged.

Covered: _to_datetimes() and both callers, kwargs forwarding, whole-read fallback and lazy batching, cast/timezone, NULL and empty strings, fractional truncation, non-boolean ignore_errors values (Polars rejects them before the conversion), and the regression assertions.

Notes from the reviewer, not actionable: the new tests do not exercise the whole read's fallback with ignore_errors (with that option the native parse does not raise, so the fallback is not reached); whole reads with ignore_errors=True return null for excess fractional digits while chunked reads truncate them, which predates this change.

)
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]
14 changes: 14 additions & 0 deletions tests/pyathena/polars/test_cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,20 @@ def test_iter_chunks_data_consistency(self):
# Should have multiple chunks
assert len(chunked_dfs) > 1

def test_iter_chunks_result_file_values(self, polars_cursor):
"""Chunks of a CSV result file have the types and values of the whole result."""
whole = polars_cursor.execute(RESULT_FILE_VALUES_QUERY).as_polars()
chunks = list(polars_cursor.execute(RESULT_FILE_VALUES_QUERY, chunksize=1).iter_chunks())
assert all(chunk.height <= 1 for chunk in chunks)
chunked = pl.concat(chunks)
assert chunked.schema == whole.schema
assert chunked.equals(whole)
# Fractions finer than microseconds are truncated.
assert chunked["col_timestamp_12"].to_list() == [
datetime(2020, 1, 2, 3, 4, 5, 123456),
None,
]

def test_iter_chunks_chunk_sizes(self):
"""Test that chunks have correct sizes."""
with contextlib.closing(connect(schema_name=ENV.schema)) as conn:
Expand Down
Loading
Loading