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
2 changes: 1 addition & 1 deletion docs/pandas.md
Original file line number Diff line number Diff line change
Expand Up @@ -584,7 +584,7 @@ It also changes the values of a column with a string dtype whose values all look

Apart from PandasCursor's own options such as `engine` and `chunksize`, an option passed here replaces the value PyAthena sets for the same pandas.read_csv() argument.
For example, `dtype` replaces the whole column type mapping, and `parse_dates` replaces the list of date, time, and timestamp columns.
With `parse_dates`, `dtype`, or `converters` passed here, PyAthena does not convert the `date`, `timestamp`, and `timestamp with time zone` columns after reading. pandas reads them with these options and the values PyAthena sets for the others: `parse_dates` for date, time, and timestamp columns, and the converter mapping, which converts `timestamp with time zone` values in their zones. The fetch methods return their values as pandas holds them. A conversion function that the converter maps to one of these types also replaces PyAthena's conversion of the columns of that type.
With `parse_dates`, `dtype`, or `converters` passed here, PyAthena does not convert the `date`, `time`, `timestamp`, and `timestamp with time zone` columns after reading. pandas reads them with these options and the values PyAthena sets for the others: `parse_dates` for date, time, and timestamp columns, and the converter mapping, which converts `timestamp with time zone` values in their zones. `time` columns that pandas parses become `time` objects, and the fetch methods return the values of the other columns as pandas holds them. A conversion function that the converter maps to one of these types also replaces PyAthena's conversion of the columns of that type.

(pandas-s3-read-options)=

Expand Down
63 changes: 16 additions & 47 deletions pyathena/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,38 +35,22 @@ def _to_date(value: str | datetime | date | None) -> date | None:
return value.date()
if isinstance(value, date):
return value
return datetime.strptime(value, "%Y-%m-%d").date()


def _parse_datetime(value: str) -> datetime:
"""Parse an Athena TIMESTAMP value of any precision.

Args:
value: The value as ``YYYY-MM-DD HH:MM:SS`` followed by an optional
fraction of up to 12 digits.

Returns:
The datetime. Digits beyond microseconds are truncated.
"""
seconds, _, fraction = value.partition(".")
parsed = datetime.strptime(seconds, "%Y-%m-%d %H:%M:%S")
if fraction:
parsed = parsed.replace(microsecond=int(fraction[:6].ljust(6, "0")))
return parsed
return date.fromisoformat(value)


def _to_datetime(varchar_value: str | None) -> datetime | None:
"""Convert an Athena TIMESTAMP value to a datetime.
"""Convert an Athena TIMESTAMP value of any precision to a datetime.

Args:
varchar_value: The value as text, or None.
varchar_value: The value as ``YYYY-MM-DD HH:MM:SS`` followed by an optional
fraction of up to 12 digits, or None.

Returns:
The datetime, or None.
The datetime, or None. Digits beyond microseconds are truncated.
"""
if varchar_value is None:
return None
return _parse_datetime(varchar_value)
return datetime.fromisoformat(varchar_value)

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

Base 0a58be36ff188fc1dc9541d1d28e5383500a8110, head 3bdd65d9e0d61628da7e3341c970c2238c481f90.

Covered:

  • Behavior and failure paths:
    • Callers of the changed functions: _DEFAULT_CONVERTERS (Cursor, DictCursor, S3FSCursor, and the nested values of TypedValueConverter in pyathena/parser.py, which does not catch converter errors); the Arrow, Polars, and pandas mappings; and _to_utc_timestamps().
    • fromisoformat() on Python 3.11 and 3.14 for 0 to 12 fractional digits and years 1 and 9999. Results equal the old truncation.
    • Text outside Athena's formats (T separator, basic forms, offsets) is now accepted; noted under "Behavior change" in the PR description.
  • Data boundaries, _to_times() (pyathena/pandas/result_set.py:454):
    • NULL from the C engine (NaN, with future.infer_string on and off), from pyarrow strings (pd.NA), and as an empty string becomes None.
    • Header-only and all-NULL time columns give the same dtype and values as master for whole, chunked, and engine="pyarrow" reads.
  • Options:
    • dtype={}, converters={}, usecols, keep_default_na, chunksize, and engine="pyarrow" give the same values as master.
    • parse_dates=[] and parse_dates=["ts"] failed on master with AttributeError; a time column left out of parse_dates now stays text with the c, python, and pyarrow engines.
    • index_col="t" failed on master with KeyError; the index now holds time objects.
    • _get_fetch_types() leaves out the time columns, whose values are already time objects.
  • Tests: with the source changes reverted, the 7 new or changed cases fail; the updated _types_frame expectation pins the raw text read.

Pre-existing and out of scope: since 36b23f0, the values.dtype.kind == "M" branches of _to_timestamps() and _to_utc_timestamps() (pyathena/pandas/result_set.py:432, :496) look unreachable, because the temporal converters are empty whenever parse_dates, dtype, or converters is given. _to_times() does not add such a branch. This PR leaves them unchanged.

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 on the repair 9ebd8ff3 (affected scope): CLEAN

git range-diff scope: _to_times() accepts parsed datetime values, and _trunc_date() calls _replace_by_label().

  • Column that pandas parsed: _to_times() keeps master's .dt.time.astype(object).where(notna, None), so NULL is None as before.
  • Text column that pandas did not parse: it is reassigned unchanged; parse_dates=[] and parse_dates=["ts"] still give text with the c, python, and pyarrow engines.
  • Index level: _replace_by_label() rebuilds it, as for the other temporal columns.
  • Labels that are absent or shared:
    • usecols that leaves the column out is skipped.
    • A duplicate label, for which df[name] is a DataFrame, is skipped by _replace_by_label()'s Series check. Duplicate CSV names get distinct labels from _key_csv_columns_by_labels().
  • Tests: test_time_index[*-pandas_parses] fails with only the repair reverted, and passes on the head.

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 on the repair f5029cfe (independent-review repairs, affected scope): CLEAN

  • _to_times(): pyarrow's time64 values come out of to_numpy(dtype=object) as datetime.time and are kept. datetime is not a subclass of time, so no other value type passes through. The order is unchanged: text, then time, then None for NaN and pd.NA.
  • _trunc_date(): it skips only the labels in _csv_temporal_converters, which hold time objects. With parse_dates, dtype, or converters given, the mapping is empty, so pandas-parsed columns and index levels are still converted.
  • Re-checked: the edge matrix (options, engines, usecols, index_col, chunks) and the dtype=None matrix with TIME(0) and TIME(3), whole and with index_col. Results match master, except for the intended fixes.



# The length of Athena timestamp text, such as "2020-01-02 03:04:05.123456", that holds
Expand Down Expand Up @@ -133,7 +117,7 @@ def _to_datetime_with_tz(varchar_value: str | None) -> datetime | None:
for NULL, is None.

Returns:
The aware datetime, or None.
The aware datetime, or None. Digits beyond microseconds are truncated.

Raises:
DataError: If the zone name is unknown, or the local time is ambiguous or
Expand All @@ -143,7 +127,7 @@ def _to_datetime_with_tz(varchar_value: str | None) -> datetime | None:
return None
datetime_, _, tz = varchar_value.rpartition(" ")
zone = _parse_time_zone(tz)
value = _parse_datetime(datetime_).replace(tzinfo=zone)
value = datetime.fromisoformat(datetime_).replace(tzinfo=zone)
if isinstance(zone, timezone):
return value
# PEP 495: fold=0 and fold=1 give different offsets only at a transition. At a
Expand All @@ -161,35 +145,19 @@ def _to_datetime_with_tz(varchar_value: str | None) -> datetime | None:
return value


def _parse_time(value: str) -> time:
"""Parse an Athena TIME value of any precision.

Args:
value: The value as ``HH:MM:SS`` followed by an optional fraction of up to
12 digits.

Returns:
The time. Digits beyond microseconds are truncated.
"""
seconds, _, fraction = value.partition(".")
parsed = datetime.strptime(seconds, "%H:%M:%S").time()
if fraction:
parsed = parsed.replace(microsecond=int(fraction[:6].ljust(6, "0")))
return parsed


def _to_time(varchar_value: str | None) -> time | None:
"""Convert an Athena TIME value to a time.
"""Convert an Athena TIME value of any precision to a time.

Args:
varchar_value: The value as text, or None. An empty string is None.
varchar_value: The value as ``HH:MM:SS`` followed by an optional fraction of
up to 12 digits, or None. An empty string is None.

Returns:
The time, or None.
The time, or None. Digits beyond microseconds are truncated.
"""
if not varchar_value:
return None
return _parse_time(varchar_value)
return time.fromisoformat(varchar_value)


def _to_time_with_tz(varchar_value: str | None) -> time | None:
Expand All @@ -201,12 +169,13 @@ def _to_time_with_tz(varchar_value: str | None) -> time | None:
None.

Returns:
The time with a fixed-offset ``tzinfo``, or None.
The time with a fixed-offset ``tzinfo``, or None. Digits beyond microseconds
are truncated.
"""
if not varchar_value:
return None
index = max(varchar_value.rfind("+"), varchar_value.rfind("-"))
return _parse_time(varchar_value[:index]).replace(
return time.fromisoformat(varchar_value[:index]).replace(
tzinfo=_parse_utc_offset(varchar_value[index:])
)

Expand Down
85 changes: 66 additions & 19 deletions pyathena/pandas/result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from concurrent.futures import ThreadPoolExecutor
from contextlib import ExitStack
from contextvars import copy_context
from datetime import datetime, timezone
from datetime import datetime, time, timezone
from functools import partial
from io import BufferedReader, BytesIO, IOBase, StringIO, TextIOWrapper
from itertools import chain, islice
Expand All @@ -30,7 +30,7 @@
from pyathena import OperationalError
from pyathena.arrow.reader import ParquetChunkReader
from pyathena.arrow.util import to_column_info
from pyathena.converter import Converter, _parse_time_zone, _to_datetime_with_tz
from pyathena.converter import Converter, _parse_time_zone, _to_datetime_with_tz, _to_time
from pyathena.error import DataError, ProgrammingError
from pyathena.filesystem.s3 import S3FileSystem
from pyathena.model import AthenaQueryExecution
Expand All @@ -57,7 +57,7 @@

# The Athena types whose CSV text AthenaPandasResultSet reads as text and converts after
# reading; see _TemporalConverter.
_CSV_TEMPORAL_TYPES = ("timestamp", "date", "timestamp with time zone")
_CSV_TEMPORAL_TYPES = ("timestamp", "date", "timestamp with time zone", "time")
# A timestamp's text up to its sixth fractional digit, for years of four digits.
_TIMESTAMP_TEXT_LENGTH = len("2000-01-01 00:00:00.000000")

Expand Down Expand Up @@ -314,12 +314,13 @@ def _convert_values(self, values: Series) -> Series:
class _TemporalConverter:
"""A converter for the text of a temporal column that ``pandas.read_csv()`` read.

``timestamp``, ``date``, and ``timestamp with time zone`` columns are read as text
and converted as whole columns after reading, so that their dtypes depend only on
the Athena type: ``datetime64[us]``, ``datetime64[us]``, and ``datetime64[us, UTC]``.
Fractional digits beyond microseconds are truncated. A column that pandas already
parsed, as with ``parse_dates`` when a ``dtype`` given to ``execute()`` replaces
PyAthena's, gets the same unit.
``timestamp``, ``date``, ``timestamp with time zone``, and ``time`` columns are
read as text and converted as whole columns after reading, so that their dtypes
depend only on the Athena type: ``datetime64[us]``, ``datetime64[us]``,
``datetime64[us, UTC]``, and ``object`` with ``time`` objects and None for NULL.
Fractional digits beyond microseconds are truncated. A column that pandas or
pyarrow already parsed, as when ``dtype=None`` given to ``execute()`` replaces
PyAthena's dtype mapping, gets the same dtype.
"""

__slots__ = ("_type",)
Expand Down Expand Up @@ -366,6 +367,8 @@ def _convert_values(self, values: Series) -> Series:
"""
if self._type == "timestamp with time zone":
return _to_utc_timestamps(values)
if self._type == "time":
return _to_times(values)
return _to_timestamps(values, date=self._type == "date")


Expand Down Expand Up @@ -448,6 +451,33 @@ def _to_timestamps(values: Series, date: bool = False) -> Series:
return pd.to_datetime(text, format="ISO8601").dt.as_unit("us")


def _to_times(values: Series) -> Series:
"""Convert time text to ``time`` objects.

pandas cannot infer the format of time text, which it would parse one value at a
time with ``dateutil``.

Args:
values: The text values, with missing values for NULL, datetime values that
pandas parsed, or ``time`` values that pyarrow parsed.

Returns:
The values as ``time`` objects, with None for NULL.
"""
import pandas as pd

if values.dtype.kind == "M":
return values.dt.time.astype(object).where(values.notna(), None)
return pd.Series(
[
_to_time(v) if isinstance(v, str) else v if isinstance(v, time) else None

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

  • Reviewer: codex exec -s read-only, Codex v0.163.0, model gpt-6-astra, reasoning effort max, session 01a154d0-2a67-7a33-b37a-3398e2e70d46.
  • Scope: base 0a58be36ff188fc1dc9541d1d28e5383500a8110, head 9ebd8ff35c115d431af91572e9972729444690a1, a detached snapshot.
  • Prompt: no PR number, description, commit messages, or prior findings.
  • Static review only: no edits, builds, tests, or GitHub access. The snapshot stayed clean.
  • Covered:
    • the shared converters through Cursor/DictCursor/S3FSCursor, nested typed conversion, Arrow/Polars/pandas, and the sync, threaded async, and aio callers;
    • NULL and empty strings, truncation, time zones, exception types, and column names;
    • whole and chunked reads, managed results, the three CSV engines, future.infer_string, option overrides, indexes, usecols, duplicate names, and the pandas fetch methods;
    • tests, docs, conventions, and allocation costs.
  • No regression found in the scalar fromisoformat() substitutions for Athena's formats.

Result: FINDINGS (2 introduced by the diff)

1. P2. Times that pyarrow already parsed silently become NULL (pyathena/pandas/result_set.py, _to_times(); line 472 at the reviewed head).

  • Scenario: engine="pyarrow" and dtype=None (which keeps _TemporalConverter but replaces the string dtype mapping), no chunksize, a result above the size threshold, and a TIME(0) column with 03:04:05. pyarrow infers time values, and the comprehension turned every non-string into None. At base, the column went through parse_dates and the datetime-to-time conversion.
  • Verified: reproduced: master gives time(3, 4, 5), the reviewed head gave None in the DataFrame, in fetchone(), and in the index with index_col. TIME with a fraction was not affected.
  • Repair f5029cfe:
    • _to_times() keeps time values.
    • test_temporal_options_none now has a TIME(0) column; its dtype-pyarrow case fails with the repair reverted and passes on the head.
    • The _TemporalConverter docstring now names the actual case for already parsed columns, dtype=None.

Pre-existing observation (P3 docs), folded into the same repair: the _TemporalConverter docstring said that a dtype given to execute() leads to already parsed columns, but a non-None dtype disables these converters.

Correction to my round-two record: I wrote that the kind == "M" branches of _to_timestamps() and _to_utc_timestamps() looked unreachable. They are reachable with dtype=None, as this finding shows.

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. CLEAN

  • Reviewer: codex exec -s read-only, model gpt-6-astra, session 01a154dc-c629-72f0-abc5-5a3dab21b681.
  • Scope: the repair only, 9ebd8ff35c115d431af91572e9972729444690a1..f5029cfefee44133cfddda1ccc9b9940c48c9191, base unchanged. Detached snapshot at f5029cfe, which stayed clean.
  • Static review only: no tests, builds, edits, or GitHub access.
  • Covered:
    • NULL handling;
    • the c, python, and pyarrow engines, and future.infer_string on and off;
    • whole and chunked reads, index_col including MultiIndex levels, usecols, and the fetch methods;
    • the changed docstrings and the test's distinctness.

Result:

  • _to_times() keeps the time values that pyarrow parsed.
  • _trunc_date() skips the labels that _TemporalConverter converted and still converts the time columns and index levels that pandas parsed.
  • The docstrings are accurate.
  • The amended test_temporal_options_none protects a distinct contract (TIME(0) with engine="pyarrow", dtype=None) and would receive None without the repair.

for v in values.to_numpy(dtype=object)
],
index=values.index,
dtype=object,
)


def _to_utc_timestamps(values: Series) -> Series:
"""Convert ``timestamp with time zone`` text to ``datetime64[us, UTC]``.

Expand Down Expand Up @@ -1515,6 +1545,7 @@ def _get_fetch_types(self) -> dict[Any, str]:
return {
label: "date" if converter.type == "date" else "datetime"
for label, converter in self._csv_temporal_converters.items()
if converter.type != "time"
}
if self._kwargs.keys() & {"dtype_backend", "coerce_int96_timestamp_unit"}:
return {}
Expand All @@ -1529,11 +1560,11 @@ def _get_csv_temporal_converters(
) -> dict[Any, _TemporalConverter]:
"""Get the converters of the temporal columns that are converted after reading.

PyAthena converts the ``timestamp``, ``date``, and ``timestamp with time zone``
columns after reading, unless ``parse_dates``, ``dtype``, or ``converters`` is
given to ``execute()``, with which pandas reads them, or the converter maps the
type to a conversion function other than the default ``timestamp with time
zone`` one.
PyAthena converts the ``timestamp``, ``date``, ``timestamp with time zone``, and
``time`` columns after reading, unless ``parse_dates``, ``dtype``, or
``converters`` is given to ``execute()``, with which pandas reads them, or the
converter maps the type to a conversion function other than the default
``timestamp with time zone`` one.

Args:
columns: The label and the description of each column that is read.
Expand Down Expand Up @@ -1584,13 +1615,29 @@ def _get_csv_converter(
return converter

def _trunc_date(self, df: DataFrame) -> DataFrame:
if self._time_columns:
"""Convert the time columns that pandas parsed as datetimes to ``time`` objects.

pandas parses the time columns with ``parse_dates`` when ``parse_dates``,
``dtype``, or ``converters`` given to ``execute()`` keeps PyAthena from
converting them after reading. A time column can also be an index level. Time
columns that PyAthena converted after reading or that pandas did not parse are
left as they are.

Args:
df: The DataFrame or chunk, updated in place.

Returns:
The same DataFrame.
"""
for time_col in self._time_columns:
if time_col in self._csv_temporal_converters:

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. P3. A converted time index is rebuilt again

  • Location: pyathena/pandas/result_set.py, _trunc_date(); line 1630 at the reviewed head.
  • Scenario: with index_col=["tm", "id"], _TemporalConverter converts tm and rebuilds the MultiIndex. _trunc_date() then called _replace_by_label() for tm again. Its callback returned the values unchanged, but the helper still materialized every level and called MultiIndex.from_arrays().
  • Basis: static, not benchmarked by the reviewer.
  • Verified: by reading the code. _trunc_date() runs after the after-read conversion, and a converted time column holds time objects, never datetimes.
  • Repair f5029cfe: _trunc_date() skips the labels in _csv_temporal_converters. There is no added test, because the effect is cost, not values. test_time_index (default and dtype={}, whole and chunked) still passes.

Pre-existing, not changed: malformed scalar text such as 25:00:00 raises ValueError without a column name in row conversion (pyathena/result_set.py:1113, and the Arrow and Polars equivalents). Only DataError gets the column name. This was the same with strptime(), so it is left out of this PR.

Validation on f5029cfe:

  • just lint
  • 572 offline tests
  • the Athena temporal subset of every cursor: 219 passed, 7 skipped
  • a 1M-row temporal PandasCursor whole read: 3.85–3.95 s, unchanged

continue
# A NULL is None, as with the GetQueryResults fallback and the other types.
truncated = df.loc[:, self._time_columns].apply(
lambda r: r.dt.time.astype(object).where(r.notna(), None)
_replace_by_label(

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

Base 0a58be36ff188fc1dc9541d1d28e5383500a8110, reviewed head 3bdd65d9e0d61628da7e3341c970c2238c481f90, repaired head 9ebd8ff35c115d431af91572e9972729444690a1.

Claims checked: the PR description, the commit message, the docs/pandas.md paragraph, and the changed docstrings.

Findings:

  1. The PandasCursor table did not belong to the published head. Its times and peak RSS came from a vectorized _to_times() that was later reverted. Repair: re-measured on 9ebd8ff3, with 5 runs alternating with master on the same interpreter. Peak RSS is reported as measured: whole-read medians 732 → 790 MiB, chunked 611 → 631 MiB, with the run-to-run ranges and the phase sampling that points to allocator retention. I tried blocks of 10,000 values, which lowered the traced peak of the conversion from 97 to 54 MiB but not the read's peak RSS, so the simple code stays.
  2. Overclaim in "the fetch methods of every cursor". PandasCursor's datetime64 columns and UNLOAD results never call these functions. Repair: the WHY section and the release notes now name the actual callers.
  3. The docs claim that "time columns that pandas parses become time objects" did not hold for an index level. With index_col="tm" and dtype={}, _trunc_date() skipped the parsed index, which stayed datetime64 with today's date. Repair: _trunc_date() goes through _replace_by_label(), as the after-read conversion does. test_time_index[*-pandas_parses] covers this and fails with only the repair reverted.

Also checked:

  • fromisoformat() on Python 3.12, 3.13, and 3.15, in addition to 3.11 and 3.14: same results for 12 fractional digits.
  • The removed private helpers _parse_datetime() and _parse_time() were added in 8b083aa (v4.0.0) and have no other callers in the repository.
  • The micro-benchmark numbers were re-measured with the head's functions.

Re-run on 9ebd8ff3: just lint; 572 offline tests; and the Athena temporal subset of every cursor (219 passed, 7 skipped).

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 two follow-up on the repair f5029cfe: CLEAN after correcting claims

  • Correction: in this round's first pass, I wrote that the kind == "M" branches looked unreachable. That was wrong: dtype=None reaches them (see the independent review thread).
  • Docstring check: the new _TemporalConverter sentence says that a column pyarrow already parsed with dtype=None "gets the same dtype". Checked for each type:
    • timestamp: datetime64 goes to as_unit("us").
    • timestamp with time zone: converted to UTC.
    • date: pyarrow's date objects take the text path of _to_timestamps().
    • time: time objects are kept.
  • PR description: TEST now names f5029cfe and the added test_temporal_options_none case. The measurements stay attributed to 9ebd8ff3, with a timing check on f5029cfe.

df,
time_col,
lambda values: _to_times(values) if values.dtype.kind == "M" else values,
)
for time_col in self._time_columns:
df.isetitem(df.columns.get_loc(time_col), truncated[time_col])
return df

@override
Expand Down
Loading
Loading