Repository navigation
Test the temporal types of each synchronous cursor #1226
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,6 +26,7 @@ | |
| ) | ||
| from pyathena.util import RetryConfig | ||
| from tests import ENV | ||
| from tests.pyathena import temporal | ||
| from tests.pyathena.conftest import connect | ||
| from tests.pyathena.util import ( | ||
| AMBIGUOUS_TIME_ZONE_QUERY, | ||
|
|
@@ -42,6 +43,17 @@ def _pandas_converter_without_bigint_dtype(): | |
| return converter | ||
|
|
||
|
|
||
| def _pandas_value(column: temporal.Column): | ||
| """The value that a DataFrame holds for a temporal column.""" | ||
| if column.value is None: | ||
| return None | ||
| if column.athena_type == "timestamp with time zone": | ||
| return pd.Timestamp(column.value).tz_convert("UTC") | ||
| if column.athena_type in ("timestamp", "date"): | ||
| return pd.Timestamp(column.value) | ||
| return column.value | ||
|
|
||
|
|
||
| class TestPandasCursor: | ||
| @pytest.mark.parametrize( | ||
| ("engine", "chunksize"), [("auto", None), ("c", 2), ("python", 2), ("pyarrow", None)] | ||
|
|
@@ -509,6 +521,47 @@ def test_invalid_arraysize(self, pandas_cursor): | |
| with pytest.raises(ProgrammingError): | ||
| pandas_cursor.arraysize = -1 | ||
|
|
||
| @pytest.mark.parametrize( | ||
| ("pandas_cursor", "unload"), | ||
| [ | ||
| pytest.param( | ||
| {"cursor_kwargs": {"unload": False}}, | ||
| False, | ||
| marks=pytest.mark.xfail(strict=True, reason="#1219"), | ||
| ), | ||
| pytest.param( | ||
| {"cursor_kwargs": {"unload": True}}, | ||
| True, | ||
| marks=pytest.mark.xfail(strict=True, reason="#1214, #1219"), | ||
| ), | ||
| ], | ||
| indirect=["pandas_cursor"], | ||
| ids=["csv", "unload"], | ||
| ) | ||
| def test_temporal_types(self, pandas_cursor, unload): | ||
| """Temporal values convert as the contract of #1212 defines. | ||
|
|
||
| Aware values are compared by instant, as the DataFrame holds them in UTC. | ||
| """ | ||
| dtypes = { | ||
| "timestamp": "datetime64[us]", | ||
| "date": "datetime64[us]", | ||
| "timestamp with time zone": "datetime64[us, UTC]", | ||
| "time": "object", | ||
| "time with time zone": "object", | ||
| } | ||
| columns = temporal.columns(unload) | ||
| df = pandas_cursor.execute(temporal.query(unload)).as_pandas() | ||
| assert {name: str(dtype) for name, dtype in df.dtypes.items()} == { | ||
| c.name: dtypes[c.athena_type] for c in columns | ||
| } | ||
| assert [None if pd.isna(v) else v for v in df.iloc[0]] == [ | ||
| _pandas_value(c) for c in columns | ||
| ] | ||
| # The fetch methods read the same DataFrame, whose aware values are in UTC. | ||
| row = pandas_cursor.fetchone() | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 3. [P2] The DataFrame tests ran the query twice (relayed), for the frame and again for the fetch methods. Repair (66bfa6a): the DataFrame, Arrow, and Polars tests fetch from the execution they read. A probe confirmed that Validation at 66bfa6a: |
||
| assert row == temporal.fetched_row(unload) | ||
|
|
||
| @pytest.mark.parametrize( | ||
| ("pandas_cursor", "chunksize"), | ||
| [ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,192 @@ | ||
| # Copyright 2026 The PyAthena authors | ||
| # | ||
| # Licensed under the MIT License. | ||
| # See LICENSE or https://opensource.org/licenses/MIT. | ||
| # | ||
| # SPDX-License-Identifier: MIT | ||
| """A query of Athena temporal values and what each cursor returns for them (#1212). | ||
|
|
||
| Each column is one format. The fetch methods return ``value``, fractional digits | ||
| beyond six truncated. ArrowCursor and PolarsCursor keep the types that they cannot | ||
| represent as Athena's text, ``text``. | ||
| """ | ||
|
|
||
| from datetime import date, datetime, time, timedelta, timezone | ||
| from typing import Any, NamedTuple | ||
| from zoneinfo import ZoneInfo | ||
|
|
||
|
|
||
| class Column(NamedTuple): | ||
| name: str | ||
| sql: str | ||
| athena_type: str | ||
| value: Any | ||
| text: str | None = None | ||
|
|
||
|
|
||
| _UTC = ZoneInfo("UTC") | ||
| _PLUS_0530 = timezone(timedelta(hours=5, minutes=30)) | ||
|
|
||
| COLUMNS = [ | ||
| Column( | ||
| "ts_p0", | ||
| "CAST('2020-01-02 03:04:05' AS TIMESTAMP(0))", | ||
| "timestamp", | ||
| datetime(2020, 1, 2, 3, 4, 5), | ||
| ), | ||
| Column( | ||
| "ts_p3", | ||
| "TIMESTAMP '2020-01-02 03:04:05.123'", | ||
| "timestamp", | ||
| datetime(2020, 1, 2, 3, 4, 5, 123000), | ||
| ), | ||
| Column( | ||
| "ts_p6", | ||
| "CAST('2020-01-02 03:04:05.123456' AS TIMESTAMP(6))", | ||
| "timestamp", | ||
| datetime(2020, 1, 2, 3, 4, 5, 123456), | ||
| ), | ||
| Column( | ||
| "ts_p9", | ||
| "CAST('2020-01-02 03:04:05.123456789' AS TIMESTAMP(9))", | ||
| "timestamp", | ||
| datetime(2020, 1, 2, 3, 4, 5, 123456), | ||
| ), | ||
| Column( | ||
| "ts_p12", | ||
| "CAST('2020-01-02 03:04:05.123456789012' AS TIMESTAMP(12))", | ||
| "timestamp", | ||
| datetime(2020, 1, 2, 3, 4, 5, 123456), | ||
| ), | ||
| Column( | ||
| "ts_before_epoch", | ||
| "CAST('1969-12-31 23:59:59.987654321' AS TIMESTAMP(9))", | ||
| "timestamp", | ||
| datetime(1969, 12, 31, 23, 59, 59, 987654), | ||
| ), | ||
| Column("ts_min", "TIMESTAMP '0001-01-01 00:00:00.000'", "timestamp", datetime(1, 1, 1)), | ||
| Column( | ||
| "ts_1600", | ||
| "TIMESTAMP '1600-01-02 03:04:05.123'", | ||
| "timestamp", | ||
| datetime(1600, 1, 2, 3, 4, 5, 123000), | ||
| ), | ||
| Column( | ||
| "ts_2300", | ||
| "TIMESTAMP '2300-01-02 03:04:05.123'", | ||
| "timestamp", | ||
| datetime(2300, 1, 2, 3, 4, 5, 123000), | ||
| ), | ||
| Column( | ||
| "ts_max", | ||
| "CAST('9999-12-31 23:59:59.999999999999' AS TIMESTAMP(12))", | ||
| "timestamp", | ||
| datetime(9999, 12, 31, 23, 59, 59, 999999), | ||
| ), | ||
| Column("ts_null", "CAST(NULL AS TIMESTAMP(3))", "timestamp", None), | ||
| Column("date", "DATE '2020-01-02'", "date", date(2020, 1, 2)), | ||
| Column("date_min", "DATE '0001-01-01'", "date", date(1, 1, 1)), | ||
| Column("date_max", "DATE '9999-12-31'", "date", date(9999, 12, 31)), | ||
| Column("date_null", "CAST(NULL AS DATE)", "date", None), | ||
| Column( | ||
| "tstz_utc", | ||
| "TIMESTAMP '2020-01-02 03:04:05.123 UTC'", | ||
| "timestamp with time zone", | ||
| datetime(2020, 1, 2, 3, 4, 5, 123000, tzinfo=_UTC), | ||
| "2020-01-02 03:04:05.123 UTC", | ||
| ), | ||
| Column( | ||
| "tstz_zone", | ||
| "TIMESTAMP '2020-01-02 03:04:05.123 Asia/Tokyo'", | ||
| "timestamp with time zone", | ||
| datetime(2020, 1, 2, 3, 4, 5, 123000, tzinfo=ZoneInfo("Asia/Tokyo")), | ||
| "2020-01-02 03:04:05.123 Asia/Tokyo", | ||
| ), | ||
| Column( | ||
| "tstz_offset", | ||
| "TIMESTAMP '2020-01-02 03:04:05.123 +05:30'", | ||
| "timestamp with time zone", | ||
| datetime(2020, 1, 2, 3, 4, 5, 123000, tzinfo=_PLUS_0530), | ||
| "2020-01-02 03:04:05.123 +05:30", | ||
| ), | ||
| Column( | ||
| "tstz_p12", | ||
| "CAST('2020-01-02 03:04:05.123456789012 UTC' AS TIMESTAMP(12) WITH TIME ZONE)", | ||
| "timestamp with time zone", | ||
| datetime(2020, 1, 2, 3, 4, 5, 123456, tzinfo=_UTC), | ||
| "2020-01-02 03:04:05.123456789012 UTC", | ||
| ), | ||
| Column( | ||
| "tstz_min", | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 2. [P2] Aware timestamps lacked years 1 and 9999 (relayed). A conversion through nanosecond storage would pass with 2020 values only. Repair (66bfa6a): added |
||
| "CAST('0001-01-01 00:00:00.000 UTC' AS TIMESTAMP(3) WITH TIME ZONE)", | ||
| "timestamp with time zone", | ||
| datetime(1, 1, 1, tzinfo=_UTC), | ||
| "0001-01-01 00:00:00.000 UTC", | ||
| ), | ||
| Column( | ||
| "tstz_max", | ||
| "CAST('9999-12-31 23:59:59.999 UTC' AS TIMESTAMP(3) WITH TIME ZONE)", | ||
| "timestamp with time zone", | ||
| datetime(9999, 12, 31, 23, 59, 59, 999000, tzinfo=_UTC), | ||
| "9999-12-31 23:59:59.999 UTC", | ||
| ), | ||
| Column( | ||
| "tstz_null", "CAST(NULL AS TIMESTAMP(3) WITH TIME ZONE)", "timestamp with time zone", None | ||
| ), | ||
| Column("time_p3", "TIME '03:04:05.123'", "time", time(3, 4, 5, 123000), "03:04:05.123"), | ||
| Column( | ||
| "time_p12", | ||
| "CAST('03:04:05.123456789012' AS TIME(12))", | ||
| "time", | ||
| time(3, 4, 5, 123456), | ||
| "03:04:05.123456789012", | ||
| ), | ||
| Column("time_null", "CAST(NULL AS TIME(3))", "time", None), | ||
| Column( | ||
| "time_tz", | ||
| "TIME '03:04:05.123 +05:30'", | ||
| "time with time zone", | ||
| time(3, 4, 5, 123000, tzinfo=_PLUS_0530), | ||
| "03:04:05.123+05:30", | ||
| ), | ||
| Column( | ||
| "time_tz_p12", | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Independent review (relayed). Reviewer: Codex CLI 0.162.1, 1. [P2] Repair (66bfa6a): added
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Independent follow-up on the repair (relayed): CLEAN. Reviewer: Codex CLI 0.162.1, |
||
| "CAST('03:04:05.123456789012 +05:30' AS TIME(12) WITH TIME ZONE)", | ||
| "time with time zone", | ||
| time(3, 4, 5, 123456, tzinfo=_PLUS_0530), | ||
| "03:04:05.123456789012+05:30", | ||
| ), | ||
| Column("time_tz_null", "CAST(NULL AS TIME(3) WITH TIME ZONE)", "time with time zone", None), | ||
| ] | ||
|
|
||
| # UNLOAD writes only TIMESTAMP(3) and date of these types. | ||
| UNLOAD_COLUMNS = [ | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Self-review round two (claims and operations). Head Claims checked against evidence:
AWS operator: 14 queries per run, all on literals (8 SELECTs and 6 UNLOADs). No new fixtures or session resources. |
||
| *(c for c in COLUMNS if c.name in ("ts_p3", "ts_min", "ts_1600", "ts_2300", "ts_null")), | ||
| Column( | ||
| "ts_max", | ||
| "TIMESTAMP '9999-12-31 23:59:59.999'", | ||
| "timestamp", | ||
| datetime(9999, 12, 31, 23, 59, 59, 999000), | ||
| ), | ||
| *(c for c in COLUMNS if c.athena_type == "date"), | ||
| ] | ||
|
|
||
|
|
||
| def columns(unload: bool = False) -> list[Column]: | ||
| """The columns of the query, or of its UNLOAD form.""" | ||
| return UNLOAD_COLUMNS if unload else COLUMNS | ||
|
|
||
|
|
||
| def query(unload: bool = False) -> str: | ||
| """The SELECT query of the columns.""" | ||
| return "SELECT " + ", ".join(f"{c.sql} AS {c.name}" for c in columns(unload)) | ||
|
|
||
|
|
||
| def fetched_row(unload: bool = False) -> tuple[Any, ...]: | ||
| """The row that the fetch methods return.""" | ||
| return tuple(c.value for c in columns(unload)) | ||
|
|
||
|
|
||
| def zones(row: tuple[Any, ...]) -> list[Any]: | ||
| """The time zone of each aware value: a fixed-offset timezone or a ZoneInfo.""" | ||
| return [v.tzinfo for v in row if isinstance(v, datetime | time) and v.tzinfo is not None] | ||
There was a problem hiding this comment.
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, reworked design). Base
18c1eea1(master), head25569f1a065ff9c322eade38529dc1b297fe01f2. Result: FINDINGS. Two findings, repaired in 25569f1.Inventory:
tests/pyathena/temporal.py(columns, UNLOAD subset, query, expected row, zones), andtest_temporal_typesin the five sync cursor test classes._pandas_value()helper.Verified, no change needed:
--runxfail.TIMESTAMP(3)anddate.zones()comparesZoneInfoandtimezoneobjects by equality, sotimezone.utcandZoneInfo('UTC')differ.Nonein fetch and as the container's null in frames.pd.Timestamp == dateis False, so a fetchedTimestampfordatefails as intended.