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
53 changes: 37 additions & 16 deletions pyathena/pandas/result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,17 +343,27 @@ def __init__(
d[0] for d in description if d[1] in ("time", "time with time zone")
]

import pandas as pd

# The whole result when it was not read in chunks.
self._df: DataFrame | None = None

@laughingman7743 laughingman7743 Oct 3, 2026 •

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 a18ebdaa80c0c85466eacbdb3eb3df76d7ed135a, head f41ca3c05cf61338ab2ab408541efdb6cf13b9fc.

Covered: AthenaPandasResultSet / AthenaPolarsResultSet construction for the S3 output, GetQueryResults fallback, and failed-state paths; as_pandas(), as_polars(), as_arrow(), iter_chunks(), close(); the sync/async/aio cursor wrappers (no subclasses; PandasCursor.iter_chunks() closes the iterator it receives, which is now a fresh one in non-chunked mode); explicit chunksize and auto_optimize_chunksize chunked paths (unchanged single-pass); the two new tests.

Checks:

  • Hypothesis: eager _trunc_date in __init__ could now raise on results that were never read. Refuted live: zero-row time, all-NULL time, and mixed time/NULL results convert identically on master and this head (master only differs in returning [] from fetchall() after as_pandas(), which is the bug).
  • Mutation isolation verified offline (pandas 3.0.6 CoW, polars 1.44.2): without the shallow copy / clone, df["a"] = 0, in-place rename, and df[0, "a"] = 0 leaked into fetched rows; with them, they do not.
  • Both new tests fail on the original code.

Limitation (not a defect): in non-chunked mode the result set now holds the DataFrame until close() or the next execute(), as before v2.13.0 for pandas, before v3.24.0 for Polars, and like ArrowCursor. Previously a fetch-only caller released it once the single chunk was exhausted.

if self.state == AthenaQueryExecution.STATE_SUCCEEDED and self.output_location:
df = self._as_pandas()
result = self._as_pandas()
trunc_date = _no_trunc_date if self.is_unload else self._trunc_date
self._df_iter = PandasDataFrameIterator(df, trunc_date, self._csv_stream)
if isinstance(result, pd.DataFrame):
self._df = trunc_date(result)
else:
self._df_iter = PandasDataFrameIterator(result, trunc_date, self._csv_stream)
elif self.state == AthenaQueryExecution.STATE_SUCCEEDED:
df = self._as_pandas_from_api()
self._df_iter = PandasDataFrameIterator(df, self._trunc_date)
# GetQueryResults values are already converted and need no time truncation.
self._df = self._as_pandas_from_api()
else:
import pandas as pd

self._df_iter = PandasDataFrameIterator(pd.DataFrame(), _no_trunc_date)
self._df = pd.DataFrame()
if self._df is not None:
# A shallow copy keeps assignments to the DataFrame from as_pandas()
# out of the rows that the fetch methods return. Mutable values in its
# cells, such as lists from JSON columns, are still shared.
self._df_iter = PandasDataFrameIterator(self._df.copy(deep=False), _no_trunc_date)

@laughingman7743 laughingman7743 Oct 3, 2026 •

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

Reviewer: Codex CLI 0.160.0, model gpt-6-astra, reasoning effort high, sandbox read-only, session 01a1015d-691c-71d1-8c1a-e2e76abd7851. Static review only (no builds, tests, or network). Snapshot: detached worktree at head a26c7633d81164fed18507525713ecb950385fc4, base a18ebdaa80c0c85466eacbdb3eb3df76d7ed135a. The prompt contained the literal diff and the intended behavior, without the PR number, description, commits, or prior findings. Afterwards, both the snapshot and the PR worktree were clean and still at a26c7633.

Covered (reviewer): S3 CSV, UNLOAD Parquet, API fallback, empty and non-SUCCEEDED construction; whole-result access, explicit/automatic chunking, fetch positions, close/reset, CSV streams and readers; mutation isolation, memory retention, pandas time truncation, docs; sync, future-based async, and aio wrappers; both new tests.

Findings and author verification:

  1. Diff-related, P2 pyathena/pandas/result_set.py:364: the shallow copy does not isolate mutable cell contents. With CAST(ARRAY[1, 2] AS JSON), df.at[0, "j"].append(3) changes the later fetchone(). Verified (object cells are shared Python objects). Action: narrow the comment and the PR body to assignments to the DataFrame. A deep copy would double the memory.
  2. Pre-existing, made worse by the diff, P2 pyathena/pandas/result_set.py:358: in the GetQueryResults fallback, TIME values are already datetime.time, so _trunc_date's .dt.time raises AttributeError. Verified live with the managed work group: master raises it on fetchone(), while this head raises it in execute(). Action: fix in this PR.
  3. Pre-existing, P2 pyathena/polars/result_set.py:138: the fallback DataFrame already holds converted values, and iterrows() converts them again. Verified live: TIME fetchone() raises TypeError: strptime() argument 1 must be str, not datetime.time on both master and head. Action (maintainer chose to fold in): fix in this PR.
  4. Pre-existing, P2 pyathena/polars/result_set.py:359: chunked UNLOAD keeps the UNLOAD command metadata. Verified live: unload=True, chunksize=10 gives description [('rows', 'bigint', ...)] and fetchone() (None,); without chunksize, (1, 'x'). Action (maintainer): separate issue.
  5. Pre-existing, P2 pyathena/polars/result_set.py:711: close() replaces _df_iter without closing it (pandas closes it). Verified by reading the code. Action (maintainer chose to fold in): fix in this PR.

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: c857dd3 (on top of a26c763; same base a18ebda)

  • Finding 1: narrowed both comments ("assignments to the DataFrame"; pandas notes that mutable cell values such as JSON lists stay shared) and the PR body. No deep copy.
  • Finding 2: the pandas GetQueryResults fallback no longer applies _trunc_date. DefaultTypeConverter already returns datetime.time for time. time with time zone stays a string, as in Cursor; the old .dt.time also raised on it.
  • Finding 3: the Polars fallback uses no row converters (_df_converters = {}), for the fetch iterator and for iter_chunks().
  • Finding 4: filed as PolarsCursor with unload=True and chunksize returns the UNLOAD statement's metadata and None rows #1007.
  • Finding 5: AthenaPolarsResultSet.close() calls self._df_iter.close() before replacing it. For a whole-result iterator, close() is a no-op; a chunk generator gets GeneratorExit, which the except Exception in _iter_*_chunks does not catch.

Validation: just lint passed. test_fetch_all_rows[managed] (pandas and Polars) now selects a TIME column, and the new test_close_stops_chunks covers finding 5. With the source changes of c857dd3 reverted, these 3 fail; with them, they pass. The related pandas, Polars, and aio subsets: 177 passed.

Self-review of the repair (round one, behavior): no findings. The fallback DataFrame types are unchanged, and only the fetch conversion and the truncation differ. Round two (claims): the commit message, comments, and PR body were rechecked against the measured errors (AttributeError in execute() on a26c763 and in fetchone() on master; TypeError from strptime()); no findings.

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 review (relayed): no regressions; 2 pre-existing findings

Reviewer: Codex CLI 0.160.0, gpt-6-astra, effort high, read-only, session 01a1016a-31a5-7862-ac29-da0c5dd01643. Static review of a26c7633..c857dd3e06a2d5a466e2997f110ea533a2267db5 (range-diff: earlier commits unchanged) traced into both result sets, the cursor wrappers, _fetch_all_rows, and the converters. Afterwards, the snapshot and the PR worktree were clean at c857dd3e.

The reviewer resolved all four earlier comments: assignment isolation is documented with the shared mutable cells; fallback TIME bypasses .dt.time (also with chunksize); Polars fallback rows are not converted again, for fetch and iter_chunks().iterrows(); and the stored CSV/Parquet chunk generators are closed. By source tracing, the managed TIME tests and test_close_stops_chunks fail without the fix.

Pre-existing findings and author disposition:

  1. P2 pyathena/converter.py:481: DefaultTypeConverter has no time with time zone mapping, so fallback values stay strings. That is the existing Cursor behavior, out of scope. It did show that the new comment at pyathena/pandas/result_set.py:358 ("time columns hold times") was too broad. Repaired in 7ce2bf4 (comment only: "already converted and need no time truncation"). just lint passed.
  2. P3 pyathena/polars/result_set.py:124: PolarsDataFrameIterator.close() does nothing for a whole-DataFrame reader, unlike the pandas iterator. Deferred: this is pre-existing, the iterator only holds a DataFrame reference, and AthenaPolarsResultSet.close() replaces the iterator anyway.

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 c857dd3e..7ce2bf4ca262003d4b3708a77161a7c03c0382e6 (comment only).

Reviewer: Codex CLI 0.160.0, gpt-6-astra, effort high, read-only, session 01a1016e-7848-7901-ac2a-c2eb4a17074f. Result: the comment at pyathena/pandas/result_set.py:358 is accurate. With no output location, DefaultTypeConverter turns time into datetime.time and leaves time with time zone as a string; neither needs _trunc_date(). Static review only.

self._iterrows = self._df_iter.iterrows()

def _get_parquet_engine(self) -> str:
Expand Down Expand Up @@ -842,22 +852,30 @@ def as_pandas(self) -> PandasDataFrameIterator | DataFrame:
"""Return the query results as a DataFrame or an iterator of DataFrame chunks.

Returns:
If ``chunksize`` is None, one DataFrame that joins the chunks the result
iterator has not yet yielded (read in chunks when ``auto_optimize_chunksize``
chose a chunk size), which is the whole result unless rows were already
fetched; otherwise the ``PandasDataFrameIterator`` that yields DataFrame chunks.
If ``chunksize`` is None, the DataFrame of the whole result, the same one
on every call. When ``auto_optimize_chunksize`` chose a chunk size, one
DataFrame that joins the chunks the result iterator has not yet yielded,
which is the whole result only if neither the fetch methods nor
``iter_chunks()`` read from it before, and a later call returns an empty
DataFrame. If ``chunksize`` is set, the iterator that ``iter_chunks()``
returns.
"""
if self._chunksize is None:
if self._df is not None:
return self._df
return self._df_iter.as_pandas()
return self._df_iter
return self.iter_chunks()

def iter_chunks(self) -> PandasDataFrameIterator:
"""Iterate over result chunks as pandas DataFrames.

This method provides an iterator interface for processing large result sets.
When chunksize is specified, or ``auto_optimize_chunksize`` chose a chunk size
for a large CSV result, it yields DataFrames in chunks for memory-efficient
processing. Otherwise, it yields the entire result as a single DataFrame.
When a CSV result is read in chunks, because chunksize is specified or

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): FINDINGS → repaired in a26c763

Base a18ebdaa80c0c85466eacbdb3eb3df76d7ed135a, head f41ca3c05cf61338ab2ab408541efdb6cf13b9fc. Full pass over the PR body, the commit message, and the changed docstrings and comments.

Claims checked:

  • "pandas since v2.13.0; Polars since v3.24.0": git tag --contains puts d5cb976 first in v2.13.0 and 6f7addc first in v3.24.0. Before v3.24.0, as_polars() returned self._df.
  • Shallow-copy isolation relies on Copy-on-Write: pyproject.toml requires pandas>=3.0.0 (CoW is always on) and polars>=1.39.0; verified offline in round one.
  • "async/aio variants share the result sets": pandas/async_cursor.py, polars/async_cursor.py, and aio/{pandas,polars}/cursor.py construct AthenaPandasResultSet / AthenaPolarsResultSet.
  • PandasCursor.iter_chunks() closes its iterator (with result_set.iter_chunks() as chunks); on master that closed the shared fetch iterator.

Findings (repaired):

  1. pyathena/pandas/result_set.py iter_chunks(): the new sentence said chunks with chunksize come from the fetch iterator, but _read_parquet ignores chunksize. A pandas UNLOAD result with chunksize=1000 gets the stored DataFrame and a fresh iterator. The docstring now conditions on the CSV result being read in chunks.
  2. pyathena/polars/result_set.py as_polars() / as_arrow() / iter_chunks(): "when chunksize is set ... a later call returns an empty DataFrame" was false for results without an S3 result file (the GetQueryResults path stores the DataFrame even with chunksize). The wording now conditions on the result file being read in chunks.
  3. The PR body now states the pandas UNLOAD/chunksize case.

Callers and operations: no change in S3 or Athena requests (same reads, same places). No user guide in docs/ describes the old consuming behavior. #927 wrote the guides for the intended behavior. Behavior changes are listed in the release note. Limitation from round one (DataFrame retained until close()) still stands.

``auto_optimize_chunksize`` chose a chunk size, it yields DataFrames in chunks
for memory-efficient processing. These chunks come from the same iterator as
the fetch methods, so a chunk that one of them reads is not available to the
other. Otherwise, each call returns a new iterator that yields the entire
result as a single DataFrame, and the fetch methods keep their position.

Returns:
PandasDataFrameIterator that yields pandas DataFrames for each chunk
Expand All @@ -876,6 +894,8 @@ def iter_chunks(self) -> PandasDataFrameIterator:
>>> for df in cursor.iter_chunks():
... process(df) # Single DataFrame with all data
"""
if self._df is not None:
return PandasDataFrameIterator(self._df, _no_trunc_date)
return self._df_iter

@override
Expand All @@ -884,6 +904,7 @@ def close(self) -> None:

super().close()
self._df_iter.close()
self._df_iter = PandasDataFrameIterator(pd.DataFrame(), _no_trunc_date)
self._df = pd.DataFrame()
self._df_iter = PandasDataFrameIterator(self._df, _no_trunc_date)
self._iterrows = enumerate([])
self._data_manifest = []
77 changes: 47 additions & 30 deletions pyathena/polars/result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,23 +254,33 @@ def __init__(
self._chunksize = chunksize
self._kwargs = kwargs

# Build DataFrame iterator (handles both chunked and non-chunked cases)
# Note: _create_dataframe_iterator() calls _as_polars() which may update
# _metadata for unload queries, so we must cache column names AFTER this.
import polars as pl

# The whole result when it was not read in chunks.
# Note: _as_polars() may update _metadata for unload queries, so the converters
# and column names must be read AFTER it.
self._df: pl.DataFrame | None = None
# Converters for the rows of self._df. GetQueryResults values are already converted.
self._df_converters: dict[str, Callable[[str | None], Any | None]] = {}
if self.state == AthenaQueryExecution.STATE_SUCCEEDED and self.output_location:
self._df_iter = self._create_dataframe_iterator()
if self._chunksize is None:
self._df = self._as_polars()
self._df_converters = self.converters
else:
self._df_iter = self._create_dataframe_iterator()
elif self.state == AthenaQueryExecution.STATE_SUCCEEDED:
df = self._as_polars_from_api()
self._df_iter = PolarsDataFrameIterator(df, self.converters, self._get_column_names())
self._df = self._as_polars_from_api()
else:
import polars as pl

self._df = pl.DataFrame()
if self._df is not None:
# A clone keeps assignments to the DataFrame from as_polars()
# out of the rows that the fetch methods return.
self._df_iter = PolarsDataFrameIterator(
pl.DataFrame(), self.converters, self._get_column_names()
self._df.clone(), self._df_converters, self._get_column_names()
)

# Cache column names for efficient access in fetchone()
# Must be after _create_dataframe_iterator() which updates _metadata for unload
# Must be after _as_polars() which updates _metadata for unload
self._column_names_cache: list[str] = self._get_column_names()
self._iterrows = self._df_iter.iterrows()

Expand Down Expand Up @@ -343,20 +353,12 @@ def _get_column_names(self) -> list[str]:
return [d[0] for d in description]

def _create_dataframe_iterator(self) -> PolarsDataFrameIterator:
"""Create a DataFrame iterator for the result set.
"""Create a DataFrame iterator that reads the result file in chunks.

Returns:
PolarsDataFrameIterator that handles both chunked and non-chunked cases.
PolarsDataFrameIterator that reads each chunk lazily.
"""
if self._chunksize is not None:
# Chunked mode: create lazy iterator
reader: Iterator[pl.DataFrame] | pl.DataFrame = (
self._iter_parquet_chunks() if self.is_unload else self._iter_csv_chunks()
)
else:
# Non-chunked mode: load entire DataFrame
reader = self._as_polars()

reader = self._iter_parquet_chunks() if self.is_unload else self._iter_csv_chunks()
return PolarsDataFrameIterator(reader, self.converters, self._get_column_names())

@override
Expand Down Expand Up @@ -538,12 +540,15 @@ def as_polars(self) -> pl.DataFrame:
method for accessing results with PolarsCursor.

Note:
When chunksize is set, calling this method will collect all chunks
into a single DataFrame, loading all data into memory. Use
iter_chunks() for memory-efficient processing of large datasets.
When chunksize is set and the result file is read in chunks, calling this
method will collect the chunks that the fetch methods and iter_chunks()
have not yet read into a single DataFrame, loading them all into memory,
and a later call returns an empty DataFrame. Use iter_chunks() for
memory-efficient processing of large datasets.

Returns:
Polars DataFrame containing all query results.
Polars DataFrame containing all query results. When the result is not
read in chunks, it is the same DataFrame on every call.

Example:
>>> cursor = connection.cursor(PolarsCursor)
Expand All @@ -552,6 +557,8 @@ def as_polars(self) -> pl.DataFrame:
>>> print(f"DataFrame has {df.height} rows")
>>> filtered = df.filter(pl.col("value") > 100)
"""
if self._df is not None:
return self._df
return self._df_iter.as_polars()

def as_arrow(self) -> Table:
Expand All @@ -561,7 +568,9 @@ def as_arrow(self) -> Table:
interoperability with other Arrow-compatible tools and libraries.

Returns:
Apache Arrow Table containing all query results.
Apache Arrow Table containing all query results. When the result file is
read in chunks, it contains the chunks that have not yet been read, as
with as_polars().

Raises:
ImportError: If pyarrow is not installed.
Expand All @@ -573,7 +582,7 @@ def as_arrow(self) -> Table:
>>> # Use with other Arrow-compatible libraries
"""
try:
return self._df_iter.as_polars().to_arrow()
return self.as_polars().to_arrow()
except ImportError as e:
raise ImportError(
"pyarrow is required for as_arrow(). Install it with: pip install pyarrow"
Expand Down Expand Up @@ -667,8 +676,12 @@ def iter_chunks(self) -> PolarsDataFrameIterator:

This method provides an iterator interface for processing large result sets.
When chunksize is specified, it yields DataFrames in chunks using lazy
evaluation for memory-efficient processing. When chunksize is not specified,
it yields the entire result as a single DataFrame.
evaluation for memory-efficient processing. These chunks come from the same
iterator as the fetch methods, so a chunk that one of them reads is not
available to the other. When chunksize is not specified, or the result has
no result file to read in chunks, each call returns a new iterator that
yields the entire result as a single DataFrame, and the fetch methods keep
their position.

Returns:
PolarsDataFrameIterator that yields Polars DataFrames for each chunk
Expand All @@ -687,6 +700,8 @@ def iter_chunks(self) -> PolarsDataFrameIterator:
>>> for df in cursor.iter_chunks():
... process(df) # Single DataFrame with all data
"""
if self._df is not None:
return PolarsDataFrameIterator(self._df, self._df_converters, self._get_column_names())
return self._df_iter

@override
Expand All @@ -695,5 +710,7 @@ def close(self) -> None:
import polars as pl

super().close()
self._df_iter = PolarsDataFrameIterator(pl.DataFrame(), {}, [])
self._df_iter.close()
self._df = pl.DataFrame()
self._df_iter = PolarsDataFrameIterator(self._df, {}, [])
self._iterrows = iter([])
18 changes: 16 additions & 2 deletions tests/pyathena/pandas/test_cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1416,6 +1416,20 @@ def test_pandas_cursor_iter_chunks_without_chunksize(self, pandas_cursor):
# Should yield exactly one chunk (the entire DataFrame)
assert chunk_count == 1

def test_pandas_cursor_whole_result_reused(self, pandas_cursor):
"""Test that as_pandas() and iter_chunks() do not consume the fetched rows."""
pandas_cursor.execute("SELECT number FROM (VALUES (1), (2), (3)) AS t(number)")
df = pandas_cursor.as_pandas()
assert df["number"].tolist() == [1, 2, 3]
assert pandas_cursor.as_pandas() is df
df["number"] = 0

assert pandas_cursor.fetchone() == (1,)
assert [len(chunk) for chunk in pandas_cursor.iter_chunks()] == [3]
assert [len(chunk) for chunk in pandas_cursor.iter_chunks()] == [3]
assert pandas_cursor.as_pandas() is df
assert pandas_cursor.fetchall() == [(2,), (3,)]

def test_pandas_cursor_chunked_vs_regular_same_data(self, pandas_cursor):
"""Test that chunked and regular reading produce the same data."""
query = "SELECT * FROM many_rows LIMIT 100" # Use a reasonable size for testing
Expand Down Expand Up @@ -1515,5 +1529,5 @@ def test_pandas_cursor_iter_chunks_consistency(self, pandas_cursor):
indirect=["pandas_cursor"],
)
def test_fetch_all_rows(self, pandas_cursor):
pandas_cursor.execute("SELECT 1 AS col")
assert pandas_cursor.fetchall() == [(1,)]
pandas_cursor.execute("SELECT 1 AS col, CAST('12:34:56' AS TIME) AS col_time")
assert pandas_cursor.fetchall() == [(1, datetime(2017, 1, 1, 12, 34, 56).time())]
31 changes: 29 additions & 2 deletions tests/pyathena/polars/test_cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,33 @@ def test_iter_chunks_without_chunksize(self, polars_cursor):
assert isinstance(chunks[0], pl.DataFrame)
assert chunks[0].height == 1

def test_whole_result_reused(self, polars_cursor):
"""Test that as_polars(), as_arrow(), and iter_chunks() do not consume the rows."""
polars_cursor.execute("SELECT number FROM (VALUES (1), (2), (3)) AS t(number)")
df = polars_cursor.as_polars()
assert df["number"].to_list() == [1, 2, 3]
assert polars_cursor.as_polars() is df
assert polars_cursor.as_arrow().column("number").to_pylist() == [1, 2, 3]
df[0, "number"] = 0

assert polars_cursor.fetchone() == (1,)
assert [chunk.height for chunk in polars_cursor.iter_chunks()] == [3]
assert [chunk.height for chunk in polars_cursor.iter_chunks()] == [3]
assert polars_cursor.as_polars() is df
assert polars_cursor.fetchall() == [(2,), (3,)]

@pytest.mark.parametrize(
"polars_cursor", [{"cursor_kwargs": {"chunksize": 5}}], indirect=["polars_cursor"]
)
def test_close_stops_chunks(self, polars_cursor):
"""Test that closing the result set closes the chunk iterator it returned."""
polars_cursor.execute("SELECT * FROM many_rows LIMIT 15")
result_set = polars_cursor.result_set
chunks = result_set.iter_chunks()
assert next(chunks).height == 5
result_set.close()
assert list(chunks) == []

def test_iter_chunks_many_rows(self):
"""Test chunked iteration with many rows."""
with contextlib.closing(connect(schema_name=ENV.schema)) as conn:
Expand Down Expand Up @@ -691,5 +718,5 @@ def test_null_vs_empty_string(self, polars_cursor):
indirect=["polars_cursor"],
)
def test_fetch_all_rows(self, polars_cursor):
polars_cursor.execute("SELECT 1 AS col")
assert polars_cursor.fetchall() == [(1,)]
polars_cursor.execute("SELECT 1 AS col, CAST('12:34:56' AS TIME) AS col_time")
assert polars_cursor.fetchall() == [(1, datetime(2017, 1, 1, 12, 34, 56).time())]
Loading