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
49 changes: 38 additions & 11 deletions pyathena/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from typing import Any, ClassVar
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

from pyathena.error import DataError
from pyathena.parser import (
TypedValueConverter,
TypeNode,
Expand Down Expand Up @@ -94,44 +95,70 @@ def _parse_utc_offset(value: str) -> timezone | None:


@functools.cache
def _parse_time_zone(value: str) -> tzinfo | None:
def _parse_time_zone(value: str) -> tzinfo:
"""Parse the time zone of an Athena TIMESTAMP WITH TIME ZONE value.

Results are cached per text, so an unknown zone name logs a warning only on its
first lookup.

Args:
value: A ``+HH:MM`` or ``-HH:MM`` UTC offset or an IANA time zone name.

Returns:
The fixed-offset time zone or ``ZoneInfo``, or None if the IANA time zone
database available to Python cannot provide a zone with the name.
The fixed-offset time zone or ``ZoneInfo``.

Raises:
DataError: If the IANA time zone database available to Python cannot
provide a zone with the name.
"""
offset = _parse_utc_offset(value)
if offset is not None:
return offset
try:
return ZoneInfo(value)
except (ZoneInfoNotFoundError, ValueError, OSError):
_logger.warning(f"Unknown time zone {value!r}; returning naive datetimes for it.")
return None
except (ZoneInfoNotFoundError, ValueError, OSError) as e:
raise DataError(
f"Unknown time zone {value!r}. "
"Update the tzdata package or the system time zone database."

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, and operations)
Base b2d7aa24b00a204e9e150952c708e3bb1126f9e2, head 0ebd096bdd (full pass over the PR body, commit messages, docstrings, and the issue premises they repeat).

Claims checked:

  • Before/after table: master's naive fallback with one warning and the fold=0 result were reproduced; the new messages are taken from the code.
  • Per-cursor exception classes: measured on live Athena with this branch; Cursor raises in execute() (the tests assert it, including managed results).
  • Type-hinted arrays, maps, and rows raise DataError (checked for each).
  • tzdata 2024.2 is IANA 2024b (tzdata.IANA_VERSION); uv lock --check passes with uv 0.12.5 and 0.13.1.
  • Benchmark: same interpreter (Python 3.15), master's module loaded from origin/master.
  • Docs: no page describes the naive fallback; docs/s3fs.md (aware datetime) and docs/pandas.md stay correct.

Callers and operations:

  • SQLAlchemy receives the DB API DataError.
  • Installations pinning tzdata<2024.2 will fail to resolve; this is in the release note.
  • No AWS requests are added.
  • A false DataError is possible when Python's tz rules have a transition that Athena's do not; that skew limitation is documented under Document the temporal type contract and gate the release on it #1221.

Result: FINDINGS, PR body corrected:

  1. "Every cursor" was wrong; SparkCursor does not convert values, so the claim is narrowed.
  2. The claim that named zones come only from queries is limited to what was measured (Iceberg reads back as UTC; Hive has no time zone type).
  3. The tzdata 2024b coverage claim is stated as the Trino zone-index comparison it rests on.
  4. The test record names the repair head.

No code changes in this round.

) from e


def _to_datetime_with_tz(varchar_value: str | None) -> datetime | None:
"""Convert an Athena TIMESTAMP WITH TIME ZONE value to an aware datetime.

Athena writes a value with a zone name as the local time in that zone. A local
time that a change of the zone's UTC offset, such as DST, repeats or skips does
not identify one instant, so it is not converted.

Args:
varchar_value: The value as text with a trailing zone name or ``+HH:MM`` /
``-HH:MM`` UTC offset, or None. An empty string, which pandas and the
Arrow CSV reader return for NULL, is None.

Returns:
The aware datetime, a naive datetime if the zone name is unknown, or None.
The aware datetime, or None.

Raises:
DataError: If the zone name is unknown, or the local time is ambiguous or
does not exist in the zone.
"""
if not varchar_value:
return None
datetime_, _, tz = varchar_value.rpartition(" ")
return _parse_datetime(datetime_).replace(tzinfo=_parse_time_zone(tz))
zone = _parse_time_zone(tz)
value = _parse_datetime(datetime_).replace(tzinfo=zone)
if isinstance(zone, timezone):

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 result from Codex CLI, codex exec -s read-only, model gpt-6-astra, session 01a1470d-dcec-76f0-ac96-44fd17980d97)

  • Base: b2d7aa24b00a204e9e150952c708e3bb1126f9e2. Head: cabcb4e3.
  • Snapshot: a detached worktree of the head. It was unchanged afterwards, and so was the PR worktree.
  • The prompt contained the intended behavior and the conventions only: no PR number, description, commit messages, or prior findings.

Reviewer's coverage:

  • The shared converter: unknown zones, repeated and skipped local times, transition boundaries, offsets, UTC, and NULL or empty values. Fold handling was checked against PEP 495.
  • Parser and typed nested conversions; synchronous and asynchronous result sets; the pandas, Arrow, Polars, and S3FS callers.
  • The changed unit and integration tests, independent expected values, and the wiring of the real Athena fixtures.
  • The tzdata>=2024.2 floor and the consistency of the lock file (revision 3, resolved 2024.2).

Result: CLEAN. No actionable findings against the stated behavior, including the accepted PandasCursor wrapping and the absence of the column name.

Limits: static review only. No builds, tests, or AWS execution.

return value
# PEP 495: fold=0 and fold=1 give different offsets only at a transition. At a
# repeated local time, fold=0 is the earlier instant; at a skipped one, the later.
folded = value.replace(fold=1)
if value.utcoffset() != folded.utcoffset():
if value.timestamp() < folded.timestamp():
reason = "is ambiguous: the local time occurs twice"
else:
reason = "does not exist: the local time is skipped"
raise DataError(
f"{varchar_value!r} {reason} in {tz}. Convert the value to UTC in SQL, "
"for example with AT TIME ZONE 'UTC'."
)
return value


def _parse_time(value: str) -> time:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ dependencies = [
"botocore>=1.43.31",
"tenacity>=8.0.1",
"fsspec>=2026.9.0",
"tzdata",
"tzdata>=2024.2",
]
requires-python = ">=3.11"
readme = "README.md"
Expand Down
12 changes: 11 additions & 1 deletion tests/pyathena/arrow/test_cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@
from pyathena.arrow.converter import DefaultArrowTypeConverter
from pyathena.arrow.cursor import ArrowCursor
from pyathena.arrow.result_set import AthenaArrowResultSet
from pyathena.error import DatabaseError, ProgrammingError
from pyathena.error import DatabaseError, DataError, ProgrammingError
from pyathena.model import AthenaQueryExecution
from pyathena.util import RetryConfig
from tests import ENV
from tests.pyathena.conftest import connect
from tests.pyathena.util import (
AMBIGUOUS_TIME_ZONE_QUERY,
CONVERTED_VALUES_QUERY,
CONVERTED_VALUES_ROW,
RESULT_FILE_VALUES_QUERY,
Expand Down Expand Up @@ -1066,6 +1067,15 @@ def test_fetch_all_rows(self, arrow_cursor):
"2024-02-29 23:59:58.123 +05:30"
]

def test_ambiguous_time_zone_value(self, arrow_cursor):
"""Fetch methods raise DataError for a local time that its zone repeats.

The table keeps the text, as for every TIMESTAMP WITH TIME ZONE value.
"""
arrow_cursor.execute(AMBIGUOUS_TIME_ZONE_QUERY)
with pytest.raises(DataError, match="is ambiguous"):
arrow_cursor.fetchall()

@pytest.mark.skipif(not ENV.managed_work_group, reason="AWS_ATHENA_MANAGED_WORKGROUP not set")
def test_managed_results_match_result_file(self):
"""Managed results have the types and values of the CSV result file.
Expand Down
21 changes: 20 additions & 1 deletion tests/pyathena/pandas/test_cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import pytest
from pandas.io.parsers import TextFileReader

from pyathena.error import DatabaseError, OperationalError, ProgrammingError
from pyathena.error import DatabaseError, DataError, OperationalError, ProgrammingError
from pyathena.filesystem.s3 import S3FileSystem
from pyathena.model import AthenaQueryExecution
from pyathena.pandas.converter import DefaultPandasTypeConverter
Expand All @@ -28,6 +28,7 @@
from tests import ENV
from tests.pyathena.conftest import connect
from tests.pyathena.util import (
AMBIGUOUS_TIME_ZONE_QUERY,
CONVERTED_VALUES_QUERY,
CONVERTED_VALUES_ROW,
RESULT_FILE_VALUES_QUERY,
Expand Down Expand Up @@ -1903,6 +1904,24 @@ def test_fetch_all_rows(self, pandas_cursor):
pandas_cursor.execute(CONVERTED_VALUES_QUERY)
assert pandas_cursor.fetchall() == [CONVERTED_VALUES_ROW]

def test_ambiguous_time_zone_value(self, pandas_cursor):
"""A local time that its zone repeats raises instead of choosing one.

A whole read converts the values in execute() and wraps the converter's
DataError in OperationalError.
"""
with pytest.raises(DatabaseError, match="is ambiguous"):
pandas_cursor.execute(AMBIGUOUS_TIME_ZONE_QUERY)

@pytest.mark.parametrize(
"pandas_cursor", [{"cursor_kwargs": {"chunksize": 1}}], indirect=["pandas_cursor"]
)
def test_ambiguous_time_zone_value_chunked(self, pandas_cursor):
"""A chunked read raises DataError when it reads the chunk with the value."""
pandas_cursor.execute(AMBIGUOUS_TIME_ZONE_QUERY)
with pytest.raises(DataError, match="is ambiguous"):
pandas_cursor.fetchall()

@pytest.mark.skipif(not ENV.managed_work_group, reason="AWS_ATHENA_MANAGED_WORKGROUP not set")
def test_managed_results_match_result_file(self):
"""Managed results have the dtypes and values of the CSV result file.
Expand Down
12 changes: 11 additions & 1 deletion tests/pyathena/polars/test_cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import polars as pl
import pytest

from pyathena.error import DatabaseError, ProgrammingError
from pyathena.error import DatabaseError, DataError, ProgrammingError
from pyathena.model import AthenaQueryExecution
from pyathena.polars.converter import DefaultPolarsTypeConverter
from pyathena.polars.cursor import PolarsCursor
Expand All @@ -26,6 +26,7 @@
from tests import ENV
from tests.pyathena.conftest import connect
from tests.pyathena.util import (
AMBIGUOUS_TIME_ZONE_QUERY,
CONVERTED_VALUES_QUERY,
CONVERTED_VALUES_ROW,
RESULT_FILE_VALUES_QUERY,
Expand Down Expand Up @@ -902,6 +903,15 @@ def test_fetch_all_rows(self, polars_cursor):
"2024-02-29 23:59:58.123 +05:30"
]

def test_ambiguous_time_zone_value(self, polars_cursor):
"""Fetch methods raise DataError for a local time that its zone repeats.

The table keeps the text, as for every TIMESTAMP WITH TIME ZONE value.
"""
polars_cursor.execute(AMBIGUOUS_TIME_ZONE_QUERY)
with pytest.raises(DataError, match="is ambiguous"):
polars_cursor.fetchall()

@pytest.mark.skipif(not ENV.managed_work_group, reason="AWS_ATHENA_MANAGED_WORKGROUP not set")
def test_managed_results_match_result_file(self):
"""Managed results have the types and values of the CSV result file.
Expand Down
15 changes: 13 additions & 2 deletions tests/pyathena/s3fs/test_cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import pytest

from pyathena.converter import _to_default
from pyathena.error import DatabaseError, ProgrammingError
from pyathena.error import DatabaseError, DataError, ProgrammingError
from pyathena.model import AthenaQueryExecution
from pyathena.s3fs.converter import DefaultS3FSTypeConverter
from pyathena.s3fs.cursor import S3FSCursor
Expand All @@ -19,7 +19,12 @@
from pyathena.util import RetryConfig
from tests import ENV
from tests.pyathena.conftest import connect
from tests.pyathena.util import CONVERTED_VALUES_QUERY, CONVERTED_VALUES_ROW, cached_file_systems
from tests.pyathena.util import (
AMBIGUOUS_TIME_ZONE_QUERY,
CONVERTED_VALUES_QUERY,
CONVERTED_VALUES_ROW,
cached_file_systems,
)


def _s3fs_converter_with_json_text():
Expand Down Expand Up @@ -541,6 +546,12 @@ def test_fetch_all_rows(self, s3fs_cursor):
s3fs_cursor.execute(CONVERTED_VALUES_QUERY)
assert s3fs_cursor.fetchall() == [CONVERTED_VALUES_ROW]

def test_ambiguous_time_zone_value(self, s3fs_cursor):
"""A local time that its zone repeats raises DataError instead of choosing one."""
s3fs_cursor.execute(AMBIGUOUS_TIME_ZONE_QUERY)
with pytest.raises(DataError, match="is ambiguous"):
s3fs_cursor.fetchall()

@pytest.mark.parametrize(
"s3fs_cursor",
[
Expand Down
88 changes: 64 additions & 24 deletions tests/pyathena/test_converter.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import logging
from datetime import date, datetime, time, timedelta, timezone
import re
from datetime import UTC, date, datetime, time, timedelta, timezone
from decimal import Decimal
from unittest.mock import patch
from zoneinfo import ZoneInfo
Expand All @@ -8,7 +8,6 @@

from pyathena.converter import (
DefaultTypeConverter,
_parse_time_zone,
_to_array,
_to_datetime,
_to_datetime_with_tz,
Expand All @@ -18,6 +17,7 @@
_to_time,
_to_time_with_tz,
)
from pyathena.error import DataError


@pytest.mark.parametrize(
Expand Down Expand Up @@ -724,34 +724,74 @@ def test_to_datetime_with_tz_offsets_and_zone_names(input_value, expected):


@pytest.mark.parametrize("zone_name", ["Foo/Bar", "../etc"])
def test_to_datetime_with_tz_unknown_zone_name(caplog, zone_name):
"""An unknown zone name gives a naive datetime and one warning per name."""
_parse_time_zone.cache_clear()
with caplog.at_level(logging.WARNING, logger="pyathena.converter"):
results = [
_to_datetime_with_tz(f"2024-02-29 23:59:58.123 {zone_name}"),
_to_datetime_with_tz(f"2024-02-29 23:59:59 {zone_name}"),
]
assert results == [
datetime(2024, 2, 29, 23, 59, 58, 123000),
datetime(2024, 2, 29, 23, 59, 59),
]
assert [record.getMessage() for record in caplog.records] == [
f"Unknown time zone {zone_name!r}; returning naive datetimes for it."
]
def test_to_datetime_with_tz_unknown_zone_name(zone_name):
"""An unknown zone name raises DataError instead of giving a naive datetime."""
with pytest.raises(DataError, match=f"Unknown time zone '{re.escape(zone_name)}'"):
_to_datetime_with_tz(f"2024-02-29 23:59:58.123 {zone_name}")


def test_to_datetime_with_tz_unreadable_zone_name():
"""A zone name the database cannot load gives a naive datetime.
"""A zone name the database cannot load raises DataError.

With only the tzdata package, ``ZoneInfo("America")`` opens a directory and
raises ``IsADirectoryError``.
"""
_parse_time_zone.cache_clear()
with patch("pyathena.converter.ZoneInfo", side_effect=IsADirectoryError("America")):
result = _to_datetime_with_tz("2024-02-29 23:59:58 America")
_parse_time_zone.cache_clear()
assert result == datetime(2024, 2, 29, 23, 59, 58)
with (
patch("pyathena.converter.ZoneInfo", side_effect=IsADirectoryError("America")),
pytest.raises(DataError, match="Unknown time zone 'America'"),
):
_to_datetime_with_tz("2024-02-29 23:59:58 America")


@pytest.mark.parametrize(
("input_value", "match"),
[
# Athena writes 05:30 and 06:30 UTC both as this text.
("2020-11-01 01:30:00.000 America/New_York", "is ambiguous"),
("2020-11-01 01:00:00.000 America/New_York", "is ambiguous"),
("2020-11-01 01:59:59.999999 America/New_York", "is ambiguous"),
("2020-10-25 01:30:00.000 Europe/London", "is ambiguous"),
# A 30-minute DST.
("2020-04-05 01:45:00.000 Australia/Lord_Howe", "is ambiguous"),
("2020-03-08 02:30:00.000 America/New_York", "does not exist"),
("2020-03-08 02:00:00.000 America/New_York", "does not exist"),
("2020-03-29 01:30:00.000 Europe/London", "does not exist"),
],
)
def test_to_datetime_with_tz_repeated_or_skipped_local_time(input_value, match):
"""A local time that a DST transition repeats or skips raises DataError."""
with pytest.raises(DataError, match=match):
_to_datetime_with_tz(input_value)


def test_typed_datetime_with_tz_elements_ambiguous_local_time():
"""An ambiguous element of a type-hinted array raises DataError."""
with pytest.raises(DataError, match="is ambiguous"):
DefaultTypeConverter().convert(
"array",
"[2020-11-01 01:30:00.000 America/New_York, null]",
type_hint="array(timestamp(3) with time zone)",
)


@pytest.mark.parametrize(
("input_value", "expected_utc"),
[
("2020-11-01 00:59:59.999999 America/New_York", datetime(2020, 11, 1, 4, 59, 59, 999999)),
("2020-11-01 02:00:00.000 America/New_York", datetime(2020, 11, 1, 7)),
("2020-03-08 01:59:59.999999 America/New_York", datetime(2020, 3, 8, 6, 59, 59, 999999)),
("2020-03-08 03:00:00.000 America/New_York", datetime(2020, 3, 8, 7)),
("2020-11-01 01:30:00.000 UTC", datetime(2020, 11, 1, 1, 30)),
("2020-11-01 01:30:00.000 -04:00", datetime(2020, 11, 1, 5, 30)),
],
)
def test_to_datetime_with_tz_around_dst_transitions(input_value, expected_utc):
"""Local times next to a DST transition convert to one instant.

So does the repeated local time with a zone or offset that has no transition.
"""
result = _to_datetime_with_tz(input_value)
assert result.astimezone(UTC) == expected_utc.replace(tzinfo=UTC)


@pytest.mark.parametrize(
Expand Down
Loading
Loading