Skip to content
Merged
2 changes: 1 addition & 1 deletion docs/pandas.md
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,7 @@ Common performance options:
- `dtype`: Explicit column data types
- `parse_dates`: Columns to parse as dates

With `engine="pyarrow"`, PandasCursor uses the PyArrow engine only when pyarrow is installed, no chunksize is set (explicitly or by `auto_optimize_chunksize`), `quoting` is the default, the result has no columns that need a converter (`boolean`, `decimal`, `varbinary`, and `json` with the default converter), and the result file is at least `AthenaPandasResultSet.PYARROW_MIN_FILE_SIZE_BYTES` bytes.
With `engine="pyarrow"`, PandasCursor uses the PyArrow engine only when pyarrow is installed, no chunksize is set (explicitly or by `auto_optimize_chunksize`), `quoting` is the default, the result has no columns that need a converter (`boolean`, `decimal`, `varbinary`, `json`, `time with time zone`, and `timestamp with time zone` with the default converter), and the result file is at least `AthenaPandasResultSet.PYARROW_MIN_FILE_SIZE_BYTES` bytes.
Otherwise, it falls back to the C engine.

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.
Expand Down
1 change: 1 addition & 0 deletions docs/s3fs.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ The following type mappings are used:
| timestamp | datetime.datetime |
| timestamp with time zone | datetime.datetime (timezone-aware) |
| time | datetime.time |
| time with time zone | datetime.time (timezone-aware) |
| varbinary | bytes |
| array, map, row (struct) | Parsed into Python list/dict (see {ref}`usage-type-hints` for the types of nested values); values too complex to parse are returned as the original string |
| json | Parsed JSON value (dict, list, or scalar) |
Expand Down
10 changes: 8 additions & 2 deletions pyathena/arrow/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@

from pyathena.converter import (
Converter,
_csv_to_json,
_to_binary,
_to_date,
_to_datetime_with_tz,
_to_decimal,
_to_default,
_to_json,
_to_time,
_to_time_with_tz,
)
from pyathena.util import override

Expand All @@ -24,9 +26,11 @@
_DEFAULT_ARROW_CONVERTERS: dict[str, Callable[[str | None], Any | None]] = {
"date": _to_date,
"time": _to_time,
"time with time zone": _to_time_with_tz,
"timestamp with time zone": _to_datetime_with_tz,
"decimal": _to_decimal,
"varbinary": _to_binary,
"json": _csv_to_json,
"json": _to_json,
}


Expand Down Expand Up @@ -85,6 +89,8 @@ def _dtypes(self) -> dict[str, type[Any]]:
"timestamp": pa.timestamp("ms"),
"date": pa.timestamp("ms"),
"time": pa.string(),
"time with time zone": pa.string(),
"timestamp with time zone": pa.string(),
"varbinary": pa.string(),
"array": pa.string(),
"map": pa.string(),
Expand Down
16 changes: 10 additions & 6 deletions pyathena/arrow/result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from pyathena import OperationalError
from pyathena.arrow.util import to_column_info
from pyathena.converter import Converter, _json_text_converter, _to_default
from pyathena.converter import Converter, _text_value_converter, _to_default
from pyathena.error import ProgrammingError
from pyathena.model import AthenaQueryExecution
from pyathena.result_set import AthenaResultSet
Expand Down Expand Up @@ -147,7 +147,8 @@ def __init__(

self._table = pa.Table.from_pydict({})
# The fetch methods convert the values read from a result file. GetQueryResults
# values are already converted, except json values, which stay text.
# values are already converted, except json and time with time zone values,
# which stay text.
self._convert_rows = bool(self.output_location)
self._batches = iter(self._table.to_batches(arraysize))

Expand Down Expand Up @@ -255,7 +256,9 @@ def _fetch(self) -> None:
else:
dict_rows = rows.to_pydict()
converters = (
self.converters if self._convert_rows else self._json_converters(self.converters)
self.converters
if self._convert_rows
else self._text_value_converters(self.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 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, sandbox read-only, session 01a10288-3226-7ff1-9b1e-407de4a0f67a. Static review of a73c6db4..b23cf45e (the merge-base after the rebase over #1010 and #1031). Snapshot: detached worktree at b23cf45e95507735eb21c1b88271ce6c990b9b39. Afterwards, the snapshot and the PR worktree were clean at b23cf45e.

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:

  1. Regression, P2 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 after execute(), 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 new test_fetch_converts_each_value_once.
  2. Pre-existing, P2 pyathena/parser.py:325: the array(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.
  3. Pre-existing, P2 pyathena/parser.py:46, :351: native arrays drop empty-string elements. Verified live on master: Cursor returns ['x', 'y'] for ARRAY['x', '', 'y'] and ['x'] for ARRAY['x', ''], with or without array(varchar) hints. Out of scope; reported to the maintainer.

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.

Repair: 3cb1343 (finding 1)

_fetch() is #1010's single pass again; the only difference from master is the renamed _text_value_converters. The diff of pyathena/{arrow,polars,pandas}/result_set.py and pyathena/result_set.py against master now has only the helper renames, the _TEXT_VALUE_TYPES selection, comments and docstrings, and the pandas parse_dates change.

Validation: just lint passed. The new TestArrowCursor::test_fetch_converts_each_value_once (a counting varchar converter set after execute()) 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.

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): CLEAN for b23cf45e..3cb13439ff8dc21d9ba2a83ef997fc2d616eef69.

Reviewer: Codex CLI 0.160.0, gpt-6-astra, effort high, read-only, session 01a10295-5979-74b2-83bd-17df3a3436b6. Static review; afterwards, the snapshot was clean at 3cb13439. Covered: the commit, the whole pyathena diff against a73c6db4, the conversion paths of the Arrow, Polars, pandas, and base result sets, converter propagation through execute(), and the new test. Arrow _fetch() calls each converter once per value on the S3 path, and the fallback converts only json and time 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).

)
if converters:
column_names = dict_rows.keys()
Expand Down Expand Up @@ -387,12 +390,13 @@ def _as_arrow_from_api(self, converter: Converter | None = None) -> Table:

Args:
converter: Type converter for result values. Defaults to
``DefaultTypeConverter`` with json values kept as text, as in
the CSV result file.
``DefaultTypeConverter`` with json and time with time zone values kept as
text, as in the CSV result file. Arrow has no type for JSON values or for
times with a time zone.
"""
import pyarrow as pa

rows = self._fetch_all_rows(converter or _json_text_converter())
rows = self._fetch_all_rows(converter or _text_value_converter())
if not rows:
return pa.Table.from_pydict({})
description = self.description if self.description else []
Expand Down
118 changes: 100 additions & 18 deletions pyathena/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:

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

Round one (behavior):

  • _parse_utc_offset() matches only [+-]HH:MM (a full match), so UTC, America/New_York, and other zone names still go to gettz(). _to_time_with_tz() reuses it, so the time and timestamp offset parsing are the same code.
  • _to_datetime_with_tz('') is None. Athena never returns empty TIMESTAMP text, and pandas/Arrow return '' for NULL.
  • Arrow/Polars: timestamp with time zone is read as a string (pa.string() / pl.String) and converted when fetching, as time with time zone is. The fallback keeps it as text (_TEXT_VALUE_TYPES), nested values too (_text_value_converter()).
  • pandas: removed from _PARSE_DATES and mapped in _DEFAULT_PANDAS_CONVERTERS, so a converter column also disables the automatic pyarrow engine (docs updated).
  • UNLOAD rejects timestamp with time zone (measured), so the Parquet converters need nothing.
  • Regression coverage: the shared CONVERTED_VALUES_QUERY adds +05:30, -08:00, America/New_York, and NULL. All 10 test_fetch_all_rows cases fail with the source changes reverted, and so do 4 cases of the new unit test.

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 Closes #854. Measured claims: master's naive offsets, strings, Arrow zone loss, and Polars TypeError (tzfile(...) is not a fixed offset timezone) come from the live matrix run on master 28ec68d, and this branch gives aware datetimes in all 30 cursor × mode × query cases. No findings.

"""Parse a ``+HH:MM`` or ``-HH:MM`` UTC offset.

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 of the whole PR at 79fb784 (relayed): no regressions; 2 pre-existing findings

Reviewer: Codex CLI 0.160.0, model gpt-6-astra, reasoning effort high, sandbox read-only, session 01a102a7-0d36-79a3-bafa-4fc8366fb3aa. Static review of 28ec68d9..79fb7848 (full scope after the #854 fold-in and the rebase). Snapshot: detached worktree at 79fb784848701c674bb537c96624fba9f54614c6. Afterwards, the snapshot and the PR worktree were clean at 79fb7848.

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

  1. P2 pyathena/parser.py:326: the array(json) preview heuristic sends [1234567890, "a,b"] to the native splitter. Already Complex-value parsing loses values (typed array(json), native arrays) and PandasCursor NULL TIME differs by result storage #1041.
  2. P2 pyathena/parser.py:351, :46: native arrays drop empty-string elements. Verified live on master earlier (ARRAY['x', '', 'y'] → ['x', 'y']); reported to the maintainer.

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:

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 (no regressions, 1 pre-existing)

Reviewer: Codex CLI 0.160.0, model gpt-6-astra, reasoning effort high, sandbox read-only, session 01a1022b-eae4-7663-8219-5ec79f0666a4. Static review only. Snapshot: detached worktree at head f0626671c39648936cb0e408e9daf05ada2cda1d, base 9a373fd3eb4b4486ea5a1eece70b7886740b3683. The prompt contained the literal diff and the intended behavior only. Afterwards, the snapshot and the PR worktree were clean at f0626671.

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, pyathena/converter.py:111 with pyathena/arrow/result_set.py:337: the Arrow CSV reader keeps non-binary string NULLs as "" (documented in docs/null_handling.md), so _to_time("") raises for a NULL TIME in ArrowCursor S3 results.

Author verification (live, 2026-10-03, ArrowCursor S3 results): SELECT 1 AS id, CAST(NULL AS TIME) AS t raises ValueError on master 9a373fd and on f0626671. In the same measurement, a NULL TIME WITH TIME ZONE, DECIMAL, or DATE gives (1, None), and a NULL JSON also raises (JSONDecodeError from _to_json("")). Action: fixed for TIME in this PR, because _to_time is rewritten here. The JSON case is outside this PR and is reported to the maintainer separately.

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.

Repair: 44b7f30 (TIME part of the finding)

_to_time() now returns None for an empty string, as _to_decimal(), _to_boolean(), and _to_time_with_tz() do. Athena never returns an empty TIME text, and DefaultTypeConverter gets None for NULL from GetQueryResults, so only the Arrow CSV path changes.

Validation:

  • just lint passed.
  • test_to_time_any_precision gains ("", None): 127 offline converter tests passed.
  • TestArrowCursor::test_fetch_all_rows adds CAST(NULL AS TIME) AS col_time_null, expecting None. Both default and managed pass. With the converter.py change reverted, default fails and managed passes (the fallback was already None).

Self-review of the repair: round one (behavior) and round two (claims) found nothing. The docstrings of _to_time and _to_time_with_tz name both empty-string sources (pandas read_csv converters, measured offline; the Arrow CSV reader, documented in docs/null_handling.md and measured live).

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): CLEAN for f0626671..44b7f30feba01c7752163d483f23ab81356499cc.

Reviewer: Codex CLI 0.160.0, gpt-6-astra, effort high, read-only, session 01a10233-16e5-7a00-813d-d86f54e62579. Static review; afterwards, the snapshot was clean at 44b7f30f. Covered: DefaultTypeConverter (typed complex values, tuple/dict results, sync, threaded async, and aio), S3FS (both CSV readers and the fallback), Arrow CSV with and without binary columns plus the fallback and UNLOAD, Polars eager and chunked CSV plus the fallback and UNLOAD, pandas' separate TIME handling, the docstrings, and the tests. The guard fixes the "" case. None and every nonempty TIME text behave as before. The Arrow test reaches the failing path. No regressions.

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.

Repair: 4d51ead (the JSON case reported above; folded in by the maintainer's choice)

Measured on master 9a373fd with S3 result files and SELECT 1 AS id, CAST(NULL AS JSON) AS j: PandasCursor.execute() raised OperationalError (from _to_json('') in the read_csv converter), ArrowCursor fetches raised JSONDecodeError, and Cursor, PolarsCursor, and S3FSCursor returned None.

_to_json() now returns None for '' and gained a docstring. Athena never returns empty JSON text: an empty JSON string is "", which still decodes to '' (unit-tested).

Validation:

  • just lint passed.
  • New test_to_json; 137 offline converter tests passed.
  • The shared test query (renamed CONVERTED_VALUES_QUERY/CONVERTED_VALUES_ROW) adds CAST(NULL AS JSON). All 10 test_fetch_all_rows cases pass. With only this commit's converter.py change reverted, pandas[default] and arrow[default] fail.
  • -k "json or complex or null or time or fetch_all" across the cursor, converter, pandas, Arrow, Polars, S3FS, aio, and SQLAlchemy test directories: 340 passed.

Self-review of the repair: round one (behavior) found nothing; every _to_json mapping (default, Arrow, Polars, pandas) gets None for NULL. Round two (claims) found nothing; the PR title, body, and release notes now include the JSON fix.

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): FINDINGS, 1 regression → repaired in 213c6e0

Reviewer: Codex CLI 0.160.0, gpt-6-astra, effort high, read-only, session 01a10241-af07-78a3-859e-3dbe0d3c0895. Static review of 44b7f30f..4d51eadf; afterwards, the snapshot was clean at 4d51eadf. Covered: the default, Arrow, Polars, pandas, and S3FS converters; untyped and typed array/map/struct helpers; SQLAlchemy JSON handling; the docstring, tests, and constant references (no stale TIME_VALUES_*).

Finding, regression P2, pyathena/converter.py:179: DefaultTypeConverter().convert("array", '[""]', type_hint="array(json)") returned [""] before and [None] after 4d51ead. The typed parser passes the decoded element through _to_json_str to _to_json (pyathena/parser.py:285, :327) and relied on JSONDecodeError to fall back to the native format. Verified locally: 44b7f30 gives [''], and 4d51ead gives [None] and {'k': None} for map(varchar,json).

Repair: _to_json() keeps its previous behavior (None only), so the default, S3FS, Polars, and typed complex paths are unchanged. The pandas and Arrow converters, the two that read CSV result files where '' means NULL, map json to the new _to_json_from_file(), which treats '' as None.

Validation:

  • just lint passed.
  • New test_to_json_empty_string (only _to_json_from_file maps '' to None; _to_json('') still raises) and test_typed_json_empty_string_element (array(json) and map(varchar,json) keep ""): 139 offline converter tests passed.
  • Live: all 10 test_fetch_all_rows cases (NULL JSON in pandas and Arrow default) pass. -k "json or complex or null or time or fetch_all" across the cursor, pandas, Arrow, Polars, S3FS, aio, and SQLAlchemy test directories: 280 passed.

Self-review of the repair: round one (behavior) found nothing. _to_json_from_file is used only by converters whose input is CSV text from a result file. Polars reads NULL as null, not '' (its NULL JSON case passes with _to_json). Round two (claims): the PR body is updated to name _to_json_from_file.

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): CLEAN for 4d51eadf..213c6e0d4a5d55448c0b56de3ce0b1f75eaddf41.

Reviewer: Codex CLI 0.160.0, gpt-6-astra, effort high, read-only, session 01a10248-23be-72c1-adff-ec5a8f215feb. Static review; afterwards, the snapshot was clean at 213c6e0d. Covered: the typed array/map fallback paths, pandas and Arrow CSV conversion, the default/S3FS/Polars mappings, the docstrings, and the regression tests. Typed empty JSON strings are preserved, pandas and Arrow still turn empty CSV JSON fields into None, and the default, S3FS, and Polars converters keep the strict _to_json.

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.

Repair: ce31538 (replaces _to_json_from_file; maintainer chose this design after a benchmark)

The separate _to_json_from_file() existed only because the typed complex-value parser relied on _to_json("") raising. The parser now encodes JSON-typed elements with json.dumps() (_to_json_str(value, type_node)), so _to_json() treats '' as NULL like the other converters, and the pandas and Arrow mappings use _to_json again.

Behavior differences against master's parser (local, DefaultTypeConverter().convert(..., type_hint=...)):

  • array(json) '["{\"a\": 1}", "123"]': [{'a': 1}, 123] → ['{"a": 1}', '123']. Measured with Athena: ARRAY[CAST('{"a": 1}' AS JSON), CAST('123' AS JSON)] renders ["{\"a\": 1}", "123"] (JSON string scalars). The same values in map(varchar,json) and row(a json, b json) (native format) already gave strings on master.
  • row(a json, b json) with JSON-format text ({"a": "x", "b": {"c": 1}}, {"a": "", ...}, {"a": true, ...}): JSONDecodeError → {'a': 'x', 'b': {'c': 1}} and so on.
  • Unchanged: array(json) of objects, numbers, booleans, null, "", and mixed values; map(varchar,json); array(varchar); map(varchar,varchar).

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 _to_json about 0.67 µs either way.

Validation: just lint passed. The new test_typed_json_elements and test_to_json cases plus test_parser.py and the converter tests: 174 passed offline; 4 new cases fail with the commit's source reverted. Live: 558 related tests passed (json, complex, null, time, fetch_all, type_hint, struct, array, map across the cursor, aio, and SQLAlchemy directories).

Self-review of the repair: round one (behavior) found nothing. _to_json_str has no other callers, and only JSON-typed elements change. Round two (claims) found nothing. The PR body and release notes now describe the array(json) behavior change and the row(json) fix. Because the parser contract changed, the next independent review covers the whole PR.

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:

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 9a373fd3eb4b4486ea5a1eece70b7886740b3683, head f0626671c39648936cb0e408e9daf05ada2cda1d.

Covered: _parse_time, _to_time, and _to_time_with_tz; the mappings in the default, S3FS (deepcopy of _DEFAULT_CONVERTERS), Arrow, Polars, and pandas converters, and the Arrow/Polars CSV column types; AthenaPandasResultSet (_PARSE_DATES, _time_columns, _trunc_date, the converters passed to read_csv, pyarrow engine selection, chunked reads, and the fallback); AthenaArrowResultSet (_fetch and _as_arrow_from_api); AthenaPolarsResultSet (_df_converters, the chunked CSV converters, and _as_polars_from_api); the sync/async/aio cursors (shared result sets and converters).

Checks:

  • _to_time_with_tz: the time part never contains + or -, so the last sign marks the offset. Athena always appends ±HH:MM (measured for p = 0..12 and current_time).
  • pandas: read_csv passes '' for NULL to converters (verified offline), and _to_time_with_tz('') is None. A converter column disables the automatic pyarrow engine (_get_csv_engine requires not self.converters), so an explicit engine="pyarrow" cannot leave the column unconverted.
  • Arrow fetch: the S3 paths still resolve self.converters per batch (the Do not convert ArrowCursor fallback values twice #1023 contract). The fallback resolves only the time-with-tz converters, and converters.get(k) leaves other fallback columns unchanged.
  • Polars: _df_converters for the fallback holds only the time-with-tz converters. The S3 non-chunked and chunked paths are unchanged apart from the new mapping and pl.String dtype.
  • UNLOAD rejects time with time zone (measured), so the Parquet paths need nothing.
  • Regression coverage: all 10 test_fetch_all_rows cases fail with the source reverted.

"""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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
8 changes: 6 additions & 2 deletions pyathena/pandas/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,

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, operations): CLEAN

Base 9a373fd3eb4b4486ea5a1eece70b7886740b3683, head f0626671c39648936cb0e408e9daf05ada2cda1d. Full pass over the PR body, the commit message, the docstrings, the comments, the docs, #1018, and #1030.

  • ".dt.time dropped the pandas offset; different offsets stayed strings and .dt raised AttributeError; NULL was NaT": measured live on master 9a373fd with PandasCursor. VALUES (+09:00), (-05:30) raised AttributeError: Can only use .dt accessor with datetimelike values (after pandas' dateutil UserWarning), and VALUES (+09:00), (NULL) gave [(time(12, 0),), (NaT,)]. On this head, aware times and None.
  • "Arrow and Polars time types drop the offset": measured offline (pa.table gives time64[us], pl.DataFrame gives Time, both naive).
  • "UNLOAD does not support time with time zone": measured (NOT_SUPPORTED: Unsupported Hive type: time(3) with time zone).
  • "as _parse_datetime() does (Support SQLAlchemy datetime literals and microsecond timestamps #818)": both truncate digits beyond six with fraction[:6].ljust(6, "0").
  • Docs: docs/pandas.md now lists the converter types for the pyarrow-engine condition, including time with time zone. docs/s3fs.md gains the table row. No other doc lists time conversions (grep for time with time zone and datetime.time).
  • Callers: the time and time with time zone return types change for Cursor and S3FSCursor users, as stated in the release notes. pyathena.TIME (the DB API type object) already covers both types. No AWS request changes.

"timestamp with time zone": _to_datetime_with_tz,
}


Expand Down
6 changes: 1 addition & 5 deletions pyathena/pandas/result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,9 +257,7 @@ class AthenaPandasResultSet(AthenaResultSet):
_PARSE_DATES: ClassVar[list[str]] = [
"date",
"time",
"time with time zone",
"timestamp",
"timestamp with time zone",
]

def __init__(
Expand Down Expand Up @@ -343,9 +341,7 @@ def __init__(

# Cache time column names for efficient _trunc_date processing
description = self.description if self.description else []
self._time_columns: list[str] = [
d[0] for d in description if d[1] in ("time", "time with time zone")
]
self._time_columns: list[str] = [d[0] for d in description if d[1] == "time"]

import pandas as pd

Expand Down
28 changes: 19 additions & 9 deletions pyathena/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,11 @@ def parse(self, type_str: str) -> TypeNode:
return TypeNode(type_name=type_name, children=[key_type, value_type])
return TypeNode(type_name=type_name)

# Types with parameters like decimal(10, 2), varchar(255)
# Types with parameters like decimal(10, 2), varchar(255), or
# time(3) with time zone, whose suffix is part of the type name.
# Other trailing text is ignored.
if " ".join(type_str[close_idx + 1 :].lower().split()) == "with time zone":
type_name = f"{type_name} with time zone"
return TypeNode(type_name=type_name)

def _split_type_args(self, s: str) -> list[str]:
Expand Down Expand Up @@ -268,19 +272,21 @@ def convert(self, value: str, type_node: TypeNode) -> Any:
return converter_fn(value)

@staticmethod
def _to_json_str(value: Any) -> str:
def _to_json_str(value: Any, type_node: TypeNode) -> str:
"""Convert a JSON-parsed value back to a string for further conversion.

Uses json.dumps for dict/list to produce valid JSON, and str() for
scalar types to produce converter-compatible strings.
Uses json.dumps for dict/list values and for every value of a JSON type,
so that the JSON converter decodes the original JSON text, and str() for
the other scalar types to produce converter-compatible strings.

Args:
value: A value from json.loads output.
type_node: The type of the value.

Returns:
String representation suitable for type conversion.
"""
if isinstance(value, (dict, list)):
if isinstance(value, (dict, list)) or type_node.type_name == "json":
return json.dumps(value)
return str(value)

Expand Down Expand Up @@ -324,7 +330,7 @@ def _convert_typed_array(self, value: str, type_node: TypeNode) -> list[Any] | N
return [
None
if elem is None
else self.convert(self._to_json_str(elem), element_type)
else self.convert(self._to_json_str(elem, element_type), element_type)
for elem in parsed
]
except json.JSONDecodeError:
Expand Down Expand Up @@ -379,8 +385,12 @@ def _convert_typed_map(self, value: str, type_node: TypeNode) -> dict[str, Any]
parsed = json.loads(value)
if isinstance(parsed, dict):
return {
str(self.convert(self._to_json_str(k), key_type) if k is not None else k): (
self.convert(self._to_json_str(v), value_type)
str(
self.convert(self._to_json_str(k, key_type), key_type)
if k is not None
else k
): (
self.convert(self._to_json_str(v, value_type), value_type)
if v is not None
else None
)
Expand Down Expand Up @@ -447,7 +457,7 @@ def _convert_typed_struct(self, value: str, type_node: TypeNode) -> dict[str, An
for i, (k, v) in enumerate(parsed.items()):
ft = self._get_field_type(k, type_node, i)
result[k] = (
self.convert(self._to_json_str(v), ft) if v is not None else None
self.convert(self._to_json_str(v, ft), ft) if v is not None else None
)
return result
except json.JSONDecodeError:
Expand Down
Loading
Loading