-
Notifications
You must be signed in to change notification settings - Fork 116
Convert time zone values to aware times and empty TIME/JSON text to NULL #1033
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
f88ba4d
b1b9c4c
824e903
f26016d
82492b2
61f0be7
02ebd27
d1dee80
79fb784
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 |
|---|---|---|
|
|
@@ -9,7 +9,7 @@ | |
| from abc import ABCMeta, abstractmethod | ||
| from collections.abc import Callable | ||
| from copy import deepcopy | ||
| from datetime import date, datetime, time | ||
| from datetime import date, datetime, time, timedelta, timezone | ||
| from decimal import Decimal | ||
| from typing import Any, ClassVar | ||
|
|
||
|
|
@@ -67,25 +67,92 @@ def _to_datetime(varchar_value: str | None) -> datetime | None: | |
| return _parse_datetime(varchar_value) | ||
|
|
||
|
|
||
| _UTC_OFFSET_PATTERN: re.Pattern[str] = re.compile(r"([+-])(\d{2}):(\d{2})") | ||
|
|
||
|
|
||
| def _parse_utc_offset(value: str) -> timezone | None: | ||
|
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 of the #854 fold-in (79fb784, rebased on master 28ec68d): rounds one and two CLEAN The maintainer asked for #854 to be folded in (relayed by another maintainer session). The pyathena commit before the fold-in is the rebased Round one (behavior):
Round two (claims): the PR title, body, release notes, and commit message state the cursor-wide change. The commit message credits @aminghadersohi, and the body has |
||
| """Parse a ``+HH:MM`` or ``-HH:MM`` UTC offset. | ||
|
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 of the whole PR at 79fb784 (relayed): no regressions; 2 pre-existing findings Reviewer: Codex CLI 0.160.0, model Covered (reviewer): typed/untyped array, map, and row parsing; JSON/native formats, nesting, NULLs, empty strings, and keys; converter construction across sync/async/aio cursors; S3 and API fallbacks; pandas engines and chunking; Arrow converter timing; SQLAlchemy JSON handling; docs and test assertions. Findings (both pre-existing, outside this PR):
Reviewer notes on coverage: the precision, numeric-offset, suffix, and JSON-string assertions are meaningful against the base. The Arrow convert-once test guards behavior that the base already had (it caught the rebase leftover at b23cf45). Chunked reads, explicit pandas engines, async/aio variants, and nested fallback table construction are not exercised by the new integration cases. The nested fallback was checked live, as recorded in the earlier replies. |
||
|
|
||
| Args: | ||
| value: The text to parse. | ||
|
|
||
| Returns: | ||
| The fixed-offset time zone, or None if the text is not an offset. | ||
| """ | ||
| match = _UTC_OFFSET_PATTERN.fullmatch(value) | ||
| if not match: | ||
| return None | ||
| sign, hours, minutes = match.groups() | ||
| offset = timedelta(hours=int(hours), minutes=int(minutes)) | ||
| return timezone(-offset if sign == "-" else offset) | ||
|
|
||
|
|
||
| def _to_datetime_with_tz(varchar_value: str | None) -> datetime | None: | ||
| """Convert an Athena TIMESTAMP WITH TIME ZONE value to an aware datetime. | ||
|
|
||
| Args: | ||
| varchar_value: The value as text with a trailing zone name, or None. | ||
| 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, or None. | ||
| """ | ||
| if varchar_value is None: | ||
| if not varchar_value: | ||
| return None | ||
| datetime_, _, tz = varchar_value.rpartition(" ") | ||
| return _parse_datetime(datetime_).replace(tzinfo=gettz(tz)) | ||
| return _parse_datetime(datetime_).replace(tzinfo=_parse_utc_offset(tz) or gettz(tz)) | ||
|
|
||
|
|
||
| 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: | ||
| if varchar_value is None: | ||
| """Convert an Athena TIME value to a time. | ||
|
|
||
| Args: | ||
| varchar_value: The value as text, or None. An empty string, which the Arrow | ||
| CSV reader returns for NULL, is None. | ||
|
|
||
| Returns: | ||
| The time, or None. | ||
| """ | ||
| if not varchar_value: | ||
|
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): FINDINGS (no regressions, 1 pre-existing) Reviewer: Codex CLI 0.160.0, model Covered (reviewer): parser signs, offsets, precision, NULL and empty input; all five cursor families and the async/aio construction paths; S3 CSV/TXT, chunking, the API fallback, and failed/empty states; pandas engine selection; Arrow converter resolution (still per fetched batch); Polars row conversion; SQLAlchemy integration; docs and regression tests. No regressions found. Finding, pre-existing P2, Author verification (live, 2026-10-03, ArrowCursor S3 results):
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. Repair: 44b7f30 (TIME part of the finding)
Validation:
Self-review of the repair: round one (behavior) and round two (claims) found nothing. The docstrings of
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 (relayed): CLEAN for Reviewer: Codex CLI 0.160.0,
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. Repair: 4d51ead (the JSON case reported above; folded in by the maintainer's choice) Measured on master 9a373fd with S3 result files and
Validation:
Self-review of the repair: round one (behavior) found nothing; every
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 (relayed): FINDINGS, 1 regression → repaired in 213c6e0 Reviewer: Codex CLI 0.160.0, Finding, regression P2, Repair: Validation:
Self-review of the repair: round one (behavior) found nothing.
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 (relayed): CLEAN for Reviewer: Codex CLI 0.160.0,
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. Repair: ce31538 (replaces The separate Behavior differences against master's parser (local,
Performance (100-element values, 3 runs): string elements about 164 → 113 µs (no exception fallback), object elements about 196 → 203 µs, numbers and booleans unchanged, and Validation: Self-review of the repair: round one (behavior) found nothing. |
||
| return None | ||
| return datetime.strptime(varchar_value, "%H:%M:%S.%f").time() | ||
| return _parse_time(varchar_value) | ||
|
|
||
|
|
||
| def _to_time_with_tz(varchar_value: str | None) -> time | None: | ||
|
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 one (implementation behavior): CLEAN Base Covered: Checks:
|
||
| """Convert an Athena TIME WITH TIME ZONE value to an aware time. | ||
|
|
||
| Args: | ||
| varchar_value: The value as text with a trailing ``+HH:MM`` or ``-HH:MM`` | ||
| offset, or None. An empty string, which pandas and the Arrow CSV reader | ||
| return for NULL, is None. | ||
|
|
||
| Returns: | ||
| The time with a fixed-offset ``tzinfo``, or None. | ||
| """ | ||
| if not varchar_value: | ||
| return None | ||
| index = max(varchar_value.rfind("+"), varchar_value.rfind("-")) | ||
| return _parse_time(varchar_value[:index]).replace( | ||
| tzinfo=_parse_utc_offset(varchar_value[index:]) | ||
| ) | ||
|
|
||
|
|
||
| def _to_float(varchar_value: str | None) -> float | None: | ||
|
|
@@ -119,17 +186,12 @@ def _to_binary(varchar_value: str | None) -> bytes | None: | |
|
|
||
|
|
||
| def _to_json(varchar_value: str | None) -> Any | None: | ||
| if varchar_value is None: | ||
| return None | ||
| return json.loads(varchar_value) | ||
|
|
||
|
|
||
| def _csv_to_json(varchar_value: str | None) -> Any | None: | ||
| """Convert an Athena JSON value read from a CSV result file. | ||
| """Convert an Athena JSON value to a Python value. | ||
|
|
||
| Args: | ||
| varchar_value: The value as JSON text, or None. An empty string, which | ||
| CSV results use for SQL NULL, is also treated as NULL. | ||
| varchar_value: The JSON text, or None. An empty string, which pandas and the | ||
| Arrow CSV reader return for NULL, is None; Athena never returns empty JSON | ||
| text. | ||
|
|
||
| Returns: | ||
| The decoded value, or None for SQL NULL. | ||
|
|
@@ -494,6 +556,7 @@ def _to_default(varchar_value: str | None) -> str | None: | |
| "timestamp with time zone": _to_datetime_with_tz, | ||
| "date": _to_date, | ||
| "time": _to_time, | ||
| "time with time zone": _to_time_with_tz, | ||
| "varbinary": _to_binary, | ||
| "array": _to_array, | ||
| "map": _to_map, | ||
|
|
@@ -744,12 +807,31 @@ def _parse_type_hint(self, type_hint: str) -> TypeNode: | |
| return self._parsed_hints[normalized] | ||
|
|
||
|
|
||
| def _json_text_converter() -> DefaultTypeConverter: | ||
| """Return a ``DefaultTypeConverter`` that keeps json values as text. | ||
| # The types whose values the Arrow and Polars GetQueryResults fallbacks keep as text, | ||
| # as in a CSV result file, and convert when the rows are fetched. | ||
| _TEXT_VALUE_TYPES: tuple[str, ...] = ("json", "time with time zone", "timestamp with time zone") | ||
|
|
||
|
|
||
| def _text_value_converter() -> DefaultTypeConverter: | ||
| """Return a ``DefaultTypeConverter`` that keeps ``_TEXT_VALUE_TYPES`` values as text. | ||
|
|
||
| Values nested in typed complex values keep only the time zone types as text, | ||
| because Arrow and Polars time and timestamp types hold one time zone per column; | ||
| nested JSON values are decoded as before. | ||
|
|
||
| Returns: | ||
| The converter. | ||
| """ | ||
| converter = DefaultTypeConverter() | ||
| converter.set("json", _to_default) | ||
| for type_ in _TEXT_VALUE_TYPES: | ||
| converter.set(type_, _to_default) | ||
| converter._typed_converter = TypedValueConverter( | ||
| converters={ | ||
| **_DEFAULT_CONVERTERS, | ||
| "time with time zone": _to_default, | ||
| "timestamp with time zone": _to_default, | ||
| }, | ||
| default_converter=_to_default, | ||
| struct_parser=_to_struct, | ||
| ) | ||
| return converter | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,11 +9,13 @@ | |
|
|
||
| from pyathena.converter import ( | ||
| Converter, | ||
| _csv_to_json, | ||
| _to_binary, | ||
| _to_boolean, | ||
| _to_datetime_with_tz, | ||
| _to_decimal, | ||
| _to_default, | ||
| _to_json, | ||
| _to_time_with_tz, | ||
| ) | ||
| from pyathena.util import override | ||
|
|
||
|
|
@@ -24,7 +26,9 @@ | |
| "boolean": _to_boolean, | ||
| "decimal": _to_decimal, | ||
| "varbinary": _to_binary, | ||
| "json": _csv_to_json, | ||
| "json": _to_json, | ||
| "time with time zone": _to_time_with_tz, | ||
|
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, callers, operations): CLEAN Base
|
||
| "timestamp with time zone": _to_datetime_with_tz, | ||
| } | ||
|
|
||
|
|
||
|
|
||
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.
Independent review of the whole PR at b23cf45 (relayed): FINDINGS (1 regression, 2 pre-existing)
Reviewer: Codex CLI 0.160.0, model
gpt-6-astra, reasoning effort high, sandboxread-only, session01a10288-3226-7ff1-9b1e-407de4a0f67a. Static review ofa73c6db4..b23cf45e(the merge-base after the rebase over #1010 and #1031). Snapshot: detached worktree atb23cf45e95507735eb21c1b88271ce6c990b9b39. Afterwards, the snapshot and the PR worktree were clean atb23cf45e.Covered (reviewer): typed/untyped array, map, and row parsing; JSON/native formats, nesting, NULLs, empty strings, and keys; converter construction and customization; sync/async/aio cursor paths; S3 and API fallbacks; pandas engine selection and chunking; Arrow/Polars text preservation; S3FS; SQLAlchemy JSON handling; docs and regression assertions.
Findings and author verification:
pyathena/arrow/result_set.py:279:_fetch()converted every value twice. The rebase left this branch's earlier conversion block after Return Athena JSON values from SQLAlchemy JSON columns and the pandas, Arrow, and S3FS cursors #1010's, and the second pass overwrote the first. Verified: with a counting converter set afterexecute(), master and the fix call it['a', 'b'], and b23cf45 calls it['a', 'b', 'a', 'b']. Fixed in 3cb1343 (Return Athena JSON values from SQLAlchemy JSON columns and the pandas, Arrow, and S3FS cursors #1010's single pass with the renamed helper) with the newtest_fetch_converts_each_value_once.pyathena/parser.py:325: thearray(json)preview heuristic,[1234567890, "a,b"]. Already Complex-value parsing loses values (typed array(json), native arrays) and PandasCursor NULL TIME differs by result storage #1041.pyathena/parser.py:46,:351: native arrays drop empty-string elements. Verified live on master:Cursorreturns['x', 'y']forARRAY['x', '', 'y']and['x']forARRAY['x', ''], with or withoutarray(varchar)hints. Out of scope; reported to the maintainer.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.
Repair: 3cb1343 (finding 1)
_fetch()is #1010's single pass again; the only difference from master is the renamed_text_value_converters. The diff ofpyathena/{arrow,polars,pandas}/result_set.pyandpyathena/result_set.pyagainst master now has only the helper renames, the_TEXT_VALUE_TYPESselection, comments and docstrings, and the pandasparse_dateschange.Validation:
just lintpassed. The newTestArrowCursor::test_fetch_converts_each_value_once(a countingvarcharconverter set afterexecute()) passes, and fails with b23cf45's_fetch()(['a', 'b', 'a', 'b']). 700 related live tests passed (-k "json or complex or null or time or fetch or type_hint or struct or array or map or convert"across the cursor, aio, and SQLAlchemy directories).Self-review of the repair: round one checked the other result sets for leftover pre-rebase blocks (none); round two found no claims needing changes.
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.
Independent follow-up (relayed): CLEAN for
b23cf45e..3cb13439ff8dc21d9ba2a83ef997fc2d616eef69.Reviewer: Codex CLI 0.160.0,
gpt-6-astra, effort high,read-only, session01a10295-5979-74b2-83bd-17df3a3436b6. Static review; afterwards, the snapshot was clean at3cb13439. Covered: the commit, the wholepyathenadiff againsta73c6db4, the conversion paths of the Arrow, Polars, pandas, and base result sets, converter propagation throughexecute(), and the new test. Arrow_fetch()calls each converter once per value on the S3 path, and the fallback converts onlyjsonandtime with time zone. Converter mappings are resolved at fetch time, and no leftover conversion block remains. The test detects both double conversion (4 calls) and stale mappings (0 calls).